From 56ab6ce78ecab41b2d5c3d36d97e95237751cb1a Mon Sep 17 00:00:00 2001 From: Shakker Date: Thu, 6 Aug 2026 18:38:30 +0100 Subject: [PATCH] fix: label browser-local read-only preferences --- ui/src/app/app-shell-view.ts | 1 + ui/src/components/app-sidebar-base.ts | 1 + ui/src/components/app-sidebar-nav-menus.ts | 6 ++ ui/src/components/sidebar-menus-controller.ts | 1 + ui/src/components/sidebar-menus-render.ts | 1 + .../appearance-settings-defaults.e2e.test.ts | 61 ++++++++++++++++++- ui/src/pages/chat/chat-controls.test.ts | 10 +++ ui/src/pages/chat/chat-pane-render.ts | 3 + .../pages/chat/chat-pane-session-controls.ts | 11 +++- ui/src/pages/chat/components/chat-controls.ts | 8 ++- ui/src/styles/chat/layout.css | 9 +++ ui/src/styles/layout.css | 7 +++ 12 files changed, 116 insertions(+), 3 deletions(-) diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 79a917dc6e58..3578989dec4b 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -197,6 +197,7 @@ export function renderApplicationShell(host: ShellViewHost) { terminalAvailable, catalogOpenTarget: normalizeCatalogOpenTarget(uiSettings.catalogOpenTarget), canPairDevice: gatewayConnected && (operatorAccess.canAdmin || operatorAccess.canPair), + preferencesBrowserOnly: gatewayConnected && context.runtimeConfig.canPatch === false, sidebarEntries: navigationSnapshot.sidebarEntries, workboardBoards: host.sidebarWorkboardSnapshot.boards, workboardBoardsReady: host.sidebarWorkboardSnapshot.ready, diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index 4ba5613d5c8d..921c2f43b9f0 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -32,6 +32,7 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { @property({ attribute: false }) terminalAvailable = false; @property({ attribute: false }) catalogOpenTarget: CatalogOpenTarget = "viewer"; @property({ attribute: false }) canPairDevice = false; + @property({ attribute: false }) preferencesBrowserOnly = false; @property({ attribute: false }) sessionKey = ""; @property({ attribute: false }) sidebarEntries: readonly string[] = DEFAULT_SIDEBAR_ENTRIES; @property({ attribute: false }) workboardBoards: readonly SidebarWorkboardBoard[] = []; diff --git a/ui/src/components/app-sidebar-nav-menus.ts b/ui/src/components/app-sidebar-nav-menus.ts index d1a9db3282d3..fc71c3992b81 100644 --- a/ui/src/components/app-sidebar-nav-menus.ts +++ b/ui/src/components/app-sidebar-nav-menus.ts @@ -234,6 +234,7 @@ export function renderSidebarMoreMenu(params: SidebarMoreMenuParams) { type SidebarCustomizeMenuParams = { position: SidebarMenuPosition | null; sidebarEntries: readonly string[]; + preferencesBrowserOnly: boolean; isRouteEnabled: (routeId: NavigationRouteId) => boolean; workboardBoards: readonly SidebarWorkboardBoard[]; workboardRenderers?: SidebarWorkboardRenderers; @@ -284,6 +285,11 @@ export function renderSidebarCustomizeMenu(params: SidebarCustomizeMenuParams) { style="position: fixed; left: ${position.x}px; top: ${position.y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;" > + ${params.preferencesBrowserOnly + ? html`` + : nothing} ${SIDEBAR_NAV_ROUTES.filter((routeId) => params.isRouteEnabled(routeId)).map((routeId) => { const visible = params.sidebarEntries.includes( serializeSidebarEntry({ type: "route", route: routeId }), diff --git a/ui/src/components/sidebar-menus-controller.ts b/ui/src/components/sidebar-menus-controller.ts index 1ab6055677aa..4cdb99a9c263 100644 --- a/ui/src/components/sidebar-menus-controller.ts +++ b/ui/src/components/sidebar-menus-controller.ts @@ -84,6 +84,7 @@ export interface SidebarMenusControllerHost readonly onUpdateSidebarEntries?: (entries: string[]) => void; readonly onPreloadRoute?: (routeId: NavigationRouteId) => Promise; readonly pinnedAgentIds: readonly string[]; + readonly preferencesBrowserOnly: boolean; readonly selectedSessionKeys: ReadonlySet; readonly sessionData: SessionOrganizerControllerHost["sessionData"] & Pick< diff --git a/ui/src/components/sidebar-menus-render.ts b/ui/src/components/sidebar-menus-render.ts index 94264b000713..f60730fd2889 100644 --- a/ui/src/components/sidebar-menus-render.ts +++ b/ui/src/components/sidebar-menus-render.ts @@ -104,6 +104,7 @@ export function renderSidebarCustomizeMenuForController(controller: SidebarMenus return renderSidebarCustomizeMenu({ position, sidebarEntries: host.sidebarEntries, + preferencesBrowserOnly: host.preferencesBrowserOnly, isRouteEnabled: (routeId) => controller.isRouteEnabled(routeId), workboardBoards: host.workboardBoards, workboardRenderers: host.workboardRenderers, diff --git a/ui/src/e2e/appearance-settings-defaults.e2e.test.ts b/ui/src/e2e/appearance-settings-defaults.e2e.test.ts index ea548f170532..6ab3475b4df0 100644 --- a/ui/src/e2e/appearance-settings-defaults.e2e.test.ts +++ b/ui/src/e2e/appearance-settings-defaults.e2e.test.ts @@ -487,7 +487,7 @@ describeControlUiE2e("Control UI Appearance defaults mocked Gateway E2E", () => } }); - it("keeps read-only edits browser-local across reload and restores the server baseline", async () => { + it("keeps every read-only preference surface browser-local across reload", async () => { const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block", @@ -545,6 +545,65 @@ describeControlUiE2e("Control UI Appearance defaults mocked Gateway E2E", () => .not.toContain("Stored in this browser only"); await page.waitForTimeout(100); expect(await gateway.getRequests("config.patch")).toHaveLength(0); + + await page.goto(`${server.baseUrl}chat`); + const viewMenuTrigger = page.locator(".chat-view-menu-trigger"); + await viewMenuTrigger.click(); + const viewMenu = page.locator("wa-dropdown.chat-view-menu"); + await expect + .poll(() => viewMenu.locator(".chat-view-menu__provenance").textContent()) + .toContain("Stored in this browser only"); + const reasoning = viewMenu.getByRole("menuitemcheckbox", { name: "Reasoning" }); + await reasoning.click(); + await expect.poll(() => reasoning.getAttribute("aria-checked")).toBe("false"); + + const sidebar = page.locator("openclaw-app-sidebar"); + await sidebar.locator(".sidebar-nav__head-action").click(); + await sidebar + .locator("wa-dropdown.sidebar-more-menu") + .getByRole("menuitem", { name: "Edit pinned items" }) + .click(); + const customizeMenu = sidebar.locator( + "wa-dropdown.sidebar-customize-menu:not(.sidebar-more-menu):not(.sidebar-agent-menu)", + ); + await expect + .poll(() => customizeMenu.locator(".sidebar-customize-menu__provenance").textContent()) + .toContain("Stored in this browser only"); + const tasks = customizeMenu.getByRole("menuitemcheckbox", { name: "Tasks" }); + await tasks.click(); + await expect.poll(() => tasks.getAttribute("aria-checked")).toBe("true"); + await page.waitForTimeout(100); + expect(await gateway.getRequests("config.patch")).toHaveLength(0); + + await page.reload(); + await viewMenuTrigger.click(); + await expect + .poll(() => viewMenu.locator(".chat-view-menu__provenance").textContent()) + .toContain("Stored in this browser only"); + await expect + .poll(() => + viewMenu + .getByRole("menuitemcheckbox", { name: "Reasoning" }) + .getAttribute("aria-checked"), + ) + .toBe("false"); + + await sidebar.locator(".sidebar-nav__head-action").click(); + await sidebar + .locator("wa-dropdown.sidebar-more-menu") + .getByRole("menuitem", { name: "Edit pinned items" }) + .click(); + await expect + .poll(() => customizeMenu.locator(".sidebar-customize-menu__provenance").textContent()) + .toContain("Stored in this browser only"); + await expect + .poll(() => + customizeMenu + .getByRole("menuitemcheckbox", { name: "Tasks" }) + .getAttribute("aria-checked"), + ) + .toBe("true"); + expect(await gateway.getRequests("config.patch")).toHaveLength(0); } finally { await context.close(); } diff --git a/ui/src/pages/chat/chat-controls.test.ts b/ui/src/pages/chat/chat-controls.test.ts index faf75a24e788..4a7865805cd2 100644 --- a/ui/src/pages/chat/chat-controls.test.ts +++ b/ui/src/pages/chat/chat-controls.test.ts @@ -41,6 +41,7 @@ function createProps(overrides: Record = {}): ChatControlsProps stream: null, }, onboarding: false, + preferencesBrowserOnly: false, settings: createSettings(), viewMenuOpen: true, onSettingsChange: () => undefined, @@ -101,6 +102,15 @@ describe("chat composer view menu", () => { ); }); + it("labels read-only preference changes as browser-local", () => { + const container = document.createElement("div"); + render(renderChatControls(createProps({ preferencesBrowserOnly: true })), container); + + expect(container.querySelector(".chat-view-menu__provenance")?.textContent?.trim()).toBe( + t("quickSettings.personal.browserOnly"), + ); + }); + it("disables the rows and pins display state during onboarding", () => { const container = document.createElement("div"); const onSettingsChange = vi.fn(); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index bf4c7e25500e..f79c0ee01e26 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -411,6 +411,9 @@ export class ChatPane extends ChatPaneHeader { selectedSession, agentDefaultModel, mutationAccess: mutationAccess.runtimePatch, + preferencesBrowserOnly: + this.context.runtimeConfig.state.connected && + this.context.runtimeConfig.canPatch === false, }), sessionWorkspace: catalogKey ? undefined : sessionWorkspace, backgroundTasks: catalogKey ? undefined : backgroundTasks, diff --git a/ui/src/pages/chat/chat-pane-session-controls.ts b/ui/src/pages/chat/chat-pane-session-controls.ts index 32d611308a53..9b7db3074dc0 100644 --- a/ui/src/pages/chat/chat-pane-session-controls.ts +++ b/ui/src/pages/chat/chat-pane-session-controls.ts @@ -39,8 +39,16 @@ export function renderChatPaneComposerControls(params: { selectedSession: GatewaySessionRow | undefined; agentDefaultModel: string | undefined; mutationAccess: SessionMethodAccess; + preferencesBrowserOnly: boolean; }) { - const { paneId, state, selectedSession, agentDefaultModel, mutationAccess } = params; + const { + paneId, + state, + selectedSession, + agentDefaultModel, + mutationAccess, + preferencesBrowserOnly, + } = params; const mutationAllowed = () => mutationAccess.allowed; return renderChatControls({ paneId, @@ -74,6 +82,7 @@ export function renderChatPaneComposerControls(params: { : Promise.resolve(false), }, onboarding: state.onboarding, + preferencesBrowserOnly, settings: state.settings, viewMenuOpen: state.chatViewMenuOpen, onSettingsChange: state.applySettings, diff --git a/ui/src/pages/chat/components/chat-controls.ts b/ui/src/pages/chat/components/chat-controls.ts index 7bcebb848dbb..40ea0897e286 100644 --- a/ui/src/pages/chat/components/chat-controls.ts +++ b/ui/src/pages/chat/components/chat-controls.ts @@ -1,5 +1,5 @@ // Chat-owned composer display controls: the View menu plus model controls. -import { html } from "lit"; +import { html, nothing } from "lit"; import type { UiSettings } from "../../../app/settings.ts"; import { icons } from "../../../components/icons.ts"; import "../../../components/tooltip.ts"; @@ -11,6 +11,7 @@ type ChatControlsProps = { paneId: string; model: ChatModelControlsProps; onboarding: boolean; + preferencesBrowserOnly: boolean; settings: UiSettings; viewMenuOpen: boolean; onSettingsChange: (patch: Partial) => void; @@ -105,6 +106,11 @@ export function renderChatControls(props: ChatControlsProps) { `, )} + ${props.preferencesBrowserOnly + ? html`
+ ${t("quickSettings.personal.browserOnly")} +
` + : nothing} diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 273a9f1943bf..b826cd45c93f 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -2569,6 +2569,15 @@ openclaw-chat-video-player { white-space: nowrap; } +.chat-view-menu__provenance { + margin: 4px 6px 2px; + padding-top: 7px; + border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + .agent-chat__stt-interim { padding: 10px 14px 0; color: var(--muted); diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 8e6fe27bf205..0279016d4268 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -2346,6 +2346,13 @@ wa-dropdown.sidebar-customize-menu::part(menu) { color: var(--muted); } +.sidebar-customize-menu__provenance { + margin: 0 10px 6px; + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + .sidebar-customize-menu__group-title { margin: 6px 4px 2px; padding: 7px 6px 4px;