el16z3c/dsh-think-ux ↗★ 0

dsh-think-ux

Smooth think-row preview + bottom-follow glide for the DeepSeek Harness (dsh) Web UI: a pure DOM client plugin, no bundle changes 适合追求极致视觉体验、希望平滑展示模型思考过程与流式输出的用户。

Package
dsh-think-ux
Compatibility
Unverified
Version
0.1.2
License
MIT
Last updated
Sep 15, 2026

Install

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:el16z3c/dsh-think-ux

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.

awesome-dsh

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 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)

ConstantDefaultEffect
CAP_LINES24think-preview line cap
CHASE_TAU_MS70glide exponential time constant (both chasers)
CHASE_MAX_PX16constant glide speed (px per 60 fps frame, ~960 px/s)
GAP_FAST_MIN800episode split: starting gap ≥ this = pure exponential swoosh, below = constant glide
COLLAPSE_MS180settle-collapse animation duration; 0 = instant unmount
MAIN_SMOOTH_FOLLOWtruekill switch for the main-view glide + anchor override + method shadows
READER_INTENT_TTL_MS700reader-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

  1. 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.

  2. Reader scroll intent via a scrollTop write trap. Any reader-initiated upward movement (wheel up, touch finger-down drag, PageUp/Home/ArrowUp, or any upward scrollTop 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 scrollTop 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 defineProperty trap on the scroller's scrollTop.

    The discriminator is structural, not heuristic: the bundle's follow re-pin is a plain JS assignment (el.scrollTop = el.scrollHeight in toBottom/followRef), while every reader input method — wheel, touch, scrollbar thumb, track click — scrolls natively inside the browser and never passes through the JS property setter. So the reader's return can never be misclassified (no event-shape heuristics, no thresholds to tune):

    • a write whose target is at or beyond floor-minus-25 while the reader sits more than 25 px above the floor (inside the window) is a re-pin (the bundle writes el.scrollHeight, which overshoots and is clamped to the floor): it is let land (the bundle's own bookkeeping stays consistent) and the reader's position is restored in the same tick — the yank never paints, and the resulting scroll event makes the bundle's 500 ms sample heal atBottomRef=false, so it stops re-pinning on its own;
    • a DOWNWARD arrival within 45 px of the floor (the re-follow zone) ends the intent and actively bottoms out: the pristine bundle only re-engages its follow at its own 25 px threshold, so a reader stopping in the 25–45 px band would otherwise strand (intent off, follow dead). The ≤45 px bottom-out nudge lands the bundle at 0 px, where its debounced sample flips atBottomRef back to true and native follow resumes (typically within the 500 ms sample debounce plus the next content chunk). A reader who PASSES through the band on the way up is armed, not nudged; a reader who stops there is left there.
    • exceptions (re-pin-shaped writes LET THROUGH, intent ends): a NEW reader-initiated element appeared since arming — a durable user flow row (data-chat-flow-kind="user"), a pending-steering bubble (data-pending-steering), or a submission echo (data-submission-echo) — i.e. the bundle's "show me my message" jump; or a trusted click on the scroller's back-to-bottom button (the only `` inside the conversation scroller and outside the [data-chat-flow] column), queued by a capture-phase click listener so the button's own toBottom write is never reverted even inside the intent window.

    The 700 ms window covers the bundle's 500 ms scroll-sample debounce (SCROLL_SAMPLE_INTERVAL_MS = 500), the only window in which the bundle can yank.

  3. The main conversation view glides too. While the reader is at the bottom (follow mode), streamed agent output glides up instead of jumping in token chunks: the bundle's follow re-pin is intercepted and handed to an exponential chaser (same 70 ms CHASE_TAU_MS constant), and both the reader's return within the 45 px re-follow zone and the back-to-bottom button land with a glide rather than a snap. Any upward reader input stops follow mode immediately; a content-growth observer on the scroller re-arms a stopped chase while the reader is at the bottom, so a stale bundle atBottom sample cannot strand the view. Chase writes go through the original prototype setter (bypassing the re-pin trap) and land exactly at the floor, so the bundle's 500 ms at-bottom bookkeeping stays coherent. The chase speed is set per episode (chaseStep + st.episode, shared by both chasers): each chase episode — a gap created by one content event, closed to the tail — runs at ONE speed, chosen from the gap the episode STARTS with (a speed that tracks the shrinking gap decays a big swoosh into the slow flat speed in the last ~800 px and visibly crawls home — "fast to near the bottom, then slow"):

    • starting gap >= GAP_FAST_MIN (800 px): pure exponential (21 % of the gap per frame at 60 fps) for the WHOLE episode — opening a long session swooshes all the way to the bottom (~0.7 s for 30 000 px), no slow tail;
    • starting gap fast when a big insertion grows the gap past the threshold mid-episode (a large content block is a swoosh, not a crawl; no downgrade, so no oscillation). Browser scroll anchoring (overflow-anchor, default auto) is disabled on each bound scroller while MAIN_SMOOTH_FOLLOW is on: with the reader pinned at the bottom, an insertion of a large chunk (a tool row, a code block) makes the anchoring adjustment shift scrollTop by the WHOLE chunk in one frame — a native snap, visible as a stiff jump, and it fights the 16 px/frame chase. With anchoring off, every bottom tracking goes through the episode-speed chase (glide for small gaps, swoosh for large ones). The pre-override inline value is restored on unbind. Scroll methods are shadowed too: a scrollTo/scrollBy call on the scroller bypasses the property trap, so a bottom-targeted call made with no armed reader intent (e.g. the turn rail's follow) is swallowed and handed to the chaser (glide); every other call passes through untouched (rail centering, saved-position restore). The scrollTo(x, y) form reads the SECOND argument as the vertical coordinate (the first is horizontal; a single-number call is x-only), matching the DOM spec. Kill switch / rollback: the MAIN_SMOOTH_FOLLOW constant at the top of lib/client.js — false restores the bundle's current snap behavior for the main view (the think-row follower keeps working); see Rollback.
  4. One active instance per document (singleton takeover). The cordis runner registers this plugin per conversation surface: every in-page session switch invalidates + re-loads the module, creating a new plugin instance in the SAME document. Left alone, N live instances each run a document-wide MutationObserver and bind EVERY conversation scroller, so N chaser loops step on the same scroller — the source of the intermittent stiff follow (a fresh page has few live instances; after session switches it has many). The newest instance takes over the document: it publishes itself on globalThis.__DSH_THINK_UX_LIVE__ and releases its predecessor (observer, traps, rAF chasers, write probe, style tag). Teardown is idempotent, so the runner's effect cleanup of a superseded instance is a no-op.

Rollback

The git repo IS the rollback mechanism: every deployed state is a commit.

  • Revert to a previous state: git checkout 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 \plugins\dsh-think-ux\ (upgrade-surviving source of truth) and \versions\\plugins\dsh-think-ux\ (the copy the profile loads), verifies SHA256 parity, and prints the profile state. If the web