The 0.1.7 line's SettingsForms service retired the `get(ns)` section
read, so the balance route never resolved the DeepSeek API key on V4+
deployments and the capsule stayed hidden. deepseekSectionOf now folds
over whichever face the running line serves: the registered
`llm-deepseek` section through V3, and on V4+ the Config-form
`describe()` projection, matching the provider's row by its own
apiKeyEnv-bearing section shape and preferring the settings ids the
generations have served it as. Rows fold in isolation, and a describe
that throws is no section — the capsule still arms nowhere it cannot.
The per-baseline `balance` probe pins the settings face and the
provider row's sources against each tag's real sources.
The panel's first row is now the range's six KPI figures in one
full-width line, lifted out of the insight column; the aggregate Token
and Timing Stats pair moves below it, and the 3:7 body keeps the
activity heatmap and the preferences entry beside the session column.
The band folds 6 -> 3x2 at 1000px of container and 2x3 at 640px, so no
cell ever ellipsizes its figure.
The panel's first row folds the range's sessions into the two donut cards
the per-session Context tab opens with. Token Stats splits the billed
volume by the SAME composition categories: each session's billedParts
estimate sums by category (output exact), and the center total stays the
merged billed figure the Tokens Used cell shows. Timing Stats reuses the
per-session card verbatim over a summed TimingTotals whose additive-
optional decode-split and throughput fields sum their carriers only, so
older cached rows keep the un-split shape and no-chip fallbacks. The pair
sits at half width over the 3:7 body and folds to one column at 700px of
panel width.
dsh's 0.1.6 selection refactor moved session selection to the view owner:
uiWorkspace.openSession is the verb the built-in sidebar's row click uses,
and the sessions service no longer carries open(). The plugin's jump (the
Context Dashboard's session cards and the Agent network card's nodes) still
called the retired sessions.open spelling, so on 0.1.7-rc.2 the panel closed
but no session opened — silently, by the verb's fail-soft design.
One shared openSessionVia (services.ts) now serves both call sites: it
re-proves both faces per call, prefers the view owner's verb (invoked bound
to the service instance), and keeps the retired sessions.open as the
degradation path. The per-baseline sessionNav seam pins both spellings
presence-as-declared in the real-code matrix, and the always-on client-face
probe drives the jump through each generation's exact composition (the V4
baseline serves no sessions-side selection at all — the exact shape issue
#90 died on).
The 0.1.7 sidebar wraps every tab body in a definite-height `.tabBody`
whose `overflow: hidden` clips the grown tab root out of reach, while the
dockkit pane body above it never sees the overflow — the Context pane in
the right Sidebar could not scroll at all. The sidebar-scoped root now
keeps its own scroller (the shared `height: 100%` + `overflow-y: auto`
chrome, inset aside): the height resolves against the 0.1.7 tab body or
the 0.1.5 pane body alike, and degrades to the pane scrolling wherever
neither height resolves — one scroller either way.
The plugin.settingsOpen hint - the Plugin Info card's row value and
tooltip, and now the insights panel's settings row hint too - read
"Open preferences" / 打开偏好设置; both locales now name what the jump
actually opens, this plugin's own settings page ("Open plugin
settings" / 打开插件设置), not the shell's preference center. The two
settingsCard comments quoting the jump's label follow.
The Context Insights panel's insight column now closes with a framed
settings row: the plugin gear plus the localized jump hint, styled as
the column's third card (the session-card hover). The click rides the
same guarded preferences jump as the Plugin Info card's row
(settingsJump.ts) and closes the overlay with it - the jump drives the
shell chrome behind the panel, so staying open would hide its landing.
The panel spec drives the jump end-to-end against a simulated
Plugins-page chrome, from the panel-entry click to the card-open
control.
The supported range is now two baselines - dsh-v0.1.5-rc.1 (session
format V3) and dsh-v0.1.7-rc.2 (V4) - enforced by the runtime gate at
BASELINE_DSH_VERSION = 0.1.5-rc.1; below it the fallback units and the
upgrade modal take over (0.1.2/0.1.3 hosts were previously served by the
real folds).
The pre-V3 log shapes are dead code on every supported harness: the
running harness migrates historical logs on every read path (or refuses
the session wholesale), and 0.1.5-rc.1 rejects a request/header carrying
header.system outright. The fold therefore drops the V0/V2-only paths -
the header.system envelope system prompt, assistant/chunk floods,
{start, end} replacement endpoints, tool/code-dispatch - and the state
fields only they wrote (systemsFromHeader, the stepStart decode
accumulators). stateVersion stays 20: no supported log folds
differently, and pre-V3-era cached rows are discarded by the projection
cache's identity gate (and the strict stepStart sub-schema) instead of
served. The headers unit folds metadata-only epochs (envelope
systemTokens write dropped; the v1 cached-row read-compat and its pinned
stateVersion 1 stay); replaceRangeOf speaks startSeq/endSeq only; the
browser's epoch fetch maps request/header to tools and system/message to
the prompt text.
tests/baselines.ts re-fits to the two entries (the right Sidebar seam is
required on both), the compat matrix drives both tags' REAL sources
(canonical logs re-shaped to V3, the hostile probe renumbered past them),
and the version fixtures pin the new floor. Manifest: dshReleases
{0.1.5-rc.1, 0.1.7-rc.2}, dsh peers >=0.1.5-rc.1 (satisfies the 0.1.7
admission check under includePrerelease), devDeps pinned to the
0.1.5-rc.1 surface plus the ui-primitives runtime closure the client
lane inlines (test-only), and the release workflow fetches the two tags.
docs/compatibility.md is rewritten around the two-line matrix; README,
AGENTS.md and the issue template follow.
Also riding this move, from the audit pass: the fold re-proves an inject
source's form before it books the event record (the harness validates
`kind` but not `form` - a hostile one previously failed the strict
wire/state schemas on every delivery, the #44 freeze class), the headers
unit guards a data-less envelope (it has no try/catch of its own), the
schemastery schema annotations drop the explicit z<T> wrappers the 3.18.4
Mode generics reject, and the version-history note records why
stateVersion is deliberately not bumped.
dsh 0.1.7-rc.2 dropped the offset parameter from
sessionProjectionCache.cachedSnapshot (the lifecycle identity now rides
the header alone). The warm-up's fixed 3-arg probe bound the branded
offset onto `keys`, where `new Set(0)` threw for every session with
served rows - so the whole corpus read as unserved and took the full
cold-read path on each process's first dashboard open (contained per
session, but minutes of repeated decode+fold on a large one).
The probe now tries the 0.1.5-rc.1 spelling (meta, offset, keys) first
and falls back to the 0.1.7-rc.2 spelling (meta, keys); a record-less
session returns undefined without throwing on either face, which stays
the correct "not served". Covered by a two-face fixture.
The placeholder promised less than the user wanted and the matcher
delivered less than the placeholder should: the search box only
substring-matched title and directory. It now also matches the
session's newest own message (timeline.lastUser, the card preview
text), case-insensitively, and a non-string fold shape safely never
matches. The placeholder copy follows in both locales ("Search title /
path / last message..." / "搜索标题 / 目录 / 最后消息...").
The overlay card sized to its content up to a max-height cap, so every
list/filter change wobbled the panel's height and its centered top and
bottom gaps. The card now takes a fixed height - the viewport minus a
40px band top and bottom (the width keeps its 16px side frame) -
resolved against the definite-height backdrop so no viewport units are
involved; the inner columns keep scrolling their own overflow and the
folded width keeps scrolling the card whole.
The card's depth always rode billed tokens; now a Sessions/Steps toggle
in the card's top-right corner (default steps) picks the metric, and the
day tooltip gains a steps line beside the sessions count. Sessions and
steps shape the window's days on different spans - one long session
drops a step a day, another day stacks a run - so the join tracks an
independent window maximum per metric and never scales one off the
other's peak. A billed day with no settled step (a legal wire shape)
stays gray under steps yet still tips and pins. The toggle rides the
shared lc-gran idiom inside a lc-heat-ctl wrapper - one auto margin for
the hint text plus the group - and specs cover both scales plus the
panel-level switch.
The month captions sat ~2px off the cell tops and the weekday gutter
only 4px from the grid - cramped at a glance. The month band grows to
18px (the label's line-height rides it, so the caption centers in the
band) and the gutter gap to 8px; the band is shared by every column and
the gutter, so the centered cell rows stay aligned.
The single-glyph 一/三/五 and M/W/F read as cryptic initials; the gutter
now carries the full names 星期一/星期三/星期五 and Mon/Wed/Fri. The
weekday gutter drops its 10px single-glyph width and self-sizes to its
widest label (the locales differ), with nowrap keeping a full name on
one line.
Two borrowed from GitHub's contribution graph. The grid gains the
Less→More legend under its right edge - the five swatches reuse the
cells' own depth classes, so the key can never drift from the scale it
explains (ov.heat.less/more, 少/多). The month gutter now also names the
window's opening column, so a month already running at the window's
edge is announced instead of left blank; month-start columns keep the
existing rule and text. The component grows a column wrapper to hold
grid and key, and the specs cover the legend's five steps and both
label paths (the wi>0 opens-on-the-1st case guards the short-circuit's
covered branch).
The weekday gutter drew 一/三/五 on rows 1/3/5 while the grid was
Monday-first, so every caption named the row below its day - today's
Thursday cell read as Wednesday. Rows now start on Sunday (row 0 =
Sunday): days.ts renames mondayOfWeek to sundayOfWeek and anchors on
getDay() directly, the grid builds off that anchor, and the labels key
by the row index they name so no row arithmetic can shift them again.
The month gutter is untouched - the label still marks the column
containing the month's 1st, now read off the column's Saturday. Specs
re-derive every pinned date from the Sunday-first calendar.
The Plugins page's package cards draw each bundle's icon from the Host's
package metadata (manifest-relative SVG/PNG/JPEG/WebP, inlined as a data
URI), which we never declared - the cards fell back to the default
artwork. The Context emblem's single graphic source is now the
package-root icon.svg: the package.json "icon" field points the Host's
reader at it, and the client build inlines the same file through a
"*.svg?raw" channel (the bundled ContextIcon re-renders it at every
requested size), so the code carries no second copy of the paths. A spec
pins the file and the component in lockstep; the files list ships the
asset.
The Config-form jump stops settling for the card list: after the panel
entry's click, a bounded poll (40 ticks at 100ms, riding the same
scheduler contract the tests drive) follows the page's package load and
opens the bundle's page through the card's title control — the one
button inside the shipped data-plugin-package list item that is not the
enable switch, so the match survives every locale. A poll that finds
nothing (still loading, plugin absent, or the page already open on the
detail view, where the list is unmounted) silently degrades to the card
list.
The preferences jump prefers the Plugins main-panel entry (matched by
its shipped aria-label) where the generation ships it — one click and
done, the page's internal view state has no external face — and falls
back to the settings-dialog choreography for the older lines. The jump
copy becomes generation-neutral (Open preferences).
The Config-form generation retired the settingsScope service and the
settings.plugin.item slot, which took the preferences card with them.
A second deferred inject — fired only where the configForms service
exists, so each line runs exactly one transport — binds the shared
settings store onto the dsh-context config form and registers the flat
card on the Plugins page's keyed plugins.bundle.config seat, alive only
while the Host serves the namespace (whileServed). Both seats render the
same six preference rows over the same store; the settings-section card
keeps its shape for the older lines.
The Config-form generation (dsh 0.1.7+) derives the Plugins page's live
form from each loader entry's own Config schema, so the entry schema must
be the schemastery one: cordis keeps validating the config: block through
its Standard Schema face, and the same schema now carries the six display
preferences as .volatile() fields (live-editable there, namespace = the
entry id). The fold bounds stay ordinary fields — their edit remounts the
entry, a preference edit commits volatile-only.
The .volatile() modifier ships only with the newer schemastery, so the
builder feature-detects it and the same bundle loads on the older lines
(3.18.2 predates the modifier). The devDependency bumps to 3.18.4 to
exercise the marked path in tests. Deliberate relaxations against the
replaced zod schema: unknown keys merge through, and integers are
expressed as min 1 + step 1.
The generation split (thinking / answer / tool args) already tiles its
window off the stream's block-start markers; the same markers now count
into additive-optional per-bucket tallies (reasoningBlocks / textBlocks /
toolArgBlocks), so the card qualifies each decode slice with the number
of blocks actually decoded - the honest per-slice counterpart of TTFT's
call count and tools' run count (one model call emits zero to many
blocks, so a call count there would be a false tally). Overhead stays
unqualified: it is the residue, with nothing behind it to count.
Both log generations carry the markers: the V0 chunk flood counts them
into the step slot alongside the spans, and the V2+ embedded stream
yields them from the same single pass that prices the split
(decodeSpansOfStream -> decodeTallyOfStream). Counts ride the split's
own first-token gate, and rows cached before the fields existed render
the duration alone.
Three per-event costs leave the fold: every state-changing event shallow-copied
all seven retained collections, every event re-scanned the retained requests to
count turn runs, and every tool call stringified its arguments even when the
tool can never row a file op. Cloning now follows the branch's own mutation
list, the run count rides an incremental ledger (recomputed only where a trim
replaces the array), and raw arguments are kept only where they are read.
Measured over 279 real sessions (282,783 events): the fold drops 16.8% corpus
wide, ~51% on V3-shaped logs and ~57% on sessions at the retention caps, with
state output field-identical apart from the new optional `turnRuns` field and
the intended `argsRaw` omission. `stateVersion` is unchanged, so cached rows
keep parsing and stay usable.
The Windows check rode along: `path.relative` returns an ABSOLUTE path across
drives, so the old `..`-prefix test read a foreign drive's module as a child of
the plugin's own tree and degraded version detection; `!isAbsolute(rel)` closes
it.
Review follow-up on top of the original six-file change:
- an absent `timing` slot stays absent. The selective clone materialized
`timing: undefined`, which is not losslessly JSON-serializable, so
`snapshotJsonValue` rejected the whole checkpoint: every projection-cache
write for that session was skipped, and a log ending on an unpaired
`tool/result` never got a cache row at all — every reopen then paid the full
cold fold this change set aimed to cut;
- the no-`undefined`-member rule is now pinned on every intermediate fold
state, not only the last one — the gap that let the above ride through a
green suite, since reading the slot cannot tell an absent key from an
`undefined`-valued one;
- the "which tool needs its raw arguments" gate moved beside the file-op
parser (`opBearingTool` / `rawArgsNeeded`), so the fold no longer restates
the tool list.
Verified: `pnpm run lint:fix && pnpm run test && pnpm run build` green (1763
tests, per-file coverage 100%), plus a field-level diff of the fold over
282,783 real events (no divergence beyond the intended deltas), the
incremental ledger cross-checked against a full scan on every event including
both trims, and a frozen-predecessor run proving no in-place write.
Co-authored-by: Nwflower <1679659@qq.com>
Add the V4 baseline (session format 4, cordis 4.0.3): the fold's switched
event families are unchanged, so its entry rides the V3 seam face with a
per-generation settings surface — the `register` face and the retired
`settings.plugin.item` slot are asserted BOTH ways (present through V3,
absent on V4+), and the modal dock probe now pins the sidebar-leading
grid track the measure actually reads instead of one generation's exact
template. New ICON_SEAMS probe: every icon the client renders must exist
in at least one spelling per tag, so a platform rename fails the matrix
instead of the browser. Declare the release in the manifest, fetch its
tag in the release workflow, and document the V4 generation with its
known deltas (deferred: the preferences card's Plugins-page migration,
image-offload accounting).
dsh 0.1.7 renamed every `Icon*Outline16`/`Icon*Outline14` to
`Icon*OutlineRegular`/`Icon*OutlineMedium`, so the plugin's named imports
arrived as `undefined` at runtime and the Context tab crashed with React
error #130. Each icon now resolves through BOTH spellings at module load —
modern first, legacy fallback — with a render-nothing component and a
bounded property-read catch so any future rename degrades to a missing
glyph instead of a crashed tab.
V4 lifted `isError` from the tool-result content wrapper onto the message
and made the event-level `data.error` identity optional, so a failing
result could lose its error flag. The fold now reads the mark from BOTH
spellings — surface nodes and file-op rows keep it on every generation.
dsh 0.1.7 (session format V4) removed `settings.register` — configuration
forms derive from each loader entry's own Config schema there. The inject
callback now probes the method and stays inert on a generation without it
instead of throwing inside the fiber.
The Plugin / GitHub / Settings labels open with siDeepseek, siGithub,
and the harness's IconSettingsOutline14 gear, riding currentColor at the
label's baseline. simple-icons is a dev-only dependency pinned to the
harness ui-primitives' own 16.31.0 and tree-shakes into lib/client.js as
exactly the two mark paths, so no runtime dependency is added and the
marks are identical to the harness's own SiteGlyph artwork.
The context stats card takes three flex parts and the plugin card one,
with the plugin card's floor lowered to 240px so the ratio shows at
common widths (its rows ellipsize and recover values on hover). Below
the 360 + 240px floors the pair wraps onto full-width lines as before.
A dashboard reopen landing inside the balance read's window joined the
in-flight route read but never registered its landing callback — the
read reported only to the open that started it, so the reopened
dashboard showed the stale remembered figure for its whole session
(issue #82, exactly as reported). The landing callbacks now collect in
a set and all fire when the read lands; the previous behavior was even
pinned by a test asserting the joiner stayed silent, which now asserts
the opposite. The store-level join test and a capsule-level
close-reopen repro pin the regression both ways.
The timing card gains a top-right tok/s chip, computed over the same
pairing as the harness's own session-stats fold: per assistant call,
the provider-reported output tokens paired with the first-token →
message window, summed only over calls carrying BOTH — so the plugin's
figure equals the harness pill's by construction (verified against the
running harness). The formatter mirrors the harness's too: whole tokens
from ten up, one decimal below.
The seat rides TimingTotals as additive-optional fields (speedTokens /
speedMs — a usage-gated subset of genMs), so cached rows written before
it simply render no chip, with no stateVersion bump. Unreadable or
absent pairing renders no chip; the tooltip names the pairing.
The range selector gains 近 24 小时 (Last 24 hours) ahead of 7d — the
finest window the dashboard offered was a week, too coarse for the
hour-scale sessions a working day produces. The window filters by the
rows' last activity exactly like the other ranges; the heatmap keeps
its own fixed 8-week view.
The tooltip repeated the pill's own total, and a zero side (granted or
topped-up) rode the bubble as a meaningless line. The pill keeps
carrying the total; the tooltip now lists only the non-zero parts, and
an all-zero account renders the pill bare — no bubble, nothing to break
down. The unused balance.tip.total key drops from both dictionaries.
The capsule's figure could come from two stale layers, and its total could
disagree with its own breakdown. Both are gone.
The host route remembered a successful read for five minutes, so opening the
console first pinned the capsule to that older snapshot — and the balance
moves with every billed request, so the two could not agree. Nothing outlives
a read now beyond the outbound fetch already in flight.
The client paints the remembered figure (memory, then storage) while it
revalidates in the background, so a reopening panel shows a number at once and
settles on the live one without ever trading it for a blank pill: a failed or
absent read notifies nobody and keeps serving what it remembered.
The total is now the granted and topped-up parts summed rather than the
platform's own `total_balance`, which rounds independently of them and can
land a cent away from what the breakdown beside it adds up to (the viewer's
arithmetic is the authority); an entry no longer needs `total_balance` to
prove its shape at all.
The specs cover the storage and transport failures that must keep the figure,
the per-open revalidation, and the disagreeing total.
Replace the native `title` on the balance pill — browser-throttled to about
a second — with the harness Tooltip primitive, the same zero-delay seam the
heatmap cells and the assistant jump action already use. The multi-line
breakdown keeps its shape through the bubble's `pre-line`, and an
`aria-label` carries the same text for assistive tech.
The spec drives the bubble through real hover events instead of reading
`title`, and covers the leave that drops it.
The Context Dashboard's balance pill becomes a link to
platform.deepseek.com/usage — the console page behind the figure it
shows — opened in a new tab with noreferrer, the same external-link
shape the Plugin Info rows use. Hover underlines the figure as the
click affordance; the read, degradation, and tooltip behavior are
unchanged.
The dashboard header gains a capsule with the DeepSeek open-platform
balance (issue #80). The host mounts a /api/dsh-context/balance route
through the same Connection exact-route registry as the detail and
backfill routes, resolves the connection facts per request the way
llm-deepseek serves its own requests (its llm-deepseek settings section
for the credential ref and endpoint override, the credentials service
for the key), and reads /user/balance behind a short TTL cache — the
key never leaves the host process.
Every face is optional and runtime-proved: a deployment without the
DeepSeek provider, without a resolvable credential, without the route
registry, or whose platform read fails (network, non-2xx, malformed
payload, hostile entries dropped whole) answers a typed null, and the
capsule renders nothing — no pill, no spinner, no error state. The
entry follows the display locale (zh → CNY) with the account's first
currency as fallback, and the tooltip carries the granted/topped-up
breakdown.
The persistence seam translates the format edge's
SessionFormatUnsupportedMigrationError into SessionFormatUnsupportedError
(with the "source vN artifact remains unchanged" suffix) before the error
escapes, so the exact-name check never matched on any supported dsh
baseline: the skip summary never fired and every refused legacy session
kept its own WARN (issue #75). Classify by the SessionFormatUnsupported
prefix instead, and cover both faces of the translation in the fixture.
The models.dev deepseek branch publishes the official OFF-PEAK rates
(deepseek-flash: 0.15/0.6/0.003 USD per 1M = api-docs.deepseek.com's
valley tier), but the estimator read the book as the peak list and then
halved the off bucket — every DeepSeek session under-estimated ~2x in
both periods. Flip the semantics: DeepSeek's peak bucket prices at
twice the book rate and the off bucket at book; the tooltip's peak
column doubles for DeepSeek rows with its structure untouched, so
deepseek-flash now reproduces the official peak|off-peak list exactly.
Fixes#78
The jump's landing used to reset the scrollport to the top, where the
stats cards hide the composition card and the pinned turn's trend chart
below the fold. The landing now reveals the Current Composition card
(`data-lc-current`) so the card and the trend chart share the
scrollport.
The reveal walks from the anchor to the nearest scrollable ancestor —
host-agnostic by need: the conversation tab scrolls the shared
`[data-conversation-scroll]` container while the right Sidebar scrolls
dockkit's pane body, a CSS-module class with no stable attribute of its
own. The rect delta lands the anchor flush regardless of the
container's current position, overriding the scroll ledger's restore;
repeat jumps re-reveal. No scrollable ancestor (or a hostile chain)
degrades to a quiet no-op.
The jump's seq resolution ran inside the click handler, but the
assistant-actions seat's `useChat` is a real React hook — calling it
off-render throws the dispatcher guard (React #321), which the seat
guard swallowed, so no focus was ever recorded and the landing view
stayed on its default selection. The button now resolves the reply's
seq at RENDER time (unconditional seat read, click consumes the
captured value), so the pin finally fires in production.
The relay also wakes mounted views now: the sidebar landing keeps the
Context panel mounted across jumps, so repeat clicks re-pin it instead
of recording a focus nothing consumes (the conversation-tab landing had
the same gap whenever the tab was already active).
The assistant-action jump now opens the Context tab on the right Sidebar
over the guarded `ctx.sidebarRight.openTab` optional seam (the column
expands in the same step, keeping the clicked reply in view) instead of
switching the conversation tab. Every harness or placement the seam
cannot serve — lines older than 0.1.5-rc.1, tab-only placement, no
mounted session surface, a hostile face — keeps the conversation-tab
activation as fallback. The tooltip is reworded neutrally ("Context
view") since the landing follows what the harness serves.
The contextActivity/contextTimeline warm-up used to cold-read the whole
session corpus at every startup, whether the dashboard was ever opened or
not — and a legacy log the running harness refuses to migrate re-attempted
and re-warned per session on every boot (one reporter measured 115 warn
lines, ~60 KB per start; #75). Under that sustained read pressure the host
also stalls its own client handshake, leaving the workspace sidebar to
mount without its session rows until a restart.
The pass now runs on demand, once per host process: the client POSTs the
plugin's /api/dsh-context/backfill fetch route (the same authenticated
/api fence as the detail route) the first time the Context Insights panel
opens, and every later hit is a no-op. The route and the cold-path faces
compose in either order; a deployment without the connection service or
the exact-route registry never arms the pass — such a client could not
open the dashboard to read the rows anyway.
Skips are accounted instead of spammed: a migration-refused log
(permanent, source-side) drops to a per-session debug line and joins one
summary info line per pass; unexpected per-session failures keep their
own warn, now proof against hostile error objects whose toString throws.
The inter-session yield widens to 100 ms so an on-demand pass never
squeezes the host's other services.
The green pill on the sidebar-foot entry read as a notification badge
(#74): a running-task count is not something a footer control should
surface as an attention marker. The entry returns to a plain icon +
label; the count's only consumer was the badge, so the tally helper and
the button's useSessions seat plumbing go with it.
Fixes#74.
The cost cell's sub-line now counts the sessions whose spend the price
book could estimate (costSessions), and the cache-hit cell's the
sessions whose usage feeds the rate (usageSessions) — a session with
usage but no book rates feeds the rate while pricing to nothing, and a
session without usage counts toward neither figure.
The cost and cache-hit subtitles now qualify with the session count they
aggregate over (a shared ov.kpi.sessionsSub key) instead of long
explanations that overflowed the narrow KPI cards; the heatmap card's
hint reads 'Filter by date'.
Each column is labeled when a month begins inside it: the month changes
between its Monday and Sunday, or its Monday is the 1st; the label names
the Sunday's month. Months that began before the window stay unlabeled.