mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 07:04:01 -06:00
fix: honor operator scopes in session controls
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
<wa-dropdown-item
|
||||
class="session-menu__item"
|
||||
value="rename-group"
|
||||
?disabled=${!params.connected}
|
||||
?disabled=${!params.connected || Boolean(params.disabledReason)}
|
||||
title=${params.disabledReason ?? nothing}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.edit}</span>
|
||||
<span class="session-menu__text">${t("sessionsView.renameGroupMenu")}</span>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item class="session-menu__item" value="new-group">
|
||||
<wa-dropdown-item
|
||||
class="session-menu__item"
|
||||
value="new-group"
|
||||
?disabled=${!params.connected || Boolean(params.disabledReason)}
|
||||
title=${params.disabledReason ?? nothing}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.folder}</span>
|
||||
<span class="session-menu__text">${t("sessionsView.newGroup")}</span>
|
||||
</wa-dropdown-item>
|
||||
@@ -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}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.trash}</span>
|
||||
<span class="session-menu__text">${t("sessionsView.deleteGroupMenu")}</span>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<Record<SessionMenuActionKind, string>>;
|
||||
} = {},
|
||||
): Promise<SessionMenuElement> {
|
||||
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" });
|
||||
|
||||
|
||||
@@ -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<SessionMenuActionKind, string>
|
||||
> = {};
|
||||
@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`
|
||||
<wa-dropdown-item
|
||||
slot="submenu"
|
||||
@@ -227,7 +244,8 @@ class SessionMenu extends OpenClawLightDomElement {
|
||||
${radio ? ref((element) => syncDropdownItemRadio(element, checked)) : nothing}
|
||||
data-shortcut=${digit ?? nothing}
|
||||
aria-keyshortcuts=${digit ?? nothing}
|
||||
?disabled=${this.disabled}
|
||||
?disabled=${this.actionDisabled(actionKind)}
|
||||
title=${this.actionTitle(actionKind)}
|
||||
>
|
||||
<span class="session-menu__text">${label}</span>
|
||||
${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 {
|
||||
<button
|
||||
type="button"
|
||||
class="session-menu__remove-icon"
|
||||
?disabled=${this.disabled || !currentIcon}
|
||||
?disabled=${this.actionDisabled("set-icon", !currentIcon)}
|
||||
@click=${() => this.runAction({ kind: "set-icon", icon: null })}
|
||||
>
|
||||
${t("sessionsView.removeIcon")}
|
||||
@@ -433,7 +451,8 @@ class SessionMenu extends OpenClawLightDomElement {
|
||||
value="toggle-pin"
|
||||
data-shortcut="p"
|
||||
aria-keyshortcuts="P"
|
||||
?disabled=${this.disabled || session.archived}
|
||||
?disabled=${this.actionDisabled("toggle-pin", session.archived)}
|
||||
title=${this.actionTitle("toggle-pin")}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true"
|
||||
>${session.pinned ? icons.pinOff : icons.pin}</span
|
||||
@@ -448,7 +467,8 @@ class SessionMenu extends OpenClawLightDomElement {
|
||||
<wa-dropdown-item
|
||||
class="session-menu__item"
|
||||
value="change-icon"
|
||||
?disabled=${this.disabled}
|
||||
?disabled=${this.actionDisabled("set-icon")}
|
||||
title=${this.actionTitle("set-icon")}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true"
|
||||
>${icons.spark}</span
|
||||
@@ -461,7 +481,8 @@ class SessionMenu extends OpenClawLightDomElement {
|
||||
value="toggle-unread"
|
||||
data-shortcut="u"
|
||||
aria-keyshortcuts="U"
|
||||
?disabled=${this.disabled}
|
||||
?disabled=${this.actionDisabled("toggle-unread")}
|
||||
title=${this.actionTitle("toggle-unread")}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true"
|
||||
>${session.unread ? icons.eye : icons.circle}</span
|
||||
@@ -485,7 +506,8 @@ class SessionMenu extends OpenClawLightDomElement {
|
||||
value="rename"
|
||||
data-shortcut="r"
|
||||
aria-keyshortcuts="R"
|
||||
?disabled=${this.disabled}
|
||||
?disabled=${this.actionDisabled("rename")}
|
||||
title=${this.actionTitle("rename")}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true"
|
||||
>${icons.edit}</span
|
||||
@@ -498,7 +520,8 @@ class SessionMenu extends OpenClawLightDomElement {
|
||||
value="fork"
|
||||
data-shortcut="f"
|
||||
aria-keyshortcuts="F"
|
||||
?disabled=${this.disabled || this.forkDisabled}
|
||||
?disabled=${this.actionDisabled("fork", this.forkDisabled)}
|
||||
title=${this.actionTitle("fork")}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true"
|
||||
>${icons.copy}</span
|
||||
@@ -528,7 +551,11 @@ class SessionMenu extends OpenClawLightDomElement {
|
||||
</wa-dropdown-item>
|
||||
`
|
||||
: nothing}
|
||||
<wa-dropdown-item class="session-menu__item" ?disabled=${this.disabled}>
|
||||
<wa-dropdown-item
|
||||
class="session-menu__item"
|
||||
?disabled=${this.actionDisabled("move-to-group")}
|
||||
title=${this.actionTitle("move-to-group")}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true"
|
||||
>${icons.folder}</span
|
||||
>
|
||||
@@ -546,7 +573,8 @@ class SessionMenu extends OpenClawLightDomElement {
|
||||
class="session-menu__item session-menu__item--destructive"
|
||||
value="stop-cloud-worker"
|
||||
variant="danger"
|
||||
?disabled=${this.disabled}
|
||||
?disabled=${this.actionDisabled("stop-cloud-worker")}
|
||||
title=${this.actionTitle("stop-cloud-worker")}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true"
|
||||
>${icons.stop}</span
|
||||
@@ -560,7 +588,11 @@ class SessionMenu extends OpenClawLightDomElement {
|
||||
value="toggle-archived"
|
||||
data-shortcut="a"
|
||||
aria-keyshortcuts="A"
|
||||
?disabled=${this.disabled || (!session.archived && !this.archiveAllowed)}
|
||||
?disabled=${this.actionDisabled(
|
||||
"toggle-archived",
|
||||
!session.archived && !this.archiveAllowed,
|
||||
)}
|
||||
title=${this.actionTitle("toggle-archived")}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true"
|
||||
>${session.archived ? icons.archiveRestore : icons.archive}</span
|
||||
@@ -580,7 +612,11 @@ class SessionMenu extends OpenClawLightDomElement {
|
||||
variant="danger"
|
||||
data-shortcut="d"
|
||||
aria-keyshortcuts="D"
|
||||
?disabled=${this.disabled || !(session.archived || this.archiveAllowed)}
|
||||
?disabled=${this.actionDisabled(
|
||||
"delete",
|
||||
!(session.archived || this.archiveAllowed),
|
||||
)}
|
||||
title=${this.actionTitle("delete")}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true"
|
||||
>${icons.trash}</span
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactiveControllerHost } from "lit";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { readSessionMethodAccess } from "../lib/session-method-access.ts";
|
||||
import {
|
||||
moveSessionSection,
|
||||
normalizeSessionSectionOrder,
|
||||
@@ -47,6 +48,23 @@ export interface SessionOrganizerControllerHost extends ReactiveControllerHost {
|
||||
sidebarSessionStatusFilter(): SidebarSessionStatusFilter;
|
||||
}
|
||||
|
||||
function requireSessionMutationAccess(
|
||||
host: SessionOrganizerControllerHost,
|
||||
scope: SidebarSessionMutationScope,
|
||||
request: {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
requiredScope?: "operator.write" | "operator.admin";
|
||||
},
|
||||
): boolean {
|
||||
const access = readSessionMethodAccess(scope.gateway.snapshot, request);
|
||||
if (access.allowed) {
|
||||
return true;
|
||||
}
|
||||
host.sessionData.publishSessionMutationError(scope, access.reason);
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function patchSession(
|
||||
host: SessionOrganizerControllerHost,
|
||||
session: SidebarRecentSession,
|
||||
@@ -58,6 +76,16 @@ export async function patchSession(
|
||||
return "stale";
|
||||
}
|
||||
const agentId = sessionRowAgentId(session, scope);
|
||||
const requestParams = {
|
||||
key: session.key,
|
||||
...patch,
|
||||
agentId,
|
||||
};
|
||||
if (
|
||||
!requireSessionMutationAccess(host, scope, { method: "sessions.patch", params: requestParams })
|
||||
) {
|
||||
return "failed";
|
||||
}
|
||||
try {
|
||||
const patched = await scope.sessions.patch(session.key, patch, {
|
||||
agentId,
|
||||
@@ -258,15 +286,19 @@ export async function deleteSessionsBatch(
|
||||
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
return;
|
||||
}
|
||||
const requests = rows.map((row) => ({
|
||||
key: row.key,
|
||||
agentId: parseAgentSessionKey(row.key)?.agentId ?? scope.selectedAgentId,
|
||||
deleteTranscript: true,
|
||||
...(row.archived === true ? { archivedOnly: true } : {}),
|
||||
}));
|
||||
for (const params of requests) {
|
||||
if (!requireSessionMutationAccess(host, scope, { method: "sessions.delete", params })) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await scope.sessions.deleteMany(
|
||||
rows.map((row) => ({
|
||||
key: row.key,
|
||||
agentId: parseAgentSessionKey(row.key)?.agentId ?? scope.selectedAgentId,
|
||||
deleteTranscript: true,
|
||||
...(row.archived === true ? { archivedOnly: true } : {}),
|
||||
})),
|
||||
);
|
||||
const result = await scope.sessions.deleteMany(requests);
|
||||
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
return;
|
||||
}
|
||||
@@ -357,6 +389,14 @@ async function rememberSessionGroup(
|
||||
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
return "stale";
|
||||
}
|
||||
if (
|
||||
!requireSessionMutationAccess(host, scope, {
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
})
|
||||
) {
|
||||
return "failed";
|
||||
}
|
||||
try {
|
||||
await scope.sessions.groupsPut([...groups, name]);
|
||||
return host.sessionData.isSessionMutationScopeCurrent(scope) ? "completed" : "stale";
|
||||
@@ -404,6 +444,14 @@ export async function renameSessionGroup(
|
||||
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!requireSessionMutationAccess(host, scope, {
|
||||
method: "sessions.groups.rename",
|
||||
requiredScope: "operator.write",
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const outcome = await scope.sessions.groupsRename(group, next);
|
||||
return outcome === "completed" && host.sessionData.isSessionMutationScopeCurrent(scope);
|
||||
@@ -421,6 +469,14 @@ export async function deleteSessionGroup(
|
||||
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!requireSessionMutationAccess(host, scope, {
|
||||
method: "sessions.groups.delete",
|
||||
requiredScope: "operator.write",
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const outcome = await scope.sessions.groupsDelete(group);
|
||||
return outcome === "completed" && host.sessionData.isSessionMutationScopeCurrent(scope);
|
||||
@@ -440,6 +496,14 @@ export async function reorderSidebarSection(
|
||||
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!requireSessionMutationAccess(host, scope, {
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// knownSessionGroups() is the full discovered set (gateway catalog plus
|
||||
// row-discovered categories), so normalize only prunes deleted groups.
|
||||
@@ -491,12 +555,18 @@ export async function forkSession(
|
||||
return;
|
||||
}
|
||||
const agentId = parseAgentSessionKey(session.key)?.agentId ?? scope.selectedAgentId;
|
||||
const createParams = {
|
||||
parentSessionKey: session.key,
|
||||
fork: true,
|
||||
agentId,
|
||||
};
|
||||
if (
|
||||
!requireSessionMutationAccess(host, scope, { method: "sessions.create", params: createParams })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const key = await scope.sessions.create({
|
||||
parentSessionKey: session.key,
|
||||
fork: true,
|
||||
agentId,
|
||||
});
|
||||
const key = await scope.sessions.create(createParams);
|
||||
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
return;
|
||||
}
|
||||
@@ -529,6 +599,14 @@ export async function stopCloudWorker(
|
||||
return;
|
||||
}
|
||||
const agentId = parseAgentSessionKey(session.key)?.agentId ?? scope.selectedAgentId;
|
||||
if (
|
||||
!requireSessionMutationAccess(host, scope, {
|
||||
method: "sessions.reclaim",
|
||||
requiredScope: "operator.admin",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await scope.client.request(
|
||||
"sessions.reclaim",
|
||||
@@ -556,12 +634,21 @@ export async function deleteSession(
|
||||
return;
|
||||
}
|
||||
const agentId = parseAgentSessionKey(session.key)?.agentId ?? scope.selectedAgentId;
|
||||
const deleteParams = {
|
||||
agentId,
|
||||
deleteTranscript: true,
|
||||
...(session.archived === true ? { archivedOnly: true } : {}),
|
||||
};
|
||||
if (
|
||||
!requireSessionMutationAccess(host, scope, {
|
||||
method: "sessions.delete",
|
||||
params: { key: session.key, ...deleteParams },
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const outcome = await scope.sessions.delete(session.key, {
|
||||
agentId,
|
||||
deleteTranscript: true,
|
||||
...(session.archived === true ? { archivedOnly: true } : {}),
|
||||
});
|
||||
const outcome = await scope.sessions.delete(session.key, deleteParams);
|
||||
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
import { DEFAULT_SIDEBAR_ENTRIES, serializeSidebarEntry } from "../app-navigation.ts";
|
||||
import type { RouteId } from "../app-route-paths.ts";
|
||||
import type { ApplicationContext } from "../app/context.ts";
|
||||
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
|
||||
import { normalizeAgentLabel } from "../lib/agents/display.ts";
|
||||
import { openEditor } from "../lib/editor-links.ts";
|
||||
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
|
||||
import { openExternalUrlSafe } from "../lib/open-external-url.ts";
|
||||
import { readSessionMethodAccess } from "../lib/session-method-access.ts";
|
||||
import {
|
||||
canArchiveSessionRow,
|
||||
normalizeAgentId,
|
||||
@@ -18,12 +21,82 @@ import {
|
||||
renderSidebarSessionGroupMenu,
|
||||
renderSidebarSessionSortMenu,
|
||||
} from "./app-sidebar-session-menu-renderers.ts";
|
||||
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
|
||||
import type { SessionMenuAction } from "./session-menu.ts";
|
||||
import type {
|
||||
SidebarMenusController,
|
||||
SidebarMenusControllerHost,
|
||||
} from "./sidebar-menus-controller.ts";
|
||||
|
||||
function sessionMenuActionDisabledReasons(
|
||||
snapshot: ApplicationContext<RouteId>["gateway"]["snapshot"] | undefined,
|
||||
session: SidebarRecentSession,
|
||||
batchRows: readonly SidebarRecentSession[] | null,
|
||||
): Partial<Record<SessionMenuAction["kind"], string>> {
|
||||
const reason = (request: {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
requiredScope?: "operator.write" | "operator.admin";
|
||||
}) => {
|
||||
const access = readSessionMethodAccess(snapshot, request);
|
||||
return access.allowed ? undefined : access.reason;
|
||||
};
|
||||
const patchReason = reason({
|
||||
method: "sessions.patch",
|
||||
params: { key: session.key, label: null },
|
||||
});
|
||||
const groupReason = reason({
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
});
|
||||
const deleteRows = batchRows ?? [session];
|
||||
const deleteReason = deleteRows
|
||||
.map((row) =>
|
||||
reason({
|
||||
method: "sessions.delete",
|
||||
params: { key: row.key, ...(row.archived ? { archivedOnly: true } : {}) },
|
||||
}),
|
||||
)
|
||||
.find((value): value is string => Boolean(value));
|
||||
return {
|
||||
...(patchReason
|
||||
? {
|
||||
"toggle-pin": patchReason,
|
||||
"set-icon": patchReason,
|
||||
"toggle-unread": patchReason,
|
||||
rename: patchReason,
|
||||
"move-to-group": patchReason,
|
||||
"toggle-archived": patchReason,
|
||||
}
|
||||
: {}),
|
||||
...(groupReason || patchReason ? { "new-group": groupReason ?? patchReason } : {}),
|
||||
...(deleteReason ? { delete: deleteReason } : {}),
|
||||
...(batchRows
|
||||
? {}
|
||||
: {
|
||||
...(reason({
|
||||
method: "sessions.create",
|
||||
params: { parentSessionKey: session.key, fork: true },
|
||||
})
|
||||
? {
|
||||
fork: reason({
|
||||
method: "sessions.create",
|
||||
params: { parentSessionKey: session.key, fork: true },
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(reason({ method: "sessions.reclaim", requiredScope: "operator.admin" })
|
||||
? {
|
||||
"stop-cloud-worker": reason({
|
||||
method: "sessions.reclaim",
|
||||
requiredScope: "operator.admin",
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSidebarCustomizeMenuForController(controller: SidebarMenusController) {
|
||||
const { host } = controller;
|
||||
const position = controller.customizeMenuPosition;
|
||||
@@ -172,6 +245,11 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo
|
||||
.anchor=${menu}
|
||||
.trigger=${controller.sessionMenuTrigger}
|
||||
.disabled=${!host.connected}
|
||||
.actionDisabledReasons=${sessionMenuActionDisabledReasons(
|
||||
context?.gateway.snapshot,
|
||||
session,
|
||||
batchRows,
|
||||
)}
|
||||
.forkDisabled=${host.sessionData.sessionsLoading || session.modelSelectionLocked}
|
||||
.archiveAllowed=${archiveAllowed}
|
||||
.cloudWorkerStopAllowed=${Boolean(
|
||||
@@ -253,10 +331,15 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo
|
||||
export function renderSidebarSessionGroupMenuForController(controller: SidebarMenusController) {
|
||||
const { host } = controller;
|
||||
const menu = controller.sessionGroupMenu;
|
||||
const groupAccess = readSessionMethodAccess(host.sessionDataContext?.gateway.snapshot, {
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
});
|
||||
return renderSidebarSessionGroupMenu({
|
||||
menu,
|
||||
trigger: controller.sessionGroupMenuTrigger,
|
||||
connected: host.connected,
|
||||
disabledReason: groupAccess.allowed ? undefined : groupAccess.reason,
|
||||
onAction: (action, group) => {
|
||||
controller.closeSessionGroupMenu({ restoreFocus: true });
|
||||
switch (action) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
createSessionManagementE2eSuite,
|
||||
installMockGateway,
|
||||
sessionRow,
|
||||
sessionsListResponse,
|
||||
} from "./session-management.test-support.ts";
|
||||
|
||||
const suite = createSessionManagementE2eSuite();
|
||||
const archived = sessionRow(
|
||||
"agent:main:archived-write-scope",
|
||||
"Archived write scope",
|
||||
Date.parse("2026-08-01T12:00:00.000Z"),
|
||||
{ archived: true },
|
||||
);
|
||||
|
||||
async function openArchivedPage(operatorScopes: string[]) {
|
||||
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: ["chat.metadata", "chat.startup", "sessions.delete"],
|
||||
operatorScopes,
|
||||
sessionArchiveFiltering: true,
|
||||
methodResponses: {
|
||||
"sessions.delete": { deleted: true },
|
||||
"sessions.list": sessionsListResponse([archived]),
|
||||
},
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}sessions?status=archived`);
|
||||
const deleteAll = page.getByRole("button", { name: /Delete all archived/ });
|
||||
await deleteAll.waitFor();
|
||||
return { context, deleteAll, gateway, page };
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("lets write-scoped operators delete archived sessions with archivedOnly", async () => {
|
||||
const { context, deleteAll, gateway, page } = await openArchivedPage([
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
]);
|
||||
try {
|
||||
page.on("dialog", (dialog) => void dialog.accept());
|
||||
await expect.poll(() => deleteAll.isEnabled()).toBe(true);
|
||||
await deleteAll.click();
|
||||
|
||||
await expect(gateway.waitForRequest("sessions.delete")).resolves.toMatchObject({
|
||||
params: {
|
||||
archivedOnly: true,
|
||||
deleteTranscript: true,
|
||||
key: archived.key,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps archived deletion disabled for read-scoped operators", async () => {
|
||||
const { context, deleteAll, gateway } = await openArchivedPage(["operator.read"]);
|
||||
try {
|
||||
await expect.poll(() => deleteAll.isDisabled()).toBe(true);
|
||||
await deleteAll.click({ force: true });
|
||||
expect(await gateway.getRequests("sessions.delete")).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { SessionsListResult } from "../../api/types.ts";
|
||||
import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
|
||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||
import {
|
||||
createContext,
|
||||
@@ -31,9 +32,15 @@ describe("sessions page archived deletion", () => {
|
||||
preservedWorktrees: [],
|
||||
})),
|
||||
});
|
||||
const { gateway } = createGateway({} as GatewayBrowserClient);
|
||||
const mutableGateway = createGateway({} as GatewayBrowserClient);
|
||||
mutableGateway.emit({
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.read", "operator.write"] },
|
||||
features: { methods: ["sessions.delete"] },
|
||||
} as ApplicationGatewaySnapshot["hello"],
|
||||
});
|
||||
const page = await createRenderedPage(
|
||||
createContext(gateway, sessions),
|
||||
createContext(mutableGateway.gateway, sessions),
|
||||
{
|
||||
count: 2,
|
||||
sessions: [
|
||||
@@ -68,6 +75,28 @@ describe("sessions page archived deletion", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let write-scoped operators delete an active session", async () => {
|
||||
const key = "agent:main:active";
|
||||
const sessions = createSessions();
|
||||
const mutableGateway = createGateway({} as GatewayBrowserClient);
|
||||
mutableGateway.emit({
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.read", "operator.write"] },
|
||||
features: { methods: ["sessions.delete"] },
|
||||
} as ApplicationGatewaySnapshot["hello"],
|
||||
});
|
||||
const page = await createRenderedPage(createContext(mutableGateway.gateway, sessions), {
|
||||
count: 1,
|
||||
sessions: [{ key, archived: false }],
|
||||
} as SessionsListResult);
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
await page.deleteSessionFromMenu({ key, archived: false } as SessionsListResult["sessions"][0]);
|
||||
|
||||
expect(sessions.deleteMany).not.toHaveBeenCalled();
|
||||
expect(page.error).toBe("This action requires operator.admin access.");
|
||||
});
|
||||
|
||||
it("aborts delete-all when an enumeration page fails", async () => {
|
||||
const sessions = createSessions({
|
||||
list: vi.fn(async () => null) as unknown as SessionCapability["list"],
|
||||
|
||||
@@ -14,7 +14,11 @@ import { applicationContext, type ApplicationContext } from "../../app/context.t
|
||||
import { hasOperatorWriteAccess } from "../../app/operator-access.ts";
|
||||
import { renderAgentScopeControl } from "../../components/agent-scope-control.ts";
|
||||
import { fetchSessionMenuWork } from "../../components/session-menu-work.ts";
|
||||
import type { SessionMenuAction, SessionMenuWork } from "../../components/session-menu.ts";
|
||||
import type {
|
||||
SessionMenuAction,
|
||||
SessionMenuActionKind,
|
||||
SessionMenuWork,
|
||||
} from "../../components/session-menu.ts";
|
||||
import "../../components/session-menu.ts";
|
||||
import { isStoppableCloudWorkerPlacement } from "../../components/session-row-badges.ts";
|
||||
import { renderSessionsHubHeader } from "../../components/sessions-hub-header.ts";
|
||||
@@ -25,6 +29,7 @@ import { openEditor } from "../../lib/editor-links.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { openExternalUrlSafe } from "../../lib/open-external-url.ts";
|
||||
import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts";
|
||||
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
|
||||
import {
|
||||
scopedSessionPullRequestKey,
|
||||
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
|
||||
@@ -405,6 +410,90 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
);
|
||||
}
|
||||
|
||||
private mutationDisabledReason(request: {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
requiredScope?: "operator.write" | "operator.admin";
|
||||
}): string | undefined {
|
||||
const access = readSessionMethodAccess(this.context?.gateway.snapshot, request);
|
||||
return access.allowed ? undefined : access.reason;
|
||||
}
|
||||
|
||||
private requireMutationAccess(
|
||||
scope: SessionsPageRequestScope,
|
||||
request: {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
requiredScope?: "operator.write" | "operator.admin";
|
||||
},
|
||||
): boolean {
|
||||
const access = readSessionMethodAccess(scope.gateway.snapshot, request);
|
||||
if (access.allowed) {
|
||||
return true;
|
||||
}
|
||||
this.error = access.reason;
|
||||
return false;
|
||||
}
|
||||
|
||||
private selectedDeleteDisabledReason(): string | undefined {
|
||||
const rowsByKey = new Map(this.result?.sessions.map((row) => [row.key, row]) ?? []);
|
||||
for (const key of this.selectedKeys) {
|
||||
const row = rowsByKey.get(key);
|
||||
const reason = this.mutationDisabledReason({
|
||||
method: "sessions.delete",
|
||||
params: {
|
||||
key,
|
||||
...(row?.archived === true ? { archivedOnly: true } : {}),
|
||||
},
|
||||
});
|
||||
if (reason) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private sessionMenuActionDisabledReasons(
|
||||
row: GatewaySessionRow,
|
||||
): Partial<Record<SessionMenuActionKind, string>> {
|
||||
const patchReason = this.mutationDisabledReason({
|
||||
method: "sessions.patch",
|
||||
params: { key: row.key, label: null },
|
||||
});
|
||||
const groupReason = this.mutationDisabledReason({
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
});
|
||||
const forkReason = this.mutationDisabledReason({
|
||||
method: "sessions.create",
|
||||
params: { parentSessionKey: row.key, fork: true },
|
||||
});
|
||||
const reclaimReason = this.mutationDisabledReason({
|
||||
method: "sessions.reclaim",
|
||||
requiredScope: "operator.admin",
|
||||
});
|
||||
const deleteReason = this.mutationDisabledReason({
|
||||
method: "sessions.delete",
|
||||
params: { key: row.key, ...(row.archived === true ? { archivedOnly: true } : {}) },
|
||||
});
|
||||
return {
|
||||
...(patchReason
|
||||
? {
|
||||
"toggle-pin": patchReason,
|
||||
"set-icon": patchReason,
|
||||
"toggle-unread": patchReason,
|
||||
rename: patchReason,
|
||||
"move-to-group": patchReason,
|
||||
"toggle-archived": patchReason,
|
||||
}
|
||||
: {}),
|
||||
...(groupReason || patchReason ? { "new-group": groupReason ?? patchReason } : {}),
|
||||
...(forkReason ? { fork: forkReason } : {}),
|
||||
...(reclaimReason ? { "stop-cloud-worker": reclaimReason } : {}),
|
||||
...(deleteReason ? { delete: deleteReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private applyRouteData() {
|
||||
const data = this.routeData;
|
||||
const context = this.context;
|
||||
@@ -712,16 +801,20 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
const requests = rows.map((row) => ({
|
||||
key: row.key,
|
||||
agentId: this.sessionAgentId(row.key, scope.context),
|
||||
...options,
|
||||
...(row.archived === true ? { archivedOnly: true } : {}),
|
||||
}));
|
||||
for (const params of requests) {
|
||||
if (!this.requireMutationAccess(scope, { method: "sessions.delete", params })) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.sessionMutationPending = true;
|
||||
try {
|
||||
const result = await scope.sessions.deleteMany(
|
||||
rows.map((row) => ({
|
||||
key: row.key,
|
||||
agentId: this.sessionAgentId(row.key, scope.context),
|
||||
...options,
|
||||
...(row.archived === true ? { archivedOnly: true } : {}),
|
||||
})),
|
||||
);
|
||||
const result = await scope.sessions.deleteMany(requests);
|
||||
if (!this.isRequestScopeCurrent(scope)) {
|
||||
return;
|
||||
}
|
||||
@@ -860,7 +953,13 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
return;
|
||||
}
|
||||
const scope = this.captureRequestScope();
|
||||
if (!scope) {
|
||||
if (
|
||||
!scope ||
|
||||
!this.requireMutationAccess(scope, {
|
||||
method: "sessions.reclaim",
|
||||
requiredScope: "operator.admin",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const agentId = parseAgentSessionKey(row.key)?.agentId;
|
||||
@@ -899,6 +998,15 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
|
||||
private async rememberCustomGroup(name: string) {
|
||||
const scope = this.captureRequestScope();
|
||||
if (
|
||||
scope &&
|
||||
!this.requireMutationAccess(scope, {
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await rememberSessionCustomGroup({
|
||||
name,
|
||||
knownCategories: this.knownCategories(),
|
||||
@@ -959,9 +1067,18 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
if (!scope) {
|
||||
return "stale";
|
||||
}
|
||||
const agentId = this.sessionAgentId(key, scope.context);
|
||||
if (
|
||||
!this.requireMutationAccess(scope, {
|
||||
method: "sessions.patch",
|
||||
params: { key, ...patch, ...(agentId ? { agentId } : {}) },
|
||||
})
|
||||
) {
|
||||
return "failed";
|
||||
}
|
||||
try {
|
||||
const patched = await scope.sessions.patch(key, patch, {
|
||||
agentId: this.sessionAgentId(key, scope.context),
|
||||
agentId,
|
||||
});
|
||||
if (!this.isRequestScopeCurrent(scope)) {
|
||||
return "stale";
|
||||
@@ -1016,12 +1133,16 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
return;
|
||||
}
|
||||
const agentId = this.sessionAgentId(key, scope.context);
|
||||
const createParams = {
|
||||
parentSessionKey: key,
|
||||
fork: true,
|
||||
...(agentId ? { agentId } : {}),
|
||||
};
|
||||
if (!this.requireMutationAccess(scope, { method: "sessions.create", params: createParams })) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const forkedKey = await scope.sessions.create({
|
||||
parentSessionKey: key,
|
||||
fork: true,
|
||||
...(agentId ? { agentId } : {}),
|
||||
});
|
||||
const forkedKey = await scope.sessions.create(createParams);
|
||||
if (!this.isRequestScopeCurrent(scope)) {
|
||||
return;
|
||||
}
|
||||
@@ -1096,7 +1217,13 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
return;
|
||||
}
|
||||
const scope = this.captureRequestScope();
|
||||
if (!scope) {
|
||||
if (
|
||||
!scope ||
|
||||
!this.requireMutationAccess(scope, {
|
||||
method: "sessions.compaction.branch",
|
||||
requiredScope: "operator.write",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.checkpointBusyKey = checkpointId;
|
||||
@@ -1135,7 +1262,13 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
return;
|
||||
}
|
||||
const scope = this.captureRequestScope();
|
||||
if (!scope) {
|
||||
if (
|
||||
!scope ||
|
||||
!this.requireMutationAccess(scope, {
|
||||
method: "sessions.compaction.restore",
|
||||
requiredScope: "operator.admin",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.checkpointBusyKey = checkpointId;
|
||||
@@ -1251,6 +1384,7 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
.anchor=${menu}
|
||||
.trigger=${this.sessionMenuTrigger}
|
||||
.disabled=${this.loading}
|
||||
.actionDisabledReasons=${this.sessionMenuActionDisabledReasons(row)}
|
||||
.forkDisabled=${row.modelSelectionLocked === true}
|
||||
.archiveAllowed=${archiveAllowed}
|
||||
.cloudWorkerStopAllowed=${isStoppableCloudWorkerPlacement(row.placement) &&
|
||||
@@ -1390,6 +1524,31 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
checkpointLoadingKey: this.checkpointLoadingKey,
|
||||
checkpointBusyKey: this.checkpointBusyKey,
|
||||
checkpointErrorByKey: this.checkpointErrorByKey,
|
||||
patchWriteDisabledReason: this.mutationDisabledReason({
|
||||
method: "sessions.patch",
|
||||
params: { key: "", label: null },
|
||||
}),
|
||||
patchAdminDisabledReason: this.mutationDisabledReason({
|
||||
method: "sessions.patch",
|
||||
params: { key: "", thinkingLevel: null },
|
||||
}),
|
||||
groupWriteDisabledReason: this.mutationDisabledReason({
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
}),
|
||||
deleteArchivedDisabledReason: this.mutationDisabledReason({
|
||||
method: "sessions.delete",
|
||||
params: { key: "", archivedOnly: true, deleteTranscript: true },
|
||||
}),
|
||||
checkpointBranchDisabledReason: this.mutationDisabledReason({
|
||||
method: "sessions.compaction.branch",
|
||||
requiredScope: "operator.write",
|
||||
}),
|
||||
checkpointRestoreDisabledReason: this.mutationDisabledReason({
|
||||
method: "sessions.compaction.restore",
|
||||
requiredScope: "operator.admin",
|
||||
}),
|
||||
deleteSelectedDisabledReason: this.selectedDeleteDisabledReason(),
|
||||
onFiltersChange: (next) => this.updateFilters(next),
|
||||
onClearFilters: () => {
|
||||
this.activeMinutes = "";
|
||||
|
||||
@@ -98,6 +98,13 @@ export type SessionsProps = {
|
||||
checkpointLoadingKey: string | null;
|
||||
checkpointBusyKey: string | null;
|
||||
checkpointErrorByKey: Record<string, string>;
|
||||
patchWriteDisabledReason?: string;
|
||||
patchAdminDisabledReason?: string;
|
||||
groupWriteDisabledReason?: string;
|
||||
deleteArchivedDisabledReason?: string;
|
||||
checkpointBranchDisabledReason?: string;
|
||||
checkpointRestoreDisabledReason?: string;
|
||||
deleteSelectedDisabledReason?: string;
|
||||
onFiltersChange: (next: {
|
||||
activeMinutes: string;
|
||||
limit: string;
|
||||
@@ -1027,7 +1034,10 @@ export function renderSessions(props: SessionsProps) {
|
||||
? html`
|
||||
<button
|
||||
class="btn danger"
|
||||
?disabled=${props.loading || archivedCount === 0}
|
||||
?disabled=${props.loading ||
|
||||
archivedCount === 0 ||
|
||||
Boolean(props.deleteArchivedDisabledReason)}
|
||||
title=${props.deleteArchivedDisabledReason ?? nothing}
|
||||
@click=${props.onDeleteAllArchived}
|
||||
>
|
||||
${icons.trash} ${t("sessionsView.deleteAllArchived")}
|
||||
@@ -1207,7 +1217,12 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {
|
||||
</label>
|
||||
${props.groupBy === "category"
|
||||
? html`
|
||||
<button class="btn btn--sm" @click=${() => props.onRequestNewCategory()}>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${Boolean(props.groupWriteDisabledReason)}
|
||||
title=${props.groupWriteDisabledReason ?? nothing}
|
||||
@click=${() => props.onRequestNewCategory()}
|
||||
>
|
||||
${icons.plus} ${t("sessionsView.newGroup")}
|
||||
</button>
|
||||
`
|
||||
@@ -1223,7 +1238,8 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm danger"
|
||||
?disabled=${props.loading}
|
||||
?disabled=${props.loading || Boolean(props.deleteSelectedDisabledReason)}
|
||||
title=${props.deleteSelectedDisabledReason ?? nothing}
|
||||
@click=${props.onDeleteSelected}
|
||||
>
|
||||
${icons.trash} ${t("sessionsView.deleteSelected")}
|
||||
@@ -1650,7 +1666,8 @@ function renderSessionDetailsRow(params: {
|
||||
<input
|
||||
class="settings-input"
|
||||
.value=${row.label ?? ""}
|
||||
?disabled=${props.loading}
|
||||
?disabled=${props.loading || Boolean(props.patchWriteDisabledReason)}
|
||||
title=${props.patchWriteDisabledReason ?? nothing}
|
||||
placeholder=${t("sessionsView.optionalPlaceholder")}
|
||||
@change=${(e: Event) => {
|
||||
const value =
|
||||
@@ -1661,14 +1678,14 @@ function renderSessionDetailsRow(params: {
|
||||
</label>
|
||||
${renderOverrideSelect({
|
||||
label: t("sessionsView.thinking"),
|
||||
disabled: props.loading,
|
||||
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
|
||||
options: thinkLevels,
|
||||
current: thinking,
|
||||
onChange: (value) => props.onPatch(row.key, { thinkingLevel: value || null }),
|
||||
})}
|
||||
${renderOverrideSelect({
|
||||
label: t("sessionsView.fast"),
|
||||
disabled: props.loading,
|
||||
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
|
||||
options: fastLevels,
|
||||
current: fastMode,
|
||||
onChange: (value) =>
|
||||
@@ -1678,14 +1695,14 @@ function renderSessionDetailsRow(params: {
|
||||
})}
|
||||
${renderOverrideSelect({
|
||||
label: t("sessionsView.verbose"),
|
||||
disabled: props.loading,
|
||||
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
|
||||
options: verboseLevels,
|
||||
current: verbose,
|
||||
onChange: (value) => props.onPatch(row.key, { verboseLevel: value || null }),
|
||||
})}
|
||||
${renderOverrideSelect({
|
||||
label: t("sessionsView.reasoning"),
|
||||
disabled: props.loading,
|
||||
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
|
||||
options: reasoningLevels.map((level) => ({
|
||||
value: level,
|
||||
label: level || t("sessionsView.inherit"),
|
||||
@@ -1750,7 +1767,9 @@ function renderSessionDetailsRow(params: {
|
||||
<div class="session-checkpoint-card__actions">
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${props.checkpointBusyKey === checkpoint.checkpointId}
|
||||
?disabled=${props.checkpointBusyKey === checkpoint.checkpointId ||
|
||||
Boolean(props.checkpointBranchDisabledReason)}
|
||||
title=${props.checkpointBranchDisabledReason ?? nothing}
|
||||
@click=${() =>
|
||||
props.onBranchFromCheckpoint(row.key, checkpoint.checkpointId)}
|
||||
>
|
||||
@@ -1758,7 +1777,9 @@ function renderSessionDetailsRow(params: {
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${props.checkpointBusyKey === checkpoint.checkpointId}
|
||||
?disabled=${props.checkpointBusyKey === checkpoint.checkpointId ||
|
||||
Boolean(props.checkpointRestoreDisabledReason)}
|
||||
title=${props.checkpointRestoreDisabledReason ?? nothing}
|
||||
@click=${() =>
|
||||
props.onRestoreCheckpoint(row.key, checkpoint.checkpointId)}
|
||||
>
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("AppSidebar section reordering", () => {
|
||||
if (options.withCatalog) {
|
||||
gateway.publish({
|
||||
hello: {
|
||||
features: { methods: ["sessions.catalog.list"] },
|
||||
features: { methods: ["sessions.catalog.list", "sessions.groups.put"] },
|
||||
} as ApplicationGatewaySnapshot["hello"],
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user