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

+4
View File
@@ -0,0 +1,4 @@
node_modules/
*.bak
*.orig
.DS_Store
+66
View File
@@ -0,0 +1,66 @@
# dsh-history-nav
Browser history and deep-link URLs for the DeepSeek Harness (DSH) Web UI.
The DSH shell keeps its navigation (selected session, central panel, settings dialog/section) in memory only, so the browser **Back/Forward** buttons and page reloads lose your place. This client plugin mirrors that state into the URL hash and the browser history.
## Features
- URL hash reflects where you are:
```
#/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` / loading a page with a hash restores the state (shareable, bookmarkable links to sessions, panels and settings sections).
- Mouse buttons 4/5 (Back/Forward) map to `history.back()` / `history.forward()`.
## How it works
| File | Role |
| --- | --- |
| `client.js` | Web client module (`@local/dsh-history-nav`), injects `@deepseek-ai/dsh-client-ui-layout` and `@deepseek-ai/dsh-client-ui-workspace`; owns all behaviour. |
| `index.js` | Empty Host half (`apply() {}`). |
| `cordis.patch.yml` | Bundle patch inserting the `dsh-history-nav` row. |
## Installation
Requirements: a working DeepSeek Harness (`dsh`) install with the **web** profile (or any profile that includes the Web UI).
### Option A — install from git (recommended)
```bash
dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/dsh-history-nav.git
```
You can also paste `git+https://git.rizzy.dev/DSH-Plugins/dsh-history-nav.git` into **Settings → Plugins → Install** in the Web UI, or ask the agent to install it with its `plugin_manager` tool (`install_bundle`).
The plugin manager adds `@local/dsh-history-nav` to `dependencies` and to `dsh.profile.bundles` in `~/.dsh/profiles/web/package.json`.
### Option B — local checkout (for development)
```bash
git clone https://git.rizzy.dev/DSH-Plugins/dsh-history-nav.git ~/dsh-plugins/dsh-history-nav
dsh plugin --profile web add link:$HOME/dsh-plugins/dsh-history-nav
```
With a `link:` install, edits in the checkout are picked up directly (client changes reload on page refresh, Host changes after a DSH restart or HMR reload).
### Enable / verify
1. Make sure `@local/dsh-history-nav` is listed in `dsh.profile.bundles` of `~/.dsh/profiles/web/package.json` (the installer does this; add it by hand if you edited `package.json` manually).
2. Restart DSH (`dsh web`) or reload the page.
3. Check **Settings → Plugins**: the `@local/dsh-history-nav` bundle should be enabled.
### Uninstall
```bash
dsh plugin --profile web remove @local/dsh-history-nav
```
### Updating
Re-run the `add` command from Option A (or `git pull` in your Option B checkout).
## Configuration
None. Once enabled, open a session and watch the address bar update; use Back/Forward or reload the page to verify restoration.
+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');
},
};
},
});
+3
View File
@@ -0,0 +1,3 @@
- insert:
- id: dsh-history-nav
name: '@local/dsh-history-nav'
+2
View File
@@ -0,0 +1,2 @@
/** Host half; the Client module owns all behaviour. */
export function apply() {}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@local/dsh-history-nav",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.js", "./client": "./client.js" },
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": {
"platform": "web",
"immediately": true,
"inject": [
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-ui-workspace"
]
}
}
}