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 = {}) {