The browser's category lists were capped at the newest 200 nodes, so a
long session showed token sums with no item rows for everything older.
Projection pushes ship the whole value but only fire on surface-moving
events (a few per step, ~150B per node), and auto-compaction keeps the
live surface far below 2000 in healthy sessions — so the new default
effectively lists every live node, with the bound kept (and the honest
'outside the served window' note) as a backstop for pathological cases.
The turn strip's per-turn palette was positionally identical to the six
category colors (turn 1 indigo = system, turn 2 amber = tools, ...).
Sitting 4px under the bars, the strip read as a detached full-width
bottom segment that never aligned with the bars above — the bars looked
broken, especially in multi-turn sessions.
The strip now uses a neutral two-tone zebra (adjacent turns still read
as separate ranges) with secondary-label text; the in-turn bar tint goes
neutral too. Verified against the live GUI: every composition bar's
segments measure pixel-aligned; the strip now clearly reads as an axis.
Hovering a history bar now transiently previews that step in the Context
browser — picker, meta line, composition bar and category lists all
follow the pointer, with a 'preview' marker in the meta row. Leaving
the chart returns the browser to its own picker selection; a seq trimmed
out of retention is ignored. Accordion state survives the hover, so one
open category can be scrubbed across steps for comparison.
Injections land on the surface first, so in a long session the plain
maxNodes tail window dropped their identity while their tokens kept
counting (sums cover the full surface) — the browser's inject section
showed a gray '0 items' row with a real token sum and no way to see
what was inside.
The served slice is now the newest maxNodes tail PLUS every live inject
node older than the tail (they are few; the concatenation stays
seq-ordered). droppedNodes and surfaceFloor count only the unserved
non-inject overflow, so the 'N earlier messages' note no longer
double-counts pinned injects, and per-step reconstruction sees them too.
Overview + trend stack in the left column and the context browser takes
the right column, reusing the shared lc-cols flex (wraps to one column
on narrow widths).
Section spacing moves from per-card margins to the row container:
.lc-cols owns the 14px gap to the next section, cards that ARE columns
drop their own bottom margin, and the last card inside a wrapper column
drops its own — fixing the head row sitting flush against the overview
card and making every section boundary exactly 14px however the flex
wraps.
Expanding a Context browser element whose seq is outside the loaded
conversation window now pages older history in automatically (one page
in flight, up to 20 pages) until the join hits, instead of leaving the
reader with the 80-char preview plus a static note.
The pagination verb reaches the card through the harness's
sessions.provide channel: the plugin contributes a loadOlderHistory
standard prop resolved to session.loadOlder(), so every session-scope
slot component receives it. Hosts without the channel (or a duplicate
refusal) fail soft to the previous preview-plus-hint degradation.
dsh-session exports deriveEventMessage(event) as the canonical per-event
message projection:
user/message -> event.data
assistant/message -> event.data.message (or null when content is empty)
tool/result -> event.data.message
other -> null
The fold's three surface-event cases now call this one function instead of
hand-rolling casts and picks.
Behavior is identical: empty assistant/message projects to no message
(usage-only), so applySurface receives null in that case and the existing
emptyIsZero short-circuit prices 0 tokens (without the +ROLE_OVERHEAD that
a non-empty message would pay).
MessageLike is kept as the plugin's narrow structural type; deriveEventMessage's
return type (Message from dsh-llm) is structurally compatible with MessageLike
(modulo the optional error field, which is harmless).
applySurface's message parameter widens to MessageLike | null | undefined
to accept deriveEventMessage's null return directly.
The harness's global scrollbar skin (ui-theme/src/styles/scrollbar.css)
supplies the styled scrollbar via scrollbar-width: thin; declaring both
scrollbar-width and ::-webkit-scrollbar on the same element is mutually
exclusive across engines (see harness's own note at scrollbar.css:26-43),
so the local webkit rules were silently ignored on Chromium/Safari
anyway. Drop them; the chart scroll uses the harness's skin.
The events card header now carries four pill buttons (注入/压缩/剪枝/切换)
instead of a select: all picked by default, clicking an unpicked kind adds
it (A -> A+B -> ...), clicking a picked one removes it, and removing the
last one resets to all. Each kind gets a distinct hue shared between the
pills and the row chips (绿/红/紫/蓝); the hint subtitle and the filter
dropdown are gone.
Each event row now carries a kind chip (注入/压缩/剪枝/切换) colored by
impact direction (+ adds, - frees, ⇄ neutral), the events card header
gains a one-line hint about the block's meaning, and the native title
tooltip attaches only while the label actually truncates (measured after
every render and on resize) instead of always.
Side-by-side head row (3:2 flex, wraps when tight): plugin name, version,
and GitHub repo as whole-cell links (name/version -> GitHub/npm), with the
version/repo baked in from package.json via esbuild define. A once-per-page
npm registry check adds an 'Update!' badge + update-command tooltip on the
version cell whenever the registry release differs; failures stay silent.
Bilingual strings, cell-hover underline + brand border affordance.
Drop recycled/switches/est/actual-prompt/output cells and split the
single recycled counter into two — compactions and prunes — so the
counts are distinguishable in the board. Bold the cell labels and
center the values for a cleaner scan.
token-meter is a core service that owns estimation and replay (its docs:
'the singleton ctx.tokenMeter directly owns estimation and replay'), and the
published package registers three projections on the shared
SessionProjectionMap — tokenUsage / contextPressure / contextBreakdown.
The host was duplicating the contextPressure fold (pressureTokens +
sampledSurfaceTokens + occupancyWindow, sampleUsage on assistant/message and
usage chunks) and the client was rendering from that mirror.
Host: the occupancy mirror leaves the fold entirely (assistant/chunk is now
structurally ignored — same reference, no change feed). stateVersion 1 -> 2
so cached rows from the old fold are discarded. The strict wire schema no
longer accepts occupancy; the shared type keeps it optional as legacy for
downstream consumers.
Client: the headline reads the official token-meter contextPressure
projection via the useProjection seat (the same key the chat's context ring
reads); absent key/value degrades to the existing derived fallback (last
request prompt + surface movement), so the UI renders identically in every
deployment. UI functionality is unchanged — same headline, same percentages,
same composition anchoring.
diff-fold.mjs is repurposed: the plugin no longer has an occupancy fold to
compare, but its heuristic composition (current.system/tools + surface total)
must stay equal to the official contextBreakdown projection — verified
MATCH on three real session logs.
Tests: host asserts occupancy is absent + chunk reference stability; client
feeds the official projection through the stub and asserts the same UI text.
Verified against the harness source before locking in:
- agent-loop appends request/header (reason 'change' when the envelope
differs) BEFORE request/context per request, so a model switch is always a
header change — firing the model event only there equals the durable
record, and route/capacity metadata can never double-fire it.
- dsh-llm TokenUsage buckets are disjoint: billed prompt-side =
inputTokens + cacheReadTokens + cacheWriteTokens, outputTokens already
contains reasoningTokens — the fold's prompt/output mapping was already
correct; comments now cite the official semantics.
No behavior change; new assertions cover the switch event (exactly one, from
model-a to model-b), provider-only changes (route update, not a switch), and
the usage mapping (1000 = 800+150+50; output 30).
The docs' plugin contract expects an exported Config (Standard Schema) so
invalid entry config fails the load loudly, and the harness config principle
is 'anything two deployments may want to set differently is a configuration
field'. The fold previously hardcoded MAX_REQUEST_STEPS / MAX_KEPT_TURNS /
MAX_EVENTS / MAX_NODES in fold.ts.
- config.ts: exported Config (zod, implements Standard Schema v1 — the same
validator cordis uses via Config['~standard'].validate) with strict,
defaulted fields; resolveBounds() turns entry config into FoldBounds.
- fold.ts/timeline.ts: bounds thread through applyTimeline/trimState/
buildTimelineView; the projection unit is now created per instance by
createContextTimelineDefinition(config), so cordis config edits hot-reload
(HMR) like any other entry config.
- index.ts: apply(ctx, config) registers the config-bound unit; Config is
re-exported for patchers and the config catalog.
- Defaults equal the previous constants, so behavior is unchanged; persisted
state shape is untouched (no stateVersion bump — bounds are retention only).
- tests: host test drives a bounded instance (maxNodes/maxKeptTurns).
The module-level inject: ['sessionProjections'] already gates apply and
auto-restarts when the registry provider is replaced; wrapping the same
registration in ctx.inject(...) created a redundant dependent fiber
(cordis-api/registry.md documents ctx.inject as shorthand for
ctx.plugin({ inject, apply })). Registering in apply keeps one gate.
Typing /context in the composer opens a centered dialog with the current
composition (provider-anchored headline, stacked bar, legend) and the
last-10-turn trend chart — the same pushed contextTimeline projection the
tab renders, no RPC, no polling.
The command is the plugin's own '/' trigger source, fully client-side:
no host descriptor, no session log records, nothing model-visible. The
composer token is consumed only when the modal closes (deferred
slash/input-consume-token with span / bare-token guards, fail-soft if the
draft changed meanwhile). Esc, backdrop click, and the close button all
close it and restore composer focus.
Also extracts the provider-anchored headline derivation shared by the tab
and the modal (headline.ts).
Composition-bar hover now recedes everything except the hovered part: the
container gets a lc-stacked-dim modifier whose unselected segments and the
free track drop to 35% opacity while the hovered segment/chip/free-track stays
at 100% (with the existing brightness boost). The occupied-region reference
frame is also stronger: 2px dashed label-primary at full opacity (plus a hair
inner ring) so it reads as an obvious dark frame against the colored
segments. Pure CSS + one container class — sheet and tests unchanged in
behavior (box/dim are hover-gated like before).
The composition bar lays segments out against the FULL context window (width
= x / window) while legend/tooltip percentages are shares of the OCCUPIED
part (x / used) — so a slice that is half the used context reads ~20% wide
when the window still has headroom, which looks contradictory.
On hover (segment, legend chip, or the free track) a dashed frame now
outlines the occupied region (left edge to the used/window boundary), making
the 'share of used' reference visible; the frame renders only when a free
track exists (otherwise width already equals the percentage) and is
pointer-events:none. Segment tooltips now append 'of used context'
(zh: 占已用上下文) and legend chips carry the same hint as a title.
Adds the .lc-occupied-box style (shared --dsw-alias tokens), one i18n key per
locale, and render-test assertions that the frame appears on hover and
clears when hover leaves.
All view components are now tsx JSX function components instead of
hand-rolled h() factories; the bundle externalizes the framework seed words
(react, @deepseek-ai/dsh-client-ui-primitives) so the loader module table
resolves the single shared framework instance at runtime.
Reuses shared ui-primitives where a clean fit exists (injection and
model-switch event glyphs -> IconPlusOutline16/IconBranchOutline16); the
bespoke data-viz chrome (charts, shared-hover tooltip) intentionally stays
custom but continues to draw the shared --dsw-alias-* tokens.
esbuild uses the classic JSX transform, so the emitted createElement trees
are isomorphic to the previous h() calls and the element-parsing tests, the
chart-render assertions, and the real-React repro all stay green unchanged.
Adds @deepseek-ai/dsh-client-ui-primitives as a type devDep and documents the
framework-reuse findings in docs/redesign.md §8.
Replace the custom /dsh-context RPC channel + 2s client polling with a native
contextTimeline session-projection unit: the host registers one pure
init/apply/view unit on ctx.sessionProjections (framework-driven, persisted by
the projection cache, streamed via session/projection push frames), and the
client reads the finished value through the framework standard
useProjection seat. Deletes the client stale-while-revalidate cache and the
local service type stubs; grows the modern shelf registration (locale) and
adds the published @deepseek-ai/* types as devDeps plus zod as the single
runtime dependency (wire schema, kept external in the host bundle).
Fixes tool-result node attribution to read the durable source.callId /
content toolCallId instead of the never-present block.callId (tool labels had
silently never rendered).
Retention is tightened to trim by whole turn-runs deterministically (bounded
state, no 1200<->1500 oscillation under incremental folding).
Tests now drive the projection unit directly (host) and feed the view through
a useProjection stub (client); all chart/render assertions preserved. Adds
docs/redesign.md (review + target architecture, phases 0-3 landed) and
docs/CLIENT_SURFACE.md (client-side audit).
The context page scrolls inside the conversation shared page scroller
([data-conversation-scroll]); the chat auto-scrolls that container to the
bottom on every visit. The view own .lc-root height:100% never resolves
(the slot wrapper has auto height), so the context content flows in the
shared scroller: a fresh mount inherited the chat bottom position and
stayed locked there once the snapshot cache warmed up.
Own the scroller like the chat chatScroll ledger: keep a module-level
per-session position, restore it in a mount layout effect (first visits
start at the top), save it in an unmount layout cleanup before the incoming
view re-scrolls the container, and never re-apply on polls.
The context tab always showed less than the official chat context
indicator because the two sides used different rulers: the plugin
displayed its fixed-density heuristic total (4 chars/token, which
systematically undercounts CJK text and JSON schemas by ~10-15%),
while the official ring shows provider-reported billed tokens. The
running host also predated the occupancy feature entirely, so the
client fell back to the raw heuristic headline.
Core change: totals are anchored to the provider sample, heuristics
only supply the composition ratios (the official ContextMeter's own
trick):
- host: fold a contextPressure-equivalent occupancy projection
(pressureTokens / sampledSurfaceTokens / occupancyWindow) and serve
it on the snapshot; usage is sampled BEFORE the message joins the
surface, and streamed assistant/chunk usage samples count too, so
steps that fail mid-stream no longer fall back to pure heuristics.
- client: headline = occupancy.projectedTokens, with a last-request
fallback deriving the same figure against older hosts (a page
refresh fixes the live view before any server restart).
- composition bar, legend and trend bars are re-proportioned to the
anchored total (ratios stay heuristic, values marked ≈); the
conflicting 'estimate ≈ n (~p%)' second percentage is gone.
Verified by tests/diff-fold.mjs (kept as a differential regression
tool): the plugin's occupancy equals the official
contextPressure.projectedTokens to the token on all 33 real session
logs, incl. a 17k-event log with 9 compactions and a chunk-only-usage
session.
The overview's headline is now the provider-reported CURRENT occupancy
(contextPressure formula: newest usage sample + heuristic surface movement)
— the same figure the official chat ring shows — with the heuristic estimate
as the secondary label. On the live session: 475.1k (48%) provider vs 409.0k
(41%) heuristic; the gap is the provider tokenizer vs the 4-chars/token
heuristic (CJK-heavy content undercounts ~15%), not missing data — our
heuristic fold matches dsh's official fold to the token.
The producer's compaction shadow price covers the shadowedSeqs — which can
include prune replacement nodes whose own seqs lie beyond the declared range
end. Removing by the range left those nodes in the surface and overcounted
messages by 8 x the replacement price (10,416 tokens on the live session).
Removal now follows the armed shadowedSeqs (per-category bookkeeping kept),
making our heuristic totals exactly match dsh's official contextBreakdown
fold. Also: provider-based actual occupancy in the overview now uses the
official contextPressure formula (sample + surface movement), and the
missing 'overview.actual' dict key is added.
- replace the delayed native title with an instant custom .lc-chart-tip glued
to the hovered bar's column (single line: turn/step, time, total, actual)
- turn-aware dimming: while a turn is focused (bar or strip hover), bars and
strip blocks outside the active turn fade to 35%; the whole current turn
and its strip block stay fully opaque
- shared/types.ts: single wire contract (type-only) used by both halves
- host/: services (contracts), pricing (token-meter), fold (incremental fold
+ retention), snapshot (build + event turn/step attribution), index (RPC)
- client/: i18n, styles, format, categories, viewkit (shared deps),
cache (per-session stale-while-revalidate), components/* (one module per
component), view (composition root), index (plugin entry)
- first-screen: initial render seeds from the session cache, polling pauses
while the tab is hidden (resume + immediate fetch on visible), and a
transient fetch error never blanks already-visible data
- build.mjs: bundle:true for both halves (react external in the client
bundle); the client half is now covered by tsc --noEmit for the first time
The TrendChart layout effect has no deps and dispatched setEdges on every
render. Steady state survived via React's same-value eager bailout, but a
granularity switch genuinely flips the fades (narrow turn chart has no
overflow), and the programmatic scrollLeft assignment fires an async scroll
event whose dispatch leaves the fiber with pending lanes — the eager
bailout is skipped, every commit's effect enqueues yet another update, and
the queue grows without bound until React #185 (maximum update depth)
throws and the slot's error boundary blanks the tab.
Mirror the fades in a ref and never dispatch when the values are unchanged,
killing the cascade at the source instead of relying on bailout internals.
The real-React repro now emulates layout well enough to catch this class:
scrollWidth follows the bar count and programmatic scrolls dispatch an
async scroll event, so the old code crashes the test and the fix passes.
The old text only mentioned clicking for details; hovering now previews
the detail (click pins), and the Step/Turn toggle changes granularity.
Both languages updated.
Tooltips and detail headers now read 'Turn 44 · Step 5' (step mode) and
'Turn 44 · 5 steps' (turn mode) instead of the terse 'T44 · step 5';
Chinese uses the natural '第 44 轮 · 第 5 步'. The compact turn strip
blocks keep the short 'T44' label (14px columns cannot fit 'Turn').
Returning from turn to step mode kept the stale left edge (the narrow
turn chart had clamped the scroll position), so the newest bars were off
screen. The layout effect now detects a granularity change and re-runs the
first-mount anchor — both directions land on the right edge. Plain
re-renders (polls) still respect a scrolled-away position.
The tooltip is now derived from the shared hover key instead of internal
pointer state: hovering either a segment or its legend chip brightens the
same segment, highlights the chip, and shows the same tooltip centered on
the segment (percentage positioning, no measuring). Moving the pointer
within a segment no longer repositions the tooltip — the centered
placement is consistent for both directions.
The active button used white text on the brand color, but dark themes
resolve brand-primary to a near-white shade — white-on-white. Now uses
the harness's own primary-button pairing (button-primary-fill background,
label-primary-foreground text), which flips to dark text on the light
brand in dark mode.
The TS port passed the event ENVELOPE (data) instead of the model-visible
message (data.message) for assistant/message and tool/result — every such
node was priced at a flat 4 tokens (role framing only), so tool-heavy and
assistant-rich sessions were massively undercounted in the Current
composition. This is why the overview (20.4k) disagreed with the real
usage. Mirrors dsh's deriveEventMessage exactly now; empty-content
assistant messages price 0; tools total uses dsh's whole-array price; the
overview additionally shows the last provider-reported actual prompt
alongside the estimate.
The fold capped the timeline at a step count, so long sessions lost
older turns entirely in turn granularity. When the step bound is
exceeded, the timeline is now trimmed to the newest 300 WHOLE turn runs
(1500-step backstop for pathological many-step turns) — a turn is never
cut in half, and turn mode always shows the full recent turn range.
The harness session log itself has no event cap, so this was the only
limiter.
The Current composition stacked bar and the legend below share one hover
state: hovering a segment brightens it and highlights its legend chip
(bold text + ring on the color dot), and hovering a chip highlights the
matching segment. The mini bar in the request detail is unaffected (no
legend there); the tooltip keeps working.
Turn aggregates no longer stretch to their step span — every bar keeps
the fixed 14px column in both granularities, and the turn strip blocks
match (uniform in turn mode, step-spanning in step mode), so bars and
blocks stay aligned 1:1.