Unify surface modal header actions
Add a shared grouped action rail for Browser, Desktop, and Editor floating surface modals so modality switches, canvas docking, focus mode, New, and close controls appear in a consistent order with separators. Fix the Desktop focus button class collision that hid the canvas docking button, and align dock button labels with canvas terminology.
Alessandro committed
May 22, 2026 at 14:19 UTC
b64c43e736f21643b6db2bd720da189fe2f15b40
10 files changed
+234
-48
plugins/_browser/webui/browser-store.js
+29
-7
@@ -7,7 +7,11 @@ import { store as chatInputStore } from "/components/chat/input/input-store.js";
7
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
8
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
9
import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
10
-import { openLatest as openLatestSurface, registerUrlHandler } from "/js/surfaces.js";
10
+import {
11
+ openLatest as openLatestSurface,
12
+ placeSurfaceModalHeaderAction,
13
+ registerUrlHandler,
14
+} from "/js/surfaces.js";
15
16
const websocket = getNamespacedClient("/ws");
17
websocket.addHandlers(["ws_webui"]);
@@ -2634,6 +2638,27 @@ const model = {
2638
};
2639
clampGeometry();
2640
2641
+ const newAction = globalThis.document.createElement("div");
2642
+ newAction.className = "browser-header-actions surface-modal-new-action";
2643
+ newAction.innerHTML = `
2644
+ <button type="button" class="browser-header-new-button surface-modal-new-button" title="New Browser" aria-label="New Browser">
2645
+ <span class="material-symbols-outlined" aria-hidden="true">add</span>
2646
+ <span>New</span>
2647
+ </button>
2648
+ `;
2649
+ const newButton = newAction.querySelector(".browser-header-new-button");
2650
+ const onNewClick = async () => {
2651
+ if (!newButton || newButton.disabled || this.isBusy()) return;
2652
+ newButton.disabled = true;
2653
+ try {
2654
+ await this.openNewBrowser();
2655
+ } finally {
2656
+ if (globalThis.document?.contains?.(newButton)) newButton.disabled = false;
2657
+ }
2658
+ };
2659
+ newButton?.addEventListener("click", onNewClick);
2660
+ placeSurfaceModalHeaderAction(header, newAction, "new");
2661
+
2662
const focusButton = globalThis.document.createElement("button");
2663
focusButton.type = "button";
2664
focusButton.className = "surface-button browser-modal-focus-button";
@@ -2658,12 +2683,7 @@ const model = {
2683
updateFocusButton(false);
2684
};
2685
updateFocusButton(false);
2661
- const closeButton = inner.querySelector(".modal-close");
2662
- if (closeButton) {
2663
- closeButton.insertAdjacentElement("beforebegin", focusButton);
2664
- } else {
2665
- header.appendChild(focusButton);
2666
- }
2686
+ placeSurfaceModalHeaderAction(header, focusButton, "window");
2687
const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
2688
focusButton.addEventListener("click", onFocusClick);
2689
@@ -2723,6 +2743,8 @@ const model = {
2743
header.addEventListener("pointerdown", onPointerDown);
2744
2745
this._floatingCleanup = () => {
2746
+ newButton?.removeEventListener("click", onNewClick);
2747
+ newAction.remove();
2748
focusButton.removeEventListener("click", onFocusClick);
2749
focusButton.remove();
2750
header.removeEventListener("pointerdown", onPointerDown);
plugins/_browser/webui/main.html
+1
-1
@@ -2,7 +2,7 @@
2
class="browser-modal"
3
data-surface-id="browser"
4
data-surface-modal-path="/plugins/_browser/webui/main.html"
5
- data-surface-dock-title="Open Browser in surface"
5
+ data-surface-dock-title="Open Browser in canvas"
6
data-surface-dock-icon="dock_to_right"
7
data-canvas-surface="browser"
8
data-canvas-modal-path="/plugins/_browser/webui/main.html"
plugins/_desktop/webui/desktop-panel.html
+1
-1
@@ -137,7 +137,7 @@
137
}
138
139
.modal-inner.office-modal .modal-header {
140
- grid-template-columns: minmax(0, 1fr) repeat(5, auto);
140
+ grid-template-columns: minmax(0, 1fr) auto auto;
141
}
142
143
.office-modal-input-shield {
plugins/_desktop/webui/desktop-store.js
+7
-18
@@ -2,7 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2
import { callJsonApi } from "/js/api.js";
3
import { getNamespacedClient } from "/js/websocket.js";
4
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5
-import { handleUrlIntent } from "/js/surfaces.js";
5
+import { handleUrlIntent, placeSurfaceModalHeaderAction } from "/js/surfaces.js";
6
7
const officeSocket = getNamespacedClient("/ws");
8
officeSocket.addHandlers(["ws_webui"]);
@@ -2337,9 +2337,9 @@ const model = {
2337
if (!header || header.querySelector(".office-header-actions")) return () => {};
2338
2339
const root = globalThis.document.createElement("div");
2340
- root.className = "office-header-actions";
2340
+ root.className = "office-header-actions surface-modal-new-action";
2341
root.innerHTML = `
2342
- <button type="button" class="office-header-new-button" aria-haspopup="menu" aria-expanded="false">
2342
+ <button type="button" class="office-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false">
2343
<span class="material-symbols-outlined" aria-hidden="true">add</span>
2344
<span>New</span>
2345
<span class="material-symbols-outlined office-new-chevron" aria-hidden="true">expand_more</span>
@@ -2396,14 +2396,7 @@ const model = {
2396
globalThis.document.addEventListener("click", onDocumentClick);
2397
globalThis.document.addEventListener("keydown", onDocumentKeydown);
2398
2399
- const firstHeaderAction = header.querySelector(
2400
- ".modal-surface-switcher, .modal-dock-button, .office-modal-focus-button, .modal-close",
2401
- );
2402
- if (firstHeaderAction) {
2403
- firstHeaderAction.insertAdjacentElement("beforebegin", root);
2404
- } else {
2405
- header.appendChild(root);
2406
- }
2399
+ placeSurfaceModalHeaderAction(header, root, "new");
2400
2401
setOpen(false);
2402
return () => {
@@ -2501,20 +2494,16 @@ const model = {
2494
2495
const focusButton = globalThis.document.createElement("button");
2496
focusButton.type = "button";
2504
- focusButton.className = "modal-dock-button office-modal-focus-button";
2497
+ focusButton.className = "surface-button office-modal-focus-button";
2498
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
2499
const updateFocusButton = (active) => {
2500
const label = active ? "Restore size" : "Focus mode";
2501
focusButton.setAttribute("aria-label", label);
2502
+ focusButton.setAttribute("title", label);
2503
focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
2504
};
2505
updateFocusButton(false);
2512
- const closeButton = inner.querySelector(".modal-close");
2513
- if (closeButton) {
2514
- closeButton.insertAdjacentElement("beforebegin", focusButton);
2515
- } else {
2516
- header.appendChild(focusButton);
2517
- }
2506
+ placeSurfaceModalHeaderAction(header, focusButton, "window");
2507
cleanup.push(() => focusButton.remove());
2508
2509
const setFocusMode = (enabled) => {
plugins/_desktop/webui/main.html
+1
-1
@@ -2,7 +2,7 @@
2
class="surface-modal office-modal modal-no-backdrop"
3
data-surface-id="desktop"
4
data-surface-modal-path="/plugins/_desktop/webui/main.html"
5
- data-surface-dock-title="Open Desktop in surface"
5
+ data-surface-dock-title="Open Desktop in canvas"
6
data-surface-dock-icon="dock_to_right"
7
data-canvas-surface="desktop"
8
data-canvas-modal-path="/plugins/_desktop/webui/main.html"
plugins/_editor/webui/editor-panel.html
+1
-1
@@ -322,7 +322,7 @@
322
}
323
324
.modal-inner.editor-modal .modal-header {
325
- grid-template-columns: minmax(0, 1fr) repeat(4, auto);
325
+ grid-template-columns: minmax(0, 1fr) auto auto;
326
}
327
328
.editor-tabs {
plugins/_editor/webui/editor-store.js
+6
-15
@@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2
import { callJsonApi } from "/js/api.js";
3
import { getNamespacedClient } from "/js/websocket.js";
4
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5
+import { placeSurfaceModalHeaderAction } from "/js/surfaces.js";
6
import {
7
buildMarkdownPages,
8
isExternalHref,
@@ -1591,9 +1592,9 @@ const model = {
1592
if (!header || header.querySelector(".editor-header-actions")) return () => {};
1593
1594
const root = document.createElement("div");
1594
- root.className = "editor-header-actions";
1595
+ root.className = "editor-header-actions surface-modal-new-action";
1596
root.innerHTML = `
1596
- <button type="button" class="editor-header-new-button" aria-haspopup="menu" aria-expanded="false">
1597
+ <button type="button" class="editor-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false">
1598
<span class="material-symbols-outlined" aria-hidden="true">add</span>
1599
<span>New</span>
1600
<span class="material-symbols-outlined editor-new-chevron" aria-hidden="true">expand_more</span>
@@ -1642,12 +1643,7 @@ const model = {
1643
document.addEventListener("click", onMarkdownClick);
1644
document.addEventListener("keydown", onMarkdownKeydown);
1645
1645
- const firstHeaderAction = header.querySelector(".modal-close");
1646
- if (firstHeaderAction) {
1647
- firstHeaderAction.insertAdjacentElement("beforebegin", root);
1648
- } else {
1649
- header.appendChild(root);
1650
- }
1646
+ placeSurfaceModalHeaderAction(header, root, "new");
1647
1648
setOpen(false);
1649
return () => {
@@ -1666,10 +1662,9 @@ const model = {
1662
inner.dataset.editorModalReady = "1";
1663
inner.classList.add("editor-modal");
1664
const cleanup = [];
1669
- const closeButton = inner.querySelector(".modal-close");
1665
const focusButton = document.createElement("button");
1666
focusButton.type = "button";
1672
- focusButton.className = "modal-dock-button editor-modal-focus-button";
1667
+ focusButton.className = "surface-button editor-modal-focus-button";
1668
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
1669
const updateFocusButton = (active) => {
1670
const label = active ? "Restore size" : "Focus mode";
@@ -1684,11 +1679,7 @@ const model = {
1679
updateFocusButton(active);
1680
};
1681
focusButton.addEventListener("click", onFocusClick);
1687
- if (closeButton) {
1688
- closeButton.insertAdjacentElement("beforebegin", focusButton);
1689
- } else {
1690
- header.appendChild(focusButton);
1691
- }
1682
+ placeSurfaceModalHeaderAction(header, focusButton, "window");
1683
cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
1684
cleanup.push(() => focusButton.remove());
1685
plugins/_editor/webui/main.html
+1
-1
@@ -2,7 +2,7 @@
2
class="surface-modal editor-modal modal-no-backdrop"
3
data-surface-id="editor"
4
data-surface-modal-path="/plugins/_editor/webui/main.html"
5
- data-surface-dock-title="Open Editor in surface"
5
+ data-surface-dock-title="Open Editor in canvas"
6
data-surface-dock-icon="dock_to_right"
7
data-canvas-surface="editor"
8
data-canvas-modal-path="/plugins/_editor/webui/main.html"
webui/css/surfaces.css
+92
@@ -29,6 +29,42 @@
29
gap: 5px;
30
}
31
32
+.surface-modal-actions {
33
+ display: inline-flex;
34
+ align-items: center;
35
+ gap: 5px;
36
+ min-width: 0;
37
+}
38
+
39
+.surface-modal-action-group {
40
+ display: inline-flex;
41
+ align-items: center;
42
+ gap: 5px;
43
+ min-width: 0;
44
+}
45
+
46
+.surface-modal-action-group[hidden],
47
+.surface-modal-action-separator[hidden] {
48
+ display: none;
49
+}
50
+
51
+.surface-modal-action-separator {
52
+ display: block;
53
+ flex: 0 0 1px;
54
+ width: 1px;
55
+ height: 20px;
56
+ margin: 0 5px;
57
+ border-radius: 999px;
58
+ background: color-mix(in srgb, var(--color-border) 72%, transparent);
59
+}
60
+
61
+.surface-modal-new-action {
62
+ position: relative;
63
+ display: inline-flex;
64
+ align-items: center;
65
+ flex: 0 0 auto;
66
+}
67
+
68
.surface-dock-button,
69
.surface-button,
70
.modal-dock-button,
@@ -68,6 +104,62 @@
104
font-size: 19px;
105
}
106
107
+.surface-modal-new-button {
108
+ appearance: none;
109
+ display: inline-flex;
110
+ align-items: center;
111
+ justify-content: center;
112
+ gap: 5px;
113
+ height: 34px;
114
+ min-height: 34px;
115
+ padding: 0 9px 0 8px;
116
+ border: 1px solid transparent;
117
+ border-radius: 7px;
118
+ background: transparent;
119
+ color: var(--color-text);
120
+ cursor: pointer;
121
+ font: inherit;
122
+ font-size: 12px;
123
+ font-weight: 750;
124
+ letter-spacing: 0;
125
+ line-height: 1;
126
+ opacity: 0.82;
127
+ white-space: nowrap;
128
+ transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
129
+}
130
+
131
+.surface-modal-new-button:hover:not(:disabled),
132
+.surface-modal-new-action.is-open .surface-modal-new-button {
133
+ opacity: 1;
134
+ border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
135
+ background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
136
+}
137
+
138
+.surface-modal-new-button:disabled {
139
+ cursor: default;
140
+ opacity: 0.45;
141
+}
142
+
143
+.surface-modal-new-button .material-symbols-outlined {
144
+ font-size: 18px;
145
+ line-height: 1;
146
+}
147
+
148
+@media (max-width: 560px) {
149
+ .surface-modal-actions,
150
+ .surface-modal-action-group {
151
+ gap: 3px;
152
+ }
153
+
154
+ .surface-modal-action-separator {
155
+ margin: 0 3px;
156
+ }
157
+
158
+ .surface-modal-new-button span:not(.material-symbols-outlined) {
159
+ display: none;
160
+ }
161
+}
162
+
163
.surface-image,
164
.modal-surface-image {
165
display: block;
webui/js/surfaces.js
+95
-3
@@ -8,6 +8,7 @@ const LEGACY_SURFACE_IDS = new Map([
8
9
const registeredSurfaces = new Map();
10
const urlHandlers = new Set();
11
+const SURFACE_MODAL_ACTION_GROUPS = ["surfaces", "window", "new"];
12
13
export const CORE_SURFACES = [
14
{
@@ -277,6 +278,97 @@ function getModalSwitchSurfaces(metadata) {
278
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
279
}
280
281
+function directChildByClass(parent, className) {
282
+ return Array.from(parent?.children || []).find((child) => child.classList?.contains(className)) || null;
283
+}
284
+
285
+function ensureSurfaceModalActionRail(header) {
286
+ if (!header) return null;
287
+ let rail = directChildByClass(header, "surface-modal-actions");
288
+ if (!rail) {
289
+ rail = document.createElement("div");
290
+ rail.className = "surface-modal-actions";
291
+ rail.setAttribute("aria-label", "Surface modal actions");
292
+
293
+ const closeButton = directChildByClass(header, "modal-close") || header.querySelector?.(".modal-close");
294
+ if (closeButton) {
295
+ closeButton.insertAdjacentElement("beforebegin", rail);
296
+ } else {
297
+ header.appendChild(rail);
298
+ }
299
+ }
300
+
301
+ for (const [index, groupName] of SURFACE_MODAL_ACTION_GROUPS.entries()) {
302
+ if (!rail.querySelector(`[data-surface-modal-action-group="${groupName}"]`)) {
303
+ if (index > 0 && !rail.querySelector(`[data-surface-modal-separator-before="${groupName}"]`)) {
304
+ const separator = document.createElement("span");
305
+ separator.className = "surface-modal-action-separator";
306
+ separator.dataset.surfaceModalSeparatorBefore = groupName;
307
+ separator.setAttribute("aria-hidden", "true");
308
+ rail.appendChild(separator);
309
+ }
310
+
311
+ const group = document.createElement("div");
312
+ group.className = `surface-modal-action-group surface-modal-action-group-${groupName}`;
313
+ group.dataset.surfaceModalActionGroup = groupName;
314
+ rail.appendChild(group);
315
+ }
316
+ }
317
+
318
+ refreshSurfaceModalActionRail(header);
319
+ return rail;
320
+}
321
+
322
+function surfaceModalActionGroup(header, groupName) {
323
+ const rail = ensureSurfaceModalActionRail(header);
324
+ return rail?.querySelector?.(`[data-surface-modal-action-group="${groupName}"]`) || null;
325
+}
326
+
327
+export function refreshSurfaceModalActionRail(header) {
328
+ const rail = directChildByClass(header, "surface-modal-actions");
329
+ if (!rail) return;
330
+
331
+ const groups = Object.fromEntries(
332
+ SURFACE_MODAL_ACTION_GROUPS.map((groupName) => [
333
+ groupName,
334
+ rail.querySelector(`[data-surface-modal-action-group="${groupName}"]`),
335
+ ]),
336
+ );
337
+ const hasActions = Object.fromEntries(
338
+ Object.entries(groups).map(([groupName, group]) => [
339
+ groupName,
340
+ Boolean(group?.children?.length),
341
+ ]),
342
+ );
343
+
344
+ for (const [groupName, group] of Object.entries(groups)) {
345
+ if (group) group.hidden = !hasActions[groupName];
346
+ }
347
+
348
+ const beforeWindow = rail.querySelector('[data-surface-modal-separator-before="window"]');
349
+ if (beforeWindow) beforeWindow.hidden = !(hasActions.surfaces && (hasActions.window || hasActions.new));
350
+
351
+ const beforeNew = rail.querySelector('[data-surface-modal-separator-before="new"]');
352
+ if (beforeNew) beforeNew.hidden = !(hasActions.window && hasActions.new);
353
+}
354
+
355
+export function placeSurfaceModalHeaderAction(header, element, groupName = "window", options = {}) {
356
+ if (!header || !element) return;
357
+ const normalizedGroup = SURFACE_MODAL_ACTION_GROUPS.includes(groupName) ? groupName : "window";
358
+ const group = surfaceModalActionGroup(header, normalizedGroup);
359
+ if (!group) return;
360
+
361
+ if (options.prepend) {
362
+ if (element.parentElement !== group || group.firstElementChild !== element) {
363
+ group.insertBefore(element, group.firstElementChild);
364
+ }
365
+ } else if (element.parentElement !== group) {
366
+ group.appendChild(element);
367
+ }
368
+
369
+ refreshSurfaceModalActionRail(header);
370
+}
371
+
372
function markSurfaceModal(modal, metadata) {
373
const element = modal?.element;
374
const inner = modal?.inner || element?.querySelector?.(".modal-inner");
@@ -350,11 +442,11 @@ function configureModalSurfaceSwitcher(modal, metadata) {
442
switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
443
}
444
353
- modal.close?.insertAdjacentElement("beforebegin", switcher);
445
+ placeSurfaceModalHeaderAction(modal.header, switcher, "surfaces");
446
}
447
448
function configureModalDockButton(modal, metadata) {
357
- if (!metadata || !modal?.header || modal.header.querySelector(".surface-dock-button, .modal-dock-button")) {
449
+ if (!metadata || !modal?.header || modal.header.querySelector(".surface-dock-button")) {
450
return;
451
}
452
@@ -384,7 +476,7 @@ function configureModalDockButton(modal, metadata) {
476
}
477
});
478
387
- modal.close?.insertAdjacentElement("beforebegin", button);
479
+ placeSurfaceModalHeaderAction(modal.header, button, "window", { prepend: true });
480
}
481
482
async function configureSurfaceModal(event) {