Files
dsh-context/docs/redesign.md
T
bowenliang123 38dbd2ad92 refactor(ui): move the context tab onto the harness's React stack
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.
2026-08-16 03:51:00 +08:00

30 KiB
Raw Blame History

dsh-context redesign — use the harness natively

Status: implemented (v0.9.0). The analysis below documents the review and the target architecture; the Data path (host + client), the modern slot registration, and the removal of the custom RPC / polling / client cache are fully landed. Remaining future work is scoped at the end of §5.

Scope: review the plugin (v0.8.0) against the current DeepSeek Harness (master @ 47f943859b, ~0.1.0-rc.5; npm next dist-tag at 0.1.0-rc.6), and define a target architecture that leans on the harness's native services, client framework, and data pipeline instead of re-implementing them.

The rule of thumb the redesign follows (from the harness's own docs — docs/subsystems/session-projection.md):

The framework drives, the domain computes: the registry subscribes to session/event once and folds every committed event through every unit; domains hold no subscriptions and clients never fold domain events — they receive finished values.


1. What the plugin does today (v0.8.0)

Two halves shipped in one package:

  • Host half (src/host/, bundled to lib/index.js): a plain Cordis plugin (inject: ['connection']) that
    1. folds a session's durable event log into a per-request context timeline (fold.ts — incremental per-session FoldState),
    2. prices messages with its own copy of the harness's token-meter heuristic (pricing.ts: ~4 chars/token, block/role overhead),
    3. tracks compactions/prunes via the shadow-price protocol (pendingShadowedSeqs armed by compaction/summary/compaction/prune),
    4. serves the result over a custom RPC channel /dsh-context (connection.rpc.handle) — live sessions fold from sessions.get(id).events, cold sessions from sessionQuery.readSession, cached per session in a module-level Map.
  • Client half (src/client/, bundled to lib/client.js as a window.__ModuleLoader__.load({id, factory}) closure): registers the conversation.view tab ("上下文/Context", order 20) and renders
    1. a stats board (turns/steps/recycled/injects/switches/estimated + actuals),
    2. a current-composition stacked bar + legend (system/tools/user/inject/assistant/tool) anchored to provider-reported occupancy,
    3. a per-request stacked-bar history chart (step/turn granularity, ✂ compaction markers, hover tooltip, pinned detail),
    4. a context-events list and a model-visible message list. Data arrives by polling the RPC every 2s (visibility-aware), with a per-session stale-while-revalidate cache (cache.ts), hand-rolled React (react.ts h() factories), <style data-plugin> injection, and ctx.locale.register/bind dictionaries.

2. The harness today — capabilities a plugin can rely on

All facts below are from the harness monorepo at ~/dev/deepseek-harness (master), cross-checked against the published npm surface where relevant.

2.1 Native session services

Service Package What it gives a plugin
ctx.sessions @deepseek-ai/dsh-session Session (.events, .surface.nodes, .deriveMessages(), .requestHeader(), .requestContext(), .seq, .header), SessionStore.get/list/fork, lifecycle events session/created | session/event | session/disposed | session/flush
ctx.sessionQuery @deepseek-ai/dsh-session-query readSession → {session, events}, listEvents, listSessions, search — for cold/persisted logs
ctx.tokenMeter @deepseek-ai/dsh-token-meter measure(session) → TokenMeasurement (baseline / surface tokens / per-node priced surface), estimateMessage(message) — the official fixed-density estimator the plugin currently copies. Public surface is the service class; the package root exports only the service and types (pure estimate.ts helpers are not re-exported for npm consumers), so price through the injected service.
ctx.sessionProjections @deepseek-ai/dsh-session-projection Register pure ProjectionDefinition units {key, schema(zod), init, apply(state,event), view(state), stateVersion}; eager drive over session/event; per-session watermark cells; snapshot(session) / checkpoint(session) / onChanged
ctx.sessionProjectionCache @deepseek-ai/dsh-session-projection-cache Persisted (sessionId, key, ver, seq, val) rows, throttled write-behind + turn/end/disposal checkpoints, cold-read ladder
ctx.connection.rpc @deepseek-ai/dsh-client-connection handle(channel, handler, {authority}) and intercept('/api', matcher, handler, {authority}) — the shared /api channel convention (the official gateway registers endpoints via intercept('/api', …, {authority:'trusted-host'}); handle('/api', …) is itself rejected)

Config: Cordis plugins read config as the second argument of apply(ctx, config) (function plugins; class plugins via constructor). Services declare static Config: z<Config> = z.object({...}) with @deepseek-ai/schemastery z — defaults on fields, unknown keys rejected. There is no ctx.config property; apply(ctx, config) is the mechanism.

2.2 The session event vocabulary (current)

SessionEventMap (all payloads quoted from packages/core/session/src/types.ts):

  • turn/start {turn}, turn/end {turn, reason} — reason is a merge-extensible sum type: completed | aborted | blocked | error | max-tokens | interrupted.
  • step/start {turn, step}, step/end {turn, step} — the step lifecycle authority (one step/end per entered step, even on failure/cancel/max-tokens).
  • user/message — data is the UserMessage (role user; source.kind distinguishes human prompt vs plugin/skill injection).
  • assistant/chunk {turn, step, chunk} (raw stream, incl. type:'usage' chunks).
  • assistant/message {turn, step, message, usage?}.
  • tool/call {turn, step, callId, name, arguments}.
  • tool/result {turn, step, message, error? {name, code}, meta?}.
  • todo/write {todos} — whole-list snapshot.
  • request/header {header, reason} — reason initial | resume | change; header.config {provider, model, ...}, header.system, header.tools.
  • request/context — data is RequestContext {provider, model, contextWindow?}.
  • session/end-seed — constructor seed boundary.
  • Surface metadata on the three message-producing types only: surfaceOp: 'append' | {op:'replace', start, end} plus sourceEventSeqs provenance.
  • Compaction family: compaction/start {compactionId, ...}, compaction/summary {compactionId, shadowedSeqs, shadowedRange, shadowedTokenCount, summary, provider, model, usage?}, compaction/end {compactionId, ...} — the actual surface replacement is the immediately following user/message with surfaceOp:{op:'replace'} whose source is the compact checkpoint (kind:'plugin', plugin:'compact', compactionId) — the chat already detects it (isCompactCheckpointSource); compaction/prune {shadowedRange, shadowedSeqs, shadowedTokenCount} precedes a content-only tool/result rewrite.

2.3 Projections already shipped by the harness (free data)

Key Provider Value
contextPressure dsh-token-meter pressureTokens, projectedTokens, contextWindow — exactly the "provider-anchored occupancy" the plugin's overview card re-derives
contextBreakdown dsh-token-meter systemTokens, toolsTokens, messageTokens (heuristic composition)
tokenUsage dsh-token-meter cumulative uncachedInputTokens, outputTokens, cacheReadTokens, cacheWriteTokens
sessionStats dsh-session-stats whole-log turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens

These arrive in the browser with zero plugin code: the history tail page carries a projections baseline block (SessionProjectionsBlock{asOfSeq, values}) and live session/projection push frames update it under a higher-seq-wins rule. Client components read them through the framework-standard useProjection(key) seat.

2.4 Client framework

  • ctx.slots.register({name, id, order, locale?, label?, inject?, store?, children?}, Component) — the modern registration: locale: declares a dictionary namespace and the framework synthesizes a typed t prop; inject: (sessionId) => ({...}) is the registrant's per-session business face (incl. a hooks: compartment that becomes use<Name> selector hooks).
  • conversation.view slot contract: {kind:'list'; scope:'session'; owner:{inspect?, onInspectDone?}} — view components receive the session standard kit: sessionId, useSession (ConversationSnapshot), useSessions, useInput/inputActions, and — via the runtime merge — useProjection.
  • useProjection(key) / useSession(selector) are delivered to every session-scope slot component (packages/client/web-react/src/scoped-slots.tsx).
  • Native context classification: contextProvenance(source) / contextForm(source) / KnownContextForm from @deepseek-ai/dsh-client-runtime (sessions/context-provenance.ts) map a logged user/message source to a role (inject/recall) and a human-facing producer label; KnownContextForm = 'instructions' | 'catalog' | 'snapshot' | 'notice' | 'relay' | 'recall' — exactly the vocabulary the plugin's isInjection + form.* i18n keys re-implement.
  • Live conversation data: ctx.sessions.binding(sessionId), snapshot store, loadOlder() history paging, conversationEvents / conversationViews registries, and ConversationNodeDefinition/ConversationViewBuilder — the trajectory view (ui-trajectory) is the reference implementation of a full tab built from the conversation event stream.
  • Official context widgets already in the chat: ContextMeter ring (reads contextPressure + contextBreakdown) and StatsLine (reads tokenUsage + sessionStats).

2.5 Packaging / publishing

  • dsh.client manifest (inject, platform, immediately) is still how a package ships a browser bundle (@deepseek-ai/dsh-client-modules composes the __DSH_BOOT__ entry graph from exports['./client']).
  • dsh.bundle + cordis.patch.yml still load the host row.
  • The @deepseek-ai/dsh-* packages are now published to npm (next dist-tag, e.g. @deepseek-ai/dsh-token-meter@0.1.0-rc.6); the "broken npm type chains" the plugin's src/host/services.ts documents as its reason for local type declarations are largely resolved.

3. Verdict — what is still reasonable, what is not

3.1 Solid (keep)

  • Packaging: dsh.bundle + dsh.client split, esbuild closure bundle, zero runtime deps — still the sanctioned way to ship a two-half plugin.
  • Fold design: per-session incremental state resuming from st.n; bounded history (MAX_REQUEST_STEPS/MAX_KEPT_TURNS, trimmed by whole turns) — a correct and cheap replay discipline.
  • Defensive decoding of event payloads (never trusts shapes), structured labels + client-side localization (i18n dictionaries exist for zh/en).
  • The shadow-price protocol (pendingShadowedSeqs): the plugin reinvented the harness's ShadowPriceClaim correctly for the old event vocabulary.
  • Timeline attribution (turn/step stamps on events, boundary ranges) and the chart/detail UX are genuinely additive — nothing in the harness renders a per-request context history.

3.2 Duplicated / re-implemented harness internals (replace)

Plugin code Harness equivalent Notes
pricing.ts (chars/token heuristic, block/role overhead, tools total, firstText, toolCallNames, isInjection) ctx.tokenMeter.estimateMessage (public method) + the shipped projections The plugin is deliberately a frozen copy of an older estimator; it has already drifted (e.g. source-kind vocabulary, empty-assistant pricing nuances, header delta removal). The six-color overview can reuse contextBreakdown (system/tools) and price the four surface categories through the meter.
fold.ts surface tracking + per-category sums + shadow pricing tokenMeter.measure() + the projection units The harness folds the same surface with validated surfaceOp/sourceEventSeqs — including tool/result rewrite rules the plugin does not know about.
/dsh-context custom RPC channel + endpoint session projections (tail-page baseline + session/projection push frames) + the official /api session endpoints A dedicated channel is no longer the idiomatic read path for derived session data; the projection pipeline is push, checkpointed, and cold-session-safe.
Client 2s polling + cache.ts stale-while-revalidate browser projection store + useProjection; host sessionProjectionCache Polling re-fetches unchanged state; the cache duplicates what the harness already persists/streams.
Hand-rolled locale wiring (ctx.locale.bind) + label thunk locale: registration field → framework t prop Same dictionaries, less plumbing; typed keys at registration time.
Hand-rolled h() React factories shared web-react/slots component machinery + ui-primitives (Tooltip etc.) Works today (the loader supplies React), but every official view is a real functional component; hooks (useProjection/useSession) can't be called from h() factories.

3.3 Obsolete / drifted against the current event model

  • One real bug (found by the audit): fold.ts:157 reads block.callId on the tool-result content block to attribute a node to its tool; the field is toolCallId (ToolResultBlock.toolCallId, also available as message.source.callId). Consequence: per-node tool labels for tool/result surface nodes never render. One-line fix — token math and bookkeeping are unaffected.
  • fold.ts handles user/message | tool/result | assistant/message | request/header | request/context | tool/call | assistant/chunk | compaction/summary | compaction/prune — it misses turn/start, turn/end (+ reasons), step/start, step/end, todo/write, session/end-seed, compaction/start, compaction/end.
  • compaction/summary/prune payloads now carry compactionId/shadowedRange (plus provider/model/usage); the plugin ignores them (works, but a compaction is better detected by its replacement user/message source kind:'plugin', plugin:'compact' — the chat's isCompactCheckpointSource).
  • tool/result results can now be rewritten by later replace ops (assertToolResultRewrite) — the plugin's surface fold would double-count a pruned tool result's content if it ever meets a rewrite.
  • The plugin's model/provider/contextWindow tracking duplicates session.requestHeader()/session.requestContext().
  • sessionQuery.listEvents now returns lightweight SessionEventRecord[] (metadata, not raw events); the plugin only probes .length, so it still works, but any future fold-from-listEvents would break.
  • ctx.connection.rpc.handle('/api', …) is now rejected (reserved for intercept); the plugin's /dsh-context channel is unaffected.

3.4 Missed native data & mechanisms

  • contextPressure/contextBreakdown/tokenUsage/sessionStats projections — free, exact-official figures for the overview card and stats board.
  • useProjection / useSession framework seats — push instead of poll.
  • Native injection classification (contextProvenance/contextForm/ KnownContextForm) — replaces the plugin's own isInjection + form.* vocabulary with the harness's, so injection labels stay in sync with the chat.
  • turn/end reasons (max-tokens, error, aborted) — valuable timeline annotations the plugin has no way to see today.
  • step/start/step/end — precise per-step lifecycle (the plugin infers steps from assistant/message, so cancelled/failed steps are invisible).
  • Compaction brackets (compaction/start/compaction/end, incl. error) and the compact-checkpoint source marker — richer compaction records than the plugin's summary-only view.
  • Plugin configuration — the plugin has no Config at all; Cordis plugins read it as apply(ctx, config) with static Config: z<Config> (schemastery).

4. Target architecture (native-first)

┌─ Host half (install-time plugin row) ───────────────────────────────┐
│  inject: ['sessionProjections', 'tokenMeter']                        │
│                                                                     │
│  ctx.inject(['sessionProjections'], ctx => {                        │
│    ctx.sessionProjections.register(contextTimelineDefinition)       │
│    ctx.sessionProjections.register(contextStatsDefinition)          │
│  })                                                                 │
│                                                                     │
│  • pricing via ctx.tokenMeter.estimateMessage (no local copy)       │
│  • timeline/events/nodes = bounded state of pure projection units   │
│    (plain JSON, deterministic apply, stateVersion-bumped)           │
│  • aggregate stats reuse the shipped units (sessionStats,           │
│    tokenUsage); add only what they lack (recycled, injects,         │
│    switches)                                                        │
│  • optional Config read as apply(ctx, config) (schemastery):        │
│    caps, granularity, colors                                        │
└──────────────────────────────────────────────────────────────────────┘
                          │  session/projection push frames (+tail-page
                          │  baseline; persisted by sessionProjectionCache)
                          ▼
┌─ Client half (dsh.client bundle) ───────────────────────────────────┐
│  ctx.effect(() => ctx.locale.register(NS, { zh, en }))               │
│  const t = ctx.locale.bind(NS)                                       │
│  ctx.slots.inject('conversation.view', () => ctx.slots.register({    │
│    name: 'conversation.view', id: 'context', order: 20,              │
│    locale: NS,                                                       │
│    label: () => t('tab'),                                            │
│    inject: (sessionId) => ({ per-session hooks / verbs }),           │
│  }, ContextView))                                                    │
│                                                                     │
│  ContextView receives the session standard kit:                      │
│  • useProjection('contextPressure' | 'contextBreakdown' |            │
│                  'tokenUsage' | 'sessionStats' | 'contextTimeline'   │
│                  | 'contextStats', selector?, eq?)                   │
│  • useSession(conversation snapshot) for anything event-live         │
│  • sessionId, t (framework-synthesized)                              │
│  No polling, no custom RPC, no client-side cache, no manual locale.  │
└──────────────────────────────────────────────────────────────────────┘

Concrete projection units (one per concern, mirroring the harness's own split):

  1. contextTimeline — bounded per-request records (turn/step/time/seq + the six category totals + provider prompt/output), the surface node list (capped, e.g. 200, preview text capped), and compaction/prune markers. View is exactly today's Snapshot.requests/events/nodes section. State is plain JSON and bounded exactly like fold.ts today (1500 steps / 300 turns).
  2. contextStats — recycled tokens (compaction+prune sums), injection and model-switch counts, estimated totals, and the per-category current sums the six-color overview needs (the shipped contextBreakdown only splits system/tools/messages — the unit prices the four surface categories through ctx.tokenMeter.estimateMessage captured in its registration closure, and the client merges system/tools from contextBreakdown).
  3. Keep contextPressure/contextBreakdown/tokenUsage/sessionStats from the harness — do not re-derive them.

Why projections win over a client-side conversation view for this plugin:

  • The plugin's whole value is full-session analytics; a browser-side ConversationViewBuilder (the trajectory pattern) only folds the loaded window and must loadOlder() page by page — for very long sessions the whole log ends up shipped to the browser anyway.
  • Projections are checkpointed per session on the host (survive resume, no re-fold), pushed as a baseline with the tail page (cold sessions need no extra read), and incremental (apply per committed event).
  • The client stays a pure renderer of finished values — the exact division the harness documents.

The conversation-view route remains a valid alternative for a lighter, window-scoped component (recipe: ctx.conversationEvents.register(definition) per event family → ctx.conversationViews.register({target, create}) → component reads useSession(s => s.views.get('context')) and calls the injected loadOlder()); it is the right choice if a future feature needs per-event interactivity rather than whole-session analytics.


5. Migration plan (minimal-change, keep the UI green)

Status: Phases 0–3 landed (v0.9.0); Phases 4–5 open. What shipped: the toolCallId tool-name fix; the fold moved into one contextTimeline session-projection unit (src/host/timeline.ts + src/host/fold.ts) registered on ctx.sessionProjections — the custom /dsh-context RPC channel, src/host/snapshot.ts, the per-session cache and the client 2s polling are deleted. The client reads the finished value through the framework standard useProjection seat, registered the modern way (conversation.view entry with locale). zod is now the plugin's one runtime dependency (wire schema); the @deepseek-ai/* types come from the published npm packages as devDeps. Retention was tightened to trim by whole turn-runs deterministically (bounded state, no 1200↔1500 oscillation).

Keep docs/ screenshots semantics and tests passing at every step.

Phase 0 — baseline (done): fix the one verified bug now (fold.ts:157: block.callId → block.toolCallId, with message.source.callId as the authoritative fallback) — one line, restores per-node tool labels. Bump the dependency floor (Cordis/@deepseek-ai/* types as devDeps now resolve from npm next); update src/host/services.ts type stubs to the current contracts (new SessionEventMap, surfaceOp, sessionProjections, listEvents record shape). Should otherwise be pure type-level work.

Phase 1 — data path on host (done, merged with Phases 2–3): the fold now is a projection unit (contextTimeline), so the "compatibility RPC reader" was never needed — host and client shipped the new path together.

Phase 2 — client reads projections (done): ContextView reads useProjection('contextTimeline'); registration moved to the modern shape (locale:); cache.ts and the poll effect deleted.

Phase 3 — drop the channel (done): /dsh-context RPC + connection inject removed from both halves; tests drive the projection units directly.

Phase 4 — reclaim native data (open): annotate the timeline with turn/end reasons, use step/start/step/end for true step lifecycle (failed/cancelled/ max-tokens steps), pocket compaction/start/compaction/end (incl. error) and the compact-checkpoint user/message marker, classify injections through contextProvenance/contextForm/KnownContextForm, and optionally surface todo/write.

Phase 5 — polish & config (partial): the client moved onto the real React stack — all view components are now tsx JSX function components, the injection and model-switch event glyphs reuse the shared ui-primitives icons, and the bundle externalizes the framework seed words (react, @deepseek-ai/dsh-client-ui-primitives) so the loader shares the single framework instance. Remaining: a Config read as apply(ctx, config) (schemastery) for caps/colors, swapping the bespoke hover tooltips onto ui-primitives Tooltip where semantics allow (the shared-hover linkage and in-chart positioning are bespoke), and pricing through ctx.tokenMeter.estimateMessage.


6. Risks / open questions

  • Projection payload size: contextTimeline pushes its whole bounded view on each relevant event (like today's poll payloads, but event-driven). Bounds (steps/turns/nodes) should be configurable; per-turn aggregation on the host can shrink the default.
  • stateVersion discipline: once persisted rows exist, every fold-semantics change must bump stateVersion (cache invalidation).
  • Token pricing parity: replacing pricing.ts with the official estimator changes displayed numbers slightly (that is the point — reuse the same heuristic as the chat ring). The pure estimate.ts helpers are not a stable npm surface — price through ctx.tokenMeter.estimateMessage (and read system/tools from contextBreakdown) rather than importing internal subpaths.
  • Interaction with official context ring/StatsLine: the chat already shows occupancy; the tab should stay additive (full timeline + events + messages), not repeat the ring.
  • Version floor: projections and the locale:/inject: slot kit require a recent harness (0.1.0-rc.6 era); state the minimum version in the README.

7. Sources

  • Harness monorepo ~/dev/deepseek-harness @ 47f943859b (master) — all signatures quoted in §2; the companion audit docs/CLIENT_SURFACE.md is the full client-side surface report (compat table with all 14 client assumptions marked compatible; rows 3/4/7/9 recommended for upgrade).
  • The host-side audit (services, event vocabulary, compaction protocol, host compat table incl. the one verified (c) bug) is incorporated into §2–§3.
  • Published npm surface (@deepseek-ai/dsh-*, next dist-tag) cross-checked via the registry.

8. Client framework upgrade — reuse Harness Web's React stack & styles

The web UI is React 18 (react ^18.2.0 in apps/web; react-dom, react/jsx-runtime, react-dom/client all available) on a tiered framework: dsh-client-ui-slots (the slot system) → dsh-client-web-react (renderer + bindSnapshotSelector/SessionProvider) → dsh-client-ui-primitives (shared components styled only via --dsw-* tokens) → dsh-client-ui-conversation (the chat slots this plugin hooks).

What a third-party bundle may import at runtime

The frozen loader module table (packages/client/web/src/platform.ts PLATFORM_MODULES) is the exact allow-list. A dsh.client bundle's require resolves seed → static → memoized records → …, so these seed words are safe to keep external and dereference at runtime:

react, react/jsx-runtime, react-dom, react-dom/client,
@deepseek-ai/cordis,
@deepseek-ai/dsh-client-ui-slots,
@deepseek-ai/dsh-client-web-react,
@deepseek-ai/dsh-client-ui-primitives,
@deepseek-ai/dsh-client-ui-attachment,
@deepseek-ai/dsh-client-schema-form

Any other @deepseek-ai/* value import is a build-time purity error (first-party gate: packages/client/tsdown.client.ts dsh-client-bundle-purity); only type-only imports are erased and safe. Framework singletons (e.g. ui-primitives) are shared — their CSS arrives already compiled in the shell.

What this enables (reuse, don't rebuild)

  • Real functional components + JSX instead of hand-rolled h() factories (jsx-runtime is a seed; the shell shares ONE React instance).
  • Standard kit is already injected (useSession/useProjection/useInput/t) and this plugin already reads useProjection.
  • Shared primitives for free: Tooltip, Pill, Button, Menu, HoverCard, Modal, Toast, 72 Icon* glyphs, MarkdownText/MessageText, JsonTree, TerminalBlock, ReadBlock, DiffBlock, SearchBlock, useCopyFeedback/writeClipboard … (imported at runtime by first-party clients like ui-trajectory/ui-goal — sanctioned).
  • Styles: UI theme owns the --dsw-alias-* semantic tokens (ui-theme/src/styles/design-platform.css, applied document-wide by the theme runtime); the plugin already consumes them. CSS Modules are the documented convention (compiled by lightningcss into <style data-plugin> tags; the loader inventories/removes those). The current hand-injected <style data-plugin="dsh-context"> is the official mechanism, so the bespoke chart CSS stays as-is; everything else (tooltips, chips, buttons, icons) can move onto ui-primitives.

Migration shape (Phase 5 concretized — mostly landed)

Landed: the client is real React — all view components are tsx JSX function components (esbuild classic transform → React.createElement, so the loader shares one React instance), and the bundle externalizes the used platform seed words (react, @deepseek-ai/dsh-client-ui-primitives) so the injected require resolves the shared framework singletons at runtime. The injection (IconPlusOutline16) and model-switch (IconBranchOutline16) event glyphs now use the shared primitives; compaction/prune keep the ✂ marker.

  • Keep the standalone esbuild build (this package is an independent npm plugin; the first-party tsdown.client.ts preset is harness-workspace bound), but:
    1. add the used seed words (@deepseek-ai/dsh-client-ui-primitives, react/jsx-runtime) to external so the loader answers them at runtime;
    2. flip the client source to tsx functional components + JSX (esbuild jsx: automatic, jsxImportSource default) — components already receive props, including the framework hooks;
    3. adopt Tooltip (replaces the two hand-rolled .lc-*-tip tooltips), Pill/Button (granularity toggle, chips), and Icon* where meaningful; keep bespoke chart markup/CSS (no shared equivalent).
  • CSS Modules for the bespoke styles are optional — a small lightningcss esbuild plugin can emit the hashed class map + <style data-plugin-css> tag to match the loader contract; the current data-plugin <style> also remains valid. Token reuse is already in place.
  • Net effect: strictly less custom CSS/HTML, one shared React instance, typed i18n t seat, and the same UI.