diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 5846a32f3bac..02adafc2b642 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -15772,6 +15772,8 @@ public struct SessionRow: Codable, Sendable { public let archivereason: AnyCodable? public let pinned: Bool? public let pinnedat: Double? + public let snoozeduntil: Double? + public let snoozedat: Double? public let unread: Bool? public let lastreadat: Double? public let markedunreadat: Double? @@ -15867,6 +15869,8 @@ public struct SessionRow: Codable, Sendable { archivereason: AnyCodable? = nil, pinned: Bool? = nil, pinnedat: Double? = nil, + snoozeduntil: Double? = nil, + snoozedat: Double? = nil, unread: Bool? = nil, lastreadat: Double? = nil, markedunreadat: Double? = nil, @@ -15961,6 +15965,8 @@ public struct SessionRow: Codable, Sendable { self.archivereason = archivereason self.pinned = pinned self.pinnedat = pinnedat + self.snoozeduntil = snoozeduntil + self.snoozedat = snoozedat self.unread = unread self.lastreadat = lastreadat self.markedunreadat = markedunreadat @@ -16057,6 +16063,8 @@ public struct SessionRow: Codable, Sendable { case archivereason = "archiveReason" case pinned case pinnedat = "pinnedAt" + case snoozeduntil = "snoozedUntil" + case snoozedat = "snoozedAt" case unread case lastreadat = "lastReadAt" case markedunreadat = "markedUnreadAt" @@ -18230,6 +18238,7 @@ public struct SessionsPatchMutation: Codable, Sendable { public let ttlminutes: Int? public let archived: Bool? public let pinned: Bool? + public let snoozeduntil: AnyCodable? public let unread: Bool? public let contextwindow: AnyCodable? public let thinkinglevel: AnyCodable? @@ -18269,6 +18278,7 @@ public struct SessionsPatchMutation: Codable, Sendable { ttlminutes: Int? = nil, archived: Bool? = nil, pinned: Bool? = nil, + snoozeduntil: AnyCodable? = nil, unread: Bool? = nil, contextwindow: AnyCodable? = nil, thinkinglevel: AnyCodable? = nil, @@ -18307,6 +18317,7 @@ public struct SessionsPatchMutation: Codable, Sendable { self.ttlminutes = ttlminutes self.archived = archived self.pinned = pinned + self.snoozeduntil = snoozeduntil self.unread = unread self.contextwindow = contextwindow self.thinkinglevel = thinkinglevel @@ -18347,6 +18358,7 @@ public struct SessionsPatchMutation: Codable, Sendable { case ttlminutes = "ttlMinutes" case archived case pinned + case snoozeduntil = "snoozedUntil" case unread case contextwindow = "contextWindow" case thinkinglevel = "thinkingLevel" @@ -18397,6 +18409,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let ttlminutes: Int? public let archived: Bool? public let pinned: Bool? + public let snoozeduntil: AnyCodable? public let unread: Bool? public let contextwindow: AnyCodable? public let thinkinglevel: AnyCodable? @@ -18445,6 +18458,7 @@ public struct SessionsPatchParams: Codable, Sendable { ttlminutes: Int? = nil, archived: Bool? = nil, pinned: Bool? = nil, + snoozeduntil: AnyCodable? = nil, unread: Bool? = nil, contextwindow: AnyCodable? = nil, thinkinglevel: AnyCodable? = nil, @@ -18492,6 +18506,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.ttlminutes = ttlminutes self.archived = archived self.pinned = pinned + self.snoozeduntil = snoozeduntil self.unread = unread self.contextwindow = contextwindow self.thinkinglevel = thinkinglevel @@ -18541,6 +18556,7 @@ public struct SessionsPatchParams: Codable, Sendable { case ttlminutes = "ttlMinutes" case archived case pinned + case snoozeduntil = "snoozedUntil" case unread case contextwindow = "contextWindow" case thinkinglevel = "thinkingLevel" diff --git a/docs/gateway/protocol/rpc-session-control.md b/docs/gateway/protocol/rpc-session-control.md index 717eabf6ab1e..76e37fe54514 100644 --- a/docs/gateway/protocol/rpc-session-control.md +++ b/docs/gateway/protocol/rpc-session-control.md @@ -39,7 +39,8 @@ Once session stores are admitted, authorization for direct session targets prepa - `sessions.send` sends a message into an existing session. - `sessions.steer` is a deprecated alias for `chat.send` with `queueMode: "interrupt"`; removal follows the protocol deprecation policy. - `sessions.abort` aborts active work for a session. Pass `key` plus optional `runId`, or `runId` alone for active runs the gateway can resolve to a session. Supplying `runId` keeps cancellation scoped to that run. Set `clearQueued: true` on a key-only non-global request to also discard followup and lane queues owned by that session. Existing callers that omit `clearQueued` preserve those queues. The literal `global` key keeps the existing agent-qualified `chat.abort` ownership rules and does not perform non-global followup or lane cleanup. -- `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. `contextWindow` accepts only an id advertised by the selected model's `contextWindows` array; `null` restores `contextWindowDefault`. Session organization fields and the per-session `model`, `thinkingLevel`, and `fastMode` overrides require `operator.write`, including clearing an override with `null`. The same field policy applies to `sessions.patchMany`. Context-window, verbose, trace, reasoning-visibility, tool, and other privileged overrides still require `operator.admin`; combining them with write-scoped fields does not lower that requirement. Only an admin model selection can persist as the configured agent default. On multi-user gateways, archive and restore additionally require the session creator or `operator.admin`; membership and assigned ownership do not grant archive access. Archive and restore patches require the caller-observed `sessionId` from `sessions.list` or `sessions.describe` as `expectedSessionId`; missing or changed targets fail without materializing or mutating a replacement. With `archived: true`, the Gateway protects agent main sessions (including `global` when global scope is configured) and the `unknown` sentinel; for every other real session it first fences new admission, cancels exact-session active, pending, queued, reply, embedded, and worker work, and waits for admission and runtime terminal-persistence drains before committing `archivedAt`. A cancellation, drain, or persistence failure returns retryable `UNAVAILABLE` and leaves the session unarchived. `sessions.patchMany` carries `expectedSessionId` per target, prepares archive targets in input order inside the same batch lifecycle fence, and returns ordered per-target outcomes. Spawn lineage (`spawnedBy`, `spawnedWorkspaceDir`, `spawnedCwd`, `spawnDepth`, `subagentRole`, `subagentControlScope`) is no longer publicly patchable; those facts are written once by trusted creation paths, and requests that still send them are rejected. +- `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. `contextWindow` accepts only an id advertised by the selected model's `contextWindows` array; `null` restores `contextWindowDefault`. Session organization fields and the per-session `model`, `thinkingLevel`, and `fastMode` overrides require `operator.write`, including clearing an override with `null`. The same field policy applies to `sessions.patchMany`. Context-window, verbose, trace, reasoning-visibility, tool, and other privileged overrides still require `operator.admin`; combining them with write-scoped fields does not lower that requirement. Only an admin model selection can persist as the configured agent default. On multi-user gateways, archive and restore additionally require the session creator or `operator.admin`; membership and assigned ownership do not grant archive access. Archive, restore, snooze, and wake patches require the caller-observed `sessionId` from `sessions.list` or `sessions.describe` as `expectedSessionId`; missing or changed targets fail without materializing or mutating a replacement. With `archived: true`, the Gateway protects agent main sessions (including `global` when global scope is configured) and the `unknown` sentinel; for every other real session it first fences new admission, cancels exact-session active, pending, queued, reply, embedded, and worker work, and waits for admission and runtime terminal-persistence drains before committing `archivedAt`. A cancellation, drain, or persistence failure returns retryable `UNAVAILABLE` and leaves the session unarchived. `sessions.patchMany` carries `expectedSessionId` per target, prepares archive targets in input order inside the same batch lifecycle fence, and returns ordered per-target outcomes. Spawn lineage (`spawnedBy`, `spawnedWorkspaceDir`, `spawnedCwd`, `spawnDepth`, `subagentRole`, `subagentControlScope`) is no longer publicly patchable; those facts are written once by trusted creation paths, and requests that still send them are rejected. +- `sessions.patch { snoozedUntil }` sets a future positive integer wake time in epoch milliseconds; `null` clears snooze. The Gateway stamps `snoozedAt` when the wake time changes, preserving it for an identical value. Snooze accepts active sessions eligible for pinning and rejects the same protected targets as archive, plus archived and child sessions. It keeps the session active and its pin intact, without lifecycle drains, automation disabling, worktree transitions, or work-admission changes. Pinning or archiving clears snooze. Real user/channel interaction and a completed run that updates user-facing activity also clear it; system events and preserved-state runs do not. Session rows expose `snoozedUntil` and `snoozedAt`, so clients compare the wake time to their own clock. Time expiry emits no event and leaves stale fields until a later write clears them; explicit snooze and wake patches publish the normal `sessions.changed` event with reason `patch`. `sessions.list` does not filter by snooze. - `sessions.assignOwner` (`operator.write`) reassigns the session's mutable owner to a person or configured agent (`{ key, owner: { type, id } }`). It requires an identified caller (authenticated profile or trusted agent identity), authorizes by session visibility, and records `assignedBy`/`assignedAt` on the row's `owner` field. The write-once `createdActor` and creator-anchored sharing authority are unchanged; see [Multi-user mode](/concepts/multi-user#assigning-an-owner). - `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance. Explicit `agentId` values that cannot identify an agent are rejected before reset cleanup or mutation; they never fall back to the default agent. `sessions.reset` accepts an optional `expectedSessionId` from `sessions.list` or `sessions.describe`. If that session ID is no longer current when the reset enters its lifecycle fence, the Gateway rejects the request before interrupting work with `INVALID_REQUEST` and `error.details.reason: "session-changed"`; re-read the session before deciding whether to retry. Omitting the field preserves unconditional reset behavior. The guard does not reject changes that keep the same session ID, including metadata edits or another reset-in-place. - `sessions.get` returns the full stored session row. diff --git a/docs/reference/session-management-compaction/schema.md b/docs/reference/session-management-compaction/schema.md index 3e79d0fa456b..f0958bd441cb 100644 --- a/docs/reference/session-management-compaction/schema.md +++ b/docs/reference/session-management-compaction/schema.md @@ -44,6 +44,7 @@ The runtime store keeps `SessionEntry` values in per-agent SQLite. The value typ - `pinnedAt`: optional pin timestamp. Active pinned sessions sort ahead of unpinned sessions; archiving a session clears its pin. - Codex thread interop: both fields follow the Codex thread-management shape - the `archived`/`pinned` booleans on the wire are always derived from the timestamp and stamped server-side, matching Codex `threads.archived_at` semantics and camelCase serialization. OpenClaw timestamps are epoch milliseconds while Codex uses epoch seconds, so bridges convert at the `codex` plugin seam. The Codex thread methods cover archiving only (`thread/archive`/`thread/unarchive`) and include no pin method, so pinned state stays OpenClaw-side. The shapes match, so a Codex pin method would let bound sessions round-trip pin state mechanically. - Codex supervision lists only non-archived native threads. A Gateway-local `idle` or `notLoaded` activity-unknown thread can be archived through native `thread/archive` only after the operator explicitly confirms that no other Codex process owns it; the plugin performs a fresh process-local status read first, and the thread then disappears from the catalog. That read cannot prove that another App Server process is not using the thread. OpenClaw refuses to archive active and error rows, and paired-node archive is unavailable until the node bridge can own the full streamed thread lifecycle. Unarchiving in a native Codex client makes the thread eligible to appear again. +- `snoozedUntil` / `snoozedAt`: optional epoch-millisecond wake time and server-stamped snooze time, stored only in `session_nodes.entry_json`. `sessions.patch { snoozedUntil }` accepts a future positive integer or `null` to wake the session, with `expectedSessionId` required. Snooze hides eligible active root sessions from the Control UI's Active sidebar without changing lifecycle or work admission. Rows expose the timestamps; clients compare the wake time to their clock, with no derived `snoozed` boolean or server timer. Expired fields can remain until a later write clears them. User/channel interaction and completed runs that update user-facing activity clear both fields; system events and preserved-state runs keep them. Snoozing retains a pin, while pinning or archiving clears snooze. - `lastReadAt` / `markedUnreadAt`: read-state timestamps stamped server-side by `sessions.patch { unread }` - `unread: false` records a read (sets `lastReadAt`, clears `markedUnreadAt`); `unread: true` records `markedUnreadAt` and marks the session unread until the next activation or explicit read. Session rows expose the marker alongside a derived `unread` boolean so already-open clients preserve manual reminders while still acknowledging new activity. Automatic read patches from clients that support the advertised unread acknowledgement contract include `expectedMarkedUnreadAt` (`null` means no marker); a newer marker makes that acknowledgement a successful no-op instead of erasing newer intent. Bare `unread: false` requests retain the legacy clear behavior, so protection across several connected clients requires each active client to support the contract. Sessions never marked read stay `unread: false`, so existing installs do not light up on upgrade. - `lastActivityAt`: timestamp of the last completed agent run that counts as unread-worthy activity (user, channel, and cron runs). Heartbeat and internal-event turns, plus metadata patches, do not update it; `updatedAt` is not an activity signal. - `sessionFile`: legacy marker retained for migration/archive compatibility; active runtime uses SQLite identity diff --git a/docs/web/control-ui/feature-reference.md b/docs/web/control-ui/feature-reference.md index c201f8a345fa..9ecb3b16a9ad 100644 --- a/docs/web/control-ui/feature-reference.md +++ b/docs/web/control-ui/feature-reference.md @@ -37,7 +37,7 @@ Control UI capabilities grouped by area, each with the Gateway RPC methods behin - Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`). Channel cards refresh automatically when the Gateway accepts a configuration change, including after guided setup; a slow probe does not delay the updated runtime status. - Channel probe refreshes keep the previous snapshot visible while slow provider checks finish, and label partial snapshots when a probe or audit exceeds its UI budget. - - Threads (a workspace page at `/sessions`, with a **Worktrees** tab alongside it): list configured-agent sessions by default, pin frequent root sessions, rename them, archive or restore sessions, fall back from stale unconfigured agent session keys, and apply per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`). A three-way **Active / Archived / All** filter controls both this page and the sidebar; All dims archived rows and labels them explicitly. Archived sessions keep their transcripts and remain shelved until explicitly unarchived or deleted. Sessions archived automatically at the active-session cap can also be deleted automatically when the session store exceeds its disk budget; manually archived and legacy sessions stay protected. Rows show an unread dot for active sessions with activity since they were last read, with mark-unread/mark-read actions (`sessions.patch { unread }`), and a Fork action that branches the transcript into a new session (`sessions.create { parentSessionKey, fork: true }`). Overview tiles above the table summarize the loaded roster (session count, live runs, unread sessions, total tokens, and archived count when available), each row carries a kind glyph with a live-run dot, status renders as a plain dot plus label, and the Tokens column shows a context-window usage meter when the session reports token and context sizes. Row management actions live in a per-row menu (kebab button or right-click) mirroring the sidebar's session menu, and the row drawer carries the agent runtime and run duration alongside the other session details. + - Threads (a workspace page at `/sessions`, with a **Worktrees** tab alongside it): list configured-agent sessions by default, pin frequent root sessions, rename them, archive or restore sessions, fall back from stale unconfigured agent session keys, and apply per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`). The sidebar offers **Active / Snoozed / Archived / All** status filters; All includes snoozed sessions and dims archived rows with an explicit label. **Snooze** in an eligible session's sidebar menu hides it from the Active sidebar without stopping runs or blocking messages. Choose a preset wake time (in one or three hours, this evening, tomorrow, or next Monday); time, a new user message, or a completed agent run wakes it. Find it under Snoozed and choose **Wake session**, or use **Undo** in the snooze confirmation. Snoozed rows show their wake time. The Sessions page retains its Active / Archived / All lifecycle views. Archived sessions keep their transcripts and remain shelved until explicitly unarchived or deleted. Sessions archived automatically at the active-session cap can also be deleted automatically when the session store exceeds its disk budget; manually archived and legacy sessions stay protected. Rows show an unread dot for active sessions with activity since they were last read, with mark-unread/mark-read actions (`sessions.patch { unread }`), and a Fork action that branches the transcript into a new session (`sessions.create { parentSessionKey, fork: true }`). Overview tiles above the table summarize the loaded roster (session count, live runs, unread sessions, total tokens, and archived count when available), each row carries a kind glyph with a live-run dot, status renders as a plain dot plus label, and the Tokens column shows a context-window usage meter when the session reports token and context sizes. Row management actions live in a per-row menu (kebab button or right-click) mirroring the sidebar's session menu, and the row drawer carries the agent runtime and run duration alongside the other session details. - Native Claude and Codex sidebar catalogs stream one host at a time, then reconcile after node connectivity changes, on page focus, and at most every 30 seconds while visible. Catalog changes trigger a faster follow-up pass, so sessions created in the native tools appear without reloading the Control UI. Claude Desktop rows also retain their local custom-group label when present; OpenClaw reads that mapping from Desktop's local store and never writes it. - Session grouping: a Group by control organizes the sessions table into sections by custom groups, channel, kind, agent, or date. Custom groups persist per session via `sessions.patch` (`category`), so sessions started from message channels (Discord, Telegram, WhatsApp, ...) can be categorized too; assign groups by dragging rows onto a section, or with the per-row group selector, and create groups with the New group action. - Memory (a tab on the Agents page, scoped to the selected agent): dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`). When the `memory-wiki` plugin is enabled, the Diary view adds **Imported Insights** and **Memory Wiki** sub-tabs that browse imported source chats and the compiled wiki — clustered synthesis, entity, and concept pages plus annotated sources and reports, with claims, open questions, contradictions, and inline page previews (`wiki.importInsights`, `wiki.overview`, `wiki.get`). diff --git a/docs/web/control-ui/sessions-and-sidebar.md b/docs/web/control-ui/sessions-and-sidebar.md index 5ce2542cfb9e..f2d48d73756d 100644 --- a/docs/web/control-ui/sessions-and-sidebar.md +++ b/docs/web/control-ui/sessions-and-sidebar.md @@ -184,7 +184,7 @@ Hover a session to see its project and branch. Repository details and the workin Hover a session with an enabled automation and choose **Automation attached** to open its **Automations** page. A single matching automation opens directly in the editor; multiple matches appear in a session-filtered list. You can inspect settings and history or edit with the usual permissions. **Show all automations** clears the session filter. Cmd/Ctrl-click opens the link in a new browser tab. -In the default chip mode, the sidebar organizes everything around the active agent. The identity row at the top is that agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Other** for the agent's ungrouped chat sessions (the main session appears only as Home, including when it is pinned or has subagents; independent conversations it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and external CLI catalogs. The **Other** heading is omitted when it is the only section. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Other, and assigning an independent session to a custom group wins over the automatic zone classification. The global **Sessions** toolbar holds **Filter & sort** and the **+** that opens the New session page. Inside **Filter & sort**, **Display** contains sorting (Created, Updated, or Owners when the loaded session roster contains multiple owners) and **Group by** — **Custom groups** (the default zone layout above), **Project** to bucket sessions by their repo or workspace checkout (sessions without one keep their zones), **Person** to bucket by owner when the loaded roster has several, or **None** for a single flat list with no zone headers. **Filters** contains the persisted **Status** filter for Active, Archived, or All. The Owners sort mode orders owner groups by name and keeps Created order within each group. On multi-user gateways **Filters** starts with an **Owners** filter, above Status: **All owners**, one specific person or agent, or **Involving me** — sessions you own, have prompted, or have been explicitly mentioned in, excluding sessions you personally hid with **Hide from Involving me**. A new explicit mention brings a hidden session back. **Show in Involving me** restores it from **All owners**. The personal Hide/Show menu entry appears only when the Gateway has more than one identity. These personal choices do not archive sessions or alter access, and the Gateway evaluates the filter before pagination (see [Multi-user mode](/concepts/multi-user#finding-sessions-by-owner)). Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parents with nested persistent sessions or forks show a disclosure and child count; expand it to inspect those sessions, their status, and runtime without leaving the sidebar. Selecting a nested session opens its chat and reveals its ancestor path. If a persistent session has subagent runs between it and its nearest loaded non-subagent ancestor, it nests under that ancestor. If no such ancestor is loaded, it keeps its normal top-level placement. These child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Subagent runs never appear as sidebar rows, even when selected or assigned a custom group, and do not add a disclosure to a parent with no nested persistent sessions. Inspect them through their session transcripts. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Accepted work immediately shows an activity ring around the row’s own icon for Home, sessions, child sessions, and catalog rows; a row without an icon shows a compact ring in the icon slot. Subagent runs still contribute to their ancestors’ running, queued, and failed counts, unread attention, and failure warnings. A session’s ring stays active while its subagents work. When only delegated work is executing, the ring is labeled **Subagents working**. Select an ancestor’s child-failure warning to inspect the failed run in chat without adding a sidebar row. Collapsed groups and collapsed child toggles summarize hidden running rows on the right. It spins during startup and execution, pauses with **Queued** only during a scheduler-confirmed concurrency-slot wait, and resumes when a slot is granted. With reduced motion enabled, the ring stays still. A session holding composer text you typed but never sent shows a pencil badge until the draft is sent or cleared; the active session hides it because its composer is already in view. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each session row offers a direct **Archive** button alongside **Pin**; archived rows offer **Restore**. Right-click a row, or focus its link and press **Shift+F10** or the **Menu** key, to open the full [session menu](#session-menu). Touch layouts keep Pin, Archive, and a separate menu button visible so every action remains available without right-click. The chat header also provides the session menu. The chat header composes the same single-session management actions with its pane-specific **Panels**, **Layout**, and **View** actions. Cmd/Ctrl-click opens a session in a new browser tab. Alt/Option-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names, order, and New Session defaults live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Each custom group header has a **+** that opens the normal New Session page and assigns the created session to that group. When the **Other** header is visible, its **+** opens an ungrouped draft without inheriting the current named group. **New session defaults** in the group menu sets its working directory and Local or Worktree preference; the page prefills those values but leaves them editable. Leaving the directory empty uses the selected agent's workspace. A Git repository without an initial commit can use Current checkout; creating a Worktree requires an initial commit. The menu also has Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Other. +In the default chip mode, the sidebar organizes everything around the active agent. The identity row at the top is that agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Other** for the agent's ungrouped chat sessions (the main session appears only as Home, including when it is pinned or has subagents; independent conversations it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and external CLI catalogs. The **Other** heading is omitted when it is the only section. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Other, and assigning an independent session to a custom group wins over the automatic zone classification. The global **Sessions** toolbar holds **Filter & sort** and the **+** that opens the New session page. Inside **Filter & sort**, **Display** contains sorting (Created, Updated, or Owners when the loaded session roster contains multiple owners) and **Group by** — **Custom groups** (the default zone layout above), **Project** to bucket sessions by their repo or workspace checkout (sessions without one keep their zones), **Person** to bucket by owner when the loaded roster has several, or **None** for a single flat list with no zone headers. **Filters** contains the persisted **Status** filter for Active, Snoozed, Archived, or All. The Owners sort mode orders owner groups by name and keeps Created order within each group. On multi-user gateways **Filters** starts with an **Owners** filter, above Status: **All owners**, one specific person or agent, or **Involving me** — sessions you own, have prompted, or have been explicitly mentioned in, excluding sessions you personally hid with **Hide from Involving me**. A new explicit mention brings a hidden session back. **Show in Involving me** restores it from **All owners**. The personal Hide/Show menu entry appears only when the Gateway has more than one identity. These personal choices do not archive sessions or alter access, and the Gateway evaluates the filter before pagination (see [Multi-user mode](/concepts/multi-user#finding-sessions-by-owner)). Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parents with nested persistent sessions or forks show a disclosure and child count; expand it to inspect those sessions, their status, and runtime without leaving the sidebar. Selecting a nested session opens its chat and reveals its ancestor path. If a persistent session has subagent runs between it and its nearest loaded non-subagent ancestor, it nests under that ancestor. If no such ancestor is loaded, it keeps its normal top-level placement. These child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Subagent runs never appear as sidebar rows, even when selected or assigned a custom group, and do not add a disclosure to a parent with no nested persistent sessions. Inspect them through their session transcripts. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Accepted work immediately shows an activity ring around the row’s own icon for Home, sessions, child sessions, and catalog rows; a row without an icon shows a compact ring in the icon slot. Subagent runs still contribute to their ancestors’ running, queued, and failed counts, unread attention, and failure warnings. A session’s ring stays active while its subagents work. When only delegated work is executing, the ring is labeled **Subagents working**. Select an ancestor’s child-failure warning to inspect the failed run in chat without adding a sidebar row. Collapsed groups and collapsed child toggles summarize hidden running rows on the right. It spins during startup and execution, pauses with **Queued** only during a scheduler-confirmed concurrency-slot wait, and resumes when a slot is granted. With reduced motion enabled, the ring stays still. A session holding composer text you typed but never sent shows a pencil badge until the draft is sent or cleared; the active session hides it because its composer is already in view. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each session row offers a direct **Archive** button alongside **Pin**; archived rows offer **Restore**. Right-click a row, or focus its link and press **Shift+F10** or the **Menu** key, to open the full [session menu](#session-menu). Touch layouts keep Pin, Archive, and a separate menu button visible so every action remains available without right-click. The chat header also provides the session menu. The chat header composes the same single-session management actions with its pane-specific **Panels**, **Layout**, and **View** actions. Cmd/Ctrl-click opens a session in a new browser tab. Alt/Option-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names, order, and New Session defaults live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Each custom group header has a **+** that opens the normal New Session page and assigns the created session to that group. When the **Other** header is visible, its **+** opens an ungrouped draft without inheriting the current named group. **New session defaults** in the group menu sets its working directory and Local or Worktree preference; the page prefills those values but leaves them editable. Leaving the directory empty uses the selected agent's workspace. A Git repository without an initial commit can use Current checkout; creating a Worktree requires an initial commit. The menu also has Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Other. Persistent child conversations and forks stay nested after their turns finish, including after a reload or Gateway restart. Archiving hides them from the @@ -239,6 +239,21 @@ Loaded persistent child-session rows stay visible while an expanded or selected **Archived** hides active sessions even when their conversation remains open. In **Active**, a directly opened archived session can retain its selected row. Archiving a visible session hides its row immediately while keeping its conversation open. Repeated archive actions stay disabled while the Gateway confirms the request. Confirmation offers **Undo**, including when you leave the Sessions page before the archive finishes or navigate away from the archived chat while the notification remains visible. Undo targets the original conversation and expires on a Gateway reconnect. If the request fails, the row returns with an error explaining what prevented archiving. Confirmed archive, restore, and pin changes remain applied to loaded rows if the follow-up refresh fails. If archiving already removed a row from every loaded list, Undo needs a successful refresh to show it again. The refresh error is shown separately; it does not undo a successful archive or restore. Refresh the session list to recover missing rows. +**Snooze** hides an eligible session from the **Active** sidebar until its wake time, +until you send it a message, or until an agent run completes on it. The session +stays active: snoozing does not stop a run, block messages or agent tools, disable +automations, or change its worktree. System events and runs that preserve the +session's visible state do not wake it. Snooze is saved by the Gateway, so +connected clients share the same wake time. + +Choose **Snoozed** in the Status filter to find snoozed sessions, or **All** to see +them alongside awake and archived sessions. Snoozed root rows show **Wakes** and +the local wake time. The row menu's **Wake session** +item also shows the scheduled time; choose it to bring the session back early. +The snooze confirmation offers **Undo**. Snoozing a +pinned session keeps its pin, so it returns to the pinned zone when it wakes; +pinning a snoozed session wakes it immediately. Archiving clears its snooze. + Session previews are hidden by default for compact, single-line rows. Enable **Show message preview** in the **Sessions** filter menu to restore routine status text and message previews. The browser remembers your choice. Errors and requests for attention remain visible with previews off. Team mode keeps all session rows on one line. Three fixed slots on the right show the collapsed child count, unread state (a dot for one, a count for more), and activity or attention. Requests for input and errors take priority over activity in the state slot; expand a parent or group to inspect each conversation. Collapsed agent groups use the same slots. Nested expand controls are plain carets in the left gutter. **Hide empty groups** in the same menu shows your current choice and opens three options: @@ -287,8 +302,9 @@ conversation a nested child. Pinned parents keep their indented child trees and vertical guides in the Pages section. Expanding or collapsing a pinned parent shows or hides its children together. -The menu groups routine actions first: **Pin/Unpin**, **Rename**, **Mark as unread/read**, and **Archive/Unarchive**. **Delete** stays separate at the bottom. +The menu groups routine actions first: **Pin/Unpin**, **Rename**, **Mark as unread/read**, and **Archive/Unarchive**. The sidebar session menu also offers **Snooze/Wake session** beside Archive. **Delete** stays separate at the bottom. +- In the sidebar session menu, **Snooze** offers **In 1 hour**, **In 3 hours**, **This evening** (18:00 local, when more than one hour away), **Tomorrow** (09:00 local), and **Next week** (next Monday at 09:00 local). On Sundays, Next week is omitted because it matches Tomorrow. Each preset shows its wake time in your locale. Active sessions eligible for pinning can be snoozed, except protected main and sentinel sessions; archived and child sessions cannot. A snoozed session offers **Wake session** instead. - **Icon & color** opens one picker with color swatches, an icon grid, and **Reset to default**. It stays open while you change both; the sidebar reflects your changes. - **Move to group** includes **New group** and **Remove from group**. Multi-user gateways also offer **Assign to** ([session ownership](/concepts/multi-user#assigning-an-owner)). - **Fork conversation** creates a separate conversation; while a run is active, it forks from the last completed message. Forks of local folder and project sessions keep that workspace, so existing file references continue to open. **Fork from here** keeps the same local workspace as well. diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index b4ead1b66dd9..b4483dda7477 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -210,6 +210,7 @@ describe("lazy protocol validators", () => { ttlMinutes: 30, archived: false, pinned: true, + snoozedUntil: 1_800_000_000_000, unread: true, contextWindow: "1m", thinkingLevel: "high", diff --git a/packages/gateway-protocol/src/schema/sessions-patch.test.ts b/packages/gateway-protocol/src/schema/sessions-patch.test.ts index 1d1bb601cb3e..f1ee9ac6686b 100644 --- a/packages/gateway-protocol/src/schema/sessions-patch.test.ts +++ b/packages/gateway-protocol/src/schema/sessions-patch.test.ts @@ -2,6 +2,23 @@ import { describe, expect, it } from "vitest"; import { validateSessionsPatchParams, validateSessionsPatchManyParams } from "../index.js"; describe("session patch schema", () => { + it.each([ + [1_800_000_000_000, true], + [null, true], + [0, false], + [-1, false], + [1.5, false], + ["1800000000000", false], + ] as const)("validates snoozedUntil %j for single and batch patches", (snoozedUntil, valid) => { + expect(validateSessionsPatchParams({ key: "agent:main:chat", snoozedUntil })).toBe(valid); + expect( + validateSessionsPatchManyParams({ + targets: [{ key: "agent:main:chat" }], + patch: { snoozedUntil }, + }), + ).toBe(valid); + }); + it.each(["off", null] as const)( "accepts sandbox mode %s with single and batch CAS", (sandboxMode) => { diff --git a/packages/gateway-protocol/src/schema/sessions-patch.ts b/packages/gateway-protocol/src/schema/sessions-patch.ts index f25befb71776..545e77a3fd6a 100644 --- a/packages/gateway-protocol/src/schema/sessions-patch.ts +++ b/packages/gateway-protocol/src/schema/sessions-patch.ts @@ -40,6 +40,12 @@ const SessionsPatchMutationProperties = { ttlMinutes: Type.Optional(Type.Integer({ minimum: 1, maximum: 120 })), archived: Type.Optional(Type.Boolean()), pinned: Type.Optional(Type.Boolean()), + snoozedUntil: Type.Optional( + Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], { + description: + "Epoch ms wake time that hides the session from active lists until then; null wakes it.", + }), + ), unread: Type.Optional( Type.Boolean({ description: "Set true to mark unread; false records the session as read." }), ), diff --git a/packages/gateway-protocol/src/schema/sessions-row.ts b/packages/gateway-protocol/src/schema/sessions-row.ts index 588a56950f7f..19cb5bfedbe2 100644 --- a/packages/gateway-protocol/src/schema/sessions-row.ts +++ b/packages/gateway-protocol/src/schema/sessions-row.ts @@ -148,6 +148,8 @@ export const SessionRowSchema = Type.Object( archiveReason: Type.Optional(SessionEntryArchiveReasonSchema), pinned: Type.Optional(Type.Boolean()), pinnedAt: Type.Optional(Type.Number()), + snoozedUntil: Type.Optional(Type.Number()), + snoozedAt: Type.Optional(Type.Number()), unread: Type.Optional(Type.Boolean()), lastReadAt: Type.Optional(Type.Number()), markedUnreadAt: Type.Optional(Type.Number()), diff --git a/src/agents/command/session-store.snooze.test.ts b/src/agents/command/session-store.snooze.test.ts new file mode 100644 index 000000000000..d420d66d7f88 --- /dev/null +++ b/src/agents/command/session-store.snooze.test.ts @@ -0,0 +1,86 @@ +// Verifies snooze settlement against current session metadata after a run. +import { describe, expect, it } from "vitest"; +import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js"; +import { + createRunResult, + loadPersistedSessionEntry, + seedSessionStore, + updateSessionStoreAfterAgentRun, + withTempSessionStore, +} from "./session-store.test-support.js"; + +describe("updateSessionStoreAfterAgentRun snooze", () => { + it.each([ + { + name: "completed run", + inFlight: false, + touchActivity: true, + preserve: false, + snoozed: false, + }, + { + name: "snoozed during the run", + inFlight: true, + touchActivity: true, + preserve: false, + snoozed: false, + }, + { + name: "heartbeat run", + inFlight: false, + touchActivity: false, + preserve: false, + snoozed: true, + }, + { + name: "preserved-state run", + inFlight: true, + touchActivity: true, + preserve: true, + snoozed: true, + }, + ])( + "settles snooze against the current entry for $name", + async ({ inFlight, touchActivity, preserve, snoozed }) => { + await withTempSessionStore(async ({ storePath }) => { + const sessionKey = "agent:main:explicit:snooze-completion"; + const sessionId = "snooze-completion"; + const snooze = { snoozedUntil: 4_102_444_800_000, snoozedAt: 1_800_000_000_000 }; + const entry: SessionEntry = { + sessionId, + updatedAt: 1, + lastActivityAt: 1, + pinnedAt: 1, + ...(inFlight ? {} : snooze), + }; + const sessionStore = { [sessionKey]: entry }; + await seedSessionStore(storePath, { [sessionKey]: { ...entry, ...snooze } }); + + await updateSessionStoreAfterAgentRun({ + cfg: {}, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "openai", + defaultModel: "gpt-5.4", + result: createRunResult({ sessionId, provider: "openai", model: "gpt-5.4" }), + touchInteraction: false, + touchActivity, + preserveUserFacingSessionModelState: preserve, + }); + + const persisted = loadPersistedSessionEntry(storePath, sessionKey); + expect(persisted).toEqual(sessionStore[sessionKey]); + expect(persisted?.pinnedAt).toBe(1); + if (snoozed) { + expect(persisted).toMatchObject({ ...snooze, lastActivityAt: 1 }); + } else { + expect(persisted).not.toHaveProperty("snoozedUntil"); + expect(persisted).not.toHaveProperty("snoozedAt"); + expect(persisted?.lastActivityAt).toBeGreaterThan(1); + } + }); + }, + ); +}); diff --git a/src/agents/command/session-store.test-support.ts b/src/agents/command/session-store.test-support.ts index 1975ed8c6db7..8ea527b9be23 100644 --- a/src/agents/command/session-store.test-support.ts +++ b/src/agents/command/session-store.test-support.ts @@ -1,3 +1,4 @@ +// Shares durable session-store fixtures and run results across command tests. import path from "node:path"; import { onTestFinished } from "vitest"; import { createFixtureLifetime } from "../../../test/helpers/fixture-lifetime.js"; @@ -9,6 +10,7 @@ import { import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js"; import { closeOpenClawAgentDatabasesAsync } from "../../state/openclaw-agent-db-lifecycle.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; +import type { EmbeddedAgentRunResult } from "../embedded-agent.js"; import { updateSessionStoreAfterAgentRun as updateSessionStoreAfterAgentRunBase } from "./session-store.js"; export async function withTempSessionStore( @@ -73,3 +75,10 @@ export async function updateSessionStoreAfterAgentRun( agentDir: params.agentDir ?? "/tmp/openclaw-session-store-test-agent", }); } + +export function createRunResult( + agentMeta: NonNullable, + meta: Partial> = {}, +): EmbeddedAgentRunResult { + return { meta: { durationMs: 1, ...meta, agentMeta } }; +} diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index cd85dd2a7d02..a5f126c0e078 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -22,6 +22,7 @@ import { recordCliCompactionInStore, } from "./session-store.js"; import { + createRunResult, loadPersistedSessionEntry, loadPersistedSessionStore, seedSessionStore, @@ -49,13 +50,6 @@ function acpMeta() { }; } -function createRunResult( - agentMeta: NonNullable, - meta: Partial> = {}, -): EmbeddedAgentRunResult { - return { meta: { durationMs: 1, ...meta, agentMeta } }; -} - function contextBudgetStatus( overrides: Partial> = {}, ): NonNullable { diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index a499b2290d2a..8bef279e659b 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -208,7 +208,7 @@ export async function updateSessionStoreAfterAgentRun(params: { // their exact still-current row and cannot recreate a deleted owner. return null; } - return preserveUserFacingRunState + const patch: Partial = preserveUserFacingRunState ? metadataPatch : projectSessionSnapshotChanges({ initial: entry, @@ -216,6 +216,12 @@ export async function updateSessionStoreAfterAgentRun(params: { current: currentEntry, reassertAbortedLastRun: result.meta.aborted === true, }); + if (touchActivity && !preserveUserFacingRunState && currentEntry.snoozedUntil !== undefined) { + // Clear the current snooze, including one set while this run was in flight. + patch.snoozedUntil = undefined; + patch.snoozedAt = undefined; + } + return patch; }, { ...(preserveUserFacingRunState || params.compactionAccounting diff --git a/src/auto-reply/reply/session-entry-lifecycle-carry.ts b/src/auto-reply/reply/session-entry-lifecycle-carry.ts new file mode 100644 index 000000000000..0e2b436fdada --- /dev/null +++ b/src/auto-reply/reply/session-entry-lifecycle-carry.ts @@ -0,0 +1,25 @@ +// Carries session visibility and interaction facts through an inbound entry rebuild. +import type { SessionEntry } from "../../config/sessions/types.js"; + +export function projectSessionEntryLifecycleCarry({ + entry, + baseEntry, + isSystemEvent, + now, +}: { + entry: SessionEntry | undefined; + baseEntry: SessionEntry | undefined; + isSystemEvent: boolean; + now: number; +}): Pick< + SessionEntry, + "lastInteractionAt" | "agentStatus" | "pinnedAt" | "snoozedUntil" | "snoozedAt" +> { + return { + lastInteractionAt: isSystemEvent ? baseEntry?.lastInteractionAt : now, + agentStatus: isSystemEvent ? baseEntry?.agentStatus : undefined, + pinnedAt: entry?.pinnedAt, + snoozedUntil: isSystemEvent ? entry?.snoozedUntil : undefined, + snoozedAt: isSystemEvent ? entry?.snoozedAt : undefined, + }; +} diff --git a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts index b04be7caf5e6..3acb929d08d4 100644 --- a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts +++ b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts @@ -64,3 +64,61 @@ it("keeps an expired session unchanged for heartbeat and resets on the next user }); expect(user).toMatchObject({ isNewSession: true, sessionId: "daily-session-id" }); }); + +it.each([false, true])( + "only user interaction spends snooze (system event: %s)", + async (isSystemEvent) => { + stateDir = tempDirs.make("openclaw-heartbeat-snooze-"); + const storePath = path.join(stateDir, "sessions.json"); + const sessionKey = "agent:main:main:user123"; + const now = Date.now(); + const snooze = { snoozedUntil: now + 3_600_000, snoozedAt: now - 1_000 }; + await replaceSessionEntry( + { storePath, sessionKey }, + { + sessionId: "snoozed-session", + updatedAt: now, + systemSent: true, + sessionStartedAt: now, + lastInteractionAt: now - 1_000, + ...snooze, + }, + ); + + const result = await initSessionState({ + cfg: { + agents: { defaults: { workspace: stateDir } }, + session: { store: storePath, reset: { mode: "idle", idleMinutes: 5 } }, + }, + commandAuthorized: true, + ctx: finalizeInboundContext({ + From: "user123", + To: "bot123", + SessionKey: sessionKey, + Provider: "quietchat", + Surface: "quietchat", + ChatType: "direct", + CommandAuthorized: true, + Body: "test message", + ...(isSystemEvent ? { InternalTurnSource: "heartbeat" } : {}), + }), + }); + + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe("snoozed-session"); + const persisted = loadSessionEntry({ storePath, sessionKey }); + if (!persisted) { + throw new Error(`Expected persisted session for ${sessionKey}`); + } + for (const entry of [result.sessionEntry, persisted]) { + if (isSystemEvent) { + expect(entry).toMatchObject(snooze); + expect(entry.lastInteractionAt).toBe(now - 1_000); + } else { + expect(entry.snoozedUntil).toBeUndefined(); + expect(entry.snoozedAt).toBeUndefined(); + expect(entry.lastInteractionAt).toBeGreaterThanOrEqual(now); + } + } + }, +); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index c8edc2306e31..bb7cf36722a5 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -130,6 +130,7 @@ import { resolveSessionDeliveryRoute, } from "./session-delivery.js"; import { createReplySessionEntryHandle } from "./session-entry-handle.js"; +import { projectSessionEntryLifecycleCarry } from "./session-entry-lifecycle-carry.js"; import { buildSessionEndHookPayload, buildSessionStartHookPayload, @@ -909,11 +910,9 @@ async function initSessionStateAttemptLocked( sessionStartedAt: isNewSession ? now : (baseEntry?.sessionStartedAt ?? lifecycleTimestamps.sessionStartedAt), - lastInteractionAt: isSystemEvent ? baseEntry?.lastInteractionAt : now, - agentStatus: isSystemEvent ? baseEntry?.agentStatus : undefined, + ...projectSessionEntryLifecycleCarry({ entry, baseEntry, isSystemEvent, now }), systemSent, abortedLastRun: recoveredTerminalEntry ? undefined : abortedLastRun, - pinnedAt: entry?.pinnedAt, usageFamilyKey, usageFamilySessionIds, previousSessionId: baseEntry?.previousSessionId, diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 60321a261dec..653a9209ed62 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -63,6 +63,46 @@ it("keeps only recognized archive reasons on archived rows", () => { ).toMatchObject({ archivedBy: { type: "human", id: "operator-1" } }); }); +it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, 0, -1])( + "drops malformed snooze wake time %j and its orphan timestamp", + (snoozedUntil) => { + const entry = normalizePersistedSessionEntryShape({ + sessionId: "snoozed-session", + updatedAt: 42, + snoozedUntil, + snoozedAt: 41, + }); + expect(entry).toBeDefined(); + expect(entry).not.toHaveProperty("snoozedUntil"); + expect(entry).not.toHaveProperty("snoozedAt"); + }, +); + +it("drops snooze metadata from archived entries so a restore cannot resurface a hidden session", () => { + const entry = normalizePersistedSessionEntryShape({ + sessionId: "capped-session", + updatedAt: 42, + archivedAt: 43, + archiveReason: "active-session-cap", + snoozedUntil: Number.MAX_SAFE_INTEGER, + snoozedAt: 41, + }); + expect(entry).toMatchObject({ archivedAt: 43, archiveReason: "active-session-cap" }); + expect(entry).not.toHaveProperty("snoozedUntil"); + expect(entry).not.toHaveProperty("snoozedAt"); +}); + +it("retains valid snooze metadata without turning it into a work-admission barrier", () => { + const entry = normalizePersistedSessionEntryShape({ + sessionId: "snoozed-session", + updatedAt: 42, + snoozedUntil: 100, + snoozedAt: 41, + }); + expect(entry).toMatchObject({ snoozedUntil: 100, snoozedAt: 41 }); + expect(resolveSessionWorkStartError("agent:main:snoozed", entry)).toBeUndefined(); +}); + it("preserves shipped pending key-as-session-id rows without a transcript id", () => { const entry = normalizePersistedSessionEntryShape( { diff --git a/src/config/sessions/store-entry-shape.ts b/src/config/sessions/store-entry-shape.ts index 307895ad8e25..d824c0356de0 100644 --- a/src/config/sessions/store-entry-shape.ts +++ b/src/config/sessions/store-entry-shape.ts @@ -151,6 +151,18 @@ export function projectCanonicalSessionEntryShape(value: Record delete canonicalValue.archivedBy; delete canonicalValue.archiveReason; } + // An archived entry never carries a snooze: automatic archival (cap, age, + // stale-dashboard) writes archive facts without the patch path, and a later + // restore must not resurface a still-hidden session. + if ( + canonicalValue.archivedAt !== undefined || + typeof canonicalValue.snoozedUntil !== "number" || + !Number.isFinite(canonicalValue.snoozedUntil) || + canonicalValue.snoozedUntil <= 0 + ) { + delete canonicalValue.snoozedUntil; + delete canonicalValue.snoozedAt; + } return canonicalValue as unknown as SessionEntry; } diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index a871a4f21ee2..c12bb8cf7365 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -314,6 +314,10 @@ type SessionEntryCore = SessionRestartRecoveryState & archiveReason?: SessionEntryArchiveReason; /** Timestamp (ms) when the session was pinned for quick access. */ pinnedAt?: number; + /** Epoch ms wake time; suppresses the active session in sidebar lists until then. */ + snoozedUntil?: number; + /** Server-stamped epoch ms when the current snooze was set. */ + snoozedAt?: number; /** Timestamp (ms) when an operator client last marked the session read. */ lastReadAt?: number; /** Agent-declared sidebar presence; projection drops it after expiresAt. */ diff --git a/src/gateway/server-methods/sessions-patch-archive.ts b/src/gateway/server-methods/sessions-patch-archive.ts index e429f5901ca3..e2cfee33ada3 100644 --- a/src/gateway/server-methods/sessions-patch-archive.ts +++ b/src/gateway/server-methods/sessions-patch-archive.ts @@ -42,7 +42,7 @@ import { unexpectedPatchError, } from "./sessions-patch-errors.js"; import { - isAgentMainSessionKey, + resolveProtectedSessionVisibilityError, resolveSessionWorkerPlacementPatchError, sessionLog, } from "./sessions-shared.js"; @@ -86,16 +86,6 @@ function archiveUnavailableError(key: string, message: "active" | "stopping"): E ); } -function protectedArchiveError(cfg: OpenClawConfig, canonicalKey: string): ErrorShape | undefined { - if (canonicalKey === "unknown") { - return errorShape(ErrorCodes.INVALID_REQUEST, "Cannot archive the unknown session sentinel."); - } - if (canonicalKey === "global" || isAgentMainSessionKey(cfg, canonicalKey)) { - return errorShape(ErrorCodes.INVALID_REQUEST, "Cannot archive an agent's main session."); - } - return undefined; -} - function archiveTargetChanged(params: { baselineEntry: SessionEntry | undefined; currentEntry: SessionEntry | undefined; @@ -173,7 +163,11 @@ export async function prepareSessionPatchArchive(params: { if (missingHarnessSessionError) { return err(errorShape(ErrorCodes.INVALID_REQUEST, missingHarnessSessionError)); } - const protectedError = protectedArchiveError(cfg, freshCanonicalKey); + const protectedError = resolveProtectedSessionVisibilityError( + cfg, + freshCanonicalKey, + "archive", + ); if (protectedError) { return err(protectedError); } @@ -319,7 +313,7 @@ export function validateSessionPatchArchiveProjection(params: { return archiveChangedError(params.key); } return ( - protectedArchiveError(params.cfg, params.primaryKey) ?? + resolveProtectedSessionVisibilityError(params.cfg, params.primaryKey, "archive") ?? resolvePluginSessionOwnershipError({ action: "patch", entry: params.existingEntry, diff --git a/src/gateway/server-methods/sessions-shared.ts b/src/gateway/server-methods/sessions-shared.ts index b02e59b528b1..3ef98b3a56f6 100644 --- a/src/gateway/server-methods/sessions-shared.ts +++ b/src/gateway/server-methods/sessions-shared.ts @@ -2,6 +2,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { ErrorCodes, errorShape, + type ErrorShape, type SessionOperationEvent, type SessionsPatchParams, } from "../../../packages/gateway-protocol/src/index.js"; @@ -175,3 +176,17 @@ export function isAgentMainSessionKey(cfg: OpenClawConfig, sessionKey: string): } return sessionKey === resolveAgentMainSessionKey({ cfg, agentId: parsed.agentId }); } + +export function resolveProtectedSessionVisibilityError( + cfg: OpenClawConfig, + canonicalKey: string, + action: "archive" | "snooze", +): ErrorShape | undefined { + if (canonicalKey === "unknown") { + return errorShape(ErrorCodes.INVALID_REQUEST, `Cannot ${action} the unknown session sentinel.`); + } + if (canonicalKey === "global" || isAgentMainSessionKey(cfg, canonicalKey)) { + return errorShape(ErrorCodes.INVALID_REQUEST, `Cannot ${action} an agent's main session.`); + } + return undefined; +} diff --git a/src/gateway/session-event-payload.test.ts b/src/gateway/session-event-payload.test.ts index 6ba3a203cc9c..a30604c7e3a3 100644 --- a/src/gateway/session-event-payload.test.ts +++ b/src/gateway/session-event-payload.test.ts @@ -21,6 +21,16 @@ it("clears a saved dashboard default in subscribed session metadata", () => { expect({ ...previous, ...cleared }).toMatchObject({ boardPresentation: null }); }); +it("publishes snooze metadata and clears it when a subscribed session wakes", () => { + const sessionRow = { key: "agent:main:dashboard", kind: "direct" as const, updatedAt: 1 }; + const previous = buildGatewaySessionSnapshot({ + sessionRow: { ...sessionRow, snoozedUntil: 3_600_000, snoozedAt: 1 }, + }); + expect(previous).toMatchObject({ snoozedUntil: 3_600_000, snoozedAt: 1 }); + const cleared = buildGatewaySessionSnapshot({ sessionRow }); + expect({ ...previous, ...cleared }).toMatchObject({ snoozedUntil: null, snoozedAt: null }); +}); + it("projects session actors and explicitly clears absent attribution", () => { expect( buildGatewaySessionSnapshot({ diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts index d4d1675a7242..beded21bd6c9 100644 --- a/src/gateway/session-event-payload.ts +++ b/src/gateway/session-event-payload.ts @@ -48,6 +48,8 @@ function buildGatewaySessionEventFields(params: { archiveReason: sessionRow.archiveReason ?? null, pinned: sessionRow.pinned ?? false, pinnedAt: sessionRow.pinnedAt ?? null, + snoozedUntil: sessionRow.snoozedUntil ?? null, + snoozedAt: sessionRow.snoozedAt ?? null, unread: sessionRow.unread ?? false, lastReadAt: sessionRow.lastReadAt, markedUnreadAt: sessionRow.markedUnreadAt ?? null, diff --git a/src/gateway/session-utils-provider.test-support.ts b/src/gateway/session-utils-provider.test-support.ts new file mode 100644 index 000000000000..6ac5b93880fb --- /dev/null +++ b/src/gateway/session-utils-provider.test-support.ts @@ -0,0 +1,24 @@ +// Shared row fixtures isolate provider artifact loading from session projection contracts. +import { vi } from "vitest"; +import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; + +const providerArtifactMocks = vi.hoisted(() => ({ + resolveBundledProviderPolicySurface: vi.fn< + typeof import("../plugins/provider-public-artifacts.js").resolveBundledProviderPolicySurface + >(() => null), +})); + +vi.mock("../plugins/provider-public-artifacts.js", () => ({ + resolveBundledProviderPolicySurface: providerArtifactMocks.resolveBundledProviderPolicySurface, + resolveProviderPolicySurface: providerArtifactMocks.resolveBundledProviderPolicySurface, +})); + +export function getSessionProviderArtifactMocks() { + return providerArtifactMocks; +} + +export function resetSessionProviderArtifacts() { + clearPluginMetadataLifecycleCaches(); + providerArtifactMocks.resolveBundledProviderPolicySurface.mockReset(); + providerArtifactMocks.resolveBundledProviderPolicySurface.mockReturnValue(null); +} diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 8200bf9aa613..71c1903fd87a 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -422,10 +422,9 @@ export function materializeSessionRow(input: ReturnType { + beforeEach(resetSessionProviderArtifacts); + afterAll(closeSessionSqliteDatabasesForTest); + + test.each([["agent:main:dashboard:pinned", { parentSessionKey: "agent:main:main" }]])( + "session lists separate archived rows and sort pinned %s first", + async (pinnedKey, lineage) => { + const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" }); + const store: Record = { + recent: { sessionId: "recent", updatedAt: 30 }, + [pinnedKey]: { + sessionId: "pinned", + updatedAt: 10, + pinnedAt: 40, + snoozedUntil: 1_800_000_000_000, + snoozedAt: 40, + ...lineage, + }, + archived: { + sessionId: "archived", + updatedAt: 20, + archivedAt: 50, + archiveReason: "active-session-cap", + }, + } satisfies Record; + + const active = await listSessionFixture({ cfg, storePath: "", store, opts: {} }); + expect(active.sessions.map((session) => session.key)).toEqual([pinnedKey, "recent"]); + expect(active.sessions[0]).toMatchObject({ + pinned: true, + pinnedAt: 40, + snoozedUntil: 1_800_000_000_000, + snoozedAt: 40, + archived: false, + }); + + const archived = await listSessionFixture({ + cfg, + storePath: "", + store, + opts: { archived: true }, + }); + expect(archived.sessions).toMatchObject([ + { + key: "archived", + archived: true, + archivedAt: 50, + archiveReason: "active-session-cap", + pinned: false, + }, + ]); + + const all = await listSessionFixture({ + cfg, + storePath: "", + store, + opts: { archived: "all" }, + }); + expect(all.sessions.map((session) => session.key)).toEqual([pinnedKey, "recent", "archived"]); + }, + ); + + test.each([["agent:main:subagent:child", {}]] as const)( + "ignores stale child pins and snooze metadata in session list projection: %s %j", + async (key, lineage) => { + const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" }); + const store: Record = { + "agent:main:dashboard:root": { sessionId: "root", updatedAt: 30 }, + [key]: { + sessionId: "child", + updatedAt: 10, + pinnedAt: 40, + snoozedUntil: 1_800_000_000_000, + snoozedAt: 40, + ...lineage, + }, + }; + for (const limit of [2, 201]) { + const listed = await listSessionFixture({ cfg, storePath: "", store, opts: { limit } }); + const child = listed.sessions.find((row) => row.key === key); + expect.soft(child?.pinned).toBe(false); + expect.soft(child?.pinnedAt).toBeUndefined(); + expect.soft(child?.snoozedUntil).toBeUndefined(); + expect.soft(child?.snoozedAt).toBeUndefined(); + expect(listed.sessions.map((row) => row.key)).toEqual(["agent:main:dashboard:root", key]); + } + }, + ); +}); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index bce70f0b1db2..4bbc2d1c7dbf 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1,5 +1,6 @@ // Session utility tests cover key parsing, store migration, agent/default rows, // model identity resolution, title derivation, and byte-capped row payloads. +import "./session-utils-provider.test-support.js"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -25,7 +26,6 @@ import type { CronJob } from "../cron/types.js"; import { clearAgentRunContext, registerAgentRunContext } from "../infra/agent-run-registry.js"; import type { ExecApprovalsFile } from "../infra/exec-approvals-core.js"; import * as execApprovalsStore from "../infra/exec-approvals-store.js"; -import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { @@ -72,16 +72,9 @@ import { } from "./session-utils.test-support.js"; import { applySessionContextWindowPatch } from "./sessions-patch-context-window.js"; -const providerArtifactMocks = vi.hoisted(() => ({ - resolveBundledProviderPolicySurface: vi.fn< - typeof import("../plugins/provider-public-artifacts.js").resolveBundledProviderPolicySurface - >(() => null), -})); - -vi.mock("../plugins/provider-public-artifacts.js", () => ({ - resolveBundledProviderPolicySurface: providerArtifactMocks.resolveBundledProviderPolicySurface, - resolveProviderPolicySurface: providerArtifactMocks.resolveBundledProviderPolicySurface, -})); +const { getSessionProviderArtifactMocks, resetSessionProviderArtifacts } = + await import("./session-utils-provider.test-support.js"); +const providerArtifactMocks = getSessionProviderArtifactMocks(); test("resolves fixed-store and auth compatibility owners", () => { const cfg = retainLegacyDefaultAgentId( @@ -261,12 +254,7 @@ describe("gateway session utils", () => { }); }); - beforeEach(() => { - // Real metadata/artifact loading belongs to owner tests; projections only need the contract. - clearPluginMetadataLifecycleCaches(); - providerArtifactMocks.resolveBundledProviderPolicySurface.mockReset(); - providerArtifactMocks.resolveBundledProviderPolicySurface.mockReturnValue(null); - }); + beforeEach(resetSessionProviderArtifacts); afterAll(closeSessionSqliteDatabasesForTest); @@ -558,73 +546,6 @@ describe("gateway session utils", () => { ]); }); - test.each([["agent:main:dashboard:pinned", { parentSessionKey: "agent:main:main" }]])( - "session lists separate archived rows and sort pinned %s first", - async (pinnedKey, lineage) => { - const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" }); - const store: Record = { - recent: { sessionId: "recent", updatedAt: 30 }, - [pinnedKey]: { sessionId: "pinned", updatedAt: 10, pinnedAt: 40, ...lineage }, - archived: { - sessionId: "archived", - updatedAt: 20, - archivedAt: 50, - archiveReason: "active-session-cap", - }, - } satisfies Record; - - const active = await listSessionFixture({ cfg, storePath: "", store, opts: {} }); - expect(active.sessions.map((session) => session.key)).toEqual([pinnedKey, "recent"]); - expect(active.sessions[0]).toMatchObject({ - pinned: true, - pinnedAt: 40, - archived: false, - }); - - const archived = await listSessionFixture({ - cfg, - storePath: "", - store, - opts: { archived: true }, - }); - expect(archived.sessions).toMatchObject([ - { - key: "archived", - archived: true, - archivedAt: 50, - archiveReason: "active-session-cap", - pinned: false, - }, - ]); - - const all = await listSessionFixture({ - cfg, - storePath: "", - store, - opts: { archived: "all" }, - }); - expect(all.sessions.map((session) => session.key)).toEqual([pinnedKey, "recent", "archived"]); - }, - ); - - test.each([["agent:main:subagent:child", {}]] as const)( - "ignores stale child pins in session list projection and ordering: %s %j", - async (key, lineage) => { - const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" }); - const store: Record = { - "agent:main:dashboard:root": { sessionId: "root", updatedAt: 30 }, - [key]: { sessionId: "child", updatedAt: 10, pinnedAt: 40, ...lineage }, - }; - for (const limit of [2, 201]) { - const listed = await listSessionFixture({ cfg, storePath: "", store, opts: { limit } }); - const child = listed.sessions.find((row) => row.key === key); - expect.soft(child?.pinned).toBe(false); - expect.soft(child?.pinnedAt).toBeUndefined(); - expect(listed.sessions.map((row) => row.key)).toEqual(["agent:main:dashboard:root", key]); - } - }, - ); - test("session lists page from an offset after filtering and sorting", async () => { const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" }); const store = Object.fromEntries( diff --git a/src/gateway/sessions-patch-lifecycle-flags.ts b/src/gateway/sessions-patch-lifecycle-flags.ts new file mode 100644 index 000000000000..c2a0e60f03c0 --- /dev/null +++ b/src/gateway/sessions-patch-lifecycle-flags.ts @@ -0,0 +1,93 @@ +// Applies archive, pin, snooze, and unread facts to the projected session entry. +import type { ErrorShape, SessionsPatchParams } from "../../packages/gateway-protocol/src/index.js"; +import { isPinnableSessionEntry } from "../config/sessions/session-pin-policy.js"; +import type { SessionEntry } from "../config/sessions/types.js"; +import { invalidSessionRequest as invalid } from "./session-request-error.js"; + +export function applySessionPatchLifecycleFlags(params: { + patch: SessionsPatchParams; + next: SessionEntry; + existingEntry?: SessionEntry; + storeKey: string; + now: number; + archivedBy?: SessionEntry["archivedBy"]; +}): ErrorShape | undefined { + const { patch, next, existingEntry, storeKey, now, archivedBy } = params; + if ("archived" in patch) { + if (patch.archived === true) { + // Archived sessions leave the active quick-access set in the same write. + if (next.archivedAt === undefined) { + next.archivedAt = now; + next.archiveReason = "manual"; + if (archivedBy) { + next.archivedBy = archivedBy; + } else { + delete next.archivedBy; + } + } + delete next.pinnedAt; + delete next.snoozedUntil; + delete next.snoozedAt; + } else { + delete next.archivedAt; + delete next.archivedBy; + delete next.archiveReason; + } + } + + const pinnable = isPinnableSessionEntry(storeKey, next); + if (!pinnable) { + delete next.pinnedAt; + } + if ("snoozedUntil" in patch) { + const snoozedUntil = patch.snoozedUntil; + if (snoozedUntil === null) { + delete next.snoozedUntil; + delete next.snoozedAt; + } else if (snoozedUntil !== undefined) { + if (next.archivedAt !== undefined) { + return invalid("cannot snooze an archived session; restore it first").error; + } + if (!pinnable) { + return invalid("cannot snooze a child session; snooze its parent session instead").error; + } + if (!(snoozedUntil > now)) { + return invalid("snooze wake time must be in the future").error; + } + if (next.snoozedUntil !== snoozedUntil) { + next.snoozedUntil = snoozedUntil; + next.snoozedAt = now; + } + } + } + if ("pinned" in patch) { + if (patch.pinned === true) { + if (next.archivedAt !== undefined) { + return invalid("cannot pin an archived session; restore it first").error; + } + if (!pinnable) { + return invalid("cannot pin a child session; pin its parent session instead").error; + } + next.pinnedAt ??= now; + // Pinning promotes the session into the active quick-access set immediately. + delete next.snoozedUntil; + delete next.snoozedAt; + } else { + delete next.pinnedAt; + } + } + + if ("unread" in patch) { + if (patch.unread === true) { + // This timestamp is also the conditional-ack revision. Repeated writes in + // one clock tick must still represent distinct manual unread intent. + next.markedUnreadAt = Math.max(now, (existingEntry?.markedUnreadAt ?? 0) + 1); + } else { + next.lastReadAt = now; + delete next.markedUnreadAt; + delete next.agentStatus; + } + } + + return undefined; +} diff --git a/src/gateway/sessions-patch.snooze.test.ts b/src/gateway/sessions-patch.snooze.test.ts new file mode 100644 index 000000000000..a61c89e25c2b --- /dev/null +++ b/src/gateway/sessions-patch.snooze.test.ts @@ -0,0 +1,216 @@ +// Snooze patch coverage preserves active lifecycle, target guards, and wake semantics. +import { describe, expect, test, vi } from "vitest"; +import type { SessionEntry } from "../config/sessions.js"; +import { + MAIN_SESSION_KEY, + runPatch, + expectPatchOk, + expectPatchError, +} from "./sessions-patch.test-support.js"; + +describe("snooze", () => { + const key = "agent:main:dashboard:work"; + const now = 1_800_000_000_000; + const wakeAt = now + 3_600_000; + + test("preserves the pin and the original snooze stamp until a different wake time is chosen", async () => { + using clock = vi.spyOn(Date, "now").mockReturnValue(now); + const store = { + [key]: { + sessionId: "work", + updatedAt: 1, + pinnedAt: 10, + parentSessionKey: MAIN_SESSION_KEY, + }, + }; + const patch = (snoozedUntil: number) => + runPatch({ store, storeKey: key, patch: { key, snoozedUntil, expectedSessionId: "work" } }); + expect(expectPatchOk(await patch(wakeAt))).toMatchObject({ + snoozedUntil: wakeAt, + snoozedAt: now, + pinnedAt: 10, + }); + expect(store[key]).not.toHaveProperty("archivedAt"); + clock.mockReturnValue(now + 100); + expect(expectPatchOk(await patch(wakeAt)).snoozedAt).toBe(now); + expect(expectPatchOk(await patch(wakeAt + 100))).toMatchObject({ + snoozedUntil: wakeAt + 100, + snoozedAt: now + 100, + pinnedAt: 10, + }); + }); + + test.each([now - 1, now])( + "rejects a wake time that is not in the future: %s", + async (snoozedUntil) => { + using clock = vi.spyOn(Date, "now"); + clock.mockReturnValue(now); + const entry = { sessionId: "work", updatedAt: 1 }; + const store = { [key]: entry }; + expectPatchError( + await runPatch({ + store, + storeKey: key, + patch: { key, snoozedUntil, expectedSessionId: "work" }, + }), + "snooze wake time must be in the future", + ); + expect(store[key]).toBe(entry); + }, + ); + + test.each([ + { + key, + entry: { archivedAt: 10 }, + error: "cannot snooze an archived session; restore it first", + }, + { + key, + entry: { spawnedBy: MAIN_SESSION_KEY }, + error: "cannot snooze a child session; snooze its parent session instead", + }, + { + key, + entry: { parentSessionKey: "agent:main:dashboard:parent" }, + error: "cannot snooze a child session; snooze its parent session instead", + }, + { + key: "agent:main:subagent:child", + entry: {}, + error: "cannot snooze a child session; snooze its parent session instead", + }, + { key: "unknown", entry: {}, error: "Cannot snooze the unknown session sentinel." }, + { key: "global", entry: {}, error: "Cannot snooze an agent's main session." }, + { key: MAIN_SESSION_KEY, entry: {}, error: "Cannot snooze an agent's main session." }, + ])("rejects snoozing $key with $entry", async ({ key: storeKey, entry, error }) => { + using clock = vi.spyOn(Date, "now"); + clock.mockReturnValue(now); + const original = { sessionId: "work", updatedAt: 1, ...entry }; + const store = { [storeKey]: original }; + expectPatchError( + await runPatch({ + store, + storeKey, + patch: { key: storeKey, snoozedUntil: wakeAt, expectedSessionId: "work" }, + }), + error, + ); + expect(store[storeKey]).toBe(original); + }); + + test("wakes idempotently without losing the pin", async () => { + const store = { + [key]: { + sessionId: "work", + updatedAt: 1, + pinnedAt: 10, + snoozedUntil: wakeAt, + snoozedAt: now, + }, + }; + for (let attempt = 0; attempt < 2; attempt++) { + const entry = expectPatchOk( + await runPatch({ + store, + storeKey: key, + patch: { key, snoozedUntil: null, expectedSessionId: "work" }, + }), + ); + expect(entry.snoozedUntil).toBeUndefined(); + expect(entry.snoozedAt).toBeUndefined(); + expect(entry.pinnedAt).toBe(10); + } + }); + + test.each([ + { + patch: { archived: true, snoozedUntil: wakeAt }, + error: "cannot snooze an archived session; restore it first", + }, + { patch: { archived: false, snoozedUntil: wakeAt }, snoozed: true }, + { patch: { pinned: true, snoozedUntil: wakeAt }, snoozed: false }, + ])("applies combined visibility patch $patch consistently", async ({ patch, error, snoozed }) => { + using clock = vi.spyOn(Date, "now"); + clock.mockReturnValue(now); + const entry = { + sessionId: "work", + updatedAt: 1, + ...(patch.archived === false ? { archivedAt: 10 } : {}), + }; + const store = { [key]: entry }; + const result = await runPatch({ + store, + storeKey: key, + patch: { key, ...patch, expectedSessionId: "work" }, + }); + if (error) { + expectPatchError(result, error); + expect(store[key]).toBe(entry); + } else { + const next = expectPatchOk(result); + expect(next.archivedAt).toBeUndefined(); + expect(next.snoozedUntil).toBe(snoozed ? wakeAt : undefined); + expect(next.snoozedAt).toBe(snoozed ? now : undefined); + if (patch.pinned) { + expect(next.pinnedAt).toBe(now); + } + } + }); + + test.each([ + { action: "snooze", snoozedUntil: wakeAt, sessionId: undefined }, + { action: "snooze", snoozedUntil: wakeAt, sessionId: "" }, + { action: "wake", snoozedUntil: null, sessionId: undefined }, + { action: "wake", snoozedUntil: null, sessionId: "" }, + ])("rejects $action for a provisional session identity", async ({ snoozedUntil, sessionId }) => { + const entry = { sessionId, updatedAt: 1 } as SessionEntry; + const store = { [key]: entry }; + expectPatchError( + await runPatch({ store, storeKey: key, patch: { key, snoozedUntil } }), + `session not found: ${key}`, + ); + expect(store[key]).toBe(entry); + }); + + test.each([wakeAt, null])( + "requires the caller-observed durable identity for %j", + async (snoozedUntil) => { + expectPatchError( + await runPatch({ + store: { [key]: { sessionId: "work", updatedAt: 1 } }, + storeKey: key, + patch: { key, snoozedUntil }, + }), + "expectedSessionId required for session lifecycle patch", + ); + }, + ); + + test.each([{ archived: true }, { pinned: true }])( + "clears snooze when applying %j", + async (patch) => { + using clock = vi.spyOn(Date, "now"); + clock.mockReturnValue(now); + const entry = expectPatchOk( + await runPatch({ + store: { + [key]: { + sessionId: "work", + updatedAt: 1, + pinnedAt: 10, + snoozedUntil: wakeAt, + snoozedAt: now - 1, + }, + }, + storeKey: key, + patch: { key, ...patch, expectedSessionId: "work" }, + }), + ); + expect(entry.snoozedUntil).toBeUndefined(); + expect(entry.snoozedAt).toBeUndefined(); + expect(entry.archivedAt).toBe(patch.archived ? now : undefined); + expect(entry.pinnedAt).toBe(patch.pinned ? 10 : undefined); + }, + ); +}); diff --git a/src/gateway/sessions-patch.test-support.ts b/src/gateway/sessions-patch.test-support.ts new file mode 100644 index 000000000000..c10db9b1711f --- /dev/null +++ b/src/gateway/sessions-patch.test-support.ts @@ -0,0 +1,95 @@ +// Shared Gateway patch fixtures exercise the real entry projection without persistence. +import { expect } from "vitest"; +import type { SessionCreatedActor } from "../../packages/gateway-protocol/src/index.js"; +import type { ModelCatalogEntry } from "../agents/model-catalog.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { projectSessionsPatchEntry } from "./sessions-patch.js"; + +export const MAIN_SESSION_KEY = "agent:main:main"; +const EMPTY_CFG = {} as OpenClawConfig; + +async function applySessionsPatchToStore( + params: Omit< + Parameters[0], + "existingEntry" | "isLabelInUse" + > & { + store: Record; + loadGatewayModelCatalog?: () => Promise; + }, +) { + const load = params.loadGatewayModelCatalog; + const projected = await projectSessionsPatchEntry({ + ...params, + loadGatewayModelCatalogSnapshot: load + ? async () => { + const entries = await load(); + return { entries, routeVariants: entries }; + } + : undefined, + existingEntry: params.store[params.storeKey], + isLabelInUse: (label) => + Object.entries(params.store).some( + ([sessionKey, entry]) => sessionKey !== params.storeKey && entry.label === label, + ), + }); + if (projected.ok) { + params.store[params.storeKey] = projected.entry; + } + return projected; +} + +export type ApplySessionsPatchArgs = Parameters[0]; + +export async function runPatch(params: { + patch: ApplySessionsPatchArgs["patch"]; + store?: Record; + cfg?: OpenClawConfig; + storeKey?: string; + agentId?: string; + loadGatewayModelCatalog?: ApplySessionsPatchArgs["loadGatewayModelCatalog"]; + providerAuthMetadataSnapshot?: ApplySessionsPatchArgs["providerAuthMetadataSnapshot"]; + archivedBy?: SessionCreatedActor; +}) { + return applySessionsPatchToStore({ + cfg: params.cfg ?? EMPTY_CFG, + store: params.store ?? {}, + storeKey: params.storeKey ?? MAIN_SESSION_KEY, + agentId: params.agentId, + patch: params.patch, + loadGatewayModelCatalog: params.loadGatewayModelCatalog, + providerAuthMetadataSnapshot: params.providerAuthMetadataSnapshot, + archivedBy: params.archivedBy, + }); +} + +export function expectPatchOk( + result: Awaited>, +): SessionEntry { + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error(result.error.message); + } + return result.entry; +} + +export function expectPatchError( + result: Awaited>, + message: string, +): void { + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error(`Expected patch failure containing: ${message}`); + } + expect(result.error.message).toContain(message); +} + +export function mainStoreEntry(overrides: Partial): Record { + return { + [MAIN_SESSION_KEY]: { + sessionId: "sess", + updatedAt: 1, + ...overrides, + } as SessionEntry, + }; +} diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 113b2d940711..7083b6b24f49 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -1,7 +1,6 @@ // Session patch tests cover model/provider edits, subagent patching, provider // aliases, model catalog validation, and rejected invalid patch payloads. import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import type { SessionCreatedActor } from "../../packages/gateway-protocol/src/index.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; @@ -15,36 +14,14 @@ import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../sessions/agent-ha import { MODEL_SELECTION_LOCKED_MESSAGE } from "../sessions/model-overrides.js"; import { withAgentSessionModelPatchOrigin } from "./session-model-patch-origin.js"; import { projectSessionsPatchEntry } from "./sessions-patch.js"; - -async function applySessionsPatchToStore( - params: Omit< - Parameters[0], - "existingEntry" | "isLabelInUse" - > & { - store: Record; - loadGatewayModelCatalog?: () => Promise; - }, -) { - const load = params.loadGatewayModelCatalog; - const projected = await projectSessionsPatchEntry({ - ...params, - loadGatewayModelCatalogSnapshot: load - ? async () => { - const entries = await load(); - return { entries, routeVariants: entries }; - } - : undefined, - existingEntry: params.store[params.storeKey], - isLabelInUse: (label) => - Object.entries(params.store).some( - ([sessionKey, entry]) => sessionKey !== params.storeKey && entry.label === label, - ), - }); - if (projected.ok) { - params.store[params.storeKey] = projected.entry; - } - return projected; -} +import { + type ApplySessionsPatchArgs, + MAIN_SESSION_KEY, + runPatch, + expectPatchOk, + expectPatchError, + mainStoreEntry, +} from "./sessions-patch.test-support.js"; const acpSessionMetaMocks = vi.hoisted(() => ({ readAcpSessionMetaForEntry: vi.fn(), @@ -65,16 +42,13 @@ vi.mock("../plugins/provider-thinking.js", () => ({ const SUBAGENT_MODEL = "synthetic/hf:moonshotai/Kimi-K2.7-Code"; const KIMI_SUBAGENT_KEY = "agent:kimi:subagent:child"; -const MAIN_SESSION_KEY = "agent:main:main"; const ANTHROPIC_SONNET_MODEL = "anthropic/claude-sonnet-4-6"; const ANTHROPIC_SONNET_ID = "claude-sonnet-4-6"; const ANTHROPIC_OPUS_MODEL = "anthropic/claude-opus-4-6"; const ANTHROPIC_OPUS_ID = "claude-opus-4-6"; const OPENAI_GPT_MODEL = "openai/gpt-5.4"; const OPENAI_GPT_ID = "gpt-5.4"; -const EMPTY_CFG = {} as OpenClawConfig; -type ApplySessionsPatchArgs = Parameters[0]; type ProviderAuthMetadataSnapshot = NonNullable< ApplySessionsPatchArgs["providerAuthMetadataSnapshot"] >; @@ -100,59 +74,6 @@ const BYTEPLUS_PROVIDER_AUTH_METADATA_SNAPSHOT = { ], } satisfies ProviderAuthMetadataSnapshot; -async function runPatch(params: { - patch: ApplySessionsPatchArgs["patch"]; - store?: Record; - cfg?: OpenClawConfig; - storeKey?: string; - agentId?: string; - loadGatewayModelCatalog?: ApplySessionsPatchArgs["loadGatewayModelCatalog"]; - providerAuthMetadataSnapshot?: ApplySessionsPatchArgs["providerAuthMetadataSnapshot"]; - archivedBy?: SessionCreatedActor; -}) { - return applySessionsPatchToStore({ - cfg: params.cfg ?? EMPTY_CFG, - store: params.store ?? {}, - storeKey: params.storeKey ?? MAIN_SESSION_KEY, - agentId: params.agentId, - patch: params.patch, - loadGatewayModelCatalog: params.loadGatewayModelCatalog, - providerAuthMetadataSnapshot: params.providerAuthMetadataSnapshot, - archivedBy: params.archivedBy, - }); -} - -function expectPatchOk( - result: Awaited>, -): SessionEntry { - expect(result.ok).toBe(true); - if (!result.ok) { - throw new Error(result.error.message); - } - return result.entry; -} - -function expectPatchError( - result: Awaited>, - message: string, -): void { - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error(`Expected patch failure containing: ${message}`); - } - expect(result.error.message).toContain(message); -} - -function mainStoreEntry(overrides: Partial): Record { - return { - [MAIN_SESSION_KEY]: { - sessionId: "sess", - updatedAt: 1, - ...overrides, - } as SessionEntry, - }; -} - function mainAuthOverrideStore(overrides: Partial): Record { return mainStoreEntry({ providerOverride: "anthropic", diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 4bccc9d16889..32d5a79abb10 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -49,7 +49,6 @@ import { buildSessionCreationStamp, type SessionCreatedVia, } from "../config/sessions/session-entry-provenance.js"; -import { isPinnableSessionEntry } from "../config/sessions/session-pin-policy.js"; import { normalizeSessionToolOverrides } from "../config/sessions/session-tool-overrides.js"; import { projectCanonicalSessionEntryShape } from "../config/sessions/store-entry-shape.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -88,6 +87,7 @@ import { prepareSessionPatchModelSelection, resolveSessionPatchModelSelection, } from "./server-methods/sessions-patch-model-selection.js"; +import { resolveProtectedSessionVisibilityError } from "./server-methods/sessions-shared.js"; import { applySessionExecutionSettings } from "./session-execution-settings.js"; import { isAgentSessionModelPatchOrigin, @@ -97,6 +97,7 @@ import { import { invalidSessionRequest as invalid } from "./session-request-error.js"; import { applySessionContextWindowPatch } from "./sessions-patch-context-window.js"; import { applySessionsPatchDisplayMetadata } from "./sessions-patch-display-metadata.js"; +import { applySessionPatchLifecycleFlags } from "./sessions-patch-lifecycle-flags.js"; import { applySessionsPatchSubagentPolicy } from "./sessions-patch-subagent-policy.js"; type SessionPatchProjectionParams = { @@ -188,7 +189,7 @@ function* projectSessionPatchSteps( if (harnessSessionError) { return invalid(harnessSessionError); } - if (typeof patch.archived === "boolean") { + if (typeof patch.archived === "boolean" || "snoozedUntil" in patch) { if (!params.existingEntry?.sessionId) { return invalid(`session not found: ${storeKey}`); } @@ -374,54 +375,22 @@ function* projectSessionPatchSteps( } } - if ("archived" in patch) { - if (patch.archived === true) { - // Archived sessions leave the active quick-access set in the same write. - if (next.archivedAt === undefined) { - next.archivedAt = now; - next.archiveReason = "manual"; - if (params.archivedBy) { - next.archivedBy = params.archivedBy; - } else { - delete next.archivedBy; - } - } - delete next.pinnedAt; - } else { - delete next.archivedAt; - delete next.archivedBy; - delete next.archiveReason; + if (patch.snoozedUntil !== undefined && patch.snoozedUntil !== null) { + const protectedError = resolveProtectedSessionVisibilityError(cfg, storeKey, "snooze"); + if (protectedError) { + return { ok: false, error: protectedError }; } } - - const pinnable = isPinnableSessionEntry(storeKey, next); - if (!pinnable) { - delete next.pinnedAt; - } - if ("pinned" in patch) { - if (patch.pinned === true) { - if (next.archivedAt !== undefined) { - return invalid("cannot pin an archived session; restore it first"); - } - if (!pinnable) { - return invalid("cannot pin a child session; pin its parent session instead"); - } - next.pinnedAt ??= now; - } else { - delete next.pinnedAt; - } - } - - if ("unread" in patch) { - if (patch.unread === true) { - // This timestamp is also the conditional-ack revision. Repeated writes in - // one clock tick must still represent distinct manual unread intent. - next.markedUnreadAt = Math.max(now, (params.existingEntry?.markedUnreadAt ?? 0) + 1); - } else { - next.lastReadAt = now; - delete next.markedUnreadAt; - delete next.agentStatus; - } + const lifecycleFlagsError = applySessionPatchLifecycleFlags({ + patch, + next, + existingEntry: params.existingEntry, + storeKey, + now, + archivedBy: params.archivedBy, + }); + if (lifecycleFlagsError) { + return { ok: false, error: lifecycleFlagsError }; } const rawThinking = patch.thinkingLevel; diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 24b77b292cda..971c0d950d84 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -22,6 +22,8 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "archivedBy", "archiveReason", "pinnedAt", + "snoozedUntil", + "snoozedAt", "lastReadAt", "agentStatus", "observerDigest", diff --git a/src/shared/session-method-scopes-base.ts b/src/shared/session-method-scopes-base.ts index fa36a68ed175..71b6dbbdee42 100644 --- a/src/shared/session-method-scopes-base.ts +++ b/src/shared/session-method-scopes-base.ts @@ -86,6 +86,7 @@ const SESSIONS_PATCH_WRITE_SCOPE_MUTATIONS: ReadonlySet = new Set([ "boardPresentation", "pinned", "archived", + "snoozedUntil", "unread", "model", "agentRuntime", diff --git a/src/shared/session-method-scopes.test.ts b/src/shared/session-method-scopes.test.ts index eb2331d26d39..8d41908c3778 100644 --- a/src/shared/session-method-scopes.test.ts +++ b/src/shared/session-method-scopes.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { resolveSessionMethodScope } from "./session-method-scopes-base.js"; import { resolveDynamicSessionMutationRequiredScope } from "./session-method-scopes.js"; describe("resolveDynamicSessionMutationRequiredScope", () => { @@ -116,6 +117,22 @@ describe("resolveDynamicSessionMutationRequiredScope", () => { ).toBe("operator.admin"); }); + it.each([ + { pinned: true }, + { archived: true }, + { snoozedUntil: 1_800_000_000_000 }, + { snoozedUntil: null }, + ])("allows session-scoped visibility mutations for single and batch patch %j", (patch) => { + const target = { key: "agent:main:thread", expectedSessionId: "session-1" }; + for (const [method, params] of [ + ["sessions.patch", { ...target, ...patch }], + ["sessions.patchMany", { targets: [target], patch }], + ] as const) { + expect(resolveSessionMethodScope(method, params)).toBe("operator.sessions.write"); + expect(resolveDynamicSessionMutationRequiredScope(method, params)).toBe("operator.write"); + } + }); + it.each([ { contextWindow: "extended" }, { toolOverrides: {} }, diff --git a/test/vitest/vitest.database-worker-core-paths.mjs b/test/vitest/vitest.database-worker-core-paths.mjs index 75512665fabd..0486436527c4 100644 --- a/test/vitest/vitest.database-worker-core-paths.mjs +++ b/test/vitest/vitest.database-worker-core-paths.mjs @@ -156,6 +156,7 @@ export const databaseWorkerCoreTestFiles = [ "src/agents/bash-tools.process.finished-retention.test.ts", "src/agents/bash-tools.test.ts", "src/agents/code-mode.bridge.host-denial.test.ts", + "src/agents/command/session-store.snooze.test.ts", "src/agents/command/session-store.test.ts", "src/agents/core-coding-tools.exec-workdir.test.ts", "src/agents/sessions/agent-session-tool-result-redaction.test.ts", diff --git a/test/vitest/vitest.gateway-server-paths.mjs b/test/vitest/vitest.gateway-server-paths.mjs index ba637844e98b..466bb5d0a7e7 100644 --- a/test/vitest/vitest.gateway-server-paths.mjs +++ b/test/vitest/vitest.gateway-server-paths.mjs @@ -213,6 +213,7 @@ export const gatewayDatabaseWorkerTestFiles = [ "src/gateway/session-utils.queued-collector-narrow-abort.test.ts", "src/gateway/session-utils.queued-collector.test.ts", "src/gateway/session-utils.single-row-cache.test.ts", + "src/gateway/session-utils.snooze.test.ts", "src/gateway/session-utils.subagent-payloads.test.ts", "src/gateway/session-utils.subagent.test.ts", "src/gateway/session-utils.test.ts", diff --git a/ui/src/components/app-sidebar-agent-session-rows.ts b/ui/src/components/app-sidebar-agent-session-rows.ts index 20a9e81ffed3..f332e7060115 100644 --- a/ui/src/components/app-sidebar-agent-session-rows.ts +++ b/ui/src/components/app-sidebar-agent-session-rows.ts @@ -105,6 +105,7 @@ export function projectSidebarAgentSessionRows({ : host.sessionData, selectedAgentId: selected, statusFilter: host.sessionsStatusFilter, + now: Date.now(), deletionState: (key, agentId) => host.sessionDataContext?.sessions.deletionState( key, @@ -307,6 +308,7 @@ export function projectSidebarHomeSession({ : host.sessionData, selectedAgentId: agentId, statusFilter: host.sessionsStatusFilter, + now: Date.now(), deletionState: (key, owner) => host.sessionDataContext?.sessions.deletionState(key, owner), archiveVisibility: (key) => host.sessionDataContext?.sessions.archiveVisibility(key), }); diff --git a/ui/src/components/app-sidebar-session-archive-visibility.test.ts b/ui/src/components/app-sidebar-session-archive-visibility.test.ts new file mode 100644 index 000000000000..1ef3b7db7d4c --- /dev/null +++ b/ui/src/components/app-sidebar-session-archive-visibility.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewaySessionRow } from "../api/types.ts"; +import { + createSessionCapabilityHarness, + sessionsResult, +} from "../lib/sessions/session-capability.test-support.ts"; +import { buildSessionListParams } from "../lib/sessions/session-requests.ts"; +import { createTestGatewayClient } from "../test-helpers/gateway-client.ts"; +import { projectSidebarArchiveVisibility } from "./app-sidebar-session-archive-visibility.ts"; +import type { SidebarSessionStatusFilter } from "./app-sidebar-session-types.ts"; + +function project(rows: GatewaySessionRow[], statusFilter: SidebarSessionStatusFilter, now = 100) { + return projectSidebarArchiveVisibility({ + sessionData: { + sessionsAgentId: "main", + sessionsResult: sessionsResult(rows, now), + sessionResultsByAgent: {}, + childSessionRowsByParent: {}, + loadedChildSessionKeys: new Set(), + loadingChildSessionKeys: new Set(), + childSessionErrorsByParent: new Map(), + }, + selectedAgentId: "main", + statusFilter, + now, + deletionState: () => undefined, + archiveVisibility: (key) => + rows.find((row) => row.key === key)?.archived ? "archived" : undefined, + }); +} + +afterEach(() => vi.useRealTimers()); + +describe("sidebar snooze visibility", () => { + const rows: GatewaySessionRow[] = [ + { key: "awake", kind: "direct" }, + { key: "snoozed", kind: "direct", snoozedUntil: 200, pinned: true }, + { key: "expired", kind: "direct", snoozedUntil: 100 }, + { key: "archived", kind: "direct", archived: true, snoozedUntil: 200 }, + ]; + it.each([ + ["active", ["awake", "expired"]], + ["snoozed", ["snoozed"]], + ["archived", ["archived"]], + ["all", ["awake", "snoozed", "expired", "archived"]], + ] as const)("shows the expected rows in %s", (filter, keys) => { + expect(project(rows, filter).rows.map((row) => row.key)).toEqual(keys); + }); + + it("requests the same active lifecycle window for Snoozed and Active", () => { + expect(buildSessionListParams({ archivedFilter: "snoozed", agentId: "main" })).toEqual( + buildSessionListParams({ archivedFilter: "active", agentId: "main" }), + ); + }); + + it("applies snooze and wake publications immediately without a pending visibility state or list read", async () => { + vi.useFakeTimers(); + vi.setSystemTime(100); + const row: GatewaySessionRow = { + key: "agent:main:snooze", + sessionId: "snooze-id", + kind: "direct", + updatedAt: 100, + }; + const request = vi.fn(async (method: string) => { + if (method === "sessions.subscribe") { + return { subscribed: true }; + } + if (method === "sessions.list") { + return sessionsResult([row], 100); + } + throw new Error(`Unexpected request: ${method}`); + }); + const client = createTestGatewayClient(request); + const { sessions, emitEvent } = createSessionCapabilityHarness(client.request.bind(client)); + await sessions.refresh({ agentId: "main", force: true }); + const reads = request.mock.calls.filter(([method]) => method === "sessions.list").length; + for (const snoozed of [true, false]) { + const changed = { + ...row, + updatedAt: snoozed ? 101 : 102, + ...(snoozed ? { snoozedUntil: 200, snoozedAt: 101 } : {}), + }; + emitEvent({ + type: "event", + event: "sessions.changed", + payload: { + sessionKey: row.key, + reason: "patch", + ts: changed.updatedAt, + session: changed, + ancestorSessions: [], + }, + }); + expect( + project(sessions.state.result?.sessions ?? [], "active").rows.map((entry) => entry.key), + ).toEqual(snoozed ? [] : [row.key]); + expect(sessions.archiveVisibility(row.key)).toBeUndefined(); + } + expect(request.mock.calls.filter(([method]) => method === "sessions.list")).toHaveLength(reads); + }); +}); diff --git a/ui/src/components/app-sidebar-session-archive-visibility.ts b/ui/src/components/app-sidebar-session-archive-visibility.ts index ac0d6c246343..0905fb9afc96 100644 --- a/ui/src/components/app-sidebar-session-archive-visibility.ts +++ b/ui/src/components/app-sidebar-session-archive-visibility.ts @@ -1,6 +1,7 @@ import type { GatewaySessionRow } from "../api/types.ts"; import type { SessionCapability } from "../lib/sessions/index.ts"; import { normalizeAgentId } from "../lib/sessions/session-key.ts"; +import { isSessionSnoozed } from "../lib/sessions/session-snooze.ts"; import type { SidebarSessionStatusFilter } from "./app-sidebar-session-types.ts"; import type { SessionDataController } from "./session-data-controller.ts"; @@ -17,19 +18,25 @@ export function projectSidebarArchiveVisibility(input: { >; selectedAgentId: string; statusFilter: SidebarSessionStatusFilter; + now: number; deletionState: SessionCapability["deletionState"]; archiveVisibility: SessionCapability["archiveVisibility"]; }) { - const isLifecycleHidden = (key: string) => { + const isLifecycleHidden = (key: string): boolean => { const visibility = input.archiveVisibility(key); return ( - input.deletionState(key, input.selectedAgentId) || + Boolean(input.deletionState(key, input.selectedAgentId)) || visibility === "pending" || - (input.statusFilter === "active" && visibility === "archived") + ((input.statusFilter === "active" || input.statusFilter === "snoozed") && + visibility === "archived") ); }; - const isSessionHidden = (row: Pick) => - isLifecycleHidden(row.key) || (input.statusFilter === "archived" && row.archived !== true); + const isSessionHidden = (row: Pick) => + isLifecycleHidden(row.key) || + (input.statusFilter === "archived" && row.archived !== true) || + (input.statusFilter === "active" && isSessionSnoozed(row, input.now)) || + (input.statusFilter === "snoozed" && + (row.archived === true || !isSessionSnoozed(row, input.now))); const selectedAgentId = normalizeAgentId(input.selectedAgentId); const sourceRows = selectedAgentId === normalizeAgentId(input.sessionData.sessionsAgentId ?? "") @@ -49,13 +56,17 @@ export function projectSidebarArchiveVisibility(input: { ); const isChildSessionVisible = (parentKey: string, childKey: string, row?: GatewaySessionRow) => { const known = row ?? knownRows.get(childKey); - if (known ? isSessionHidden(known) : isLifecycleHidden(childKey)) { + if ( + known + ? isSessionHidden(known) + : input.statusFilter === "snoozed" || isLifecycleHidden(childKey) + ) { return false; } // Raw lineage includes archives. Only a complete active child window can // establish absence; pending/failed reads must retain discovery links. return ( - input.statusFilter !== "active" || + (input.statusFilter !== "active" && input.statusFilter !== "snoozed") || !input.sessionData.loadedChildSessionKeys.has(parentKey) || input.sessionData.loadingChildSessionKeys.has(parentKey) || input.sessionData.childSessionErrorsByParent.has(parentKey) || diff --git a/ui/src/components/app-sidebar-session-catalogs.test.ts b/ui/src/components/app-sidebar-session-catalogs.test.ts index 20277faf75ef..66767465f9a8 100644 --- a/ui/src/components/app-sidebar-session-catalogs.test.ts +++ b/ui/src/components/app-sidebar-session-catalogs.test.ts @@ -4,7 +4,9 @@ import type { SessionCatalog, SessionCatalogHost, } from "../../../packages/gateway-protocol/src/index.ts"; +import type { GatewaySessionRow } from "../api/types.ts"; import { i18n } from "../i18n/index.ts"; +import { projectSidebarArchiveVisibility } from "./app-sidebar-session-archive-visibility.ts"; import { findCatalogSessionHovercardRow, formatSidebarTimestamp, @@ -150,6 +152,62 @@ describe("projectSidebarSessionCatalogs", () => { canArchive: false, }); + it.each([ + ["active", 100, ["native"]], + ["active", 200, ["native", "adopted"]], + ["all", 100, ["native", "adopted"]], + ] as const)( + "applies shared %s visibility at %i to adopted rows only", + (statusFilter, now, expected) => { + const row: GatewaySessionRow = { + key: "agent:main:adopted", + kind: "direct", + snoozedUntil: 200, + }; + const hosts: SessionCatalogHost[] = [ + { + hostId: "gateway:local", + label: "Gateway", + kind: "gateway", + connected: true, + sessions: [ + session("native", "Native"), + { ...session("adopted", "Adopted"), sessionKey: row.key }, + ], + }, + ]; + const visibility = projectSidebarArchiveVisibility({ + sessionData: { + sessionsAgentId: "main", + sessionsResult: null, + sessionResultsByAgent: {}, + childSessionRowsByParent: {}, + loadedChildSessionKeys: new Set(), + loadingChildSessionKeys: new Set(), + childSessionErrorsByParent: new Map(), + }, + selectedAgentId: "main", + statusFilter, + now, + deletionState: () => undefined, + archiveVisibility: () => undefined, + }); + const projected = projectSidebarSessionCatalogs( + [catalog(hosts)], + null, + [row], + visibility.isSessionHidden, + ); + expect( + projected.flatMap((entry) => + entry.visibleHosts.flatMap((host) => + host.sessions.map((threadRow) => threadRow.threadId), + ), + ), + ).toEqual(expected); + }, + ); + it("removes empty hosts", () => { const hosts: SessionCatalogHost[] = [ { diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index c3868ee88ad9..49314da26a7c 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -133,11 +133,17 @@ export function projectSidebarSessionCatalogs( catalogs: readonly SessionCatalog[], ownerId: string | null, liveRows: readonly GatewaySessionRow[], + isSessionHidden?: (row: GatewaySessionRow) => boolean, ): SidebarSessionCatalog[] { // The current list wins over cached agent lists, including an unset live owner. - const liveOwners = new Map(liveRows.toReversed().map(({ key, owner }) => [key, owner?.actor.id])); + const liveRowsByKey = new Map(liveRows.toReversed().map((row) => [row.key, row])); return catalogs.flatMap((catalog) => { - const visibleHosts = visibleCatalogHosts(catalog.hosts, ownerId, liveOwners); + const visibleHosts = visibleCatalogHosts( + catalog.hosts, + ownerId, + liveRowsByKey, + isSessionHidden, + ); return visibleHosts.length > 0 ? [{ ...catalog, visibleHosts }] : []; }); } @@ -145,18 +151,20 @@ export function projectSidebarSessionCatalogs( function visibleCatalogHosts( hosts: readonly SessionCatalogHost[], ownerId?: string | null, - liveOwnerIdBySessionKey: ReadonlyMap = new Map(), + liveRowsByKey: ReadonlyMap = new Map(), + isSessionHidden?: (row: GatewaySessionRow) => boolean, ): SessionCatalogHost[] { const visible: SessionCatalogHost[] = []; for (const host of hosts) { const sessions = host.sessions.filter((session) => { + const adoptedRow = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined; + if (adoptedRow && isSessionHidden?.(adoptedRow)) { + return false; + } if (!ownerId) { return true; } - const sessionKey = session.sessionKey; - const adopted = Boolean(sessionKey && liveOwnerIdBySessionKey.has(sessionKey)); - const effectiveOwnerId = - adopted && sessionKey ? liveOwnerIdBySessionKey.get(sessionKey) : session.createdActor?.id; + const effectiveOwnerId = adoptedRow ? adoptedRow.owner?.actor.id : session.createdActor?.id; return effectiveOwnerId === ownerId; }); if (sessions.length > 0) { diff --git a/ui/src/components/app-sidebar-session-filter-summary.ts b/ui/src/components/app-sidebar-session-filter-summary.ts index c7ac7c27ef4b..5625f420cb5b 100644 --- a/ui/src/components/app-sidebar-session-filter-summary.ts +++ b/ui/src/components/app-sidebar-session-filter-summary.ts @@ -28,11 +28,13 @@ function renderSessionFilterSummary(host: SessionListHost) { const parts = [ ...(ownerId ? [owner?.label ?? ownerId] : []), ...(host.sessionInvolvingMeFilterActive ? [t("sessionsView.involvingMe")] : []), - ...(host.sessionsStatusFilter === "archived" - ? [t("sessionsView.archived")] - : host.sessionsStatusFilter === "all" - ? [t("sessionsView.all")] - : []), + ...(host.sessionsStatusFilter === "snoozed" + ? [t("sessionsView.snoozed")] + : host.sessionsStatusFilter === "archived" + ? [t("sessionsView.archived")] + : host.sessionsStatusFilter === "all" + ? [t("sessionsView.all")] + : []), ]; const summaryText = parts.join(" · "); const showAll = t("chat.sidebar.showAllSessions"); diff --git a/ui/src/components/app-sidebar-session-filter.browser.test.ts b/ui/src/components/app-sidebar-session-filter.browser.test.ts index 044f9b1b68e1..ab0bf1678949 100644 --- a/ui/src/components/app-sidebar-session-filter.browser.test.ts +++ b/ui/src/components/app-sidebar-session-filter.browser.test.ts @@ -150,6 +150,9 @@ describe.runIf("__vitest_browser__" in globalThis)("sidebar session filter popov await userEvent.tab(); await expect.element(active).toHaveFocus(); await userEvent.keyboard("{ArrowRight}"); + await expect.element(page.getByRole("radio", { name: "Snoozed", exact: true })).toHaveFocus(); + expect(loadStoredSidebarSessionStatusFilter()).toBe("snoozed"); + await userEvent.keyboard("{ArrowRight}"); await expect .element(page.getByRole("radio", { name: "Archived", exact: true })) .toHaveFocus(); diff --git a/ui/src/components/app-sidebar-session-menu-renderers.ts b/ui/src/components/app-sidebar-session-menu-renderers.ts index 2ae1158651db..fa75c655ac62 100644 --- a/ui/src/components/app-sidebar-session-menu-renderers.ts +++ b/ui/src/components/app-sidebar-session-menu-renderers.ts @@ -477,9 +477,11 @@ export function renderSidebarSessionSortMenu(params: { label: value === "active" ? t("common.active") - : value === "archived" - ? t("sessionsView.archived") - : t("sessionsView.all"), + : value === "snoozed" + ? t("sessionsView.snoozed") + : value === "archived" + ? t("sessionsView.archived") + : t("sessionsView.all"), })), params.onStatusFilterChange, )} diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts index 75ad24294e50..3b55e75b2433 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.ts @@ -209,6 +209,7 @@ export function buildSidebarSessionNavigationState(input: { kind: row.kind, pinned: row.pinned === true, pinnable: isPinnableUiSessionRow(row), + snoozedUntil: row.snoozedUntil, archived: row.archived === true, visibility: row.visibility, sharingRole: row.sharingRole, diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index a30044b6aba4..9329b0e0102a 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -27,7 +27,6 @@ import { import { projectSidebarAgentSessionRows } from "./app-sidebar-agent-session-rows.ts"; import { AppSidebarBase } from "./app-sidebar-base.ts"; import { scheduleSidebarChildSessions } from "./app-sidebar-child-session-data.ts"; -import { excludeSessionCatalogRows } from "./app-sidebar-session-catalog-state.ts"; import { adoptedCatalogSessionKeys, type SidebarSessionCatalog, @@ -54,6 +53,11 @@ import { SidebarSessionProjection, type SidebarVisibleSections, } from "./app-sidebar-session-projection.ts"; +import { + visibleSidebarSessionCatalogs, + sidebarCatalogLiveRows, + sidebarSessionSnoozeWakeRows, +} from "./app-sidebar-session-snooze-visibility.ts"; import { loadStoredHiddenSessionCatalogIds, loadStoredSidebarSessionSortMode, @@ -198,28 +202,14 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { // Adopted-key exclusion and rendering share this projection so hidden catalogs // never remove their adopted rows from the regular session list. - visibleSessionCatalogs = () => - this.sessionsStatusFilter === "archived" - ? [] - : excludeSessionCatalogRows( - this.sessionData.sessionCatalogs, - this.sessionData.pendingCatalogArchives, - ).filter((catalog) => !this.hiddenSessionCatalogIds.has(catalog.id)); + visibleSessionCatalogs = () => visibleSidebarSessionCatalogs(this); - protected catalogLiveRows = () => [ - ...(this.sessionData.sessionsResult?.sessions ?? []), - ...Object.values(this.sessionData.sessionResultsByAgent).flatMap((result) => result.sessions), - ]; + protected catalogLiveRows = () => sidebarCatalogLiveRows(this.sessionData); protected sidebarSessionCatalogs = () => { // Catalogs consume the rows stage's resolved owner. this.selectedAgentSessionRows(this.getSessionNavigationState()); - return memoizedSidebarCatalogs( - this.catalogsMemo, - this, - this.activeSessionOwnerId, - this.catalogLiveRows, - ); + return memoizedSidebarCatalogs(this.catalogsMemo, this, this.activeSessionOwnerId); }; sessionCatalogIdsWithoutVisibleRows = (): string[] => { @@ -674,6 +664,13 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { return this.applySessionOwnerFilter(projected, this.selectedAgentSessionResult()?.owners); }, ); + this.attention.scheduleSessionSnoozeWake( + sidebarSessionSnoozeWakeRows( + this.sessionData, + this.selectedAgentSessionResult(), + navigationState, + ), + ); // A pending facet refresh can settle without replacing rows; retain its lifecycle observation. this.sessionOwnerFilter.observeOwnerFacet( this.selectedAgentSessionResult()?.owners !== undefined, diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts index ac8b2cf7e8e6..3f1ba08fbbe3 100644 --- a/ui/src/components/app-sidebar-session-row-render.ts +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -21,6 +21,7 @@ import type { import { writeSessionDragData } from "../lib/sessions/drag.ts"; import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts"; import { canArchiveSessionRow, resolveUiConfiguredMainKey } from "../lib/sessions/session-key.ts"; +import { formatSessionSnoozeWakeTime, isSessionSnoozed } from "../lib/sessions/session-snooze.ts"; import type { NewSessionTarget } from "../pages/new-session/location.ts"; import type { CatalogBackingSessionDisplay, @@ -233,7 +234,12 @@ function renderSidebarSessionIndicators( icon, ); const stateDescription = describeSessionState(session); - const hasTrail = session.isChild && (session.runtimeMs != null || session.startedAt != null); + const snoozed = + !session.isChild && + (host.sessionsStatusFilter === "snoozed" || host.sessionsStatusFilter === "all") && + isSessionSnoozed(session, Date.now()); + const hasTrail = + snoozed || (session.isChild && (session.runtimeMs != null || session.startedAt != null)); const metaId = hasTrail ? sidebarSessionMetaId(session.key) : undefined; const stateId = !team && stateDescription ? sidebarSessionStateId(session.key) : undefined; const persistentIndicator = html`${ - session.runtimeMs != null - ? session.hasActiveRun - ? html`` + : (formatDurationCompact(session.runtimeMs) ?? "0ms") + : html`` - : (formatDurationCompact(session.runtimeMs) ?? "0ms") - : html`` }` : nothing; diff --git a/ui/src/components/app-sidebar-session-snooze-visibility.ts b/ui/src/components/app-sidebar-session-snooze-visibility.ts new file mode 100644 index 000000000000..dbc6bd324b83 --- /dev/null +++ b/ui/src/components/app-sidebar-session-snooze-visibility.ts @@ -0,0 +1,66 @@ +import type { SessionsListResult } from "../api/types.ts"; +import type { ApplicationContext } from "../app/context.ts"; +import { projectSidebarArchiveVisibility } from "./app-sidebar-session-archive-visibility.ts"; +import { excludeSessionCatalogRows } from "./app-sidebar-session-catalog-state.ts"; +import { projectSidebarSessionCatalogs } from "./app-sidebar-session-catalogs.ts"; +import type { SidebarSessionNavigationState } from "./app-sidebar-session-navigation-logic.ts"; +import type { SidebarSessionStatusFilter } from "./app-sidebar-session-types.ts"; +import type { SessionDataController } from "./session-data-controller.ts"; + +type SidebarSnoozeVisibilityHost = { + readonly sessionData: SessionDataController; + readonly sessionDataContext: Pick | undefined; + readonly sessionsStatusFilter: SidebarSessionStatusFilter; + readonly hiddenSessionCatalogIds: ReadonlySet; + expandedAgentId(): string; +}; + +// Shares root and adopted-catalog snooze visibility and deadline inputs with their existing owners. +export function visibleSidebarSessionCatalogs(host: SidebarSnoozeVisibilityHost) { + return host.sessionsStatusFilter === "archived" || host.sessionsStatusFilter === "snoozed" + ? [] + : excludeSessionCatalogRows( + host.sessionData.sessionCatalogs, + host.sessionData.pendingCatalogArchives, + ).filter((catalog) => !host.hiddenSessionCatalogIds.has(catalog.id)); +} + +export function sidebarCatalogLiveRows(sessionData: SessionDataController) { + return [ + ...(sessionData.sessionsResult?.sessions ?? []), + ...Object.values(sessionData.sessionResultsByAgent).flatMap((result) => result.sessions), + ]; +} + +export function projectSidebarSnoozeCatalogs( + host: SidebarSnoozeVisibilityHost, + ownerId: string | null, +) { + const visibility = projectSidebarArchiveVisibility({ + sessionData: host.sessionData, + selectedAgentId: host.expandedAgentId(), + statusFilter: host.sessionsStatusFilter, + now: Date.now(), + deletionState: (key, agentId) => host.sessionDataContext?.sessions.deletionState(key, agentId), + archiveVisibility: (key) => host.sessionDataContext?.sessions.archiveVisibility(key), + }); + return projectSidebarSessionCatalogs( + visibleSidebarSessionCatalogs(host), + ownerId, + sidebarCatalogLiveRows(host.sessionData), + visibility.isSessionHidden, + ); +} + +export function sidebarSessionSnoozeWakeRows( + sessionData: SessionDataController, + selectedResult: SessionsListResult | null, + navigationState: SidebarSessionNavigationState, +) { + return [ + ...(selectedResult?.sessions ?? []), + ...sidebarCatalogLiveRows(sessionData), + ...navigationState.visibleSessionRows, + ...(sessionData.activeSessionLineageRoot ? [sessionData.activeSessionLineageRoot] : []), + ]; +} diff --git a/ui/src/components/app-sidebar-session-types.test.ts b/ui/src/components/app-sidebar-session-types.test.ts index da5e1bae0014..0d3d57d48674 100644 --- a/ui/src/components/app-sidebar-session-types.test.ts +++ b/ui/src/components/app-sidebar-session-types.test.ts @@ -43,7 +43,9 @@ describe("sidebar session status preference", () => { expect(loadStoredSidebarSessionStatusFilter()).toBe("active"); }); - it("stores archived and all filters", () => { + it("stores snoozed, archived, and all filters", () => { + storeSidebarSessionStatusFilter("snoozed"); + expect(loadStoredSidebarSessionStatusFilter()).toBe("snoozed"); storeSidebarSessionStatusFilter("archived"); expect(loadStoredSidebarSessionStatusFilter()).toBe("archived"); storeSidebarSessionStatusFilter("all"); diff --git a/ui/src/components/app-sidebar-session-types.ts b/ui/src/components/app-sidebar-session-types.ts index a3613a13ab48..a75aab8d92c4 100644 --- a/ui/src/components/app-sidebar-session-types.ts +++ b/ui/src/components/app-sidebar-session-types.ts @@ -123,6 +123,7 @@ export type SidebarRecentSession = { kind?: string; pinned: boolean; pinnable: boolean; + snoozedUntil?: number; archived?: boolean; visibility?: SessionVisibility; sharingRole?: GatewaySessionRow["sharingRole"]; @@ -261,7 +262,7 @@ export type SidebarSessionGroupMenuState = { }; export type SidebarSessionSortMode = "created" | "updated" | "people"; -export type SidebarSessionStatusFilter = "active" | "archived" | "all"; +export type SidebarSessionStatusFilter = "active" | "snoozed" | "archived" | "all"; export type SidebarEmptyGroupsMode = "filtering" | "always" | "never"; export type SidebarSessionOwnerFilter = { ownerId: string | null; @@ -311,7 +312,7 @@ export type SidebarCatalogSessionMutationScope = SidebarSessionMutationScope & { export type SidebarSessionPatch = Pick< SessionsPatchMutation, - "archived" | "pinned" | "unread" | "label" | "icon" | "color" | "category" + "archived" | "pinned" | "snoozedUntil" | "unread" | "label" | "icon" | "color" | "category" >; export const SIDEBAR_SESSION_PAGE_SIZE = 10; @@ -366,7 +367,7 @@ export function loadStoredSidebarSessionsShowSystem(): boolean { export function loadStoredSidebarSessionStatusFilter(): SidebarSessionStatusFilter { const stored = getSafeLocalStorage()?.getItem(SIDEBAR_SESSION_STATUS_FILTER_STORAGE_KEY); - return stored === "archived" || stored === "all" ? stored : "active"; + return stored === "snoozed" || stored === "archived" || stored === "all" ? stored : "active"; } function sidebarSessionOwnerFilterStorageKey(gatewayUrl: string, selfUserId: string): string { @@ -543,6 +544,7 @@ export const SIDEBAR_SESSION_SORT_OPTIONS = [ export const SIDEBAR_SESSION_STATUS_OPTIONS = [ "active", + "snoozed", "archived", "all", ] as const satisfies readonly SidebarSessionStatusFilter[]; diff --git a/ui/src/components/app-sidebar.projection-memo.test.ts b/ui/src/components/app-sidebar.projection-memo.test.ts index 6672a8c71b49..444ab81457ac 100644 --- a/ui/src/components/app-sidebar.projection-memo.test.ts +++ b/ui/src/components/app-sidebar.projection-memo.test.ts @@ -217,6 +217,89 @@ describe("sidebar projection memo", () => { expect(row(key("first"))).toBeNull(); }); + it.each(["active", "snoozed"] as const)( + "refreshes %s row visibility when a cached snooze expires without another input", + async (statusFilter) => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const target = session("snoozed", { snoozedUntil: 11_000, snoozedAt: 9_000 }); + const { sidebar, row } = await mount([target]); + sidebar.sessionsStatusFilter = statusFilter; + await settleLitElement(sidebar); + expect(row(target.key) !== null).toBe(statusFilter === "snoozed"); + + sidebar.teamOnlineExpanded = true; + await settleLitElement(sidebar); + await vi.advanceTimersByTimeAsync(999); + await settleLitElement(sidebar); + expect(row(target.key) !== null).toBe(statusFilter === "snoozed"); + + await vi.advanceTimersByTimeAsync(2); + await settleLitElement(sidebar); + expect(row(target.key) !== null).toBe(statusFilter === "active"); + }, + ); + + it("hides and wakes an adopted catalog row using its cached agent result", async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const target = session("adopted", { key: "agent:work:adopted", agentId: "work" }); + const { sidebar, sessions, row } = await mount([]); + const cachedResult = { + ...sessions.sessions.state.result!, + sessions: [target], + count: 1, + }; + sidebar.sessionData.sessionResultsByAgent = { work: cachedResult }; + sidebar.sessionData.sessionCatalogs = [ + { + id: "fixture", + label: "Fixture", + capabilities: { continueSession: true, archive: true }, + hosts: [ + { + hostId: "gateway:fixture", + label: "Fixture host", + kind: "gateway", + connected: true, + sessions: [ + { + threadId: "adopted-thread", + sessionKey: target.key, + name: "Adopted catalog thread", + status: "idle", + archived: false, + canContinue: true, + canArchive: true, + }, + ], + }, + ], + }, + ]; + sidebar.requestUpdate(); + await settleLitElement(sidebar); + expect(row(target.key)?.hasAttribute("data-catalog-session-key")).toBe(true); + + sidebar.sessionData.sessionResultsByAgent = { + work: { + ...cachedResult, + sessions: [{ ...target, snoozedUntil: 11_000, snoozedAt: 10_000 }], + }, + }; + sidebar.requestUpdate(); + await settleLitElement(sidebar); + expect(row(target.key)).toBeNull(); + await vi.advanceTimersByTimeAsync(999); + await settleLitElement(sidebar); + expect(row(target.key)).toBeNull(); + + await vi.advanceTimersByTimeAsync(2); + await settleLitElement(sidebar); + expect(row(target.key)?.hasAttribute("data-catalog-session-key")).toBe(true); + expect(row(target.key)?.textContent).toContain("adopted"); + }); + it("updates roster selection and menu state without reprojecting rows or sections", async () => { const mounted = await mountRoster(); const sidebar = mounted.sidebar as unknown as Sidebar; diff --git a/ui/src/components/session-attention-controller.test.ts b/ui/src/components/session-attention-controller.test.ts new file mode 100644 index 000000000000..58a0bb6765d7 --- /dev/null +++ b/ui/src/components/session-attention-controller.test.ts @@ -0,0 +1,70 @@ +import type { ReactiveController } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isSessionSnoozed } from "../lib/sessions/session-snooze.ts"; +import { SessionAttentionController } from "./session-attention-controller.ts"; + +afterEach(() => vi.useRealTimers()); + +function setup(snoozedUntil: number) { + vi.useFakeTimers(); + vi.setSystemTime(100); + const row = { snoozedUntil }; + let visible = !isSessionSnoozed(row, Date.now()); + const host = { + isConnected: true, + sessionAttentionContext: undefined, + addController: (_controller: ReactiveController) => {}, + removeController: (_controller: ReactiveController) => {}, + updateComplete: Promise.resolve(true), + requestUpdate: vi.fn(() => { + visible = !isSessionSnoozed(row, Date.now()); + controller.scheduleSessionSnoozeWake([row]); + }), + }; + const controller = new SessionAttentionController(host); + controller.scheduleSessionSnoozeWake([row]); + return { host, controller, row, visible: () => visible }; +} + +describe("session snooze deadline invalidation", () => { + it("resurfaces a row at the deadline without polling", () => { + const h = setup(200); + vi.advanceTimersByTime(99); + expect(h.visible()).toBe(false); + expect(h.host.requestUpdate).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(isSessionSnoozed(h.row, Date.now())).toBe(false); + vi.advanceTimersByTime(1); + expect(h.visible()).toBe(true); + expect(h.host.requestUpdate).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + h.controller.hostDisconnected(); + }); + + it("replaces the old deadline on a new projection and retires it on disconnect", () => { + const h = setup(200); + h.row.snoozedUntil = 300; + h.controller.scheduleSessionSnoozeWake([h.row]); + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(101); + expect(h.host.requestUpdate).not.toHaveBeenCalled(); + h.host.isConnected = false; + h.controller.hostDisconnected(); + h.controller.scheduleSessionSnoozeWake([h.row]); + vi.advanceTimersByTime(200); + expect(h.host.requestUpdate).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("clamps distant deadlines and rearms them until due", () => { + const h = setup(2_147_483_847); + vi.advanceTimersByTime(2_147_483_647); + expect(h.visible()).toBe(false); + expect(h.host.requestUpdate).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(101); + expect(h.visible()).toBe(true); + expect(vi.getTimerCount()).toBe(0); + h.controller.hostDisconnected(); + }); +}); diff --git a/ui/src/components/session-attention-controller.ts b/ui/src/components/session-attention-controller.ts index f0c88af3af64..8ec4870ce946 100644 --- a/ui/src/components/session-attention-controller.ts +++ b/ui/src/components/session-attention-controller.ts @@ -17,6 +17,7 @@ import { t } from "../i18n/index.ts"; import { formatUiExternalText } from "../lib/format-error.ts"; import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; import { uiConversationMatches } from "../lib/sessions/session-key.ts"; +import { nextSessionSnoozeWakeAt } from "../lib/sessions/session-snooze.ts"; import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; import { SIDEBAR_SESSION_NO_ATTENTION, @@ -45,6 +46,7 @@ export class SessionAttentionController implements ReactiveController { private attentionGatewayConnected = false; private agentStatusExpiryTimer: ReturnType | null = null; private agentStatusExpiryAt: number | null = null; + private snoozeWakeTimer: ReturnType | null = null; constructor(private readonly host: SessionAttentionControllerHost) { host.addController(this); @@ -80,6 +82,10 @@ export class SessionAttentionController implements ReactiveController { this.agentStatusExpiryTimer = null; this.agentStatusExpiryAt = null; } + if (this.snoozeWakeTimer !== null) { + globalThis.clearTimeout(this.snoozeWakeTimer); + this.snoozeWakeTimer = null; + } disposeQuestionPromptState(this.questionPromptState); } @@ -151,6 +157,24 @@ export class SessionAttentionController implements ReactiveController { ); } + scheduleSessionSnoozeWake(rows: Iterable>): void { + if (this.snoozeWakeTimer !== null) { + globalThis.clearTimeout(this.snoozeWakeTimer); + this.snoozeWakeTimer = null; + } + const wakeAt = nextSessionSnoozeWakeAt(rows, Date.now()); + if (!this.host.isConnected || wakeAt === null) { + return; + } + this.snoozeWakeTimer = globalThis.setTimeout( + () => { + this.snoozeWakeTimer = null; + this.invalidate(); + }, + Math.min(2_147_483_647, Math.max(0, wakeAt - Date.now() + 1)), + ); + } + createResolver(): SessionAttentionResolver { const context = this.host.sessionAttentionContext; const identity = { diff --git a/ui/src/components/session-menu-access.ts b/ui/src/components/session-menu-access.ts index 82343d8b70d1..f26f43d76756 100644 --- a/ui/src/components/session-menu-access.ts +++ b/ui/src/components/session-menu-access.ts @@ -60,6 +60,9 @@ export function sessionMenuReasons(params: { const archiveReason = lifecycleRows.some((row) => !row.sessionId?.trim()) ? "Session lifecycle action requires a durable session identity." : batchPatchReason({ archived: true }, true); + const snoozeReason = !session.sessionId?.trim() + ? "Session lifecycle action requires a durable session identity." + : patchReason({ snoozedUntil: null }, true); const groupReason = reason({ method: "sessions.groups.put", requiredScope: "operator.write", @@ -85,6 +88,7 @@ export function sessionMenuReasons(params: { const cloudWorkerStopReason = cloudWorkerStopAction ? reason(cloudWorkerStopAction) : undefined; return { ...(pinReason ? { "toggle-pin": pinReason } : {}), + ...(snoozeReason ? { snooze: snoozeReason, wake: snoozeReason } : {}), ...(renameReason ? { rename: renameReason } : {}), ...(iconReason ? { "set-icon": iconReason } : {}), ...(colorReason ? { "set-color": colorReason } : {}), diff --git a/ui/src/components/session-menu-actions.ts b/ui/src/components/session-menu-actions.ts index c255ab35016a..b323b2786660 100644 --- a/ui/src/components/session-menu-actions.ts +++ b/ui/src/components/session-menu-actions.ts @@ -18,6 +18,7 @@ import { renderSessionGroupOptions, sessionArchiveShortcut, } from "./session-menu-options.ts"; +import { SessionMenuSnooze, type SessionSnoozeMenuAction } from "./session-menu-snooze.ts"; import type { SessionCreatedActor, SessionOwnerOption } from "./session-owner-chip.ts"; import { SessionOwnerMenu } from "./session-owner-menu.ts"; import "../styles/sidebar-menus.css"; @@ -30,6 +31,7 @@ export type SessionMenuData = { pinned: boolean; unread: boolean; archived: boolean; + snoozedUntil: number | null; hiddenFromInvolvingMe?: boolean; archiving?: boolean; category: string | null; @@ -66,7 +68,8 @@ export type SessionManagementAction = | { kind: "set-icon"; icon: string | null } | { kind: "set-color"; color: string | null } | { kind: "assign-owner"; owner: Pick } - | { kind: "move-to-group"; category: string | null }; + | { kind: "move-to-group"; category: string | null } + | SessionSnoozeMenuAction; export type SessionManagementActionKind = SessionManagementAction["kind"]; @@ -76,6 +79,7 @@ export const EMPTY_SESSION_MENU_DATA: SessionMenuData = { pinned: false, unread: false, archived: false, + snoozedUntil: null, category: null, icon: null, color: null, @@ -94,6 +98,7 @@ type SessionMenuActionsState = { forkDisabled: boolean; forkFromLastCompleted: boolean; archiveAllowed: boolean; + snoozeAllowed?: boolean; archiveShortcut?: boolean; deleteAllowed: boolean; groups: readonly string[]; @@ -111,6 +116,15 @@ export class SessionMenuActions { private readonly ownerMenu: SessionOwnerMenu; private iconPickerMode: "grid" | "custom" = "grid"; private customIconValue = ""; + private readonly snoozeMenu = new SessionMenuSnooze({ + readWakeTime: () => this.readState().session.snoozedUntil, + eligible: () => !this.actionExtraDisabled("snooze"), + disabled: (kind) => this.actionDisabled(kind, this.actionExtraDisabled(kind)), + disabledReason: (kind) => this.readState().actionDisabledReasons[kind], + renderItem: (...args) => this.renderItem(...args), + renderSubmenu: (...args) => this.renderSubmenu(...args), + runAction: (action) => this.runAction(action), + }); constructor( private readonly host: SessionMenuActionsHost, @@ -164,6 +178,11 @@ export class SessionMenuActions { return batch || !session.sessionId; case "toggle-pin": return batch || session.pinnable === false || session.isChild === true || session.archived; + case "snooze": + case "wake": + return ( + !state.snoozeAllowed || this.actionExtraDisabled("toggle-pin") || !state.archiveAllowed + ); case "toggle-involving-me": return ( !this.involvementAvailable || @@ -217,6 +236,9 @@ export class SessionMenuActions { this.runAction({ kind }); return true; } + if (this.snoozeMenu.handleSelect(value)) { + return true; + } if (value.startsWith("open-in:")) { const state = this.readState(); const editor = EDITOR_IDS.find((candidate) => candidate === value.slice("open-in:".length)); @@ -406,6 +428,7 @@ export class SessionMenuActions { ) : nothing } + ${this.snoozeMenu.renderAction()} ${this.renderItem( "toggle-archived", t( @@ -511,6 +534,8 @@ export class SessionMenuActions { private renderSubmenuBody(view: Exclude, inline = false) { const state = this.readState(); switch (view) { + case "snooze": + return this.snoozeMenu.renderSubmenu(inline); case "copy": return this.renderCopySubmenu(inline); case "open-in": diff --git a/ui/src/components/session-menu-compact.ts b/ui/src/components/session-menu-compact.ts index c970d96a1e48..4181047f318b 100644 --- a/ui/src/components/session-menu-compact.ts +++ b/ui/src/components/session-menu-compact.ts @@ -9,11 +9,13 @@ export type CompactSessionMenuView = | "open-in" | "assign-owner" | "icon" - | "group"; + | "group" + | "snooze"; const COMPACT_SESSION_MENU_VIEW_BY_VALUE: Record = { "compact:back": "root", "compact:open-copy": "copy", + "compact:open-snooze": "snooze", "compact:open-assign-owner": "assign-owner", "compact:open-group": "group", "compact:open-icon": "icon", diff --git a/ui/src/components/session-menu-snooze.ts b/ui/src/components/session-menu-snooze.ts new file mode 100644 index 000000000000..d6a23c3fa818 --- /dev/null +++ b/ui/src/components/session-menu-snooze.ts @@ -0,0 +1,95 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { t } from "../i18n/index.ts"; +import { formatTimeMs } from "../lib/format.ts"; +import { + formatSessionSnoozeWakeTime, + isSessionSnoozed, + resolveSessionSnoozePresets, +} from "../lib/sessions/session-snooze.ts"; +import { icons } from "./icons.ts"; + +export type SessionSnoozeMenuAction = { kind: "snooze"; snoozedUntil: number } | { kind: "wake" }; +type SnoozeActionKind = SessionSnoozeMenuAction["kind"]; +type SessionSnoozeMenuHost = { + readWakeTime: () => number | null; + eligible: () => boolean; + disabled: (kind: SnoozeActionKind) => boolean; + disabledReason: (kind: SnoozeActionKind) => string | undefined; + renderItem: (kind: "wake", label: string, icon: TemplateResult) => TemplateResult; + renderSubmenu: ( + view: "snooze", + label: string, + icon: TemplateResult, + disabled: boolean, + title?: string, + ) => TemplateResult; + runAction: (action: SessionSnoozeMenuAction) => void; +}; + +/** Owns snooze and wake menu presentation, preset labels, and selection dispatch. */ +export class SessionMenuSnooze { + constructor(private readonly host: SessionSnoozeMenuHost) {} + + handleSelect(value: string): boolean { + if (value === "wake") { + this.host.runAction({ kind: "wake" }); + return true; + } + if (!value.startsWith("snooze:")) { + return false; + } + const snoozedUntil = Number(value.slice("snooze:".length)); + if (Number.isFinite(snoozedUntil) && snoozedUntil > Date.now()) { + this.host.runAction({ kind: "snooze", snoozedUntil }); + } + return true; + } + + renderAction() { + if (!this.host.eligible()) { + return nothing; + } + const snoozedUntil = this.host.readWakeTime(); + return isSessionSnoozed({ snoozedUntil: snoozedUntil ?? undefined }, Date.now()) + ? this.host.renderItem( + "wake", + `${t("sessionsView.wakeSession")} · ${formatSessionSnoozeWakeTime(snoozedUntil!)}`, + icons.clock, + ) + : this.host.renderSubmenu( + "snooze", + t("sessionsView.snooze"), + icons.clock, + this.host.disabled("snooze"), + this.host.disabledReason("snooze"), + ); + } + + renderSubmenu(inline = false) { + const now = new Date(); + const labels = { + hour: "sessionsView.snoozeHour", + "three-hours": "sessionsView.snoozeThreeHours", + evening: "sessionsView.snoozeEvening", + tomorrow: "sessionsView.snoozeTomorrow", + "next-week": "sessionsView.snoozeNextWeek", + } as const; + return html`${resolveSessionSnoozePresets(now).map(({ id, snoozedUntil }) => { + // The preset label already names the day; the time column only adds the clock time. + const when = + id === "next-week" + ? formatSessionSnoozeWakeTime(snoozedUntil, now) + : formatTimeMs(snoozedUntil); + return html` + ${t(labels[id])} · ${when} + `; + })}`; + } +} diff --git a/ui/src/components/session-menu.test.ts b/ui/src/components/session-menu.test.ts index 6b6b40833c9f..95be34c3946e 100644 --- a/ui/src/components/session-menu.test.ts +++ b/ui/src/components/session-menu.test.ts @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createDeferred as deferred } from "../../../test/helpers/promise.js"; import type { ApplicationContextProvider } from "../test-helpers/application-context.ts"; import { @@ -1042,3 +1042,69 @@ describe("session menu", () => { expect(menu.querySelector("wa-dropdown")).not.toBe(staleDropdown); }); }); + +describe("sidebar snooze menu", () => { + afterEach(() => vi.useRealTimers()); + + it.each([false, true])( + "dispatches the selected wake time from the preset submenu (compact: %s)", + async (compact) => { + vi.useFakeTimers({ toFake: ["Date"] }); + const now = new Date(2026, 8, 29, 9); + vi.setSystemTime(now); + const onAction = vi.fn<(action: SessionMenuAction) => void>(); + const menu = await mountMenu({ snoozeAllowed: true, compact, onAction }); + if (compact) { + selectMenuValue(menu, "compact:open-snooze"); + await menu.updateComplete; + } + const labels = menuItemLabels(compact ? menu : menuItem(menu, "Snooze")); + expect(labels.filter((label) => label.includes(" · "))).toEqual([ + "In 1 hour · 10:00 AM", + "In 3 hours · 12:00 PM", + "This evening · 6:00 PM", + "Tomorrow · 9:00 AM", + "Next week · Mon 9:00 AM", + ]); + const value = menuItem(menu, "This evening · 6:00 PM").getAttribute("value")!; + selectMenuValue(menu, value); + expect(onAction).toHaveBeenCalledWith({ + kind: "snooze", + snoozedUntil: new Date(2026, 8, 29, 18).getTime(), + }); + }, + ); + + it("offers Wake session with the scheduled time and treats expired snoozes as awake", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const now = new Date(2026, 8, 29, 9).getTime(); + vi.setSystemTime(now); + const onAction = vi.fn<(action: SessionMenuAction) => void>(); + const menu = await mountMenu({ + snoozeAllowed: true, + session: { snoozedUntil: now + 3_600_000 }, + onAction, + }); + expect(menuItemLabels(menu)).toContain("Wake session · 10:00 AM"); + expect(menuItemLabels(menu)).not.toContain("Snooze"); + selectMenuValue(menu, "wake"); + expect(onAction).toHaveBeenCalledWith({ kind: "wake" }); + menu.session = { ...menu.session, snoozedUntil: now }; + await menu.updateComplete; + expect(menuItemLabels(menu)).toContain("Snooze"); + expect(menuItemLabels(menu).some((label) => label.startsWith("Wake session"))).toBe(false); + }); + + it.each([{ archived: true }, { isChild: true }, { pinnable: false }])( + "omits snooze actions for ineligible rows %j", + async (session) => { + const onAction = vi.fn<(action: SessionMenuAction) => void>(); + const menu = await mountMenu({ snoozeAllowed: true, session, onAction }); + expect(menuItemLabels(menu)).not.toContain("Snooze"); + expect(menuItemLabels(menu).some((label) => label.startsWith("Wake session"))).toBe(false); + selectMenuValue(menu, `snooze:${Date.now() + 3_600_000}`); + selectMenuValue(menu, "wake"); + expect(onAction).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/ui/src/components/session-menu.ts b/ui/src/components/session-menu.ts index 7f659c81c8a2..88f2808553fa 100644 --- a/ui/src/components/session-menu.ts +++ b/ui/src/components/session-menu.ts @@ -61,6 +61,7 @@ class SessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) forkDisabled = false; @property({ attribute: false }) forkFromLastCompleted = false; @property({ attribute: false }) archiveAllowed = false; + @property({ attribute: false }) snoozeAllowed = false; @property({ attribute: false }) deleteAllowed = false; @property({ attribute: false }) cloudWorkerStopAllowed = false; @property({ attribute: false }) groups: readonly string[] = []; diff --git a/ui/src/components/session-organizer-batch-mutations.test.ts b/ui/src/components/session-organizer-batch-mutations.test.ts index 259b21be798a..8f780c652646 100644 --- a/ui/src/components/session-organizer-batch-mutations.test.ts +++ b/ui/src/components/session-organizer-batch-mutations.test.ts @@ -37,6 +37,7 @@ import { deleteSessionsBatch, patchSession, stopCloudWorker, + snoozeSessionWithUndo, } from "./session-organizer-operations.runtime.ts"; vi.mock("../lib/toast.ts", () => ({ showToast: vi.fn() })); @@ -122,6 +123,7 @@ function createHarness( })); const deleteOne = vi.fn(async () => ({ deleted: true })); const groupsDelete = vi.fn(async () => "completed" as const); + const connection = {}; const scope = { epoch: 1, context: { @@ -131,6 +133,8 @@ function createHarness( }, gateway: { snapshot }, sessions: { + captureConnectionScope: () => connection, + isConnectionScopeCurrent: () => current, patch, patchMany: ( targets: SessionsPatchManyParams["targets"], @@ -825,3 +829,50 @@ describe("session organizer destructive confirmations", () => { expect(harness.request).not.toHaveBeenCalled(); }); }); + +describe("session organizer snooze", () => { + beforeEach(() => vi.mocked(showToast).mockClear()); + afterEach(() => vi.useRealTimers()); + + it("snoozes the captured session and offers a scoped wake through Undo", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const now = new Date(2026, 8, 29, 9).getTime(); + vi.setSystemTime(now); + const h = createHarness(); + const row = sessionRow(0); + await snoozeSessionWithUndo(h.host, row, now + 3_600_000, h.scope); + expect(h.patch).toHaveBeenCalledWith( + row.key, + { snoozedUntil: now + 3_600_000 }, + { + agentId: "main", + expectedSessionId: row.sessionId, + }, + ); + const toast = vi.mocked(showToast).mock.calls.at(-1)?.[0]; + expect(toast).toMatchObject({ message: "Snoozed until 10:00 AM", actionLabel: "Undo" }); + expect(toast?.onAction).toBeTypeOf("function"); + toast?.onAction?.(); + expect(h.patch).toHaveBeenLastCalledWith( + row.key, + { snoozedUntil: null }, + { + agentId: "main", + expectedSessionId: row.sessionId, + }, + ); + expect(h.pruneSidebarSessionEntry).not.toHaveBeenCalled(); + h.retireScope(); + toast?.onAction?.(); + expect(h.patch).toHaveBeenCalledTimes(2); + }); + + it("reports a rejected snooze through the ordinary session mutation error path", async () => { + const h = createHarness(); + const error = new Error("snooze wake time must be in the future"); + h.patch.mockRejectedValueOnce(error); + await snoozeSessionWithUndo(h.host, sessionRow(0), 200, h.scope); + expect(h.publishSessionMutationError).toHaveBeenCalledWith(h.scope, error); + expect(showToast).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/session-organizer-controller.ts b/ui/src/components/session-organizer-controller.ts index 4538b8e3c854..067c80603c36 100644 --- a/ui/src/components/session-organizer-controller.ts +++ b/ui/src/components/session-organizer-controller.ts @@ -102,6 +102,12 @@ export class SessionOrganizerController { return operations.patchSession(this.host, session, patch, scope, options); }; + async snoozeSessionWithUndo(session: SidebarRecentSession, snoozedUntil: number): Promise { + await this.runOperation((operations, scope) => + operations.snoozeSessionWithUndo(this.host, session, snoozedUntil, scope), + ); + } + async archiveSessionWithUndo(session: SidebarRecentSession): Promise { await this.runOperation((operations, scope) => operations.archiveSessionWithUndo(this.host, session, scope), diff --git a/ui/src/components/session-organizer-operations.runtime.ts b/ui/src/components/session-organizer-operations.runtime.ts index aa31fc84fc26..46c81e0a2b47 100644 --- a/ui/src/components/session-organizer-operations.runtime.ts +++ b/ui/src/components/session-organizer-operations.runtime.ts @@ -6,6 +6,7 @@ import { formatUiError } from "../lib/format-error.ts"; import { readSessionMethodAccess } from "../lib/session-method-access.ts"; import { resolveSessionRenamePatch } from "../lib/session-rename.ts"; import { resolveUiSessionRowAgentId } from "../lib/sessions/session-key.ts"; +import { formatSessionSnoozeWakeTime } from "../lib/sessions/session-snooze.ts"; import { formatPreservedWorktreeConfirmation, formatPreservedWorktreesNotice, @@ -65,7 +66,10 @@ export async function patchSession( agentId, ...(session.sessionId ? { expectedSessionId: session.sessionId } : {}), }; - if (typeof patch.archived === "boolean" && !session.sessionId?.trim()) { + if ( + (typeof patch.archived === "boolean" || patch.snoozedUntil !== undefined) && + !session.sessionId?.trim() + ) { host.sessionData.publishSessionMutationError( scope, "Session lifecycle action requires a durable session identity.", @@ -131,6 +135,25 @@ export async function patchSession( } } +export async function snoozeSessionWithUndo( + host: SessionActionHost, + session: SessionActionRow, + snoozedUntil: number, + scope: SidebarSessionMutationScope, +) { + const result = await patchSession(host, session, { snoozedUntil }, scope, { sessionScope: true }); + if (result !== "completed" || !host.sessionData.isSessionMutationScopeCurrent(scope)) { + return; + } + const undoHost = sessionUndoHost(host, scope); + showToast({ + message: t("sessionsView.sessionSnoozed", { time: formatSessionSnoozeWakeTime(snoozedUntil) }), + actionLabel: t("common.undo"), + onAction: () => + void patchSession(undoHost, session, { snoozedUntil: null }, scope, { sessionScope: true }), + }); +} + export async function archiveSessionWithUndo( host: SessionActionHost, session: SessionActionRow, @@ -204,10 +227,18 @@ function archiveUndoAction( archived: readonly { session: SessionActionRow; pinned: boolean }[], scope: SidebarSessionMutationScope, ): () => void { + const undoHost = sessionUndoHost(host, scope); + return () => void restoreArchivedSessions(undoHost, archived, scope); +} + +function sessionUndoHost( + host: SessionActionHost, + scope: SidebarSessionMutationScope, +): SessionActionHost { // The toast outlives its originating pane. The session owner fences reconnects; // the captured row IDs still fence replacement conversations during restore. const connection = scope.sessions.captureConnectionScope(); - const undoHost: SessionActionHost = { + return { pruneSidebarSessionEntry: (key) => host.pruneSidebarSessionEntry(key), selectSession: (key) => host.selectSession(key), sidebarSessionStatusFilter: () => host.sidebarSessionStatusFilter(), @@ -224,7 +255,6 @@ function archiveUndoAction( }, }, }; - return () => void restoreArchivedSessions(undoHost, archived, scope); } // Undo restores captured rows; the roster owner refreshes whichever queries are now visible. diff --git a/ui/src/components/sidebar-menus-render.ts b/ui/src/components/sidebar-menus-render.ts index 9a60d5f9c7d8..12d1a564d0bf 100644 --- a/ui/src/components/sidebar-menus-render.ts +++ b/ui/src/components/sidebar-menus-render.ts @@ -271,6 +271,7 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo unread: batchRows ? allUnread : session.unread, hiddenFromInvolvingMe: session.hiddenFromInvolvingMe, archived: allArchived, + snoozedUntil: session.snoozedUntil ?? null, archiving: rows.some((row) => context?.sessions.archiveVisibility(row.key) === "pending"), category: batchRows ? sharedCategory : (session.category ?? null), icon: batchRows ? null : (session.icon ?? null), @@ -291,6 +292,7 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo .splitAllowed=${canSplitSessionView()} .forkDisabled=${host.sessionData.sessionsLoading || session.modelSelectionLocked} .forkFromLastCompleted=${session.gatewayHasActiveRun ?? session.hasActiveRun} + .snoozeAllowed=${true} .archiveAllowed=${archiveAllowed} .deleteAllowed=${deleteAllowed} .cloudWorkerStopAllowed=${cloudWorkerStopAllowed} @@ -399,6 +401,16 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo case "new-group": void host.sessionOrganizer.createSessionGroup([session]); break; + case "snooze": + void host.sessionOrganizer.snoozeSessionWithUndo(session, action.snoozedUntil); + break; + case "wake": + void host.sessionOrganizer.patchSession( + session, + { snoozedUntil: null }, + { sessionScope: true }, + ); + break; case "toggle-archived": if (session.archived) { void host.sessionOrganizer.patchSession( diff --git a/ui/src/components/sidebar-projection-memo.ts b/ui/src/components/sidebar-projection-memo.ts index 41c7eb0a2dfb..2113cc9cc802 100644 --- a/ui/src/components/sidebar-projection-memo.ts +++ b/ui/src/components/sidebar-projection-memo.ts @@ -6,10 +6,7 @@ import { i18n } from "../i18n/index.ts"; import type { createStoredChatOutboxReader } from "../lib/chat/outbox-store-projection.ts"; import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts"; import { projectSidebarHomeSession } from "./app-sidebar-agent-session-rows.ts"; -import { - projectSidebarSessionCatalogs, - type SidebarSessionCatalog, -} from "./app-sidebar-session-catalogs.ts"; +import type { SidebarSessionCatalog } from "./app-sidebar-session-catalogs.ts"; import { findActiveSidebarLineageRow } from "./app-sidebar-session-lookup.ts"; import { buildSidebarSessionNavigationState, @@ -19,6 +16,7 @@ import type { SidebarSessionProjection, SidebarVisibleSections, } from "./app-sidebar-session-projection.ts"; +import { projectSidebarSnoozeCatalogs } from "./app-sidebar-session-snooze-visibility.ts"; import type { SidebarEmptyGroupsMode, SidebarRecentSession, @@ -235,10 +233,13 @@ export function memoizedSidebarCatalogs( memo: SidebarProjectionMemo, host: SidebarProjectionHost, ownerId: string | null, - liveRows: () => GatewaySessionRow[], ) { return memo.read( () => [ + host.sessionDataContext, + host.sessionDataContext?.sessions.revision, + host.expandedAgentId(), + host.resolveSessionAttention, host.sessionData.sessionCatalogs, host.sessionData.pendingCatalogArchives, host.hiddenSessionCatalogIds, @@ -247,7 +248,7 @@ export function memoizedSidebarCatalogs( host.sessionData.sessionsResult, host.sessionData.sessionResultsByAgent, ], - () => projectSidebarSessionCatalogs(host.visibleSessionCatalogs(), ownerId, liveRows()), + () => projectSidebarSnoozeCatalogs(host, ownerId), ); } diff --git a/ui/src/e2e/session-owner-filter-keyboard.e2e.test.ts b/ui/src/e2e/session-owner-filter-keyboard.e2e.test.ts index 1e3875cca3f3..f9aff6fc28ba 100644 --- a/ui/src/e2e/session-owner-filter-keyboard.e2e.test.ts +++ b/ui/src/e2e/session-owner-filter-keyboard.e2e.test.ts @@ -78,7 +78,7 @@ suite.define(() => { await page.keyboard.press("Tab"); await expectBrowser(active).toBeFocused(); await page.keyboard.press("ArrowRight"); - await expectBrowser(menu.getByRole("radio", { name: "Archived", exact: true })).toBeChecked(); + await expectBrowser(menu.getByRole("radio", { name: "Snoozed", exact: true })).toBeChecked(); await page.keyboard.press("ArrowLeft"); await expectBrowser(active).toBeChecked(); // Row order, switches, and display submenus are covered by the sidebar @@ -189,7 +189,7 @@ suite.define(() => { const active = menu.getByRole("radio", { name: "Active", exact: true }); await active.focus(); await page.keyboard.press("ArrowRight"); - await expectBrowser(menu.getByRole("radio", { name: "Archived", exact: true })).toBeChecked(); + await expectBrowser(menu.getByRole("radio", { name: "Snoozed", exact: true })).toBeChecked(); await page.keyboard.press("Escape"); await expectBrowser(menu).toHaveCount(0); await expectBrowser( diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 6452ccf44272..0eb816da461f 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1013,6 +1013,17 @@ export const en: TranslationMap & { unknown: "Unknown", sessionState: "Session state", all: "All", + snooze: "Snooze", + wakeSession: "Wake session", + snoozeHour: "In 1 hour", + snoozeThreeHours: "In 3 hours", + snoozeEvening: "This evening", + snoozeTomorrow: "Tomorrow", + snoozeNextWeek: "Next week", + snoozed: "Snoozed", + snoozeWakes: "Wakes {time}", + sessionSnoozed: "Snoozed until {time}", + snoozeTomorrowTime: "tomorrow {time}", sessionArchived: "Session archived", archiving: "Archiving…", sessionsArchived: "Archived {count} sessions", diff --git a/ui/src/lib/sessions/navigation.ts b/ui/src/lib/sessions/navigation.ts index 6d548246e416..69b3df4d21ae 100644 --- a/ui/src/lib/sessions/navigation.ts +++ b/ui/src/lib/sessions/navigation.ts @@ -24,7 +24,7 @@ import { uiConversationMatches, uiSessionRowMatchesSelectedChat, } from "./session-key.ts"; -export type SessionArchivedFilter = "active" | "archived" | "all"; +export type SessionArchivedFilter = "active" | "snoozed" | "archived" | "all"; type SessionNavigationInput = { result: SessionsListResult | null; diff --git a/ui/src/lib/sessions/patch.ts b/ui/src/lib/sessions/patch.ts index b9c03712661e..1f9aa0a0d5d8 100644 --- a/ui/src/lib/sessions/patch.ts +++ b/ui/src/lib/sessions/patch.ts @@ -28,6 +28,7 @@ export type SessionPatch = Pick< | "toolOverrides" | "archived" | "pinned" + | "snoozedUntil" | "unread" >; diff --git a/ui/src/lib/sessions/session-snooze.test.ts b/ui/src/lib/sessions/session-snooze.test.ts new file mode 100644 index 000000000000..eaee7fcf538d --- /dev/null +++ b/ui/src/lib/sessions/session-snooze.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { i18n } from "../../i18n/index.ts"; +import { captureI18nStateForTesting } from "../../i18n/lib/translate.test-support.ts"; +import { + formatSessionSnoozeWakeTime, + isSessionSnoozed, + nextSessionSnoozeWakeAt, + resolveSessionSnoozePresets, +} from "./session-snooze.ts"; + +describe("session snooze", () => { + it.each([ + [undefined, false], + [Number.NaN, false], + [Number.POSITIVE_INFINITY, false], + [Number.NEGATIVE_INFINITY, false], + [0, false], + [-1, false], + [99, false], + [100, false], + [101, true], + ])("classifies wake time %s at the deadline boundary", (snoozedUntil, expected) => { + expect(isSessionSnoozed({ snoozedUntil }, 100)).toBe(expected); + }); + + it.each([ + { now: new Date(2026, 8, 29, 9), evening: true, nextMonday: new Date(2026, 9, 5, 9) }, + { now: new Date(2026, 8, 29, 17, 30), evening: false, nextMonday: new Date(2026, 9, 5, 9) }, + { now: new Date(2026, 8, 27, 9), evening: true, nextMonday: null }, + { now: new Date(2026, 2, 8, 0, 30), evening: true, nextMonday: null }, + { now: new Date(2026, 10, 1, 0, 30), evening: true, nextMonday: null }, + ])("resolves calendar presets for $now", ({ now, evening, nextMonday }) => { + const before = now.getTime(); + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 9); + const tonight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18); + expect(resolveSessionSnoozePresets(now)).toEqual([ + { id: "hour", snoozedUntil: before + 3_600_000 }, + { id: "three-hours", snoozedUntil: before + 10_800_000 }, + ...(evening ? [{ id: "evening", snoozedUntil: tonight.getTime() }] : []), + { id: "tomorrow", snoozedUntil: tomorrow.getTime() }, + ...(nextMonday ? [{ id: "next-week", snoozedUntil: nextMonday.getTime() }] : []), + ]); + expect(now.getTime()).toBe(before); + }); + + it("omits evening when exactly an hour away and advances Monday to the following week", () => { + const presets = resolveSessionSnoozePresets(new Date(2026, 8, 28, 17)); + expect(presets.map(({ id }) => id)).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); + expect(presets.at(-1)?.snoozedUntil).toBe(new Date(2026, 9, 5, 9).getTime()); + }); + + it("finds the earliest future deadline without retaining expired or malformed values", () => { + expect( + nextSessionSnoozeWakeAt( + [ + {}, + { snoozedUntil: Infinity }, + { snoozedUntil: 100 }, + { snoozedUntil: 300 }, + { snoozedUntil: 200 }, + ], + 100, + ), + ).toBe(200); + expect(nextSessionSnoozeWakeAt([{ snoozedUntil: 100 }], 100)).toBeNull(); + expect(nextSessionSnoozeWakeAt([], 100)).toBeNull(); + }); +}); + +describe("snooze wake-time presentation", () => { + let restore: () => Promise; + beforeEach(() => { + restore = captureI18nStateForTesting(); + }); + afterEach(() => restore()); + + it("distinguishes today, tomorrow, the coming week, and later dates", async () => { + await i18n.setLocale("en"); + const now = new Date(2026, 8, 29, 9); + expect(formatSessionSnoozeWakeTime(new Date(2026, 8, 29, 18).getTime(), now)).toBe("6:00 PM"); + expect(formatSessionSnoozeWakeTime(new Date(2026, 8, 30, 9).getTime(), now)).toBe( + "tomorrow 9:00 AM", + ); + expect(formatSessionSnoozeWakeTime(new Date(2026, 9, 5, 9).getTime(), now)).toMatch( + /Mon.*9:00 AM/u, + ); + expect(formatSessionSnoozeWakeTime(new Date(2026, 9, 10, 9).getTime(), now)).toMatch( + /Oct 10.*9:00 AM/u, + ); + expect( + formatSessionSnoozeWakeTime(new Date(2026, 9, 5, 9).getTime(), new Date(2026, 8, 28, 10)), + ).toMatch(/Mon.*9:00 AM/u); + }); + + it("uses the current UI locale for the wake time", async () => { + await i18n.setLocale("de"); + expect( + formatSessionSnoozeWakeTime(new Date(2026, 8, 29, 18).getTime(), new Date(2026, 8, 29, 9)), + ).toBe("18:00"); + }); +}); diff --git a/ui/src/lib/sessions/session-snooze.ts b/ui/src/lib/sessions/session-snooze.ts new file mode 100644 index 000000000000..282c5301e66f --- /dev/null +++ b/ui/src/lib/sessions/session-snooze.ts @@ -0,0 +1,73 @@ +import type { GatewaySessionRow } from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; +import { formatDateTimeMs, formatTimeMs } from "../format.ts"; + +type SnoozeRow = Pick; +type SnoozePreset = { + id: "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; + snoozedUntil: number; +}; + +export function isSessionSnoozed(row: SnoozeRow, nowMs: number): boolean { + return ( + typeof row.snoozedUntil === "number" && + Number.isFinite(row.snoozedUntil) && + row.snoozedUntil > nowMs + ); +} + +export function resolveSessionSnoozePresets(now: Date): ReadonlyArray { + const hour = 60 * 60 * 1000; + const presets: SnoozePreset[] = [ + { id: "hour", snoozedUntil: now.getTime() + hour }, + { id: "three-hours", snoozedUntil: now.getTime() + 3 * hour }, + ]; + const evening = new Date(now); + evening.setHours(18, 0, 0, 0); + if (evening.getTime() - now.getTime() > hour) { + presets.push({ id: "evening", snoozedUntil: evening.getTime() }); + } + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(9, 0, 0, 0); + presets.push({ id: "tomorrow", snoozedUntil: tomorrow.getTime() }); + const nextMonday = new Date(now); + nextMonday.setDate(nextMonday.getDate() + ((8 - nextMonday.getDay()) % 7 || 7)); + nextMonday.setHours(9, 0, 0, 0); + if (nextMonday.getTime() !== tomorrow.getTime()) { + presets.push({ id: "next-week", snoozedUntil: nextMonday.getTime() }); + } + return presets; +} + +export function nextSessionSnoozeWakeAt(rows: Iterable, nowMs: number): number | null { + let next: number | null = null; + for (const row of rows) { + if (isSessionSnoozed(row, nowMs) && (next === null || row.snoozedUntil! < next)) { + next = row.snoozedUntil!; + } + } + return next; +} + +export function formatSessionSnoozeWakeTime(snoozedUntil: number, now = new Date()): string { + const wake = new Date(snoozedUntil); + const day = new Date(now); + day.setHours(0, 0, 0, 0); + if (wake.toDateString() === day.toDateString()) { + return formatTimeMs(snoozedUntil); + } + day.setDate(day.getDate() + 1); + if (wake.toDateString() === day.toDateString()) { + return t("sessionsView.snoozeTomorrowTime", { time: formatTimeMs(snoozedUntil) }); + } + const nextWeek = new Date(now); + nextWeek.setDate(nextWeek.getDate() + 7); + return formatDateTimeMs(snoozedUntil, { + ...(wake.getTime() > now.getTime() && wake.getTime() <= nextWeek.getTime() + ? { weekday: "short" } + : { month: "short", day: "numeric" }), + hour: "numeric", + minute: "2-digit", + }); +} diff --git a/ui/src/pages/chat/chat-pane-session-menu.ts b/ui/src/pages/chat/chat-pane-session-menu.ts index 37606675ae05..828f8e5c65aa 100644 --- a/ui/src/pages/chat/chat-pane-session-menu.ts +++ b/ui/src/pages/chat/chat-pane-session-menu.ts @@ -111,6 +111,7 @@ export abstract class ChatPaneSessionMenu extends ChatPaneContext { isChild: Boolean(resolveSidebarSessionParentKey(row, new Set([mainSessionKey]))), pinned: row.pinned === true, pinnable, + snoozedUntil: row.snoozedUntil ?? null, unread: row.unread === true, hiddenFromInvolvingMe: row.hiddenFromInvolvingMe, archived: row.archived === true, @@ -320,6 +321,14 @@ export abstract class ChatPaneSessionMenu extends ChatPaneContext { } break; } + case "snooze": + await operations.snoozeSessionWithUndo(host, session, action.snoozedUntil, scope); + break; + case "wake": + await operations.patchSession(host, session, { snoozedUntil: null }, scope, { + sessionScope: true, + }); + break; case "toggle-archived": if (session.archived) { await operations.patchSession(host, session, { archived: false }, scope, { diff --git a/ui/src/pages/sessions/session-menu.ts b/ui/src/pages/sessions/session-menu.ts index b33ad286f75d..672135e4fbfc 100644 --- a/ui/src/pages/sessions/session-menu.ts +++ b/ui/src/pages/sessions/session-menu.ts @@ -15,6 +15,8 @@ import { import { canCopySessionMarkdown } from "../../lib/sessions/session-menu-navigation.ts"; import { pluginSessionMenuActions } from "../../plugins/control-ui-actions.ts"; +type SessionsPageMenuAction = Exclude; + export function renderSessionManagementMenu(params: { context: ApplicationContext; row: GatewaySessionRow; @@ -24,7 +26,7 @@ export function renderSessionManagementMenu(params: { groups: string[]; work: SessionMenuWork | null; onClose: () => void; - onAction: (action: SessionMenuAction) => void; + onAction: (action: SessionsPageMenuAction) => void; }) { const { context, row } = params; const gateway = context.gateway.snapshot; @@ -48,6 +50,7 @@ export function renderSessionManagementMenu(params: { sessionId: normalizeOptionalString(row.sessionId) ?? null, pinned: row.pinned === true, pinnable, + snoozedUntil: row.snoozedUntil ?? null, unread: row.unread === true, hiddenFromInvolvingMe: row.hiddenFromInvolvingMe, archived: row.archived === true, @@ -78,7 +81,12 @@ export function renderSessionManagementMenu(params: { .work=${params.work} .pluginActions=${pluginSessionMenuActions(context.plugins, row)} .onClose=${params.onClose} - .onAction=${params.onAction} + .onAction=${(action: SessionMenuAction) => { + // Snooze controls belong to the sidebar; the page retains its existing action contract. + if (action.kind !== "snooze" && action.kind !== "wake") { + params.onAction(action); + } + }} > `; } diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index 18322bc300e7..6293a3df6bd5 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -14,7 +14,7 @@ import { requestCloudWorkerStop } from "../../components/cloud-worker-stop.runti import { resolveCloudWorkerStopAction } from "../../components/cloud-worker-stop.ts"; import { showConfirmDialog } from "../../components/confirm-dialog.ts"; import { fetchSessionMenuWork } from "../../components/session-menu-work.ts"; -import type { SessionMenuAction, SessionMenuWork } from "../../components/session-menu.ts"; +import type { SessionMenuWork } from "../../components/session-menu.ts"; import "../../components/session-menu.ts"; import { formatBatchSessionRemovalError, @@ -1250,7 +1250,7 @@ class SessionsPage extends OpenClawLightDomElement { groups: this.knownCategories(), work: this.sessionMenuWork, onClose: () => this.closeSessionMenu(), - onAction: (action: SessionMenuAction) => { + onAction: (action) => { switch (action.kind) { case "open-pr": openExternalUrlSafe(action.url); diff --git a/ui/src/test-helpers/control-ui-session-fixtures.ts b/ui/src/test-helpers/control-ui-session-fixtures.ts index 0b3c57b81ec7..b6353dc2b821 100644 --- a/ui/src/test-helpers/control-ui-session-fixtures.ts +++ b/ui/src/test-helpers/control-ui-session-fixtures.ts @@ -195,6 +195,21 @@ export function createControlUiSessionFixtures( ); set("pinned", next.pinnedAt !== undefined); } + // Mirror the Gateway: archive and pin clear a snooze; null wakes; a new wake time restamps. + if (Object.hasOwn(fields, "snoozedUntil")) { + const snoozedUntil = fields.snoozedUntil; + if (typeof snoozedUntil === "number" && next.snoozedUntil !== snoozedUntil) { + set("snoozedUntil", snoozedUntil); + set("snoozedAt", ++timestamp); + } else if (snoozedUntil === null) { + set("snoozedUntil", undefined); + set("snoozedAt", undefined); + } + } + if (fields.archived === true || fields.pinned === true) { + set("snoozedUntil", undefined); + set("snoozedAt", undefined); + } // Advance the fixture's synthetic timeline without making its later events stale. const latestUpdatedAt = Math.max( 0, diff --git a/ui/src/test-helpers/session-menu.ts b/ui/src/test-helpers/session-menu.ts index da80a8f05b2e..e5ab5bf69296 100644 --- a/ui/src/test-helpers/session-menu.ts +++ b/ui/src/test-helpers/session-menu.ts @@ -38,6 +38,7 @@ export async function mountMenu( work?: SessionMenuWork | null; pluginActions?: readonly PluginSessionMenuAction[]; archiveAllowed?: boolean; + snoozeAllowed?: boolean; deleteAllowed?: boolean; cloudWorkerStopAllowed?: boolean; selectionCount?: number; @@ -64,6 +65,7 @@ export async function mountMenu( pinned: false, unread: false, archived: false, + snoozedUntil: null, category: null, icon: null, color: null, @@ -85,6 +87,7 @@ export async function mountMenu( .actionDisabledReasons=${options.actionDisabledReasons ?? {}} .forkDisabled=${false} .forkFromLastCompleted=${options.forkFromLastCompleted ?? false} + .snoozeAllowed=${options.snoozeAllowed ?? false} .archiveAllowed=${options.archiveAllowed ?? true} .deleteAllowed=${ options.deleteAllowed ?? (session.archived || (options.archiveAllowed ?? true))