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``
+ : 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;