mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
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:
parent
4b49386f96
commit
3106abce96
10 changed files with 207 additions and 25 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
5
extensions/webui/initFw_end/restoreRestorableModals.js
Normal file
5
extensions/webui/initFw_end/restoreRestorableModals.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { restoreRestorableModalStack } from "/js/modals.js";
|
||||
|
||||
export default function restoreRestorableModals() {
|
||||
restoreRestorableModalStack();
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue