The row carried the producer's gross shadow price, while the trend chart
visualizes the net drop. The shadow-price protocol guarantees the
replacement folds in synchronously after the metering event, so rewrite
the event's tokens to removed-minus-replacement at that moment; a
metering event left without a replacement keeps the gross price.
Plan mode includes a guidance section in every model request while
active, so its durable plan/mode event is a real context-composition
change and earns a neutral mode event row (own kind chip + filter).
A request header logged with reason 'resume' carrying a different model
is likewise a real switch made between sessions — record it alongside
the in-session 'change' ones.
A notice-form source already carries the producer's bounded one-line
account; record it as the event's detail and render the row as
title · producer · account, matching the dsh transcript row shape.
The dsh transcript treats a user/message as injected context whenever its
durable source kind is not 'user'; the fold's narrower check (plugin /
skill-invocation / has form) misclassified formless producers — goal
continuation rounds, team relays — as user messages, skewing the token
attribution and hiding their inject events. Keep the form check as a
fallback for foreign sources without a readable kind.
Mirror the dsh transcript's context provenance on inject events:
agent-instructions names its reconciled file paths, a plugin source its
plugin id, and any other producer (skill-catalog, ...) its durable kind,
so the events panel shows '目录更新 · skill-catalog' style rows like the
harness GUI does.
Each image attachment card now carries its own border (border-l1, 8px
radius); hover strengthens the frame to border-l2 and washes the card with
the interactive row hover token — the same affordance as the browser's
clickable category and message rows.
The attachment card's thumbnail click used to open the full image in a new
browser tab. Now the whole card (tile + metadata column) is one click
target, and clicking it opens an in-page preview modal mirroring the chat
history's ImageLightbox recipe: body portal via the module-table react-dom,
blurred mask on the same dsw theme variables, contain-fit image, circular
close control (IconCloseOutline16 from the seeded ui-primitives), Escape/
mask close, and focus restored to the opener. The ui-attachment package
itself is not seeded into the browser module table and cross-plugin value
imports are gated off, so the recipe is ported onto the plugin's lc-*
classes with fallbacks; load failures still retry on click.
The image count moves off the persisted whole-session counter onto a
per-node imgs field (absent when zero), and the view sums it over the
live surface — matching the Tool Calls cell's semantics: images of a
message compacted or pruned out of the context stop counting. The
persisted-state shape changes, so stateVersion bumps to 8 and cached
rows are refolded.
The cell sits left of Images and counts the tool calls whose result is
live in the current context: one tool/result folds to exactly one tool
surface node, so the host derives the figure at view time from the live
surface (no persisted counter, no state-version bump). Calls still in
flight and results compacted or pruned out of the surface are excluded.
* feat: add PR checks workflow for automated testing and validation
* feat(client): hide the composer while the Context tab is active
The view ring mounts one view at a time, so the tab's .lc-root existing
inside the shell scrollport is exactly 'Context tab active' — a single
:has() rule hides the sticky composer seat and hands its height to the
dashboard. display:none keeps the seat mounted, so drafts survive the
round trip.
Interactive takeovers (approval / question / plan-review panels) replace
the bar inside the same seat; keep the seat visible while one is present
so a pending decision is never hidden behind the focus mode.
Closes#11
* ci: build before test in PR checks
Client tests mount the built bundle (viewBed.ts reads lib/client.js), so
pnpm test without a prior build fails with ENOENT in a clean checkout.
ContextViewBody returned the loading frame BEFORE its displayRequests/
markers useMemos: when the projection value landed on the same mounted
instance (a pure timing race), the hook count grew between renders and
React threw #310, which the ErrorBoundary masked as a '数据读取失败'
card. EventList returned its empty branch before useRef/useLayoutEffect:
an empty -> non-empty transition in one instance at best corrupts the
hook list (React logs 'Expected static flag was missing') and at worst
throws the same #310 on builds that don't recover.
Both early returns now sit below every hook; data-less renders fall back
to empty collections so the memos stay unconditional.
Regression tests mount the built client bundle on real React in jsdom and
drive both transitions (loading -> loaded, events empty -> non-empty),
failing on a boundary catch or any hook-order console error.
Closes#12
Adds a display-mode pair (全量/增量, Total/Diff) next to the Step/Turn granularity group in the Context History card. Diff mode plots each request's per-category |change| vs the previous request, with the hover tip reporting the signed net change (Δ +1.2k / Δ −535.5k); Total keeps the cumulative composition and stays the default.
Original PR by @xieshuaix (delta toggle); reworked before merge: the on/off delta switch became a two-way mode control (mode: 'total' | 'diff' end to end), the two toggle groups were wrapped so the title's auto-margin push happens once, trend.hint was shortened/localized, and the fiber-slot/button-count tests were updated.
- drop the redundant node.cat === 'tool' guard (categoryOf always
returns 'tool' in the tool/result case)
- fall back to the rendered <skill_content> wrapper when node.tool is
unresolvable (trimmed window, replay), so a skill load never silently
degrades to an ordinary untagged tool row; the wrapper only appears in
genuine skill results
- regression test for the fallback path, and that a plain tool result
without the wrapper stays untagged
A skill load via the `skill` tool returns the loaded skill's instructions
as a tool result. Keep it a tool result (that is what it is), but make it
findable: tag the node with the skill name (so the Context browser labels the
row `Skill · <name>`) and record an inject event so a `Skill 注入(name)`
entry shows in the Context Events card.
- fold: detect tool/result with node.tool === 'skill', parse the skill name
from the rendered <skill_content name="…"> wrapper, set node.skill, emit a
sub:'skill' inject event. Category stays 'tool'.
- browser: tool row shows the Skill · <name> tag when node.skill is set.
- i18n: add node.skillTag (zh/en).
- test: regression for the skill-tool result path.
The row joins the conversation snapshot and counts image blocks via the
same imageRefOf the expanded body uses; the chip (localized 'attach.image',
×N when several) shows only while collapsed — expanded, the image grid
speaks for itself.
Every expanded element body is now a uniform stack of Section cards
(labeled head + body): prose carries the Raw/MD switch in the head, tool
calls/params/images/raw JSON share the same frame, and one blocks→sections
pipeline serves user/assistant/tool-result/injection bodies. Replaces the
floating-switch, bare-pre, and per-kind card idioms; drops the dead
lc-br-blk/lc-br-pre/lc-br-md/lc-br-rich-inline styles and the unused
RichCard/inHead/dim parameters. Element-row tool tags lose their inverted
chip and share the subtle injection-form tag style. Test bed: function
components now receive props.children like real React.
Fold counts image blocks (user uploads + nested tool-result images)
cumulatively over the complete session log, rides the wire snapshot as
an optional additive field, and the stats board shows it as a new cell
between prunes and cache hit. stateVersion 7 refolds cached rows.
Follow DeepSeek's peak/off-peak rule adjustment: Saturdays and Sundays
(Beijing Time) no longer distinguish peak windows, so the whole weekend
prices at the half-price off-peak rate. Peak windows are now expressed in
Beijing Time (weekdays 09:00-12:00, 14:00-18:00), equivalent to the old
UTC windows on weekdays. List prices themselves are unchanged.
Each fact gets its own labeled row: Raw (pre-normalization dims), Sent
(normalized dims + stored size, dynamic B/kB/MB units), Token (estimate).
Facts that dsh never recorded (e.g. no originalDimensions, no byte size)
simply leave no row behind.
The attachment grid becomes a two-column equal-width grid
(repeat(2, minmax(0, 1fr))) and every image card gains a token-cost
line (≈N tokens, with an explanatory tooltip) estimated through the
official DeepSeek image-size conversion on the stored dimensions.
The composition legend and tooltips now show the exact system/tools/
messages counts of the chat composer ring's click-open panel (the
token-meter's contextBreakdown projection) instead of the fold's own
sums re-anchored to the provider total: parts carry the heuristic count
(raw) for display beside the provider-anchored bar width (value), and
the message bucket subdivides into the four surface categories by the
fold's per-category ratios, always summing exactly to the official
message figure. Absent the projection (older harness), the fold's own
sums serve unchanged.
The token-meter heuristic prices image blocks through its generic JSON
branch (~40 tokens for the durable ref), while DeepSeek's vision model
actually bills 117-384 tokens per image by pixel dimensions. Port the
official docs image-token calculator (patch grid 14px, downsample x3,
384-token cap, minPixels 384^2) into src/shared/imageTokens.ts and price
image blocks with it in the fold; unknown dimensions keep the JSON
fallback. stateVersion bumps to 6 so cached rows refold.
The differential fold check replays a parallel image-delta surface so
image-bearing logs still match the official contextBreakdown exactly,
and adapts to the 0.1.1 wire.view contract.
Tool-result and call-bearing assistant rows now open with an inverted
mono chip naming the tool(s) (label/surface tokens swapped — black on
white in light mode, white on black in dark), multiple calls joining as
a breadcrumb (bash › write). The preview then carries the call's own
summary line: a bash-style `description`, else the target path of
path-taking tools (edit/read/write via file_path/path/filePath), falling
back to the reply text or the generic label. The redundant 'calls: …'
preview is gone — the chip says it once.
Both render the call as a card whose head names the tool (→ / ←) with
the argument count, the body lists arguments as name/value rows — the
type column is gone, the name sits in a narrow wrapping left column,
the value wraps in a mono right column (height-capped). The tool name
steps up to 13px mono. The tool result's raw lc-br-call block is
replaced by the same card.
The retained request log is bounded by maxKeptTurns/maxRequestSteps
(defaults 300/1500), not 160 as the comment claimed — a long session's
chart is thousands of bar nodes, and every bar hover re-rendered all of
them through ContextViewBody.
Extract the bar as a React.memo component (hover flips flags on at most
two bars; req/marker identities stay stable between projection pushes)
and memoize the parent's turn-aggregation and marker attachment so turn
mode stops allocating fresh bar props on every hover render. Bars carry
data-seq for identification now that the element key lives on the
memoized component; the fake-React test beds learn memo().
The callId→name map is looked up exactly once — when the result event
folds in — but entries were kept for the session's whole lifetime. As
persisted state, the map was serialized on every projection checkpoint
and shallow-copied by every fold step's lazy clone.
Rebuild the map without the consumed ids at lookup time (no dynamic
delete, per repo lint); consume-once holds it at pending-call size, so
the copy is trivial.
A tool call inside an assistant message's blocks now forms its own card,
mirroring the tool definition's parameter card: the head names the call
target with the parsed argument count, the body lists each argument as a
name/type/value row (values render raw when strings, JSON otherwise).
Arguments that are not a parseable JSON object fall back to the raw
payload inside the same card.
The spec now arms two calls — one parseable, one not — and asserts the
card chrome, the parsed row, the count badge, and the raw fallback. The
view-bed text helpers learned to read number children (React renders
them as text), which the count badge needs.
No new CSS: the card and rows reuse the tool-schema classes.
Mirror dsh's own lint setup (oxlint 1.76.0 pinned exact, oxlint-tsgolint
7.0.2001 with options.typeAware, correctness category off, hand-picked
rule list; jsPlugins: eslint-plugin-sonarjs + @stylistic/eslint-plugin
with the official style profile: indent 2, semi never, single quotes,
comma-dangle multiline, max-len 140). Scope mirrors official too: src TS
only, generated/bundled output (lib, *.js, *.mjs, *.config.ts) ignored.
Wiring: pnpm run lint / lint:fix scripts, plus a Lint step in the
release workflow between Build and Test.
Fixes applied (runtime behavior unchanged; verified against dsh
0.1.0-rc.7 and 0.1.1-rc.2 wire shapes, see below):
- autofix pass: arrow-parens, comma-dangle, eol-last, indent, quotes
- restrict-plus-operands: 31 string+number concatenations -> template
literals (restrict-template-expressions allows numbers)
- no-unused-vars: drop unused imports/destructured names
- no-unnecessary-type-conversion: drop redundant String() where the
field is already string-typed
- no-unsafe-*: type the fetch-json boundary in latestVersion, narrow
unknown before spread/cast instead of trusting any
- no-unnecessary-condition, three honest categories:
* boundary sanitizers (services.timelineOf/headersOf, RawBlocks,
headers.recordOf) now read through Record<string, unknown> instead
of casting first, so the defensive checks are type-meaningful again
* post-sanitize consumers (contextView/headline/assemble/browser)
drop the fallback-empty-array on collections the sanitizer
guarantees
* array-index-undefined gaps (fold turn attach, headers dedupe) use
.at() so the past-the-end guard is real to the type system
- no-non-null-assertion: stackedBar reserve carries the checked max
- no-floating-promises: void the fire-and-forget version check
- intentional-contract suppressions with rationale: react.ts keeps its
injected require (closure-factory loader contract)
- type-honest hardening kept deliberately: MessageSource.sections
entries stay nullable (foreign plugins author snapshot sources) and
isInjection still rejects a null source — foreign message payloads
must never crash the fold
Cross-version audit (0.1.0-rc.7 vs 0.1.1-rc.2, message parsing focus):
- core/session types.ts: only additive optional interrupted on
assistant/message (unread by us); every event payload we fold
(request/header, request/context, tool/call, tool/result,
user/message, assistant/message, compaction/*) is byte-identical
- llm types.ts / message.ts / content.ts block shapes: identical
- token-meter estimate.ts: identical, and official has NO null-entry
guards on content blocks — our removed guards match official trust,
our retained fallback-empty-string / typeof reads stay a leniency
superset
- deriveEventMessage (core/session surface.ts): identical
- ui-conversation chat-nodes contract: unchanged; our client reads are
minimally-typed unknowns by design
- tool/call callId is a branded string in both versions, so the
narrowed typeof guard drops only an impossible coercion
The host half value-imports zod (Config schema, timeline/headers wire
validators) and the build keeps it external, so a profile install without
zod would fail at plugin load. optional:true would silence the one
warning that catches that. react and dsh-client-ui-primitives stay
optional: only the browser half uses them and the module table provides
them there.
- tsdown.config.ts gains the three CSS virtual loaders mirrored verbatim
from packages/client/tsdown.client.ts: .module.css (lightningcss-hashed
class map + tagged injection), .css?inline (compiled text), and plain
.css (unhashed tagged injection), each registered as a watch dependency
- src/client/styles.ts (223 rules as a string array) becomes
src/client/styles.css — real stylesheet authoring, minified by
lightningcss at build (client bundle 134.8 -> 130.3 kB)
- injection now happens at factory materialization with the official tag
shape (data-plugin + data-plugin-css + dedupe guard), replacing the
hand-rolled ctx.effect; the HMR receiver owns removal by data-plugin,
so the hmr spec pins that lifecycle instead of fiber disposal
- Decision: class names keep the flat lc-* namespace (the anti-collision
discipline, per the research note) instead of per-component hashed
modules — the stylesheet's rules are interconnected across components
(parent:hover child, shared state classes) and 241 test selectors read
literal classes off the built bundle, which a content hash would make
unknowable; the hashed .module.css channel is mirrored and available
for future self-contained components
- globals.d.ts declares the *.css / *.module.css module shapes for the
strict typecheck
- banner/intro/footer three-part closure-factory wrap (verbatim from
packages/client/tsdown.client.ts), intro now owns var module/exports
- deps.neverBundle/alwaysBundle guards: host keeps production deps
external, client keeps only module-table specifiers external
- bundle purity gate: any non-requested @deepseek-ai/* value import is
a build error (INLINE_SAFE / vendored-library / generated-remote
allowances mirrored)
- NODE_ENV + import.meta.env defines for inlined node-idiom deps
- client sourcemap on, matching the official artifact contract
- client smoke test now runs the bundle verbatim through the
__ModuleLoader__ handoff instead of slicing the factory body by
exact output strings (decoupled from bundler layout)
Adapt to DeepSeek Harness 0.1.1's multimodal pipeline (and the vision
capability of DeepSeek-V4-Flash-Vision-Exp): a user message carrying
durable image refs expands into a card layout — prose in the text card,
image attachments as thumbnail cards (name, normalized + original
dimensions, stored size), and unrecognized blocks as raw JSON. Image
blocks in assistant messages and tool results (e.g. read_image output)
now render too, instead of being dropped or dumped as JSON.
Thumbnails load through the harness conversation service's resolveImage
(the same session-authorized loader the chat history uses); when the
service is absent the cards degrade to metadata only. The text card
uses an un-padded RichCard variant so all cards align at one indent.
Sanitize the contextTimeline projection into a render-safe shape at the
services boundary (collections -> arrays, non-object entries dropped,
current -> numeric breakdown, wrong-typed scalars dropped) so a corrupt
or failed host payload degrades to empty sections instead of throwing
during render, keeping every usable piece of data visible.
Wrap the Context tab and /context dialog bodies in a React error
boundary (styled error card + Retry) as the backstop for any residual
render error; harden fmtTime against invalid timestamps, headersOf
against malformed epochs, and the cache-hit/cost math against NaN.
Add client regression tests: corrupt payloads render with usable data,
the modal degrades, and a throwing projection reader lands in the error
card.
dsh 0.1.1-rc.1 changed the session-projection unit contract: registration
now reads stateSchema (persisted fold state) + wire (client view), and a
unit without a wire block is treated as host-only — its value is never
delivered to the browser. The units still emitted only the pre-0.1.1
fields (schema + top-level view), so on 0.1.1-rc.1 the Context tab stayed
on its loading screen forever: contextTimeline/contextHeaders never
arrived through useProjection.
Emit BOTH contracts on each unit: schema/view for dsh <= 0.1.0-rc.8, and
stateSchema/wire for dsh >= 0.1.1-rc.1 (each registry reads its own
fields off the same definition and ignores the extras). Added a strict
timelineStateSchema for the persisted state (the cold-read rung also
parses checkpoint rows with it) and mirror checks in host.test.mjs.
Verified against the real registries of both versions (snapshot
delivery, change feed, checkpoint, viewCheckpoint, restore) and the real
plugin-install flow on the 0.1.0-rc.7 and 0.1.1-rc.1 CLIs.
Stats board gains an 预估费用/Est. Cost cell pricing the complete
session's billed tokens at hardcoded DeepSeek V4 list prices: the host
folds per-request usage totals (peak/off-peak split, cache hit/miss
input, output incl. reasoning) from the session log, flash and pro on
any provider, displayed in the locale's currency. The cell carries a
"?" marker whose hover bubble explains the whole-session estimate and
lists the per-1M-token price table straight from the pricing table, so
the printed rates can never drift from the math.
The contextTimeline fold persisted `undefined`-valued properties (model/provider/lastModel/contextWindow at init, pendingShadowedSeqs set to undefined on consume, and turn/step written as undefined when absent from assistant/message data). The harness projection cache snapshots the whole session checkpoint through the lossless-JSON snapshotter, which rejects any `undefined` property — so every projection-cache write failed and the `title` projection (session list) degraded after restart. stateVersion 3→4 discards stale rows; a regression test now drives the real snapshotJsonValue.
Fixes#5, #7.
Co-authored-by: kiligzzz <kiligzzz@users.noreply.github.com>
Co-authored-by: Bowen Liang <liang.bowen.123@qq.com>
The context browser's detail cards (system prompt, tool description,
user/assistant message and injection bodies) gain a segmented Raw |
Markdown switch at the card's top-right corner — the same pill chrome
as the trend chart's Step/Turn control. Markdown is the default view,
rendered through the harness's shared MarkdownText (GFM, sanitized,
resolved from the platform module table — zero plugin-side markdown
dependency); Raw restores the exact source <pre>. Each card owns its
mode state via the new shared richText kit (explicit render props, no
React context), so two open details switch independently. Reasoning
blocks, tool-call args and tool results stay raw.
Markdown chrome is scoped to the detail cards: base size 13px (a
proportional face matching the raw view's 12px monospace in visual
weight), headings collapse to a weight-only bump, inline code keeps
full size and is pinned under the paragraph line so wrapped chips no
longer stick across lines.
Replace the hand-rolled esbuild script (scripts/build.mjs) with a single
tsup config that already existed but never ran: tsup re-bundles the config
into a temp .mjs, so import.meta.url pointed at the temp dir and the
package.json read failed, silently leaving no entries. Read the manifest
from process.cwd() instead. The host half now keeps @deepseek-ai/dsh-session
external (resolving to the harness's single runtime instance) instead of
inlining it; the client half keeps the __ModuleLoader__.load wrap.
Align deps with the official dsh convention: runtime harness packages
(cordis, dsh-session, client-ui-primitives, react) move to peerDependencies
with dev mirrors (client-only ones optional); zod stays a dependency;
exact rc pins become caret ranges (^0.1.0-rc.8) so rc9/0.2 releases don't
break installs. Drop the esbuild devDependency (tsup brings its own) and
add tsup itself.
The selected (locked) bar was declared before the hovered rule at equal
specificity, so the dashed hover frame kept overriding the solid lock
frame while the pointer was still on the just-clicked bar — the lock only
became visible after the pointer left. Declare .lc-bar-selected last and
use the label-primary (near-black) frame so clicking immediately shows
the black solid frame and clicking again reverts to the dashed one.