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.
This commit is contained in:
Alessandro 2026-06-23 18:28:16 +02:00
parent 4b49386f96
commit 3106abce96
10 changed files with 207 additions and 25 deletions

View file

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

View file

@ -0,0 +1,5 @@
import { restoreRestorableModalStack } from "/js/modals.js";
export default function restoreRestorableModals() {
restoreRestorableModalStack();
}

View file

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

View file

@ -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"));
},

View file

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

View file

@ -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() {

View file

@ -49,7 +49,6 @@
<span class="nav-button-label">Up</span>
</button>
<div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
<span class="material-symbols-outlined path-input-icon" aria-hidden="true">folder_open</span>
<input
id="current-path"
class="path-input"
@ -688,15 +687,6 @@
min-width: 0;
}
.path-input-icon {
position: absolute;
left: 0.75rem;
font-size: 1.15rem;
color: var(--color-primary);
opacity: 0.8;
pointer-events: none;
}
.path-input {
width: 100%;
height: 2.25rem;
@ -705,7 +695,7 @@
border-radius: 6px;
background: color-mix(in srgb, var(--color-input) 78%, transparent);
color: var(--color-text);
padding: 0 2.5rem 0 2.35rem;
padding: 0 2.5rem 0 0.75rem;
font: inherit;
font-family: 'Roboto Mono', monospace;
-webkit-font-optical-sizing: auto;

View file

@ -29,6 +29,7 @@
- Opening the same modal path multiple times must continue creating multiple stack entries; no dedupe is assumed.
- `closeModal()` with no path closes the top modal; `closeModal(path)` closes that path wherever it is in the stack; missing paths are no-ops.
- Modal stack semantics are top-modal-first for Escape, close buttons, z-index, and backdrop placement.
- 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.
- The modal shell structure is `.modal` > `.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.

View file

@ -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;
});

View file

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