dsh-think-ux
Smooth "thinking" experience for the DeepSeek Harness (dsh) Web UI: while a
model reasons, its think row expands as a capped 24-line preview that glides
to the bottom as text streams in; when reasoning settles, the preview
collapses with a short height animation instead of a one-frame ~490 px
jump. The main conversation view follows the same way — streamed output
glides up (long-session opens swoosh), and a scrollTop write trap makes
reader-vs-bundle scroll intent unambiguous, so the view glides instead of
snapping.
Pure DOM client plugin: no bundle changes, no services, no network calls,
no timers beyond one settle animation. Verified against DSH 0.1.5-rc.2.
Install
dsh plugin --profile web add dsh-think-ux
Then refresh the Web session (the profile layer picks it up on reload).
For another profile: dsh plugin --profile <name> add dsh-think-ux.
Uninstall
dsh plugin --profile web remove dsh-think-ux
Refresh; the Web UI returns to stock behavior. Nothing to clean up.
Tunable constants (top of lib/client.js)
| Constant | Default | Effect |
|---|
CAP_LINES | 24 | think-preview line cap |
CHASE_TAU_MS | 70 | glide exponential time constant (both chasers) |
CHASE_MAX_PX | 16 | constant glide speed (px per 60 fps frame, ~960 px/s) |
GAP_FAST_MIN | 800 | episode split: starting gap ≥ this = pure exponential swoosh, below = constant glide |
COLLAPSE_MS | 180 | settle-collapse animation duration; 0 = instant unmount |
MAIN_SMOOTH_FOLLOW | true | kill switch for the main-view glide + anchor override + method shadows |
READER_INTENT_TTL_MS | 700 | reader-intent window (covers the bundle's 500 ms sample) |
Any change is a one-line edit + reinstall of the local copy (see Local
development).
It is NOT upgrade-proof. It is verified against DSH 0.1.5-rc.2 and depends
on that version's DOM attributes and client load protocol. Behavioral
assumptions (the selectors, the click-to-toggle row, the bundle's plain
scrollTop write path) degrade quietly when broken — the plugin simply
stops doing its thing (see Residual risks). Registration/manifest
mismatches do NOT degrade quietly: the 0.1.0 release shipped a client
registration name that did not match the package name, and that made the
whole Web UI fail to load ("Failed to load plugins", not stock behavior).
If you see that page naming dsh-think-ux, you are on 0.1.0 — run
dsh plugin --profile web update dsh-think-ux (0.1.0 is deprecated on npm;
0.1.1 fixed it).
Behavior
-
Think rows expand while reasoning streams. Any [data-variant="think"]
row with data-state="running" is auto-expanded via a synthetic click on
its [data-disclosure-row] element (React keeps owning the state). When the
row settles (data-state="ok") the plugin auto-collapses it — unless the
reader toggled that row themselves; a trusted click on the row hands it over
permanently (plugin never touches it again for the row's lifetime). The
collapse plays a short height animation on the capped body
(COLLAPSE_MS, 180 ms) BEFORE the unmounting click: an instant body
removal drops ~490 px of content in one frame and clamps a
bottom-pinned reader down by the whole box in one visible jump, while the
animation lets the browser clamp frame by frame (a smooth slide). The
animation ENDS via the body's own transitionend (guarded by target +
property); a COLLAPSE_MS + 300 ms safety timer — started at the
animation's real start, not the settle — ends it early if the transition
never completes (hidden tab, cancelled mid-flight, long main-thread
stall; the early end just skips the tail of the slide). The finish
clears the transition but KEEPS the inline height: 0 so the body
unmounts with zero residue (clearing it first would flash the natural
full height for a frame under load). A reader toggle during the
animation cancels it (the body hands back to its natural height); a row
REMOVED mid-animation (settle + unmount in the same frame) is cleaned up
by the removal path (the record and listeners do not linger on the
detached node); COLLAPSE_MS = 0 restores the instant unmount.
History rows and rows under [data-turn-process-inline][hidden] are left
alone.
While auto-expanded, the body is a capped preview: at most 24 lines
(line height taken from the bundle's own secondary-content token,
calc(20px + var(--dsh-content-font-delta-secondary,0px))), with 24 px
top/bottom fades. It is a hidden-scrollbar scroll box that a single rAF
ticker chases to the bottom with the main view's smooth-episode
step (70 ms time constant CHASE_TAU_MS, constant CHASE_MAX_PX
speed ~960 px/s — the preview body is at most ~500 px, below the
GAP_FAST_MIN threshold, so it always glides), so appended streaming
text glides up smoothly instead of jumping in token chunks. A reader scroll up inside the preview
pauses that row's follow (terminal style); returning within 25 px of
the bottom resumes it. The cap applies only to plugin-managed rows: a
reader toggle lifts it permanently for that row (their expansion is
full-height and unscrollable), and settle auto-collapse removes it
anyway.
-
Reader scroll intent via a write trap. Any reader-initiated
upward movement (wheel up, touch finger-down drag, PageUp/Home/ArrowUp, or
any upward drift) arms a 700 ms intent window. A passive clamp
is NOT reader intent: when content above the reader shrinks (a settled
think row collapsing), the browser clamps down — it reads as
upward drift but ends at the floor, so it does not arm (otherwise every
turn boundary would freeze the smooth follow for 700 ms and fast-catch-up).
A real upward move leaves the at-bottom band within a few frames and arms
there. The plugin then installs a trap on the scroller's
.
Rollback
The git repo IS the rollback mechanism: every deployed state is a commit.
- Revert to a previous state:
git checkout <sha> then pwsh -File deploy.ps1 (e.g. git checkout 3e55717 restores the working
smooth-think / snap-main state; then refresh the GUI).
- In-place switch:
MAIN_SMOOTH_FOLLOW = false in lib/client.js +
redeploy turns off only the main-body glide (the chase, the method
shadows, the re-pin intent system and the overflow-anchor: none
override are all gated on it — with it off every scroll write passes
through natively and the scroller's pre-override anchor value is
restored on unbind).
- Settle-collapse animation:
COLLAPSE_MS = 0 in lib/client.js +
redeploy = instant unmount on settle (the pre-animation behavior,
whose one-frame ~490 px clamp jump at each turn boundary was the
visible stiff snap); any small value is the animation duration.
- Chase-speed states:
GAP_FAST_MIN = 0 in lib/client.js + redeploy =
pure exponential everywhere (fast swoosh for every gap, including
streaming inserts); GAP_FAST_MIN = 9999999 = one flat 960 px/s speed
for every gap (the all-capped state, whose slow tail on session open
motivated the episode rule); the constant tunes which gaps swoosh vs
glide.
- Diagnostics: the final build ships with
DIAGNOSTICS = false and
TRACE_SINK_URL = null — no prototype probe, no console traces, no
sink POSTs (the ~22k-line hunt log came from the on-state). To hunt a
jank report: DIAGNOSTICS = true +
TRACE_SINK_URL = "http://127.0.0.1:3999/" in lib/client.js +
redeploy, start trace-sink.cjs (workspace cleanup-review) to
collect the log as JSONL on disk, then reproduce. Traces cover intent
arming, every episode start episode sc#N fast|smooth gap=Npx,
chase fast frame step=Npx, fast upgrade gap=Npx, land ep=.. Nms,
uncaught motion > 16 px with the isTrusted flag, native
non-intercepted writes > 16 px with the caller stack; every line is
mirrored via fire-and-forget POSTs (a missing sink is a silent no-op)
and carries a 4-char per-instance id ([think-ux:XXXX]) so instances
from different surfaces are separable in the shared log; the lifecycle
lines instance up (doc title=...), takeover from instance XXXX and
instance down (XXXX) show the singleton hand-off.
- Last resort: uninstall (above) — the Web UI falls back to stock behavior.
Local development (file:// path)
For hacking on the plugin (or for installs that predate the npm package):
no build step (plain JS):
package.json dsh.bundle.patch + dsh.client.platform=web, inject: []
lib/index.js host half — marker only, apply() no-op
lib/client.js browser half — all behavior
deploy.ps1 parameterized file:// deployer (SHA-verified, prints profile snippet)
trace-sink.cjs local HTTP sink for the DIAGNOSTICS mirror (dev only)
One command, from the repo root:
pwsh -File deploy.ps1 # derives root+version from $env:DSH_HOME
pwsh -File deploy.ps1 -DshRoot 'C:\dsh' -Version '0.1.5-rc.2' # explicit
It copies the plugin byte-for-byte into <root>\plugins\dsh-think-ux\
(upgrade-surviving source of truth) and
<root>\versions\<ver>\plugins\dsh-think-ux\ (the copy the profile loads),
verifies SHA256 parity, and prints the profile state. If the web profile
<root>\home\<ver>\profiles\web\cordis.patch.yml lacks the insert row, add the
snippet it prints:
- insert:
- id: dsh-think-ux
name: file:///<root-as-file-uri>/versions/<ver>/plugins/dsh-think-ux/lib/index.js
Then refresh the GUI (the profile insert is picked up on reload; no other
restart needed). On a DSH version upgrade: rerun deploy.ps1 -Version <newver> and re-add the insert row for the new version dir (the top-level
plugins\ copy survives the upgrade untouched).
Constraints honored
- No
@deepseek-ai/* requires; inject: [] (pure DOM, no bundle services).
- No bare
fetch (the runner's closure trap shadows it with a throwing
redirect on per-agent (re)loads — the trace mirror goes through
window.fetch). Timer globals (setTimeout/clearTimeout) ARE
available in the 0.1.5-rc.2 runner: the settle collapse (COLLAPSE_MS)
uses them, verified at runtime; if a future runner withholds them the
collapse self-degrades to the instant unmount (try/catch guard).
Everything else runs on Date.now() + requestAnimationFrame +
MutationObserver.
ctx.effect(callback, label) is a context verb and needs no service
declaration; unload cascades the effect cleanup (observer, listeners, maps).
- Selectors are stable attributes only (
data-conversation-scroll,
data-variant, data-state, data-expanded, data-disclosure-row,
[hidden]) — never hashed CSS-module class names.
Residual risks (honest)
- Selector stability. The behavior depends on the chat package keeping
data-variant="think" / data-state / data-expanded /
data-disclosure-row. These are documented component-attribute names, not
build hashes, but a future version could rename them. Failure mode is
graceful: the plugin simply stops doing anything (no errors, no breakage of
the host UI).
- Click-to-toggle assumption. Expansion is driven by dispatching a click
because the collapsed body unmounts (no
keepContentWhenOpen); if a future
build keeps content mounted and exposes a different toggle primitive, the
synthetic click may double-toggle. Guard: the plugin re-reads data-expanded
before every click and expects exactly the recorded result; an unexpected
external toggle marks the row as user-controlled and stops touching it.
- Row identity loss on remount. Row bookkeeping is keyed by element
identity. If a row's element is re-created mid-run (parent swap), the new
element loses
userToggled history: a user-opened row that comes back
already open is taken over as managed (no click needed) and auto-collapsed
on settle — the plugin's default policy, not the user's choice. In
0.1.5-rc.2 this is not reachable in normal operation: the bundle shares one
keyed renderer instance across streaming/settled/interrupted, the
in-page "container" change is a hidden-attribute toggle on the same
wrapper, and any true remount starts collapsed (local useState(false)),
which the plugin re-manages (re-expand while running, collapse on settle).
The takeover branch makes the loss degrade to "default policy" instead of
"stuck expanded".
- Nested preview scroller. The capped body is a hidden-scrollbar scroller
nested inside the main conversation scroller. A reader wheel/touch up
inside the preview bubbles to the main scroller's listeners and can arm the
700 ms intent window — the desired semantics (reading up anywhere pauses the
main-view yank), but the two scroll layers share the intent system. The
re-pin trap only watches the MAIN scroller's
scrollTop; preview scrolling
never passes through it and is unaffected by intent state. If a future
bundle adds its own per-row scroll handling, the smooth follow degrades to
plain (janky) auto-scroll or none — the row features are unaffected.
- Capped preview line count. The 24-line cap is computed from the bundle's
secondary-content line-height token; if a future version changes that token
the cap drifts by a fraction of a line (cosmetic only — the box stays
bounded either way). The fade mask is clamped (
min/max stops) so short
bodies (fewer than ~2 lines) degrade to a symmetric fade instead of an
inverted gradient.
- The re-pin trap sees JS property assignments
(); the scroll (/ on the
bound scroller) are shadowed, so a bottom-targeted call with no armed
reader intent is also handed to the glide. Together they cover every
programmatic scroll in 0.1.5-rc.2 (toBottom, followRef, land-on-row,
saved-position restore, turn-rail ). If a future version
switches the follow to (or another API), re-pins bypass
both layers and the yank becomes visible again (the row features are
unaffected; with on, the uncaught motion is named in the
console). Native reader scrolling is never affected either way.