diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index e090420aa6b6..ecbbb197bb66 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -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"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index f4c21256f7e1..c605059a8eaf 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index af61ce9ec9ea..14ed83f192fa 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -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" } diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 5ad0ce8ba48d..854e9efb0184 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -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. diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index 123542ffadcb..fc42cb5db993 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -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 (``, ``, ``, ``, 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). diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index aad425396a23..798a195ace5d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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 diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 9607fa5e896f..da4a55870b24 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -318,10 +318,15 @@ export { SessionsResetParamsSchema, SessionsDeleteParamsSchema, SessionGroupSchema, + SessionGroupDefaultsSchema, SessionsGroupsListParamsSchema, SessionsGroupsListResultSchema, + SessionsGroupsDefaultsParamsSchema, + SessionsGroupsDefaultsResultSchema, SessionsGroupsPutParamsSchema, SessionsGroupsRenameParamsSchema, + SessionsGroupsUpdateParamsSchema, + SessionsGroupsUpdateResultSchema, SessionsGroupsDeleteParamsSchema, SessionsGroupsMutationResultSchema, SessionsCompactParamsSchema, diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts index 4f7bd4d07fa0..11506892d591 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts @@ -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, diff --git a/packages/gateway-protocol/src/schema/sessions-create.ts b/packages/gateway-protocol/src/schema/sessions-create.ts index 2578306e8d5c..f65553fe98fc 100644 --- a/packages/gateway-protocol/src/schema/sessions-create.ts +++ b/packages/gateway-protocol/src/schema/sessions-create.ts @@ -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()), diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 3c0c7b69e1b3..37ee48709218 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -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; export type SessionsDeleteParams = Static; export type SessionGroup = Static; +export type SessionGroupDefaults = Static; export type SessionsGroupsListParams = Static; export type SessionsGroupsListResult = Static; +export type SessionsGroupsDefaultsParams = Static; +export type SessionsGroupsDefaultsResult = Static; export type SessionsGroupsPutParams = Static; export type SessionsGroupsRenameParams = Static; +export type SessionsGroupsUpdateParams = Static; +export type SessionsGroupsUpdateResult = Static; export type SessionsGroupsDeleteParams = Static; export type SessionsGroupsMutationResult = Static; export type SessionsCompactParams = Static; diff --git a/packages/gateway-protocol/src/sessions-groups-validators.test.ts b/packages/gateway-protocol/src/sessions-groups-validators.test.ts index 3aeb2674317c..bd3a886fed9a 100644 --- a/packages/gateway-protocol/src/sessions-groups-validators.test.ts +++ b/packages/gateway-protocol/src/sessions-groups-validators.test.ts @@ -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, + ); + }); }); diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index f73fcc322279..01f19a3e8acd 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -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); diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 22ec3f067ac5..dd94924eb561 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -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"]], diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 745cf7cf5481..110a117729c9 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -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", diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index b9814d62271f..132b86994a6e 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -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"], diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 42e5d525078d..212da4505614 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -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[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, diff --git a/src/gateway/server-methods/sessions-groups.test.ts b/src/gateway/server-methods/sessions-groups.test.ts new file mode 100644 index 000000000000..dadf18b56c0e --- /dev/null +++ b/src/gateway/server-methods/sessions-groups.test.ts @@ -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, + respond: ReturnType, + scopes = ["operator.write", "operator.admin"], +) { + return { + params, + respond, + client: { connect: { scopes } }, + context: { + getRuntimeConfig: () => ({}), + getSessionEventSubscriberConnIds: () => new Set(), + }, + } as unknown as GatewayRequestHandlerOptions; +} + +function renameOptions(params: Record, respond: ReturnType) { + 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((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", + }), + ); + }); +}); diff --git a/src/gateway/server-methods/sessions-groups.ts b/src/gateway/server-methods/sessions-groups.ts index 1764df040504..0c27dcd183ea 100644 --- a/src/gateway/server-methods/sessions-groups.ts +++ b/src/gateway/server-methods/sessions-groups.ts @@ -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( diff --git a/src/gateway/server-methods/workspace-path-containment.ts b/src/gateway/server-methods/workspace-path-containment.ts index 994dffe2ecf4..a2f102bd3337 100644 --- a/src/gateway/server-methods/workspace-path-containment.ts +++ b/src/gateway/server-methods/workspace-path-containment.ts @@ -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; + } + }); +} diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 695d29ecf702..1740970edf4e 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -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"; diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 72be123430dd..01c3406f9010 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -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[]; diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index a970a21f892f..7148e2c78c9f 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -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, }, diff --git a/src/gateway/session-group-defaults-access.ts b/src/gateway/session-group-defaults-access.ts new file mode 100644 index 000000000000..31c520d92607 --- /dev/null +++ b/src/gateway/session-group-defaults-access.ts @@ -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(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)); +} diff --git a/src/gateway/session-group-mutation-targets.ts b/src/gateway/session-group-mutation-targets.ts new file mode 100644 index 000000000000..a002b2f882d3 --- /dev/null +++ b/src/gateway/session-group-mutation-targets.ts @@ -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 { + const targetsByName = new Map(); + 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; +} diff --git a/src/gateway/session-groups.test.ts b/src/gateway/session-groups.test.ts index 24ad780cf228..c5192ca53b9c 100644 --- a/src/gateway/session-groups.test.ts +++ b/src/gateway/session-groups.test.ts @@ -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); diff --git a/src/gateway/session-groups.ts b/src/gateway/session-groups.ts index e584819de2ab..35deabd8e582 100644 --- a/src/gateway/session-groups.ts +++ b/src/gateway/session-groups.ts @@ -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(); +const ensuredSessionGroupDefaultsDatabases = new WeakSet(); 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(); 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. diff --git a/src/gateway/session-sharing.test.ts b/src/gateway/session-sharing.test.ts index e637406912f8..7a19d9f15312 100644 --- a/src/gateway/session-sharing.test.ts +++ b/src/gateway/session-sharing.test.ts @@ -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(), + } 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(), + } 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[] = []; + await sessionGroupHandlers["sessions.groups.defaults"]?.({ + params: {}, + client: viewer, + context, + respond: (...response: Parameters) => 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[] = []; + await sessionGroupHandlers["sessions.groups.update"]?.({ + params: { name: "Personal", cwd: null, worktree: false }, + client: viewer, + context, + sessionMutationAuthorization: personalAuthorization.authorization, + respond: (...response: Parameters) => 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" }), diff --git a/src/gateway/session-sharing.ts b/src/gateway/session-sharing.ts index 524b6bb88ea3..4c2bf1180518 100644 --- a/src/gateway/session-sharing.ts +++ b/src/gateway/session-sharing.ts @@ -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([ ["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, diff --git a/src/gateway/test/server-sessions.test-helpers.ts b/src/gateway/test/server-sessions.test-helpers.ts index 2394f1f42a50..61e0f7fadf35 100644 --- a/src/gateway/test/server-sessions.test-helpers.ts +++ b/src/gateway/test/server-sessions.test-helpers.ts @@ -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>; +type SessionsHandlerOptions = Parameters[0]; export async function directSessionReq( method: keyof SessionsHandlers, params: Record, opts?: { context?: Record; - client?: Parameters[0]["client"]; - isWebchatConnect?: Parameters[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( } as never, client: opts?.client ?? null, isWebchatConnect: opts?.isWebchatConnect ?? (() => false), + sessionMutationAuthorization: opts?.sessionMutationAuthorization, }); if (!result) { throw new Error(`${method} did not respond`); diff --git a/src/state/openclaw-database-maintenance.test.ts b/src/state/openclaw-database-maintenance.test.ts index 438e6632a8e6..e0177403c187 100644 --- a/src/state/openclaw-database-maintenance.test.ts +++ b/src/state/openclaw-database-maintenance.test.ts @@ -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", ]); diff --git a/src/state/openclaw-database-preflight.test.ts b/src/state/openclaw-database-preflight.test.ts index de059e782f6a..e8f189dfc90a 100644 --- a/src/state/openclaw-database-preflight.test.ts +++ b/src/state/openclaw-database-preflight.test.ts @@ -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(); diff --git a/src/state/openclaw-database-preflight.ts b/src/state/openclaw-database-preflight.ts index 35c82f208643..52ef339519a2 100644 --- a/src/state/openclaw-database-preflight.ts +++ b/src/state/openclaw-database-preflight.ts @@ -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, diff --git a/src/state/openclaw-state-db-additive-columns.ts b/src/state/openclaw-state-db-additive-columns.ts index 46394bd2a4a8..b54c57adcbc4 100644 --- a/src/state/openclaw-state-db-additive-columns.ts +++ b/src/state/openclaw-state-db-additive-columns.ts @@ -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); diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index d10b666e901f..c01590b73995 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -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 { diff --git a/src/state/openclaw-state-schema-compatibility.ts b/src/state/openclaw-state-schema-compatibility.ts index f38936fcc6ea..10db163a4a51 100644 --- a/src/state/openclaw-state-schema-compatibility.ts +++ b/src/state/openclaw-state-schema-compatibility.ts @@ -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( + CLAW_FIRST_USE_ADDITIVE_STATE_COLUMNS, +); const CLAW_STARTUP_ADDITIVE_STATE_COLUMN_SET = new Set( 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) + ); +} diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index f977b15afc54..72884ffbabc8 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -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 diff --git a/ui/src/components/app-sidebar-session-list-render.ts b/ui/src/components/app-sidebar-session-list-render.ts index ba012a97e4d7..3e628b0f5c34 100644 --- a/ui/src/components/app-sidebar-session-list-render.ts +++ b/ui/src/components/app-sidebar-session-list-render.ts @@ -194,6 +194,21 @@ function renderSessionSection(params: { : nothing} ${group ? html` + + + + ${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` +
+ ${renderSessionMenuItem( + { + value: "agent-workspace", + label: t("sessionsView.groupDefaultsCwdPlaceholder"), + icon: icons.folder, + checked: !trimmedCwd, + onSelect: () => applyFolder(""), + }, + submitting, + )} + +
+ `} +
+ ${trimmedCwd || t("sessionsView.groupDefaultsCwdHint")} + + + ${failure + ? html`` + : nothing} +
+ + +
+ + + `, + host, + ); + } + + paint(); + }); +} diff --git a/ui/src/components/session-organizer-catalog.ts b/ui/src/components/session-organizer-catalog.ts index 4b8077032128..307064f5833c 100644 --- a/ui/src/components/session-organizer-catalog.ts +++ b/ui/src/components/session-organizer-catalog.ts @@ -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 { + 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, diff --git a/ui/src/components/session-organizer-controller.ts b/ui/src/components/session-organizer-controller.ts index 558d7181bb75..402d93cddf2b 100644 --- a/ui/src/components/session-organizer-controller.ts +++ b/ui/src/components/session-organizer-controller.ts @@ -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; + 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 | 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 { + 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) { this.collapsedSessionSections = new Set(sections); this.host.requestUpdate(); diff --git a/ui/src/components/session-organizer-operations.runtime.ts b/ui/src/components/session-organizer-operations.runtime.ts index 1100814a9a85..337e574e13d6 100644 --- a/ui/src/components/session-organizer-operations.runtime.ts +++ b/ui/src/components/session-organizer-operations.runtime.ts @@ -34,6 +34,7 @@ export { deleteSessionGroup, renameSessionGroup, reorderSidebarSection, + updateSessionGroupDefaults, } from "./session-organizer-catalog.ts"; export async function patchSession( diff --git a/ui/src/components/sidebar-menus-render.ts b/ui/src/components/sidebar-menus-render.ts index c7aff96e448c..6a254d9435f0 100644 --- a/ui/src/components/sidebar-menus-render.ts +++ b/ui/src/components/sidebar-menus-render.ts @@ -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; diff --git a/ui/src/e2e/session-management.group-defaults.e2e.test.ts b/ui/src/e2e/session-management.group-defaults.e2e.test.ts new file mode 100644 index 000000000000..1ad5437d6502 --- /dev/null +++ b/ui/src/e2e/session-management.group-defaults.e2e.test.ts @@ -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; + }; + }; + }; + }; + 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; + }; + }; + }; + }; + 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(); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index a99d209c35e7..5cfe802c286f 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/lib/agents/index.test.ts b/ui/src/lib/agents/index.test.ts index f7002aae3782..db29892a4a5e 100644 --- a/ui/src/lib/agents/index.test.ts +++ b/ui/src/lib/agents/index.test.ts @@ -68,6 +68,7 @@ function createState(): { state: AgentsState; request: ReturnType { agentId: "main", profiles: [{ id: "full", label: "Full" }], groups: [], + groupSettings: [], }); await pending; diff --git a/ui/src/lib/sessions/cloud-recovery.ts b/ui/src/lib/sessions/cloud-recovery.ts index 3c59e01aa479..e8ec508b1170 100644 --- a/ui/src/lib/sessions/cloud-recovery.ts +++ b/ui/src/lib/sessions/cloud-recovery.ts @@ -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", diff --git a/ui/src/lib/sessions/create.ts b/ui/src/lib/sessions/create.ts index 17f09c474d14..3e4ffe1b2069 100644 --- a/ui/src/lib/sessions/create.ts +++ b/ui/src/lib/sessions/create.ts @@ -19,6 +19,7 @@ export type SessionCreateParams = { forkFrom?: "last-completed"; succeedsParent?: boolean; label?: string; + category?: string; model?: string; thinkingLevel?: string; incognito?: boolean; diff --git a/ui/src/lib/sessions/custom-groups.test.ts b/ui/src/lib/sessions/custom-groups.test.ts index ccf97672d6d4..4b609596afd9 100644 --- a/ui/src/lib/sessions/custom-groups.test.ts +++ b/ui/src/lib/sessions/custom-groups.test.ts @@ -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", () => { diff --git a/ui/src/lib/sessions/custom-groups.ts b/ui/src/lib/sessions/custom-groups.ts index 3ead973386e4..955f70f0fb3c 100644 --- a/ui/src/lib/sessions/custom-groups.ts +++ b/ui/src/lib/sessions/custom-groups.ts @@ -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 | 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(); + if (Array.isArray(values)) { + for (const value of values) { + const record = value as Record | 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[] { diff --git a/ui/src/lib/sessions/index.test.ts b/ui/src/lib/sessions/index.test.ts index 22bcc9e6c2c7..57eaf9a48a0c 100644 --- a/ui/src/lib/sessions/index.test.ts +++ b/ui/src/lib/sessions/index.test.ts @@ -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") { diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index 725df774e15a..c1caf2c18d63 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -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); diff --git a/ui/src/lib/sessions/section-order.test.ts b/ui/src/lib/sessions/section-order.test.ts index 8b9587d6d838..098efc42a34c 100644 --- a/ui/src/lib/sessions/section-order.test.ts +++ b/ui/src/lib/sessions/section-order.test.ts @@ -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, diff --git a/ui/src/lib/sessions/session-capability.ts b/ui/src/lib/sessions/session-capability.ts index 2b271ba9ce8f..8ecb0116b08f 100644 --- a/ui/src/lib/sessions/session-capability.ts +++ b/ui/src/lib/sessions/session-capability.ts @@ -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; - /** Loads the gateway-owned group catalog, coalescing successful connection attempts. */ - groupsLoad: () => Promise; + /** Loads one connection-owned group catalog; null means the attempt retired or failed. */ + groupsLoad: () => Promise; + /** 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; /** Renames a group; stale means the initiating connection retired before reconciliation. */ groupsRename: (from: string, to: string) => Promise; + /** Updates the New Session defaults for one group. */ + groupsUpdate: ( + name: string, + defaults: { cwd: string | null; worktree: boolean }, + ) => Promise; /** Deletes a group; stale means the initiating connection retired before reconciliation. */ groupsDelete: (name: string) => Promise; subscribeCreated: (listener: (key: string) => void) => () => void; diff --git a/ui/src/lib/sessions/session-group-catalog.ts b/ui/src/lib/sessions/session-group-catalog.ts index adde83a0c7a6..6821ad3d7ff5 100644 --- a/ui/src/lib/sessions/session-group-catalog.ts +++ b/ui/src/lib/sessions/session-group-catalog.ts @@ -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 | null = null; let retryTimer: ReturnType | 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 => { + 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, + }; } diff --git a/ui/src/lib/sessions/session-group-migration.test.ts b/ui/src/lib/sessions/session-group-migration.test.ts index 8001407493ec..fdca7102220d 100644 --- a/ui/src/lib/sessions/session-group-migration.test.ts +++ b/ui/src/lib/sessions/session-group-migration.test.ts @@ -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, 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, scopes: string[]): Ses }; } +function createGatewayHarness(request: ReturnType, 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(); + }); +}); diff --git a/ui/src/lib/sessions/session-roster-refresh.ts b/ui/src/lib/sessions/session-roster-refresh.ts index 730eb04a91b1..16c97ca09038 100644 --- a/ui/src/lib/sessions/session-roster-refresh.ts +++ b/ui/src/lib/sessions/session-roster-refresh.ts @@ -283,6 +283,7 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) { error, deletedSessions: [], groups: state.groups, + groupSettings: state.groupSettings, sectionOrder: state.sectionOrder, }, error ? "session-observer" : undefined, diff --git a/ui/src/pages/new-session/catalog-target.test.ts b/ui/src/pages/new-session/catalog-target.test.ts index 6b5138635e30..201d7d40f453 100644 --- a/ui/src/pages/new-session/catalog-target.test.ts +++ b/ui/src/pages/new-session/catalog-target.test.ts @@ -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)); + }); }); diff --git a/ui/src/pages/new-session/catalog-target.ts b/ui/src/pages/new-session/catalog-target.ts index 9a432bb8a086..0b64d8a31234 100644 --- a/ui/src/pages/new-session/catalog-target.ts +++ b/ui/src/pages/new-session/catalog-target.ts @@ -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 | null = null; + private lastKey = ""; + + constructor( + private readonly readData: () => NewSessionRouteData | undefined, + private readonly revalidate: () => Promise | 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 | 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`
${renderTarget(params.data)} ${isTarget(params.data) ? nothing : params.agentSelect} diff --git a/ui/src/pages/new-session/cloud-recovery.test.ts b/ui/src/pages/new-session/cloud-recovery.test.ts index 74d47ef341bf..10585effb98e 100644 --- a/ui/src/pages/new-session/cloud-recovery.test.ts +++ b/ui/src/pages/new-session/cloud-recovery.test.ts @@ -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, }, diff --git a/ui/src/pages/new-session/create-params.test.ts b/ui/src/pages/new-session/create-params.test.ts index 95dfa9e1a969..61a27ffba045 100644 --- a/ui/src/pages/new-session/create-params.test.ts +++ b/ui/src/pages/new-session/create-params.test.ts @@ -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({ diff --git a/ui/src/pages/new-session/create-params.ts b/ui/src/pages/new-session/create-params.ts index 3cf5d3c57456..d99ff0c3fb3b 100644 --- a/ui/src/pages/new-session/create-params.ts +++ b/ui/src/pages/new-session/create-params.ts @@ -40,11 +40,13 @@ export function buildDraftSessionCreateParams(draft: { workspace?: string; execNode?: string; catalogId?: string; + category?: string; }): Record { 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 } : {}), diff --git a/ui/src/pages/new-session/draft-gateway-state.ts b/ui/src/pages/new-session/draft-gateway-state.ts index 8672f019e43c..fea49f8c18d1 100644 --- a/ui/src/pages/new-session/draft-gateway-state.ts +++ b/ui/src/pages/new-session/draft-gateway-state.ts @@ -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); diff --git a/ui/src/pages/new-session/draft-place-browser.test.ts b/ui/src/pages/new-session/draft-place-browser.test.ts index 98becd306408..5c2bc5705c83 100644 --- a/ui/src/pages/new-session/draft-place-browser.test.ts +++ b/ui/src/pages/new-session/draft-place-browser.test.ts @@ -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) { +afterEach(() => { + localStorage.clear(); +}); + +function createBrowser(request: (method: string) => Promise, 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) { }, }, }, + 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) { 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, + }); + }); +}); diff --git a/ui/src/pages/new-session/draft-place-state.ts b/ui/src/pages/new-session/draft-place-state.ts index 984c36967aab..5928bf529c28 100644 --- a/ui/src/pages/new-session/draft-place-state.ts +++ b/ui/src/pages/new-session/draft-place-state.ts @@ -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 }); } } diff --git a/ui/src/pages/new-session/draft-repository-state.ts b/ui/src/pages/new-session/draft-repository-state.ts index a28063899aae..f56c5ec73b0b 100644 --- a/ui/src/pages/new-session/draft-repository-state.ts +++ b/ui/src/pages/new-session/draft-repository-state.ts @@ -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(); } diff --git a/ui/src/pages/new-session/draft-submission-contract.ts b/ui/src/pages/new-session/draft-submission-contract.ts new file mode 100644 index 000000000000..2adbce8ed50c --- /dev/null +++ b/ui/src/pages/new-session/draft-submission-contract.ts @@ -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; +}; diff --git a/ui/src/pages/new-session/draft-submission-flow.ts b/ui/src/pages/new-session/draft-submission-flow.ts index 4ebc8e61e62c..83ac1b04f630 100644 --- a/ui/src/pages/new-session/draft-submission-flow.ts +++ b/ui/src/pages/new-session/draft-submission-flow.ts @@ -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 { + 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) || diff --git a/ui/src/pages/new-session/location.test.ts b/ui/src/pages/new-session/location.test.ts index 51c95d744f67..55e455c9e2a0 100644 --- a/ui/src/pages/new-session/location.test.ts +++ b/ui/src/pages/new-session/location.test.ts @@ -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: "", }); }); }); diff --git a/ui/src/pages/new-session/location.ts b/ui/src/pages/new-session/location.ts index 397206bd6abe..931ce6c2fc20 100644 --- a/ui/src/pages/new-session/location.ts +++ b/ui/src/pages/new-session/location.ts @@ -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 { +): Pick { const params = new URLSearchParams(search); return { agentId: params.get("agent")?.trim() ?? "", catalogId: params.get("catalog")?.trim() ?? "", + group: params.get("group")?.trim() ?? "", }; } diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 3b5ae122f8e9..fb7fd38f9f81 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -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``; diff --git a/ui/src/pages/new-session/route-loader.ts b/ui/src/pages/new-session/route-loader.ts new file mode 100644 index 000000000000..a0215a44492d --- /dev/null +++ b/ui/src/pages/new-session/route-loader.ts @@ -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 { + 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; +} diff --git a/ui/src/pages/new-session/route.test.ts b/ui/src/pages/new-session/route.test.ts index 4547b8bc6f3a..b24c8eecccb5 100644 --- a/ui/src/pages/new-session/route.test.ts +++ b/ui/src/pages/new-session/route.test.ts @@ -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", diff --git a/ui/src/pages/new-session/route.ts b/ui/src/pages/new-session/route.ts index e4e23524916f..512e2877cef5 100644 --- a/ui/src/pages/new-session/route.ts +++ b/ui/src/pages/new-session/route.ts @@ -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 { - 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``, - })), + loader: async (context: ApplicationContext, { location }) => + (await import("./route-loader.ts")).load(context, location.search), + component: () => import("./new-session-page.ts"), }); diff --git a/ui/src/styles/new-session.css b/ui/src/styles/new-session.css index 8d955d651c7c..0aecaedd89c9 100644 --- a/ui/src/styles/new-session.css +++ b/ui/src/styles/new-session.css @@ -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; - } } diff --git a/ui/src/test-helpers/app-sidebar-cases/group-mutations.ts b/ui/src/test-helpers/app-sidebar-cases/group-mutations.ts index 13f6933a3784..aa4873344093 100644 --- a/ui/src/test-helpers/app-sidebar-cases/group-mutations.ts +++ b/ui/src/test-helpers/app-sidebar-cases/group-mutations.ts @@ -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( - '[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(".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(".session-menu__item"); - items[items.length - 1]?.click(); + selectGroupMenuAction(menu, "delete-group"); const confirm = await waitForFast(() => { const button = document.body.querySelector( "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(".session-menu__item"); - items[items.length - 1]?.click(); + selectGroupMenuAction(menu, "delete-group"); const confirm = await waitForFast(() => { const button = document.body.querySelector( "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(".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(() => { diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index 13010b64c27c..919e8bdde903 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -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; 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, diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 3d2032f4a4b3..a7c9851116a7 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -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; 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; 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}`, );