Drop the restated build narration in tsdown.config.ts and vitest.config.ts and
keep only the decisions a reader cannot recover from the code: the platform
module subset, the pinned native toolchain, the virtual-id suffix rule, the
unminified/typed host half, and the compat lane's preconditions. Stop claiming
to "mirror" the harness lists, which these are subsets of.
Apply the repo's comments guideline to src/client/**: delete comments that
restate JSX, Tailwind utilities or standard React behaviour, narrate history,
duplicate docs/, or describe what the next line does; keep only container-query
and containment traps, portal requirements, reference-stability rules with a
real re-render consequence, CSS<->TS constant parities, harness seat and payload
contracts. Align the page's name with the shipped UI label ("Context Insights",
not "Context Dashboard"), unify drifted phrasings, drop the last non-English
fragments, and rewrap survivors onto fewer, fuller lines within the 140-col limit.
Comment lines 5,183 -> 1,822. No code change: comment-stripped content is
identical per file; the client bundle differs only by dead Tailwind utilities
that existed because deleted comments contained those words.
Apply the repo's comments guideline to src/host/** and src/shared/**: delete
comments that restate the adjacent declaration, narrate development history,
duplicate docs/compatibility.md, or walk through a function body; keep only
critical decisions and facts the code cannot self-explain (harness and log-shape
contracts, retention invariants, untrusted-input traps, plain-JSON cache
preconditions). Rewrap the survivors onto fewer, fuller lines within the 140-col
lint limit, and drop the last non-English comment fragments.
Comment lines 4,020 -> 1,026. No code change: every file is proven identical
after comment stripping, and the built lib/index.js is byte-identical to the
previous build (modulo the build-time banner).
Every retained bar carried `animate-lc-bar-in`, so the chart's mount animated
each one — the host retains up to maxRequestSteps (1500) requests — and every
animating bar layerizes on its own. Only the columns a pane can reach are ever
seen: the chart mounts anchored to the newest bars, so the rise is now drawn by
the newest RISE_CAP (200) columns, the stagger counted from the window's edge
so those columns still cascade left to right. Bars appended to a live session
keep rising, and a log shorter than the window is unchanged.
Measured on the 218-step session, the per-bar animations cost 17ms of the worst
frame gap and 16ms of blocking on top of the strip fix. With both changes the
open measures 243 animations, a 133ms worst frame gap (was 250ms) and 98ms of
blocking (was 173ms); with every CSS animation disabled it measures the same
133ms, so what remains is plain mount cost. Three tests pin the window in
total, DNA and DNA+delta modes.
Opening the Context tab on the 218-step session started 1584 CSS animations at
once, 1324 of them the timing card's strip — one `animate-lc-stacked-in` per
band. Tracing the open showed 1582 animationstart / 1578 animationend
dispatches, 343 Layerize events (392ms of layer churn) and a 250ms worst frame
gap where the shell's own tab switch measures 67-83ms. Disabling just the
strip's per-band animations on the same build already cut that open to a 150ms
gap and 109ms of blocking (from 173ms).
The bands now ride one `.lc-tstrip-fill` wrapper carrying the single sweep, so
the strip wipes open on one composited transform over the same track. Packing,
colours, the kind hover link and the tip are untouched; the per-band ripple it
replaces was capped at eight stagger slots, so a packed strip already settled
as a sweep. A regression test pins the single wrapper and the absence of any
per-band animation class.
The backoff timer re-armed unconditionally after every failed read, so an
unreachable models.dev registry kept being polled for the rest of the page's
life even after the Insights page that subscribed had gone. Measured with
fake timers: 7 polls in the 20 minutes after the last unsubscribe, at the
capped 240s cadence, forever.
The timer callback now ends the cycle when no subscriber remains; the next
subscribe already kicks a fresh read. Measured after: 0 polls in the same
window, and the first subscribe still fills the book as before.
The per-session DetailStore map is page-lifetime and every store kept its
fetched collections forever, so the page's heap grew with each session ever
opened. Measured over 24 distinct visits with planted weak references: 9, 17
and then 25 live payloads (one per visit), ~194KB of JSON (~300KB of live
heap) each — nothing released them but a reload.
The newest DETAIL_STORES_MAX sessions now keep their payload for an instant
re-open; older idle ones are demoted (payload and ledger dropped) and read
again when opened. Measured after: a flat 8 live payloads at 8, 16 and 24
visits, the two oldest re-opens read once more, the newest re-opens read
nothing. An active store is never demoted, so the view on screen keeps its
data; rendering was byte-identical across the change.
The per-session DetailStore map is page-lifetime, so a store whose Context
tab, sidebar panel, or /context modal had gone kept trailing its own read
schedule: a broken detail route was re-POSTed every 2.4s for the rest of the
page's life with nobody reading the answer (measured on a real session: 2
extra POSTs in the 3s after unmount, unbounded).
The last unsubscribe now parks the store: the armed window is cleared and no
read is armed while nobody subscribes. The cached payload is kept, so a
re-open still renders instantly (measured: identical rendering digest and no
refetch on remount); a resume with nothing cached clears the ledger and the
old backoff, so the mount's own request(rev) reads again instead of sticking
on the retry note.
The Less→More key reused the grid's own 20px cells, so the key read as a
second row of cells competing with the grid. Its swatches now render at half
size and keep the grid's depth classes, so the key stays a caption while
never drifting from the colors it explains.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the timing-stats text row from 模型输出/Answer to 模型回复/Answers to match the Answer wording already used by the stats card, and update the affected assertions.
The notice trailed the version inside the value box, whose ellipsis cuts
the line last-in-first-out, so at the card's 240px floor it was always the
part lost. Measured in the live harness with English labels, the box
overflowed by 29-44px and ~15px of a 52px chip survived, at every version
string tried. A block-level chip drops below instead, leaving the version
its own ellipsis on the first line.
The Insights page's range group gains a calendar picker: one panel that
takes BOTH ends, in either order. A backwards pick — end before start —
is sorted on the second click, so `orderedDayRange` hands every reader
downstream (the scope window, the ledger-day predicate, the chip that
names it) a pair with `from <= to`; `dayRangeWindow` orders it again at
the seam, so a hand-built pair can never filter the page to nothing.
The trigger IS the scope: one pill carrying the start and the end, fed
the LIVE window — a picked range when one owns the scope, the preset's
own window read as days otherwise, so "last 30 days" prints the thirty
days it covers and the two controls can never disagree. Only a picked
range takes the chip's brand voice and grows the way back, and the panel
keeps no second copy of the two dates: the chip above it is already
showing them, live, while the pick runs.
Underneath, the range model moves from a start-only floor to a two-sided
`RangeWindow`, which closes the top of a picked scope: the ledger-day
predicate gains the ceiling it never had, so a day beyond the range no
longer leaks into the skill card, and both picked days count whole
(midnight through the last millisecond). `days.ts` gains the calendar
arithmetic this needs — a day's epoch bounds, its weekday, month
stepping, and one month laid out as Sunday-first weeks — with the
round-trip key validation its siblings already used.
The panel portals to `<body>` and seats itself off its trigger's rect,
the containment escape the page's hover tips already take: the page is
the `lc-ov` query container, so an in-tree popover would be both
captured and clipped by the one scroller. It measures itself before
paint, clamps inside the viewport, flips above the trigger when it would
fall off the bottom, and retracts on any scroll, resize, outside press,
or Escape.
Closes#128
The Insights page's first range option read "Last 24 hours" while the
ledger it scopes is day-grained, so the rolling window and the day floor
underneath it disagreed: past midnight the 24h window still admitted
yesterday's ledger day whole, and before 1am it admitted only part of it.
"Today" is the local calendar day from midnight — the window the day-
granular heatmap beside it already draws.
`rangeStartOf` opens the window on the local midnight by date-field
arithmetic (the harness's own `setHours(0, 0, 0, 0)` idiom, as in
`startOfLocalDay`), which stays exact across DST-short and DST-long days
where an epoch-ms subtract drifts into the neighbouring day. The harness
helper itself is only reachable through a deep
`@deepseek-ai/dsh-client-ui-chat/src/*` path — a package this plugin does
not depend on — so the idiom is mirrored at its one call site instead of
taking the dependency.
Both locales carry the shorter 今天 / Today; the deprecated
`ov.range.24h` key is gone rather than aliased.
The specs that anchored time on raw epoch offsets silently assumed UTC
(`11 * 86_400_000`), and the panel fixture at `NOW - 1h` would have
failed whenever the suite runs in the first hour after midnight; every
boundary is now anchored on a local `Date`, so the day floor is proved
identically in every timezone.
A case-insensitive substring filter rides the skill card's totals row,
narrowing the rows, the summary counts, and the overflow line; the
input stays mounted on an empty match, next to a zeroed summary and a
dedicated no-match note. The bars keep the scope's heaviest tally as
their denominator so they hold still while the filter narrows.
A pin the filter hides now falls back to the detached detail block with
its head row — previously the head rendered only for pins outside the
scope, leaving a filter-hidden (or render-bound-hidden) pin unnamed.
@deepseek-ai/dsh-client-store declares both under devDependencies
while its published lib/index.js imports them, so the client lane's
inlined harness bundle needs them hoisted at the root exactly like
`diff` in the previous commit. A clean-room install of the lockfile now
resolves the whole runtime import graph from the checkout alone:
zustand, immer, diff, shiki, katex, anser, clsx, simple-icons and the
micromark/mdast set.
@deepseek-ai/dsh-client-ui-primitives declares `diff` under
devDependencies while its published lib/index.js imports it, so the bare
import only resolves when the consumer's workspace hoists it — exactly
how shiki, katex, anser, clsx and the micromark/mdast set are already
carried here. `diff` was the one name missing, which failed the release
workflow's `pnpm test` with "Failed to resolve import \"diff\"" across 47
client suites while passing on any tree with a hoisted copy above it.
The host half runs in Node straight off disk, where parse time is irrelevant
and no sourcemap ships beside it, so minifying only cost stack-trace
readability. The browser half stays minified: it is the artifact the browser
downloads and parses.
v0.65.0: lib/index.js 56.87 -> 99.11 kB (gzip 16.85 -> 24.21), comments
already stripped.
Render both halves with `comments: { legal: true, annotation: false, jsdoc:
false }` (rolldown's OutputOptions). The `/* @__PURE__ */` and `/* v8 ignore
*/` markers that survive the minifier are build-time input whose only
remaining effect in the output is bytes, and plain source comments are noise
in a shipped artifact; legal comments stay for licenses, while `index.d.ts`
keeps the JSDoc that documents the integration contract.
v0.65.0: lib/client.js 355.18 -> 336.46 kB (gzip 94.09 -> 91.32), lib/index.js
57.33 -> 56.87 kB.
The client bundle is a classic script the shell loads into its module
table, never a Node module, so its target must not fall back to
engines.node — that fallback also tripped tsdown's legacy-CJS warning on
every build. deps.onlyBundle turns the "everything else inlines" rule
into an enforced allowlist: any other node_modules package inlined into
the client bundle fails the build.
The Context Insights page's second insight card lists every skill loaded
within the page's scope (the range floor, or the heatmap's pinned day):
a color-dotted row per name with its load tally, a proportional share
bar scaled to the heaviest row, and the last load's relative time,
ordered by the title's loads / last-used toggle. A row click pins the
session list to that skill's loaders; the pinned row wraps with its
detail into one ringed unit — the description first, then a fact grid
(origin, usage, exact last load, the full path as a click-to-copy
value). A structured hover card answers the same in a floating register.
The catalog read joins description, origin, and path onto the tallies by
name and degrades to bare tallies when the route cannot serve.
The activity ledger's day entry gains a per-name skill table, booked from
the two durable load gestures the timeline fold tags sub: 'skill' — a
/name invocation's skill-invocation source, and a skill-tool result's
<skill_content> wrapper trusted only when its callId pairs with a pending
skill-tool call (a read of source text quoting the wrapper books
nothing). Tables are bounded per day and the pairing list per session;
the state shape change bumps the unit's stateVersion (3 → 4) so cached
rows refold on upgrade.
The plugin-owned /api/dsh-context/skills route serves what a loaded skill
IS — registry description, origin bucket, on-disk path — resolving the
session's cwd and preset scope the way the harness's own skill catalog
does (web compositions mount discovery on presets, so the global registry
alone lists nothing), every face re-proved per request.
The bare "Settings" / "设置" label read as the shell's own settings next
to the plugin's; both the page-heading chip and the plugin-info row now
name the plugin's own page.
The Context Insights page's preferences entry sat as a full-width row
under the activity heatmap, at the bottom of the insight column and easy
to miss. It now rides the page heading as a quiet chip directly right of
the title, cut like the balance capsule beside it, with the long
"open plugin settings" hint carried on the hover title.
The shell's own panel rows draw their glyphs with the icon set's one-unit
stroke in a 16-unit box — 6.25% of the icon edge — while the sheet's bars
measure 98-101 units of its 1024 artboard (9.6-9.9%), so the row read half
again as heavy as the Plugins and Automation tasks rows beside it; at that
weight antialiasing also filled the strokes enough to look darker than
neighbours that ride the very same `--dsw-alias-label-primary` currentColor
in both themes.
The mono seat now derives the thin glyph from the SAME single artwork
instead of shipping a second SVG: the sheet masks down to the harness
weight by 17 units a side (98 -> 64) as the intersection of the drawing
with its own copies shifted along each axis — a white flood minus one
black shift per direction, every shift painted through the drawing's
complement mask, which stays the only copy of the artwork on the page.
Compositing, not a filter: `feMorphology` rounds its radius to whole
device pixels, so it measured as a no-op at 1x and an over-thin at 2x.
The polychrome identity seats (tab chip, /context entry, guide capsule)
are untouched.
Verified: live strokes measure 17px against the clock's 17px and the
pinwheel's 19px at 256px; screenshots in light and dark; lint:fix + test
(100% per-file coverage) + build.
The session node's second-row cells (Skill Loads / Answers) become
keyboard-accessible jumps (role=button + Enter/Space, the team card's
idiom): clicking scrolls the Context Browser card into view and opens
the matching section on the live surface — skill loads expand Skill
Injections with the row lens cleared; answers expand Assistant Messages
with the Answer kind chip picked, so tool-call-only steps filter out.
Hover/focus carry the team's affordance (soft wash, brightened label,
brand ring).
The browser gains a one-shot `catFocus` prop mirroring `nodeFocus`:
select live, open the named category, pick the optional kind chip,
clear the lens, report handled so the same cell can fire again. The
cells stay inert where no browser mounts (no handler, no affordance);
tooltips name the destination in both locales.
Verified end to end: lint:fix + test (100% per-file coverage) + build.
The Current Session node now reads two figure rows: turns/steps above
skill loads/answers, each row's cells splitting the width evenly and
centering (one column pair whatever a label's length).
- Skill Loads (counts.skills): the distinct skill names among the
retained skill-tagged inject events — `/name` invocations and
`skill`-tool loads; the `<available_skills>` catalog digest rides an
untagged inject event and never counts. Derived at view time, so no
stateVersion bump: cached rows recompute the exact tally on serve.
- Answers (state.answers, head scalar): one per assistant message
carrying a non-blank text block — tool-call-only steps are work, not
replies. A running total over the complete log (the humanInputs
precedent), so the timeline stateVersion bumps 26 → 27 and the v20
warm-up rebuilds idle sessions' rows on the first dashboard open.
- The subagent count leaves the session node (the Agent Team ledger
row still carries it); docs/compatibility.md records the bump.
Verified end to end: lint:fix + test (100% per-file coverage) + build.
The overlay rode the brand token (near-black in light, near-white in
dark), making the secondary curve the loudest element on the chart and
colliding with the bar hover outline and the DNA highlight slices that
share the token. It now paints in label-tertiary gray — the right-hand
axis labels' own family — at a thinner 1.25px, over a 3px card-bg
underlay that knocks the bar colors out behind the line so it lifts
clear instead of fighting them.
Rows the log holds no request for yet — the newest assistant reply, the
tool results still waiting to be sent — now wear a PENDING step stamp
instead of none. The prediction follows the harness's step loop
(agent.ts): a newest user message opens a new turn, a text-only final
reply means the turn ended, and pending tool results or an in-flight
injection continue the same turn, so the next step is lastStep + 1.
dsh names that step at step/start before the request exists — the
session log's own tail confirms the prediction (step/start Turn 1 Step
42 over exactly the rows that lacked a stamp).
The estimate reads like the confirmed stamp but with a quiet dotted
underline and a 'not yet sent' tooltip in both locales; the confirmed
stamp replaces it the moment the request logs. Past-step views never
speculate: they reconstruct only what their request already carried.
Every context-browser row (system prompt, tool schemas, user/assistant/
tool/skill/inject messages) now names the turn/step of the request that
first carried the item. The stamp derives client-side from the first
request logged after the item's seq — assemble's inclusion rule is
n.seq < R.seq — the same next-request attachment the events card uses,
so no wire change and graceful degradation on older rows.
The stamp merges with the row clock into ONE muted meta run
('T1 S41 · 22:58:10'), so the meta tail reads as a single unit instead
of three separate clusters; the token figure stays separate. Hover
explains the stamp in both locales. The live tail (items no request
carries yet) and turn-less introducing requests (older hosts) carry no
stamp rather than a mislabeled one.
Both tsdown configs (build and --watch) now stamp every built JS file with
a comment header carrying the package name, version, description, author,
license, homepage, build time, and the current git commit, branch, commit
date, and dirty state. The banner goes through tsdown's top-level option,
which rolldown applies post-minification so the comment keeps its
formatting, and the { js } scope keeps it off the published index.d.ts.
Git state is read per build, so watch rebuilds pick up new commits, and
the git lines degrade to omission outside a git checkout.
tsc --noEmit supports --incremental in TS >=5.6: it emits a .tsbuildinfo
(ignored via *.tsbuildinfo) and reuses it across runs, halving the
per-check cost (0.74s -> ~0.35s) on every pnpm test since vitest runs the
typecheck first. Still catches injected src errors (verified).
Vitest's experimental.fsModuleCache was measured and rejected: cold runs
pay a ~23MB write with no gain, warm runs sit inside the run-to-run noise
band, and caching transformed modules conflicts with the suite's stated
contract that specs import src/ directly so coverage reflects what ships.
The src-only tsc pass was redundant with the tests pass: tsconfig.tests.json
extends the base config, so its program loads every src file (96/96, both
passes identical) plus the specs, and tsc type-checks the whole program —
injected src errors surface from the tests pass alone. Verified zero src-only
diagnostics on the clean tree. One pass now covers src + tests; drop the
duplicated full-program compile.
The src and tests tsc --noEmit passes are independent (different program
scopes), so they fork instead of running back-to-back: ~0.9s -> ~0.65s.
The vitest body is CPU-bound jsdom startup (workers already saturated:
sequential client is 62s vs 14s in parallel), so no test-file changes are
warranted — the spec sleeps are deliberate (act-attributed state via real
timers, host pacing, real debounce), not redundancy.
- The two BASELINES entries differed in exactly three fields (id, tag,
session) over ~95 verbatim lines; one baseline() factory now spells the
shared V4 seams once, with the per-line comments kept above each call
(a future line that diverges on a needle outgrows the factory and
inlines its entry instead).
- The identical node fixture factory lived in five specs under two
shapes; both moved to the shared test kit as surfaceNode (cards'
tokens-5 shape) and surfaceNodeAt (assemble/brief's minimal shape).
- compareVersions had zero production callers (the gate rides
meetsBaseline alone) and is not re-exported through the package entry;
its spec block duplicated what meetsBaseline's cases already pin
through compareParsed (a major-gap case joins meetsBaseline's block to
keep every arm covered). isNewerVersion keeps its own numeric-only
compare: ignoring prereleases there is the upgrade nudge's deliberate
semantics, and adopting the channel-aware order would change
user-visible behavior.
- pad2 hand-rolled padStart(2, '0') in a function that already used
padStart for the year.
- StoredHeaderTool/StoredHeaderRecord and DecodeCounts/DecodeSpans lost
export keywords nothing inside or outside the repo imported.
- The ConnectionHostFace interface and its bind-at-extraction guard were
copy-pasted across detail/backfill/balance; one host/connection.ts
module now owns the face and fetchRouteRegistrar.
- accumulateCost (timeline fold) and pricedDayOf (activity ledger) were
two hand-written copies of the same clone-along-path cost accumulation,
period rule included; both now call the exported addBilledUsage.
- The cost zod schemas were defined field-for-field in activity.ts and
timeline.ts; activity imports costUsageSchema from the wire owner.
Rejected from the audit after inspection: sharing firstText/firstFullText
(the walks differ in the acceptance predicate — trim-nonempty vs
nonempty — and unifying would change skill-name matching on a
whitespace-only first block of untrusted log data), and unifying the two
host asRecord guards (a one-line stdlib idiom; cross-module imports for
five lines is churn, not simplification).
makeDetailFetcher's read switched to a plain fetch in a82e6a1, leaving a
lone void-statement as the ctx parameter's only use — yet ctx kept being
threaded through detailStoreOf, useTimelineSource, and makeAgentHeads.
All four drop the parameter; the spec's ctxWithCall factory (which only
ever stubbed global fetch and returned a ctx nobody read) collapses to
a plain stubDetailFetch.