Merge remote-tracking branch 'origin/master' into mergebot/pr1389
# Conflicts: # packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
为通过外部脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
|
||||
浏览器侧订阅系统 SSE(Server-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 通道。
|
||||
浏览器侧订阅系统 SSE(Server-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 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) }
|
||||
|
||||
@@ -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;',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)).
|
||||
|
||||
|
||||
@@ -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))。
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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>
|
||||
: (
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
38
packages/client/ui-sidebar/tests/sidebar-styles.spec.ts
Normal file
38
packages/client/ui-sidebar/tests/sidebar-styles.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 标题仍是可配置的产品后缀。
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user