From 78ff986584af467b166c3f5f4fa064c65af8f9d7 Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 10 Oct 2026 17:37:32 +0000 Subject: [PATCH] Initial commit: dsh-history-nav DSH plugin with docs --- .gitignore | 4 + README.md | 66 +++++++++++ client.js | 291 +++++++++++++++++++++++++++++++++++++++++++++++ cordis.patch.yml | 3 + index.js | 2 + package.json | 18 +++ 6 files changed, 384 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 client.js create mode 100644 cordis.patch.yml create mode 100644 index.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb38758 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.bak +*.orig +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..9655282 --- /dev/null +++ b/README.md @@ -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/[/parent//mode/][/panel/][/settings/
] + ``` +- 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. diff --git a/client.js b/client.js new file mode 100644 index 0000000..4f9c701 --- /dev/null +++ b/client.js @@ -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/[/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'); + }, + }; + }, +}); diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..28d1e1c --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: dsh-history-nav + name: '@local/dsh-history-nav' diff --git a/index.js b/index.js new file mode 100644 index 0000000..54fae66 --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ +/** Host half; the Client module owns all behaviour. */ +export function apply() {} diff --git a/package.json b/package.json new file mode 100644 index 0000000..73d06d9 --- /dev/null +++ b/package.json @@ -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" + ] + } + } +}