Initial commit: dsh-history-nav DSH plugin with docs
This commit is contained in:
commit
78ff986584
6 files changed
+384
No files matched your search
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
*.bak
|
||||
*.orig
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
- insert:
|
||||
- id: dsh-history-nav
|
||||
name: '@local/dsh-history-nav'
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Host half; the Client module owns all behaviour. */
|
||||
export function apply() {}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user