dsh-turn-rail-persistent
A DeepSeek Harness (dsh) plugin that keeps the built-in conversation turn rail — the Codex-style ladder of horizontal marks down the edge of the transcript — visible at every transcript width, instead of letting the host hide it on narrow transcripts.
On its own this plugin adds no UI: it un-hides a rail the host already ships, and leaves its position, geometry, and behavior untouched.
The problem it solves
@deepseek-ai/dsh-client-ui-chat already renders a TurnNavigator: one 12px mark per turn (fixed 10px pitch) down the right edge of the transcript, a hover/focus preview of that turn's prompt and settled response, and a click that jumps to — or pages history in for — the corresponding user input.
The host hides that whole rail on narrow transcripts:
@container (width <= 900px) { .<hash>_slot { display: none } }
So as soon as sidebars, a details panel, or a small window squeeze the transcript below 900px, the rail disappears — and it looks like dsh never had the feature. There is no setting for this, and no other plugin addresses it.
Install
# from this repository
dsh plugin --profile <your-profile> add github:KKKKeybird/dsh-turn-rail-persistent
# from npm (once published)
dsh plugin --profile <your-profile> add dsh-turn-rail-persistent
Or one click in Settings → Plugin Market once the plugin is listed in the curated awesome-dsh-plugin registry.
Restart dsh (for the desktop app: restart the app) so the plugin is mounted. No configuration is required.
How it works
Two mechanisms, no host API:
- Tag by structure.
railNavs() finds the rendered rail from its mark wrappers — an element whose class list carries a *_markPosition CSS-module local, walking up to its <nav> — and tags the nav (data-dsh-turn-rail) and its slot (data-dsh-turn-rail-slot). Matching by structure rather than by hashed local names (eGxaPq_* today, something else after the next build) keeps it working across host rebuilds.
- Restore one declaration. The whole stylesheet is
[data-dsh-turn-rail-slot]{ display: block !important; }. An !important declaration outranks the normal one inside the host's @container block, so the rail stays mounted at any width. No geometry is touched: the rail keeps the host's edge, height band, ladder pitch, and preview clamp.
A mutation watcher re-tags after a session switch rebuilds the view, and skips its DOM scan while the tagged rail is still mounted (the transcript mutates on every streaming delta).
What is not changed
Mark pitch, active/busy/unloaded states, hover previews, turn paging, scroll follow, and the rail's own "fewer than two turns renders nothing" early return (if (items.length < 2) return null) all stay the host's implementation — so host upgrades bring their improvements along automatically.
The only trade-off: at a very narrow transcript the rail now overlaps the transcript's edge (it is a 28px-wide frame) rather than vanishing.
Tests
node --test test/
test/client-load.test.mjs runs the real lib/client.js inside a node:vm context against a minimal DOM stub shaped like the host's rail, and asserts registration, tagging, the always-on rule (no @container, no min-width, no geometry rules), re-tagging after a rebuilt view, no false positives on unrelated <nav> elements, and clean disposal.
test/host-contract.test.mjs asserts the plugin's assumptions against the host bundle installed on the running machine: the rail is still hidden by @container (width<=900px) on _slot{display:none}, and mark wrappers still carry a *_markPosition local. It skips when no host bundle is present; a failure there means the plugin needs updating for a newer host.
Compatibility
dsh >=0.1.5-rc.2 <0.2.0 (engines.dsh); Node ^22.19.0 || >=24.0.0.
- Host-only UI change: no model-facing behavior, no tools registered, no network access, no files read or written.
License
MIT