Preserve surface windows across refresh
Restore restorable surface modals through the initFw_end extension hook and keep right-canvas state in session storage for normal reloads. Fix Desktop host visibility after modal cleanup so preserved iframes reattach to real modal or canvas hosts, and remove the duplicate file-browser path icon overlay.
Alessandro committed
Jun 23, 2026 at 18:28 UTC
3106abce964a67d895686ed563775c48b46a76a5
10 files changed
+206
-24
extensions/webui/initFw_end/AGENTS.md
+1
-1
@@ -6,7 +6,7 @@
6
7
## Ownership
8
9
-- JavaScript files own post-bootstrap global setup such as self-update helpers.
9
+- JavaScript files own post-bootstrap global setup such as self-update helpers and session-scoped UI restoration hooks.
10
11
## Local Contracts
12
extensions/webui/initFw_end/restoreRestorableModals.js
new
+5
@@ -0,0 +1,5 @@
1
+import { restoreRestorableModalStack } from "/js/modals.js";
2
+
3
+export default function restoreRestorableModals() {
4
+ restoreRestorableModalStack();
5
+}
plugins/_desktop/AGENTS.md
+1
@@ -16,6 +16,7 @@
16
- Preserve session startup, cleanup, and route protection for desktop access.
17
- Keep desktop state injected into prompts accurate and bounded.
18
- Do not expose desktop routes without the expected auth protections.
19
+- Keep Desktop host visibility tied to an attached modal or canvas host; modal cleanup may preserve the iframe in keepalive, but must not leave stale modal mode behind.
20
- Keep Markdown and plain text file open-with handling routed to the Editor surface through the desktop intent bridge; Desktop owns the Xfce launcher/MIME setup, while Editor owns `.md` and `.txt` editing.
21
22
## Work Guidance
plugins/_desktop/webui/desktop-store.js
+9
-2
@@ -311,6 +311,7 @@ const model = {
311
},
312
313
cleanup() {
314
+ const wasModal = this._mode === "modal";
315
this.flushInput();
316
this.stopDesktopMonitor();
317
this.stopDesktopResizeObserver();
@@ -321,7 +322,11 @@ const model = {
322
if (!this._desktopIntentionalShutdown) this.moveDesktopFrameToKeepalive();
323
this._floatingCleanup?.();
324
this._floatingCleanup = null;
324
- if (this._mode === "modal") this._root = null;
325
+ if (wasModal) {
326
+ this._root = null;
327
+ this._mode = "canvas";
328
+ this._desktopHostVisible = false;
329
+ }
330
},
331
332
async refresh() {
@@ -1098,7 +1103,9 @@ const model = {
1103
},
1104
1105
isDesktopHostVisible() {
1101
- if (this._mode === "modal") return true;
1106
+ if (this._mode === "modal") {
1107
+ return Boolean(this._root?.isConnected && this._root.closest?.(".modal"));
1108
+ }
1109
const surface = this._root?.closest?.('[data-surface-id="desktop"]');
1110
return Boolean(surface?.classList?.contains("is-mounted") || surface?.classList?.contains("is-active"));
1111
},
webui/components/canvas/AGENTS.md
+1
@@ -16,6 +16,7 @@
16
- Preserve responsive layout and avoid overlapping the chat/sidebar shells.
17
- Right-canvas rail and tab buttons are explicit canvas entry points above the mobile breakpoint; at mobile widths, keep the rail visible but route non-action surfaces into floating modals instead of the side canvas shell.
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
22
## Work Guidance
webui/components/canvas/right-canvas-store.js
+94
-9
@@ -13,6 +13,7 @@ import {
13
} from "/js/surfaces.js";
14
15
const STORAGE_KEY = "a0.rightCanvas";
16
+const SESSION_STORAGE_KEY = "a0.rightCanvas.session";
17
const DEFAULT_WIDTH = 720;
18
const MIN_WIDTH = 0;
19
const DESKTOP_BREAKPOINT = 1200;
@@ -32,6 +33,13 @@ function normalizeWidth(value, fallback = DEFAULT_WIDTH) {
33
return Number.isFinite(width) ? Math.max(MIN_WIDTH, Math.round(width)) : fallback;
34
}
35
36
+function isReloadNavigation() {
37
+ const navigation = performance.getEntriesByType?.("navigation")?.[0];
38
+ if (navigation?.type) return navigation.type === "reload";
39
+ if (!performance.navigation) return false;
40
+ return performance.navigation?.type === performance.navigation?.TYPE_RELOAD;
41
+}
42
+
43
const model = {
44
surfaces: [],
45
activeSurfaceId: "",
@@ -46,11 +54,15 @@ const model = {
54
_rootElement: null,
55
_resizeCleanup: null,
56
_lastPayloadBySurface: {},
57
+ _restoreOpenRequested: false,
58
+ _restoreOpenTimer: null,
59
+ _restoringSurface: false,
60
61
async init(element = null) {
62
if (element) this._rootElement = element;
63
if (this._initialized) {
64
this.applyLayoutState();
65
+ this.scheduleRestoreOpenSurface();
66
return;
67
}
68
@@ -70,6 +82,7 @@ const model = {
82
await callJsExtensions("right_canvas_register_surfaces", this);
83
this._registering = false;
84
this.ensureActiveSurface();
85
+ this.scheduleRestoreOpenSurface();
86
}
87
},
88
@@ -127,6 +140,9 @@ const model = {
140
return false;
141
}
142
143
+ this._restoreOpenRequested = false;
144
+ this.cancelRestoreOpenSurface();
145
+
146
if (surface.actionOnly) {
147
try {
148
await surface.open?.(payload || {});
@@ -226,6 +242,8 @@ const model = {
242
},
243
244
async close() {
245
+ this._restoreOpenRequested = false;
246
+ this.cancelRestoreOpenSurface();
247
this.isOpen = false;
248
this.persist();
249
this.applyLayoutState();
@@ -315,6 +333,9 @@ const model = {
333
const openModal = globalThis.ensureModalOpen || globalThis.openModal;
334
if (!openModal) return false;
335
336
+ this._restoreOpenRequested = false;
337
+ this.cancelRestoreOpenSurface();
338
+
339
if (this.isOpen && this.activeSurfaceId === targetId) {
340
this.isOpen = false;
341
this.persist();
@@ -420,20 +441,58 @@ const model = {
441
}
442
},
443
444
+ cancelRestoreOpenSurface() {
445
+ if (!this._restoreOpenTimer) return;
446
+ globalThis.clearTimeout?.(this._restoreOpenTimer);
447
+ this._restoreOpenTimer = null;
448
+ },
449
+
450
+ scheduleRestoreOpenSurface() {
451
+ if (!this._restoreOpenRequested || this._restoreOpenTimer) return;
452
+ if (!this.shouldRender() || this.isMobileMode) return;
453
+ this._restoreOpenTimer = globalThis.setTimeout?.(() => {
454
+ this._restoreOpenTimer = null;
455
+ this.restoreOpenSurface();
456
+ }, 0) || null;
457
+ },
458
+
459
+ async restoreOpenSurface() {
460
+ if (!this._restoreOpenRequested || this._restoringSurface) return false;
461
+ if (!this.shouldRender() || this.isMobileMode) {
462
+ return false;
463
+ }
464
+
465
+ const targetId = normalizeSurfaceId(this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
466
+ if (!targetId || !this.getSurface(targetId)) {
467
+ return false;
468
+ }
469
+
470
+ this._restoreOpenRequested = false;
471
+ this._restoringSurface = true;
472
+ try {
473
+ return await this.open(targetId, { source: "reload-restore" });
474
+ } finally {
475
+ this._restoringSurface = false;
476
+ }
477
+ },
478
+
479
persist() {
480
+ const state = {
481
+ isOpen: this.isOpen,
482
+ activeSurfaceId: this.activeSurfaceId,
483
+ surfaceModes: this.surfaceModes,
484
+ width: this.width,
485
+ };
486
try {
425
- localStorage.setItem(
426
- STORAGE_KEY,
427
- JSON.stringify({
428
- isOpen: this.isOpen,
429
- activeSurfaceId: this.activeSurfaceId,
430
- surfaceModes: this.surfaceModes,
431
- width: this.width,
432
- }),
433
- );
487
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
488
} catch (error) {
489
console.warn("Could not persist right canvas state", error);
490
}
491
+ try {
492
+ sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state));
493
+ } catch (error) {
494
+ console.warn("Could not persist right canvas session state", error);
495
+ }
496
},
497
498
restore() {
@@ -452,6 +511,31 @@ const model = {
511
} catch (error) {
512
console.warn("Could not restore right canvas state", error);
513
}
514
+
515
+ if (isReloadNavigation()) {
516
+ try {
517
+ const savedSession = migratePersistedSurfaceState(
518
+ JSON.parse(sessionStorage.getItem(SESSION_STORAGE_KEY) || "{}"),
519
+ );
520
+ if (savedSession?.activeSurfaceId) this.activeSurfaceId = String(savedSession.activeSurfaceId);
521
+ if (savedSession?.surfaceModes) {
522
+ this.surfaceModes = Object.fromEntries(
523
+ Object.entries(savedSession.surfaceModes || {}).map(([surfaceId, mode]) => [
524
+ surfaceId,
525
+ normalizeSurfaceMode(mode),
526
+ ]),
527
+ );
528
+ }
529
+ if (Number.isFinite(Number(savedSession?.width))) this.width = Number(savedSession.width);
530
+ this._restoreOpenRequested = Boolean(savedSession?.isOpen && savedSession?.activeSurfaceId);
531
+ } catch (error) {
532
+ console.warn("Could not restore right canvas session state", error);
533
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
534
+ }
535
+ } else {
536
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
537
+ }
538
+
539
this.setWidth(this.width, { persist: false });
540
},
541
@@ -485,6 +569,7 @@ const model = {
569
document.body.classList.toggle("right-canvas-open", this.isOpen && !this.isMobileMode && this.shouldRender());
570
document.body.classList.toggle("right-canvas-overlay-mode", this.isOverlayMode);
571
document.body.classList.toggle("right-canvas-mobile-mode", this.isMobileMode);
572
+ this.scheduleRestoreOpenSurface();
573
},
574
575
widthStyle() {
webui/components/modals/file-browser/file-browser.html
+1
-11
@@ -49,7 +49,6 @@
49
<span class="nav-button-label">Up</span>
50
</button>
51
<div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
52
- <span class="material-symbols-outlined path-input-icon" aria-hidden="true">folder_open</span>
52
<input
53
id="current-path"
54
class="path-input"
@@ -688,15 +687,6 @@
687
min-width: 0;
688
}
689
691
- .path-input-icon {
692
- position: absolute;
693
- left: 0.75rem;
694
- font-size: 1.15rem;
695
- color: var(--color-primary);
696
- opacity: 0.8;
697
- pointer-events: none;
698
- }
699
-
690
.path-input {
691
width: 100%;
692
height: 2.25rem;
@@ -705,7 +695,7 @@
695
border-radius: 6px;
696
background: color-mix(in srgb, var(--color-input) 78%, transparent);
697
color: var(--color-text);
708
- padding: 0 2.5rem 0 2.35rem;
698
+ padding: 0 2.5rem 0 0.75rem;
699
font: inherit;
700
font-family: 'Roboto Mono', monospace;
701
-webkit-font-optical-sizing: auto;
webui/js/AGENTS.md
+1
@@ -29,6 +29,7 @@
29
- Opening the same modal path multiple times must continue creating multiple stack entries; no dedupe is assumed.
30
- `closeModal()` with no path closes the top modal; `closeModal(path)` closes that path wherever it is in the stack; missing paths are no-ops.
31
- Modal stack semantics are top-modal-first for Escape, close buttons, z-index, and backdrop placement.
32
+- Restorable modal state is session-scoped and opt-in; surface modals may set `data-modal-restore="surface"` and `modals.js` restores only those path-based surface windows after reload navigation. Browser hard-refresh is still reported to app code as reload, so do not treat this as durable cross-session UI state.
33
- The modal shell structure is `.modal` > `.modal-inner` > `.modal-header`, `.modal-scroll` containing `.modal-bd`, and `.modal-footer-slot`.
34
- `data-modal-footer` content is relocated from modal body into `.modal-footer-slot`.
35
- Click-outside close requires both `mousedown` and `mouseup` on the outer `.modal` container.
webui/js/modals.js
+89
@@ -4,11 +4,21 @@ import { callJsExtensions } from "/js/extensions.js";
4
5
// Modal functionality
6
const modalStack = [];
7
+const RESTORABLE_MODAL_STACK_KEY = "a0.modalStack.restorable";
8
+let restoringModalSession = false;
9
+let restoredModalSession = false;
10
11
function sameModalPath(left = "", right = "") {
12
return String(left || "").replace(/^\/+/, "") === String(right || "").replace(/^\/+/, "");
13
}
14
15
+function isReloadNavigation() {
16
+ const navigation = performance.getEntriesByType?.("navigation")?.[0];
17
+ if (navigation?.type) return navigation.type === "reload";
18
+ if (!performance.navigation) return false;
19
+ return performance.navigation?.type === performance.navigation?.TYPE_RELOAD;
20
+}
21
+
22
function modalHasClass(modalOrElement, className) {
23
const element = modalOrElement?.element || modalOrElement;
24
return Boolean(
@@ -34,6 +44,42 @@ function modalSuppressesBackdrop(modalOrElement) {
44
|| modalDatasetFlag(modalOrElement, "modalNoBackdrop");
45
}
46
47
+function modalRestoreMode(modalOrElement) {
48
+ const element = modalOrElement?.element || modalOrElement;
49
+ const inner = element?.querySelector?.(".modal-inner");
50
+ return String(element?.dataset?.modalRestore || inner?.dataset?.modalRestore || "").trim();
51
+}
52
+
53
+function modalCanRestore(modalOrElement) {
54
+ return modalRestoreMode(modalOrElement) === "surface";
55
+}
56
+
57
+function restorableModalSnapshot() {
58
+ return modalStack
59
+ .filter((modal) => modalCanRestore(modal))
60
+ .map((modal) => ({ path: modal.path }));
61
+}
62
+
63
+export function persistRestorableModalStack(options = {}) {
64
+ if (restoringModalSession && options.force !== true) return;
65
+ try {
66
+ const modals = restorableModalSnapshot();
67
+ if (modals.length === 0) {
68
+ sessionStorage.removeItem(RESTORABLE_MODAL_STACK_KEY);
69
+ return;
70
+ }
71
+ sessionStorage.setItem(
72
+ RESTORABLE_MODAL_STACK_KEY,
73
+ JSON.stringify({
74
+ version: 1,
75
+ modals,
76
+ }),
77
+ );
78
+ } catch (error) {
79
+ console.warn("Could not persist restorable modals", error);
80
+ }
81
+}
82
+
83
function dispatchModalEvent(name, modal, detail = {}) {
84
document.dispatchEvent(
85
new CustomEvent(name, {
@@ -52,6 +98,7 @@ function activateModal(modal) {
98
updateModalZIndexes();
99
restoreModalScrollSnapshot(modal);
100
dispatchModalEvent("modal-activated", modal);
101
+ persistRestorableModalStack();
102
}
103
104
function findModalIndexByPath(modalPath) {
@@ -308,11 +355,52 @@ export function getModalStack() {
355
export function refreshModalStack() {
356
if (modalStack.length === 0) {
357
updateModalZIndexes();
358
+ persistRestorableModalStack();
359
return;
360
}
361
activateModal(modalStack[modalStack.length - 1]);
362
}
363
364
+export function restoreRestorableModalStack() {
365
+ if (restoredModalSession) return;
366
+ if (!isReloadNavigation()) {
367
+ sessionStorage.removeItem(RESTORABLE_MODAL_STACK_KEY);
368
+ return;
369
+ }
370
+ restoredModalSession = true;
371
+
372
+ let saved;
373
+ try {
374
+ saved = JSON.parse(sessionStorage.getItem(RESTORABLE_MODAL_STACK_KEY) || "{}");
375
+ } catch (error) {
376
+ console.warn("Could not restore restorable modals", error);
377
+ sessionStorage.removeItem(RESTORABLE_MODAL_STACK_KEY);
378
+ return;
379
+ }
380
+
381
+ const paths = Array.isArray(saved?.modals)
382
+ ? saved.modals
383
+ .map((entry) => String(entry?.path || "").trim())
384
+ .filter(Boolean)
385
+ : [];
386
+ if (paths.length === 0) return;
387
+
388
+ restoringModalSession = true;
389
+ for (const path of paths) {
390
+ try {
391
+ const openPromise = ensureModalOpen(path);
392
+ openPromise?.catch?.((error) => console.error(`Failed to restore modal ${path}`, error));
393
+ } catch (error) {
394
+ console.error(`Failed to restore modal ${path}`, error);
395
+ }
396
+ }
397
+
398
+ globalThis.setTimeout?.(() => {
399
+ restoringModalSession = false;
400
+ persistRestorableModalStack({ force: true });
401
+ }, 1500);
402
+}
403
+
404
export async function ensureModalOpen(modalPath, beforeClose = null) {
405
if (focusModal(modalPath)) return null;
406
return openModal(modalPath, beforeClose);
@@ -428,6 +516,7 @@ export async function closeModal(modalPath = null) {
516
},
517
}),
518
);
519
+ persistRestorableModalStack();
520
521
return true;
522
});
webui/js/surfaces.js
+4
-1
@@ -597,7 +597,9 @@ function markSurfaceModal(modal, metadata) {
597
const inner = modal?.inner || element?.querySelector?.(".modal-inner");
598
if (!element || !inner) return;
599
element.dataset.surfaceId = metadata.surfaceId;
600
+ element.dataset.modalRestore = "surface";
601
element.classList.add("surface-floating", "modal-floating", "modal-no-backdrop", "modal-explicit-close");
602
+ inner.dataset.modalRestore = "surface";
603
inner.classList.add("surface-modal", "modal-no-backdrop", "modal-explicit-close");
604
}
605
@@ -709,8 +711,9 @@ async function configureSurfaceModal(event) {
711
markSurfaceModal(modal, metadata);
712
configureModalSurfaceSwitcher(modal, metadata);
713
configureModalDockButton(modal, metadata);
712
- const { refreshModalStack } = await modalApi();
714
+ const { persistRestorableModalStack, refreshModalStack } = await modalApi();
715
refreshModalStack();
716
+ persistRestorableModalStack?.({ force: true });
717
}
718
719
export async function open(surfaceId = "", payload = {}) {