dsh-vscode-scrollbar
A DSH (DeepSeek Harness) plugin that gives the conversation the Visual Studio Code editor chrome:
- the VS Code scrollbar — a 14px always-visible bar with a flat, translucent thumb that darkens on
hover and lightens while you drag it, and
- the VS Code minimap — a scaled-down preview column on the right that shows the whole
conversation, with a viewport slider you can drag or click to navigate.
The stock Harness scrollbar is a 5px, rounded, overlay-style bar and the conversation has no minimap
at all. This plugin changes only the conversation's scroll column; every other scrollbar in the app
(menus, code blocks, sidebars, the composer) keeps the stock look.
What changes
Scrollbar
| Property | Harness default | With this plugin |
|---|
| Track width | 5px | 14px |
| Track background | transparent | transparent |
| Thumb shape | pill (border-radius:999px, inset border) | flat rectangle, no radius |
| Thumb — light theme | theme scrollbar alias | rgba(100,100,100,.4) (#64646466) |
| Thumb — dark theme | theme scrollbar alias | rgba(121,121,121,.4) (#79797966) |
| Hover | theme scrollbar alias | rgba(100,100,100,.7) (#646464B3) |
| Dragging (active) | theme scrollbar alias | rgba(191,191,191,.4) (#BFBFBF66) |
| Scrollbar buttons | none | none |
These are VS Code's own defaults (scrollbarSlider.* in its light and dark themes;
editor.scrollbar.verticalScrollbarSize = 14). Dark mode is read from the
body[data-ds-dark-theme] attribute that the Harness theme service sets, so the colors follow the
active palette exactly like VS Code does — no plugin-wide color override, no second theme system.
Minimap
| Property | Harness default | With this plugin |
|---|
| Preview column | none | 80px column flush against the scrollbar, capped at half the window height |
| Content | none | snapshot of the whole conversation, scaled into the column; images are painted, heavyweight media (video/canvas/SVG/forms) is dropped |
| Viewport slider | none | VS Code minimapSlider: square translucent band, visible only while hovering the minimap or dragging |
| Checkpoint rail | right-aligned at the edge, hidden under the preview | parked 110px from the right edge, left of the column and fully visible |
| Click | n/a | jumps the conversation to that point and keeps dragging from there |
| Drag / wheel | n/a | drag scrubs the conversation, the wheel scrolls it |
| "加载更早" pages | collapsed behind a button | expanded automatically, so older turns are never folded away |
| Minimum pane width | n/a | 1180px — below that the column hides instead of covering text |
Behaviour
- The minimap only appears when there is spare room. The chat column is capped at 920px, so on wide
windows the preview sits in the empty right margin; on a narrow window it hides rather than covering
the messages. It also stays hidden while the conversation is shorter than the viewport.
- The column is flush against the scrollbar and capped at half the window height. Inside that column
the whole conversation is mapped 1:1 through the preview scale, so the slider marks the exact
preview slice the viewport is showing. (VS Code itself uses the full editor height; the cap is this
plugin's only deliberate deviation from it.)
- The slider is VS Code's
minimapSlider: a square translucent band in the scrollbar-slider colors
that only appears while the pointer is over the minimap or while dragging (showSlider: mouseover).
Its size and offset come from the conversation's own numbers (viewportHeight * scaleY,
scrollTop * scaleY), never from the native scrollbar thumb.
- Clicking anywhere in the minimap centres the viewport on that line and then keeps the same gesture
as a drag, so press-and-drag scrubs continuously. The wheel over the minimap scrolls the
conversation, as it does over VS Code's minimap.
- Harness's turn-navigation rail (the short checkpoint bars on the right edge) keeps its stock position
until the preview appears, then moves out of the column's way and stays clickable.
- Harness paginates old turns behind a "加载更早" / "Load earlier" button. The plugin clicks it as soon
as it renders (paced while the button is in its disabled "加载中" state, capped at 500 pages), so the
conversation is not folded into that bucket. This cannot be turned off without disabling the plugin.
- The preview refreshes after new content settles, debounced by 350 ms, so a streaming answer does not
rebuild the clone on every token. Scrolling is rAF-throttled.
- It is read-only: interactions pass through the preview, and the clone has its ids, SVG, canvas, video,
form controls and running animations removed, so it stays cheap and cannot interfere with the real
conversation.
Install / 安装
Install from GitHub into the profile you use:
dsh plugin --profile <profile> add github:azkbzxf/dsh-vscode-scrollbar
Or install a local checkout through the plugin manager tool:
{ "action": "install_bundle", "target": "D:\\path\\to\\dsh-vscode-scrollbar" }
A newly added bundle activates on the running instance: the Host row is live immediately and the
Client half hot-loads into the open page. Replacing an already-installed copy needs the page to be
reloaded (or DSH restarted) before the fresh client build is used.
Remove it again with:
dsh plugin --profile <profile> remove dsh-vscode-scrollbar
How it works
The conversation scroll container ships no stable class name — its classes are CSS-module hashes
(_727C8a_scrollBody) that change between builds. It does ship a stable DOM contract, though: every
conversation body renders as a [data-slot="conversation.session"] element, and the shared shell
itself styles that relationship (its composer-overlay rules already use
[data-slot="conversation.session"]). Both halves start from that anchor.
Scrollbar
- find each
[data-slot="conversation.session"] anchor,
- walk up to the nearest ancestor whose computed
overflow-y is auto/scroll — that is the chat
scroll container,
- tag it with
data-dsh-vscode-scrollbar, and
- let a scoped stylesheet own
::-webkit-scrollbar* for the tagged element only.
The stylesheet also carries a :has(> [data-slot="conversation.session"]) selector branch, so the
bar renders correctly even before the marker is applied.
Minimap
- A fixed-position layer is appended to
document.body and placed from the scroll container's
getBoundingClientRect(): flush against the 14px bar (right - scrollbarWidth - 80px), with the
container's top, the container's width, and a height capped at half the window (or the container
height when that is shorter). It is z-index:30, read-only and never takes layout space. While it
is shown, a stylesheet rule parks Harness's turn-navigation rail (nav[aria-label="轮次导航"]) at
right:110px, clear of the 80px column; with the column hidden the rail returns to its stock
right:12px.
- The chat column is measured on the live tree: every message row is a
[data-chat-flow-kind]
flex child of a column capped at max-width: var(--dsh-chat-content-width) and centred, so a
rendered row reports the column width exactly. Measuring the clone instead would be wrong — the
clone is detached from the ancestors that publish that CSS variable, so its rows reflow to the
full pane width.
- The anchor is cloned, sanitized in one tree walk, and given its own layout box: slot anchors render
with
display: contents (the outlet wrapper takes no box), so the clone is forced to
display: block, sized from the pane's content width, and handed the measured column width as
--dsh-chat-content-width. It is appended untransformed first, because the natural height is what
the preview is fitted to.
- The clone is transformed with
scale(sx, sy) translate(-columnLeft, 0): sx = 80px / columnWidth
and sy = columnHeight / naturalHeight, so the whole conversation fills the (capped) column the way
VS Code's minimap fits a file into its column. Images stay painted (a chat minimap with empty holes
where screenshots were reads as broken); stateful media (video, canvas, SVG, forms) is dropped from
the clone.
- The slider lives in the preview's own coordinate system, not the scrollbar's: its height is
viewportHeight * scaleY and its top is scrollTop * scaleY, clamped to the track, so it always
marks the viewport the preview is showing. The same scale inverts pointer positions: a click maps
y / scaleY - viewportHeight / 2 back to scrollTop, and a drag adds deltaY / scaleY. Its colour,
square shape and hover-only visibility copy VS Code's minimapSlider (#64646466 light /
#79797966 dark, hover #646464B3, active #BFBFBF33).
- A
MutationObserver on the anchor rebuilds the clone (trailing 350 ms debounce), a
ResizeObserver plus a window resize listener re-measure the column and pane, and scroll events
schedule a rAF-throttled slider update.
Configuration
No settings are required. The scrollbar values are exposed as CSS custom properties on the tagged
container, so a theme plugin or a user stylesheet can tune them without forking this package:
--dsh-vscode-scrollbar-size /* track width/height, default 14px */
--dsh-vscode-scrollbar-thumb /* idle thumb color */
--dsh-vscode-scrollbar-thumb-hover /* hovered thumb color */
--dsh-vscode-scrollbar-thumb-active /* dragged thumb color */
Minimap geometry is fixed (80px column flush against the scrollbar, half-window height cap, 1180px
minimum pane width); it has no settings of its own. Automatic expansion of "加载更早" pages is also
unconditional.
Compatibility
- Verified against DSH Desktop 2.0.18 (client
0.2.1-rc.2, Electron 44 / Chromium).
- Uses only
::-webkit-scrollbar, DOM cloning and the two observers — no imports of Harness Client
packages, no React tree edits, no DOM of other plugins.
- Chromium-based targets only. Browsers without
::-webkit-scrollbar (Firefox, Safari) keep the
platform scrollbar and get no minimap; the plugin does not crash there, it simply has no effect.
What's new
- 1.2.1 — the minimap paints images again. 1.2.0 hid them to mirror VS Code, but in a chat
that reads as empty holes where screenshots used to be; images are painted, stateful media
(video/canvas/SVG/forms) is still dropped, and the half-height cap and hover slider stay.
- 1.2.0 — the minimap now follows VS Code's own behaviour instead of only its geometry: the column
is flush against the scrollbar and capped at half the window height (VS Code uses the full height);
the slider is the real
minimapSlider — square, scrollbar-slider colours, visible only on hover or
while dragging; images are painted, stateful media is dropped; pressing anywhere in the minimap
jumps and starts a drag; the wheel over the minimap scrolls the conversation. DSH's "加载更早"
pagination is expanded automatically, so older turns are no longer folded behind it.
- 1.1.3 — the minimap no longer hides Harness's turn-navigation rail: while the column is shown the
rail is parked 110px from the right edge, clear of the preview and still clickable. The slider is now
a pure position overlay — height
viewportHeight * scaleY, offset scrollTop * scaleY — so it marks
the preview line being viewed instead of imitating the native scrollbar thumb's size.
- 1.1.2 — fixes the minimap against the real conversation: the chat column is measured from the
live
[data-chat-flow-kind] rows (a detached clone reflows to the full pane), images stay in the
preview so its height matches the conversation, the preview is fitted to the pane, and the slider
now uses the scroll container's own viewport/scroll ratio, so it tracks the 14px scrollbar exactly.
- 1.1.1 — fixes the minimap layout: slot anchors are
display: contents, so the preview clone now
gets its own box instead of reflowing inside the 80px column.
- 1.1.0 — adds the VS Code minimap preview column next to the scrollbar.
- 1.0.0 — VS Code-style conversation scrollbar.
Development
Files:
client.js Client half: tagging + stylesheet + minimap
index.js Host half (empty apply — the plugin is pure Client UI)
cordis.patch.yml composition row
locale/en.json plugin title/description shown by the plugin manager
locale/zh.json
icon.svg plugin icon
test/harness.mjs offline harness for both features
Run the harness (needs only Node — no browser, no install):
node test/harness.mjs client.js
It stubs the DOM, layout rectangles and the observers, and asserts: the stylesheet is attached once;
the nearest scrollable ancestor of a conversation anchor is tagged while a non-scrollable wrapper is
left alone; the minimap measures the chat column from the live message rows, reproduces it on the
clone, fills the pane, and maps the slider through the preview scale; scrolling moves only
the slider; a click maps back to scrollTop; dragging the thumb scrolls proportionally; the preview
hides on a narrow pane and returns when widened; conversation mutations rebuild the clone; and
disposal removes the sheet, markers, layer, observers and listeners.
Package for a release tarball:
npm pack
Known limitations
- Only the conversation scroll column is restyled and previewed; code blocks, menus and sidebars keep
the stock bar and no minimap. A 14px bar inside a code block would be noise.
- The preview is a snapshot refreshed after 350 ms of quiet, not a live mirror: fast streaming shows up
in steps rather than continuously.
- The clone keeps images and message structure but drops stateful media — SVG, canvas, video, audio,
forms and editors stay out — and events are not cloned, so the preview is inert and cheap.
- Below 1180px of pane width the minimap hides instead of overlapping messages.
- If a future Harness build drops the
data-slot="conversation.session" contract and renames the
scroll container, the plugin stops applying (it does not break anything); the fix is a selector
update.
License
MIT