Merge remote-tracking branch 'origin/master' into mergebot/pr1389

# Conflicts:
#	packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css
This commit is contained in:
imccyu
2026-08-04 16:06:38 +08:00
574 changed files with 8022 additions and 29830 deletions

View File

@@ -51,7 +51,7 @@ Non-negotiables across the layers:
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `runtime/src/client/sessions/notifier.ts`.
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
## Directory regime (plugin packages)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/README.md
README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8
README.md: 31d884c04a8b0233713b77b82b3d9cc7052003ca
README.zh.md: 9c95db529306519136d6d68758889350e5dd65e4

View File

@@ -11,7 +11,7 @@ The browser side of the dsh web GUI: shell kernel, module system, wire consumer,
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
| `hmr/` | Dev-only hot reload for script-loaded client plugins (`--dev` graphs) | (dev entry) |
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |

View File

@@ -11,7 +11,7 @@ dsh web GUI 的浏览器侧shell 内核、模块系统、协议消费层、
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
| `runtime/` | 客户端 cordis 启动与无 React 对象服务slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
| `hmr/` | 仅开发用的外部脚本加载型客户端插件热重载(`--dev` 图) | (开发条目) |
| `locale/` | 浏览器语言偏好(`zh``en`)与 ns×locale 词典注册表 | `ctx.locale` |
| `ui-slots/` | slot 注册表纯核心SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light``dark``system` | `ctx.theme` |

View File

@@ -1179,6 +1179,16 @@ interface StreamConn<F> {
push(envelope: RpcRequest<F>): void
}
interface ReasoningChunkStormState {
sessionId: string
chunkCount: number
chunksPerInterval: number
intervalMs: number
emitted: number
marker: string
emitting: boolean
}
/** Deterministic fixture branches used by keyless Web assembly tests. */
export interface FixtureOptions {
/** Start with no real Workspace or Session. */
@@ -1461,6 +1471,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const streamBreakers = new Set<() => void>()
/** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */
const retryScenarios = new Map<SessionId, { turn: number; stepStarted: boolean }>()
/** The single opt-in browser stress producer; normal fixture journeys never start it. */
let activeReasoningChunkStorm: ReasoningChunkStormState | null = null
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
@@ -1484,6 +1496,86 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
},
/** Start an externally paced reasoning stream for the opt-in browser stress lane. */
startReasoningChunkStorm(
id: string,
chunkCount: number,
chunksPerInterval: number,
intervalMs: number,
): string {
if (!Number.isSafeInteger(chunkCount) || chunkCount < 1) {
throw new Error('fixture: reasoning chunk count must be a positive safe integer')
}
if (!Number.isSafeInteger(chunksPerInterval) || chunksPerInterval < 1) {
throw new Error('fixture: reasoning chunks per interval must be a positive safe integer')
}
if (!Number.isSafeInteger(intervalMs) || intervalMs < 1) {
throw new Error('fixture: reasoning interval must be a positive safe integer')
}
if (activeReasoningChunkStorm?.emitting === true) {
throw new Error('fixture: reasoning chunk storm already running')
}
const sessionId = sid(id)
const log = logOf(sessionId)
let turn = nextTurn.get(sessionId) ?? 0
for (const event of log) {
const candidate = (event as unknown as { data?: { turn?: unknown } }).data?.turn
if (typeof candidate === 'number') turn = Math.max(turn, candidate + 1)
}
nextTurn.set(sessionId, turn + 1)
const marker = `REASONING_STRESS_COMPLETE:${String(turn)}:${String(chunkCount)}`
const state: ReasoningChunkStormState = {
sessionId: id,
chunkCount,
chunksPerInterval,
intervalMs,
emitted: 0,
marker,
emitting: true,
}
activeReasoningChunkStorm = state
setRunning(sessionId, true)
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(sessionId, {
type: 'user/message', surfaceOp: 'append',
data: userMessage(text(`Reasoning chunk stress: ${String(chunkCount)} chunks.`)),
})
append(sessionId, { type: 'step/start', data: { turn, step: 0 } })
append(sessionId, {
type: 'assistant/chunk',
data: { turn, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } },
})
const startedAt = Date.now()
const pump = (): void => {
const elapsedIntervals = Math.floor((Date.now() - startedAt) / intervalMs) + 1
const due = Math.max(state.emitted + chunksPerInterval, elapsedIntervals * chunksPerInterval)
const end = Math.min(due, chunkCount)
for (let index = state.emitted; index < end; index++) {
const chunkText = index === chunkCount - 1
? `\n${marker}`
: index % 64 === 63 ? '推理\n' : '推理'
append(sessionId, {
type: 'assistant/chunk',
data: { turn, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: chunkText } },
})
}
state.emitted = end
if (end < chunkCount) {
setTimeout(pump, intervalMs)
} else {
state.emitting = false
}
}
setTimeout(pump, 0)
return marker
},
/** Return a copy so browser probes cannot mutate the active producer. */
reasoningChunkStormState(): ReasoningChunkStormState | null {
return activeReasoningChunkStorm === null ? null : { ...activeReasoningChunkStorm }
},
/** Open one failed model step whose partial remains visible until llm/retry arrives. */
beginModelRetry(id: string): void {
const sessionId = sid(id)

View File

@@ -19,6 +19,16 @@ interface TimingHooks {
failNextHistory(): void
appendUser(id: string, msg: string): void
appendTitle(id: string, title: string): void
startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
reasoningChunkStormState(): {
sessionId: string
chunkCount: number
chunksPerInterval: number
intervalMs: number
emitted: number
marker: string
emitting: boolean
} | null
beginModelRetry(id: string): void
scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
cancelModelRetryDuringBackoff(id: string, delayMs?: number): void
@@ -873,6 +883,50 @@ describe('createFixtureApi', () => {
expect(abort.signal.aborted).toBe(false)
expect(habort.signal.aborted).toBe(false)
})
it('paces the opt-in reasoning stress hook from an external interval', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
const api = createFixtureApi()
const hooks = timing()
expect(hooks.reasoningChunkStormState()).toBeNull()
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 0, 1, 16)).toThrow(/chunk count/)
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 0, 16)).toThrow(/chunks per interval/)
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 0)).toThrow(/reasoning interval/)
const abort = new AbortController()
try {
const streamed = collect(api.events.mux(req({}), abort.signal), abort, frames => frames.some(frame => (
frame.type === 'session/event'
&& frame.event.type === 'assistant/chunk'
&& frame.event.data.chunk.type === 'reasoning-delta'
&& frame.event.data.chunk.text.includes('REASONING_STRESS_COMPLETE')
)))
const marker = hooks.startReasoningChunkStorm('fx-alpha', 3, 2, 16)
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 16)).toThrow(/already running/)
expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 0, emitting: true, marker })
await vi.advanceTimersByTimeAsync(0)
expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 2, emitting: true })
await vi.advanceTimersByTimeAsync(16)
expect(hooks.reasoningChunkStormState()).toEqual({
sessionId: 'fx-alpha', chunkCount: 3, chunksPerInterval: 2, intervalMs: 16,
emitted: 3, marker, emitting: false,
})
const frames = await streamed
const deltas = frames.flatMap(frame => (
frame.type === 'session/event'
&& frame.event.type === 'assistant/chunk'
&& frame.event.data.chunk.type === 'reasoning-delta'
? [frame.event.data.chunk.text]
: []
))
expect(deltas).toEqual(['推理', '推理', `\n${marker}`])
} finally {
abort.abort()
vi.useRealTimers()
}
})
})
describe('FixtureApiClient (protocol-level fake carrier)', () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4
README.md: f91a6c6f685c88a1ea19312985ad3e933222192a
README.zh.md: 1d20a22d211c13d62089fb5618f40636ab7ae60a

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
## Model Experience

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
为通过外部脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
浏览器侧订阅系统 SSEServer-Sent Events通道`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate``registry.delete`(在 fiber dispose资源释放之前执行仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR热模块替换无需 builder→host 通道。
浏览器侧订阅系统 SSEServer-Sent Events通道`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate``prefetch`(旧 fiber 仍在服务时加载并注册新组合包)`registry.delete`(在 fiber dispose资源释放之前执行仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR热模块替换无需 builder→host 通道。
## 模型体验

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-hmr",
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
"description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -2,7 +2,7 @@
* client-hmr, browser half: hot-reload driver for client plugin entries.
*
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
* `rebuilt` frame it reloads the entry's bundle and swaps the cordis
* fiber in place. Every graph entry is a plugin bundle under the web2 model
* — `immediately` rows differ only in stage-one prefetch (a boot
* optimization), so all rostered plugin packages share these reload semantics;
@@ -14,7 +14,7 @@
* cascades into its UI dependents with no HMR-side bookkeeping.
*
* Reload order (lazy CJS table): invalidate (drop the stale factory and
* materialized record) → prefetch (fetch + execute + register the fresh
* materialized record) → prefetch (load and register the fresh
* factory) → registry-first teardown → drain old fiber unload → remove
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
@@ -110,7 +110,7 @@ export function apply(ctx: Context): void {
}
// Invalidate first (drop stale factory + record — a live factory makes
// prefetch a no-op and re-registration a loud duplicate), then run the
// async half while the old fiber still serves: fetch + execute registers
// async half while the old fiber still serves: script loading registers
// the fresh factory with zero side effects (lazy CJS — module bodies run
// at materialization, not execution).
modLoader.invalidate(id)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f
README.md: 7d661c806955d0fac021dd6620994aab83c0f773
README.zh.md: a1da42a552dbe8770fcb78bf458c01a0057ce8dd

View File

@@ -6,9 +6,9 @@ Client module system: the browser peer of Node's internal ESM loader, built as a
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
## Model Experience

View File

@@ -6,9 +6,9 @@
惰性 CJS 模型web2执行插件组合包只会注册其 factory`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块系统会递归物化它因此加载顺序无需外部编排require 循环会抛出异常factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR热模块替换钩子。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR热模块替换钩子。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
## 模型体验

View File

@@ -17,11 +17,11 @@
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* module; registered factory → materialize; graph row → load + materialize;
* anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* factories walks the same order minus the load branch: loading is async,
* so only already-registered bundles can be required — and cross-plugin value
* imports are a build error anyway.
*
* This file is the browser-safe contract face (zero node imports): the
@@ -56,7 +56,7 @@ export interface WebBootEntry {
rev: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
/** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
immediately?: boolean
}
@@ -210,18 +210,17 @@ export interface ClientModuleLoader {
*/
registerStatic(id: string, module: unknown): void
/**
* Stage-one arrival: fetch the entry's bundle and execute it, registering
* its factory (no materialization — module side effects wait for import).
* Stage-one arrival: load the entry's script to register its factory (no
* materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* fetch (HMR), {@link invalidate} first.
* load (HMR), {@link invalidate} first.
* @param id - graph entry name.
*/
prefetch(id: string): Promise<void>
/**
* Full reset of one module: drop its registered factory, its materialized
* record, and any consumed bundle text, so the next prefetch/import
* refetches and re-executes (the HMR invalidation hook).
* Full reset of one module: drop its registered factory and materialized
* record so the next prefetch/import reloads it (the HMR invalidation hook).
* @param id - entry name to invalidate.
*/
invalidate(id: string): void
@@ -233,11 +232,6 @@ export interface ClientModuleSystemOptions {
modules: BootModuleRow[]
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
/** Bundle-load seam. Defaults to a same-origin classic `<script src>` element. */
loadBundle?: (url: string) => Promise<void>
}

View File

@@ -2,38 +2,28 @@
* ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
* documented on the public interfaces in `./manifest.ts`; this file owns the
* state tables and the fetch/execute/materialize machinery.
* state tables and the load/materialize machinery.
*/
import type {
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
} from './manifest.ts'
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
interface RegisteredFactory {
factory: ClientPluginHandoff['factory']
url: string
}
/** Default bundle fetch seam: same-origin fetch().text(). */
const defaultFetchBundle = async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
}
/** Default bundle execution seam: a <script> element carrying the code. */
const defaultExecuteBundle = (code: string, url: string): void => {
/** Default bundle-load seam: same-origin external classic script. */
const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve, reject) => {
const el = document.createElement('script')
// Inline execution (not src) so the fetch half stays parallelizable; the
// sourceURL comment keeps devtools stack frames attributed to the bundle.
el.textContent = `${code}\n//# sourceURL=${url}`
document.head.appendChild(el)
// Execution is synchronous for inline scripts: the factory is registered by
// now, so the node (and its source text) has no further job. Removing it
// keeps repeated HMR rebuilds from accumulating dead script nodes.
el.remove()
}
el.async = true
el.src = url
el.addEventListener('load', () => {
el.remove()
resolve()
}, { once: true })
el.addEventListener('error', () => {
el.remove()
reject(new Error(`client-modules: bundle script ${url} failed to load`))
}, { once: true })
document.head.append(el)
})
/**
* A plugin bundle IS its package's client half: `<id>/client` (the exports
@@ -72,31 +62,21 @@ export class ClientModuleSystem implements ClientModuleLoader {
private readonly seed: Map<string, unknown>
private readonly statics = new Map<string, unknown>()
private readonly factories = new Map<string, RegisteredFactory>()
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
private readonly factories = new Map<string, ClientPluginHandoff['factory']>()
/** In-flight prefetch (script load) per id; concurrent callers share it. */
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, BootModuleRow>()
// Execution URL of the bundle currently being executed (bound into the
// factory registration so diagnostics can name the source).
private executingUrl = ''
// Graph id of the row currently being executed ('' outside arrive):
// the load sink cross-checks the handoff id against it so a mis-stamped
// bundle cannot register under another entry's identity.
private executingId = ''
private readonly fetchBundle: (url: string) => Promise<string>
private readonly executeBundle: (code: string, url: string) => void
private readonly loadBundle: (url: string) => Promise<void>
/**
* Build the module system over the parsed boot rows.
* @param options - module rows, module-table staticModules, fetch/execute seams.
* @param options - module rows, module-table staticModules, and bundle-load seam.
*/
constructor(options: ClientModuleSystemOptions) {
this.seed = new Map(Object.entries(options.staticModules))
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
this.loadBundle = options.loadBundle ?? defaultLoadBundle
for (const row of options.modules) {
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
@@ -110,37 +90,22 @@ export class ClientModuleSystem implements ClientModuleLoader {
// Registration is keyed by the handoff id; a duplicate means a bundle
// executed twice without an invalidate — always a bug, always loud.
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
// A fetched row's bundle must register the id its row names — a
// mis-stamped bundle registering under another entry's identity
// would let that entry silently materialize foreign exports.
if (this.executingId !== '' && handoff.id !== this.executingId) {
throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
}
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
this.factories.set(handoff.id, handoff.factory)
},
}
}
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
/** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: BootModuleRow): Promise<void> {
const { id, url } = row
const pending = this.pendingArrival.get(id)
if (pending !== undefined) return pending
if (this.factories.has(id)) return Promise.resolve()
const task = (async (): Promise<void> => {
const code = await this.fetchBundle(url)
this.executingUrl = url
this.executingId = id
try {
this.executeBundle(code, url)
} finally {
this.executingUrl = ''
this.executingId = ''
}
const task = this.loadBundle(url).then(() => {
if (!this.factories.has(id)) {
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
}
})().finally(() => { this.pendingArrival.delete(id) })
}).finally(() => { this.pendingArrival.delete(id) })
this.pendingArrival.set(id, task)
return task
}
@@ -158,7 +123,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
this.materializing.add(id)
try {
const edges = new Set<string>()
const surface = registered.factory(this.makeRequire(edges))
const surface = registered(this.makeRequire(edges))
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
this.loadCache.set(id, record)
return record

View File

@@ -2,8 +2,8 @@
* Node half of the client module system (dshClient dual-face package): scans
* the host Loader's entries for `dshClient` packages, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
* index render to inject the boot manifest, and provides the
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
* map, taps the index render to inject the boot manifest, and provides the
* `clientModuleHost` service (the HMR node half's registration/notification
* face).
*
@@ -424,9 +424,15 @@ export class ClientModuleHostService extends Service {
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
// The id may contain a scope slash. Anything else under /plugins (including
// /plugins/events when the HMR row is absent) is an unknown resource.
const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js')
? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length))
const prefix = '/plugins/'
const mapSuffix = '/client.js.map'
const bundleSuffix = '/client.js'
const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
const suffix = isSourceMap ? mapSuffix : bundleSuffix
const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix)
? this.clientPath(pathname.slice(prefix.length, -suffix.length))
: undefined
const path = clientPath === undefined ? undefined : `${clientPath}${isSourceMap ? '.map' : ''}`
if (path === undefined) {
res.writeHead(404)
res.end()
@@ -434,7 +440,10 @@ export class ClientModuleHostService extends Service {
}
try {
const body = await readFile(path)
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
res.writeHead(200, {
'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
})
res.end(body)
} catch {
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.

View File

@@ -4,7 +4,7 @@
* registers the factory), materialization on first import/require with
* memoization and recursive self-sequencing, the resolution branch order,
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
* default transport seams, and the loud failure modes (duplicate
* default transport seam, and the loud failure modes (duplicate
* registration, cycles, table misses, double boot).
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -20,7 +20,6 @@ type Factory = ClientPluginHandoff['factory']
afterEach(() => {
vi.unstubAllGlobals()
delete win.__ModuleLoader__
delete (document as unknown as Record<string, unknown>).__realmBridge
for (const el of document.querySelectorAll('style, script')) el.remove()
})
@@ -33,9 +32,9 @@ interface Bench {
}
/**
* Loader over scripted bundles: fetch resolves to the row url (optionally
* gated on a release callback); execute registers the scripted factory
* through the window sink (`null` scripts a bundle that never calls load).
* Loader over scripted bundles: load records the row URL, optionally waits on
* a release callback, then registers the scripted factory through the window
* sink (`null` scripts a bundle that never calls load).
*/
function bench(
entries: BootModuleRow[],
@@ -47,15 +46,12 @@ function bench(
const loader = new ClientModuleSystem({
modules: entries,
staticModules: opts.seed ?? {},
fetchBundle: (url) => {
loadBundle: async (url) => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
await new Promise<void>((resolve) => { gates.set(url, resolve) })
}
return Promise.resolve(url)
},
executeBundle: (code) => {
const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
const id = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
const factory = id === undefined ? undefined : bundles[id]
if (factory == null || id === undefined) return
win.__ModuleLoader__?.load({ id, factory })
@@ -65,7 +61,7 @@ function bench(
}
describe('lazy CJS arrival', () => {
it('prefetch fetches and executes but does not run the factory', async () => {
it('prefetch loads and registers but does not run the factory', async () => {
const ran: string[] = []
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
await b.loader.prefetch('a')
@@ -85,7 +81,7 @@ describe('lazy CJS arrival', () => {
expect(b.loader.loadCache.get('a')?.id).toBe('a')
})
it('import without prefetch fetches, executes, and materializes in one call', async () => {
it('import without prefetch loads, registers, and materializes in one call', async () => {
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
const surface = await b.loader.import('a', '', {})
expect((surface as { marker: string }).marker).toBe('direct')
@@ -228,7 +224,7 @@ describe('failure modes', () => {
})
describe('HMR reset', () => {
it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
it('invalidate drops the factory and record so the module reloads and re-registers', async () => {
let generation = 0
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
const first = await b.loader.import('a', '', {})
@@ -275,27 +271,35 @@ describe('style claiming', () => {
})
})
describe('default transport seams', () => {
it('fetches same-origin and executes through an inline script tag', async () => {
// In a browser the loader's globalThis IS the page window; vitest's jsdom
// evaluates <script> in a separate realm that shares only the document,
// so the fixture bundle restores the sink from a document bridge before
// using the normal calling convention.
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
describe('default transport seam', () => {
it('loads through an external classic script and removes the settled node', async () => {
const append = vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
const script = nodes[0]
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
expect(script.async).toBe(true)
expect(script.getAttribute('src')).toBe('/plugins/dee/client.js?rev=0')
queueMicrotask(() => {
win.__ModuleLoader__?.load({ id: 'dee', factory: () => ({ marker: 'via-script' }) })
script.dispatchEvent(new Event('load'))
})
})
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
const surface = await loader.import('dee', '', {})
expect((surface as { marker: string }).marker).toBe('via-script')
// The script node is removed right after its synchronous execution —
// repeated HMR rebuilds must not accumulate dead script nodes.
expect(append).toHaveBeenCalledOnce()
expect([...document.querySelectorAll('script')]).toEqual([])
})
it('a non-ok bundle response is loud with the status', async () => {
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
it('a script load failure is loud and removes the node', async () => {
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
const script = nodes[0]
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
queueMicrotask(() => { script.dispatchEvent(new Event('error')) })
})
const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
await expect(loader.prefetch('dee')).rejects.toThrow(
'bundle script /plugins/dee/client.js?rev=0 failed to load',
)
expect([...document.querySelectorAll('script')]).toEqual([])
})
})

View File

@@ -1,12 +1,13 @@
/** Node-half composition diagnostics for package metadata and built client bundles. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { ClientModuleHostService } from '../src/index.ts'
let root: string | undefined
@@ -33,8 +34,8 @@ function writePackage(packageName: string): string {
return clientPath
}
/** Construct the node-half service over the enabled fixture entries. */
function construct(packageNames: string[]): ClientModuleHostService {
/** Construct the node-half service and capture its plugin-bundle route. */
function constructWithRoute(packageNames: string[]): { service: ClientModuleHostService; route: WebRoute } {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(root!).href + '/'
ctx.provide('loader', {
@@ -44,13 +45,24 @@ function construct(packageNames: string[]): ClientModuleHostService {
}
},
})
let route: WebRoute | undefined
const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
port: 0,
register: () => () => {},
register: (candidate) => {
if (candidate.path === '/plugins') route = candidate
return () => {}
},
tapIndex: () => () => {},
}
ctx.provide('httpServer', httpServer as HttpServerService)
return new ClientModuleHostService(ctx)
const service = new ClientModuleHostService(ctx)
if (route === undefined) throw new Error('client bundle route was not registered')
return { service, route }
}
/** Construct the node-half service over the enabled fixture entries. */
function construct(packageNames: string[]): ClientModuleHostService {
return constructWithRoute(packageNames).service
}
describe('client bundle activation', () => {
@@ -84,4 +96,40 @@ describe('client bundle activation', () => {
expect(String(thrown)).toContain('EISDIR')
expect(String(thrown)).not.toContain('pnpm run build')
})
it('serves the source map beside a registered client bundle', async () => {
const packageName = '@fixture/source-map'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = {}\n')
const map = '{"version":3,"sources":["src/client/index.tsx"]}\n'
writeFileSync(`${clientPath}.map`, map)
const { route } = constructWithRoute([packageName])
let status = 0
let headers: Record<string, string> | undefined
let body = ''
const response = {
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
status = nextStatus
headers = nextHeaders
return response
},
end(chunk?: Uint8Array) {
body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8')
return response
},
} as unknown as ServerResponse
await route.handler({
method: 'GET',
url: `/plugins/${packageName}/client.js.map`,
} as IncomingMessage, response)
expect(status).toBe(200)
expect(headers).toEqual({
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-cache',
})
expect(body).toBe(map)
})
})

View File

@@ -8,7 +8,7 @@ import type {
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50
@@ -431,11 +431,3 @@ export class SessionHistorySource implements SessionHistoryFace {
return this.inspectionCache.value
}
}
function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}

View File

@@ -1,5 +1,6 @@
// Notifier: subscription + microtask-batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
// Notifier: subscription + batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush, while
// N markFrameDirty calls collapse into one animation-frame flush;
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
@@ -9,12 +10,13 @@
// swallow the notification — push subscribers (object-layer watchers) would
// otherwise starve whenever any reader pulls first.
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
/** Subscription + batched notification primitive (shared by Session and SessionManager). */
export class Notifier {
private listeners = new Set<() => void>()
private dirty = false
private notifyPending = false
private scheduled = false
private scheduled: 'none' | 'microtask' | 'frame' = 'none'
private scheduleGeneration = 0
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
constructor(private readonly rebuild: () => void) {}
@@ -35,19 +37,16 @@ export class Notifier {
markDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled) return
this.scheduled = true
queueMicrotask(() => {
this.scheduled = false
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
for (const listener of this.listeners) listener()
})
if (this.scheduled === 'microtask') return
this.schedule('microtask')
}
/** Stream-change entry: mark dirty and publish the cumulative state at most once per frame. */
markFrameDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled !== 'none') return
this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')
}
/**
@@ -57,11 +56,8 @@ export class Notifier {
notifyNow(): void {
this.dirty = true
this.notifyPending = true
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
this.notifyPending = false
this.dirty = false
this.rebuild()
for (const listener of this.listeners) listener()
this.invalidateSchedule()
this.flush()
}
/**
@@ -73,4 +69,35 @@ export class Notifier {
this.dirty = false
this.rebuild()
}
private schedule(kind: 'microtask' | 'frame'): void {
const generation = ++this.scheduleGeneration
this.scheduled = kind
const publish = () => {
if (generation !== this.scheduleGeneration) return
this.scheduled = 'none'
this.flush()
}
if (kind === 'frame') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
private invalidateSchedule(): void {
this.scheduleGeneration++
this.scheduled = 'none'
}
private flush(): void {
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
for (const listener of this.listeners) listener()
}
}

View File

@@ -6,6 +6,19 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
import { toAssistantBlock } from './conversation.ts'
/**
* Whether a stream chunk changes the partial assistant projection shown by the UI.
* @param type - Stream chunk discriminant.
* @returns Whether publishing the accumulated partial can change the visible snapshot.
*/
export function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}
/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
export class PartialAccumulator {
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.

View File

@@ -21,7 +21,7 @@ import { PendingWait } from './pending.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { displayFailureMessage } from './failure-display.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
@@ -687,6 +687,10 @@ export class Session implements SessionFace {
return
}
this.appendLive(event, view)
if (event.type === 'assistant/chunk') {
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
return
}
this.notifier.markDirty()
}

View File

@@ -1,13 +1,17 @@
/**
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
* laziness, synchronous notifyNow, and unsubscribe.
* Notifier: microtask/frame batching, rebuild-before-notify ordering,
* no-listener laziness, synchronous notifyNow, and unsubscribe.
*/
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Notifier } from '../src/client/sessions/notifier.ts'
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Notifier', () => {
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
const order: string[] = []
@@ -60,6 +64,57 @@ describe('Notifier', () => {
expect(rebuilds).toBe(1)
})
it('collapses frame-dirty changes into one cumulative frame publication', () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.markFrameDirty()
notifier.markFrameDirty()
notifier.markFrameDirty()
expect(order).toEqual([])
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(order).toEqual(['rebuild', 'notify'])
})
it('lets a structural microtask publication supersede a pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const notifier = new Notifier(() => undefined)
notifier.subscribe(() => { notifications++ })
notifier.markFrameDirty()
notifier.markDirty()
await microtask()
expect(notifications).toBe(1)
frames.shift()!(0)
expect(notifications).toBe(1)
})
it('falls back to microtask batching when animation frames are unavailable', async () => {
let notifications = 0
const notifier = new Notifier(() => undefined)
notifier.subscribe(() => { notifications++ })
notifier.markFrameDirty()
notifier.markFrameDirty()
expect(notifications).toBe(0)
await microtask()
expect(notifications).toBe(1)
})
it('unsubscribed listeners stop receiving notifications', async () => {
let calls = 0
const notifier = new Notifier(() => undefined)

View File

@@ -6,7 +6,7 @@
* enough.
*/
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
@@ -20,6 +20,10 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
const SID = 'fk-s1' as SessionId
const PARENT = 'fk-parent' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api) }
}
@@ -163,6 +167,40 @@ describe('live event path', () => {
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const { session } = await opened()
const published: Array<string | null> = []
session.subscribe(() => {
const block = session.getSnapshot().partial?.blocks[0]
published.push(block?.kind === 'text' ? block.text : null)
})
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.chunkStart(6, 1))
feed(ev.chunkText(7, 1, '累'))
feed(ev.chunkText(8, 1, '计'))
expect(published).toEqual([])
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(published).toEqual(['累计'])
feed(ev.chunkText(9, 1, '完成'))
feed(ev.assistant(10, 1, '累计完成'))
await Promise.resolve()
expect(published).toEqual(['累计', null])
frames.shift()!(0)
expect(published).toEqual(['累计', null])
})
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }

View File

@@ -9,7 +9,8 @@
* The virtual loader registers each real stylesheet as a watch dependency.
*/
import { readFile } from 'node:fs/promises'
import { basename, dirname, resolve as resolvePath } from 'node:path'
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
import { PLATFORM_MODULES } from './web/src/platform.ts'
@@ -45,6 +46,16 @@ const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url))
/** Rebase a physical lib-relative source onto the browser's repository-shaped URL tree. */
function browserSourcePath(source: string, sourcemapPath: string): string {
if (!source.startsWith('.')) return source
const physicalSource = resolvePath(dirname(sourcemapPath), source)
const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
return repositoryPath.startsWith('packages/') ? `../../../${repositoryPath}` : source
}
/**
* Build the tsdown config for one UI plugin package: the node-half lib build
* plus the browser client bundle. A package-level tsdown.config.ts REPLACES
@@ -78,6 +89,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
platform: 'browser',
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
dts: false,
// Plugin code is fetched outside Vite's module graph, so its own bundle
// must carry the TS/TSX mapping consumed by browser profiling tools.
sourcemap: true,
clean: false,
external: [...CLIENT_EXTERNALS],
// Browser bundles inline node-idiom deps (zustand/immer read
@@ -156,6 +170,11 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
}],
outputOptions: {
entryFileNames: 'client.js',
// The map is served from /plugins/<scoped-package>/client.js.map. The
// browser resolves its local sources back into the repository-shaped
// /packages/<group>/<package>/src tree; sourcesContent keeps them usable
// without exposing that tree as an HTTP route.
sourcemapPathTransform: browserSourcePath,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18
README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138
README.md: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d
README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246

View File

@@ -22,7 +22,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Both render sites show the same complete source list the one the tool returned and the model saw — bounded only by the card's own scroll container height, with no row-versus-panel cap ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), [source scroll](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md)).
A `read` call declaring the `read` render intent renders the returned file window inline, at both conversation render sites, through ui-primitives' `ReadBlock` — the line-numbered, syntax-highlighted content the tool projects. `contract/read-card-model.ts` is the single derivation from the snapshot's `resultView`; the read card is result-side only (a call carries no file content until `execute` returns), so a running read shows its summary alone and it yields null — the generic path — for a non-read result view or a `card` tag this client version does not know. The keyed `ReadRow` composes the shared `ToolRow`, feeding the card as ToolRow's `read` body, so it is the row's collapsed-by-default expanded card; the summary stays a path link that opens the file through the host. The render-site fallback and the details panel are read-aware too. Rows cap at `CHAT_READ_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md)).

View File

@@ -20,7 +20,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值wire 上不可信其为 `search``fetch`),它返回 null落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search``web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`8面板为 16与终端卡片所画的摘要面对阅读面的同一划分[决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值wire 上不可信其为 `search``fetch`),它返回 null落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search``web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片详情面板渲染它并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。两个渲染点显示同一份完整来源列表——工具返回、模型看到的那一份——仅受卡片自身滚动容器的高度约束,不存在行与面板的两级上限[决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)、[来源滚动](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md))。
声明 `read` 渲染意图的 `read` 调用,会在两个对话渲染点上都通过 ui-primitives 的 `ReadBlock` 内联渲染返回的文件窗口——工具投影出的带行号、语法高亮的内容。`contract/read-card-model.ts` 是从快照的 `resultView` 推导的唯一位置read 卡片是仅结果侧的(调用在 `execute` 返回前不携带文件内容),所以运行中的 read 只显示摘要,且对非 read 的 result view 或本客户端版本不认识的 `card` 标签返回 null落回通用路径。键控的 `ReadRow` 组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `read` body 传入,因此它是该行默认折叠的展开卡片;摘要仍是一个经 host 打开文件的路径链接。渲染点兜底行与详情面板同样感知 read。行的上限是 `CHAT_READ_MAX_LINES`8面板为 16[决策](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md))。

View File

@@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ViewTab } from './contract/views.ts'
import type {
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
@@ -113,10 +113,10 @@ export function apply(ctx: Context): void {
return () => { row.dispose() }
}, 'ui-conversation: Enter behavior settings row')
// Chat scroll offsets by session, surviving view switches (the chat view
// unmounts under the tab ring). Deliberately not persisted: a fresh page
// load should keep the open-jump-to-bottom default.
const chatScrollTops = new Map<SessionId, number>()
// Chat semantic reader positions by session, surviving view switches and
// width reflow when the tab ring remounts the view. Deliberately not
// persisted: a fresh page load keeps the open-jump-to-bottom default.
const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
@@ -316,11 +316,11 @@ export function apply(ctx: Context): void {
actions.setView('trajectory')
},
chatScroll: {
save: (top) => {
if (top === null) chatScrollTops.delete(sessionId)
else chatScrollTops.set(sessionId, top)
save: (position) => {
if (position === null) chatScrollPositions.delete(sessionId)
else chatScrollPositions.set(sessionId, position)
},
read: () => chatScrollTops.get(sessionId) ?? null,
read: () => chatScrollPositions.get(sessionId) ?? null,
},
forkAt: (seq) => {
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })

View File

@@ -45,6 +45,12 @@
gap: 16px;
}
/* Settled-flow identity boundary. It is neutral today and becomes the natural
measurement/mount unit for a virtualizer without changing the column gap. */
.flowItem {
min-width: 0;
}
.toolGroup {
display: flex;
flex-direction: column;

View File

@@ -44,6 +44,59 @@ function scrollerOf(from: HTMLElement): HTMLElement {
return (from.closest('[data-conversation-scroll]')) ?? from
}
interface PagingAnchor {
/** Stable node/call identity, independent of boundary-spanning group keys. */
key: string
/** Row top relative to the scrollport after the latest user scroll. */
top: number
}
/** Find an already-rendered settled row without interpolating a selector. */
function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')) {
if (row.dataset.chatAnchorKey === key) return row
}
return null
}
/** Row position in scrollport coordinates (viewport-independent). */
function flowTop(row: HTMLElement, scrollport: HTMLElement): number {
return row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top
}
/** Select a visible stable node/call identity, falling back only when layout
* has not exposed a visible box yet. */
function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | null {
const viewport = scrollport.getBoundingClientRect()
const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
// Scroll events are hot: hit-test a few points through the stretched flow
// rows before considering the full mounted set. The fallback keeps jsdom
// and pre-layout states deterministic; a virtualizer naturally bounds it.
if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) {
const content = list.getBoundingClientRect()
const left = Math.max(viewport.left, content.left)
const right = Math.min(viewport.right, content.right)
const x = left + Math.max(0, right - left) / 2
const height = visibleBottom - viewport.top
const points = [1, Math.min(32, height / 3), height / 2, Math.max(1, height - 1)]
for (const offset of points) {
for (const element of document.elementsFromPoint(x, viewport.top + offset)) {
const row = element instanceof HTMLElement
? element.closest<HTMLElement>('[data-chat-anchor-key]')
: null
if (row !== null && list.contains(row)) return row
}
}
}
const rows = [...list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
const visibleRows = rows.filter((row) => {
const rect = row.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < visibleBottom
})
return visibleRows[0] ?? rows[0] ?? null
}
type OpenFile = (path: string) => void
type InspectCall = (callId: string) => void
@@ -51,6 +104,8 @@ type InspectCall = (callId: string) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
/** ui-slots' UseSession is deliberately wide (dependency direction); the
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
@@ -66,6 +121,18 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n
return null
}
/** Capture a reflow-resistant reader position from the current rendered window. */
function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null {
const row = pagingAnchor(list, scrollport)
const anchorKey = row?.dataset.chatAnchorKey
if (row === null || anchorKey === undefined) return null
return {
anchorKey,
anchorTop: flowTop(row, scrollport),
scrollTop: scrollport.scrollTop,
}
}
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
@@ -86,7 +153,12 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
inspect: () => { inspectCall(node.callId) },
}), [node, toolName, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
<div
className={css.callRow}
data-chat-anchor-key={`call:${node.callId}`}
data-chat-call-id={node.callId}
data-selected={selected || undefined}
>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} t={t} />,
@@ -124,7 +196,12 @@ const CallRow = memo(function CallRow({
inspect: () => { inspectCall(callId) },
}), [callId, toolName, block, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
<div
className={css.callRow}
data-chat-anchor-key={`call:${callId}`}
data-chat-call-id={callId}
data-selected={selected || undefined}
>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} t={t} />,
@@ -213,17 +290,13 @@ function TurnStatus() {
)
}
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow, t }: {
/** The streaming partial, isolated so chunk batches re-render only this tail;
* the column ResizeObserver owns bottom-follow when its box grows. */
function StreamingTail({ useSession, t }: {
useSession: UseConversation
onGrow: () => void
t: ChatViewSlotProps['t']
}) {
const partial = useSession(s => s.partial)
useLayoutEffect(() => {
onGrow()
})
if (partial === null) return null
return <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
}
@@ -261,10 +334,17 @@ export function ChatView({
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
const [atBottom, setAtBottom] = useState(true)
/** Paging anchor: height/position at click, compensated after the prepend lands. */
const anchorRef = useRef<{ h: number; t: number } | null>(null)
/** Last position delivered or written on the main thread. */
const observedTopRef = useRef(0)
/** Pre-input position for the current wheel gesture. */
const wheelStartRef = useRef<number | null>(null)
const wheelEpochRef = useRef(0)
/** Paging anchor: semantic row/position at click, updated by reader scrolls
* while the request is pending and restored after the prepend lands. */
const anchorRef = useRef<PagingAnchor | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
@@ -281,9 +361,14 @@ export function ChatView({
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
const toBottom = (el: HTMLElement): void => {
wheelStartRef.current = null
wheelEpochRef.current += 1
anchorRef.current = null
el.scrollTop = el.scrollHeight
observedTopRef.current = el.scrollTop
atBottomRef.current = true
setAtBottom(true)
chatScroll.save(null)
}
useLayoutEffect(() => {
@@ -300,10 +385,16 @@ export function ChatView({
if (saved === null) {
toBottom(el)
} else {
el.scrollTop = saved
el.scrollTop = saved.scrollTop
const row = anchorElement(local, saved.anchorKey)
if (row !== null) el.scrollTop += flowTop(row, el) - saved.anchorTop
observedTopRef.current = el.scrollTop
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
const normalized = isAtBottom ? null : scrollPosition(local, el)
if (isAtBottom) chatScroll.save(null)
else if (normalized !== null) chatScroll.save(normalized)
}
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
@@ -311,10 +402,15 @@ export function ChatView({
followSigRef.current = followSig
return
}
// Prepend (head seq decreased): compensate by the height delta.
// Prepend (head seq decreased): preserve the same settled row at the
// position established by the reader's latest scroll. This excludes
// unrelated tail/composer growth while the request was in flight.
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
const anchor = anchorRef.current
anchorRef.current = null
const row = anchorElement(local, anchor.key)
if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top
observedTopRef.current = el.scrollTop
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastKey
@@ -343,26 +439,66 @@ export function ChatView({
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
if (local === null) return
const el = scrollerOf(local)
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
// Only wheel input may make raw scroll geometry change follow ownership.
// Browser clamping and delayed programmatic scroll events otherwise have
// the same event shape and must preserve the current ownership state.
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
const wheelStart = wheelStartRef.current
const movedByWheel = wheelStart !== null
&& Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5
const isAtBottom = movedByWheel
? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
: atBottomRef.current
if (!movedByWheel && isAtBottom) {
toBottom(el)
return
}
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
const position = isAtBottom ? null : scrollPosition(local, el)
if (isAtBottom) {
anchorRef.current = null
} else if (anchorRef.current !== null && position !== null) {
anchorRef.current = { key: position.anchorKey, top: position.anchorTop }
}
// Continuous save (unmount happens after ref detach, so saving there is
// too late); pinned-to-bottom clears so a remount keeps following.
chatScroll.save(isAtBottom ? null : el.scrollTop)
if (isAtBottom) chatScroll.save(null)
else if (position !== null) chatScroll.save(position)
observedTopRef.current = el.scrollTop
}
// Bind scroll to the resolved scrollport (host or local) once per mount.
// Bind scroll and the wheel provenance needed to distinguish reader input
// from layout-driven scrolls on the resolved scrollport once per mount.
useEffect(() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
if (local === null) return
const el = scrollerOf(local)
const onScroll = (): void => { onScrollRef.current() }
const onWheel = (event: WheelEvent): void => {
if (event.ctrlKey || event.deltaY === 0) return
const startTop = observedTopRef.current
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1
if (!canMove) return
wheelStartRef.current = startTop
const epoch = ++wheelEpochRef.current
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (wheelEpochRef.current === epoch) wheelStartRef.current = null
})
})
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => { el.removeEventListener('scroll', onScroll) }
el.addEventListener('wheel', onWheel, { capture: true, passive: true })
return () => {
wheelStartRef.current = null
el.removeEventListener('scroll', onScroll)
el.removeEventListener('wheel', onWheel, true)
}
}, [])
// Follow streaming growth the parent never re-renders for (stable ref).
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
@@ -371,16 +507,43 @@ export function ChatView({
if (local !== null && atBottomRef.current) {
const el = scrollerOf(local)
el.scrollTop = el.scrollHeight
observedTopRef.current = el.scrollTop
chatScroll.save(null)
}
}
const onGrow = useRef(() => followRef.current?.()).current
// Streaming, tool disclosures, and other flow changes resize the column;
// the sticky composer resizes outside it. This observer owns ChatView's
// dynamic-height follow decisions and writes only while the reader is pinned.
useEffect(() => {
const column = columnRef.current
const local = listRef.current
if (column === null || local === null || typeof ResizeObserver === 'undefined') return
const scrollport = scrollerOf(local)
const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
const observer = new ResizeObserver(() => { followRef.current?.() })
observer.observe(column)
if (composer !== null) observer.observe(composer)
return () => { observer.disconnect() }
}, [])
// A failed/empty page leaves the head unchanged. Once the request leaves
// its busy state there is no future prepend for the saved anchor to own.
useEffect(() => {
if (!loadingOlder) anchorRef.current = null
}, [loadingOlder])
const loadOlderAnchored = (): void => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (local !== null) {
const el = scrollerOf(local)
anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
const row = pagingAnchor(local, el)
if (row !== null && row.dataset.chatAnchorKey !== undefined) {
anchorRef.current = {
key: row.dataset.chatAnchorKey,
top: flowTop(row, el),
}
}
}
loadOlder()
}
@@ -392,7 +555,6 @@ export function ChatView({
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
return (
<ToolGroup
key={item.key}
renderSlot={renderSlot}
results={item.results}
openFile={openFile}
@@ -408,7 +570,6 @@ export function ChatView({
if (node.kind === 'assistant') {
return (
<AssistantMarkdown
key={item.key}
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
@@ -421,13 +582,12 @@ export function ChatView({
)
}
if (node.kind === 'command') {
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} t={t} />
return <CommandRow renderSlot={renderSlot} node={node} t={t} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return (
<MessageItem
key={item.key}
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
onFork={forkAt}
@@ -440,7 +600,7 @@ export function ChatView({
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll}>
<div className={css.column}>
<div ref={columnRef} className={css.column} data-chat-flow="">
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
{openState === 'error' && openError !== null && (
<div className={css.openError}>
@@ -454,8 +614,18 @@ export function ChatView({
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} t={t} />
{items.map(item => (
<div
key={item.key}
className={css.flowItem}
data-chat-anchor-key={item.kind === 'node' ? `node:${String(item.node.seq)}` : undefined}
data-chat-flow-key={item.key}
data-chat-flow-kind={item.kind === 'node' ? item.node.kind : 'tool-group'}
>
{renderItem(item)}
</div>
))}
<StreamingTail useSession={useSession} t={t} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (

View File

@@ -20,7 +20,7 @@
// independent); an error row's collapsed summary is the failure's first line in
// the error color.
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
@@ -30,10 +30,10 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts'
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
import { CHAT_WEB_MAX_SOURCES } from '../contract/web-card-model.ts'
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
import css from './ToolRow.module.css'
export interface ToolRowProps {
@@ -177,13 +177,17 @@ export function ToolRow({
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const isThink = variant === 'think'
const followSummaryEnd = isThink && state === 'running' && !open
useLayoutEffect(() => {
const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
const summaryElement = summaryRef.current
if (summaryElement === null) return
summaryElement.scrollLeft = followSummaryEnd
? summaryElement.scrollWidth - summaryElement.clientWidth
: 0
}, [followSummaryEnd, summaryText])
})
useEffect(() => {
if (!isThink) return
scheduleSummaryScroll()
}, [followSummaryEnd, isThink, scheduleSummaryScroll, summaryText])
const toggleExpand = () => {
setExpanded(v => !v)
}
@@ -276,7 +280,7 @@ export function ToolRow({
</>
)
: webBody !== null
? <WebBlock {...webBody} maxSources={CHAT_WEB_MAX_SOURCES} className={css.webBody} />
? <WebBlock {...webBody} className={css.webBody} />
: isThink
? <div className={css.thinkBody}>{body}</div>
: (

View File

@@ -0,0 +1,42 @@
/** Frame-throttled scheduling for non-essential visual alignment. */
import { useCallback, useLayoutEffect, useRef } from 'react'
const DEFAULT_INTERVAL_FRAMES = 3
/**
* Return a stable scheduler that coalesces visual updates over a frame interval.
* Repeated calls retain the latest callback, and unmount cancels pending work.
* @param update - DOM alignment to run after the throttle interval.
* @param intervalFrames - Frames to wait before applying the latest alignment.
* @returns a stable function that schedules the latest update.
*/
export function useThrottledVisualUpdate(
update: () => void,
intervalFrames = DEFAULT_INTERVAL_FRAMES,
): () => void {
const updateRef = useRef(update)
updateRef.current = update
const pendingFrameRef = useRef<number | null>(null)
useLayoutEffect(() => () => {
if (pendingFrameRef.current === null) return
cancelAnimationFrame(pendingFrameRef.current)
pendingFrameRef.current = null
}, [])
return useCallback(() => {
if (pendingFrameRef.current !== null) return
let remainingFrames = intervalFrames
const advance = (): void => {
remainingFrames -= 1
if (remainingFrames > 0) {
pendingFrameRef.current = requestAnimationFrame(advance)
return
}
pendingFrameRef.current = null
updateRef.current()
}
pendingFrameRef.current = requestAnimationFrame(advance)
}, [intervalFrames])
}

View File

@@ -435,6 +435,16 @@ export class PendingApproval {
export type ApprovalComposerProps =
PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'>
/** In-memory reader position resilient to transcript width reflow. */
export interface ChatScrollPosition {
/** Stable rendered node/call identity nearest the visible reading edge. */
readonly anchorKey: string
/** Anchor top relative to the transcript scrollport when saved. */
readonly anchorTop: number
/** Approximate offset used before the semantic anchor is measured. */
readonly scrollTop: number
}
/**
* Injected share of the chat view entry: the two callbacks whose targets live
* outside the view (layout orchestration; the session object layer).
@@ -456,10 +466,10 @@ export interface ChatViewInjected {
* fresh page load starts empty and keeps the open-jump-to-bottom default.
*/
chatScroll: {
/** Record the scroll offset; null clears it (pinned to bottom). */
save: (top: number | null) => void
/** Last recorded offset, or null when pinned or never recorded. */
read: () => number | null
/** Record a semantic reader position; null clears it when pinned. */
save: (position: ChatScrollPosition | null) => void
/** Last reader position, or null when pinned or never recorded. */
read: () => ChatScrollPosition | null
}
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
forkAt: (seq: number) => void

View File

@@ -15,16 +15,6 @@
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
/**
* Sources the chat row's web body shows before collapsing the middle — half
* the primitive's own default, which the details panel keeps. A chat row is a
* summary surface inside the message flow: the flow must stay scannable across
* many calls, while the details panel is the single-call reading surface. A
* design constant of this UI's row geometry, not a deployment choice, so it is
* fixed here rather than a plugin Config field.
*/
export const CHAT_WEB_MAX_SOURCES = 8
/**
* Derive the web-card props for a tool call, or null when this call is not a
* web card and belongs on the generic path.

View File

@@ -180,13 +180,12 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
)
}
const web = webCardModel(material.block)
// Full source-list allowance here (the panel is the single-call reading
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the
// panel also renders the flattened result content — the model-visible text
// the card does not carry verbatim (a web_fetch card shows only the URL and
// status, so its fetched body lives only here; a search card's answer and
// sources are structured, so the flattened form repeats them as the raw text
// the model saw).
// The card shows every source the tool returned (the same list the model saw),
// scrolling within its own capped height. Below the card the panel also renders
// the flattened result content — the model-visible text the card does not carry
// verbatim (a web_fetch card shows only the URL and status, so its fetched body
// lives only here; a search card's answer and sources are structured, so the
// flattened form repeats them as the raw text the model saw).
if (web !== null) {
const settled = 'kind' in material.block ? material.block : null
const body = settled === null ? '' : resultText(settled)

View File

@@ -92,11 +92,11 @@
box-shadow: var(--dsw-shadow-lv2);
font-size: 16px;
line-height: 24px;
/* Elevated surface in dark, same as the menus: the textarea inside scrolls
once the composer hits its height cap, so the thumb takes the l2 pair.
Declared on the card because the elevation belongs to the surface, and the
custom properties inherit down to the textarea that actually scrolls (see
ui-theme styles/scrollbar.css for the rebinding contract). */
/* Elevated surface in dark, same as the menus: the draft scrollport inside
scrolls once the composer hits its height cap, so the thumb takes the l2
pair. Declared on the card because the elevation belongs to the surface,
and the custom properties inherit down to the box that actually scrolls
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
@@ -116,8 +116,21 @@
height: 0;
}
/* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height
(min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea
/* The draft's scrollport, and the ONLY scrolling box in the composer: the
caret is the textarea's and every visible glyph is the backdrop's, so the two
layers stay together only by riding one offset the browser applies to both at
once. Scrolling one box and assigning the offset to the other cannot hold —
a wheel gesture is composited off the main thread, so the assignment lands
frames late and the words visibly trail the caret. The 14-line cap lives here
because this is the box the cap describes. */
.scroll {
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
}
/* Mirror-div auto-grow stack: the hidden mirror is in normal flow and sets the FULL draft
height (min 2 lines in hero); backdrop and textarea ride it absolutely, so both layers are
as tall as the draft and the scrollport above shows a window onto them. Mirror and textarea
MUST share font, line-height, padding and wrapping rules or heights diverge. */
.grow {
position: relative;
@@ -169,7 +182,11 @@
width: 100%;
height: 100%;
resize: none;
overflow-y: auto;
/* Never a scroller of its own: it is as tall as the draft, so it has no
scrollable overflow to hold an offset that could differ from the glyphs'.
The browser still reveals the caret — the scroll-into-view walks up to
.scroll and moves both layers together. */
overflow: hidden;
border: none;
outline: none;
background: transparent;
@@ -193,22 +210,24 @@
share the stack, so placeholder advances agree by construction. */
font-family: 'DshChipCell', var(--dsw-font-family);
font-size: inherit;
/* Three consumers, not two: the mirror sizes the stack, the layers must break
lines identically, and the caret reveal parses this value to step one line
down for a caret that sits after a newline. That parse needs a length, so a
theme resolving this to `normal` would make the reveal a silent no-op. */
line-height: inherit;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
/* These three MUST wrap at one width, because InputBar mirrors a single
scroll offset between .input and .backdrop and a layer that wraps onto
more lines is taller, has a larger scroll maximum, and clamps the mirrored
offset below the caret. Only .input scrolls, so only .input can lose
content width to a scrollbar that consumes layout space.
`scrollbar-gutter: stable` here does NOT buy that guarantee and was
removed after measuring: WebKit applies it to overflow-y:auto but not to
the overflow:hidden layers, so it left .input at 768 against 776 — the
same gap it was meant to close — while costing chromium 8px of text width
unconditionally. The gap it would have closed is measured and recorded in
the Agent Note (2026-07-31-composer-glyph-layer-tracks-the-textarea);
closing it needs one geometry every engine agrees on, not this property. */
/* These three MUST wrap at one width: the mirror decides the box height
the other two are laid out in, and a glyph layer that breaks lines
elsewhere than the textarea puts the words under the wrong caret. They do
so by construction now that all three sit INSIDE .scroll — a scrollbar
that consumes layout space narrows the scrollport, which is their shared
containing block, so it costs all three the same width on every engine.
Scrolling the textarea itself is what used to break this, and no property
fixed it: WebKit reserved gutter space for the overflow-y:auto textarea
and not for the overflow:hidden layers beside it, leaving them 8px apart
(768 against 776) — worth 2 to 5 wrapped lines on a long draft. */
}
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
@@ -226,10 +245,6 @@
.mirror {
visibility: hidden;
pointer-events: none;
/* 14-line cap, shared with the composer takeovers (declared on
ConversationRoot .composerSeat). */
max-height: var(--dsh-composer-text-max-height);
overflow: hidden;
}
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24

View File

@@ -63,7 +63,8 @@ export function InputBar({
const draft = input?.draft ?? ''
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const backdropRef = useRef<HTMLDivElement | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const mirrorRef = useRef<HTMLDivElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
const composingRef = useRef(false)
@@ -88,29 +89,101 @@ export function InputBar({
const locked = disabled
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
// Unlock (mount / session switch) returns focus to the box.
useEffect(() => {
if (!locked) inputRef.current?.focus()
}, [locked, sessionId])
// Scroll the draft scrollport the minimum that brings `caret` into view — the
// browser's own behavior for typing, performed for the paths where it does
// not act.
//
// The mirror is the caret's ruler: it renders the same draft at the same
// metrics and the same wrap width in the same stack (that is what makes it
// the height authority), so a Range collapsed at the caret's index reports
// where the caret is without a caret API.
const revealCaret = (caret: number): void => {
const scrollEl = scrollRef.current
const mirrorEl = mirrorRef.current
const text = mirrorEl?.firstChild
if (scrollEl === null || mirrorEl === null || !(text instanceof Text)) return
// A box that cannot scroll has nothing to reveal: the draft fits, so every
// caret is already in view and the assignment below would clamp to itself.
if (scrollEl.scrollHeight <= scrollEl.clientHeight) return
const at = Math.min(caret, text.data.length)
// A caret straight after a newline sits on a line with nothing on it to
// measure — the shape a trailing-newline draft ends in — and the engines
// disagree there: chromium returns NO client rects at all (an all-zero box,
// which would scroll the wrong way), firefox reports the line above, WebKit
// the right one. Measure the newline itself instead, which is the line the
// caret just left, and step one line down; that they all agree on.
const afterNewline = at > 0 && text.data[at - 1] === '\n'
const range = document.createRange()
range.setStart(text, afterNewline ? at - 1 : at)
if (afterNewline) range.setEnd(text, at)
else range.collapse(true)
const line = afterNewline ? Number.parseFloat(getComputedStyle(mirrorEl).lineHeight) : 0
const rect = range.getBoundingClientRect()
const box = scrollEl.getBoundingClientRect()
if (rect.bottom + line > box.bottom) scrollEl.scrollTop += rect.bottom + line - box.bottom
else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line
}
// Two DOM listeners on the textarea, one lifetime (it is never unmounted —
// the inert state renders the same element disabled).
//
// wheel — active conversation scrollport: chain the gesture. While the
// textarea (capped at 14 lines with overflow-y:auto) can still move in this
// direction, keep the native scroll; only at its own edge forward delta to
// the host so a short draft never traps the gesture and a long draft stays
// scrollable. Hero mounts have no host and keep native wheel scrolling.
//
// scroll — the backdrop paints every visible glyph (the textarea's own text
// is transparent) but is clipped, not scrolled, so it does not follow the
// textarea on its own: without this mirror a draft past the cap moves the
// caret while the words stay frozen in place. Every way the box moves ends
// in a `scroll` event, edits included (the caret is scrolled into view), and
// the layers share an extent, so a draft that shrinks past the offset clamps
// both to the same maximum — one listener covers the coupling.
// Reveal the focus end of the current selection. Today's entry paths leave a
// collapsed selection, but honoring direction keeps a future range-preserving
// path from revealing its anchor instead of its focus.
const revealSelectionFocus = (el: HTMLTextAreaElement): void => {
// selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
const caret = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd
// oxlint-disable-next-line typescript/no-unnecessary-condition
revealCaret(caret ?? el.value.length)
}
// Unlock (mount / session switch) returns focus to the box, and owns the
// reveal that comes with it. `preventScroll` because this focus is ours, not
// a gesture: the textarea is as tall as the draft, so the browser's reveal
// would walk up to the conversation scrollport and move the transcript under
// a user who only switched session. That leaves the caret to us — the DOM is
// reused across sessions, so switching to a longer draft keeps the previous
// offset while the value swap puts the caret at the new draft's end, which is
// off screen (measured on all three engines: offset 0 with the caret 940px
// down). Suppress the walk, then reveal in our own box.
useEffect(() => {
const el = inputRef.current
if (locked || el === null) return
el.focus({ preventScroll: true })
revealSelectionFocus(el)
}, [locked, sessionId])
// A persisted draft arrives AFTER the unlock effect: ConversationSession
// adopts it in its own mount effect, and a parent's mount effect runs after
// its children's. Reveal when the draft becomes non-empty so a restored long
// draft does not stay at its head with the caret at its end. This effect does
// not focus: send-clear, failed-send restore, and first-character transitions
// must not steal focus from another control the user moved to.
useEffect(() => {
const el = inputRef.current
if (locked || draft === '' || el === null) return
revealSelectionFocus(el)
}, [draft !== ''])
// Caret restore after an edit the composer performs itself. The machine owns
// the draft and the undo log, so paste and cut suppress the native edit and
// write the value through the machine — and a
// programmatic selection change reveals nothing: measured in chromium and
// WebKit, pasting a long block leaves the view where it was while the caret
// sits at the end of the draft. Native typing gets its reveal from the
// browser; these two have to ask for it, so they share one restore.
const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => {
requestAnimationFrame(() => {
el.setSelectionRange(caret, caret)
revealCaret(caret)
})
}
// Wheel chaining on the draft scrollport, one lifetime (it is never
// unmounted — the inert state renders the same element disabled). While the
// capped box can still move in this direction, keep the native scroll; only
// at its own edge forward the delta to the active conversation scrollport, so
// a short draft never traps the gesture and a long draft stays scrollable.
// Hero mounts have no host and keep native wheel scrolling.
useEffect(() => {
const el = scrollRef.current
if (el === null) return
const onWheel = (e: WheelEvent): void => {
const host = el.closest('[data-conversation-scroll]')
@@ -121,16 +194,8 @@ export function InputBar({
e.preventDefault()
host.scrollTop += e.deltaY
}
const onScroll = (): void => {
const backdropEl = backdropRef.current
if (backdropEl !== null) backdropEl.scrollTop = el.scrollTop
}
el.addEventListener('wheel', onWheel, { passive: false })
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('scroll', onScroll)
}
return () => { el.removeEventListener('wheel', onWheel) }
}, [])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
@@ -234,7 +299,7 @@ export function InputBar({
e.clipboardData.setData('text/plain', text)
if (cut && !machineBusy && !locked) {
keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
requestAnimationFrame(() => { el.setSelectionRange(start, start) })
restoreCaret(el, start)
}
void slice
}
@@ -253,7 +318,7 @@ export function InputBar({
// land (paste-upgrade). The DOM layer only starts the transaction.
keyboard.pasteBegin(text, sel)
const caret = sel.start + text.length
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
restoreCaret(el, caret)
keyboard.track(keyboard.snapshot.draft, caret)
}
@@ -264,10 +329,13 @@ export function InputBar({
void e
}
// Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly.
// Button presses steal focus from the textarea; suppress at mousedown so
// typing continues seamlessly. `preventScroll` for the same reason as the
// unlock effect, and with no reveal of its own: the caret has not moved, and
// the next keystroke gets the browser's native one.
const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault()
inputRef.current?.focus()
inputRef.current?.focus({ preventScroll: true })
}
const onToggleCommandMenu = (): void => {
@@ -369,22 +437,6 @@ export function InputBar({
const displayHint = translated !== hintKey ? translated : deco.hint
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
}
// Trailing-line sentinel, the same one the mirror div carries and for the
// same reason: a textarea reserves a line box for the caret after a final
// newline, while `white-space: pre-wrap` collapses a text node's trailing
// newline and generates none. Without it a draft ending in a newline makes
// the backdrop exactly one line SHORTER than the textarea, so mirroring the
// offset at the very bottom clamps and the glyphs sit a line behind the
// caret. The extra newline is absorbed by that same collapse when the draft
// does not end in one, so it costs no height in the ordinary case.
//
// The mirror only fails one way — a backdrop SHORTER than the textarea
// clamps the assignment, while a taller one takes every offset exactly and
// hides the surplus below the clip. That is why the ghost hint needs no
// handling of its own: it can only add content after the draft and before
// this sentinel, never remove a line box, so it moves the pair to equal or
// to the safe side.
backdrop.push('\n')
}
return (
@@ -402,32 +454,38 @@ export function InputBar({
<div className={css.card} data-composer-card>
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
rows by '\n' cannot see soft wraps. */}
<div className={css.grow}>
<div ref={backdropRef} aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<textarea
ref={inputRef}
className={css.input}
value={draft}
disabled={locked}
readOnly={machineBusy}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
onSelect={onSelect}
onCopy={(e) => { onCopyOrCut(e, false) }}
onCut={(e) => { onCopyOrCut(e, true) }}
onPaste={onPaste}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the
absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14
lines in CSS — is the only thing that scrolls. The caret belongs to the textarea and the
glyphs to the backdrop, so they can only stay together by moving together: one scroll
offset the browser applies to both layers at once, never a JS mirror between two boxes,
which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
<div ref={scrollRef} className={css.scroll} data-input-scroll>
<div className={css.grow}>
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<textarea
ref={inputRef}
className={css.input}
value={draft}
disabled={locked}
readOnly={machineBusy}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
onSelect={onSelect}
onCopy={(e) => { onCopyOrCut(e, false) }}
onCut={(e) => { onCopyOrCut(e, true) }}
onPaste={onPaste}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
<div ref={mirrorRef} aria-hidden className={css.mirror} data-input-mirror>{`${draft}\n`}</div>
</div>
</div>
<div className={css.row}>
<div className={css.tools}>

View File

@@ -1,8 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, 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 { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -12,6 +11,36 @@ import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
let nextAnimationFrameId = 1
let animationFrames = new Map<number, FrameRequestCallback>()
function flushAnimationFrames(count: number): void {
for (let index = 0; index < count; index += 1) {
const callbacks = [...animationFrames.values()]
animationFrames.clear()
for (const callback of callbacks) callback(index)
}
}
beforeEach(() => {
nextAnimationFrameId = 1
animationFrames = new Map()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextAnimationFrameId
nextAnimationFrameId += 1
animationFrames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
animationFrames.delete(id)
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
@@ -341,6 +370,10 @@ describe('ThinkRow', () => {
streaming
/>,
)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(2)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(1)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
@@ -351,6 +384,7 @@ describe('ThinkRow', () => {
streaming={false}
/>,
)
flushAnimationFrames(3)
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)

View File

@@ -22,7 +22,10 @@ import { ChatView } from '../src/client/chat/ChatView.tsx'
import { zh } from '../src/client/locales.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// Keyless create() persists under the bare declared key; clear between cases
// so one harness's selection cannot rehydrate into the next.
beforeEach(() => {
@@ -112,10 +115,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const loadOlder = vi.fn()
const inspectCall = vi.fn<(callId: string) => void>()
// In-memory scroll memory matching the apply.ts per-session map contract.
let savedScrollTop: number | null = null
const chatScroll = {
save: (top: number | null) => { savedScrollTop = top },
read: () => savedScrollTop,
let savedScroll: ReturnType<ChatViewSlotProps['chatScroll']['read']> = null
const chatScroll: ChatViewSlotProps['chatScroll'] = {
save: (position) => { savedScroll = position },
read: () => savedScroll,
}
const forkAt = vi.fn()
// Selection rides the REAL chat store (same construction path as
@@ -154,6 +157,32 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
}
/** Simulate reader input before the browser delivers the host scroll event. */
function readerScroll(element: HTMLElement, top: number): void {
fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
element.scrollTop = top
fireEvent.scroll(element)
}
function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
let scrollHeight = initialHeight
let scrollTop = 0
Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => scrollHeight })
Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => clientHeight })
Object.defineProperty(element, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) },
})
return {
setHeight: (value: number) => { scrollHeight = value },
setLayout: (height: number, top: number) => {
scrollHeight = height
scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
},
}
}
describe('chat-flow derivation', () => {
it('groups consecutive tool results and keeps stable keys', () => {
const nodes: ConversationNode[] = [
@@ -247,20 +276,36 @@ describe('ChatView', () => {
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 })
it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement
const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement
let firstTop = 100
let nextTop = 300
vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
() => ({ top: 0, bottom: 200 } as DOMRect),
)
vi.spyOn(first, 'getBoundingClientRect').mockImplementation(
() => ({ top: firstTop, bottom: firstTop + 40 } as DOMRect),
)
vi.spyOn(next, 'getBoundingClientRect').mockImplementation(
() => ({ top: nextTop, bottom: nextTop + 40 } as DOMRect),
)
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
scroller.scrollTop = 50
fireEvent.scroll(scroller)
readerScroll(scroller, 50)
fireEvent.click(view.getByText('加载更早'))
// The reader moves after the request starts; this, not the click-time
// row, is the intent the arriving page must preserve.
firstTop = -200
nextTop = 60
readerScroll(scroller, 90)
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)
nextTop = 560
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) })
expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
})
it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
@@ -272,6 +317,18 @@ describe('ChatView', () => {
expect(view.getByText('running tools')).toBeTruthy()
expect(view.getAllByText('Bash')).toHaveLength(2)
expect(view.getByText('run a')).toBeTruthy()
expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
key: row.getAttribute('data-chat-flow-key'),
kind: row.getAttribute('data-chat-flow-kind'),
}))).toEqual([
{ key: 'n1', kind: 'user' },
{ key: 'n2', kind: 'assistant' },
{ key: 'g3', kind: 'tool-group' },
])
expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id')))
.toEqual(['a', 'b'])
expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
.toEqual(['node:1', 'node:2', 'call:a', 'call:b'])
})
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
@@ -622,31 +679,106 @@ describe('ChatView', () => {
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
})
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
it('prepend preserves a semantic row; 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 })
const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement
let anchoredTop = 100
vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
() => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
)
readerScroll(scroller, 80)
// Arm the paging anchor, then deliver an older page (head seq decreases).
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
anchoredTop = 700
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift
// 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('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => {
const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
let prepended = false
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'call:late') {
const top = prepended ? 400 : 100
return { top, bottom: top + 40 } as DOMRect
}
return { top: 0, bottom: 200 } as DOMRect
})
try {
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
readerScroll(scroller, 80)
fireEvent.click(view.getByText('加载更早'))
// Total height grows by 500, but only 300 belongs before the call row.
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
prepended = true
act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) })
expect(scroller.scrollTop).toBe(380)
} finally {
rect.mockRestore()
}
})
it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => {
const h = makeHarness({ nodes: [retry(5)], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
let prepended = false
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'node:5') {
const top = prepended ? 400 : 100
return { top, bottom: top + 40 } as DOMRect
}
return { top: 0, bottom: 200 } as DOMRect
})
try {
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
readerScroll(scroller, 80)
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
prepended = true
act(() => { h.set({ nodes: [retry(4), retry(5)] }) })
expect(scroller.scrollTop).toBe(380)
expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull()
} finally {
rect.mockRestore()
}
})
it('back-to-bottom cancels an in-flight paging anchor', () => {
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 })
readerScroll(scroller, 50)
fireEvent.click(view.getByText('加载更早'))
fireEvent.click(view.getByLabelText('回到底部'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
expect(scroller.scrollTop).toBe(1_300)
expect(h.chatScroll.read()).toBeNull()
})
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)
readerScroll(scroller, 100) // far from bottom
const backButton = view.getByLabelText('回到底部')
expect(backButton).toBeTruthy()
// Streaming growth must NOT drag a scrolled-away reader down.
@@ -658,6 +790,71 @@ describe('ChatView', () => {
expect(view.queryByLabelText('回到底部')).toBeNull()
})
it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
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
const metrics = installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
fireEvent.scroll(scroller)
// The wheel cannot move farther down. A stream-finalization shrink clamps
// the old position, then reflow grows the layout before scroll delivery.
fireEvent.wheel(scroller, { deltaY: 120 })
metrics.setLayout(1_040, 500)
fireEvent.scroll(scroller)
expect(scroller.scrollTop).toBe(740)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(h.chatScroll.read()).toBeNull()
metrics.setHeight(1_200)
act(() => { h.set({ running: true }) })
expect(scroller.scrollTop).toBe(900)
})
it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
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
installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
fireEvent.scroll(scroller)
scroller.scrollTop = 500
fireEvent.wheel(scroller, { deltaY: -200 })
fireEvent.scroll(scroller)
expect(view.getByLabelText('回到底部')).toBeTruthy()
})
it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
let notify: (() => void) | undefined
const observe = vi.fn()
class ResizeObserverStub {
constructor(callback: ResizeObserverCallback) {
notify = () => { callback([], this as unknown as ResizeObserver) }
}
observe = observe
disconnect = vi.fn()
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
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: 1_000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
scroller.scrollTop = 700
fireEvent.scroll(scroller)
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
act(() => { notify?.() })
expect(scroller.scrollTop).toBe(1_200)
readerScroll(scroller, 200)
Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
act(() => { notify?.() })
expect(scroller.scrollTop).toBe(200)
expect(observe).toHaveBeenCalledTimes(1)
})
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
@@ -666,8 +863,7 @@ describe('ChatView', () => {
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
// re-render from setAtBottom must not force scrollTop to scrollHeight.
scroller.scrollTop = 690 // distance-to-bottom = 10
fireEvent.scroll(scroller)
readerScroll(scroller, 690) // distance-to-bottom = 10
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(scroller.scrollTop).toBe(690)
})
@@ -684,8 +880,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />, { container: host })
// Open jump uses the host, not the local .scroll node.
expect(host.scrollTop).toBe(2000)
host.scrollTop = 100
fireEvent.scroll(host)
readerScroll(host, 100)
expect(view.getByLabelText('回到底部')).toBeTruthy()
fireEvent.click(view.getByLabelText('回到底部'))
expect(host.scrollTop).toBe(2000)
@@ -694,29 +889,72 @@ describe('ChatView', () => {
}
})
it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
it('a remount restores the saved semantic row after width reflow', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
document.body.appendChild(host)
let anchorTop = 80
vi.spyOn(host, 'getBoundingClientRect').mockImplementation(
() => ({ top: 0, bottom: 500 } as DOMRect),
)
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'node:1') {
return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
}
return { top: 0, bottom: 40 } as DOMRect
})
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
// Fresh open (nothing saved): the bottom jump stands.
const view = render(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(2000)
// Reader scrolls up; the position is recorded continuously.
host.scrollTop = 100
fireEvent.scroll(host)
readerScroll(host, 100)
// View-tab switch away and back: the view unmounts, then remounts.
view.rerender(<div />)
anchorTop = 560
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(100)
expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift
// The restored position is above the floor: follow stays disarmed.
expect(view.getByLabelText('回到底部')).toBeTruthy()
} finally {
rect.mockRestore()
host.remove()
}
})
it('normalizes a semantic restore clamped to the bottom before an immediate remount', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
let scrollTop = 0
Object.defineProperty(host, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = Math.min(value, 1_500) },
})
document.body.appendChild(host)
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect
return { top: 0, bottom: 500 } as DOMRect
})
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 })
const view = render(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(1_500)
expect(h.chatScroll.read()).toBeNull()
view.rerender(<div />)
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(1_500)
} finally {
rect.mockRestore()
host.remove()
}
})

View File

@@ -4,7 +4,7 @@
// semantics (input stays free; primary turns stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,6 +18,18 @@ import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
// jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
// is absent — and the composer measures the caret with one when it restores the
// selection after an edit it performed itself. Every case here runs against a
// zero rect; the reveal case below substitutes its own and restores this one.
const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
Range.prototype.getBoundingClientRect = ZERO_RECT
// Read through the descriptor so the native method is never referenced unbound;
// the reveal case below wraps it to record what it was asked to measure.
const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
.value as (this: Range, node: Node, offset: number) => void
const SCTX = {} as ClientContext
const SID = 's1' as SessionId
@@ -321,7 +333,7 @@ describe('running and lock semantics (queue cut 1)', () => {
expect((textarea).value).toBe('typed')
})
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
it('wheel over a non-overflowing draft forwards to the conversation host', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
@@ -337,17 +349,18 @@ describe('running and lock semantics (queue cut 1)', () => {
}
})
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
it('wheel chains: long drafts scroll inside the draft scrollport until each edge, then the host', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
const { view, textarea } = bench()
host.appendChild(view.container)
document.body.appendChild(host)
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
const scrollport = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
Object.defineProperty(scrollport, 'clientHeight', { value: 100, configurable: true })
Object.defineProperty(scrollport, 'scrollHeight', { value: 400, configurable: true })
let scrollTop = 150
Object.defineProperty(textarea, 'scrollTop', {
Object.defineProperty(scrollport, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = value },
@@ -371,35 +384,151 @@ describe('running and lock semantics (queue cut 1)', () => {
}
})
it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
it('the caret layer and the glyph layer ride one scrollport', () => {
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
Object.defineProperty(backdrop, 'scrollTop', { value: 0, writable: true, configurable: true })
Object.defineProperty(textarea, 'scrollTop', { value: 0, writable: true, configurable: true })
// A scrolled draft: the textarea moves, the clipped backdrop must follow.
textarea.scrollTop = 120
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(120)
// Every later move tracks too, including back to the top — a one-shot
// mirror would leave the glyphs parked at the first offset it saw.
textarea.scrollTop = 0
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(0)
// The caret is the textarea's and every visible glyph is the backdrop's, so
// one box has to carry both or an offset can exist in one and not the other.
// jsdom has no layout and loads no stylesheet — which box scrolls is the
// browser scenario's to assert; what is checkable here is that the
// scrollport element holds both layers.
expect(scroll.contains(textarea)).toBe(true)
expect(scroll.contains(backdrop)).toBe(true)
// The glyph layer carries the draft and nothing else: with one scrollport
// it no longer pads its own height to match a second box's scroll extent.
expect(backdrop.textContent).toBe('line\n'.repeat(40))
})
it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => {
// jsdom has no layout, so the HEIGHTS this protects cannot be asserted here
// (the browser scenario owns that); what is checkable is that the backdrop's
// text is the draft plus exactly one newline. A textarea reserves a line box
// after a final newline and `pre-wrap` collapses one, so without the
// sentinel a draft ending in a newline leaves the backdrop a line short and
// the mirrored offset clamps.
const withNewline = bench({ draft: 'alpha\nbeta\n' })
const backdrop = withNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
expect(backdrop.textContent).toBe('alpha\nbeta\n\n')
const withoutNewline = bench({ draft: 'alpha\nbeta' })
const plain = withoutNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
expect(plain.textContent).toBe('alpha\nbeta\n')
it('an edit the composer performs itself scrolls the caret back into view', async () => {
// Paste and cut suppress the native edit, so no engine reveals the caret
// for them. jsdom has no layout: the rects are stubbed,
// and what is asserted is the arithmetic — minimal scroll, in both
// directions, and nothing at all for a caret already inside the box.
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
expect(mirror.firstChild).toBeInstanceOf(Text)
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
// jsdom reports scrollHeight === clientHeight for every element, which is
// the composer's own "nothing to reveal" case; a scrollable box is what
// puts the reveal on the table at all.
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
onTestFinished(() => {
Range.prototype.getBoundingClientRect = ZERO_RECT
Range.prototype.setStart = NATIVE_SET_START
})
// Which layer the caret is measured against, and at which index: the stub
// records `setStart` so a helper that measured the backdrop instead, or
// always collapsed at 0, fails here rather than only in the browser lane.
let measured: { node: Node; offset: number } | null = null
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
measured = { node, offset }
NATIVE_SET_START.call(this, node, offset)
}
const caretAt = (top: number): void => {
Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
}
const settle = async (): Promise<void> => {
await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
}
// Pasted text lands below the fold: scroll down by exactly the overshoot.
caretAt(500)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
await settle()
expect(scroll.scrollTop).toBe(88) // 524 - 436
// Measured on the mirror's own text, at the index the paste left the caret
// (an empty draft's selection start, 0, plus the pasted length).
expect(measured!.node).toBe(mirror.firstChild)
expect(measured!.offset).toBe('pasted'.length)
// A caret already inside the box does not move it.
caretAt(200)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
await settle()
expect(scroll.scrollTop).toBe(88)
// Above the fold (a cut can leave it there): scroll back up.
caretAt(60)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
await settle()
expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
// A caret straight after a newline has nothing on its line to measure, so
// the newline it just left is measured instead and one line is added.
// chromium reports no client rects at all for the collapsed position.
mirror.style.lineHeight = '24px'
caretAt(500)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
await settle()
// The four pastes accumulate at the draft's head, so the caret is at the
// end of what they inserted — and the measured index is the newline before it.
expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
})
it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
// The composer DOM is reused across sessions, so the previous session's
// offset survives while the value swap puts the caret at the new draft's
// end. `preventScroll` keeps the browser from revealing it through the
// conversation scrollport, which leaves the reveal to the effect itself.
const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
// The draft ends in a newline, so the reveal takes the after-newline path
// and needs a resolvable line-height (jsdom computes `normal`).
mirror.style.lineHeight = '24px'
// Which index the effect reveals at, not merely that it scrolled: a
// revealCaret(0) would land the same offset without this.
onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
let measured: { node: Node; offset: number } | null = null
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
measured = { node, offset }
NATIVE_SET_START.call(this, node, offset)
}
const focused: (boolean | undefined)[] = []
textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
expect(focused).toEqual([true])
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
// The draft ends in a newline, so the rule measures that newline: the
// caret's own index is the mirror text's length minus its sentinel.
expect(measured!.node).toBe(mirror.firstChild)
expect(measured!.offset).toBe(textarea.value.length - 1)
})
it('a persisted draft adopted after mount gets its caret revealed too', () => {
// ConversationSession seeds the stored draft in its own mount effect, which
// runs after this component's: the first reveal measures an empty mirror,
// so the draft's arrival has to run it again without reclaiming focus.
const { view, textarea, shell } = bench()
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
// The restored draft ends in a newline, so the reveal takes the
// after-newline path and needs a resolvable line-height (jsdom says `normal`).
mirror.style.lineHeight = '24px'
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
const other = document.createElement('input')
document.body.appendChild(other)
onTestFinished(() => { other.remove() })
other.focus()
expect(scroll.scrollTop).toBe(0)
act(() => { shell.setDraft('restored\n'.repeat(40)) })
expect(document.activeElement).toBe(other)
// The caret the machine left at the draft's end, revealed once the draft exists.
expect(textarea.selectionStart).toBe(textarea.value.length)
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {

View File

@@ -17,7 +17,7 @@ import type {
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
import { webCardModel } from '../src/client/contract/web-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
@@ -135,8 +135,7 @@ describe('chat row web body', () => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('the WebRow collapses to the summary row, expanding to the search card capped tighter than the panel', () => {
expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16)
it('the WebRow collapses to the summary row, expanding to the full search card', () => {
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
// Collapsed: the summary row alone, no card in the DOM.
expect(view.getByText('Search')).toBeTruthy()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 7318acd9b9a6047b1144789bcd2655132237f6c5
README.zh.md: e326846dc2099472bc0a81dff093ff24b614559b
README.md: 00e9560f43c83e1edc61c185a4fc562c6c923e8b
README.zh.md: 21226ab211106b7722139828762605cb71a4b498

View File

@@ -30,7 +30,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Web retrieval
`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `<li value>`, and the expand control is a marker-less `<li>` so the `<ol>` stays valid HTML. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `<ol>` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md).
`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. The whole list renders in one fixed-height scroll container (`max-height: 320px`, `overflow-y: auto`), so a list taller than that scrolls vertically in place instead of growing the card; `<li value>` pins each source's citation number, contiguous from 1, rather than leaving it to the `<ol>`'s implicit count. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `<ol>` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) and [the source scroll note](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md).
## Model Experience
@@ -45,5 +45,5 @@ None; this package neither assembles nor sends a provider request.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment.
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source-list and fetch truncation notes and its empty-search note stay inline Chinese, pending the same label-prop treatment.
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.

View File

@@ -30,7 +30,7 @@
## Web 检索
`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind`kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL时回退到原始 URL因此标签绝不为空其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `<li value>` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `<li>`,使 `<ol>` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `<ol>`chat 行不呈现原始 result content`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。
`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind`kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL时回退到原始 URL因此标签绝不为空其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。整份列表渲染在一个定高滚动容器里(`max-height: 320px``overflow-y: auto`),因此超出该高度的列表在原地纵向滚动,而不是把卡片撑高;`<li value>` 固定每个 source 的引用编号,从 1 起连续,而不依赖 `<ol>` 的隐式计数。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `<ol>`chat 行不呈现原始 result content`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)与[来源滚动笔记](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md)
## 模型体验
@@ -45,5 +45,5 @@
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**尚未实现已交付的四种状态done/warning/ongoing/error构成完整的 P-I 表层。
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard``copyLabel`/`copiedLabel`)、`TerminalBlock``labels`)、`JsonTree``labels`)、`CodeBlock``copyLabel`/`copiedLabel`)、`MarkdownText``codeLabels`)、`JsonBlock``truncatedLabel`)、`ConnectionBanner``label`)和 `Modal``closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard``copyLabel`/`copiedLabel`)、`TerminalBlock``labels`)、`JsonTree``labels`)、`CodeBlock``copyLabel`/`copiedLabel`)、`MarkdownText``codeLabels`)、`JsonBlock``truncatedLabel`)、`ConnectionBanner``label`)和 `Modal``closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
- **`TerminalBlock` 不是终端模拟器**它渲染已结束或仍在运行的命令输出而不是交互式会话SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token保持字面 rgb。

View File

@@ -1,7 +1,8 @@
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface,
16px vertical margin) so a web card, a terminal card, and a fenced code block
read as one family. A source list is prose, not aligned output, so it wraps
normally rather than scrolling horizontally like a terminal card's output. */
read as one family. A source list is prose, not aligned output, so each row
wraps horizontally rather than scrolling sideways like a terminal card; the
list as a whole scrolls vertically within a capped height (see .sources). */
.block {
--dsl-web-radius: 12px;
@@ -27,13 +28,29 @@
margin-bottom: 0;
}
/* The citation list: ordered so each source reads as a numbered reference. */
/* The citation list: ordered so each source reads as a numbered reference. The
whole list — the sources the tool returned, matching what the model saw —
renders here; a max-height caps the card so a long list scrolls in place
rather than growing the card unbounded. The height is a design constant of the
card's geometry, not a deployment choice, so it lives here rather than a plugin
config field.
`overflow-y` makes this a scroll container, which also clips inline-start
overflow: a marker wider than `padding-left` loses its leading digits with no
way to scroll them back. Markers are right-aligned to the content edge, so the
padding must fit the widest one the list can produce. `searchMaxResults` is an
unbounded positive integer, so the padding is sized in `em` — against this
element's own font, the one a marker inherits — to hold a three-digit marker
(`999. ` measures 2.35em in the app font stack) plus the gap the one-digit
case already had. */
.sources {
margin: 0;
padding-left: 20px;
padding-left: 2.5em;
display: flex;
flex-direction: column;
gap: 10px;
max-height: 320px;
overflow-y: auto;
}
.source {
@@ -65,26 +82,6 @@
font: var(--dsw-font-xs-13);
}
.expandItem {
list-style: none;
}
.expand {
display: block;
width: 100%;
padding: 0;
border: none;
background-color: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
text-align: left;
}
.expand:hover {
color: var(--dsw-alias-label-secondary);
}
.truncated {
margin-top: 8px;
color: var(--dsw-alias-label-tertiary);

View File

@@ -9,22 +9,20 @@
// allowlist MarkdownText applies to untrusted assistant-authored links (it also
// permits mailto, excluded here); an unparseable or non-http URL renders as
// plain text. Geometry, radius, and fonts mirror CodeBlock/TerminalBlock so a
// web card reads as one family with them; a long source list caps at maxSources
// with a head/tail collapse using the same arithmetic as TerminalBlock's output
// cap.
// web card reads as one family with them; the whole source list renders inside a
// fixed-height scroll container (its `.sources` max-height), so a long list
// scrolls in place rather than growing the card — and that container's
// `padding-left` must stay wide enough for the widest `<li>` marker, since a
// scroll container clips inline-start overflow irrecoverably. The card draws every source the
// view carries: the tool already cut the list to its source cap, and `truncated`
// reports that cut. A content-only transform downstream of the tool — spill-policy
// replacing an oversized result's text while leaving its presentationMeta whole —
// can still narrow what the model reads below this list.
import { useCallback, useState } from 'react'
import clsx from 'clsx'
import { MarkdownText } from './markdown/MarkdownText.tsx'
import css from './WebBlock.module.css'
/**
* Sources shown before the height cap collapses the middle of a citation list.
* Matches TerminalBlock's default output budget so both cards cut a long body
* at the same place; the chat row narrows it through the maxSources prop.
*/
export const DEFAULT_WEB_MAX_SOURCES = 16
/**
* One citeable source drawn in a search card: the projection of the contract's
* `WebSource`, with the optional fields kept optional so a provider that
@@ -50,8 +48,6 @@ export interface WebSearchBlockProps {
sources: WebSourceView[]
/** True when the tool cut the source list to its result cap. */
truncated: boolean
/** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */
maxSources?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
@@ -65,13 +61,6 @@ export interface WebFetchBlockProps {
statusCode: number
/** True when the provider or the output cap cut the fetched content. */
truncated: boolean
/**
* Accepted and ignored, so both card kinds take one uniform prop set (a fetch
* card has no source list to cap) — the same way TerminalBlock accepts one
* `maxLines` across its arms. Lets a render site spread `maxSources` onto
* either kind without a per-kind conditional.
*/
maxSources?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
@@ -137,9 +126,9 @@ function SafeLink({ url, label, className }: { url: string; label: string; class
/**
* One source row in a search card: the safe link plus its snippet and date. The
* `<li value>` pins the source's original 1-based position, so a collapsed list
* whose tail is drawn after the head still numbers each source by its real
* citation index rather than by its position in the visible subset.
* `<li value>` pins the source's 1-based citation index explicitly rather than
* relying on the `<ol>`'s implicit numbering, so a row reads by its real index
* even inside the scroll container.
* @param props.source - the source to render.
* @param props.ordinal - the source's 1-based position in the full list.
* @returns the source list item.
@@ -159,21 +148,12 @@ function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: numbe
}
/**
* The search card body: the answer over the capped source list.
* The search card body: the answer over the full source list, which scrolls in
* place once it exceeds the `.sources` container height.
* @param props - see {@link WebSearchBlockProps}.
* @returns the search card element.
*/
function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) {
const [expanded, setExpanded] = useState(false)
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const hidden = sources.length - maxSources
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock's output cap, so a long body's head
// and tail slices agree between the two cards.
const headCount = Math.ceil(maxSources / 2)
const tailCount = maxSources - headCount
const head = capped ? sources.slice(0, headCount) : sources
const tail = capped ? sources.slice(sources.length - tailCount) : []
function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBlockProps) {
// A provider may legitimately return no answer and no sources; the chat WebRow
// does not show the raw result content, so without this the user would see an
// empty card. Mirror the backend's `No results found.` render text.
@@ -187,27 +167,7 @@ function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_M
<div className={css.empty}></div>
) : (
<ol className={css.sources}>
{head.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
{hidden > 0 && (
<li className={css.expandItem}>
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden} 条来源`}
</button>
</li>
)}
{tail.map((source, index) => (
<SourceItem
key={sources.length - tailCount + index}
source={source}
ordinal={sources.length - tailCount + index + 1}
/>
))}
{sources.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
</ol>
)}
{truncated && <div className={css.truncated}></div>}

View File

@@ -32,7 +32,7 @@ export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
export type {
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
} from './SearchBlock.tsx'
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
export { WebBlock } from './WebBlock.tsx'
export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'

View File

@@ -1,19 +1,19 @@
// @vitest-environment jsdom
// WebBlock: both kinds of the web card. The search card's answer, its citation
// list with the title-or-hostname label fallback and optional snippet/date, the
// source-list height cap and its expand control, and the truncated indicator;
// the fetch card's linked URL, status, and truncation. Safe-link attributes on
// both kinds: an http(s) URL becomes an external anchor (target/rel), any other
// URL renders as plain text with no href.
// full source list under one <ol>, and the truncated indicator; the fetch
// card's linked URL, status, and truncation. Safe-link
// attributes on both kinds: an http(s) URL becomes an external anchor
// (target/rel), any other URL renders as plain text with no href.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts'
import { cleanup, render } from '@testing-library/react'
import { WebBlock } from '../src/index.ts'
import type { WebSourceView } from '../src/index.ts'
afterEach(cleanup)
/** `count` sources with sequential hostnames, so the cap slices read distinctly. */
/** `count` sources with sequential hostnames, so each row reads distinctly. */
function sources(count: number): WebSourceView[] {
return Array.from({ length: count }, (_value, index) => ({
url: `https://site-${index}.example.com/page`,
@@ -123,58 +123,25 @@ describe('WebBlock search card', () => {
expect(off.queryByText('来源列表已截断')).toBeNull()
})
it('renders every source and no expand control under the cap', () => {
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
it('renders every source in one <ol> with no expand control', () => {
// The card shows the whole list the tool returned, with no head/tail
// collapse and no expand button. jsdom does not resolve the CSS Modules
// layout, so the scroll geometry the `.sources` max-height produces is
// pinned by the assembled browser case in apps/web/tests/web-search-round.e2e.ts,
// not here.
const view = render(<WebBlock kind="search" sources={sources(30)} truncated={false} />)
expect(view.container.querySelectorAll('li[class^="_source_"]')).toHaveLength(30)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
})
it('slices head and tail over the cap and expands on click', () => {
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
// maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent))
.toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9'])
const toggle = view.getByRole('button', { name: '展开其余 6 条来源' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.textContent).toBe('… 其余 6 条来源')
fireEvent.click(toggle)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10)
const collapse = view.getByRole('button', { name: '收起来源' })
expect(collapse.getAttribute('aria-expanded')).toBe('true')
expect(collapse.textContent).toBe('收起')
fireEvent.click(collapse)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
})
it('numbers a collapsed tail by each source original position, not its visible slot', () => {
// maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read
// as citations 9 and 10 (via <li value>), not renumbered 3 and 4.
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10'])
})
it('keeps the expander out of the ordered-list numbering', () => {
// The expander is a marker-less <li>, so it is valid inside <ol> and does not
// consume a citation number between the head and tail sources.
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
expect(view.container.querySelector('button')).toBeNull()
// Every direct child of the <ol> is a source <li> (no marker-less expander).
const ol = view.container.querySelector('ol')!
// Every direct child is an <li> (no bare <button> child — invalid HTML).
expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
})
it('renders the head slice alone when the cap leaves no tail', () => {
const view = render(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
})
it('caps at the documented default when maxSources is absent', () => {
const view = render(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
it('numbers every source by its 1-based citation index via <li value>', () => {
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} />)
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '3', '4'])
})
})

View File

@@ -7,10 +7,11 @@
mid-slide. */
.root {
--dsh-sidebar-inline-padding: 12px;
display: flex;
flex-direction: column;
height: 100%;
padding: 6px 12px;
padding: 6px var(--dsh-sidebar-inline-padding);
box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
@@ -199,16 +200,22 @@
max-width: 0;
}
/* Region seat: always mounted so the foot never moves; the browser inside
handles its own wide/rail content. */
/* Region seat: always mounted so the foot never moves. Its trailing margin
cancels the wide shell inset so the nested scrollbar can sit at the sidebar
edge; the browser restores that inset inside its own rows. */
.regionArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
overflow: hidden;
}
.collapsed .regionArea {
margin-right: 0;
}
/* Foot seat: a pure layout socket pinned under the region; the ui-settings
trigger row inside owns its own geometry (49px wide row / 36px rail
circle) and hover chrome. */

View File

@@ -0,0 +1,38 @@
/** Sidebar shell inset contract shared with the nested workspace browser. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/SidebarRoot.module.css', import.meta.url)), 'utf8')
/**
* Declarations of one exact selector, keyed by property.
* @param selector - exact selector text.
* @returns the normalized declarations, or undefined when absent.
*/
function declarations(selector: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
const found = new Map<string, string>()
for (const part of body.split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
}
return found
}
return undefined
}
describe('SidebarRoot.module.css inset', () => {
it('shares and cancels the wide shell trailing padding structurally', () => {
const root = declarations('.root')
expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px')
expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)')
expect(declarations('.regionArea')?.get('margin-right')).toBe(
'calc(-1 * var(--dsh-sidebar-inline-padding))',
)
expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
})
})

View File

@@ -38,9 +38,8 @@ describe('tsdown client artifact', () => {
async function loadArtifact() {
let handoff: Handoff | undefined
;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } }
// Same execution form the loader uses (inline script eval, window scope) —
// the implied-eval ban targets accidental string execution, not this
// deliberate bundle-execution fixture.
// The implied-eval ban targets accidental string execution, not this
// deliberate built-bundle fixture running in the window scope.
// oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call
new Function(code!)()
expect(handoff).toBeDefined()

View File

@@ -4,10 +4,19 @@
rail state renders only the two 36x36 icon controls. */
.root {
--dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
--dsh-session-list-scrollbar-width: 8px;
--dsh-session-list-scrollbar-offset: 2px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-right: var(--dsh-session-list-edge-inset);
}
.root.rail {
padding-right: 0;
}
.iconButton {
@@ -167,9 +176,14 @@
min-height: 0;
display: flex;
flex-direction: column;
margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
overflow: hidden;
}
.rail .listArea {
margin-right: 0;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
@@ -184,7 +198,7 @@
.fade {
position: absolute;
left: 0;
right: 0;
right: var(--dsh-session-list-edge-inset);
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
@@ -200,31 +214,30 @@
from { opacity: 0; }
}
/* List: the only scrolling region. Block, not a flex column: as flex items
the 54/34 rows would shrink under content overflow; block children keep
their design heights and the 4px rhythm rides margins instead of gap. */
/* List: the only scrolling region. Block children keep their design heights
under content overflow. The 2px edge offset, stable 8px themed scrollbar,
and remaining padding equal the shell's right inset, with or without
overflow, so moving the bar does not move the rows. */
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-right: var(--dsh-session-list-scrollbar-offset);
padding-right: calc(
var(--dsh-session-list-edge-inset)
- var(--dsh-session-list-scrollbar-width)
- var(--dsh-session-list-scrollbar-offset)
);
/* Clears the 72px bottom fade overlay: at scroll end the last row sits
above the gradient instead of under it. */
padding-bottom: 48px;
/* Row trailing content (the relative time, and the hover action buttons
that replace it) sits flush against the row's 8px right padding, so an
overlaid scrollbar covers it. Reserving the gutter keeps the bar beside
the rows instead of on top of them; `stable` holds the reservation when
the list is short enough not to scroll, so expanding a group does not
shift every row left. */
scrollbar-gutter: stable;
}
.list > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
}
.searchTree > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
.flatList > * + *,
.searchTree > [role='treeitem'] + [role='treeitem'],
.groupSection > * + * {
margin-top: 2px;
}
.searchStatus,
@@ -239,22 +252,11 @@
color: var(--dsw-alias-label-secondary);
}
/* One workspace section: header row + expanded session run. Rows inside
keep the former flat-list 4px gap as sibling margins; the inter-group
breathing room (figma 133:7661 batch separator, 20px after an expanded
run) rides the NEXT section's top margin so the last group adds none. */
.groupSection > * + * {
margin-top: 4px;
}
/* One workspace section: header row + a compact expanded session run. */
.groupSection + .groupSection {
margin-top: 4px;
}
.groupSection:has([aria-expanded='true']) + .groupSection {
margin-top: 20px;
}
.empty {
padding: 16px 12px;
color: var(--dsw-alias-label-tertiary);

View File

@@ -240,7 +240,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
<div className={clsx(css.list, css.flatList)} role="tree" aria-label={t('section.sessions')}>
{rows.length === 0 && (
<div className={css.empty}>{t('empty.none')}</div>
)}

View File

@@ -1,8 +1,7 @@
/**
* WorkspaceBrowser scroll-region style contract, asserted against the CSS text
* on disk: the session list reserves its scrollbar gutter so the scrollbar
* cannot overlay row trailing content, and reserves it whether or not the list
* currently overflows so expanding a group does not shift rows sideways.
* WorkspaceBrowser spacing contract, asserted against the CSS text on disk:
* row fills share the shell's trailing inset, the stable scrollbar counts
* inside it, and flat, grouped, and search views keep their intended rhythm.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
@@ -11,38 +10,62 @@ import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
/**
* Declarations of one class rule, keyed by property with whitespace collapsed.
* Declarations of one selector rule, keyed by property with whitespace collapsed.
* Declaration order and trailing semicolons are normalized away.
* @param className - local class name, without the leading dot.
* @param selector - one exact selector, including a leading dot for local classes.
* @returns the rule's declarations, or undefined when no such rule exists.
*/
function declarations(className: string): Map<string, string> | undefined {
function declarations(selector: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments)
if (match === null) return undefined
const found = new Map<string, string>()
// The body group is unconditional in the pattern; the fallback only satisfies
// noUncheckedIndexedAccess.
for (const part of (match[2] ?? '').split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
const found = new Map<string, string>()
for (const part of body.split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
}
return found
}
return found
return undefined
}
describe('WorkspaceBrowser.module.css list', () => {
const list = declarations('list')
const root = declarations('.root')
const listArea = declarations('.listArea')
const list = declarations('.list')
it('is the scrolling region', () => {
expect(list).toBeDefined()
expect(list!.get('overflow-y')).toBe('auto')
})
it('reserves the scrollbar gutter unconditionally', () => {
// Row trailing content sits flush against the row's right padding, so an
// overlay scrollbar covers it. `stable` keeps the reservation when the list
// is short enough not to scroll, so expanding a group does not shift rows.
it('counts the themed scrollbar inside the shell trailing inset', () => {
expect(root?.get('--dsh-session-list-edge-inset')).toBe('var(--dsh-sidebar-inline-padding)')
expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px')
expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px')
expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)')
expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))')
expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)')
expect(list?.get('margin-right')).toBe('var(--dsh-session-list-scrollbar-offset)')
expect(list?.get('padding-right')).toBe([
'calc(',
'var(--dsh-session-list-edge-inset)',
'- var(--dsh-session-list-scrollbar-width)',
'- var(--dsh-session-list-scrollbar-offset)',
')',
].join(' '))
expect(declarations('.list::-webkit-scrollbar')).toBeUndefined()
})
it('reserves the scrollbar whether or not the list overflows', () => {
expect(list!.get('scrollbar-gutter')).toBe('stable')
})
it('keeps 2px between rows and 4px between workspace groups', () => {
expect(declarations('.flatList > * + *')?.get('margin-top')).toBe('2px')
expect(declarations(".searchTree > [role='treeitem'] + [role='treeitem']")?.get('margin-top')).toBe('2px')
expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px')
expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px')
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/web/README.md
README.md: a4325f5dbe8ddd9bbe77086eb16fdb7aed9adc83
README.zh.md: d4cb7a43da4e9c84a401ee0a1ac8c30d4816926e
README.md: b8b03dcb58442116cc01a2ff4c30e266e3233ee9
README.zh.md: 280ec52602321367715ae2a71c22cff265908299

View File

@@ -8,7 +8,7 @@ Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin pack
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for the shared module surface: seed-table keys, tsdown client externals, and the vite alias set are its projections.
The optional `seams` parameter forwards the module system's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
The optional `seams` parameter forwards the module system's `loadBundle` transport override (`BootSeams`); production callers omit it — it exists for test environments where external `<script>` execution cannot reach the page context (jsdom).
The shell owns browser-title projection. With a selected session carrying a durable title, it renders `<session title> — <existing HTML title>` and reacts to later title revisions; no selection or a selected untitled session preserves the existing title, and shell unmount restores it. The existing HTML title remains the configurable product suffix.

View File

@@ -8,7 +8,7 @@ Web 外壳内核:`new AppWebEntry(el, seams?).run()` 通过两阶段启动w
`PLATFORM_MODULES`src/platform.ts是共享模块表层的唯一真源种子表 key、tsdown 客户端 external 和 vite alias 集都是它的投影。
可选 `seams` 参数会转发模块系统的 `fetchBundle``executeBundle` 传输覆盖(`BootSeams`);生产调用方省略此参数。它用于 `<script>` 执行无法到达页面上下文的测试环境jsdom
可选 `seams` 参数会转发模块系统的 `loadBundle` 传输覆盖(`BootSeams`);生产调用方省略此参数。它用于外部 `<script>` 执行无法到达页面上下文的测试环境jsdom
外壳拥有浏览器标题投影。选中带有持久标题的会话时,它会渲染 `<session title> — <existing HTML title>` 并响应后续标题修订;未选择会话或选中无标题会话时,会保留现有标题;外壳卸载时恢复标题。现有 HTML 标题仍是可配置的产品后缀。

View File

@@ -24,7 +24,7 @@
* synchronous cross-package require edges (e.g. locale → runtime/client) that
* fiber inject waiting cannot protect — a bundle's factory must be
* registered before any dependent entry materializes. Per-row prefetch
* failures still resolve silently (the create-side import refetches and
* failures still resolve silently (the create-side import reloads and
* owns the loud failure), so the barrier never turns one bad bundle into a
* boot-wide fail-fast.
*
@@ -47,8 +47,8 @@ import { getStaticModules } from './seed.ts'
import { STATE_LABELS, createLoaderStatusStore, createSignal } from './loader-status.ts'
import './base.css'
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
export type BootSeams = Pick<ClientModuleSystemOptions, 'fetchBundle' | 'executeBundle'>
/** Module transport seam the shell passes through (jsdom tests replace the <script> path). */
export type BootSeams = Pick<ClientModuleSystemOptions, 'loadBundle'>
/**
* The modules package's own graph row id. The kernel adopts that entry
@@ -152,7 +152,7 @@ export class AppWebEntry {
await Promise.all(this.manifest.plugins
.filter(row => row.immediately)
.map(row => this.modules.prefetch(row.id).catch(() => {
// Import refetches and reports this loudly per entry; swallowing
// Import reloads and reports this loudly per entry; swallowing
// here keeps one failing prefetch from masking the others.
})))
}
@@ -189,7 +189,7 @@ export class AppWebEntry {
const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID), APP_SHELL_ID]
// Entry creation order carries no semantics (fiber inject waiting owns
// activation order); creating concurrently lets non-prefetched bundle
// fetches parallelize. The app-shell assembly entry is appended by the
// loads parallelize. The app-shell assembly entry is appended by the
// kernel: it is shell-own code (host graph rows are all plugin bundles),
// and mounting the assembly is not a composition decision — it rides the
// same entry lifecycle so the sweep and status cover it uniformly.