File Activity only folded top-level tool-result nodes, so PTC sessions
(run_code as the only tool) showed an empty card and the Minimal preset
went unseen. The fold now covers every surface that executes a file op:
- Nested Code-Mode calls: each settled sub-call in the conversation
node's subCalls tree folds as one op, attributed to the nested tool
and located on its parent run_code result (FileOp.parent; the op's
own seq names the dispatch event, which has no surface row). The op
log shows the program's description as the "why". Narrowing is fully
defensive: malformed blocks drop out, and the walk is bounded by a
depth cap plus a visited-set cycle guard.
- str_replace_editor (Minimal preset): view reads; create,
str_replace, and insert write with their own line deltas.
- Searches resolve to real files when the result's presentation meta
carries the complete matched-file list (FileOp.hits shows the count,
include filters join the detail); a capped or malformed meta falls
back to the searched path/pattern as before, and meta attribution
survives an aged-out call head.
Closes#24
Hovering around the trend chart used to change the detail panel's height: the brief section only rendered rows that resolved (the turn opener vanishes outside retention, usage-only replies carry no reply node), and collapsed entirely - margins included - when nothing did.
- the brief lane ALWAYS renders: a fixed three-row min-height, rows appear inside it, unknown steps leave blanks instead of collapsing
- the In row is one of those constants - an empty step shows a muted "(nothing new)" placeholder in place of its chips, and stays inert (a row with no node has nothing to locate)
The 26px gradient overlays at the chart's left/right edges (shown when the history overflows) read as washed-out white patches; drop the overlays, their edges state machine, and the .lc-chart-fade styles. Scroll anchoring, turn-label centering, and the hover tip are untouched.
The absolutely positioned tooltip contributed scrollable overflow inside
.lc-chart-scroll: a wide reply preview hovering a right-edge bar inflated
scrollWidth and toggled the horizontal scrollbar per hover, jumping the
whole trend card. A new positioned .lc-chart-wrap now anchors the edge
fades and the tip beside the scrollable content; syncTip glues the tip to
its bar visible slice via transform (edge-clamped, centered when wider
than the viewport), which also pins the left fade that used to drift
off-screen with the scrolled content.
A new Agent Network card at the foot of the Context tab: the whole agent family (current agent, ancestors, subagents at any depth) as a live, fully responsive node map — fused context rings per session, family-colored lineage, halo status language, hover inspector, and click-to-jump into any agent's session. 693 tests, 100% coverage.
Order registries as official npm then npmmirror; each source failure
(non-ok response, missing/non-string version, network error) advances to
the next source, and only an all-source failure resolves to null so the
plugin info card keeps its baked-in version. TTL cache and module-level
promise reuse are unchanged.
Two compounding defects made every incoming event flash the whole card:
- Rows were keyed by seq PLUS reverse-index; the list renders newest-
first, so an appended event lands at index 0 and shifted every
existing row's key, forcing React to destroy and rebuild the entire
list on each push. Rows now key on the durable seq alone, so a push
prepends one row and keeps every other row's DOM node.
- The truncation-title layout effect had no deps: every render (hover
included) re-read scrollWidth/clientWidth for every label — one
forced synchronous layout per row — and rebound the resize listener.
It now resyncs only when the events list changes, with the resize
listener bound once (root resolved inside the callback so the
empty-to-non-empty transition keeps it live).
The chart's layout effect ran after every render (no deps), so any
hover/select re-render near the right edge re-ran the stick-to-end
check — and by the time it read el.scrollWidth the value was already
the post-append width, which could misfire the anchor. The effect now
runs only when granularity, focusTurn, or the request list changes,
and the near-end check compares against the PREVIOUS pass's scrollWidth
(kept in a ref), so a reader near the right edge still follows an
appended bar while a mid-chart reader is never yanked back, and a
hover/select render never touches the scroll at all.
React 18 dev replays a failed render through a fake DOM event; jsdom
reports it as an uncaught window error and vitest forwards it to an
uncaughtException, which no console.error spy can catch. A window error
listener that prevents the default keeps the deliberate throw inside the
test, alongside the spy silencing React's own boundary message.
- Remove the MAX_OPS=8 cap: an expanded file renders its whole operation
log instead of folding older ops behind a static "+n earlier ops" line.
- Remove the unresolved counter and footnote: a file-kind call whose
arguments aged out of the retained window names no target and is now
skipped outright, with totals[kind].ops incremented only after path
resolution — every op the card counts, it also rows.
- Drop the files.unresolved/files.moreOps strings and the dead
.lc-fa-more/.lc-fa-note rules.
The file list now defaults to most-operations-first (ties broken by
recency) and a lc-gran toggle on the meta strip switches to
most-recently-touched order; the sort applies to the filtered copy, so
the fold keeps serving entries latest-first.
The bottom-right card no longer lists model-visible messages; it answers
what the agent DID to the user's files up to the picked step. Tool-result
nodes (live + archive) join the conversation snapshot for call arguments
and fold into one row per file with per-purpose counts (read/written/
searched), estimated line deltas from edit/write arguments, multimodal
and directory form glyphs, and failure dots. Header chips double as
purpose/form filters next to a path search box; a row expands into the
file's operation log, and each operation reveals the exact tool result
in the Context browser. The card scrubs live with the trend chart pick
(exclusive next-request bound); ops whose paths aged out of retention
are honestly counted in a footnote.
The chip row lived in CurrentComposition, was fed the fold's per-tool
toolList summary, and the chips focused the Context browser on a tool
via a one-shot toolFocus callback. The browser already exposes the
tool schemas through its headers section, so the chip row was a
duplicate entry point that no longer pulled its weight.
Removes the lc-tools DOM block, the toolFocus/onToolFocusHandled
prop wiring across ContextView/ContextModal/Browser, the
'tools.top'/'tools.more' translations, the .lc-tools* CSS, and the
now-unused toolList summary across the wire + state schemas
(stateVersion 9 -> 10). estimateToolSchema stays because headers.ts
still prices individual tools for the browser's tool-schema cards.
The Context browser joined full content against the chat conversation
window; when a surface node's seq sat outside it, the fallback paged
blindly backwards through session.loadOlder() (up to 20 pages of 50
messages prepended into the shared chat window) and finally dead-ended
on a static note.
sessions.history already supports seq-anchored pages cut on whole
append-origin message boundaries, so ONE call with beforeSeq: seq + 1
returns exactly the page holding the event whenever the durable log
still has it. The browser now:
- fetches on miss with that single anchored read and maps the raw page
into the node shapes it already renders (new client/historyPage.ts:
user messages, assistant blocks, tool results paired with their
in-page call head, compaction checkpoints with their summary);
- merges fetched nodes under the zero-cost window join (join wins);
- reports honest states instead of one dead end: loading, absent from
the session log (compacted or cleared), failed with a retry button;
- drops the sessions.provide contribution and the MAX_AUTO_PAGES
machinery entirely - the chat scrollback is never mutated again.
Hosts without the connection api keep the static preview-plus-hint
degradation.
All 19 root-level specs and the fake-React viewBed helper are
superseded by the source-level suite (606 tests, 100% per-file
coverage). The packaging behaviors they pinned (bundle handoff, CSS
channel, HMR disposal) live on in scripts/bundle-smoke.mjs.
The old suite exercised the built lib/ bundle, so v8 remapped almost
nothing (8% src coverage) and every run needed a build first. The new
suite imports src/ directly under two vitest projects (node for
host/shared, jsdom for client), rendering real React 18 trees against
the real dictionaries and ui-primitives, with faithful in-memory
harness faces (cordis inject/effect, locale chain, slots, sessions
provide) instead of plugin mocks.
- 43 fine-grained, stateless, single-module spec files (606 tests)
forked across workers: ~2.5s wall, no build step
- v8 thresholds at 100/100/100/100 per file over src/**
- host specs drive the real projection definitions (init/apply/view)
plus one full cordis + SessionStore + SessionProjectionRegistry
integration; the framework's snapshotJsonValue pins the
projection-cache precondition on every intermediate fold state
- oxlint's src ruleset now covers tests/** as well
- typecheck covers the tests via tsconfig.tests.json
Brief lines now decompose every node into a fact tag plus preview text,
mirroring the browser element row a click reveals: tool inputs lead with
the tool NAME chip, replies chip their call breadcrumb, injections tag
the form, user messages tag image attachments. Inputs and the reply share
one nodeChip renderer — compact clickable chips for inputs, a single
row-grown chip for the reply.
'History' in dsh names the chat message log itself, so the trend card's
old names (Context History / 历史趋势) read as a message list; the new
names align the two locales and match what the card shows. The overview
card follows the same pairing (当前构成 -> 当前上下文, already
'Current Context' in English).
Each history bar now carries its semantic identity below the detail head,
derived client-side from the served surface nodes (a request's seq IS its
response's node seq):
- User row: the message that opened the turn, recalled on any of its steps
- In row: what newly entered the context (tool-result chips, err dots)
- Response row: the reply text, the call breadcrumb for text-less replies,
and the call suffix recovered via the conversation join for text+calls
replies (the fold keeps calls only on text-less nodes)
Plus: one-line reply previews in the chart tooltip, and a click-to-reveal
bridge that opens the row's node in the Context browser (one-shot nodeFocus
prop, same hand-back pattern as toolFocus; reply nodes resolve on the NEXT
step's surface, per the assemble seq window).
No host/wire changes; steps beyond node/archive retention degrade silently.
Shared call-argument summaries extracted to callSummary.ts (browser reuses).
- contextView: drop the tick-based locale re-render subscription; the
harness slot outlet subscribes the LocaleFace revision and re-renders
every entry on a locale switch (verified back to dsh 0.1.0-rc.7), and
the bound t reads the active locale at call time
- browser: delete the never-read catColor map; countOf now takes the
prebuilt byCat/prevByCat maps instead of rebuilding byCatOf per call
(up to 13 map builds per render)
- categories: export CAT_COLORS built once from CATS; nodes reuses it
instead of rebuilding per render
- trendChart: deltaOf rest-destructures prompt/output out instead of
spread + two deletes
- statsBoard: hoist the pure cell helper to module scope
- services: drop the now-unconsumed LocaleService.subscribe face member
- tests: shift ContextView hook-slot indices after the tick removal,
drop the dead stats.recycleSub fixture key and the dead locale
subscribe stub
job_output always terminates its read with the tool-jobs status line
[status: killed] / [status: failed, detail], and a background job that
dies settles with isError false — so the read showed a green OK for a
dead job. tailStatusOf now treats the two terminal failure statuses as
failures; completed/running/stopping stay notices.
This goes beyond the official chat UI, which renders job_output as a
generic card and shows OK too.
tool-bash-persistent/tool-pwsh-persistent append shell-death markers at
the end of a result's text with isError still false: [shell killed by
signal: X] and [shell exited: code N] ride LAST, after the [exit code: N]
of the command whose failure killed the shell — so the command marker
alone no longer anchors at the end and was missed.
tailStatusOf now parses the shell marker first and re-checks the
preceding text for the command's exit code, so the pill still surfaces
it. A clean shell exit (code 0 or code-less) loses the session but is
treated as a notice, not a command failure.
dsh settles a failing command as a completed call (isError stays false;
the exit status is result data), so err/isError alone never flags a
non-zero exit and the context browser showed a green OK where the chat
row shows red. Mirror the chat's terminalFailed: a result is failed when
its text ends with dsh's [exit code: N] or [killed by signal: X] marker.
The exit-code match is now end-anchored like dsh's parseExitStatus, so
marker text quoted inside the output (e.g. a cat'ed log) is not a
failure, and the FAILED pill always surfaces the parsed exit code
instead of gating it behind err.
Delta mode previously took per-category magnitudes for the bars while
the detail panel below still showed cumulative totals. Now:
- The chart draws diverging stacks: growth piles up from a solid zero
baseline, shrinkage hangs down from it, with the zero line splitting
the bar area proportionally so the px-per-token scale is identical on
both sides. Axis reads +max / 0 / -max.
- The detail panel pairs the active request with the previous displayed
record (same pairing as the chart): a signed net chip replaces the
provider usage pills, category rows show signed figures on the
success/error tokens, and each row's mini bar diverges from a center
zero hairline, echoing the chart.
Aligns with the token-meter vocabulary of recent dsh releases (signed
token delta) and with the existing zh label 增量. UI labels, i18n keys,
settings schema, client logic and tests all switch from diff to delta.
The settings schema is now loose on defaultTrendMode so a stale
persisted value degrades to the default instead of throwing, and the
client ignores unrecognized values the same way.
Drop the heuristic estimate from the step header; show the provider-reported
cache-hit share of the billed prompt (cacheRead / prompt, truncated to two
decimals) instead. The host folds each request's cacheReadTokens into its
record (stateVersion 9); the stats board reuses the same formula via a shared
format helper.
Remove assertion restatements, act narrations and decorative section
markers; keep fixture math, harness-gotcha rationale and slot maps.
Fix stale comments: ctxSlots maps (trendMode shift), a dangling fixture
fragment, and the cardRig 'expanded via header' claim.
The turn strip renders each label inside a nested `lc-turn-label` span
(added with the scroll-centering work), so the label assertions must read
through textOf instead of expecting a bare string. The scroll/layout
handlers also call updateTurnLabels (querySelectorAll over the scroller),
which the fake scroll elements must stub — an empty querySelectorAll keeps
the label-centering a no-op without touching the asserted behaviors.
A failed tool result now leads its collapsed row with the chat's red
run-state dot (6px core, 10px halo) right after the chevron, so the
failure scans without expanding. The failure state comes from the fold's
`err` stamp OR the conversation snapshot's `isError` — the mismatch
matters: dsh only stamps the event envelope's `error` info
conditionally, while the snapshot block always carries `isError`. The
redundant ⚠ suffix on the tool-name chip is dropped (the dot marks
failures), and per-row conversation lookups are hoisted into one `conv`
for the merged condition.
The call half of an expanded tool result drops its argument-count badge
(the rows below already show the arguments); the head's right edge now
carries a run-state pill — green OK by default, red Failed with the
payload's terminal `exit N` code when the fold's `err` stamp or the
snapshot's `isError` marks a failure (exit recovered from the dsh
`[exit code: N]` marker). The payload card heads its line count before
the raw/markdown switch (switch stays at the far right), and its text now
renders through the same switchable prose anatomy as every other detail
section instead of the fixed dim raw view.
Rework the Settings \u2192 Plugins card onto the framework's own patterns:
collapsed by default like the official PluginCard (header button discloses
the controls in place, chevron rotates), and each preference row is the
official settings-row pattern \u2014 a selector pill opening the shared Menu
dropdown from dsh-client-ui-primitives. Picking an option writes
immediately through the settings scope (display preferences, like the
official Language row).
Add a second preference, defaultTrendMode (total/diff): the Context tab's
trend chart opens cumulative or per-request diff per the stored choice;
in-chart toggling stays mount-local and never writes back. The settings
store's choose() generalizes to set(field, value), with one prefsOf()
validator shared by scope sync and the local echo.
Register the dsh-context settings namespace on the Host (dsh-settings seam,
schemastery schema, optional composition) and claim its Plugin configuration
card in the browser (settings.plugin.item, keyed by namespace). The card
edits the default trend granularity the Context tab opens with; in-chart
toggling stays mount-local and never writes back.
Degrades cleanly: no settings provider -> no registration, no card, step
default; remote browser (memory mode) -> unavailable scope, no card.
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.
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.
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.