Start new sessions with folder group defaults (#123276)

* feat(ui): add folder group session defaults

* fix(ci): align folder group contracts

* fix(protocol): refresh Android gateway methods

* fix(ui): reuse folder picker for group defaults

* fix(ui): harden session group defaults

* test(ui): align group defaults with current main

* test(ui): keep group catalog defaults path-free

* fix(ui): close folder group CI gaps

* fix(ui): satisfy folder group CI contracts

* fix(session-groups): enforce defaults safety

* test(gateway): keep group defaults in workspace

* fix(session-groups): enforce defaults participation

* fix(session-groups): close review authorization races

* fix(session-groups): canonicalize defaults authorization

---------

Co-authored-by: Jesse Merhi <jesse-merhi@users.noreply.github.com>
Co-authored-by: Jesse Merhi <openclaw@users.noreply.github.com>
This commit is contained in:
Jesse Merhi
2026-08-15 15:24:13 +10:00
committed by GitHub
parent 2ee5c2f6ca
commit edb5adfbf5
80 changed files with 3447 additions and 314 deletions
@@ -406,8 +406,10 @@ enum class GatewayMethod(
SessionsDelete("sessions.delete"),
SessionsCompact("sessions.compact"),
SessionsGroupsList("sessions.groups.list"),
SessionsGroupsDefaults("sessions.groups.defaults"),
SessionsGroupsPut("sessions.groups.put"),
SessionsGroupsRename("sessions.groups.rename"),
SessionsGroupsUpdate("sessions.groups.update"),
SessionsGroupsDelete("sessions.groups.delete"),
LastHeartbeat("last-heartbeat"),
SetHeartbeats("set-heartbeats"),
@@ -8437,6 +8437,7 @@ public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?
public let label: String?
public let category: String?
public let model: String?
public let thinkinglevel: String?
public let incognito: Bool?
@@ -8462,6 +8463,7 @@ public struct SessionsCreateParams: Codable, Sendable {
key: String? = nil,
agentid: String? = nil,
label: String? = nil,
category: String? = nil,
model: String? = nil,
thinkinglevel: String? = nil,
incognito: Bool? = nil,
@@ -8486,6 +8488,7 @@ public struct SessionsCreateParams: Codable, Sendable {
self.key = key
self.agentid = agentid
self.label = label
self.category = category
self.model = model
self.thinkinglevel = thinkinglevel
self.incognito = incognito
@@ -8512,6 +8515,7 @@ public struct SessionsCreateParams: Codable, Sendable {
case key
case agentid = "agentId"
case label
case category
case model
case thinkinglevel = "thinkingLevel"
case incognito
@@ -9217,6 +9221,28 @@ public struct SessionGroup: Codable, Sendable {
}
}
public struct SessionGroupDefaults: Codable, Sendable {
public let name: String
public let cwd: String?
public let worktree: Bool?
public init(
name: String,
cwd: String? = nil,
worktree: Bool? = nil)
{
self.name = name
self.cwd = cwd
self.worktree = worktree
}
private enum CodingKeys: String, CodingKey {
case name
case cwd
case worktree
}
}
public struct SessionsGroupsListParams: Codable, Sendable {}
public struct SessionsGroupsListResult: Codable, Sendable {
@@ -9237,6 +9263,22 @@ public struct SessionsGroupsListResult: Codable, Sendable {
}
}
public struct SessionsGroupsDefaultsParams: Codable, Sendable {}
public struct SessionsGroupsDefaultsResult: Codable, Sendable {
public let defaults: [SessionGroupDefaults]
public init(
defaults: [SessionGroupDefaults])
{
self.defaults = defaults
}
private enum CodingKeys: String, CodingKey {
case defaults
}
}
public struct SessionsGroupsPutParams: Codable, Sendable {
public let names: [String]
public let sectionorder: [String]?
@@ -9273,6 +9315,46 @@ public struct SessionsGroupsRenameParams: Codable, Sendable {
}
}
public struct SessionsGroupsUpdateParams: Codable, Sendable {
public let name: String
public let cwd: AnyCodable
public let worktree: Bool
public init(
name: String,
cwd: AnyCodable,
worktree: Bool)
{
self.name = name
self.cwd = cwd
self.worktree = worktree
}
private enum CodingKeys: String, CodingKey {
case name
case cwd
case worktree
}
}
public struct SessionsGroupsUpdateResult: Codable, Sendable {
public let ok: Bool
public let defaults: [SessionGroupDefaults]
public init(
ok: Bool,
defaults: [SessionGroupDefaults])
{
self.ok = ok
self.defaults = defaults
}
private enum CodingKeys: String, CodingKey {
case ok
case defaults
}
}
public struct SessionsGroupsDeleteParams: Codable, Sendable {
public let name: String
@@ -1,5 +1,5 @@
{
"startupJsGzipBytes": 333338,
"reason": "cumulative 2026-08-15 UI fix growth (tab retry/session attention/talk backpressure stream) plus chat transcript virtualizer re-attach fix (#123974); CI runs 31861213699 and 31861214469 build-artifacts bytes",
"startupJsGzipBytes": 334391,
"reason": "cumulative 2026-08-15 UI growth plus folder group defaults catalog and lazy New Session route resolution",
"updatedAt": "2026-08-15"
}
+2 -2
View File
@@ -649,9 +649,9 @@ methods. Treat this as feature discovery, not a full enumeration of
- `sessions.preview` returns bounded transcript previews for specific session keys.
- `sessions.describe` returns one gateway session row for an exact session key.
- `sessions.resolve` resolves or canonicalizes a session target by key, raw session ID, label, or Control UI short ID. Ambiguous short IDs return a bounded candidate list as a successful RPC result.
- `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. Without `worktreeName`, OpenClaw derives a readable name from the session label or generated first-message title, then falls back to a crustacean-themed name; names already occupied by another owner, local branch, or unmanaged path receive a numeric suffix. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key).
- `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically; optional `category` assigns the session to a custom group and registers that group when first used. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. Without `worktreeName`, OpenClaw derives a readable name from the session label or generated first-message title, then falls back to a crustacean-themed name; names already occupied by another owner, local branch, or unmanaged path receive a numeric suffix. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key).
- `sessions.dispatch` (`operator.admin`) moves an existing local OpenClaw session with a live, registry-owned session managed worktree to a configured cloud-worker profile. Pass `{ key, profileId, agentId? }`. The Gateway does not advertise the method when no worker profile is configured. Dispatch closes local turn admission before draining active work and returns only after placement reaches `active` worker ownership. Arbitrary plain directories are not dispatchable; after admission, the workspace transport may use manifest mirroring if the managed worktree's Git metadata later becomes unavailable. SSH fallback candidates rotate only for idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed and are not replayed. Dispatch is one-way; worker-to-local pull-back is not part of this RPC.
- `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). Membership stays on each session's `category` field; rename and delete update member sessions server-side.
- `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). The read-scoped list result is intentionally path-free. `sessions.groups.defaults` and `sessions.groups.update` require `operator.write` and read or replace one custom group's optional working-directory and worktree defaults. Non-admin callers can save only directories inside a configured agent workspace; other absolute Gateway paths require `operator.admin`. Membership stays on each session's `category` field; rename and delete update member sessions server-side.
- `sessions.send` sends a message into an existing session.
- `sessions.steer` is the interrupt-and-steer variant for an active session.
- `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.
+1 -1
View File
@@ -96,7 +96,7 @@ Disable the feature entirely with **Settings → General → Quick Chat**; the s
- Data plane: Gateway WS methods `chat.history`, `chat.message.get`, `chat.send`, `chat.abort`, `chat.inject`, plus `question.list` and `question.resolve`, and events `chat`, `agent`, `presence`, `tick`, `health`; question cards follow `question.requested` and `question.resolved` events and refresh from `question.list` after reconnects.
- `chat.history` returns a display-normalized transcript: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`<tool_call>`, `<function_call>`, `<tool_calls>`, `<function_calls>`, including truncated blocks) and leaked model control tokens are stripped, pure silent-token assistant rows such as exact `NO_REPLY`/`no_reply` are omitted, and oversized rows can be replaced with a truncated placeholder.
- Session: defaults to the primary session as above; the UI can switch between sessions.
- Session groups: `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` own the group catalog. Membership is the session `category` updated through `sessions.patch`.
- Session groups: `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` own the path-free group catalog. Write-scoped `sessions.groups.defaults` and `sessions.groups.update` own optional New Session folder/worktree defaults. Membership is the session `category` updated through `sessions.patch` or assigned during `sessions.create`.
- Unread state: after a session activates and its live history loads successfully, the app clears that session's unread marker. Failed history loads do not clear it; a transient patch failure retries on the next activation.
- Onboarding uses a dedicated session to keep first-run setup separate.
- Offline cache: the app keeps a small read-only cache of recent chat sessions and transcripts per gateway (`~/Library/Application Support/OpenClaw/chat-cache.sqlite`): cold opens paint the last known transcript immediately and refresh once the Gateway responds, and recent chats stay browsable while disconnected (sending stays disabled until the connection is back).
+1 -1
View File
@@ -238,7 +238,7 @@ and [Linux](/platforms/linux) desktop apps, the
## Sidebar navigation
The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Threads** for the agent's chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the sort control (Created, Last updated, or People when the Gateway reports multiple server identities), Group by, a persisted **Status** filter for Active, Archived, or All, and the **+** that opens the New session page. People orders creator groups by name and keeps Created order within each group. Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. A session holding composer text you typed but never sent shows a pencil badge until the draft is sent or cleared; the active session hides it because its composer is already in view. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive or Unarchive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Threads.
The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Threads** for the agent's chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the sort control (Created, Last updated, or People when the Gateway reports multiple server identities), Group by, a persisted **Status** filter for Active, Archived, or All, and the **+** that opens the New session page. People orders creator groups by name and keeps Created order within each group. Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. A session holding composer text you typed but never sent shows a pencil badge until the draft is sent or cleared; the active session hides it because its composer is already in view. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive or Unarchive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names, order, and New Session defaults live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Each custom group header has a **+** that opens the normal New Session page and assigns the created session to that group. **New session defaults** in the group menu sets its working directory and Local or Worktree preference; the page prefills those values but leaves them editable. Leaving the directory empty uses the selected agent's workspace. The menu also has Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Threads.
## New session page
+5
View File
@@ -318,10 +318,15 @@ export {
SessionsResetParamsSchema,
SessionsDeleteParamsSchema,
SessionGroupSchema,
SessionGroupDefaultsSchema,
SessionsGroupsListParamsSchema,
SessionsGroupsListResultSchema,
SessionsGroupsDefaultsParamsSchema,
SessionsGroupsDefaultsResultSchema,
SessionsGroupsPutParamsSchema,
SessionsGroupsRenameParamsSchema,
SessionsGroupsUpdateParamsSchema,
SessionsGroupsUpdateResultSchema,
SessionsGroupsDeleteParamsSchema,
SessionsGroupsMutationResultSchema,
SessionsCompactParamsSchema,
@@ -58,10 +58,15 @@ export const SessionLifecycleProtocolSchemas = {
SessionsResetParams: sessions.SessionsResetParamsSchema,
SessionsDeleteParams: sessions.SessionsDeleteParamsSchema,
SessionGroup: sessions.SessionGroupSchema,
SessionGroupDefaults: sessions.SessionGroupDefaultsSchema,
SessionsGroupsListParams: sessions.SessionsGroupsListParamsSchema,
SessionsGroupsListResult: sessions.SessionsGroupsListResultSchema,
SessionsGroupsDefaultsParams: sessions.SessionsGroupsDefaultsParamsSchema,
SessionsGroupsDefaultsResult: sessions.SessionsGroupsDefaultsResultSchema,
SessionsGroupsPutParams: sessions.SessionsGroupsPutParamsSchema,
SessionsGroupsRenameParams: sessions.SessionsGroupsRenameParamsSchema,
SessionsGroupsUpdateParams: sessions.SessionsGroupsUpdateParamsSchema,
SessionsGroupsUpdateResult: sessions.SessionsGroupsUpdateResultSchema,
SessionsGroupsDeleteParams: sessions.SessionsGroupsDeleteParamsSchema,
SessionsGroupsMutationResult: sessions.SessionsGroupsMutationResultSchema,
SessionsCompactParams: sessions.SessionsCompactParamsSchema,
@@ -9,6 +9,7 @@ export const SessionsCreateParamsSchema = closedObject({
key: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString),
category: Type.Optional(SessionLabelString),
model: Type.Optional(NonEmptyString),
thinkingLevel: Type.Optional(NonEmptyString),
incognito: Type.Optional(Type.Boolean()),
@@ -569,6 +569,13 @@ export const SessionGroupSchema = closedObject({
position: Type.Integer({ minimum: 0 }),
});
/** New Session defaults visible only to operators who can update them. */
export const SessionGroupDefaultsSchema = closedObject({
name: SessionLabelString,
cwd: Type.Optional(NonEmptyString),
worktree: Type.Optional(Type.Boolean()),
});
const SidebarSectionIdString = Type.String({ minLength: 1, maxLength: 512 });
/** Custom session group catalog in display order. */
@@ -577,6 +584,14 @@ export const SessionsGroupsListResultSchema = closedObject({
sectionOrder: Type.Optional(Type.Array(SidebarSectionIdString, { maxItems: 232 })),
});
/** Reads the New Session defaults for the custom group catalog. */
export const SessionsGroupsDefaultsParamsSchema = closedObject({});
/** Write-scoped group defaults, kept separate from the read-scoped catalog. */
export const SessionsGroupsDefaultsResultSchema = closedObject({
defaults: Type.Array(SessionGroupDefaultsSchema),
});
/** Replaces the ordered group catalog; creates listed names, keeps member categories untouched. */
export const SessionsGroupsPutParamsSchema = closedObject({
names: Type.Array(SessionLabelString, { maxItems: 200 }),
@@ -589,6 +604,19 @@ export const SessionsGroupsRenameParamsSchema = closedObject({
to: SessionLabelString,
});
/** Updates the New Session defaults owned by one custom group. */
export const SessionsGroupsUpdateParamsSchema = closedObject({
name: SessionLabelString,
cwd: Type.Union([NonEmptyString, Type.Null()]),
worktree: Type.Boolean(),
});
/** Result after updating defaults without widening the read-scoped catalog. */
export const SessionsGroupsUpdateResultSchema = closedObject({
ok: Type.Literal(true),
defaults: Type.Array(SessionGroupDefaultsSchema),
});
/** Deletes a group and clears every member session's category. */
export const SessionsGroupsDeleteParamsSchema = closedObject({ name: SessionLabelString });
@@ -831,10 +859,15 @@ export type SessionsPluginPatchResult = Static<typeof SessionsPluginPatchResultS
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
export type SessionGroup = Static<typeof SessionGroupSchema>;
export type SessionGroupDefaults = Static<typeof SessionGroupDefaultsSchema>;
export type SessionsGroupsListParams = Static<typeof SessionsGroupsListParamsSchema>;
export type SessionsGroupsListResult = Static<typeof SessionsGroupsListResultSchema>;
export type SessionsGroupsDefaultsParams = Static<typeof SessionsGroupsDefaultsParamsSchema>;
export type SessionsGroupsDefaultsResult = Static<typeof SessionsGroupsDefaultsResultSchema>;
export type SessionsGroupsPutParams = Static<typeof SessionsGroupsPutParamsSchema>;
export type SessionsGroupsRenameParams = Static<typeof SessionsGroupsRenameParamsSchema>;
export type SessionsGroupsUpdateParams = Static<typeof SessionsGroupsUpdateParamsSchema>;
export type SessionsGroupsUpdateResult = Static<typeof SessionsGroupsUpdateResultSchema>;
export type SessionsGroupsDeleteParams = Static<typeof SessionsGroupsDeleteParamsSchema>;
export type SessionsGroupsMutationResult = Static<typeof SessionsGroupsMutationResultSchema>;
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
@@ -1,9 +1,40 @@
import { describe, expect, it } from "vitest";
import { validateSessionsGroupsListResult, validateSessionsGroupsMutationResult } from "./index.js";
import {
validateSessionsGroupsDefaultsResult,
validateSessionsGroupsListResult,
validateSessionsGroupsMutationResult,
validateSessionsGroupsUpdateParams,
validateSessionsGroupsUpdateResult,
} from "./index.js";
describe("session group result validators", () => {
it("accepts legacy gateway payloads without sectionOrder", () => {
expect(validateSessionsGroupsListResult({ groups: [] })).toBe(true);
expect(validateSessionsGroupsMutationResult({ ok: true, groups: [] })).toBe(true);
});
it("accepts group defaults", () => {
expect(validateSessionsGroupsListResult({ groups: [{ name: "Client", position: 0 }] })).toBe(
true,
);
expect(
validateSessionsGroupsListResult({
groups: [{ name: "Client", position: 0, cwd: "/repos/client" }],
}),
).toBe(false);
expect(
validateSessionsGroupsDefaultsResult({
defaults: [{ name: "Client", cwd: "/repos/client", worktree: true }],
}),
).toBe(true);
expect(
validateSessionsGroupsUpdateResult({
ok: true,
defaults: [{ name: "Client", cwd: "/repos/client", worktree: true }],
}),
).toBe(true);
expect(validateSessionsGroupsUpdateParams({ name: "Client", cwd: null, worktree: false })).toBe(
true,
);
});
});
@@ -261,8 +261,12 @@ export const validateSessionsResetParams = compile(S.SessionsResetParamsSchema);
export const validateSessionsDeleteParams = compile(S.SessionsDeleteParamsSchema);
export const validateSessionsGroupsListParams = compile(S.SessionsGroupsListParamsSchema);
export const validateSessionsGroupsListResult = compile(S.SessionsGroupsListResultSchema);
export const validateSessionsGroupsDefaultsParams = compile(S.SessionsGroupsDefaultsParamsSchema);
export const validateSessionsGroupsDefaultsResult = compile(S.SessionsGroupsDefaultsResultSchema);
export const validateSessionsGroupsPutParams = compile(S.SessionsGroupsPutParamsSchema);
export const validateSessionsGroupsRenameParams = compile(S.SessionsGroupsRenameParamsSchema);
export const validateSessionsGroupsUpdateParams = compile(S.SessionsGroupsUpdateParamsSchema);
export const validateSessionsGroupsUpdateResult = compile(S.SessionsGroupsUpdateResultSchema);
export const validateSessionsGroupsDeleteParams = compile(S.SessionsGroupsDeleteParamsSchema);
export const validateSessionsGroupsMutationResult = compile(S.SessionsGroupsMutationResultSchema);
export const validateSessionsCompactParams = compile(S.SessionsCompactParamsSchema);
+2
View File
@@ -82,8 +82,10 @@ describe("method scope resolution", () => {
["projects.add", ["operator.write"]],
["projects.searchRemote", ["operator.read"]],
["sessions.groups.list", ["operator.read"]],
["sessions.groups.defaults", ["operator.write"]],
["sessions.groups.put", ["operator.write"]],
["sessions.groups.rename", ["operator.write"]],
["sessions.groups.update", ["operator.write"]],
["sessions.groups.delete", ["operator.write"]],
["sessions.catalog.list", ["operator.read"]],
["sessions.catalog.read", ["operator.read"]],
@@ -83,6 +83,8 @@ const TRAIN_2026_7_METHODS = [
const CURRENT_TRAIN_METHODS = [
"device.pair.setupStatus",
"sessions.patchMany",
"sessions.groups.update",
"sessions.groups.defaults",
"sessions.recover",
"update.hold",
"sessions.catalog.startTerminal",
+2
View File
@@ -252,8 +252,10 @@ const CORE_GATEWAY_METHOD_SPECS = [
["sessions.delete", "sessions-delete", "dynamic", "<=2026.7"],
["sessions.compact", "sessions-compact", "operator.admin", "<=2026.7"],
["sessions.groups.list", "sessions-groups", "operator.read", "<=2026.7"],
["sessions.groups.defaults", "sessions-groups", "operator.write", "2026.8"],
["sessions.groups.put", "sessions-groups", "operator.write", "<=2026.7"],
["sessions.groups.rename", "sessions-groups", "operator.write", "<=2026.7"],
["sessions.groups.update", "sessions-groups", "operator.write", "2026.8"],
["sessions.groups.delete", "sessions-groups", "operator.write", "<=2026.7"],
["last-heartbeat", "system", "operator.read", "<=2026.7"],
["set-heartbeats", "system", "operator.admin", "<=2026.7"],
+40 -3
View File
@@ -30,6 +30,7 @@ import { resolveUserPath } from "../../utils.js";
import { buildDashboardSessionTitleSource } from "../dashboard-session-title.js";
import { ADMIN_SCOPE, authorizeOperatorScopesForRequiredScope } from "../method-scopes.js";
import { buildDashboardSessionKey, createGatewaySession } from "../session-create-service.js";
import { ensureSessionGroupRegistered } from "../session-groups.js";
import type { PrepareGatewaySessionLifecycle } from "../session-lifecycle-preparation.js";
import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js";
import { resolveSessionStoreAgentId } from "../session-store-key.js";
@@ -53,14 +54,47 @@ import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
import { resolveWorkspacePathContainment } from "./workspace-path-containment.js";
function registerCreatedSessionCategory(
category: string | undefined,
context: Parameters<typeof emitSessionsChanged>[0],
): void {
if (!category) {
return;
}
try {
if (ensureSessionGroupRegistered(category)) {
// Catalog bookkeeping follows the authoritative session commit and has
// its own invalidation. Its failure must not make a durable create ambiguous.
emitSessionsChanged(context, { reason: "groups" });
}
} catch (error) {
sessionLog.warn(`failed to register created session category: ${formatErrorMessage(error)}`);
}
}
export const sessionCreateHandlers: GatewayRequestHandlers = {
"sessions.create": async ({ req, params, respond, context, client, isWebchatConnect }) => {
"sessions.create": async ({
req,
params,
respond,
context,
client,
isWebchatConnect,
sessionMutationAuthorization,
}) => {
if (!assertValidParams(params, validateSessionsCreateParams, "sessions.create", respond)) {
return;
}
const p = params;
const cfg = context.getRuntimeConfig();
const authority = createAgentRuntimeAuthorityGuard(client, context, respond);
const commitGuard =
authority.commitGuard || sessionMutationAuthorization
? () => {
authority.commitGuard?.();
sessionMutationAuthorization?.assertCurrent();
}
: undefined;
const catalogId = normalizeOptionalString(p.catalogId);
if (catalogId && p.model) {
respond(
@@ -405,7 +439,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
// Checkout hooks and .openclaw/worktree-setup.sh run repo code; keep them
// admin-only so this write-scoped path cannot execute gated repo scripts.
runSetupScript: scopes.includes(ADMIN_SCOPE),
...(authority.commitGuard ? { commitGuard: authority.commitGuard } : {}),
...(commitGuard ? { commitGuard } : {}),
});
provisioned = true;
}
@@ -489,11 +523,13 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
if (!authority.ensureActive()) {
return;
}
const category = normalizeOptionalString(p.category);
const created = await createGatewaySession({
cfg,
key: sessionKey,
agentId: sessionAgentId,
label: p.label,
category: p.category,
...(catalogTarget ? { catalogTarget: catalogTarget.target } : { model: p.model }),
thinkingLevel: p.thinkingLevel,
projectId: requestedProjectId,
@@ -534,7 +570,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
authorizedPluginId: normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId),
loadGatewayModelCatalog: () =>
context.loadGatewayModelCatalog({ agentId: modelCatalogAgentId }),
...(authority.commitGuard ? { commitGuard: authority.commitGuard } : {}),
...(commitGuard ? { commitGuard } : {}),
afterCreate: async ({ key, agentId, entry, storePath }) => {
// Session persistence already committed under the guard. Closure after
// that point may suppress follow-on work, but cannot roll back the session.
@@ -588,6 +624,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
respond(false, undefined, created.error);
return;
}
registerCreatedSessionCategory(category, context);
if (created.resetExisting) {
await captureCreatedSessionDiffBaseline({
key: created.key,
@@ -0,0 +1,222 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayRequestHandlerOptions } from "./types.js";
const groupMocks = vi.hoisted(() => ({
NotFound: class SessionGroupNotFoundError extends Error {},
rename: vi.fn(),
update: vi.fn(),
}));
const pathMocks = vi.hoisted(() => ({
isCurrent: vi.fn(),
resolveContainment: vi.fn(),
}));
vi.mock("../session-groups.js", () => ({
deleteSessionGroup: vi.fn(),
listSessionGroupDefaults: vi.fn(() => []),
listSessionGroups: vi.fn(() => []),
listSidebarSectionOrder: vi.fn(() => []),
putSessionGroups: vi.fn(() => []),
renameSessionGroup: groupMocks.rename,
SessionGroupNotFoundError: groupMocks.NotFound,
updateSessionGroupDefaults: groupMocks.update,
}));
vi.mock("./workspace-path-containment.js", () => ({
isWorkspacePathContainmentCurrent: pathMocks.isCurrent,
resolveWorkspacePathContainment: pathMocks.resolveContainment,
}));
import { sessionGroupHandlers } from "./sessions-groups.js";
function updateOptions(
params: Record<string, unknown>,
respond: ReturnType<typeof vi.fn>,
scopes = ["operator.write", "operator.admin"],
) {
return {
params,
respond,
client: { connect: { scopes } },
context: {
getRuntimeConfig: () => ({}),
getSessionEventSubscriberConnIds: () => new Set<string>(),
},
} as unknown as GatewayRequestHandlerOptions;
}
function renameOptions(params: Record<string, unknown>, respond: ReturnType<typeof vi.fn>) {
return {
params,
respond,
context: { getRuntimeConfig: () => ({}) },
} as unknown as GatewayRequestHandlerOptions;
}
describe("sessions.groups.update", () => {
beforeEach(() => {
groupMocks.update.mockReset();
pathMocks.isCurrent.mockReset();
pathMocks.isCurrent.mockReturnValue(true);
pathMocks.resolveContainment.mockReset();
});
it("rejects a relative cwd before mutating defaults", async () => {
const respond = vi.fn();
await expectDefined(
sessionGroupHandlers["sessions.groups.update"],
'sessionGroupHandlers["sessions.groups.update"] test invariant',
)(updateOptions({ name: "Travel", cwd: "tmp/travel", worktree: false }, respond));
expect(groupMocks.update).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "INVALID_REQUEST" }),
);
});
it("rejects a stale target without recreating it", async () => {
groupMocks.update.mockReturnValue(null);
const respond = vi.fn();
await expectDefined(
sessionGroupHandlers["sessions.groups.update"],
'sessionGroupHandlers["sessions.groups.update"] test invariant',
)(updateOptions({ name: "Travel", cwd: "/tmp/travel", worktree: true }, respond));
expect(groupMocks.update).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: "unknown session group: Travel",
}),
);
});
it("rejects a non-admin cwd outside configured workspaces", async () => {
pathMocks.resolveContainment.mockResolvedValue(null);
const respond = vi.fn();
await expectDefined(
sessionGroupHandlers["sessions.groups.update"],
'sessionGroupHandlers["sessions.groups.update"] test invariant',
)(
updateOptions({ name: "Travel", cwd: "/outside/travel", worktree: false }, respond, [
"operator.write",
]),
);
expect(groupMocks.update).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("operator.admin") }),
);
});
it("persists the canonical workspace-contained cwd for a write caller", async () => {
pathMocks.resolveContainment.mockResolvedValue({
path: "/workspace/client",
workspaceRoot: "/workspace",
});
groupMocks.update.mockReturnValue([
{ name: "Client", cwd: "/workspace/client", worktree: true },
]);
const respond = vi.fn();
const assertCurrent = vi.fn();
const options = updateOptions(
{ name: "Client", cwd: "/workspace/link", worktree: true },
respond,
["operator.write"],
);
options.sessionMutationAuthorization = {
assertCurrent,
assertTargetCurrent: vi.fn(),
};
await expectDefined(
sessionGroupHandlers["sessions.groups.update"],
'sessionGroupHandlers["sessions.groups.update"] test invariant',
)(options);
expect(assertCurrent).toHaveBeenCalledOnce();
expect(groupMocks.update).toHaveBeenCalledWith("Client", {
cwd: "/workspace/client",
worktree: true,
});
expect(respond).toHaveBeenCalledWith(
true,
{
ok: true,
defaults: [{ name: "Client", cwd: "/workspace/client", worktree: true }],
},
undefined,
);
});
it("rejects containment retired by a runtime config change before commit", async () => {
const containment = {
path: "/workspace/client",
workspaceRoot: "/workspace",
};
let finishContainment: ((value: typeof containment) => void) | undefined;
pathMocks.resolveContainment.mockImplementation(
async () =>
await new Promise<typeof containment>((resolve) => {
finishContainment = resolve;
}),
);
const initialConfig = { agents: { defaults: { workspace: "/workspace" } } };
const retiredConfig = { agents: { defaults: { workspace: "/replacement" } } };
let runtimeConfig = initialConfig;
pathMocks.isCurrent.mockImplementation((_containment, cfg) => cfg === initialConfig);
const respond = vi.fn();
const options = updateOptions(
{ name: "Client", cwd: "/workspace/client", worktree: true },
respond,
["operator.write"],
);
options.context.getRuntimeConfig = () => runtimeConfig;
const update = expectDefined(
sessionGroupHandlers["sessions.groups.update"],
'sessionGroupHandlers["sessions.groups.update"] test invariant',
)(options);
await vi.waitFor(() => expect(pathMocks.resolveContainment).toHaveBeenCalledOnce());
runtimeConfig = retiredConfig;
finishContainment?.(containment);
await update;
expect(pathMocks.isCurrent).toHaveBeenCalledWith(containment, retiredConfig);
expect(groupMocks.update).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("operator.admin") }),
);
});
});
describe("sessions.groups.rename", () => {
beforeEach(() => {
groupMocks.rename.mockReset();
});
it("rejects an unknown source group", async () => {
groupMocks.rename.mockRejectedValue(new groupMocks.NotFound("unknown session group: Missing"));
const respond = vi.fn();
await expectDefined(
sessionGroupHandlers["sessions.groups.rename"],
'sessionGroupHandlers["sessions.groups.rename"] test invariant',
)(renameOptions({ name: "Missing", to: "Other" }, respond));
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: "unknown session group: Missing",
}),
);
});
});
@@ -1,24 +1,39 @@
// Session group catalog mutations.
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
ErrorCodes,
errorShape,
missingScopeErrorShape,
validateSessionsGroupsDefaultsParams,
validateSessionsGroupsDeleteParams,
validateSessionsGroupsListParams,
validateSessionsGroupsPutParams,
validateSessionsGroupsRenameParams,
validateSessionsGroupsUpdateParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { ADMIN_SCOPE } from "../method-scopes.js";
import { filterMutableSessionGroupRecords } from "../session-group-defaults-access.js";
import { resolveSessionGroupMutationTargetsByName } from "../session-group-mutation-targets.js";
import {
deleteSessionGroup,
listSessionGroupDefaults,
listSidebarSectionOrder,
listSessionGroups,
putSessionGroups,
renameSessionGroup,
SessionGroupNotFoundError,
updateSessionGroupDefaults,
} from "../session-groups.js";
import { SessionMutationAuthorizationChangedError } from "../session-sharing.js";
import { emitSessionsChanged } from "./session-change-event.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
import {
isWorkspacePathContainmentCurrent,
resolveWorkspacePathContainment,
} from "./workspace-path-containment.js";
export const sessionGroupHandlers: GatewayRequestHandlers = {
"sessions.groups.list": async ({ params, respond }) => {
@@ -33,6 +48,24 @@ export const sessionGroupHandlers: GatewayRequestHandlers = {
undefined,
);
},
"sessions.groups.defaults": async ({ params, respond, client, context }) => {
if (
!assertValidParams(
params,
validateSessionsGroupsDefaultsParams,
"sessions.groups.defaults",
respond,
)
) {
return;
}
const defaults = filterMutableSessionGroupRecords({
cfg: context.getRuntimeConfig(),
client,
records: listSessionGroupDefaults(),
});
respond(true, { defaults }, undefined);
},
"sessions.groups.put": async ({ params, respond, context }) => {
if (
!assertValidParams(params, validateSessionsGroupsPutParams, "sessions.groups.put", respond)
@@ -69,9 +102,98 @@ export const sessionGroupHandlers: GatewayRequestHandlers = {
if (error instanceof SessionMutationAuthorizationChangedError) {
throw error;
}
if (error instanceof SessionGroupNotFoundError) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message));
return;
}
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
},
"sessions.groups.update": async ({
params,
respond,
context,
client,
sessionMutationAuthorization,
}) => {
if (
!assertValidParams(
params,
validateSessionsGroupsUpdateParams,
"sessions.groups.update",
respond,
)
) {
return;
}
if (params.cwd && !path.isAbsolute(params.cwd)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "session group cwd must be absolute"),
);
return;
}
const name = normalizeOptionalString(params.name);
if (!name) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "session group name must not be empty"),
);
return;
}
let cwd = params.cwd;
const clientScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
if (cwd && !clientScopes.includes(ADMIN_SCOPE)) {
const containment = await resolveWorkspacePathContainment(cwd, context.getRuntimeConfig());
if (
!containment ||
!isWorkspacePathContainmentCurrent(containment, context.getRuntimeConfig())
) {
respond(
false,
undefined,
missingScopeErrorShape({ missingScope: ADMIN_SCOPE, requiredScopes: [ADMIN_SCOPE] }),
);
return;
}
cwd = containment.path;
}
sessionMutationAuthorization?.assertCurrent();
if (sessionMutationAuthorization) {
const currentTargets =
resolveSessionGroupMutationTargetsByName(context.getRuntimeConfig()).get(name) ?? [];
for (const target of currentTargets) {
sessionMutationAuthorization.assertTargetCurrent(target);
}
}
const defaults = updateSessionGroupDefaults(name, {
cwd,
worktree: params.worktree,
});
if (!defaults) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown session group: ${name}`),
);
return;
}
respond(
true,
{
ok: true,
defaults: filterMutableSessionGroupRecords({
cfg: context.getRuntimeConfig(),
client,
records: defaults,
}),
},
undefined,
);
emitSessionsChanged(context, { reason: "groups" });
},
"sessions.groups.delete": async ({ params, respond, context, sessionMutationAuthorization }) => {
if (
!assertValidParams(
@@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
@@ -72,3 +73,20 @@ export async function resolveWorkspacePathContainment(
.toSorted((left, right) => right.length - left.length)[0];
return workspaceRoot ? { path: requestedRealPath, workspaceRoot } : null;
}
/** Revalidates an async containment result against the current workspace configuration. */
export function isWorkspacePathContainmentCurrent(
containment: { path: string; workspaceRoot: string },
cfg: OpenClawConfig,
): boolean {
return listAgentIds(cfg).some((agentId) => {
try {
const currentRoot = fsSync.realpathSync(resolveAgentWorkspaceDir(cfg, agentId));
return (
currentRoot === containment.workspaceRoot && isPathInside(currentRoot, containment.path)
);
} catch {
return false;
}
});
}
+104
View File
@@ -40,6 +40,7 @@ import {
attachGatewayLocalUserIngress,
prepareGatewayLocalUserIngress,
} from "./local-user-ingress.js";
import { listSessionGroups } from "./session-groups.js";
import { resolveGatewaySessionStoreTarget } from "./session-utils.js";
import {
agentCommandMock,
@@ -194,6 +195,109 @@ function describeSessionStoreForensics(storePath: string): string {
return JSON.stringify({ storeDir, files, resolvedTargetPath: target.path, rows });
}
test("sessions.create assigns and registers its requested group", async () => {
const { storePath } = await createSessionStoreDir();
const broadcastToConnIds = vi.fn();
const created = await directSessionReq<{ key: string }>(
"sessions.create",
{
agentId: "main",
category: "Client work",
},
{
context: {
broadcastToConnIds,
getSessionEventSubscriberConnIds: () => new Set(["conn-1"]),
},
},
);
expect(created.ok).toBe(true);
const key = requireNonEmptyString(created.payload?.key, "grouped session key");
expect(loadSessionEntry({ sessionKey: key, storePath })?.category).toBe("Client work");
expect(listSessionGroups().map((group) => group.name)).toContain("Client work");
expect(broadcastToConnIds).toHaveBeenCalledWith(
"sessions.changed",
expect.objectContaining({ reason: "groups" }),
new Set(["conn-1"]),
{ dropIfSlow: true },
);
});
test("sessions.create carries keyed adoption authorization through the durable commit", async () => {
const { storePath } = await createSessionStoreDir();
const key = "agent:main:dashboard:categorized-adoption";
await writeSessionStore({
entries: {
[key]: sessionStoreEntry("session-categorized-adoption", { category: "Personal" }),
},
});
const assertCurrent = vi.fn();
const adopted = await directSessionReq(
"sessions.create",
{ agentId: "main", key, category: "Projects" },
{
sessionMutationAuthorization: {
assertCurrent,
assertTargetCurrent: vi.fn(),
},
},
);
expect(adopted.ok).toBe(true);
expect(assertCurrent).toHaveBeenCalled();
expect(loadSessionEntry({ sessionKey: key, storePath })?.category).toBe("Projects");
});
test("sessions.create registers a category only after the session commit succeeds", async () => {
await createSessionStoreDir();
const category = "Deferred category";
let validations = 0;
const failed = await directSessionReq(
"sessions.create",
{ agentId: "main", category, key: "agent:main:dashboard:failed-category-create" },
{
context: {
validateAgentRuntimeApprovalAuthority: () => ++validations < 3,
},
client: {
connect: { scopes: ["operator.write"] },
internal: {
agentRuntimeIdentity: {
kind: "agentRuntime",
agentId: "main",
sessionKey: "agent:main:main",
},
},
} as never,
},
);
expect(failed.ok).toBe(false);
expect(listSessionGroups().map((group) => group.name)).not.toContain(category);
const broadcastToConnIds = vi.fn();
const created = await directSessionReq(
"sessions.create",
{ agentId: "main", category, key: "agent:main:dashboard:successful-category-create" },
{
context: {
broadcastToConnIds,
getSessionEventSubscriberConnIds: () => new Set(["conn-1"]),
},
},
);
expect(created.ok).toBe(true);
expect(listSessionGroups().filter((group) => group.name === category)).toHaveLength(1);
expect(
broadcastToConnIds.mock.calls.filter(([, payload]) => payload?.reason === "groups"),
).toHaveLength(1);
});
test("concurrent sessions.create requests adopt one canonical keyed session", async () => {
const { storePath } = await createSessionStoreDir();
const key = "agent:main:dashboard:concurrent-keyed-session";
+16 -1
View File
@@ -20,7 +20,8 @@ import {
getSessionsHandlers,
} from "./test/server-sessions.test-helpers.js";
const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness();
const { createSessionStoreDir, defaultAgentWorkspace, openClient } =
setupGatewaySessionsTestHarness();
async function seedLinearTranscript(params: {
contents: string[];
@@ -853,6 +854,20 @@ test("write-scoped operators manage chat organization but not admin session sett
expect(reordered.payload?.groups.map((group) => group.name)).toEqual(["Someday", "Travel"]);
expect(reordered.payload?.sectionOrder).toEqual(["work", "category:Travel", "ungrouped"]);
const defaultsUpdated = await rpcReq<{
ok: true;
defaults: Array<{ name: string; cwd?: string; worktree?: boolean }>;
}>(ws, "sessions.groups.update", {
name: "Travel",
cwd: defaultAgentWorkspace,
worktree: true,
});
expect(defaultsUpdated.ok).toBe(true);
expect(defaultsUpdated.payload?.defaults).toContainEqual({
name: "Travel",
cwd: defaultAgentWorkspace,
worktree: true,
});
const renamedGroup = await rpcReq<{
ok: true;
sectionOrder: string[];
+2
View File
@@ -228,6 +228,7 @@ export async function createGatewaySession(params: {
key?: string;
agentId?: string;
label?: string;
category?: string;
/** Trusted model-generated title, persisted with a newly created dashboard session. */
generatedDisplayName?: string;
model?: string;
@@ -968,6 +969,7 @@ export async function createGatewaySession(params: {
patch: {
key: target.canonicalKey,
label: normalizeOptionalString(params.label),
category: normalizeOptionalString(params.category),
model: catalogModel ?? requestedModel,
thinkingLevel: requestedThinkingLevel,
},
@@ -0,0 +1,45 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { GatewayClient } from "./server-methods/types.js";
import { resolveSessionGroupMutationTargetsByName } from "./session-group-mutation-targets.js";
import {
authorizeSessionSharingTarget,
isGatewayAdmin,
resolveSessionSharingTarget,
} from "./session-sharing.js";
import type {
GatewaySessionStoreCache,
GatewaySessionStoreDiscoveryCache,
} from "./session-utils-store-lookup.js";
/** Keep shared group settings visible only where every member session is mutable. */
export function filterMutableSessionGroupRecords<T extends { name: string }>(params: {
cfg: OpenClawConfig;
client: GatewayClient | null;
records: readonly T[];
}): T[] {
const allowed = new Set(params.records.map((record) => record.name));
if (isGatewayAdmin(params.client)) {
return [...params.records];
}
const storeCache: GatewaySessionStoreCache = new Map();
const targetDiscoveryCache: GatewaySessionStoreDiscoveryCache = new Map();
for (const [name, targetRefs] of resolveSessionGroupMutationTargetsByName(params.cfg)) {
if (!allowed.has(name)) {
continue;
}
for (const targetRef of targetRefs) {
const target = resolveSessionSharingTarget({
cfg: params.cfg,
sessionKey: targetRef.sessionKey,
agentId: targetRef.agentId,
storeCache,
targetDiscoveryCache,
});
if (target && authorizeSessionSharingTarget({ client: params.client, target })) {
allowed.delete(name);
break;
}
}
}
return params.records.filter((record) => allowed.has(record.name));
}
@@ -0,0 +1,26 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions.js";
import { listSessionEntriesCore } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SessionMutationTarget } from "./session-sharing-target-input.js";
export function resolveSessionGroupMutationTargetsByName(
cfg: OpenClawConfig,
): Map<string, SessionMutationTarget[]> {
const targetsByName = new Map<string, SessionMutationTarget[]>();
for (const storeTarget of resolveAllAgentSessionStoreTargetsSync(cfg)) {
for (const { sessionKey, entry } of listSessionEntriesCore({
agentId: storeTarget.agentId,
storePath: storeTarget.storePath,
})) {
const groupName = normalizeOptionalString(entry.category);
if (!groupName) {
continue;
}
const targets = targetsByName.get(groupName) ?? [];
targets.push({ sessionKey, agentId: storeTarget.agentId });
targetsByName.set(groupName, targets);
}
}
return targetsByName;
}
+129
View File
@@ -14,10 +14,12 @@ import {
import {
deleteSessionGroup,
ensureSessionGroupRegistered,
listSessionGroupDefaults,
listSidebarSectionOrder,
listSessionGroups,
putSessionGroups,
renameSessionGroup,
updateSessionGroupDefaults,
} from "./session-groups.js";
describe("session groups catalog", () => {
@@ -117,6 +119,133 @@ describe("session groups catalog", () => {
).toEqual({ name: "sidebar_sections" });
});
it("keeps catalog reads and reorders schema-read-only until defaults are used", async () => {
const databasePath = openOpenClawStateDatabase({ env }).path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const legacy = new DatabaseSync(databasePath);
legacy.exec("ALTER TABLE session_groups DROP COLUMN cwd;");
legacy.exec("ALTER TABLE session_groups DROP COLUMN worktree;");
legacy
.prepare("INSERT INTO session_groups (name, position, created_at) VALUES (?, ?, ?)")
.run("Client", 0, Date.now());
legacy.close();
const beforeFeatureUse = openOpenClawStateDatabase({ env })
.db.prepare("PRAGMA table_info(session_groups)")
.all() as Array<{ name: string }>;
expect(beforeFeatureUse.map((column) => column.name)).not.toEqual(
expect.arrayContaining(["cwd", "worktree"]),
);
expect(listSessionGroups(env)).toEqual([{ name: "Client", position: 0 }]);
expect(putSessionGroups(["Client"], undefined, env)).toEqual([{ name: "Client", position: 0 }]);
const afterCatalogUse = openOpenClawStateDatabase({ env })
.db.prepare("PRAGMA table_info(session_groups)")
.all() as Array<{ name: string }>;
expect(afterCatalogUse.map((column) => column.name)).not.toEqual(
expect.arrayContaining(["cwd", "worktree"]),
);
expect(listSessionGroupDefaults(env)).toEqual([{ name: "Client" }]);
await renameSessionGroup({ cfg, name: "Client", to: "Customer", env });
expect(listSessionGroupDefaults(env)).toEqual([{ name: "Customer" }]);
const afterDefaultsReadAndRename = openOpenClawStateDatabase({ env })
.db.prepare("PRAGMA table_info(session_groups)")
.all() as Array<{ dflt_value: unknown; name: string; notnull: number; type: string }>;
expect(afterDefaultsReadAndRename.map((column) => column.name)).not.toEqual(
expect.arrayContaining(["cwd", "worktree"]),
);
expect(
updateSessionGroupDefaults("Customer", { cwd: "/repos/customer", worktree: true }, env),
).toContainEqual({ name: "Customer", cwd: "/repos/customer", worktree: true });
const columns = openOpenClawStateDatabase({ env })
.db.prepare("PRAGMA table_info(session_groups)")
.all() as Array<{ dflt_value: unknown; name: string; notnull: number; type: string }>;
expect(columns.filter((column) => column.name === "cwd" || column.name === "worktree")).toEqual(
[
expect.objectContaining({ dflt_value: null, name: "cwd", notnull: 0, type: "TEXT" }),
expect.objectContaining({
dflt_value: null,
name: "worktree",
notnull: 0,
type: "INTEGER",
}),
],
);
});
it("preserves New Session defaults through reorder and rename", async () => {
putSessionGroups(["Client", "Other"], undefined, env);
expect(
updateSessionGroupDefaults("Client", { cwd: "/repos/client", worktree: true }, env),
).toContainEqual({
name: "Client",
cwd: "/repos/client",
worktree: true,
});
putSessionGroups(["Other", "Client"], undefined, env);
await renameSessionGroup({ cfg, name: "Client", to: "Customer", env });
expect(listSessionGroupDefaults(env)).toContainEqual({
name: "Customer",
cwd: "/repos/client",
worktree: true,
});
});
it("rejects renaming an unknown group after defaults schema activation", async () => {
putSessionGroups(["Client"], undefined, env);
updateSessionGroupDefaults("Client", { cwd: "/repos/client", worktree: true }, env);
await expect(renameSessionGroup({ cfg, name: "Missing", to: "Other", env })).rejects.toThrow(
"unknown session group: Missing",
);
expect(listSessionGroups(env)).toEqual([{ name: "Client", position: 0 }]);
expect(listSessionGroupDefaults(env)).toEqual([
{ name: "Client", cwd: "/repos/client", worktree: true },
]);
});
it("clears New Session defaults without removing the group", () => {
putSessionGroups(["Client"], undefined, env);
updateSessionGroupDefaults("Client", { cwd: "/repos/client", worktree: true }, env);
expect(updateSessionGroupDefaults("Client", { cwd: null, worktree: false }, env)).toEqual([
{ name: "Client", worktree: false },
]);
});
it("does not recreate a deleted group from a stale defaults update", async () => {
putSessionGroups(["Client"], undefined, env);
await deleteSessionGroup({ cfg, name: "Client", env });
expect(
updateSessionGroupDefaults("Client", { cwd: "/repos/client", worktree: true }, env),
).toBeNull();
expect(listSessionGroups(env)).toEqual([]);
});
it("keeps a stale defaults update schema-free on a legacy database", () => {
const databasePath = openOpenClawStateDatabase({ env }).path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const legacy = new DatabaseSync(databasePath);
legacy.exec("ALTER TABLE session_groups DROP COLUMN cwd;");
legacy.exec("ALTER TABLE session_groups DROP COLUMN worktree;");
legacy.close();
expect(
updateSessionGroupDefaults("Missing", { cwd: "/repos/missing", worktree: true }, env),
).toBeNull();
const columns = openOpenClawStateDatabase({ env })
.db.prepare("PRAGMA table_info(session_groups)")
.all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).not.toEqual(
expect.arrayContaining(["cwd", "worktree"]),
);
});
it("absorbs ad-hoc categories at the end of the catalog", () => {
putSessionGroups(["Work"], undefined, env);
ensureSessionGroupRegistered("Travel", env);
+143 -19
View File
@@ -7,6 +7,7 @@ import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions.js";
import { applySessionEntryReplacements } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import { ensureColumn, tableHasColumn } from "../state/openclaw-state-db-schema-helpers.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
@@ -18,14 +19,31 @@ import { SessionMutationAuthorizationChangedError } from "./session-sharing.js";
// statements; a bare transaction would open the default state DB while the
// SQL hits the override, losing atomicity under OPENCLAW_STATE_DIR overrides.
type SessionGroupRecord = { name: string; position: number };
type SessionGroupRecord = {
name: string;
position: number;
};
type SessionGroupDefaultsRecord = {
name: string;
cwd?: string;
worktree?: boolean;
};
type SessionGroupsDatabase = Pick<
OpenClawStateKyselyDatabase,
"session_groups" | "sidebar_sections"
>;
export class SessionGroupNotFoundError extends Error {
constructor(name: string) {
super(`unknown session group: ${name}`);
this.name = "SessionGroupNotFoundError";
}
}
const ensuredSidebarSectionDatabases = new WeakSet<DatabaseSync>();
const ensuredSessionGroupDefaultsDatabases = new WeakSet<DatabaseSync>();
const SIDEBAR_SECTIONS_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS sidebar_sections (
section_id TEXT NOT NULL PRIMARY KEY,
@@ -57,6 +75,12 @@ function ensureSidebarSectionsSchema(env: NodeJS.ProcessEnv): void {
ensuredSidebarSectionDatabases.add(database.db);
}
function hasSessionGroupDefaultsSchema(db: DatabaseSync): boolean {
return (
tableHasColumn(db, "session_groups", "cwd") && tableHasColumn(db, "session_groups", "worktree")
);
}
function normalizeGroupNames(names: readonly string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
@@ -110,10 +134,33 @@ export function listSessionGroups(env: NodeJS.ProcessEnv = process.env): Session
.select(["name", "position"])
.orderBy("position", "asc")
.orderBy("name", "asc");
return executeSqliteQuerySync(db, query).rows.map((row) => ({
name: row.name,
position: row.position,
}));
return executeSqliteQuerySync(db, query).rows;
}
export function listSessionGroupDefaults(
env: NodeJS.ProcessEnv = process.env,
): SessionGroupDefaultsRecord[] {
const db = dbFor(env);
if (!hasSessionGroupDefaultsSchema(db)) {
return listSessionGroups(env).map(({ name }) => ({ name }));
}
return executeSqliteQuerySync(
db,
kyselyFor(db)
.selectFrom("session_groups")
.select(["name", "cwd", "worktree"])
.orderBy("position", "asc")
.orderBy("name", "asc"),
).rows.map((row) => {
const group: SessionGroupDefaultsRecord = { name: row.name };
if (row.cwd) {
group.cwd = row.cwd;
}
if (row.worktree !== null) {
group.worktree = row.worktree === 1;
}
return group;
});
}
export function listSidebarSectionOrder(env: NodeJS.ProcessEnv = process.env): string[] {
@@ -149,17 +196,25 @@ export function putSessionGroups(
executeSqliteQuerySync(
db,
kysely.selectFrom("session_groups").select(["name", "created_at"]),
).rows.map((row) => [row.name, row.created_at]),
).rows.map((row) => [row.name, row]),
);
executeSqliteQuerySync(
db,
normalized.length === 0
? kysely.deleteFrom("session_groups")
: kysely.deleteFrom("session_groups").where("name", "not in", normalized),
);
executeSqliteQuerySync(db, kysely.deleteFrom("session_groups"));
normalized.forEach((name, position) => {
const prior = existing.get(name);
executeSqliteQuerySync(
db,
kysely.insertInto("session_groups").values({
name,
position,
created_at: existing.get(name) ?? now,
}),
prior
? kysely.updateTable("session_groups").set({ position }).where("name", "=", name)
: kysely.insertInto("session_groups").values({
name,
position,
created_at: now,
}),
);
});
if (normalizedSectionOrder) {
@@ -176,7 +231,7 @@ export function putSessionGroups(
},
{ env },
);
return normalized.map((name, position) => ({ name, position }));
return listSessionGroups(env);
}
/**
@@ -226,10 +281,20 @@ function renameCatalogEntry(from: string, to: string, env: NodeJS.ProcessEnv): v
runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = kyselyFor(db);
const hasDefaults = hasSessionGroupDefaultsSchema(db);
const source = executeSqliteQuerySync(
db,
kysely.selectFrom("session_groups").selectAll().where("name", "=", from).limit(1),
hasDefaults
? kysely.selectFrom("session_groups").selectAll().where("name", "=", from).limit(1)
: kysely
.selectFrom("session_groups")
.select(["name", "position", "created_at"])
.where("name", "=", from)
.limit(1),
).rows[0];
if (!source) {
throw new SessionGroupNotFoundError(from);
}
const targetExists = executeSqliteQuerySync(
db,
kysely.selectFrom("session_groups").select("name").where("name", "=", to).limit(1),
@@ -264,19 +329,78 @@ function renameCatalogEntry(from: string, to: string, env: NodeJS.ProcessEnv): v
// Rename into an existing group merges memberships; keep its catalog row.
return;
}
const base = {
name: to,
position: source.position,
created_at: source.created_at,
};
executeSqliteQuerySync(
db,
kysely.insertInto("session_groups").values({
name: to,
position: source?.position ?? 0,
created_at: source?.created_at ?? Date.now(),
}),
kysely.insertInto("session_groups").values(
hasDefaults
? {
...base,
cwd: "cwd" in source && typeof source.cwd === "string" ? source.cwd : null,
worktree:
"worktree" in source && typeof source.worktree === "number"
? source.worktree
: null,
}
: base,
),
);
},
{ env },
);
}
export function updateSessionGroupDefaults(
name: string,
defaults: { cwd: string | null; worktree: boolean },
env: NodeJS.ProcessEnv = process.env,
): SessionGroupDefaultsRecord[] | null {
const normalized = normalizeOptionalString(name);
if (!normalized) {
throw new Error("group defaults update requires a non-empty name");
}
const database = openOpenClawStateDatabase({ env });
let updated = false;
let defaultsSchemaEnsured = false;
runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = kyselyFor(db);
const existing = executeSqliteQuerySync(
db,
kysely.selectFrom("session_groups").select("name").where("name", "=", normalized).limit(1),
).rows[0];
if (!existing) {
return;
}
if (!ensuredSessionGroupDefaultsDatabases.has(db)) {
ensureColumn(db, "session_groups", "cwd TEXT");
ensureColumn(db, "session_groups", "worktree INTEGER");
defaultsSchemaEnsured = true;
}
const result = executeSqliteQuerySync(
db,
kysely
.updateTable("session_groups")
.set({
cwd: normalizeOptionalString(defaults.cwd) ?? null,
worktree: defaults.worktree ? 1 : 0,
})
.where("name", "=", normalized),
);
updated = result.numAffectedRows === 1n;
},
{ env },
);
if (defaultsSchemaEnsured) {
ensuredSessionGroupDefaultsDatabases.add(database.db);
}
return updated ? listSessionGroupDefaults(env) : null;
}
/**
* Bulk-updates member session categories across every agent store without
* bumping updatedAt: group maintenance must not reshuffle recency ordering.
+144
View File
@@ -3,7 +3,14 @@ import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js";
import { addSessionMember } from "../config/sessions/session-sharing-store.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { sessionGroupHandlers } from "./server-methods/sessions-groups.js";
import type { GatewayClient } from "./server-methods/types.js";
import type { GatewayRequestContext, RespondFn } from "./server-methods/types.js";
import {
listSessionGroupDefaults,
putSessionGroups,
updateSessionGroupDefaults,
} from "./session-groups.js";
import {
allowedSessionVisibilities,
authorizeIncognitoSessionTarget,
@@ -12,6 +19,7 @@ import {
createSessionListEntryFilter,
resolveSessionSharingRole,
resolveSessionVisibility,
SessionMutationAuthorizationChangedError,
} from "./session-sharing.js";
afterEach(() => closeOpenClawAgentDatabasesForTest());
@@ -88,6 +96,142 @@ function target(createdActor?: { type: "human"; id: string; label?: string }): S
}
describe("session sharing policy", () => {
it("requires participation before sessions.create can adopt a categorized key", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
const sessionKey = "agent:main:dashboard:categorized-adoption";
await upsertSessionEntryCore(
{ agentId: "main", sessionKey },
{
sessionId: "session-categorized-adoption",
updatedAt: 1,
visibility: "read-only",
category: "Personal",
createdActor: { type: "human", id: "owner@example.com" },
},
);
const authorization = resolveSessionMutationAuthorization({
client: client({ user: "viewer@example.com" }),
method: "sessions.create",
requestParams: { key: sessionKey, category: "Projects" },
context: { getRuntimeConfig: () => ({}) } as GatewayRequestContext,
});
expect(authorization.error).toMatchObject({
details: { code: "SESSION_PARTICIPATION_REQUIRED" },
});
});
});
it("rechecks group members before committing a defaults update", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
putSessionGroups(["Race"]);
updateSessionGroupDefaults("Race", { cwd: "/repos/race", worktree: true });
const viewer = client({ user: "viewer@example.com" });
const context = {
getRuntimeConfig: () => ({}),
getSessionEventSubscriberConnIds: () => new Set<string>(),
} as unknown as GatewayRequestContext;
const authorization = resolveSessionMutationAuthorization({
client: viewer,
method: "sessions.groups.update",
requestParams: { name: " Race ", cwd: null, worktree: false },
context,
});
expect(authorization.error).toBeNull();
await upsertSessionEntryCore(
{ agentId: "main", sessionKey: "agent:main:late-restricted-member" },
{
sessionId: "session-late-restricted-member",
updatedAt: 1,
visibility: "read-only",
category: "Race",
createdActor: { type: "human", id: "owner@example.com" },
},
);
await expect(
sessionGroupHandlers["sessions.groups.update"]?.({
params: { name: " Race ", cwd: null, worktree: false },
client: viewer,
context,
sessionMutationAuthorization: authorization.authorization,
respond: () => undefined,
} as never),
).rejects.toBeInstanceOf(SessionMutationAuthorizationChangedError);
expect(listSessionGroupDefaults()).toEqual([
{ name: "Race", cwd: "/repos/race", worktree: true },
]);
});
});
it("filters group defaults and blocks updates for sessions the caller cannot mutate", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
putSessionGroups(["Projects", "Personal"]);
updateSessionGroupDefaults("Projects", { cwd: "/repos/projects", worktree: true });
updateSessionGroupDefaults("Personal", { cwd: "/repos/personal", worktree: false });
await upsertSessionEntryCore(
{ agentId: "main", sessionKey: "agent:main:restricted-project" },
{
sessionId: "session-restricted-project",
updatedAt: 1,
visibility: "read-only",
category: "Projects",
createdActor: { type: "human", id: "owner@example.com" },
},
);
const viewer = client({ user: "viewer@example.com" });
const context = {
getRuntimeConfig: () => ({}),
getSessionEventSubscriberConnIds: () => new Set<string>(),
} as unknown as GatewayRequestContext;
expect(
resolveSessionMutationAuthorization({
client: viewer,
method: "sessions.groups.update",
requestParams: { name: "Projects", cwd: null, worktree: false },
context,
}).error,
).toMatchObject({ details: { code: "SESSION_PARTICIPATION_REQUIRED" } });
const responses: Parameters<RespondFn>[] = [];
await sessionGroupHandlers["sessions.groups.defaults"]?.({
params: {},
client: viewer,
context,
respond: (...response: Parameters<RespondFn>) => responses.push(response),
} as never);
expect(responses).toEqual([
[
true,
{ defaults: [{ name: "Personal", cwd: "/repos/personal", worktree: false }] },
undefined,
],
]);
const personalAuthorization = resolveSessionMutationAuthorization({
client: viewer,
method: "sessions.groups.update",
requestParams: { name: "Personal", cwd: null, worktree: false },
context,
});
expect(personalAuthorization.error).toBeNull();
const updateResponses: Parameters<RespondFn>[] = [];
await sessionGroupHandlers["sessions.groups.update"]?.({
params: { name: "Personal", cwd: null, worktree: false },
client: viewer,
context,
sessionMutationAuthorization: personalAuthorization.authorization,
respond: (...response: Parameters<RespondFn>) => updateResponses.push(response),
} as never);
expect(updateResponses).toEqual([
[true, { ok: true, defaults: [{ name: "Personal", worktree: false }] }, undefined],
]);
});
});
it("reports an incognito denial against the caller's requested key", () => {
const hiddenTarget = {
...target({ type: "human", id: "owner@example.com" }),
+12 -18
View File
@@ -7,12 +7,7 @@ import {
type SessionVisibility,
} from "../../packages/gateway-protocol/src/index.js";
import { AgentSelectionRequiredError } from "../agents/agent-scope.js";
import {
isSessionMember,
resolveAllAgentSessionStoreTargetsSync,
type SessionEntry,
} from "../config/sessions.js";
import { listSessionEntriesCore } from "../config/sessions/session-accessor.js";
import { isSessionMember, type SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isIncognitoSessionKey } from "../routing/session-key.js";
import { verifyBoardViewTicket } from "./board-view-ticket.js";
@@ -23,6 +18,7 @@ import type {
SessionMutationAuthorization,
} from "./server-methods/types.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { resolveSessionGroupMutationTargetsByName } from "./session-group-mutation-targets.js";
import {
invalidateSessionSharingSnapshot,
loadCachedSessionSharingSnapshot,
@@ -284,6 +280,7 @@ const SESSION_KEY_PARAM_BY_METHOD = new Map<string, "key" | "sessionKey">([
["sessions.compaction.branch", "key"],
["sessions.compaction.restore", "key"],
["sessions.compact", "key"],
["sessions.create", "key"],
["sessions.delete", "key"],
["sessions.dispatch", "key"],
["sessions.files.set", "sessionKey"],
@@ -320,6 +317,7 @@ const REQUIRED_SESSION_TARGET_METHODS = new Set([
"sessions.fork",
"sessions.groups.delete",
"sessions.groups.rename",
"sessions.groups.update",
"sessions.patch",
"sessions.pluginPatch",
"sessions.reclaim",
@@ -334,17 +332,9 @@ function resolveSessionGroupMutationTargets(params: {
requestParams: unknown;
}): SessionMutationTarget[] | undefined {
const groupName = readStringParam(params.requestParams, "name");
if (!groupName) {
return undefined;
}
return resolveAllAgentSessionStoreTargetsSync(params.getCfg()).flatMap((storeTarget) =>
listSessionEntriesCore({
agentId: storeTarget.agentId,
storePath: storeTarget.storePath,
}).flatMap(({ sessionKey, entry }) =>
entry.category?.trim() === groupName ? [{ sessionKey, agentId: storeTarget.agentId }] : [],
),
);
return groupName
? (resolveSessionGroupMutationTargetsByName(params.getCfg()).get(groupName) ?? [])
: undefined;
}
function resolveApprovalSessionTarget(
@@ -393,7 +383,11 @@ function resolveSessionMutationTargets(params: {
})
: undefined;
}
if (params.method === "sessions.groups.rename" || params.method === "sessions.groups.delete") {
if (
params.method === "sessions.groups.rename" ||
params.method === "sessions.groups.delete" ||
params.method === "sessions.groups.update"
) {
return resolveSessionGroupMutationTargets({
getCfg: params.getCfg,
requestParams: params.requestParams,
@@ -316,11 +316,13 @@ export function setupGatewaySessionsTestHarness() {
function createGatewaySessionsTestHarness(startServer: boolean) {
installGatewayTestHooks({ scope: "suite" });
const defaultAgentWorkspace = path.join(os.tmpdir(), "openclaw-gateway-test");
let harness: GatewayServerHarness | undefined;
let sharedSessionStoreDir: string | undefined;
let sessionStoreCaseSeq = 0;
beforeAll(async () => {
await fs.mkdir(defaultAgentWorkspace, { recursive: true });
if (startServer) {
const { startGatewayServerHarness } = await getGatewayServerHarnessModule();
harness = await startGatewayServerHarness();
@@ -535,6 +537,7 @@ function createGatewaySessionsTestHarness(startServer: boolean) {
createConfiguredGlobalAgentSessionStore,
createSessionStoreDir,
createSelectedGlobalSessionStore,
defaultAgentWorkspace,
getHarness: requireHarness,
openClient,
resetConfiguredGlobalAgentSessionStore,
@@ -667,14 +670,16 @@ export function expectNoSessionQueueCleanup() {
}
type SessionsHandlers = Awaited<ReturnType<typeof getSessionsHandlers>>;
type SessionsHandlerOptions = Parameters<SessionsHandlers[keyof SessionsHandlers]>[0];
export async function directSessionReq<TPayload = unknown>(
method: keyof SessionsHandlers,
params: Record<string, unknown>,
opts?: {
context?: Record<string, unknown>;
client?: Parameters<SessionsHandlers[keyof SessionsHandlers]>[0]["client"];
isWebchatConnect?: Parameters<SessionsHandlers[keyof SessionsHandlers]>[0]["isWebchatConnect"];
client?: SessionsHandlerOptions["client"];
isWebchatConnect?: SessionsHandlerOptions["isWebchatConnect"];
sessionMutationAuthorization?: SessionsHandlerOptions["sessionMutationAuthorization"];
coercePayload?: (payload: unknown) => TPayload;
},
): Promise<{ ok: boolean; payload?: TPayload; error?: { code?: string; message?: string } }> {
@@ -719,6 +724,7 @@ export async function directSessionReq<TPayload = unknown>(
} as never,
client: opts?.client ?? null,
isWebchatConnect: opts?.isWebchatConnect ?? (() => false),
sessionMutationAuthorization: opts?.sessionMutationAuthorization,
});
if (!result) {
throw new Error(`${method} did not respond`);
@@ -152,12 +152,13 @@ describe("OpenClaw database maintenance schema validation", () => {
}
});
it("keeps every registered same-version column bare, canonical, and ensured", () => {
expect(
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
({ columnName, tableName }) => `${tableName}.${columnName}`,
),
).toEqual(OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY.allowedMissingColumns);
it("keeps every same-version additive column bare and canonical", () => {
const additiveColumns = CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
({ columnName, tableName }) => `${tableName}.${columnName}`,
);
expect(OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY.allowedMissingColumns).toEqual(
additiveColumns,
);
expect(
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
({ columnName, dataType, tableName }) => `${tableName}.${columnName} ${dataType}`,
@@ -178,6 +179,8 @@ describe("OpenClaw database maintenance schema validation", () => {
"worker_session_placements.terminal_at_ms INTEGER",
"worktrees.run_end_cleanup_json TEXT",
"device_bootstrap_tokens.setup_id TEXT",
"session_groups.cwd TEXT",
"session_groups.worktree INTEGER",
"installed_plugin_index.workspace_dir TEXT",
"secret_store_entries.allowed_hosts TEXT",
]);
@@ -230,6 +230,21 @@ describe("OpenClaw database schema preflight", () => {
}
});
it("accepts first-use session group columns without requiring a startup write", async () => {
const databasePath = createExplicitStateDatabase(
OPENCLAW_STATE_SCHEMA_SQL.replace(
" created_at INTEGER NOT NULL,\n cwd TEXT,\n worktree INTEGER\n",
" created_at INTEGER NOT NULL\n",
),
);
await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toMatchObject({
status: "exact",
requiresWrite: false,
issues: [],
});
});
it("rejects an explicit preflight path with sidecars without touching it", async () => {
const databasePath = createExplicitStateDatabase();
const sqlite = requireNodeSqlite();
+9 -4
View File
@@ -37,6 +37,7 @@ import {
} from "./openclaw-state-ownership.js";
import {
getOpenClawStateRuntimeSchema,
isOpenClawStateFirstUseSchemaIssue,
isOpenClawStateStartupRepairableSchemaIssue,
OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY,
STATE_PERSISTENT_SCHEMA_COMPATIBILITY,
@@ -256,7 +257,9 @@ export async function preflightOpenClawStateDatabasePath(
OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY,
);
const blockingIssues = maintenanceIssues.filter(
(issue) => !isOpenClawStateStartupRepairableSchemaIssue(issue),
(issue) =>
!isOpenClawStateStartupRepairableSchemaIssue(issue) &&
!isOpenClawStateFirstUseSchemaIssue(issue),
);
if (blockingIssues.length > 0) {
return result("incompatible", { issues: deduplicateSchemaIssues(blockingIssues) });
@@ -267,7 +270,9 @@ export async function preflightOpenClawStateDatabasePath(
STATE_PERSISTENT_SCHEMA_COMPATIBILITY,
);
const projectedRuntimeBlockingIssues = projectedRuntimeIssues.filter(
(issue) => !isOpenClawStateStartupRepairableSchemaIssue(issue),
(issue) =>
!isOpenClawStateStartupRepairableSchemaIssue(issue) &&
!isOpenClawStateFirstUseSchemaIssue(issue),
);
if (projectedRuntimeBlockingIssues.length > 0) {
return result("incompatible", {
@@ -275,8 +280,8 @@ export async function preflightOpenClawStateDatabasePath(
});
}
const startupRepairableIssues = deduplicateSchemaIssues([
...maintenanceIssues,
...projectedRuntimeIssues,
...maintenanceIssues.filter(isOpenClawStateStartupRepairableSchemaIssue),
...projectedRuntimeIssues.filter(isOpenClawStateStartupRepairableSchemaIssue),
]);
return result(startupRepairableIssues.length > 0 ? "startup-repairable" : "exact", {
issues: startupRepairableIssues,
@@ -23,21 +23,28 @@ export const CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS = [
{ columnName: "terminal_at_ms", dataType: "INTEGER", tableName: "worker_session_placements" },
{ columnName: "run_end_cleanup_json", dataType: "TEXT", tableName: "worktrees" },
{ columnName: "setup_id", dataType: "TEXT", tableName: "device_bootstrap_tokens" },
{ columnName: "cwd", dataType: "TEXT", tableName: "session_groups" },
{ columnName: "worktree", dataType: "INTEGER", tableName: "session_groups" },
{ columnName: "workspace_dir", dataType: "TEXT", tableName: "installed_plugin_index" },
{ columnName: "allowed_hosts", dataType: "TEXT", tableName: "secret_store_entries" },
] as const satisfies readonly LazyAdditiveStateColumnDefinition[];
// Most same-version columns repair during a writable shared-state open. Setup
// correlation is different: unrelated opens and generic bootstrap credentials
// must leave older databases untouched until setup pairing first uses it.
function isFirstUseAdditiveStateColumn({
columnName,
tableName,
}: LazyAdditiveStateColumnDefinition): boolean {
return (
(tableName === "device_bootstrap_tokens" && columnName === "setup_id") ||
(tableName === "session_groups" && (columnName === "cwd" || columnName === "worktree"))
);
}
// Most same-version columns repair during a writable shared-state open. These
// feature-owned columns stay absent until setup or group defaults first uses them.
export const CLAW_STARTUP_ADDITIVE_STATE_COLUMN_DEFINITIONS =
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.filter(
({ columnName, tableName }) =>
tableName !== "device_bootstrap_tokens" || columnName !== "setup_id",
(definition) => !isFirstUseAdditiveStateColumn(definition),
);
export const CLAW_FIRST_USE_ADDITIVE_STATE_COLUMN_DEFINITIONS =
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.filter(
({ columnName, tableName }) =>
tableName === "device_bootstrap_tokens" && columnName === "setup_id",
);
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.filter(isFirstUseAdditiveStateColumn);
+2
View File
@@ -1144,8 +1144,10 @@ export interface SecretStoreEntries {
export interface SessionGroups {
created_at: number;
cwd: string | null;
name: string;
position: number;
worktree: number | null;
}
export interface SessionStateEvents {
@@ -25,6 +25,9 @@ const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINIT
const CLAW_FIRST_USE_ADDITIVE_STATE_COLUMNS = CLAW_FIRST_USE_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
({ columnName, tableName }) => `${tableName}.${columnName}`,
);
const CLAW_FIRST_USE_ADDITIVE_STATE_COLUMN_SET = new Set<string>(
CLAW_FIRST_USE_ADDITIVE_STATE_COLUMNS,
);
const CLAW_STARTUP_ADDITIVE_STATE_COLUMN_SET = new Set<string>(
CLAW_STARTUP_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
({ columnName, tableName }) => `${tableName}.${columnName}`,
@@ -131,3 +134,11 @@ export function isOpenClawStateStartupRepairableSchemaIssue(issue: SqliteSchemaI
getOpenClawStateCanonicalNamedIndexSet().has(issue.objectName)
);
}
/** Identify compatible schema differences repaired only by their feature owner. */
export function isOpenClawStateFirstUseSchemaIssue(issue: SqliteSchemaIssue): boolean {
return (
issue.code === "missing-column" &&
CLAW_FIRST_USE_ADDITIVE_STATE_COLUMN_SET.has(issue.objectName)
);
}
+3 -1
View File
@@ -2002,7 +2002,9 @@ CREATE TABLE IF NOT EXISTS user_preferences (
CREATE TABLE IF NOT EXISTS session_groups (
name TEXT NOT NULL PRIMARY KEY,
position INTEGER NOT NULL,
created_at INTEGER NOT NULL
created_at INTEGER NOT NULL,
cwd TEXT,
worktree INTEGER
) STRICT;
-- Gateway-owned sidebar section layout. IDs are ungrouped, groups, work, or
@@ -194,6 +194,21 @@ function renderSessionSection(params: {
: nothing}
${group
? html`
<button
type="button"
class="sidebar-session-group-actions sidebar-new-session"
title=${newSessionAccess.allowed
? t("sessionsView.newSessionInGroup", { group })
: newSessionAccess.reason}
aria-label=${t("sessionsView.newSessionInGroup", { group })}
?disabled=${!newSessionAccess.allowed}
@click=${(event: MouseEvent) => {
event.stopPropagation();
host.openNewSession({ group });
}}
>
${icons.plus}
</button>
<button
type="button"
class="sidebar-session-group-actions"
@@ -19,7 +19,11 @@ import {
trackDropdownKeyboardDismissal,
} from "./web-awesome.ts";
type SidebarSessionGroupMenuAction = "rename-group" | "new-group" | "delete-group";
type SidebarSessionGroupMenuAction =
| "group-defaults"
| "rename-group"
| "new-group"
| "delete-group";
function renderSidebarMenuTrigger(position: { x: number; y: number }, label: string) {
return html`
@@ -87,6 +91,7 @@ export function renderSidebarSessionGroupMenu(params: {
menu: SidebarSessionGroupMenuState | null;
trigger: HTMLElement | null;
connected: boolean;
groupDefaultsUnavailable?: boolean;
actionDisabledReasons?: Partial<Record<SidebarSessionGroupMenuAction, string>>;
onAction: (action: SidebarSessionGroupMenuAction, group: string) => void;
onClose: (restoreFocus: boolean) => void;
@@ -109,7 +114,10 @@ export function renderSidebarSessionGroupMenu(params: {
event.preventDefault();
const value = event.detail.item.value;
if (
(value === "rename-group" || value === "new-group" || value === "delete-group") &&
(value === "group-defaults" ||
value === "rename-group" ||
value === "new-group" ||
value === "delete-group") &&
!params.actionDisabledReasons?.[value]
) {
params.onAction(value, menu.group);
@@ -121,6 +129,20 @@ export function renderSidebarSessionGroupMenu(params: {
params.onClose(consumeDropdownKeyboardDismissal(event))}
>
${renderSidebarMenuTrigger(menu, t("sessionsView.groupMenu", { group: menu.group }))}
<wa-dropdown-item
class="session-menu__item"
value="group-defaults"
?disabled=${!params.connected ||
Boolean(params.actionDisabledReasons?.["group-defaults"])}
title=${params.actionDisabledReasons?.["group-defaults"] ?? nothing}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.settings}</span>
<span class="session-menu__text"
>${params.groupDefaultsUnavailable
? `${t("common.retry")}: ${t("sessionsView.groupDefaultsMenu")}`
: t("sessionsView.groupDefaultsMenu")}</span
>
</wa-dropdown-item>
<wa-dropdown-item
class="session-menu__item"
value="rename-group"
@@ -116,7 +116,7 @@ export interface SessionListHost {
startSidebarSectionDrag(sectionId: string): void;
finishSidebarSectionDrag(): void;
toggleSection(sectionId: string): void;
openNewSession(): void;
openNewSession(target?: NewSessionTarget): void;
readNewSessionAccess(): import("../lib/session-method-access.ts").SessionMethodAccess;
readSessionMutationAccess(request: {
method: string;
+24 -2
View File
@@ -1,5 +1,6 @@
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
import { state } from "lit/decorators.js";
import type { FsListDirResult } from "../../../packages/gateway-protocol/src/index.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import { isSessionRouteId, pathForRoute } from "../app-route-paths.ts";
import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts";
@@ -19,6 +20,7 @@ import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-gro
import { showToast } from "../lib/toast.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { SETTINGS_SEARCH_TARGETS } from "../pages/config/settings-targets.ts";
import type { NewSessionTarget } from "../pages/new-session/location.ts";
import { sidebarPluginTabs } from "./app-sidebar-nav-menus.ts";
import {
renderAppSidebarAttention,
@@ -75,6 +77,26 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
override readonly sessionOrganizer = new SessionOrganizerController(this);
override readonly sidebarMenus = new SidebarMenusController(this);
sessionGroupDefaults(name: string) {
if (this.context?.sessions.groupsStatus() !== "ready") {
return null;
}
const group = this.context?.sessions.state.groupSettings.find((entry) => entry.name === name);
return group ? { cwd: group.cwd ?? "", worktree: group.worktree === true } : null;
}
async listSessionGroupFolders(path?: string): Promise<FsListDirResult> {
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
throw new Error(t("sessionsView.groupDefaultsStale"));
}
const result = await scope.client.request<FsListDirResult>("fs.listDir", path ? { path } : {});
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
throw new Error(t("sessionsView.groupDefaultsStale"));
}
return result;
}
// Lazy: the controller pulls core token-suppression modules that must stay
// out of the startup chunk (QA smoke startup-JS budget). It loads on the
// first update with the preference enabled; earlier events are safely
@@ -334,8 +356,8 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
this.sessionOrganizer.handleSessionListDrop(event);
}
openNewSession(): void {
this.requestOpenNewSession(this.expandedAgentId());
openNewSession(target?: NewSessionTarget): void {
this.requestOpenNewSession(this.expandedAgentId(), target);
}
setVisibleSessionLimit(sectionId: string, limit: number): void {
@@ -0,0 +1,279 @@
import { readMissingScopeError } from "@openclaw/gateway-client/browser";
import { html, nothing, render } from "lit";
import type { FsListDirResult } from "../../../packages/gateway-protocol/src/index.js";
import { t } from "../i18n/index.ts";
import { renderSessionMenuItem } from "../pages/new-session/cloud-target.ts";
import type { BrowserTarget } from "../pages/new-session/discovery.ts";
import { folderDisplayName, isAbsolutePath } from "../pages/new-session/path.ts";
import { renderPlaceBrowser } from "../pages/new-session/place-browser.ts";
import "../styles/new-session.css";
import { icons } from "./icons.ts";
import "./modal-dialog.ts";
import "./web-awesome-popover.ts";
export type SessionGroupDefaults = { cwd: string; worktree: boolean };
type Options = {
group: string;
defaults: SessionGroupDefaults;
listDirectory: (path?: string) => Promise<FsListDirResult>;
submit: (defaults: SessionGroupDefaults) => Promise<string | null>;
};
let active = false;
export function showSessionGroupDefaultsDialog(options: Options): Promise<void> {
if (active) {
return Promise.resolve();
}
active = true;
const host = document.createElement("div");
document.body.append(host);
return new Promise<void>((resolve) => {
let cwd = options.defaults.cwd;
let submitting = false;
let failure: string | null = null;
let browserVisible = false;
let browserLoading = false;
let browserError: string | null = null;
let browserListing: FsListDirResult | null = null;
let browserPathDraft = "";
let browserRequestToken = 0;
const browserTarget: BrowserTarget = { nodeId: "", label: t("newSession.gateway") };
const finish = () => {
browserRequestToken += 1;
render(nothing, host);
host.remove();
active = false;
resolve();
};
const handleSubmit = async (event: Event) => {
event.preventDefault();
if (submitting) {
return;
}
const form = event.currentTarget as HTMLFormElement;
const data = new FormData(form);
submitting = true;
failure = null;
paint();
try {
failure = await options.submit({
cwd: cwd.trim(),
worktree: data.get("mode") === "worktree",
});
} catch (error) {
failure = String(error);
}
if (!failure) {
finish();
return;
}
submitting = false;
paint();
};
const closePicker = () => {
const picker = host.querySelector<HTMLElement & { open: boolean }>(
"wa-popover.session-group-defaults__folder-popover",
);
if (picker) {
picker.open = false;
}
};
const showPickerRoot = () => {
browserRequestToken += 1;
browserVisible = false;
browserLoading = false;
browserError = null;
browserListing = null;
browserPathDraft = "";
paint();
};
const applyFolder = (path: string) => {
cwd = path.trim();
showPickerRoot();
closePicker();
};
const loadDirectory = async (path?: string) => {
const requestToken = ++browserRequestToken;
const requestedPath = path?.trim() || undefined;
browserLoading = true;
browserError = null;
browserListing = null;
browserPathDraft = requestedPath ?? "";
paint();
try {
const listing = await options.listDirectory(requestedPath);
if (requestToken !== browserRequestToken) {
return;
}
browserListing = listing;
if (listing.path && browserPathDraft === (requestedPath ?? "")) {
browserPathDraft = listing.path;
}
} catch (error) {
if (requestToken !== browserRequestToken) {
return;
}
browserError = readMissingScopeError(error)?.missingScope
? t("newSession.browseRequiresAdmin")
: error instanceof Error
? error.message
: t("newSession.browserLoadFailed");
} finally {
if (requestToken === browserRequestToken) {
browserLoading = false;
paint();
}
}
};
const showBrowser = () => {
browserVisible = true;
void loadDirectory(cwd || undefined);
};
function paint() {
const trimmedCwd = cwd.trim();
const folderLabel = trimmedCwd
? folderDisplayName(trimmedCwd)
: t("sessionsView.groupDefaultsCwdPlaceholder");
const usableBrowserPath = isAbsolutePath(browserPathDraft.trim())
? browserPathDraft.trim()
: null;
render(
html`
<openclaw-modal-dialog
label=${t("sessionsView.groupDefaultsTitle", { group: options.group })}
@modal-cancel=${(event: Event) => {
if (submitting) {
event.preventDefault();
return;
}
finish();
}}
>
<form class="exec-approval-card" @submit=${handleSubmit}>
<div class="exec-approval-header">
<div class="exec-approval-title">
${t("sessionsView.groupDefaultsTitle", { group: options.group })}
</div>
</div>
<div class="field">
<span>${t("sessionsView.groupDefaultsCwd")}</span>
<span class="new-session-page__select">
<button
id="session-group-defaults-folder-trigger"
type="button"
class="new-session-page__trigger"
aria-label="${t("sessionsView.groupDefaultsCwd")}: ${folderLabel}"
aria-haspopup="dialog"
?disabled=${submitting}
>
<span class="new-session-page__target-icon" aria-hidden="true"
>${icons.folder}</span
>
<span class="new-session-page__trigger-label">${folderLabel}</span>
<span class="new-session-page__trigger-chevron" aria-hidden="true"
>${icons.chevronDown}</span
>
</button>
</span>
<wa-popover
class="new-session-page__select new-session-page__project-popover new-session-page__picker-popover session-group-defaults__folder-popover"
for="session-group-defaults-folder-trigger"
placement="bottom-start"
without-arrow
@wa-hide=${showPickerRoot}
>
${browserVisible
? renderPlaceBrowser({
listing: browserListing,
target: browserTarget,
loading: browserLoading,
error: browserError,
pathDraft: browserPathDraft,
usablePath: usableBrowserPath,
registerProjectPath: null,
registeringProject: false,
onPathDraftChange: (value) => {
browserPathDraft = value;
paint();
},
onNavigate: (path) => void loadDirectory(path),
onBack: showPickerRoot,
onRegisterProject: () => undefined,
onClose: showPickerRoot,
onApplyFolder: applyFolder,
})
: html`
<div class="new-session-page__picker-root">
${renderSessionMenuItem(
{
value: "agent-workspace",
label: t("sessionsView.groupDefaultsCwdPlaceholder"),
icon: icons.folder,
checked: !trimmedCwd,
onSelect: () => applyFolder(""),
},
submitting,
)}
<button
type="button"
class="session-menu__item"
data-value="browse"
aria-pressed="false"
?disabled=${submitting}
@click=${showBrowser}
>
<span class="session-menu__check" aria-hidden="true"></span>
<span class="session-menu__text">${t("newSession.browse")}</span>
<span class="new-session-page__menu-chevron" aria-hidden="true"
>${icons.chevronRight}</span
>
</button>
</div>
`}
</wa-popover>
<span class="muted" title=${trimmedCwd || nothing}
>${trimmedCwd || t("sessionsView.groupDefaultsCwdHint")}</span
>
</div>
<label class="field">
<span>${t("sessionsView.groupDefaultsMode")}</span>
<select name="mode" ?disabled=${submitting}>
<option value="local" ?selected=${!options.defaults.worktree}>
${t("sessionsView.groupDefaultsLocal")}
</option>
<option value="worktree" ?selected=${options.defaults.worktree}>
${t("sessionsView.groupDefaultsWorktree")}
</option>
</select>
</label>
${failure
? html`<div class="exec-approval-error" role="alert">${failure}</div>`
: nothing}
<div class="exec-approval-actions">
<button type="submit" class="btn primary" ?disabled=${submitting}>
${t("common.save")}
</button>
<button type="button" class="btn" ?disabled=${submitting} @click=${finish}>
${t("common.cancel")}
</button>
</div>
</form>
</openclaw-modal-dialog>
`,
host,
);
}
paint();
});
}
@@ -117,6 +117,37 @@ export async function deleteSessionGroup(
}
}
export async function updateSessionGroupDefaults(
host: SessionOrganizerControllerHost,
group: string,
defaults: { cwd: string | null; worktree: boolean },
scope: SidebarSessionMutationScope,
): Promise<SidebarSessionMutationResult> {
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
return "stale";
}
if (
!requireSessionMutationAccess(host, scope, {
method: "sessions.groups.update",
requiredScope: "operator.write",
})
) {
return "failed";
}
try {
const outcome = await scope.sessions.groupsUpdate(group, defaults);
return outcome === "completed" && host.sessionData.isSessionMutationScopeCurrent(scope)
? "completed"
: "stale";
} catch (error) {
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
return "stale";
}
host.sessionData.publishSessionMutationError(scope, error);
return "failed";
}
}
export async function reorderSidebarSection(
host: SessionOrganizerControllerHost,
sourceSectionId: string,
@@ -1,4 +1,5 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { ReactiveControllerHost } from "lit";
import type { FsListDirResult } from "../../../packages/gateway-protocol/src/index.js";
import {
parseSidebarEntry,
SIDEBAR_NAV_ROUTES,
@@ -38,6 +39,8 @@ import type { SessionMenuAction } from "./session-menu.ts";
type SessionOrganizerOperations = typeof import("./session-organizer-operations.runtime.ts");
type InputDialogOpener = (typeof import("./input-dialog.ts"))["showInputDialog"];
type SessionGroupDefaultsDialogOpener =
(typeof import("./session-group-defaults-dialog.ts"))["showSessionGroupDefaultsDialog"];
export interface SessionOrganizerControllerHost extends ReactiveControllerHost {
readonly sessionData: Pick<
@@ -57,6 +60,8 @@ export interface SessionOrganizerControllerHost extends ReactiveControllerHost {
clearSessionSelection(): void;
findSidebarSessionByKey(sessionKey: string): SidebarRecentSession | undefined;
knownSessionGroups(): string[];
listSessionGroupFolders(path?: string): Promise<FsListDirResult>;
sessionGroupDefaults(name: string): { cwd: string; worktree: boolean } | null;
knownSessionCatalogIds(): string[];
knownSectionOrder(): string[];
pruneSidebarSessionEntry(key: string): void;
@@ -67,7 +72,7 @@ export interface SessionOrganizerControllerHost extends ReactiveControllerHost {
}
/** Custom session groups, collapse state, and drag-and-drop assignment. */
export class SessionOrganizerController implements ReactiveController {
export class SessionOrganizerController {
collapsedSessionSections = loadStoredCollapsedSessionSections();
draggingSessionKey: string | null = null;
draggingSidebarSection: string | null = null;
@@ -81,19 +86,7 @@ export class SessionOrganizerController implements ReactiveController {
sessionListRemovalDrop = false;
private operationsLoad: Promise<SessionOrganizerOperations> | null = null;
constructor(private readonly host: SessionOrganizerControllerHost) {
host.addController(this);
}
hostConnected(): void {}
// No dialog teardown here on purpose. The sidebar detaches for reasons that
// are not the operator leaving — a narrow viewport drops it entirely — and
// cancelling on those would throw away a name mid-edit. The dialog is a
// body-level modal, so it outlives the sidebar's DOM position by design; the
// Sessions page binds its own dialogs because a page unmount really is a
// navigation.
hostDisconnected(): void {}
constructor(private readonly host: SessionOrganizerControllerHost) {}
private async loadOperations(
scope: SidebarSessionMutationScope,
@@ -530,6 +523,47 @@ export class SessionOrganizerController implements ReactiveController {
this.host.requestUpdate();
}
async editSessionGroupDefaults(group: string): Promise<void> {
let showDialog: SessionGroupDefaultsDialogOpener;
try {
showDialog = (await import("./session-group-defaults-dialog.ts"))
.showSessionGroupDefaultsDialog;
} catch (error) {
const scope = this.host.sessionData.beginSessionMutation();
if (scope) {
this.host.sessionData.publishSessionMutationError(scope, error);
}
return;
}
const defaults = this.host.sessionGroupDefaults(group);
if (defaults) {
await showDialog({
group,
defaults,
listDirectory: (path) => this.host.listSessionGroupFolders(path),
submit: async (nextDefaults) => {
const scope = this.host.sessionData.beginSessionMutation();
if (!scope || !this.host.sessionGroupDefaults(group)) {
return t("sessionsView.groupDefaultsStale");
}
const operations = await this.loadOperations(scope);
const result = await operations?.updateSessionGroupDefaults(
this.host,
group,
{ cwd: nextDefaults.cwd || null, worktree: nextDefaults.worktree },
scope,
);
return result === "completed"
? null
: result === "stale"
? t("sessionsView.groupDefaultsStale")
: (this.host.sessionData.sessionMutationError ??
t("sessionsView.groupDefaultsFailed"));
},
});
}
}
saveCollapsedSessionSections(sections: ReadonlySet<string>) {
this.collapsedSessionSections = new Set(sections);
this.host.requestUpdate();
@@ -34,6 +34,7 @@ export {
deleteSessionGroup,
renameSessionGroup,
reorderSidebarSection,
updateSessionGroupDefaults,
} from "./session-organizer-catalog.ts";
export async function patchSession(
+25 -3
View File
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
import { keyed } from "lit/directives/keyed.js";
import { DEFAULT_SIDEBAR_ENTRIES, serializeSidebarEntry } from "../app-navigation.ts";
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
import { t } from "../i18n/index.ts";
import { normalizeAgentLabel } from "../lib/agents/display.ts";
import { openEditor } from "../lib/editor-links.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
@@ -265,7 +266,12 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo
export function renderSidebarSessionGroupMenuForController(controller: SidebarMenusController) {
const { host } = controller;
const menu = controller.sessionGroupMenu;
const groupDefaultsStatus = host.sessionDataContext?.sessions.groupsStatus() ?? "idle";
const groupActionAccess = {
"group-defaults": readSessionMethodAccess(host.sessionDataContext?.gateway.snapshot, {
method: "sessions.groups.update",
requiredScope: "operator.write",
}),
"rename-group": readSessionMethodAccess(host.sessionDataContext?.gateway.snapshot, {
method: "sessions.groups.rename",
requiredScope: "operator.write",
@@ -283,14 +289,30 @@ export function renderSidebarSessionGroupMenuForController(controller: SidebarMe
menu,
trigger: controller.sessionGroupMenuTrigger,
connected: host.connected,
groupDefaultsUnavailable: groupDefaultsStatus === "unavailable",
actionDisabledReasons: Object.fromEntries(
Object.entries(groupActionAccess).flatMap(([action, access]) =>
access.allowed ? [] : [[action, access.reason]],
),
Object.entries(groupActionAccess).flatMap(([action, access]) => {
if (!access.allowed) {
return [[action, access.reason]];
}
return action === "group-defaults" &&
groupDefaultsStatus !== "ready" &&
groupDefaultsStatus !== "unavailable"
? [[action, t("common.loading")]]
: [];
}),
),
onAction: (action, group) => {
controller.closeSessionGroupMenu({ restoreFocus: true });
switch (action) {
case "group-defaults":
if (groupDefaultsStatus === "unavailable") {
host.sessionDataContext?.sessions.groupsInvalidate();
void host.sessionDataContext?.sessions.groupsLoad();
break;
}
void host.sessionOrganizer.editSessionGroupDefaults(group);
break;
case "rename-group":
void host.sessionOrganizer.renameSessionGroupFromMenu(group);
break;
@@ -0,0 +1,479 @@
import { expect, it } from "vitest";
import {
captureUiProof,
createSessionManagementE2eSuite,
installMockGateway,
sessionsListResponse,
} from "./session-management.test-support.ts";
const suite = createSessionManagementE2eSuite();
suite.define(() => {
it("starts a session from a group with its saved folder and worktree defaults", async () => {
const workspace = "/home/peter/openclaw";
const initialGroupCwd = "/home/peter";
const groupCwd = "/home/peter/client-work";
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"fs.listDir": {
path: groupCwd,
parent: "/home/peter",
home: "/home/peter",
entries: [],
},
"sessions.create": { key: "agent:main:client-work", runStarted: true },
"sessions.list": sessionsListResponse([]),
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
sessionGroups: ["Client work"],
sessionGroupDefaults: { "Client work": { cwd: initialGroupCwd, worktree: false } },
workspace,
workspaceGit: true,
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const group = page.locator('[data-session-section="category:Client work"]');
await group.waitFor({ state: "visible", timeout: 10_000 });
await group.locator(".sidebar-recent-sessions__head").hover();
await group.getByRole("button", { name: "Group options for Client work" }).click();
await page.getByRole("menuitem", { name: "New session defaults…" }).click();
await page.evaluate(async () => customElements.whenDefined("wa-popover"));
const dialog = page.locator(
`openclaw-modal-dialog[label='New session defaults for "Client work"']`,
);
await dialog.waitFor({ state: "visible" });
const folderTrigger = dialog.locator("#session-group-defaults-folder-trigger");
await expect.poll(() => folderTrigger.getAttribute("aria-label")).toContain("peter");
await folderTrigger.click();
const folderPicker = dialog.locator("wa-popover.session-group-defaults__folder-popover");
await folderPicker.getByRole("button", { name: "Browse folders" }).click();
expect((await gateway.waitForRequest("fs.listDir")).params).toEqual({
path: initialGroupCwd,
});
await expect
.poll(() => folderPicker.locator("input.new-session-page__browser-path").inputValue())
.toBe(groupCwd);
for (const viewport of [
{ height: 844, name: "phone", width: 390 },
{ height: 1024, name: "tablet", width: 768 },
{ height: 900, name: "desktop", width: 1440 },
{ height: 500, name: "landscape", width: 932 },
]) {
await page.setViewportSize(viewport);
await expect
.poll(async () => {
const bounds = await folderPicker.locator(".new-session-page__browser").boundingBox();
return {
horizontal: Boolean(
bounds && bounds.x >= 0 && bounds.x + bounds.width <= viewport.width,
),
vertical: Boolean(
bounds && bounds.y >= 0 && bounds.y + bounds.height <= viewport.height,
),
};
})
.toEqual({ horizontal: true, vertical: true });
await captureUiProof(page, `group-defaults-folder-picker-${viewport.name}.png`);
}
await folderPicker.getByRole("button", { name: "Use this folder" }).click();
await page.setViewportSize({ height: 900, width: 1280 });
await expect.poll(() => folderTrigger.textContent()).toContain("client-work");
await expect.poll(() => dialog.locator('input[name="cwd"]').count()).toBe(0);
await dialog.locator('select[name="mode"]').selectOption("worktree");
await dialog.getByRole("button", { name: "Save" }).click();
expect((await gateway.waitForRequest("sessions.groups.update")).params).toMatchObject({
name: "Client work",
cwd: groupCwd,
worktree: true,
});
await group.locator(".sidebar-recent-sessions__head").hover();
await group.getByRole("button", { name: "New session in Client work" }).click();
await page.locator(".new-session-page__message").waitFor();
await expect.poll(() => new URL(page.url()).searchParams.get("group")).toBe("Client work");
await expect
.poll(() =>
page
.locator("#new-session-project-trigger .new-session-page__trigger-label")
.textContent(),
)
.toContain("client-work");
expect((await gateway.waitForRequest("worktrees.branches")).params).toMatchObject({
repoRoot: groupCwd,
});
await expect
.poll(() => page.locator("#new-session-detail-trigger").getAttribute("data-worktree"))
.toBe("true");
await page.locator(".new-session-page__message").fill("prepare the client release");
await page.getByRole("button", { name: "Start session" }).click();
expect((await gateway.waitForRequest("sessions.create")).params).toMatchObject({
agentId: "main",
category: "Client work",
cwd: groupCwd,
message: "prepare the client release",
worktree: true,
});
} finally {
await context.close();
}
});
it("omits the group category for a legacy Gateway", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
featureMethods: ["sessions.create", "sessions.groups.list"],
methodResponses: {
"sessions.create": { key: "agent:main:legacy-group", runStarted: true },
"sessions.list": sessionsListResponse([]),
},
sessionGroups: ["Client work"],
workspace: "/home/peter/openclaw",
workspaceGit: false,
});
try {
await page.goto(`${suite.server.baseUrl}new?group=Client+work`);
await page.locator(".new-session-page__message").fill("start on an older Gateway");
await page.getByRole("button", { name: "Start session" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "main",
message: "start on an older Gateway",
});
expect(create.params).not.toHaveProperty("category");
} finally {
await context.close();
}
});
it("revalidates an open group route when its defaults or identity change", async () => {
const initialCwd = "/home/peter/client-work";
const refreshedCwd = "/home/peter/refreshed-client-work";
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([]),
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
sessionGroups: ["Client work"],
sessionGroupDefaults: { "Client work": { cwd: initialCwd, worktree: true } },
workspace: "/home/peter/openclaw",
workspaceGit: true,
});
try {
await page.goto(`${suite.server.baseUrl}new?group=Client+work`);
const project = page.locator("#new-session-project-trigger .new-session-page__trigger-label");
await expect.poll(() => project.textContent()).toContain("client-work");
await page.locator(".new-session-page__message").fill("keep this draft");
await page.evaluate(async (cwd) => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: {
context: {
sessions: {
groupsUpdate: (
name: string,
defaults: { cwd: string | null; worktree: boolean },
) => Promise<unknown>;
};
};
};
};
await app.runtime?.context.sessions.groupsUpdate("Client work", {
cwd,
worktree: false,
});
}, refreshedCwd);
await expect.poll(() => project.textContent()).toContain("refreshed-client-work");
await expect
.poll(() => page.locator("#new-session-detail-trigger").getAttribute("data-worktree"))
.toBe("false");
await expect
.poll(() => page.locator(".new-session-page__message").inputValue())
.toBe("keep this draft");
await page.evaluate(async () => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: {
context: {
sessions: {
groupsRename: (from: string, to: string) => Promise<unknown>;
};
};
};
};
await app.runtime?.context.sessions.groupsRename("Client work", "Customer work");
});
const unavailable = page.locator(".new-session-page__catalog-unavailable");
await expect
.poll(() => unavailable.textContent())
.toContain("This session target is unavailable.");
await expect
.poll(() => page.getByRole("button", { name: "Start session" }).isDisabled())
.toBe(true);
} finally {
await context.close();
}
});
it("fails an open group route closed while remote catalog invalidation is unresolved", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([]),
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
},
},
sessionGroups: ["Client work"],
sessionGroupDefaults: {
"Client work": { cwd: "/home/peter/client-work", worktree: true },
},
workspace: "/home/peter/openclaw",
workspaceGit: true,
});
try {
await page.goto(`${suite.server.baseUrl}new?group=Client+work`);
const start = page.getByRole("button", { name: "Start session" });
await page.locator(".new-session-page__message").fill("wait for fresh defaults");
await expect.poll(() => start.isEnabled()).toBe(true);
await gateway.deferNext("sessions.groups.list");
await gateway.emitGatewayEvent("sessions.changed", { reason: "groups" });
await gateway.waitForRequest("sessions.groups.list");
await expect.poll(() => start.isDisabled()).toBe(true);
await expect
.poll(() => page.locator(".new-session-page__catalog-unavailable button").isDisabled())
.toBe(true);
await gateway.deferNext("sessions.groups.list");
await gateway.rejectDeferred("sessions.groups.list", {
code: "UNAVAILABLE",
message: "catalog reload failed",
});
await gateway.waitForRequest("sessions.groups.list");
await expect
.poll(() => page.locator(".new-session-page__catalog-unavailable").textContent())
.toContain("This session target is unavailable.");
await expect.poll(() => start.isDisabled()).toBe(true);
await expect
.poll(() => page.locator(".new-session-page__catalog-unavailable button").isDisabled())
.toBe(true);
await gateway.resolveDeferred("sessions.groups.list", {
groups: [{ name: "Client work", position: 0 }],
});
await expect.poll(() => start.isEnabled()).toBe(true);
} finally {
await context.close();
}
});
it("keeps rejected group defaults editable and allows retry", async () => {
const groupCwd = "/home/peter/client-work";
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.groups.update"],
methodResponses: { "sessions.list": sessionsListResponse([]) },
sessionGroups: ["Client work"],
sessionGroupDefaults: { "Client work": { cwd: groupCwd, worktree: true } },
workspace: "/home/peter/openclaw",
workspaceGit: true,
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const group = page.locator('[data-session-section="category:Client work"]');
await group.waitFor({ state: "visible", timeout: 10_000 });
await group.locator(".sidebar-recent-sessions__head").hover();
await group.getByRole("button", { name: "Group options for Client work" }).click();
await page.getByRole("menuitem", { name: "New session defaults…" }).click();
const dialog = page.locator(
`openclaw-modal-dialog[label='New session defaults for "Client work"']`,
);
await dialog.waitFor({ state: "visible" });
await dialog.locator('select[name="mode"]').selectOption("local");
await dialog.getByRole("button", { name: "Save" }).click();
await gateway.waitForRequest("sessions.groups.update");
await gateway.rejectDeferred("sessions.groups.update", {
code: "INVALID_REQUEST",
message: "rejected group defaults",
});
const alert = dialog.getByRole("alert");
await alert.waitFor({ state: "visible" });
await expect.poll(() => alert.textContent()).toContain("rejected group defaults");
await expect.poll(() => dialog.locator('select[name="mode"]').inputValue()).toBe("local");
await dialog.getByRole("button", { name: "Save" }).click();
await expect
.poll(async () => (await gateway.getRequests("sessions.groups.update")).length)
.toBe(2);
await dialog.waitFor({ state: "detached" });
await group.locator(".sidebar-recent-sessions__head").hover();
await group.getByRole("button", { name: "New session in Client work" }).click();
await page.locator("#new-session-detail-trigger").waitFor();
await expect
.poll(() => page.locator("#new-session-detail-trigger").getAttribute("data-worktree"))
.toBe("false");
} finally {
await context.close();
}
});
it("offers retry when authoritative group defaults are unavailable", async () => {
const groupCwd = "/home/peter/client-work";
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.groups.defaults"],
methodResponses: { "sessions.list": sessionsListResponse([]) },
sessionGroups: ["Client work"],
sessionGroupDefaults: { "Client work": { cwd: groupCwd, worktree: true } },
workspace: "/home/peter/openclaw",
workspaceGit: true,
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const group = page.locator('[data-session-section="category:Client work"]');
await group.waitFor({ state: "visible", timeout: 10_000 });
await gateway.waitForRequest("sessions.groups.list");
await gateway.waitForRequest("sessions.groups.defaults");
await group.locator(".sidebar-recent-sessions__head").hover();
await group.getByRole("button", { name: "Group options for Client work" }).click();
const defaultsAction = page.locator('wa-dropdown-item[value="group-defaults"]');
await defaultsAction.waitFor({ state: "attached" });
await expect.poll(() => defaultsAction.isDisabled()).toBe(true);
expect(await gateway.getRequests("sessions.groups.update")).toHaveLength(0);
expect(
await page
.locator(`openclaw-modal-dialog[label='New session defaults for "Client work"']`)
.count(),
).toBe(0);
await gateway.rejectDeferred("sessions.groups.defaults", {
code: "INVALID_REQUEST",
message: "defaults unavailable",
});
await expect.poll(() => defaultsAction.textContent()).toContain("Retry");
await expect.poll(() => defaultsAction.isEnabled()).toBe(true);
await gateway.deferNext("sessions.groups.list");
await defaultsAction.click();
await gateway.waitForRequest("sessions.groups.list");
await gateway.resolveDeferred("sessions.groups.list", {
groups: [{ name: "Client work", position: 0 }],
});
await expect
.poll(async () => (await gateway.getRequests("sessions.groups.defaults")).length)
.toBe(2);
await group.locator(".sidebar-recent-sessions__head").hover();
await group.getByRole("button", { name: "Group options for Client work" }).click();
const readyDefaultsAction = page.locator('wa-dropdown-item[value="group-defaults"]');
await expect.poll(() => readyDefaultsAction.textContent()).not.toContain("Retry");
await expect.poll(() => readyDefaultsAction.isEnabled()).toBe(true);
await readyDefaultsAction.click();
const dialog = page.locator(
`openclaw-modal-dialog[label='New session defaults for "Client work"']`,
);
await dialog.waitFor({ state: "visible" });
await expect
.poll(() =>
dialog.locator("#session-group-defaults-folder-trigger").getAttribute("aria-label"),
)
.toContain("client-work");
await expect.poll(() => dialog.locator('select[name="mode"]').inputValue()).toBe("worktree");
expect(await gateway.getRequests("sessions.groups.update")).toHaveLength(0);
await dialog.getByRole("button", { name: "Cancel" }).click();
} finally {
await context.close();
}
});
it("blocks a missing group until a fresh catalog retry resolves it", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: { "sessions.list": sessionsListResponse([]) },
sessionGroups: [],
workspace: "/home/peter/openclaw",
workspaceGit: true,
});
try {
await page.goto(`${suite.server.baseUrl}new?group=Deleted`);
const unavailable = page.locator(".new-session-page__catalog-unavailable");
await unavailable.waitFor();
await expect
.poll(() => unavailable.textContent())
.toContain("This session target is unavailable.");
await page.locator(".new-session-page__message").fill("do not create this session");
const start = page.getByRole("button", { name: "Start session" });
await expect.poll(() => start.isDisabled()).toBe(true);
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
const listRequestsBeforeRetry = (await gateway.getRequests("sessions.groups.list")).length;
await page.getByRole("button", { name: "Retry" }).click();
await expect
.poll(async () => (await gateway.getRequests("sessions.groups.list")).length)
.toBeGreaterThan(listRequestsBeforeRetry);
await expect.poll(() => start.isDisabled()).toBe(true);
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
} finally {
await context.close();
}
});
});
+11
View File
@@ -1031,6 +1031,17 @@ export const en: TranslationMap = {
removeFromGroup: "Remove from group",
moveBackToGroups: "Move back to Groups",
groupMenu: "Group options for {group}",
newSessionInGroup: "New session in {group}",
groupDefaultsMenu: "New session defaults…",
groupDefaultsTitle: 'New session defaults for "{group}"',
groupDefaultsCwd: "Working directory",
groupDefaultsCwdPlaceholder: "Use the agent workspace",
groupDefaultsCwdHint: "Leave empty to use the selected agent's workspace.",
groupDefaultsMode: "Environment",
groupDefaultsLocal: "Local",
groupDefaultsWorktree: "Worktree",
groupDefaultsFailed: "Could not save the group defaults.",
groupDefaultsStale: "Gateway connection replaced before the defaults were saved. Try again.",
renameGroupMenu: "Rename group…",
renameGroupTitle: 'Rename group "{group}"',
groupNameLabel: "Group name",
+2
View File
@@ -68,6 +68,7 @@ function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn<T
error: null,
deletedSessions: [],
groups: [],
groupSettings: [],
sectionOrder: [],
},
},
@@ -332,6 +333,7 @@ describe("loadToolsCatalog", () => {
agentId: "main",
profiles: [{ id: "full", label: "Full" }],
groups: [],
groupSettings: [],
});
await pending;
+1
View File
@@ -30,6 +30,7 @@ export type CloudSessionRecovery = {
// Keep the create -> dispatch -> first-send handoff recoverable across reloads,
// while scoping it to this tab, Gateway, and authenticated credential.
const CLOUD_CREATE_STRING_FIELDS = [
"category",
"model",
"thinkingLevel",
"worktreeBaseRef",
+1
View File
@@ -19,6 +19,7 @@ export type SessionCreateParams = {
forkFrom?: "last-completed";
succeedsParent?: boolean;
label?: string;
category?: string;
model?: string;
thinkingLevel?: string;
incognito?: boolean;
+34 -4
View File
@@ -1,15 +1,45 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { readSessionCustomGroupNames, readSidebarSectionOrder } from "./custom-groups.ts";
import {
mergeSessionGroupDefaults,
readSessionCustomGroups,
readSidebarSectionOrder,
} from "./custom-groups.ts";
describe("session group catalog readers", () => {
it("normalizes valid names and ignores malformed entries", () => {
expect(
readSessionCustomGroupNames({
readSessionCustomGroups({
groups: [{ name: " Alpha " }, { name: "" }, { name: 42 }, null],
}),
).toEqual(["Alpha"]);
expect(readSessionCustomGroupNames(null)).toEqual([]);
).toEqual([{ name: "Alpha", position: 0 }]);
expect(readSessionCustomGroups(null)).toEqual([]);
});
it("keeps the catalog path-free and merges validated New Session defaults", () => {
const groups = readSessionCustomGroups({
groups: [
{ name: " Client ", position: 4, cwd: " /leaked/client ", worktree: false },
{ name: "Local", position: "bad", cwd: "/leaked/local", worktree: true },
],
});
expect(groups).toEqual([
{ name: "Client", position: 4 },
{ name: "Local", position: 1 },
]);
expect(
mergeSessionGroupDefaults(groups, {
defaults: [
{ name: " Client ", cwd: " /repos/client ", worktree: true },
{ name: "Local", cwd: 42, worktree: false },
{ name: "Missing", cwd: "/repos/missing", worktree: true },
null,
],
}),
).toEqual([
{ name: "Client", position: 4, cwd: "/repos/client", worktree: true },
{ name: "Local", position: 1, worktree: false },
]);
});
it("reads normalized section order", () => {
+48 -5
View File
@@ -4,14 +4,57 @@
const BUILT_IN_SESSION_SECTION_IDS = new Set(["ungrouped", "groups", "work"]);
export function readSessionCustomGroupNames(payload: unknown): string[] {
const groups = (payload as { groups?: Array<{ name?: unknown }> } | null)?.groups;
export type SessionGroupSettings = {
name: string;
position: number;
cwd?: string;
worktree?: boolean;
};
export function readSessionCustomGroups(payload: unknown): SessionGroupSettings[] {
const groups = (payload as { groups?: unknown } | null)?.groups;
if (!Array.isArray(groups)) {
return [];
}
return groups.flatMap((group) =>
typeof group?.name === "string" && group.name.trim() ? [group.name.trim()] : [],
);
return groups.flatMap((entry, index) => {
const group = entry as Record<string, unknown> | null;
const name = typeof group?.name === "string" ? group.name.trim() : "";
if (!name) {
return [];
}
return [
{
name,
position:
typeof group?.position === "number" && Number.isSafeInteger(group.position)
? group.position
: index,
},
];
});
}
export function mergeSessionGroupDefaults(
groups: readonly SessionGroupSettings[],
payload: unknown,
): SessionGroupSettings[] {
const values = (payload as { defaults?: unknown } | null)?.defaults;
const defaults = new Map<string, { cwd?: string; worktree?: boolean }>();
if (Array.isArray(values)) {
for (const value of values) {
const record = value as Record<string, unknown> | null;
const name = typeof record?.name === "string" ? record.name.trim() : "";
if (!name) {
continue;
}
const cwd = typeof record?.cwd === "string" ? record.cwd.trim() : "";
defaults.set(name, {
...(cwd ? { cwd } : {}),
...(typeof record?.worktree === "boolean" ? { worktree: record.worktree } : {}),
});
}
}
return groups.map((group) => ({ ...group, ...defaults.get(group.name) }));
}
export function readSidebarSectionOrder(payload: unknown): string[] {
+17
View File
@@ -150,6 +150,23 @@ describe("createSessionCapability", () => {
sessions.dispose();
});
it("loads a metadata-less group catalog without probing the newer defaults method", async () => {
const request = vi.fn(async (method: string) => {
if (method === "sessions.groups.list") {
return { groups: [{ name: "Research", position: 0 }] };
}
throw new Error(`Unexpected request: ${method}`);
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client);
const sessions = createSessionCapability(gateway);
await expect(sessions.groupsLoad()).resolves.toEqual([{ name: "Research", position: 0 }]);
expect(request).toHaveBeenCalledOnce();
expect(sessions.state.groups).toEqual(["Research"]);
sessions.dispose();
});
it("publishes state.error when group rename is rejected", async () => {
const request = vi.fn(async (method: string) => {
if (method === "sessions.groups.rename") {
+6
View File
@@ -92,6 +92,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
error: null,
deletedSessions: [],
groups: [],
groupSettings: [],
sectionOrder: [],
};
const connection = createGatewayConnectionLifecycle(gateway.snapshot);
@@ -374,6 +375,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
error: null,
deletedSessions: [],
groups: state.groups,
groupSettings: state.groupSettings,
sectionOrder: state.sectionOrder,
});
return;
@@ -521,8 +523,12 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
listBranches: operations.listBranches,
switchBranch: operations.switchBranch,
groupsLoad: groups.load,
groupsGeneration: groups.generation,
groupsStatus: groups.status,
groupsInvalidate: groups.invalidate,
groupsPut: groups.put,
groupsRename: groups.rename,
groupsUpdate: groups.update,
groupsDelete: groups.delete,
subscribeCreated(listener) {
createdListeners.add(listener);
+31 -11
View File
@@ -7,16 +7,32 @@ describe("gateway-owned sidebar section order", () => {
it("sends and publishes the order", async () => {
const sectionOrder = ["catalog:codex", "work", "category:Beta", "ungrouped", "category:Alpha"];
const request = vi.fn(async (method: string, params: unknown) => {
expect(method).toBe("sessions.groups.put");
expect(params).toEqual({ names: ["Beta", "Alpha"], sectionOrder });
return {
ok: true,
groups: [
{ name: "Beta", position: 0 },
{ name: "Alpha", position: 1 },
],
sectionOrder,
};
if (method === "sessions.groups.put") {
expect(params).toEqual({ names: ["Beta", "Alpha"], sectionOrder });
return {
ok: true,
groups: [
{ name: "Beta", position: 0 },
{ name: "Alpha", position: 1 },
],
sectionOrder,
};
}
if (method === "sessions.groups.list") {
expect(params).toEqual({});
return {
groups: [
{ name: "Beta", position: 0 },
{ name: "Alpha", position: 1 },
],
sectionOrder,
};
}
if (method === "sessions.groups.defaults") {
expect(params).toEqual({});
return { defaults: [] };
}
throw new Error(`Unexpected request: ${method}`);
});
const client = { request } as unknown as GatewayBrowserClient;
const sessions = createSessionCapability({
@@ -25,7 +41,11 @@ describe("gateway-owned sidebar section order", () => {
phase: "connected",
sessionKey: "agent:main:main",
assistantAgentId: "main",
hello: { features: { methods: ["sessions.groups.put"] } } as GatewayHelloOk,
hello: {
features: {
methods: ["sessions.groups.put", "sessions.groups.list", "sessions.groups.defaults"],
},
} as GatewayHelloOk,
},
subscribe: () => () => undefined,
subscribeEvents: () => () => undefined,
+17 -2
View File
@@ -19,6 +19,7 @@ import type {
import type { ApplicationGatewayPhase } from "../../app/gateway.ts";
import type { GatewayConnectionScope } from "../gateway-connection-lifecycle.ts";
import type { SessionCreateOutcome, SessionCreateParams } from "./create.ts";
import type { SessionGroupSettings } from "./custom-groups.ts";
import type { SessionArchivedFilter } from "./navigation.ts";
import type { SessionPatchRoute } from "./patch.ts";
import type {
@@ -36,11 +37,14 @@ export type SessionState = {
deletedSessions: readonly SessionDeleteTarget[];
/** Gateway-owned custom group catalog in display order. */
groups: readonly string[];
/** New Session defaults associated with each gateway-owned group. */
groupSettings: readonly SessionGroupSettings[];
/** Gateway-owned sidebar section order; pinned is intentionally absent. */
sectionOrder: readonly string[];
};
export type SessionGroupMutationResult = "completed" | "stale";
export type SessionGroupDefaultsStatus = "idle" | "loading" | "ready" | "unavailable";
export type SessionListOptions = {
agentId?: string;
@@ -244,8 +248,14 @@ export type SessionCapability = {
leafEntryId: string,
options?: { agentId?: string | null },
) => Promise<SessionsBranchesSwitchResult>;
/** Loads the gateway-owned group catalog, coalescing successful connection attempts. */
groupsLoad: () => Promise<void>;
/** Loads one connection-owned group catalog; null means the attempt retired or failed. */
groupsLoad: () => Promise<readonly SessionGroupSettings[] | null>;
/** Generation of the catalog/defaults snapshot used by group-target routes. */
groupsGeneration: () => number;
/** Whether group defaults are current enough for a group-target route. */
groupsStatus: () => SessionGroupDefaultsStatus;
/** Invalidates the connection-owned group catalog before an explicit route retry. */
groupsInvalidate: () => void;
/** Replaces the group catalog; stale means the initiating connection retired. */
groupsPut: (
names: readonly string[],
@@ -253,6 +263,11 @@ export type SessionCapability = {
) => Promise<SessionGroupMutationResult>;
/** Renames a group; stale means the initiating connection retired before reconciliation. */
groupsRename: (from: string, to: string) => Promise<SessionGroupMutationResult>;
/** Updates the New Session defaults for one group. */
groupsUpdate: (
name: string,
defaults: { cwd: string | null; worktree: boolean },
) => Promise<SessionGroupMutationResult>;
/** Deletes a group; stale means the initiating connection retired before reconciliation. */
groupsDelete: (name: string) => Promise<SessionGroupMutationResult>;
subscribeCreated: (listener: (key: string) => void) => () => void;
+219 -55
View File
@@ -1,11 +1,17 @@
import { getSafeLocalStorage } from "../../local-storage.ts";
import { isGatewayMethodAdvertised } from "../gateway-methods.ts";
import { readSessionMethodAccess } from "../session-method-access.ts";
import { readSessionCustomGroupNames, readSidebarSectionOrder } from "./custom-groups.ts";
import {
readSessionCustomGroups,
readSidebarSectionOrder,
mergeSessionGroupDefaults,
type SessionGroupSettings,
} from "./custom-groups.ts";
import type {
SessionConnectionOwner,
SessionConnectionScope,
SessionGateway,
SessionGroupDefaultsStatus,
SessionGroupMutationResult,
SessionState,
} from "./session-capability.ts";
@@ -21,18 +27,20 @@ type SessionGroupCatalogHost = {
const LEGACY_GROUPS_STORAGE_KEY = "openclaw:sessions:custom-groups";
const GROUPS_LIST_METHOD = "sessions.groups.list";
const GROUPS_DEFAULTS_METHOD = "sessions.groups.defaults";
function readLegacyStoredGroups(): string[] {
try {
const raw = getSafeLocalStorage()?.getItem(LEGACY_GROUPS_STORAGE_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
const parsed: unknown = JSON.parse(
getSafeLocalStorage()?.getItem(LEGACY_GROUPS_STORAGE_KEY) ?? "[]",
);
return Array.isArray(parsed)
? [
...new Set(
parsed.flatMap((name) => {
const normalized = typeof name === "string" ? name.trim() : "";
return normalized ? [normalized] : [];
}),
parsed
.filter((name): name is string => typeof name === "string")
.map((name) => name.trim())
.filter(Boolean),
),
]
: [];
@@ -44,11 +52,14 @@ function readLegacyStoredGroups(): string[] {
export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
let loadedEpoch = -1;
let loadGeneration = 0;
let catalogGeneration = 0;
let defaultsStatus: SessionGroupDefaultsStatus = "idle";
let pendingLoad: Promise<readonly SessionGroupSettings[] | null> | null = null;
let retryTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
const clearRetry = () => {
if (retryTimer !== null) {
globalThis.clearTimeout(retryTimer);
clearTimeout(retryTimer);
retryTimer = null;
}
};
@@ -56,19 +67,55 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
const invalidate = () => {
loadedEpoch = -1;
loadGeneration += 1;
catalogGeneration += 1;
pendingLoad = null;
clearRetry();
defaultsStatus = "loading";
// Every invalidation publishes its generation, including back-to-back
// events while the previous reload is still pending.
host.publish({ ...host.readState() });
};
const dispose = () => {
loadedEpoch = -1;
loadGeneration += 1;
pendingLoad = null;
clearRetry();
};
const publishCatalog = (groups: readonly string[], sectionOrder: readonly string[]) => {
const publishCatalog = (
groupSettings: readonly SessionGroupSettings[],
sectionOrder: readonly string[],
status: SessionGroupDefaultsStatus,
) => {
const state = host.readState();
const groups = groupSettings.map((group) => group.name);
const groupsUnchanged =
groups.length === state.groups.length &&
groups.every((group, i) => group === state.groups[i]);
const orderUnchanged =
sectionOrder.length === state.sectionOrder.length &&
sectionOrder.every((sectionId, i) => sectionId === state.sectionOrder[i]);
if (!groupsUnchanged || !orderUnchanged) {
host.publish({ ...state, groups: [...groups], sectionOrder: [...sectionOrder] });
const settingsUnchanged =
groupSettings.length === state.groupSettings.length &&
groupSettings.every((group, index) => {
const current = state.groupSettings[index];
return (
current?.name === group.name &&
current.position === group.position &&
current.cwd === group.cwd &&
current.worktree === group.worktree
);
});
const statusChanged = defaultsStatus !== status;
defaultsStatus = status;
if (!groupsUnchanged || !settingsUnchanged || !orderUnchanged || statusChanged) {
host.publish({
...state,
groups: [...groups],
groupSettings: [...groupSettings],
sectionOrder: [...sectionOrder],
});
}
};
@@ -80,6 +127,35 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
throw error;
};
const finishLoadFailure = (
scope: SessionConnectionScope,
generation: number,
error: unknown,
retry: boolean,
) => {
if (!host.connection.isCurrent(scope) || generation !== loadGeneration) {
return null;
}
if (defaultsStatus !== "unavailable") {
defaultsStatus = "unavailable";
host.publish({ ...host.readState() });
}
if (!retry) {
return null;
}
loadedEpoch = -1;
const delay = host.retryDelayMs(error);
if (delay !== null) {
retryTimer = setTimeout(() => {
retryTimer = null;
if (host.connection.isCurrent(scope) && generation === loadGeneration) {
void load();
}
}, delay);
}
return null;
};
const loadAttempt = async (
scope: SessionConnectionScope,
generation: number,
@@ -88,71 +164,102 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
try {
const listed = await scope.client.request(GROUPS_LIST_METHOD, {});
if (!host.connection.isCurrent(scope) || generation !== loadGeneration) {
return;
return null;
}
let names = readSessionCustomGroupNames(listed);
let settings = readSessionCustomGroups(listed);
let sectionOrder = readSidebarSectionOrder(listed);
// Browser-local catalogs predate the gateway store and migrate exactly once.
const legacy = readLegacyStoredGroups();
const legacyMigrationAccess = readSessionMethodAccess(host.snapshot(), {
method: "sessions.groups.put",
requiredScope: "operator.write",
});
if (names.length === 0 && legacy.length > 0 && legacyMigrationAccess.allowed) {
const put = await scope.client.request("sessions.groups.put", { names: legacy });
if (!host.connection.isCurrent(scope) || generation !== loadGeneration) {
return;
if (
legacy.length > 0 &&
readSessionMethodAccess(host.snapshot(), {
method: "sessions.groups.put",
requiredScope: "operator.write",
}).allowed
) {
if (settings.length === 0) {
const put = await scope.client.request("sessions.groups.put", { names: legacy });
if (!host.connection.isCurrent(scope) || generation !== loadGeneration) {
return null;
}
settings = readSessionCustomGroups(put);
sectionOrder = readSidebarSectionOrder(put);
}
names = readSessionCustomGroupNames(put);
sectionOrder = readSidebarSectionOrder(put);
}
if (legacy.length > 0 && legacyMigrationAccess.allowed) {
try {
getSafeLocalStorage()?.removeItem(LEGACY_GROUPS_STORAGE_KEY);
} catch {
// The gateway catalog is canonical even when browser cleanup fails.
}
}
publishCatalog(names, sectionOrder);
} catch (error) {
if (
!host.connection.isCurrent(scope) ||
generation !== loadGeneration ||
advertised !== true
) {
// Gateways without feature metadata retain the legacy one-shot probe.
return;
const defaultsAllowed =
isGatewayMethodAdvertised(host.snapshot(), GROUPS_DEFAULTS_METHOD) === true &&
readSessionMethodAccess(host.snapshot(), {
method: GROUPS_DEFAULTS_METHOD,
requiredScope: "operator.write",
}).allowed;
if (!defaultsAllowed) {
publishCatalog(settings, sectionOrder, "ready");
return settings;
}
loadedEpoch = -1;
const delay = host.retryDelayMs(error);
if (delay === null) {
return;
}
// An older rejection cannot revive retries after a newer catalog load wins.
retryTimer = globalThis.setTimeout(() => {
retryTimer = null;
if (host.connection.isCurrent(scope) && generation === loadGeneration) {
void load();
// The path-free catalog is independently useful to the sidebar. Defaults
// readiness only gates group-target routes and must not erase those names.
publishCatalog(settings, sectionOrder, "loading");
try {
const defaults = await scope.client.request(GROUPS_DEFAULTS_METHOD, {});
if (!host.connection.isCurrent(scope) || generation !== loadGeneration) {
return null;
}
}, delay);
settings = mergeSessionGroupDefaults(settings, defaults);
} catch (error) {
return finishLoadFailure(scope, generation, error, true);
}
publishCatalog(settings, sectionOrder, "ready");
return settings;
} catch (error) {
// Gateways without feature metadata retain the legacy one-shot probe.
return finishLoadFailure(scope, generation, error, advertised === true);
}
};
/** Group consumers may probe once per connection; explicitly absent features never probe. */
const load = async () => {
const scope = host.connection.capture();
if (!scope || loadedEpoch === scope.epoch) {
return;
if (!scope) {
return null;
}
if (loadedEpoch === scope.epoch) {
return pendingLoad ?? host.readState().groupSettings;
}
const advertised = isGatewayMethodAdvertised(host.snapshot(), GROUPS_LIST_METHOD);
clearRetry();
const generation = ++loadGeneration;
loadedEpoch = scope.epoch;
if (advertised === false) {
publishCatalog([], []);
return;
if (defaultsStatus !== "loading") {
defaultsStatus = "loading";
host.publish({ ...host.readState() });
}
await loadAttempt(scope, generation, advertised);
if (advertised === false) {
publishCatalog([], [], "ready");
return [];
}
const promise = loadAttempt(scope, generation, advertised).finally(() => {
if (pendingLoad === promise) {
pendingLoad = null;
}
});
pendingLoad = promise;
return promise;
};
const publishPathFreeMutation = (
groupSettings: readonly SessionGroupSettings[],
sectionOrder: readonly string[],
) => {
// Catalog mutations do not carry authoritative defaults. Retire any older
// defaults read and keep group routes blocked until a fresh read completes.
invalidate();
publishCatalog(groupSettings, sectionOrder, "loading");
void load();
};
const put = async (
@@ -171,7 +278,12 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
if (!host.connection.isCurrent(scope)) {
return "stale";
}
publishCatalog(readSessionCustomGroupNames(result), readSidebarSectionOrder(result));
publishPathFreeMutation(
mergeSessionGroupDefaults(readSessionCustomGroups(result), {
defaults: host.readState().groupSettings,
}),
readSidebarSectionOrder(result),
);
return "completed";
} catch (error) {
return finishMutationFailure(host.connection.isCurrent(scope), error);
@@ -188,7 +300,15 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
if (!host.connection.isCurrent(scope)) {
return "stale";
}
publishCatalog(readSessionCustomGroupNames(result), readSidebarSectionOrder(result));
const current = host.readState().groupSettings;
const targetExists = current.some((group) => group.name === to);
const renamedDefaults = current.flatMap((group) =>
group.name === from ? (targetExists ? [] : [{ ...group, name: to }]) : [group],
);
publishPathFreeMutation(
mergeSessionGroupDefaults(readSessionCustomGroups(result), { defaults: renamedDefaults }),
readSidebarSectionOrder(result),
);
// Mutation response commits before a background member-row reconciliation.
void host.refreshRows();
return "completed";
@@ -207,7 +327,12 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
if (!host.connection.isCurrent(scope)) {
return "stale";
}
publishCatalog(readSessionCustomGroupNames(result), readSidebarSectionOrder(result));
publishPathFreeMutation(
mergeSessionGroupDefaults(readSessionCustomGroups(result), {
defaults: host.readState().groupSettings,
}),
readSidebarSectionOrder(result),
);
void host.refreshRows();
return "completed";
} catch (error) {
@@ -215,5 +340,44 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
}
};
return { delete: remove, dispose: invalidate, invalidate, load, put, rename };
const update = async (
name: string,
defaults: { cwd: string | null; worktree: boolean },
): Promise<SessionGroupMutationResult> => {
const scope = host.connection.capture();
if (!scope) {
return "stale";
}
try {
const result = await scope.client.request("sessions.groups.update", { name, ...defaults });
if (!host.connection.isCurrent(scope)) {
return "stale";
}
const state = host.readState();
const pathFreeGroups = state.groupSettings.map(({ name: groupName, position }) => ({
name: groupName,
position,
}));
publishCatalog(
mergeSessionGroupDefaults(pathFreeGroups, result),
state.sectionOrder,
"ready",
);
return "completed";
} catch (error) {
return finishMutationFailure(host.connection.isCurrent(scope), error);
}
};
return {
delete: remove,
dispose,
generation: () => catalogGeneration,
invalidate,
load,
put,
rename,
status: () => defaultsStatus,
update,
};
}
@@ -1,5 +1,6 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../../test/helpers/promise.js";
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import { createSessionCapability } from "./index.ts";
@@ -15,7 +16,9 @@ function createGateway(request: ReturnType<typeof vi.fn>, scopes: string[]): Ses
assistantAgentId: "main",
hello: {
auth: { role: "operator", scopes },
features: { methods: ["sessions.groups.list", "sessions.groups.put"] },
features: {
methods: ["sessions.groups.list", "sessions.groups.defaults", "sessions.groups.put"],
},
} as GatewayHelloOk,
},
subscribe: () => () => undefined,
@@ -23,6 +26,30 @@ function createGateway(request: ReturnType<typeof vi.fn>, scopes: string[]): Ses
};
}
function createGatewayHarness(request: ReturnType<typeof vi.fn>, scopes: string[]) {
const gateway = createGateway(request, scopes);
let snapshot = gateway.snapshot;
const listeners = new Set<(next: SessionGateway["snapshot"]) => void>();
return {
gateway: {
get snapshot() {
return snapshot;
},
subscribe(listener: (next: SessionGateway["snapshot"]) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeEvents: () => () => undefined,
} satisfies SessionGateway,
publish(connected: boolean) {
snapshot = { ...snapshot, phase: connected ? "connected" : "stopped" };
for (const listener of listeners) {
listener(snapshot);
}
},
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -59,6 +86,9 @@ describe("legacy session group migration", () => {
if (method === "sessions.groups.put") {
return { groups: [{ name: "Research" }] };
}
if (method === "sessions.groups.defaults") {
return { defaults: [{ name: "Research" }] };
}
throw new Error(`Unexpected request: ${method}`);
});
const sessions = createSessionCapability(createGateway(request, ["operator.write"]));
@@ -71,3 +101,192 @@ describe("legacy session group migration", () => {
sessions.dispose();
});
});
describe("session group catalog loading", () => {
it("clears cached cwd when a defaults update omits the removed value", async () => {
const request = vi.fn(async (method: string) => {
if (method === "sessions.groups.list") {
return { groups: [{ name: "Client", position: 0 }] };
}
if (method === "sessions.groups.defaults") {
return { defaults: [{ name: "Client", cwd: "/repos/client", worktree: true }] };
}
if (method === "sessions.groups.update") {
return { defaults: [{ name: "Client", worktree: false }] };
}
throw new Error(`Unexpected request: ${method}`);
});
const gateway = createGateway(request, ["operator.write"]);
gateway.snapshot.hello = {
...gateway.snapshot.hello,
features: {
methods: ["sessions.groups.list", "sessions.groups.defaults", "sessions.groups.update"],
},
} as GatewayHelloOk;
const sessions = createSessionCapability(gateway);
await sessions.groupsLoad();
expect(sessions.state.groupSettings).toEqual([
{ name: "Client", position: 0, cwd: "/repos/client", worktree: true },
]);
await expect(sessions.groupsUpdate("Client", { cwd: null, worktree: false })).resolves.toBe(
"completed",
);
expect(sessions.state.groupSettings).toEqual([
{ name: "Client", position: 0, worktree: false },
]);
sessions.dispose();
});
it("keeps a loaded path-free catalog when defaults are unavailable", async () => {
const request = vi.fn(async (method: string) => {
if (method === "sessions.groups.list") {
return { groups: [{ name: "Client", position: 0 }] };
}
if (method === "sessions.groups.defaults") {
throw new Error("defaults unavailable");
}
throw new Error(`Unexpected request: ${method}`);
});
const sessions = createSessionCapability(createGateway(request, ["operator.write"]));
await expect(sessions.groupsLoad()).resolves.toBeNull();
expect(sessions.state.groups).toEqual(["Client"]);
expect(sessions.state.groupSettings).toEqual([{ name: "Client", position: 0 }]);
expect(sessions.groupsStatus()).toBe("unavailable");
sessions.dispose();
});
it("keeps path-free mutations pending until fresh defaults arrive", async () => {
const refreshedDefaults = createDeferred<{
defaults: Array<{ name: string; cwd: string; worktree: boolean }>;
}>();
let defaultsCalls = 0;
const request = vi.fn(async (method: string) => {
if (method === "sessions.groups.list") {
return { groups: [{ name: "Client", position: 0 }] };
}
if (method === "sessions.groups.defaults") {
defaultsCalls += 1;
if (defaultsCalls === 1) {
throw new Error("defaults unavailable");
}
return await refreshedDefaults.promise;
}
if (method === "sessions.groups.put") {
return { groups: [{ name: "Client", position: 0 }] };
}
throw new Error(`Unexpected request: ${method}`);
});
const sessions = createSessionCapability(createGateway(request, ["operator.write"]));
await expect(sessions.groupsLoad()).resolves.toBeNull();
expect(sessions.groupsStatus()).toBe("unavailable");
await expect(sessions.groupsPut(["Client"])).resolves.toBe("completed");
await vi.waitFor(() => expect(defaultsCalls).toBe(2));
expect(sessions.state.groups).toEqual(["Client"]);
expect(sessions.groupsStatus()).toBe("loading");
refreshedDefaults.resolve({
defaults: [{ name: "Client", cwd: "/repos/client", worktree: true }],
});
await vi.waitFor(() => expect(sessions.groupsStatus()).toBe("ready"));
expect(sessions.state.groupSettings).toEqual([
{ name: "Client", position: 0, cwd: "/repos/client", worktree: true },
]);
sessions.dispose();
});
it("joins an in-flight load so route consumers see current defaults", async () => {
const defaults = createDeferred<{
defaults: Array<{ name: string; cwd: string; worktree: boolean }>;
}>();
const request = vi.fn(async (method: string) => {
if (method === "sessions.groups.list") {
return { groups: [{ name: "Client", position: 0 }] };
}
if (method === "sessions.groups.defaults") {
return await defaults.promise;
}
throw new Error(`Unexpected request: ${method}`);
});
const sessions = createSessionCapability(createGateway(request, ["operator.write"]));
const backgroundLoad = sessions.groupsLoad();
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("sessions.groups.defaults", {}));
let joined = false;
const routeLoad = sessions.groupsLoad().then(() => {
joined = true;
});
await Promise.resolve();
expect(joined).toBe(false);
defaults.resolve({
defaults: [{ name: "Client", cwd: "/repos/client", worktree: true }],
});
await Promise.all([backgroundLoad, routeLoad]);
expect(sessions.state.groupSettings).toEqual([
{ name: "Client", position: 0, cwd: "/repos/client", worktree: true },
]);
sessions.dispose();
});
it("returns only defaults owned by the current same-client connection", async () => {
const stale = createDeferred<{
defaults: Array<{ name: string; cwd: string; worktree: boolean }>;
}>();
const current = createDeferred<{
defaults: Array<{ name: string; cwd: string; worktree: boolean }>;
}>();
let calls = 0;
const request = vi.fn(async (method: string) => {
if (method === "sessions.subscribe") {
return { subscribed: true };
}
if (method === "sessions.list") {
return {
ts: 1,
path: "",
count: 0,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [],
};
}
if (method === "sessions.groups.list") {
return { groups: [{ name: "Client", position: 0 }] };
}
if (method === "sessions.groups.defaults") {
calls += 1;
return await (calls === 1 ? stale.promise : current.promise);
}
throw new Error(`Unexpected request: ${method}`);
});
const harness = createGatewayHarness(request, ["operator.write"]);
const sessions = createSessionCapability(harness.gateway);
const staleLoad = sessions.groupsLoad();
await vi.waitFor(() => expect(calls).toBe(1));
harness.publish(false);
harness.publish(true);
const currentLoad = sessions.groupsLoad();
await vi.waitFor(() => expect(calls).toBe(2));
stale.resolve({
defaults: [{ name: "Client", cwd: "/gateway-a", worktree: true }],
});
await expect(staleLoad).resolves.toBeNull();
current.resolve({
defaults: [{ name: "Client", cwd: "/gateway-b", worktree: false }],
});
await expect(currentLoad).resolves.toEqual([
{ name: "Client", position: 0, cwd: "/gateway-b", worktree: false },
]);
expect(sessions.state.groupSettings).toEqual([
{ name: "Client", position: 0, cwd: "/gateway-b", worktree: false },
]);
sessions.dispose();
});
});
@@ -283,6 +283,7 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
error,
deletedSessions: [],
groups: state.groups,
groupSettings: state.groupSettings,
sectionOrder: state.sectionOrder,
},
error ? "session-observer" : undefined,
@@ -1,12 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { SessionCapability } from "../../lib/sessions/session-capability.ts";
import {
allowsSelectedAgent,
GroupRouteRevalidation,
resolveAgentId,
resolveCreateTarget,
routeKey,
routeKeyFromSearch,
} from "./catalog-target.ts";
import type { NewSessionRouteData } from "./location.ts";
describe("new-session catalog target", () => {
const agents = [{ id: "main" }, { id: "research" }];
@@ -139,4 +142,38 @@ describe("new-session catalog target", () => {
expect(resolveAgentId(location, [], "main")).toBe("main");
expect(resolveAgentId(location, [{ id: "roboclaw" }], "roboclaw")).toBe("roboclaw");
});
it("revalidates when a missing group reappears with empty defaults", async () => {
let data: NewSessionRouteData = {
agentId: "main",
requestedAgentId: "main",
catalogId: "",
model: "",
catalogLabel: "",
startTerminal: false,
group: "Client",
groupStatus: "resolved",
groupCwd: "",
groupWorktree: false,
groupCatalogGeneration: 1,
groupDefaultsStatus: "ready",
};
const state = { groupSettings: [] as Array<{ name: string; position: number }> };
const sessions = {
state,
groupsGeneration: () => 1,
groupsStatus: () => "ready",
} as unknown as SessionCapability;
const revalidate = vi.fn(async () => {
data = { ...data, groupStatus: "missing" };
});
const coordinator = new GroupRouteRevalidation(() => data, revalidate);
coordinator.synchronize(sessions);
await vi.waitFor(() => expect(revalidate).toHaveBeenCalledTimes(1));
state.groupSettings = [{ name: "Client", position: 0 }];
coordinator.synchronize(sessions);
await vi.waitFor(() => expect(revalidate).toHaveBeenCalledTimes(2));
});
});
+122 -6
View File
@@ -3,11 +3,12 @@ import type { SessionsCatalogListResult } from "../../../../packages/gateway-pro
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import type { SessionCapability } from "../../lib/sessions/session-capability.ts";
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
import { newSessionLocationFromSearch, type NewSessionRouteData } from "./location.ts";
function draftRouteKey(requestedAgentId: string, catalogId: string): string {
return JSON.stringify([requestedAgentId, catalogId]);
function draftRouteKey(requestedAgentId: string, catalogId: string, group: string): string {
return JSON.stringify([requestedAgentId, catalogId, group]);
}
/**
@@ -17,22 +18,136 @@ function draftRouteKey(requestedAgentId: string, catalogId: string): string {
* would make that fill-in look like a navigation and discard the draft.
*/
export function routeKey(data?: NewSessionRouteData): string {
return draftRouteKey(data?.requestedAgentId ?? "", data?.catalogId ?? "");
return draftRouteKey(data?.requestedAgentId ?? "", data?.catalogId ?? "", data?.group ?? "");
}
export function routeKeyFromSearch(search: string): string {
const location = newSessionLocationFromSearch(search);
return draftRouteKey(location.agentId, location.catalogId);
return draftRouteKey(location.agentId, location.catalogId, location.group ?? "");
}
export function isTarget(data?: NewSessionRouteData): boolean {
return Boolean(data?.catalogId);
}
export function isResolvedTarget(data?: NewSessionRouteData): boolean {
function isResolvedTarget(data?: NewSessionRouteData): boolean {
return Boolean(data?.catalogId && data.model && data.catalogLabel);
}
function isPendingRouteTarget(data?: NewSessionRouteData): boolean {
return (
(isTarget(data) && !isResolvedTarget(data)) ||
Boolean(data?.group && data.groupStatus !== "resolved")
);
}
export function groupDefaultsKey(data?: NewSessionRouteData): string {
return JSON.stringify([
data?.groupStatus ?? "",
data?.groupCwd ?? "",
data?.groupWorktree === true,
data?.groupCatalogGeneration ?? -1,
data?.groupDefaultsStatus ?? "idle",
]);
}
function groupRouteNeedsRevalidation(
data: NewSessionRouteData | undefined,
sessions: SessionCapability,
): boolean {
const groupName = data?.group?.trim();
if (!groupName) {
return false;
}
const generation = sessions.groupsGeneration();
const status = sessions.groupsStatus();
if (data?.groupCatalogGeneration !== generation || data.groupDefaultsStatus !== status) {
return true;
}
if (status !== "ready") {
return false;
}
const current = sessions.state.groupSettings.find((group) => group.name === groupName);
return current
? data.groupStatus !== "resolved" ||
(data.groupCwd ?? "") !== (current.cwd ?? "") ||
data.groupWorktree !== (current.worktree === true)
: data.groupStatus === "resolved";
}
function groupRouteCatalogKey(
data: NewSessionRouteData | undefined,
sessions: SessionCapability,
): string {
const current = sessions.state.groupSettings.find((group) => group.name === data?.group);
return JSON.stringify([
data?.group ?? "",
sessions.groupsGeneration(),
sessions.groupsStatus(),
Boolean(current),
current?.cwd ?? "",
current?.worktree === true,
]);
}
export function isGroupRoutePending(
data: NewSessionRouteData | undefined,
sessions: SessionCapability | undefined,
): boolean {
return Boolean(data?.group && (!sessions || groupRouteNeedsRevalidation(data, sessions)));
}
export function isRoutePending(
data: NewSessionRouteData | undefined,
sessions: SessionCapability | undefined,
): boolean {
return isPendingRouteTarget(data) || isGroupRoutePending(data, sessions);
}
export function resolvedGroupName(
data: NewSessionRouteData | undefined,
sessions: SessionCapability | undefined,
): string | undefined {
return data?.groupStatus === "resolved" && !isGroupRoutePending(data, sessions)
? data.group
: undefined;
}
export class GroupRouteRevalidation {
private pending: Promise<unknown> | null = null;
private lastKey = "";
constructor(
private readonly readData: () => NewSessionRouteData | undefined,
private readonly revalidate: () => Promise<unknown> | undefined,
) {}
synchronize(sessions: SessionCapability) {
if (this.pending) {
return;
}
const data = this.readData();
const key = groupRouteCatalogKey(data, sessions);
if (this.lastKey === key || !groupRouteNeedsRevalidation(data, sessions)) {
return;
}
const pending = this.revalidate();
if (!pending) {
return;
}
this.lastKey = key;
this.pending = pending;
void pending
.catch(() => undefined)
.finally(() => {
if (this.pending === pending) {
this.pending = null;
this.synchronize(sessions);
}
});
}
}
export function resolveAgentId(
data: Pick<NewSessionRouteData, "agentId" | "catalogId"> | undefined,
availableAgents: readonly { id: string }[],
@@ -101,8 +216,9 @@ export function renderBar(params: {
placeSelect: unknown;
retrying: boolean;
onRetry: () => void;
groupPending?: boolean;
}) {
const pending = isTarget(params.data) && !isResolvedTarget(params.data);
const pending = isPendingRouteTarget(params.data) || params.groupPending === true;
return html`
<div class="new-session-page__triggers">
${renderTarget(params.data)} ${isTarget(params.data) ? nothing : params.agentSelect}
@@ -393,6 +393,7 @@ describe("cloud session recovery", () => {
key: recovery.sessionKey,
agentId: "cloud",
message: "" as const,
category: "Client work",
thinkingLevel: "high",
worktree: true as const,
},
@@ -155,6 +155,25 @@ describe("buildDraftSessionCreateParams", () => {
});
});
it("assigns a session created from a custom group", () => {
expect(
buildDraftSessionCreateParams({
agentId: "main",
message: "start grouped work",
worktree: true,
cwd: "/repos/client",
workspace: "/workspace",
category: " Client work ",
}),
).toEqual({
agentId: "main",
message: "start grouped work",
category: "Client work",
cwd: "/repos/client",
worktree: true,
});
});
it("sends a custom Gateway folder without requiring a worktree", () => {
expect(
buildDraftSessionCreateParams({
@@ -40,11 +40,13 @@ export function buildDraftSessionCreateParams(draft: {
workspace?: string;
execNode?: string;
catalogId?: string;
category?: string;
}): Record<string, unknown> {
const cwd = normalizeOptionalString(draft.cwd);
const workspace = normalizeOptionalString(draft.workspace);
const execNode = normalizeOptionalString(draft.execNode);
const catalogId = normalizeOptionalString(draft.catalogId);
const category = normalizeOptionalString(draft.category);
const model = normalizeOptionalString(draft.model);
const thinkingLevel = normalizeOptionalString(draft.thinkingLevel);
const projectId = normalizeOptionalString(draft.projectId);
@@ -57,6 +59,7 @@ export function buildDraftSessionCreateParams(draft: {
...(draft.visibility === "draft" ? { visibility: "draft" } : {}),
...(draft.attachments?.length ? { attachments: draft.attachments } : {}),
...(catalogId ? { catalogId } : {}),
...(category ? { category } : {}),
...(!catalogId && model ? { model } : {}),
...(!catalogId && thinkingLevel ? { thinkingLevel } : {}),
...(projectId ? { projectId } : {}),
@@ -188,6 +188,16 @@ export class DraftGatewayState {
return this.preferenceModeValue === "loading";
}
resolvedGroupCategory(): string | undefined {
const snapshot = this.read();
return isGatewayMethodAdvertised(
snapshot.context?.gateway.snapshot ?? {},
"sessions.groups.defaults",
) === true
? catalog.resolvedGroupName(snapshot.data, snapshot.context?.sessions)
: undefined;
}
refreshCloudProfiles() {
return this.cloudProfileTask.run();
}
@@ -269,11 +279,16 @@ export class DraftGatewayState {
}
retryPendingCatalogTarget() {
const { data } = this.read();
const { context, data } = this.read();
if (this.catalogRetryingValue) {
return;
}
if (!this.gatewayConnectedValue || !catalog.isTarget(data) || catalog.isResolvedTarget(data)) {
if (data?.group && context?.sessions.groupsStatus() === "loading") {
globalThis.clearTimeout(this.catalogRetryTimer);
this.catalogRetryTimer = undefined;
return;
}
if (!this.gatewayConnectedValue || !catalog.isRoutePending(data, context?.sessions)) {
globalThis.clearTimeout(this.catalogRetryTimer);
this.catalogRetryTimer = undefined;
this.catalogRetryScope = "";
@@ -298,11 +313,14 @@ export class DraftGatewayState {
if (
this.catalogRetryScope !== retryScope ||
!this.gatewayConnectedValue ||
!catalog.isTarget(current.data) ||
catalog.isResolvedTarget(current.data)
(current.data?.group && current.context?.sessions.groupsStatus() === "loading") ||
!catalog.isRoutePending(current.data, current.context?.sessions)
) {
return;
}
if (current.data?.group) {
current.context?.sessions.groupsInvalidate();
}
const revalidation = current.context?.revalidate("new-session");
if (!revalidation) {
return;
@@ -319,11 +337,14 @@ export class DraftGatewayState {
if (
this.catalogRetryingValue ||
!this.gatewayConnectedValue ||
!catalog.isTarget(data) ||
catalog.isResolvedTarget(data)
(data?.group && context?.sessions.groupsStatus() === "loading") ||
!catalog.isRoutePending(data, context?.sessions)
) {
return;
}
if (data?.group) {
context?.sessions.groupsInvalidate();
}
const revalidation = context?.revalidate("new-session");
if (!revalidation) {
return;
@@ -344,7 +365,11 @@ export class DraftGatewayState {
readPreference(agentId: string): NewSessionPreference | null {
const snapshot = this.read();
if (catalog.isTarget(snapshot.data) || snapshot.pendingCloud.sessionKey) {
if (
catalog.isTarget(snapshot.data) ||
snapshot.data?.group ||
snapshot.pendingCloud.sessionKey
) {
return null;
}
return this.preferenceModeValue === "remote"
@@ -354,7 +379,11 @@ export class DraftGatewayState {
persistPreference(agentIdValue: string, workspace: string, patch: NewSessionPreference) {
const snapshot = this.read();
if (catalog.isTarget(snapshot.data) || snapshot.pendingCloud.sessionKey) {
if (
catalog.isTarget(snapshot.data) ||
snapshot.data?.group ||
snapshot.pendingCloud.sessionKey
) {
return;
}
const agentId = normalizeAgentId(agentIdValue);
@@ -1,8 +1,10 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ApplicationContext } from "../../app/context.ts";
import { DraftGatewayState } from "./draft-gateway-state.ts";
import { DraftPlaceBrowser } from "./draft-place-browser.ts";
import type { NewSessionRouteData } from "./location.ts";
import { loadNewSessionPreference, patchNewSessionPreference } from "./preferences.ts";
class ControllerHost implements ReactiveControllerHost {
readonly updateComplete = Promise.resolve(true);
@@ -11,7 +13,11 @@ class ControllerHost implements ReactiveControllerHost {
requestUpdate() {}
}
function createBrowser(request: (method: string) => Promise<unknown>) {
afterEach(() => {
localStorage.clear();
});
function createBrowser(request: (method: string) => Promise<unknown>, data?: NewSessionRouteData) {
const host = new ControllerHost();
const client = { request, recoveryScope: "principal-a", recoveryScopeReady: true };
const context = {
@@ -26,12 +32,19 @@ function createBrowser(request: (method: string) => Promise<unknown>) {
},
},
},
sessions: {
state: {
groupSettings: [{ name: "Client", cwd: "/workspace/client", worktree: false }],
},
groupsGeneration: () => 1,
groupsStatus: () => "ready",
},
} as unknown as ApplicationContext;
const gateway = new DraftGatewayState(
host,
() => ({
context,
data: undefined,
data,
isConnected: true,
isAdmin: false,
canStartAsDraft: false,
@@ -73,7 +86,7 @@ function createBrowser(request: (method: string) => Promise<unknown>) {
body: () => null,
},
);
return browser;
return { browser, gateway };
}
describe("DraftPlaceBrowser", () => {
@@ -86,7 +99,7 @@ describe("DraftPlaceBrowser", () => {
},
],
])("keeps roster recents when %s", async (_label, request) => {
const browser = createBrowser(request);
const { browser } = createBrowser(request);
await browser.refreshProjects();
@@ -107,3 +120,36 @@ describe("DraftPlaceBrowser", () => {
]);
});
});
describe("DraftGatewayState", () => {
it("keeps group route defaults isolated from ordinary New Session preferences", () => {
patchNewSessionPreference("ws://gateway.example", "main", {
folder: "/workspace/ordinary",
worktree: true,
});
const { gateway } = createBrowser(async () => ({}), {
agentId: "main",
requestedAgentId: "main",
catalogId: "",
group: "Client",
groupStatus: "resolved",
groupCwd: "/workspace/client",
groupWorktree: false,
groupCatalogGeneration: 1,
groupDefaultsStatus: "ready",
model: "",
catalogLabel: "",
startTerminal: false,
});
expect(gateway.readPreference("main")).toBeNull();
gateway.persistPreference("main", "/workspace", {
folder: "/workspace/client",
worktree: false,
});
expect(loadNewSessionPreference("ws://gateway.example", "main")).toEqual({
folder: "/workspace/ordinary",
worktree: true,
});
});
});
+35 -5
View File
@@ -131,6 +131,22 @@ export class DraftPlaceState {
return this.repositoryState.preferenceReady;
}
canAdoptGroupDefaults(): boolean {
return (
!this.folderSelectedByUser &&
!this.whereSelectedByUser &&
!this.projectSelectedByUser &&
!this.repositoryState.hasUserSelection
);
}
adoptGroupDefaults() {
if (this.read().data?.groupStatus !== "resolved" || !this.canAdoptGroupDefaults()) {
return;
}
this.adoptAgentDefaults({ preserveSelectedAgent: true });
}
setAgentsHydrated(value: boolean) {
this.agentsHydratedValue = value;
}
@@ -242,16 +258,30 @@ export class DraftPlaceState {
storedFolder === preference?.workspace &&
preference.workspace !== workspace;
const storedFolderUsable = Boolean(storedFolder) && !storedWorkspaceMoved;
this.folderValue = storedFolderUsable ? storedFolder : workspace;
const groupTarget = Boolean(snapshot.data?.group);
const groupFolder = snapshot.data?.groupCwd ?? "";
const groupWorktree = snapshot.data?.groupWorktree === true;
this.folderValue = groupTarget
? groupFolder || workspace
: storedFolderUsable
? storedFolder
: workspace;
this.folderGatewayApproved = false;
this.folderSelectedByUser = false;
this.repositoryState.adoptPreference(preference);
const preferredWhere = preference?.where ?? { kind: "local" };
this.repositoryState.adoptPreference(groupTarget ? { worktree: groupWorktree } : preference);
if (groupTarget) {
// Group defaults own the initial local/worktree choice. Repository
// discovery still rejects worktrees when the selected folder is not Git.
this.repositoryState.forceWorktree(groupWorktree);
}
const preferredWhere = groupTarget
? { kind: "local" as const }
: (preference?.where ?? { kind: "local" as const });
this.preferredWhereRestore = preferredWhere.kind === "local" ? null : preferredWhere;
this.preferredProjectRestore = preference?.projectId ?? "";
this.preferredProjectRestore = groupTarget ? "" : (preference?.projectId ?? "");
this.whereSelectedByUser = false;
this.projectSelectedByUser = false;
if (storedWorkspaceMoved) {
if (storedWorkspaceMoved && !groupTarget) {
this.persistPreference({ folder: workspace });
}
}
@@ -32,6 +32,7 @@ export class DraftRepositoryController {
private preferredWorktreeRestore = false;
private preferredBaseRefRestore = "";
private worktreeSelectedByUser = false;
private detailsSelectedByUser = false;
constructor(
private readonly read: () => DraftRepositorySnapshot,
@@ -58,11 +59,16 @@ export class DraftRepositoryController {
return !this.preferredWorktreeRestore;
}
get hasUserSelection(): boolean {
return this.worktreeSelectedByUser || this.detailsSelectedByUser;
}
adoptPreference(preference: NewSessionPreference | null) {
this.preferredWorktreeRestore = preference?.worktree === true;
this.preferredBaseRefRestore = preference?.baseRef ?? "";
this.worktreeNameValue = preference?.worktreeName ?? "";
this.worktreeSelectedByUser = false;
this.detailsSelectedByUser = false;
}
reset() {
@@ -75,6 +81,7 @@ export class DraftRepositoryController {
this.preferredWorktreeRestore = false;
this.preferredBaseRefRestore = "";
this.worktreeSelectedByUser = false;
this.detailsSelectedByUser = false;
}
invalidate() {
@@ -125,6 +132,7 @@ export class DraftRepositoryController {
this.baseRefEditGeneration += 1;
this.baseRefValue = baseRef;
this.preferredBaseRefRestore = "";
this.detailsSelectedByUser = true;
this.callbacks.persistPreference({ baseRef });
this.callbacks.requestUpdate();
}
@@ -134,6 +142,7 @@ export class DraftRepositoryController {
return;
}
this.worktreeNameValue = worktreeName;
this.detailsSelectedByUser = true;
this.callbacks.persistPreference({ worktreeName });
this.callbacks.requestUpdate();
}
@@ -0,0 +1,13 @@
import type { ApplicationContext } from "../../app/context.ts";
import type { NewSessionRouteData } from "./location.ts";
export type DraftSubmissionSnapshot = Readonly<{
context: ApplicationContext | undefined;
data: NewSessionRouteData | undefined;
isConnected: boolean;
}>;
export type DraftSubmissionCallbacks = {
requestUpdate: () => void;
closeTransientUi: () => void;
};
@@ -32,20 +32,12 @@ import {
} from "./create-params.ts";
import type { DraftGatewayState } from "./draft-gateway-state.ts";
import type { DraftPlaceState } from "./draft-place-state.ts";
import type { NewSessionRouteData } from "./location.ts";
import type {
DraftSubmissionCallbacks,
DraftSubmissionSnapshot,
} from "./draft-submission-contract.ts";
import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts";
type DraftSubmissionSnapshot = Readonly<{
context: ApplicationContext | undefined;
data: NewSessionRouteData | undefined;
isConnected: boolean;
}>;
type DraftSubmissionCallbacks = {
requestUpdate: () => void;
closeTransientUi: () => void;
};
export class DraftSubmissionFlow {
private visibilityValue: NewSessionVisibility = "normal";
private messageValue = "";
@@ -153,6 +145,7 @@ export class DraftSubmissionFlow {
visibility?: NewSessionVisibility;
} = {},
): Record<string, unknown> {
const snapshot = this.read();
return assembleDraftSessionCreateParams({
agentId: this.place.agentId,
message: options.message ?? "",
@@ -167,7 +160,8 @@ export class DraftSubmissionFlow {
cwd: this.place.folder,
workspace: this.place.workspacePath(),
execNode: this.place.execNode,
catalogId: this.read().data?.catalogId,
catalogId: snapshot.data?.catalogId,
category: this.gateway.resolvedGroupCategory(),
});
}
@@ -200,6 +194,9 @@ export class DraftSubmissionFlow {
}
submitDisabledReason(): string | undefined {
if (catalog.isRoutePending(this.read().data, this.read().context?.sessions)) {
return t("newSession.catalogUnavailable");
}
if (this.place.modelControl.isModelUnavailable(this.place.selectedAgent())) {
return `${t("modelSetup.failure.auth")}. ${t("modelSetup.failureGuidance.auth")}`;
}
@@ -232,6 +229,7 @@ export class DraftSubmissionFlow {
this.submittingValue ||
this.gateway.preferenceLoading ||
this.requiresModelSetup() ||
catalog.isRoutePending(this.read().data, this.read().context?.sessions) ||
this.place.modelControl.isModelUnavailable(this.place.selectedAgent()) ||
this.attachmentDraft.pendingReads > 0 ||
(!pendingCloud && this.submissionOutcomeUnknownValue) ||
+13
View File
@@ -13,6 +13,18 @@ describe("new-session location", () => {
).toEqual({
agentId: "main/agent",
catalogId: "claude",
group: "",
});
});
it("round-trips a custom group target", () => {
const search = newSessionSearch("main", { group: "Client work" });
expect(search).toBe("?agent=main&group=Client+work");
expect(newSessionLocationFromSearch(search)).toEqual({
agentId: "main",
catalogId: "",
group: "Client work",
});
});
@@ -21,6 +33,7 @@ describe("new-session location", () => {
expect(newSessionLocationFromSearch("")).toEqual({
agentId: "",
catalogId: "",
group: "",
});
});
});
+15 -3
View File
@@ -4,30 +4,42 @@ export type NewSessionRouteData = {
/** The agent the URL asked for, which only a navigation can change. */
requestedAgentId: string;
catalogId: string;
group?: string;
groupStatus?: "resolved" | "missing" | "unavailable";
groupCwd?: string;
groupWorktree?: boolean;
groupCatalogGeneration?: number;
groupDefaultsStatus?: import("../../lib/sessions/session-capability.ts").SessionGroupDefaultsStatus;
model: string;
catalogLabel: string;
startTerminal: boolean;
};
export type NewSessionTarget = { catalogId: string };
export type NewSessionTarget =
| { catalogId: string; group?: never }
| { group: string; catalogId?: never };
export function newSessionSearch(agentId: string, target?: NewSessionTarget): string {
const params = new URLSearchParams();
if (agentId) {
params.set("agent", agentId);
}
if (target) {
if (target?.catalogId) {
params.set("catalog", target.catalogId);
}
if (target?.group) {
params.set("group", target.group);
}
return params.size > 0 ? `?${params.toString()}` : "";
}
export function newSessionLocationFromSearch(
search: string,
): Pick<NewSessionRouteData, "agentId" | "catalogId"> {
): Pick<NewSessionRouteData, "agentId" | "catalogId" | "group"> {
const params = new URLSearchParams(search);
return {
agentId: params.get("agent")?.trim() ?? "",
catalogId: params.get("catalog")?.trim() ?? "",
group: params.get("group")?.trim() ?? "",
};
}
+20 -1
View File
@@ -52,6 +52,7 @@ class NewSessionPage extends OpenClawLightDomElement {
private context?: ApplicationContext;
private openedFor: string | null = null;
private openedGroupDefaults = "";
private openedAgentId = "";
private messageOwnerKey = "";
private presenceSignature = "";
@@ -61,6 +62,10 @@ class NewSessionPage extends OpenClawLightDomElement {
private connectMachineSetup: DevicePairSetup | null = null;
private connectMachineRequestId = 0;
@state() private imageLightbox: ImageLightboxItem | null = null;
private readonly groupRouteRevalidation = new catalog.GroupRouteRevalidation(
() => this.data,
() => this.context?.revalidate("new-session"),
);
private readonly gateway: DraftGatewayState;
private readonly browser: DraftPlaceBrowser;
private readonly place: DraftPlaceState;
@@ -193,6 +198,7 @@ class NewSessionPage extends OpenClawLightDomElement {
.watch(
() => this.context?.sessions,
(sessions, notify) => sessions.subscribe(notify),
(sessions) => this.groupRouteRevalidation.synchronize(sessions),
)
.watch(
() => this.context?.config,
@@ -255,15 +261,21 @@ class NewSessionPage extends OpenClawLightDomElement {
? catalog.routeKey(this.data)
: catalog.routeKeyFromSearch(window.location.search);
const resolvedAgentId = this.data?.agentId ?? "";
const groupDefaults = catalog.groupDefaultsKey(this.data);
if (this.openedFor !== openKey) {
const ownedMessage = this.messageOwnerKey === openKey ? this.submission.message : "";
this.openedFor = openKey;
this.openedGroupDefaults = groupDefaults;
this.openedAgentId = resolvedAgentId;
this.place.setAgentsHydrated(agentsReady);
this.resetDraft();
this.messageOwnerKey = restoreDraft(this.context, this.submission, openKey, ownedMessage);
return;
}
if (this.openedGroupDefaults !== groupDefaults) {
this.openedGroupDefaults = groupDefaults;
this.place.adoptGroupDefaults();
}
if (this.openedAgentId !== resolvedAgentId) {
this.openedAgentId = resolvedAgentId;
this.place.setAgentsHydrated(false);
@@ -329,11 +341,15 @@ class NewSessionPage extends OpenClawLightDomElement {
private renderTargetBar() {
const agents = this.place.agents();
const sessions = this.context?.sessions;
return catalog.renderBar({
data: this.data,
groupPending: catalog.isGroupRoutePending(this.data, sessions),
agentSelect: agents.length > 1 ? this.renderAgentSelect() : nothing,
placeSelect: this.renderPlaceChips(),
retrying: this.gateway.catalogRetrying,
retrying:
this.gateway.catalogRetrying ||
Boolean(this.data?.group && sessions?.groupsStatus() === "loading"),
onRetry: this.gateway.handleCatalogRetry,
});
}
@@ -700,3 +716,6 @@ class NewSessionPage extends OpenClawLightDomElement {
if (!customElements.get("openclaw-new-session-page")) {
customElements.define("openclaw-new-session-page", NewSessionPage);
}
export const render = (data: unknown) =>
html`<openclaw-new-session-page .data=${data}></openclaw-new-session-page>`;
+111
View File
@@ -0,0 +1,111 @@
import type { ApplicationContext } from "../../app/context.ts";
import { listSelectableAgents } from "../../lib/agents/display.ts";
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
import { resolveAgentId, resolveCreateTarget } from "./catalog-target.ts";
import { newSessionLocationFromSearch, type NewSessionRouteData } from "./location.ts";
export async function load(
context: ApplicationContext,
search: string,
): Promise<NewSessionRouteData> {
const requestedLocation = newSessionLocationFromSearch(search);
const requestedAgentId = requestedLocation.agentId.trim();
let groupCwd = "";
let groupWorktree = false;
let groupStatus: NewSessionRouteData["groupStatus"];
let groupCatalogGeneration: number | undefined;
let groupDefaultsStatus: NewSessionRouteData["groupDefaultsStatus"];
if (requestedLocation.group) {
const startedGeneration = context.sessions.groupsGeneration();
const settings = await context.sessions.groupsLoad();
groupCatalogGeneration = context.sessions.groupsGeneration();
groupDefaultsStatus = context.sessions.groupsStatus();
const currentSettings =
startedGeneration === groupCatalogGeneration && groupDefaultsStatus === "ready"
? settings
: null;
const group = currentSettings?.find((candidate) => candidate.name === requestedLocation.group);
groupStatus = currentSettings === null ? "unavailable" : group ? "resolved" : "missing";
groupCwd = group?.cwd ?? "";
groupWorktree = group?.worktree === true;
}
if (!requestedLocation.catalogId) {
return {
...requestedLocation,
requestedAgentId,
groupStatus,
groupCwd,
groupWorktree,
groupCatalogGeneration,
groupDefaultsStatus,
model: "",
catalogLabel: "",
startTerminal: false,
};
}
const unresolved = (agentId = ""): NewSessionRouteData => ({
...requestedLocation,
agentId,
requestedAgentId,
groupStatus,
groupCwd,
groupWorktree,
groupCatalogGeneration,
groupDefaultsStatus,
model: "",
catalogLabel: "",
startTerminal: false,
});
const initialGateway = context.gateway.snapshot;
const initialAgentsState = context.agents.state;
if (
initialGateway.phase !== "connected" ||
!initialGateway.client ||
!initialAgentsState.connected ||
initialAgentsState.client !== initialGateway.client
) {
return unresolved();
}
// ensureList is fail-closed: offline and request-error paths return cached
// data or null, allowing the unresolved catalog page to mount and retry.
const loadedAgentsList = initialAgentsState.agentsList ?? (await context.agents.ensureList());
const gateway = context.gateway.snapshot;
const agentsState = context.agents.state;
if (
gateway.phase !== "connected" ||
!gateway.client ||
gateway.client !== initialGateway.client ||
!agentsState.connected ||
agentsState.client !== gateway.client ||
agentsState.agentsList !== loadedAgentsList
) {
return unresolved();
}
const agentsList = loadedAgentsList;
const availableAgents = listSelectableAgents(agentsList?.agents ?? []);
const gatewayDefaultId =
gateway.phase === "connected" && gateway.hello ? gateway.assistantAgentId : null;
if (
!agentsList &&
requestedAgentId &&
(!gatewayDefaultId || normalizeAgentId(requestedAgentId) !== normalizeAgentId(gatewayDefaultId))
) {
return unresolved();
}
const fallbackAgentId = agentsList
? availableAgents.some((agent) => agent.id === agentsList.defaultId)
? agentsList.defaultId
: availableAgents[0]?.id
: gatewayDefaultId;
const agentId = fallbackAgentId
? agentsList
? resolveAgentId(requestedLocation, availableAgents, fallbackAgentId)
: resolveAgentId(undefined, [], fallbackAgentId)
: "";
const plain = unresolved(agentId);
if (gateway.phase !== "connected" || !gateway.client || !agentId) {
return plain;
}
const target = await resolveCreateTarget(gateway.client, requestedLocation.catalogId, agentId);
return target ? { ...plain, ...target } : plain;
}
+37
View File
@@ -56,6 +56,43 @@ function createContext(params: {
}
describe("new-session route catalog target", () => {
it("does not apply group defaults from a retired connection", async () => {
const context = {
sessions: {
state: {
groupSettings: [
{ name: "Client", position: 0, cwd: "/gateway-a/client", worktree: true },
],
},
groupsLoad: vi.fn(async () => null),
groupsGeneration: vi.fn(() => 1),
groupsStatus: vi.fn(() => "unavailable"),
},
} as unknown as ApplicationContext;
const data = await loadNewSessionData(context, "?group=Client");
expect(data.groupStatus).toBe("unavailable");
expect(data.groupCwd).toBe("");
expect(data.groupWorktree).toBe(false);
});
it("marks a deleted group target missing", async () => {
const context = {
sessions: {
state: { groupSettings: [] },
groupsLoad: vi.fn(async () => []),
groupsGeneration: vi.fn(() => 1),
groupsStatus: vi.fn(() => "ready"),
},
} as unknown as ApplicationContext;
const data = await loadNewSessionData(context, "?group=Deleted");
expect(data.group).toBe("Deleted");
expect(data.groupStatus).toBe("missing");
});
it("defers an unvalidated route agent before roster hydration", async () => {
const { context, request } = createContext({
assistantAgentId: "roboclaw",
+3 -89
View File
@@ -1,98 +1,12 @@
import type { RouteLocation } from "@openclaw/uirouter";
import { definePage } from "@openclaw/uirouter";
import { html } from "lit";
import { routePageSpec } from "../../app-route-paths.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { listSelectableAgents } from "../../lib/agents/display.ts";
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
import { newSessionLocationFromSearch, type NewSessionRouteData } from "./location.ts";
async function loadNewSessionData(
context: ApplicationContext,
search: string,
): Promise<NewSessionRouteData> {
const requestedLocation = newSessionLocationFromSearch(search);
const requestedAgentId = requestedLocation.agentId.trim();
if (!requestedLocation.catalogId) {
return {
...requestedLocation,
requestedAgentId,
model: "",
catalogLabel: "",
startTerminal: false,
};
}
const { resolveAgentId, resolveCreateTarget } = await import("./catalog-target.ts");
const unresolved = (agentId = ""): NewSessionRouteData => ({
...requestedLocation,
agentId,
requestedAgentId,
model: "",
catalogLabel: "",
startTerminal: false,
});
const initialGateway = context.gateway.snapshot;
const initialAgentsState = context.agents.state;
if (
initialGateway.phase !== "connected" ||
!initialGateway.client ||
!initialAgentsState.connected ||
initialAgentsState.client !== initialGateway.client
) {
return unresolved();
}
// ensureList is fail-closed: offline and request-error paths return cached
// data or null, allowing the unresolved catalog page to mount and retry.
const loadedAgentsList = initialAgentsState.agentsList ?? (await context.agents.ensureList());
const gateway = context.gateway.snapshot;
const agentsState = context.agents.state;
if (
gateway.phase !== "connected" ||
!gateway.client ||
gateway.client !== initialGateway.client ||
!agentsState.connected ||
agentsState.client !== gateway.client ||
agentsState.agentsList !== loadedAgentsList
) {
return unresolved();
}
const agentsList = loadedAgentsList;
const availableAgents = listSelectableAgents(agentsList?.agents ?? []);
const gatewayDefaultId =
gateway.phase === "connected" && gateway.hello ? gateway.assistantAgentId : null;
if (
!agentsList &&
requestedAgentId &&
(!gatewayDefaultId || normalizeAgentId(requestedAgentId) !== normalizeAgentId(gatewayDefaultId))
) {
return unresolved();
}
const fallbackAgentId = agentsList
? availableAgents.some((agent) => agent.id === agentsList.defaultId)
? agentsList.defaultId
: availableAgents[0]?.id
: gatewayDefaultId;
const agentId = fallbackAgentId
? agentsList
? resolveAgentId(requestedLocation, availableAgents, fallbackAgentId)
: resolveAgentId(undefined, [], fallbackAgentId)
: "";
const plain = unresolved(agentId);
if (gateway.phase !== "connected" || !gateway.client || !agentId) {
return plain;
}
const target = await resolveCreateTarget(gateway.client, requestedLocation.catalogId, agentId);
return target ? { ...plain, ...target } : plain;
}
export const page = definePage({
...routePageSpec("new-session"),
loaderDeps: (_context: ApplicationContext, location: RouteLocation) => location.search,
loader: (context: ApplicationContext, { location }) =>
loadNewSessionData(context, location.search),
component: () =>
import("./new-session-page.ts").then(() => ({
render: (data: unknown) =>
html`<openclaw-new-session-page .data=${data}></openclaw-new-session-page>`,
})),
loader: async (context: ApplicationContext, { location }) =>
(await import("./route-loader.ts")).load(context, location.search),
component: () => import("./new-session-page.ts"),
});
-5
View File
@@ -767,9 +767,4 @@ wa-dropdown.new-session-page__start-menu {
.new-session-page__select {
position: static;
}
wa-popover.new-session-page__select::part(body) {
width: auto;
max-width: none;
}
}
@@ -4,6 +4,7 @@ import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
import {
createGatewayHarness,
createSessionsHarness,
deferred,
mountSidebar,
type SessionGroupMutationResult,
type SidebarLifecycleState,
@@ -83,7 +84,7 @@ describe("AppSidebar group mutation collapsed state", () => {
async function openGroupMenu(sidebar: SidebarLifecycleState) {
const actions = sidebar.querySelector<HTMLButtonElement>(
'[data-session-section="category:Alpha"] .sidebar-session-group-actions',
'[data-session-section="category:Alpha"] .sidebar-session-group-actions[aria-haspopup="menu"]',
);
if (!actions) {
throw new Error("expected group actions trigger");
@@ -97,16 +98,36 @@ describe("AppSidebar group mutation collapsed state", () => {
return menu;
}
function selectGroupMenuAction(menu: Element, value: string) {
const item = menu.querySelector(`[value="${value}"]`);
if (!item) {
throw new Error(`expected ${value} group action`);
}
menu.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
cancelable: true,
detail: { item: { value } },
}),
);
}
async function renameGroupThroughDialog(sidebar: SidebarLifecycleState, name: string) {
const menu = await openGroupMenu(sidebar);
menu.querySelectorAll<HTMLButtonElement>(".session-menu__item")[0]?.click();
selectGroupMenuAction(menu, "rename-group");
await waitForFast(() => {
const input = document.body.querySelector('openclaw-modal-dialog input[name="value"]');
if (!(input instanceof HTMLInputElement)) {
throw new Error("expected group rename input");
}
return input;
});
await submitInputDialog(name);
}
async function deleteGroupThroughConfirm(sidebar: SidebarLifecycleState) {
const menu = await openGroupMenu(sidebar);
const items = menu.querySelectorAll<HTMLButtonElement>(".session-menu__item");
items[items.length - 1]?.click();
selectGroupMenuAction(menu, "delete-group");
const confirm = await waitForFast(() => {
const button = document.body.querySelector<HTMLButtonElement>(
"openclaw-modal-dialog .exec-approval-actions .btn.danger",
@@ -194,8 +215,7 @@ describe("AppSidebar group mutation collapsed state", () => {
const toast = document.body.appendChild(document.createElement("openclaw-toast-host"));
await toast.updateComplete;
const menu = await openGroupMenu(sidebar);
const items = menu.querySelectorAll<HTMLButtonElement>(".session-menu__item");
items[items.length - 1]?.click();
selectGroupMenuAction(menu, "delete-group");
const confirm = await waitForFast(() => {
const button = document.body.querySelector<HTMLButtonElement>(
"openclaw-modal-dialog .exec-approval-actions .btn.danger",
@@ -225,11 +245,42 @@ describe("AppSidebar group mutation collapsed state", () => {
toast.remove();
});
it("rejects a folder listing from a retired same-client connection", async () => {
const listing = deferred<{
path: string;
home: string;
entries: Array<{ name: string; path: string; type: "directory" }>;
}>();
const client = {
request: async (method: string) => {
if (method === "fs.listDir") {
return await listing.promise;
}
throw new Error(`Unexpected request: ${method}`);
},
} as GatewayBrowserClient;
const gatewayHarness = createGatewayHarness(client);
const harness = createSessionsHarness("main", ["agent:main:main"]);
const { sidebar } = await mountSidebar(gatewayHarness.gateway, harness.sessions);
const pending = sidebar.listSessionGroupFolders("/repos/client");
gatewayHarness.publish({ phase: "stopped" });
gatewayHarness.publish({ phase: "connected" });
listing.resolve({
path: "/repos/client",
home: "/home/peter",
entries: [],
});
await expect(pending).rejects.toThrow(
"Gateway connection replaced before the defaults were saved. Try again.",
);
});
it("leaves the catalog alone when the delete confirm is cancelled", async () => {
const { sidebar, harness } = await mountCollapsedGroup({});
const menu = await openGroupMenu(sidebar);
const items = menu.querySelectorAll<HTMLButtonElement>(".session-menu__item");
items[items.length - 1]?.click();
selectGroupMenuAction(menu, "delete-group");
// Cancel is the focused default; answering it must end the flow with the
// group, its members and the collapsed key untouched.
const cancel = await waitForFast(() => {
+9
View File
@@ -70,6 +70,11 @@ export type SidebarLifecycleState = HTMLElement & {
) => void;
readonly sessionData: SessionDataController;
readonly sessionOrganizer: SessionOrganizerController;
listSessionGroupFolders(path?: string): Promise<{
path: string;
home: string;
entries: Array<{ name: string; path: string; type: "directory" | "file" }>;
}>;
requestUpdate: () => void;
updateComplete: Promise<boolean>;
updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null;
@@ -202,6 +207,7 @@ export function createSessionState(agentId: string, keys: string[]): SessionStat
error: null,
deletedSessions: [],
groups: [],
groupSettings: [],
sectionOrder: [],
};
}
@@ -310,6 +316,9 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
}
},
groupsLoad: () => Promise.resolve(),
groupsGeneration: () => 0,
groupsStatus: () => "ready",
groupsInvalidate: () => undefined,
groupsPut,
groupsRename,
groupsDelete,
+34
View File
@@ -221,9 +221,11 @@ const defaultControlUiFeatureMethods = [
"sessions.dispatch",
"sessions.fork",
"sessions.groups.delete",
"sessions.groups.defaults",
"sessions.groups.list",
"sessions.groups.put",
"sessions.groups.rename",
"sessions.groups.update",
"sessions.patch",
"sessions.reclaim",
"sessions.reset",
@@ -231,6 +233,7 @@ const defaultControlUiFeatureMethods = [
"update.hold",
"update.run",
"update.status",
"worktrees.branches",
] as const;
export type MockGatewayRequest = {
@@ -312,6 +315,8 @@ export type ControlUiMockGatewayScenario = {
sessionKey?: string;
/** Initial gateway-owned custom group catalog (sessions.groups.*), in order. */
sessionGroups?: string[];
/** Optional New Session defaults keyed by custom group name. */
sessionGroupDefaults?: Record<string, { cwd?: string; worktree?: boolean }>;
terminalEnabled?: boolean;
cliAgentsEnabled?: boolean;
workspace?: string;
@@ -825,6 +830,7 @@ function normalizeScenario(
sessionArchiveFiltering: scenario.sessionArchiveFiltering ?? false,
sessionKey,
sessionGroups: scenario.sessionGroups ?? [],
sessionGroupDefaults: scenario.sessionGroupDefaults ?? {},
terminalEnabled: scenario.terminalEnabled ?? false,
cliAgentsEnabled: scenario.cliAgentsEnabled ?? false,
workspace: scenario.workspace ?? "",
@@ -973,10 +979,12 @@ function installControlUiMockGateway(
const groupsStateKey = "openclaw.control-ui-e2e.sessionGroups";
let groupsState: {
names: string[];
defaults: Record<string, { cwd?: string; worktree?: boolean }>;
sectionOrder: string[];
renames: Array<{ from: string; to: string | null }>;
} = {
names: [...input.scenario.sessionGroups],
defaults: { ...input.scenario.sessionGroupDefaults },
sectionOrder: [],
renames: [],
};
@@ -991,6 +999,7 @@ function installControlUiMockGateway(
if (rawGroups) {
groupsState = JSON.parse(rawGroups) as typeof groupsState;
groupsState.sectionOrder ??= [];
groupsState.defaults ??= {};
}
} catch {
// Storage-disabled browser contexts still get the scenario catalog.
@@ -1081,6 +1090,12 @@ function installControlUiMockGateway(
};
}
function groupDefaultsPayload() {
return {
defaults: groupsState.names.map((name) => ({ name, ...groupsState.defaults[name] })),
};
}
function normalizedGroupNames(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
@@ -1775,6 +1790,8 @@ function installControlUiMockGateway(
}
case "sessions.groups.list":
return groupsPayload();
case "sessions.groups.defaults":
return groupDefaultsPayload();
case "sessions.groups.put": {
groupsState.names = normalizedGroupNames(isRecord(params) ? params.names : undefined);
if (isRecord(params) && Array.isArray(params.sectionOrder)) {
@@ -1794,6 +1811,10 @@ function installControlUiMockGateway(
names.splice(sourceIndex < 0 ? names.length : sourceIndex, 0, to);
}
groupsState.names = names;
if (!groupsState.defaults[to] && groupsState.defaults[from]) {
groupsState.defaults[to] = groupsState.defaults[from];
}
delete groupsState.defaults[from];
const sourceSectionId = `category:${from}`;
const targetSectionId = `category:${to}`;
groupsState.sectionOrder = groupsState.sectionOrder.flatMap((sectionId) => {
@@ -1807,10 +1828,23 @@ function installControlUiMockGateway(
}
return { ok: true, updatedSessions: 0, ...groupsPayload() };
}
case "sessions.groups.update": {
const name = isRecord(params) && typeof params.name === "string" ? params.name.trim() : "";
if (name) {
const cwd = isRecord(params) && typeof params.cwd === "string" ? params.cwd.trim() : "";
groupsState.defaults[name] = {
...(cwd ? { cwd } : {}),
worktree: isRecord(params) && params.worktree === true,
};
persistGroupsState();
}
return { ok: true, ...groupDefaultsPayload() };
}
case "sessions.groups.delete": {
const name = isRecord(params) && typeof params.name === "string" ? params.name.trim() : "";
if (name) {
groupsState.names = groupsState.names.filter((existing) => existing !== name);
delete groupsState.defaults[name];
groupsState.sectionOrder = groupsState.sectionOrder.filter(
(sectionId) => sectionId !== `category:${name}`,
);