/** * Browser history + URL integration for the DSH Web UI. * * The shell keeps its navigation (selected session, central panel, settings * dialog + section) in memory and never touches window.history or the URL. * This plugin mirrors that state into the URL hash and into history entries: * * #/session/[/parent//mode/][/panel/][/settings/
] * * - every user navigation does history.pushState (so Back/Forward work), * changes during app startup use replaceState; * - popstate / hashchange / a page load with a hash restores the state; * - mouse buttons 4/5 (Back/Forward) map to history.back()/forward(). */ window.__ModuleLoader__.load({ id: '@local/dsh-history-nav', factory() { const KEY = 'dshNav'; const SETTLE_MS = 200; // time the shell gets to settle after a restore const BOOT_GRACE_MS = 1500; // un-hashed changes this early are startup, not navigation const BOOT_RESTORE_MS = 15000; const POLL_MS = 200; const keyOf = (v) => JSON.stringify(v ?? null); const enc = encodeURIComponent; const dec = (s) => { try { return decodeURIComponent(s); } catch { return s; } }; // ---- URL <-> nav ------------------------------------------------------ // nav = { session: null | {id, parent?, mode?}, panel: null | string, settings: null | string } function format(nav) { const parts = []; if (nav.session) { parts.push('session', enc(nav.session.id)); if (nav.session.parent) parts.push('parent', enc(nav.session.parent), 'mode', enc(nav.session.mode || 'unknown')); } if (nav.panel) parts.push('panel', enc(nav.panel)); if (nav.settings != null) parts.push('settings', enc(nav.settings || '_')); return '#/' + parts.join('/'); } function parse(hash) { const nav = { session: null, panel: null, settings: null }; const body = (hash || '').replace(/^#\/?/, ''); if (!body) return nav; const seg = body.split('/').map(dec); const map = {}; for (let i = 0; i + 1 < seg.length; i += 2) map[seg[i]] = seg[i + 1]; if (map.session) { nav.session = { id: map.session }; if (map.parent) { nav.session.parent = map.parent; nav.session.mode = map.mode || 'unknown'; } } if (map.panel) nav.panel = map.panel; if (map.settings != null) nav.settings = map.settings === '_' ? '' : map.settings; return nav; } const isEmpty = (nav) => !nav.session && !nav.panel && nav.settings == null; function start(ctx) { const layout = ctx.layout; const ws = ctx.uiWorkspace; const sessions = ctx.get('sessions'); const slots = ctx.get('slots'); const startedAt = Date.now(); // ---- settings dialog store (shared instance behind the sidebar.settings entry) let settingsStore = null; let settingsOff = null; function attachSettings() { let store = null; try { const entry = slots && slots.entries('sidebar.settings')[0]; const decl = entry && entry.store; store = decl ? (typeof decl === 'function' ? decl() : decl.create()) : null; } catch { store = null; } if (store === settingsStore) return; if (settingsOff) { settingsOff(); settingsOff = null; } settingsStore = store; if (store && store.subscribe) settingsOff = store.subscribe(check); check(); } function firstSection() { try { const rows = slots.entries('settings.section') .map((e) => ({ id: e.options.id, order: e.options.order ?? 0 })) .sort((a, b) => a.order - b.order); return rows[0] ? rows[0].id : ''; } catch { return ''; } } // ---- read current state const read = () => { let sel = {}; if (ws.selection && ws.selection.getSnapshot) { sel = ws.selection.getSnapshot() || {}; } else if (sessions) { const list = sessions.list.getSnapshot(); const id = list.ids.find((i) => list.byId[i] && list.byId[i].retainedBy && list.byId[i].retainedBy.mainView > 0); if (id) sel = { sessionId: id }; } let session = null; if (sel.subagentAddress) { const a = sel.subagentAddress; session = { id: a.childSessionId, parent: a.parentSessionId, mode: a.mode }; } else if (sel.sessionId) { session = { id: sel.sessionId }; } const panel = layout.panelInfo.getSnapshot(); let settings = null; const st = settingsStore && settingsStore.getSnapshot(); if (st && st.open) settings = st.activeId ?? firstSection(); return { session, panel: (panel && panel.activePanelId) ?? null, settings }; }; const url = (nav) => location.pathname + location.search + format(nav); const entry = (nav, idx) => { const base = history.state && typeof history.state === 'object' ? history.state : {}; return { ...base, [KEY]: { nav, idx } }; }; // ---- apply a target state function applyNav(target) { const cur = read(); let sessionChanged = false; try { if (target.session && keyOf(target.session) !== keyOf(cur.session)) { const s = target.session; ws.openSession(s.parent ? { parentSessionId: s.parent, childSessionId: s.id, mode: s.mode || 'unknown' } : s.id); // also reveals the conversation (panel -> null) sessionChanged = true; } } catch (error) { console.warn('[dsh-history-nav] cannot restore session', error); } const panelNow = sessionChanged ? null : cur.panel; if (keyOf(panelNow) !== keyOf(target.panel)) { try { layout.selectPanel(target.panel); } catch (error) { console.warn('[dsh-history-nav] cannot restore panel', error); } } const st = settingsStore; if (st && st.actions) { try { if (target.settings == null) { if (cur.settings != null) st.actions.close(); } else if (cur.settings == null || cur.settings !== target.settings) { if (target.settings) st.actions.openSection(target.settings); else st.actions.open(); } } catch (error) { console.warn('[dsh-history-nav] cannot restore settings', error); } } } // ---- boot: restore from the URL hash when present let bootTarget = parse(location.hash); let booting = !isEmpty(bootTarget); // true while waiting to apply the URL let bootTimer = 0; const prior = history.state && history.state[KEY]; let idx = prior && Number.isInteger(prior.idx) ? prior.idx : 0; let committed = booting ? bootTarget : read(); let restoring = false; let settleTimer = 0; function finishBoot() { clearInterval(bootTimer); booting = false; committed = read(); history.replaceState(entry(committed, idx), '', url(committed)); } if (booting) { let stable = 0; bootTimer = setInterval(() => { const age = Date.now() - startedAt; const t = { ...bootTarget }; if (t.session && sessions) { const list = sessions.list.getSnapshot(); const exists = (id) => list.byId[id] !== undefined; if (list.phase === 'ready' && !exists(t.session.parent || t.session.id)) t.session = null; else if (list.phase !== 'ready' || !exists(t.session.parent || t.session.id)) t.session = undefined; // not yet } if (t.session === undefined) { // session list not ready yet if (age > BOOT_RESTORE_MS) finishBoot(); return; } if (t.settings != null && !settingsStore) { attachSettings(); if (!settingsStore && age < BOOT_RESTORE_MS) return; if (!settingsStore) t.settings = null; } // Keep the session the user's URL asked for; unresolved parts fall back to current. if (!t.session) t.session = read().session; applyNav(t); const now = read(); const ok = keyOf(now.session) === keyOf(t.session) && now.panel === t.panel && (t.settings == null ? now.settings == null : now.settings === t.settings); stable = ok ? stable + 1 : 0; if (stable >= 2 || age > BOOT_RESTORE_MS) finishBoot(); }, POLL_MS); } else { history.replaceState(entry(committed, idx), '', url(committed)); } // ---- record changes function check() { if (restoring || booting) return; const now = read(); if (keyOf(now) === keyOf(committed)) return; const age = Date.now() - startedAt; const startup = age < BOOT_GRACE_MS || (idx === 0 && committed.session == null && now.session != null && age < BOOT_RESTORE_MS); if (startup) { history.replaceState(entry(now, idx), '', url(now)); } else { idx += 1; history.pushState(entry(now, idx), '', url(now)); } committed = now; } function restoreFrom(nav) { restoring = true; committed = nav; applyNav(nav); clearTimeout(settleTimer); settleTimer = setTimeout(() => { restoring = false; const now = read(); if (keyOf(now) !== keyOf(committed)) { // Target only partly restorable (e.g. archived session): record reality. committed = now; history.replaceState(entry(now, idx), '', url(now)); } }, SETTLE_MS); } function onPopState(event) { if (booting) return; const data = event.state && event.state[KEY]; if (data) { idx = Number.isInteger(data.idx) ? data.idx : 0; restoreFrom(data.nav); } else { // Entry created by hand-editing the URL / following a link with a hash. const nav = parse(location.hash); idx += 1; history.replaceState(entry(nav, idx), '', location.href); restoreFrom(nav); } } function onMouse(event) { if (event.button !== 3 && event.button !== 4) return; event.preventDefault(); event.stopPropagation(); if (event.type !== 'mouseup') return; if (event.button === 3) { if (idx > 0) history.back(); // never leave the app through Back } else { history.forward(); } } const unsubs = []; if (ws.selection && ws.selection.subscribe) unsubs.push(ws.selection.subscribe(check)); else if (sessions) unsubs.push(sessions.list.subscribe(check)); unsubs.push(layout.panelInfo.subscribe(check)); if (slots && slots.subscribe) unsubs.push(slots.subscribe('sidebar.settings', attachSettings)); attachSettings(); window.addEventListener('popstate', onPopState); const mouseEvents = ['mousedown', 'mouseup', 'auxclick', 'pointerdown', 'pointerup']; for (const type of mouseEvents) window.addEventListener(type, onMouse, true); return () => { clearTimeout(settleTimer); clearInterval(bootTimer); for (const u of unsubs) u(); if (settingsOff) settingsOff(); window.removeEventListener('popstate', onPopState); for (const type of mouseEvents) window.removeEventListener(type, onMouse, true); }; } return { inject: ['layout', 'uiWorkspace', 'slots'], apply(ctx) { ctx.effect(() => start(ctx), 'dsh-history-nav: browser history'); }, }; }, });