From 3106abce964a67d895686ed563775c48b46a76a5 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:28:16 +0200 Subject: [PATCH] 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. --- extensions/webui/initFw_end/AGENTS.md | 2 +- .../initFw_end/restoreRestorableModals.js | 5 + plugins/_desktop/AGENTS.md | 1 + plugins/_desktop/webui/desktop-store.js | 11 +- webui/components/canvas/AGENTS.md | 1 + webui/components/canvas/right-canvas-store.js | 105 ++++++++++++++++-- .../modals/file-browser/file-browser.html | 12 +- webui/js/AGENTS.md | 1 + webui/js/modals.js | 89 +++++++++++++++ webui/js/surfaces.js | 5 +- 10 files changed, 207 insertions(+), 25 deletions(-) create mode 100644 extensions/webui/initFw_end/restoreRestorableModals.js diff --git a/extensions/webui/initFw_end/AGENTS.md b/extensions/webui/initFw_end/AGENTS.md index 5f18507b4..7390427de 100644 --- a/extensions/webui/initFw_end/AGENTS.md +++ b/extensions/webui/initFw_end/AGENTS.md @@ -6,7 +6,7 @@ ## Ownership -- JavaScript files own post-bootstrap global setup such as self-update helpers. +- JavaScript files own post-bootstrap global setup such as self-update helpers and session-scoped UI restoration hooks. ## Local Contracts diff --git a/extensions/webui/initFw_end/restoreRestorableModals.js b/extensions/webui/initFw_end/restoreRestorableModals.js new file mode 100644 index 000000000..51462c2bf --- /dev/null +++ b/extensions/webui/initFw_end/restoreRestorableModals.js @@ -0,0 +1,5 @@ +import { restoreRestorableModalStack } from "/js/modals.js"; + +export default function restoreRestorableModals() { + restoreRestorableModalStack(); +} diff --git a/plugins/_desktop/AGENTS.md b/plugins/_desktop/AGENTS.md index e7bdef438..5fc6f449c 100644 --- a/plugins/_desktop/AGENTS.md +++ b/plugins/_desktop/AGENTS.md @@ -16,6 +16,7 @@ - Preserve session startup, cleanup, and route protection for desktop access. - Keep desktop state injected into prompts accurate and bounded. - Do not expose desktop routes without the expected auth protections. +- 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. - 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. ## Work Guidance diff --git a/plugins/_desktop/webui/desktop-store.js b/plugins/_desktop/webui/desktop-store.js index 9c793da90..83f4cc086 100644 --- a/plugins/_desktop/webui/desktop-store.js +++ b/plugins/_desktop/webui/desktop-store.js @@ -311,6 +311,7 @@ const model = { }, cleanup() { + const wasModal = this._mode === "modal"; this.flushInput(); this.stopDesktopMonitor(); this.stopDesktopResizeObserver(); @@ -321,7 +322,11 @@ const model = { if (!this._desktopIntentionalShutdown) this.moveDesktopFrameToKeepalive(); this._floatingCleanup?.(); this._floatingCleanup = null; - if (this._mode === "modal") this._root = null; + if (wasModal) { + this._root = null; + this._mode = "canvas"; + this._desktopHostVisible = false; + } }, async refresh() { @@ -1098,7 +1103,9 @@ const model = { }, isDesktopHostVisible() { - if (this._mode === "modal") return true; + if (this._mode === "modal") { + return Boolean(this._root?.isConnected && this._root.closest?.(".modal")); + } const surface = this._root?.closest?.('[data-surface-id="desktop"]'); return Boolean(surface?.classList?.contains("is-mounted") || surface?.classList?.contains("is-active")); }, diff --git a/webui/components/canvas/AGENTS.md b/webui/components/canvas/AGENTS.md index 77dec922a..1d5eca08e 100644 --- a/webui/components/canvas/AGENTS.md +++ b/webui/components/canvas/AGENTS.md @@ -16,6 +16,7 @@ - Preserve responsive layout and avoid overlapping the chat/sidebar shells. - 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. - 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. +- Preserve docked canvas open state across same-tab reloads with session-scoped state, but do not treat it as durable cross-session UI state. - In mobile mode, keep the rail below blocking modal layers and compact it on very narrow screens instead of letting it cover modal content. ## Work Guidance diff --git a/webui/components/canvas/right-canvas-store.js b/webui/components/canvas/right-canvas-store.js index 315eeabd1..e6b3b7989 100644 --- a/webui/components/canvas/right-canvas-store.js +++ b/webui/components/canvas/right-canvas-store.js @@ -13,6 +13,7 @@ import { } from "/js/surfaces.js"; const STORAGE_KEY = "a0.rightCanvas"; +const SESSION_STORAGE_KEY = "a0.rightCanvas.session"; const DEFAULT_WIDTH = 720; const MIN_WIDTH = 0; const DESKTOP_BREAKPOINT = 1200; @@ -32,6 +33,13 @@ function normalizeWidth(value, fallback = DEFAULT_WIDTH) { return Number.isFinite(width) ? Math.max(MIN_WIDTH, Math.round(width)) : fallback; } +function isReloadNavigation() { + const navigation = performance.getEntriesByType?.("navigation")?.[0]; + if (navigation?.type) return navigation.type === "reload"; + if (!performance.navigation) return false; + return performance.navigation?.type === performance.navigation?.TYPE_RELOAD; +} + const model = { surfaces: [], activeSurfaceId: "", @@ -46,11 +54,15 @@ const model = { _rootElement: null, _resizeCleanup: null, _lastPayloadBySurface: {}, + _restoreOpenRequested: false, + _restoreOpenTimer: null, + _restoringSurface: false, async init(element = null) { if (element) this._rootElement = element; if (this._initialized) { this.applyLayoutState(); + this.scheduleRestoreOpenSurface(); return; } @@ -70,6 +82,7 @@ const model = { await callJsExtensions("right_canvas_register_surfaces", this); this._registering = false; this.ensureActiveSurface(); + this.scheduleRestoreOpenSurface(); } }, @@ -127,6 +140,9 @@ const model = { return false; } + this._restoreOpenRequested = false; + this.cancelRestoreOpenSurface(); + if (surface.actionOnly) { try { await surface.open?.(payload || {}); @@ -226,6 +242,8 @@ const model = { }, async close() { + this._restoreOpenRequested = false; + this.cancelRestoreOpenSurface(); this.isOpen = false; this.persist(); this.applyLayoutState(); @@ -315,6 +333,9 @@ const model = { const openModal = globalThis.ensureModalOpen || globalThis.openModal; if (!openModal) return false; + this._restoreOpenRequested = false; + this.cancelRestoreOpenSurface(); + if (this.isOpen && this.activeSurfaceId === targetId) { this.isOpen = false; this.persist(); @@ -420,20 +441,58 @@ const model = { } }, - persist() { + cancelRestoreOpenSurface() { + if (!this._restoreOpenTimer) return; + globalThis.clearTimeout?.(this._restoreOpenTimer); + this._restoreOpenTimer = null; + }, + + scheduleRestoreOpenSurface() { + if (!this._restoreOpenRequested || this._restoreOpenTimer) return; + if (!this.shouldRender() || this.isMobileMode) return; + this._restoreOpenTimer = globalThis.setTimeout?.(() => { + this._restoreOpenTimer = null; + this.restoreOpenSurface(); + }, 0) || null; + }, + + async restoreOpenSurface() { + if (!this._restoreOpenRequested || this._restoringSurface) return false; + if (!this.shouldRender() || this.isMobileMode) { + return false; + } + + const targetId = normalizeSurfaceId(this.activeSurfaceId || this.panelSurfaces[0]?.id || ""); + if (!targetId || !this.getSurface(targetId)) { + return false; + } + + this._restoreOpenRequested = false; + this._restoringSurface = true; try { - localStorage.setItem( - STORAGE_KEY, - JSON.stringify({ - isOpen: this.isOpen, - activeSurfaceId: this.activeSurfaceId, - surfaceModes: this.surfaceModes, - width: this.width, - }), - ); + return await this.open(targetId, { source: "reload-restore" }); + } finally { + this._restoringSurface = false; + } + }, + + persist() { + const state = { + isOpen: this.isOpen, + activeSurfaceId: this.activeSurfaceId, + surfaceModes: this.surfaceModes, + width: this.width, + }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (error) { console.warn("Could not persist right canvas state", error); } + try { + sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.warn("Could not persist right canvas session state", error); + } }, restore() { @@ -452,6 +511,31 @@ const model = { } catch (error) { console.warn("Could not restore right canvas state", error); } + + if (isReloadNavigation()) { + try { + const savedSession = migratePersistedSurfaceState( + JSON.parse(sessionStorage.getItem(SESSION_STORAGE_KEY) || "{}"), + ); + if (savedSession?.activeSurfaceId) this.activeSurfaceId = String(savedSession.activeSurfaceId); + if (savedSession?.surfaceModes) { + this.surfaceModes = Object.fromEntries( + Object.entries(savedSession.surfaceModes || {}).map(([surfaceId, mode]) => [ + surfaceId, + normalizeSurfaceMode(mode), + ]), + ); + } + if (Number.isFinite(Number(savedSession?.width))) this.width = Number(savedSession.width); + this._restoreOpenRequested = Boolean(savedSession?.isOpen && savedSession?.activeSurfaceId); + } catch (error) { + console.warn("Could not restore right canvas session state", error); + sessionStorage.removeItem(SESSION_STORAGE_KEY); + } + } else { + sessionStorage.removeItem(SESSION_STORAGE_KEY); + } + this.setWidth(this.width, { persist: false }); }, @@ -485,6 +569,7 @@ const model = { document.body.classList.toggle("right-canvas-open", this.isOpen && !this.isMobileMode && this.shouldRender()); document.body.classList.toggle("right-canvas-overlay-mode", this.isOverlayMode); document.body.classList.toggle("right-canvas-mobile-mode", this.isMobileMode); + this.scheduleRestoreOpenSurface(); }, widthStyle() { diff --git a/webui/components/modals/file-browser/file-browser.html b/webui/components/modals/file-browser/file-browser.html index c390caa30..25d4a11be 100644 --- a/webui/components/modals/file-browser/file-browser.html +++ b/webui/components/modals/file-browser/file-browser.html @@ -49,7 +49,6 @@ Up
- `.modal-inner` > `.modal-header`, `.modal-scroll` containing `.modal-bd`, and `.modal-footer-slot`. - `data-modal-footer` content is relocated from modal body into `.modal-footer-slot`. - Click-outside close requires both `mousedown` and `mouseup` on the outer `.modal` container. diff --git a/webui/js/modals.js b/webui/js/modals.js index 798aeb9e6..4df1b5153 100644 --- a/webui/js/modals.js +++ b/webui/js/modals.js @@ -4,11 +4,21 @@ import { callJsExtensions } from "/js/extensions.js"; // Modal functionality const modalStack = []; +const RESTORABLE_MODAL_STACK_KEY = "a0.modalStack.restorable"; +let restoringModalSession = false; +let restoredModalSession = false; function sameModalPath(left = "", right = "") { return String(left || "").replace(/^\/+/, "") === String(right || "").replace(/^\/+/, ""); } +function isReloadNavigation() { + const navigation = performance.getEntriesByType?.("navigation")?.[0]; + if (navigation?.type) return navigation.type === "reload"; + if (!performance.navigation) return false; + return performance.navigation?.type === performance.navigation?.TYPE_RELOAD; +} + function modalHasClass(modalOrElement, className) { const element = modalOrElement?.element || modalOrElement; return Boolean( @@ -34,6 +44,42 @@ function modalSuppressesBackdrop(modalOrElement) { || modalDatasetFlag(modalOrElement, "modalNoBackdrop"); } +function modalRestoreMode(modalOrElement) { + const element = modalOrElement?.element || modalOrElement; + const inner = element?.querySelector?.(".modal-inner"); + return String(element?.dataset?.modalRestore || inner?.dataset?.modalRestore || "").trim(); +} + +function modalCanRestore(modalOrElement) { + return modalRestoreMode(modalOrElement) === "surface"; +} + +function restorableModalSnapshot() { + return modalStack + .filter((modal) => modalCanRestore(modal)) + .map((modal) => ({ path: modal.path })); +} + +export function persistRestorableModalStack(options = {}) { + if (restoringModalSession && options.force !== true) return; + try { + const modals = restorableModalSnapshot(); + if (modals.length === 0) { + sessionStorage.removeItem(RESTORABLE_MODAL_STACK_KEY); + return; + } + sessionStorage.setItem( + RESTORABLE_MODAL_STACK_KEY, + JSON.stringify({ + version: 1, + modals, + }), + ); + } catch (error) { + console.warn("Could not persist restorable modals", error); + } +} + function dispatchModalEvent(name, modal, detail = {}) { document.dispatchEvent( new CustomEvent(name, { @@ -52,6 +98,7 @@ function activateModal(modal) { updateModalZIndexes(); restoreModalScrollSnapshot(modal); dispatchModalEvent("modal-activated", modal); + persistRestorableModalStack(); } function findModalIndexByPath(modalPath) { @@ -308,11 +355,52 @@ export function getModalStack() { export function refreshModalStack() { if (modalStack.length === 0) { updateModalZIndexes(); + persistRestorableModalStack(); return; } activateModal(modalStack[modalStack.length - 1]); } +export function restoreRestorableModalStack() { + if (restoredModalSession) return; + if (!isReloadNavigation()) { + sessionStorage.removeItem(RESTORABLE_MODAL_STACK_KEY); + return; + } + restoredModalSession = true; + + let saved; + try { + saved = JSON.parse(sessionStorage.getItem(RESTORABLE_MODAL_STACK_KEY) || "{}"); + } catch (error) { + console.warn("Could not restore restorable modals", error); + sessionStorage.removeItem(RESTORABLE_MODAL_STACK_KEY); + return; + } + + const paths = Array.isArray(saved?.modals) + ? saved.modals + .map((entry) => String(entry?.path || "").trim()) + .filter(Boolean) + : []; + if (paths.length === 0) return; + + restoringModalSession = true; + for (const path of paths) { + try { + const openPromise = ensureModalOpen(path); + openPromise?.catch?.((error) => console.error(`Failed to restore modal ${path}`, error)); + } catch (error) { + console.error(`Failed to restore modal ${path}`, error); + } + } + + globalThis.setTimeout?.(() => { + restoringModalSession = false; + persistRestorableModalStack({ force: true }); + }, 1500); +} + export async function ensureModalOpen(modalPath, beforeClose = null) { if (focusModal(modalPath)) return null; return openModal(modalPath, beforeClose); @@ -428,6 +516,7 @@ export async function closeModal(modalPath = null) { }, }), ); + persistRestorableModalStack(); return true; }); diff --git a/webui/js/surfaces.js b/webui/js/surfaces.js index 185aa4701..0f016e8d5 100644 --- a/webui/js/surfaces.js +++ b/webui/js/surfaces.js @@ -597,7 +597,9 @@ function markSurfaceModal(modal, metadata) { const inner = modal?.inner || element?.querySelector?.(".modal-inner"); if (!element || !inner) return; element.dataset.surfaceId = metadata.surfaceId; + element.dataset.modalRestore = "surface"; element.classList.add("surface-floating", "modal-floating", "modal-no-backdrop", "modal-explicit-close"); + inner.dataset.modalRestore = "surface"; inner.classList.add("surface-modal", "modal-no-backdrop", "modal-explicit-close"); } @@ -709,8 +711,9 @@ async function configureSurfaceModal(event) { markSurfaceModal(modal, metadata); configureModalSurfaceSwitcher(modal, metadata); configureModalDockButton(modal, metadata); - const { refreshModalStack } = await modalApi(); + const { persistRestorableModalStack, refreshModalStack } = await modalApi(); refreshModalStack(); + persistRestorableModalStack?.({ force: true }); } export async function open(surfaceId = "", payload = {}) {