Add responsive interface control visibility

Add an Interface settings section with independent mobile and desktop toggles for the project selector, clock, connection status, and right canvas rail. Persist the visibility map in instance settings, apply it at the shared WebUI surfaces, preserve existing defaults, and cover the contract with focused regression tests and DOX updates.

Alessandro committed Jul 10, 2026 at 19:38 UTC 3ac63c61662ff7d8bad98da527169b658b4ec5c3
19 files changed +396 -10
api/settings_get.py.dox.md
+1
@@ -28,6 +28,7 @@
28 ## Key Concepts
29
30 - Important called helpers/classes observed in the source: `settings.get_settings`, `settings.convert_out`.
31 +- The returned settings map includes normalized `ui_control_visibility` mobile and desktop flags for the configurable WebUI controls.
32 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
33
34 ## Work Guidance
api/settings_set.py.dox.md
+1
@@ -26,6 +26,7 @@
26 ## Key Concepts
27
28 - Important called helpers/classes observed in the source: `settings.convert_in`, `settings.set_settings`, `settings.convert_out`, `settings.Settings`.
29 +- The settings payload accepts normalized `ui_control_visibility` mobile and desktop flags and returns the persisted map with the rest of the settings.
30 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
31
32 ## Work Guidance
helpers/settings.py
+27
@@ -59,6 +59,7 @@ class Settings(TypedDict):
59 max_consecutive_unusable_responses: int
60 timezone: str
61 time_format: str
62 + ui_control_visibility: dict[str, dict[str, bool]]
63
64 workdir_path: str
65 workdir_show: bool
@@ -164,6 +165,12 @@ API_KEY_PLACEHOLDER = "************"
165 TIMEZONE_AUTO = "auto"
166 TIME_FORMAT_12H = "12h"
167 TIME_FORMAT_24H = "24h"
168 +UI_CONTROL_VISIBILITY_DEFAULTS = {
169 + "projectSelector": {"mobile": True, "desktop": True},
170 + "time": {"mobile": False, "desktop": True},
171 + "connectionStatus": {"mobile": True, "desktop": True},
172 + "rightCanvasRail": {"mobile": True, "desktop": True},
173 +}
174
175 SETTINGS_FILE = files.get_abs_path("usr/settings.json")
176 _settings: Settings | None = None
@@ -210,6 +217,22 @@ def _normalize_time_format(value: Any, default: str = TIME_FORMAT_12H) -> str:
217 return default if default in {TIME_FORMAT_12H, TIME_FORMAT_24H} else TIME_FORMAT_12H
218
219
220 +def _normalize_ui_control_visibility(value: Any) -> dict[str, dict[str, bool]]:
221 + submitted = value if isinstance(value, dict) else {}
222 + normalized = {}
223 + for control, devices in UI_CONTROL_VISIBILITY_DEFAULTS.items():
224 + submitted_devices = submitted.get(control, {})
225 + if not isinstance(submitted_devices, dict):
226 + submitted_devices = {}
227 + normalized[control] = {
228 + device: submitted_devices.get(device)
229 + if isinstance(submitted_devices.get(device), bool)
230 + else default
231 + for device, default in devices.items()
232 + }
233 + return normalized
234 +
235 +
236 def _resolve_runtime_timezone(setting_value: str, browser_timezone: str | None = None) -> str:
237 if setting_value == TIMEZONE_AUTO:
238 candidate = str(browser_timezone or "").strip()
@@ -407,6 +430,7 @@ def normalize_settings(settings: Settings) -> Settings:
430 )
431 copy["timezone"] = _normalize_timezone_setting(copy.get("timezone"), default["timezone"])
432 copy["time_format"] = _normalize_time_format(copy.get("time_format"), default["time_format"])
433 + copy["ui_control_visibility"] = _normalize_ui_control_visibility(copy.get("ui_control_visibility"))
434
435 return copy
436
@@ -507,6 +531,9 @@ def get_default_settings() -> Settings:
531 ),
532 timezone=_normalize_timezone_setting(get_default_value("timezone", TIMEZONE_AUTO)),
533 time_format=_normalize_time_format(get_default_value("time_format", TIME_FORMAT_12H)),
534 + ui_control_visibility=_normalize_ui_control_visibility(
535 + get_default_value("ui_control_visibility", UI_CONTROL_VISIBILITY_DEFAULTS)
536 + ),
537 workdir_path=get_default_value("workdir_path", files.get_abs_path_dockerized("usr/workdir")),
538 workdir_show=get_default_value("workdir_show", True),
539 workdir_max_depth=get_default_value("workdir_max_depth", 5),
helpers/settings.py.dox.md
+3 -1
@@ -25,6 +25,7 @@
25 - `_is_valid_timezone(value: str) -> bool`
26 - `_normalize_timezone_setting(value: Any, default: str=...) -> str`
27 - `_normalize_time_format(value: Any, default: str=...) -> str`
28 +- `_normalize_ui_control_visibility(value: Any) -> dict[str, dict[str, bool]]`
29 - `_resolve_runtime_timezone(setting_value: str, browser_timezone: str | None=...) -> str`
30 - `_timezone_options() -> list[FieldOption]`
31 - `convert_out(settings: Settings) -> SettingsOutput`
@@ -50,7 +51,7 @@
51 - `_dict_to_env(data_dict)`
52 - `set_root_password(password: str)`
53 - `get_runtime_config(set: Settings)`
53 -- Notable constants/configuration names: `T`, `PASSWORD_PLACEHOLDER`, `API_KEY_PLACEHOLDER`, `TIMEZONE_AUTO`, `TIME_FORMAT_12H`, `TIME_FORMAT_24H`, `SETTINGS_FILE`.
54 +- Notable constants/configuration names: `T`, `PASSWORD_PLACEHOLDER`, `API_KEY_PLACEHOLDER`, `TIMEZONE_AUTO`, `TIME_FORMAT_12H`, `TIME_FORMAT_24H`, `UI_CONTROL_VISIBILITY_DEFAULTS`, `SETTINGS_FILE`.
55
56 ## Runtime Contracts
57
@@ -65,6 +66,7 @@
66 - Applying settings refreshes active context configs while preserving each subordinate agent's own profile.
67 - Applying settings starts a deferred `MCPConfig.update(...)` with the current `mcp_servers` string when global MCP server settings change.
68 - `max_consecutive_unusable_responses` defaults to `2` and controls the cost circuit breaker for malformed or repeated main-model outputs.
69 +- `ui_control_visibility` stores validated mobile and desktop visibility flags for the project selector, clock, connection status, and right canvas rail; missing or malformed values fall back per device.
70 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
71
72 ## Work Guidance
helpers/ui_server.py
+9
@@ -1,6 +1,7 @@
1 from dataclasses import dataclass, field
2 from datetime import timedelta
3 import asyncio
4 +import json
5 import logging
6 import os
7 import secrets
@@ -262,6 +263,13 @@ class UiRouteHandlers:
263 user_time_format_setting = str(settings_helper.get_settings().get("time_format", "12h"))
264 except Exception:
265 user_time_format_setting = "12h"
266 + try:
267 + user_ui_control_visibility = json.dumps(
268 + settings_helper.get_settings()["ui_control_visibility"],
269 + separators=(",", ":"),
270 + )
271 + except Exception:
272 + user_ui_control_visibility = json.dumps(settings_helper.UI_CONTROL_VISIBILITY_DEFAULTS)
273
274 index = files.read_file("webui/index.html")
275 return files.replace_placeholders_text(
@@ -273,6 +281,7 @@ class UiRouteHandlers:
281 logged_in=("true" if login.get_credentials_hash() else "false"),
282 user_timezone_setting=user_timezone_setting,
283 user_time_format_setting=user_time_format_setting,
284 + user_ui_control_visibility=user_ui_control_visibility,
285 )
286
287 @requires_auth
helpers/ui_server.py.dox.md
+1
@@ -41,6 +41,7 @@
41 ## Key Concepts
42
43 - Important called helpers/classes observed in the source: `logging.getLogger.setLevel`, `Localization.get.apply_process_timezone`, `_positive_int_env`, `field`, `Flask`, `threading.RLock`, `socketio.AsyncServer`, `WsManager`, `set_shared_ws_manager`, `cls`, `server_runtime.refresh_runtime_settings`, `settings_helper.get_settings`, `settings_helper.set_runtime_settings_snapshot`, `self.ws_manager.set_server_restart_broadcast`, `UiRouteHandlers`, `self.webapp.add_url_rule`, `register_api_route`, `register_ws_namespace`, `files.read_file`, `render_template_string`, `session.pop`.
44 +- `serve_index()` bootstraps the normalized UI control visibility map alongside timezone and time-format preferences so controls render correctly before Settings is opened.
45 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
46
47 ## Work Guidance
tests/test_ui_control_visibility.py new
+79
@@ -0,0 +1,79 @@
1 +import json
2 +from pathlib import Path
3 +import subprocess
4 +import sys
5 +
6 +
7 +ROOT = Path(__file__).resolve().parents[1]
8 +
9 +
10 +def read(path: str) -> str:
11 + return (ROOT / path).read_text(encoding="utf-8")
12 +
13 +
14 +def test_ui_controls_have_independent_mobile_and_desktop_visibility() -> None:
15 + preferences = read("webui/components/sidebar/bottom/preferences/preferences-store.js")
16 + settings_store = read("webui/components/settings/settings-store.js")
17 + settings = read("webui/components/settings/agent/agent-settings.html")
18 + interface = read("webui/components/settings/agent/interface.html")
19 + chat_top = read("webui/components/chat/top-section/chat-top.html")
20 + canvas = read("webui/components/canvas/right-canvas.html")
21 + index = read("webui/index.html")
22 + ui_server = read("helpers/ui_server.py")
23 +
24 + for control in ("projectSelector", "time", "connectionStatus", "rightCanvasRail"):
25 + assert control in preferences
26 + assert control in settings_store
27 +
28 + assert "section-interface" in settings_store
29 + assert 'settings/agent/interface.html' in settings
30 + assert "smartphone" in interface
31 + assert "desktop_windows" in interface
32 + assert 'globalThis.addEventListener("resize"' in preferences
33 + assert "uiControlVisibility" in index
34 + assert "user_ui_control_visibility" in ui_server
35 + assert "ui_control_visibility" in settings_store
36 + assert "Shown everywhere" in settings_store
37 + assert "Mobile only" in settings_store
38 + assert "Desktop only" in settings_store
39 + assert "Hidden everywhere" in settings_store
40 + assert "isUiControlVisible('time')" in chat_top
41 + assert "isUiControlVisible('connectionStatus')" in chat_top
42 + assert "isUiControlVisible('projectSelector')" in chat_top
43 + assert "isUiControlVisible('rightCanvasRail')" in canvas
44 +
45 +
46 +def test_ui_control_visibility_settings_are_normalized() -> None:
47 + result = subprocess.run(
48 + [
49 + sys.executable,
50 + "-c",
51 + """
52 +import json
53 +from helpers import settings
54 +
55 +defaults = settings.get_default_settings()["ui_control_visibility"]
56 +normalized = settings.normalize_settings({
57 + **settings.get_default_settings(),
58 + "ui_control_visibility": {
59 + "time": {"mobile": True, "desktop": False},
60 + "projectSelector": "invalid",
61 + "unknown": {"mobile": False},
62 + },
63 +})["ui_control_visibility"]
64 +print(json.dumps({"defaults": defaults, "normalized": normalized}))
65 +""",
66 + ],
67 + cwd=ROOT,
68 + check=True,
69 + capture_output=True,
70 + text=True,
71 + )
72 + data = json.loads(result.stdout)
73 + defaults = data["defaults"]
74 + normalized = data["normalized"]
75 +
76 + assert defaults["time"] == {"mobile": False, "desktop": True}
77 + assert normalized["time"] == {"mobile": True, "desktop": False}
78 + assert normalized["projectSelector"] == {"mobile": True, "desktop": True}
79 + assert "unknown" not in normalized
webui/components/canvas/AGENTS.md
+1
@@ -18,6 +18,7 @@
18 - Keep the right-canvas rail and docked shell hidden while the welcome screen is active; non-action surface opens during welcome must route into floating/modal surfaces instead of docking beside the welcome screen.
19 - Preserve docked canvas open state across same-tab reloads with session-scoped state, but do not treat it as durable cross-session UI state.
20 - In mobile mode, keep the rail below blocking modal layers and compact it on very narrow screens instead of letting it cover modal content.
21 +- Keep the canvas shell functional when the instance-level visibility preference hides only the right-canvas rail.
22
23 ## Work Guidance
24
webui/components/canvas/right-canvas.html
+2 -1
@@ -2,6 +2,7 @@
2 <head>
3 <script type="module">
4 import { store } from "/components/canvas/right-canvas-store.js";
5 + import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
6 </script>
7 </head>
8 <body>
@@ -24,7 +25,7 @@
25 >
26 <div class="right-canvas-resize-handle" title="Resize canvas" @pointerdown="$store.rightCanvas.startResize($event)"></div>
27
27 - <nav class="right-canvas-rail" aria-label="Canvas surfaces">
28 + <nav class="right-canvas-rail" aria-label="Canvas surfaces" x-show="$store.preferences.isUiControlVisible('rightCanvasRail')">
29 <button
30 type="button"
31 class="right-canvas-rail-button right-canvas-toggle-button"
webui/components/chat/AGENTS.md
+1
@@ -25,6 +25,7 @@
25 - The setup gate must delegate Cloud/Local setup, account connections, and advanced model configuration to the existing onboarding and plugin settings modals; do not duplicate provider/model/key forms inline.
26 - A connected OAuth account without Main/Utility model selection is its own gate state; route to model configuration and do not select models automatically.
27 - Model setup surfaces that change readiness must notify the gate with `model-setup-changed`, `model-configured`, or an existing modal/onboarding completion signal so the pending prompt can retry automatically.
28 +- The top-section project selector, clock, and connection indicator must respect the instance-level mobile/desktop visibility preferences.
29
30 ## Work Guidance
31
webui/components/chat/top-section/chat-top.html
+8 -3
@@ -6,6 +6,7 @@
6 <!-- Import the alpine store -->
7 <script type="module">
8 import { store } from "/components/chat/top-section/chat-top-store.js";
9 + import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
10 </script>
11 </head>
12
@@ -17,12 +18,16 @@
18 <!-- Time and Date -->
19 <div id="time-date-container">
20 <x-extension id="chat-top-start"></x-extension>
20 - <div id="time-date"></div>
21 - <x-component path="sync/sync-status.html"></x-component>
21 + <div id="time-date" x-show="$store.preferences.isUiControlVisible('time')"></div>
22 + <div x-show="$store.preferences.isUiControlVisible('connectionStatus')">
23 + <x-component path="sync/sync-status.html"></x-component>
24 + </div>
25 <!-- Notification Toggle positioned next to time-date -->
26 <x-component path="notifications/notification-icons.html"></x-component>
27 <!-- Project Selector -->
25 - <x-component path="projects/project-selector.html"></x-component>
28 + <div x-show="$store.preferences.isUiControlVisible('projectSelector')">
29 + <x-component path="projects/project-selector.html"></x-component>
30 + </div>
31 <x-extension id="chat-top-end"></x-extension>
32 </div>
33 </template>
webui/components/settings/AGENTS.md
+2 -1
@@ -17,6 +17,7 @@
17 - Settings tabs that expose plugin `settings_sections` must mount `settings/plugins/plugins-subsection.html` with matching `data-tab` and sidebar/nav section IDs.
18 - Do not store secrets in localStorage, URLs, or console output.
19 - Preserve Store Gating and modal footer conventions in settings components.
20 +- Interface control visibility is edited as a Save/Cancel draft, persisted with instance settings, and applied through the shared frontend preference store after Settings saves successfully.
21 - MCP manager tool toggles write `disabled_tools` into the draft JSON and require Apply before changing the running MCP tool set.
22 - Confirmed MCP server removals apply immediately and refresh server status; other MCP manager draft edits still require Apply.
23 - MCP manager local command forms accept shell-style command and argument lines; quote argument values that intentionally contain spaces.
@@ -31,7 +32,7 @@
32
33 ## Verification
34
34 -- Smoke-test changed settings tabs and save/reload behavior after visible or API changes.
35 +- Smoke-test changed settings tabs, Interface mobile/desktop selectors, and save/reload behavior after visible or API changes.
36
37 ## Child DOX Index
38
webui/components/settings/agent/agent-settings.html
+10
@@ -39,6 +39,12 @@
39 <span>Locale</span>
40 </a>
41 </li>
42 + <li>
43 + <a href="#section-interface">
44 + <span class="material-symbols-outlined" aria-hidden="true">dashboard_customize</span>
45 + <span>Interface</span>
46 + </a>
47 + </li>
48 <li>
49 <a href="#section-agent-plugins">
50 <span class="material-symbols-outlined" aria-hidden="true">extension</span>
@@ -68,6 +74,10 @@
74 <x-component path="settings/agent/locale.html"></x-component>
75 </div>
76
77 + <div id="section-interface" class="section">
78 + <x-component path="settings/agent/interface.html"></x-component>
79 + </div>
80 +
81 <!-- Plugin settings subsection: shows plugins tagged with "agent" -->
82 <div id="section-agent-plugins" class="section">
83 <x-component path="settings/plugins/plugins-subsection.html" data-tab="agent"></x-component>
webui/components/settings/agent/interface.html new
+161
@@ -0,0 +1,161 @@
1 +<html>
2 + <head>
3 + <title>Interface</title>
4 + </head>
5 +
6 + <body>
7 + <div x-data>
8 + <template x-if="$store.settings.uiVisibility">
9 + <div>
10 + <div class="section-title">Interface controls</div>
11 + <div class="section-description">
12 + Choose where each control appears. Bright devices are shown; dim devices are hidden.
13 + </div>
14 +
15 + <div class="ui-visibility-list">
16 + <template x-for="control in $store.settings.uiControls" :key="control.id">
17 + <div class="ui-visibility-row">
18 + <div class="ui-visibility-control">
19 + <span class="material-symbols-outlined" aria-hidden="true" x-text="control.icon"></span>
20 + <div>
21 + <div class="field-title" x-text="control.label"></div>
22 + <div class="ui-visibility-state" x-text="$store.settings.uiControlVisibilityLabel(control.id)"></div>
23 + </div>
24 + </div>
25 +
26 + <div class="ui-device-selector" :aria-label="`${control.label} visibility`">
27 + <button
28 + type="button"
29 + class="ui-device-button"
30 + :class="{ 'is-visible': $store.settings.isUiControlVisible(control.id, 'mobile') }"
31 + :aria-label="`Show ${control.label.toLowerCase()} on mobile`"
32 + :aria-pressed="$store.settings.isUiControlVisible(control.id, 'mobile').toString()"
33 + :title="$store.settings.isUiControlVisible(control.id, 'mobile') ? 'Shown on mobile' : 'Hidden on mobile'"
34 + @click="$store.settings.toggleUiControl(control.id, 'mobile')"
35 + >
36 + <span class="material-symbols-outlined" aria-hidden="true">smartphone</span>
37 + <span>Mobile</span>
38 + </button>
39 + <button
40 + type="button"
41 + class="ui-device-button"
42 + :class="{ 'is-visible': $store.settings.isUiControlVisible(control.id, 'desktop') }"
43 + :aria-label="`Show ${control.label.toLowerCase()} on desktop`"
44 + :aria-pressed="$store.settings.isUiControlVisible(control.id, 'desktop').toString()"
45 + :title="$store.settings.isUiControlVisible(control.id, 'desktop') ? 'Shown on desktop' : 'Hidden on desktop'"
46 + @click="$store.settings.toggleUiControl(control.id, 'desktop')"
47 + >
48 + <span class="material-symbols-outlined" aria-hidden="true">desktop_windows</span>
49 + <span>Desktop</span>
50 + </button>
51 + </div>
52 + </div>
53 + </template>
54 + </div>
55 + </div>
56 + </template>
57 + </div>
58 + </body>
59 +
60 + <style>
61 + .ui-visibility-list {
62 + margin-top: 1rem;
63 + overflow: hidden;
64 + border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
65 + border-radius: 10px;
66 + background: color-mix(in srgb, var(--color-panel) 48%, transparent);
67 + }
68 +
69 + .ui-visibility-row {
70 + display: grid;
71 + grid-template-columns: minmax(0, 1fr) auto;
72 + align-items: center;
73 + gap: 1rem;
74 + min-height: 78px;
75 + padding: 0.8rem 0.9rem;
76 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent);
77 + }
78 +
79 + .ui-visibility-row:last-child {
80 + border-bottom: 0;
81 + }
82 +
83 + .ui-visibility-control {
84 + display: flex;
85 + align-items: center;
86 + gap: 0.75rem;
87 + min-width: 0;
88 + }
89 +
90 + .ui-visibility-control > .material-symbols-outlined {
91 + display: grid;
92 + flex: 0 0 38px;
93 + width: 38px;
94 + height: 38px;
95 + place-items: center;
96 + border-radius: 9px;
97 + background: color-mix(in srgb, var(--color-text) 8%, transparent);
98 + color: var(--color-text);
99 + font-size: 21px;
100 + }
101 +
102 + .ui-visibility-state {
103 + margin-top: 0.18rem;
104 + color: var(--color-text-muted);
105 + font-size: 0.78rem;
106 + }
107 +
108 + .ui-device-selector {
109 + display: grid;
110 + grid-template-columns: repeat(2, 78px);
111 + gap: 0.45rem;
112 + }
113 +
114 + .ui-device-button {
115 + display: flex;
116 + min-height: 54px;
117 + align-items: center;
118 + justify-content: center;
119 + flex-direction: column;
120 + gap: 0.15rem;
121 + padding: 0.4rem;
122 + border: 1px solid transparent;
123 + border-radius: 8px;
124 + background: transparent;
125 + color: var(--color-text-muted);
126 + cursor: pointer;
127 + font: inherit;
128 + font-size: 0.72rem;
129 + opacity: 0.35;
130 + transition: opacity 0.16s ease, color 0.16s ease, border-color 0.16s ease, background-color 0.16s ease;
131 + }
132 +
133 + .ui-device-button:hover,
134 + .ui-device-button:focus-visible {
135 + opacity: 0.72;
136 + outline: none;
137 + border-color: color-mix(in srgb, var(--color-border) 80%, transparent);
138 + }
139 +
140 + .ui-device-button.is-visible {
141 + opacity: 1;
142 + color: var(--color-text);
143 + border-color: color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
144 + background: color-mix(in srgb, var(--color-primary) 10%, transparent);
145 + }
146 +
147 + .ui-device-button .material-symbols-outlined {
148 + font-size: 22px;
149 + }
150 +
151 + @media (max-width: 640px) {
152 + .ui-visibility-row {
153 + grid-template-columns: 1fr;
154 + }
155 +
156 + .ui-device-selector {
157 + grid-template-columns: repeat(2, minmax(0, 1fr));
158 + }
159 + }
160 + </style>
161 +</html>
webui/components/settings/settings-store.js
+43
@@ -1,6 +1,7 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import * as API from "/js/api.js";
3 import { store as notificationStore } from "/components/notifications/notification-store.js";
4 +import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
5 import {
6 getBrowserTimezone,
7 setConfiguredTimeFormat,
@@ -14,6 +15,13 @@ const UPDATE_STATUS_REFRESH_COOLDOWN_MS = 60 * 1000;
15 // Match the modal header/padding breathing room before promoting a section link.
16 const SECTION_ACTIVATION_OFFSET = 56;
17
18 +const UI_CONTROLS = Object.freeze([
19 + { id: "projectSelector", label: "Project selector", icon: "folder_open" },
20 + { id: "time", label: "Time", icon: "schedule" },
21 + { id: "connectionStatus", label: "Connection status", icon: "wifi" },
22 + { id: "rightCanvasRail", label: "Right canvas rail", icon: "dock_to_right" },
23 +]);
24 +
25 const TAB_ITEMS = Object.freeze([
26 {
27 id: "agent",
@@ -25,6 +33,7 @@ const TAB_ITEMS = Object.freeze([
33 { id: "section-voice", label: "Voice", icon: "mic" },
34 { id: "section-workdir", label: "Workdir", icon: "folder" },
35 { id: "section-locale", label: "Locale", icon: "language" },
36 + { id: "section-interface", label: "Interface", icon: "dashboard_customize" },
37 { id: "section-agent-plugins", label: "Plugins", icon: "extension" },
38 ],
39 },
@@ -110,6 +119,7 @@ const model = {
119 _updateStatusRefreshedAt: 0,
120 expandedNavGroups: {},
121 searchQuery: "",
122 + uiVisibility: null,
123
124 // Tab state
125 _activeTab: DEFAULT_TAB,
@@ -140,6 +150,7 @@ const model = {
150 async onOpen() {
151 this.error = null;
152 this.isLoading = true;
153 + this.uiVisibility = preferencesStore.uiVisibilitySnapshot();
154
155 try {
156 const response = await API.callJsonApi("settings_get", null);
@@ -147,6 +158,8 @@ const model = {
158 this.settings = response.settings;
159 this.additional = response.additional || null;
160 this.applyLocaleRuntime(this.settings);
161 + preferencesStore.setUiVisibility(this.settings.ui_control_visibility);
162 + this.uiVisibility = preferencesStore.uiVisibilitySnapshot();
163 } else {
164 throw new Error("Invalid settings response");
165 }
@@ -182,6 +195,34 @@ const model = {
195 this.error = null;
196 this.isLoading = false;
197 this.searchQuery = "";
198 + this.uiVisibility = null;
199 + },
200 +
201 + get uiControls() {
202 + return UI_CONTROLS;
203 + },
204 +
205 + isUiControlVisible(control, device) {
206 + return this.uiVisibility?.[control]?.[device] !== false;
207 + },
208 +
209 + toggleUiControl(control, device) {
210 + this.uiVisibility = {
211 + ...this.uiVisibility,
212 + [control]: {
213 + ...this.uiVisibility?.[control],
214 + [device]: !this.isUiControlVisible(control, device),
215 + },
216 + };
217 + },
218 +
219 + uiControlVisibilityLabel(control) {
220 + const mobile = this.isUiControlVisible(control, "mobile");
221 + const desktop = this.isUiControlVisible(control, "desktop");
222 + if (mobile && desktop) return "Shown everywhere";
223 + if (mobile) return "Mobile only";
224 + if (desktop) return "Desktop only";
225 + return "Hidden everywhere";
226 },
227
228 // Tab management
@@ -589,6 +630,7 @@ const model = {
630 return false;
631 }
632
633 + this.settings.ui_control_visibility = this.uiVisibility;
634 this.isLoading = true;
635 try {
636 const response = await API.callJsonApi("settings_set", {
@@ -599,6 +641,7 @@ const model = {
641 this.settings = response.settings;
642 this.additional = response.additional || this.additional;
643 this.applyLocaleRuntime(this.settings);
644 + preferencesStore.setUiVisibility(response.settings.ui_control_visibility);
645 toast("Settings saved successfully", "success");
646 document.dispatchEvent(
647 new CustomEvent("settings-updated", { detail: response.settings })
webui/components/sidebar/AGENTS.md
+1
@@ -21,6 +21,7 @@
21 - A restored selected parent chat with children auto-expands once during context hydration unless the user has already toggled it.
22 - The Tasks list is reserved for scheduler-backed task contexts and must not be used for chat-bound parallel children.
23 - Avoid text or controls overflowing fixed sidebar widths.
24 +- Instance-level interface visibility preferences own independent mobile and desktop states for the chat-top controls and right canvas rail; mobile uses the shared 768px breakpoint.
25
26 ## Work Guidance
27
webui/components/sidebar/bottom/preferences/preferences-store.js
+45
@@ -3,8 +3,29 @@ import * as css from "/js/css.js";
3 import { ttsService } from "/js/tts-service.js";
4 import { applyModeSteps } from "/components/messages/process-group/process-group-dom.js";
5
6 +const UI_VISIBILITY_DEFAULTS = Object.freeze({
7 + projectSelector: { mobile: true, desktop: true },
8 + time: { mobile: false, desktop: true },
9 + connectionStatus: { mobile: true, desktop: true },
10 + rightCanvasRail: { mobile: true, desktop: true },
11 +});
12 +
13 +function normalizeUiVisibility(value = {}) {
14 + return Object.fromEntries(
15 + Object.entries(UI_VISIBILITY_DEFAULTS).map(([control, defaults]) => [
16 + control,
17 + {
18 + mobile: typeof value?.[control]?.mobile === "boolean" ? value[control].mobile : defaults.mobile,
19 + desktop: typeof value?.[control]?.desktop === "boolean" ? value[control].desktop : defaults.desktop,
20 + },
21 + ])
22 + );
23 +}
24 +
25 // Preferences store centralizes user preference toggles and side-effects
26 const model = {
27 + _initialized: false,
28 +
29 // UI toggles (initialized with safe defaults, loaded from localStorage in init)
30 get autoScroll() {
31 return this._autoScroll;
@@ -70,6 +91,22 @@ const model = {
91 },
92 _detailMode: "current", // Default: show current step only
93
94 + _uiVisibility: normalizeUiVisibility(globalThis.runtimeInfo?.uiControlVisibility),
95 + _isMobileViewport: false,
96 +
97 + uiVisibilitySnapshot() {
98 + return normalizeUiVisibility(this._uiVisibility);
99 + },
100 +
101 + setUiVisibility(value) {
102 + this._uiVisibility = normalizeUiVisibility(value);
103 + },
104 +
105 + isUiControlVisible(control) {
106 + const device = this._isMobileViewport ? "mobile" : "desktop";
107 + return this._uiVisibility?.[control]?.[device] !== false;
108 + },
109 +
110 // Detail mode options for UI sidebar
111 detailModeOptions: [
112 { label: "NO", value: "collapsed", title: "All collapsed" },
@@ -80,6 +117,9 @@ const model = {
117
118 // Initialize preferences and apply current state
119 init() {
120 + if (this._initialized) return;
121 + this._initialized = true;
122 +
123 try {
124 // Load persisted preferences with safe fallbacks
125 try {
@@ -124,6 +164,11 @@ const model = {
164 this._showUtils = false; // Default to speech off if localStorage is unavailable
165 }
166
167 + this._isMobileViewport = globalThis.innerWidth <= 768;
168 + globalThis.addEventListener("resize", () => {
169 + this._isMobileViewport = globalThis.innerWidth <= 768;
170 + });
171 +
172 // Apply all preferences
173 this._applyDarkMode(this._darkMode);
174 this._applyAutoScroll(this._autoScroll);
webui/index.css
-4
@@ -982,10 +982,6 @@ input:checked + .slider:before {
982 /* Media Queries */
983 @media (max-width: 640px) {
984 /* Responsive tiles for mobile */
985 - #time-date {
986 - display: none;
987 - }
988 -
985 .preview-section {
986 grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
987 gap: 8px;
webui/index.html
+1
@@ -68,6 +68,7 @@
68 loggedIn: "{{logged_in}}" === "true",
69 timezone: "{{user_timezone_setting}}",
70 timeFormat: "{{user_time_format_setting}}",
71 + uiControlVisibility: {{user_ui_control_visibility}},
72 };
73 </script>
74 <!-- Plugin head injections (scripts, stylesheets) -->