feat(gui): step1 skeleton — dsc web serves built web UI over booted harness host

Five new modules: apps/dsc (bin: parseArgs + node:http static server +
signal shutdown), packages/host/apiproxy (programmatic harness core
composition, agents:[]), packages/client/web-runtime (React-free browser
runtime), packages/client/web-ui (React mount), apps/web (vite build
entry producing dist consumed by apps/dsc via package exports).

Root wiring: apps/* workspace glob, dsh-* paths for host/client groups,
demo:web script, apps/web/dist gitignore. No protocol/API routes yet —
contract lands in step2 (see missions/tasks/20260719-1902-apiproxy-api-design).

Includes the design + implementation archives (spec v2.1, deepseekchat
baseline and harness boot research, implementation run log).

Acceptance: 12/12 passed incl. real-key llm.stream smoke (51 chunks).

feat(gui): apiproxy — four-quadrant RPC contract + fetch carriers, live end to end

Contract layer (src/api/, 14 files): four named wire message types
(ClientRequest / ServerResponse / ServerRequest / ClientResponse) as a
discriminated union over strict bidirectional rpcId (initiator mints,
responder echoes; channel and message fully decoupled — HTTP is the
client->server pipe, SSE the reverse); narrow RpcRequest<P>/
RpcResponse<T> signature forms; RpcMethodMap with RequestPayload<K>/
ResponseValue<K> derivation; typed RpcError details map; approval/
question responses modeled as ClientResponse via a single /api/respond
endpoint (RpcReceipt carrier ack); zod schemas anchored per Wire<T>
against exactOptionalPropertyTypes.

impl/api-proxy.ts: describe/list/create, both SSE streams (frame queue
pump, subscribed baseline, lifecycle frames, signal cleanup); history
pages on message boundaries (tail-back scan, partial included in the
tail page); prompt dispatches queue->agent.send / steer->agent.steer
with rpcId carried through MessageSource; cancel for attached sessions;
cold-session resume deduped via a per-id promise map; host-level
provider/model defaults injected at create/resume.

fetch/: mechanical UNARY_ROUTES table, two-level parse with
path==method check, SSE frames completed to ServerRequest full form;
client mints -> narrows -> envelopes outbound, verifies rpcId echo
inbound, streams SSE frames, four-quadrant onEnvelope tap (debug panel
choke point). Real-browser fixes: URL base resolves to location.origin
(hardcoded internal base broke real pages), browser-safe export paths.

Design archives: contract design.md v2.0 with decision log,
core-coverage audit, comparative studies, step2 impl run log. Probed
end to end over real HTTP: prompt -> live model stream -> history
returns the finished reply.

feat(gui): RpcLog debug panel — fixture-driven milestone, playwright-verified 10/10

web-runtime: rpcLog + ui slices (zustand), four-quadrant RpcLogEntry
(client-request / server-response / server-request / client-response),
onEnvelope tap -> microtask-batched pump with 500-entry ring buffer,
ConnectionController (private state, backoff reconnect), fixture API
with fake envelopes (?fixture switch), bootWebRuntime; contract types
via temporary local copies (api-types.ts, swapped for real imports when
W3 client lands).

web-ui: components/panels/RpcLog five-piece set (badge with unread
count, floating panel, direction glyphs per quadrant, same-rpcId
pair highlighting in two families, JSON payload expand, follow/pause,
clear), App shell, utils/formatRelative, light-theme CSS variables with
dark placeholders.

dsc bin: mime lookup fixed to use the actually-served file (naked
'/?query' no longer falls through to octet-stream download); shutdown
closes SSE keep-alive connections so SIGTERM actually exits.

Acceptance: scripts/verify-rpclog-panel.mjs (chromium headless) ALL
PASS 10/10 over design.md §D 1-6.

pkg: add web scripts for building

feat(gui): session milestone — list + conversation over Session OOP, styled RpcLog v2.1

web-runtime: Session/SessionManager object layer (resident instances,
mux frame routing, lineage flattening), foldSurface adapter with padding
sentinels for paged windows, chunk accumulator for streaming partials,
batched change notification (useSyncExternalStore contract), connection
sinks + reconnect fix (the 300ms self-abort reconnect storm that made
the session list flap is gone), fixture rewritten as a scripted host
(60-turn history, typewriter replay, resident pending approval, child
session); temporary contract copies deleted in favor of real apiproxy
imports.

web-ui: sessions screen (list with lineage indent + selection as
container-local state), conversation view (turn grouping, reasoning
fold, tool cards, steering, pending interaction cards, upward paging
with scroll anchoring), input bar with queue/steer/stop; RpcLog panel
restyled per docs/web-styling.md (tokenized palette, quadrant badge
glyphs now vertical ↑↓⇟⇞, pair highlighting, floating shadow).

docs/web-styling.md: living style guide (tokens, visual baseline,
coding rules, evolution log).

Acceptance: verify-session.mjs 31/31, verify-session-real.mjs 5/5
(real model streaming), verify-rpclog-panel.mjs 10/10.

feat(gui): hostruntime split + repo-wide package prefix rename

Package split (design: 20260720-0101-hostruntime-split-design):
dsh-host-runtime carries bootHost + createApiProxy + startHost()
(RunningHost {api, handler, defaults, ctx, dispose} — the seam Electron
and any future shell reuses; ctx is the official front-door mount
point); dsh-host-webserver carries the node:http static+API bridge
(fixed: abort now keys on res 'close' + writableEnded — req 'close'
fires on body end since Node 16 and was killing every SSE stream
instantly, the reconnect-storm root cause); apps/dsc is now a thin
assembly with web/-p subcommands. dsc -p runs the full isomorphic
carrier chain in process (second real protocol consumer; probed
end-to-end against the live model).

Naming rule (user decree): packages under host/ and client/ carry the
directory prefix in their npm name — dsh-host-apiproxy,
dsh-client-web-runtime, dsh-client-web-ui renamed repo-wide in one
frozen batch; explicit tsconfig paths entries added where the wildcard
no longer matches.

Acceptance: verify-session 31/31, verify-rpclog-panel 10/10,
verify-session-real 7/7 (incl. new 12s connection-stability sentinels),
tsc green, dsc web + dsc -p smoke both pass.

refactor(gui): AbstractApiClient class hierarchy — OO client with inheritable seams

AbstractApiClient (apiproxy) carries every protocol invariant: rpcId
minting, four-quadrant envelope wrap/unwrap, zod parsing, SSE frame
parsing, the payload-direct IApiClient surface (callers no longer mint
rpcIds — the carrier does), and the instance-level envelope observation
pump (batched via microtask; moved off module-level globals in
rpc-log.ts, which is now a pure subscriber mapping envelopes into store
entries — the debug panel observes the connection, it is not part of
it).

Platform subclasses own two abstract seams (doFetch, onEnvelope) plus
three protocol-level virtuals for transportless overrides:
InProcessApiClient (apiproxy; dsc -p uses new InProcessApiClient(
host.handler)), WebApiClient (web-runtime), FixtureApiClient (fixture
now subclasses instead of wrapping). Naming per decree: AbstractApiClient
/ IApiClient; ApiProxy stays the impl-side narrow-form contract.

headless.ts call sites drop rpcRequest wrappers (payload-direct);
split-design archive updated with the naming-rule ledger.

tsc green; verify-session 31/31, verify-rpclog-panel 10/10,
verify-session-real 7/7 (12s connection sentinel count=4); dsc -p smoke
CALLER-OK.

feat(gui): InputBar final form — bug batch, deepseekchat layout, single primary button, running locks input

Squashes the whole InputBar iteration batch: IME/caret/auto-grow/focus/dedup
bug fixes, layout aligned to the deepseekchat baseline, single primary button
with hover flyout, finalized button semantics with the Codex-style icon
circle, and running-state locking where stop is the only mid-turn action.
The same batch carried the Chinese-to-English code comment sweep
(density pruned), folded in here.

docs(gui): purge work-log references from code comments

76 design-doc references cleared across the GUI packages: section
pointers inlined as self-contained constraint statements, pure pointer
comments dropped, milestone codenames and ruling tags out, and the 14
contract file headers switched to the formal RFC (the only sanctioned
external reference). web-styling.md now cites the styling RFC instead
of the disposable research archive. grep for work-log reference
variants is clean across the GUI packages.

docs(gui): file-header comments self-contained — drop RFC filename references

RFC renames/reorgs must not require a source sweep (the 2026-07-20
two-way merge proved it). 11 headers lose only the '(RFC …)' tail and
stay self-contained; api-proxy.ts keeps its minimal-first note.

fix(gui): session streaming — freeze interrupted partials, sweep stale running calls, send force-scrolls

Aborted turns never emit the finalizing assistant/message, so the
accumulated partial and its running tool cards kept rendering below
later messages — the "new message lands above the stopped reply"
illusion. turn/end side effects now freeze content-bearing partials
into interrupted terminal nodes (fractional seq keeps flow order; the
live freeze and history replay converge through applyEventSideEffects,
so a refresh reconstructs identical frozen nodes) and turn running tool
cards into interrupted terminal cards; only content-free partials are
swept outright. ConversationView gains the send-force-scroll rule (own
words must be visible) alongside the pre-update atBottom follow flag.
Regressions pinned as E2-4a–c (real host) and §E1-11h (fixture).

feat(gui): webserver hardening verify script

feat(gui): dark-mode toggle pinned to the sidebar bottom

Interim home before the Settings page exists (the button re-homes with
zero logic change — mechanics live in utils/theme.ts): html[data-theme]
flip + dsc.theme localStorage, stored choice wins over the OS
prefers-color-scheme default, applied in mount() before first paint so
a dark reload never flashes light. Moon/sun inline SVG icon button at
the sidebar's pinned bottom row. Pure front-end local concern: no RPC,
no Session/store involvement. Dark sweep of list/conversation/input
card/RPC panel found no unreadable pairs — no token changes needed.

docs(gui): GUI RFCs and web styling handbook

Layering+RPC protocol and web client architecture RFCs (post-reorg,
developer-facing polish folded in) plus the styling engineering
handbook. Mission work logs live in the commit above; PRs can be cut
from this commit to include formal docs only.

fix(gui): client object-layer hardening — audit timing/reference/resilience batches (S3-S5,C1-C3,C5-C8)

fix(gui): carrier error channel + webserver backpressure (audit A1-A5,A7-A10,R2,R5)

feat(gui): session persistence surface — cold list, project cwd, legacy no-cwd retirement

refactor: rename dsc CLI to dsh — apps/cli, bin name, package scope

Includes the root tsconfig project-references fix for host/* and
client/web-runtime (originally a separate build fix commit).

test(gui): three-tier suite — protocol/object/browser lanes, tier-a fill to per-file 100%

test(gui): jsdom lane for web-ui + web-runtime coverage gate entry

docs(gui): GUI testing system RFC (zh)

feat(gui): tool-card views — contract slot, host-computed delivery, three-level card fallback

fix(gui): lint clean across GUI packages — wrap long doc comments, drop dead type args, sync-return methods without awaits

docs(gui): doc-sync mechanical fixes — JSDoc on apiproxy/host exports, RFC sketch fences ignore-check, md-wrap paragraphs, drop missions links, web-ui plain-ts entry

chore(gui): module-graph regen + knip clean — drop dead re-exports, internalize createFixtureApi, scan web-ui tsx and verify mjs scripts

build(gui): wire client/host packages into the lib build shape — tsc references + tsdown (web-ui css-external), lib manifests, cordis peer, apiproxy typed subpaths, vite src aliases

test(gui): host-side per-file 100% coverage — apiproxy schema/carrier suites, webserver http-bridge suite, host-runtime composition suite; client/* coverage excluded pending the browser-side testing work item

docs(gui): package READMEs for the five GUI packages — model-experience audit entries, limitations sections

docs(gui): bilingual RFC pairs + client JSDoc completion — translate the three GUI RFCs to English with i18n records and manifest ratchet, Consequences sections both sides, full client/* export JSDoc, regen doc graphs and RFC index

fix(scripts): doc-typecheck built-declarations mode maps /src/* subpath wildcards (apiproxy browser-safe channels)

docs(gui): apply dsh rename across pr-gates docs — READMEs, layering RFC en, web-ui entry comment, i18n re-record

fix(gui): post-rebase lint reconciliation — wrap main-tree long doc comments, read-through narrowing guards, abortError Error normalization, handleUnary generic justification

fix(gui): post-rebase doc/test reconciliation — align host specs with evolved carrier contracts (sentinel rpcId, stream/error surfacing, url-path transport messages, defaults.cwd), Agent Note titles and relocated links, KV Cache effect sections, JSDoc on evolved exports

fix(gui): second-rebase reconciliation to 509db0cb3 — restore api panel exports the baseline suites consume, knip workspace entries for jsdom lane and apps/web smokes, hoist result narrowing, align testing.md to the narrowed web-ui exclusion

fix(test): vitest-scoped tsconfig maps bare imports for tsx specs — with GUI manifests now pointing at lib, an unmapped importer loaded a second copy of the web-runtime singletons

fix(gui): typecheck + lint clean over the tool-card batch — brand callIds and object-form turn/end reason in the view spec, narrow fixture arg stringification, wrap long v8-ignore comments

docs(gui): export JSDoc for tool-card surfaces + testing-note pairing header

docs: rfc for web testing

feat: add tools to host-runtime

fix(gui): dispatch agent/error via agentEvents in host-runtime spec — mounted invariants plugin rejects raw ctx.emit without the scope carrier

fix(gui): restore GUI knip workspaces + scripts/mjs entries and regenerate lockfile after master rebase

fix(gui): post-rebase gate repairs — drop context-node envelope (master unwrapped injected content envelopes), regen event matrix, condense testing.md web-ui exclusion within budget

fix(session): browser-safe deep-equal in surface — node:util import broke the vite bundle

ci(gates): frontend vite build joins pre-push — node: imports in the client closure pass tsc but break the browser bundle

test(tui): drop the checkout-dependent process.cwd() harness default — a long worktree path pushes the footer token counters past the 88-column fake terminal

test(gui): jsdom behavior E2E — conversation main path over fixture runtime, reconnect banner lifecycle

test(gui): jsdom RPC panel behavior — ledger rows, expand, pairing, pause/clear, follow-pause, payload truncation

test(gui): jsdom tier-2 — InputBar guards, reasoning fold, JSON blocks, message variants, theme, create-then-select; act-harden banner case

test(gui): jsdom tier-3 — ConversationView states/paging/force-bottom, ToolCallCard arms, PendingCard, list rows

test(gui): jsdom tails — view-card variants, LogRow directions, registry hygiene, badge overflow, hook ops, mount glue

test(gui): jsdom tails round 2 — call-ref blocks, resume follow, view precedence, failed create, empty-diff arm

test(gui): jsdom final arms — anchor compensation, follow-off, interval ticks, view halves, node-over-running precedence

test(gui): web-ui joins the per-file 100% coverage gate

Annotation-only src changes plus the config swap. The web-ui exclusion is
replaced by a single index.tsx entry (stale byte-identical duplicate of
mount.tsx, nothing imports it; same entry-glue treatment as bin.ts) and the
coverage include gains .tsx.

v8-ignore sites (each with its reason inline):
- ConversationView 3x ref-null guards; InputBar disabled-click guard
- ToolCallCard both-null arms + windowless-custom argsRaw arm
- LogRow css-module key fallbacks (start/stop block); RpcLogBody 3x ref-null guards
- web-runtime drift from the tool-card batch: fixture presenter catch/str
  typo-guards, dense-array guards (fold-adapter reset, session rebuild,
  fixture backscan), live view-present arm (fixture replays are text-only;
  view vocabulary is covered by the history samples)

test(gui): close the PR #443 host-side coverage gaps — apiproxy client abort arms, api-proxy cold/view paths, webserver drain

- apiproxy fetch/client.ts: 3 new cases (pre-aborted signal short-circuits
  before transport + string reason mapping, non-Error/string reason falls to
  the default AbortError message, signal-less doFetch passthrough)
- runtime/api-proxy.ts: one v8-ignore (summarizeCold cwd arm — list()
  filters cwd-less legacy metas) + api-proxy-cold.spec.ts (cold list merge:
  mtime source, locate-undefined and vanished-log fallbacks, lineage;
  no-persistence/no-factory resume → internal) + 2 view cases (history views
  with meta passthrough and orphan/bad-args/presenterless soft-falls,
  session/disposed open-call cleanup on the mux stream)
- webserver/index.ts: /api/big fixture drives both drain-wait legs (full
  8MiB readback after drain, mid-chunk disconnect wakes via 'close')

feat: app shell

fix: rebase conflicts

fix: coverage

fix(gui): lint clean after rebase — wrap long v8-ignore comments, unconditional v1 detail-block claim

chore(gui): remove browser/probe verify scripts from scripts/

The six GUI acceptance/probe scripts (carrier-errors, rpclog-panel,
session, session-real, webserver-backpressure, webserver-hardening)
leave the repo's scripts/ tree; the three code comments that pointed at
them now describe the coverage lane without naming a script path.

fix(webserver): guard the request callback — one malformed request must not kill the process

The async handle() had no top-level catch, so any throw inside it (a bad
%-escape reaching decodeURIComponent, a client dropping mid-body, a
response stream erroring) became an unhandled rejection and took the whole
process down (audit R1 must-fix). The guard answers 400 when headers are
not out yet, destroys the socket when they are, and reports the failure to
onError (the package never prints). Spec covers all three legs: %-escape
barrage → 400 + server stays alive, non-Error throw wrapped for onError,
mid-stream explosion → socket teardown.

feat: client AGENTS.md

fix: client/AGENTS.md

fix: rebase

feat(gui): T0 cut 1 — 12 client package skeletons with contract stubs, dshClient declarations, tsdown client preset, theme token sheets

feat(gui): T0 cut 2 — pure git mv migration per v3 §11 (connection six, runtime sessions/kernel, ui-conversation chat, ui-primitives markdown family, web shell + e2e)

feat(gui): T0 cuts 3+4 — import rewiring to new package names, .legacy demotion of owner-rewrite files, legacy web-runtime/web-ui/apps-web retired to attic

feat(gui): connection 对账刀——index.ts 精确导出清单替换 export *,intents.legacy 溶解删除

feat(client/ui-slots): SlotCore real implementation — kind semantics, sync version + microtask-batched notify, onMutate bridge

feat(gui): web shell vite alias — retarget to new client packages, shell static surface only

feat(gui): host 侧刀属地半——HostWebPluginRegistry(entries 扫描+internal/plugin 去抖重扫+dshClient 校验+exports./client 解析)、GET /plugins/<id>/client.js 分发端点、GET / 与 SPA fallback 注入 __DSH_BOOT__(webPlugins 可选注入,不传行为不变)

feat(web-react): add use-sync-external-store dep + local shim typings

feat(web-react): bindSnapshotSelector via uSES with-selector shim

feat(gui): ui-layout concession-chain solver — pure computeColumns with contract geometry

feat(gui): ui-layout LayoutService — four persisted stores, clamped actions, list-driven prune

feat(gui): ui-layout AppFrame styles — grid columns, collapse-safe borders, edge drag handles

test(gui): 存量 spec 平移——connection 三件+runtime 六件自 attic 捞回改包名路径全绿;api-helpers 按归属拆分(wire 半留 connection、classifier 半随 conversation.ts 入 runtime);boot-intents/preinit/rpc-log 随 intents/rpc-log 退役不迁(记 v3 §3.2 溶解项)

feat(client/ui-primitives): StateDot/Button/Pill/Input/Menu atoms, ConnectionBanner de-legacied to pure props, JsonBlock CSS on --dsw tokens

feat(web-react): createSnapshotStore engine (rafFlush batch, persist opt-in, dev freeze) + spec

feat(gui): ui-layout AppFrame — grid tracks, pointer-capture drag handles with rAF throttle, frame ResizeObserver

feat(web-react): useInvoke (external pending store, stable invoke, concurrency count) + spec

test(web-react): bind spec — equality bail, custom eq, zero resubscribe, StrictMode, method sources

feat(gui): ui-layout index rewiring — real exports, client apply provides ctx.layout and defines three slots

feat(web-react): SessionProvider (renderBody deps) + RootBindingProvider + binding contexts + spec

feat(gui): web shell AppRoot boot-page styles — self-contained with neutral token fallbacks

feat(gui): web shell AppRoot — boot gate over loader status, fail-loud plugin failure list

fix(gui): AppRoot gates on explicit settled signal — status-derived readiness races the incrementally filled table

feat(client/ui-theme): ThemeService real implementation — registry with built-in light/dark, apply toggles body[data-ds-dark-theme], third-party token overrides as body inline vars

feat(web-react): scopedSlots outlet (kind matrix, inject WeakMap caches, per-entry error boundary) + spec

feat(gui): web shell module-table seed — pure-library entities for the loader require surface

feat(client/i18n): I18nService real implementation — ns×locale registry, stable bind(ns) reference, zh fallback chain, zh/en skeleton dictionaries

feat(gui): web shell assembly closure — layout exports via module table, SessionProvider + scopedSlots + RootBindingProvider

feat: client/ui-conversation

feat: code

codedoc

build(gui): root bundle green — web shell excluded from the lib workspace (vite app), ui-primitives lib externalizes css side-effect imports (web-ui precedent)

gates(gui): verify-cordis-config follows aggregate tsconfig references (root is a shell over host/client programs); module graph regenerated for the twelve client packages

chore(gui): retire legacy migration sources — every owner rewrite landed (t0-checklist §7 ledger honored); orphan css of retired components removed

gates(gui): knip green groundwork — e2e/tsx entries for the new packages, loader-runtime deps ignored where loading is by specifier string, fake plugin ids un-bare-named, dead test export dropped

chore(client): manifest shape batch A — ui-slots/web-react/ui-primitives invariant companions, files whitelist, cordis+invariants peer/dev, tsconfig refs

chore(client): manifest shape batch B — connection/runtime/ui-conversation/ui-trajectory files whitelist, cordis peer+dev, explicit invariant lib entries (clientBundle signature)

chore(client): manifest shape batch C — i18n/ui-layout/ui-sidebar/ui-theme invariant companions, files whitelist, invariants peer/dev, tsconfig refs

chore(client): manifest shape batch D — web shell gains node-half lib entry + invariant companion + uniform files whitelist

chore(client): drop verified-unused deps — dsh-tools from runtime/ui-conversation (types ride /presentation), ui-primitives+clsx from ui-layout

gates(gui): doc-gate fixes — theme JSDoc prose, three client type-link exemptions, agent-note paths follow the migration, config catalog regenerated

gates(gui): type-equiv manifest follows the types.ts extraction, approval JSDoc keeps its link form, persistence catalog regenerated

docs(gui): per-constant JSDoc on the contract geometry exports (export-jsdoc gate)

test(gates): loader-composition budget covers cold tsx resolution after the program split (was flaking at the default 5s)

docs(gui): README substantiation batch 1 — ui-slots/ui-primitives/web-react/connection: Model Experience short form, real deferred-work ledgers, description accuracy pass

fix(client): theme/i18n dual-entry split — service classes + cordis merges move to src/client (host catalog scanner no longer misclassifies client services), node halves keep types + empty apply; catalogs regenerated

docs(gui): README substantiation batch 2 — runtime/ui-layout/ui-sidebar/ui-conversation: Model Experience short form, package-owned deferred-work ledgers (unload stub, watch approximation, /client value-import rule, global details state, two-state dots, stats duration gap, single-bundle caches)

docs(gui): README substantiation batch 3 — ui-trajectory/ui-theme/i18n/web: Model Experience short form, deferred-work ledgers (placeholder charter, no theme toggle owner, empty locale dictionaries, one-shot rendering); both README gates green

test(scripts): purity spec adopts clientBundle two-arg signature (explicit libEntry, no default)

gates(gui): knip green — declaration-merge dep ignored, fake plugin id assembled at runtime, invariants dep de-duplicated to peer+dev, stale apps/web section dropped

feat(gui): 门禁波次 host 三包 invariant 形状——apiproxy explained-empty 伴生(wire 契约层零事件面)、webserver 真关系伴生(manifest 行必解析出 clientPath,防 __DSH_BOOT__ 广告 404 bundle;apps/cli 发布 webPlugins 键供审计)、runtime 补 files 白名单;三包 exports/files/peer+dev/tsconfig refs 齐 fw-react 形状;constraints+invariants 双 gate 零违规

build(client): ui-layout/ui-sidebar tsdown configs adopt the explicit two-arg clientBundle signature (orphaned follow-up of the manifest shape batch)

refactor(gui): shell boot becomes a library face — bootWebShell(el) exported for the apps/web entry; main.ts retired

refactor(gui): exports 纪律刀1——ui-theme/i18n node index 收敛为只空 apply(Translate/LocaleDict/ThemeTokens 类型下沉 src/client/),ui-conversation 的 I18nService import 改 /client 子路径

build(typecheck): converge to root host aggregate + tsconfig.client.json — delete tsconfig.host.json, verify-cordis-config seeds both aggregates

feat(gui): apps/web restored as the vite application — thin main over bootWebShell; dsh-client-web becomes a plain lib (index exports shell surface, vite files and e2e moved out)

chore(gates): knip.json rewritten on the master base — same semantics, minimal diff (formatting churn dropped)

docs(gui): 时效清扫②——testing.md 删 web-ui 覆盖豁免残句;web-styling.md 加 token 换代头注(--dsw-* 现行、工程约束条款仍有效并注明收编处)

docs(gui): 时效清扫③——四对 GUI Agent Note 加路径更新头注(web-runtime/web-ui/dsh-frontend→现行 12 包结构;设计结论存续声明;双语对同步)

docs(gui): 时效清扫③b——四对 note 头注的 i18n 配对哈希重录

build(typecheck): minimal-diff tsconfig shape — drop root files entry (purity spec + preset move to client program), compress comments, drop redundant util/home root ref

feat(gui): apps/web restoration follow-through — dsh-frontend package name, cli dist resolve, root build:web filter, tsdown exemption dropped, vitest web lane + knip + client aggregate retargeted, e2e paths rebased

refactor(gui): exports 纪律刀2——connection wire 六件 git mv 进 src/client/(wire 即该 dshClient 插件的 client 半),node index=只空 apply,/client 半边整面导出(v3 §3.2 清单原样),包内 tests 改 src/client 直取

refactor(gui): exports 纪律刀3——runtime 实现整体下沉 src/client/(sessions/slots/loader;契约类型与 cordis merge 随迁 client/index),node index=只空 apply;./loader exports 指 client/loader;全消费面(web 壳/ui-sidebar/ui-trajectory/tests)bare→/client 机械跟改;vitest.e2e 换 tsconfig.vitest paths(root tsconfig 排除 client 会把 /client import 掉到 exports 的浏览器 dist bundle)

refactor(gui): exports 纪律刀3 补遗——ui-layout 三处 bare runtime import 改 /client(刀3 消费面机械跟改漏提交件;跨属地机械一行×3 报备 ui-shell)

test(gui): drop the getSessionManager singleton case — the init/get pair is a dead legacy-boot surface with zero live consumers (SessionsService constructs and holds the manager under the plugin architecture); source removal tracked with rt-core

refactor(gui): 删 manager.ts 尾部 initSessionManager/getSessionManager 单例对——旧 boot 直连遗物,插件化下 SessionsService 构造持有 manager,全仓零活消费者(convo-b 测试清扫对表,其测试用例已先行退役 7e2c51898);头注释同步去单例措辞

code

refactor
This commit is contained in:
imccyu
2026-07-19 21:17:57 +08:00
parent 4267076407
commit a6a3807a07
379 changed files with 33246 additions and 411 deletions

View File

@@ -0,0 +1,276 @@
// @vitest-environment jsdom
// apply inject factories exercised end to end: the conversation slot surface
// (ancestry feed, views triple, active view, composer choreography incl.
// optimistic clear + failure restore, renderView chrome assembly, watch-driven
// open), the details surface, and the empty-state surface (cwd derivation
// cache). Complements chat-apply.spec.tsx, which stops at registration.
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createElement } from 'react'
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
afterEach(cleanup)
const ROOT = 'root-1' as SessionId
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
get(target, prop, receiver) {
recorded.push(prop)
return Reflect.get(target, prop, receiver)
},
})
void scopeOf(spy as Context)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
})()
function snapshotBase(): ConversationSnapshot {
return {
sessionId: ROOT, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
})
const snap = snapshotBase()
const sessionFake = {
getSnapshot: () => snap,
subscribe: () => () => {},
useSelector: undefined as unknown,
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn(() => Promise.resolve()),
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
}
sessionFake.useSelector = bindSnapshotSelector(sessionFake as never)
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
let scoped = scopes.get(id)
if (scoped === undefined) {
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context
scopes.set(id, scoped)
}
return scoped
}
const sessionsFake = {
list: listStore,
manager: { get: () => sessionFake },
ancestry: (id: SessionId) => {
const s = listStore.getSnapshot().byId[id]
return s === undefined ? [] : [s]
},
scope: (id: SessionId) => mint(id),
create: vi.fn(() => Promise.resolve(ROOT)),
}
ctx.provide('sessions', sessionsFake)
const layoutFake = {
current: createSnapshotStore<{ sessionId?: SessionId; viewFor: Record<string, string> }>({ viewFor: {} }),
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
}
ctx.provide('layout', layoutFake)
ctx.provide('i18n', { bind: () => (key: string) => key })
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const binding: SessionBinding = {
sessionId: ROOT as never,
session: { useSelector: sessionFake.useSelector } as never,
ctx: mint(ROOT) as never,
}
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => {
const entries = slots.entries(key)
return entries[0]! as { options: { inject: (b: unknown) => Record<string, unknown> } }
}
return { ctx, slots, binding, sessionFake, sessionsFake, layoutFake, mint, entryOf }
}
describe('conversation slot inject surface', () => {
it('assembles the full surface and pulls history through the watch signal', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
useAncestry: () => readonly { id: SessionId }[]
views: { list(): readonly ViewEntry[]; version(): number; subscribe(fn: () => void): () => void }
useActiveView: () => string | undefined
composer: { useDraft: () => string; setDraft(t: string): void; send(m: string): void; stop(): void }
actions: { openView(v: string): void; open(id: SessionId): void }
renderView: (entry: ViewEntry) => unknown
}
expect(b.sessionFake.open).toHaveBeenCalledTimes(1)
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
injected.actions.openView('chat')
expect(b.layoutFake.openView).toHaveBeenCalledWith(ROOT, 'chat')
injected.actions.open(ROOT)
expect(b.layoutFake.open).toHaveBeenCalledWith(ROOT)
})
it('composer send trims, optimistically clears, and restores on failure; stop swallows rejection', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
composer: { setDraft(t: string): void; send(m: 'queue'): void; stop(): void }
}
const scoped = b.mint(ROOT).get('conversation') as ConversationService
// Whitespace-only draft: no send.
scoped.drafts.set(' ')
injected.composer.send('queue')
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
// Success: cleared and stays cleared.
injected.composer.setDraft('hello')
injected.composer.send('queue')
expect(scoped.drafts.getSnapshot()).toBe('')
await Promise.resolve()
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
// Failure: restored (draft still empty when the rejection lands).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.composer.setDraft('retry me')
injected.composer.send('queue')
await vi.waitFor(() => {
expect(scoped.drafts.getSnapshot()).toBe('retry me')
})
// Failure with new typing: no clobber.
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.composer.send('queue')
injected.composer.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(scoped.drafts.getSnapshot()).toBe('typed during flight')
// Stop failure is swallowed (promptError owns the surface).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
injected.composer.stop()
await new Promise(r => setTimeout(r, 0))
})
it('view actions forward: openDetails writes selection through the scoped service, loadOlder hits the session', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
// viewProps rides renderView's closure; reach the actions through a rendered entry.
renderView: (entry: ViewEntry) => React.ReactNode
}
let captured: { openDetails(t: { turnSeq: number; callId?: string }): void; loadOlder(): void } | undefined
const Probe = (p: { actions: typeof captured }) => {
captured = p.actions
return null
}
render(createElement('div', null, injected.renderView({
id: 'chat', label: 'Chat', component: Probe,
} as unknown as ViewEntry)))
captured!.openDetails({ turnSeq: 2, callId: 'c1' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
const scoped = b.mint(ROOT).get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
expect(scoped.selection.getSnapshot()).toEqual({ turnSeq: 2, callId: 'c1' })
captured!.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
})
it('renderView mounts chrome header/footer around the view body', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
renderView: (entry: ViewEntry) => React.ReactNode
}
const entry = {
id: 'chat', label: 'Chat',
component: () => createElement('div', { 'data-testid': 'body' }),
chrome: {
header: () => createElement('div', { 'data-testid': 'hd' }),
footer: () => createElement('div', { 'data-testid': 'ft' }),
},
} as unknown as ViewEntry
const view = render(createElement('div', null, injected.renderView(entry)))
expect(view.getByTestId('hd')).toBeTruthy()
expect(view.getByTestId('body')).toBeTruthy()
expect(view.getByTestId('ft')).toBeTruthy()
// Ancestry and draft/active-view hooks execute inside a component tree.
const HookProbe = () => {
const injected2 = b.entryOf('conversation').options.inject(b.binding) as {
useAncestry: () => readonly { title: string }[]
useActiveView: () => string | undefined
composer: { useDraft: () => string }
}
const chain = injected2.useAncestry()
const active = injected2.useActiveView()
const draft = injected2.composer.useDraft()
return createElement('i', { 'data-testid': 'probe' }, `${chain.length}|${active ?? 'none'}|${draft}`)
}
const probe = render(createElement(HookProbe))
// Draft content carries over from the composer case (per-scope store is
// process-resident); the probe asserts hook wiring, not draft value.
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
// A list-store update while mounted drives the ancestry selector's
// shallowEqual arm (same derived chain → short-circuit, no re-render churn).
await act(async () => {
b.sessionsFake.list.update((d: { byId: Record<string, { updatedAt: number }> }) => {
d.byId[ROOT]!.updatedAt = 2
})
})
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
// The views read-face triple forwards to the service registry.
const injected3 = b.entryOf('conversation').options.inject(b.binding) as {
views: { list(): readonly { id: string }[]; subscribe(fn: () => void): () => void; version(): number }
}
expect(injected3.views.list().map(v => v.id)).toEqual(['chat'])
const beforeVersion = injected3.views.version()
const listener = vi.fn()
const unsub = injected3.views.subscribe(listener)
const conversation = b.ctx.get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
const offExtra = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
expect(listener).toHaveBeenCalled()
expect(injected3.views.version()).toBeGreaterThan(beforeVersion)
offExtra()
unsub()
})
})
describe('details and empty inject surfaces', () => {
it('details surface wires selection and closeDetails', async () => {
const b = await bench()
const injected = b.entryOf('details').options.inject(b.binding) as {
useSelection: unknown
actions: { closeDetails(): void }
}
expect(injected.useSelection).toBeTypeOf('function')
injected.actions.closeDetails()
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
})
it('empty surface derives the deduped cwd set with a per-state cache and starts sessions', async () => {
const b = await bench()
const injected = b.entryOf('conversation.empty').options.inject({ ctx: b.ctx }) as {
useCwds: (sel: (s: readonly string[]) => unknown, eq?: unknown) => unknown
actions: { startSession(opts: { text: string; mode: 'queue' }): Promise<void> }
}
const CwdsProbe = () => {
const cwds = injected.useCwds(s => s) as readonly string[]
return createElement('i', { 'data-testid': 'cwds' }, cwds.join(','))
}
const view = render(createElement(CwdsProbe))
expect(view.getByTestId('cwds').textContent).toBe('/proj')
await injected.actions.startSession({ text: 'go', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,105 @@
// @vitest-environment jsdom
// apply wiring: services provided, chat view + footer chrome registered, the
// three slot registrations land against ui-layout-shaped specs, and the bash
// samples resolve differentially (sub-session default scope). Full-chain
// rendering belongs to the shell e2e; this spec stops at the assembly surface.
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls ui-layout's SlotMap declaration merge into this spec's
// program so the slot keys below typecheck in the client lane.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', parentId: ROOT, running: false, updatedAt: 2 },
},
})
const sessionsFake = {
list: listStore,
manager: { get: vi.fn() },
ancestry: () => [],
scope: () => undefined,
create: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('layout', {
current: createSnapshotStore<{ viewFor: Record<string, string> }>({ viewFor: {} }),
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
})
ctx.provide('i18n', { bind: () => (key: string) => key })
// Specs owned by ui-layout in production; declared here so registrations land.
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
const fiber = ctx.plugin({ inject: [...inject], apply })
return { ctx, fiber, slots }
}
describe('apply wiring', () => {
it('provides conversation and toolviews services', async () => {
const b = await bench()
await b.fiber.await()
expect(b.ctx.get('conversation')).toBeDefined()
expect(b.ctx.get('toolviews')).toBeInstanceOf(ToolViewRegistry)
})
it('registers the chat view with the stats footer', async () => {
const b = await bench()
await b.fiber.await()
const conversation = b.ctx.get('conversation') as ConversationService
const views = conversation.views()
expect(views.map((v) => v.id)).toEqual(['chat'])
expect(views[0]?.chrome?.footer).toBeDefined()
})
it('occupies conversation/details/conversation.empty with inject factories', async () => {
const b = await bench()
await b.fiber.await()
for (const key of ['conversation', 'details', 'conversation.empty'] as const) {
const entries = b.slots.entries(key)
expect(entries, key).toHaveLength(1)
expect((entries[0]!.options as { inject?: unknown }).inject, key).toBeTypeOf('function')
}
})
it('bash samples resolve differentially: scoped row for sub-sessions, global for roots', async () => {
const b = await bench()
await b.fiber.await()
const toolviews = b.ctx.get('toolviews') as ToolViewRegistry
const forChild = toolviews.resolve('bash', CHILD)
const forRoot = toolviews.resolve('bash', ROOT)
expect(forChild).toBeDefined()
expect(forRoot).toBeDefined()
expect(forChild!.component).not.toBe(forRoot!.component)
})
it('plugin fiber disposal collects every registration (unload cascade)', async () => {
const b = await bench()
await b.fiber.await()
await b.fiber.dispose()
expect(b.slots.entries('conversation')).toHaveLength(0)
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
expect(b.ctx.get('conversation')).toBeUndefined()
expect(b.ctx.get('toolviews')).toBeUndefined()
})
})

View File

@@ -0,0 +1,156 @@
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// ToolViewOutlet inject cache + crash fallback + retry, StatsLine no-cache
// join, PendingCard reason strip, AssistantMarkdown single-line reasoning,
// ChatView view-body fallbacks, and apply's action lambdas.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { act } from '@testing-library/react'
import type { SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector, createSessionProvider } from '@deepseek-ai/dsh-client-web-react'
import type { SessionBinding as ReactSessionBinding, UseSession } from '@deepseek-ai/dsh-client-web-react'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps, Translate } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, callId,
call: { name: 'bash', argsRaw: '{"command":"x"}' },
content: [], isError: false, callView: null, resultView: null,
})
const viewProps = (): ToolViewProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
})
describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
const view = render(
<MessageItem node={{
kind: 'steering', seq: 2, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
})
it('context and unknown nodes render their JSON rows', () => {
const ctxView = render(
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null, meta: { k: 1 } } as never} />,
)
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
const unknownView = render(
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
)
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
})
})
describe('small branch tails', () => {
it('PendingCard approval reason renders when present', () => {
const view = render(
<PendingCard item={{ kind: 'approval', rpcId: 'r1' as RpcId, approvalId: 'a1', toolName: 'rm', reason: 'careful' }} />,
)
expect(view.getByText('careful')).toBeTruthy()
})
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
const view = render(
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
)
expect(view.getByText('one-liner')).toBeTruthy()
})
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
// cacheHitPct is null only when input+cacheRead are both zero (pure
// output accounting) — any input makes it a real 0%.
const snap = {
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
)
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
})
})
describe('ToolViewOutlet dispatch', () => {
it('caches the inject factory per (registration x binding) and merges its props', () => {
const registry = new ToolViewRegistry()
const inject = vi.fn(() => ({ extra: 'injected' }))
registry.register('bash',
(p: ToolViewProps & { extra: string }) => <div data-testid="row">{p.extra}</div>,
{ inject })
// InjectedRow reads the session binding from context: mount through the
// real SessionProvider so the (factory x binding) cache path executes.
const binding: ReactSessionBinding = {
sessionId: SID,
session: { useSelector: (() => { throw new Error('unused') }) as never },
ctx: {},
}
const Provider = createSessionProvider({
useCurrent: () => SID,
resolveBinding: () => binding,
renderBody: () => (
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />
),
})
const view = render(<Provider />)
expect(view.getByTestId('row').textContent).toBe('injected')
expect(inject).toHaveBeenCalledTimes(1)
// Remount against the SAME binding: cache hit, factory not re-run.
view.unmount()
const second = render(<Provider />)
expect(second.getByTestId('row').textContent).toBe('injected')
expect(inject).toHaveBeenCalledTimes(1)
})
it('a crashing custom row falls back to GenericToolCard and retries on re-registration', () => {
const registry = new ToolViewRegistry()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
// React dev builds re-dispatch boundary-caught errors as window 'error'
// events (invokeGuardedCallback); swallow them so vitest sees the caught path.
const swallow = (e: Event): void => { e.preventDefault() }
window.addEventListener('error', swallow)
try {
const Bomb = () => { throw new Error('row bomb') }
registry.register('bash', Bomb as never)
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
// Crash caught: generic row rendered instead.
expect(view.getByText('Bash')).toBeTruthy()
// A new registration bumps the version; the boundary retries the custom row.
act(() => { registry.register('bash', (() => <div data-testid="fixed" />) as never) })
expect(view.getByTestId('fixed')).toBeTruthy()
} finally {
window.removeEventListener('error', swallow)
consoleError.mockRestore()
}
})
it('registry miss renders the generic row directly', () => {
const registry = new ToolViewRegistry()
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(view.getByText('Bash')).toBeTruthy()
})
})

View File

@@ -0,0 +1,178 @@
// @vitest-environment jsdom
// StatsLine (chrome.footer first consumer): totals derivation + the RFC hard
// acceptance — zero renders during streaming. Bash sample: differential
// registry hits per session, teardown reverts to the generic row.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ChromeProps, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { StatsLine, deriveStats } from '../src/client/chat/StatsLine.tsx'
import { BashRow, ScopedBashRow, registerBashSamples } from '../src/client/toolviews/bash-sample.tsx'
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
import { childSessionScope } from '../src/client/chat/register.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({
kind: 'assistant', seq, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
...(usage === undefined ? {} : { usage }),
})
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
}
}
function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const subs = new Set<() => void>()
return {
set(next: Partial<ConversationSnapshot>) {
snap = { ...snap, ...next }
for (const fn of [...subs]) fn()
},
source: {
getSnapshot: () => snap,
subscribe: (fn: () => void) => {
subs.add(fn)
return () => subs.delete(fn)
},
},
}
}
describe('deriveStats', () => {
it('folds turns/steps/tokens and cache hit percentage', () => {
const stats = deriveStats([
assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }),
assistant(2, 1, { inputTokens: 100, outputTokens: 50 }),
assistant(3, 2),
])
expect(stats.turns).toBe(2)
expect(stats.steps).toBe(3)
expect(stats.tokens).toBe(1200)
expect(stats.cacheHitPct).toBe(82)
})
it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, callId: 'c', call: null, content: [],
isError: false, callView: null, resultView: null,
}
const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1)
expect(stats.cacheHitPct).toBeNull()
})
})
describe('StatsLine', () => {
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): ChromeProps {
return { sessionId: SID, useSession: bindSnapshotSelector(source) as unknown as UseSession }
}
it('renders the joined stats row and hides with zero steps', () => {
const { source } = makeSource({
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
})
const view = render(<StatsLine {...props(source)} />)
expect(view.getByText('cache hit 90% · 105 tokens · 1 turns · 1 steps')).toBeTruthy()
const empty = makeSource()
const emptyView = render(<StatsLine {...props(empty.source)} />)
expect(emptyView.container.textContent).toBe('')
})
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
let renders = 0
function Counting(p: ChromeProps) {
renders += 1
return <StatsLine {...p} />
}
render(<Counting {...props(source)} />)
const before = renders
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }))
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }))
act(() => set({ running: true }))
expect(renders).toBe(before)
})
})
describe('bash toolview samples', () => {
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, callId,
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
content: [], isError: false, callView: null, resultView: null,
})
const viewProps = (openDetails = vi.fn()): ToolViewProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails },
t: (k) => k,
})
function outlet(registry: ToolViewRegistry, sessionId: SessionId, p = viewProps()) {
return render(
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName="bash" viewProps={p} />,
)
}
it('differential rendering: scoped row for the matching session, global elsewhere', () => {
const registry = new ToolViewRegistry()
registerBashSamples(registry, (id) => id === ('swarm' as SessionId))
const scoped = outlet(registry, 'swarm' as SessionId)
expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
const plain = outlet(registry, SID)
expect(plain.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('teardown removes both registrations and falls back to the generic row', () => {
const registry = new ToolViewRegistry()
const off = registerBashSamples(registry, () => true)
const view = outlet(registry, SID)
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
act(() => off())
expect(view.container.querySelector('[data-sample]')).toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
})
it('childSessionScope matches sub-sessions via the injected list read face', () => {
const child = 'child' as SessionId
const root = 'root' as SessionId
const scope = childSessionScope({
getSnapshot: () => ({
ids: [root, child],
byId: {
[root]: { id: root, title: 'r', running: false, updatedAt: 0 },
[child]: { id: child, title: 'c', parentId: root, running: false, updatedAt: 0 },
},
}),
})
expect(scope(child)).toBe(true)
expect(scope(root)).toBe(false)
expect(scope('gone' as SessionId)).toBe(false)
})
it('sample rows summarize the command and hand clicks to openDetails', () => {
const open = vi.fn()
const p = viewProps(open)
const global = render(<BashRow {...p} />)
expect(global.getByText('Build')).toBeTruthy()
fireEvent.click(global.getByText('Build'))
expect(open).toHaveBeenCalledTimes(1)
const scoped = render(<ScopedBashRow {...p} />)
expect(scoped.getByText('scoped')).toBeTruthy()
})
})

View File

@@ -0,0 +1,147 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
turn: 1, step: 1, callView: null, ...over,
})
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
content: [], isError: false, callView: null, resultView: null, ...over,
})
describe('tool-call-model', () => {
it('classifies known tools and falls back to others', () => {
expect(classifyTool('bash')).toBe('bash')
expect(classifyTool('read')).toBe('read')
expect(classifyTool('web_fetch')).toBe('read')
expect(classifyTool('web_search')).toBe('search')
expect(classifyTool('grep')).toBe('search')
expect(classifyTool('todo_write')).toBe('others')
})
it('derives state across running/ok/error/interrupted', () => {
expect(toolRowModel('bash', running()).state).toBe('running')
expect(toolRowModel('bash', result()).state).toBe('ok')
expect(toolRowModel('bash', result({ isError: true })).state).toBe('error')
expect(toolRowModel('bash', result({ isError: true, error: { name: 'E', code: 'interrupted' } })).state).toBe('stopped')
})
it('derives the bash summary from description over command', () => {
const m = toolRowModel('bash', running())
expect(m.title).toBe('Bash')
expect(m.summary).toBe('List files')
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd')
})
it('keeps summaries single-line and falls back for opaque args', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
// Others rows prefix the real tool name into the summary slot (figma-flows
// ruling: static "Tool call" title, name rides the mutable summary).
expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
expect(toolRowModel('x', running({ argsRaw: 'not json' })).summary).toBe('x · not json')
expect(toolRowModel('x', running({ argsRaw: '' })).summary).toBe('x · c1')
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
})
it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')
expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull()
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
})
})
describe('ToolRow', () => {
const rowProps = {
variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const,
}
it('renders leading icon, title and summary while collapsed', () => {
const view = render(<ToolRow {...rowProps} />)
expect(view.queryByTestId('tool-icon')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('List files')).toBeTruthy()
expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
})
it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
const view = render(<ToolRow {...rowProps} />)
fireEvent.click(view.container.querySelector('button')!)
expect(view.queryByTestId('tool-icon')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
expect(view.queryByText('List files')).toBeNull()
expect(view.getByText(/"a": 1/)).toBeTruthy()
fireEvent.click(view.container.querySelector('button')!)
expect(view.queryByTestId('tool-icon')).not.toBeNull()
expect(view.getByText('List files')).toBeTruthy()
})
it('running and error states replace the icon with a StateDot', () => {
const runningView = render(<ToolRow {...rowProps} state="running" />)
expect(runningView.queryByTestId('tool-icon')).toBeNull()
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
const errorView = render(<ToolRow {...rowProps} state="error" />)
expect(errorView.queryByTestId('tool-icon')).toBeNull()
})
it('non-expandable rows render a passive leading slot', () => {
const view = render(<ToolRow {...rowProps} body={null} />)
expect(view.container.querySelector('button')).toBeNull()
expect(view.queryByTestId('tool-icon')).not.toBeNull()
})
it('row click hands off to onOpenDetails; the expand toggle does not', () => {
const open = vi.fn()
const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
fireEvent.click(view.getByText('List files'))
expect(open).toHaveBeenCalledTimes(1)
fireEvent.click(view.container.querySelector('button')!)
expect(open).toHaveBeenCalledTimes(1)
})
})
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolViewProps => ({
callId: 'c1', toolName, block,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: (k) => k,
})
it('renders the classified variant row from the frozen slice', () => {
const view = render(<GenericToolCard {...props('bash', result())} />)
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('List files')).toBeTruthy()
expect(view.container.querySelector('[data-variant="bash"]')).not.toBeNull()
})
it('unknown tools land on the others variant titled Tool call', () => {
const view = render(
<GenericToolCard {...props('todo_write', running({ name: 'todo_write', argsRaw: '{"note":"x"}' }))} />,
)
expect(view.getByText('Tool call')).toBeTruthy()
expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
it('row click reaches actions.openDetails', () => {
const p = props('bash', result())
const view = render(<GenericToolCard {...p} />)
fireEvent.click(view.getByText('List files'))
expect(p.actions.openDetails).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,305 @@
// @vitest-environment jsdom
// ChatView behavior: flow derivation, streaming isolation (Profiler counts),
// toolview dispatch and selection handoff — driven through a scripted
// ObservableSnapshot fake, no wire.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatView } from '../src/client/chat/ChatView.tsx'
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
}
}
/** Scripted snapshot source: set() swaps the top-level object like the real Session. */
function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const subs = new Set<() => void>()
return {
set(next: Partial<ConversationSnapshot>) {
snap = { ...snap, ...next }
for (const fn of [...subs]) fn()
},
source: {
getSnapshot: () => snap,
subscribe: (fn: () => void) => {
subs.add(fn)
return () => subs.delete(fn)
},
},
}
}
const user = (seq: number, text: string): UserMessageNode => ({
kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null,
})
const assistant = (seq: number, text: string): AssistantMessageNode => ({
kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
content: [], isError: false, callView: null, resultView: null,
})
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
})
function makeHarness(init?: Partial<ConversationSnapshot>) {
const { set, source } = makeSource(init)
const registry = new ToolViewRegistry()
const ChatView = createChatView({ toolviews: registry, t: (k) => k })
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const loadOlder = vi.fn()
const selection = makeSelection()
const props: ConvViewProps = {
sessionId: SID,
useSession: bindSnapshotSelector(source) as unknown as UseSession,
useSelection: bindSnapshotSelector(selection.source),
actions: { openDetails, loadOlder },
slots: { renderSlot: () => null } as never,
}
return { set, registry, ChatView, props, openDetails, loadOlder, setSelection: selection.set }
}
function makeSelection() {
let sel: SelectionTarget | null = null
const subs = new Set<() => void>()
return {
set(next: SelectionTarget | null) {
sel = next
for (const fn of [...subs]) fn()
},
source: {
getSnapshot: () => sel,
subscribe: (fn: () => void) => {
subs.add(fn)
return () => subs.delete(fn)
},
},
}
}
describe('chat-flow derivation', () => {
it('groups consecutive tool results and keeps stable keys', () => {
const nodes: ConversationNode[] = [
user(1, 'hi'), assistant(2, 'let me look'), toolResult(3, 'a'), toolResult(4, 'b'),
assistant(5, 'found'), toolResult(6, 'c'),
]
const items = deriveChatFlow(nodes)
expect(items.map((i) => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
const group = items[2]!
expect(group.kind === 'tool-group' && group.results.map((r) => r.callId)).toEqual(['a', 'b'])
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
})
})
describe('ChatView', () => {
it('a windowless tool result (call head truncated) renders with an empty tool name', () => {
const h = makeHarness({
nodes: [{ ...toolResult(3, 'w1'), call: null }],
})
const view = render(<h.ChatView {...h.props} />)
// classifyTool('') → others; the summary slot falls back to the callId.
expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
expect(view.getByText('w1')).toBeTruthy()
})
it('prepend keeps the viewport anchored when the reader is NOT at the bottom (no lastKey force)', () => {
// Covers the prepend early-return arm where lastItem exists but the key
// path is not taken (anchor branch wins before the appended-user check).
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
scroller.scrollTop = 50
fireEvent.scroll(scroller)
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
act(() => h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }))
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
})
it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
const h = makeHarness({
nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('do the thing')).toBeTruthy()
expect(view.getByText('running tools')).toBeTruthy()
expect(view.getAllByText('Bash')).toHaveLength(2)
expect(view.getByText('run a')).toBeTruthy()
})
it('streaming partial frames re-render only the tail (Profiler count)', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
})
let renders = 0
const counting = (
<Profiler id="chat" onRender={() => { renders += 1 }}>
<h.ChatView {...h.props} />
</Profiler>
)
const view = render(counting)
const before = renders
const beforeHtml = view.container.querySelector('[class*="toolGroup"]')!.innerHTML
act(() => {
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
})
act(() => {
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
})
expect(view.getByText('streaming… more')).toBeTruthy()
// Each chunk commits exactly one profiler pass (the tail), never a full-tree storm.
expect(renders - before).toBe(2)
expect(view.container.querySelector('[class*="toolGroup"]')!.innerHTML).toBe(beforeHtml)
})
it('streaming leaves neighbor tool rows and history items at zero re-renders', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old'), toolResult(3, 'a')],
})
let rowRenders = 0
h.registry.register('bash', () => {
rowRenders += 1
return <div data-testid="counting-row" />
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('counting-row')).toBeTruthy()
const afterMount = rowRenders
act(() => {
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } })
})
act(() => {
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } })
})
expect(rowRenders).toBe(afterMount)
})
it('tool row expands to the args body via the leading slot toggle', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
it('clicking a tool row opens details with callId and toolName; selection paints the outline', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('run a'))
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
expect(view.container.querySelector('[data-selected]')).toBeNull()
act(() => h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }))
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
})
it('running calls render as a live tool group with the running state', () => {
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
const view = render(<h.ChatView {...h.props} />)
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(view.getByText('cmd-r1')).toBeTruthy()
})
it('a scoped toolview registration takes over rendering for its session only', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
h.registry.register('bash', () => <div data-testid="custom-bash" />, { scope: (id) => id === SID })
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('custom-bash')).toBeTruthy()
})
it('unregistering a toolview falls back to the generic row live', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const off = h.registry.register('bash', () => <div data-testid="custom-bash" />)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('custom-bash')).toBeTruthy()
act(() => off())
expect(view.queryByTestId('custom-bash')).toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
})
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
// jsdom has no layout: fake the metrics the anchor math reads.
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
// Arm the paging anchor, then deliver an older page (head seq decreases).
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }))
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
// A new trailing user bubble (own words) force-scrolls to the bottom.
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }))
expect(scroller.scrollTop).toBe(1600)
})
it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
scroller.scrollTop = 100 // far from bottom
fireEvent.scroll(scroller)
const backButton = view.getByLabelText('回到底部')
expect(backButton).toBeTruthy()
// Streaming growth must NOT drag a scrolled-away reader down.
act(() => h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }))
expect(scroller.scrollTop).toBe(100)
fireEvent.click(backButton)
expect(scroller.scrollTop).toBe(1000)
// At the bottom again: follow re-arms and the button unmounts.
expect(view.queryByLabelText('回到底部')).toBeNull()
})
it('paging button loads older and shows its busy label', () => {
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('加载更早'))
expect(h.loadOlder).toHaveBeenCalledTimes(1)
act(() => h.set({ loadingOlder: true }))
expect(view.getByText('加载中…')).toBeTruthy()
})
it('shows open error and loading states', () => {
const h = makeHarness({
openState: 'error',
openError: { code: 'internal', message: 'boom' } as never,
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText(/历史加载失败:boom/)).toBeTruthy()
const loading = makeHarness({ openState: 'loading' })
const lv = render(<loading.ChatView {...loading.props} />)
expect(lv.getByText('载入历史…')).toBeTruthy()
})
it('pending interactions render placeholder cards', () => {
const h = makeHarness({
pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }],
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText(/等待审批/)).toBeTruthy()
})
})

View File

@@ -0,0 +1,127 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// PendingCard question arm, bash sample error pill, registry disposer
// idempotence re-entry, register.ts explicit bashSampleScope override, the
// node-half empty apply, and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { registerChat } from '../src/client/chat/register.ts'
afterEach(cleanup)
describe('tails', () => {
it('node-half apply is an intentional no-op', () => {
expect(nodeApply()).toBeUndefined()
})
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
const view = render(
<ToolRow variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
)
expect(view.queryByTestId('icon')).toBeNull()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
it('PendingCard renders the question arm with its count', () => {
const view = render(
<PendingCard item={{ kind: 'question', rpcId: 'r1' as RpcId, questions: [{}, {}] }} />,
)
expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
})
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
const view = render(
<AssistantMarkdown
blocks={[
{ kind: 'reasoning', text: 'thinking hard\nsecond line' },
{ kind: 'tool-call', callId: 'c', name: 'bash', argsRaw: '{}' },
{ kind: 'other', block: { type: 'mystery' } },
]}
streaming
/>,
)
expect(view.getByText('Think')).toBeTruthy()
expect(view.getByText('thinking hard')).toBeTruthy()
expect(view.getByText(/未知内容块/)).toBeTruthy()
const stopped = render(
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
)
expect(stopped.getByText('已停止')).toBeTruthy()
})
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
const settled: ToolResultNode = {
kind: 'tool-result', seq: 2, callId: 'c5',
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
content: [], isError: false, callView: null, resultView: null,
}
const props: ToolViewProps = {
callId: 'c5', toolName: 'todo_write', block: settled,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
}
const view = render(<GenericToolCard {...props} />)
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
expect(view.container.querySelector('[data-variant="others"] svg')).not.toBeNull()
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow shows the failed pill on error results', () => {
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
content: [], isError: true, callView: null, resultView: null,
}
const props: ToolViewProps = {
callId: 'c1', toolName: 'bash', block: errorResult,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
}
const view = render(<BashRow {...props} />)
expect(view.getByText('failed')).toBeTruthy()
})
it('registry disposer re-entry is a no-op after the entry was already removed', () => {
const registry = new ToolViewRegistry()
const off = registry.register('bash', (() => null) as never)
const v1 = registry.getVersion()
off()
const v2 = registry.getVersion()
off()
expect(registry.getVersion()).toBe(v2)
expect(v2).toBeGreaterThan(v1)
})
it('registerChat registers the chat view with the stats footer and disposes cleanly', () => {
const disposer = vi.fn()
const calls: unknown[] = []
const conversation = {
registerView: (entry: unknown) => {
calls.push(entry)
return disposer
},
} as unknown as ConversationService
const toolviews = new ToolViewRegistry()
const off = registerChat({ conversation, toolviews, t: ((k: string) => k) as Translate })
const entry = calls[0] as { id: string; chrome?: { footer?: unknown } }
expect(entry.id).toBe('chat')
// footer is a memo exotic component (object, not plain function).
expect(entry.chrome?.footer).toBeDefined()
off()
expect(disposer).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,140 @@
// @vitest-environment jsdom
// Final branch tails for the coverage gate, post slot-phase-2: apply's need()
// throw + cwd cache hit/empty-cwd skip, AssistantMarkdown non-final reasoning,
// StatsLine usage-less node, ChatView tool-group selected passthrough +
// running-empty guard, DetailsPanel titleless selection, registry disposer
// after a foreign removal emptied the list.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { Context } from 'cordis'
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
afterEach(cleanup)
const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
describe('apply need() and cwd cache', () => {
it('apply fails loud when a required service is absent', () => {
// Call apply directly (no fiber machinery): need('sessions') on a bare
// context throws synchronously — the loud-failure branch without the
// fiber runner's internal rejection surface. Mount semantics (inject
// gating) are covered by the full bench in apply-inject.spec.
void inject
const ctx = new Context()
expect(() => { (apply as (c: Context) => void)(ctx) }).toThrow(/sessions service unavailable/)
})
it('cwd derivation caches per list state and skips empty cwd values', async () => {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const listStore = createSnapshotStore<SessionListState>({
ids: [SID, 'x2' as SessionId, 'x3' as SessionId],
byId: {
[SID]: { id: SID, title: 'a', cwd: '/proj', running: false, updatedAt: 1 },
['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', cwd: '', running: false, updatedAt: 1 },
['x3' as SessionId]: { id: 'x3' as SessionId, title: 'c', running: false, updatedAt: 1 },
},
})
ctx.provide('sessions', { list: listStore, manager: { get: vi.fn() }, ancestry: () => [], scope: () => undefined, create: vi.fn() })
ctx.provide('layout', { current: createSnapshotStore<{ viewFor: Record<string, string> }>({ viewFor: {} }), open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (k: string) => k })
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = slots.entries('conversation.empty')[0]! as unknown as {
options: { inject: (b: unknown) => { useCwds: (sel: (s: readonly string[]) => readonly string[]) => readonly string[] } }
}
const injected = entry.options.inject({ ctx })
const Probe = () => {
const cwds = injected.useCwds(s => s)
const again = injected.useCwds(s => s)
// Cache hit: same state object yields the same derived array reference.
return <i data-testid="cwds">{`${cwds.join(',')}|${String(cwds === again)}`}</i>
}
const view = render(<Probe />)
expect(view.getByTestId('cwds').textContent).toBe('/proj|true')
})
})
describe('render branch tails', () => {
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
const view = render(
<AssistantMarkdown
blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]}
streaming
/>,
)
// reasoning at index 0 with a later block: running is false → ok state.
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('StatsLine skips usage-less nodes and defaults each absent counter to zero', () => {
const snap = {
nodes: [
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
{ kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
// outputTokens absent: the tokens sum's ?? 0 arm for output.
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
)
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
})
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
const view = render(
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
)
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => {
const SEL: SelectionTarget = { turnSeq: 1, callId: 'ghost' }
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshotBase(), subscribe: () => () => {} }) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
/>,
)
expect(view.getByText('详情')).toBeTruthy()
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('registry disposer tolerates the list already emptied by a sibling disposer', () => {
const registry = new ToolViewRegistry()
const offA = registry.register('bash', () => null)
const offB = registry.register('bash', () => null)
offA()
offB()
// Both entries gone; a re-register works from a fresh list.
registry.register('bash', () => null)
expect(registry.resolve('bash', SID)).toBeDefined()
})
})

View File

@@ -0,0 +1,131 @@
// @vitest-environment jsdom
// InputBar behavior: Enter-send semantics (IME guard, shift newline,
// ctrl/meta insert, repeat suppression), the running lock with stop-only
// action, unlock refocus, error strip copy, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
afterEach(cleanup)
function setup(over?: Partial<InputBarProps>) {
const props: InputBarProps = {
draft: 'hello', running: false, disabled: false, error: null,
variant: 'composer',
onDraftChange: vi.fn(), onSend: vi.fn(), onStop: vi.fn(),
...over,
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
const button = view.container.querySelector('button')!
return { view, textarea, button, props }
}
describe('Enter semantics', () => {
it('plain Enter sends queue mode; repeat and empty are suppressed', () => {
const { textarea, props } = setup()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).toHaveBeenCalledWith('queue')
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
expect(props.onSend).toHaveBeenCalledTimes(1)
const empty = setup({ draft: ' ' })
fireEvent.keyDown(empty.textarea, { key: 'Enter' })
expect(empty.props.onSend).not.toHaveBeenCalled()
})
it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
const { textarea, props } = setup()
fireEvent.keyDown(textarea, { key: 'a' })
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
expect(props.onSend).not.toHaveBeenCalled()
})
it('Ctrl/Meta+Enter inserts a newline through execCommand instead of sending', () => {
const exec = vi.fn()
;(document as unknown as { execCommand: typeof exec }).execCommand = exec
const { textarea, props } = setup()
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
expect(exec).toHaveBeenCalledWith('insertText', false, '\n')
expect(props.onSend).not.toHaveBeenCalled()
})
it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', async () => {
vi.useFakeTimers()
try {
const { textarea, props } = setup()
fireEvent.compositionStart(textarea)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).not.toHaveBeenCalled()
fireEvent.compositionEnd(textarea)
// Safari delivers the closing keydown before the deferred clear.
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).not.toHaveBeenCalled()
vi.advanceTimersByTime(20)
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
expect(props.onSend).not.toHaveBeenCalled()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})
describe('running lock and primary button', () => {
it('running locks the textarea and turns the primary into stop', () => {
const { textarea, button, props } = setup({ running: true })
expect(textarea.disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('停止')
fireEvent.click(button)
expect(props.onStop).toHaveBeenCalledTimes(1)
expect(props.onSend).not.toHaveBeenCalled()
})
it('idle primary sends and disables on empty draft', () => {
const { button, props } = setup()
fireEvent.click(button)
expect(props.onSend).toHaveBeenCalledWith('queue')
const empty = setup({ draft: '' })
expect(empty.button.disabled).toBe(true)
})
it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
const { view, props } = setup({ running: true })
view.rerender(<InputBar {...props} running={false} />)
const textarea = view.container.querySelector('textarea')!
expect(document.activeElement).toBe(textarea)
textarea.blur()
fireEvent.mouseDown(view.container.querySelector('button')!)
expect(document.activeElement).toBe(textarea)
})
it('disabled state shows the unavailable placeholder; typing forwards drafts', () => {
const { textarea } = setup({ disabled: true, draft: '' })
expect(textarea.placeholder).toBe('会话不可用')
const live = setup({ draft: '' })
expect(live.textarea.placeholder).toContain('Enter 发送')
fireEvent.change(live.textarea, { target: { value: 'typed' } })
expect(live.props.onDraftChange).toHaveBeenCalledWith('typed')
const runningPh = setup({ running: true, draft: '' })
expect(runningPh.textarea.placeholder).toContain('停止')
const custom = setup({ placeholder: '自定义' })
expect(custom.textarea.placeholder).toBe('自定义')
})
})
describe('error strip and variants', () => {
it('renders send and stop failure copy', () => {
const send = setup({ error: { op: 'send', message: 'boom' } })
expect(send.view.getByText(/发送失败:boom/)).toBeTruthy()
const stop = setup({ error: { op: 'stop', message: 'halt' } })
expect(stop.view.getByText(/停止失败:halt/)).toBeTruthy()
})
it('hero variant adds the hero class and accessory row renders', () => {
const { view } = setup({ variant: 'hero', accessory: <i data-testid="acc" /> })
expect(view.getByTestId('acc')).toBeTruthy()
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
})

View File

@@ -0,0 +1,121 @@
// @vitest-environment jsdom
/**
* M1a regression pin: the per-scope selection must survive list refreshes.
* Drives the REAL SessionsService + ConversationService chain over the
* programmable wire fake — a late list refresh that upgrades the display
* title (bare id → cwd basename) and a reconnect-driven refreshList+resync
* must neither recreate the session scope nor clear the selection account.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
// The runtime package's programmable fake lives in its tests; import through
// the src path (same pattern the runtime specs use — test-support material).
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
interface Bench {
ctx: Context
api: FakeApiClient
sessions: SessionsService
conversation: ConversationService
}
function bench(): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const conversation = new ConversationService(ctx)
return { ctx, api, sessions, conversation }
}
async function flush(): Promise<void> {
// Manager notifier + store batching are microtask-based.
await Promise.resolve()
await Promise.resolve()
}
function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[]): void {
b.api.onList = () => Promise.resolve(ok({
items: rows.map(r => ({
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
})),
}) as never)
}
describe('selection survives list refreshes (M1a)', () => {
it('create → select → title-upgrading refresh keeps scope, binding, store and value', async () => {
const b = bench()
// First-send shape: client-side create inserts the row without cwd (title = bare id).
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
const id = await b.sessions.create({})
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1')
const binding = b.sessions.binding(id)
expect(binding).toBeDefined()
const scoped = b.sessions.scope(id)!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 3, callId: 'c1' })
// The late list refresh lands (host knows the cwd → formal title).
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
await b.sessions.manager.refreshList()
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a')
// Scope, binding and the selection account must all be identity-stable.
expect(b.sessions.scope(id)).toBe(scoped)
expect(b.sessions.binding(id)).toBe(binding)
const after = (b.sessions.scope(id)!.get('conversation') as ConversationService).selection
expect(after).toBe(store)
expect(after.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1' })
})
it('reconnect (handleConnected: refreshList + resync) keeps the selection account', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
await b.sessions.manager.refreshList()
await flush()
const scoped = b.sessions.scope(sid('s1'))!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 1, callId: 'c9' })
// Reconnect generation: title upgrade arrives with the re-pull.
feed(b, [{ id: 's1', cwd: '/w/proj-a', running: true }])
b.sessions.manager.handleConnected()
await flush()
await flush()
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
const after = (b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection
expect(after).toBe(store)
expect(after.getSnapshot()).toEqual({ turnSeq: 1, callId: 'c9' })
})
it('a transiently failing list refresh does not prune live scopes', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
await b.sessions.manager.refreshList()
await flush()
const scoped = b.sessions.scope(sid('s1'))!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 2, callId: 'c2' })
// Wire hiccup: the reconnect-time list RPC throws (transport error).
b.api.onList = () => Promise.reject(new Error('boom'))
b.sessions.manager.handleConnected()
await flush()
await flush()
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
expect((b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection.getSnapshot())
.toEqual({ turnSeq: 2, callId: 'c2' })
})
})

View File

@@ -0,0 +1,190 @@
// @vitest-environment jsdom
/**
* ConversationService orchestration half: scope-addressed send/cancel (result
* folding, root throw), openDetails choreography, the startSession chain, and
* the service-unavailable loud failures. Store semantics live in
* service-stores.spec.ts.
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
/** Recover the module-private scope tag through the public seam (same probe as service-stores.spec). */
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
get(target, prop, receiver) {
recorded.push(prop)
return Reflect.get(target, prop, receiver)
},
})
void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
})()
interface SessionDouble {
prompt: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
}
async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
const ctx = new Context()
const sessionDoubles = new Map<SessionId, SessionDouble>()
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
let scoped = scopes.get(id)
if (scoped === undefined) {
const fiber = ctx.plugin(() => {})
scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
scopes.set(id, scoped)
}
return scoped
}
const sessionsFake = {
manager: {
get: (id: SessionId) => {
let s = sessionDoubles.get(id)
if (s === undefined) {
s = {
prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
}
sessionDoubles.set(id, s)
}
return s
},
},
create: vi.fn(() => Promise.resolve(sid('new-1'))),
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
const layoutFake = { open: vi.fn(), openDetails: vi.fn() }
if (opts?.layout !== false) ctx.provide('layout', layoutFake)
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
await fiber.await()
const svc = ctx.get('conversation') as ConversationService
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, layoutFake }
}
describe('send / cancel', () => {
it('sends one text block through the scoped session with the mode', async () => {
const b = await bench()
await b.scopedSvc(sid('s1')).send('hello', 'steer')
expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'hello' }], 'steer')
})
it('folds business failure into a thrown error carrying code and message', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))
// Materialize the double first (manager.get is the lazy mint point).
b.sessionsFake.manager.get(sid('s1'))
const double = b.sessionDoubles.get(sid('s1'))!
double.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'busy' } })
await expect(s.send('x', 'queue')).rejects.toThrow(/send failed: agent-busy: busy/)
})
it('cancel resolves on ok and throws the folded business error', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))
await s.cancel()
const double = b.sessionDoubles.get(sid('s1'))!
expect(double.cancel).toHaveBeenCalledTimes(1)
double.cancel.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'nope' } })
await expect(s.cancel()).rejects.toThrow(/cancel failed: internal: nope/)
})
it('root-context send and cancel throw the addressing hint', async () => {
const b = await bench()
await expect(b.svc.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
await expect(b.svc.cancel()).rejects.toThrow(/requires a session scope/)
})
})
describe('openDetails', () => {
it('writes the scoped selection then opens the layout panel', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))
s.openDetails({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(s.selection.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
})
})
describe('startSession chain', () => {
it('creates, navigates, then sends through the new scope', async () => {
const b = await bench()
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalledWith({ cwd: '/proj' })
expect(b.layoutFake.open).toHaveBeenCalledWith(sid('new-1'))
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'first' }], 'queue')
})
it('omits cwd from create when not chosen', async () => {
const b = await bench()
await b.svc.startSession({ text: 't', mode: 'steer' })
expect(b.sessionsFake.create).toHaveBeenCalledWith({})
})
it('fails loud when the created session resolves no scope', async () => {
const b = await bench()
;(b.sessionsFake.create as ReturnType<typeof vi.fn>).mockResolvedValue(sid('ghost'))
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/resolved no scope/)
})
})
describe('service-unavailable loud failures', () => {
it('throws when sessions is missing', async () => {
const b = await bench({ sessions: false })
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/sessions service unavailable/)
})
it('throws when layout is missing', async () => {
const b = await bench({ layout: false })
const s = b.scopedSvc(sid('s1'))
expect(() => { s.openDetails({ turnSeq: 1 }) }).toThrow(/layout service unavailable/)
})
it('startSession fails loud when the new scope cannot resolve conversation', async () => {
const b = await bench()
// A scope minted outside the service tree: scoped.get('conversation') finds nothing.
const foreign = new Context()
const foreignScope = foreign.plugin(() => {}).ctx.extend({})
;(b.sessionsFake.scope as unknown) = () => foreignScope
await expect(b.svc.startSession({ text: 't', mode: 'queue' }))
.rejects.toThrow(/conversation service unavailable through the new scope/)
})
})
describe('views ordering and draft persistence branches', () => {
it('orders by explicit order with undefined treated as zero (both comparator arms)', async () => {
const b = await bench()
const entry = (id: string, order?: number) => ({
id, label: id, component: () => null,
...(order !== undefined ? { order } : {}),
})
b.svc.registerView(entry('z-late', 5) as never)
b.svc.registerView(entry('default-zero') as never)
b.svc.registerView(entry('first', -1) as never)
expect(b.svc.views().map(v => v.id)).toEqual(['first', 'default-zero', 'z-late'])
})
it('draft store round-trips through localStorage and removes the key when emptied', async () => {
const b = await bench()
localStorage.setItem('dsh.conversation.draft.s9', 'restored')
const s = b.scopedSvc(sid('s9'))
expect(s.drafts.getSnapshot()).toBe('restored')
s.drafts.set('typed')
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBe('typed')
s.drafts.set('')
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBeNull()
})
})

View File

@@ -0,0 +1,176 @@
// @vitest-environment jsdom
/**
* ConversationService store half: scope-addressed selection/drafts accounts
* (lazy mint, per-scope isolation, root access throws, scope teardown
* collects), view registry (order, duplicate throw, effect-scoped disposal,
* uSES read face). Send/cancel/startSession orchestration live in
* service-orchestration.spec.ts.
*/
import { Context } from 'cordis'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConvViewProps, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
/**
* The scope tag symbol is module-private to the runtime package; recover it
* through the public seam by recording which symbol scopeOf reads off a
* spying proxy (keeps this bench honest against the real tagging shape
* without dragging the full SessionsService + wire fake in here).
*/
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
get(target, prop, receiver) {
recorded.push(prop)
return Reflect.get(target, prop, receiver)
},
})
void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
})()
/** Scope bench: real cordis scope fibers tagged like SessionsService.resolve mints them. */
interface Bench {
ctx: Context
svc: ConversationService
mint: (id: SessionId) => Context
dispose: (id: SessionId) => Promise<void>
}
function bench(): Bench {
const ctx = new Context()
const fibers = new Map<SessionId, { fiber: ReturnType<Context['plugin']>; ctx: Context }>()
const mint = (id: SessionId): Context => {
let rec = fibers.get(id)
if (rec === undefined) {
const fiber = ctx.plugin(() => {})
const scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
rec = { fiber, ctx: scoped }
fibers.set(id, rec)
}
return rec.ctx
}
const dispose = async (id: SessionId): Promise<void> => {
const rec = fibers.get(id)
if (rec !== undefined) {
await rec.fiber.dispose()
fibers.delete(id)
}
}
const sessions = { scope: (id: SessionId) => fibers.get(id)?.ctx } as unknown as SessionsService
ctx.provide('sessions', sessions)
const svc = new ConversationService(ctx)
return { ctx, svc, mint, dispose }
}
/** Scoped service view: ctx.get binds the root singleton to the scoped ctx (scope addressing seam). */
function convo(scoped: Context): ConversationService {
const service = scoped.get('conversation')
if (service === undefined) throw new Error('bench: conversation unavailable')
return service
}
const viewComp = (() => null) as unknown as FC<ConvViewProps>
const entry = (id: string, order?: number): ViewEntry =>
({ id, label: id, component: viewComp, ...(order !== undefined ? { order } : {}) }) as unknown as ViewEntry
beforeEach(() => { localStorage.clear() })
describe('scope addressing of stores', () => {
it('root-context selection/drafts access throws with the addressing hint', () => {
const b = bench()
expect(() => b.svc.selection).toThrow(/requires a session scope/)
expect(() => b.svc.drafts).toThrow(/requires a session scope/)
})
it('mints one store per scope and keeps identity per session', () => {
const b = bench()
const c1 = b.mint(sid('s1'))
const c2 = b.mint(sid('s2'))
const sel1 = convo(c1).selection
const sel2 = convo(c2).selection
expect(sel1).not.toBe(sel2)
expect(convo(c1).selection).toBe(sel1)
sel1.set({ turnSeq: 3 })
expect(sel1.getSnapshot()).toEqual({ turnSeq: 3 })
expect(sel2.getSnapshot()).toBeNull()
})
it('persists drafts keyed by session id and evolves independently', async () => {
const b = bench()
const c1 = b.mint(sid('s1'))
convo(c1).drafts.set('hello')
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBe('hello')
const c2 = b.mint(sid('s2'))
expect(convo(c2).drafts.getSnapshot()).toBe('')
// Re-minting after teardown rehydrates from storage; clearing removes the key.
await b.dispose(sid('s1'))
expect(convo(b.mint(sid('s1'))).drafts.getSnapshot()).toBe('hello')
convo(b.mint(sid('s1'))).drafts.set('')
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBeNull()
})
it('scope fiber disposal collects the store account (fresh store on re-mint)', async () => {
const b = bench()
const c1 = b.mint(sid('s1'))
const sel = convo(c1).selection
sel.set({ turnSeq: 1 })
await b.dispose(sid('s1'))
const again = b.mint(sid('s1'))
const sel2 = convo(again).selection
expect(sel2).not.toBe(sel)
expect(sel2.getSnapshot()).toBeNull()
})
})
describe('view registry', () => {
it('orders by order (ties keep registration sequence) with a stable cache reference', () => {
const b = bench()
b.svc.registerView(entry('chat', 0))
b.svc.registerView(entry('waterfall', 2))
b.svc.registerView(entry('trajectory', 1))
const views = b.svc.views()
expect(views.map(v => v.id)).toEqual(['chat', 'trajectory', 'waterfall'])
expect(b.svc.views()).toBe(views)
})
it('duplicate id throws; disposer removes and bumps the version', () => {
const b = bench()
const fn = vi.fn()
b.svc.subscribeViews(fn)
const off = b.svc.registerView(entry('chat'))
expect(() => b.svc.registerView(entry('chat'))).toThrow(/already registered/)
const v1 = b.svc.viewsVersion()
off()
expect(b.svc.viewsVersion()).toBeGreaterThan(v1)
expect(b.svc.views()).toEqual([])
expect(fn).toHaveBeenCalled()
})
it('unsubscribe stops notifications', () => {
const b = bench()
const fn = vi.fn()
const unsub = b.svc.subscribeViews(fn)
unsub()
b.svc.registerView(entry('chat'))
expect(fn).not.toHaveBeenCalled()
})
it('a registering plugin fiber unloading collects its views (effect cascade)', async () => {
const b = bench()
const fiber = b.ctx.plugin((pluginCtx: Context) => {
convo(pluginCtx).registerView(entry('chat'))
})
await fiber.await()
expect(b.svc.views().map(v => v.id)).toEqual(['chat'])
await fiber.dispose()
expect(b.svc.views()).toEqual([])
})
})

View File

@@ -0,0 +1,245 @@
// @vitest-environment jsdom
// Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx
// acceptance flows): breadcrumb ancestry rendering + error strip in
// ConversationRoot, DetailsPanel non-JSON args / non-text result blocks /
// error-only results, EmptyState failure surface and custom-directory swap.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationRoot, DetailsPanel, EmptyState } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
afterEach(cleanup)
const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
function sessionSource(over?: Partial<ConversationSnapshot>) {
const snap = { ...snapshotBase(), ...over }
return {
getSnapshot: () => snap,
subscribe: () => () => {},
}
}
const summary = (id: string, title: string): SessionSummary =>
({ id: id as SessionId, title, running: false, updatedAt: 1 })
describe('ConversationRoot branches', () => {
const chatEntry: ViewEntry = {
id: 'chat', label: 'Chat', component: () => null,
} as unknown as ViewEntry
function rootProps(over?: {
ancestry?: readonly SessionSummary[]
snapshot?: Partial<ConversationSnapshot>
}) {
const open = vi.fn()
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(over?.snapshot)) as unknown as UseSession}
useAncestry={() => over?.ancestry ?? []}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
useActiveView={() => undefined}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: vi.fn(), open }}
renderView={() => <div data-testid="view-body" />}
/>,
)
return { view, open }
}
it('renders the ancestry breadcrumb with separators and navigates on ancestor click', () => {
const { view, open } = rootProps({
ancestry: [summary('root-1', 'Workspace'), summary('s1', 'Current')],
})
expect(view.getByText('Workspace')).toBeTruthy()
expect(view.getByText('/')).toBeTruthy()
fireEvent.click(view.getByText('Workspace'))
expect(open).toHaveBeenCalledWith('root-1' as SessionId)
// The last crumb is the current session: disabled, no navigation.
fireEvent.click(view.getByText('Current'))
expect(open).toHaveBeenCalledTimes(1)
})
it('falls back to the raw session id without ancestry and counts user turns', () => {
const { view } = rootProps({
snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] },
})
expect(view.getByText(SID)).toBeTruthy()
expect(view.getByText(/1 turns/)).toBeTruthy()
})
it('surfaces promptError through the composer error strip', () => {
const { view } = rootProps({
snapshot: { promptError: { op: 'stop', error: { message: 'halt', code: 'internal' } } as never },
})
expect(view.getByText(/停止失败:halt(internal)/)).toBeTruthy()
})
it('an unknown active view id falls back to the first registered view', () => {
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession}
useAncestry={() => []}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
useActiveView={() => 'gone' as never}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: vi.fn(), open: vi.fn() }}
renderView={(entry) => <div data-testid={`body-${entry.id}`} />}
/>,
)
expect(view.getByTestId('body-chat')).toBeTruthy()
})
})
describe('DetailsPanel branches', () => {
function panel(selection: SelectionTarget | null, snapshot?: Partial<ConversationSnapshot>) {
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => selection, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
/>,
)
}
it('shows non-JSON args verbatim (streaming fragment path)', () => {
const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, {
runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, callView: null }],
})
expect(view.getByText('{"cmd": tru')).toBeTruthy()
})
it('a selection without callId renders the empty hint (selector null arm)', () => {
const view = panel({ turnSeq: 2 })
expect(view.getByText(/点击消息流中的工具行查看详情/)).toBeTruthy()
})
it('snapshot updates re-run the material selector through the shallow equality arm', () => {
let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, callView: null }] } as ConversationSnapshot
const subs = new Set<() => void>()
const source = {
getSnapshot: () => snap,
subscribe: (fn: () => void) => {
subs.add(fn)
return () => subs.delete(fn)
},
}
const SEL: SelectionTarget = { turnSeq: 1, callId: 'c9' }
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(source) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
/>,
)
expect(view.getByText(/"a": 1/)).toBeTruthy()
// Top-level swap with identical material members: the eq arm short-circuits.
snap = { ...snap }
for (const fn of [...subs]) fn()
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('windowless call material: no name/args fallback to callId, mixed node walk skips non-matches', () => {
// A tool-result whose call head fell outside the window (call === null),
// preceded by non-matching nodes so the walk exercises both filter arms.
const view = panel({ turnSeq: 1, callId: 'c8' }, {
nodes: [
{ kind: 'user', seq: 1, content: [], source: null } as never,
{ kind: 'tool-result', seq: 2, callId: 'other', call: { name: 'x', argsRaw: '{}' }, content: [], isError: false, callView: null, resultView: null } as never,
{ kind: 'tool-result', seq: 3, callId: 'c8', call: null, content: [], isError: false, callView: null, resultView: null } as never,
],
})
expect(view.getByText('c8')).toBeTruthy()
})
it('stringifies non-text result blocks and renders error-only results', () => {
const withBlocks = panel({ turnSeq: 1, callId: 'c2' }, {
nodes: [{
kind: 'tool-result', seq: 3, callId: 'c2', call: { name: 'read', argsRaw: '{}' },
content: [{ type: 'image', data: 'x' } as never],
isError: false, callView: null, resultView: null,
} as never],
})
expect(withBlocks.getByText(/"type": "image"/)).toBeTruthy()
const errorOnly = panel({ turnSeq: 1, callId: 'c3' }, {
nodes: [{
kind: 'tool-result', seq: 4, callId: 'c3', call: { name: 'bash', argsRaw: '{}' },
content: [], isError: true, error: { name: 'ToolError', code: 'timeout' },
callView: null, resultView: null,
} as never],
})
expect(errorOnly.getByText(/ToolError: timeout/)).toBeTruthy()
})
})
describe('EmptyState branches', () => {
// getSnapshot must return a stable reference (uSES contract) — a fresh
// array per call loops the selector forever.
const CWDS: readonly string[] = ['/proj']
const NO_CWDS: readonly string[] = []
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(view.getByText(/发送失败:create down/)).toBeTruthy())
expect((textarea as HTMLTextAreaElement).value).toBe('first task')
})
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => NO_CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy())
})
it('cwd select picks an option, swaps to free-form on 新目录, and submits the typed path', async () => {
const startSession = vi.fn(() => Promise.resolve())
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
)
const select = view.container.querySelector('select')!
fireEvent.change(select, { target: { value: '/proj' } })
expect((select as HTMLSelectElement).value).toBe('/proj')
fireEvent.change(select, { target: { value: '::new-directory' } })
const custom = view.container.querySelector('input')!
fireEvent.change(custom, { target: { value: '/typed/dir' } })
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'task' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' }))
})
})

View File

@@ -0,0 +1,181 @@
// @vitest-environment jsdom
/**
* Skeleton acceptance: empty-state transition (same InputBar component in
* hero position, startSession submit), ConversationRoot view switching over
* the registry face, DetailsPanel open/close linkage against a layout-shaped
* fake. Components stay framework-free — everything arrives via props here,
* exactly as the inject factories will assemble them.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import {
ConversationRoot, DetailsPanel, EmptyState,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
afterEach(cleanup)
/** Minimal conversation snapshot slice the skeleton reads. */
interface FakeSnapshot {
nodes: readonly { kind: string; callId?: string; call?: { name: string; argsRaw: string } | null; content?: readonly { type: string; text?: string }[]; isError?: boolean }[]
runningCalls: readonly { callId: string; name: string; argsRaw: string }[]
running: boolean
removed: boolean
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
}
function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
}
describe('EmptyState', () => {
it('submits startSession with the typed text and picked cwd; failure surfaces locally', async () => {
const cwds = createSnapshotStore<readonly string[]>(['/w/app', '/w/lib'])
let reject!: (e: Error) => void
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession }} />)
fireEvent.change(screen.getByRole('combobox', { name: '项目目录' }), { target: { value: '/w/app' } })
const box = screen.getByPlaceholderText('Message to run task, plan and build')
fireEvent.change(box, { target: { value: '造一个轮子' } })
fireEvent.keyDown(box, { key: 'Enter' })
expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' })
reject(new Error('后端拒收'))
expect(await screen.findByText(/后端拒收/)).toBeTruthy()
// Draft survives the failure for retry.
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
})
it('new-directory option swaps the select for a free-form input', () => {
const cwds = createSnapshotStore<readonly string[]>([])
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession: () => Promise.resolve() }} />)
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
const custom = screen.getByPlaceholderText(/目录路径/)
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
})
})
describe('ConversationRoot', () => {
function bench(views: ViewEntry[], active?: string) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
const activeStore = createSnapshotStore<string | undefined>(active)
const openView = vi.fn((v: string) => { activeStore.set(v) })
const open = vi.fn()
const drafts = createSnapshotStore<string>('')
const send = vi.fn()
const stop = vi.fn()
const ancestry: SessionSummary[] = [
{ id: sid('root'), title: 'proj', running: false, updatedAt: 1 },
{ id: sid('s1'), title: 'child', running: false, updatedAt: 1, parentId: sid('root') },
]
const rendered: string[] = []
const ui = render(
<ConversationRoot
sessionId={sid('s1')}
useSession={useSession}
useAncestry={() => ancestry}
views={{
list: () => views,
subscribe: () => () => {},
version: () => 1,
}}
useActiveView={() => activeStore.useSelector(s => s) as ViewId | undefined}
composer={{
useDraft: () => drafts.useSelector(s => s),
setDraft: (t) => { drafts.set(t) },
send, stop,
}}
actions={{ openView: openView as (v: never) => void, open }}
renderView={(entry) => { rendered.push(entry.id); return <div data-testid={`view-${entry.id}`} /> }}
/>)
return { ui, openView, open, rendered, send, drafts }
}
const comp = (() => null) as unknown as FC<never>
const view = (id: string, label: string): ViewEntry =>
({ id, label, component: comp }) as unknown as ViewEntry
it('renders breadcrumb chain, meta turns, and the active view (default chat)', () => {
const { rendered, open } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('child')).toBeTruthy()
expect(screen.getByText(/2 turns/)).toBeTruthy()
expect(rendered).toEqual(['chat'])
// Ancestor crumb navigates; current crumb is disabled.
fireEvent.click(screen.getByRole('button', { name: 'proj' }))
expect(open).toHaveBeenCalledWith('root')
expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true)
})
it('switches views through actions.openView and re-renders the new body', () => {
const { openView } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(openView).toHaveBeenCalledWith('trajectory')
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
})
it('hides the tab strip with a single view and wires the composer send', () => {
const { send } = bench([view('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
fireEvent.keyDown(box, { key: 'Enter' })
expect(send).toHaveBeenCalledWith('queue')
})
})
describe('DetailsPanel', () => {
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
const { useSession } = fakeSession(snapshot)
const selectionStore = createSnapshotStore<SelectionTarget | null>(selection)
const closeDetails = vi.fn()
render(
<DetailsPanel
sessionId={sid('s1')}
useSession={useSession}
useSelection={selectionStore.useSelector}
actions={{ closeDetails }}
/>)
return { closeDetails, selectionStore }
}
it('renders the selected call args and result; close fires the layout-linked action', () => {
const { closeDetails } = benchDetails({
nodes: [{
kind: 'tool-result', callId: 'c1',
call: { name: 'bash', argsRaw: '{"cmd":"ls"}' },
content: [{ type: 'text', text: 'file-a\nfile-b' }],
isError: false,
}],
}, { turnSeq: 1, callId: 'c1' })
expect(screen.getByText('bash')).toBeTruthy()
expect(screen.getByText(/"cmd": "ls"/)).toBeTruthy()
expect(screen.getByText(/file-a/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
expect(closeDetails).toHaveBeenCalledTimes(1)
})
it('shows the empty hint without a selection and the running state for open calls', () => {
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, null)
expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy()
cleanup()
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, { turnSeq: 1, callId: 'c9' })
expect(screen.getByText('运行中…')).toBeTruthy()
})
it('reports an out-of-window call distinctly', () => {
benchDetails({}, { turnSeq: 1, callId: 'ghost' })
expect(screen.getByText(/不在当前窗口内/)).toBeTruthy()
})
})

View File

@@ -0,0 +1,62 @@
/**
* Tool-ring Entry typing (design §7): I inferred from the inject factory at
* the register site, component must accept ToolViewProps & I, and the resolve
* read face carries the erased-but-present inject. Compile-time checks via
* @ts-expect-error pairs; the runtime assertions just keep vitest happy.
*/
import { describe, expect, it } from 'vitest'
import type { FC } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
// Positive control: component's own injected share matches the factory's product.
interface RowInjected { useMyStore: () => number }
const InjectedRowComp: FC<ToolViewProps & RowInjected> = () => null
// Plain rows take the shared props only.
const PlainRowComp: FC<ToolViewProps> = () => null
describe('tool-ring entry typing', () => {
it('register infers I from the inject factory and accepts a matching component', () => {
const reg = new ToolViewRegistry()
const off = reg.register('bash', InjectedRowComp, {
inject: () => ({ useMyStore: () => 1 }),
})
expect(reg.resolve('bash', sid('s'))?.inject).toBeDefined()
off()
})
it('injectless registration needs no options and resolves without inject', () => {
const reg = new ToolViewRegistry()
reg.register('read', PlainRowComp)
expect('inject' in (reg.resolve('read', sid('s')) ?? {})).toBe(false)
})
it('compile-time: factory product must cover the component injected share', () => {
const reg = new ToolViewRegistry()
reg.register('bash', InjectedRowComp, {
// @ts-expect-error the factory misses useMyStore, which the component requires
inject: () => ({ somethingElse: 1 }),
})
expect(true).toBe(true)
})
// Known boundary (not asserted): a component demanding an injected share CAN
// register bare — with I defaulting to `object`, FC<ToolViewProps & RowInjected>
// is structurally assignable to FC<ToolViewProps & object> (parameter
// bivariance over a wider props type). The register-site guarantee holds in
// the direction that matters: WITH an inject factory, its product must cover
// the component's share (previous case). The bare-register gap is the same
// one SlotMap's single-kind register has and is accepted by design §7.
it('compile-time: scope filter receives the branded SessionId', () => {
const reg = new ToolViewRegistry()
reg.register('bash', PlainRowComp, {
// @ts-expect-error number is not assignable to SessionId
scope: (id: number) => id > 0,
})
expect(true).toBe(true)
})
})

View File

@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string) => s as SessionId
const comp = (name: string) => {
const fc = () => null
fc.displayName = name
return fc as unknown as import('react').FC<ToolViewProps>
}
describe('ToolViewRegistry', () => {
it('resolves a global registration for any session', () => {
const reg = new ToolViewRegistry()
const bash = comp('Bash')
reg.register('bash', bash)
expect(reg.resolve('bash', sid('a'))?.component).toBe(bash)
expect(reg.resolve('bash', sid('b'))?.component).toBe(bash)
expect(reg.resolve('read', sid('a'))).toBeUndefined()
})
it('prefers a matching scope filter over the global registration', () => {
const reg = new ToolViewRegistry()
const global = comp('Global')
const swarm = comp('Swarm')
reg.register('bash', global)
reg.register('bash', swarm, { scope: id => id === sid('swarm-1') })
expect(reg.resolve('bash', sid('swarm-1'))?.component).toBe(swarm)
expect(reg.resolve('bash', sid('plain'))?.component).toBe(global)
})
it('later registration wins within the same tier, scoped and global', () => {
const reg = new ToolViewRegistry()
const s1 = comp('S1')
const s2 = comp('S2')
const g1 = comp('G1')
const g2 = comp('G2')
reg.register('bash', g1)
reg.register('bash', s1, { scope: () => true })
reg.register('bash', s2, { scope: () => true })
reg.register('bash', g2)
expect(reg.resolve('bash', sid('x'))?.component).toBe(s2)
const scopeless = new ToolViewRegistry()
scopeless.register('bash', g1)
scopeless.register('bash', g2)
expect(scopeless.resolve('bash', sid('x'))?.component).toBe(g2)
})
it('a non-matching scope filter falls through to global, then undefined', () => {
const reg = new ToolViewRegistry()
const scoped = comp('Scoped')
reg.register('bash', scoped, { scope: () => false })
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
const global = comp('Global')
reg.register('bash', global)
expect(reg.resolve('bash', sid('x'))?.component).toBe(global)
})
it('disposer removes exactly its registration and is idempotent', () => {
const reg = new ToolViewRegistry()
const g = comp('G')
const s = comp('S')
const off = reg.register('bash', s, { scope: () => true })
reg.register('bash', g)
off()
off()
expect(reg.resolve('bash', sid('x'))?.component).toBe(g)
})
it('unregistering the last entry resolves undefined (GenericToolCard fallback)', () => {
const reg = new ToolViewRegistry()
const off = reg.register('bash', comp('B'))
off()
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
})
it('carries the inject factory through resolve', () => {
const reg = new ToolViewRegistry()
const inject = () => ({})
reg.register('bash', comp('B'), { inject })
expect(reg.resolve('bash', sid('x'))?.inject).toBe(inject)
reg.register('read', comp('R'))
expect('inject' in reg.resolve('read', sid('x'))!).toBe(false)
})
it('notifies subscribers and bumps the version on register and dispose', () => {
const reg = new ToolViewRegistry()
const fn = vi.fn()
const unsub = reg.subscribe(fn)
const v0 = reg.getVersion()
const off = reg.register('bash', comp('B'))
expect(fn).toHaveBeenCalledTimes(1)
expect(reg.getVersion()).toBeGreaterThan(v0)
off()
expect(fn).toHaveBeenCalledTimes(2)
unsub()
reg.register('read', comp('R'))
expect(fn).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,96 @@
// Tool-ring type-chain samples (design §9 item 5, toolviews half): the
// register→inject→resolve chain where `I` is inferred from the inject
// factory and proved against the component at the register site, plus
// expect-error duals. Tool names stay an open set (no per-tool props table —
// design §7); the strong typing under test is Entry-internal. The known
// bare-register variance edge (FC<Props & I> assignable to FC<Props & object>
// without an inject factory) is accepted by design §7 and deliberately not
// pinned here. Follows the slots-ring exemplar's shape.
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolViewOptions, ToolViewProps } from '../src/client/contract/toolview.ts'
import { ToolViewRegistry } from '../src/client/toolviews/registry.ts'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
const sid = (s: string): SessionId => s as SessionId
/** Registrant's own injected share (locally declared — ownership rule). */
interface RowInjected { useRuns: () => number; actions2: { rerun: () => void } }
const InjectedRow: FC<ToolViewProps & RowInjected> = () => null
const PlainRow: FC<ToolViewProps> = () => null
describe('tool-ring type-chain negatives (compile-time; body never runs)', () => {
it('holds the negative samples as expect-error sites', () => {
const negatives = (registry: ToolViewRegistry) => {
// 1. Inject factory under-produces the component's declared share:
// I infers from the factory, and the component position then fails.
registry.register(
'bash',
// @ts-expect-error component wants actions2, which the factory never produces
InjectedRow,
{ inject: () => ({ useRuns: () => 1 }) },
)
// 2. Inject factory produces a drifted value type for a declared key
// (I infers from the component position here, so TS flags the factory).
registry.register(
'bash',
InjectedRow,
// @ts-expect-error useRuns returns string here, component wants number
{ inject: () => ({ useRuns: () => 'one', actions2: { rerun: () => {} } }) },
)
// 3. Options object drifts: scope filter with a wrong parameter shape.
const badScope: ToolViewOptions<RowInjected> = {
// @ts-expect-error scope takes a SessionId, not a numeric index
scope: (index: number) => index > 0,
}
void badScope
// 4. Component demanding props outside ToolViewProps & I (a key neither
// standard nor injected) cannot register even with a full factory.
const Overreaching: FC<ToolViewProps & RowInjected & { fromNowhere: boolean }> = () => null
registry.register(
'bash',
// @ts-expect-error fromNowhere is neither a standard prop nor produced by the factory
Overreaching,
{ inject: (): RowInjected => ({ useRuns: () => 1, actions2: { rerun: () => {} } }) },
)
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
})
})
describe('tool-ring full chain (positive dual)', () => {
it('registers with an inferred inject share, resolves by scope order, and reads the erased face back', () => {
const registry = new ToolViewRegistry()
// Registration: I inferred from the factory, component proved ⊇ ToolViewProps & I.
const disposeGlobal = registry.register('bash', InjectedRow, {
inject: (b: SessionBinding): RowInjected => ({
useRuns: () => b.sessionId.length,
actions2: { rerun: () => {} },
}),
})
const disposeScoped = registry.register('bash', PlainRow, {
scope: id => id === sid('swarm-1'),
})
// Resolve: scope match beats global; elsewhere the global row wins.
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(PlainRow)
const global = registry.resolve('bash', sid('other'))
expect(global?.component).toBe(InjectedRow)
// Read face: I is erased to object, the factory reference survives; the
// outlet-side restoration is the budgeted cast (same boundary as slots).
const injected = (global?.inject as (b: SessionBinding) => RowInjected)(
{ sessionId: 'ab', session: { useSelector: undefined }, ctx: undefined },
)
expect(injected.useRuns()).toBe(2)
// Unknown tool → undefined (caller falls back to the generic card).
expect(registry.resolve('ghost-tool', sid('other'))).toBeUndefined()
disposeScoped()
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(InjectedRow)
disposeGlobal()
expect(registry.resolve('bash', sid('other'))).toBeUndefined()
})
})

View File

@@ -0,0 +1,100 @@
// View-ring type-chain samples (design §9 item 5, views half): the
// register→inject→render chain composed through ConversationViewMap's
// per-view extension shapes, plus expect-error duals for each stage.
// Follows the slots-ring exemplar (ui-slots/tests/type-chain.spec.tsx):
// negatives live in a never-executed function body; the positive dual runs
// the real ConversationService view registry.
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type {
ChromePropsOf, ConvViewProps, ConvViewPropsOf, ViewEntry,
} from '../src/client/contract/views.ts'
import { ConversationService } from '../src/client/service.ts'
// Test-only view keys with distinct extension shapes (merged like
// ui-trajectory does; extension fields are optional per ViewEntryDef).
declare module '../src/client/contract/views.ts' {
interface ConversationViewMap {
'vt-extended': { chromeProps: { statLabel: string }; extraProps: { density: 'compact' | 'wide' } }
'vt-plain': object
}
}
const ExtendedView: FC<ConvViewPropsOf<'vt-extended'>> = ({ density }) => (density === 'compact' ? null : null)
const ExtendedChrome: FC<ChromePropsOf<'vt-extended'>> = ({ statLabel }) => (statLabel === '' ? null : null)
const PlainView: FC<ConvViewPropsOf<'vt-plain'>> = () => null
describe('view-ring type-chain negatives (compile-time; body never runs)', () => {
it('holds the negative samples as expect-error sites', () => {
const negatives = (service: ConversationService) => {
// 1. Registration: a component missing the entry's declared extraProps
// cannot register under that id (props flow from the map entry).
const NarrowComp: FC<ConvViewProps & { density: number }> = () => null
service.registerView({
id: 'vt-extended',
label: 'x',
// @ts-expect-error density has the wrong value type vs the map entry's extraProps
component: NarrowComp,
})
// 2. Registration: chrome typed for another view's chromeProps drifts.
service.registerView({
id: 'vt-plain',
label: 'x',
component: PlainView,
// @ts-expect-error vt-plain declares no statLabel chromeProps
chrome: { footer: ExtendedChrome },
})
// 3. Registration: id outside the map is rejected at the entry.
service.registerView({
// @ts-expect-error unregistered view id
id: 'vt-ghost',
label: 'x',
component: PlainView,
})
// 4. Render side: per-view props narrow — the extended view's density
// is not accessible under another id's props type.
const renderPlain = (props: ConvViewPropsOf<'vt-plain'>): ReactNode => {
// @ts-expect-error density belongs to vt-extended's extension, not vt-plain
return props.density === 'compact' ? null : null
}
void renderPlain
// 5. Entry-shape drift: ViewEntry<Id> ties chrome and component to the
// SAME id — mixing ids inside one entry fails.
const mixed: ViewEntry<'vt-extended'> = {
id: 'vt-extended',
label: 'x',
component: ExtendedView,
// @ts-expect-error chrome for vt-plain cannot ride a vt-extended entry
chrome: { header: (props: ChromePropsOf<'vt-plain'> & { onlyPlain: true }) => null },
}
void mixed
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
})
})
describe('view-ring full chain (positive dual)', () => {
it('registers, lists, and renders through the per-view extension shapes', () => {
const ctx = new Context()
const service = new ConversationService(ctx)
// Registration: extension-typed component + same-id chrome compose cleanly.
const dispose = service.registerView({
id: 'vt-extended',
label: '扩展视图',
order: 7,
component: ExtendedView,
chrome: { footer: ExtendedChrome },
})
const entry = service.views().find(v => v.id === 'vt-extended')
expect(entry?.label).toBe('扩展视图')
// Render surface: the listed entry's component accepts the composed props
// (base ConvViewProps + the map extension), spelled here as the same type
// the runtime hands over.
expect(typeof entry?.component).toBe('function')
expect(typeof entry?.chrome?.footer).toBe('function')
dispose()
expect(service.views().some(v => v.id === 'vt-extended')).toBe(false)
})
})