mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
feat(sessions): snooze sessions from the sidebar menu (#161960)
* feat(sessions): snooze sessions from the sidebar menu
Active session lists need a way to defer a conversation without archiving
it or interrupting its work.
Store and protocol retain optional wake and set timestamps in entry JSON
and row snapshots, reserving the core names against plugin slot collisions.
Scoped patches validate durable identity, future wake
times, and eligible roots. Pin and archive clear snooze; snooze keeps pin.
Gateway user interactions and completed activity clear snooze against the
current entry, including snoozes set during a run. System events and
preserved-state runs keep it. Existing row publications carry the change.
The Control UI sidebar adds calendar-aware presets, Snoozed filtering,
Wake and Undo actions, localized wake labels, and deadline invalidation
through the existing attention controller. Root rows reuse the existing
trail, and adopted catalog rows share the visibility projection.
Docs explain the active-session overlay and patch contract. Coherent
sibling modules and shared test fixtures keep the original owners within
their line budgets without removing coverage.
Release-note context: snooze a sidebar session until a preset time, send
it a message, or let its run finish to bring it back. Snoozing never stops
or blocks the session, and its Gateway-owned state follows every client.
* chore(sessions): restore the plugin-seam note on lifecycle timestamps
* fix(ui): keep snooze preset times compact and teach the mock fixture snooze
The Snooze submenu labels already name the day ("Tomorrow"), so the time
column now shows only the clock time except for "Next week", which keeps
its weekday. The shared Control UI session fixture now applies snoozedUntil
patches with the same rules as the Gateway (null wakes, archive and pin
clear, a new wake time restamps), so the mocked dev server and e2e harness
hide and restore snoozed rows like a real Gateway.
* fix(sessions): drop snooze metadata when an entry is archived
Automatic archival (active-session cap, age retention, stale dashboards)
writes archive facts without the sessions.patch path, so a snoozed session
kept its wake time through the archive and a later restore left it hidden
from the Active list. The canonical entry shape now removes snoozedUntil
and snoozedAt from any archived entry, which covers every persisted write,
every read projection, and the existing-entry projection the restore patch
uses.
* chore(protocol): regenerate Swift models for session snooze fields
* test(gateway): keep the snooze session-utils test in inventory order
* test(ui): walk the Snoozed status option in the sidebar filter keyboard test
* test(ui): expect Snoozed after one arrow from Active in the owner filter e2e
This commit is contained in:
parent
7c5997006c
commit
d63df96b48
76 changed files with 1958 additions and 324 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ Control UI capabilities grouped by area, each with the Gateway RPC methods behin
|
|||
<Accordion title="Channels, sessions, memory">
|
||||
- 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`).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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." }),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
86
src/agents/command/session-store.snooze.test.ts
Normal file
86
src/agents/command/session-store.snooze.test.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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<T>(
|
||||
|
|
@ -73,3 +75,10 @@ export async function updateSessionStoreAfterAgentRun(
|
|||
agentDir: params.agentDir ?? "/tmp/openclaw-session-store-test-agent",
|
||||
});
|
||||
}
|
||||
|
||||
export function createRunResult(
|
||||
agentMeta: NonNullable<EmbeddedAgentRunResult["meta"]["agentMeta"]>,
|
||||
meta: Partial<Omit<EmbeddedAgentRunResult["meta"], "agentMeta">> = {},
|
||||
): EmbeddedAgentRunResult {
|
||||
return { meta: { durationMs: 1, ...meta, agentMeta } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<EmbeddedAgentRunResult["meta"]["agentMeta"]>,
|
||||
meta: Partial<Omit<EmbeddedAgentRunResult["meta"], "agentMeta">> = {},
|
||||
): EmbeddedAgentRunResult {
|
||||
return { meta: { durationMs: 1, ...meta, agentMeta } };
|
||||
}
|
||||
|
||||
function contextBudgetStatus(
|
||||
overrides: Partial<NonNullable<SessionEntry["contextBudgetStatus"]>> = {},
|
||||
): NonNullable<SessionEntry["contextBudgetStatus"]> {
|
||||
|
|
|
|||
|
|
@ -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<SessionEntry> = 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
|
||||
|
|
|
|||
25
src/auto-reply/reply/session-entry-lifecycle-carry.ts
Normal file
25
src/auto-reply/reply/session-entry-lifecycle-carry.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -151,6 +151,18 @@ export function projectCanonicalSessionEntryShape(value: Record<string, unknown>
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
24
src/gateway/session-utils-provider.test-support.ts
Normal file
24
src/gateway/session-utils-provider.test-support.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -422,10 +422,9 @@ export function materializeSessionRow(input: ReturnType<typeof readSessionRowInp
|
|||
const storedOrigin = deliveryFields.origin;
|
||||
const avatar = normalizeOptionalString(storedOrigin?.avatar);
|
||||
const controlUiBasePath = normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath);
|
||||
const pinnedAt =
|
||||
entry?.pinnedAt !== undefined && isPinnableSessionEntry(key, entry)
|
||||
? entry.pinnedAt
|
||||
: undefined;
|
||||
// Snooze shares the pin root-session rule.
|
||||
const pinnable = isPinnableSessionEntry(key, entry);
|
||||
const pinnedAt = pinnable ? entry?.pinnedAt : undefined;
|
||||
|
||||
// Reserve temporal fields in wire order; presentation fills a fresh copy.
|
||||
const row: GatewaySessionRow = {
|
||||
|
|
@ -491,6 +490,8 @@ export function materializeSessionRow(input: ReturnType<typeof readSessionRowInp
|
|||
archiveReason: entry?.archiveReason,
|
||||
pinned: pinnedAt !== undefined,
|
||||
pinnedAt,
|
||||
snoozedUntil: pinnable ? entry?.snoozedUntil : undefined,
|
||||
snoozedAt: pinnable ? entry?.snoozedAt : undefined,
|
||||
unread: deriveSessionUnread(entry),
|
||||
lastReadAt: entry?.lastReadAt,
|
||||
markedUnreadAt: entry?.markedUnreadAt,
|
||||
|
|
|
|||
101
src/gateway/session-utils.snooze.test.ts
Normal file
101
src/gateway/session-utils.snooze.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// Session lists project snooze metadata for roots without changing server-side membership.
|
||||
import "./session-utils-provider.test-support.js";
|
||||
import { afterAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { listSessionFixture } from "./session-list.test-support.js";
|
||||
import {
|
||||
closeSessionSqliteDatabasesForTest,
|
||||
createModelDefaultsConfig,
|
||||
} from "./session-utils.test-support.js";
|
||||
|
||||
const { resetSessionProviderArtifacts } = await import("./session-utils-provider.test-support.js");
|
||||
|
||||
describe("session list snooze projection", () => {
|
||||
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<string, SessionEntry> = {
|
||||
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<string, SessionEntry>;
|
||||
|
||||
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<string, SessionEntry> = {
|
||||
"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]);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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<string, SessionEntry> = {
|
||||
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<string, SessionEntry>;
|
||||
|
||||
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<string, SessionEntry> = {
|
||||
"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(
|
||||
|
|
|
|||
93
src/gateway/sessions-patch-lifecycle-flags.ts
Normal file
93
src/gateway/sessions-patch-lifecycle-flags.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
216
src/gateway/sessions-patch.snooze.test.ts
Normal file
216
src/gateway/sessions-patch.snooze.test.ts
Normal file
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
95
src/gateway/sessions-patch.test-support.ts
Normal file
95
src/gateway/sessions-patch.test-support.ts
Normal file
|
|
@ -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<typeof projectSessionsPatchEntry>[0],
|
||||
"existingEntry" | "isLabelInUse"
|
||||
> & {
|
||||
store: Record<string, SessionEntry>;
|
||||
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
|
||||
},
|
||||
) {
|
||||
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<typeof applySessionsPatchToStore>[0];
|
||||
|
||||
export async function runPatch(params: {
|
||||
patch: ApplySessionsPatchArgs["patch"];
|
||||
store?: Record<string, SessionEntry>;
|
||||
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<ReturnType<typeof applySessionsPatchToStore>>,
|
||||
): SessionEntry {
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
return result.entry;
|
||||
}
|
||||
|
||||
export function expectPatchError(
|
||||
result: Awaited<ReturnType<typeof applySessionsPatchToStore>>,
|
||||
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<SessionEntry>): Record<string, SessionEntry> {
|
||||
return {
|
||||
[MAIN_SESSION_KEY]: {
|
||||
sessionId: "sess",
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
} as SessionEntry,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<typeof projectSessionsPatchEntry>[0],
|
||||
"existingEntry" | "isLabelInUse"
|
||||
> & {
|
||||
store: Record<string, SessionEntry>;
|
||||
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
|
||||
},
|
||||
) {
|
||||
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<typeof applySessionsPatchToStore>[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<string, SessionEntry>;
|
||||
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<ReturnType<typeof applySessionsPatchToStore>>,
|
||||
): SessionEntry {
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
return result.entry;
|
||||
}
|
||||
|
||||
function expectPatchError(
|
||||
result: Awaited<ReturnType<typeof applySessionsPatchToStore>>,
|
||||
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<SessionEntry>): Record<string, SessionEntry> {
|
||||
return {
|
||||
[MAIN_SESSION_KEY]: {
|
||||
sessionId: "sess",
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
} as SessionEntry,
|
||||
};
|
||||
}
|
||||
|
||||
function mainAuthOverrideStore(overrides: Partial<SessionEntry>): Record<string, SessionEntry> {
|
||||
return mainStoreEntry({
|
||||
providerOverride: "anthropic",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
|||
"archivedBy",
|
||||
"archiveReason",
|
||||
"pinnedAt",
|
||||
"snoozedUntil",
|
||||
"snoozedAt",
|
||||
"lastReadAt",
|
||||
"agentStatus",
|
||||
"observerDigest",
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ const SESSIONS_PATCH_WRITE_SCOPE_MUTATIONS: ReadonlySet<string> = new Set([
|
|||
"boardPresentation",
|
||||
"pinned",
|
||||
"archived",
|
||||
"snoozedUntil",
|
||||
"unread",
|
||||
"model",
|
||||
"agentRuntime",
|
||||
|
|
|
|||
|
|
@ -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: {} },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
102
ui/src/components/app-sidebar-session-archive-visibility.test.ts
Normal file
102
ui/src/components/app-sidebar-session-archive-visibility.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<GatewaySessionRow, "key" | "archived">) =>
|
||||
isLifecycleHidden(row.key) || (input.statusFilter === "archived" && row.archived !== true);
|
||||
const isSessionHidden = (row: Pick<GatewaySessionRow, "key" | "archived" | "snoozedUntil">) =>
|
||||
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) ||
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<string, string | undefined> = new Map(),
|
||||
liveRowsByKey: ReadonlyMap<string, GatewaySessionRow> = 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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`<span class="sidebar-session-indicator"
|
||||
|
|
@ -250,16 +256,20 @@ function renderSidebarSessionIndicators(
|
|||
const trail = hasTrail
|
||||
? html`<span class="session-row-trail" id=${metaId}
|
||||
>${
|
||||
session.runtimeMs != null
|
||||
? session.hasActiveRun
|
||||
? html`<openclaw-elapsed-time
|
||||
.startMs=${session.runtimeSampledAt! - session.runtimeMs}
|
||||
snoozed
|
||||
? t("sessionsView.snoozeWakes", {
|
||||
time: formatSessionSnoozeWakeTime(session.snoozedUntil!),
|
||||
})
|
||||
: session.runtimeMs != null
|
||||
? session.hasActiveRun
|
||||
? html`<openclaw-elapsed-time
|
||||
.startMs=${session.runtimeSampledAt! - session.runtimeMs}
|
||||
></openclaw-elapsed-time>`
|
||||
: (formatDurationCompact(session.runtimeMs) ?? "0ms")
|
||||
: html`<openclaw-elapsed-time
|
||||
.startMs=${session.startedAt!}
|
||||
.endMs=${session.endedAt ?? null}
|
||||
></openclaw-elapsed-time>`
|
||||
: (formatDurationCompact(session.runtimeMs) ?? "0ms")
|
||||
: html`<openclaw-elapsed-time
|
||||
.startMs=${session.startedAt!}
|
||||
.endMs=${session.endedAt ?? null}
|
||||
></openclaw-elapsed-time>`
|
||||
}</span
|
||||
>`
|
||||
: nothing;
|
||||
|
|
|
|||
66
ui/src/components/app-sidebar-session-snooze-visibility.ts
Normal file
66
ui/src/components/app-sidebar-session-snooze-visibility.ts
Normal file
|
|
@ -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<ApplicationContext, "sessions"> | undefined;
|
||||
readonly sessionsStatusFilter: SidebarSessionStatusFilter;
|
||||
readonly hiddenSessionCatalogIds: ReadonlySet<string>;
|
||||
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] : []),
|
||||
];
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
70
ui/src/components/session-attention-controller.test.ts
Normal file
70
ui/src/components/session-attention-controller.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof globalThis.setTimeout> | null = null;
|
||||
private agentStatusExpiryAt: number | null = null;
|
||||
private snoozeWakeTimer: ReturnType<typeof globalThis.setTimeout> | 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<Pick<GatewaySessionRow, "snoozedUntil">>): 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 = {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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<SessionOwnerOption, "type" | "id"> }
|
||||
| { 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<CompactSessionMenuView, "root">, inline = false) {
|
||||
const state = this.readState();
|
||||
switch (view) {
|
||||
case "snooze":
|
||||
return this.snoozeMenu.renderSubmenu(inline);
|
||||
case "copy":
|
||||
return this.renderCopySubmenu(inline);
|
||||
case "open-in":
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ export type CompactSessionMenuView =
|
|||
| "open-in"
|
||||
| "assign-owner"
|
||||
| "icon"
|
||||
| "group";
|
||||
| "group"
|
||||
| "snooze";
|
||||
|
||||
const COMPACT_SESSION_MENU_VIEW_BY_VALUE: Record<string, CompactSessionMenuView> = {
|
||||
"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",
|
||||
|
|
|
|||
95
ui/src/components/session-menu-snooze.ts
Normal file
95
ui/src/components/session-menu-snooze.ts
Normal file
|
|
@ -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`
|
||||
<wa-dropdown-item
|
||||
slot=${inline ? nothing : "submenu"}
|
||||
class="session-menu__item"
|
||||
value=${`snooze:${snoozedUntil}`}
|
||||
?disabled=${this.host.disabled("snooze")}
|
||||
title=${this.host.disabledReason("snooze") ?? nothing}
|
||||
><span class="session-menu__text">${t(labels[id])} · ${when}</span></wa-dropdown-item
|
||||
>
|
||||
`;
|
||||
})}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ export class SessionOrganizerController {
|
|||
return operations.patchSession(this.host, session, patch, scope, options);
|
||||
};
|
||||
|
||||
async snoozeSessionWithUndo(session: SidebarRecentSession, snoozedUntil: number): Promise<void> {
|
||||
await this.runOperation((operations, scope) =>
|
||||
operations.snoozeSessionWithUndo(this.host, session, snoozedUntil, scope),
|
||||
);
|
||||
}
|
||||
|
||||
async archiveSessionWithUndo(session: SidebarRecentSession): Promise<void> {
|
||||
await this.runOperation((operations, scope) =>
|
||||
operations.archiveSessionWithUndo(this.host, session, scope),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<SidebarSessionCatalog[]>,
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export type SessionPatch = Pick<
|
|||
| "toolOverrides"
|
||||
| "archived"
|
||||
| "pinned"
|
||||
| "snoozedUntil"
|
||||
| "unread"
|
||||
>;
|
||||
|
||||
|
|
|
|||
101
ui/src/lib/sessions/session-snooze.test.ts
Normal file
101
ui/src/lib/sessions/session-snooze.test.ts
Normal file
|
|
@ -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<void>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
73
ui/src/lib/sessions/session-snooze.ts
Normal file
73
ui/src/lib/sessions/session-snooze.ts
Normal file
|
|
@ -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<GatewaySessionRow, "snoozedUntil">;
|
||||
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<SnoozePreset> {
|
||||
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<SnoozeRow>, 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",
|
||||
});
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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<SessionMenuAction, { kind: "snooze" | "wake" }>;
|
||||
|
||||
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);
|
||||
}
|
||||
}}
|
||||
></openclaw-session-menu>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue