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) -->