From 4e484da05258f5a01a56fc74832a7906efd2d2db Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 2 Aug 2026 05:42:51 +0100 Subject: [PATCH] fix: honor operator scopes in session controls --- ui/src/components/app-sidebar-base.ts | 8 + .../app-sidebar-session-menu-renderers.ts | 19 +- .../app-sidebar-session-row-render.ts | 13 +- ui/src/components/session-menu.test.ts | 31 ++- ui/src/components/session-menu.ts | 62 ++++-- .../session-organizer-operations.runtime.ts | 123 +++++++++-- ui/src/components/sidebar-menus-render.ts | 83 ++++++++ ...ion-management.operator-scopes.e2e.test.ts | 72 +++++++ .../sessions/sessions-page.archived.test.ts | 33 ++- ui/src/pages/sessions/sessions-page.ts | 195 ++++++++++++++++-- ui/src/pages/sessions/view.ts | 41 +++- .../app-sidebar-cases/section-reordering.ts | 2 +- 12 files changed, 613 insertions(+), 69 deletions(-) create mode 100644 ui/src/e2e/session-management.operator-scopes.e2e.test.ts diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index ac2b8deda938..4ba5613d5c8d 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -101,6 +101,14 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { }); } + readSessionMutationAccess(request: { + method: string; + params?: unknown; + requiredScope?: "operator.write" | "operator.admin"; + }): SessionMethodAccess { + return readSessionMethodAccess(this.connected ? this.context?.gateway.snapshot : null, request); + } + requestOpenNewSession(agentId: string, target?: NewSessionTarget): void { if (this.readNewSessionAccess().allowed) { if (target) { diff --git a/ui/src/components/app-sidebar-session-menu-renderers.ts b/ui/src/components/app-sidebar-session-menu-renderers.ts index 96949407f461..3869e138c4b4 100644 --- a/ui/src/components/app-sidebar-session-menu-renderers.ts +++ b/ui/src/components/app-sidebar-session-menu-renderers.ts @@ -25,6 +25,7 @@ export function renderSidebarSessionGroupMenu(params: { menu: SidebarSessionGroupMenuState | null; trigger: HTMLElement | null; connected: boolean; + disabledReason?: string; onAction: (action: SidebarSessionGroupMenuAction, group: string) => void; onClose: (restoreFocus: boolean) => void; }) { @@ -45,7 +46,10 @@ export function renderSidebarSessionGroupMenu(params: { @wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => { event.preventDefault(); const value = event.detail.item.value; - if (value === "rename-group" || value === "new-group" || value === "delete-group") { + if ( + !params.disabledReason && + (value === "rename-group" || value === "new-group" || value === "delete-group") + ) { params.onAction(value, menu.group); } }} @@ -65,12 +69,18 @@ export function renderSidebarSessionGroupMenu(params: { ${t("sessionsView.renameGroupMenu")} - + ${t("sessionsView.newGroup")} @@ -79,7 +89,8 @@ export function renderSidebarSessionGroupMenu(params: { class="session-menu__item session-menu__item--destructive" value="delete-group" variant="danger" - ?disabled=${!params.connected} + ?disabled=${!params.connected || Boolean(params.disabledReason)} + title=${params.disabledReason ?? nothing} > ${t("sessionsView.deleteGroupMenu")} diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts index a0d1a1c0e86d..d5f6f5bb0340 100644 --- a/ui/src/components/app-sidebar-session-row-render.ts +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -114,6 +114,11 @@ export interface SessionListHost { toggleSection(sectionId: string): void; openNewSession(): void; readNewSessionAccess(): import("../lib/session-method-access.ts").SessionMethodAccess; + readSessionMutationAccess(request: { + method: string; + params?: unknown; + requiredScope?: "operator.write" | "operator.admin"; + }): import("../lib/session-method-access.ts").SessionMethodAccess; requestOpenNewSession(agentId: string, target?: NewSessionTarget): void; setVisibleSessionLimit(sectionId: string, limit: number): void; clearSessionSelection(): void; @@ -149,6 +154,10 @@ export function renderRecentSession(params: { display?: CatalogBackingSessionDisplay; }) { const { host, session, display } = params; + const pinAccess = host.readSessionMutationAccess({ + method: "sessions.patch", + params: { key: session.key, pinned: !session.pinned }, + }); const label = display?.label ?? session.label; const { subtitle, narration } = resolveSidebarSessionSubtitle({ session, @@ -346,9 +355,9 @@ export function renderRecentSession(params: { class="session-action session-action--pin" data-sidebar-session-pin="true" type="button" - title=${pinLabel} + title=${pinAccess.allowed ? pinLabel : pinAccess.reason} aria-label=${pinLabel} - ?disabled=${!host.connected} + ?disabled=${!pinAccess.allowed} @click=${() => host.toggleSessionPin(session)} > ${icons.pin} diff --git a/ui/src/components/session-menu.test.ts b/ui/src/components/session-menu.test.ts index 84236048a331..42ccbfd2c79a 100644 --- a/ui/src/components/session-menu.test.ts +++ b/ui/src/components/session-menu.test.ts @@ -3,7 +3,7 @@ import { html, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import "./session-menu.ts"; -import type { SessionMenuAction, SessionMenuWork } from "./session-menu.ts"; +import type { SessionMenuAction, SessionMenuActionKind, SessionMenuWork } from "./session-menu.ts"; type SessionMenuData = { label: string; @@ -43,6 +43,7 @@ async function mountMenu( trigger?: HTMLElement | null; onAction?: (action: SessionMenuAction) => void; onClose?: () => void; + actionDisabledReasons?: Partial>; } = {}, ): Promise { const container = document.createElement("div"); @@ -64,6 +65,7 @@ async function mountMenu( .anchor=${{ x: 100, y: 100 }} .trigger=${options.trigger ?? null} .disabled=${false} + .actionDisabledReasons=${options.actionDisabledReasons ?? {}} .forkDisabled=${false} .archiveAllowed=${options.archiveAllowed ?? true} .cloudWorkerStopAllowed=${options.cloudWorkerStopAllowed ?? false} @@ -115,6 +117,33 @@ async function openIconPicker(menu: SessionMenuElement) { } describe("session menu", () => { + it("disables only denied mutation actions and ignores forced selection", async () => { + const onAction = vi.fn<(action: SessionMenuAction) => void>(); + const menu = await mountMenu({ + onAction, + actionDisabledReasons: { + delete: "This action requires operator.admin access.", + "toggle-pin": "This action requires operator.write access.", + }, + }); + const openChat = menuItem(menu, "Open chat"); + const pin = menuItem(menu, "Pin thread"); + const deleteItem = menuItem(menu, "Delete…"); + + expect(openChat.disabled).toBe(false); + expect(pin.disabled).toBe(true); + expect(pin.getAttribute("title")).toBe("This action requires operator.write access."); + expect(deleteItem.disabled).toBe(true); + deleteItem.dispatchEvent( + new CustomEvent("wa-select", { + bubbles: true, + composed: true, + detail: { item: { value: "delete" } }, + }), + ); + expect(onAction).not.toHaveBeenCalled(); + }); + it("shows when the session was last active", async () => { const menu = await mountMenu({ lastActive: "57d" }); diff --git a/ui/src/components/session-menu.ts b/ui/src/components/session-menu.ts index 1dae5a51ebc4..e7508826fdd4 100644 --- a/ui/src/components/session-menu.ts +++ b/ui/src/components/session-menu.ts @@ -53,6 +53,8 @@ export type SessionMenuAction = | { kind: "stop-cloud-worker" } | { kind: "delete" }; +export type SessionMenuActionKind = SessionMenuAction["kind"]; + const EMPTY_SESSION: SessionMenuData = { label: "", pinned: false, @@ -72,6 +74,9 @@ class SessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) anchor: { x: number; y: number } = { x: 0, y: 0 }; @property({ attribute: false }) trigger: HTMLElement | null = null; @property({ attribute: false }) disabled = false; + @property({ attribute: false }) actionDisabledReasons: Partial< + Record + > = {}; @property({ attribute: false }) forkDisabled = false; // Guards both Archive and Delete: hosts pass canArchiveSessionRow() so agent // main sessions and active runs stay protected from casual retirement. @@ -100,10 +105,21 @@ class SessionMenu extends OpenClawLightDomElement { } private runAction(action: SessionMenuAction) { + if (this.actionDisabledReasons[action.kind]) { + return; + } this.onClose(); this.onAction(action); } + private actionDisabled(kind: SessionMenuActionKind, extra = false): boolean { + return this.disabled || extra || Boolean(this.actionDisabledReasons[kind]); + } + + private actionTitle(kind: SessionMenuActionKind): string | typeof nothing { + return this.actionDisabledReasons[kind] ?? nothing; + } + private readonly handleSelect = (event: CustomEvent<{ item: { value?: string } }>) => { event.preventDefault(); const value = event.detail.item.value; @@ -217,6 +233,7 @@ class SessionMenu extends OpenClawLightDomElement { const takeDigit = () => (nextDigit <= 9 ? String(nextDigit++) : null); const entry = (label: string, checked: boolean, value: string, radio = true) => { const digit = takeDigit(); + const actionKind = value === "new-group" ? "new-group" : "move-to-group"; return html` syncDropdownItemRadio(element, checked)) : nothing} data-shortcut=${digit ?? nothing} aria-keyshortcuts=${digit ?? nothing} - ?disabled=${this.disabled} + ?disabled=${this.actionDisabled(actionKind)} + title=${this.actionTitle(actionKind)} > ${label} ${radio && checked @@ -324,7 +342,7 @@ class SessionMenu extends OpenClawLightDomElement { aria-label=${id} aria-checked=${String(selected)} title=${id} - ?disabled=${this.disabled} + ?disabled=${this.actionDisabled("set-icon")} @click=${() => this.runAction({ kind: "set-icon", icon: value })} > ${resolveSessionIcon(value)} @@ -339,7 +357,7 @@ class SessionMenu extends OpenClawLightDomElement { maxlength="16" aria-label=${t("sessionsView.customEmoji")} placeholder="🦞" - ?disabled=${this.disabled} + ?disabled=${this.actionDisabled("set-icon")} @keydown=${(event: KeyboardEvent) => { if (event.key !== "Enter") { return; @@ -361,7 +379,7 @@ class SessionMenu extends OpenClawLightDomElement { ` @@ -1223,7 +1238,8 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {