Merge remote-tracking branch 'origin/master' into feat/web-inline-file-mentions
This commit is contained in:
@@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
|
||||
Naming notes:
|
||||
|
||||
- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)).
|
||||
- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `support/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)).
|
||||
- `src/types.ts` contains only types — no runtime code.
|
||||
- Tests live at package level under `tests/`, not `src/__tests__/`.
|
||||
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.
|
||||
|
||||
@@ -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/api/remotes/README.md
|
||||
README.md: 7f6a2114d900413d972584c0f1c141b7f835ba36
|
||||
README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5
|
||||
README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
|
||||
README.zh.md: 7490d68781d3a7b0002b73fe06056ec86c144575
|
||||
|
||||
@@ -10,6 +10,14 @@ The current Client assembly mounts only the Goal Remote contribution. Cordis eff
|
||||
|
||||
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.
|
||||
|
||||
## Build boundary
|
||||
|
||||
An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations.
|
||||
|
||||
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory.
|
||||
|
||||
The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this BFF selects Remote application methods and identity policy but registers no model surface.
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
|
||||
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。
|
||||
|
||||
## 构建边界
|
||||
|
||||
仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。
|
||||
|
||||
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。
|
||||
|
||||
包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。
|
||||
|
||||
22
packages/api/remotes/tsconfig.client.json
Normal file
22
packages/api/remotes/tsconfig.client.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/client/index.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
36
packages/api/remotes/tsconfig.host.json
Normal file
36
packages/api/remotes/tsconfig.host.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/agent-lookup.ts",
|
||||
"src/index.ts",
|
||||
"src/invariant.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,42 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
"path": "./tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
"path": "./tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-api-remotes', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
export default clientBundle(
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
{ hostPhase: true },
|
||||
)
|
||||
|
||||
@@ -41,6 +41,6 @@
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"node-addon-landlock-run": "0.0.0-test.0"
|
||||
"@deepseek-ai/node-addon-landlock-run": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
@@ -13,14 +13,14 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
|
||||
* rung forced off, so the npm-distributed `landlock-run` confines) underneath the
|
||||
* rung forced off, so the workspace `landlock-run` launcher confines) underneath the
|
||||
* REAL `SandboxBashExecutor`, driven through the executor's public run/start
|
||||
* paths. Verifies the WORLD (files exist or don't) plus the stamped result
|
||||
* facts; the backend-only confinement proofs live with
|
||||
* `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips when the running kernel does not enforce Landlock; the
|
||||
* launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`).
|
||||
* Self-skips when the running kernel does not enforce Landlock. CI builds the launcher from
|
||||
* `native/landlock-run` before running this file.
|
||||
*/
|
||||
|
||||
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })
|
||||
|
||||
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
|
||||
import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../native/landlock-run/packages/entry"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
|
||||
@@ -218,6 +218,10 @@
|
||||
- id: skill-local
|
||||
name: '@deepseek-ai/dsh-skill-local'
|
||||
|
||||
- id: skill-badge
|
||||
name: '@deepseek-ai/dsh-skill-badge'
|
||||
disabled: true
|
||||
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-badge": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-policy": "workspace:^",
|
||||
|
||||
1
packages/bundle/base/tests/fixtures/root.cordis.yml
vendored
Normal file
1
packages/bundle/base/tests/fixtures/root.cordis.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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/bundle/headless/README.md
|
||||
README.md: d08fb08e2aca3c4e5ccd733b37fc415d492974ca
|
||||
README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0
|
||||
README.md: 661b377817482d22f58f22b573075722646729a2
|
||||
README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh --profile headless "task"`), and fails loud when a task is given to a profile without this row.
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "task"`);如果向没有这一行的 profile 传入任务,则大声失败。
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout,并经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
* (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire
|
||||
* chain — serialization, zod, SSE framing — really runs), prints the final
|
||||
* assistant text at agent quiescence, and exits (completed → 0, else 1). The
|
||||
* task text arrives as launcher-patched config
|
||||
* (`dsh --profile headless "task"`).
|
||||
* task text arrives as launcher-patched config (`dsh run "task"`).
|
||||
* @module @deepseek-ai/dsh-headless
|
||||
*/
|
||||
|
||||
@@ -86,26 +85,31 @@ async function unwrap<T>(response: RpcResponse<T>, io: HeadlessIo): Promise<T> {
|
||||
* `agent/status` subscription; the stream itself carries no status frame.
|
||||
* @param frames - the mux stream opened before the prompt.
|
||||
* @param sessionId - the headless session.
|
||||
* @param idle - resolves when the agent reaches quiescence.
|
||||
* @param idle - resolves to the final session-event sequence when the agent reaches quiescence.
|
||||
* @param io - process-facing effects for stream diagnostics.
|
||||
* @returns the aggregated outcome.
|
||||
*/
|
||||
async function consumeUntilIdle(
|
||||
frames: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
sessionId: SessionId,
|
||||
idle: Promise<void>,
|
||||
idle: Promise<number>,
|
||||
io: HeadlessIo,
|
||||
): Promise<TurnOutcome> {
|
||||
let started = false
|
||||
let text = ''
|
||||
let reason: string = 'error'
|
||||
void (async () => {
|
||||
let observedSeq = -1
|
||||
let resolveProgress: (() => void) | undefined
|
||||
const streamDone = (async () => {
|
||||
try {
|
||||
for await (const frame of frames) {
|
||||
const payload = frame.payload
|
||||
if (payload.type === 'stream/error') return
|
||||
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
|
||||
const event = payload.event
|
||||
observedSeq = event.seq
|
||||
resolveProgress?.()
|
||||
resolveProgress = undefined
|
||||
if (event.type === 'turn/start') {
|
||||
started = true
|
||||
continue
|
||||
@@ -121,7 +125,12 @@ async function consumeUntilIdle(
|
||||
io.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
|
||||
}
|
||||
})()
|
||||
await idle
|
||||
const streamEnded = streamDone.then(() => 'ended' as const)
|
||||
const idleSeq = await idle
|
||||
while (observedSeq < idleSeq) {
|
||||
const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } })
|
||||
if (await Promise.race([progress, streamEnded]) === 'ended') break
|
||||
}
|
||||
return { text, reason }
|
||||
}
|
||||
|
||||
@@ -154,9 +163,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// port of this runner must replace it with a wire-visible idle signal.
|
||||
const abort = new AbortController()
|
||||
const frames = api.events.mux({}, abort.signal)
|
||||
const idle = new Promise<void>((resolve) => {
|
||||
const idle = new Promise<number>((resolve) => {
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
if (agent.id === created.sessionId && status === 'idle') resolve()
|
||||
if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1)
|
||||
})
|
||||
})
|
||||
const done = consumeUntilIdle(frames, created.sessionId, idle, io)
|
||||
|
||||
@@ -21,26 +21,45 @@ function stamped(event: ScriptedEvent): ScriptedEvent {
|
||||
|
||||
interface RpcShapedRequest { rpcId: string }
|
||||
|
||||
interface ScriptedApiOptions {
|
||||
promptFails?: boolean
|
||||
framesAfterPrompt?: boolean
|
||||
onPrompt?: () => void
|
||||
}
|
||||
|
||||
/** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */
|
||||
function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): unknown {
|
||||
function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown {
|
||||
let releaseFrames = (): void => {}
|
||||
const framesReady = options.framesAfterPrompt === true
|
||||
? new Promise<void>((resolve) => { releaseFrames = resolve })
|
||||
: Promise.resolve()
|
||||
const prepared = events.map((event) => {
|
||||
if (event.type === 'stream/error') return { streamError: true } as const
|
||||
const { sessionId = 'S1', ...rest } = event
|
||||
return { streamError: false, sessionId, event: stamped(rest) } as const
|
||||
})
|
||||
return {
|
||||
sessions: {
|
||||
create: (request: RpcShapedRequest) =>
|
||||
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
|
||||
prompt: (request: RpcShapedRequest) => Promise.resolve(options.promptFails === true
|
||||
// A code from the closed wire union: the carrier schema rejects invented codes.
|
||||
? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } }
|
||||
: { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }),
|
||||
prompt: (request: RpcShapedRequest) => {
|
||||
releaseFrames()
|
||||
options.onPrompt?.()
|
||||
return Promise.resolve(options.promptFails === true
|
||||
// A code from the closed wire union: the carrier schema rejects invented codes.
|
||||
? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } }
|
||||
: { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } })
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: async function* () {
|
||||
for (const event of events) {
|
||||
if (event.type === 'stream/error') {
|
||||
await framesReady
|
||||
for (const item of prepared) {
|
||||
if (item.streamError) {
|
||||
yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } }
|
||||
continue
|
||||
}
|
||||
const { sessionId = 'S1', ...rest } = event
|
||||
yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } }
|
||||
yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } }
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -51,7 +70,10 @@ function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean }
|
||||
* Mount the runner against a scripted API, emit the idle transition after the
|
||||
* scripted frames drain, and wait for its exit request.
|
||||
*/
|
||||
async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): Promise<{ code: number; out: string; err: string }> {
|
||||
async function run(
|
||||
events: ScriptedEvent[],
|
||||
options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {},
|
||||
): Promise<{ code: number; out: string; err: string }> {
|
||||
const ctx = new Context()
|
||||
let out = ''
|
||||
let err = ''
|
||||
@@ -63,16 +85,25 @@ async function run(events: ScriptedEvent[], options: { promptFails?: boolean } =
|
||||
}
|
||||
ctx.provide('headlessIo', io)
|
||||
})
|
||||
ctx.provide('apiProxy', scriptedApi(events, options) as never)
|
||||
const emitIdle = (): void => {
|
||||
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
|
||||
}
|
||||
ctx.provide('apiProxy', scriptedApi(events, {
|
||||
...options.promptFails === undefined ? {} : { promptFails: options.promptFails },
|
||||
...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt },
|
||||
...options.idleInPrompt === true ? { onPrompt: emitIdle } : {},
|
||||
}) as never)
|
||||
ctx.provide('httpServer', { port: 12345 } as never)
|
||||
apply(ctx, { task: 'do the thing' })
|
||||
// Quiescence is out of band: give the scripted stream a beat to drain, then
|
||||
// flip the agent idle exactly as the loop would. Foreign agents and
|
||||
// non-idle transitions must not settle the run.
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' })
|
||||
if (options.idleInPrompt !== true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' })
|
||||
emitIdle()
|
||||
}
|
||||
const code = await exited
|
||||
await ctx.fiber.dispose()
|
||||
return { code, out, err }
|
||||
@@ -106,6 +137,15 @@ describe('headless runner', () => {
|
||||
expect(err).toContain('observing at http://127.0.0.1:12345')
|
||||
})
|
||||
|
||||
it('consumes through the idle sequence when queued frames arrive after the status transition', async () => {
|
||||
const { code, out } = await run(
|
||||
[messageTurn, text(1, 'race-free answer'), end(1, 'completed')],
|
||||
{ framesAfterPrompt: true, idleInPrompt: true },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(out).toBe('race-free answer\n')
|
||||
})
|
||||
|
||||
it('exits 1 when the final turn ends for any other reason', async () => {
|
||||
const { code } = await run([messageTurn, end(1, 'aborted')])
|
||||
expect(code).toBe(1)
|
||||
@@ -168,7 +208,7 @@ describe('headless runner', () => {
|
||||
ctx.provide('httpServer', { port: 1 } as never)
|
||||
apply(ctx, { task: 't' })
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
|
||||
expect(await exited).toBe(1)
|
||||
expect(err).toContain('event stream failed')
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -149,6 +149,10 @@
|
||||
- id: ui-conversation
|
||||
name: '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
# Tool call tree, generic fallback, and keyed business Tool views.
|
||||
- id: ui-tool
|
||||
name: '@deepseek-ai/dsh-client-ui-tool'
|
||||
|
||||
# Turn tail: the produced-files row under each closing assistant message.
|
||||
# Remove this entry to turn the surface off; the tail hole renders empty.
|
||||
- id: ui-deliverables
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
|
||||
@@ -9,7 +9,7 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<nam
|
||||
The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code:
|
||||
|
||||
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
|
||||
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
|
||||
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'tool.call.toolview'`).
|
||||
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
|
||||
|
||||
@@ -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: b950772d4cad6d873426f8aee6416fa56afca2ee
|
||||
README.zh.md: 8f1f7f46777b7037e8baa04c9ec16ef74ffd478d
|
||||
README.md: b6fa426fbe541e2b22d2bf5f19d4397361cf0899
|
||||
README.zh.md: 5a55bb8c2c31b5215fc73e75e1c4f3aca79add64
|
||||
|
||||
@@ -22,6 +22,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
|
||||
| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. |
|
||||
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
|
||||
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |
|
||||
|
||||
@@ -22,6 +22,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
|
||||
| [`ui-tool/`](ui-tool/README.md) | 编排 Tool 调用树和按 Tool 键控的视图。 |
|
||||
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 |
|
||||
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |
|
||||
|
||||
@@ -157,19 +157,19 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
|
||||
path: 'packages/client/ui-tool/src/client/tool/models/search-card-model.ts',
|
||||
matches: [
|
||||
{ lineNumber: 24, line: 'export const CHAT_SEARCH_MAX_LINES = 8' },
|
||||
{ lineNumber: 60, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' },
|
||||
{ lineNumber: 45, line: 'export const CHAT_SEARCH_MAX_LINES = 8' },
|
||||
{ lineNumber: 130, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
|
||||
path: 'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx',
|
||||
matches: [
|
||||
{ lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
|
||||
{ lineNumber: 35, line: ' const search = searchCardModel(block)' },
|
||||
{ lineNumber: 52, line: ' search={search}' },
|
||||
{ lineNumber: 78, line: " yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
{ lineNumber: 34, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
|
||||
{ lineNumber: 36, line: ' const search = searchCardModel(block)' },
|
||||
{ lineNumber: 56, line: ' search={search}' },
|
||||
{ lineNumber: 78, line: " yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -197,9 +197,9 @@ const SEARCH_MATCHES_TEXT = [
|
||||
const SEARCH_PATHS_FIXTURE = [
|
||||
'packages/client/ui-primitives/src/SearchBlock.tsx',
|
||||
'packages/client/ui-primitives/src/SearchBlock.module.css',
|
||||
'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
|
||||
'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
|
||||
'packages/client/ui-conversation/tests/search-card.spec.tsx',
|
||||
'packages/client/ui-tool/src/client/tool/models/search-card-model.ts',
|
||||
'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx',
|
||||
'packages/client/ui-tool/tests/search-card.spec.tsx',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -2449,7 +2449,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
skills: [
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true },
|
||||
{ name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false },
|
||||
],
|
||||
})
|
||||
},
|
||||
|
||||
@@ -163,6 +163,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27
|
||||
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
|
||||
README.md: a9b604974595b1b7856f74b72d36093491ec1bd1
|
||||
README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774
|
||||
|
||||
@@ -22,6 +22,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
|
||||
|
||||
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
|
||||
|
||||
## New Session and the blank mirror
|
||||
@@ -36,15 +38,15 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
|
||||
|
||||
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
|
||||
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
|
||||
|
||||
## Request inspection
|
||||
|
||||
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
|
||||
|
||||
## Code Mode sub-dispatch index
|
||||
## Code Mode child-call tree
|
||||
|
||||
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
|
||||
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable.
|
||||
|
||||
## Session title projection
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
@@ -36,15 +38,15 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
|
||||
|
||||
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时这些字段不可用,后续补上溯源的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
|
||||
## 请求检查
|
||||
|
||||
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
|
||||
|
||||
## Code Mode 子调用索引
|
||||
## Code Mode 子调用树
|
||||
|
||||
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript 的 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
|
||||
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染。
|
||||
|
||||
## Session 标题投影
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
],
|
||||
"platform": "web",
|
||||
@@ -49,14 +48,12 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-api-remotes": "^0.0.1",
|
||||
"@deepseek-ai/dsh-type-meta": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
@@ -9,13 +8,15 @@ import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { SessionHistoryService } from './session-history/service.ts'
|
||||
import { WorkspacesService } from './workspaces/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
import type { ConversationSnapshot } from './sessions/conversation.ts'
|
||||
import type { UseProjection } from './sessions/projection-store.ts'
|
||||
|
||||
export { SlotsService } from './slots.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
|
||||
export { SessionHistoryService } from './session-history/service.ts'
|
||||
export { indexSubagentDescendants } from './sessions/subagent-lineage.ts'
|
||||
export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts'
|
||||
// The provide channel is shared with the client test runtime (one
|
||||
// materialization/projection implementation; no test-side mirror to drift).
|
||||
export { SessionProvideChannel } from './sessions/provide.ts'
|
||||
@@ -23,6 +24,7 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export { resolveWorkspacePath } from './workspaces/path.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type {
|
||||
@@ -47,10 +49,10 @@ export type {
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
|
||||
AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase,
|
||||
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
|
||||
RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
@@ -84,16 +86,9 @@ declare module '@deepseek-ai/dsh-type-meta' {
|
||||
}
|
||||
}
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
|
||||
/** The conversation-snapshot selector hook supplied to session-scoped UI entries. */
|
||||
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
/**
|
||||
* One tool call as the chat flow renders it: still-running (spinner card) or
|
||||
* settled (result node). The fold produces both shapes; toolview components
|
||||
* narrow on the discriminant fields.
|
||||
*/
|
||||
export type ToolCallBlock = RunningToolCall | ToolResultNode
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/**
|
||||
* Session standard kit, real members (ui-slots declares the empty seat;
|
||||
@@ -179,8 +174,8 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the Remote root, wire handle, and Client TypeRT registry. */
|
||||
export const inject = ['remote', 'connection', 'typert']
|
||||
/** Required services: the wire handle and Client TypeRT registry. */
|
||||
export const inject = ['connection', 'typert']
|
||||
|
||||
/** Mounts the browser runtime services and connection stream.
|
||||
* @param ctx - Client Cordis context.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
@@ -7,7 +6,7 @@ import type {
|
||||
HistoryEntry, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode,
|
||||
AssistantRequestConfig, AssistantTiming, ConversationNode,
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '../sessions/conversation.ts'
|
||||
import { toAssistantBlocks } from '../sessions/conversation.ts'
|
||||
@@ -20,6 +19,7 @@ import type { ConversationPromptSnapshot } from '../sessions/request-inspection.
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
|
||||
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
|
||||
import { ToolCallTree } from '../sessions/tool-call-tree.ts'
|
||||
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
@@ -41,7 +41,6 @@ export interface ConversationHistoryProjection {
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
@@ -177,6 +176,7 @@ function materializeNode(
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -188,74 +188,22 @@ function materializeNode(
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
interface TransientProjection extends Pick<
|
||||
ConversationHistoryProjection,
|
||||
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches'
|
||||
'interruptedNodes' | 'partial' | 'runningCalls'
|
||||
> {
|
||||
toolCallTree: ToolCallTree
|
||||
}
|
||||
|
||||
function projectTransient(entries: readonly HistoryEntry[]): TransientProjection {
|
||||
let partial: PartialAccumulator | null = null
|
||||
const openCalls = new Map<string, RunningToolCall>()
|
||||
const interruptedNodes: ConversationNode[] = []
|
||||
const codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
const toolCallTree = new ToolCallTree()
|
||||
|
||||
for (const entry of entries) {
|
||||
const { event } = entry
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const siblings = codeDispatches.get(data.parentCallId) ?? []
|
||||
// The independent replay emits the same public running-call shape as
|
||||
// Chat without reading or mutating Session's live index.
|
||||
/* jscpd:ignore-start */
|
||||
codeDispatches.set(data.parentCallId, [...siblings, {
|
||||
callId: data.subCallId,
|
||||
name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0,
|
||||
step: 0,
|
||||
time: event.time,
|
||||
callView: null,
|
||||
}])
|
||||
/* jscpd:ignore-end */
|
||||
continue
|
||||
}
|
||||
if ((event.type as string) === 'tool/code-dispatch') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = codeDispatches.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
// History independently reproduces the public settled-call shape instead
|
||||
// of consuming Session's live code-dispatch projection.
|
||||
/* jscpd:ignore-start */
|
||||
const settled: CodeSubCall = {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: started?.time ?? null,
|
||||
content: data.content,
|
||||
isError: data.isError,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
}
|
||||
codeDispatches.set(
|
||||
data.parentCallId,
|
||||
at === -1
|
||||
? [...siblings, settled]
|
||||
: siblings.map((sub, index) => index === at ? settled : sub),
|
||||
)
|
||||
/* jscpd:ignore-end */
|
||||
continue
|
||||
}
|
||||
if (toolCallTree.apply(event)) continue
|
||||
switch (event.type) {
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
@@ -280,6 +228,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
step: event.data.step,
|
||||
time: event.time,
|
||||
callView: entry.view?.for === 'call' ? entry.view.view : null,
|
||||
subCalls: [],
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
break
|
||||
@@ -317,6 +266,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
@@ -331,7 +281,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
interruptedNodes,
|
||||
partial: partial?.toPartial() ?? null,
|
||||
runningCalls: [...openCalls.values()],
|
||||
codeDispatches,
|
||||
toolCallTree,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,9 +412,17 @@ export function projectConversationHistory(
|
||||
}
|
||||
}
|
||||
|
||||
const transient = projectTransient(entries)
|
||||
const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes)
|
||||
const projectedContexts = contexts.map((context): ConversationContext => {
|
||||
const nodes = transient.toolCallTree.projectNodes(context.nodes)
|
||||
return nodes === context.nodes ? context : { ...context, nodes }
|
||||
})
|
||||
return {
|
||||
eventNodes,
|
||||
contexts,
|
||||
...projectTransient(entries),
|
||||
eventNodes: projectedEventNodes,
|
||||
contexts: projectedContexts,
|
||||
interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes),
|
||||
partial: transient.partial,
|
||||
runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ export function contextProvenance(source: unknown): ContextProvenanceView {
|
||||
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
|
||||
case 'plugin':
|
||||
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
|
||||
// A user-explicit skill invocation names the skill it injected.
|
||||
case 'skill-invocation':
|
||||
return { role: 'inject', label: readString(record, 'name') ?? kind }
|
||||
// Documented default arm of the merge-extensible source map: an unknown
|
||||
// producer still identifies itself by its own durable kind.
|
||||
default:
|
||||
|
||||
@@ -174,6 +174,8 @@ export interface ToolResultNode {
|
||||
callView: ToolCallView | null
|
||||
/** Host-computed render intent from this tool/result's wire view; null = same default. */
|
||||
resultView: ToolResultView | null
|
||||
/** Child calls owned by this call, in dispatch order. */
|
||||
subCalls: readonly ToolCallBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,6 +194,12 @@ export interface CompactionSummaryNode {
|
||||
/** Summary text from the checkpoint's `compact/summary` provenance; null when
|
||||
* the window cut left that provenance outside (the marker is then not expandable). */
|
||||
summary: string | null
|
||||
/** Seq of the loaded `compact/summary` event, or null when that provenance is outside the window. */
|
||||
summaryEventSeq: number | null
|
||||
/** Number of surface items replaced, or null when summary provenance is unavailable or malformed. */
|
||||
shadowedItemCount: number | null
|
||||
/** Estimated token price of the replaced items, or null when summary provenance is unavailable or malformed. */
|
||||
shadowedTokenCount: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,7 +244,12 @@ export interface CommandNode {
|
||||
*/
|
||||
args: string | null
|
||||
/** Settlement outcome (done payload); null while the command is still executing. */
|
||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||
outcome: {
|
||||
kind: 'success' | 'error'
|
||||
text?: string
|
||||
/** Earlier authoritative domain event for a richer client-computed presentation. */
|
||||
sourceEventSeq?: number
|
||||
} | null
|
||||
}
|
||||
|
||||
/** Finalized conversation node union (kind discriminates; seq is the React key). */
|
||||
@@ -252,21 +265,6 @@ export type ConversationNode =
|
||||
| CompactionSummaryNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
* One `run_code` sub-dispatch materialized in the native call-block shapes so
|
||||
* every consumer (tool rows, details panel) renders it through the exact
|
||||
* components that render a native call: a started-but-unsettled sub-call is a
|
||||
* {@link RunningToolCall} (rows derive the running state from the shape,
|
||||
* exactly as for native calls) and its `tool/code-dispatch` settlement
|
||||
* replaces it in place with the {@link ToolResultNode} form. Never part of
|
||||
* the transcript `nodes` flow — sub-calls live under their parent via
|
||||
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
|
||||
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
|
||||
* and its JSON-stringified logged arguments; `content`/`isError` are the
|
||||
* settled sub-call's complete logged outcome.
|
||||
*/
|
||||
export type CodeSubCall = RunningToolCall | ToolResultNode
|
||||
|
||||
/** In-flight tool card material: tool/call seen, tool/result not yet. */
|
||||
export interface RunningToolCall {
|
||||
callId: string
|
||||
@@ -278,8 +276,12 @@ export interface RunningToolCall {
|
||||
time: number
|
||||
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
|
||||
callView: ToolCallView | null
|
||||
/** Child calls owned by this call, in dispatch order. */
|
||||
subCalls: readonly ToolCallBlock[]
|
||||
}
|
||||
|
||||
/** One running or settled call, recursively owning its child calls. */
|
||||
export type ToolCallBlock = RunningToolCall | ToolResultNode
|
||||
|
||||
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
|
||||
export interface QueuedMessage {
|
||||
@@ -344,13 +346,6 @@ export interface ConversationSnapshot {
|
||||
turnEnds: ReadonlyMap<number, number>
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
/**
|
||||
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
|
||||
* order. Populated from in-window `tool/code-dispatch` events (live and
|
||||
* replay identically); the per-parent array reference is stable across
|
||||
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
|
||||
*/
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
pending: readonly PendingInteraction[]
|
||||
/** Authoritative transient inbox snapshot, including queued and steering placements. */
|
||||
queue: readonly QueuedMessage[]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall,
|
||||
ConversationNode, PartialAssistant, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { ConversationContext } from './conversation-context.ts'
|
||||
import { projectConversationHistory } from '../session-history/history-fold.ts'
|
||||
@@ -34,7 +34,6 @@ export interface SessionHistoryInspection {
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,9 +111,6 @@ export function createHistoryInspection(
|
||||
get runningCalls() {
|
||||
return conversationProjection().runningCalls
|
||||
},
|
||||
get codeDispatches() {
|
||||
return conversationProjection().codeDispatches
|
||||
},
|
||||
get requests() {
|
||||
return requestProjection().requests
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type {
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
|
||||
ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
|
||||
OpenState, PromptError, QueuedMessage, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
@@ -24,6 +24,7 @@ import { Notifier } from './notifier.ts'
|
||||
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
import { ToolCallTree } from './tool-call-tree.ts'
|
||||
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
@@ -129,11 +130,8 @@ export class Session implements SessionFace {
|
||||
private queued: QueuedMessage[] = []
|
||||
private queueRev = 0
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
|
||||
* copy-on-write the per-parent array so published snapshot references never mutate. */
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
private dispatchesRev = 0
|
||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
||||
/** Window-derived child-call lifecycle and immutable tree projection. */
|
||||
private readonly toolCallTree = new ToolCallTree()
|
||||
private running = false
|
||||
private address: SubagentAddress | undefined
|
||||
private parentAvailable = false
|
||||
@@ -746,65 +744,10 @@ export class Session implements SessionFace {
|
||||
this.derivedRev++
|
||||
return
|
||||
}
|
||||
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
|
||||
// the host-side dsh-tools plugin whose types cannot enter the client
|
||||
// program (its host Context merges collide with the client's), so this
|
||||
// wire consumer narrows them structurally — the same posture as every
|
||||
// other cross-wire event payload.
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
// A started sub-dispatch enters the index as a RunningToolCall — the
|
||||
// exact shape a native in-flight call renders from — under its parent
|
||||
// run_code callId; it never joins the surface flow.
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const running: CodeSubCall = {
|
||||
callId: data.subCallId, name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0, step: 0, time: event.time, callView: null,
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
this.codeDispatches.set(data.parentCallId, [...siblings, running])
|
||||
this.dispatchesRev++
|
||||
return
|
||||
}
|
||||
if ((event.type as string) === 'tool/code-dispatch') {
|
||||
// Settlement replaces the running entry in place (same array position,
|
||||
// so parallel sub-calls keep their start order) with the
|
||||
// ToolResultNode form; a settle with no observed start (history window
|
||||
// cut mid-pair, or a pre-start-event log) appends directly.
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
const settled: CodeSubCall = {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
// Duration source: the paired start's time when observed; null =
|
||||
// unknown (settle-only window), matching the native tool-result
|
||||
// contract so views never present a fabricated zero duration.
|
||||
callTime: started === undefined ? null : started.time,
|
||||
content: data.content, isError: data.isError,
|
||||
callView: null, resultView: null,
|
||||
}
|
||||
this.codeDispatches.set(
|
||||
data.parentCallId,
|
||||
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
|
||||
)
|
||||
this.dispatchesRev++
|
||||
return
|
||||
}
|
||||
// These lifecycle events are declared by a host-only plugin whose Context
|
||||
// types cannot enter the client program. ToolCallTree owns their structural
|
||||
// wire narrowing, pairing, and nested snapshot projection.
|
||||
if (this.toolCallTree.apply(event)) return
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
this.lastStepByTurn.set(event.data.turn, 0)
|
||||
@@ -834,6 +777,7 @@ export class Session implements SessionFace {
|
||||
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
|
||||
turn: event.data.turn, step: event.data.step, time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
subCalls: [],
|
||||
})
|
||||
this.callsRev++
|
||||
return
|
||||
@@ -901,7 +845,7 @@ export class Session implements SessionFace {
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call.time,
|
||||
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView, resultView: null,
|
||||
callView: call.callView, resultView: null, subCalls: [],
|
||||
})
|
||||
this.derivedRev++
|
||||
}
|
||||
@@ -948,8 +892,7 @@ export class Session implements SessionFace {
|
||||
this.turnTimingsRev++
|
||||
this.turnEnds = new Map()
|
||||
this.turnEndsRev++
|
||||
this.codeDispatches = new Map()
|
||||
this.dispatchesRev++
|
||||
this.toolCallTree.reset()
|
||||
for (let i = 0; i < this.events.length; i++) {
|
||||
const event = this.events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
@@ -988,22 +931,18 @@ export class Session implements SessionFace {
|
||||
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
|
||||
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
|
||||
}
|
||||
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
|
||||
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
|
||||
}
|
||||
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
|
||||
this.queueCache = { rev: this.queueRev, value: this.queued }
|
||||
}
|
||||
const partial = this.partial?.toPartial() ?? null
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
nodes: this.toolCallTree.projectNodes(nodes),
|
||||
turnTimings: this.turnTimingsCache.value,
|
||||
turnEnds: this.turnEndsCache.value,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value),
|
||||
pending: this.pendingCache.value,
|
||||
codeDispatches: this.dispatchesCache.value,
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
subagent: this.address === undefined
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Pure subagent-lineage aggregation over the retained session-list mirror.
|
||||
* Ordinary forks terminate propagation so each visible session owns only its
|
||||
* uninterrupted subagent subtree.
|
||||
* @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage
|
||||
*/
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionSummary } from './service.ts'
|
||||
|
||||
/** Descendant counts projected for one possible parent session. */
|
||||
export interface SubagentDescendantSummary {
|
||||
/** All descendants connected through uninterrupted subagent-origin lineage. */
|
||||
readonly count: number
|
||||
/** Descendants whose exact session summary is currently running. */
|
||||
readonly runningCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Index every subagent descendant under each ancestor it reaches through an
|
||||
* uninterrupted subagent-origin chain. Cycles fail soft and orphan owners
|
||||
* remain harmless map keys until their summaries arrive.
|
||||
* @param summaries - retained session summaries keyed by id.
|
||||
* @returns descendant totals and running totals keyed by possible parent id.
|
||||
*/
|
||||
export function indexSubagentDescendants(
|
||||
summaries: Readonly<Record<SessionId, SessionSummary>>,
|
||||
): ReadonlyMap<SessionId, SubagentDescendantSummary> {
|
||||
const indexed = new Map<SessionId, { count: number; runningCount: number }>()
|
||||
for (const descendant of Object.values(summaries)) {
|
||||
if (descendant.origin !== 'subagent') continue
|
||||
const seen = new Set<SessionId>()
|
||||
let current: SessionSummary | undefined = descendant
|
||||
while (current?.origin === 'subagent' && current.parentId !== undefined
|
||||
&& !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
const aggregate = indexed.get(current.parentId)
|
||||
if (aggregate === undefined) {
|
||||
indexed.set(current.parentId, {
|
||||
count: 1,
|
||||
runningCount: descendant.running ? 1 : 0,
|
||||
})
|
||||
} else {
|
||||
aggregate.count += 1
|
||||
if (descendant.running) aggregate.runningCount += 1
|
||||
}
|
||||
current = summaries[current.parentId]
|
||||
}
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
212
packages/client/runtime/src/client/sessions/tool-call-tree.ts
Normal file
212
packages/client/runtime/src/client/sessions/tool-call-tree.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
} from './conversation.ts'
|
||||
|
||||
interface ProjectedBlock {
|
||||
source: ToolCallBlock
|
||||
children: readonly ToolCallBlock[]
|
||||
value: ToolCallBlock
|
||||
}
|
||||
|
||||
/** Fixed wire-safety ceiling for every recursive Tool call consumer. */
|
||||
export const MAX_TOOL_CALL_TREE_DEPTH = 256
|
||||
|
||||
function sameReferences<T>(
|
||||
left: readonly T[],
|
||||
right: readonly T[],
|
||||
): boolean {
|
||||
return left.length === right.length
|
||||
&& left.every((block, index) => block === right[index])
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns Code Dispatch pairing and projects its private parent index into the
|
||||
* recursive Tool call contract exposed by conversation snapshots.
|
||||
*/
|
||||
export class ToolCallTree {
|
||||
private readonly childrenByParent = new Map<string, readonly ToolCallBlock[]>()
|
||||
private readonly depthByCall = new Map<string, number>()
|
||||
private readonly projectedByCall = new Map<string, ProjectedBlock>()
|
||||
private revision = 0
|
||||
private nodesCache: {
|
||||
source: readonly ConversationNode[]
|
||||
revision: number
|
||||
value: readonly ConversationNode[]
|
||||
} | null = null
|
||||
private runningCache: {
|
||||
source: readonly RunningToolCall[]
|
||||
revision: number
|
||||
value: readonly RunningToolCall[]
|
||||
} | null = null
|
||||
|
||||
/** Forget all event-derived child calls before replaying a new window. */
|
||||
reset(): void {
|
||||
this.childrenByParent.clear()
|
||||
this.depthByCall.clear()
|
||||
this.projectedByCall.clear()
|
||||
this.revision++
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one event when it belongs to the Code Dispatch lifecycle.
|
||||
* @param event - Session event from the current live or history window.
|
||||
* @returns Whether the event was consumed as a child-call lifecycle event.
|
||||
*/
|
||||
apply(event: SessionEvent): boolean {
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const running: RunningToolCall = {
|
||||
callId: data.subCallId,
|
||||
name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0,
|
||||
step: 0,
|
||||
time: event.time,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
|
||||
if (!this.acceptEdge(data.parentCallId, data.subCallId)) return true
|
||||
this.childrenByParent.set(data.parentCallId, [...siblings, running])
|
||||
this.revision++
|
||||
return true
|
||||
}
|
||||
if ((event.type as string) !== 'tool/code-dispatch') return false
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
if (at === -1 && !this.acceptEdge(data.parentCallId, data.subCallId)) return true
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
const settled: ToolResultNode = {
|
||||
kind: 'tool-result',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: started?.time ?? null,
|
||||
content: data.content,
|
||||
isError: data.isError,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
this.childrenByParent.set(
|
||||
data.parentCallId,
|
||||
at === -1
|
||||
? [...siblings, settled]
|
||||
: siblings.map((sub, index) => index === at ? settled : sub),
|
||||
)
|
||||
this.revision++
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach recursively projected children to all settled roots in a node list.
|
||||
* @param nodes - Cache-stable base conversation nodes.
|
||||
* @returns The original list when no root changed, otherwise a structurally shared list.
|
||||
*/
|
||||
projectNodes(nodes: readonly ConversationNode[]): readonly ConversationNode[] {
|
||||
if (this.nodesCache?.source === nodes && this.nodesCache.revision === this.revision) {
|
||||
return this.nodesCache.value
|
||||
}
|
||||
const projected = nodes.map((node): ConversationNode => {
|
||||
if (node.kind !== 'tool-result') return node
|
||||
return this.projectBlock(node) as ToolResultNode
|
||||
})
|
||||
const value = sameReferences(nodes, projected) ? nodes : projected
|
||||
this.nodesCache = { source: nodes, revision: this.revision, value }
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach recursively projected children to all running root calls.
|
||||
* @param calls - Cache-stable base running calls.
|
||||
* @returns The original list when no root changed, otherwise a structurally shared list.
|
||||
*/
|
||||
projectRunningCalls(calls: readonly RunningToolCall[]): readonly RunningToolCall[] {
|
||||
if (this.runningCache?.source === calls && this.runningCache.revision === this.revision) {
|
||||
return this.runningCache.value
|
||||
}
|
||||
const projected = calls.map(call => this.projectBlock(call) as RunningToolCall)
|
||||
const value = sameReferences(calls, projected) ? calls : projected
|
||||
this.runningCache = { source: calls, revision: this.revision, value }
|
||||
return value
|
||||
}
|
||||
|
||||
private projectBlock(block: ToolCallBlock): ToolCallBlock {
|
||||
const children = this.childrenByParent.get(block.callId) ?? block.subCalls
|
||||
const projectedChildren = children.map(child => this.projectBlock(child))
|
||||
const childValue = sameReferences(children, projectedChildren)
|
||||
? children
|
||||
: projectedChildren
|
||||
const cached = this.projectedByCall.get(block.callId)
|
||||
if (cached?.source === block && sameReferences(cached.children, childValue)) {
|
||||
return cached.value
|
||||
}
|
||||
const value: ToolCallBlock = block.subCalls === childValue
|
||||
? block
|
||||
: { ...block, subCalls: childValue }
|
||||
this.projectedByCall.set(block.callId, {
|
||||
source: block,
|
||||
children: childValue,
|
||||
value,
|
||||
})
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an edge only when every recursive consumer can traverse it safely.
|
||||
* Host-minted ids exclude cycles and current bindings emit one level; a
|
||||
* malformed wire/history edge is consumed without hiding the rest of the session.
|
||||
*/
|
||||
private acceptEdge(parentCallId: string, subCallId: string): boolean {
|
||||
if (this.wouldCreateCycle(parentCallId, subCallId)) return false
|
||||
const pending = [{
|
||||
callId: subCallId,
|
||||
depth: (this.depthByCall.get(parentCallId) ?? 1) + 1,
|
||||
}]
|
||||
const updates = new Map<string, number>()
|
||||
for (const candidate of pending) {
|
||||
const knownDepth = updates.get(candidate.callId)
|
||||
?? this.depthByCall.get(candidate.callId)
|
||||
?? 1
|
||||
if (candidate.depth <= knownDepth) continue
|
||||
if (candidate.depth > MAX_TOOL_CALL_TREE_DEPTH) return false
|
||||
updates.set(candidate.callId, candidate.depth)
|
||||
for (const child of this.childrenByParent.get(candidate.callId) ?? []) {
|
||||
pending.push({ callId: child.callId, depth: candidate.depth + 1 })
|
||||
}
|
||||
}
|
||||
for (const [callId, depth] of updates) this.depthByCall.set(callId, depth)
|
||||
return true
|
||||
}
|
||||
|
||||
private wouldCreateCycle(parentCallId: string, subCallId: string): boolean {
|
||||
if (parentCallId === subCallId) return true
|
||||
const pending = [subCallId]
|
||||
const visited = new Set(pending)
|
||||
for (const callId of pending) {
|
||||
for (const child of this.childrenByParent.get(callId) ?? []) {
|
||||
if (child.callId === parentCallId) return true
|
||||
if (visited.has(child.callId)) continue
|
||||
visited.add(child.callId)
|
||||
pending.push(child.callId)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,11 @@ function materializeNode(
|
||||
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
// Injected context (plugin/goal source) folds to a context node, not a
|
||||
// user message; only a direct human prompt is a user node. A compaction
|
||||
// checkpoint never reaches here (isCompactCheckpoint routes it away).
|
||||
case 'user/message': {
|
||||
// Injected context (plugin/goal/skill-invocation source) folds to a
|
||||
// context node, not a user message; only a direct human prompt is a
|
||||
// user node. A compaction checkpoint never reaches here
|
||||
// (isCompactCheckpoint routes it away).
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
@@ -80,6 +81,7 @@ function materializeNode(
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
@@ -101,6 +103,7 @@ function materializeNode(
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
/* v8 ignore next 2 -- defensive arm: only the four surface-eligible types
|
||||
@@ -156,6 +159,29 @@ function compactSummaryText(event: SessionEvent): string | null {
|
||||
return text.trim() === '' ? null : text
|
||||
}
|
||||
|
||||
interface CompactSummaryDetails {
|
||||
readonly summary: string | null
|
||||
readonly shadowedItemCount: number | null
|
||||
readonly shadowedTokenCount: number | null
|
||||
}
|
||||
|
||||
/** Recover human-facing summary material from one structurally narrowed wire event. */
|
||||
function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails {
|
||||
const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown }
|
||||
const shadowedSeqs = data.shadowedSeqs
|
||||
const tokenCount = data.shadowedTokenCount
|
||||
return {
|
||||
summary: compactSummaryText(event),
|
||||
shadowedItemCount: Array.isArray(shadowedSeqs)
|
||||
&& shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0)
|
||||
? shadowedSeqs.length
|
||||
: null,
|
||||
shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0
|
||||
? tokenCount as number
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One landed checkpoint -> the human-facing compaction marker. The summary text
|
||||
* comes from the checkpoint's own provenance (`sourceEventSeqs` names the
|
||||
@@ -170,13 +196,28 @@ function materializeCompaction(
|
||||
): CompactionSummaryNode {
|
||||
const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs
|
||||
let summary: string | null = null
|
||||
let summaryEventSeq: number | null = null
|
||||
let shadowedItemCount: number | null = null
|
||||
let shadowedTokenCount: number | null = null
|
||||
for (const seq of sources ?? []) {
|
||||
const candidate = eventIndex.get(seq)
|
||||
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
|
||||
summary = compactSummaryText(candidate)
|
||||
const details = compactSummaryDetails(candidate)
|
||||
summary = details.summary
|
||||
summaryEventSeq = candidate.seq
|
||||
shadowedItemCount = details.shadowedItemCount
|
||||
shadowedTokenCount = details.shadowedTokenCount
|
||||
break
|
||||
}
|
||||
return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary }
|
||||
return {
|
||||
kind: 'compaction',
|
||||
seq: checkpoint.seq,
|
||||
time: checkpoint.time,
|
||||
summary,
|
||||
summaryEventSeq,
|
||||
shadowedItemCount,
|
||||
shadowedTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
/** Log-ordered human transcript over a paged raw event window (never consults surface order). */
|
||||
@@ -321,9 +362,22 @@ export class TranscriptAdapter {
|
||||
return true
|
||||
}
|
||||
if ((event.type as string) !== 'command/done') return false
|
||||
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
|
||||
const data = event.data as unknown as {
|
||||
commandId: CommandId
|
||||
kind: 'success' | 'error'
|
||||
text?: string
|
||||
sourceEventSeq?: number
|
||||
}
|
||||
const run = this.commandIdx.get(data.commandId)
|
||||
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
|
||||
const sourceEventSeq = data.kind === 'success'
|
||||
&& Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0
|
||||
? data.sourceEventSeq as number
|
||||
: undefined
|
||||
const outcome = {
|
||||
kind: data.kind,
|
||||
...data.text === undefined ? {} : { text: data.text },
|
||||
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
|
||||
}
|
||||
if (run === undefined) {
|
||||
// Cross-window cut: the run page fell out of the window — build the
|
||||
// node from the done alone (same soft-fall as a call-less tool result).
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Context } from 'cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
|
||||
SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -35,16 +35,11 @@ export interface RootOwnerProps { children?: never }
|
||||
/** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */
|
||||
const ROOT_INSTANCE_KEY = 'root'
|
||||
|
||||
// FIXME(slot-parity): the engine's arbitrated persist extensions — create()
|
||||
// takes the scope key (per-session localStorage suffix) and instances expose
|
||||
// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike;
|
||||
// these local structural faces bridge until fw-slots lifts them.
|
||||
/** Canonical type-erased store handle used by the runtime lifecycle map. */
|
||||
type EngineStoreHandle = Exclude<StoreDecl, StoreFactory>
|
||||
|
||||
/** Store handle face as the engine actually ships it (scope-key-aware create). */
|
||||
interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance }
|
||||
|
||||
/** Engine instance face: the host-contract shape plus persisted-state cleanup. */
|
||||
interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void }
|
||||
/** Canonical engine instance derived from the handle's create contract. */
|
||||
type EngineStoreInstance = ReturnType<EngineStoreHandle['create']>
|
||||
|
||||
/** Store axis record: one per live handle, dropped when the last holding entry unloads. */
|
||||
interface StoreAxisRecord {
|
||||
|
||||
13
packages/client/runtime/src/client/workspaces/path.ts
Normal file
13
packages/client/runtime/src/client/workspaces/path.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Resolve a workspace-relative path into the Host-facing spelling used by openPath.
|
||||
* @param cwd - session workspace root, when known.
|
||||
* @param path - absolute or workspace-relative path.
|
||||
* @returns an absolute path when a workspace root is available, otherwise the original path.
|
||||
*/
|
||||
export function resolveWorkspacePath(cwd: string | undefined, path: string): string {
|
||||
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
|
||||
if (cwd === undefined || cwd === '') return path
|
||||
const base = cwd.replace(/[/\\]+$/, '')
|
||||
const rel = path.replace(/^[/\\]+/, '')
|
||||
return `${base}/${rel}`
|
||||
}
|
||||
@@ -36,7 +36,10 @@ describe('compaction checkpoint recognition', () => {
|
||||
it('recognizes a checkpoint carrying the seam-canonical source', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([canonicalCheckpoint(1)])
|
||||
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }])
|
||||
expect(adapter.nodes()).toEqual([{
|
||||
kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null,
|
||||
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
|
||||
}])
|
||||
})
|
||||
|
||||
it("agrees with the seam's own predicate on the source it recognizes", () => {
|
||||
|
||||
@@ -92,8 +92,19 @@ export const ev = {
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }),
|
||||
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
|
||||
commandDone: (
|
||||
seq: number,
|
||||
commandId: string,
|
||||
kind: 'success' | 'error' = 'success',
|
||||
text?: string,
|
||||
sourceEventSeq?: number,
|
||||
): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: {
|
||||
commandId,
|
||||
kind,
|
||||
...text === undefined ? {} : { text },
|
||||
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
|
||||
} }),
|
||||
/** A compaction's log-only `compact/summary` provenance record. */
|
||||
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
|
||||
at(seq, { type: 'compact/summary', data: {
|
||||
|
||||
@@ -198,6 +198,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
|
||||
@@ -168,6 +168,37 @@ describe('projectConversationHistory', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects nested dispatches onto settled and interrupted history calls', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.turnStart(0, 1),
|
||||
ev.toolCall(1, 1, 'settled', 'run_code', '{}'),
|
||||
ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }),
|
||||
ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }),
|
||||
ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
|
||||
ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'),
|
||||
ev.toolResult(6, 1, 'settled', 'done'),
|
||||
ev.turnEnd(7, 1),
|
||||
ev.turnStart(8, 2),
|
||||
ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'),
|
||||
ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }),
|
||||
ev.turnEnd(11, 2, 'aborted'),
|
||||
].map(event => ({ event })))
|
||||
|
||||
const settled = {
|
||||
callId: 'settled',
|
||||
subCalls: [{
|
||||
callId: 'settled:code:1',
|
||||
subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }],
|
||||
}],
|
||||
}
|
||||
expect(projection.eventNodes).toMatchObject([settled])
|
||||
expect(projection.contexts[0]?.nodes).toMatchObject([settled])
|
||||
expect(projection.interruptedNodes).toMatchObject([{
|
||||
callId: 'interrupted',
|
||||
subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }],
|
||||
}])
|
||||
})
|
||||
|
||||
it('drops completed token payloads without changing inspection projections', () => {
|
||||
const events = [
|
||||
ev.user(0, 'before'),
|
||||
|
||||
@@ -1151,7 +1151,17 @@ describe('resync', () => {
|
||||
|
||||
})
|
||||
|
||||
describe('run_code sub-dispatch indexing', () => {
|
||||
describe('nested run_code sub-dispatches', () => {
|
||||
const subCallsOf = (session: Session, callId: string) => {
|
||||
const snapshot = session.getSnapshot()
|
||||
const running = snapshot.runningCalls.find(call => call.callId === callId)
|
||||
if (running !== undefined) return running.subCalls
|
||||
for (const node of snapshot.nodes) {
|
||||
if (node.kind === 'tool-result' && node.callId === callId) return node.subCalls
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
@@ -1161,19 +1171,19 @@ describe('run_code sub-dispatch indexing', () => {
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
|
||||
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
|
||||
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
|
||||
const live = session.getSnapshot().codeDispatches.get('p1')
|
||||
const live = subCallsOf(session, 'p1')
|
||||
expect(live).toHaveLength(2)
|
||||
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
|
||||
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
|
||||
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
|
||||
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
|
||||
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
|
||||
const mixed = session.getSnapshot().codeDispatches.get('p1')
|
||||
const mixed = subCallsOf(session, 'p1')
|
||||
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
|
||||
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
|
||||
// The settle carries the paired start's time as callTime (duration source).
|
||||
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
|
||||
const settled = session.getSnapshot().codeDispatches.get('p1')
|
||||
const settled = subCallsOf(session, 'p1')
|
||||
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
|
||||
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
|
||||
})
|
||||
@@ -1187,7 +1197,7 @@ describe('run_code sub-dispatch indexing', () => {
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
|
||||
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
|
||||
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
|
||||
const subs = session.getSnapshot().codeDispatches.get('p1')
|
||||
const subs = subCallsOf(session, 'p1')
|
||||
expect(subs).toHaveLength(2)
|
||||
expect(subs?.[0]).toMatchObject({
|
||||
kind: 'tool-result', callId: 'p1:code:1',
|
||||
@@ -1205,23 +1215,29 @@ describe('run_code sub-dispatch indexing', () => {
|
||||
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('rebuilds the same index from a history window (replay parity)', async () => {
|
||||
it('rebuilds the same nested tree from a history window (replay parity)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse([
|
||||
...plainTurn(0, 0, '问', '答'),
|
||||
ev.turnStart(6, 1),
|
||||
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
|
||||
ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'),
|
||||
ev.toolResult(9, 1, 'p1', '{"done":true}'),
|
||||
ev.turnEnd(10, 1),
|
||||
ev.codeDispatchStart(8, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }),
|
||||
ev.codeDispatch(9, 'p1:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
|
||||
ev.codeDispatch(10, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }, 'alpha'),
|
||||
ev.toolResult(11, 1, 'p1', '{"done":true}'),
|
||||
ev.turnEnd(12, 1),
|
||||
])
|
||||
await session.open()
|
||||
const subs = session.getSnapshot().codeDispatches.get('p1')
|
||||
const subs = subCallsOf(session, 'p1')
|
||||
expect(subs).toHaveLength(1)
|
||||
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } })
|
||||
expect(subs?.[0]).toMatchObject({
|
||||
callId: 'p1:code:1',
|
||||
call: { name: 'run_code' },
|
||||
subCalls: [{ callId: 'p1:code:1:code:1', call: { name: 'read' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => {
|
||||
it('keeps an unaffected root reference and path-copies it on a new child', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
|
||||
await session.open()
|
||||
@@ -1230,13 +1246,48 @@ describe('run_code sub-dispatch indexing', () => {
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
|
||||
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
|
||||
const before = session.getSnapshot()
|
||||
const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')!
|
||||
feed(ev.chunkStart(9, 1))
|
||||
feed(ev.chunkText(10, 1, '流式'))
|
||||
const after = session.getSnapshot()
|
||||
expect(after.codeDispatches).toBe(before.codeDispatches)
|
||||
const afterRoot = after.runningCalls.find(call => call.callId === 'p1')!
|
||||
expect(afterRoot).toBe(beforeRoot)
|
||||
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
|
||||
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches)
|
||||
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2)
|
||||
const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')!
|
||||
expect(changedRoot).not.toBe(afterRoot)
|
||||
expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0])
|
||||
expect(changedRoot.subCalls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('path-copies only the owning branch when a nested child changes', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '树', '结构'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"first"}'))
|
||||
feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}'))
|
||||
feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child'))
|
||||
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling'))
|
||||
feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two'))
|
||||
const before = session.getSnapshot()
|
||||
const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')!
|
||||
const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')!
|
||||
const beforeChild = beforeFirst.subCalls[0]!
|
||||
const beforeSibling = beforeFirst.subCalls[1]!
|
||||
|
||||
feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf'))
|
||||
const after = session.getSnapshot()
|
||||
const afterFirst = after.runningCalls.find(call => call.callId === 'p1')!
|
||||
const afterSecond = after.runningCalls.find(call => call.callId === 'p2')!
|
||||
|
||||
expect(afterFirst).not.toBe(beforeFirst)
|
||||
expect(afterSecond).toBe(beforeSecond)
|
||||
expect(afterFirst.subCalls[0]).not.toBe(beforeChild)
|
||||
expect(afterFirst.subCalls[1]).toBe(beforeSibling)
|
||||
expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([
|
||||
{ callId: 'p1:code:1:code:1', call: { name: 'read' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
53
packages/client/runtime/tests/subagent-lineage.spec.ts
Normal file
53
packages/client/runtime/tests/subagent-lineage.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { indexSubagentDescendants } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
|
||||
function summary(
|
||||
id: string,
|
||||
parentId?: SessionId,
|
||||
origin?: 'subagent',
|
||||
running = false,
|
||||
): SessionSummary {
|
||||
return {
|
||||
id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0,
|
||||
...(parentId === undefined ? {} : { parentId }),
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
}
|
||||
}
|
||||
|
||||
function index(...summaries: SessionSummary[]) {
|
||||
return indexSubagentDescendants(Object.fromEntries(
|
||||
summaries.map(item => [item.id, item]),
|
||||
))
|
||||
}
|
||||
|
||||
describe('indexSubagentDescendants', () => {
|
||||
it('counts every nested descendant and its exact running state', () => {
|
||||
const owner = summary('owner')
|
||||
const child = summary('child', owner.id, 'subagent')
|
||||
const grandchild = summary('grandchild', child.id, 'subagent', true)
|
||||
|
||||
const result = index(owner, child, grandchild)
|
||||
expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 })
|
||||
expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 })
|
||||
})
|
||||
|
||||
it('stops at ordinary forks and fails soft on cycles and missing parents', () => {
|
||||
const owner = summary('owner')
|
||||
const child = summary('child', owner.id, 'subagent', true)
|
||||
const fork = summary('fork', child.id)
|
||||
const forkChild = summary('fork-child', fork.id, 'subagent', true)
|
||||
const orphan = summary('orphan', sid('missing'), 'subagent', true)
|
||||
const cycleA = summary('cycle-a', sid('cycle-b'), 'subagent')
|
||||
const cycleB = summary('cycle-b', sid('cycle-a'), 'subagent')
|
||||
|
||||
const result = index(owner, child, fork, forkChild, orphan, cycleA, cycleB)
|
||||
expect(result.get(owner.id)).toEqual({ count: 1, runningCount: 1 })
|
||||
expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 })
|
||||
expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 })
|
||||
expect(result.get(cycleA.id)).toEqual({ count: 2, runningCount: 0 })
|
||||
expect(result.get(cycleB.id)).toEqual({ count: 2, runningCount: 0 })
|
||||
})
|
||||
})
|
||||
89
packages/client/runtime/tests/tool-call-tree.spec.ts
Normal file
89
packages/client/runtime/tests/tool-call-tree.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RunningToolCall, ToolCallBlock } from '../src/client/sessions/conversation.ts'
|
||||
import {
|
||||
MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree,
|
||||
} from '../src/client/sessions/tool-call-tree.ts'
|
||||
|
||||
const at = (seq: number, type: string, data: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent
|
||||
|
||||
const start = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
|
||||
at(seq, 'tool/code-dispatch-start', {
|
||||
parentCallId, subCallId, name: 'run_code', arguments: {},
|
||||
})
|
||||
|
||||
const settle = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
|
||||
at(seq, 'tool/code-dispatch', {
|
||||
parentCallId, subCallId, name: 'run_code', arguments: {},
|
||||
isError: false, content: [],
|
||||
})
|
||||
|
||||
const root = (callId: string): RunningToolCall => ({
|
||||
callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
|
||||
time: 1_700_000_000_000, callView: null, subCalls: [],
|
||||
})
|
||||
|
||||
describe('ToolCallTree', () => {
|
||||
it('rejects a self-parenting dispatch edge', () => {
|
||||
const tree = new ToolCallTree()
|
||||
const roots = [root('root')]
|
||||
|
||||
expect(tree.apply(start(0, 'root', 'root'))).toBe(true)
|
||||
expect(tree.projectRunningCalls(roots)).toBe(roots)
|
||||
})
|
||||
|
||||
it('rejects a settling edge that would close a multi-call cycle', () => {
|
||||
const tree = new ToolCallTree()
|
||||
tree.apply(start(0, 'a', 'b'))
|
||||
tree.apply(start(1, 'b', 'c'))
|
||||
|
||||
expect(tree.apply(settle(2, 'c', 'a'))).toBe(true)
|
||||
expect(tree.projectRunningCalls([root('a')])).toMatchObject([{
|
||||
callId: 'a',
|
||||
subCalls: [{
|
||||
callId: 'b',
|
||||
subCalls: [{ callId: 'c', subCalls: [] }],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('accepts an acyclic graph with a shared descendant', () => {
|
||||
const tree = new ToolCallTree()
|
||||
tree.apply(start(0, 'a', 'b'))
|
||||
tree.apply(start(1, 'a', 'c'))
|
||||
tree.apply(start(2, 'b', 'd'))
|
||||
tree.apply(start(3, 'c', 'd'))
|
||||
|
||||
expect(tree.apply(start(4, 'root', 'a'))).toBe(true)
|
||||
expect(tree.projectRunningCalls([root('root')])).toMatchObject([{
|
||||
callId: 'root',
|
||||
subCalls: [{
|
||||
callId: 'a',
|
||||
subCalls: [{ callId: 'b' }, { callId: 'c' }],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects an edge beyond the recursive depth safety limit', () => {
|
||||
const tree = new ToolCallTree()
|
||||
for (let depth = 1; depth < MAX_TOOL_CALL_TREE_DEPTH; depth++) {
|
||||
tree.apply(start(depth, `call-${depth - 1}`, `call-${depth}`))
|
||||
}
|
||||
|
||||
expect(tree.apply(start(
|
||||
MAX_TOOL_CALL_TREE_DEPTH,
|
||||
`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`,
|
||||
`call-${MAX_TOOL_CALL_TREE_DEPTH}`,
|
||||
))).toBe(true)
|
||||
|
||||
let current: ToolCallBlock = tree.projectRunningCalls([root('call-0')])[0]!
|
||||
let depth = 1
|
||||
while (current.subCalls.length > 0) {
|
||||
current = current.subCalls[0]!
|
||||
depth++
|
||||
}
|
||||
expect(depth).toBe(MAX_TOOL_CALL_TREE_DEPTH)
|
||||
expect(current.callId).toBe(`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`)
|
||||
})
|
||||
})
|
||||
@@ -164,6 +164,28 @@ describe('TranscriptAdapter', () => {
|
||||
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
|
||||
})
|
||||
|
||||
it('materializes a skill-invocation injection as a named instructions context', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '/hidden-demo check the fixture' }],
|
||||
source: { kind: 'user' },
|
||||
}) }),
|
||||
at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>' }],
|
||||
source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never,
|
||||
}) }),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
// The gesture stays a user bubble; the injected body folds to a context
|
||||
// row named after the skill, presented as instructions.
|
||||
expect(nodes.map(node => node.kind)).toEqual(['user', 'context'])
|
||||
expect(nodes[1]).toMatchObject({
|
||||
provenance: { role: 'inject', label: 'hidden-demo' },
|
||||
form: 'instructions',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips events core does not call surface-eligible, marker or not', () => {
|
||||
// The transcript is the append-origin surface, so log-only events (a chunk,
|
||||
// a turn boundary, a compact/* provenance record) and a future type core
|
||||
@@ -223,8 +245,14 @@ describe('TranscriptAdapter', () => {
|
||||
checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
|
||||
])
|
||||
expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first' },
|
||||
{ kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' },
|
||||
{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first',
|
||||
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
{
|
||||
kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second',
|
||||
summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -296,7 +324,7 @@ describe('TranscriptAdapter', () => {
|
||||
...(summary === undefined ? [] : [summary]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
])
|
||||
expect(adapter.nodes()).toEqual([
|
||||
expect(adapter.nodes()).toMatchObject([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
|
||||
])
|
||||
})
|
||||
@@ -310,7 +338,10 @@ describe('TranscriptAdapter', () => {
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
])
|
||||
expect(adapter.nodes()).toEqual([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' },
|
||||
{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要',
|
||||
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -324,7 +355,10 @@ describe('TranscriptAdapter', () => {
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
})])
|
||||
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }])
|
||||
expect(adapter.nodes()).toEqual([{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null,
|
||||
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
|
||||
}])
|
||||
})
|
||||
|
||||
it('skips a non-summary provenance seq before reaching the real one', () => {
|
||||
@@ -468,20 +502,22 @@ describe('TranscriptAdapter', () => {
|
||||
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
|
||||
})
|
||||
|
||||
it('renders the /compact row alongside the marker its own command produced', () => {
|
||||
// The row that reports the compaction is a command node; dropping command
|
||||
// folding would delete it together with every other slash-command row.
|
||||
it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '压缩前的问题'),
|
||||
ev.commandRun(1, 'cmd-compact', 'compact'),
|
||||
compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
|
||||
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
|
||||
ev.commandDone(4, 'cmd-compact', 'success', '已压缩'),
|
||||
ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
|
||||
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
|
||||
expect(nodes[1]).toMatchObject({
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 },
|
||||
})
|
||||
expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
|
||||
6
packages/client/schema-form/tsdown.config.ts
Normal file
6
packages/client/schema-form/tsdown.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clientLibrary } from '../tsdown.client.ts'
|
||||
|
||||
export default clientLibrary(
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
)
|
||||
@@ -50,7 +50,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
|
||||
turnEnds: new Map(),
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
codeDispatches: new Map(),
|
||||
pending: [],
|
||||
queue: [],
|
||||
running: false,
|
||||
|
||||
6
packages/client/test-runtime/tsdown.config.ts
Normal file
6
packages/client/test-runtime/tsdown.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clientLibrary } from '../tsdown.client.ts'
|
||||
|
||||
export default clientLibrary(
|
||||
'@deepseek-ai/dsh-client-test-runtime',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
)
|
||||
@@ -9,6 +9,7 @@
|
||||
* The virtual loader registers each real stylesheet as a watch dependency.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { UserConfig } from 'tsdown'
|
||||
@@ -34,6 +35,12 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|
|
||||
/** Generated descriptor/codec contribution with no shared runtime identity. */
|
||||
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
|
||||
|
||||
/**
|
||||
* Workspace mode replaces an empty config array with the root defaults. A
|
||||
* falsey entry instead removes this package before entry resolution.
|
||||
*/
|
||||
const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
|
||||
|
||||
/**
|
||||
* Documented TEMPORARY exemption, not a platform module (hence not in
|
||||
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
|
||||
@@ -61,19 +68,85 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the root workspace shape, so the lib half must be restated here — dropping
|
||||
* it leaves the package without lib/index.js and the host Loader cannot
|
||||
* import its node half.
|
||||
* plus the browser client bundle. Client packages emit both halves during the
|
||||
* Client pass by default; packages needed for Host reflection may opt into the
|
||||
* earlier Host pass. A package-level tsdown.config.ts REPLACES the root
|
||||
* workspace shape, so the lib half must be restated here — dropping it leaves
|
||||
* the package without lib/index.js and the host Loader cannot import its node
|
||||
* half.
|
||||
* @param id - plugin id (package name), stamped into the __ModuleLoader__.load
|
||||
* handoff and onto the injected style tags.
|
||||
* @param libEntry - node-half entries, spelled at the call site so the
|
||||
* package-invariants gate can see `lib/types/invariant.js` in each package's
|
||||
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
|
||||
* @returns tsdown user configs emitting lib/*.js and lib/client.js.
|
||||
* @param options - phase placement, lib overrides, and companion Node configs.
|
||||
* @returns ENV-selected tsdown config for the current build face.
|
||||
*/
|
||||
export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] {
|
||||
return [{
|
||||
export function clientBundle(
|
||||
id: string,
|
||||
libEntry: readonly string[],
|
||||
options: ClientBundleOptions = {},
|
||||
): BuildFaceConfig {
|
||||
const lib = clientLibraryConfig(id, libEntry, options.lib)
|
||||
return ({ env }) => {
|
||||
const face = buildFace(env?.DSH_BUILD_FACE)
|
||||
const client = clientConfig(id, face === undefined
|
||||
? 'src/client/index.ts'
|
||||
: 'lib/types/client/index.js')
|
||||
const node = [lib, ...(options.companions ?? [])]
|
||||
if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
|
||||
if (face === 'client') return options.hostPhase === true ? [client] : [...node, client]
|
||||
return [...node, client]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Client-only Node library during the Client pass.
|
||||
* @param id - Package name used in tsdown diagnostics.
|
||||
* @param libEntry - Emitted JavaScript entries consumed from `lib/types`.
|
||||
* @returns ENV-selected tsdown config for the Client build face.
|
||||
*/
|
||||
export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig {
|
||||
const lib = clientLibraryConfig(id, libEntry)
|
||||
return clientOnly([lib])
|
||||
}
|
||||
|
||||
/**
|
||||
* Select arbitrary package-local configs only during the Client pass.
|
||||
* @param configs - Node-side configs emitted after Client tsc.
|
||||
* @returns ENV-selected tsdown config for the Client build face.
|
||||
*/
|
||||
export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
|
||||
return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host'
|
||||
? [SKIP_WORKSPACE_BUILD]
|
||||
: [...configs]
|
||||
}
|
||||
|
||||
interface ClientBundleOptions {
|
||||
/** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
|
||||
readonly hostPhase?: boolean
|
||||
/** Additional Node-side configs emitted alongside the package library. */
|
||||
readonly companions?: readonly UserConfig[]
|
||||
/** Overrides for the package's primary Node-side library config. */
|
||||
readonly lib?: UserConfig
|
||||
}
|
||||
|
||||
type BuildFace = 'host' | 'client' | undefined
|
||||
|
||||
type BuildFaceConfig = (inlineConfig: Pick<UserConfig, 'env'>) => UserConfig[]
|
||||
|
||||
function buildFace(value: unknown): BuildFace {
|
||||
if (value === undefined || value === 'host' || value === 'client') return value
|
||||
throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`)
|
||||
}
|
||||
|
||||
function clientLibraryConfig(
|
||||
id: string,
|
||||
libEntry: readonly string[],
|
||||
overrides: UserConfig = {},
|
||||
): UserConfig {
|
||||
return {
|
||||
name: id,
|
||||
entry: [...libEntry],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
@@ -82,8 +155,14 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
}, {
|
||||
entry: { client: 'src/client/index.ts' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function clientConfig(id: string, entry: string): UserConfig {
|
||||
return {
|
||||
name: `${id}/client`,
|
||||
entry: { client: entry },
|
||||
// Browser bundle lands next to the node half (single lib/ artifact dir;
|
||||
// the entryFileNames pin keeps it exactly lib/client.js). clean must stay
|
||||
// off — a default clean would wipe the node-half output emitted above.
|
||||
@@ -139,7 +218,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
name: 'dsh-css-modules-inline',
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!source.endsWith('.module.css')) return null
|
||||
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
|
||||
const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
|
||||
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
||||
},
|
||||
async load(virtualId: string) {
|
||||
@@ -151,7 +230,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
code: source,
|
||||
cssModules: { pattern: `[hash]_[local]` },
|
||||
cssModules: { pattern: '[hash]_[local]' },
|
||||
minify: true,
|
||||
})
|
||||
const classMap: Record<string, string> = {}
|
||||
@@ -160,13 +239,13 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
return [
|
||||
`const css = ${JSON.stringify(code.toString())};`,
|
||||
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
|
||||
`if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
|
||||
` const tag = document.createElement('style');`,
|
||||
'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
|
||||
' const tag = document.createElement(\'style\');',
|
||||
` tag.dataset.plugin = ${JSON.stringify(id)};`,
|
||||
` tag.dataset.pluginCss = tagId;`,
|
||||
` tag.textContent = css;`,
|
||||
` document.head.appendChild(tag);`,
|
||||
`}`,
|
||||
' tag.dataset.pluginCss = tagId;',
|
||||
' tag.textContent = css;',
|
||||
' document.head.appendChild(tag);',
|
||||
'}',
|
||||
`export default ${JSON.stringify(classMap)};`,
|
||||
].join('\n')
|
||||
},
|
||||
@@ -179,8 +258,18 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
// without exposing that tree as an HTTP route.
|
||||
sourcemapPathTransform: browserSourcePath,
|
||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
|
||||
footer: `return module.exports; } });`,
|
||||
footer: 'return module.exports; } });',
|
||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||
},
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an emitted JS asset import against its source-tree counterpart. */
|
||||
function sourceAssetPath(source: string, importer: string): string {
|
||||
const emitted = resolvePath(dirname(importer), source)
|
||||
if (existsSync(emitted)) return emitted
|
||||
const marker = `${sep}lib${sep}types${sep}`
|
||||
const boundary = emitted.indexOf(marker)
|
||||
if (boundary < 0) return emitted
|
||||
return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length))
|
||||
}
|
||||
|
||||
@@ -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: 2fe73dfa59d34dbb24389b412a01347aa2d14398
|
||||
README.zh.md: da02d1dadff8ea6187e33396a139eaca58dab6e6
|
||||
README.md: 3e3a6b1a09cbed77700fb656882178efd3744a80
|
||||
README.zh.md: f3f25426156b3778859ec8f64e8d947362b967ab
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, and turn status), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), details shell, and scope-addressed ConversationService. Tool presentation belongs to [`ui-tool`](../ui-tool/README.md).
|
||||
|
||||
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
|
||||
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with structured summary provenance shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable.
|
||||
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
|
||||
|
||||
@@ -16,27 +16,15 @@ Approvals take over the composer through the chain this package declares: `Appro
|
||||
|
||||
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
|
||||
|
||||
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is an underlined link — it reads as one at rest, not only on hover, because a path styled like the surrounding prose is an affordance nobody finds — and it opens the file through the Host (`host.openPath`, relative paths resolve against the session cwd). A document a browser renders prefers the default browser where the Host platform can name one; Windows and WSL use the Windows registered association. The Host opens it on the Host's own machine: a client reached over a network sees nothing, which is the deliberate scope of this surface. Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
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 below its summary row; tool rows are summary surfaces, so 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 keeps the summary 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 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)).
|
||||
|
||||
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) composes the shared `ToolRow`, feeding the diff as ToolRow's `diff` body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. The render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
|
||||
The chat view keeps Tool placement but delegates Tool presentation. It passes each ordered root call through `conversation.chat.tool`, and the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle fills the whole-Tool seat with [`ui-tool`](../ui-tool/README.md), which selects Runtime-projected Code Dispatch children and owns root/child composition, per-name dispatch, generic rendering, and render-intent cards; the details seat alone retains a raw-result fallback when that renderer is absent.
|
||||
|
||||
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments.
|
||||
|
||||
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
|
||||
|
||||
Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin with only the slot service edge: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`. The declaration is the activation and reload dependency; `ConversationService` is required only by registrations that call its actions. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders.
|
||||
|
||||
The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> completed · <active item>` plus a `+<n>` count of the other active ones, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). When the deployment permits parallel work, several items may be `in_progress` at once, so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. The row hands the count to `ToolRow`'s `summarySuffix`, the shared row's non-shrinking slot beside that ellipsized text (an error row drops it, since its collapsed summary is the failure line). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted; status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
`TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted). The dock adapter owns selection so the panel stays a pure function of its props. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. The `todo_write` Tool row belongs to [`ui-tool`](../ui-tool/README.md).
|
||||
|
||||
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
|
||||
|
||||
@@ -50,7 +38,7 @@ The composer bar declares session-scoped single seats for `'conversation.input.p
|
||||
|
||||
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
|
||||
|
||||
`src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations.
|
||||
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
|
||||
|
||||
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert.
|
||||
|
||||
@@ -64,7 +52,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
|
||||
- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离与轮次状态)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、详情壳层,以及按 scope 寻址的 ConversationService。Tool 展示属于 [`ui-tool`](../ui-tool/README.md)。
|
||||
|
||||
压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。
|
||||
压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个具备结构化摘要溯源的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。
|
||||
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
||||
|
||||
@@ -14,29 +14,17 @@
|
||||
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
|
||||
|
||||
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是带下划线的链接——静止状态下就读得出是链接,而不只在悬停时,因为一条与周围正文同样样式的路径是没人会发现的交互——点击即经由 Host 打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。浏览器能渲染的文档会在 Host 平台能够确定默认浏览器时优先使用它;Windows 与 WSL 则使用 Windows 注册的文件关联。Host 在它自己的机器上打开:经网络访问的客户端看不到任何东西,这是本交互面刻意划定的范围。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片放在摘要行下方;工具行是摘要 surface,因此卡片的复制与展开控件是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,因此摘要保持有界;面板仍是单次调用的阅读 surface。内联输出按渲染意图开放——终端卡片与 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 渲染卡片,详情面板渲染它,并在卡片下方渲染摊平的模型可见结果内容——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))。
|
||||
|
||||
声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)组合共享的 `ToolRow`,把 diff 作为 ToolRow 的 `diff` body 传入,因此它是该行默认折叠的展开卡片;摘要路径链接仍经 host 打开文件,而出错的改动(没有 diff 卡片)经 ToolRow 的 Output 区呈现其错误文本,首行进入折叠摘要。渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
|
||||
聊天视图保留 Tool 的消息流位置,但委托其展示。它通过 `conversation.chat.tool` 传递每个已排序的 root call;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 由 [`ui-tool`](../ui-tool/README.md) 填充整体 Tool 席位,并由后者选择 Runtime 已投影的 Code Dispatch 子调用,负责 root/child 编排、按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。
|
||||
|
||||
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。
|
||||
|
||||
声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
|
||||
|
||||
工具行使用键控、Session scope 的 `'conversation.chat.toolview'` slot;其渲染点通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 fallback。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 将其与 Session 标准工具包组合。注册方是只依赖 slot 服务的普通插件:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`。声明本身就是激活与重载依赖;只有调用 `ConversationService` 操作的注册项才需要该服务。Trajectory 与 waterfall(瀑布式事件)工具视图 slot 共享此形状并使用各自的渲染点;RendersCheck 会拒绝没有任何渲染方的声明。
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 经 `toolviews/plan-summary.ts` 的 `planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,以及「其余活跃项的数量」`+<n>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。部署允许并行工作时,可以有多个条目同时处于 `in_progress`,因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。该行把数量交给 `ToolRow` 的 `summarySuffix`——共享行在被截断文本旁的不收缩位(出错的行会丢弃它,因为其折叠摘要是失败首行)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加它自行计算的、以 `·` 连接的各状态计数(本地化,形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略;状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` Tool 行属于 [`ui-tool`](../ui-tool/README.md)。
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
|
||||
@@ -50,7 +38,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
|
||||
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
|
||||
|
||||
`src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。
|
||||
`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。
|
||||
|
||||
完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。
|
||||
|
||||
@@ -64,7 +52,6 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
|
||||
- **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-conversation",
|
||||
"description": "Conversation domain: skeleton (header/tabs/composer), chat view, ctx.toolviews registry, minimal details panel",
|
||||
"description": "Conversation domain: skeleton, ordered chat flow, composer, and details host",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { resolveWorkspacePath, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import type { InputNotice } from './input/contract.ts'
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
@@ -24,14 +23,7 @@ import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
|
||||
import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { readToolview } from './toolviews/read-row.tsx'
|
||||
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
|
||||
import { searchToolview } from './toolviews/search-row.tsx'
|
||||
import { webToolview } from './toolviews/web-row.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { queueDockEntry } from './queue/QueueDock.tsx'
|
||||
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
@@ -41,7 +33,7 @@ import { en, NS, zh, type ConversationKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The conversation surfaces' copy (skeleton, chat view, toolviews, docks). */
|
||||
/** The conversation skeleton, chat flow, commands, details, and docks copy. */
|
||||
conversation: ConversationKey
|
||||
}
|
||||
}
|
||||
@@ -304,10 +296,8 @@ export function apply(ctx: Context): void {
|
||||
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
|
||||
|
||||
// The chat view: first entry of the ring this package just declared.
|
||||
// Declaring the keyed toolview hole here is claiming it: ChatView is the
|
||||
// only component authorized to render per-tool rows. Shares the chat
|
||||
// store, so its selection writes land in the same per-session instance the
|
||||
// details panel reads.
|
||||
// ChatView owns ordered Tool placement but delegates each whole root call
|
||||
// to ui-tool, which owns root/subcall composition and atomic dispatch.
|
||||
slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'chat',
|
||||
@@ -315,7 +305,7 @@ export function apply(ctx: Context): void {
|
||||
label: () => t('view.chat'),
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
|
||||
'conversation.chat.tool': { kind: 'single', scope: 'session' },
|
||||
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
|
||||
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
|
||||
},
|
||||
@@ -330,7 +320,7 @@ export function apply(ctx: Context): void {
|
||||
fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner),
|
||||
openFile: (path) => {
|
||||
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
|
||||
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
|
||||
void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => {
|
||||
// Host/OS open failures stay silent in the chat row; the native
|
||||
// app surfaces its own error dialog when the path is unusable.
|
||||
})
|
||||
@@ -369,34 +359,6 @@ export function apply(ctx: Context): void {
|
||||
// this service remains only where conversation actions are required.
|
||||
ctx.plugin(ConversationService, { input: inputHub, blocks: composerBlocks })
|
||||
|
||||
// The bash sample rides the same declaration seam, in third-party posture
|
||||
// (ToolRow-matching Bash · {description} chrome).
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
// The read row rides the same seam (a product registration, not a sample):
|
||||
// Read · {path} chrome with the file's read card resident below it.
|
||||
ctx.plugin(readToolview)
|
||||
|
||||
// The write/edit rows ride the same seam: a file-mutation call declares the
|
||||
// diff render intent, so these rows stack the applied diff card under their
|
||||
// path-link summary (the terminal card's posture, applied to diffs).
|
||||
ctx.plugin(fileMutationToolview)
|
||||
|
||||
// The grep/glob search row rides the same seam: one component registered
|
||||
// under both tool names, since both declare the same search render intent.
|
||||
ctx.plugin(searchToolview)
|
||||
|
||||
// The web rows ride the same seam: one WebRow registered under both
|
||||
// web_search and web_fetch, rendering the completed retrieval's web card
|
||||
// resident under the summary (a product registration, not a sample).
|
||||
ctx.plugin(webToolview)
|
||||
|
||||
// The todo_write row rides the same seam (a product registration, not a sample).
|
||||
ctx.plugin(todoToolview)
|
||||
|
||||
// The ask_user_question row: waiting/answered/cancelled interaction outcome.
|
||||
ctx.plugin(askQuestionToolview)
|
||||
|
||||
// The plan strip rides the input dock above the queue rows (same posture).
|
||||
ctx.plugin(todoDockEntry)
|
||||
|
||||
@@ -407,6 +369,9 @@ export function apply(ctx: Context): void {
|
||||
slots.register({
|
||||
name: 'details',
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.details.tool': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (): DetailsInjected => ({
|
||||
closeDetails: () => { layout.closeDetails() },
|
||||
|
||||
@@ -12,14 +12,12 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { hasContentText } from './chat-flow.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
export interface AssistantMarkdownProps {
|
||||
@@ -52,18 +50,6 @@ export interface AssistantMarkdownProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
const nl = text.indexOf('\n')
|
||||
return nl === -1 ? text : text.slice(0, nl)
|
||||
}
|
||||
|
||||
/** Latest non-blank reasoning line while the block is still streaming. */
|
||||
function latestLine(text: string): string {
|
||||
const visible = text.trimEnd()
|
||||
const nl = visible.lastIndexOf('\n')
|
||||
return nl === -1 ? visible : visible.slice(nl + 1)
|
||||
}
|
||||
|
||||
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
|
||||
function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
const parts: string[] = []
|
||||
@@ -74,20 +60,6 @@ function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
}
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) {
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant="think"
|
||||
icon={<IconThinkOutline14 size={14} />}
|
||||
title="Think"
|
||||
summary={running ? latestLine(text) : firstLine(text)}
|
||||
body={text}
|
||||
state={running ? 'running' : 'ok'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail,
|
||||
fileMentions, t,
|
||||
@@ -132,7 +104,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
fileMentions={mentions}
|
||||
/>
|
||||
)
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
||||
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return (
|
||||
|
||||
@@ -64,17 +64,6 @@
|
||||
/* Selection still sets data-selected for details linkage; no outline —
|
||||
tool rows match Think chrome (no selected ring). */
|
||||
|
||||
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
|
||||
the code turn reads as one unit; each nested row is itself a .callRow. */
|
||||
.subCalls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 22px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Turn activity keeps the former loader's one-line footprint. A pale
|
||||
brand-blue band sweeps from left to right; reduced-motion keeps it static. */
|
||||
.turnStatus {
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
// assistant narration, tool summary rows grouped into step runs, pending
|
||||
// cards, paging, and bottom-follow. Session stats live on
|
||||
// 'conversation.composer.dock' (sticky with the composer). Pure component
|
||||
// registered directly; its registration declares the keyed
|
||||
// 'conversation.chat.toolview' hole, so tool rows render through the props
|
||||
// renderSlot share (entryKey = tool name, GenericToolCard as the render-site
|
||||
// fallback).
|
||||
// registered directly; its registration declares the whole-Tool
|
||||
// 'conversation.chat.tool' seat. ui-tool owns root/subcall composition and
|
||||
// keyed per-tool dispatch behind that boundary.
|
||||
//
|
||||
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
|
||||
// column), that host is the scrollport and this view is flow content; when
|
||||
@@ -25,15 +24,15 @@ import {
|
||||
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||||
} from 'react'
|
||||
import type {
|
||||
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||||
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
|
||||
import { formatRunDuration } from './message-chrome.ts'
|
||||
import { deriveTurnMetrics } from './turn-metrics.ts'
|
||||
@@ -103,8 +102,8 @@ type OpenFile = (path: string) => void
|
||||
|
||||
type InspectCall = (callId: string) => void
|
||||
|
||||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||||
type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
/** Declared child-slot render share (stable framework binding). */
|
||||
type RenderChatSlot = ChatViewSlotProps['renderSlot']
|
||||
|
||||
type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
|
||||
|
||||
@@ -112,6 +111,11 @@ type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']
|
||||
* chat view narrows once to the runtime snapshot the binding actually feeds. */
|
||||
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
function treeContainsCall(block: ToolCallBlock, callId: string | undefined): boolean {
|
||||
return callId !== undefined
|
||||
&& (block.callId === callId || block.subCalls.some(child => treeContainsCall(child, callId)))
|
||||
}
|
||||
|
||||
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
|
||||
if (!running) return null
|
||||
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||||
@@ -135,129 +139,49 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
* renders the running state exactly as a native in-flight row. */
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CodeSubCall
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const settled = 'kind' in node
|
||||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||||
const owner = useMemo(() => ({
|
||||
callId: node.callId, toolName, block: node, openFile, cwd,
|
||||
inspect: () => { inspectCall(node.callId) },
|
||||
}), [node, toolName, openFile, cwd, inspectCall])
|
||||
return (
|
||||
<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} />,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** One tool call row (result or running): dispatches through the keyed
|
||||
* toolview slot with the owner payload; unregistered tools fall back to
|
||||
* GenericToolCard at this render site. A `run_code` call additionally
|
||||
* renders its logged sub-dispatches as always-visible indented rows —
|
||||
* each one the same keyed-slot dispatch as a native top-level call. */
|
||||
const CallRow = memo(function CallRow({
|
||||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall, t,
|
||||
/** One ordered root Tool call handed intact to the Tool presentation plugin. */
|
||||
const ToolSeat = memo(function ToolSeat({
|
||||
renderSlot, callId, toolName, block, openFile, selectedCallId, cwd, inspectCall,
|
||||
}: {
|
||||
renderSlot: RenderToolRow
|
||||
renderSlot: RenderChatSlot
|
||||
callId: string
|
||||
toolName: string
|
||||
block: ToolResultNode | RunningToolCall
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per
|
||||
* parent; running entries settle in place); undefined for ordinary calls. */
|
||||
subCalls?: readonly CodeSubCall[] | undefined
|
||||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||||
selectedCallId?: string | undefined
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block, openFile, cwd,
|
||||
inspect: () => { inspectCall(callId) },
|
||||
}), [callId, toolName, block, openFile, cwd, inspectCall])
|
||||
return (
|
||||
<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} />,
|
||||
})}
|
||||
{subCalls !== undefined && subCalls.length > 0 && (
|
||||
<div className={css.subCalls} data-subcalls>
|
||||
{subCalls.map(node => (
|
||||
<SubCallRow
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
node={node}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
callId, toolName, block, selectedCallId, cwd, openFile, inspectCall,
|
||||
}), [callId, toolName, block, selectedCallId, cwd, openFile, inspectCall])
|
||||
return renderSlot('conversation.chat.tool', owner)
|
||||
})
|
||||
|
||||
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, cwd, inspectCall }: {
|
||||
renderSlot: RenderChatSlot
|
||||
results: readonly ToolResultNode[]
|
||||
openFile: OpenFile
|
||||
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
|
||||
/** Tool ownership resolves whether the selection is this root or one of its children. */
|
||||
selectedCallId: string | undefined
|
||||
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
{results.map(node => (
|
||||
<CallRow
|
||||
<ToolSeat
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={node.callId}
|
||||
toolName={node.call?.name ?? ''}
|
||||
block={node}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(node.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
selectedCallId={treeContainsCall(node, selectedCallId) ? selectedCallId : undefined}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -267,17 +191,21 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
/** One command lifecycle row: keyed dispatch on the command name with the
|
||||
* generic card as the render-site fallback (zero registration required). A
|
||||
* run-less cross-window node has no name and always lands on the fallback. */
|
||||
const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: {
|
||||
renderSlot: RenderChatSlot
|
||||
node: CommandNode
|
||||
compaction?: Extract<ConversationNode, { kind: 'compaction' }>
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({ node }), [node])
|
||||
const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node])
|
||||
const fallback = node.name === 'compact'
|
||||
? <CompactionCommandCard {...owner} t={t} />
|
||||
: <GenericCommandCard {...owner} t={t} />
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: node.name ?? '',
|
||||
fallback: <GenericCommandCard {...owner} t={t} />,
|
||||
fallback,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
@@ -331,8 +259,8 @@ function StreamingTail({ useSession, t }: {
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
* The chat view slot entry: pure component over the composed props; each
|
||||
* ordered root Tool call crosses the declared whole-Tool render seat.
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
|
||||
@@ -346,7 +274,6 @@ export function ChatView({
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
const running = useSession(s => s.running)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const openState = useSession(s => s.openState)
|
||||
const openError = useSession(s => s.openError)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
@@ -565,18 +492,23 @@ export function ChatView({
|
||||
|
||||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||||
if (item.kind === 'tool-group') {
|
||||
const inGroup = selectedCallId !== undefined
|
||||
&& item.results.some(r => r.callId === selectedCallId
|
||||
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
|
||||
return (
|
||||
<ToolGroup
|
||||
renderSlot={renderSlot}
|
||||
results={item.results}
|
||||
openFile={openFile}
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
codeDispatches={codeDispatches}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.kind === 'command-compaction') {
|
||||
return (
|
||||
<CommandRow
|
||||
renderSlot={renderSlot}
|
||||
node={item.command}
|
||||
compaction={item.compaction}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
@@ -644,9 +576,17 @@ export function ChatView({
|
||||
<div
|
||||
key={item.key}
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={item.kind === 'node' ? `node:${String(item.node.seq)}` : undefined}
|
||||
data-chat-anchor-key={item.kind === 'node'
|
||||
? `node:${String(item.node.seq)}`
|
||||
: item.kind === 'command-compaction'
|
||||
? `node:${String(item.compaction.seq)}`
|
||||
: undefined}
|
||||
data-chat-flow-key={item.key}
|
||||
data-chat-flow-kind={item.kind === 'node' ? item.node.kind : 'tool-group'}
|
||||
data-chat-flow-kind={item.kind === 'node'
|
||||
? item.node.kind
|
||||
: item.kind === 'command-compaction'
|
||||
? item.kind
|
||||
: 'tool-group'}
|
||||
>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
@@ -655,19 +595,16 @@ export function ChatView({
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map(call => (
|
||||
<CallRow
|
||||
<ToolSeat
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
openFile={openFile}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
selectedCallId={treeContainsCall(call, selectedCallId) ? selectedCallId : undefined}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// CompactionCommandCard: the `/compact` command's running row and its
|
||||
// successful checkpoint disclosure. Outcomes without a checkpoint keep the
|
||||
// generic command card so no-history, cancellation, and failures retain their
|
||||
// complete handler-authored text.
|
||||
|
||||
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
|
||||
import { CompactionItem } from './CompactionItem.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
|
||||
interface CompactionCommandCardProps extends CommandRowOwnerProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/** Render one manual compaction lifecycle without duplicating its checkpoint marker. */
|
||||
export function CompactionCommandCard({ node, compaction, t }: CompactionCommandCardProps) {
|
||||
if (compaction !== undefined) {
|
||||
return (
|
||||
<CompactionItem
|
||||
node={compaction}
|
||||
title="compact"
|
||||
fallbackSummary={node.outcome?.text ?? null}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.outcome !== null) return <GenericCommandCard node={node} t={t} />
|
||||
return <GenericCommandCard node={node} t={t} runningSummary={t('message.compaction.running')} />
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
import { memo, useState } from 'react'
|
||||
import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconApiOutline14,
|
||||
IconChevronDownOutline14,
|
||||
IconChevronRightOutline14,
|
||||
MarkdownText,
|
||||
@@ -18,6 +19,10 @@ import css from './MessageItem.module.css'
|
||||
|
||||
interface CompactionItemProps {
|
||||
node: CompactionSummaryNode
|
||||
/** Optional command title for a manual compaction folded into this marker. */
|
||||
title?: string
|
||||
/** Command settlement text used when structured compaction counts are unavailable. */
|
||||
fallbackSummary?: string | null
|
||||
/** The owning view's locale seat. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -27,10 +32,22 @@ interface CompactionItemProps {
|
||||
* @param props - the marker node off the snapshot cache.
|
||||
* @returns the marker row, with the summary disclosure when one is available.
|
||||
*/
|
||||
export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) {
|
||||
export const CompactionItem = memo(function CompactionItem({
|
||||
node,
|
||||
title,
|
||||
fallbackSummary,
|
||||
t,
|
||||
}: CompactionItemProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = node.summary !== null
|
||||
const open = expandable && expanded
|
||||
const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null
|
||||
? t('message.compaction.completed', {
|
||||
items: node.shadowedItemCount,
|
||||
tokens: node.shadowedTokenCount,
|
||||
})
|
||||
: fallbackSummary
|
||||
?? (expandable ? t('message.compaction.expand') : t('message.compaction.unavailable'))
|
||||
return (
|
||||
<div className={css.compactionRow}>
|
||||
<button
|
||||
@@ -40,14 +57,20 @@ export const CompactionItem = memo(function CompactionItem({ node, t }: Compacti
|
||||
aria-expanded={expandable ? open : undefined}
|
||||
onClick={() => { setExpanded(value => !value) }}
|
||||
>
|
||||
<span className={css.compactionLeading}>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
<span className={css.compactionLeading} aria-hidden>
|
||||
<span className={css.compactionContextIcon} data-compaction-icon="context">
|
||||
<IconApiOutline14 />
|
||||
</span>
|
||||
<span
|
||||
className={css.compactionDisclosureIcon}
|
||||
data-compaction-disclosure={open ? 'expanded' : 'collapsed'}
|
||||
>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.compactionTitle}>{t('message.compaction')}</span>
|
||||
<span className={css.compactionTitle}>{title ?? t('message.compaction')}</span>
|
||||
<span className={css.compactionSep} aria-hidden />
|
||||
<span className={css.compactionSummary}>
|
||||
{expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')}
|
||||
</span>
|
||||
<span className={css.compactionSummary}>{summary}</span>
|
||||
</button>
|
||||
{open && node.summary !== null
|
||||
&& <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { contextBody } from './ContextBody.tsx'
|
||||
import css from './ContextInjectionRow.module.css'
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root[data-state='running'] .row::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-command-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-command-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary[data-error],
|
||||
.body[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
max-height: 260px;
|
||||
margin: 4px 0 4px 4px;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root[data-state='running'] .row::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -4,42 +4,70 @@
|
||||
// fallback (an unregistered command name lands here); registrants may compose
|
||||
// it as a base, feeding the same owner payload through.
|
||||
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import type { ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
|
||||
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DisclosureRow, IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
import css from './GenericCommandCard.module.css'
|
||||
|
||||
type CommandRowState = 'running' | 'ok' | 'error'
|
||||
|
||||
/** Node state → row state semantic (running while unsettled; outcome kind after). */
|
||||
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState {
|
||||
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): CommandRowState {
|
||||
if (outcome === null) return 'running'
|
||||
return outcome.kind === 'error' ? 'error' : 'ok'
|
||||
}
|
||||
|
||||
function leadingFor(state: CommandRowState): ReactNode {
|
||||
return state === 'error' ? <StateDot state="error" /> : <IconApiOutline14 size={14} />
|
||||
}
|
||||
|
||||
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
|
||||
export interface GenericCommandCardProps extends CommandRowOwnerProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
/** Command-specific running copy; absent uses the generic command label. */
|
||||
runningSummary?: string | undefined
|
||||
}
|
||||
|
||||
export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
|
||||
export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const text = node.outcome?.text
|
||||
const summary = node.outcome === null
|
||||
? t('command.running')
|
||||
? runningSummary ?? t('command.running')
|
||||
: text ?? (node.outcome.kind === 'error' ? t('command.failed') : t('command.done'))
|
||||
// Title is the bare command name: the row already reads `name · outcome`,
|
||||
// and the dispatched line's own `/` and arguments only restate what the
|
||||
// settlement text says (`permission · preset workspace-write`). A
|
||||
// cross-window node whose run page fell out of the window has no name.
|
||||
const title = node.name ?? t('command.title')
|
||||
const state = stateOf(node.outcome)
|
||||
const body = text !== undefined && text.includes('\n') ? text : null
|
||||
const open = expanded && body !== null
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant="others"
|
||||
icon={<IconApiOutline14 size={14} />}
|
||||
title={title}
|
||||
summary={summary}
|
||||
// Expandable only when the outcome text overflows a one-line summary.
|
||||
body={text !== undefined && text.includes('\n') ? text : null}
|
||||
state={stateOf(node.outcome)}
|
||||
/>
|
||||
<div className={css.root} data-variant="others" data-state={state}>
|
||||
{state === 'running' && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
|
||||
{state === 'error' && <span className={a11yCss.visuallyHidden}>{t('row.failed')}</span>}
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
leadingClassName={css.leading}
|
||||
titleClassName={css.title}
|
||||
chevronClassName={css.chevron}
|
||||
icon={leadingFor(state)}
|
||||
title={title}
|
||||
open={open}
|
||||
expandable={body !== null}
|
||||
expandOnRowClick
|
||||
keepContentWhenOpen
|
||||
onToggle={() => { setExpanded(value => !value) }}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.summary} data-error={state === 'error' || undefined}>{summary}</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<pre className={css.body} data-error={state === 'error' || undefined}>{body}</pre>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* Compaction marker: one dim 24px row with a chevron disclosure for the
|
||||
summary body. Dimmed title (not label-primary) — the row is a boundary
|
||||
notice, not conversation content. */
|
||||
/* Compaction marker: one dim 24px row with a context icon at rest and a
|
||||
hover/focus disclosure for the summary body. Dimmed title (not
|
||||
label-primary) — the row is a boundary notice, not conversation content. */
|
||||
.compactionRow {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@@ -65,15 +65,36 @@
|
||||
|
||||
.compactionLeading {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.compactionContextIcon,
|
||||
.compactionDisclosureIcon {
|
||||
display: inline-flex;
|
||||
grid-area: 1 / 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.compactionDisclosureIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover .compactionContextIcon,
|
||||
.compactionButton:not(:disabled):focus-visible .compactionContextIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover .compactionDisclosureIcon,
|
||||
.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.compactionTitle {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -137,29 +137,27 @@ function TurnErrorItem({ node, t }: {
|
||||
/**
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
* logged model text remains the single truth; this is presentation only. Two
|
||||
* shapes decorate: legacy `<skill>name</skill>` spans (pre-decision-21
|
||||
* history) and plain-text `/name` / `@name` word-boundary tokens (decision
|
||||
* 21: the sent text IS the reference — the bubble uses the same plainest
|
||||
* token scan as the composer, minus the lexicon: sent tokens were validated
|
||||
* at compose time, so shape alone decorates).
|
||||
* logged model text remains the single truth; this is presentation only.
|
||||
* Plain-text `/name` / `@name` word-boundary tokens decorate (decision 21:
|
||||
* the sent text IS the reference — the bubble uses the same plainest token
|
||||
* scan as the composer, minus the lexicon: sent tokens were validated at
|
||||
* compose time, so shape alone decorates).
|
||||
*/
|
||||
function projectUserText(text: string): ReactNode {
|
||||
const re = /<skill>([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g
|
||||
const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g
|
||||
const parts: ReactNode[] = []
|
||||
let cursor = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const legacy = m[1] !== undefined
|
||||
const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0)
|
||||
const label = legacy ? `/${m[1]}` : m[3] ?? ''
|
||||
const tokenStart = m.index + (m[1]?.length ?? 0)
|
||||
const label = m[2] ?? ''
|
||||
if (tokenStart > cursor) parts.push(<MessageText key={cursor} text={text.slice(cursor, tokenStart)} />)
|
||||
parts.push(
|
||||
<span key={tokenStart} className={css.refChip} data-ref-chip={label.startsWith('@') ? 'subagent' : 'skill'}>
|
||||
{label}
|
||||
</span>,
|
||||
)
|
||||
cursor = legacy ? m.index + m[0].length : tokenStart + label.length
|
||||
cursor = tokenStart + label.length
|
||||
}
|
||||
if (parts.length === 0) return <MessageText text={text} />
|
||||
if (cursor < text.length) parts.push(<MessageText key={cursor} text={text.slice(cursor)} />)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root[data-state='running'] .row::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-reasoning-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-reasoning-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary[data-follow-end] {
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.thinkBody {
|
||||
padding: 4px 0 4px 22px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root[data-state='running'] .row::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Assistant reasoning disclosure, independent of Tool-call presentation. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { DisclosureRow, IconThinkOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
import css from './ReasoningRow.module.css'
|
||||
|
||||
function firstLine(text: string): string {
|
||||
const newline = text.indexOf('\n')
|
||||
return newline === -1 ? text : text.slice(0, newline)
|
||||
}
|
||||
|
||||
function latestLine(text: string): string {
|
||||
const visible = text.trimEnd()
|
||||
const newline = visible.lastIndexOf('\n')
|
||||
return newline === -1 ? visible : visible.slice(newline + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one assistant reasoning block as the Think disclosure row.
|
||||
* @param props.text - complete or streaming reasoning text.
|
||||
* @param props.running - whether this block is the streaming tail.
|
||||
* @param props.t - conversation locale seat for the running status.
|
||||
* @returns the reasoning disclosure.
|
||||
*/
|
||||
export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const summaryRef = useRef<HTMLSpanElement>(null)
|
||||
const summary = running ? latestLine(text) : firstLine(text)
|
||||
const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
|
||||
const element = summaryRef.current
|
||||
if (element === null) return
|
||||
element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0
|
||||
})
|
||||
useEffect(() => {
|
||||
scheduleSummaryScroll()
|
||||
}, [running, scheduleSummaryScroll, summary])
|
||||
|
||||
return (
|
||||
<div className={css.root} data-variant="think" data-state={running ? 'running' : 'ok'}>
|
||||
{running && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
leadingClassName={css.leading}
|
||||
titleClassName={css.title}
|
||||
chevronClassName={css.chevron}
|
||||
icon={<IconThinkOutline14 size={14} />}
|
||||
title="Think"
|
||||
open={expanded}
|
||||
expandable
|
||||
expandOnRowClick
|
||||
onToggle={() => { setExpanded(value => !value) }}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span ref={summaryRef} className={css.summary} data-follow-end={running || undefined}>{summary}</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.thinkBody}>{text}</div>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -9,13 +9,48 @@
|
||||
* flow share their gates.
|
||||
*/
|
||||
import type {
|
||||
AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode,
|
||||
AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One renderable flow item; key is the React key and the parent's identity unit. */
|
||||
export type ChatFlowItem =
|
||||
| { kind: 'node'; key: string; node: ConversationNode }
|
||||
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
|
||||
| {
|
||||
kind: 'command-compaction'
|
||||
key: string
|
||||
command: CommandNode
|
||||
compaction: CompactionSummaryNode
|
||||
}
|
||||
|
||||
/** Match explicit command outcome references to exactly one compaction checkpoint. */
|
||||
function commandCompactionPairs(nodes: readonly ConversationNode[]): {
|
||||
readonly byCommandId: ReadonlyMap<string, CompactionSummaryNode>
|
||||
readonly byCompactionSeq: ReadonlyMap<number, CommandNode>
|
||||
} {
|
||||
const commandsBySource = new Map<number, CommandNode | null>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue
|
||||
const source = node.outcome.sourceEventSeq
|
||||
if (source === undefined) continue
|
||||
commandsBySource.set(source, commandsBySource.has(source) ? null : node)
|
||||
}
|
||||
const compactionsBySummary = new Map<number, CompactionSummaryNode | null>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue
|
||||
const summary = node.summaryEventSeq
|
||||
compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node)
|
||||
}
|
||||
const byCommandId = new Map<string, CompactionSummaryNode>()
|
||||
const byCompactionSeq = new Map<number, CommandNode>()
|
||||
for (const [source, command] of commandsBySource) {
|
||||
const compaction = compactionsBySummary.get(source)
|
||||
if (command === null || compaction === undefined || compaction === null) continue
|
||||
byCommandId.set(command.commandId, compaction)
|
||||
byCompactionSeq.set(compaction.seq, command)
|
||||
}
|
||||
return { byCommandId, byCompactionSeq }
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the node has model-visible text content worth IconActions chrome.
|
||||
@@ -115,9 +150,28 @@ export function assistantBranchSeqs(
|
||||
*/
|
||||
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
|
||||
const items: ChatFlowItem[] = []
|
||||
const pairs = commandCompactionPairs(nodes)
|
||||
let group: ToolResultNode[] | null = null
|
||||
for (const node of nodes) {
|
||||
if (rendersNothing(node)) continue
|
||||
if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) {
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'compaction') {
|
||||
group = null
|
||||
const command = pairs.byCompactionSeq.get(node.seq)
|
||||
if (command !== undefined) {
|
||||
items.push({
|
||||
kind: 'command-compaction',
|
||||
key: `c${command.commandId}`,
|
||||
command,
|
||||
compaction: node,
|
||||
})
|
||||
} else {
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'tool-result') {
|
||||
if (group === null) {
|
||||
group = [node]
|
||||
@@ -138,7 +192,13 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
|
||||
}
|
||||
} else {
|
||||
group = null
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
items.push({
|
||||
kind: 'node',
|
||||
key: node.kind === 'command' && node.name === 'compact'
|
||||
? `c${node.commandId}`
|
||||
: `n${node.seq}`,
|
||||
node,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
/** 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.
|
||||
* @param intervalFrames - frames to wait before applying the latest alignment.
|
||||
* @returns a stable function that schedules the latest update.
|
||||
*/
|
||||
export function useThrottledVisualUpdate(
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerBlock } from '../input/blocks.ts'
|
||||
@@ -32,13 +32,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
*/
|
||||
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
|
||||
/**
|
||||
* The chat view's per-tool row hole: keyed dispatch on the wire tool name
|
||||
* (the key space is runtime-open — SlotMap declares slots, never keys).
|
||||
* Declared by the chat view entry (declaring is claiming); the render
|
||||
* site dispatches via `entryKey: toolName` with GenericToolCard as the
|
||||
* `fallback` for unregistered tools.
|
||||
* One root Tool call at its ordered ChatFlow position. The chat view owns
|
||||
* placement; ui-tool owns root/subcall composition and keyed dispatch.
|
||||
* The filler preserves the call-anchor DOM contract documented by
|
||||
* {@link ToolTreeOwnerProps} for every root and child wrapper.
|
||||
*/
|
||||
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
|
||||
'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps }
|
||||
/**
|
||||
* The chat view's per-command row hole: keyed dispatch on the command
|
||||
* name (`command/run.name`; a run-less cross-window node has none and
|
||||
@@ -56,6 +55,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* to return null; an all-declined chain renders nothing.
|
||||
*/
|
||||
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
|
||||
/** Selected Tool call output inside the details panel. */
|
||||
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
|
||||
/**
|
||||
* The composer takeover chain: entries are selector-routed replacements
|
||||
* of the default InputBar. Declared by this package's 'conversation'
|
||||
@@ -203,56 +204,57 @@ export interface TurnTailOwnerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner share of a per-view toolview slot: the call material the rendering
|
||||
* view supplies per row. Uniform across views — the trajectory/waterfall
|
||||
* toolview slots (same kind/scope/owner, names fixed by the slot-naming
|
||||
* discipline) land with their own row render sites; today only the chat slot
|
||||
* is declared (RendersCheck rejects a declaration nobody renders).
|
||||
* Owner currency of the chat view's whole-Tool rendering seat. The filler
|
||||
* wraps every rendered root and child with `data-chat-anchor-key="call:<id>"`
|
||||
* and `data-chat-call-id="<id>"`, plus `data-selected="true"` for the selected
|
||||
* call. ChatView consumes those anchors to restore prepend/paging position.
|
||||
*/
|
||||
export interface ToolRowOwnerProps {
|
||||
/** Tool call identity (details linkage; stable across running → settled). */
|
||||
export interface ToolTreeOwnerProps {
|
||||
/** Root Tool call identity, stable across running → settled. */
|
||||
callId: CallId
|
||||
/** Wire tool name (also the keyed dispatch key at the render site). */
|
||||
/** Root wire Tool name. */
|
||||
toolName: string
|
||||
/** Frozen call slice: the running call or the settled result node. */
|
||||
/** Frozen root call slice: running call or settled result node. */
|
||||
block: ToolCallBlock
|
||||
/** Selected call id; the Tool owner resolves whether it is root or child. */
|
||||
selectedCallId?: CallId | undefined
|
||||
/** Session workspace root; path summaries display relative to it. */
|
||||
cwd?: string | undefined
|
||||
/**
|
||||
* Open a tool-arg filesystem path with the host OS default application.
|
||||
* The chat view resolves relative paths against the session cwd.
|
||||
* The conversation owner resolves relative paths against the session cwd.
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
/**
|
||||
* Jump to this call's record in the trajectory view (the expanded row's
|
||||
* hover Inspect affordance). Undefined when no trajectory jump is wired.
|
||||
* Jump to any call in this tree in the trajectory view.
|
||||
*/
|
||||
inspect?: (() => void) | undefined
|
||||
inspectCall: (callId: CallId) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Full props of a registered tool-row component: the slot's runtime share
|
||||
* (owner payload + session standard kit + global seat). Registrants type
|
||||
* their component `FC<ToolRowProps & I>` with `I` inferred from their inject
|
||||
* factory. Declared against the chat slot; the three per-view toolview slots
|
||||
* share one declaration shape, so this alias serves them all.
|
||||
*/
|
||||
export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
|
||||
/** Owner currency of the details panel's Tool output renderer. */
|
||||
export interface DetailsToolOwnerProps {
|
||||
/** Frozen selected call slice. */
|
||||
block: ToolCallBlock
|
||||
/** Session workspace root for card cwd and relative-path display. */
|
||||
cwd?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner share of the per-command row slot: the frozen {@link CommandNode}
|
||||
* slice off the snapshot (cache-stable reference — memo premise). The node
|
||||
* carries the whole lifecycle (structured name/args, pairing id,
|
||||
* outcome-or-executing), so a
|
||||
* registrant needs no second data channel; domain state arrives through its
|
||||
* own projection cell.
|
||||
* carries the whole lifecycle (structured name/args, pairing id, and
|
||||
* outcome-or-executing). A successful domain command may also carry the
|
||||
* explicitly linked projection node needed to fold two log records into one
|
||||
* presentation row.
|
||||
*/
|
||||
export interface CommandRowOwnerProps {
|
||||
/** Folded command lifecycle node (run + optional done). */
|
||||
node: CommandNode
|
||||
/** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */
|
||||
compaction?: CompactionSummaryNode
|
||||
}
|
||||
|
||||
/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */
|
||||
/** Full props of a registered command-row component. */
|
||||
export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
|
||||
|
||||
/**
|
||||
@@ -551,9 +553,9 @@ export interface ChatViewInjected {
|
||||
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */
|
||||
/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */
|
||||
export type ChatViewSlotProps =
|
||||
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
|
||||
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
|
||||
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
@@ -565,8 +567,9 @@ export interface DetailsInjected {
|
||||
closeDetails: () => void
|
||||
}
|
||||
|
||||
/** Full details-slot component props: selection rides the shared store, call material useSession; copy the locale seat. */
|
||||
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected & PropsLocale<'conversation'>
|
||||
/** Full details-slot props: selection store, Tool output seat, injected close callback, and locale. */
|
||||
export type DetailsSlotProps = PropsRuntime<'details'> & PropsRenderSlots<'conversation.details.tool'>
|
||||
& PropsStore<ChatStore> & DetailsInjected & PropsLocale<'conversation'>
|
||||
|
||||
/** Owner share common to the hero / New-Session Workspace pickers. */
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
|
||||
@@ -10,15 +10,14 @@ export type { IConversation } from './service.ts'
|
||||
export type {
|
||||
CallId, ChatStoreState, SelectionTarget, ViewTab,
|
||||
} from './contract/views.ts'
|
||||
export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||
export type { ConversationKey } from './locales.ts'
|
||||
export type {
|
||||
ChatFileMentions,
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
|
||||
ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps,
|
||||
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, TurnTailOwnerProps,
|
||||
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
|
||||
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
|
||||
ToolTreeOwnerProps, TurnTailOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ export const zh = {
|
||||
'message.context.recall.truncated': '已截断',
|
||||
'message.steering': '插话',
|
||||
'message.compaction': '上下文已压缩',
|
||||
'message.compaction.running': '正在压缩…',
|
||||
'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)',
|
||||
'message.compaction.expand': '点击查看压缩摘要',
|
||||
'message.compaction.unavailable': '压缩摘要不可用',
|
||||
'message.unknownSurface': '未知 surface 事件:{type}',
|
||||
@@ -220,6 +222,8 @@ export const en = {
|
||||
'message.context.recall.truncated': 'truncated',
|
||||
'message.steering': 'Interjection',
|
||||
'message.compaction': 'Context compacted',
|
||||
'message.compaction.running': 'Compacting context…',
|
||||
'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)',
|
||||
'message.compaction.expand': 'View compaction summary',
|
||||
'message.compaction.unavailable': 'Compaction summary unavailable',
|
||||
'message.unknownSurface': 'Unknown surface event: {type}',
|
||||
|
||||
@@ -92,36 +92,3 @@
|
||||
.code[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Above the card, which is where the render-intent contract puts a terminal
|
||||
call's description; the panel has no summary row to carry it. */
|
||||
.terminalDescription {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* A card body (terminal, diff, or search) sits directly under its section
|
||||
label, so it drops the primitive's standalone vertical margin; the section
|
||||
owns the spacing. Card-neutral: no card-kind-specific value. */
|
||||
.cardBody {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The recovery footer for a capped search: the result text (its `Full … stored
|
||||
at …` locator) below the card in the muted tone, since the card holds only the
|
||||
retained rows. */
|
||||
.searchRecovery {
|
||||
margin: 6px 0 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The read and web cards sit directly under their section label, same as the
|
||||
terminal card: drop the primitive's standalone vertical margin. */
|
||||
.read,
|
||||
.web {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -7,16 +7,11 @@
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock, DiffBlock, ReadBlock, SearchBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Fragment } from 'react'
|
||||
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
@@ -45,19 +40,27 @@ function runningMaterial(call: RunningToolCall): CallMaterial {
|
||||
return { name: call.name, argsRaw: call.argsRaw, block: call }
|
||||
}
|
||||
|
||||
function findCall(block: ToolCallBlock, callId: string): ToolCallBlock | undefined {
|
||||
if (block.callId === callId) return block
|
||||
for (const child of block.subCalls) {
|
||||
const found = findCall(child, callId)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
||||
for (const node of s.nodes) {
|
||||
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
|
||||
if (node.kind !== 'tool-result') continue
|
||||
const found = findCall(node, callId)
|
||||
if (found !== undefined) {
|
||||
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
|
||||
}
|
||||
}
|
||||
const open = s.runningCalls.find(c => c.callId === callId)
|
||||
if (open !== undefined) return runningMaterial(open)
|
||||
// run_code sub-dispatches: the native call-block shapes, so a selected
|
||||
// sub-row resolves through the same material as a native call — the
|
||||
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
|
||||
for (const subs of s.codeDispatches.values()) {
|
||||
for (const sub of subs) {
|
||||
if (sub.callId !== callId) continue
|
||||
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
|
||||
for (const root of s.runningCalls) {
|
||||
const found = findCall(root, callId)
|
||||
if (found !== undefined) {
|
||||
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -72,7 +75,15 @@ function pretty(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails, t }: DetailsPanelProps) {
|
||||
/** Flatten a settled result for the no-ui-tool fallback. */
|
||||
function rawResultText(block: ToolCallBlock): string {
|
||||
if (!('kind' in block)) return ''
|
||||
const parts = block.content.map(item => item.type === 'text' ? item.text : JSON.stringify(item, null, 2))
|
||||
if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
// Session workspace root: an omitted or relative terminal cwd resolves
|
||||
// against it, which the pure presenter cannot see.
|
||||
@@ -118,7 +129,17 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
state (the terminal card's expand and copy), which React
|
||||
would otherwise carry into the next selection because the
|
||||
panel does not unmount between calls. */}
|
||||
<OutputBody key={callId} material={material} cwd={sessionCwd} t={t} />
|
||||
<Fragment key={callId}>
|
||||
{renderSlot('conversation.details.tool', { block: material.block, cwd: sessionCwd }, {
|
||||
fallback: 'kind' in material.block
|
||||
? (
|
||||
<pre className={css.code} data-error={material.block.isError || undefined}>
|
||||
{rawResultText(material.block)}
|
||||
</pre>
|
||||
)
|
||||
: <div className={css.empty}>{t('details.running')}</div>,
|
||||
})}
|
||||
</Fragment>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -126,83 +147,3 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. A read-card call
|
||||
* renders through the shared ReadBlock at that same full height, so the whole
|
||||
* returned window is line-numbered and highlighted. A diff-card call — a
|
||||
* write/edit's applied change — renders through the shared DiffBlock at the same
|
||||
* full height. A search-card call — a `grep`/`glob` result view — renders
|
||||
* through the shared SearchBlock at the same full height allowance, with a
|
||||
* capped search's recovery footer below it. A web-card call — a
|
||||
* `web_search`/`web_fetch` result — renders through WebBlock at its own full
|
||||
* source-list allowance. Every other call, and a running call with no card yet,
|
||||
* keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @param props.t - the panel's locale seat, passed down as a plain prop.
|
||||
* @returns the Output section's body element.
|
||||
*/
|
||||
function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string | undefined; t: DetailsPanelProps['t'] }) {
|
||||
const terminal = terminalCardModel(material.block, cwd)
|
||||
if (terminal !== null) {
|
||||
// The contract renders the presenter's description above the card, and the
|
||||
// panel has no summary row to carry it, so it is drawn here.
|
||||
return (
|
||||
<>
|
||||
{terminal.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminal.description}</div>
|
||||
)}
|
||||
<TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.cardBody} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
const read = readCardModel(material.block, cwd)
|
||||
// The panel takes the primitive's own default cap, not the row's tighter one:
|
||||
// it is the single-call reading surface, so the whole window is available.
|
||||
if (read !== null) return <ReadBlock {...read} className={css.read} />
|
||||
const diff = diffCardModel(material.block)
|
||||
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
|
||||
const search = searchCardModel(material.block)
|
||||
if (search !== null) {
|
||||
return (
|
||||
<>
|
||||
<SearchBlock {...search.card} className={css.cardBody} />
|
||||
{/* A capped search's recovery locator lives only in the result text;
|
||||
show it below the card so the dropped rows stay reachable. */}
|
||||
{search.recovery !== undefined && (
|
||||
<div className={css.searchRecovery}>{search.recovery}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
const web = webCardModel(material.block)
|
||||
// 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)
|
||||
return (
|
||||
<>
|
||||
<WebBlock {...web} className={css.web} />
|
||||
{body !== '' && <pre className={css.code}>{body}</pre>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
// A settled call always carries the result node the flattened form needs;
|
||||
// the running shape has no result to flatten.
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>{t('details.running')}</div>
|
||||
const result = material.block
|
||||
return (
|
||||
<pre className={css.code} data-error={result.isError || undefined}>
|
||||
{resultText(result)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const inject = ['invariants']
|
||||
/**
|
||||
* No runtime invariant: the conversation service emits no cordis events, and
|
||||
* both rings this package owns (the 'conversation.view' tab ring and the
|
||||
* 'conversation.chat.toolview' row hole) ride the slot system, whose ledger
|
||||
* 'conversation.chat.tool' whole-call seat) ride the slot system, whose ledger
|
||||
* invariants live with the runtime slots package.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
@@ -1,35 +1,14 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Assembly-level acceptance on SlotTestRuntime (real apply, real slot
|
||||
* machinery, real renderer; data fed as fixtures) for surfaces that were
|
||||
* previously pinned only by the assembled-app jsdom snapshots
|
||||
* (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts):
|
||||
*
|
||||
* - the todo_write turn reaches BOTH surfaces through the product
|
||||
* registrations (keyed toolview row in the flow, plan strip in the input
|
||||
* dock via the 'todos' projection) and the strip follows projection
|
||||
* retirement;
|
||||
* - the bash keyed row carries its resident terminal card, and the fallback
|
||||
* row reaches the same card through its expand control;
|
||||
* - the resident composer textarea survives the blank→active conversion as
|
||||
* the SAME DOM node (focus/IME continuity rides React reconciliation:
|
||||
* component identity + tree position, which this assembled tree pins).
|
||||
*
|
||||
* Component-level behavior (collapse interaction, card model arms, summary
|
||||
* derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this
|
||||
* suite only proves the assembled wiring.
|
||||
*/
|
||||
/** Conversation assembly acceptance independent of Tool presentation. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
@@ -50,30 +29,6 @@ beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
|
||||
const TODOS: TodoItem[] = [
|
||||
{ content: '梳理需求', status: 'completed' },
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
|
||||
const todoResult = (seq: number): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
|
||||
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
|
||||
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
|
||||
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
|
||||
function AppRoot({ renderSlot }: AppRootProps) {
|
||||
return <>{renderSlot('conversation', {})}</>
|
||||
@@ -84,7 +39,6 @@ const LAYOUT_CHILDREN = {
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
} as const
|
||||
|
||||
/** Stateful occupant proving the root-scoped Hero workspace outlet is not rebuilt. */
|
||||
function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
||||
const [count, setCount] = useState(0)
|
||||
return (
|
||||
@@ -94,7 +48,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
||||
)
|
||||
}
|
||||
|
||||
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
||||
async function bench(opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
@@ -104,7 +58,7 @@ async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
||||
snapshot: {
|
||||
nodes,
|
||||
nodes: [],
|
||||
...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
|
||||
},
|
||||
session: {
|
||||
@@ -117,69 +71,6 @@ async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => {
|
||||
const runtime = await bench([todoResult(3)])
|
||||
// The dock strip reads the host-computed 'todos' projection.
|
||||
runtime.sessions.behavior(SID).projections.set('todos', TODOS)
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed toolview registration took the row (summary derived from args).
|
||||
const row = view.container.querySelector('[data-tool="todo_write"]')
|
||||
expect(row).not.toBeNull()
|
||||
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
|
||||
|
||||
// The plan strip sits in the input dock, fed by the projection
|
||||
// (default-collapsed: the header summary shows; rows appear on expand).
|
||||
const panel = view.container.querySelector('[data-testid="todo-panel"]')
|
||||
expect(panel).not.toBeNull()
|
||||
expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
|
||||
fireEvent.click(panel!.querySelector('button')!)
|
||||
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
|
||||
.toEqual(['completed', 'in_progress', 'pending'])
|
||||
|
||||
// Next turn retires the standing plan (host pushes null): the strip
|
||||
// clears while the historical row stays in the flow.
|
||||
await runtime.flush()
|
||||
runtime.sessions.behavior(SID).projections.set('todos', null)
|
||||
await waitFor(() => {
|
||||
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
|
||||
})
|
||||
expect(view.container.querySelector('[data-tool="todo_write"]')).not.toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal card assembly', () => {
|
||||
it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
|
||||
const runtime = await bench([
|
||||
bashResult(3, 'c-keyed'),
|
||||
// An unregistered tool with terminal views: GenericToolCard fallback.
|
||||
bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
|
||||
])
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed BashRow: collapsed by default, the whole summary row is the toggle.
|
||||
const keyedRow = view.container.querySelector('[data-sample="bash"]')
|
||||
const keyed = keyedRow?.parentElement
|
||||
expect(keyed?.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(keyedRow!)
|
||||
await waitFor(() => {
|
||||
expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
|
||||
})
|
||||
|
||||
// Fallback row: same unified expand interaction.
|
||||
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
|
||||
expect(fallback).not.toBeNull()
|
||||
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(fallback!.querySelector('[data-expandable]')!)
|
||||
await waitFor(() => {
|
||||
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
|
||||
})
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resident composer', () => {
|
||||
it('renders the locked view state while no session exists at all', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
@@ -190,8 +81,6 @@ describe('resident composer', () => {
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
const view = runtime.renderRoot()
|
||||
// No session entity: the inert twin renders (disabled textarea), and the
|
||||
// workspace picker chip is the only live control.
|
||||
const textarea = view.container.querySelector('textarea')
|
||||
expect(textarea).not.toBeNull()
|
||||
expect(textarea!.disabled).toBe(true)
|
||||
@@ -242,12 +131,8 @@ describe('resident composer', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
|
||||
const runtime = await bench([], { blank: true })
|
||||
// The hero renders the LIVE composer only when the blank session's
|
||||
// workspace resolves a chip title; an ownerless blank session shows the
|
||||
// disabled twin instead (deleted-workspace semantics).
|
||||
const runtime = await bench({ blank: true })
|
||||
await runtime.workspaces.update((draft) => {
|
||||
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
|
||||
})
|
||||
@@ -256,13 +141,11 @@ describe('resident composer', () => {
|
||||
expect(hero).not.toBeNull()
|
||||
expect(hero!.disabled).toBe(false)
|
||||
|
||||
// First acceptance: the session leaves blank and the composer docks.
|
||||
await runtime.sessions.updateSnapshot(SID, (draft) => {
|
||||
draft.blank = false
|
||||
draft.composerPhase = 'active'
|
||||
})
|
||||
const docked = view.container.querySelector('textarea')
|
||||
expect(docked).toBe(hero)
|
||||
expect(view.container.querySelector('textarea')).toBe(hero)
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -291,8 +174,6 @@ describe('prompt rejection through the assembled composer', () => {
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
|
||||
|
||||
// The rejection lands in snapshot.promptError (the Session's own path);
|
||||
// the fixture mirrors that hop — the assembled InputBar renders it.
|
||||
await runtime.sessions.updateSnapshot(SID, (draft) => {
|
||||
draft.promptError = {
|
||||
op: 'send',
|
||||
@@ -301,7 +182,6 @@ describe('prompt rejection through the assembled composer', () => {
|
||||
})
|
||||
const alert = await view.findByRole('alert')
|
||||
expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
|
||||
// Failure restore: the machine returned the draft to the same textarea.
|
||||
await waitFor(() => {
|
||||
expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this')
|
||||
})
|
||||
@@ -311,7 +191,7 @@ describe('prompt rejection through the assembled composer', () => {
|
||||
|
||||
describe('title projection across assembled surfaces', () => {
|
||||
it('one summary update re-labels the current-session crumb', async () => {
|
||||
const runtime = await bench([])
|
||||
const runtime = await bench()
|
||||
const view = runtime.renderRoot()
|
||||
const hierarchy = view.getByRole('navigation', { name: '会话层级' })
|
||||
expect(within(hierarchy).getByRole('button', { name: 'S' }).hasAttribute('disabled')).toBe(true)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply wiring: the conversation service provided, the chat view registered
|
||||
// as the first 'conversation.view' ring entry declaring the keyed toolview
|
||||
// hole, the slot registrations land against a root entry's children
|
||||
// declarations (the AppFrame role), the shared store handle rides all strict
|
||||
// session entries, and the bash sample + todo row mount through declaration
|
||||
// injection as keyed entries. Full-chain rendering belongs to the
|
||||
// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
|
||||
// stops at the assembly surface.
|
||||
// as the first 'conversation.view' ring entry declaring the whole-Tool seat,
|
||||
// the slot registrations land against a root entry's children declarations
|
||||
// (the AppFrame role), and the shared store handle rides all strict session
|
||||
// entries. Tool composition belongs to ui-tool and its machinery spec.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -56,7 +53,7 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
|
||||
it('registers the chat view as the first ring entry, declaring the whole-Tool seat', async () => {
|
||||
const b = await bench()
|
||||
const entries = b.slots.entries('conversation.view')
|
||||
expect(entries.map(e => e.options.id)).toEqual(['chat'])
|
||||
@@ -65,7 +62,7 @@ describe('apply wiring', () => {
|
||||
expect(entries[0]?.options.order).toBe(0)
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
// the ledger with the contract's kind/scope.
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
|
||||
expect(b.slots.spec('conversation.chat.tool')).toEqual({ kind: 'single', scope: 'session' })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -92,14 +89,13 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the tool rows as keyed entries through declaration injection', async () => {
|
||||
it('leaves per-Tool rows to the ui-tool plugin', async () => {
|
||||
const b = await bench()
|
||||
// The actual toolview declaration activates every registrant. The
|
||||
// file-mutation registrant claims both write and edit for the diff card; the
|
||||
// one search row registers under both grep and glob; the web rows register
|
||||
// one component under both web tool names.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'grep', 'glob', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
|
||||
expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0)
|
||||
// Stats stick with the composer (not inside ChatView).
|
||||
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
||||
await b.runtime.dispose()
|
||||
@@ -112,8 +108,8 @@ describe('apply wiring', () => {
|
||||
// The declared ring collapses with its declaring entry, and the chat
|
||||
// entry's keyed hole (with the sample's registration) collapses with it.
|
||||
expect(b.slots.entries('conversation.view')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
|
||||
expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.tool')).toBeUndefined()
|
||||
expect(b.slots.entries('details')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.general.item')).toHaveLength(0)
|
||||
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
|
||||
|
||||
@@ -691,11 +691,15 @@ describe('MessageItem arms', () => {
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'compaction', seq: 5, time: 1_000,
|
||||
summary: '## 摘要标题\n\n保留的事实。',
|
||||
summaryEventSeq: 4,
|
||||
shadowedItemCount: 16,
|
||||
shadowedTokenCount: 11_309,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const row = view.getByRole('button', { name: /上下文已压缩/ })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy()
|
||||
expect(view.queryByText(/保留的事实/)).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
@@ -705,7 +709,10 @@ describe('MessageItem arms', () => {
|
||||
})
|
||||
|
||||
it('a marker whose provenance fell outside the window is not expandable', () => {
|
||||
const view = render(<MessageItem t={t} node={{ kind: 'compaction', seq: 6, time: 1_000, summary: null }} />)
|
||||
const view = render(<MessageItem t={t} node={{
|
||||
kind: 'compaction', seq: 6, time: 1_000, summary: null,
|
||||
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
|
||||
}} />)
|
||||
const row = view.getByRole('button', { name: /上下文已压缩/ })
|
||||
expect(row).toHaveProperty('disabled', true)
|
||||
expect(row.getAttribute('aria-expanded')).toBeNull()
|
||||
@@ -864,6 +871,7 @@ describe('MessageItem arms', () => {
|
||||
view.rerender(<MessageItem t={t} node={node} retryActive />)
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('formatMessageClock', () => {
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
// @vitest-environment jsdom
|
||||
// StatsLine (composer.dock entry): totals derivation + the RFC
|
||||
// hard acceptance — zero renders during streaming. Bash sample row: ToolRow
|
||||
// chrome (Bash · description) without a row click target.
|
||||
// StatsLine (composer.dock entry): totals derivation + the RFC hard
|
||||
// acceptance — zero renders during streaming.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
|
||||
const t: StatsLineProps['t'] = makeTranslate(zh, commonZh)
|
||||
const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn)
|
||||
|
||||
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
|
||||
@@ -47,7 +42,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -91,7 +86,7 @@ describe('deriveStats', () => {
|
||||
it('ignores tool results with no call time', () => {
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
|
||||
isError: false, callView: null, resultView: null,
|
||||
isError: false, callView: null, resultView: null, subCalls: [],
|
||||
}
|
||||
const stats = deriveStats([tool, assistant(1, 1)])
|
||||
expect(stats.steps).toBe(1)
|
||||
@@ -109,7 +104,7 @@ describe('deriveStats', () => {
|
||||
}
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
|
||||
isError: false, callView: null, resultView: null,
|
||||
isError: false, callView: null, resultView: null, subCalls: [],
|
||||
}
|
||||
const stats = deriveStats([timed, untimed, tool])
|
||||
expect(stats.llmMs).toBe(2_500)
|
||||
@@ -301,43 +296,3 @@ describe('StatsLine', () => {
|
||||
expect(renders).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bash sample row', () => {
|
||||
const SID = 'root-1' as SessionId
|
||||
|
||||
const result = (callId: string): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId,
|
||||
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
|
||||
callTime: 2_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
function listStore() {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: {
|
||||
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const rowProps = (): BashRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
openFile: vi.fn(),
|
||||
sessionId: SID,
|
||||
useSessions: bindSnapshotSelector(listStore()),
|
||||
t,
|
||||
} as unknown as BashRowProps)
|
||||
|
||||
it('summarizes as Bash · description without a row click target', () => {
|
||||
const view = render(<BashRow {...rowProps()} />)
|
||||
const row = view.container.querySelector('[data-sample="bash"]')!
|
||||
expect(row.textContent).toContain('Bash')
|
||||
expect(row.textContent).toContain('Build')
|
||||
expect(row.getAttribute('data-clickable')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
// ChatView behavior: flow derivation, streaming isolation (Profiler counts),
|
||||
// toolview dispatch and selection handoff — driven through a scripted
|
||||
// ObservableSnapshot fake, no wire.
|
||||
// Tool seat ownership and selection handoff — driven through a scripted
|
||||
// ObservableSnapshot fake, no wire or Tool presentation plugin.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
|
||||
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
|
||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
|
||||
UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatViewSlotProps, SelectionTarget, ToolTreeOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
@@ -37,7 +37,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -88,10 +88,23 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
})
|
||||
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, subCalls: [],
|
||||
})
|
||||
const command = (over: Partial<CommandNode> = {}): CommandNode => ({
|
||||
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
|
||||
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
...over,
|
||||
})
|
||||
const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummaryNode => ({
|
||||
kind: 'compaction', seq: 8, time: 8_000,
|
||||
summary: '## 压缩摘要\n\n保留的事实。',
|
||||
summaryEventSeq: 7,
|
||||
shadowedItemCount: 16,
|
||||
shadowedTokenCount: 11_309,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Empty sessions-list hook for the global standard-kit seat. */
|
||||
@@ -124,12 +137,25 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const forkAt = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
|
||||
// every tool lands on GenericToolCard); keyed dispatch to registered rows
|
||||
// is the slot machinery's behavior, covered by its own specs.
|
||||
const chat = createChatStore().create()
|
||||
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const toolOwners: ToolTreeOwnerProps[] = []
|
||||
const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
|
||||
if (key !== 'conversation.chat.tool') return opts?.fallback ?? null
|
||||
const tool = owner as ToolTreeOwnerProps
|
||||
toolOwners.push(tool)
|
||||
// Tool providers own their subtree. The host double carries only the
|
||||
// semantic anchor required by ChatView's prepend-position contract.
|
||||
return (
|
||||
<div
|
||||
data-testid={`tool-seat-${tool.callId}`}
|
||||
data-chat-anchor-key={`call:${tool.callId}`}
|
||||
data-chat-call-id={tool.callId}
|
||||
>
|
||||
{tool.toolName || '(unnamed)'}:{tool.callId}
|
||||
</div>
|
||||
)
|
||||
}) as unknown as ChatViewSlotProps['renderSlot']
|
||||
const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain']
|
||||
// SessionProvider seat arrives with the session-scope child declaration;
|
||||
@@ -157,10 +183,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
// Absent-service default; mention tests override with a real resolver.
|
||||
fileMentions: () => undefined,
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: makeTranslate(zh, commonZh),
|
||||
t,
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
|
||||
return {
|
||||
set, ChatView, props, openDetails, openFile, loadOlder, inspectCall,
|
||||
chatScroll, forkAt, setSelection, toolOwners,
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulate reader input (any device): a delivered position that deviates
|
||||
@@ -214,6 +243,79 @@ describe('chat-flow derivation', () => {
|
||||
expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
|
||||
})
|
||||
|
||||
it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => {
|
||||
const running = command({
|
||||
seq: 1,
|
||||
commandId: 'cmd-compact' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: null,
|
||||
})
|
||||
expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact')
|
||||
|
||||
const settled = {
|
||||
...running,
|
||||
outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 },
|
||||
}
|
||||
const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 })
|
||||
const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint])
|
||||
expect(flowKeys(items)).toBe('n0|n2|ccmd-compact')
|
||||
expect(items.at(-1)).toEqual({
|
||||
kind: 'command-compaction',
|
||||
key: 'ccmd-compact',
|
||||
command: settled,
|
||||
compaction: checkpoint,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not split adjacent tool results around a folded /compact command', () => {
|
||||
const folded = command({
|
||||
seq: 2,
|
||||
commandId: 'cmd-compact' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 4 },
|
||||
})
|
||||
const items = deriveChatFlow([
|
||||
toolResult(1, 'a'),
|
||||
folded,
|
||||
toolResult(3, 'b'),
|
||||
compaction({ seq: 5, summaryEventSeq: 4 }),
|
||||
])
|
||||
expect(flowKeys(items)).toBe('g1|ccmd-compact')
|
||||
expect(
|
||||
items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId),
|
||||
).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => {
|
||||
const automatic = compaction({ seq: 2, summaryEventSeq: 1 })
|
||||
expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2')
|
||||
|
||||
const first = command({
|
||||
seq: 3,
|
||||
commandId: 'cmd-a' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 9 },
|
||||
})
|
||||
const second = command({
|
||||
seq: 4,
|
||||
commandId: 'cmd-b' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 9 },
|
||||
})
|
||||
const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 })
|
||||
expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10')
|
||||
|
||||
const sole = command({
|
||||
seq: 11,
|
||||
commandId: 'cmd-sole' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 12 },
|
||||
})
|
||||
const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 })
|
||||
const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 })
|
||||
expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14')
|
||||
})
|
||||
|
||||
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
|
||||
// A tool-call-only step message (and blank text/reasoning) renders nothing:
|
||||
// it must not split the run into two groups with an empty line between.
|
||||
@@ -329,14 +431,13 @@ describe('chat-flow derivation', () => {
|
||||
})
|
||||
|
||||
describe('ChatView', () => {
|
||||
it('a windowless tool result (call head truncated) renders with an empty tool name', () => {
|
||||
it('hands a windowless tool result to the Tool seat with an empty tool name', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [{ ...toolResult(3, 'w1'), call: null }],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// classifyTool('') → others; the summary slot falls back to the callId.
|
||||
expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
|
||||
expect(view.getByText('w1')).toBeTruthy()
|
||||
expect(view.getByTestId('tool-seat-w1')).toBeTruthy()
|
||||
expect(h.toolOwners[0]).toMatchObject({ callId: 'w1', toolName: '' })
|
||||
})
|
||||
|
||||
it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
|
||||
@@ -378,8 +479,8 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText('do the thing')).toBeTruthy()
|
||||
expect(view.getByText('running tools')).toBeTruthy()
|
||||
expect(view.getAllByText('Bash')).toHaveLength(2)
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
expect(view.getByTestId('tool-seat-a').textContent).toBe('bash:a')
|
||||
expect(view.getByTestId('tool-seat-b').textContent).toBe('bash:b')
|
||||
expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
|
||||
key: row.getAttribute('data-chat-flow-key'),
|
||||
kind: row.getAttribute('data-chat-flow-kind'),
|
||||
@@ -545,14 +646,12 @@ describe('ChatView', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
|
||||
it('hands the trajectory callback to the Tool seat', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [toolResult(3, 'a')],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
|
||||
fireEvent.click(view.getByText('Inspect'))
|
||||
expect(h.inspectCall).toHaveBeenCalledWith('a')
|
||||
render(<h.ChatView {...h.props} />)
|
||||
expect(h.toolOwners[0]?.inspectCall).toBe(h.inspectCall)
|
||||
})
|
||||
|
||||
it('shows assistant IconActions only on the last content message of each turn', () => {
|
||||
@@ -777,7 +876,7 @@ describe('ChatView', () => {
|
||||
// not re-render, so the row's renderSlot call count freezes during chunks.
|
||||
let rowRenders = 0
|
||||
h.props.renderSlot = ((key: string, _owner: object) => {
|
||||
if (key !== 'conversation.chat.toolview') return null
|
||||
if (key !== 'conversation.chat.tool') return null
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
})
|
||||
@@ -793,44 +892,19 @@ describe('ChatView', () => {
|
||||
expect(rowRenders).toBe(afterMount)
|
||||
})
|
||||
|
||||
it('tool row expands to the args body via the whole-row toggle', () => {
|
||||
it('updates the selected call id handed to the Tool seat', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking a bash summary does not open details; selection still marks data-selected', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('run a'))
|
||||
expect(h.openDetails).not.toHaveBeenCalled()
|
||||
expect(h.openFile).not.toHaveBeenCalled()
|
||||
expect(view.container.querySelector('[data-selected]')).toBeNull()
|
||||
render(<h.ChatView {...h.props} />)
|
||||
expect(h.toolOwners.at(-1)?.selectedCallId).toBeUndefined()
|
||||
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
|
||||
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
|
||||
expect(h.toolOwners.at(-1)?.selectedCallId).toBe('a')
|
||||
})
|
||||
|
||||
it('clicking a file-tool path summary opens the host file, not details', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'r1',
|
||||
call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' },
|
||||
callTime: 2_500, content: [], isError: false, callView: null, resultView: null,
|
||||
}],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('src/a.ts'))
|
||||
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
|
||||
expect(h.openDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('running calls render as a live tool group with the running state', () => {
|
||||
it('hands running calls to a live Tool group', () => {
|
||||
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(view.getByText('cmd-r1')).toBeTruthy()
|
||||
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
|
||||
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
|
||||
expect(view.getByRole('status').textContent).toBe('Deep diving...')
|
||||
})
|
||||
|
||||
@@ -858,19 +932,25 @@ describe('ChatView', () => {
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
})
|
||||
|
||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const calls: { key: string; entryKey?: string }[] = []
|
||||
h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
||||
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
|
||||
it('hands each ordered root call to the whole-Tool slot', () => {
|
||||
const block = toolResult(3, 'a')
|
||||
const h = makeHarness({ nodes: [block] })
|
||||
const calls: { key: string; owner: object; entryKey?: string }[] = []
|
||||
h.props.renderSlot = ((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
||||
calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
|
||||
return opts?.fallback ?? null
|
||||
})
|
||||
render(<h.ChatView {...h.props} />)
|
||||
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
|
||||
// name, and the fallback (GenericToolCard) renders on an empty ledger.
|
||||
// (Registered-row takeover and live unload are slot machinery behavior,
|
||||
// owned by the slot system's own specs.)
|
||||
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
key: 'conversation.chat.tool',
|
||||
owner: { callId: 'a', toolName: 'bash', selectedCallId: undefined },
|
||||
})
|
||||
const owner = calls[0]?.owner as ToolTreeOwnerProps
|
||||
expect(owner.block).toBe(block)
|
||||
expect(owner.openFile).toBe(h.openFile)
|
||||
expect(owner.inspectCall).toBe(h.inspectCall)
|
||||
expect(calls[0]?.entryKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
|
||||
@@ -1213,11 +1293,6 @@ describe('ChatView', () => {
|
||||
})
|
||||
|
||||
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
|
||||
const command = (over: Partial<CommandNode>): CommandNode => ({
|
||||
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
|
||||
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
...over,
|
||||
})
|
||||
// Settled success: the bare command name is the title, the outcome text
|
||||
// the summary — neither the dispatched `/` nor its arguments reach the row
|
||||
// (the settlement text already says what the command did).
|
||||
@@ -1235,6 +1310,7 @@ describe('ChatView', () => {
|
||||
const fv = render(<failed.ChatView {...failed.props} />)
|
||||
expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
expect(fv.getByText('命令失败')).toBeTruthy()
|
||||
expect(fv.getByText('失败')).toBeTruthy()
|
||||
|
||||
// Still executing: running state with the executing copy.
|
||||
const executing = makeHarness({
|
||||
@@ -1243,6 +1319,7 @@ describe('ChatView', () => {
|
||||
const xv = render(<executing.ChatView {...executing.props} />)
|
||||
expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(xv.getByText('执行中…')).toBeTruthy()
|
||||
expect(xv.getByText('运行中')).toBeTruthy()
|
||||
|
||||
// Cross-window soft-fall (run page truncated): generic title, outcome preserved.
|
||||
const orphan = makeHarness({
|
||||
@@ -1252,4 +1329,65 @@ describe('ChatView', () => {
|
||||
expect(ov.getByText('命令')).toBeTruthy()
|
||||
expect(ov.getByText('已完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders /compact as one stateful disclosure from running through completion', () => {
|
||||
const running = command({
|
||||
commandId: 'cmd-compact' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: null,
|
||||
})
|
||||
const h = makeHarness({ nodes: [running] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText('正在压缩…')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [{
|
||||
...running,
|
||||
outcome: {
|
||||
kind: 'success',
|
||||
text: 'Compacted 16 history items (~11309 tokens).',
|
||||
sourceEventSeq: 7,
|
||||
},
|
||||
}, compaction()],
|
||||
})
|
||||
})
|
||||
|
||||
expect(view.queryByText('正在压缩…')).toBeNull()
|
||||
expect(view.queryByText('上下文已压缩')).toBeNull()
|
||||
expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy()
|
||||
const row = view.getByRole('button', { name: /compact/ })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull()
|
||||
expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull()
|
||||
expect(view.queryByText('保留的事实。')).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull()
|
||||
expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps /compact no-history and error settlements on the generic command row', () => {
|
||||
const noHistory = makeHarness({
|
||||
nodes: [command({
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', text: 'No compactable history yet.' },
|
||||
})],
|
||||
})
|
||||
const noHistoryView = render(<noHistory.ChatView {...noHistory.props} />)
|
||||
expect(noHistoryView.getByText('No compactable history yet.')).toBeTruthy()
|
||||
expect(noHistoryView.queryByRole('button')).toBeNull()
|
||||
|
||||
const failed = makeHarness({
|
||||
nodes: [command({
|
||||
commandId: 'cmd-compact-failed' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'error', text: 'Compaction cancelled.' },
|
||||
})],
|
||||
})
|
||||
const failedView = render(<failed.ChatView {...failed.props} />)
|
||||
expect(failedView.getByText('Compaction cancelled.')).toBeTruthy()
|
||||
expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
||||
// bash sample state dots, the node-half empty apply, and AssistantMarkdown
|
||||
// reasoning/unknown block arms.
|
||||
// Branch tails the acceptance specs do not reach: the node-half empty apply
|
||||
// and AssistantMarkdown reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RunningToolCall, SessionId, SessionListState, 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'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -29,14 +20,6 @@ describe('tails', () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
||||
const view = render(
|
||||
<ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
|
||||
)
|
||||
expect(view.queryByTestId('icon')).toBeNull()
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
@@ -73,67 +56,4 @@ describe('tails', () => {
|
||||
expect(blank.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
|
||||
const settled: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
|
||||
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
|
||||
callTime: 1_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const props: GenericToolCardProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
|
||||
}
|
||||
const view = render(<GenericToolCard {...props} />)
|
||||
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
|
||||
expect(view.container.querySelector('[data-variant="others"] svg')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
t,
|
||||
} as unknown as BashRowProps)
|
||||
|
||||
const running: RunningToolCall = {
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
|
||||
turn: 1, step: 1, time: 1_000, callView: null,
|
||||
}
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
callTime: 500,
|
||||
content: [], isError: true, callView: null, resultView: null,
|
||||
}
|
||||
const stoppedResult: ToolResultNode = {
|
||||
...errorResult,
|
||||
error: { name: 'E', code: 'interrupted' },
|
||||
}
|
||||
|
||||
const runningView = render(<BashRow {...props(running)} />)
|
||||
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(runningView.getByText('Bash')).toBeTruthy()
|
||||
expect(runningView.getByText('List')).toBeTruthy()
|
||||
runningView.unmount()
|
||||
|
||||
const errorView = render(<BashRow {...props(errorResult)} />)
|
||||
expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
expect(errorView.getByText('失败')).toBeTruthy()
|
||||
errorView.unmount()
|
||||
|
||||
const stoppedView = render(<BashRow {...props(stoppedResult)} />)
|
||||
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
expect(stoppedView.getByText('已停止')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,8 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { DetailsSlotProps, DetailsToolOwnerProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
@@ -33,9 +34,20 @@ afterEach(() => {
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Minimal framework seat for direct DetailsPanel host tests. */
|
||||
const SessionProviderStub: SessionProviderComponent = ({ children }) => children(SID)
|
||||
|
||||
/** Observe the owner currency without importing the Tool details renderer. */
|
||||
function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] {
|
||||
return (_key, owner) => {
|
||||
owners?.push(owner as DetailsToolOwnerProps)
|
||||
return <div data-testid="tool-details-seat" />
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -95,6 +107,8 @@ describe('render branch tails', () => {
|
||||
})
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
SessionProvider={SessionProviderStub}
|
||||
renderSlot={renderToolDetailsProbe()}
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
@@ -112,26 +126,39 @@ describe('render branch tails', () => {
|
||||
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => {
|
||||
it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => {
|
||||
localStorage.clear()
|
||||
const snap = snapshotBase()
|
||||
const longText = 'x'.repeat(1_000)
|
||||
snap.codeDispatches = new Map([['p1', [{
|
||||
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
|
||||
callTime: 8_000,
|
||||
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
|
||||
}]]])
|
||||
snap.runningCalls = [{
|
||||
callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
|
||||
time: 7_000, callView: null, subCalls: [{
|
||||
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
|
||||
callTime: 8_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
subCalls: [{
|
||||
kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
|
||||
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
|
||||
callTime: 8_500,
|
||||
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
|
||||
subCalls: [],
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const owners: DetailsToolOwnerProps[] = []
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
SessionProvider={SessionProviderStub}
|
||||
renderSlot={renderToolDetailsProbe(owners)}
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
@@ -145,10 +172,15 @@ describe('render branch tails', () => {
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
// Sub-call material: the sub-tool name titles the panel, args pretty-print,
|
||||
// and the COMPLETE logged output renders (no truncation anywhere).
|
||||
// Conversation resolves the selected sub-call and hands its complete
|
||||
// frozen block to the Tool-owned details seat.
|
||||
expect(view.getByText('read')).toBeTruthy()
|
||||
expect(view.getByText(/notes\/demo\.txt/)).toBeTruthy()
|
||||
expect(view.getByText(longText)).toBeTruthy()
|
||||
expect(view.getByTestId('tool-details-seat')).toBeTruthy()
|
||||
expect(owners).toHaveLength(1)
|
||||
expect(owners[0]?.block).toMatchObject({
|
||||
callId: 'p1:code:1:code:1',
|
||||
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
|
||||
content: [{ type: 'text', text: longText }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
|
||||
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
117
packages/client/ui-conversation/tests/reasoning-row.spec.tsx
Normal file
117
packages/client/ui-conversation/tests/reasoning-row.spec.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.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()
|
||||
})
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
describe('ReasoningRow', () => {
|
||||
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
|
||||
streaming
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('运行中')).toBeTruthy()
|
||||
const summary = view.getByText('Newest reasoning tokens')
|
||||
Object.defineProperties(summary, {
|
||||
scrollWidth: { configurable: true, value: 300 },
|
||||
clientWidth: { configurable: true, value: 100 },
|
||||
})
|
||||
|
||||
view.rerender(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
|
||||
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')
|
||||
|
||||
view.rerender(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
flushAnimationFrames(3)
|
||||
expect(view.getByText('Inspect the session')).toBeTruthy()
|
||||
expect(view.queryByText('运行中')).toBeNull()
|
||||
expect(summary.scrollLeft).toBe(0)
|
||||
expect(summary.hasAttribute('data-follow-end')).toBe(false)
|
||||
})
|
||||
|
||||
it('expands from either Think or the reasoning summary', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
const row = view.getByRole('button')
|
||||
|
||||
fireEvent.click(view.getByText('Inspect the session'))
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText(/Check persistence/)).toBeTruthy()
|
||||
|
||||
fireEvent.click(view.getByText('Think'))
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByText('Think'))
|
||||
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
|
||||
expect(view.queryByText('IN')).toBeNull()
|
||||
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
|
||||
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -70,7 +70,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status rows
|
||||
* including several `in_progress` at once, collapse), its TodoDock adapter
|
||||
* (selects the plan off the session snapshot and follows changes), the row's
|
||||
* plan summary (counts plus the two halves of the active summary — the named
|
||||
* task and the `+N` count that parallel work adds, kept apart so the row never
|
||||
* ellipsizes the count away), and the todo_write toolview row (progress summary
|
||||
* from args, generic fallback on malformed JSON, shared ToolRow state dots and
|
||||
* leading expansion).
|
||||
* including several `in_progress` at once, collapse), and its TodoDock
|
||||
* adapter (selects the plan off the session snapshot and follows changes).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TodoItem } 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'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { planSummary } from '../src/client/toolviews/plan-summary.ts'
|
||||
import { NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
type TodoRowProps = Parameters<typeof TodoRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: TodoDockProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
@@ -45,40 +35,6 @@ const PARALLEL: TodoItem[] = [
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
describe('planSummary', () => {
|
||||
it('counts done/total and names the single active item with no extra count', () => {
|
||||
expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('reports the extra active count separately when several items are in progress', () => {
|
||||
// Parallel work marks several: naming one and hiding the rest would lose
|
||||
// them, and the count stays unjoined so the row cannot ellipsize it.
|
||||
expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
|
||||
})
|
||||
|
||||
it('has no hint when nothing is in progress', () => {
|
||||
expect(planSummary([{ content: '都完了', status: 'completed' }]))
|
||||
.toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('has no hint when the first active item carries no usable content (model JSON)', () => {
|
||||
// Unvalidated args: a missing, mistyped, empty, or whitespace-only content
|
||||
// yields no hint — and no orphan count, even with a second active item to
|
||||
// count. Whitespace-only is the tool's own rejection rule (trimmed
|
||||
// non-empty), and a rejected call keeps its args verbatim.
|
||||
expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
|
||||
.toMatchObject({ activeContent: null, activeExtra: 0 })
|
||||
expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
|
||||
expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
|
||||
expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
|
||||
.toMatchObject({ activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('is empty-safe', () => {
|
||||
expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TodoPanel', () => {
|
||||
it('renders nothing while the list is empty', () => {
|
||||
const { container } = render(<TodoPanel todos={[]} t={t} />)
|
||||
@@ -178,110 +134,3 @@ describe('TodoDock', () => {
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
|
||||
call: { name: 'todo_write', argsRaw },
|
||||
content: [], isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
function rowProps(block: unknown): TodoRowProps {
|
||||
return {
|
||||
callId: 'c1', toolName: 'todo_write', block,
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
t,
|
||||
} as unknown as TodoRowProps
|
||||
}
|
||||
|
||||
describe('TodoRow', () => {
|
||||
const ARGS = JSON.stringify({ todos: LIST })
|
||||
|
||||
it('summarizes counts and the active item from the call args', () => {
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
|
||||
expect(screen.getByText('更新任务清单')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports the extra active count outside the ellipsized summary text', () => {
|
||||
const { container } = render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
|
||||
const text = screen.getByText('1/5 已完成 · 写组件')
|
||||
const extra = screen.getByText('+2')
|
||||
// Separate spans: .summary truncates, the count must not travel inside it.
|
||||
expect(text.contains(extra)).toBe(false)
|
||||
expect(container.textContent).toContain('1/5 已完成 · 写组件+2')
|
||||
})
|
||||
|
||||
it('omits the active clause when no item is in progress and reads running-call args', () => {
|
||||
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
|
||||
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
expect(screen.getByText('1/1 已完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the counts when an active item has unusable content, instead of the generic summary', () => {
|
||||
// planSummary yields activeContent null here, but the counts are known good,
|
||||
// so the row drops only the active clause — `?? model.summary` never runs.
|
||||
const args = JSON.stringify({ todos: [{ content: 'done', status: 'completed' }, { content: 42, status: 'in_progress' }] })
|
||||
const { container } = render(<TodoRow {...rowProps(resultNode(args))} />)
|
||||
expect(screen.getByText('1/2 已完成')).toBeTruthy()
|
||||
expect(container.textContent).not.toContain('+')
|
||||
})
|
||||
|
||||
it('keeps the non-ok execution states visible through the shared row states', () => {
|
||||
// A running call (no result yet) carries the running state (row sweep).
|
||||
const args = JSON.stringify({ todos: LIST })
|
||||
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
|
||||
running.unmount()
|
||||
// A cancelled call wrote no todo/write: the row must not read as a completed update.
|
||||
const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
|
||||
expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the generic summary on malformed args and marks the error state', () => {
|
||||
const view = render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
// Generic others summary: "<tool> · <raw>".
|
||||
expect(screen.getByText('todo_write · not json')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back when parsed args carry no todos array', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
|
||||
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('leading toggle expands the raw args body', () => {
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
// The expanded body is the pretty-printed args, not the tool output.
|
||||
expect(screen.getByText(/搭骨架/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'null root', argsRaw: 'null' },
|
||||
{ label: 'non-object root', argsRaw: '42' },
|
||||
{ label: 'null items', argsRaw: '{"todos":[null]}' },
|
||||
])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => {
|
||||
render(<TodoRow {...rowProps(resultNode(argsRaw))} />)
|
||||
// No throw, and the generic others summary carries the raw args verbatim.
|
||||
expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('window-truncated result (call head lost) falls back to the callId summary', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('', { call: null }))} />)
|
||||
expect(screen.getByText('todo_write · c1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('todoToolview injects the toolview declaration directly', () => {
|
||||
expect(todoToolview.name).toBe('todo-toolview')
|
||||
expect(todoToolview.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
todoToolview.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// View-ring + toolview-hole type-chain samples, slot form: both are declared
|
||||
// slots, so the register→inject→render chain and its compile-time locks are
|
||||
// the slot system's (ui-slots/tests/type-chain.spec.tsx owns the generic
|
||||
// duals). This spec pins the package-specific surface: the SlotMap rows
|
||||
// (kind/scope/owner), list- and keyed-kind registration shapes, the ChatView
|
||||
// and tool-row composed-props contracts, and the runtime dual — a real
|
||||
// SlotsService ledger driving registration/order/disposal the way
|
||||
// ConversationRoot's tab projection consumes it.
|
||||
// View-ring type-chain samples. This spec pins the conversation-owned SlotMap
|
||||
// row, list-kind registration shape, composed view props, and the runtime
|
||||
// ledger projection consumed by ConversationRoot.
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatViewSlotProps, ConvViewProps, ToolRowProps } from '../src/client/contract/slots.ts'
|
||||
import type { ChatViewSlotProps, ConvViewProps } from '../src/client/contract/slots.ts'
|
||||
|
||||
describe('view-ring type negatives (compile-time; body never runs)', () => {
|
||||
it('holds the negative samples as expect-error sites', () => {
|
||||
@@ -54,30 +49,6 @@ describe('view-ring type negatives (compile-time; body never runs)', () => {
|
||||
return null
|
||||
}
|
||||
void chatProps
|
||||
// 7. Keyed hole registration requires the key shape field.
|
||||
// @ts-expect-error missing `key` on a keyed-slot registration
|
||||
slots.register({ name: 'conversation.chat.toolview' }, (_p: ToolRowProps) => null)
|
||||
// 8. A list-kind shape field is rejected on the keyed hole.
|
||||
slots.register(
|
||||
// @ts-expect-error `id`/`order` belong to list slots, not the keyed hole
|
||||
{ name: 'conversation.chat.toolview', key: 'k', order: 1 },
|
||||
(_p: ToolRowProps) => null)
|
||||
// 9. Tool-row components stay within their composed contract: the
|
||||
// owner share + standard kit supply no chat-view members.
|
||||
const overreaching = (props: ToolRowProps): ReactNode => {
|
||||
// @ts-expect-error loadOlder lives on ChatViewSlotProps, not the row contract
|
||||
void props.loadOlder
|
||||
return null
|
||||
}
|
||||
void overreaching
|
||||
// 10. Owner-share drift is red at the row component seam: block is the
|
||||
// call union, not arbitrary payload.
|
||||
const drifted = (props: ToolRowProps): ReactNode => {
|
||||
// @ts-expect-error the block union has no `argsParsed` member
|
||||
void props.block.argsParsed
|
||||
return null
|
||||
}
|
||||
void drifted
|
||||
return null as ReactNode
|
||||
}
|
||||
expect(negatives).toBeTypeOf('function')
|
||||
|
||||
@@ -38,7 +38,7 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
})
|
||||
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'write'),
|
||||
@@ -74,6 +74,7 @@ describe('producedForClosing derivation', () => {
|
||||
expect(producedForClosing(nodes, 999)).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
it('counts a generic edit and never spills across the turn boundary', () => {
|
||||
const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'str_replace_editor'),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes"
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
|
||||
@@ -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: e6ca935426903ec0cc99f3fcacc1f937aa60b4ba
|
||||
README.zh.md: 4c23e7bd78a8ddde3e3cfe354203e81d3bb5b5e1
|
||||
README.md: 098a202a4ac9ee263ce1beaaee7a8624ebf26b80
|
||||
README.zh.md: 2b33af3316dede35cb5a226e41365f692a8b25d3
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user