Initial commit: dsh-history-nav DSH plugin with docs

This commit is contained in:
Agent committed 2026-10-10 17:37:32 +00:00
commit 78ff986584
6 files changed
+384

No files matched your search

+291
View File
@@ -0,0 +1,291 @@
/**
* 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/<id>[/parent/<id>/mode/<m>][/panel/<id>][/settings/<section>]
*
* - 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');
},
};
},
});