From e23c201da87955e8c50f75c3ee4504fd6866d369 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 22 Jul 2026 13:21:18 +0800 Subject: [PATCH 01/32] perf(ci): parallelize packed-companion probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-built-package-invariants ran 100+ npm-pack + plain-Node probes serially, dominating the CI artifacts lane (~4min of a ~5min job; ~7.5min on Windows). Each probe stages its packed view inside its own package and spawns its own processes, so the probes are independent — run them through the same bounded worker pool shape as publint-all, capped by DSH_BUILT_INVARIANTS_CONCURRENCY (default availableParallelism), failures kept in manifest order. Measured on the gate alone: 2m07s serial -> 17s at concurrency 8. CI lanes pin the cap to 8, matching DSH_PUBLINT_CONCURRENCY. --- .../2026-07-06-parallel-pre-push-gates.md | 2 + .github/workflows/ci.yml | 12 +++ scripts/verify-built-package-invariants.mjs | 93 ++++++++++++++----- 3 files changed, 85 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 710c37cb3a..e649c8c2c9 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -20,6 +20,8 @@ The build gate makes the hook self-contained from a clean worktree. `publint`, ` [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. +[scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) has the same per-package independence — each probe stages its packed view inside its own package and spawns its own `npm pack` and Node processes — so it uses the same bounded-pool shape with `DSH_BUILT_INVARIANTS_CONCURRENCY` as its cap. Serially it dominated the CI artifacts lane (about 4 minutes for 100+ packages, over half the lane's wall clock); the pool collapses that to the slowest probe batch, and failures keep manifest order. + The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain the scheduler mirrors, while `doc-sync` has since moved its member list into the scheduler itself ([doc-sync through the gate scheduler](2026-07-21-doc-sync-through-gate-scheduler.md)). ## Alternatives considered diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de9a03288b..3aabcb60cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} + DSH_BUILT_INVARIANTS_CONCURRENCY: ${{ matrix.built_invariants_concurrency }} DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: @@ -32,30 +33,35 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' publint_concurrency: '8' + built_invariants_concurrency: '8' coverage_max_workers: '' eslint_cache: '' - lane: lint command: pnpm run check:ci:lint gate_concurrency: '1' publint_concurrency: '8' + built_invariants_concurrency: '8' coverage_max_workers: '' eslint_cache: '1' - lane: coverage command: pnpm run check:ci:coverage gate_concurrency: '1' publint_concurrency: '8' + built_invariants_concurrency: '8' coverage_max_workers: '4' eslint_cache: '' - lane: snapshot command: pnpm run check:ci:snapshot gate_concurrency: '1' publint_concurrency: '8' + built_invariants_concurrency: '8' coverage_max_workers: '' eslint_cache: '' - lane: artifacts command: pnpm run check:ci:artifacts gate_concurrency: '3' publint_concurrency: '8' + built_invariants_concurrency: '8' coverage_max_workers: '' eslint_cache: '' steps: @@ -192,6 +198,7 @@ jobs: env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} + DSH_BUILT_INVARIANTS_CONCURRENCY: ${{ matrix.built_invariants_concurrency }} DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: @@ -202,30 +209,35 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' publint_concurrency: '8' + built_invariants_concurrency: '8' coverage_max_workers: '' eslint_cache: '' - lane: lint command: pnpm run check:ci:lint gate_concurrency: '1' publint_concurrency: '8' + built_invariants_concurrency: '8' coverage_max_workers: '' eslint_cache: '1' - lane: coverage command: pnpm run check:ci:coverage gate_concurrency: '1' publint_concurrency: '8' + built_invariants_concurrency: '8' coverage_max_workers: '4' eslint_cache: '' - lane: snapshot command: pnpm run check:ci:snapshot gate_concurrency: '1' publint_concurrency: '8' + built_invariants_concurrency: '8' coverage_max_workers: '' eslint_cache: '' - lane: artifacts command: pnpm run check:ci:artifacts gate_concurrency: '3' publint_concurrency: '8' + built_invariants_concurrency: '8' coverage_max_workers: '' eslint_cache: '' steps: diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index 4b298946d1..7b9aa0c187 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -1,6 +1,6 @@ /** Verify every packed companion through its package self-reference under plain Node. */ -import { spawnSync } from 'node:child_process' +import { execFile } from 'node:child_process' import { copyFileSync, globSync, @@ -9,12 +9,16 @@ import { readFileSync, rmSync, } from 'node:fs' +import { availableParallelism } from 'node:os' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const CONCURRENCY_ENV = 'DSH_BUILT_INVARIANTS_CONCURRENCY' const root = resolve(import.meta.dirname, '..') const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href -const failures = [] const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort() const packArgs = ['pack', '--dry-run', '--json', '--ignore-scripts'] // Windows cannot spawn npm's .cmd shim directly; setup-node installs this JS @@ -23,24 +27,54 @@ const npmInvocation = process.platform === 'win32' ? [process.execPath, [resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), ...packArgs]] : ['npm', packArgs] -for (const manifestPath of manifests) { +function probeConcurrency(total) { + if (total === 0) return 0 + + const raw = process.env[CONCURRENCY_ENV] + if (raw !== undefined && raw !== '') { + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`verify-built-package-invariants: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) + } + return Math.min(total, parsed) + } + + return Math.min(total, availableParallelism()) +} + +async function runCommand(command, args, cwd) { + try { + const { stdout, stderr } = await execFileAsync(command, args, { + cwd, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }) + return { status: 0, stdout, stderr, message: undefined } + } catch (error) { + const failed = /** @type {{ code?: number; stdout?: unknown; stderr?: unknown; message?: string }} */ (error) + return { + status: typeof failed.code === 'number' ? failed.code : 1, + stdout: typeof failed.stdout === 'string' ? failed.stdout : '', + stderr: typeof failed.stderr === 'string' ? failed.stderr : '', + message: failed.message ?? 'command failed', + } + } +} + +/** Probe one manifest's packed companion; resolves to a failure string or undefined. */ +async function verifyManifest(manifestPath) { const packageDir = dirname(resolve(root, manifestPath)) const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) const packageName = manifest.name if (typeof packageName !== 'string' || packageName.length === 0) { - failures.push(`${manifestPath}: missing package name`) - continue + return `${manifestPath}: missing package name` } - const pack = spawnSync(npmInvocation[0], npmInvocation[1], { - cwd: packageDir, - encoding: 'utf8', - }) + const pack = await runCommand(npmInvocation[0], npmInvocation[1], packageDir) if (pack.status !== 0) { - const detail = pack.error?.message - ?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`) - failures.push(`${packageName}: ${detail}`) - continue + const detail = pack.stderr.trim() || pack.stdout.trim() || pack.message + || `npm pack exited ${pack.status}` + return `${packageName}: ${detail}` } let files @@ -49,8 +83,7 @@ for (const manifestPath of manifests) { files = result[0]?.files if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory') } catch (error) { - failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`) - continue + return `${packageName}: cannot parse npm pack inventory: ${String(error)}` } // Keep the packed view below its owning package so Node reaches the real @@ -79,20 +112,36 @@ for (const manifestPath of manifests) { } if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing'); ` - const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], { - cwd: stagedPackageDir, - encoding: 'utf8', - }) + const result = await runCommand(process.execPath, ['--input-type=module', '--eval', probe], stagedPackageDir) if (result.status !== 0) { - const detail = result.error?.message - ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) - failures.push(`${packageName}: ${detail}`) + const detail = result.stderr.trim() || result.stdout.trim() || result.message + || `node exited ${result.status}` + return `${packageName}: ${detail}` } + return undefined } finally { rmSync(stagedPackageDir, { recursive: true, force: true }) } } +/** Run every manifest probe through a bounded worker pool, keeping failures in manifest order. */ +async function runAll(paths, concurrency) { + let next = 0 + const results = new Array(paths.length) + const workers = Array.from({ length: concurrency }, async () => { + for (;;) { + const index = next + next += 1 + if (index >= paths.length) return + results[index] = await verifyManifest(paths[index]) + } + }) + await Promise.all(workers) + return results.filter(failure => failure !== undefined) +} + +const failures = await runAll(manifests, probeConcurrency(manifests.length)) + if (failures.length > 0) { console.error('verify-built-package-invariants: packed companion failures:') for (const failure of failures) console.error(` ${failure}`) From 0580bc9068b042e6d1557dea298005139a572cae Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 22 Jul 2026 15:30:53 +0800 Subject: [PATCH 02/32] fix(ci): reject partially parsed concurrency limits Number.parseInt accepts a numeric prefix, so values like 1.5 or 8junk silently ran an unintended worker count. Require the full string to round-trip (same pattern as run-gates' positiveIntArg). --- scripts/verify-built-package-invariants.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index 7b9aa0c187..7412404a97 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -33,7 +33,7 @@ function probeConcurrency(total) { const raw = process.env[CONCURRENCY_ENV] if (raw !== undefined && raw !== '') { const parsed = Number.parseInt(raw, 10) - if (!Number.isSafeInteger(parsed) || parsed < 1) { + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { throw new Error(`verify-built-package-invariants: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) } return Math.min(total, parsed) From 55af920defaa3b5f845a91dab8c2b3d464e4801d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 17:36:57 +0800 Subject: [PATCH 03/32] fix(vendor/include): keep config reloads resilient --- ...-20-config-hot-reload-resilience.i18n.yaml | 6 + ...2026-07-20-config-hot-reload-resilience.md | 38 ++++++ ...6-07-20-config-hot-reload-resilience.zh.md | 38 ++++++ ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 2 + .../2026-07-20-dsh-cli-personal-config.zh.md | 2 + .../2026-07-21-tui-reload-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-reload-command.md | 6 +- .../2026-07-21-tui-reload-command.zh.md | 6 +- .../ui/app-boot/tests/config-reload.spec.ts | 128 ++++++++++++++++++ vendor/README.md | 1 + vendor/include/src/index.ts | 63 +++++++-- 12 files changed, 274 insertions(+), 24 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md create mode 100644 packages/ui/app-boot/tests/config-reload.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml new file mode 100644 index 0000000000..b16ef70d7c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40 +2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md new file mode 100644 index 0000000000..1a8e29c603 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md @@ -0,0 +1,38 @@ +# Agent Note: A config hot-reload must not kill or degrade a live app + +Status: implemented + +English | [中文](2026-07-20-config-hot-reload-resilience.zh.md) + +## Problem + +The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones. + +## Decision + +Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers: + +- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down. +- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged". +- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay. + +Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`. + +## Alternatives considered + +**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include. + +**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place. + +**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file). + +## Consequences + +- A bad `cordis.yml` edit now logs `ignoring config reload at ` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred. +- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base. +- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync. +- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree). + +## Testing + +`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md new file mode 100644 index 0000000000..6c7a421bfa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 配置热重载不得杀死或降级正在运行的应用 + +Status: implemented + +[English](2026-07-20-config-hot-reload-resilience.md) | 中文 + +## Problem + +各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出,HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树(Code Mode、个人 overlay)做任何热重载,都会悄悄把打过补丁的配置项回退、并把插入的配置项移除。 + +## Decision + +加固 vendor 的 `@cordisjs/plugin-include`(在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方: + +- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出。 +- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。 +- `refresh()` 与 `internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。 + +启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`(或「config file not found」);存在但无效的文件现在会以真实的解析错误失败,而不是被误标为文件缺失、或被 `initial` 静默覆盖。 + +## Alternatives considered + +**在 HMR 监听回调里捕获,而不是在 `refresh()` 里。** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷。 + +**在 `installFailLoud` 里过滤配置文件相关的 rejection。** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃。 + +**用 PTY e2e 证明 TUI 能在错误编辑后存活。** 否决其作为主要门禁:PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件)。 + +## Consequences + +- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at `,agent 继续运行在上一份完好的配置树上;下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓。 +- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置。 +- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用。 +- 已知缺口,不在本次范围内:HMR 监听器只处理 chokidar 的 `change` 事件,因此通过重命名替换文件的编辑方式(BSD `sed -i`、`git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI(未修改的代码树上即已如此)。 + +## Testing + +`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection,并保留之前的配置项配置;随后的合法编辑正常生效;overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index a8c9c28d58..7addc991d2 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -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 -2026-07-20-dsh-cli-personal-config.md: e349374a6bc7fc0137bf14836469aef8bae8d49d -2026-07-20-dsh-cli-personal-config.zh.md: 88210dc386a245002de927950dab2852e40218ea +2026-07-20-dsh-cli-personal-config.md: 514bb5b12a3e04c7deaad1e8616472eed1c920e1 +2026-07-20-dsh-cli-personal-config.zh.md: 16fada82c59c8a356e6df112234e6b7565aae1bf diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index e349374a6b..514bb5b12a 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -22,6 +22,8 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes. +Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied. + ## Alternatives considered **A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 88210dc386..16fada82c5 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -22,6 +22,8 @@ Status: implemented PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 +与热重载的交互:include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效。 + ## Alternatives considered **独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字。** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web`、`-p`)的 `dsh` CLI,并且默认位空缺。两个互相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml index 321131ac96..f3fba8a006 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml @@ -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 -2026-07-21-tui-reload-command.md: de9a5502214a610d88024730b1c0c1044a396c92 -2026-07-21-tui-reload-command.zh.md: 25d1d448459221698ca63377f8f18d05a0fa3d21 +2026-07-21-tui-reload-command.md: e5600f0ab5cd82dc556df76006fcf532d8c7d302 +2026-07-21-tui-reload-command.zh.md: 3798b0518df1c379cca808bd4af38490016567cb diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md index de9a550221..e5600f0ab5 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md @@ -10,7 +10,7 @@ HMR's file watcher only reacts to in-place `change` events under its configured ## Decision -`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`). +`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`); invalid files warn and keep the running tree (the hot-reload-resilience contract); include `patches` — including the dsh CLI's personal overlay — re-apply on every re-read. The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not `inject`): tests and embedders run the TUI without a Loader, where `/reload` degrades to a warning notice instead of failing the mount. Module-source hot reload stays watcher-owned; `/reload` refreshes configs only. @@ -28,8 +28,8 @@ The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not - The command reports tree count and completion as transcript notices; per-file failures surface only in loader logs, which the TUI does not display — acceptable for a dev-only surface, noted in the completion message. - A re-entrancy guard serializes reloads: `/reload` while one is in flight is refused with a warning, keeping the loader's unmutexed tree-update pass single-writer; the guard releases on completion or failure. - `/reload` runs only while the agent is idle: a reload can dispose and re-mount entries, which under an active turn could tear tools or the adapter out from under in-flight calls. The check is advisory (a send can race in after it) but removes the common footgun. -- If any `refresh()` rejects, the command reports the failure instead of leaving an unhandled rejection. +- If `refresh()`'s never-reject contract ever changes, the command reports the failure instead of leaving an unhandled rejection. ## Testing -`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: a probe edit reloads successfully. +`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: probe edit → reload applies; invalid edit → reload keeps the running tree. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md index 25d1d44845..3798b0518d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md @@ -10,7 +10,7 @@ HMR 的文件监听器只对其配置根目录(示例中即配置叶子所在 ## Decision -`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较)。 +`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较);无效文件记录警告并保留运行中的树(热重载韧性契约);include 的 `patches`——包括 dsh CLI 的个人 overlay——在每次重读时重新应用。 TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`,而非 `inject`):测试和嵌入方在没有 Loader 的情况下运行 TUI,此时 `/reload` 退化为一条警告通知而不是挂载失败。模块源码热重载仍由监听器负责;`/reload` 只刷新配置。 @@ -28,8 +28,8 @@ TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`, - 命令以 transcript 通知报告树数量与完成;单文件失败只出现在 loader 日志里,TUI 不显示——对仅供开发的表面可以接受,完成消息中已注明。 - 重入保护串行化重载:前一次进行中时 `/reload` 会被拒绝并提示警告,使 loader 无互斥的树更新过程保持单写者;保护在完成或失败时释放。 - `/reload` 只在 agent 空闲时运行:重载可能卸载并重新挂载配置项,在活跃轮次下这会把工具或适配器从进行中的调用脚下抽掉。检查是建议性的(检查后仍可能有 send 竞争进来),但消除了常见的坑。 -- 任一 `refresh()` 若 reject,命令会报告失败而不是留下未处理的 rejection。 +- 若 `refresh()` 的永不 reject 契约将来改变,命令会报告失败而不是留下未处理的 rejection。 ## Testing -`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑后 reload 成功生效。 +`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑 → reload 生效;无效编辑 → reload 保留运行中的树。 diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts new file mode 100644 index 0000000000..05ebe80b58 --- /dev/null +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -0,0 +1,128 @@ +/** + * Config hot-reload resilience of the booted include tree. `dsh-app-boot` + * installs a fail-loud unhandled-rejection handler, so a `refresh()` that + * rethrows a config-file parse error would kill a live app on one bad + * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event + * callback nobody else catches). These tests pin the vendored + * `@cordisjs/plugin-include` contract that boot relies on: an invalid file + * keeps the last good tree, and a valid re-read re-applies overlay patches + * exactly like the initial load. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import type { Include } from '@cordisjs/plugin-include' +import { boot } from '../src/index.ts' + +const NAME = 'dsh-test-bin' + +const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n' + +interface TreeFixture { + ctx: Context + dir: string + include: Include +} + +async function bootTree(configBody: string): Promise { + const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) + writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) + writeFileSync(join(dir, 'cordis.yml'), configBody) + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined) + if (entry?.subtree === undefined) throw new Error('booted tree has no include entry') + return { ctx, dir, include: entry.subtree as Include } +} + +function entryConfig(ctx: Context, id: string): unknown { + return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config +} + +describe('include refresh with an invalid file', () => { + it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => { + const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n') + try { + expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) + + writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n') + await expect(include.refresh()).resolves.toBeUndefined() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) + + // An empty file parses to `undefined` without a YAML error; it must be + // treated exactly like a parse failure, not crash the entry walk. + writeFileSync(join(dir, 'cordis.yml'), '') + await expect(include.refresh()).resolves.toBeUndefined() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) + + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n') + await include.refresh() + await ctx.loader.await() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 }) + } finally { + await ctx.fiber.dispose() + } + }) +}) + +describe('include refresh with overlay patches', () => { + it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) + writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) + writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: base', + " name: 'cordis:include'", + ' config:', + ' path: ./base.yml', + ' patches:', + ' - id: noop', + ' name: ./noop.mjs', + ' config:', + ' value: patched', + ' - insert:', + ' - id: extra', + ' name: ./noop.mjs', + '', + ].join('\n')) + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + try { + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base') + if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry') + const include = entry.subtree as Include + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' }) + expect(entryConfig(ctx, 'extra')).toBeUndefined() + expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true) + + writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n') + await include.refresh() + await ctx.loader.await() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' }) + expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true) + + // Hot-update of the include entry's own config (the `internal/update` + // path): the new patches must apply now AND stick for later re-reads — + // the listener vetoes the fiber restart, so it must persist the new + // config itself or the next refresh() re-applies the old overlay. + await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } }) + await ctx.loader.await() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) + expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false) + + writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n') + await include.refresh() + await ctx.loader.await() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) + + // Removing every patch must revert to the file's own values: patching + // may not bake earlier patch results into the cached parse. + await entry.update({ config: { path: './base.yml', patches: [] } }) + await ctx.loader.await() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' }) + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/vendor/README.md b/vendor/README.md index ae43760ceb..1f4d61e6b1 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,6 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. +8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 2258d3af06..b1517d5458 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -77,7 +77,15 @@ export class Include extends EntryTree { ctx.on('internal/update', (config, _, next) => { if (config.path !== this.config.path) return next() - this.root.update(this.data!) + // Veto the fiber restart (children update in place), but persist the new + // config ourselves — `Fiber.update` only assigns `this.config` behind + // `next()`, and a stale `this.config.patches` would make the next + // `refresh()` re-apply the old overlay. + this.config = config + this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => { + this.ctx.logger.warn('config update at %C failed', this.filename) + this.ctx.logger.warn(error) + }) }) } @@ -93,22 +101,37 @@ export class Include extends EntryTree { private async read(forced = false) { const content = await readFile(this.filename, 'utf8') if (!forced && this.content === content) return false - this.content = content + let data: any if (this.type === 'application/yaml') { - this.data = yaml.load(this.content, { schema }) as any + data = yaml.load(content, { schema }) } else if (this.type === 'application/json') { - this.data = JSON.parse(this.content) as any + data = JSON.parse(content) } else { const module = await import(/* @vite-ignore */ this.filename) - this.data = module.default || module + data = module.default || module } + // An empty or truncated file (common mid-edit: editors and `sed -i` write + // through temp states) parses to `undefined`, not an error; reject every + // non-array shape here so callers see one "invalid file" signal. Content + // and data commit only on success, so an edit that is later reverted to + // the exact last good content correctly reads as "unchanged". + if (!Array.isArray(data)) { + throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`) + } + this.content = content + this.data = data await this.checkAccess() return true } - private applyPatches(data: EntryOptions[]): EntryOptions[] { - const { patches } = this.config - if (!patches?.length) return data + private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { + // Always detach from the cached parse: patching shared entry objects would + // bake earlier patch values into `this.data`, so repeated application + // (config hot-reloads) could never revert a removed or changed patch. The + // supported extensions guarantee JSON-safe plain data, so `structuredClone` + // cannot throw here. + if (!patches?.length) return [...data] + data = structuredClone(data) const entryMap = new Map() const buildMap = (entries: EntryOptions[]) => { @@ -174,7 +197,11 @@ export class Include extends EntryTree { async* [Service.init]() { try { await this.read() - } catch { + } catch (error) { + // Only a missing file falls back to `initial` (or the not-found error): + // an existing-but-invalid file must fail loud with its real parse error, + // never be mislabelled as absent or silently overwritten. + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error if (this.config.initial) { this.writeFile(this.config.initial as any) await this.read() @@ -184,18 +211,26 @@ export class Include extends EntryTree { } yield () => this.stop() - const data = this.applyPatches([...this.data!]) - await this.root.update(data) + await this.root.update(this.applyPatches(this.data!)) } stop() { this.root.stop() } - /** Re-read the file and refresh child entries when content changed. */ + /** + * Re-read the file and refresh child entries when content changed. An + * unreadable or unparsable file logs a warning and keeps the last good + * tree: a hot-reload of a live app must never take the process down. + */ async refresh() { - if (!await this.read()) return - this.root.update(this.data!) + try { + if (!await this.read()) return + await this.root.update(this.applyPatches(this.data!)) + } catch (error) { + this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename) + this.ctx.logger.warn(error) + } } private async _writeFile(config: EntryOptions[]) { From 93f1eaa9726d40be58c745f3d9aa887f0afd36b9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 22 Jul 2026 20:28:40 +0800 Subject: [PATCH 04/32] fix(gui): keep sidebar controls when collapsed --- ...2-collapsed-sidebar-control-rail.i18n.yaml | 6 ++ ...26-07-22-collapsed-sidebar-control-rail.md | 29 ++++++ ...07-22-collapsed-sidebar-control-rail.zh.md | 29 ++++++ apps/web/tests/smoke-fixture.e2e.ts | 25 ++++- packages/client/ui-layout/README.md | 2 +- .../ui-layout/src/client/AppFrame.module.css | 9 +- .../client/ui-layout/src/client/AppFrame.tsx | 6 +- .../client/ui-layout/src/client/columns.ts | 20 ++-- packages/client/ui-layout/src/client/index.ts | 3 +- .../client/ui-layout/tests/app-frame.spec.tsx | 11 ++- .../client/ui-layout/tests/columns.spec.ts | 23 +++-- packages/client/ui-sidebar/README.md | 4 +- .../src/client/SidebarRoot.module.css | 22 +++++ .../ui-sidebar/src/client/SidebarRoot.tsx | 96 ++++++++++++------- .../ui-sidebar/src/client/contract/slots.ts | 2 + .../client/ui-sidebar/src/client/index.ts | 1 + .../client/ui-sidebar/tests/apply.spec.tsx | 8 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 20 +++- 18 files changed, 240 insertions(+), 76 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml new file mode 100644 index 0000000000..5ae814d291 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-22-collapsed-sidebar-control-rail.md: 27e7d0e18a20b2ea6de8128f9c3518279ec21aca +2026-07-22-collapsed-sidebar-control-rail.zh.md: bfbb5230f952e49960142bb5ce090dc6753487ec diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md new file mode 100644 index 0000000000..27e7d0e18a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -0,0 +1,29 @@ +# Agent Note: A collapsed sidebar retains its control rail + +Status: implemented + +English | [中文](2026-07-22-collapsed-sidebar-control-rail.zh.md) + +## Problem + +The sidebar close action persisted `open: false`, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout. + +## Decision + +The layout maps a closed sidebar to the fixed `SIDEBAR_COLLAPSED` width of 60px: one 28px icon control between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. + +`AppFrame` marks the sidebar collapsed from the persisted `open` preference rather than from a zero resolved width. It keeps the sidebar slot mounted but removes the resize handle while collapsed. + +`SidebarRoot` subscribes to the derived open boolean. Its collapsed render removes the brand, creation controls, search, and session tree from the rendered and accessibility trees; the top control changes to `Expand sidebar`, and the bottom `Settings` control remains in the rail. + +## Alternatives considered + +- **Render an expand button over the center column** — rejected because it recovers only the toggle, not the persistent settings area, and splits sidebar chrome across two package owners. +- **Keep a zero-width grid track and let the rail overflow it** — rejected because the rail would overlap the center column and leave hit testing and responsive geometry disconnected from the grid. +- **Keep the complete sidebar tree mounted and hide it with clipping** — rejected because hidden controls remain in the semantic tree and continue subscribing and rendering even though only two controls belong in the collapsed state. + +## Consequences + +- A collapsed sidebar reserves 60px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior. +- The settings entry remains visible but retains its existing placeholder behavior; this change does not introduce an account or settings screen. +- Layout solver tests pin the compact width, sidebar component tests pin the visible controls, and the keyless real-bundle web smoke test pins collapse and recovery through the assembled client. diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md new file mode 100644 index 0000000000..bfbb5230f9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 侧边栏折叠后保留控制栏 + +Status: implemented + +[English](2026-07-22-collapsed-sidebar-control-rail.md) | 中文 + +## 问题 + +侧边栏关闭操作会持久化 `open: false`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。 + +## 决策 + +布局将关闭的侧边栏映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 + +`AppFrame` 根据持久化的 `open` 偏好标记侧边栏是否折叠,而不是根据求解后的宽度是否为零来判断。它让侧边栏插槽保持挂载,但在折叠时移除尺寸调整手柄。 + +`SidebarRoot` 订阅派生的布尔型打开状态。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。 + +## 曾考虑的替代方案 + +- **在中心列上方渲染展开按钮**:不予采纳,因为这只能恢复开关,无法保留常驻设置区域,同时还会让侧边栏 UI 由两个包(package)分别持有。 +- **保留宽度为零的网格轨道,让控制栏溢出显示**:不予采纳,因为控制栏会与中心列重叠,还会使命中测试和响应式几何关系脱离网格布局。 +- **保持完整侧边栏树挂载,并通过裁切将其隐藏**:不予采纳,因为隐藏控件仍留在语义树中,而且会继续订阅和渲染,尽管折叠状态下只需要两个控件。 + +## 后果 + +- 折叠的侧边栏占用 60px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。 +- 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。 +- 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 Web 冒烟测试则通过组装后的客户端固定折叠与恢复行为。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 0c32be5310..d6ec80d7ad 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,9 +1,9 @@ // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins // registry surface + __DSH_BOOT__ injection + built shell dist in a real // chromium. First describe: manifest injection + fail-loud half. Second -// describe: the settled success pass — five REAL tsdown bundles (the -// infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation +// describe: the settled success pass — six REAL tsdown bundles (the +// infrastructure four + layout/sidebar) load through the DI chain in ?fixture +// mode and the three-column frame appears in one flip. The full conversation // round lands in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -17,13 +17,14 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) -/** id ↔ bundle table for the success pass (immediately four + layout). */ +/** id ↔ bundle table for the success pass (immediately four + layout/sidebar). */ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ @@ -90,7 +91,7 @@ describe('web boot chain (keyless, real carrier)', () => { }) }) -describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => { +describe('web boot chain success pass (keyless, six real bundles, ?fixture)', () => { const missing = REAL_PLUGINS.filter((p) => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser @@ -139,6 +140,20 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( const owners = await page.evaluate(() => [...document.querySelectorAll('style[data-plugin]')].map((s) => (s as HTMLElement).dataset['plugin'])) expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') + expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar') + }) + + it('collapsed sidebar keeps a 60px rail with expand and settings controls', async () => { + const frame = page.locator('[class*="frame"]') + const firstTrack = async (): Promise => (await frame.evaluate( + (el) => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]! + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + expect(await firstTrack()).toBe('60px') + await expect(page.getByRole('button', { name: 'Expand sidebar' }).isVisible()).resolves.toBe(true) + await expect(page.getByRole('button', { name: 'Settings' }).isVisible()).resolves.toBe(true) + await page.getByRole('button', { name: 'Expand sidebar' }).click() + expect(await firstTrack()).toBe('300px') + await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 6c935f4b9d..f0755c3234 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-layout -Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. Contract: api-contracts v3 §5. +Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 60px control rail while details closes to zero width. Contract: api-contracts v3 §5. Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. The `conversation` entry authorizes `conversation.empty` delegation through `children`. diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index b7a03c7f2f..8be0ef0107 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -27,13 +27,8 @@ border-left: 1px solid var(--dsw-alias-border-l2); } -/* Collapsed columns keep children mounted; the border must not paint a 1px seam. - Flags live on the frame — DetailsColumn renders inside the provider body and - does not know its own width. */ -.frame[data-sidebar-collapsed] .sidebarCol { - border-right: none; -} - +/* The details subtree stays mounted at zero width, so its border must not paint + a 1px seam. The collapsed sidebar instead retains a bordered compact rail. */ .frame[data-details-collapsed] .detailsCol { border-left: none; } diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index a7efd09311..de771d5120 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -137,12 +137,14 @@ export function AppFrame(props: AppFrameProps) { ref={frameRef} className={css.frame} style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }} - data-sidebar-collapsed={cols.sidebar === 0 || undefined} + data-sidebar-collapsed={!sidebar.open || undefined} data-details-collapsed={cols.details === 0 || undefined} >
{props.sidebar}
{props.children} - {cols.sidebar > 0 && } + {sidebar.open && cols.sidebar > 0 + ? + : null} {cols.details > 0 && } ) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index b6950c19e3..4d45d41874 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -1,9 +1,9 @@ /** * Pure concession-chain column solver for the three-column AppFrame. * Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking - * details first, then sidebar, then auto-closing details (derived zero width — - * persisted open/width preferences are never rewritten, so widening the window - * restores them). Center absorbs any remaining deficit as the last resort. + * details first, then sidebar, then auto-closing details. A closed sidebar + * keeps its compact rail; persisted open/width preferences are never rewritten, + * so widening the window restores them. Center absorbs any remaining deficit. */ /** Panel viewing state consumed by the solver (mirrors LayoutService PanelState). */ @@ -21,6 +21,8 @@ export const SIDEBAR_MIN = 240 export const SIDEBAR_MAX = 420 /** Sidebar width before any user drag. */ export const SIDEBAR_DEFAULT = 300 +/** Closed-sidebar rail: one 28px control between 16px horizontal paddings. */ +export const SIDEBAR_COLLAPSED = 60 /** Details drag clamp floor. */ export const DETAILS_MIN = 300 /** Details drag clamp ceiling. */ @@ -47,13 +49,11 @@ export function clampWidth(px: number, min: number, max: number): number { * @param viewport - available frame width in px. * @param sidebar - sidebar preference (open flag + persisted width). * @param details - details preference (open flag + persisted width). - * @returns resolved widths; details 0 means visually closed (never unmounted). + * @returns resolved widths; details 0 means visually closed, while a closed sidebar keeps its compact rail. */ export function computeColumns(viewport: number, sidebar: PanelInput, details: PanelInput): Columns { - const want = (p: PanelInput, min: number, max: number): number => - p.open ? clampWidth(p.width, min, max) : 0 - const s0 = want(sidebar, SIDEBAR_MIN, SIDEBAR_MAX) - const d0 = want(details, DETAILS_MIN, DETAILS_MAX) + const s0 = sidebar.open ? clampWidth(sidebar.width, SIDEBAR_MIN, SIDEBAR_MAX) : SIDEBAR_COLLAPSED + const d0 = details.open ? clampWidth(details.width, DETAILS_MIN, DETAILS_MAX) : 0 // Step 1: everything fits at preferred widths. if (s0 + d0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0 - d0, details: d0 } @@ -63,14 +63,14 @@ export function computeColumns(viewport: number, sidebar: PanelInput, details: P if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 } // Step 3: shrink sidebar toward its minimum. - const s1 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) + const s1 = sidebar.open ? Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) : SIDEBAR_COLLAPSED if (s1 + d1 + CENTER_MIN <= viewport) return { sidebar: s1, center: CENTER_MIN, details: d1 } // Step 4: auto-close details (derived — preferences untouched). With the // details pressure gone the sidebar concession is re-solved from preference. if (d1 > 0) { if (s0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0, details: 0 } - const s2 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) + const s2 = sidebar.open ? Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) : SIDEBAR_COLLAPSED return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 } } diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index e07c5d9896..3f9801c67b 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -13,7 +13,8 @@ import { LayoutService } from './service.ts' export { AppFrame, CenterColumn, DetailsColumn, type AppFrameProps } from './AppFrame.tsx' export { clampWidth, computeColumns, - CENTER_MIN, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, + CENTER_MIN, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN, + SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, type Columns, type PanelInput, } from './columns.ts' export { LayoutService, type NavState, type PanelState, type ViewId } from './service.ts' diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index dfc49be327..be9d056a02 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react' import { AppFrame, CenterColumn, DetailsColumn, type PanelState } from '@deepseek-ai/dsh-client-ui-layout/client' -import { clampWidth } from '@deepseek-ai/dsh-client-ui-layout/client' +import { clampWidth, SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/client' /** Observer stub: captures the callback so tests can fire resizes manually. */ let fireResize: (() => void) | null = null @@ -119,6 +119,15 @@ describe('AppFrame', () => { expect(frame.hasAttribute('data-details-collapsed')).toBe(true) }) + it('closed sidebar keeps its compact rail and mounted slot content', () => { + const { frame, sidebar, getByTestId } = mountFrame() + act(() => { sidebar.update((d) => { d.open = false }) }) + expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360]) + expect(getByTestId('sidebar-content')).toBeTruthy() + expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true) + expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1) + }) + it('viewport shrink triggers the concession chain via ResizeObserver', () => { const { frame } = mountFrame() frameWidth = 1250 diff --git a/packages/client/ui-layout/tests/columns.spec.ts b/packages/client/ui-layout/tests/columns.spec.ts index 49d351a6d4..6a4ccc92ce 100644 --- a/packages/client/ui-layout/tests/columns.spec.ts +++ b/packages/client/ui-layout/tests/columns.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CENTER_MIN, clampWidth, computeColumns, - DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN, + DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MIN, } from '@deepseek-ai/dsh-client-ui-layout/client' const open = (width: number) => ({ open: true, width }) @@ -21,8 +21,9 @@ describe('computeColumns', () => { expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 }) }) - it('closed panels contribute zero width', () => { - expect(computeColumns(1920, closed(300), closed(360))).toEqual({ sidebar: 0, center: 1920, details: 0 }) + it('closed sidebar keeps its compact rail while details contributes zero width', () => { + expect(computeColumns(1920, closed(300), closed(360))) + .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 1920 - SIDEBAR_COLLAPSED, details: 0 }) }) it('preferences beyond the clamp range are clamped before solving', () => { @@ -69,10 +70,14 @@ describe('computeColumns', () => { }) it('sidebar-closed narrow window: details concedes then auto-closes', () => { - const fits = computeColumns(DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT)) - expect(fits).toEqual({ sidebar: 0, center: CENTER_MIN, details: DETAILS_MIN }) - const starved = computeColumns(DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT)) - expect(starved).toEqual({ sidebar: 0, center: DETAILS_MIN + CENTER_MIN - 1, details: 0 }) + const fits = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT)) + expect(fits).toEqual({ sidebar: SIDEBAR_COLLAPSED, center: CENTER_MIN, details: DETAILS_MIN }) + const starved = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT)) + expect(starved).toEqual({ + sidebar: SIDEBAR_COLLAPSED, + center: DETAILS_MIN + CENTER_MIN - 1, + details: 0, + }) }) it('tiny viewport: both panels yield everything to center', () => { @@ -93,8 +98,8 @@ describe('computeColumns', () => { describe('computeColumns — degenerate viewports', () => { it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes all', () => { - // Reaches step 4's re-solve with s0 = 0 (the closed-sidebar arm). + // Reaches step 4's re-solve with the compact rail as the sidebar floor. expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT))) - .toEqual({ sidebar: 0, center: 500, details: 0 }) + .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 }) }) }) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 25f840ab3d..2650b6836b 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Contract: api-contracts v3 §6. +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Its collapsed render keeps the expand control and settings entry in the layout-owned compact rail. Contract: api-contracts v3 §6. -`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — tree hook, current-session hook, actions) and `SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected` (the owner share referenced from ui-layout's slot declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory binds layout/sessions off `RootBinding`. +`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — tree hook, current-session/sidebar-open hooks, actions) and `SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected` (the owner share referenced from ui-layout's slot declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory binds layout/sessions off `RootBinding`. ## Model Experience diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 5fdcbce74c..c8f0a08964 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -15,6 +15,28 @@ font-size: 14px; } +/* Closed state is a persistent rail: the layout reserves exactly the root's + horizontal padding plus one icon control. */ +.root.collapsed { + gap: 0; +} + +.collapsed .headerBlock { + padding-bottom: 0; +} + +.collapsed .logoRow { + justify-content: center; + padding-inline: 0; +} + +.collapsed .foot { + justify-content: center; + width: 28px; + margin-top: auto; + padding: 0; +} + /* Header block (figma 133:7630): logo row + New Session, gap 16, padBottom 12. */ .headerBlock { flex: none; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 01c8fbfcd4..5a4866bc4f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -24,12 +24,10 @@ const GROUP_BY_ITEMS = [ { id: 'status', label: 'Status', disabled: true }, ] -/** - * Render the sidebar column. - * @param props - composed slot props (owner share + injected surface, contract/slots.ts). - * @returns the sidebar element tree. - */ -export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootComponentProps) { +type SidebarBodyProps = Pick + +/** Expanded-only content; unmounting drops tree/current subscriptions while the rail is collapsed. */ +function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) { const rows = useTree((s) => s.rows) const query = useTree((s) => s.query) const groupBy = useTree((s) => s.groupBy) @@ -47,32 +45,7 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC } return ( -
-
-
- - {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} - - deepseek - HARNESS - - -
- - -
- -
+
WorkSpace +
+ ) +} + +/** + * Render the sidebar column. + * @param props - composed slot props (owner share + injected surface, contract/slots.ts). + * @returns the sidebar element tree. + */ +export function SidebarRoot(props: SidebarRootComponentProps) { + const open = props.useSidebarOpen() + + return ( +
+
+
+ {open + ? ( + + {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} + + deepseek + HARNESS + + ) + : null} + +
+ + {open + ? ( + + ) + : null}
-
+ {open + ? ( + + ) + : null} + +
- Settings + {open ? Settings : null}
) diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 4d1e0dd7f4..4a5d33f7b5 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -37,6 +37,8 @@ export type SidebarRootInjected = { useTree: SnapshotSelectorHook /** Current session selector (row highlight); undefined selects nothing. */ useCurrent: () => SessionId | undefined + /** Sidebar open selector; the collapsed render keeps only persistent rail controls. */ + useSidebarOpen: () => boolean actions: SidebarActions tree: SidebarTreeActions } diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index fab3bc4836..91600c3621 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -45,6 +45,7 @@ export function apply(ctx: ClientContext): void { return { useTree: tree.store.useSelector, useCurrent: () => layout.current.useSelector(s => s.sessionId), + useSidebarOpen: () => layout.sidebar.useSelector(s => s.open), actions: { open: (id) => { layout.open(id) }, create: (cwd) => { diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index ea029c6709..ac4b794466 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -31,10 +31,12 @@ async function bench() { byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, }) const sessions = { list, create: vi.fn(async () => sid('minted')) } + const sidebar = createSnapshotStore({ open: true, width: 300 }) const layout = { current: createSnapshotStore<{ sessionId?: SessionId }>({}), + sidebar, open: vi.fn(), - toggleSidebar: vi.fn(), + toggleSidebar: vi.fn(() => { sidebar.update((d) => { d.open = !d.open }) }), } ctx.provide('sessions', sessions) ctx.provide('layout', layout) @@ -83,6 +85,10 @@ describe('apply', () => { act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) expect(layout.toggleSidebar).toHaveBeenCalledOnce() + expect(screen.getByLabelText('Expand sidebar')).toBeTruthy() + expect(screen.getByLabelText('Settings')).toBeTruthy() + act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) + expect(layout.toggleSidebar).toHaveBeenCalledTimes(2) act(() => { fireEvent.click(screen.getByText('proj')) }) act(() => { fireEvent.click(screen.getByText('alpha')) }) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index db03c672dd..bddacca286 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -51,20 +51,22 @@ function mount(...summaries: SessionSummary[]) { const list = createSnapshotStore(listStateOf(...summaries)) const tree: SidebarTreeStore = createSidebarTreeStore({ list }) const current = createSnapshotStore<{ id: SessionId | undefined }>({ id: undefined }) + const sidebar = createSnapshotStore({ open: true }) const actions: SidebarActions = { open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }), create: vi.fn(), - toggleSidebar: vi.fn(), + toggleSidebar: vi.fn(() => { sidebar.update((d) => { d.open = !d.open }) }), } const utils = render( current.useSelector((s) => s.id)} + useSidebarOpen={() => sidebar.useSelector((s) => s.open)} actions={actions} tree={tree} />, ) - return { list, tree, current, actions, ...utils } + return { list, tree, current, sidebar, actions, ...utils } } const projectData = () => [ @@ -139,10 +141,22 @@ describe('SidebarRoot', () => { expect(actions.create).toHaveBeenLastCalledWith('/proj') }) - it('collapse button and group-by menu behave', () => { + it('collapsed rail keeps the expand and settings controls', () => { const { actions } = mount(...projectData()) act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) expect(actions.toggleSidebar).toHaveBeenCalledOnce() + expect(screen.getByLabelText('Expand sidebar')).toBeTruthy() + expect(screen.getByLabelText('Settings')).toBeTruthy() + expect(screen.queryByText('HARNESS')).toBeNull() + expect(screen.queryByText('New Session')).toBeNull() + act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) + expect(actions.toggleSidebar).toHaveBeenCalledTimes(2) + expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy() + expect(screen.getByText('New Session')).toBeTruthy() + }) + + it('group-by menu behaves', () => { + mount(...projectData()) expect(screen.queryByText('Update')).toBeNull() act(() => { fireEvent.click(screen.getByLabelText('Group by')) }) expect(screen.getByText('Update')).toBeTruthy() From 7e6f0128b58db721b2284ca0993f8d483bd7894c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:51:57 +0800 Subject: [PATCH 05/32] fix(gui): keep sidebar controls when collapsed A closed sidebar previously resolved to a zero-width grid track, clipping the only toggle and the settings entry with no visible recovery; the closed preference persisted across reloads, locking the sidebar shut. - columns.ts maps the closed preference (width 0) to a fixed 60px SIDEBAR_COLLAPSED rail through every step of the concession solve; closed details still resolve to zero width. - AppFrame derives data-sidebar-collapsed and the sidebar slot's collapsed owner prop from the persisted preference instead of the resolved track width, and drops the resize handle while collapsed. - SidebarRoot reads the owner collapsed prop; the expanded-only body is a separate component that unmounts while collapsed (dropping its sessions subscription), leaving the expand toggle and Settings in the rail. - The keyless web smoke gains the ui-sidebar bundle (six real bundles) and pins the 60px rail collapse/expand round through the assembled client. --- ...2-collapsed-sidebar-control-rail.i18n.yaml | 4 +- ...26-07-22-collapsed-sidebar-control-rail.md | 8 +- ...07-22-collapsed-sidebar-control-rail.zh.md | 8 +- apps/web/tests/smoke-fixture.e2e.ts | 26 ++++-- .../client/ui-layout/src/client/AppFrame.tsx | 14 ++-- .../client/ui-layout/src/client/columns.ts | 16 ++-- packages/client/ui-layout/src/client/index.ts | 4 +- .../client/ui-layout/tests/app-frame.spec.tsx | 11 +++ .../client/ui-layout/tests/columns.spec.ts | 25 +++--- packages/client/ui-sidebar/README.md | 2 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 83 +++++++++++-------- .../ui-sidebar/tests/sidebar-root.spec.tsx | 32 +++++-- 12 files changed, 153 insertions(+), 80 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml index 5ae814d291..4cb3d0e859 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml @@ -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 -2026-07-22-collapsed-sidebar-control-rail.md: 27e7d0e18a20b2ea6de8128f9c3518279ec21aca -2026-07-22-collapsed-sidebar-control-rail.zh.md: bfbb5230f952e49960142bb5ce090dc6753487ec +2026-07-22-collapsed-sidebar-control-rail.md: 9f244010a1ec14eeafe707aedfffcf7e5bbe7736 +2026-07-22-collapsed-sidebar-control-rail.zh.md: 53007bf717404b151d0a2d1f673c20f111ee8234 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md index 27e7d0e18a..9f244010a1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -6,15 +6,15 @@ English | [中文](2026-07-22-collapsed-sidebar-control-rail.zh.md) ## Problem -The sidebar close action persisted `open: false`, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout. +The sidebar close action persisted a zero width preference, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout. ## Decision -The layout maps a closed sidebar to the fixed `SIDEBAR_COLLAPSED` width of 60px: one 28px icon control between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. +The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 60px: one 28px icon control between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. -`AppFrame` marks the sidebar collapsed from the persisted `open` preference rather than from a zero resolved width. It keeps the sidebar slot mounted but removes the resize handle while collapsed. +`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. -`SidebarRoot` subscribes to the derived open boolean. Its collapsed render removes the brand, creation controls, search, and session tree from the rendered and accessibility trees; the top control changes to `Expand sidebar`, and the bottom `Settings` control remains in the rail. +`SidebarRoot` reads the owner `collapsed` prop. Its collapsed render removes the brand, creation controls, search, and session tree from the rendered and accessibility trees — the body component unmounts, dropping its sessions subscription; the top control changes to `Expand sidebar`, and the bottom `Settings` control remains in the rail. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md index bfbb5230f9..53007bf717 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -侧边栏关闭操作会持久化 `open: false`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。 +侧边栏关闭操作会持久化宽度偏好 `0`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。 ## 决策 -布局将关闭的侧边栏映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 +布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 -`AppFrame` 根据持久化的 `open` 偏好标记侧边栏是否折叠,而不是根据求解后的宽度是否为零来判断。它让侧边栏插槽保持挂载,但在折叠时移除尺寸调整手柄。 +`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。 -`SidebarRoot` 订阅派生的布尔型打开状态。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。 +`SidebarRoot` 读取 owner 的 `collapsed` 属性。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除——主体组件卸载,随之退订会话列表;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index b75716c615..81b41c4d8c 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,9 +1,9 @@ // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins // registry surface + __DSH_BOOT__ injection + built shell dist in a real // chromium. First describe: manifest injection + fail-loud half. Second -// describe: the settled success pass — five REAL tsdown bundles (the -// infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation +// describe: the settled success pass — six REAL tsdown bundles (the +// infrastructure four + layout/sidebar) load through the DI chain in ?fixture +// mode and the three-column frame appears in one flip. The full conversation // round lands in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -17,13 +17,14 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) -/** id ↔ bundle table for the success pass (immediately four + layout). */ +/** id ↔ bundle table for the success pass (immediately four + layout/sidebar). */ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ @@ -91,7 +92,7 @@ describe('web boot chain (keyless, real carrier)', () => { }) }) -describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => { +describe('web boot chain success pass (keyless, six real bundles, ?fixture)', () => { const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser @@ -141,6 +142,21 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( const owners = await page.evaluate(() => [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin'])) expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') + expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar') + }) + + it('collapsed sidebar keeps a 60px rail with expand and settings controls', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail')) + const frame = page.locator('[class*="frame"]') + const firstTrack = async (): Promise => (await frame.evaluate( + el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]! + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + expect(await firstTrack()).toBe('60px') + await expect(page.getByRole('button', { name: 'Expand sidebar' }).isVisible()).resolves.toBe(true) + await expect(page.getByRole('button', { name: 'Settings' }).isVisible()).resolves.toBe(true) + await page.getByRole('button', { name: 'Expand sidebar' }).click() + expect(await firstTrack()).toBe('300px') + await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 5c5e989957..c5d279021f 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -128,14 +128,15 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App ref={frameRef} className={css.frame} style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }} - data-sidebar-collapsed={cols.sidebar === 0 || undefined} + data-sidebar-collapsed={panels.sidebar === 0 || undefined} data-details-collapsed={cols.details === 0 || undefined} >
- {/* Render-site slot call with live concession output: the sidebar - stays mounted at zero width (CSS hides it), and sees its rendered - state as owner params decided here, not precomputed upstream. */} - {renderSlot('sidebar', { collapsed: cols.sidebar === 0, width: cols.sidebar })} + {/* Render-site slot call with live concession output: a closed + sidebar keeps the mounted slot at the compact-rail width, and the + component sees its rendered state as owner params decided here + (collapsed follows the preference, not the resolved width). */} + {renderSlot('sidebar', { collapsed: panels.sidebar === 0, width: cols.sidebar })}
( @@ -153,7 +154,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App )} - {cols.sidebar > 0 && } + {/* The collapsed rail is fixed-width: no resize handle while closed. */} + {panels.sidebar > 0 && } {cols.details > 0 && }
) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 5d4d611707..73b23eb6a1 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -4,7 +4,9 @@ * details first, then sidebar, then auto-closing details (derived zero width — * persisted width preferences are never rewritten, so widening the window * restores them). Center absorbs any remaining deficit as the last resort. - * Inputs are the layout store's plain width preferences (0 = closed). + * Inputs are the layout store's plain width preferences (0 = closed); a + * closed sidebar resolves to the fixed SIDEBAR_COLLAPSED control rail while + * closed details resolve to zero width. */ /** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */ @@ -19,6 +21,8 @@ export const SIDEBAR_MIN = 240 export const SIDEBAR_MAX = 420 /** Sidebar width before any user drag. */ export const SIDEBAR_DEFAULT = 300 +/** Closed-sidebar rail: one 28px control between 16px horizontal paddings. */ +export const SIDEBAR_COLLAPSED = 60 /** Details drag clamp floor. */ export const DETAILS_MIN = 300 /** Details drag clamp ceiling. */ @@ -47,10 +51,10 @@ export function clampWidth(px: number, min: number, max: number): number { * @param viewport - available frame width in px. * @param sidebar - sidebar width preference in px (0 = closed). * @param details - details width preference in px (0 = closed). - * @returns resolved widths; details 0 means visually closed (never unmounted). + * @returns resolved widths; details 0 means visually closed (never unmounted), while a closed sidebar keeps its compact rail. */ export function computeColumns(viewport: number, sidebar: number, details: number): Columns { - const s0 = sidebar === 0 ? 0 : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX) + const s0 = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX) const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX) // Step 1: everything fits at preferred widths. @@ -60,15 +64,15 @@ export function computeColumns(viewport: number, sidebar: number, details: numbe const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s0 - CENTER_MIN) if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 } - // Step 3: shrink sidebar toward its minimum. - const s1 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) + // Step 3: shrink sidebar toward its minimum (the collapsed rail never shrinks). + const s1 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) if (s1 + d1 + CENTER_MIN <= viewport) return { sidebar: s1, center: CENTER_MIN, details: d1 } // Step 4: auto-close details (derived — preferences untouched). With the // details pressure gone the sidebar concession is re-solved from preference. if (d1 > 0) { if (s0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0, details: 0 } - const s2 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) + const s2 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 } } diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 00555e90dc..1aeb04593b 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -50,9 +50,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Sidebar owner share: live column state from the frame's concession solve. */ export interface SidebarOwnerProps { - /** True when the concession chain rendered the column at zero width. */ + /** True when the sidebar is closed (the column renders the compact control rail). */ collapsed: boolean - /** Rendered column width in px (0 when collapsed). */ + /** Rendered column width in px (SIDEBAR_COLLAPSED when collapsed). */ width: number } diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 7e65445a3a..120197d531 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -15,6 +15,7 @@ import { act, cleanup, render } from '@testing-library/react' import { useSyncExternalStore } from 'react' import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' +import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts' // Session-mode switch for the SessionProvider stub prop. @@ -175,6 +176,16 @@ describe('AppFrame', () => { expect(frame.hasAttribute('data-details-collapsed')).toBe(true) }) + it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => { + const { frame, instance, slotCalls, getByTestId } = mountFrame() + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360]) + expect(getByTestId('sidebar-content')).toBeTruthy() + expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true) + const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)! + expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED }) + }) + it('viewport shrink triggers the concession chain via ResizeObserver', () => { const { frame } = mountFrame() frameWidth = 1250 diff --git a/packages/client/ui-layout/tests/columns.spec.ts b/packages/client/ui-layout/tests/columns.spec.ts index 8fb355bb19..6358c45076 100644 --- a/packages/client/ui-layout/tests/columns.spec.ts +++ b/packages/client/ui-layout/tests/columns.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CENTER_MIN, clampWidth, computeColumns, - DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN, + DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MIN, } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' // Numeric preference form (0 = closed); helpers keep the scenario names readable. @@ -22,8 +22,9 @@ describe('computeColumns', () => { expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 }) }) - it('closed panels contribute zero width', () => { - expect(computeColumns(1920, closed(300), closed(360))).toEqual({ sidebar: 0, center: 1920, details: 0 }) + it('closed sidebar keeps its compact rail while closed details contribute zero width', () => { + expect(computeColumns(1920, closed(300), closed(360))) + .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 1920 - SIDEBAR_COLLAPSED, details: 0 }) }) it('preferences beyond the clamp range are clamped before solving', () => { @@ -70,10 +71,14 @@ describe('computeColumns', () => { }) it('sidebar-closed narrow window: details concedes then auto-closes', () => { - const fits = computeColumns(DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT)) - expect(fits).toEqual({ sidebar: 0, center: CENTER_MIN, details: DETAILS_MIN }) - const starved = computeColumns(DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT)) - expect(starved).toEqual({ sidebar: 0, center: DETAILS_MIN + CENTER_MIN - 1, details: 0 }) + const fits = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT)) + expect(fits).toEqual({ sidebar: SIDEBAR_COLLAPSED, center: CENTER_MIN, details: DETAILS_MIN }) + const starved = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT)) + expect(starved).toEqual({ + sidebar: SIDEBAR_COLLAPSED, + center: DETAILS_MIN + CENTER_MIN - 1, + details: 0, + }) }) it('tiny viewport: both panels yield everything to center', () => { @@ -93,9 +98,9 @@ describe('computeColumns', () => { }) describe('computeColumns — degenerate viewports', () => { - it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes all', () => { - // Reaches step 4's re-solve with s0 = 0 (the closed-sidebar arm). + it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => { + // Reaches step 4's re-solve with the compact rail as the sidebar floor. expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT))) - .toEqual({ sidebar: 0, center: 500, details: 0 }) + .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 }) }) }) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 52abd0c673..441cc7477d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. The collapsed render keeps the expand control and settings entry in the layout-owned compact rail. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 9badca1b82..da418b4d98 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -5,6 +5,8 @@ * standard useSessions hook, viewing state (expansion, search) is local * component state, and rows are derived in render via useMemo (slot design * section 6: derived data is a pure function, no materializing store). + * The collapsed render keeps only the rail controls (expand toggle + + * Settings); the body unmounts, dropping its sessions subscription. */ import { Fragment, useMemo, useState } from 'react' import clsx from 'clsx' @@ -31,12 +33,10 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] } -/** - * Render the sidebar column. - * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). - * @returns the sidebar element tree. - */ -export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { +type SidebarBodyProps = Pick + +/** Expanded-only content; unmounting drops the sessions subscription and viewing state while the rail is collapsed. */ +function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) { const list = useSessions((s) => s) // Wave-2 seam: row highlight expects `current` on the sessions list // snapshot (sessions.current lives with the runtime sessions service). @@ -61,32 +61,7 @@ export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }: } return ( -
-
-
- - {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} - - deepseek - HARNESS - - -
- - -
- -
+
WorkSpace +
+ ) +} + +/** + * Render the sidebar column. + * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). + * @returns the sidebar element tree. + */ +export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { + return ( +
+
+
+ {!collapsed && ( + + {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} + + deepseek + HARNESS + + )} + +
+ + {!collapsed && ( + + )}
-
+ {!collapsed && } + +
- Settings + {!collapsed && Settings}
) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 6beb07bdc4..dace97a6b3 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -60,17 +60,24 @@ function mount(...summaries: SessionSummary[]) { const sessions = createSnapshotStore(listStateOf(...summaries)) const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) }) const onCreate = vi.fn() - const onToggleSidebar = vi.fn() - const utils = render( + // The owner decides collapsed in production (AppFrame maps the preference); + // the harness mirrors that loop so the toggle drives a re-render. + let collapsed = false + const view = (width: number) => ( , + /> ) + const onToggleSidebar = vi.fn(() => { + collapsed = !collapsed + utils.rerender(view(collapsed ? 60 : 300)) + }) + const utils = render(view(300)) return { sessions, onOpen, onCreate, onToggleSidebar, ...utils } } @@ -151,10 +158,23 @@ describe('SidebarRoot', () => { expect(onCreate).toHaveBeenLastCalledWith('/proj') }) - it('collapse button and group-by menu behave', () => { + it('collapsed rail keeps the expand and settings controls', () => { const { onToggleSidebar } = mount(...projectData()) act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) expect(onToggleSidebar).toHaveBeenCalledOnce() + expect(screen.getByLabelText('Expand sidebar')).toBeTruthy() + expect(screen.getByLabelText('Settings')).toBeTruthy() + expect(screen.queryByText('HARNESS')).toBeNull() + expect(screen.queryByText('New Session')).toBeNull() + expect(screen.queryByRole('tree')).toBeNull() + act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) + expect(onToggleSidebar).toHaveBeenCalledTimes(2) + expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy() + expect(screen.getByText('New Session')).toBeTruthy() + }) + + it('group-by menu behaves', () => { + mount(...projectData()) expect(screen.queryByText('Update')).toBeNull() act(() => { fireEvent.click(screen.getByLabelText('Group by')) }) expect(screen.getByText('Update')).toBeTruthy() From a6649fb54523f62eadcf87c03994000bad24c515 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:52:46 +0800 Subject: [PATCH 06/32] test(web): drop the stale fail-loud boot smoke case Since the store engine moved into the client runtime, the ui-layout bundle externalizes @deepseek-ai/dsh-client-runtime/client. This case's two-row manifest (ui-layout + a deliberately absent probe) no longer boots ui-layout at all: the unseeded runtime specifier fails it first, serial loading stops, and the probe never reaches the failure list, so the assertion times out on a premise the engine migration retired. --- apps/web/tests/smoke-fixture.e2e.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 81b41c4d8c..136a12599d 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,6 +1,6 @@ // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins // registry surface + __DSH_BOOT__ injection + built shell dist in a real -// chromium. First describe: manifest injection + fail-loud half. Second +// chromium. First describe: manifest injection + static serving. Second // describe: the settled success pass — six REAL tsdown bundles (the // infrastructure four + layout/sidebar) load through the DI chain in ?fixture // mode and the three-column frame appears in one flip. The full conversation @@ -77,15 +77,6 @@ describe('web boot chain (keyless, real carrier)', () => { expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin') }) - it('boots to the loading page and fail-louds the absent plugin', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud')) - await page.waitForSelector('text=HARNESS', { timeout: 10_000 }) - await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 }) - await page.waitForSelector('text=@probe/absent', { timeout: 2000 }) - // The real UI must not have flipped in: the gate opens only on settled(). - expect(await page.locator('[class*="frame"]').count()).toBe(0) - }) - it('applies the token sheets before any plugin CSS', async () => { const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family')) expect(family.trim().length).toBeGreaterThan(0) From 5da8e3b7872ca34114e71289877eb8a238a973ca Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:45:03 +0800 Subject: [PATCH 07/32] feat(gui): animate sidebar collapse and grow the rail control set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapsed rail becomes a 56px icon column (24px controls between 16px paddings) carrying expand, new session, search, and new workspace — each aligned with its expanded counterpart; rail search expands the sidebar and focuses the search box. Collapse/expand now animates: the frame transitions grid-template-columns (and the surviving handle its left) on the deepsuite sider curve — --ds-ease-in-out over --ds-transition-duration-slow, supplied by ui-theme's base sheet. Transitions pause during drags (data-dragging on the frame, set for the whole gesture) and under prefers-reduced-motion. --- ...2-collapsed-sidebar-control-rail.i18n.yaml | 4 +- ...26-07-22-collapsed-sidebar-control-rail.md | 8 +- ...07-22-collapsed-sidebar-control-rail.zh.md | 8 +- apps/web/tests/smoke-fixture.e2e.ts | 24 ++++- packages/client/ui-layout/README.md | 2 +- .../ui-layout/src/client/AppFrame.module.css | 28 +++++ .../client/ui-layout/src/client/AppFrame.tsx | 20 ++-- .../client/ui-layout/src/client/columns.ts | 4 +- packages/client/ui-sidebar/README.md | 2 +- .../src/client/SidebarRoot.module.css | 22 ++-- .../ui-sidebar/src/client/SidebarRoot.tsx | 100 ++++++++++++++---- .../ui-sidebar/tests/sidebar-root.spec.tsx | 21 +++- packages/client/ui-theme/src/styles/base.css | 9 +- 13 files changed, 186 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml index 4cb3d0e859..5a06df6e59 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml @@ -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 -2026-07-22-collapsed-sidebar-control-rail.md: 9f244010a1ec14eeafe707aedfffcf7e5bbe7736 -2026-07-22-collapsed-sidebar-control-rail.zh.md: 53007bf717404b151d0a2d1f673c20f111ee8234 +2026-07-22-collapsed-sidebar-control-rail.md: 90039110f4c1e97002fe45ebe42697452b2c6155 +2026-07-22-collapsed-sidebar-control-rail.zh.md: e5cd1c4911bbe02e10d2f1506943024bde862f12 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md index 9f244010a1..90039110f4 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -10,11 +10,11 @@ The sidebar close action persisted a zero width preference, and the layout mappe ## Decision -The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 60px: one 28px icon control between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. +The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 56px: a 24px icon column between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. -`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. +`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. Collapse and expand animate: the frame transitions `grid-template-columns` (and the remaining handle its `left`) on the deepsuite sider curve — `--ds-ease-in-out` over `--ds-transition-duration-slow`, both supplied by ui-theme's base sheet; transitions pause during drags and under `prefers-reduced-motion`. -`SidebarRoot` reads the owner `collapsed` prop. Its collapsed render removes the brand, creation controls, search, and session tree from the rendered and accessibility trees — the body component unmounts, dropping its sessions subscription; the top control changes to `Expand sidebar`, and the bottom `Settings` control remains in the rail. +`SidebarRoot` reads the owner `collapsed` prop. Its collapsed render is the rail: expand toggle, new session, search, and new workspace icons (each aligned with its expanded counterpart's behavior — the search icon expands the sidebar and focuses the search box) plus the `Settings` foot. The brand, capsule button, search field, and session tree leave the rendered and accessibility trees — the body component unmounts, dropping its sessions subscription. ## Alternatives considered @@ -24,6 +24,6 @@ The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COL ## Consequences -- A collapsed sidebar reserves 60px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior. +- A collapsed sidebar reserves 56px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior. - The settings entry remains visible but retains its existing placeholder behavior; this change does not introduce an account or settings screen. - Layout solver tests pin the compact width, sidebar component tests pin the visible controls, and the keyless real-bundle web smoke test pins collapse and recovery through the assembled client. diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md index 53007bf717..e5cd1c4911 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -10,11 +10,11 @@ Status: implemented ## 决策 -布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 +布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 -`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。 +`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。折叠与展开带动画:frame 对 `grid-template-columns`(以及余下手柄的 `left`)应用 deepsuite 侧栏曲线过渡——`--ds-ease-in-out` 配 `--ds-transition-duration-slow`,两个变量由 ui-theme 的 base 表提供;拖拽期间和 `prefers-reduced-motion` 下过渡暂停。 -`SidebarRoot` 读取 owner 的 `collapsed` 属性。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除——主体组件卸载,随之退订会话列表;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。 +`SidebarRoot` 读取 owner 的 `collapsed` 属性。折叠渲染即控制栏:展开开关、新建会话、搜索、新建工作区四个图标(行为与展开态对应控件对齐——搜索图标会展开侧边栏并聚焦搜索框),加上底部的 `Settings`。品牌标识、胶囊按钮、搜索框和会话树离开渲染树与可访问性树——主体组件卸载,随之退订会话列表。 ## 曾考虑的替代方案 @@ -24,6 +24,6 @@ Status: implemented ## 后果 -- 折叠的侧边栏占用 60px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。 +- 折叠的侧边栏占用 56px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。 - 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。 - 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 Web 冒烟测试则通过组装后的客户端固定折叠与恢复行为。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 136a12599d..1d4fd671bb 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -136,18 +136,32 @@ describe('web boot chain success pass (keyless, six real bundles, ?fixture)', () expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar') }) - it('collapsed sidebar keeps a 60px rail with expand and settings controls', async () => { + it('collapsed sidebar animates to a 56px rail with the four controls', async () => { onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail')) const frame = page.locator('[class*="frame"]') const firstTrack = async (): Promise => (await frame.evaluate( el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]! + // The tracks transition on the deepsuite curve; assert the animated + // settle rather than an instant jump. + const settledTrack = async (px: string): Promise => { + await expect.poll(firstTrack, { timeout: 2000 }).toBe(px) + } await page.getByRole('button', { name: 'Collapse sidebar' }).click() - expect(await firstTrack()).toBe('60px') - await expect(page.getByRole('button', { name: 'Expand sidebar' }).isVisible()).resolves.toBe(true) - await expect(page.getByRole('button', { name: 'Settings' }).isVisible()).resolves.toBe(true) + await settledTrack('56px') + for (const name of ['Expand sidebar', 'New session', 'Search sessions', 'New workspace', 'Settings']) { + await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true) + } await page.getByRole('button', { name: 'Expand sidebar' }).click() - expect(await firstTrack()).toBe('300px') + await settledTrack('300px') await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true) + // Rail search: collapse again, the search control expands and lands in the box. + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + await settledTrack('56px') + await page.getByRole('button', { name: 'Search sessions' }).click() + await settledTrack('300px') + const focused = await page.evaluate(() => + (document.activeElement as HTMLInputElement | null)?.placeholder ?? '') + expect(focused).toContain('Search') }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index c334e18903..6cb5fa29a4 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-layout -Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 60px control rail while details closes to zero width. Contract: api-contracts v3 §5. +Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5. Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'. diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index 8be0ef0107..631bb929db 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -5,6 +5,21 @@ height: 100%; overflow: hidden; background: var(--dsw-alias-bg-base); + /* Collapse/expand animates the tracks on the deepsuite sider curve + (--ds-ease-in-out / --ds-transition-duration-slow, ui-theme base.css). */ + transition: grid-template-columns var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +/* Dragging writes widths at pointer cadence; easing them would detach the + column from the handle. */ +.frame[data-dragging] { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .frame { + transition: none; + } } .sidebarCol { @@ -46,6 +61,19 @@ cursor: col-resize; z-index: 2; touch-action: none; + /* Rides the same curve as the tracks so the pill stays on the moving + border during collapse/expand; paused while dragging (frame rule). */ + transition: left var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.frame[data-dragging] .handle { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .handle { + transition: none; + } } .handle::after { diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index c5d279021f..e40c94454d 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -35,13 +35,13 @@ function DetailsColumn(props: { children?: ReactNode }) { } /** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. */ -function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void }) { +function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) { const [dragging, setDragging] = useState(false) const origin = useRef(0) const latest = useRef(0) const frame = useRef(null) - const callbacks = useRef({ onStart: props.onStart, onDrag: props.onDrag }) - callbacks.current = { onStart: props.onStart, onDrag: props.onDrag } + const callbacks = useRef({ onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd }) + callbacks.current = { onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd } const onPointerDown = useCallback((e: React.PointerEvent) => { e.preventDefault() @@ -65,6 +65,7 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null } callbacks.current.onDrag(latest.current - origin.current) setDragging(false) + callbacks.current.onEnd() }, []) return ( @@ -114,8 +115,12 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App // it stays frozen for the whole gesture so dx deltas do not compound. const sidebarBase = useRef(0) const detailsBase = useRef(0) - const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar }, []) - const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details }, []) + // Track-level transitions pause for the whole gesture: eased tracks would + // detach the column edge from the pointer (AppFrame.module.css). + const [dragging, setDragging] = useState(false) + const onDragEnd = useCallback(() => { setDragging(false) }, []) + const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar; setDragging(true) }, []) + const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details; setDragging(true) }, []) const onSidebarDrag = useCallback((dx: number) => { actions.setSidebar(sidebarBase.current + dx) }, [actions]) @@ -130,6 +135,7 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }} data-sidebar-collapsed={panels.sidebar === 0 || undefined} data-details-collapsed={cols.details === 0 || undefined} + data-dragging={dragging || undefined} >
{/* Render-site slot call with live concession output: a closed @@ -155,8 +161,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App )} {/* The collapsed rail is fixed-width: no resize handle while closed. */} - {panels.sidebar > 0 && } - {cols.details > 0 && } + {panels.sidebar > 0 && } + {cols.details > 0 && }
) } diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 73b23eb6a1..d7a63aafa2 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -21,8 +21,8 @@ export const SIDEBAR_MIN = 240 export const SIDEBAR_MAX = 420 /** Sidebar width before any user drag. */ export const SIDEBAR_DEFAULT = 300 -/** Closed-sidebar rail: one 28px control between 16px horizontal paddings. */ -export const SIDEBAR_COLLAPSED = 60 +/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */ +export const SIDEBAR_COLLAPSED = 56 /** Details drag clamp floor. */ export const DETAILS_MIN = 300 /** Details drag clamp ceiling. */ diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 441cc7477d..b398db9d3a 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. The collapsed render keeps the expand control and settings entry in the layout-owned compact rail. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. The collapsed render is the layout-owned compact rail: expand / new session / search (expands and focuses the search box) / new workspace icons plus the settings entry. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index c8f0a08964..571450d13f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -15,24 +15,24 @@ font-size: 14px; } -/* Closed state is a persistent rail: the layout reserves exactly the root's - horizontal padding plus one icon control. */ +/* Closed state is a persistent rail: a 24px icon column between the 16px + horizontal paddings (SIDEBAR_COLLAPSED = 56). Controls mirror their + expanded counterparts top-down: expand, new session, search, new + workspace; settings keeps the foot. */ .root.collapsed { - gap: 0; + align-items: center; + gap: 8px; + padding: 14px 16px 6px; } -.collapsed .headerBlock { - padding-bottom: 0; -} - -.collapsed .logoRow { - justify-content: center; - padding-inline: 0; +.collapsed .iconButton { + width: 24px; + height: 24px; } .collapsed .foot { justify-content: center; - width: 28px; + width: 24px; margin-top: auto; padding: 0; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index da418b4d98..60c94d53a5 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -5,10 +5,11 @@ * standard useSessions hook, viewing state (expansion, search) is local * component state, and rows are derived in render via useMemo (slot design * section 6: derived data is a pure function, no materializing store). - * The collapsed render keeps only the rail controls (expand toggle + - * Settings); the body unmounts, dropping its sessions subscription. + * The collapsed render is the compact rail: expand / new session / search / + * new workspace icons plus the Settings foot; the body unmounts, dropping + * its sessions subscription. Rail search expands and focuses the search box. */ -import { Fragment, useMemo, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import clsx from 'clsx' import { FishLogo, @@ -33,10 +34,13 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] } -type SidebarBodyProps = Pick +type SidebarBodyProps = Pick & { + /** Focus the search input on mount (rail search control expands into search). */ + autoFocusSearch: boolean +} /** Expanded-only content; unmounting drops the sessions subscription and viewing state while the rail is collapsed. */ -function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) { +function SidebarBody({ useSessions, onOpen, onCreate, autoFocusSearch }: SidebarBodyProps) { const list = useSessions((s) => s) // Wave-2 seam: row highlight expects `current` on the sessions list // snapshot (sessions.current lives with the runtime sessions service). @@ -99,6 +103,7 @@ function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) { type="text" placeholder="Search name, keywords..." value={query} + autoFocus={autoFocusSearch} onChange={(e) => { setQuery(e.target.value) }} /> {query !== '' && ( @@ -152,41 +157,90 @@ function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) { * @returns the sidebar element tree. */ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { + // Rail search = expand + land in the search box: the flag arms right before + // the expand toggle, the remounting SidebarBody autofocuses its input, and + // the post-commit effect disarms so later remounts stay unfocused. + const [searchOnExpand, setSearchOnExpand] = useState(false) + useEffect(() => { + if (!collapsed && searchOnExpand) setSearchOnExpand(false) + }, [collapsed, searchOnExpand]) + + if (collapsed) { + // Rail (figma parity with deepsuite CollapsedSider): the four controls + // mirror their expanded counterparts top-down; actions that need the + // expanded surface expand first. + return ( +
+ + + + +
+ +
+
+ ) + } + return ( -
+
- {!collapsed && ( - - {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} - - deepseek - HARNESS - - )} + + {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} + + deepseek + HARNESS +
- {!collapsed && ( - - )} +
- {!collapsed && } +
- {!collapsed && Settings} + Settings
) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index dace97a6b3..e9367a8388 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -75,7 +75,7 @@ function mount(...summaries: SessionSummary[]) { ) const onToggleSidebar = vi.fn(() => { collapsed = !collapsed - utils.rerender(view(collapsed ? 60 : 300)) + utils.rerender(view(collapsed ? 56 : 300)) }) const utils = render(view(300)) return { sessions, onOpen, onCreate, onToggleSidebar, ...utils } @@ -158,21 +158,36 @@ describe('SidebarRoot', () => { expect(onCreate).toHaveBeenLastCalledWith('/proj') }) - it('collapsed rail keeps the expand and settings controls', () => { - const { onToggleSidebar } = mount(...projectData()) + it('collapsed rail keeps the four controls and settings', () => { + const { onToggleSidebar, onCreate } = mount(...projectData()) act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) expect(onToggleSidebar).toHaveBeenCalledOnce() expect(screen.getByLabelText('Expand sidebar')).toBeTruthy() + expect(screen.getByLabelText('New session')).toBeTruthy() + expect(screen.getByLabelText('Search sessions')).toBeTruthy() + expect(screen.getByLabelText('New workspace')).toBeTruthy() expect(screen.getByLabelText('Settings')).toBeTruthy() expect(screen.queryByText('HARNESS')).toBeNull() expect(screen.queryByText('New Session')).toBeNull() expect(screen.queryByRole('tree')).toBeNull() + // Rail creation entries route like their expanded counterparts. + act(() => { fireEvent.click(screen.getByLabelText('New session')) }) + expect(onCreate).toHaveBeenLastCalledWith() act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) expect(onToggleSidebar).toHaveBeenCalledTimes(2) expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy() expect(screen.getByText('New Session')).toBeTruthy() }) + it('rail search expands the sidebar and focuses the search box', () => { + const { onToggleSidebar } = mount(...projectData()) + act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) + act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) + expect(onToggleSidebar).toHaveBeenCalledTimes(2) + const input = screen.getByPlaceholderText('Search name, keywords...') + expect(document.activeElement).toBe(input) + }) + it('group-by menu behaves', () => { mount(...projectData()) expect(screen.queryByText('Update')).toBeNull() diff --git a/packages/client/ui-theme/src/styles/base.css b/packages/client/ui-theme/src/styles/base.css index 7fa58107d2..2d1acde71d 100644 --- a/packages/client/ui-theme/src/styles/base.css +++ b/packages/client/ui-theme/src/styles/base.css @@ -1,10 +1,13 @@ -/* Base font-family variables referenced by the token sheets but defined +/* Base variables referenced by the token sheets and component CSS but defined * upstream (deepsuite theme/global.css) — supplied here so the composite - * --dsw-font-* variables resolve. Code stack deliberately omits a bare - * `monospace` tail (Windows CJK falls back to SimSun otherwise). */ + * --dsw-font-* variables resolve and motion rides the upstream curve. Code + * font stack deliberately omits a bare `monospace` tail (Windows CJK falls + * back to SimSun otherwise). */ :root { --dsw-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; --ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas, 'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei'; + --ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --ds-transition-duration-slow: 0.3s; } From 2a1b3139f98f50d3cf83e74c5f00025bae3e7c2e Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:30:08 -0700 Subject: [PATCH 08/32] feat(tui): add interactive extension service --- ...ui-interactive-extension-service.i18n.yaml | 6 + ...07-22-tui-interactive-extension-service.md | 41 ++ ...22-tui-interactive-extension-service.zh.md | 41 ++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 3 + docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 23 + .../cordis/tool-cordis/src/api-catalog.ts | 62 +++ packages/ui/README.md | 4 +- packages/ui/tui/README.md | 4 +- packages/ui/tui/src/extension.ts | 165 ++++++ packages/ui/tui/src/index.ts | 222 ++++++-- packages/ui/tui/src/overlay-manager.ts | 353 ++++++++++++ packages/ui/tui/tests/extension.spec.ts | 518 ++++++++++++++++++ packages/ui/tui/tests/tui.spec.ts | 147 +++++ scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 7 + 19 files changed, 1546 insertions(+), 62 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md create mode 100644 .agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.zh.md create mode 100644 packages/ui/tui/src/extension.ts create mode 100644 packages/ui/tui/src/overlay-manager.ts create mode 100644 packages/ui/tui/tests/extension.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml new file mode 100644 index 0000000000..d218c2a0b2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-22-tui-interactive-extension-service.md: 82e7c751b6e5b7500f9f7d7004fda8b905dccabb +2026-07-22-tui-interactive-extension-service.zh.md: d7340e3f5dcf45e95b2d6e15ce3fc33726a555ae diff --git a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md new file mode 100644 index 0000000000..82e7c751b6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md @@ -0,0 +1,41 @@ +# Agent Note: Effect-owned TUI interactive extensions + +Status: implemented + +English | [中文](2026-07-22-tui-interactive-extension-service.zh.md) + +## Problem + +Cordis plugins can register human commands through `ctx.commands`, but a command that needs terminal interaction has no supported presentation boundary. It must either remain non-interactive or capture the TUI's private pi-tui tree, focus state, renderer, and shutdown lifecycle. That coupling makes the extension depend on one front door's internals, lets independently developed overlays compete for focus, and leaves plugin unload with no reliable way to remove queued or visible UI. + +## Decision + +A mounted `@deepseek-ai/dsh-tui` provides `ctx.tui` after terminal startup succeeds. The service belongs to that exact terminal and agent, disappears before terminal teardown, and causes plugins that inject it to unload and reload with provider availability. Other front doors do not emulate it. + +`ctx.tui.openOverlay()` is the first and only interactive extension primitive. It accepts a component factory, constrained layout options, and an optional abort signal. The factory receives a frozen host with the current viewport, semantic theme functions, display-text escaping, redraw, close, and a lifetime signal. It does not receive the pi-tui `TUI`, overlay handle, editor, transcript tree, focus controller, or terminal object. + +One private overlay manager serializes built-in and plugin requests in FIFO order. The model selector and `ctx.userInteraction` question panel use the same manager, so all modal interaction has one focus owner. Closing the active overlay restores pi-tui's previous focus before the next request activates. Overlay state is process-local presentation: it is neither appended to the session log nor rebuilt during resume. + +The service method runs through Cordis's traceable service proxy. It installs an effect on the calling plugin fiber before admitting the request; caller disposal therefore removes a queued request or closes an active overlay and awaits the same settled outcome. TUI shutdown first rejects admission, then disposes the service fiber so dependent plugins and their effects quiesce, settles remaining built-in work, and only then drains and stops the terminal. + +Component construction, rendering, input, and invalidation run behind an exception boundary. A failure closes that request with an `error` outcome, reports a visible terminal error, and lets the queue continue. Components are trusted package code: their rendered lines may contain ANSI styling, and they must call `host.display()` before including untrusted text. + +## Verification + +Manager tests pin FIFO admission, cancellation, repeated close, shutdown outcomes, guarded callbacks, host capabilities, and per-file coverage. Cordis lifecycle tests pin caller ownership, provider loss and return, unloading-time rejection, and cleanup quiescence. Fake-terminal integration tests exercise plugin overlays alongside built-in questions, restored editor input, terminal remount, startup rollback, and service disappearance. Existing TUI interaction tests continue to exercise the model selector and question panel through the shared path. + +## Alternatives considered + +**Expose pi-tui objects directly.** This gives plugins maximum freedom but makes private focus, rendering, and teardown state a public compatibility contract. It also cannot arbitrate independently loaded overlays. + +**Put interactive callbacks on command definitions.** Commands are shared by TUI and ACP and remain useful without a terminal. Adding terminal state to `ctx.commands` would couple discovery and dispatch to one presentation implementation. + +**Create a complete TUI slot and action framework at once.** Actions, editor replacement, transcript renderers, status regions, and completion providers have different composition and conflict rules. Shipping them behind one broad API would freeze those rules before a concrete consumer proves them. + +**Persist open overlays in session events.** Modal presentation is not model-visible session state, and arbitrary component state is not replayable. The plugin that owns durable data records that data through its domain service and recreates presentation when appropriate. + +## Consequences + +Interactive plugins gain a small stable front door with deterministic focus and Cordis-owned cleanup, while the TUI keeps authority over terminal lifecycle and pi-tui internals. Built-in dialogs and extensions cannot overlap or strand focus. + +The API deliberately covers modal overlays only. Human command registration remains on `ctx.commands`; actions, slots, editor replacement, event renderers, and completion providers require separate contracts when real consumers establish their ordering and ownership semantics. FIFO serialization also means one stalled overlay blocks later modal work until its owner closes, aborts, or unloads it. diff --git a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.zh.md b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.zh.md new file mode 100644 index 0000000000..d7340e3f5d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 由 effect 持有的 TUI 交互扩展 + +Status: implemented + +[English](2026-07-22-tui-interactive-extension-service.md) | 中文 + +## 问题 + +Cordis 插件可以通过 `ctx.commands` 注册用户命令,但需要终端交互的命令没有受支持的呈现边界。它只能保持非交互,或者捕获 TUI 私有的 pi-tui 树、焦点状态、渲染器和关闭生命周期。此类耦合会使扩展依赖某个入口的内部实现,让各自独立开发的浮层争抢焦点,并导致插件卸载时无法可靠移除排队中或已显示的 UI。 + +## 决策 + +挂载的 `@deepseek-ai/dsh-tui` 在终端成功启动后提供 `ctx.tui`。该服务只属于挂载时绑定的终端与 agent(智能体),在终端拆卸前消失,并使注入它的插件随着提供方的可用与否卸载和重新加载。其他入口不会模拟该服务。 + +`ctx.tui.openOverlay()` 是第一个也是唯一一个交互扩展原语。它接受组件工厂、受限的布局选项,以及可选的中止信号。工厂收到一个冻结的 host,其中包含当前视口、语义化主题函数、显示文本转义、重绘、关闭和生命周期信号。它不会收到 pi-tui `TUI`、浮层句柄、编辑器、transcript(文本记录)树、焦点控制器或终端对象。 + +一个私有浮层管理器按 FIFO 顺序串行处理内置请求和插件请求。模型选择器与 `ctx.userInteraction` 问题面板使用同一个管理器,因此所有模态交互只有一个焦点所有者。关闭活动浮层时,系统会先恢复 pi-tui 之前的焦点,再激活下一项请求。浮层状态是进程本地的呈现状态:它既不会追加到会话日志,也不会在恢复期间重建。 + +服务方法通过 Cordis 的可追踪服务代理运行。它在接纳请求前,向调用方插件的 fiber 注册一个 effect;因此,调用方执行 dispose(资源释放)时会移除排队中的请求或关闭活动浮层,并等待同一个结果完成结算。TUI 关闭时会先拒绝新请求,再 dispose 服务 fiber,让依赖插件及其 effect 完全静止,然后结算其余内置工作,最后才排空并停止终端。 + +组件构造、渲染、输入与失效处理均在异常边界内运行。任何失败都会以 `error` 结果关闭对应请求、在终端中报告一条可见错误,并让队列继续处理。组件属于受信任的包代码:其渲染行可以包含 ANSI 样式,但加入不受信任的文本前必须调用 `host.display()`。 + +## 验证 + +管理器测试固定了 FIFO 准入、取消、重复关闭、关闭结果、受保护回调、host 能力和逐文件覆盖率。Cordis 生命周期测试固定了调用方所有权、提供方消失与恢复、卸载期间的拒绝,以及清理达到完全静止。模拟终端集成测试覆盖插件浮层与内置问题的协作、编辑器输入焦点恢复、终端重新挂载、启动回滚和服务消失。既有 TUI 交互测试继续通过共享路径覆盖模型选择器与问题面板。 + +## 考虑过的替代方案 + +**直接暴露 pi-tui 对象。** 这会赋予插件最大的自由度,却会把私有的焦点、渲染与拆卸状态变成公开兼容性契约,也无法在独立加载的浮层之间进行仲裁。 + +**在命令定义中加入交互回调。** 命令由 TUI 与 ACP 共享,即使没有终端也仍然有用。向 `ctx.commands` 添加终端状态,会让发现与分派流程耦合到某一种呈现实现。 + +**一次性建立完整的 TUI slot 与 action 框架。** action、编辑器替换、transcript 渲染器、状态区域和补全提供方具有不同的组合规则与冲突规则。在具体消费方验证这些规则之前就将其纳入一个宽泛 API,会过早固化这些规则。 + +**将打开的浮层持久化为会话事件。** 模态呈现并非模型可见的会话状态,任意组件状态也无法回放。拥有持久数据的插件应通过自身的领域服务记录这些数据,并在适当时重新创建呈现。 + +## 后果 + +交互式插件获得一个小而稳定的入口,具备确定性的焦点管理和由 Cordis 持有的清理机制;TUI 则继续掌控终端生命周期和 pi-tui 内部实现。内置对话框与扩展无法重叠,也不会遗留失去归属的焦点。 + +该 API 有意只覆盖模态浮层。用户命令仍然在 `ctx.commands` 上注册;action、slot、编辑器替换、事件渲染器和补全提供方需要另行设计契约,等待实际消费方确定其顺序与所有权语义。FIFO 串行处理也意味着,一个停滞的浮层会阻塞后续模态工作,直至其所有者关闭、中止或卸载该浮层。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e3c52e2536..f5079d64fc 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 -architecture.md: 6ff2aa1ad4ca2ef051322f9d95631fe626d26e84 -architecture.zh.md: b4b26efec16d85f1fb26589c5c9bffbb35e39564 +architecture.md: 46b103ec788adbf7673e8b75643c71191318b42f +architecture.zh.md: 2684fe745fe8afd9ebf79f047dd9798ff432e506 diff --git a/docs/architecture.md b/docs/architecture.md index 6ff2aa1ad4..46b103ec78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -185,7 +185,7 @@ New behavior attaches to a documented extension point; a loop change updates thi | Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning | | Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop | | Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it | -| Add UI or editor integration | drive `ctx.agents` and render from `session/event` | +| Add UI or editor integration | drive `ctx.agents` and render from `session/event`; terminal-only overlays use `ctx.tui` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | | Add asynchronous session-title generation | register the sole provider on `ctx.sessionTitle` | | Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index b4b26efec1..2684fe745f 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -185,7 +185,7 @@ forever: | 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成进程前包装 argv | | 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stop` 是串行终止判定点 | | 添加历史记录之外的会话稳定前缀 | 组合 `agent/session-prefix`;请求头会记录该前缀 | -| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | +| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染;仅终端可用的浮层使用 `ctx.tui` | | 添加持久会话状态 | 添加一个 `SessionEventMap` 成员,并从日志渲染和回放 | | 添加异步会话标题生成 | 在 `ctx.sessionTitle` 上注册唯一提供方 | | 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent` 和 `agent/*` 续跑 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 54951fd9b4..544d1bdd61 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -61,6 +61,7 @@ flowchart LR svc_planMode["ctx.planMode
Plan collaboration state"] pkg_commands["commands"] svc_commands["ctx.commands
Human command registry"] + svc_tui["ctx.tui
Mounted-terminal interaction service"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] @@ -172,6 +173,7 @@ flowchart LR pkg_token_meter --> svc_tokenMeter pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools + pkg_tui --> svc_tui pkg_tui --> svc_userInteraction pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web @@ -277,6 +279,7 @@ flowchart LR | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | [`acp`](../packages/ui/acp) | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model. | +| `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4d2c803de0..d2b9e68ae1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1590,7 +1590,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:161`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:216`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0fcb2fc28e..5ac93a39de 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1614,6 +1614,29 @@ Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core- Source: [`packages/core/tools/src/index.ts:524`](../../packages/core/tools/src/index.ts) +## `ctx.tui` — `TuiExtensionService` (abstract seam) + +Optional terminal-local interaction service provided by one mounted TUI. + +The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugins receive only effect-owned overlay sessions. + +```ts cordis-catalog +/** + * Queue an interactive overlay owned by the calling plugin fiber. + * + * The TUI displays one overlay at a time in FIFO order. Disposing the caller + * removes a queued overlay or closes an active one before plugin teardown + * settles. This live presentation is neither logged nor replayed. + * + * @param request - component factory, layout constraints, and cancellation. + * @returns the effect-owned overlay session. + * @throws when the TUI has begun shutting down. + */ +abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession +``` + +Source: [`packages/ui/tui/src/index.ts:131`](../../packages/ui/tui/src/index.ts) + ## `ctx.userInteraction` — `UserInteractionService` `ctx.userInteraction`: one active UI provider plus an `ask()` surface. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3b9111f558..d8614a9e87 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -758,6 +758,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'tui', + summary: 'Optional terminal-local interaction service provided by one mounted TUI.', + methods: [ + { + signature: 'abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession', + jsDoc: '/**\n * Queue an interactive overlay owned by the calling plugin fiber.\n *\n * The TUI displays one overlay at a time in FIFO order. Disposing the caller\n * removes a queued overlay or closes an active one before plugin teardown\n * settles. This live presentation is neither logged nor replayed.\n *\n * @param request - component factory, layout constraints, and cancellation.\n * @returns the effect-owned overlay session.\n * @throws when the TUI has begun shutting down.\n */', + }, + ], + }, { key: 'userInteraction', summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.', @@ -2031,6 +2041,58 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolSchema', declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n}', }, + { + name: 'TuiComponent', + declaration: 'export interface TuiComponent {\n render(width: number): string[];\n handleInput?(data: string): void;\n wantsKeyRelease?: boolean;\n invalidate(): void;\n}', + }, + { + name: 'TuiFocusable', + declaration: 'export interface TuiFocusable {\n focused: boolean;\n}', + }, + { + name: 'TuiOverlayAnchor', + declaration: 'export type TuiOverlayAnchor = \'center\' | \'top-left\' | \'top-right\' | \'bottom-left\' | \'bottom-right\' | \'top-center\' | \'bottom-center\' | \'left-center\' | \'right-center\';', + }, + { + name: 'TuiOverlayCloseReason', + declaration: 'export type TuiOverlayCloseReason = \'closed\' | \'aborted\' | \'owner-disposed\' | \'tui-disposed\' | \'error\';', + }, + { + name: 'TuiOverlayHost', + declaration: 'export interface TuiOverlayHost {\n readonly signal: AbortSignal;\n readonly viewport: TuiViewport;\n readonly theme: TuiTheme;\n display(value: string): string;\n invalidate(): void;\n close(): void;\n}', + }, + { + name: 'TuiOverlayMargin', + declaration: 'export interface TuiOverlayMargin {\n readonly top?: number;\n readonly right?: number;\n readonly bottom?: number;\n readonly left?: number;\n}', + }, + { + name: 'TuiOverlayOptions', + declaration: 'export interface TuiOverlayOptions {\n readonly width?: number | `${number}%`;\n readonly minWidth?: number;\n readonly maxHeight?: number | `${number}%`;\n readonly anchor?: TuiOverlayAnchor;\n readonly margin?: number | TuiOverlayMargin;\n}', + }, + { + name: 'TuiOverlayOutcome', + declaration: 'export type TuiOverlayOutcome = {\n readonly reason: Exclude;\n} | {\n readonly reason: \'error\';\n readonly error: unknown;\n};', + }, + { + name: 'TuiOverlayRequest', + declaration: 'export interface TuiOverlayRequest {\n readonly create: (host: TuiOverlayHost) => TuiComponent & Partial;\n readonly options?: TuiOverlayOptions;\n readonly signal?: AbortSignal;\n}', + }, + { + name: 'TuiOverlaySession', + declaration: 'export interface TuiOverlaySession {\n readonly state: TuiOverlayState;\n readonly closed: Promise;\n close(): Promise;\n}', + }, + { + name: 'TuiOverlayState', + declaration: 'export type TuiOverlayState = \'queued\' | \'active\' | \'closed\';', + }, + { + name: 'TuiTheme', + declaration: 'export interface TuiTheme {\n readonly text: (value: string) => string;\n readonly muted: (value: string) => string;\n readonly dim: (value: string) => string;\n readonly accent: (value: string) => string;\n readonly success: (value: string) => string;\n readonly warning: (value: string) => string;\n readonly error: (value: string) => string;\n readonly bold: (value: string) => string;\n}', + }, + { + name: 'TuiViewport', + declaration: 'export interface TuiViewport {\n readonly columns: number;\n readonly rows: number;\n}', + }, { name: 'TurnEndReason', declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];', diff --git a/packages/ui/README.md b/packages/ui/README.md index 9c7a1f554c..f8e4704f20 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -10,11 +10,11 @@ Integrations that expose the agent to an external editor or client. These are ** | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | -| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) | +| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, answers `ctx.userInteraction`, and hosts effect-owned plugin overlays | `ctx.tui` (drives `ctx.agents`) | | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages. +A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door and supplies the terminal-local `ctx.tui` extension service; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 28d45f50d6..1ef9170576 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -8,6 +8,8 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. +After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. + The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. @@ -57,7 +59,7 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti maxToolOutputLines: 6 ``` -Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. +Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. ## Color diff --git a/packages/ui/tui/src/extension.ts b/packages/ui/tui/src/extension.ts new file mode 100644 index 0000000000..d6cb7bc4e4 --- /dev/null +++ b/packages/ui/tui/src/extension.ts @@ -0,0 +1,165 @@ +/** + * Public interactive-extension contract for one mounted TUI front door. + * + * Plugins receive terminal-specific rendering primitives without access to + * the live pi-tui tree, focus controller, overlay handles, or terminal + * lifecycle. Registrations and open overlays remain owned by the calling + * Cordis fiber. + * @module @deepseek-ai/dsh-tui/extension + */ + +/** Terminal component shape accepted from a trusted TUI extension. */ +export interface TuiComponent { + /** + * Render this component for the supplied viewport width. + * @param width - Available terminal columns. + * @returns terminal lines owned by this component. + */ + render(width: number): string[] + /** + * Handle one terminal input sequence while this component owns focus. + * @param data - Raw terminal input sequence. + */ + handleInput?(data: string): void + /** Receive key-release events instead of having them filtered by the host. */ + wantsKeyRelease?: boolean + /** Drop cached rendering derived from theme, size, or component state. */ + invalidate(): void +} + +/** Optional focus state forwarded by the host to a component. */ +export interface TuiFocusable { + /** Whether the component currently owns terminal focus. */ + focused: boolean +} + +/** Read-only semantic color roles supplied by the mounted TUI. */ +export interface TuiTheme { + /** Render ordinary foreground text. */ + readonly text: (value: string) => string + /** Render secondary information. */ + readonly muted: (value: string) => string + /** Render low-emphasis hints. */ + readonly dim: (value: string) => string + /** Render the active accent role. */ + readonly accent: (value: string) => string + /** Render a successful outcome. */ + readonly success: (value: string) => string + /** Render a warning. */ + readonly warning: (value: string) => string + /** Render an error. */ + readonly error: (value: string) => string + /** Apply the host's bold role. */ + readonly bold: (value: string) => string +} + +/** Current terminal viewport exposed without the mutable Terminal object. */ +export interface TuiViewport { + /** Terminal columns. */ + readonly columns: number + /** Terminal rows. */ + readonly rows: number +} + +/** Supported overlay anchor points. */ +export type TuiOverlayAnchor = + | 'center' + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right' + | 'top-center' + | 'bottom-center' + | 'left-center' + | 'right-center' + +/** Terminal-edge spacing for an overlay. */ +export interface TuiOverlayMargin { + /** Rows reserved above the overlay. */ + readonly top?: number + /** Columns reserved to the right of the overlay. */ + readonly right?: number + /** Rows reserved below the overlay. */ + readonly bottom?: number + /** Columns reserved to the left of the overlay. */ + readonly left?: number +} + +/** Position and size constraints retained under TUI host ownership. */ +export interface TuiOverlayOptions { + /** Width in columns or as a percentage of terminal width. */ + readonly width?: number | `${number}%` + /** Minimum width in columns. */ + readonly minWidth?: number + /** Maximum height in rows or as a percentage of terminal height. */ + readonly maxHeight?: number | `${number}%` + /** Overlay anchor; defaults to the terminal center. */ + readonly anchor?: TuiOverlayAnchor + /** Terminal-edge spacing. */ + readonly margin?: number | TuiOverlayMargin +} + +/** Capabilities available while an overlay component is queued or visible. */ +export interface TuiOverlayHost { + /** + * Aborts when the request, caller fiber, overlay session, or TUI closes. + * Extension work started for the overlay must cooperate with this signal. + */ + readonly signal: AbortSignal + /** Current viewport; a fresh immutable value is returned on every read. */ + readonly viewport: TuiViewport + /** Semantic styles that follow terminal color-scheme changes. */ + readonly theme: TuiTheme + /** + * Escape control characters in untrusted display text. + * @param value - text crossing into terminal presentation. + * @returns a printable representation that cannot emit terminal controls. + */ + display(value: string): string + /** Invalidate the component and schedule one contained terminal redraw. */ + invalidate(): void + /** Close this overlay normally; repeated calls are no-ops. */ + close(): void +} + +/** One effect-owned request to create an interactive overlay. */ +export interface TuiOverlayRequest { + /** + * Construct the component when this request reaches the front of the modal + * queue. A throw closes the session with `reason: "error"`. + */ + readonly create: (host: TuiOverlayHost) => TuiComponent & Partial + /** Host-owned position and size constraints. */ + readonly options?: TuiOverlayOptions + /** Optional request cancellation in addition to caller and TUI ownership. */ + readonly signal?: AbortSignal +} + +/** Stable reason an overlay stopped being queued or visible. */ +export type TuiOverlayCloseReason = + | 'closed' + | 'aborted' + | 'owner-disposed' + | 'tui-disposed' + | 'error' + +/** Settled overlay outcome; component failures retain their original value. */ +export type TuiOverlayOutcome = + | { readonly reason: Exclude } + | { readonly reason: 'error'; readonly error: unknown } + +/** Live state of an overlay operation. */ +export type TuiOverlayState = 'queued' | 'active' | 'closed' + +/** Handle returned to the extension that opened an overlay. */ +export interface TuiOverlaySession { + /** Current queue/display state. */ + readonly state: TuiOverlayState + /** Settles exactly once after the overlay leaves the queue or display. */ + readonly closed: Promise + /** + * Close the overlay normally and await its settled outcome. + * @returns the same immutable value exposed through {@link closed}. + */ + close(): Promise +} diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 4a16aa87d0..698879ab0a 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -31,13 +31,12 @@ import { type EditorTheme, type Focusable, type MarkdownTheme, - type OverlayHandle, type SelectListTheme, type SlashCommand, type Terminal, type TerminalColorScheme, } from '@earendil-works/pi-tui' -import type { Context } from 'cordis' +import { Service, type Context, type Fiber } from 'cordis' import z from 'schemastery' import { installAgentLlmTarget, @@ -90,6 +89,62 @@ import { type AskUserQuestionItem, type AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' +import { + TuiExtensionServiceImpl, + TuiOverlayManager, +} from './overlay-manager.ts' +import type { + TuiOverlayRequest, + TuiOverlaySession, + TuiTheme, +} from './extension.ts' + +export type { + TuiComponent, + TuiFocusable, + TuiOverlayAnchor, + TuiOverlayCloseReason, + TuiOverlayHost, + TuiOverlayMargin, + TuiOverlayOptions, + TuiOverlayOutcome, + TuiOverlayRequest, + TuiOverlaySession, + TuiOverlayState, + TuiTheme, + TuiViewport, +} from './extension.ts' + +declare module 'cordis' { + interface Context { + /** Terminal-only interaction service, available only while a TUI is mounted. */ + tui: TuiExtensionService + } +} + +/** + * Optional terminal-local interaction service provided by one mounted TUI. + * + * The concrete provider retains pi-tui, focus, and terminal lifecycle state. + * Plugins receive only effect-owned overlay sessions. + */ +export abstract class TuiExtensionService extends Service { + /** Exact agent driven by this terminal instance. */ + abstract readonly agent: Agent + + /** + * Queue an interactive overlay owned by the calling plugin fiber. + * + * The TUI displays one overlay at a time in FIFO order. Disposing the caller + * removes a queued overlay or closes an active one before plugin teardown + * settles. This live presentation is neither logged nor replayed. + * + * @param request - component factory, layout constraints, and cancellation. + * @returns the effect-owned overlay session. + * @throws when the TUI has begun shutting down. + */ + abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession +} export const name = 'ui-tui' export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] @@ -1290,7 +1345,7 @@ interface PendingQuestion { resolve(answer: AskUserQuestionAnswer): void reject(error: unknown): void onAbort: () => void - overlay: OverlayHandle | undefined + overlay: TuiOverlaySession | undefined } /** Add session candidates to pi-tui's existing command/file provider. */ @@ -1511,7 +1566,8 @@ export function createTuiChat( const commandControllers = new Set() const referenceControllers = new Set() let activeQuestion: PendingQuestion | undefined - let modelOverlay: OverlayHandle | undefined + let modelOverlay: TuiOverlaySession | undefined + let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } let contextWindow: number | undefined let contextResolution: Promise< @@ -1569,6 +1625,41 @@ export function createTuiChat( requestRender() } + const extensionTheme: TuiTheme = Object.freeze({ + text: (value: string) => palette.text(value), + muted: (value: string) => palette.muted(value), + dim: (value: string) => palette.dim(value), + accent: (value: string) => palette.accent(value), + success: (value: string) => palette.success(value), + warning: (value: string) => palette.warning(value), + error: (value: string) => palette.error(value), + bold: (value: string) => palette.bold(value), + }) + const overlayManager = new TuiOverlayManager({ + viewport: () => Object.freeze({ + columns: runtime.terminal.columns, + rows: runtime.terminal.rows, + }), + theme: () => extensionTheme, + display: displayText, + show: (component, options) => ui.showOverlay(component, options === undefined + ? undefined + : { + ...options, + ...typeof options.margin === 'object' + ? { margin: { ...options.margin } } + : {}, + }), + invalidate: requestRender, + reportError: (error) => { + const message = errorChain(error) + ctx.logger.warn(`ui-tui: overlay failed: ${message}`) + /* v8 ignore next -- shutdown removes overlays before the terminal stops */ + if (disposed) return + appendNotice(`TUI overlay failed: ${message}`, 'error') + }, + }) + const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target) const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { @@ -1608,29 +1699,29 @@ export function createTuiChat( appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') return } - modelOverlay?.hide() - modelOverlay = undefined - const close = (): void => { - modelOverlay?.hide() - modelOverlay = undefined - requestRender() - } - const dialog = new ModelDialog( - choices, - target.current, - resolved.maxModelOptions, - palette, - (selected) => { - close() - selectModel(selected) + void modelOverlay?.close() + const session = overlayManager.open({ + create: () => new ModelDialog( + choices, + target.current, + resolved.maxModelOptions, + palette, + (selected) => { + void session.close() + selectModel(selected) + }, + () => { void session.close() }, + ), + options: { + width: resolved.modelDialogWidth, + maxHeight: resolved.modelDialogMaxHeight, + anchor: 'center', + margin: 1, }, - close, - ) - modelOverlay = ui.showOverlay(dialog, { - width: resolved.modelDialogWidth, - maxHeight: resolved.modelDialogMaxHeight, - anchor: 'center', - margin: 1, + }) + modelOverlay = session + void session.closed.then(() => { + if (modelOverlay === session) modelOverlay = undefined }) requestRender() } @@ -1933,7 +2024,7 @@ export function createTuiChat( } const rejectQuestion = (pending: PendingQuestion): void => { - pending.overlay?.hide() + void pending.overlay?.close() pending.overlay = undefined removeAbortListener(pending) pending.reject(new UserInteractionError( @@ -1956,31 +2047,48 @@ export function createTuiChat( startNextQuestion() return } - const dialog = new QuestionDialog( - question, - pending.index + 1, - pending.request.questions.length, - pending.request.questions.length - pending.answers.length, - resolved.maxQuestionOptions, - palette, - (selection) => { - pending.overlay?.hide() - pending.overlay = undefined - pending.answers.push({ id: question.id, ...selection }) - pending.index += 1 - show() + const session = overlayManager.open({ + ...pending.request.signal === undefined ? {} : { signal: pending.request.signal }, + create: () => new QuestionDialog( + question, + pending.index + 1, + pending.request.questions.length, + pending.request.questions.length - pending.answers.length, + resolved.maxQuestionOptions, + palette, + (selection) => { + pending.overlay = undefined + void session.close() + pending.answers.push({ id: question.id, ...selection }) + pending.index += 1 + show() + }, + () => { + activeQuestion = undefined + rejectQuestion(pending) + startNextQuestion() + }, + ), + options: { + width: resolved.questionDialogWidth, + maxHeight: resolved.questionDialogMaxHeight, + anchor: 'bottom-left', + margin: { bottom: 1 }, }, - () => { - activeQuestion = undefined - rejectQuestion(pending) - startNextQuestion() - }, - ) - pending.overlay = ui.showOverlay(dialog, { - width: resolved.questionDialogWidth, - maxHeight: resolved.questionDialogMaxHeight, - anchor: 'bottom-left', - margin: { bottom: 1 }, + }) + pending.overlay = session + void session.closed.then((result) => { + if (pending.overlay !== session) return + pending.overlay = undefined + /* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */ + if (result.reason !== 'error') return + activeQuestion = undefined + removeAbortListener(pending) + pending.reject(new UserInteractionError( + `ask_user_question TUI failed: ${errorChain(result.error)}`, + 'ASK_ABORTED', + )) + startNextQuestion() }) requestRender() } @@ -2051,20 +2159,23 @@ export function createTuiChat( const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true + overlayManager.beginShutdown() contextResolution = undefined clearStatus() - modelOverlay?.hide() - modelOverlay = undefined for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) commandControllers.clear() for (const controller of referenceControllers) controller.abort(new Error('TUI disposed')) referenceControllers.clear() + await tuiServiceFiber?.dispose() + tuiServiceFiber = undefined if (activeQuestion !== undefined) { const pending = activeQuestion activeQuestion = undefined rejectQuestion(pending) } for (const pending of questionQueue.splice(0)) rejectQuestion(pending) + await overlayManager.dispose() + modelOverlay = undefined disposeUserInteraction() await runtime.terminal.drainInput(100, 20) ui.stop() @@ -2510,7 +2621,7 @@ export function createTuiChat( } const removeInputListener = ui.addInputListener((data) => { - if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined + if (overlayManager.hasActiveOverlay()) return undefined if (matchesKey(data, Key.ctrl('o'))) { toggleTools() return { consume: true } @@ -2654,6 +2765,9 @@ export function createTuiChat( ui.stop() throw error } + tuiServiceFiber = ctx.inject([], (serviceCtx) => { + new TuiExtensionServiceImpl(serviceCtx, agent, overlayManager) + }) startBannerReveal() return { diff --git a/packages/ui/tui/src/overlay-manager.ts b/packages/ui/tui/src/overlay-manager.ts new file mode 100644 index 0000000000..643f344a57 --- /dev/null +++ b/packages/ui/tui/src/overlay-manager.ts @@ -0,0 +1,353 @@ +/** + * Private bridge between the public TUI extension contract and pi-tui. + * + * The manager serializes modal ownership, guards extension callbacks, and + * settles every queued or active operation before terminal teardown. + * @module @deepseek-ai/dsh-tui/overlay-manager + */ + +import { Service, type Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { TuiExtensionService } from './index.ts' +import type { + Component, + Focusable, + OverlayHandle, +} from '@earendil-works/pi-tui' +import type { + TuiComponent, + TuiFocusable, + TuiOverlayCloseReason, + TuiOverlayHost, + TuiOverlayOutcome, + TuiOverlayOptions, + TuiOverlayRequest, + TuiOverlaySession, + TuiOverlayState, + TuiTheme, + TuiViewport, +} from './extension.ts' + +/** pi-tui operations retained by the front door instead of exposed to plugins. */ +export interface TuiOverlayDriver { + /** Current terminal viewport. */ + viewport(): TuiViewport + /** Current semantic theme facade. */ + theme(): TuiTheme + /** Escape text at the terminal display boundary. */ + display(value: string): string + /** Mount one guarded component and return its private pi-tui handle. */ + show(component: Component, options: TuiOverlayOptions | undefined): OverlayHandle + /** Invalidate the mounted UI and request a render. */ + invalidate(): void + /** Report a contained extension failure. */ + reportError(error: unknown): void +} + +interface OverlayEntry { + readonly request: TuiOverlayRequest + readonly controller: AbortController + readonly signal: AbortSignal + readonly closed: Promise + readonly resolveClosed: (outcome: TuiOverlayOutcome) => void + readonly session: TuiOverlaySession + state: TuiOverlayState + handle?: OverlayHandle + removeRequestAbort?: () => void + outcome?: TuiOverlayOutcome + failing?: boolean +} + +/** Turn a close reason into its immutable public outcome. */ +function outcome(reason: Exclude): TuiOverlayOutcome { + return Object.freeze({ reason }) +} + +/** Retain only supported layout fields before a queued request returns to its caller. */ +function retainOptions(options: TuiOverlayOptions): TuiOverlayOptions { + return Object.freeze({ + ...options.width === undefined ? {} : { width: options.width }, + ...options.minWidth === undefined ? {} : { minWidth: options.minWidth }, + ...options.maxHeight === undefined ? {} : { maxHeight: options.maxHeight }, + ...options.anchor === undefined ? {} : { anchor: options.anchor }, + ...options.margin === undefined + ? {} + : { + margin: typeof options.margin === 'object' + ? Object.freeze({ ...options.margin }) + : options.margin, + }, + }) +} + +/** Guard plugin component methods while preserving focus and key-release state. */ +class GuardedOverlayComponent implements Component, Focusable { + constructor( + private readonly component: TuiComponent & Partial, + private readonly fail: (error: unknown) => void, + ) {} + + get focused(): boolean { + try { + return this.component.focused ?? false + } catch (error) { + this.fail(error) + return false + } + } + + set focused(value: boolean) { + try { + if ('focused' in this.component) this.component.focused = value + } catch (error) { + this.fail(error) + } + } + + get wantsKeyRelease(): boolean { + try { + return this.component.wantsKeyRelease ?? false + } catch (error) { + this.fail(error) + return false + } + } + + render(width: number): string[] { + try { + return this.component.render(width) + } catch (error) { + this.fail(error) + return [] + } + } + + handleInput(data: string): void { + try { + this.component.handleInput?.(data) + } catch (error) { + this.fail(error) + } + } + + invalidate(): void { + try { + this.component.invalidate() + } catch (error) { + this.fail(error) + } + } +} + +/** FIFO modal owner for one mounted TUI. */ +export class TuiOverlayManager { + private readonly queue: OverlayEntry[] = [] + private active: OverlayEntry | undefined + private accepting = true + private disposeTask: Promise | undefined + + constructor(private readonly driver: TuiOverlayDriver) {} + + /** + * Whether one extension or built-in overlay currently owns terminal focus. + * @returns `true` while an overlay is active. + */ + hasActiveOverlay(): boolean { + return this.active !== undefined + } + + /** Reject new work while the TUI unloads dependent extension fibers. */ + beginShutdown(): void { + this.accepting = false + } + + /** + * Queue one overlay without assigning Cordis ownership. + * @param request - component factory, constraints, and request signal. + * @returns an internal session that can close with an ownership reason. + */ + open(request: TuiOverlayRequest): TuiOverlaySession & { + closeWith(reason: Exclude): Promise + } { + if (!this.accepting) throw new Error('TUI is shutting down') + const requestSignal = request.signal + const retainedRequest: TuiOverlayRequest = Object.freeze({ + create: request.create, + ...request.options === undefined ? {} : { options: retainOptions(request.options) }, + ...requestSignal === undefined ? {} : { signal: requestSignal }, + }) + const controller = new AbortController() + const signal = requestSignal === undefined + ? controller.signal + : AbortSignal.any([requestSignal, controller.signal]) + const deferred = Promise.withResolvers() + const session: TuiOverlaySession & { + closeWith(reason: Exclude): Promise + } = { + get state(): TuiOverlayState { + return entry.state + }, + closed: deferred.promise, + close: () => this.close(entry, outcome('closed')), + closeWith: (reason: Exclude) => + this.close(entry, outcome(reason)), + } + const entry: OverlayEntry = { + request: retainedRequest, + controller, + signal, + closed: deferred.promise, + resolveClosed: deferred.resolve, + session, + state: 'queued', + } + if (requestSignal?.aborted === true) { + void this.close(entry, outcome('aborted')) + return session + } + if (requestSignal !== undefined) { + const onAbort = (): void => { void this.close(entry, outcome('aborted')) } + requestSignal.addEventListener('abort', onAbort, { once: true }) + entry.removeRequestAbort = () => { requestSignal.removeEventListener('abort', onAbort) } + } + this.queue.push(entry) + this.activateNext() + return session + } + + /** Stop accepting work and settle every active or queued overlay. */ + dispose(): Promise { + if (this.disposeTask !== undefined) return this.disposeTask + this.beginShutdown() + const entries = [ + ...this.active === undefined ? [] : [this.active], + ...this.queue, + ] + return this.disposeTask = Promise.all( + entries.map(entry => this.close(entry, outcome('tui-disposed'))), + ).then(() => {}) + } + + private activateNext(): void { + if (!this.accepting || this.active !== undefined) return + const entry = this.queue.shift() + if (entry === undefined) return + this.active = entry + entry.state = 'active' + const host = this.host(entry) + let component: TuiComponent & Partial + try { + component = entry.request.create(host) + } catch (error) { + this.fail(entry, error) + return + } + const guarded = new GuardedOverlayComponent(component, (error) => { + this.fail(entry, error) + }) + try { + entry.handle = this.driver.show(guarded, entry.request.options) + this.driver.invalidate() + } catch (error) { + this.fail(entry, error) + } + } + + private host(entry: OverlayEntry): TuiOverlayHost { + const driver = this.driver + return Object.freeze({ + get signal(): AbortSignal { + return entry.signal + }, + get viewport(): TuiViewport { + return Object.freeze({ ...driver.viewport() }) + }, + get theme(): TuiTheme { + return driver.theme() + }, + display: (value: string) => this.driver.display(value), + invalidate: () => { + if (entry.state !== 'active') return + try { + this.driver.invalidate() + } catch (error) { + this.fail(entry, error) + } + }, + close: () => { void this.close(entry, outcome('closed')) }, + }) + } + + private fail(entry: OverlayEntry, error: unknown): void { + if (entry.state === 'closed' || entry.failing === true) return + entry.failing = true + this.report(error) + queueMicrotask(() => { + void this.close(entry, Object.freeze({ reason: 'error', error })) + }) + } + + private report(error: unknown): void { + try { + this.driver.reportError(error) + } catch { + // Error reporting is a containment boundary, never a second failure path. + } + } + + private close(entry: OverlayEntry, result: TuiOverlayOutcome): Promise { + if (entry.outcome !== undefined) return entry.closed + entry.outcome = result + entry.state = 'closed' + entry.removeRequestAbort?.() + delete entry.removeRequestAbort + if (!entry.controller.signal.aborted) entry.controller.abort(result) + const queuedIndex = this.queue.indexOf(entry) + if (queuedIndex >= 0) this.queue.splice(queuedIndex, 1) + if (this.active === entry) { + this.active = undefined + try { + entry.handle?.hide() + } catch (error) { + this.report(error) + } + delete entry.handle + } + entry.resolveClosed(result) + try { + this.driver.invalidate() + } catch (error) { + this.report(error) + } + queueMicrotask(() => { this.activateNext() }) + return entry.closed + } +} + +/** Cordis service whose method effects bind to the calling plugin fiber. */ +export class TuiExtensionServiceImpl extends Service implements TuiExtensionService { + constructor( + ctx: Context, + readonly agent: Agent, + private readonly overlays: TuiOverlayManager, + ) { + super(ctx, 'tui') + } + + /** @inheritdoc */ + openOverlay(request: TuiOverlayRequest): TuiOverlaySession { + let operation: ReturnType | undefined + const disposeOwner = this.ctx.effect( + () => () => operation?.closeWith('owner-disposed'), + 'tui.openOverlay()', + ) + try { + operation = this.overlays.open(request) + } catch (error) { + void disposeOwner() + throw error + } + void operation.closed.then(() => { void disposeOwner() }) + return operation + } +} diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts new file mode 100644 index 0000000000..84248e3648 --- /dev/null +++ b/packages/ui/tui/tests/extension.spec.ts @@ -0,0 +1,518 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { + Component, + OverlayHandle, +} from '@earendil-works/pi-tui' +import type { + TuiComponent, + TuiOverlayHost, + TuiOverlayOptions, + TuiOverlaySession, + TuiTheme, +} from '../src/extension.ts' +import { + TuiExtensionServiceImpl, + TuiOverlayManager, + type TuiOverlayDriver, +} from '../src/overlay-manager.ts' + +const theme: TuiTheme = Object.freeze({ + text: (value: string) => `text:${value}`, + muted: (value: string) => `muted:${value}`, + dim: (value: string) => `dim:${value}`, + accent: (value: string) => `accent:${value}`, + success: (value: string) => `success:${value}`, + warning: (value: string) => `warning:${value}`, + error: (value: string) => `error:${value}`, + bold: (value: string) => `bold:${value}`, +}) + +interface ShownOverlay { + component: Component + options: TuiOverlayOptions | undefined + hidden: boolean + focused: boolean +} + +interface DriverFixture { + driver: TuiOverlayDriver + shown: ShownOverlay[] + errors: unknown[] + invalidations: number + showError?: unknown +} + +function driverFixture(): DriverFixture { + const fixture: DriverFixture = { + shown: [], + errors: [], + invalidations: 0, + driver: undefined as never, + } + fixture.driver = { + viewport: () => ({ columns: 96, rows: 32 }), + theme: () => theme, + display: value => `safe:${value}`, + show(component, options) { + if (fixture.showError !== undefined) throw fixture.showError + const shown: ShownOverlay = { + component, + options, + hidden: false, + focused: true, + } + fixture.shown.push(shown) + const handle: OverlayHandle = { + hide() { + shown.hidden = true + shown.focused = false + }, + setHidden(hidden) { + shown.hidden = hidden + }, + isHidden: () => shown.hidden, + focus() { + shown.focused = true + }, + unfocus() { + shown.focused = false + }, + isFocused: () => shown.focused, + } + return handle + }, + invalidate() { + fixture.invalidations += 1 + }, + reportError(error) { + fixture.errors.push(error) + }, + } + return fixture +} + +function component(lines = ['overlay']): TuiComponent { + return { + render: () => lines, + invalidate() {}, + } +} + +async function microtask(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('TuiOverlayManager', () => { + it('serializes overlays, exposes the constrained host, and settles normal close once', async () => { + const fixture = driverFixture() + const manager = new TuiOverlayManager(fixture.driver) + let firstHost: TuiOverlayHost | undefined + const firstComponent = { + focused: false, + wantsKeyRelease: true, + inputs: [] as string[], + invalidated: 0, + render: (width: number) => [`first:${String(width)}`], + handleInput(data: string) { + this.inputs.push(data) + }, + invalidate() { + this.invalidated += 1 + }, + } + const first = manager.open({ + create(host) { + firstHost = host + return firstComponent + }, + options: { width: '75%', minWidth: 24, maxHeight: 20, anchor: 'center', margin: { bottom: 1 } }, + }) + const secondOptions: TuiOverlayOptions = { width: 40, margin: { bottom: 2 } } + const second = manager.open({ + create: () => component(['second']), + options: secondOptions, + }) + ;(secondOptions as { width: number }).width = 80 + ;(secondOptions.margin as { bottom: number }).bottom = 4 + + expect(manager.hasActiveOverlay()).toBe(true) + expect(first.state).toBe('active') + expect(second.state).toBe('queued') + expect(fixture.shown).toHaveLength(1) + expect(fixture.shown[0]?.options).toEqual({ + width: '75%', + minWidth: 24, + maxHeight: 20, + anchor: 'center', + margin: { bottom: 1 }, + }) + expect(firstHost?.viewport).toEqual({ columns: 96, rows: 32 }) + expect(Object.isFrozen(firstHost?.viewport)).toBe(true) + expect(firstHost?.theme.accent('x')).toBe('accent:x') + expect(firstHost?.display('\u001b')).toBe('safe:\u001b') + firstHost?.invalidate() + expect(fixture.shown[0]?.component.render(40)).toEqual(['first:40']) + fixture.shown[0]!.component.handleInput?.('x') + fixture.shown[0]!.component.invalidate() + expect(firstComponent.inputs).toEqual(['x']) + expect(firstComponent.invalidated).toBe(1) + expect(fixture.shown[0]?.component.wantsKeyRelease).toBe(true) + ;(fixture.shown[0]?.component as Component & { focused: boolean }).focused = true + expect(firstComponent.focused).toBe(true) + expect((fixture.shown[0]?.component as Component & { focused: boolean }).focused).toBe(true) + + const firstOutcome = await first.close() + expect(firstOutcome).toEqual({ reason: 'closed' }) + expect(await first.close()).toBe(firstOutcome) + expect(firstHost?.signal.aborted).toBe(true) + const beforeClosedInvalidation = fixture.invalidations + firstHost?.invalidate() + expect(fixture.invalidations).toBe(beforeClosedInvalidation) + await microtask() + + expect(first.state).toBe('closed') + expect(second.state).toBe('active') + expect(fixture.shown[0]?.hidden).toBe(true) + expect(fixture.shown[1]?.options).toEqual({ width: 40, margin: { bottom: 2 } }) + expect(Object.isFrozen(fixture.shown[1]?.options)).toBe(true) + expect(Object.isFrozen(fixture.shown[1]?.options?.margin)).toBe(true) + expect(fixture.shown[1]?.component.wantsKeyRelease).toBe(false) + expect((fixture.shown[1]?.component as Component & { focused: boolean }).focused).toBe(false) + ;(fixture.shown[1]?.component as Component & { focused: boolean }).focused = true + fixture.shown[1]!.component.handleInput?.('ignored') + await second.close() + await microtask() + + const numericMargin = manager.open({ + create: () => component(['numeric margin']), + options: { margin: 1 }, + }) + expect(fixture.shown[2]?.options).toEqual({ margin: 1 }) + await numericMargin.close() + await microtask() + + const emptyOptions = manager.open({ + create: () => component(['empty options']), + options: {}, + }) + expect(fixture.shown[3]?.options).toEqual({}) + await emptyOptions.close() + await microtask() + + expect(manager.hasActiveOverlay()).toBe(false) + await manager.dispose() + await manager.dispose() + }) + + it('removes pre-aborted, active, and queued requests without activating cancelled work', async () => { + const fixture = driverFixture() + const manager = new TuiOverlayManager(fixture.driver) + const preAborted = new AbortController() + preAborted.abort() + const pre = manager.open({ + signal: preAborted.signal, + create: () => component(['never']), + }) + expect(await pre.closed).toEqual({ reason: 'aborted' }) + expect(fixture.shown).toHaveLength(0) + + const activeAbort = new AbortController() + let activeHost: TuiOverlayHost | undefined + const active = manager.open({ + signal: activeAbort.signal, + create(host) { + activeHost = host + return component(['active']) + }, + }) + const queuedAbort = new AbortController() + const queued = manager.open({ + signal: queuedAbort.signal, + create: () => component(['queued']), + }) + queuedAbort.abort() + expect(await queued.closed).toEqual({ reason: 'aborted' }) + expect(queued.state).toBe('closed') + activeAbort.abort() + expect(await active.closed).toEqual({ reason: 'aborted' }) + expect(activeHost?.signal.aborted).toBe(true) + await microtask() + expect(fixture.shown).toHaveLength(1) + expect(manager.hasActiveOverlay()).toBe(false) + }) + + it('stops admission and disposes active and queued overlays with the TUI', async () => { + const fixture = driverFixture() + const manager = new TuiOverlayManager(fixture.driver) + const active = manager.open({ create: () => component(['active']) }) + const queued = manager.open({ create: () => component(['queued']) }) + manager.beginShutdown() + expect(() => manager.open({ create: () => component() })).toThrow('TUI is shutting down') + await manager.dispose() + expect(await active.closed).toEqual({ reason: 'tui-disposed' }) + expect(await queued.closed).toEqual({ reason: 'tui-disposed' }) + expect(fixture.shown).toHaveLength(1) + expect(fixture.shown[0]?.hidden).toBe(true) + await manager.dispose() + }) + + it('contains factory, mount, render, input, and invalidation failures', async () => { + const fixture = driverFixture() + const manager = new TuiOverlayManager(fixture.driver) + const factoryError = new Error('factory failed') + const factory = manager.open({ + create() { + throw factoryError + }, + }) + const afterFactory = manager.open({ create: () => component(['after factory']) }) + expect(await factory.closed).toEqual({ reason: 'error', error: factoryError }) + await microtask() + expect(afterFactory.state).toBe('active') + await afterFactory.close() + await microtask() + + const showError = new Error('show failed') + fixture.showError = showError + const show = manager.open({ create: () => component(['show']) }) + expect(await show.closed).toEqual({ reason: 'error', error: showError }) + delete fixture.showError + await microtask() + + const renderError = new Error('render failed') + const rendering = manager.open({ + create: () => ({ + render() { + throw renderError + }, + invalidate() { + throw new Error('must be suppressed after the first failure') + }, + }), + }) + const renderComponent = fixture.shown.at(-1)!.component + expect(renderComponent.render(20)).toEqual([]) + renderComponent.invalidate() + expect(fixture.errors.filter(error => error === renderError)).toHaveLength(1) + expect(await rendering.closed).toEqual({ reason: 'error', error: renderError }) + await microtask() + + const inputError = new Error('input failed') + const input = manager.open({ + create: () => ({ + render: () => ['input'], + handleInput() { + throw inputError + }, + invalidate() {}, + }), + }) + fixture.shown.at(-1)!.component.handleInput?.('x') + expect(await input.closed).toEqual({ reason: 'error', error: inputError }) + await microtask() + + const invalidateError = new Error('invalidate failed') + const invalidating = manager.open({ + create: () => ({ + render: () => ['invalidate'], + invalidate() { + throw invalidateError + }, + }), + }) + fixture.shown.at(-1)!.component.invalidate() + expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidateError }) + await microtask() + + const focusError = new Error('focus failed') + const focus = manager.open({ + create: () => ({ + get focused(): boolean { + throw focusError + }, + set focused(_value: boolean) { + throw new Error('focus assignment failed') + }, + get wantsKeyRelease(): boolean { + throw new Error('key-release query failed') + }, + render: () => ['focus'], + invalidate() {}, + }), + }) + const guarded = fixture.shown.at(-1)!.component as Component & { focused: boolean } + expect(guarded.focused).toBe(false) + guarded.focused = true + expect(guarded.wantsKeyRelease).toBe(false) + expect(await focus.closed).toEqual({ reason: 'error', error: focusError }) + expect(fixture.errors).toEqual([ + factoryError, + showError, + renderError, + inputError, + invalidateError, + focusError, + ]) + }) + + it('contains host redraw, overlay removal, and error-reporter failures', async () => { + const fixture = driverFixture() + const manager = new TuiOverlayManager(fixture.driver) + let host: TuiOverlayHost | undefined + const invalidationError = new Error('redraw failed') + let redrawFails = false + fixture.driver.invalidate = () => { + if (redrawFails) throw invalidationError + } + fixture.driver.reportError = () => { throw new Error('report failed') } + const invalidating = manager.open({ + create(value) { + host = value + return component() + }, + }) + redrawFails = true + host?.invalidate() + expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidationError }) + await microtask() + + redrawFails = false + fixture.driver.invalidate = () => {} + const hideError = new Error('hide failed') + fixture.driver.show = () => ({ + hide() { throw hideError }, + setHidden() {}, + isHidden: () => false, + focus() {}, + unfocus() {}, + isFocused: () => true, + }) + const hiding = manager.open({ + create(value) { + host = value + return component() + }, + }) + host?.close() + expect(await hiding.closed).toEqual({ reason: 'closed' }) + }) +}) + +describe('TuiExtensionService', () => { + it('binds an open overlay to the calling plugin fiber', async () => { + const ctx = new Context() + const fixture = driverFixture() + const manager = new TuiOverlayManager(fixture.driver) + const agent = {} as Agent + const provider = ctx.plugin((providerCtx) => { + new TuiExtensionServiceImpl(providerCtx, agent, manager) + }) + await provider + let session: TuiOverlaySession | undefined + let host: TuiOverlayHost | undefined + const consumer = ctx.inject(['tui'], (consumerCtx) => { + expect(consumerCtx.tui.agent).toBe(agent) + session = consumerCtx.tui.openOverlay({ + create(value) { + host = value + return component(['plugin']) + }, + }) + }) + await consumer + expect(session?.state).toBe('active') + + await consumer.dispose() + expect(await session?.closed).toEqual({ reason: 'owner-disposed' }) + expect(host?.signal.aborted).toBe(true) + await provider.dispose() + await manager.dispose() + await ctx.fiber.dispose() + }) + + it('unloads and reloads dependent plugins with the mounted TUI service', async () => { + const ctx = new Context() + const agent = {} as Agent + const sessions: TuiOverlaySession[] = [] + let starts = 0 + const consumer = ctx.inject(['tui'], (consumerCtx) => { + starts += 1 + sessions.push(consumerCtx.tui.openOverlay({ create: () => component([`start:${String(starts)}`]) })) + }) + + const firstFixture = driverFixture() + const firstManager = new TuiOverlayManager(firstFixture.driver) + const firstProvider = ctx.plugin((providerCtx) => { + new TuiExtensionServiceImpl(providerCtx, agent, firstManager) + }) + await firstProvider + await consumer + expect(starts).toBe(1) + await firstProvider.dispose() + expect(await sessions[0]?.closed).toEqual({ reason: 'owner-disposed' }) + + const secondFixture = driverFixture() + const secondManager = new TuiOverlayManager(secondFixture.driver) + const secondProvider = ctx.plugin((providerCtx) => { + new TuiExtensionServiceImpl(providerCtx, agent, secondManager) + }) + await secondProvider + await vi.waitFor(() => { expect(starts).toBe(2) }) + await sessions[1]?.close() + await consumer.dispose() + await secondProvider.dispose() + await firstManager.dispose() + await secondManager.dispose() + await ctx.fiber.dispose() + }) + + it('rejects new service work after terminal shutdown begins', async () => { + const ctx = new Context() + const fixture = driverFixture() + const manager = new TuiOverlayManager(fixture.driver) + const provider = ctx.plugin((providerCtx) => { + new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager) + }) + await provider + manager.beginShutdown() + const consumer = ctx.inject(['tui'], (consumerCtx) => { + expect(() => consumerCtx.tui.openOverlay({ create: () => component() })) + .toThrow('TUI is shutting down') + }) + await consumer + await consumer.dispose() + await provider.dispose() + await manager.dispose() + await ctx.fiber.dispose() + }) + + it('does not admit an overlay when called from an unloading plugin', async () => { + const ctx = new Context() + const fixture = driverFixture() + const manager = new TuiOverlayManager(fixture.driver) + const provider = ctx.plugin((providerCtx) => { + new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager) + }) + await provider + let error: unknown + const consumer = ctx.inject(['tui'], (consumerCtx) => { + consumerCtx.effect(() => () => { + try { + consumerCtx.tui.openOverlay({ create: () => component() }) + } catch (value) { + error = value + } + }) + }) + await consumer + await consumer.dispose() + expect(error).toMatchObject({ code: 'INACTIVE_EFFECT' }) + expect(fixture.shown).toHaveLength(0) + await provider.dispose() + await manager.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 22a50c9ccf..e7d87211c9 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -19,6 +19,8 @@ import { mountTui, renderSkillInvocation, resolveTuiConfig, + type TuiOverlayHost, + type TuiOverlaySession, type TuiRuntime, } from '../src/index.ts' import { @@ -1379,6 +1381,15 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('advertised by multiple providers') expect(result.terminal.output).toContain('already alpha/a1') + result.terminal.send('/model') + result.terminal.send('\r') + result.terminal.send('/model') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Select model') + result.terminal.send('\x1b') + await tick() + result.agent.status = 'running' result.terminal.send('/model') result.terminal.send('\r') @@ -2193,6 +2204,141 @@ describe('TUI user-interaction dialogs', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) await result.ctx.fiber.dispose() }) + + it('rejects malformed questions when a dialog cannot be constructed', async () => { + const result = await setup() + const broken = { + id: 'broken', + question: 'Broken question', + get options(): never { + throw new Error('question setup failed') + }, + } + const answer = result.ctx.userInteraction.ask({ questions: [broken] }) + await expect(answer).rejects.toThrow('ask_user_question TUI failed: question setup failed') + await tick() + expect(result.terminal.output).toContain('TUI overlay failed: question setup failed') + await dispose(result) + }) +}) + +describe('TUI extension service', () => { + it('renders effect-owned plugin overlays in the shared FIFO and restores editor input', async () => { + const result = await setup() + const sessions: TuiOverlaySession[] = [] + const hosts: TuiOverlayHost[] = [] + const plugin = result.ctx.inject(['tui'], (pluginCtx) => { + expect(pluginCtx.tui.agent).toBe(result.agent) + for (const label of ['first', 'second']) { + sessions.push(pluginCtx.tui.openOverlay({ + create(host) { + hosts.push(host) + return { + focused: false, + render: width => [ + host.theme.accent(`${label} plugin overlay`), + [ + host.theme.text('text'), + host.theme.muted('muted'), + host.theme.dim('dim'), + host.theme.success('success'), + host.theme.warning('warning'), + host.theme.error('error'), + host.theme.bold('bold'), + ].join(' '), + `${String(host.viewport.columns)}x${String(host.viewport.rows)} · ${String(width)}`, + ], + handleInput(data) { + host.invalidate() + if (data === label[0]) host.close() + }, + invalidate() {}, + } + }, + options: { width: 50, maxHeight: 8, anchor: 'center', margin: 1 }, + })) + } + }) + await plugin + await vi.waitFor(() => { + expect(result.terminal.output).toContain('first plugin overlay') + }) + expect(sessions.map(session => session.state)).toEqual(['active', 'queued']) + expect(hosts).toHaveLength(1) + + const question = result.ctx.userInteraction.ask({ + questions: [{ id: 'after-plugin', question: 'Question after plugins?', options: [{ label: 'Yes' }] }], + }) + result.terminal.send('f') + await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'closed' }) + await vi.waitFor(() => { + expect(result.terminal.output).toContain('second plugin overlay') + }) + expect(hosts).toHaveLength(2) + expect(sessions[1]?.state).toBe('active') + + result.terminal.send('s') + await expect(sessions[1]!.closed).resolves.toEqual({ reason: 'closed' }) + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Question after plugins?') + }) + result.terminal.send('\r') + await expect(question).resolves.toEqual({ + answers: [{ id: 'after-plugin', selected: ['Yes'] }], + }) + + result.terminal.send('editor works again') + result.terminal.send('\r') + expect(result.agent.sent.at(-1)).toEqual([{ type: 'text', text: 'editor works again' }]) + await plugin.dispose() + await dispose(result) + }) + + it('unloads and reloads dependent plugins with the mounted TUI', async () => { + const result = await setup() + const sessions: TuiOverlaySession[] = [] + const signals: AbortSignal[] = [] + let starts = 0 + const plugin = result.ctx.inject(['tui'], (pluginCtx) => { + starts += 1 + sessions.push(pluginCtx.tui.openOverlay({ + create(host) { + signals.push(host.signal) + return { + render: () => [`plugin mount ${String(starts)}`], + invalidate() {}, + } + }, + })) + }) + await plugin + await vi.waitFor(() => { + expect(result.terminal.output).toContain('plugin mount 1') + }) + + await result.controller.dispose() + await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'owner-disposed' }) + expect(signals[0]?.aborted).toBe(true) + expect(result.ctx.get('tui')).toBeUndefined() + + const secondTerminal = new FakeTerminal() + const secondController = createTuiChat(result.ctx, { + sessionId: result.agent.id, + color: false, + welcome: 'Mounted again.', + }, { + terminal: secondTerminal, + exit: vi.fn(), + }) + await vi.waitFor(() => { + expect(starts).toBe(2) + expect(secondTerminal.output).toContain('plugin mount 2') + }) + await sessions[1]?.close() + await secondController.dispose() + await plugin.dispose() + await result.ctx.fiber.dispose() + }) }) describe('terminal mounting', () => { @@ -2355,6 +2501,7 @@ describe('terminal mounting', () => { expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([]) expect(terminal.stopped).toBe(1) expect(terminal.progress).toEqual([false, true, false]) + expect(ctx.get('tui')).toBeUndefined() await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] })) .rejects.toMatchObject({ code: 'NO_PROVIDER' }) session.append('assistant/chunk', { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 40f1a22377..6643e90e39 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -198,6 +198,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', + TuiOverlayRequest: 'service-local extension contract is owned by packages/ui/tui/README.md', + TuiOverlaySession: 'service-local extension contract is owned by packages/ui/tui/README.md', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c93f70cf81..f8806e303e 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -197,6 +197,13 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tui', 'acp'], note: 'Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model.', }, + { + key: 'tui', + pkg: 'tui', + title: 'Mounted-terminal interaction service', + mode: 'bundle', + note: 'One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state.', + }, { key: 'skills', pkg: 'skill', From b64c3eb13fa860e6377215526035d643169bb27f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:00:36 -0700 Subject: [PATCH 09/32] fix(tui): contain overlay reentrancy --- packages/ui/tui/src/overlay-manager.ts | 32 +++++++--- packages/ui/tui/tests/extension.spec.ts | 85 ++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/packages/ui/tui/src/overlay-manager.ts b/packages/ui/tui/src/overlay-manager.ts index 643f344a57..efe8716777 100644 --- a/packages/ui/tui/src/overlay-manager.ts +++ b/packages/ui/tui/src/overlay-manager.ts @@ -52,6 +52,7 @@ interface OverlayEntry { readonly resolveClosed: (outcome: TuiOverlayOutcome) => void readonly session: TuiOverlaySession state: TuiOverlayState + component?: GuardedOverlayComponent handle?: OverlayHandle removeRequestAbort?: () => void outcome?: TuiOverlayOutcome @@ -130,11 +131,13 @@ class GuardedOverlayComponent implements Component, Focusable { } } - invalidate(): void { + invalidate(): boolean { try { this.component.invalidate() + return true } catch (error) { this.fail(error) + return false } } } @@ -242,11 +245,18 @@ export class TuiOverlayManager { this.fail(entry, error) return } + if (this.active !== entry) return const guarded = new GuardedOverlayComponent(component, (error) => { this.fail(entry, error) }) + entry.component = guarded try { - entry.handle = this.driver.show(guarded, entry.request.options) + const handle = this.driver.show(guarded, entry.request.options) + if (this.active !== entry) { + this.hide(handle) + return + } + entry.handle = handle this.driver.invalidate() } catch (error) { this.fail(entry, error) @@ -267,7 +277,8 @@ export class TuiOverlayManager { }, display: (value: string) => this.driver.display(value), invalidate: () => { - if (entry.state !== 'active') return + if (this.active !== entry || entry.component === undefined || entry.failing === true) return + if (!entry.component.invalidate() || this.active !== entry) return try { this.driver.invalidate() } catch (error) { @@ -295,6 +306,14 @@ export class TuiOverlayManager { } } + private hide(handle: OverlayHandle): void { + try { + handle.hide() + } catch (error) { + this.report(error) + } + } + private close(entry: OverlayEntry, result: TuiOverlayOutcome): Promise { if (entry.outcome !== undefined) return entry.closed entry.outcome = result @@ -306,13 +325,10 @@ export class TuiOverlayManager { if (queuedIndex >= 0) this.queue.splice(queuedIndex, 1) if (this.active === entry) { this.active = undefined - try { - entry.handle?.hide() - } catch (error) { - this.report(error) - } + if (entry.handle !== undefined) this.hide(entry.handle) delete entry.handle } + delete entry.component entry.resolveClosed(result) try { this.driver.invalidate() diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts index 84248e3648..bf11d6c601 100644 --- a/packages/ui/tui/tests/extension.spec.ts +++ b/packages/ui/tui/tests/extension.spec.ts @@ -42,6 +42,7 @@ interface DriverFixture { errors: unknown[] invalidations: number showError?: unknown + onShow?: (component: Component) => void } function driverFixture(): DriverFixture { @@ -81,6 +82,7 @@ function driverFixture(): DriverFixture { }, isFocused: () => shown.focused, } + fixture.onShow?.(component) return handle }, invalidate() { @@ -154,11 +156,12 @@ describe('TuiOverlayManager', () => { expect(firstHost?.theme.accent('x')).toBe('accent:x') expect(firstHost?.display('\u001b')).toBe('safe:\u001b') firstHost?.invalidate() + expect(firstComponent.invalidated).toBe(1) expect(fixture.shown[0]?.component.render(40)).toEqual(['first:40']) fixture.shown[0]!.component.handleInput?.('x') fixture.shown[0]!.component.invalidate() expect(firstComponent.inputs).toEqual(['x']) - expect(firstComponent.invalidated).toBe(1) + expect(firstComponent.invalidated).toBe(2) expect(fixture.shown[0]?.component.wantsKeyRelease).toBe(true) ;(fixture.shown[0]?.component as Component & { focused: boolean }).focused = true expect(firstComponent.focused).toBe(true) @@ -244,6 +247,65 @@ describe('TuiOverlayManager', () => { expect(manager.hasActiveOverlay()).toBe(false) }) + it('does not mount entries closed or aborted during component construction', async () => { + const fixture = driverFixture() + const manager = new TuiOverlayManager(fixture.driver) + const closed = manager.open({ + create(host) { + host.invalidate() + host.close() + return component(['closed during construction']) + }, + }) + await expect(closed.closed).resolves.toEqual({ reason: 'closed' }) + + const controller = new AbortController() + const aborted = manager.open({ + signal: controller.signal, + create() { + controller.abort() + return component(['aborted during construction']) + }, + }) + await expect(aborted.closed).resolves.toEqual({ reason: 'aborted' }) + + const after = manager.open({ create: () => component(['after construction closes']) }) + expect(fixture.shown).toHaveLength(1) + expect(fixture.shown[0]?.component.render(40)).toEqual(['after construction closes']) + await after.close() + }) + + it('hides a handle returned after reentrant closure during mounting', async () => { + const fixture = driverFixture() + const manager = new TuiOverlayManager(fixture.driver) + fixture.onShow = (shown) => { + ;(shown as Component & { focused: boolean }).focused = true + } + const closed = manager.open({ + create(host) { + return { + get focused(): boolean { + return false + }, + set focused(_value: boolean) { + host.close() + }, + render: () => ['closed during mount'], + invalidate() {}, + } + }, + }) + await expect(closed.closed).resolves.toEqual({ reason: 'closed' }) + expect(fixture.shown[0]?.hidden).toBe(true) + expect(manager.hasActiveOverlay()).toBe(false) + + delete fixture.onShow + const after = manager.open({ create: () => component(['after mount close']) }) + expect(fixture.shown[1]?.hidden).toBe(false) + expect(fixture.shown[1]?.component.render(40)).toEqual(['after mount close']) + await after.close() + }) + it('stops admission and disposes active and queued overlays with the TUI', async () => { const fixture = driverFixture() const manager = new TuiOverlayManager(fixture.driver) @@ -315,15 +377,22 @@ describe('TuiOverlayManager', () => { await microtask() const invalidateError = new Error('invalidate failed') + let invalidatingHost: TuiOverlayHost | undefined const invalidating = manager.open({ - create: () => ({ - render: () => ['invalidate'], - invalidate() { - throw invalidateError - }, - }), + create(host) { + invalidatingHost = host + return { + render: () => ['invalidate'], + invalidate() { + throw invalidateError + }, + } + }, }) - fixture.shown.at(-1)!.component.invalidate() + const invalidationsBeforeFailure = fixture.invalidations + invalidatingHost?.invalidate() + invalidatingHost?.invalidate() + expect(fixture.invalidations).toBe(invalidationsBeforeFailure) expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidateError }) await microtask() From 698f46b0c99775e21af6ecceb624c11ce3924674 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:03:32 +0800 Subject: [PATCH 10/32] feat(gui): morph the sidebar collapse instead of swapping renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapse read as a hard cut: only the track width animated while the panel content swapped instantly and the rail icons bore no relation to the expanded layout. Now the four control rows persist across the transition — collapse toggle, new session, new workspace, search, the same top-down order as their expanded rows — and morph their geometry (row heights, paddings, margins, capsule borders) on the deepsuite curve, so each rail icon is its expanded control converging onto the 56px axis. Wide-only content (brand, labels, input, session tree) cross-fades over 200ms, stays mounted while the collapse animates, and unmounts at the 300ms settle, still dropping the sessions subscription. The search query moves up to the root and survives the round trip; rail search focuses the surviving input after expand instead of remount-autofocus. --- ...2-collapsed-sidebar-control-rail.i18n.yaml | 4 +- ...26-07-22-collapsed-sidebar-control-rail.md | 2 +- ...07-22-collapsed-sidebar-control-rail.zh.md | 2 +- apps/web/tests/smoke-fixture.e2e.ts | 5 +- packages/client/ui-sidebar/README.md | 2 +- .../src/client/SidebarRoot.module.css | 276 ++++++++++++++---- .../ui-sidebar/src/client/SidebarRoot.tsx | 254 ++++++++-------- .../ui-sidebar/tests/sidebar-root.spec.tsx | 85 ++++-- 8 files changed, 413 insertions(+), 217 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml index 5a06df6e59..9d8ccb1790 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml @@ -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 -2026-07-22-collapsed-sidebar-control-rail.md: 90039110f4c1e97002fe45ebe42697452b2c6155 -2026-07-22-collapsed-sidebar-control-rail.zh.md: e5cd1c4911bbe02e10d2f1506943024bde862f12 +2026-07-22-collapsed-sidebar-control-rail.md: e959eef37a9e9c0fea79b82ff970daddd9257609 +2026-07-22-collapsed-sidebar-control-rail.zh.md: 7f6d6529a8aa4a655a1d3292e7f41bfb822f05a3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md index 90039110f4..e959eef37a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -14,7 +14,7 @@ The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COL `AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. Collapse and expand animate: the frame transitions `grid-template-columns` (and the remaining handle its `left`) on the deepsuite sider curve — `--ds-ease-in-out` over `--ds-transition-duration-slow`, both supplied by ui-theme's base sheet; transitions pause during drags and under `prefers-reduced-motion`. -`SidebarRoot` reads the owner `collapsed` prop. Its collapsed render is the rail: expand toggle, new session, search, and new workspace icons (each aligned with its expanded counterpart's behavior — the search icon expands the sidebar and focuses the search box) plus the `Settings` foot. The brand, capsule button, search field, and session tree leave the rendered and accessibility trees — the body component unmounts, dropping its sessions subscription. +`SidebarRoot` reads the owner `collapsed` prop and morphs in place rather than swapping renders: the four control rows persist into the rail — expand toggle, new session, new workspace, search, in the same top-down order as their expanded rows — animating their geometry (heights, paddings, margins, capsule borders) on the same curve, each aligned with its expanded counterpart's behavior (the search icon expands the sidebar and focuses the search box). Wide-only content (brand, labels, input, session tree) cross-fades out over 200ms, stays mounted while the collapse animates, and unmounts once the 300ms settle passes — dropping the sessions subscription and leaving the rendered and accessibility trees. The search query lives with the root and survives the round trip. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md index e5cd1c4911..7f6d6529a8 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -14,7 +14,7 @@ Status: implemented `AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。折叠与展开带动画:frame 对 `grid-template-columns`(以及余下手柄的 `left`)应用 deepsuite 侧栏曲线过渡——`--ds-ease-in-out` 配 `--ds-transition-duration-slow`,两个变量由 ui-theme 的 base 表提供;拖拽期间和 `prefers-reduced-motion` 下过渡暂停。 -`SidebarRoot` 读取 owner 的 `collapsed` 属性。折叠渲染即控制栏:展开开关、新建会话、搜索、新建工作区四个图标(行为与展开态对应控件对齐——搜索图标会展开侧边栏并聚焦搜索框),加上底部的 `Settings`。品牌标识、胶囊按钮、搜索框和会话树离开渲染树与可访问性树——主体组件卸载,随之退订会话列表。 +`SidebarRoot` 读取 owner 的 `collapsed` 属性,原地 morph 而非切换渲染:四个控件行持续存在并演变为控制栏——展开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致——几何(行高、内边距、外边距、胶囊边框)走同一条曲线动画,行为与展开态对应控件对齐(搜索图标会展开侧边栏并聚焦搜索框)。宽态专属内容(品牌标识、文字标签、输入框、会话树)以 200ms 交叉淡出,折叠动画期间保持挂载,300ms settle 后卸载——随之退订会话列表并离开渲染树与可访问性树。搜索关键词由根组件持有,折叠往返后保留。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 1d4fd671bb..3f1c4449fd 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -147,8 +147,11 @@ describe('web boot chain success pass (keyless, six real bundles, ?fixture)', () await expect.poll(firstTrack, { timeout: 2000 }).toBe(px) } await page.getByRole('button', { name: 'Collapse sidebar' }).click() + // Mid-collapse the wide chrome is still mounted, fading — not swapped out. + expect(await page.locator('text=HARNESS').count()).toBe(1) await settledTrack('56px') - for (const name of ['Expand sidebar', 'New session', 'Search sessions', 'New workspace', 'Settings']) { + await expect.poll(() => page.locator('text=HARNESS').count(), { timeout: 2000 }).toBe(0) + for (const name of ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) { await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true) } await page.getByRole('button', { name: 'Expand sidebar' }).click() diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index b398db9d3a..33cdeb756d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. The collapsed render is the layout-owned compact rail: expand / new session / search (expands and focuses the search box) / new workspace icons plus the settings entry. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse morphs the four control rows into the layout-owned 56px rail (expand / new session / new workspace / search — search expands and focuses the search box) plus the settings foot: geometry animates on the deepsuite curve while wide-only content cross-fades and unmounts at settle. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 571450d13f..c580d47b75 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -1,61 +1,64 @@ -/* Sidebar column (figma 133:7629): vertical stack, gap 8, padding 16/6, - sidebar fill + 1px right border painted by the layout column. Header block - (logo + New Session) and list area (section header + search + cells) carry - their own inner gaps per the style spec (1.2 / 1.3). */ +/* Sidebar column (figma 133:7629): vertical stack, padding 16/6, sidebar + fill + 1px right border painted by the layout column. Collapse morphs in + place: the four control rows persist into the 56px rail (one icon each, + x-converged by the shrinking column), geometry rides the deepsuite curve + while wide-only content cross-fades 200ms; explicit margins own the + vertical rhythm in both states so every gap can transition. */ .root { display: flex; flex-direction: column; - gap: 8px; height: 100%; padding: 6px 16px; box-sizing: border-box; background: var(--dsw-specific-sidebar-fill); color: var(--dsw-alias-label-primary); font-size: 14px; + transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -/* Closed state is a persistent rail: a 24px icon column between the 16px - horizontal paddings (SIDEBAR_COLLAPSED = 56). Controls mirror their - expanded counterparts top-down: expand, new session, search, new - workspace; settings keeps the foot. */ .root.collapsed { - align-items: center; - gap: 8px; - padding: 14px 16px 6px; + padding-top: 14px; } -.collapsed .iconButton { - width: 24px; - height: 24px; +/* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and + unmounts once the collapse settles; remounts fade back in. */ +.wide { + animation: wide-in 200ms var(--ds-ease-in-out); + transition: opacity 200ms var(--ds-ease-in-out); } -.collapsed .foot { - justify-content: center; - width: 24px; - margin-top: auto; - padding: 0; +.collapsed .wide { + opacity: 0; } -/* Header block (figma 133:7630): logo row + New Session, gap 16, padBottom 12. */ -.headerBlock { - flex: none; - display: flex; - flex-direction: column; - gap: 16px; - padding-bottom: 12px; +@keyframes wide-in { + from { opacity: 0; } } -/* Logo row: 60px, brand mark left, collapse button right. - figma pad is (l,t,r,b)=(4,8,4,8) — horizontal 4, vertical 8. */ +/* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored — + the toggle is the rail's expand control and slides in with the right edge. */ .logoRow { flex: none; display: flex; align-items: center; + justify-content: flex-end; gap: 8px; height: 60px; padding: 8px 4px; + margin-bottom: 16px; box-sizing: border-box; + overflow: hidden; + transition: + height var(--ds-transition-duration-slow) var(--ds-ease-in-out), + padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), + margin var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.collapsed .logoRow { + height: 24px; + padding: 0; + margin-bottom: 8px; } /* Brand group (figma I133:7632): fish + wordmark ride the text ink @@ -101,13 +104,22 @@ background: transparent; cursor: pointer; color: var(--dsw-alias-label-secondary); + transition: + width var(--ds-transition-duration-slow) var(--ds-ease-in-out), + height var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .iconButton:hover { background: var(--dsw-alias-interactive-bg-hover); } -/* New Session: 38px capsule (figma 133:7634). */ +.collapsed .iconButton { + width: 24px; + height: 24px; +} + +/* New Session: 38px capsule (figma 133:7634) morphing into the rail's plain + icon control — border and fill fade with the label. */ .newSession { flex: none; display: flex; @@ -116,6 +128,7 @@ gap: 6px; height: 38px; padding: 8px 16px; + margin-bottom: 20px; /* former headerBlock padBottom 12 + root gap 8 */ box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 24px; @@ -125,65 +138,84 @@ font-weight: 510; line-height: 22px; cursor: pointer; + overflow: hidden; + transition: + height var(--ds-transition-duration-slow) var(--ds-ease-in-out), + padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), + margin var(--ds-transition-duration-slow) var(--ds-ease-in-out), + gap var(--ds-transition-duration-slow) var(--ds-ease-in-out), + border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out), + background-color 200ms var(--ds-ease-in-out); } .newSession:hover { background: var(--dsw-alias-button-floating-hover); } -/* List area (figma 133:7640): section header + search + cells, gap 4. - Relative for the bottom fade overlay. */ -.listArea { - position: relative; - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - gap: 4px; +.collapsed .newSession { + height: 24px; + padding: 0; + margin-bottom: 8px; + gap: 0; + border-color: transparent; + background: transparent; } -/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, - transparent -> sidebar fill so it tracks the theme. */ -.fade { - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 72px; - background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); - pointer-events: none; +.collapsed .newSession:hover { + background: var(--dsw-alias-interactive-bg-hover); } -/* Batch separator (figma 133:7661): 20px spacer after an expanded project's - session run, before the next project row. */ -.batchGap { - flex: none; - height: 20px; +.newSessionLabel { + max-width: 200px; + overflow: hidden; + white-space: nowrap; + transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons. */ +.collapsed .newSessionLabel { + max-width: 0; +} + +/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons; + the right-anchored new-workspace button is the row's rail survivor. */ .sectionHeader { flex: none; display: flex; align-items: center; + justify-content: flex-end; gap: 4px; height: 36px; padding-left: 12px; + margin-bottom: 4px; box-sizing: border-box; border-radius: 12px; + overflow: hidden; color: var(--dsw-alias-label-tertiary); + transition: + height var(--ds-transition-duration-slow) var(--ds-ease-in-out), + padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), + margin var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.collapsed .sectionHeader { + height: 24px; + padding-left: 0; + margin-bottom: 8px; } .sectionLabel { flex: 1; min-width: 0; + overflow: hidden; + white-space: nowrap; line-height: 20px; } -/* Search input: 38px capsule (figma 133:7649). Upstream binds a dedicated - design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped - alias — a component token pinned to the static scale mirrors it (ruled - compliant: indirect via custom property, upstream-variable equivalent). */ +/* Search input: 38px capsule (figma 133:7649) morphing into the rail's + search control. Upstream binds a dedicated design-system variable (light + #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token + pinned to the static scale mirrors it (ruled compliant: indirect via + custom property, upstream-variable equivalent). */ .search { --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); flex: none; @@ -191,19 +223,64 @@ align-items: center; gap: 8px; height: 38px; - margin-bottom: 8px; /* + 4px area gap = 12px to the first cell (spec padB12) */ + margin-bottom: 12px; /* former listArea gap 4 + own 8 (spec padB12 to the first cell) */ padding: 0 14px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 24px; background: var(--dsh-search-input-fill); color: var(--dsw-alias-label-caption); + overflow: hidden; + transition: + height var(--ds-transition-duration-slow) var(--ds-ease-in-out), + padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), + margin var(--ds-transition-duration-slow) var(--ds-ease-in-out), + gap var(--ds-transition-duration-slow) var(--ds-ease-in-out), + border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out), + background-color 200ms var(--ds-ease-in-out); } :global(body[data-ds-dark-theme]) .search { --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); } +.collapsed .search { + height: 24px; + padding: 0; + margin-bottom: 8px; + gap: 0; + border-color: transparent; + background: transparent; +} + +/* The capsule's leading icon, upgraded to the rail's search control. While + expanded it is decorative: pointer-events off so clicks reach the label + (native input focus); collapsed it becomes the hit target. */ +.searchButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + pointer-events: none; + color: inherit; +} + +.collapsed .searchButton { + pointer-events: auto; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +.collapsed .searchButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + .searchInput { flex: 1; min-width: 0; @@ -234,6 +311,44 @@ color: var(--dsw-alias-label-secondary); } +/* Tree seat: always mounted so the foot never moves; the tree content inside + is wide-only and clips while the column squeezes. */ +.listArea { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Relative for the bottom fade overlay. */ +.treeBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +} + +/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, + transparent -> sidebar fill so it tracks the theme. */ +.fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 72px; + background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); + pointer-events: none; +} + +/* Batch separator (figma 133:7661): 20px spacer after an expanded project's + session run, before the next project row. */ +.batchGap { + flex: none; + height: 20px; +} + /* Tree list: the only scrolling region. */ .list { flex: 1; @@ -251,20 +366,57 @@ font-size: 13px; } -/* Foot: settings entry (figma 133:7668). */ +/* Foot: settings entry (figma 133:7668). Left padding lands the 14px glyph + on the rail's icon axis when collapsed. */ .foot { flex: none; display: flex; align-items: center; gap: 8px; height: 29px; - margin: 10px 0; + margin: 18px 0 10px; /* former root gap 8 + own 10 above; root padBottom 6 below */ padding: 0 2px 0 6px; border-radius: 12px; cursor: pointer; + overflow: hidden; color: var(--dsw-alias-label-primary); + transition: + padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), + gap var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .foot:hover { background: var(--dsw-alias-interactive-bg-hover); } + +.collapsed .foot { + gap: 0; + padding: 0 0 0 5px; +} + +.footLabel { + max-width: 120px; + overflow: hidden; + white-space: nowrap; + transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.collapsed .footLabel { + max-width: 0; +} + +@media (prefers-reduced-motion: reduce) { + .root, + .wide, + .logoRow, + .iconButton, + .newSession, + .newSessionLabel, + .sectionHeader, + .search, + .foot, + .footLabel { + transition: none; + animation: none; + } +} diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 60c94d53a5..a2f730b2d4 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -1,15 +1,19 @@ /** - * SidebarRoot (figma 133:7629): logo row + collapse, New Session, search, - * WorkSpace section header with the group-by menu, session tree list, - * Settings foot. Pure presentational — the session list arrives through the - * standard useSessions hook, viewing state (expansion, search) is local - * component state, and rows are derived in render via useMemo (slot design - * section 6: derived data is a pure function, no materializing store). - * The collapsed render is the compact rail: expand / new session / search / - * new workspace icons plus the Settings foot; the body unmounts, dropping - * its sessions subscription. Rail search expands and focuses the search box. + * SidebarRoot (figma 133:7629): logo row + collapse, New Session, WorkSpace + * section header with the group-by menu, search, session tree list, Settings + * foot. Pure presentational — the session list arrives through the standard + * useSessions hook, viewing state (expansion, search) is local component + * state, and rows are derived in render via useMemo (slot design section 6: + * derived data is a pure function, no materializing store). + * + * Collapse is a morph, not a swap: the four control rows persist into the + * 56px rail (collapse/new session/new workspace/search, one icon each, same + * top-down order as their expanded rows) and animate their geometry on the + * deepsuite curve, while wide-only content (brand, labels, input, tree) + * cross-fades out and unmounts once the collapse settles — dropping the + * sessions subscription. Rail search expands and focuses the search box. */ -import { Fragment, useEffect, useMemo, useState } from 'react' +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { FishLogo, @@ -22,6 +26,9 @@ import { deriveRows } from './tree.ts' import { ProjectRowItem, SessionRowItem } from './Rows.tsx' import css from './SidebarRoot.module.css' +/** Wide-content unmount delay; matches --ds-transition-duration-slow (0.3s). */ +const COLLAPSE_SETTLE_MS = 300 + const GROUP_BY_ITEMS = [ { id: 'workspace', label: 'WorkSpace' }, // Update/Status grouping has no design yet (figma §3) — visible, disabled. @@ -34,25 +41,48 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] } -type SidebarBodyProps = Pick & { - /** Focus the search input on mount (rail search control expands into search). */ - autoFocusSearch: boolean +/** Group-by strategy menu; own open state so it resets with the wide chrome. */ +function GroupByMenu() { + const [open, setOpen] = useState(false) + return ( + { setOpen(false) }} + items={GROUP_BY_ITEMS} + selectedId="workspace" + onSelect={() => { setOpen(false) }} + align="end" + anchor={( + + )} + /> + ) } -/** Expanded-only content; unmounting drops the sessions subscription and viewing state while the rail is collapsed. */ -function SidebarBody({ useSessions, onOpen, onCreate, autoFocusSearch }: SidebarBodyProps) { +type SessionTreeProps = Pick & { + /** Live search filter owned by the root (the query outlives the tree). */ + query: string +} + +/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ +function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) { const list = useSessions((s) => s) // Wave-2 seam: row highlight expects `current` on the sessions list // snapshot (sessions.current lives with the runtime sessions service). const current = useSessions((s) => s.current) const [expandedProjects, setExpandedProjects] = useState([]) const [expandedSessions, setExpandedSessions] = useState([]) - const [query, setQuery] = useState('') const rows = useMemo( () => deriveRows(list, { expandedProjects, expandedSessions, query }), [list, expandedProjects, expandedSessions, query], ) - const [menuOpen, setMenuOpen] = useState(false) const now = Date.now() // Presentational lookup (not tree derivation): the group holding the @@ -65,59 +95,7 @@ function SidebarBody({ useSessions, onOpen, onCreate, autoFocusSearch }: Sidebar } return ( -
-
- WorkSpace - { setMenuOpen(false) }} - items={GROUP_BY_ITEMS} - selectedId="workspace" - onSelect={() => { setMenuOpen(false) }} - align="end" - anchor={( - - )} - /> - -
- - - +
{rows.length === 0 && (
{query === '' ? 'No sessions yet' : 'No matches'}
@@ -157,44 +135,65 @@ function SidebarBody({ useSessions, onOpen, onCreate, autoFocusSearch }: Sidebar * @returns the sidebar element tree. */ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { - // Rail search = expand + land in the search box: the flag arms right before - // the expand toggle, the remounting SidebarBody autofocuses its input, and - // the post-commit effect disarms so later remounts stay unfocused. + // The query outlives the tree and the input (both wide-only) so collapsing + // does not silently drop an in-progress filter. + const [query, setQuery] = useState('') + const searchInput = useRef(null) + + // Wide content stays mounted while the collapse animates (fading via + // .collapsed .wide), unmounts at settle, and remounts right away on expand. + const [settled, setSettled] = useState(collapsed) + useEffect(() => { + if (!collapsed) { setSettled(false); return } + const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS) + return () => { window.clearTimeout(timer) } + }, [collapsed]) + const wide = !collapsed || !settled + + // Rail search = expand + land in the search box: the flag arms before the + // expand toggle; once expanded the input is mounted and takes focus. const [searchOnExpand, setSearchOnExpand] = useState(false) useEffect(() => { - if (!collapsed && searchOnExpand) setSearchOnExpand(false) + if (!collapsed && searchOnExpand) { + searchInput.current?.focus() + setSearchOnExpand(false) + } }, [collapsed, searchOnExpand]) - if (collapsed) { - // Rail (figma parity with deepsuite CollapsedSider): the four controls - // mirror their expanded counterparts top-down; actions that need the - // expanded surface expand first. - return ( -
+ return ( +
+
+ {wide && ( + + {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} + + deepseek + HARNESS + + )} - - +
+ + + +
+ {wide && WorkSpace} + {wide && } -
- -
- ) - } - return ( -
-
-
- - {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} - - deepseek - HARNESS - + {/* Expanded: the row is a click-to-focus field (the leading icon is + decorative). Collapsed: the icon is the rail's search control. */} +
{ if (!collapsed) searchInput.current?.focus() }}> + + {wide && ( + { setQuery(e.target.value) }} + /> + )} + {wide && query !== '' && ( -
- - + )}
- + {/* Always-mounted seat: its flex slot pins the foot to the bottom in + both states while the tree itself is wide-only. */} +
+ {wide && } +
- Settings + {wide && Settings}
) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index e9367a8388..b0e8a9f769 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -158,34 +158,69 @@ describe('SidebarRoot', () => { expect(onCreate).toHaveBeenLastCalledWith('/proj') }) - it('collapsed rail keeps the four controls and settings', () => { - const { onToggleSidebar, onCreate } = mount(...projectData()) - act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) - expect(onToggleSidebar).toHaveBeenCalledOnce() - expect(screen.getByLabelText('Expand sidebar')).toBeTruthy() - expect(screen.getByLabelText('New session')).toBeTruthy() - expect(screen.getByLabelText('Search sessions')).toBeTruthy() - expect(screen.getByLabelText('New workspace')).toBeTruthy() - expect(screen.getByLabelText('Settings')).toBeTruthy() - expect(screen.queryByText('HARNESS')).toBeNull() - expect(screen.queryByText('New Session')).toBeNull() - expect(screen.queryByRole('tree')).toBeNull() - // Rail creation entries route like their expanded counterparts. - act(() => { fireEvent.click(screen.getByLabelText('New session')) }) - expect(onCreate).toHaveBeenLastCalledWith() - act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) - expect(onToggleSidebar).toHaveBeenCalledTimes(2) - expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy() - expect(screen.getByText('New Session')).toBeTruthy() + it('collapse fades the wide content out, then the rail keeps the four controls', () => { + vi.useFakeTimers() + try { + const { onToggleSidebar, onCreate } = mount(...projectData()) + act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) + expect(onToggleSidebar).toHaveBeenCalledOnce() + // Fade window: the wide chrome is still mounted while it fades. + expect(screen.getByText('HARNESS')).toBeTruthy() + expect(screen.getByRole('tree')).toBeTruthy() + // Settle: wide content unmounts, the rail controls remain. + act(() => { vi.advanceTimersByTime(300) }) + expect(screen.queryByText('HARNESS')).toBeNull() + expect(screen.queryByText('New Session')).toBeNull() + expect(screen.queryByRole('tree')).toBeNull() + // Rail order mirrors the expanded rows: expand, new session, new workspace, search. + const rail = ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings'] + .map((label) => screen.getByLabelText(label)) + for (let i = 1; i < rail.length; i++) { + expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + } + // Rail creation entries route like their expanded counterparts. + act(() => { fireEvent.click(screen.getByLabelText('New session')) }) + expect(onCreate).toHaveBeenLastCalledWith() + act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) + expect(onToggleSidebar).toHaveBeenCalledTimes(2) + expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy() + expect(screen.getByText('New Session')).toBeTruthy() + } finally { + vi.useRealTimers() + } }) it('rail search expands the sidebar and focuses the search box', () => { - const { onToggleSidebar } = mount(...projectData()) - act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) - act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) - expect(onToggleSidebar).toHaveBeenCalledTimes(2) - const input = screen.getByPlaceholderText('Search name, keywords...') - expect(document.activeElement).toBe(input) + vi.useFakeTimers() + try { + const { onToggleSidebar } = mount(...projectData()) + act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) + act(() => { vi.advanceTimersByTime(300) }) + act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) + expect(onToggleSidebar).toHaveBeenCalledTimes(2) + const input = screen.getByPlaceholderText('Search name, keywords...') + expect(document.activeElement).toBe(input) + } finally { + vi.useRealTimers() + } + }) + + it('the search query survives a collapse/expand round trip', () => { + vi.useFakeTimers() + try { + mount(...projectData()) + const input = screen.getByPlaceholderText('Search name, keywords...') + act(() => { fireEvent.change(input, { target: { value: 'forked' } }) }) + act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) + act(() => { vi.advanceTimersByTime(300) }) + act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) + const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement + expect(restored.value).toBe('forked') + expect(screen.getByText('forked child')).toBeTruthy() + expect(screen.queryByText('elsewhere')).toBeNull() + } finally { + vi.useRealTimers() + } }) it('group-by menu behaves', () => { From 00d5b69882961c8136334cc04e5c512825e68c0a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:09:52 +0800 Subject: [PATCH 11/32] chore: run primary CI before push --- .../process/2026-06-11-quality-gates.md | 2 +- .../2026-07-06-parallel-pre-push-gates.md | 2 +- .../2026-07-22-fast-local-git-hooks.i18n.yaml | 4 +- .../2026-07-22-fast-local-git-hooks.md | 2 + .../2026-07-22-fast-local-git-hooks.zh.md | 2 + ...-23-local-primary-ci-before-push.i18n.yaml | 6 +++ ...2026-07-23-local-primary-ci-before-push.md | 38 +++++++++++++++++++ ...6-07-23-local-primary-ci-before-push.zh.md | 38 +++++++++++++++++++ .agents/skills/dsh-pre-push-checks/SKILL.md | 26 ++++++++----- .../dsh-pre-push-checks/agents/openai.yaml | 2 +- AGENTS.md | 4 +- docs/development.i18n.yaml | 4 +- docs/development.md | 13 ++++--- docs/development.zh.md | 13 ++++--- lefthook.yml | 8 ++-- package.json | 1 + packages/client/AGENTS.md | 2 +- scripts/run-gates.ts | 2 +- 18 files changed, 132 insertions(+), 37 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md create mode 100644 .agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index 5e1db16e52..b42d84bbac 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -2,7 +2,7 @@ Status: implemented -The hook/CI symmetry in this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md); CI remains the exhaustive enforcement path. +[Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) restores hook/CI symmetry for the primary Node inventory. [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md) continues to own the pre-commit design. ## Problem diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 87b1c0847b..4733eabac6 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -2,7 +2,7 @@ Status: implemented -The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. +[Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) now owns the local-hook contract: pre-push selects the same primary inventory as CI. The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. ## Problem diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml index 361f7a0bd0..7e6cb7f35e 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml @@ -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 -2026-07-22-fast-local-git-hooks.md: a07af1cd424c86f7fa80ea946cd5012362cc66eb -2026-07-22-fast-local-git-hooks.zh.md: 78d4ea8980476609a9140737a75152eba123b308 +2026-07-22-fast-local-git-hooks.md: 4a504c6c4f5816f00be6d86ddfbcb93baf0d7768 +2026-07-22-fast-local-git-hooks.zh.md: 792e035bc5761fc044cf231aff3bb59f31a47659 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md index a07af1cd42..4a504c6c4f 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-22-fast-local-git-hooks.zh.md) +> **Superseded for pre-push:** [Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) replaces the typecheck-only publication checkpoint with the exact primary CI inventory. The fast pre-commit decision remains in force; the pre-push design below records the policy this repository no longer uses. + ## Problem An agent already runs the tests and checks that exercise its change, while commit, push, and CI can each repeat increasingly broad subsets of the same work. A full pre-push suite therefore delays every publication, amplifies unrelated local flakes, and gives no new signal when CI immediately runs the exhaustive matrix again. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md index 78d4ea8980..792e035bc5 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-22-fast-local-git-hooks.md) | 中文 +> **pre-push 部分已被取代:**[推送前本地运行主 CI](2026-07-23-local-primary-ci-before-push.md)以精确的主 CI 清单取代仅运行类型检查的发布检查点。快速 pre-commit 的决策继续有效;下文的 pre-push 设计记录了本仓库不再采用的策略。 + ## 问题 agent(智能体)已经会运行能够覆盖自身改动的测试和检查,而提交、推送与 CI 可能分别重复其中范围越来越广的子集。因此,全量 pre-push 套件会拖慢每次推送,放大与当前改动无关的本地偶发失败,而且 CI 紧接着再次运行完整矩阵时不会提供新信号。 diff --git a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml new file mode 100644 index 0000000000..e82aad513f --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-23-local-primary-ci-before-push.md: 9d7f4d5a5aae428a003b1cbbb8911e7ad7c50831 +2026-07-23-local-primary-ci-before-push.zh.md: 3b58a828bd12d0149d8c4101d8665ee15b9fffc2 diff --git a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md new file mode 100644 index 0000000000..9d7f4d5a5a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md @@ -0,0 +1,38 @@ +# Agent Note: Local primary CI before push + +Status: implemented + +English | [中文](2026-07-23-local-primary-ci-before-push.zh.md) + +## Problem + +Hosted CI can become unavailable before repository code executes because of account, billing, quota, or runner failures. A typecheck-only publication hook then permits a remote branch update without coverage, snapshot, documentation, build, package, or built-entrypoint evidence precisely when the hosted workflow cannot supply that signal. + +Focused checks remain the right feedback loop during implementation, but their selection depends on the author correctly predicting every affected contract. Publication needs one complete, mechanically owned local baseline that does not depend on the hosted control plane starting a job. + +## Decision + +[lefthook.yml](../../../../lefthook.yml) keeps pre-commit focused on staged lint, whitespace, and vendored-source metadata. Pre-push invokes `pnpm run check:pre-push` and blocks publication on any failure. + +The `check:pre-push` package script selects the `pre-push` mode in [scripts/run-gates.ts](../../../../scripts/run-gates.ts). Both `pre-push` and `ci-primary` return the same `ciPrimaryGates()` inventory, so the hook and the primary Node CI job cannot drift through separately maintained command lists. Build consumers retain their explicit scheduler dependencies, and `DSH_GATE_CONCURRENCY` remains the resource-control seam for constrained hosts. + +Authors still run focused checks while iterating. They do not run the full aggregate immediately before a normal push because the hook owns that one exhaustive local execution. A hook failure is fixed or reported as a blocker; bypass requires explicit approval. + +This contract is equivalent to the keyless primary Node CI aggregate on the current host. It does not claim the supported-version or operating-system matrix, Python SDK, real-provider, native, or sandbox workflow signals that require their own environments. + +## Supersedes + +This decision supersedes the pre-push half of [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). Its staged pre-commit design remains in force. It also restores the local publication role described by [Parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md) without reviving a second gate inventory. + +## Alternatives considered + +- **Rely on restoring hosted CI availability** — repairs the immediate administrative failure but leaves publication without a baseline during the next control-plane or runner outage. +- **Wire pre-push to `check:all`** — reuses a broad local command, but that inventory intentionally differs from the primary CI contract and would make “CI equivalent” inaccurate. +- **Copy the CI commands into `lefthook.yml`** — makes the hook visibly comprehensive but creates a second inventory that can drift whenever CI changes. +- **Keep typecheck-only pre-push and require a manual command during outages** — preserves low latency but relies on every author noticing the outage and remembering an exceptional procedure before each update. + +## Consequences + +Every normal push pays the primary CI aggregate's wall time and may be blocked by a repository-wide local failure unrelated to the outgoing diff. In return, every published revision has observed coverage, snapshots, documentation, build, package, and built-entrypoint evidence from one shared inventory even when hosted jobs never start. + +The result is local evidence, not a substitute for unavailable remote environments. Pull requests and handoffs report hosted billing, provider, platform, and pending states separately instead of presenting a successful macOS pre-push run as a green GitHub matrix. diff --git a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md new file mode 100644 index 0000000000..3b58a828bd --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 推送前本地运行主 CI + +Status: implemented + +[English](2026-07-23-local-primary-ci-before-push.md) | 中文 + +## 问题 + +托管 CI 可能会因账户、计费、配额或运行器故障,在仓库代码开始执行之前就不可用。此时,仅运行类型检查的发布钩子会允许更新远端分支,却缺少覆盖率、快照、文档、构建、包(package)和构建后入口点的证据;恰恰这时,托管工作流无法提供这些信号。 + +实现期间,聚焦检查仍是正确的反馈循环,但检查选择取决于作者是否正确预判每项受影响的契约。发布需要一套由机制统一维护的完整本地基线,且不依赖托管控制平面能否启动作业。 + +## 决策 + +[lefthook.yml](../../../../lefthook.yml) 让 pre-commit 集中处理暂存文件 lint、空白错误和 vendor 源码元数据。Pre-push 调用 `pnpm run check:pre-push`,任何检查失败都会阻止发布。 + +`check:pre-push` 包脚本从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `pre-push` 模式。`pre-push` 和 `ci-primary` 都返回同一份 `ciPrimaryGates()` 清单,因此钩子与主 Node CI 作业不会因分别维护命令列表而产生漂移。构建产物消费方仍保留对调度器的显式依赖关系,`DSH_GATE_CONCURRENCY` 仍是资源受限主机的资源控制 seam。 + +作者在迭代时仍运行聚焦检查。正常推送前不立即运行全量聚合,因为钩子负责这一次全面的本地执行。钩子失败必须修复或报告为阻塞项;绕过钩子需要明确批准。 + +本契约等同于当前主机上的 keyless 主 Node CI 聚合。它不代表已经取得受支持版本矩阵或操作系统矩阵、Python SDK、真实模型提供方、原生构建或沙箱工作流的信号;这些信号需要各自的环境才能取得。 + +## 取代关系 + +本决策取代[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)中有关 pre-push 的部分。其中面向暂存文件的 pre-commit 设计继续有效。它还恢复了[并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)中描述的本地发布职责,但没有重新引入第二份门禁清单。 + +## 考虑过的替代方案 + +- **依靠恢复托管 CI 可用性**——可以修复当前的管理性故障,但下一次控制平面或运行器中断时,发布流程仍没有基线。 +- **将 pre-push 接入 `check:all`**——能够复用一条广泛的本地命令,但其清单有意不同于主 CI 契约,会使「等同于 CI」的表述不准确。 +- **将 CI 命令复制到 `lefthook.yml`**——能够直观展示钩子的全面性,但会创建第二份清单,并在每次 CI 变更时产生漂移。 +- **保留仅运行类型检查的 pre-push,并要求中断期间手动运行命令**——能够维持低延迟,但依赖每位作者发现中断,并在每次更新前记得执行特殊流程。 + +## 结果 + +每次正常推送都要承担主 CI 聚合的实际耗时,也可能被与待推送 diff 无关的全仓本地失败阻塞。相应地,即使托管作业从未启动,每个已发布版本仍有一套由共享清单实际运行得出的覆盖率、快照、文档、构建、包和构建后入口点证据。 + +该结果只是本地证据,不能代替不可用的远端环境。PR(Pull Request)和交接会分别报告托管服务计费、提供方、平台与待处理状态,而不会把一次成功的 macOS pre-push 运行表述成 GitHub 矩阵已通过。 diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 31f82eed94..65ab35f9a3 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -1,11 +1,11 @@ --- name: dsh-pre-push-checks -description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select the smallest tests and checks that cover the outgoing diff without reflexively running the full repository suite. +description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select focused implementation evidence and preserve the mandatory primary-CI pre-push gate. --- # DSH Pre-Push Checks -Use this skill to run relevant local evidence once before a `deepseek-harness` push. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix. +Use this skill to run relevant implementation evidence once and the complete local publication baseline once before a `deepseek-harness` push. Pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push invokes `pnpm run check:pre-push`, which selects the same primary Node inventory as `pnpm run check:ci`. Hosted CI still owns platform- and provider-specific evidence. ## Inspect the outgoing change @@ -27,15 +27,15 @@ If the branch has no upstream or that range is not meaningful for the stack, com ## Select relevant evidence -There is no universal local baseline beyond the hooks. Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression; add broader checks only for surfaces the diff actually reaches. +Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression. Run that evidence while iterating; the hook supplies the universal publication baseline. -- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it. +- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to pre-push unless the change is genuinely cross-cutting or the user requests it earlier. - **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it. - **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output. - **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke. - **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets. -Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook. +Do not manually repeat a passing check merely because commit or push follows. In particular, do not run `check:pre-push` immediately before a normal push and then repeat the same aggregate in the hook. ### Focus unit coverage on the affected source @@ -60,13 +60,19 @@ pnpm exec vitest related packages///src/.ts \ `vitest related` cannot discover behavior reached only through configuration, dynamic loading, subprocesses, workers, built artifacts, or external providers; select those owning tests explicitly. Do not use `--passWithNoTests`, lower coverage thresholds, or narrow `--coverage.include` merely to hide an uncovered affected file. If a selected package scope fails because one focused test does not cover it, add its other relevant owning tests or narrow the source scope only when the excluded modules cannot be affected by the change. -## Full local rehearsal +## Mandatory publication gate -Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate. +The normal push runs the complete keyless primary Node inventory through Lefthook: + +```sh +pnpm run check:pre-push +``` + +Invoke the command directly only when the user requests a rehearsal independent of publication or when diagnosing the hook itself. Add the relevant `pnpm run test:e2e` target when credentials are available and behavior depends on a real provider; real-API e2e is not part of the keyless primary inventory. ## Handle failures -If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs. +If a relevant check or the publication gate fails, stop and fix or explain the blocker. Do not push and hope CI differs. If a failure looks environment-specific, prove it: @@ -77,9 +83,9 @@ If a failure looks environment-specific, prove it: ## Push procedure -1. Run the selected relevant checks once. +1. Run the selected focused checks once during implementation. 2. Commit normally and inspect any files changed by the pre-commit fixer before continuing. -3. Push normally so the incremental typecheck hook runs. +3. Push normally so the complete primary Node hook runs once. 4. Verify the remote ref matches local `HEAD`. ```sh diff --git a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml b/.agents/skills/dsh-pre-push-checks/agents/openai.yaml index 4a38ea4da8..be0ef1f114 100644 --- a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml +++ b/.agents/skills/dsh-pre-push-checks/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "DSH Pre-Push Checks" - short_description: "Run the relevant DeepSeek Harness checks before push" + short_description: "Run focused and primary CI checks before push" default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch." diff --git a/AGENTS.md b/AGENTS.md index 8007f32b90..2aee7c5af9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,10 +73,10 @@ When required `gh`, `pnpm`, build, test, or generator commands fail because the ### Run relevant checks locally -Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run. +Agents MUST run focused checks while implementing; normal pre-push runs `check:pre-push`, the same primary Node inventory as `check:ci`. Select focused evidence with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run. - Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior. -- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. +- Let the hook run the full aggregate once; never bypass a failure without explicit approval. Remote CI owns the platform and provider matrix. - `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). ## Secrets / .env diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 40828094ce..cfea7907d5 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -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 -development.md: 4294038e40aa774a006874e6641ca63eea44beeb -development.zh.md: 1f07c95dd60d0554b945c29e6e3ba8bc6ca9841a +development.md: 2496afa3a5b27efdade4a7c6e66e54b55a25b075 +development.zh.md: 9b44eb1c2679be395f7a19882dcdd38261833a06 diff --git a/docs/development.md b/docs/development.md index 4294038e40..2496afa3a5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,20 +75,20 @@ DEEPSEEK_BASE_URL=https://... # optional ## Git hooks -lefthook is configured in `lefthook.yml` as a fast local checkpoint: +lefthook is configured in `lefthook.yml` with fast commit-local checks and a comprehensive publication check: - `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. -- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates). +- `pre-push` invokes `pnpm run check:pre-push`, which selects the same primary Node gate inventory as `pnpm run check:ci` from `scripts/run-gates.ts`. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. -The hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix. +Contributors run [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) while iterating. A normal push runs the complete local primary aggregate once and stops publication on any failure; do not run the same aggregate immediately before pushing. -Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction. +The pre-push result covers the keyless primary Node lane on the current host. It does not replace the supported-version and platform matrix, Python SDK tests, real-API e2e, or sandbox workflows. `pnpm run check:all` remains a broad opt-in development inventory; it is independent of both Git hooks and is not the publication contract. ## CI gates -The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. +The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. `check:pre-push` and the primary CI job share one gate inventory; the separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. ## Daily commands @@ -98,7 +98,8 @@ Use these from the repo root: pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks +pnpm run check:pre-push # primary Node CI inventory; runs automatically before push +pnpm run check:all # broad opt-in development gate set; not wired to Git hooks pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix diff --git a/docs/development.zh.md b/docs/development.zh.md index 1f07c95dd6..9b44eb1c26 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -75,20 +75,20 @@ DEEPSEEK_BASE_URL=https://... # optional ## Git 钩子 -lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: +lefthook 在 `lefthook.yml` 中配置了快速的提交级检查和全面的发布检查: - `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; -- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。 +- `pre-push` 调用 `pnpm run check:pre-push`;该命令从 `scripts/run-gates.ts` 中选择与 `pnpm run check:ci` 相同的主 Node 门禁清单。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 -这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。 +贡献者在迭代过程中运行[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally)。正常推送会在本地完整运行一次主 CI 聚合,并在任何检查失败时阻止发布;不要在推送前立即重复运行同一个聚合。 -贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。 +pre-push 的结果覆盖当前主机上的 keyless 主 Node lane,但不能代替受支持版本与平台矩阵、Python SDK 测试、真实 API e2e 或沙箱工作流。`pnpm run check:all` 仍是一份广泛的可选开发检查清单;它独立于两个 Git 钩子,也不属于发布契约。 ## CI 门禁 -keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 +keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。`check:pre-push` 与主 CI job 共用一份门禁清单;单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 ## 日常命令 @@ -98,7 +98,8 @@ keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若 pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks +pnpm run check:pre-push # primary Node CI inventory; runs automatically before push +pnpm run check:all # broad opt-in development gate set; not wired to Git hooks pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix diff --git a/lefthook.yml b/lefthook.yml index cf2e6bb11d..d566de8b1c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,5 +1,5 @@ -# Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full -# repository-wide gate matrix. +# Git hooks (lefthook). Pre-commit keeps commit-local checks fast; pre-push +# runs the primary Node CI inventory before publication. # Install: `pnpm exec lefthook install` (runs automatically via postinstall). pre-commit: @@ -19,5 +19,5 @@ pre-commit: pre-push: jobs: - - name: typecheck - run: node_modules/.bin/tsc -b --pretty false + - name: primary CI + run: pnpm run check:pre-push diff --git a/package.json b/package.json index 38db3a2ac7..7345fcd173 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:web": "npm run build:web && vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", + "check:pre-push": "tsx scripts/run-gates.ts pre-push", "check:ci": "tsx scripts/run-gates.ts ci-primary", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index d3011ed85f..e858d9ffbc 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -66,7 +66,7 @@ Run the narrowest rung that covers what you touched; escalate only when the chan 1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck. 2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`). -3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit. +3. **Before every push** — the normal hook runs `pnpm run check:pre-push` (the repo-wide primary CI inventory). Do not invoke it manually immediately before pushing. If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep. diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 896ffacfd1..0399e3249f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -168,6 +168,7 @@ function nodeOptions(...options: string[]): string { function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': + case 'pre-push': return ciPrimaryGates() case 'ci-static': return ciStaticGates() @@ -190,7 +191,6 @@ function gatesForMode(selected: Mode): Gate[] { return ciWindowsObservationalGates() case 'node-compat': return nodeCompatGates() - case 'pre-push': return [] case 'check-all': return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), From f062ba8fcdbdb6286ddf8df5bd1ecf21c0ade8f3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:30:07 +0800 Subject: [PATCH 12/32] fix(gui): keep the label fade and pin the rail icons to one axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two polish defects from the headless verification pass: - .newSessionLabel/.footLabel declared their own transition shorthand, which replaced .wide's opacity fade entirely (shorthands do not merge) — the labels vanished instantly while everything else faded. The fade is restated alongside max-width. - The rail icons drifted off a common vertical axis: the 56px track holds a 55px content box (1px column border), and the capsule rows kept their 1px border-width in the collapsed state, shaving their content to 23px and pushing the search icon 2px right. Collapsed capsules now zero the border-width (transitioning border, not border-color), the root gives back the border pixel via asymmetric padding, and every surviving row centers its icon — five glyphs on the track's 28px center. --- .../src/client/SidebarRoot.module.css | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index c580d47b75..844c406e35 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -17,8 +17,11 @@ transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out); } +/* The 56px track paints a 1px column border, leaving a 55px content box; + the right padding gives back that pixel so every row holds exactly 24px + of content and all five glyphs share the track's 28px center axis. */ .root.collapsed { - padding-top: 14px; + padding: 14px 15px 6px 16px; } /* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and @@ -55,10 +58,16 @@ margin var(--ds-transition-duration-slow) var(--ds-ease-in-out); } +/* Rail axis: every collapsed row centers its surviving icon in the 55px + content box (56px track minus the column border), putting all five glyphs + on one vertical line; capsule border-widths zero out so they stop taking + layout space. While wide content is still fading its flex:1 spans keep + absorbing the free space, so justify-content only takes over at unmount. */ .collapsed .logoRow { height: 24px; padding: 0; margin-bottom: 8px; + justify-content: center; } /* Brand group (figma I133:7632): fish + wordmark ride the text ink @@ -144,7 +153,7 @@ padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), margin var(--ds-transition-duration-slow) var(--ds-ease-in-out), gap var(--ds-transition-duration-slow) var(--ds-ease-in-out), - border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out), + border var(--ds-transition-duration-slow) var(--ds-ease-in-out), background-color 200ms var(--ds-ease-in-out); } @@ -152,12 +161,14 @@ background: var(--dsw-alias-button-floating-hover); } +/* Border width zeroes with the color: a leftover 1px border inside the + border-box would shave the rail row's content below 24px. */ .collapsed .newSession { height: 24px; padding: 0; margin-bottom: 8px; gap: 0; - border-color: transparent; + border-width: 0; background: transparent; } @@ -169,7 +180,10 @@ max-width: 200px; overflow: hidden; white-space: nowrap; - transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); + /* Opacity restated: this shorthand would otherwise replace .wide's fade. */ + transition: + max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out), + opacity 200ms var(--ds-ease-in-out); } .collapsed .newSessionLabel { @@ -201,6 +215,7 @@ height: 24px; padding-left: 0; margin-bottom: 8px; + justify-content: center; } .sectionLabel { @@ -236,7 +251,7 @@ padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), margin var(--ds-transition-duration-slow) var(--ds-ease-in-out), gap var(--ds-transition-duration-slow) var(--ds-ease-in-out), - border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out), + border var(--ds-transition-duration-slow) var(--ds-ease-in-out), background-color 200ms var(--ds-ease-in-out); } @@ -244,13 +259,16 @@ --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); } +/* Border width zeroes with the color (see .collapsed .newSession); the row + centers its surviving icon once the input unmounts. */ .collapsed .search { height: 24px; padding: 0; margin-bottom: 8px; gap: 0; - border-color: transparent; + border-width: 0; background: transparent; + justify-content: center; } /* The capsule's leading icon, upgraded to the rail's search control. While @@ -398,7 +416,10 @@ max-width: 120px; overflow: hidden; white-space: nowrap; - transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); + /* Opacity restated: this shorthand would otherwise replace .wide's fade. */ + transition: + max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out), + opacity 200ms var(--ds-ease-in-out); } .collapsed .footLabel { From dd7d198c0d41d57b698f2dac21874bf541d3b671 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:37:54 +0800 Subject: [PATCH 13/32] Revert "fix(gui): keep the label fade and pin the rail icons to one axis" This reverts commit a4ae2f6c21c1eeed2495481a55a39762f4e64ccb. --- .../src/client/SidebarRoot.module.css | 35 ++++--------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 844c406e35..c580d47b75 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -17,11 +17,8 @@ transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -/* The 56px track paints a 1px column border, leaving a 55px content box; - the right padding gives back that pixel so every row holds exactly 24px - of content and all five glyphs share the track's 28px center axis. */ .root.collapsed { - padding: 14px 15px 6px 16px; + padding-top: 14px; } /* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and @@ -58,16 +55,10 @@ margin var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -/* Rail axis: every collapsed row centers its surviving icon in the 55px - content box (56px track minus the column border), putting all five glyphs - on one vertical line; capsule border-widths zero out so they stop taking - layout space. While wide content is still fading its flex:1 spans keep - absorbing the free space, so justify-content only takes over at unmount. */ .collapsed .logoRow { height: 24px; padding: 0; margin-bottom: 8px; - justify-content: center; } /* Brand group (figma I133:7632): fish + wordmark ride the text ink @@ -153,7 +144,7 @@ padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), margin var(--ds-transition-duration-slow) var(--ds-ease-in-out), gap var(--ds-transition-duration-slow) var(--ds-ease-in-out), - border var(--ds-transition-duration-slow) var(--ds-ease-in-out), + border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out), background-color 200ms var(--ds-ease-in-out); } @@ -161,14 +152,12 @@ background: var(--dsw-alias-button-floating-hover); } -/* Border width zeroes with the color: a leftover 1px border inside the - border-box would shave the rail row's content below 24px. */ .collapsed .newSession { height: 24px; padding: 0; margin-bottom: 8px; gap: 0; - border-width: 0; + border-color: transparent; background: transparent; } @@ -180,10 +169,7 @@ max-width: 200px; overflow: hidden; white-space: nowrap; - /* Opacity restated: this shorthand would otherwise replace .wide's fade. */ - transition: - max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out), - opacity 200ms var(--ds-ease-in-out); + transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .collapsed .newSessionLabel { @@ -215,7 +201,6 @@ height: 24px; padding-left: 0; margin-bottom: 8px; - justify-content: center; } .sectionLabel { @@ -251,7 +236,7 @@ padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), margin var(--ds-transition-duration-slow) var(--ds-ease-in-out), gap var(--ds-transition-duration-slow) var(--ds-ease-in-out), - border var(--ds-transition-duration-slow) var(--ds-ease-in-out), + border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out), background-color 200ms var(--ds-ease-in-out); } @@ -259,16 +244,13 @@ --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); } -/* Border width zeroes with the color (see .collapsed .newSession); the row - centers its surviving icon once the input unmounts. */ .collapsed .search { height: 24px; padding: 0; margin-bottom: 8px; gap: 0; - border-width: 0; + border-color: transparent; background: transparent; - justify-content: center; } /* The capsule's leading icon, upgraded to the rail's search control. While @@ -416,10 +398,7 @@ max-width: 120px; overflow: hidden; white-space: nowrap; - /* Opacity restated: this shorthand would otherwise replace .wide's fade. */ - transition: - max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out), - opacity 200ms var(--ds-ease-in-out); + transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .collapsed .footLabel { From c79b34bda594c9d6bd8c2973d6160934f327e6f4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:02:58 +0800 Subject: [PATCH 14/32] Revert "chore: run primary CI before push" --- .../process/2026-06-11-quality-gates.md | 2 +- .../2026-07-06-parallel-pre-push-gates.md | 2 +- .../2026-07-22-fast-local-git-hooks.i18n.yaml | 4 +- .../2026-07-22-fast-local-git-hooks.md | 2 - .../2026-07-22-fast-local-git-hooks.zh.md | 2 - ...-23-local-primary-ci-before-push.i18n.yaml | 6 --- ...2026-07-23-local-primary-ci-before-push.md | 38 ------------------- ...6-07-23-local-primary-ci-before-push.zh.md | 38 ------------------- .agents/skills/dsh-pre-push-checks/SKILL.md | 26 +++++-------- .../dsh-pre-push-checks/agents/openai.yaml | 2 +- AGENTS.md | 4 +- docs/development.i18n.yaml | 4 +- docs/development.md | 13 +++---- docs/development.zh.md | 13 +++---- lefthook.yml | 8 ++-- package.json | 1 - packages/client/AGENTS.md | 2 +- scripts/run-gates.ts | 2 +- 18 files changed, 37 insertions(+), 132 deletions(-) delete mode 100644 .agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml delete mode 100644 .agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md delete mode 100644 .agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index b42d84bbac..5e1db16e52 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -2,7 +2,7 @@ Status: implemented -[Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) restores hook/CI symmetry for the primary Node inventory. [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md) continues to own the pre-commit design. +The hook/CI symmetry in this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md); CI remains the exhaustive enforcement path. ## Problem diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 4733eabac6..87b1c0847b 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -2,7 +2,7 @@ Status: implemented -[Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) now owns the local-hook contract: pre-push selects the same primary inventory as CI. The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. +The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. ## Problem diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml index 7e6cb7f35e..361f7a0bd0 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml @@ -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 -2026-07-22-fast-local-git-hooks.md: 4a504c6c4f5816f00be6d86ddfbcb93baf0d7768 -2026-07-22-fast-local-git-hooks.zh.md: 792e035bc5761fc044cf231aff3bb59f31a47659 +2026-07-22-fast-local-git-hooks.md: a07af1cd424c86f7fa80ea946cd5012362cc66eb +2026-07-22-fast-local-git-hooks.zh.md: 78d4ea8980476609a9140737a75152eba123b308 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md index 4a504c6c4f..a07af1cd42 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md @@ -4,8 +4,6 @@ Status: implemented English | [中文](2026-07-22-fast-local-git-hooks.zh.md) -> **Superseded for pre-push:** [Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) replaces the typecheck-only publication checkpoint with the exact primary CI inventory. The fast pre-commit decision remains in force; the pre-push design below records the policy this repository no longer uses. - ## Problem An agent already runs the tests and checks that exercise its change, while commit, push, and CI can each repeat increasingly broad subsets of the same work. A full pre-push suite therefore delays every publication, amplifies unrelated local flakes, and gives no new signal when CI immediately runs the exhaustive matrix again. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md index 792e035bc5..78d4ea8980 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -4,8 +4,6 @@ Status: implemented [English](2026-07-22-fast-local-git-hooks.md) | 中文 -> **pre-push 部分已被取代:**[推送前本地运行主 CI](2026-07-23-local-primary-ci-before-push.md)以精确的主 CI 清单取代仅运行类型检查的发布检查点。快速 pre-commit 的决策继续有效;下文的 pre-push 设计记录了本仓库不再采用的策略。 - ## 问题 agent(智能体)已经会运行能够覆盖自身改动的测试和检查,而提交、推送与 CI 可能分别重复其中范围越来越广的子集。因此,全量 pre-push 套件会拖慢每次推送,放大与当前改动无关的本地偶发失败,而且 CI 紧接着再次运行完整矩阵时不会提供新信号。 diff --git a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml deleted file mode 100644 index e82aad513f..0000000000 --- a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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 -2026-07-23-local-primary-ci-before-push.md: 9d7f4d5a5aae428a003b1cbbb8911e7ad7c50831 -2026-07-23-local-primary-ci-before-push.zh.md: 3b58a828bd12d0149d8c4101d8665ee15b9fffc2 diff --git a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md deleted file mode 100644 index 9d7f4d5a5a..0000000000 --- a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: Local primary CI before push - -Status: implemented - -English | [中文](2026-07-23-local-primary-ci-before-push.zh.md) - -## Problem - -Hosted CI can become unavailable before repository code executes because of account, billing, quota, or runner failures. A typecheck-only publication hook then permits a remote branch update without coverage, snapshot, documentation, build, package, or built-entrypoint evidence precisely when the hosted workflow cannot supply that signal. - -Focused checks remain the right feedback loop during implementation, but their selection depends on the author correctly predicting every affected contract. Publication needs one complete, mechanically owned local baseline that does not depend on the hosted control plane starting a job. - -## Decision - -[lefthook.yml](../../../../lefthook.yml) keeps pre-commit focused on staged lint, whitespace, and vendored-source metadata. Pre-push invokes `pnpm run check:pre-push` and blocks publication on any failure. - -The `check:pre-push` package script selects the `pre-push` mode in [scripts/run-gates.ts](../../../../scripts/run-gates.ts). Both `pre-push` and `ci-primary` return the same `ciPrimaryGates()` inventory, so the hook and the primary Node CI job cannot drift through separately maintained command lists. Build consumers retain their explicit scheduler dependencies, and `DSH_GATE_CONCURRENCY` remains the resource-control seam for constrained hosts. - -Authors still run focused checks while iterating. They do not run the full aggregate immediately before a normal push because the hook owns that one exhaustive local execution. A hook failure is fixed or reported as a blocker; bypass requires explicit approval. - -This contract is equivalent to the keyless primary Node CI aggregate on the current host. It does not claim the supported-version or operating-system matrix, Python SDK, real-provider, native, or sandbox workflow signals that require their own environments. - -## Supersedes - -This decision supersedes the pre-push half of [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). Its staged pre-commit design remains in force. It also restores the local publication role described by [Parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md) without reviving a second gate inventory. - -## Alternatives considered - -- **Rely on restoring hosted CI availability** — repairs the immediate administrative failure but leaves publication without a baseline during the next control-plane or runner outage. -- **Wire pre-push to `check:all`** — reuses a broad local command, but that inventory intentionally differs from the primary CI contract and would make “CI equivalent” inaccurate. -- **Copy the CI commands into `lefthook.yml`** — makes the hook visibly comprehensive but creates a second inventory that can drift whenever CI changes. -- **Keep typecheck-only pre-push and require a manual command during outages** — preserves low latency but relies on every author noticing the outage and remembering an exceptional procedure before each update. - -## Consequences - -Every normal push pays the primary CI aggregate's wall time and may be blocked by a repository-wide local failure unrelated to the outgoing diff. In return, every published revision has observed coverage, snapshots, documentation, build, package, and built-entrypoint evidence from one shared inventory even when hosted jobs never start. - -The result is local evidence, not a substitute for unavailable remote environments. Pull requests and handoffs report hosted billing, provider, platform, and pending states separately instead of presenting a successful macOS pre-push run as a green GitHub matrix. diff --git a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md deleted file mode 100644 index 3b58a828bd..0000000000 --- a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: 推送前本地运行主 CI - -Status: implemented - -[English](2026-07-23-local-primary-ci-before-push.md) | 中文 - -## 问题 - -托管 CI 可能会因账户、计费、配额或运行器故障,在仓库代码开始执行之前就不可用。此时,仅运行类型检查的发布钩子会允许更新远端分支,却缺少覆盖率、快照、文档、构建、包(package)和构建后入口点的证据;恰恰这时,托管工作流无法提供这些信号。 - -实现期间,聚焦检查仍是正确的反馈循环,但检查选择取决于作者是否正确预判每项受影响的契约。发布需要一套由机制统一维护的完整本地基线,且不依赖托管控制平面能否启动作业。 - -## 决策 - -[lefthook.yml](../../../../lefthook.yml) 让 pre-commit 集中处理暂存文件 lint、空白错误和 vendor 源码元数据。Pre-push 调用 `pnpm run check:pre-push`,任何检查失败都会阻止发布。 - -`check:pre-push` 包脚本从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `pre-push` 模式。`pre-push` 和 `ci-primary` 都返回同一份 `ciPrimaryGates()` 清单,因此钩子与主 Node CI 作业不会因分别维护命令列表而产生漂移。构建产物消费方仍保留对调度器的显式依赖关系,`DSH_GATE_CONCURRENCY` 仍是资源受限主机的资源控制 seam。 - -作者在迭代时仍运行聚焦检查。正常推送前不立即运行全量聚合,因为钩子负责这一次全面的本地执行。钩子失败必须修复或报告为阻塞项;绕过钩子需要明确批准。 - -本契约等同于当前主机上的 keyless 主 Node CI 聚合。它不代表已经取得受支持版本矩阵或操作系统矩阵、Python SDK、真实模型提供方、原生构建或沙箱工作流的信号;这些信号需要各自的环境才能取得。 - -## 取代关系 - -本决策取代[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)中有关 pre-push 的部分。其中面向暂存文件的 pre-commit 设计继续有效。它还恢复了[并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)中描述的本地发布职责,但没有重新引入第二份门禁清单。 - -## 考虑过的替代方案 - -- **依靠恢复托管 CI 可用性**——可以修复当前的管理性故障,但下一次控制平面或运行器中断时,发布流程仍没有基线。 -- **将 pre-push 接入 `check:all`**——能够复用一条广泛的本地命令,但其清单有意不同于主 CI 契约,会使「等同于 CI」的表述不准确。 -- **将 CI 命令复制到 `lefthook.yml`**——能够直观展示钩子的全面性,但会创建第二份清单,并在每次 CI 变更时产生漂移。 -- **保留仅运行类型检查的 pre-push,并要求中断期间手动运行命令**——能够维持低延迟,但依赖每位作者发现中断,并在每次更新前记得执行特殊流程。 - -## 结果 - -每次正常推送都要承担主 CI 聚合的实际耗时,也可能被与待推送 diff 无关的全仓本地失败阻塞。相应地,即使托管作业从未启动,每个已发布版本仍有一套由共享清单实际运行得出的覆盖率、快照、文档、构建、包和构建后入口点证据。 - -该结果只是本地证据,不能代替不可用的远端环境。PR(Pull Request)和交接会分别报告托管服务计费、提供方、平台与待处理状态,而不会把一次成功的 macOS pre-push 运行表述成 GitHub 矩阵已通过。 diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 65ab35f9a3..31f82eed94 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -1,11 +1,11 @@ --- name: dsh-pre-push-checks -description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select focused implementation evidence and preserve the mandatory primary-CI pre-push gate. +description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select the smallest tests and checks that cover the outgoing diff without reflexively running the full repository suite. --- # DSH Pre-Push Checks -Use this skill to run relevant implementation evidence once and the complete local publication baseline once before a `deepseek-harness` push. Pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push invokes `pnpm run check:pre-push`, which selects the same primary Node inventory as `pnpm run check:ci`. Hosted CI still owns platform- and provider-specific evidence. +Use this skill to run relevant local evidence once before a `deepseek-harness` push. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix. ## Inspect the outgoing change @@ -27,15 +27,15 @@ If the branch has no upstream or that range is not meaningful for the stack, com ## Select relevant evidence -Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression. Run that evidence while iterating; the hook supplies the universal publication baseline. +There is no universal local baseline beyond the hooks. Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression; add broader checks only for surfaces the diff actually reaches. -- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to pre-push unless the change is genuinely cross-cutting or the user requests it earlier. +- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it. - **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it. - **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output. - **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke. - **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets. -Do not manually repeat a passing check merely because commit or push follows. In particular, do not run `check:pre-push` immediately before a normal push and then repeat the same aggregate in the hook. +Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook. ### Focus unit coverage on the affected source @@ -60,19 +60,13 @@ pnpm exec vitest related packages///src/.ts \ `vitest related` cannot discover behavior reached only through configuration, dynamic loading, subprocesses, workers, built artifacts, or external providers; select those owning tests explicitly. Do not use `--passWithNoTests`, lower coverage thresholds, or narrow `--coverage.include` merely to hide an uncovered affected file. If a selected package scope fails because one focused test does not cover it, add its other relevant owning tests or narrow the source scope only when the excluded modules cannot be affected by the change. -## Mandatory publication gate +## Full local rehearsal -The normal push runs the complete keyless primary Node inventory through Lefthook: - -```sh -pnpm run check:pre-push -``` - -Invoke the command directly only when the user requests a rehearsal independent of publication or when diagnosing the hook itself. Add the relevant `pnpm run test:e2e` target when credentials are available and behavior depends on a real provider; real-API e2e is not part of the keyless primary inventory. +Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate. ## Handle failures -If a relevant check or the publication gate fails, stop and fix or explain the blocker. Do not push and hope CI differs. +If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs. If a failure looks environment-specific, prove it: @@ -83,9 +77,9 @@ If a failure looks environment-specific, prove it: ## Push procedure -1. Run the selected focused checks once during implementation. +1. Run the selected relevant checks once. 2. Commit normally and inspect any files changed by the pre-commit fixer before continuing. -3. Push normally so the complete primary Node hook runs once. +3. Push normally so the incremental typecheck hook runs. 4. Verify the remote ref matches local `HEAD`. ```sh diff --git a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml b/.agents/skills/dsh-pre-push-checks/agents/openai.yaml index be0ef1f114..4a38ea4da8 100644 --- a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml +++ b/.agents/skills/dsh-pre-push-checks/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "DSH Pre-Push Checks" - short_description: "Run focused and primary CI checks before push" + short_description: "Run the relevant DeepSeek Harness checks before push" default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch." diff --git a/AGENTS.md b/AGENTS.md index 2aee7c5af9..8007f32b90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,10 +73,10 @@ When required `gh`, `pnpm`, build, test, or generator commands fail because the ### Run relevant checks locally -Agents MUST run focused checks while implementing; normal pre-push runs `check:pre-push`, the same primary Node inventory as `check:ci`. Select focused evidence with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run. +Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run. - Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior. -- Let the hook run the full aggregate once; never bypass a failure without explicit approval. Remote CI owns the platform and provider matrix. +- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. - `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). ## Secrets / .env diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index cfea7907d5..40828094ce 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -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 -development.md: 2496afa3a5b27efdade4a7c6e66e54b55a25b075 -development.zh.md: 9b44eb1c2679be395f7a19882dcdd38261833a06 +development.md: 4294038e40aa774a006874e6641ca63eea44beeb +development.zh.md: 1f07c95dd60d0554b945c29e6e3ba8bc6ca9841a diff --git a/docs/development.md b/docs/development.md index 2496afa3a5..4294038e40 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,20 +75,20 @@ DEEPSEEK_BASE_URL=https://... # optional ## Git hooks -lefthook is configured in `lefthook.yml` with fast commit-local checks and a comprehensive publication check: +lefthook is configured in `lefthook.yml` as a fast local checkpoint: - `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. -- `pre-push` invokes `pnpm run check:pre-push`, which selects the same primary Node gate inventory as `pnpm run check:ci` from `scripts/run-gates.ts`. +- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates). The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. -Contributors run [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) while iterating. A normal push runs the complete local primary aggregate once and stops publication on any failure; do not run the same aggregate immediately before pushing. +The hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix. -The pre-push result covers the keyless primary Node lane on the current host. It does not replace the supported-version and platform matrix, Python SDK tests, real-API e2e, or sandbox workflows. `pnpm run check:all` remains a broad opt-in development inventory; it is independent of both Git hooks and is not the publication contract. +Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction. ## CI gates -The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. `check:pre-push` and the primary CI job share one gate inventory; the separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. +The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. ## Daily commands @@ -98,8 +98,7 @@ Use these from the repo root: pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run check:pre-push # primary Node CI inventory; runs automatically before push -pnpm run check:all # broad opt-in development gate set; not wired to Git hooks +pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix diff --git a/docs/development.zh.md b/docs/development.zh.md index 9b44eb1c26..1f07c95dd6 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -75,20 +75,20 @@ DEEPSEEK_BASE_URL=https://... # optional ## Git 钩子 -lefthook 在 `lefthook.yml` 中配置了快速的提交级检查和全面的发布检查: +lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: - `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; -- `pre-push` 调用 `pnpm run check:pre-push`;该命令从 `scripts/run-gates.ts` 中选择与 `pnpm run check:ci` 相同的主 Node 门禁清单。 +- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 -贡献者在迭代过程中运行[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally)。正常推送会在本地完整运行一次主 CI 聚合,并在任何检查失败时阻止发布;不要在推送前立即重复运行同一个聚合。 +这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。 -pre-push 的结果覆盖当前主机上的 keyless 主 Node lane,但不能代替受支持版本与平台矩阵、Python SDK 测试、真实 API e2e 或沙箱工作流。`pnpm run check:all` 仍是一份广泛的可选开发检查清单;它独立于两个 Git 钩子,也不属于发布契约。 +贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。 ## CI 门禁 -keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。`check:pre-push` 与主 CI job 共用一份门禁清单;单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 +keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 ## 日常命令 @@ -98,8 +98,7 @@ keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若 pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run check:pre-push # primary Node CI inventory; runs automatically before push -pnpm run check:all # broad opt-in development gate set; not wired to Git hooks +pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix diff --git a/lefthook.yml b/lefthook.yml index d566de8b1c..cf2e6bb11d 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,5 +1,5 @@ -# Git hooks (lefthook). Pre-commit keeps commit-local checks fast; pre-push -# runs the primary Node CI inventory before publication. +# Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full +# repository-wide gate matrix. # Install: `pnpm exec lefthook install` (runs automatically via postinstall). pre-commit: @@ -19,5 +19,5 @@ pre-commit: pre-push: jobs: - - name: primary CI - run: pnpm run check:pre-push + - name: typecheck + run: node_modules/.bin/tsc -b --pretty false diff --git a/package.json b/package.json index 7345fcd173..38db3a2ac7 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "test:web": "npm run build:web && vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", - "check:pre-push": "tsx scripts/run-gates.ts pre-push", "check:ci": "tsx scripts/run-gates.ts ci-primary", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index e858d9ffbc..d3011ed85f 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -66,7 +66,7 @@ Run the narrowest rung that covers what you touched; escalate only when the chan 1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck. 2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`). -3. **Before every push** — the normal hook runs `pnpm run check:pre-push` (the repo-wide primary CI inventory). Do not invoke it manually immediately before pushing. +3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit. If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep. diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 0399e3249f..896ffacfd1 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -168,7 +168,6 @@ function nodeOptions(...options: string[]): string { function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': - case 'pre-push': return ciPrimaryGates() case 'ci-static': return ciStaticGates() @@ -191,6 +190,7 @@ function gatesForMode(selected: Mode): Gate[] { return ciWindowsObservationalGates() case 'node-compat': return nodeCompatGates() + case 'pre-push': return [] case 'check-all': return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), From 691efb0025bfeeebd3aca9fe833aa01526aa960b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 11:11:21 +0800 Subject: [PATCH 15/32] =?UTF-8?q?feat(gui):=20add=20'edit'=20variant=20for?= =?UTF-8?q?=20tool=20row,=20showing=20as=20'Edit=20=C2=B7=20path'=20like?= =?UTF-8?q?=20Read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'edit' to ToolRowVariant type and TOOL_VARIANTS mapping - Add 'edit' title and summary key preference (path/file_path) - Add IconEditOutline16 as the leading icon for edit variant --- .../src/client/chat/GenericToolCard.tsx | 3 ++- .../src/client/contract/tool-call-model.ts | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 32793fb804..a772b1b191 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from 'react' import { - IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconThinkOutline14, + IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolViewProps } from '../contract/toolview.ts' import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts' @@ -17,6 +17,7 @@ const VARIANT_ICONS: Record = { search: , read: , bash: , + edit: , others: , } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 0c3e100ec8..9a3978cf5a 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -10,18 +10,18 @@ export type { ToolCallBlock } from './toolview.ts' /** The frozen slice the chat view hands to toolview components as `block` * (both members are cache-stable references off ConversationSnapshot). */ -/** The five figma row variants (think is fed by reasoning blocks, not tool calls). */ -export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'others' +/** The six figma row variants (think is fed by reasoning blocks, not tool calls). */ +export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'edit' | 'others' /** Row state semantic; colors self-supplied via StateDot (design gives none). */ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped' /** Figma row titles per variant (design literals, not translatable copy). */ export const VARIANT_TITLES: Record = { - think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', others: 'Tool call', + think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', edit: 'Edit', others: 'Tool call', } -/** Known tool name -> variant; fs write/edit intentionally fall to others (no figma form). */ +/** Known tool name -> variant; fs write intentionally fall to others (no figma form). */ const TOOL_VARIANTS: Record = { bash: 'bash', read: 'read', @@ -29,6 +29,7 @@ const TOOL_VARIANTS: Record = { web_search: 'search', grep: 'search', glob: 'search', + edit: 'edit', } /** @@ -78,6 +79,7 @@ const SUMMARY_KEYS: Record = { read: ['path', 'file_path', 'url'], search: ['query', 'pattern', 'url'], think: [], + edit: ['path', 'file_path'], others: [], } From d7d865060009019f1647a38607f05d664095913f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 11:32:16 +0800 Subject: [PATCH 16/32] fix(gui): expand thinking rows on click --- .../src/client/chat/AssistantMarkdown.tsx | 1 + .../src/client/chat/ToolRow.tsx | 47 +++++++++++++++---- .../tests/chat-tool-row.spec.tsx | 20 ++++++++ 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 61ac069b1c..fd612990ce 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -32,6 +32,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { summary={firstLine(text)} body={text} state={running ? 'running' : 'ok'} + expandOnRowClick /> ) } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 0bb92e9f66..f1a5ce7440 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -4,7 +4,7 @@ // no inline output (full results live in the details panel). Expand state is // component-local view state; row click hands the selection off to the owner. -import { useState, type ReactNode } from 'react' +import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' @@ -20,6 +20,8 @@ export interface ToolRowProps { /** Expanded-body text; null = not expandable (leading slot never toggles). */ body: string | null state: ToolRowState + /** Makes the row itself the expand control instead of only its leading icon. */ + expandOnRowClick?: boolean | undefined /** Selection handoff (row click), already bound to this call by the owner. */ onOpenDetails?: (() => void) | undefined } @@ -35,31 +37,56 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { } } -export function ToolRow({ variant, icon, title, summary, body, state, onOpenDetails }: ToolRowProps) { +export function ToolRow({ + variant, + icon, + title, + summary, + body, + state, + expandOnRowClick = false, + onOpenDetails, +}: ToolRowProps) { const [expanded, setExpanded] = useState(false) const expandable = body !== null const open = expanded && expandable + const rowExpands = expandable && expandOnRowClick + const toggleExpand = () => { + setExpanded((v) => !v) + } + const toggleFromLeading = (event: MouseEvent) => { + event.stopPropagation() + toggleExpand() + } + const toggleFromKeyboard = (event: KeyboardEvent) => { + if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + toggleExpand() + } return (
- {expandable ? ( + {expandable && !rowExpands ? ( ) : ( - {leadingFor(state, icon)} + + {open ? : leadingFor(state, icon)} + )} {title} {!open && ( diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 1a381adda8..b977cf003d 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -6,6 +6,7 @@ afterEach(cleanup) import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts' +import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { ToolRow } from '../src/client/chat/ToolRow.tsx' import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -114,6 +115,25 @@ describe('ToolRow', () => { }) }) +describe('ThinkRow', () => { + it('expands from either Think or the reasoning summary', () => { + const view = render( + , + ) + 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') + }) +}) + describe('GenericToolCard', () => { const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolViewProps => ({ callId: 'c1', toolName, block, From edb999fd0fcb0263c306f1a150db07d667b1416c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 11:40:48 +0800 Subject: [PATCH 17/32] test(gui): cover thinking row disclosure --- ...3-thinking-row-disclosure-target.i18n.yaml | 6 ++++ ...26-07-23-thinking-row-disclosure-target.md | 29 ++++++++++++++++++ ...07-23-thinking-row-disclosure-target.zh.md | 29 ++++++++++++++++++ apps/web/tests/smoke-fixture.e2e.ts | 30 +++++++++++++++---- 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.i18n.yaml new file mode 100644 index 0000000000..a2fcf167a7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-23-thinking-row-disclosure-target.md: f698c3cb0b73bf5c65b5d4b5b3f29de3080e0af6 +2026-07-23-thinking-row-disclosure-target.zh.md: 0fba5c1d8f7beec7300dcd51e118a08d57d0e74f diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.md b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.md new file mode 100644 index 0000000000..f698c3cb0b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.md @@ -0,0 +1,29 @@ +# Agent Note: Thinking rows use one disclosure target + +Status: implemented + +English | [中文](2026-07-23-thinking-row-disclosure-target.zh.md) + +## Problem + +A collapsed reasoning entry presents `Think` and its one-line reasoning summary as one visual row, but an icon-only disclosure control leaves both visible labels inert. Applying title expansion to every tool row would instead break the generic tool-row contract, where the row opens details and only the leading control expands arguments. + +## Decision + +`ToolRow` exposes the opt-in `expandOnRowClick` policy. `ThinkRow` enables it so the title and reasoning summary form one accessible disclosure target; pointer clicks, Enter, and Space toggle the same component-local expanded state. Tool rows that do not opt in retain row-to-details selection and leading-control argument expansion. + +## Verification + +The component spec pins both Think click targets and the unchanged generic tool-row handoff. The keyless browser fixture loads the real sidebar and conversation bundles, opens an authored reasoning session, clicks the summary and title, and checks the disclosure state and expanded body. + +## Alternatives considered + +**Expand every tool row from its title.** Generic tool rows use row clicks for details selection, so sharing this behavior would conflate two controls. + +**Keep icon-only disclosure.** The smallest hit target remains disconnected from the labels that describe the hidden content. + +**Render separate title and summary buttons.** Two controls for one expanded state add duplicate focus stops and ambiguous semantics. + +## Consequences + +Thinking rows gain a larger pointer target and keyboard disclosure semantics without changing other tool interactions. The generic row component carries one optional policy because disclosure ownership differs between reasoning and tool calls. diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.zh.md b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.zh.md new file mode 100644 index 0000000000..0fba5c1d8f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.zh.md @@ -0,0 +1,29 @@ +# Agent Note: thinking 行使用单一展开目标 + +Status: implemented + +[English](2026-07-23-thinking-row-disclosure-target.md) | 中文 + +## 问题 + +折叠的推理(reasoning)条目在同一视觉行中呈现 `Think` 和单行推理摘要,但仅图标可展开会让两个可见标签都无法交互。若让所有工具行均可通过标题展开,又会破坏通用工具行的契约:整行负责打开详情,只有前导控件负责展开参数。 + +## 决策 + +`ToolRow` 提供显式启用的 `expandOnRowClick` 策略。`ThinkRow` 启用该策略,让标题和推理摘要组成单一且无障碍的展开目标;鼠标点击、Enter 和 Space 都切换同一个组件本地展开状态。未启用该策略的工具行仍由整行完成详情选择,由前导控件展开参数。 + +## 验证 + +组件测试固定两个 Think 点击目标以及未改变的通用工具行交接行为。无密钥浏览器 fixture(测试前置数据)加载真实的侧边栏与会话 bundle,打开包含推理内容的既定会话,点击摘要与标题,并检查展开状态和展开后的正文。 + +## 考虑过的替代方案 + +**让每个工具行都可通过标题展开。** 通用工具行将整行点击用于详情选择,共享这一行为会混淆两个控件。 + +**保留仅图标展开。** 最小的点击目标仍与描述隐藏内容的标签脱节。 + +**把标题和摘要分别渲染为按钮。** 两个控件共享一个展开状态,会增加重复的焦点停靠点并产生含糊语义。 + +## 后果 + +thinking 行获得更大的鼠标点击目标和键盘展开语义,同时不改变其他工具交互。通用行组件承担一个可选策略,因为推理与工具调用的展开所有权不同。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index b75716c615..d1a9e8417c 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,10 +1,10 @@ // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins // registry surface + __DSH_BOOT__ injection + built shell dist in a real // chromium. First describe: manifest injection + fail-loud half. Second -// describe: the settled success pass — five REAL tsdown bundles (the -// infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation -// round lands in smoke-real under the W5 real-host standard. +// describe: the settled success pass — seven REAL tsdown bundles (the +// infrastructure four + layout/sidebar/conversation) load through the DI +// chain in ?fixture mode and the three-column frame appears in one flip. The +// full conversation round lands in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -24,6 +24,8 @@ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: b { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ @@ -91,7 +93,7 @@ describe('web boot chain (keyless, real carrier)', () => { }) }) -describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => { +describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', () => { const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser @@ -143,6 +145,24 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') }) + it('expands fixture reasoning from either its summary or Think title', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure')) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"][aria-selected]').first().click() + + const thinkRoot = page.locator('[data-variant="think"]').first() + const think = thinkRoot.getByRole('button') + await think.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await think.getAttribute('aria-expanded')).toBe('false') + + await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click() + expect(await think.getAttribute('aria-expanded')).toBe('true') + expect(await thinkRoot.locator(':scope > div').count()).toBe(2) + + await think.getByText('Think', { exact: true }).click() + expect(await think.getAttribute('aria-expanded')).toBe('false') + }) + it('stayed clean: no page errors across the whole load chain', () => { expect(pageErrors).toEqual([]) }) From 1ba00bf80cbafcaab030d590407cad7da54bcb3c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 12:07:11 +0800 Subject: [PATCH 18/32] feat(web): inject workspace instructions --- .../feature/2026-06-24-workspace-context.md | 4 +- apps/cli/README.md | 4 +- apps/cli/src/headless.ts | 7 +- apps/cli/src/web.ts | 7 +- apps/web/tests/smoke-real.e2e.ts | 107 +++++++++++++++++- packages/host/runtime/README.md | 7 +- packages/host/runtime/package.json | 1 + packages/host/runtime/src/boot.ts | 8 +- packages/host/runtime/src/start.ts | 7 +- .../host/runtime/tests/host-runtime.spec.ts | 60 +++++++++- packages/host/runtime/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 12 files changed, 199 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 3bcb0c2bea..394c0de708 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -12,7 +12,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain ## Decision -The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. `@deepseek-ai/dsh-agent-core` mounts it for both product front doors and forwards its config. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability. +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability. The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. @@ -76,7 +76,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. +Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). diff --git a/apps/cli/README.md b/apps/cli/README.md index b8ff616d59..87f5e670e3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,6 +1,6 @@ # `@deepseek-ai/dsh` -The `dsh` command-line entry, following the `apps/` assembly tier proposed by the `dsh web` PR (#443): `apps/*` are product assemblies over `packages/*` libraries. This branch ships one surface — plain `dsh [config.yml]` boots the interactive TUI coding agent — and reserves the `web` and `-p`/`--prompt` subcommands for that PR so the dispatch merges as a union. +The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. The TUI surface: @@ -10,6 +10,8 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +The Web surface treats its invoking directory as the default project and loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget. + ## Install (developer machine) Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index dc1fa192a1..303bac61f8 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -78,7 +78,12 @@ export async function runHeadless(argv: string[]): Promise { } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ boot: { persistenceRoot: './.sessions' } }) + const host = await startHost({ + boot: { + persistenceRoot: './.sessions', + workspaceContext: false, + }, + }) const api = new InProcessApiClient(host.handler) const created = await unwrap(await api.sessions.create({}), () => host.dispose()) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 02e98e78b5..66a99bb577 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -36,7 +36,12 @@ export async function runWeb(argv: string[]): Promise { } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ boot: { persistenceRoot: './.sessions' } }) + const host = await startHost({ + boot: { + persistenceRoot: './.sessions', + workspaceContext: { maxBytes: 65_536 }, + }, + }) // Web UI plugin chain: in-memory Loader tree over the eight UI packages, // then the registry that feeds __DSH_BOOT__ and /plugins//client.js. diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a51e0c9e56..9ccdcad606 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -15,7 +15,8 @@ // and theme after, reload recovery last. Tests run sequentially in-file. import type { ChildProcess } from 'node:child_process' import { spawn } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -57,6 +58,25 @@ function waitForReadyLine(child: ChildProcess): Promise { }) } +async function rpc(baseUrl: string, method: string, payload: unknown): Promise { + const response = await fetch(`${baseUrl}/api/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: `smoke-${method}`, + method, + payload, + }), + }) + if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`) + const body = await response.json() as { + result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } + } + if (!body.result.ok) throw new Error(`${method} failed: ${body.result.error.code}: ${body.result.error.message}`) + return body.result.value +} + /** W5 screenshot: evidence for the figma comparison, not a failure artifact. */ async function screen(page: Page, name: string): Promise { await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) }) @@ -116,6 +136,91 @@ describe('dsh web keyless CLI smoke', () => { rmSync(sessionsDir, { recursive: true, force: true }) } }) + + it('injects the invoking workspace AGENTS.md into the provider request', async () => { + requireDist() + const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-')) + mkdirSync(join(workspace, '.git')) + writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n') + + let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void + const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => { + resolveProviderRequest = resolve + }) + const provider = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] }) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end([ + 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', + 'data: {"choices":[{"delta":{"content":"done"}}]}', + 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + }) + }) + await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve)) + const address = provider.address() + if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port') + const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href + const child = spawn( + process.execPath, + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + { + cwd: workspace, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-workspace', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + DSH_HOME: join(workspace, '.dsh'), + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + try { + const baseUrl = await waitForReadyLine(child) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) + await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + sessionId: created.sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'go' }], + }) + const captured = await Promise.race([ + providerRequest, + new Promise((_resolve, reject) => { + setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref() + }), + ]) + const workspaceMessage = captured.messages?.find(message => + message.role === 'user' && message.content?.includes('web-workspace-context-probe')) + expect(workspaceMessage).toMatchInlineSnapshot(` + { + "content": " + The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. + + Instructions from: AGENTS.md + + web-workspace-context-probe + + ", + "role": "user", + } + `) + } finally { + const closed = child.exitCode === null + ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) + : Promise.resolve() + if (child.exitCode === null) child.kill('SIGTERM') + await closed + await new Promise(resolveClose => provider.close(() => { resolveClose() })) + rmSync(workspace, { recursive: true, force: true }) + } + }) }) describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => { diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index 39f9888180..60fe4b22fa 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-host-runtime -Host runtime assembly for `dsc`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. +Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, workspace instructions, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it. @@ -9,6 +9,7 @@ Which plugins mount and with what defaults is decided only here — shells must | Key | Default | Contract | |---|---:|---| | `persistenceRoot` | (required) | Root directory for JSONL session persistence. | +| `workspaceContext` | (required) | [`AGENTS.md`/`CLAUDE.md` loader](../../context/workspace-context/README.md) config with an explicit `maxBytes`, or `false` to disable it. | | `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. | | `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. | @@ -18,7 +19,7 @@ Unary methods take the narrow `RpcRequest

` and echo `request.rpcId`; a prompt ## Model Experience -Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents. +Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents. When `workspaceContext` is enabled, each agent-loop instance freezes the applicable workspace instructions into its logged request prefix; the owning package documents the exact [model-visible framing](../../context/workspace-context/README.md#prompt-shape). #### KV Cache effect @@ -27,5 +28,5 @@ No direct invalidation; the mounted model-facing plugins own their request-prefi ## Known Limitations and Deferred Work - **`respond` is a stub** — it always returns `not-pending`; the approval/question pending registry (stable-rpcId mint on accept, baseline replay on stream reopen, wire answerer) is the next host-side step. -- **`session.list` covers live sessions only** — cold sessions in the persistence directory are not yet merged into the listing; `host.describe.version` is a placeholder rather than the `apps/cli` package version. +- **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version. - **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet. diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index ea32040012..be3e9e1050 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -69,6 +69,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^" }, "peerDependencies": { diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index ca40b9f8b3..40b4837c91 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -23,6 +23,7 @@ import FsLocal from '@deepseek-ai/dsh-fs-local' import * as fsPolicy from '@deepseek-ai/dsh-fs-policy' import * as toolFs from '@deepseek-ai/dsh-tool-fs' import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -42,6 +43,8 @@ import * as spillPolicy from '@deepseek-ai/dsh-spill-policy' export interface BootHostOptions { /** Root directory for JSONL session persistence. */ persistenceRoot: string + /** Workspace-instruction byte budget/config, or false to disable AGENTS.md/CLAUDE.md loading. */ + workspaceContext: workspaceContext.Config | false /** Default provider route for created/resumed agents (defaults to 'deepseek', the only adapter bootHost registers). */ provider?: string /** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */ @@ -76,7 +79,7 @@ export interface HostHandle { /** * Compose the harness host plugin assembly (the one place deciding which plugins mount and * with what defaults — shells must not alter the assembly). - * @param options - persistence root and optional default provider/model. + * @param options - persistence, workspace instructions, and optional default routing. * @returns the booted handle (ctx + defaults + dispose). */ export async function bootHost(options: BootHostOptions): Promise { @@ -109,6 +112,9 @@ export async function bootHost(options: BootHostOptions): Promise { await ctx.plugin(fsPolicy) await ctx.plugin(toolFs, {}) await ctx.plugin(toolFsSearch, {}) + if (options.workspaceContext !== false) { + await ctx.plugin(workspaceContext, options.workspaceContext) + } // Skill stack with the demo default dshHome (~/.dsh via resolveDshHome). await ctx.plugin(SkillService, {}) await ctx.plugin(SkillLocal, {}) diff --git a/packages/host/runtime/src/start.ts b/packages/host/runtime/src/start.ts index 44412cf184..94e5f22da1 100644 --- a/packages/host/runtime/src/start.ts +++ b/packages/host/runtime/src/start.ts @@ -16,10 +16,9 @@ import { createApiProxy } from './api-proxy.ts' /** Options for startHost. */ export interface StartHostOptions { /** - * Passed through to bootHost verbatim (persistenceRoot required + - * provider?/model?). Future host-level knobs (profile, log sink — any - * output added to the assembly MUST be switchable off here) land as - * additive fields. + * Passed through to bootHost verbatim. Future host-level knobs (profile, + * log sink — any output added to the assembly MUST be switchable off here) + * land as additive fields. */ boot: BootHostOptions } diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index f3a2dc9a82..8f800dd08a 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from 'node:fs' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -15,11 +15,14 @@ import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/i /** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */ class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + constructor(private script: (StreamChunk[] | 'hang')[]) { super() } async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) const entry = this.script.shift() if (!entry) throw new Error('ScriptedAdapter: script exhausted') if (entry === 'hang') { @@ -79,7 +82,12 @@ afterEach(async () => { async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise { host = await startHost({ - boot: { persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')), provider: 'scripted', model: 'test-model' }, + boot: { + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')), + workspaceContext: false, + provider: 'scripted', + model: 'test-model', + }, }) host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script)) return host @@ -87,7 +95,10 @@ async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise { it('falls back to the deepseek defaults and disposes idempotently', async () => { - const handle: HostHandle = await bootHost({ persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')) }) + const handle: HostHandle = await bootHost({ + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')), + workspaceContext: false, + }) expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' }) expect(typeof handle.defaults.cwd).toBe('string') await handle.dispose() @@ -105,6 +116,41 @@ describe('bootHost / startHost', () => { await first host = undefined }) + + it('routes workspace instructions through the assembled agent request prefix', async () => { + const workspace = mkdtempSync(join(tmpdir(), 'dsh-host-workspace-')) + mkdirSync(join(workspace, '.git')) + writeFileSync(join(workspace, 'AGENTS.md'), 'host-workspace-context-probe\n') + const adapter = new ScriptedAdapter([textResponse('done')]) + host = await startHost({ + boot: { + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-workspace-sessions-')), + workspaceContext: { dshHome: join(workspace, '.dsh'), maxBytes: 65_536 }, + provider: 'scripted', + model: 'test-model', + cwd: workspace, + }, + }) + host.ctx.llm.registerAdapter(['scripted'], adapter) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + const agent = host.ctx.agents.get(sessionId) as Agent + const idle = waitForIdle(host.ctx, agent) + + expectOk(await host.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'go' }], + }))) + await idle + + const requestText = adapter.requests[0]?.messages + .flatMap(message => message.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') ?? '' + expect(requestText).toContain('Instructions from: AGENTS.md') + expect(requestText).toContain('host-workspace-context-probe') + }) }) describe('host.describe', () => { @@ -196,7 +242,9 @@ describe('sessions.prompt / cancel', () => { describe('sessions.history', () => { it('implicitly resumes a cold session, deduplicating concurrent calls to one attach', async () => { const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-host-resume-')) - const first = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } }) + const first = await startHost({ + boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' }, + }) first.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('persisted')])) const { sessionId } = expectOk(await first.api.sessions.create(request({}))) const agent = first.ctx.agents.get(sessionId) as Agent @@ -205,7 +253,9 @@ describe('sessions.history', () => { await idle await first.dispose() - host = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } }) + host = await startHost({ + boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' }, + }) host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([])) expect(host.ctx.agents.get(sessionId)).toBeUndefined() const [a, b] = await Promise.all([ diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index 9836e0b5fd..08efb88294 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -62,6 +62,9 @@ { "path": "../../fs/tool-fs-search" }, + { + "path": "../../context/workspace-context" + }, { "path": "../../llm/token-meter" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9b18ff2e5..3e28836146 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2077,6 +2077,9 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ version: link:../../workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../context/workspace-context devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ From c5ebcef8af0a3aa06304e2db97aab973cf4407b8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:30:43 +0800 Subject: [PATCH 19/32] test(ui-sidebar): cover expanded search control --- packages/client/ui-sidebar/tests/sidebar-root.spec.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index b0e8a9f769..08a2769049 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -205,6 +205,14 @@ describe('SidebarRoot', () => { } }) + it('expanded search focuses without toggling the sidebar', () => { + const { onToggleSidebar } = mount(...projectData()) + const input = screen.getByPlaceholderText('Search name, keywords...') + act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) + expect(document.activeElement).toBe(input) + expect(onToggleSidebar).not.toHaveBeenCalled() + }) + it('the search query survives a collapse/expand round trip', () => { vi.useFakeTimers() try { From fc1b308266166ffe849400500689967fa980e32e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:31:01 +0800 Subject: [PATCH 20/32] ci: keep required PR checks on portable runners --- ...rial-cross-platform-ci-reference.i18n.yaml | 4 +- ...7-21-serial-cross-platform-ci-reference.md | 12 +-- ...1-serial-cross-platform-ci-reference.zh.md | 12 +-- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 28 +++--- ...evidence-based-larger-hosted-runners.zh.md | 28 +++--- ...ortable-required-pull-request-ci.i18n.yaml | 6 ++ ...07-23-portable-required-pull-request-ci.md | 35 ++++++++ ...23-portable-required-pull-request-ci.zh.md | 35 ++++++++ .github/workflows/ci.yml | 86 ++++++++----------- 10 files changed, 152 insertions(+), 98 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md create mode 100644 .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 9922018569..aa0516648b 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -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 -2026-07-21-serial-cross-platform-ci-reference.md: ffc1fd5b37bc6c9e3427ee55a55300f93a1292f3 -2026-07-21-serial-cross-platform-ci-reference.zh.md: d7f87916865b83973abe6b0708203618cf536c8e +2026-07-21-serial-cross-platform-ci-reference.md: b795a0aff62c20967d2c85429c0c6115c1b9585d +2026-07-21-serial-cross-platform-ci-reference.zh.md: 223fd9cf20a1d8228cb0c6b1b2f3f95644becae6 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index ffc1fd5b37..b795a0aff6 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -6,7 +6,7 @@ English | [中文](2026-07-21-serial-cross-platform-ci-reference.zh.md) ## Problem -The pull-request workflow reaches its latency targets by scheduling the complete primary Node inventory concurrently inside one larger runner. The optimized scheduler still should not be its own only completeness oracle: a defect in its gate inventory or dependency graph could omit work while the optimized job stays green. +The pull-request workflow consolidates required checks into dedicated Linux and Windows jobs. Those jobs still should not be the only completeness oracle: a defect in their gate inventory or dependency graph could omit work while the required aggregate stays green. Encoding the one-minute non-Windows target and three-minute Windows target as job timeouts creates a separate failure mode. Hosted-runner startup and performance vary, so a correct gate can be cancelled at the target boundary before it emits useful diagnostics. The performance objective needs measurement against GitHub timestamps, while correctness needs enough time to finish. @@ -14,21 +14,21 @@ Reviewers also need a direct answer to a simpler question: what happens when the ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run only the optimized larger-runner and compatibility jobs. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. -Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only the optimized jobs; a master push runs only the three serial references. The one-minute non-Windows and three-minute Windows objectives are evaluated from completed hosted-job timestamps and reported as measurements; they are not `timeout-minutes` values. +Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. A higher-core hosted runner remains a possible future benchmark, but it is not the default: larger runners require organization-owned labels and provisioning, while a reference oracle should remain runnable without repository-external runner configuration. Provisioning one later can change the performance experiment without changing this correctness baseline. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered - **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression. - **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check. -- **Run the serial references on every pull request** - rejected because they deliberately trade wall time and runner consumption for simplicity and are not needed in the fast feedback loop. +- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts. - **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism. -- **Run the serial reference on larger runners** - rejected because the reference is the portable fallback for the organization-specific pull-request topology. The fast pull-request path uses provisioned larger runners; the serial master path keeps standard labels. +- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index d7f8791686..223fd9cf20 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -拉取请求工作流通过在一台更大型运行器内并发调度完整的主 Node 门禁清单来达到延迟目标。优化调度器仍不应成为自身唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使优化作业保持绿灯,也可能漏掉部分工作。 +拉取请求工作流将必需检查合并到专用的 Linux 和 Windows 作业中。这些作业仍不应成为唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使必需聚合结果保持绿灯,也可能漏掉部分工作。 将非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标写成作业超时,会引入另一种失败模式。托管运行器的启动时间和性能会波动,因此即使门禁本身正确,也可能在到达目标时间边界时被取消,来不及输出有用的诊断信息。性能目标需要根据 GitHub 时间戳衡量,而正确性验证需要给门禁留足完成时间。 @@ -14,21 +14,21 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求只运行使用更大型运行器的优化作业和兼容性作业。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 -master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行优化作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 +master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。仍可将更高核心数的托管运行器作为未来的基准测试,但不将其设为默认选择:更大型运行器需要组织自有的标签和预配,而参考判定基准应无需仓库外部的运行器配置即可运行。日后完成这类预配,可以改变性能实验而无需改变该正确性基线。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 - **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。 - **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。 -- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业有意以更长的总耗时和更多运行器用量换取简单性,快速反馈循环不需要它们。 +- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。 - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。 -- **在更大型运行器上运行串行参考流程**:不予采纳,因为该参考流程是特定组织拉取请求拓扑的可移植后备方案。快速拉取请求路径使用已预配的更大型运行器;串行 master 路径保留标准标签。 +- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 6277617a73..6dcafc39dd 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -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 -2026-07-22-evidence-based-larger-hosted-runners.md: c0fae2841f21c431d6416cd5d421929d70197abb -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 51c73a8a631af4f1254c795d09585770fc4e68bb +2026-07-22-evidence-based-larger-hosted-runners.md: c292a4ea49320d684c35d2b9986549d693efb914 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 5e59c787a85bd2093f0c3ceaa8290e7cd42528fa diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index c0fae2841f..c292a4ea49 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -14,11 +14,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256; the ceiling does not allocate idle machines or remove the need to bound workflow demand. -Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded: - -- `node 24 / complete` uses one 96-core Linux runner. One checkout, direct selection of the image's preinstalled Node 24 toolcache, pnpm- and ESLint-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 10 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore both caches without saving them, so cache compression and upload do not extend the required job; the master serial reference refreshes those caches outside the pull-request critical path. An uncached exact-head trace put ESLint at 38.11 seconds and coverage at 37.10 seconds, so the small ESLint restore remains useful on the critical path. The read-only job does not persist checkout credentials. -- Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory. -- `windows node 24 / complete` uses one 32-core Windows runner. One preparation wave feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step. +The pools are measurement infrastructure, not a dependency of ordinary pull requests. The [portable required-CI decision](2026-07-23-portable-required-pull-request-ci.md) runs branch-protection jobs on standard GitHub-hosted capacity; `suite=larger-runner-benchmark` compares isolated critical lanes across every provisioned size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. @@ -38,15 +34,15 @@ The same benchmark measured the required Windows build surfaces across every pro Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. -The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head production run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. Production therefore avoids the Windows package-store cache, uses restore-only caches on latency-critical pull-request jobs, and bounds outer concurrency so typecheck, lint, coverage, and build do not oversubscribe one host. +The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head candidate run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. -Three host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. The setup-node action later spent 3.68 seconds printing cached Linux environment details and 46.56 seconds doing the same on Windows after both had already found Node 24.18.0 in the hosted toolcache. The two latency-critical jobs select the newest preinstalled 24.x directory directly, verify its major, and fail loud if the image no longer carries it; compatibility jobs retain setup-node because selecting a non-default runtime is their contract. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. +Host setup remains part of any comparison. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, while `actions/setup-node` spent 46.56 seconds printing cached Windows environment details after finding Node in the hosted toolcache. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. -Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Production therefore retains 16 ESLint workers and admits 10 independent repository gates at once, leaving capacity for the worker pools owned by those gates without starving later independent work. +Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit. -Linux coverage caps each project at 16 workers, while Windows keeps the 12-worker cap. The process-bound project contains exactly five suite files, so its fork count cannot reach either cap. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. It also includes the pi-ai adapter suite after two hosted aggregate runs delayed an idle-watchdog socket-close observation past its 100-millisecond test deadline. A 32-worker all-gate run on the 96-core host slowed coverage to 44.6 seconds and made a compute-budget regression cross its one-second threshold, so production stops at 16. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files. +The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection. -The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs. +Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the portable required path, while larger-runner suites run only by manual dispatch. ## Alternatives considered @@ -54,11 +50,11 @@ The workflow retains two manual measurement suites. `suite=larger-runner-benchma **Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises. -**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. Production uses 96 cores for the shorter controllable critical path; the benchmark suite retains both pools so a sustained image or pricing change can reverse that choice with evidence. +**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. The benchmark suite retains both pools because a sustained image or pricing change can reverse the comparison. **Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it. -**Keep compatibility and Python on standard runners.** Warm standard runs can fit, but runner setup alone has crossed the non-Windows target. Distinct larger pools isolate these environment contracts from that allocation lottery. +**Make larger-runner pools the required default.** This offers lower measured latency when allocation works, but a missing entitlement or delayed organization transfer leaves required jobs queued without repository diagnostics. The portable path accepts longer runtime, and manual suites preserve the performance experiment. **Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process. @@ -66,10 +62,10 @@ The workflow retains two manual measurement suites. `suite=larger-runner-benchma ## Consequences -Primary Node CI has one job, one setup wave, one complete gate inventory, and no shard selectors. Together with two Node compatibility executions, Python, and Windows, production has five paid larger-runner executions instead of seven coarse-lane executions or 49 gate-level executions. +The benchmark topology pays one setup wave per measured aggregate and retains no shard selectors. It runs paid larger-runner executions only when manually dispatched instead of charging every pull request. -GitHub rounds each larger-runner execution up to a whole minute, so eliminating setup waves reduces billed time as well as workflow complexity. The final aggregator remains on a standard runner because it begins only after the paid jobs release capacity. +GitHub rounds each larger-runner execution up to a whole minute, so whole-aggregate measurement exposes both billed time and workflow complexity without making that cost part of branch protection. -The current targets are observed performance contracts, not cancellation deadlines. Exact-head production runs must show every non-Windows job below one minute and the consolidated Windows job below three minutes; manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement. +Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement. -Production CI depends on the organization-owned runner labels in [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml). Missing or renamed pools leave jobs queued instead of falling back to standard capacity. All twelve pools remain provisioned so the manual benchmarks can re-evaluate the production size without an administrative setup cycle. +Missing or renamed organization-owned labels leave only manual benchmark jobs queued. All twelve pools remain defined so the benchmark can compare sizes after allocation recovers, while required CI follows the standard-runner fallback. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 51c73a8a63..5e59c787a8 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -14,11 +14,7 @@ Status: implemented 组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256;该上限既不会分配闲置机器,也不能免除限制工作流需求的必要性。 -生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片: - -- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、直接选择托管映像中预装的 Node 24 toolcache、恢复 pnpm 和 ESLint 缓存以及安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 10 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复这两项缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这两项缓存。一次未使用缓存的分支头精确运行轨迹显示,ESLint 耗时 38.11 秒,覆盖率耗时 37.10 秒,因此在关键路径上恢复这个较小的 ESLint 缓存仍有价值。该只读作业不会持久化代码检出凭据。 -- Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。 -- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一轮准备工作供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。 +这些运行器池是测量基础设施,不是普通拉取请求的依赖。依据[可移植必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),分支保护作业在 GitHub 标准托管容量上运行;`suite=larger-runner-benchmark` 比较每种已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 @@ -38,15 +34,15 @@ Status: implemented Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 -客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的生产运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,生产环境不使用 Windows 包存储缓存,在对延迟敏感的拉取请求作业中使用只恢复不保存的缓存,并限制外层并发度,以免类型检查、lint、覆盖率和构建在同一台主机上过度争用资源。 +客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的候选运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 -3 项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。setup-node action 在 Linux 和 Windows 均已从托管 toolcache 找到 Node 24.18.0 后,仍分别花费 3.68 秒和 46.56 秒输出缓存的环境详情。两个延迟关键作业会直接选择最新的预装 24.x 目录并验证其主版本号;如果映像不再提供该目录,作业会明确报错并失败。兼容性作业仍使用 setup-node,因为选择非默认运行时正是它们的契约。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 +任何比较都必须计入主机设置。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上;`actions/setup-node` 从托管 toolcache 找到 Node 后,仍花费 46.56 秒输出缓存的 Windows 环境详情。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 -内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,生产环境将 ESLint 工作线程上限维持在 16 个,并且同时最多运行 10 项相互独立的仓库门禁,既为这些门禁自身的工作线程池留出容量,又避免后续独立工作因资源不足而迟迟无法启动。 +内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 -Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则保留 12 个工作线程的上限。进程约束项目恰好包含 5 个套件文件,因此它的 fork 数量不可能达到任一上限。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。两次托管聚合运行都将空闲看门狗对套接字关闭的观测延迟到超过其 100 毫秒测试截止时间,因此这份清单还包含 pi-ai 适配器套件。在 96 核主机上使用 32 个工作线程运行全部门禁时,覆盖率耗时变慢至 44.6 秒,还使一项计算预算回归超过其 1 秒阈值,因此生产环境将工作线程数限制在 16 个以内。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。 +进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于能否得到确定的覆盖率结果,而非标称核心数。 -工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考;拉取请求只运行优化后的作业。 +只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用可移植的必需路径,大型运行器套件仅通过手动触发运行。 ## 曾考虑的替代方案 @@ -54,11 +50,11 @@ Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则 **将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。 -**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。生产环境使用 96 核来缩短可控的关键路径;基准测试套件保留两种规格,因此如果映像或定价发生持续性变化,仍可根据证据反转这项选择。 +**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。基准测试套件保留两种规格,因为映像或定价的持续变化可能反转比较结果。 **让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。 -**让兼容性和 Python 继续使用标准运行器。** 标准运行器热运行可以达到目标,但仅运行器设置一项就曾超过非 Windows 目标。不同的大型运行器池可以让这些环境契约免受这种分配波动影响。 +**将大型运行器池设为必需的默认选择。** 分配成功时,该方案能缩短实测延迟,但缺少使用资格或组织转移延迟都会使必需作业持续排队,且不会产生仓库诊断信息。可移植路径接受更长的运行时间,手动套件则保留性能实验。 **将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。 @@ -66,10 +62,10 @@ Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则 ## 后果 -主 Node CI 只有 1 个作业、1 轮设置、1 份完整门禁清单,而且没有分片选择器。加上 2 次 Node 兼容性执行、Python 和 Windows,生产环境共有 5 次付费大型运行器执行,而非 7 次粗粒度通道执行或 49 次门禁级执行。 +基准测试拓扑对每个实测聚合流程只承担 1 轮设置开销,且不保留分片选择器。付费大型运行器仅在手动触发时执行,而不会向每个拉取请求收取这项费用。 -GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此消除设置轮次既能减少计费时长,也能降低工作流复杂度。最终聚合作业仍使用标准运行器,因为它只会在付费作业释放容量后启动。 +GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整聚合测量能同时呈现计费时长与工作流复杂度,而不会让这项成本进入分支保护路径。 -当前目标是基于观测得到的性能契约,而非取消截止时间。分支头精确的生产运行必须表明每个非 Windows 作业都低于 1 分钟,合并后的 Windows 作业低于 3 分钟;当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。 +性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。 -生产 CI 依赖 [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) 中由组织持有的运行器标签。池缺失或改名会让作业一直排队,不会回退到标准容量。全部 12 个池均保持已预配状态,因此手动基准测试无需再次经过管理配置周期,就能重新评估生产规格。 +组织自有标签缺失或改名时,只有手动基准作业会排队。全部 12 个池均保持已定义状态,因此分配恢复后,基准测试仍可比较各规格,而必需 CI 则使用标准运行器后备路径。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml new file mode 100644 index 0000000000..463777eacf --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-23-portable-required-pull-request-ci.md: a430d43f7cb3dd4df987d35f3a49d130c397f8e3 +2026-07-23-portable-required-pull-request-ci.zh.md: cbd5d150056f77e52105f56c70a1ead74f052f59 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md new file mode 100644 index 0000000000..a430d43f7c --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -0,0 +1,35 @@ +# Agent Note: Portable required pull-request CI + +Status: implemented + +English | [中文](2026-07-23-portable-required-pull-request-ci.zh.md) + +## Problem + +Required pull-request jobs assigned to organization-owned runner labels remain queued when GitHub cannot allocate those pools. The workflow is valid and standard GitHub-hosted jobs can still pass, but `all checks passed` never starts and an otherwise healthy pull request cannot satisfy branch protection. + +Billing health, a runner definition's `Ready` state, and a large autoscaling ceiling do not prove that a named pool can receive a job. Required correctness checks need a portable execution path that does not depend on repository-external runner provisioning. + +## Decision + +[CI](../../../../.github/workflows/ci.yml) runs every required pull-request job on GitHub's standard `ubuntu-latest` or `windows-2025` capacity. The primary Node and Windows jobs keep their complete consolidated inventories, while top-level gates, coverage, ESLint, publint, and snapshot replay use one worker on the smaller hosts. Node versions are selected through `actions/setup-node`, and the Windows job enables Developer Mode before installing the symlinked workspace. + +The `node 24 / complete`, Node compatibility, Python SDK, and `windows node 24 / complete` jobs remain dependencies of `all checks passed`; no gate is removed or made observational to recover availability. Branch protection continues to require `e2e` and `all checks passed`. + +The two manual larger-runner suites and all twelve organization-owned labels remain available for measurement. They do not participate in ordinary pull requests. The [larger-runner measurements](2026-07-22-evidence-based-larger-hosted-runners.md) remain evidence for future performance work, while the [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent master-push completeness check. + +## Alternatives considered + +**Wait for organization-runner allocation to recover.** A queue with no assigned runner emits no repository diagnostic and can block every pull request indefinitely, so an external recovery is not a correctness path. + +**Use only the smallest organization-owned pools.** Every named pool crosses the same organization allocation boundary; reducing core count does not remove the dependency that caused the queue. + +**Skip or demote checks while capacity is unavailable.** This would make the status green by dropping evidence rather than by running the repository's required contracts. + +**Keep larger-host worker limits on standard runners.** Concurrent full-repository gates and their inner worker pools can oversubscribe the smaller memory and CPU allocation, turning an availability repair into contention failures. + +## Consequences + +Ordinary pull requests can acquire runners without organization-specific configuration, and a live exact-head run proves the same commands that branch protection consumes. The trade-off is longer elapsed time and more rounded standard-runner minutes than the measured larger-runner topology. + +Manual larger-runner benchmarks can remain queued without blocking pull requests. Restoring larger runners to the required path needs a separate evidence-based decision after exact-head jobs receive nonzero runner IDs and complete reliably; changing a definition's status alone is insufficient. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md new file mode 100644 index 0000000000..cbd5d15005 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 可移植的拉取请求必需 CI + +Status: implemented + +[English](2026-07-23-portable-required-pull-request-ci.md) | 中文 + +## 问题 + +分配到组织自有运行器标签的拉取请求必需作业,在 GitHub 无法为这些池分配运行器时会持续排队。工作流本身有效,GitHub 标准托管作业仍能通过,但 `all checks passed` 始终无法启动,原本健康的拉取请求因此无法满足分支保护要求。 + +账单状态正常、运行器定义处于 `Ready` 状态以及较高的自动扩缩容上限,都不能证明指定的运行器池可以接收作业。必需的正确性检查需要一条可移植的执行路径,且该路径不能依赖仓库外部的运行器预配。 + +## 决策 + +[CI](../../../../.github/workflows/ci.yml) 在 GitHub 标准的 `ubuntu-latest` 或 `windows-2025` 容量上运行每项拉取请求必需作业。主 Node 作业和 Windows 作业保留各自完整的合并清单,而顶层门禁、覆盖率、ESLint、publint 和快照回放在这些较小的主机上均使用 1 个工作线程。Node 版本通过 `actions/setup-node` 选择;Windows 作业会在安装采用符号链接的工作区前启用开发人员模式。 + +`node 24 / complete`、Node 兼容性、Python SDK 和 `windows node 24 / complete` 作业继续作为 `all checks passed` 的依赖项;为恢复可用性,不会移除任何门禁,也不会将其降为观测性检查。分支保护继续要求 `e2e` 和 `all checks passed`。 + +两项手动大型运行器套件和全部 12 个组织自有标签继续用于测量,但不参与普通拉取请求。[大型运行器测量结果](2026-07-22-evidence-based-larger-hosted-runners.md)继续作为后续性能工作的证据,[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则继续作为 master 推送时独立的完整性检查。 + +## 曾考虑的替代方案 + +**等待组织运行器恢复分配。** 未分配运行器的队列不会产生仓库诊断信息,而且可能无限期阻塞每个拉取请求,因此依赖外部恢复不能构成正确性路径。 + +**仅使用最小的组织自有运行器池。** 每个指定的运行器池都需要经过相同的组织分配边界;减少核心数不能消除导致作业排队的依赖。 + +**在容量不可用时跳过检查或降低其级别。** 这种方式通过丢弃证据而非执行仓库的必需契约来使状态变绿。 + +**在标准运行器上保留大型主机的工作线程上限。** 完整仓库门禁及其内层工作线程池并发运行时,可能超出较小主机的内存和 CPU 配额,使可用性修复变成资源争用故障。 + +## 后果 + +普通拉取请求无需组织专有配置即可获得运行器,一次实际的分支头精确运行能够证明分支保护使用的同一组命令。代价是,与实测的大型运行器拓扑相比,总耗时更长,而且按整分钟计费的标准运行器用量更多。 + +手动大型运行器基准测试可以继续排队,而不会阻塞拉取请求。要将大型运行器恢复为必需路径,需要在分支头精确作业获得非零运行器 ID 并可靠完成后,另行作出基于证据的决策;仅改变运行器定义的状态还不够。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c6036968f..2eb8071a84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,20 +27,19 @@ env: jobs: - # One large runner pays hosted setup once, then the repository scheduler - # overlaps the complete unsharded primary Node inventory. Build starts eagerly; - # only consumers of emitted output wait for it. + # One portable hosted runner pays setup once, then executes the complete + # unsharded primary Node inventory without organization-owned capacity. node-24: if: github.event_name == 'pull_request' - runs-on: dsh-ubuntu-24-04-96core + runs-on: ubuntu-latest name: node 24 / complete env: - DSH_COVERAGE_MAX_WORKERS: '16' + DSH_COVERAGE_MAX_WORKERS: '1' DSH_ESLINT_CACHE: '1' - DSH_ESLINT_CONCURRENCY: '16' - DSH_GATE_CONCURRENCY: '10' - DSH_PUBLINT_CONCURRENCY: '16' - DSH_SNAPSHOT_MAX_CONCURRENCY: '8' + DSH_ESLINT_CONCURRENCY: '1' + DSH_GATE_CONCURRENCY: '1' + DSH_PUBLINT_CONCURRENCY: '1' + DSH_SNAPSHOT_MAX_CONCURRENCY: '1' steps: - uses: actions/checkout@v6 with: @@ -62,16 +61,12 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - name: Select preinstalled Node, install dependencies, and prepare bubblewrap + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack, install dependencies, and prepare bubblewrap run: | - node_root="$(printf '%s\n' "$RUNNER_TOOL_CACHE"/node/"${PRIMARY_NODE_VERSION}".*/x64 | sort -V | tail -n 1)" - if [[ ! -d "$node_root" ]]; then - echo "preinstalled Node ${PRIMARY_NODE_VERSION}.x not found in $RUNNER_TOOL_CACHE" >&2 - exit 1 - fi - echo "$node_root/bin" >> "$GITHUB_PATH" - export PATH="$node_root/bin:$PATH" - [[ "$(node --version)" == "v${PRIMARY_NODE_VERSION}."* ]] corepack enable pnpm install --frozen-lockfile & install_pid=$! @@ -90,8 +85,7 @@ jobs: node-compat: if: github.event_name == 'pull_request' - # Distinct larger-runner pools avoid both standard-runner setup outliers and - # delayed allocation when independent environment contracts share one pool. + # Each compatibility contract receives an independent standard hosted job. runs-on: ${{ matrix.runner }} name: ${{ matrix.name }} env: @@ -103,12 +97,12 @@ jobs: include: - node: '22.19' name: node 22.19 - runner: dsh-ubuntu-24-04-4core - gate_concurrency: '2' + runner: ubuntu-latest + gate_concurrency: '1' - node: 26 name: node 26 - runner: dsh-ubuntu-24-04-32core - gate_concurrency: '2' + runner: ubuntu-latest + gate_concurrency: '1' steps: - uses: actions/checkout@v6 @@ -137,7 +131,7 @@ jobs: python-sdk: if: github.event_name == 'pull_request' - runs-on: dsh-ubuntu-24-04-8core + runs-on: ubuntu-latest name: python 3.10 / keyless SDK steps: - uses: actions/checkout@v6 @@ -158,15 +152,13 @@ jobs: # from observational gates without allowing them to fail the required job. windows: if: github.event_name == 'pull_request' - runs-on: dsh-windows-2025-32core + runs-on: windows-2025 name: windows node 24 / complete env: - # Keep ESLint itself single-threaded: 16 ESLint workers took 174 seconds on - # this image. The outer scheduler still overlaps lint with the other gates. - DSH_COVERAGE_MAX_WORKERS: '12' + DSH_COVERAGE_MAX_WORKERS: '1' DSH_ESLINT_CACHE: '1' - DSH_GATE_CONCURRENCY: '16' - DSH_PUBLINT_CONCURRENCY: '16' + DSH_GATE_CONCURRENCY: '1' + DSH_PUBLINT_CONCURRENCY: '1' steps: - uses: actions/checkout@v6 @@ -177,27 +169,21 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - # Extracting the many-file pnpm store cache is slower on this image than - # a clean parallel install, and saving it adds more latency after gates. - - name: Select preinstalled Node and install (immutable) + - name: Enable Developer Mode (symlink support) + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + # Extracting the many-file pnpm store cache is slower than a clean install, + # and saving it adds more latency after gates. + - name: Enable corepack and install (immutable) shell: pwsh run: | - $nodeRoot = Get-ChildItem -Path "$env:RUNNER_TOOL_CACHE\node" -Directory | - Where-Object { $_.Name -like "$env:PRIMARY_NODE_VERSION.*" } | - Sort-Object { [version]$_.Name } | - Select-Object -Last 1 - if ($null -eq $nodeRoot) { - throw "preinstalled Node $env:PRIMARY_NODE_VERSION.x not found in $env:RUNNER_TOOL_CACHE" - } - $nodeBin = Join-Path $nodeRoot.FullName 'x64' - if (-not (Test-Path $nodeBin -PathType Container)) { - throw "preinstalled Node x64 directory not found at $nodeBin" - } - Add-Content -Path $env:GITHUB_PATH -Value $nodeBin - $env:PATH = "$nodeBin;$env:PATH" - if ((node --version) -notlike "v$env:PRIMARY_NODE_VERSION.*") { - throw "selected unexpected Node version $(node --version)" - } corepack enable pnpm install --frozen-lockfile From aee859841b8ca8cb5a0199b9510e0636cbbe6380 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 14:38:27 +0800 Subject: [PATCH 21/32] test(gui): cover edit tool row --- apps/web/tests/smoke-fixture.e2e.ts | 7 ++++++- packages/client/connection/src/client/fixture.ts | 9 +++++---- packages/client/ui-conversation/README.md | 2 ++ .../ui-conversation/tests/chat-tool-row.spec.tsx | 15 +++++++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index d1a9e8417c..2d16e91d26 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -145,7 +145,7 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') }) - it('expands fixture reasoning from either its summary or Think title', async () => { + it('renders edit and expands fixture reasoning from either click target', async () => { onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure')) await page.locator('[role="treeitem"]').first().click() await page.locator('[role="treeitem"][aria-selected]').first().click() @@ -161,6 +161,11 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', await think.getByText('Think', { exact: true }).click() expect(await think.getAttribute('aria-expanded')).toBe('false') + + const editRoot = page.locator('[data-variant="edit"]').first() + await editRoot.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1) + expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7d4a93e888..1b459198ae 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -70,7 +70,8 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } // Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card - // type. `echo` above stays presenter-less on purpose — it is the no-view fallback sample. + // type. The generic sample uses the real `edit` name so the fallback row also exercises its + // dedicated icon/title/path summary. `echo` above stays presenter-less as the unknown fallback. const toolTurn = (turn: number, name: string, args: string, resultText: string): void => { const callId = `fx-call-${turn}` push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) @@ -87,7 +88,7 @@ function buildAlphaLog(): SessionEvent[] { } toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') - toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录') + toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑') return events as unknown as SessionEvent[] } @@ -112,8 +113,8 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { card: 'diff', title: `Write ${str(args.path)}`, diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }], } - case 'fx-note': - return { card: 'generic', title: '记录笔记', kind: 'edit', rawInput: args } + case 'edit': + return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args } default: return undefined // echo et al: the documented no-view fallback path } diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bca5ee9a37..ddbc8461f0 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,6 +2,8 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +Generic tool rows classify the built-in bash, read, search, and edit names into dedicated visual variants. The edit variant renders the edit icon and `Edit · ` summary while retaining the shared row-to-details interaction. + Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain). `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain 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 (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index b977cf003d..8cbc736615 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -29,6 +29,7 @@ describe('tool-call-model', () => { expect(classifyTool('web_fetch')).toBe('read') expect(classifyTool('web_search')).toBe('search') expect(classifyTool('grep')).toBe('search') + expect(classifyTool('edit')).toBe('edit') expect(classifyTool('todo_write')).toBe('others') }) @@ -49,6 +50,7 @@ describe('tool-call-model', () => { it('keeps summaries single-line and falls back for opaque args', () => { expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a') expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts') + expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts') // Others rows prefix the real tool name into the summary slot (figma-flows // ruling: static "Tool call" title, name rides the mutable summary). expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}') @@ -158,6 +160,19 @@ describe('GenericToolCard', () => { expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() }) + it('renders edit with its dedicated title, icon variant, and path summary', () => { + const view = render( + , + ) + expect(view.getByText('Edit')).toBeTruthy() + expect(view.getByText('src/x.ts')).toBeTruthy() + expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull() + expect(view.container.querySelector('svg')).not.toBeNull() + }) + it('row click reaches actions.openDetails', () => { const p = props('bash', result()) const view = render() From 0301b7fda86d9f52fbc913d177201dc4c220528e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 14:46:07 +0800 Subject: [PATCH 22/32] feat(gui): add write tool row variant --- apps/web/tests/smoke-fixture.e2e.ts | 7 ++++++- packages/client/connection/src/client/fixture.ts | 9 ++++++--- packages/client/ui-conversation/README.md | 2 +- .../src/client/chat/GenericToolCard.tsx | 1 + .../src/client/contract/tool-call-model.ts | 11 +++++++---- .../ui-conversation/tests/chat-tool-row.spec.tsx | 15 +++++++++++++++ 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 2d16e91d26..0770037a70 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -145,7 +145,7 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') }) - it('renders edit and expands fixture reasoning from either click target', async () => { + it('renders file tool rows and expands fixture reasoning from either click target', async () => { onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure')) await page.locator('[role="treeitem"]').first().click() await page.locator('[role="treeitem"][aria-selected]').first().click() @@ -166,6 +166,11 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', await editRoot.waitFor({ state: 'visible', timeout: 10_000 }) expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1) expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1) + + const writeRoot = page.locator('[data-variant="write"]').first() + await writeRoot.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1) + expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 1b459198ae..bbf4809466 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -69,9 +69,9 @@ function buildAlphaLog(): SessionEvent[] { } push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card - // type. The generic sample uses the real `edit` name so the fallback row also exercises its - // dedicated icon/title/path summary. `echo` above stays presenter-less as the unknown fallback. + // Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in + // turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above + // stays presenter-less as the unknown fallback. const toolTurn = (turn: number, name: string, args: string, resultText: string): void => { const callId = `fx-call-${turn}` push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) @@ -89,6 +89,7 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑') + toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入') return events as unknown as SessionEvent[] } @@ -115,6 +116,8 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { } case 'edit': return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args } + case 'write': + return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args } default: return undefined // echo et al: the documented no-view fallback path } diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ddbc8461f0..3bf1c3bd6e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -Generic tool rows classify the built-in bash, read, search, and edit names into dedicated visual variants. The edit variant renders the edit icon and `Edit · ` summary while retaining the shared row-to-details interaction. +Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain). diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index a772b1b191..958a90526f 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -17,6 +17,7 @@ const VARIANT_ICONS: Record = { search: , read: , bash: , + write: , edit: , others: , } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 9a3978cf5a..ea566eb248 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -10,18 +10,19 @@ export type { ToolCallBlock } from './toolview.ts' /** The frozen slice the chat view hands to toolview components as `block` * (both members are cache-stable references off ConversationSnapshot). */ -/** The six figma row variants (think is fed by reasoning blocks, not tool calls). */ -export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'edit' | 'others' +/** The seven row variants (think is fed by reasoning blocks, not tool calls). */ +export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others' /** Row state semantic; colors self-supplied via StateDot (design gives none). */ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped' /** Figma row titles per variant (design literals, not translatable copy). */ export const VARIANT_TITLES: Record = { - think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', edit: 'Edit', others: 'Tool call', + think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', + write: 'Write', edit: 'Edit', others: 'Tool call', } -/** Known tool name -> variant; fs write intentionally fall to others (no figma form). */ +/** Known tool name -> variant. */ const TOOL_VARIANTS: Record = { bash: 'bash', read: 'read', @@ -29,6 +30,7 @@ const TOOL_VARIANTS: Record = { web_search: 'search', grep: 'search', glob: 'search', + write: 'write', edit: 'edit', } @@ -79,6 +81,7 @@ const SUMMARY_KEYS: Record = { read: ['path', 'file_path', 'url'], search: ['query', 'pattern', 'url'], think: [], + write: ['path', 'file_path'], edit: ['path', 'file_path'], others: [], } diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 8cbc736615..f8a74adeef 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -29,6 +29,7 @@ describe('tool-call-model', () => { expect(classifyTool('web_fetch')).toBe('read') expect(classifyTool('web_search')).toBe('search') expect(classifyTool('grep')).toBe('search') + expect(classifyTool('write')).toBe('write') expect(classifyTool('edit')).toBe('edit') expect(classifyTool('todo_write')).toBe('others') }) @@ -50,6 +51,7 @@ describe('tool-call-model', () => { it('keeps summaries single-line and falls back for opaque args', () => { expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a') expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts') + expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts') expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts') // Others rows prefix the real tool name into the summary slot (figma-flows // ruling: static "Tool call" title, name rides the mutable summary). @@ -173,6 +175,19 @@ describe('GenericToolCard', () => { expect(view.container.querySelector('svg')).not.toBeNull() }) + it('renders write with its dedicated title, icon variant, and path summary', () => { + const view = render( + , + ) + expect(view.getByText('Write')).toBeTruthy() + expect(view.getByText('src/x.ts')).toBeTruthy() + expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull() + expect(view.container.querySelector('svg')).not.toBeNull() + }) + it('row click reaches actions.openDetails', () => { const p = props('bash', result()) const view = render() From 464065cce2f89063215de654130bcd9a523da9d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:06:23 +0800 Subject: [PATCH 23/32] Use enterprise 32-core runners for complete CI --- .github/workflows/ci.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eb8071a84..e9d5630d33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,19 +27,19 @@ env: jobs: - # One portable hosted runner pays setup once, then executes the complete - # unsharded primary Node inventory without organization-owned capacity. + # One enterprise runner pays setup once, then executes the complete + # unsharded primary Node inventory with repository-level concurrency. node-24: if: github.event_name == 'pull_request' - runs-on: ubuntu-latest + runs-on: dsh-enterprise-ubuntu-24-04-32core-test name: node 24 / complete env: - DSH_COVERAGE_MAX_WORKERS: '1' + DSH_COVERAGE_MAX_WORKERS: '16' DSH_ESLINT_CACHE: '1' - DSH_ESLINT_CONCURRENCY: '1' - DSH_GATE_CONCURRENCY: '1' - DSH_PUBLINT_CONCURRENCY: '1' - DSH_SNAPSHOT_MAX_CONCURRENCY: '1' + DSH_ESLINT_CONCURRENCY: '16' + DSH_GATE_CONCURRENCY: '10' + DSH_PUBLINT_CONCURRENCY: '16' + DSH_SNAPSHOT_MAX_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 with: @@ -152,13 +152,13 @@ jobs: # from observational gates without allowing them to fail the required job. windows: if: github.event_name == 'pull_request' - runs-on: windows-2025 + runs-on: dsh-enterprise-windows-2025-32core-test name: windows node 24 / complete env: - DSH_COVERAGE_MAX_WORKERS: '1' + DSH_COVERAGE_MAX_WORKERS: '12' DSH_ESLINT_CACHE: '1' - DSH_GATE_CONCURRENCY: '1' - DSH_PUBLINT_CONCURRENCY: '1' + DSH_GATE_CONCURRENCY: '16' + DSH_PUBLINT_CONCURRENCY: '16' steps: - uses: actions/checkout@v6 From be62eecc602b137101a0a7c6f79bc057b4f117c0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:39:02 +0800 Subject: [PATCH 24/32] test(acp): require a real sandbox denial --- .../implemented/feature/2026-07-06-sandbox.md | 2 +- examples/acp-agent/tests/escalation.e2e.ts | 58 +++++++++++++------ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index d5d0c6d5aa..507142fac9 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -117,7 +117,7 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s - **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes. - **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip. -- **With-key:** drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip. +- **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. - **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins a successful workspace-write mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent, and policy scenarios switch explicitly. ## Deferred phases diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index ae53252743..1bf0a5ac16 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -25,13 +25,11 @@ import { cleanupAcpExampleTest } from './cleanup.ts' * model nor a sandbox runner is ever exercised. * * With-key escalation flow (self-skips without DEEPSEEK_API_KEY or a usable - * platform runner): a scripted ACP client plays the human. The prompt asserts - * a prior denial (the organic denial→marker path lives on the sandbox e2e - * legs and unit tiers), the real model escalates with `sandbox_permissions` + - * `justification`, the bridge prompts THIS client over - * `session/request_permission`, the client answers `allow-once`, and the - * retried write must land ON DISK (world-verified) — under the granted mode, - * a temp-dir session cwd is writable either way. + * platform runner): a scripted ACP client plays the human. The subprocess + * starts read-only, its first real bash write is denied, the model retries with + * `sandbox_permissions` + `justification`, and the bridge prompts THIS client + * over `session/request_permission`. An approved workspace-write retry must + * then land ON DISK (world-verified). */ const AGENT: AgentUnderTest = { @@ -58,14 +56,21 @@ interface Spawned extends LaunchedAcpTestAgent { permissionRequests: RequestPermissionRequest[] } -/** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */ -function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { +/** Boot the example with an optional sandbox override; the scripted client answers every permission prompt with `answer`. */ +function launchExampleAcpAgent( + cwd: string, + answer: 'allow-once' | 'reject-once', + sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access', +): Spawned { const permissionRequests: RequestPermissionRequest[] = [] const launched = launchAcpTestAgent({ agent: AGENT, cwd, // A dummy key lets the adapter boot keylessly; live tests carry the real key. - env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, + env: { + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', + DSH_PERMISSION_MODE: sandboxMode, + }, requestPermission(params) { permissionRequests.push(params) const option = params.options.find(o => o.optionId === answer) @@ -78,6 +83,17 @@ function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once' return Object.assign(launched, { permissionRequests }) } +function escalationPrompt(path: string, content: string): string { + return `Create ${path} containing exactly ${JSON.stringify(content)} using bash, not filesystem tools. ` + + 'First try the command without sandbox_permissions. If the sandbox denies it, retry that exact command once ' + + 'with sandbox_permissions set to workspace-write and a one-sentence justification.' +} + +function includesReadOnlyDenial(updates: LaunchedAcpTestAgent['updates']): boolean { + return updates.some(update => update.sessionUpdate === 'tool_call_update' + && JSON.stringify(update.content).includes('[sandbox: file access denied under read-only mode]')) +} + let spawned: Spawned | undefined let workdir: string | undefined @@ -137,17 +153,20 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox composition e2e: the live approval loop', () => { it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = launchExampleAcpAgent(workdir, 'allow-once') - const { client, permissionRequests } = spawned + spawned = launchExampleAcpAgent(workdir, 'allow-once', 'read-only') + const { client, permissionRequests, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] }) const res = await client.prompt({ sessionId, - prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/escalated.txt. Create it now containing exactly "ACP_ESCALATION_OK": ` - + 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification, then stop.' }], + prompt: [{ + type: 'text', + text: `${escalationPrompt(join(workdir, 'escalated.txt'), 'ACP_ESCALATION_OK')} Then stop.`, + }], }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) + expect(includesReadOnlyDenial(updates)).toBe(true) // The WORLD: the approved escalated retry landed the write. const proof = await readFile(join(workdir, 'escalated.txt'), 'utf8') @@ -166,17 +185,20 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co it('a rejected escalation stays denied: no write lands, the turn still ends', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = launchExampleAcpAgent(workdir, 'reject-once') - const { client, permissionRequests } = spawned + spawned = launchExampleAcpAgent(workdir, 'reject-once', 'read-only') + const { client, permissionRequests, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] }) const res = await client.prompt({ sessionId, - prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/refused.txt. Create it now containing "NO": ` - + 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification. If that is rejected, stop and say so.' }], + prompt: [{ + type: 'text', + text: `${escalationPrompt(join(workdir, 'refused.txt'), 'NO')} If approval is rejected, stop and say so.`, + }], }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) + expect(includesReadOnlyDenial(updates)).toBe(true) // The WORLD: rejected means the file never appeared. await expect(readFile(join(workdir, 'refused.txt'), 'utf8')).rejects.toThrow() From f7103c833cd087a3dbe3638097f45f096568c526 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:04:33 +0800 Subject: [PATCH 25/32] feat(skill): record browser demo GIFs --- ...07-23-browser-demo-gif-recording.i18n.yaml | 6 + .../2026-07-23-browser-demo-gif-recording.md | 29 ++ ...026-07-23-browser-demo-gif-recording.zh.md | 29 ++ .agents/skills/record-browser-gif/SKILL.md | 53 ++++ .../record-browser-gif/agents/openai.yaml | 4 + .../record-browser-gif/scripts/encode_gif.py | 279 ++++++++++++++++++ 6 files changed, 400 insertions(+) create mode 100644 .agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md create mode 100644 .agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md create mode 100644 .agents/skills/record-browser-gif/SKILL.md create mode 100644 .agents/skills/record-browser-gif/agents/openai.yaml create mode 100755 .agents/skills/record-browser-gif/scripts/encode_gif.py diff --git a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.i18n.yaml new file mode 100644 index 0000000000..1aee1563ad --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-23-browser-demo-gif-recording.md: 096edf453d6b61c4d9046b284ef67a460edf4e88 +2026-07-23-browser-demo-gif-recording.zh.md: f5b8eac1c8dd57a59e9c2293ecc71511078a4896 diff --git a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md new file mode 100644 index 0000000000..096edf453d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md @@ -0,0 +1,29 @@ +# Agent Note: Browser demo GIF recording + +Status: implemented + +English | [中文](2026-07-23-browser-demo-gif-recording.zh.md) + +## Problem + +Browser demonstrations have been assembled with one-off capture and encoding commands. That makes timing and output size inconsistent, encourages continuous recordings that obscure the useful state changes, and can blur the boundary between a genuine server or API flow and a fixture. Combining local recording with attachment upload or pull-request editing also gives a media task unrelated remote-write authority. + +## Decision + +The repository provides the [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) skill for local browser-demo artifacts. It uses the available browser-control workflow, establishes whether the requested flow is real, fixture-backed, or otherwise simulated, and captures a small storyboard only after semantically observable UI states. Frames and the output live outside the Git worktree by default. + +The bundled `encode_gif.py` helper orders frames lexically, assigns explicit hold durations, uses an `ffmpeg` palette pipeline, and validates source dimensions plus the encoded frame count, dimensions, duration, and byte limit through `ffprobe`. The workflow stops after returning the verified absolute GIF path; uploading the artifact and mutating a pull request, issue, or document remain separate workflows. + +## Alternatives considered + +**Record continuous video and convert it afterward.** Continuous capture preserves every cursor movement and loading transition but produces larger, noisier artifacts and makes deterministic timing harder. A state storyboard better fits short feature demonstrations where the meaningful evidence is a handful of visible transitions. + +**Keep an inline `ffmpeg` recipe in the skill.** Reconstructing quoting, timing manifests, palette filters, overwrite behavior, and post-encode checks in every run is error-prone. A bundled helper keeps those mechanics executable while the skill owns capture judgment. + +**Include GitHub attachment and description editing.** Upload and remote mutation require separate authentication, confirmation, and recovery rules. Excluding them keeps invocation of a recording skill local and reversible. + +**Use a fixture whenever it is easier to stage.** Fixtures are valid when the requested demonstration is explicitly fixture-backed, but they do not substantiate a real-server or real-API claim. The skill preserves the requested provenance and reports a missing prerequisite instead of silently changing it. + +## Consequences + +Recordings are small, repeatable local artifacts with explicit provenance and a clean repository boundary. The workflow gives up smooth continuous motion, depends on locally available `ffmpeg` and `ffprobe`, and requires the recorder to identify semantic capture points. The helper is exercised against a four-state browser demonstration and invalid duration input; skill shape and repository links are covered by the skill validator and documentation gates. diff --git a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md new file mode 100644 index 0000000000..f5b8eac1c8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 浏览器演示 GIF 录制 + +Status: implemented + +[English](2026-07-23-browser-demo-gif-recording.md) | 中文 + +## 问题 + +浏览器演示一直通过一次性的截取与编码命令制作。这会导致播放节奏和输出大小不一致,容易让录制者选择连续录制,反而掩盖有用的状态变化,还可能模糊真实服务器或 API 流程与 fixture(测试前置数据)之间的界限。将本地录制与附件上传或 PR(Pull Request)编辑合并在同一任务中,还会让本应仅处理媒体的任务获得无关的远程写入权限。 + +## 决策 + +仓库提供 [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) skill(技能),用于生成本地浏览器演示产物。该 skill 使用当前可用的浏览器控制工作流,先确认请求的流程是真实流程、由 fixture 支撑,还是采用其他模拟方式,再仅在 UI 达到语义上可观察的状态后截取一组精简的分镜帧。帧文件与输出产物默认存放在 Git worktree 之外。 + +随附的 `encode_gif.py` 辅助脚本按词法顺序排列各帧,为每帧设置明确的停留时长,通过 `ffmpeg` 调色板流水线编码,并借助 `ffprobe` 校验源图像尺寸以及编码结果的帧数、尺寸、时长和字节上限。工作流在返回已验证的 GIF 绝对路径后即结束;上传产物以及修改 PR、issue 或文档仍属于独立的工作流。 + +## 曾考虑的替代方案 + +**连续录制视频后再转换。**连续录制能保留每一次光标移动和加载过渡,但会产生体积更大、干扰更多的产物,也更难保持确定的播放时序。状态分镜更适合简短的功能演示,因为有意义的证据只是少数几个可见的状态变化。 + +**在 skill 中保留内联 `ffmpeg` 配方。**每次运行都重新组装引号转义、时序清单、调色板过滤器、覆盖行为和编码后检查,容易出错。随附的辅助脚本使这些机制保持可执行,skill 则负责判断何时截取画面。 + +**纳入 GitHub 附件上传与描述编辑。**上传和远程修改需要各自独立的身份认证、确认与恢复规则。将它们排除在外,可以使录制 skill 的调用保持本地且可撤销。 + +**每当 fixture 更容易布置时就使用它。**当请求明确要求由 fixture 支撑演示时,使用 fixture 是有效的;但它无法为真实服务器或真实 API 的声明提供证据。该 skill 会保持请求指定的演示来源,并在缺少先决条件时报告问题,不会擅自更改来源。 + +## 后果 + +录制结果成为体积小、可重复生成的本地产物,明确标注演示来源,并与仓库保持清晰边界。该工作流放弃了流畅的连续动态效果,依赖本机提供的 `ffmpeg` 和 `ffprobe`,并要求录制者识别具有语义意义的截取时点。测试使用四状态浏览器演示与无效时长输入检验辅助脚本;skill 的结构及仓库链接由 skill 校验器和文档门禁覆盖。 diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md new file mode 100644 index 0000000000..e48e16ca40 --- /dev/null +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -0,0 +1,53 @@ +--- +name: record-browser-gif +description: Record browser or Web UI interaction demos as optimized local GIFs using the available built-in browser, state-based frame capture, and deterministic encoding. Use when Codex is asked to make, record, or generate a GIF that demonstrates a browser workflow, including real-server or real-API behavior. Stop after returning the verified local artifact; do not upload it or edit a pull request. +--- + +# Record Browser GIF + +Produce a short, truthful UI demonstration as a local GIF. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size. + +## Keep the boundary explicit + +- Produce frame images and one local `.gif` artifact only. +- Never upload the artifact, post a comment, or change a pull request, issue, or document under this skill. Hand those actions to a separate workflow if the user requests them. +- Preserve the requested provenance. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture. +- Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt. + +## Record the flow + +1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required. +2. Resolve the evidence boundary before recording: identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports. +3. Choose three to six states that tell one story, such as initial, typed, submitted, and completed. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer. +4. Keep one viewport and crop for every frame. Store frames in an absolute artifact directory outside the Git worktree unless the user requests another location, and name them lexically: `00-initial.png`, `01-typed.png`, and so on. +5. Before each screenshot, wait for a concrete UI condition such as a unique label, enabled control, changed document title, or completed response. Do not use a fixed delay as proof that the application reached the state. +6. Capture no secrets, personal data, unrelated tabs, or transient notifications. Stop any unnecessarily long real-API run after the demonstrated state is visible. + +Use the browser's own screenshot API. When it returns image bytes, save those bytes directly; the encoder detects image content independently of the filename extension. + +## Encode the GIF + +Require `python3`, `ffmpeg`, and `ffprobe`. If either media binary is missing, report the dependency instead of installing software without authorization. + +Set `GIF_SKILL_DIR` to this skill's absolute directory, then encode the lexically ordered frames: + +```sh +python3 "$GIF_SKILL_DIR/scripts/encode_gif.py" \ + /absolute/path/to/frames \ + /absolute/path/to/demo.gif \ + --durations 1.5,1.5,1.5,3.5 \ + --fps 10 \ + --max-width 1200 \ + --colors 128 +``` + +One duration applies to every frame; otherwise provide one comma-separated positive duration per frame. The encoder rejects fewer than two frames, mismatched dimensions or durations, invalid limits, accidental overwrite, unexpected duration, and output above `--max-bytes`. + +For a large artifact, reduce `--max-width` first, then `--colors` or `--fps`; retain readable text and the final state long enough to inspect. Use `--force` only after resolving the exact output path. + +## Verify and deliver + +1. Read the encoder's JSON summary and confirm the output path, source and encoded frame counts, dimensions, duration, and byte size. +2. Inspect the first and final source frames and the resulting GIF. Confirm that the transition is legible, the last state is held long enough, and no sensitive content appears. +3. If capture occurred near a repository, run `git status --short` and confirm the artifact did not dirty the worktree. +4. Return the absolute GIF path, render it when the client supports local media, and state whether the recording used a real API, fixture, or another transport. Stop without uploading it or editing remote content. diff --git a/.agents/skills/record-browser-gif/agents/openai.yaml b/.agents/skills/record-browser-gif/agents/openai.yaml new file mode 100644 index 0000000000..720f55f7dc --- /dev/null +++ b/.agents/skills/record-browser-gif/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Record Browser GIF" + short_description: "Record and optimize local browser demo GIFs" + default_prompt: "Use $record-browser-gif to record this browser flow as a verified local GIF." diff --git a/.agents/skills/record-browser-gif/scripts/encode_gif.py b/.agents/skills/record-browser-gif/scripts/encode_gif.py new file mode 100755 index 0000000000..3c74bdec35 --- /dev/null +++ b/.agents/skills/record-browser-gif/scripts/encode_gif.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Encode lexically ordered browser screenshots into a verified GIF.""" + +from __future__ import annotations + +import argparse +import json +import math +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import NoReturn + + +DEFAULT_MAX_BYTES = 5 * 1024 * 1024 + + +def fail(message: str) -> NoReturn: + """Exit with a concise user-correctable error.""" + raise SystemExit(f"error: {message}") + + +def positive_float(value: str) -> float: + """Parse one finite positive command-line number.""" + try: + parsed = float(value) + except ValueError: + fail(f"expected a number, got {value!r}") + if not math.isfinite(parsed) or parsed <= 0: + fail(f"expected a positive finite number, got {value!r}") + return parsed + + +def positive_int(value: str) -> int: + """Parse one positive command-line integer.""" + try: + parsed = int(value) + except ValueError: + fail(f"expected an integer, got {value!r}") + if parsed <= 0: + fail(f"expected a positive integer, got {value!r}") + return parsed + + +def parse_durations(value: str, frame_count: int) -> list[float]: + """Expand one hold duration or validate one duration per source frame.""" + parts = [part.strip() for part in value.split(",")] + if not parts or any(not part for part in parts): + fail("--durations must be a number or a comma-separated list of numbers") + durations = [positive_float(part) for part in parts] + if len(durations) == 1: + return durations * frame_count + if len(durations) != frame_count: + fail(f"--durations supplied {len(durations)} values for {frame_count} frames") + return durations + + +def require_binary(name: str) -> str: + """Resolve a required media binary or fail without attempting installation.""" + path = shutil.which(name) + if path is None: + fail(f"required binary {name!r} is not available on PATH") + return path + + +def run_json(command: list[str]) -> dict[str, object]: + """Run a media probe and parse its JSON object.""" + try: + completed = subprocess.run(command, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as error: + detail = error.stderr.strip() or error.stdout.strip() or str(error) + fail(detail) + try: + value = json.loads(completed.stdout) + except json.JSONDecodeError as error: + fail(f"media probe returned invalid JSON: {error}") + if not isinstance(value, dict): + fail("media probe returned a non-object JSON value") + return value + + +def probe_stream(ffprobe: str, path: Path) -> dict[str, object]: + """Read the first video stream's dimensions and timing metadata.""" + result = run_json( + [ + ffprobe, + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height,nb_frames,duration,r_frame_rate", + "-of", + "json", + str(path), + ] + ) + streams = result.get("streams") + if not isinstance(streams, list) or len(streams) != 1 or not isinstance(streams[0], dict): + fail(f"expected one video stream in {path}") + return streams[0] + + +def stream_int(stream: dict[str, object], key: str, path: Path) -> int: + """Read a positive integer stream field.""" + try: + value = int(stream[key]) + except (KeyError, TypeError, ValueError): + fail(f"missing integer {key!r} in media probe for {path}") + if value <= 0: + fail(f"non-positive {key!r} in media probe for {path}") + return value + + +def ffconcat_quote(path: Path) -> str: + """Quote an absolute path for the ffconcat file directive.""" + value = str(path) + if "\n" in value or "\r" in value: + fail(f"frame path contains a newline: {path}") + return "'" + value.replace("\\", "\\\\").replace("'", "'\\''") + "'" + + +def write_concat_manifest(path: Path, frames: list[Path], durations: list[float]) -> None: + """Write an ffconcat manifest that materializes the final frame's hold.""" + lines = ["ffconcat version 1.0"] + for frame, duration in zip(frames, durations): + lines.append(f"file {ffconcat_quote(frame)}") + lines.append(f"duration {duration:.6f}") + lines.append(f"file {ffconcat_quote(frames[-1])}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def build_parser() -> argparse.ArgumentParser: + """Build the command-line contract.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("frames", type=Path, help="directory containing lexically ordered frames") + parser.add_argument("output", type=Path, help="output .gif path") + parser.add_argument("--pattern", default="*.png", help="frame glob within the input directory") + parser.add_argument( + "--durations", + default="2", + help="one hold duration or one comma-separated value per frame", + ) + parser.add_argument("--fps", type=positive_int, default=10, help="encoded frames per second") + parser.add_argument( + "--max-width", + type=positive_int, + default=1200, + help="maximum output width", + ) + parser.add_argument( + "--colors", + type=positive_int, + default=128, + help="palette colors, from 2 through 256", + ) + parser.add_argument( + "--max-bytes", + type=positive_int, + default=DEFAULT_MAX_BYTES, + help="maximum output size", + ) + parser.add_argument("--force", action="store_true", help="replace an existing output file") + return parser + + +def main() -> None: + """Validate inputs, encode the GIF, verify it, and print a JSON summary.""" + args = build_parser().parse_args() + frame_dir = args.frames.resolve() + output = args.output.resolve() + + if not frame_dir.is_dir(): + fail(f"frame directory does not exist: {frame_dir}") + if output.suffix.lower() != ".gif": + fail(f"output must end in .gif: {output}") + if output.exists() and not args.force: + fail(f"output already exists (pass --force to replace it): {output}") + if not 2 <= args.colors <= 256: + fail("--colors must be between 2 and 256") + if args.fps > 30: + fail("--fps must not exceed 30") + + frames = sorted(path.resolve() for path in frame_dir.glob(args.pattern) if path.is_file()) + if len(frames) < 2: + fail(f"expected at least two frames matching {args.pattern!r} in {frame_dir}") + if output in frames: + fail("output path must not match an input frame") + + durations = parse_durations(args.durations, len(frames)) + expected_duration = sum(durations) + ffmpeg = require_binary("ffmpeg") + ffprobe = require_binary("ffprobe") + + dimensions = { + (stream_int(stream, "width", frame), stream_int(stream, "height", frame)) + for frame in frames + for stream in [probe_stream(ffprobe, frame)] + } + if len(dimensions) != 1: + fail(f"all frames must have identical dimensions, got {sorted(dimensions)}") + + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="record-browser-gif-") as temporary: + manifest = Path(temporary) / "frames.ffconcat" + write_concat_manifest(manifest, frames, durations) + scale = f"scale='min({args.max_width},iw)':-2:flags=lanczos" + palette = f"palettegen=max_colors={args.colors}:stats_mode=diff" + filters = ( + f"fps={args.fps},{scale},split[base][palette_input];" + f"[palette_input]{palette}[palette];" + "[base][palette]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle" + ) + command = [ + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-f", + "concat", + "-safe", + "0", + "-i", + str(manifest), + "-vf", + filters, + "-loop", + "0", + "-t", + f"{expected_duration:.6f}", + "-y" if args.force else "-n", + str(output), + ] + try: + subprocess.run(command, check=True) + except subprocess.CalledProcessError as error: + fail(f"ffmpeg failed with exit code {error.returncode}") + + stream = probe_stream(ffprobe, output) + width = stream_int(stream, "width", output) + height = stream_int(stream, "height", output) + encoded_frames = stream_int(stream, "nb_frames", output) + try: + actual_duration = float(stream["duration"]) + except (KeyError, TypeError, ValueError): + fail(f"missing duration in media probe for {output}") + tolerance = max(0.2, 2 / args.fps) + if abs(actual_duration - expected_duration) > tolerance: + fail(f"expected about {expected_duration:.3f}s, encoded {actual_duration:.3f}s") + if width > args.max_width: + fail(f"expected width at most {args.max_width}, encoded {width}") + if encoded_frames < 2: + fail(f"expected an animated GIF, encoded {encoded_frames} frame") + + byte_size = output.stat().st_size + if byte_size > args.max_bytes: + fail(f"output is {byte_size} bytes, above --max-bytes {args.max_bytes}") + + print( + json.dumps( + { + "output": str(output), + "sourceFrames": len(frames), + "encodedFrames": encoded_frames, + "width": width, + "height": height, + "durationSeconds": actual_duration, + "fps": args.fps, + "bytes": byte_size, + }, + indent=2, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() From c02eb6d9416cda17d082d49a2081cb0300aad4e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:18:18 +0800 Subject: [PATCH 26/32] fix(skill): preserve GIF palette and paths --- .../skills/record-browser-gif/scripts/encode_gif.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/skills/record-browser-gif/scripts/encode_gif.py b/.agents/skills/record-browser-gif/scripts/encode_gif.py index 3c74bdec35..2a14ae47fd 100755 --- a/.agents/skills/record-browser-gif/scripts/encode_gif.py +++ b/.agents/skills/record-browser-gif/scripts/encode_gif.py @@ -114,11 +114,11 @@ def stream_int(stream: dict[str, object], key: str, path: Path) -> int: def ffconcat_quote(path: Path) -> str: - """Quote an absolute path for the ffconcat file directive.""" + """Quote an ffconcat path while preserving literal backslashes.""" value = str(path) if "\n" in value or "\r" in value: fail(f"frame path contains a newline: {path}") - return "'" + value.replace("\\", "\\\\").replace("'", "'\\''") + "'" + return "'" + value.replace("'", "'\\''") + "'" def write_concat_manifest(path: Path, frames: list[Path], durations: list[float]) -> None: @@ -153,7 +153,7 @@ def build_parser() -> argparse.ArgumentParser: "--colors", type=positive_int, default=128, - help="palette colors, from 2 through 256", + help="palette colors, from 4 through 256", ) parser.add_argument( "--max-bytes", @@ -177,8 +177,8 @@ def main() -> None: fail(f"output must end in .gif: {output}") if output.exists() and not args.force: fail(f"output already exists (pass --force to replace it): {output}") - if not 2 <= args.colors <= 256: - fail("--colors must be between 2 and 256") + if not 4 <= args.colors <= 256: + fail("--colors must be between 4 and 256") if args.fps > 30: fail("--fps must not exceed 30") @@ -206,7 +206,7 @@ def main() -> None: manifest = Path(temporary) / "frames.ffconcat" write_concat_manifest(manifest, frames, durations) scale = f"scale='min({args.max_width},iw)':-2:flags=lanczos" - palette = f"palettegen=max_colors={args.colors}:stats_mode=diff" + palette = f"palettegen=max_colors={args.colors}:stats_mode=full" filters = ( f"fps={args.fps},{scale},split[base][palette_input];" f"[palette_input]{palette}[palette];" From bbde18caff0d27deab00a83e4f963ab00d34ff97 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:09:32 +0800 Subject: [PATCH 27/32] refactor(gui): dissolve the tool ring into per-view keyed slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four rounds of structural rework on the conversation surface, converging on one registration model for the whole client: - Review fixes: open() leaves the inject factory (SessionsService owns the semantic); ConversationService mounts via ctx.plugin(); the bespoke view registry retires into the 'conversation.view' list slot. - Ring alignment: createChatView factory retired (components get everything through checkable shares at the register call site); the hand-rolled t/i18n threading is deleted wholesale — a future framework-level i18n will supply t as a standard prop keyed by slot name, so no interim manual channel. - Toolview dissolution: ToolViewRegistry / ToolViewResolver / ToolViewOutlet / ctx.toolviews retire. Tool rows are entries of the 'conversation.chat.toolview' keyed slot (scope: session) declared by the chat entry; ToolRowOwnerProps is the unified owner payload; GenericToolCard becomes the call-site fallback; registrants are plain plugins (inject ['slots','conversation'] as the load-order seam); session-dimension dispatch moves into components (useSessions reads parentId); trajectory/waterfall gain same-shape slots the day they render tool rows (RendersCheck rejects empty declarations). Slot names mirror the composition path (..). - Staging follows current: cell()/binding() are pure resolution (render-safe); the constructor subscribes to the list store and followCurrent opens the event window when the current session changes — staging IS the open signal, business verbs are the timing, React render/commit is decoupled from window lifecycle. A masked current (projection gap) keeps the stage untouched so deferred teardown semantics survive reconnects. Agent Note: .agents/notes/implemented/architecture/ 2026-07-23-toolview-dissolution.md (bilingual pair) records the decision, the four rejected alternatives, and the accepted semantic changes; the web client architecture note and packages/client/AGENTS.md carry the current-state narrative. Verified: typecheck 0, duplication 0 clones (478 files), full coverage run 6190 passed with zero threshold errors, knip 0, doc-sync 24/24, client aggregate tsc 0, render-count checks (one commit per chunk, zero row re-renders under streaming) green. --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 12 +- ...26-07-19-gui-web-client-architecture.zh.md | 12 +- .../2026-07-23-toolview-dissolution.i18n.yaml | 6 + .../2026-07-23-toolview-dissolution.md | 37 ++ .../2026-07-23-toolview-dissolution.zh.md | 37 ++ packages/client/AGENTS.md | 8 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/sessions/service.ts | 86 ++-- .../runtime/tests/sessions-service.spec.ts | 127 ++++-- packages/client/ui-conversation/README.md | 11 +- packages/client/ui-conversation/package.json | 1 - .../ui-conversation/src/client/apply.ts | 158 ++++--- .../src/client/chat/AssistantMarkdown.tsx | 5 +- .../src/client/chat/ChatView.tsx | 398 +++++++++--------- .../src/client/chat/GenericToolCard.tsx | 18 +- .../src/client/chat/StatsLine.tsx | 18 +- .../src/client/chat/ToolViewOutlet.tsx | 80 ---- .../src/client/chat/register.ts | 52 --- .../src/client/contract/slots.ts | 124 +++++- .../src/client/contract/tool-call-model.ts | 7 +- .../src/client/contract/toolview.ts | 78 ---- .../src/client/contract/views.ts | 92 +--- .../ui-conversation/src/client/index.ts | 27 +- .../ui-conversation/src/client/service.ts | 86 +--- .../src/client/skeleton/ConversationRoot.tsx | 44 +- .../ui-conversation/src/client/stores.ts | 16 +- .../src/client/toolviews/bash-sample.tsx | 75 ++-- .../src/client/toolviews/registry.ts | 103 ----- .../client/ui-conversation/src/invariant.ts | 9 +- .../tests/apply-inject.spec.tsx | 84 +++- .../ui-conversation/tests/chat-apply.spec.tsx | 60 +-- .../tests/chat-branch-tails.spec.tsx | 98 +---- .../tests/chat-stats-bash-sample.spec.tsx | 134 +++--- .../tests/chat-tool-row.spec.tsx | 14 +- .../tests/chat-toolview-slot.spec.tsx | 232 ++++++++++ .../ui-conversation/tests/chat-view.spec.tsx | 77 ++-- .../tests/coverage-tails.spec.tsx | 71 +--- .../tests/gate-branch-tails.spec.tsx | 35 +- .../tests/service-orchestration.spec.ts | 24 +- .../tests/skeleton-branches.spec.tsx | 26 +- .../ui-conversation/tests/skeleton.spec.tsx | 72 ++-- .../tests/toolview-entry-types.spec.ts | 62 --- .../tests/toolview-registry.spec.ts | 101 ----- .../tests/toolviews-type-chain.spec.ts | 94 ----- .../tests/views-type-chain.spec.tsx | 197 +++++---- packages/client/ui-trajectory/README.md | 2 +- .../src/client/TrajectoryStatsHeader.tsx | 27 +- .../src/client/TrajectoryView.tsx | 34 +- .../src/client/WaterfallView.tsx | 51 +-- .../client/ui-trajectory/src/client/index.ts | 50 +-- .../client/ui-trajectory/src/invariant.ts | 4 +- .../ui-trajectory/tests/client-bundle.spec.ts | 19 +- .../client/ui-trajectory/tests/views.spec.tsx | 110 +++-- pnpm-lock.yaml | 3 - 56 files changed, 1569 insertions(+), 1847 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md create mode 100644 .agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md delete mode 100644 packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx delete mode 100644 packages/client/ui-conversation/src/client/chat/register.ts delete mode 100644 packages/client/ui-conversation/src/client/contract/toolview.ts delete mode 100644 packages/client/ui-conversation/src/client/toolviews/registry.ts create mode 100644 packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx delete mode 100644 packages/client/ui-conversation/tests/toolview-entry-types.spec.ts delete mode 100644 packages/client/ui-conversation/tests/toolview-registry.spec.ts delete mode 100644 packages/client/ui-conversation/tests/toolviews-type-chain.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 1868da83b3..d55103ce00 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -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 -2026-07-19-gui-web-client-architecture.md: f21b840493b83c02d7abc3ba1c1bf90635166ec1 -2026-07-19-gui-web-client-architecture.zh.md: a50dc556cfc96b6d35feea6ef2b1aadae9f31c44 +2026-07-19-gui-web-client-architecture.md: 6e1cbc2d1e3e3437480c8005ca06845c23c628df +2026-07-19-gui-web-client-architecture.zh.md: 9e2b3ef60d97840cd6cbd26e8fdcf922d472391c diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index f21b840493..6e1cbc2d1e 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -49,9 +49,9 @@ Implementation homes: registry core and the props-share types in `packages/clien ## Services and scope addressing -A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-map merges). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). +A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -Beyond SlotMap, two more typed registration rings follow the same declare-merge idiom: the **view ring** (`ConversationViewMap` — an entry may declare `chromeProps`/`extraProps` extension shapes; `ConvViewPropsOf`/`ChromePropsOf` compose base + extension, so a view with no declaration gets the base for free while ui-trajectory's entries carry real per-view props) and the **tool ring** (tool names stay an open set — no global key table; typing hardens inside the entry: `ToolViewProps.block` is the real `ToolCallBlock` union defined in runtime, and register infers the registrant's injected share like slots do). +There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Session-dimension differentiation happens inside the component — `useSessions` reading `parentId` — not in registry predicates; interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). @@ -103,16 +103,16 @@ src/client/ service.ts cross-domain orchestration (imports contract only) skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel) chat/ domain: the chat view - toolviews/ domain: the tool-row registry and samples + toolviews/ domain: sample tool-row registrants (third-party posture) apply.ts the ONLY file allowed to import across domains (assembly point) index.ts thin re-export shell (contract + apply + components) ``` -Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. chat consumes the tool registry through a `ToolViewResolver` read-face interface, not the registry class). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths. +Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. the toolviews samples take `ToolRowProps` from the contract, never chat internals). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths. ## How to develop -- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores, registers slots and toolviews), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically. +- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically. - **A new slot**: see the [slot system standard RFC](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally. - **Consuming a new frame type**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept. - **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)). @@ -129,5 +129,5 @@ Token streams no longer shake the render tree: a frame storm costs unsubscribed | One statically-linked SPA bundle | Plugins must be host-composable at runtime (config-driven); a monolith re-couples every UI feature to one build | | window globals / import maps for shared deps | The DI require table keeps sharing explicit, fail-loud, and swappable; globals leak identity and version silently | | Business data in zustand slices | The event window/accumulator is a behavioral state machine, not a flat slice; the object layer keeps snapshot granularity and batching controllable | -| String-keyed global component registry for tool rows | Tool views are consumed by multiple views and need per-session differentiation — a named service (`ctx.toolviews`) with scope filters is the honest shape | +| String-keyed global component registry for tool rows | Per-view keyed child slots plus in-component session branching carry the same need with the one registration model; a parallel registry does not come back ([toolview dissolution](2026-07-23-toolview-dissolution.md)) | | Progressive/Suspense boot in P-I | One-flip boot is strictly simpler; the loader's per-plugin status face is kept so progressive lighting can land later without re-architecture | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index a50dc556cf..9e2b3ef60d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -49,9 +49,9 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain- ## 服务与 scope 寻址 -服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/views/startSession)、`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 +服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 -SlotMap 之外还有两条同 declare-merge 惯例的类型化注册环:**视图环**(`ConversationViewMap`——entry 可声明 `chromeProps`/`extraProps` 扩展形状;`ConvViewPropsOf`/`ChromePropsOf` 组合基座+扩展,无声明的视图免费得基座,ui-trajectory 的两个 entry 带真 per-view props)与**工具环**(tool 名保持开放集——无全局键表;类型强化在 entry 内部:`ToolViewProps.block` 是 runtime 定义的真 `ToolCallBlock` union,register 同 slots 一样推断注册方注入份额)。 +slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝(conversation 服务在场即保证槽已声明)。会话维差异化在组件内完成——`useSessions` 读 `parentId`——不走注册表谓词;交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 @@ -103,16 +103,16 @@ src/client/ service.ts cross-domain orchestration (imports contract only) skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel) chat/ domain: the chat view - toolviews/ domain: the tool-row registry and samples + toolviews/ domain: sample tool-row registrants (third-party posture) apply.ts the ONLY file allowed to import across domains (assembly point) index.ts thin re-export shell (contract + apply + components) ``` -域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 chat 经 `ToolViewResolver` 读面接口消费工具注册表,不碰注册表类)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。 +域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 toolviews 样例从契约取 `ToolRowProps`,永不碰 chat 内部)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。 ## 怎么开发 -- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot 与 toolview),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。 +- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。 - **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——契约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。 - **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。 - **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。 @@ -129,5 +129,5 @@ token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位 | 静态链接的单 SPA bundle | 插件必须由 host 在运行时按配置组合;单体把每个 UI 功能重新耦回一次构建 | | window 全局变量 / import map 供共享依赖 | DI require 表让共享显式、大声失败、可替换;全局变量静默泄漏身份与版本 | | 业务数据进 zustand 切片 | 事件窗口/累积器是行为状态机,不是扁平切片;对象层保住快照粒度与合批的可控性 | -| 工具行走字符串键的全局组件注册表 | 工具视图被多个视图共同消费且要按会话差异化——带 scope 过滤的具名服务(`ctx.toolviews`)才是诚实形态 | +| 工具行走字符串键的全局组件注册表 | per-view keyed 子槽 + 组件内会话分支以唯一注册模型承载同一需求;平行 registry 不复活([toolview 溶解](2026-07-23-toolview-dissolution.md)) | | P-I 就做渐进/Suspense 启动 | 一次成型严格更简单;loader 的按插件状态面已保留,渐进点亮日后可落地而无需重构 | diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml new file mode 100644 index 0000000000..6de82d1c9b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-23-toolview-dissolution.md: a420c5945d0272cf8087d5f623e9c383c286d7c2 +2026-07-23-toolview-dissolution.zh.md: 47c1f392f5f7ddbf4e6c686b2574faa7987e6126 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md new file mode 100644 index 0000000000..a420c5945d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -0,0 +1,37 @@ +# Agent Note: Toolview dissolution — tool rows are per-view keyed slots + +Status: implemented + +English | [中文](2026-07-23-toolview-dissolution.zh.md) + +> Scope: why the standalone tool ring (ToolViewRegistry/ctx.toolviews/outlet) was retired and what replaced it. The [web client architecture note](2026-07-19-gui-web-client-architecture.md) carries the shipped-state narrative this decision produced; the [slot system standard](2026-07-22-slot-type-chain-implementation.md) owns the registration model everything now runs on. + +## Problem + +After the view ring dissolved into the slot system, the client kept exactly one parallel registration model: the tool ring — a named registry (`ctx.toolviews`) with its own register grammar, its own resolve semantics (scoped-beats-global predicate dispatch), its own subscribe/version pair, its own inject cache, and its own render outlet with a private error boundary. Every one of those was a second implementation of something the slot machinery already owned, and every future capability (a store seat for row drafts, i18n injection, cross-bundle identity) would have had to be built twice or drift. The ring's one honest justification was that tool names are a runtime-open set while `SlotMap` is a closed declaration table — a registry keyed by arbitrary strings seemed structurally necessary. + +## Decision + +The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. + +Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. + +Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. + +## Accepted semantic changes + +Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance is per-view registration — a row must adapt to each view's layout anyway, so one registration per view is the correct coupling, and reuse is the same component in two register calls. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch moved from registry predicates into the component. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry. + +## Alternatives considered + +**Keep the standalone registry (the original shape).** Rejected: each of its multi-dimensional dispatch axes has a more correct home — the view dimension belongs to each view's own declared child slot (declaring is claiming, so specialization ownership lands right), and the session dimension belongs inside the component, which already holds the standard kit. What remained after both moves was a second copy of slot machinery with no distinguishing capability. + +**Promote `renderToolView` into the standard kit and move the registry into the runtime package.** Rejected: "tool row" is a conversation-domain concept; hoisting it into runtime would leak a domain vocabulary into the framework layer and still leave two registration models. + +**Derive slot declarations from subscription refCounts** (declare the slot implicitly when the first registrant subscribes). Rejected for implicit coupling and debounce complexity; noted as a possible revisit only if a genuinely multi-viewer surface appears. + +**A thin `registerToolView` facade over slots.register.** Deferred, not rejected: after dissolution the facade would carry only compile-time sugar (slot-name literal narrowing, tool→key vocabulary, props pre-composition) with zero runtime. Per "enforce at the operation boundary" (a facade is not an enforcement point) and "don't split preemptively" (today's registrant population is one bash sample), it stays unbuilt; the type sugar ships as the exported `ToolRowProps` alias. Regret clause: if registrants grow to three-to-five or a bulk-registration pattern appears, the facade is ten lines added without disturbing direct registration. + +## Consequences + +The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override), plus one subtlety the load-order seam carries: registrant plugins must declare `inject: ['conversation']` to sequence after the slot declaration, a convention the seam makes correct by construction but does not statically force on third parties. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md new file mode 100644 index 0000000000..47c1f392f5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -0,0 +1,37 @@ +# Agent Note: toolview 溶解——工具行即 per-view keyed slot + +Status: implemented + +[English](2026-07-23-toolview-dissolution.md) | 中文 + +> 范围:独立工具环(ToolViewRegistry/ctx.toolviews/outlet)为何退役、被什么取代。本决策产出的落地态叙述归 [Web 客户端架构注](2026-07-19-gui-web-client-architecture.md);一切现在所运行其上的注册模型归 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 所有。 + +## Problem + +视图环溶解进 slot 体系之后,client 侧恰好还剩一套平行注册模型:工具环——一个具名注册表(`ctx.toolviews`),带自己的 register 文法、自己的 resolve 语义(scoped 压 global 的谓词分发)、自己的 subscribe/version 对、自己的 inject 缓存、自己带私有错误边界的渲染出口。其中每一件都是 slot 机器已经拥有之物的第二份实现,而每一项未来能力(行草稿的 store 席位、i18n 注入、跨 bundle 身份)都将不得不建两遍或漂移。这条环唯一像样的存在理由是:tool 名是运行时开放集,而 `SlotMap` 是封闭声明表——以任意字符串为键的注册表看似结构上必需。 + +## Decision + +工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 + +落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions` 读 `parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 + +registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。 + +## 接受的语义变化 + +四项行为增量是刻意接受而非疏漏。跨视图出场=逐视图注册——行本须适配各视图版式,一视图一注册是正确耦合,复用即同一组件写两次 register。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发从注册表谓词移入组件。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。 + +## Alternatives considered + +**保留独立注册表(原形态)。** 拒绝:其多维分发的每一维都有更正确的家——视图维归各视图自己声明的子槽(declaring is claiming,特化面权属自然落对),会话维归已持有标配 kit 的组件内部。两步移完后剩下的只是一份没有任何独有能力的 slot 机器副本。 + +**把 `renderToolView` 提进标配 kit、注册表迁入 runtime 包。** 拒绝:「工具行」是 conversation 域概念;上提进 runtime 会把域词汇泄漏进框架层,且依然留着两套注册模型。 + +**以订阅 refCount 推导槽声明**(首个注册方订阅时隐式声明槽)。拒绝:隐式耦合加去抖复杂度;记为将来真出现多观看面时的备选。 + +**slots.register 之上的薄 `registerToolView` 门面。** 缓建而非拒绝:溶解后该门面只剩编译期三糖(槽名字面量收窄、tool→key 词汇翻译、props 预组合),运行时为零。按「enforce at the operation boundary」(门面不是强制点)与「don't split preemptively」(今天注册方人口只有一个 bash 样例)保持不建;类型糖以导出的 `ToolRowProps` 别名兑现。后悔药条款:注册方长到三五家或出现批量注册模式时,门面十行可补,不扰直注。 + +## Consequences + +client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖),外加加载序缝携带的一处微妙:注册方插件须声明 `inject: ['conversation']` 才排在槽声明之后,这条约定由序缝构造保证正确、但不对第三方静态强制。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index d3011ed85f..5bde15dc2c 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -9,7 +9,7 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-..` (e.g. `'conversation.chat.toolview'`). 3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots` (children keys) & `PropsStore` (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`, `useStore`, `renderSlot` are the four seats. 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. @@ -20,9 +20,9 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments): -1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType`). Types are the extra allowance: contract types (owner shares, injected shapes, view/toolview entry types) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. +1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType`). Types are the extra allowance: contract types (owner shares, injected shapes, composed props aliases) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. 2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile. -3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot, the view and toolview registries) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. +3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. ## ctx discipline (components never see ctx) @@ -45,7 +45,7 @@ Non-negotiables across the layers: ## Directory regime (plugin packages) -One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through the slot/view/toolview registries in `apply` — never module-level side effects. +One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. ## Styling diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 66eb5b22ac..2f6fc0259c 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -13,6 +13,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. -- **Scope teardown is watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero. +- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). - **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id. diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 77b51a68b5..b90812916a 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -51,7 +51,7 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' */ export type ClientContext = Context -/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */ +/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */ export type UseConversationSession = SnapshotSelectorHook /** diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec63b8f70d..c7d5bf9273 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -5,13 +5,14 @@ * slot-parity design), session scope tree (mintScope pattern: no-op plugin * Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk. * - * Scope lifecycle is watch-driven: a scope is minted lazily on first - * resolution; a session leaving the list tears its scope down only when - * nobody is watching it. "Watched" is approximated as the most recently - * resolved binding id — SessionProvider re-resolves on every selection - * change (keyed remount), so a switch away always re-evaluates the deferred - * teardown; a host-side death without list removal keeps the scope (frozen - * read-only view). + * Scope lifecycle is stage-driven: a scope is minted lazily on first + * resolution (pure — resolution has no side effects and is render-safe); + * the event window and deferred teardown key off the STAGED session, which + * follows `list.current` exactly. Staging is the open signal: the window + * opens ⟺ the session is on stage (today the stage is `current`; the staged + * state can widen to a multi-pane list later). A session leaving the list + * tears its scope down immediately unless it is the staged one, whose scope + * survives frozen (read-only view) until the stage moves on. */ import type { Context, Fiber } from 'cordis' import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' @@ -97,9 +98,14 @@ export class SessionsService { private readonly selection: SnapshotStore<{ sessionId?: SessionId }> private readonly scopes = new Map() - /** Most recently resolved binding id — the watch approximation for deferred teardown. */ + /** + * The staged session id — follows `list.current` exactly, holding its last + * defined value across masked gaps (a transiently absent selection blanks + * `current` without moving the stage, so reconnect re-pulls and removals + * keep the staged scope's frozen view alive until the stage moves on). + */ private watched: SessionId | undefined - /** Removed-while-watched sessions whose teardown waits for the watch to move away. */ + /** Removed-while-staged sessions whose teardown waits for the stage to move away. */ private readonly deferredRemovals = new Set() /** @@ -115,6 +121,13 @@ export class SessionsService { // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. this.manager.subscribe(() => { this.projectList() }) + // Stage follower: every current write (open() and projection alike) + // re-evaluates staging, so startup restore (persisted selection validated + // by the projection) and reconnect resurfacing open their window with no + // dedicated code path. Safe to run synchronously inside the store notify: + // the follower writes no list state — session.open()'s synchronous prefix + // touches only session-side state and its own microtask-batched notifier. + this.list.subscribe(() => { this.followCurrent() }) rootCtx.reflect.provide('sessions', this, undefined) } @@ -152,35 +165,50 @@ export class SessionsService { } /** - * Resolve the stable session binding (SessionProvider's resolveBinding feed). + * Resolve the stable session binding (scope-addressed assembly feed). Pure + * resolution — no staging, no window side effects. * @param id - session id. * @returns binding, or undefined for a session neither listed nor already scoped. */ binding(id: SessionId): SessionBinding | undefined { - const record = this.resolve(id) - if (record === undefined) return undefined - if (this.watched !== id) { - this.watched = id - this.sweepDeferred() - } - return record.binding + return this.resolve(id)?.binding } /** * Resolve the render-layer session cell (SessionProvider's feed through - * the renderer host; ctx never enters the render layer). Marks the session - * watched, same as {@link SessionsService.binding}. + * the renderer host; ctx never enters the render layer). Pure resolution — + * render-safe: SessionProvider calls this during render, so no staging, no + * window side effects (StrictMode double-invokes and concurrent discarded + * passes must stay free). * @param id - session id. * @returns cell, or undefined for a session neither listed nor already scoped. */ cell(id: string): SessionCell | undefined { - const record = this.resolve(id as SessionId) - if (record === undefined) return undefined - if (this.watched !== id) { - this.watched = id as SessionId - this.sweepDeferred() + return this.resolve(id as SessionId)?.cell + } + + /** + * Move the stage to the list's current session: sweep teardowns deferred + * behind the previous occupant and pull the new occupant's history window. + * Staging IS the open signal — the window opens ⟺ the session is on stage + * — and open() is idempotent (an in-flight or completed open no-ops; a + * failed one retries the next time current is touched). + */ + private followCurrent(): void { + const current = this.list.getSnapshot().current + // A masked gap (current blanked while the selection's session is + // transiently absent) holds the stage: tearing down on the gap would + // destroy exactly the frozen scope the mask exists to preserve. + if (current === undefined || current === this.watched) return + this.watched = current + this.sweepDeferred() + const record = this.resolve(current) + /* v8 ignore next 3 -- defensive: current is always a listed id (open() + * validates and the projection masks absent selections), so resolve + * cannot miss; kept so a future current writer cannot crash the notify. */ + if (record !== undefined) { + void record.binding.session.open() } - return record.cell } /** @@ -246,7 +274,7 @@ export class SessionsService { this.pruneScopes(byId) } - /** Tear down scopes for removed sessions nobody watches; the watched one defers until the watch moves. */ + /** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */ private pruneScopes(byId: Record): void { for (const [id, record] of this.scopes) { if (byId[id] !== undefined) continue @@ -268,11 +296,11 @@ export class SessionsService { this.rootCtx.get('slots')?.pruneStoreScope(id) } - /** Run deferred teardowns whose session is no longer watched (called when the watch moves). */ + /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */ private sweepDeferred(): void { for (const id of [...this.deferredRemovals]) { - /* v8 ignore next -- defensive: only the watched id ever defers, and every - * watch move sweeps first, so the set cannot contain the id the watch just + /* v8 ignore next -- defensive: only the staged id ever defers, and every + * stage move sweeps first, so the set cannot contain the id the stage just * moved to; kept as a guard against future extra sweep call sites. */ if (id === this.watched) continue // Still absent from the list? (A re-added id cancels the deferred teardown.) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 9d18069887..0dff0bb644 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -2,8 +2,9 @@ * SessionsService: list store projection (manager → {ids, byId, current} * with derived titles), the migrated current-selection account (open * validation, persisted mask semantics, cell resolution), scope-tree - * lifecycle (lazy mint / frozen survival / removed teardown with watch - * deferral), binding identity, ancestry walk, create. + * lifecycle (lazy mint / frozen survival / removed teardown with staged + * deferral — the stage follows list.current), binding identity, ancestry + * walk, create. */ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -76,21 +77,21 @@ describe('scope tree', () => { expect(binding?.ctx).toBe(scoped) }) - it('tears down an unwatched removed session but defers the watched one until the watch moves', async () => { + it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) const ctx1 = b.svc.scope(sid('s1')) - b.svc.binding(sid('s1')) // s1 is watched - b.svc.scope(sid('s2')) // s2 scoped but not watched + b.svc.open(sid('s1')) // s1 staged (current) + b.svc.scope(sid('s2')) // s2 scoped but off stage - await feedList(b, [{ id: 's1' }]) // s2 removed, unwatched: torn down + await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down expect(b.svc.scope(sid('s2'))).toBeUndefined() - await feedList(b, []) // s1 removed while watched: deferred, scope survives + await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives expect(b.svc.scope(sid('s1'))).toBe(ctx1) await feedList(b, [{ id: 's3' }]) - b.svc.binding(sid('s3')) // watch moves: deferred teardown sweeps s1 + b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1 expect(b.svc.scope(sid('s1'))).toBeUndefined() }) @@ -106,10 +107,10 @@ describe('scope tree', () => { const b = bench() await feedList(b, [{ id: 's1' }]) const scoped = b.svc.scope(sid('s1')) - b.svc.binding(sid('s1')) - await feedList(b, []) // removed while watched → deferred - await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears - b.svc.binding(sid('s2')) // watch moves; sweep must NOT tear down the re-listed s1 + b.svc.open(sid('s1')) + await feedList(b, []) // removed while staged → deferred + await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged) + b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1 expect(b.svc.scope(sid('s1'))).toBe(scoped) }) }) @@ -168,15 +169,52 @@ describe('cell (render-layer session kit)', () => { expect(b.svc.cell('ghost')).toBeUndefined() }) - it('moves the watch like binding(): switching cells sweeps a deferred removal', async () => { + it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => { const b = bench() - await feedList(b, [{ id: 's1' }]) - b.svc.cell('s1') // watched - await feedList(b, []) // removed while watched → deferred, scope survives + await feedList(b, [{ id: 's1' }, { id: 's2' }]) + b.svc.open(sid('s1')) // staged + b.svc.cell('s2') // resolution only — must NOT move the stage + b.svc.binding(sid('s2')) + await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives expect(b.svc.scope(sid('s1'))).toBeDefined() - await feedList(b, [{ id: 's2' }]) - b.svc.cell('s2') // watch moves → sweep tears s1 down - expect(b.svc.scope(sid('s1'))).toBeUndefined() + }) + + it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }, { id: 's2' }]) + const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') + // Resolution is addressing, not staging: no window pull. + b.svc.scope(sid('s1')) + b.svc.cell('s1') + b.svc.binding(sid('s1')) + expect(historyCalls()).toHaveLength(0) + b.svc.open(sid('s1')) + expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1']) + // Same current again: no second pull. + b.svc.open(sid('s1')) + expect(historyCalls()).toHaveLength(1) + // Stage moves: the new occupant opens. + b.svc.open(sid('s2')) + expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2']) + }) + + it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => { + const storage = new Map([ + ['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })], + ]) + vi.stubGlobal('localStorage', { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + }) + try { + const b = bench() + expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0) + await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows + const historyCalls = b.api.calls.filter(c => c.method === 'session.history') + expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1']) + } finally { + vi.unstubAllGlobals() + } }) }) @@ -187,12 +225,13 @@ describe('slot-store scope prune hook', () => { b.ctx.reflect.provide('slots', { pruneStoreScope }) await feedList(b, [{ id: 's1' }, { id: 's2' }]) b.svc.scope(sid('s1')) - b.svc.binding(sid('s2')) // s2 watched - await feedList(b, []) // s1 unwatched → immediate drop; s2 watched → deferred + b.svc.scope(sid('s2')) + b.svc.open(sid('s2')) // s2 staged + await feedList(b, []) // s1 off stage → immediate drop; s2 staged → deferred expect(pruneStoreScope).toHaveBeenCalledWith('s1') expect(pruneStoreScope).not.toHaveBeenCalledWith('s2') await feedList(b, [{ id: 's3' }]) - b.svc.binding(sid('s3')) // watch moves → deferred sweep drops s2 + b.svc.open(sid('s3')) // stage moves → deferred sweep drops s2 expect(pruneStoreScope).toHaveBeenCalledWith('s2') }) @@ -242,44 +281,46 @@ describe('coverage tails (branch duals)', () => { expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd') }) - it('binding for an unknown session returns undefined without moving the watch', async () => { + it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - b.svc.binding(sid('s1')) + b.svc.open(sid('s1')) expect(b.svc.binding(sid('ghost'))).toBeUndefined() - // Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch. + // Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing. await feedList(b, []) expect(b.svc.scope(sid('s1'))).toBeDefined() }) - it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => { + it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - b.svc.binding(sid('s1')) - await feedList(b, []) // deferred removal of the watched id - // Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch). - expect(b.svc.binding(sid('s1'))).toBeDefined() + b.svc.open(sid('s1')) + const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') + expect(historyCalls()).toHaveLength(1) + await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred expect(b.svc.scope(sid('s1'))).toBeDefined() + // Resurfacing re-projects current = s1: same stage occupant, no second pull. + await feedList(b, [{ id: 's1' }]) + expect(historyCalls()).toHaveLength(1) + expect(b.svc.list.getSnapshot().current).toBe('s1') }) - it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => { + it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => { const b = bench() await feedList(b, [{ id: 'a' }, { id: 'b' }]) - b.svc.binding(sid('a')) - b.svc.binding(sid('b')) // watch: b; both scoped - await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred - // Move the watch to a THIRD id while b stays deferred: sweep now walks a - // set containing b (torn) — and the watched-continue branch fires when the - // deferral set still holds the current watch target. + b.svc.scope(sid('a')) + b.svc.open(sid('b')) // stage: b; both scoped + await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred + // Move the stage to a THIRD id while b stays deferred: sweep walks a set + // containing b (torn). await feedList(b, [{ id: 'c' }]) - b.svc.binding(sid('c')) + b.svc.open(sid('c')) expect(b.svc.scope(sid('b'))).toBeUndefined() - // Deferral for an id whose record was never minted: force-add via removed - // list state (scope teardown raced) — sweep must tolerate the missing record. - await feedList(b, []) - b.svc.binding(sid('c')) // c now watched+removed → deferred + // Deferral for an id whose record was never minted: force the deferral + // via removed list state — sweep must tolerate the missing record. + await feedList(b, []) // c removed while staged → deferred (scope exists) await feedList(b, [{ id: 'd' }]) - b.svc.binding(sid('d')) // sweep tears c + b.svc.open(sid('d')) // sweep tears c expect(b.svc.scope(sid('c'))).toBeUndefined() }) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3bf1c3bd6e..6d3cec90ea 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -1,12 +1,16 @@ # @deepseek-ai/dsh-client-ui-conversation -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, 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, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). + +The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. -Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain). +Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain 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 (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). +Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain). + +`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain 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 (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). ## Model Experience @@ -23,4 +27,3 @@ None; this package neither assembles nor sends a provider request. - **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project. -- **Module-level toolview caches are single-bundle state** — the inject cache and registry maps must reach cross-bundle consumers through the package export surface and loader module table, never by a second bundle copy. diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 51bdffb2d6..41256612d9 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -34,7 +34,6 @@ }, "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-client-i18n": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 266602e1b2..36917d48c1 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,39 +1,32 @@ /** - * Client plugin body: provide the conversation service and toolview registry, - * register the conversation/details slot occupants and the no-session empty - * state, and mount the chat view with its samples. Assembly only — components - * receive everything through props: the framework standard kit and store - * faces arrive automatically from the declarations below; the inject - * factories contribute the plain-data-and-callbacks business face (design §5). + * Client plugin body: register the conversation/details slot occupants and + * the no-session empty state, contribute the chat entry into the + * 'conversation.view' ring that the conversation registration declares, then + * mount the conversation service (class plugin) and the bash toolview sample. + * Assembly only — components receive everything through props: the framework + * standard kit and store faces arrive automatically from the declarations + * below; the inject factories contribute the plain-data-and-callbacks + * business face (design §5). Tool rows are ordinary keyed-slot registrations + * into 'conversation.chat.toolview' — no dedicated registry exists. */ import type { Context } from 'cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client' -import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client' -import type { SelectionTarget } from './contract/views.ts' -import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts' +import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { ViewTab } from './contract/views.ts' +import type { + ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected, +} from './contract/slots.ts' import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' -import { ToolViewRegistry } from './toolviews/registry.ts' -import { childSessionScope, registerChat } from './chat/register.ts' -import { registerBashSamples } from './toolviews/bash-sample.tsx' +import { ChatView } from './chat/ChatView.tsx' +import { bashToolviewSample } from './toolviews/bash-sample.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { EmptyState } from './skeleton/EmptyState.tsx' /** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ -export const inject = ['slots', 'layout', 'sessions', 'i18n'] - -/** Resolve a service via ctx.get, failing loud. Property access is reserved - * for contexts whose fiber declares the inject (scope fibers do not). */ -// T is the caller-named cast target; inlining `as T` per call site would scatter the budgeted cast. -// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -function need(ctx: Context, name: string): T { - const value = ctx.get(name) as T | undefined - if (value === undefined) throw new Error(`ui-conversation: ${name} service unavailable`) - return value -} +export const inject = ['slots', 'layout', 'sessions'] /** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */ function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService { @@ -49,48 +42,46 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat * @param ctx - client root context. */ export function apply(ctx: Context): void { - const sessions = need(ctx, 'sessions') - const layout = need(ctx, 'layout') - const i18n = need(ctx, 'i18n') - const slots = need(ctx, 'slots') - - const conversation = new ConversationService(ctx) - const toolviews = new ToolViewRegistry() - ctx.provide('toolviews', toolviews) - - const t = i18n.bind('conversation') - // Chat view + StatsLine footer; bash samples assembled here (apply is the - // only cross-domain point — chat consumes the resolver face, samples come - // from the toolviews domain). registerView inside registerChat is already - // effect-scoped; the raw sample registrations need the effect wrapper to - // ride the fiber cascade. - ctx.effect( - () => registerChat({ conversation, toolviews, t }), - 'ui-conversation: chat view') - ctx.effect( - () => registerBashSamples(toolviews, childSessionScope(sessions.list)), - 'ui-conversation: bash toolview samples') + const sessions = ctx.sessions + const layout = ctx.layout + const slots = ctx.slots // Shared store handle, constructed here so its identity lives and dies with - // this fiber (a module-level handle would be a de-facto singleton). Both - // session-slot registrations declare it; same scope key = same instance, so - // conversation writes and details reads meet in one store. - const chat = createChatStore() + // this fiber (a module-level handle would be a de-facto singleton). The + // conversation, chat-view, and details registrations all declare it; same + // scope key = same instance, so chat-view selection writes and details + // reads meet in one store. + const chatStore = createChatStore() + // Tab projection over the view ring's ledger (list entries carry id/order/ + // label as registration options; the ledger keeps them order-sorted). + const viewTabs = (): ViewTab[] => { + const tabs: ViewTab[] = [] + for (const entry of slots.entries('conversation.view')) { + /* v8 ignore next -- unreachable: list registration validates id at load. */ + if (entry.options.id === undefined) continue + tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id }) + } + return tabs + } + + // Conversation occupant. Declaring the view ring here is claiming it: + // ConversationRoot is the only component authorized to render the ring. slots.register({ name: 'conversation', - store: chat, - inject: (sessionId: SessionId, actions: BoundActions): ConversationInjected => { - const session = sessions.manager.get(sessionId) + children: { 'conversation.view': { kind: 'list', scope: 'session' } }, + store: chatStore, + inject: (sessionId: SessionId, actions: BoundActions): ConversationInjected => { + // History pull is NOT triggered here: the runtime sessions service opens + // the event window when the watch lands on the session (cell/binding + // resolution) — an inject factory assembles callbacks, it has no side + // effect on session state. const scoped = scopedConversation(sessions, sessionId) - // Watch-driven history pull: assembling the surface IS the watch signal - // (once per entry x session; open() is idempotent and self-recovers). - void session.open() return { views: { - list: () => conversation.views(), - subscribe: fn => conversation.subscribeViews(fn), - version: () => conversation.viewsVersion(), + list: viewTabs, + subscribe: fn => slots.subscribe('conversation.view', fn), + version: () => slots.getVersion('conversation.view'), }, send: (text, mode) => { const trimmed = text.trim() @@ -107,19 +98,46 @@ export function apply(ctx: Context): void { // Stop failure surfaces via snapshot.promptError; nothing to restore. }) }, - openDetails: (target: SelectionTarget) => { - actions.select(target) - layout.openDetails() - }, - loadOlder: () => { void session.loadOlder() }, open: (target: SessionId) => { sessions.open(target) }, } }, }, ConversationRoot) + // 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. + slots.register({ + name: 'conversation.view', + id: 'chat', + order: 0, + label: 'Chat', + children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } }, + store: chatStore, + inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => ({ + openDetails: (target) => { + actions.select(target) + layout.openDetails() + }, + loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() }, + }), + }, ChatView) + + // Class-plugin mount (packages/AGENTS.md service form): the service + // registers itself as `conversation` and lives on its own child fiber. + // Mounted AFTER the chat entry register above — construction guarantee for + // toolview registrants using `inject: ['conversation']` as their load-order + // seam: the service being present implies the chat entry (and with it the + // 'conversation.chat.toolview' declaration) is on the ledger. + ctx.plugin(ConversationService) + + // The bash sample rides that exact seam, in third-party posture. + ctx.plugin(bashToolviewSample) + slots.register({ name: 'details', - store: chat, + store: chatStore, inject: (): DetailsInjected => ({ closeDetails: () => { layout.closeDetails() }, }), @@ -128,7 +146,15 @@ export function apply(ctx: Context): void { slots.register({ name: 'conversation.empty', inject: (): EmptyStateInjected => ({ - startSession: opts => conversation.startSession(opts), + // ctx.get, not ctx.conversation: the service mounts on this plugin's + // own child fiber, so it is not in the inject topology the property + // proxy enforces; get reads the global store and stays loud on a torn + // boot through the optional-chain throw below. + startSession: (opts) => { + const conversation = ctx.get('conversation') + if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') + return conversation.startSession(opts) + }, }), }, EmptyState) } diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index fd612990ce..7bb9a22e3e 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -1,8 +1,9 @@ // AssistantMarkdown: renders assistant blocks in order — markdown text body, // reasoning as the figma Think summary row (expand = indented gray text), // other-block JSON fallback. Tool-call heads are NOT rendered here: the chat -// view groups them into tool rows via the toolview outlet (figma step-summary -// flow). Shared by finalized nodes and the streaming partial (pulse marker). +// view groups them into tool rows through its keyed toolview slot (figma +// step-summary flow). Shared by finalized nodes and the streaming partial +// (pulse marker). import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b444e557ec..c68cf98571 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -1,53 +1,55 @@ // ChatView: the default conversation view — message flow with user bubbles, // assistant narration, tool summary rows grouped into step runs, pending -// cards, paging and bottom-follow. Created via factory so plugin deps -// (toolviews registry, i18n) arrive by closure, never by import. +// cards, paging, bottom-follow, and the session stats line under the flow +// (chrome dissolved into the view: the footer is part of what a chat view +// IS, not registration metadata). 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). // // Render economics (architecture RFC performance model): the list parent // subscribes to snapshot segments that do NOT change per streaming chunk // (nodes/runningCalls/pending keep their references across chunk batches), so // during a token storm only StreamingTail re-renders; history rows hold via // memo on cache-stable node slices. Selection changes re-render the parent -// map but only rows whose own selected bit flipped. +// map but only rows whose own selected bit flipped. renderSlot is +// entry-identity-stable (framework binding cache), so passing it through +// memoized rows never churns them. import { - memo, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode, + memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from 'react' import type { - ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, + ConversationNode, ConversationSnapshot, RunningToolCall, 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 { ConvViewProps, SelectionTarget, Translate } from '../contract/views.ts' -import type { ToolViewProps } from '../contract/toolview.ts' -import type { ToolViewResolver } from '../contract/toolview.ts' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { SelectionTarget } from '../contract/views.ts' import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' +import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem } from './MessageItem.tsx' import { PendingCard } from './PendingCard.tsx' -import { ToolViewOutlet } from './ToolViewOutlet.tsx' +import { StatsLine } from './StatsLine.tsx' import css from './ChatView.module.css' -/** Plugin-supplied closure deps (assembled in registerChat, apply world). */ -export interface ChatViewDeps { - toolviews: ToolViewResolver - t: Translate -} - const FOLLOW_THRESHOLD = 24 type OpenDetails = (target: SelectionTarget) => void +/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */ +type RenderToolRow = ChatViewSlotProps['renderSlot'] + /** ui-slots' UseSession is deliberately wide (dependency direction); the * chat view narrows once to the runtime snapshot the binding actually feeds. */ type UseConversation = SnapshotSelectorHook -/** One tool call row (result or running): builds the bound ToolViewProps. */ -const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, callId, toolName, block, seq, onOpenDetails, selected }: { - registry: ToolViewResolver - sessionId: SessionId - useSession: ConvViewProps['useSession'] - t: Translate +/** 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. */ +const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected }: { + renderSlot: RenderToolRow callId: string toolName: string block: ToolResultNode | RunningToolCall @@ -56,24 +58,23 @@ const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, call onOpenDetails: OpenDetails selected: boolean }) { - const viewProps = useMemo(() => ({ - callId, toolName, block, useSession, - actions: { openDetails: () => onOpenDetails({ turnSeq: seq, callId, toolName }) }, - t, - }), [callId, toolName, block, useSession, seq, onOpenDetails, t]) + const owner = useMemo(() => ({ + callId, toolName, block, + openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) }, + }), [callId, toolName, block, seq, onOpenDetails]) return (

- + {renderSlot('conversation.chat.toolview', owner, { + entryKey: toolName, + fallback: , + })}
) }) /** Consecutive tool results as one step-run group (figma VERTICAL gap10). */ -const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t, results, onOpenDetails, selectedCallId }: { - registry: ToolViewResolver - sessionId: SessionId - useSession: ConvViewProps['useSession'] - t: Translate +const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId }: { + renderSlot: RenderToolRow results: readonly ToolResultNode[] onOpenDetails: OpenDetails /** Only set when the selected call lives in THIS group (memo economy). */ @@ -84,10 +85,7 @@ const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t, {results.map((node) => ( } -/** - * Build the chat view component over plugin deps. - * @param deps - toolview registry and bound translator. - * @returns the ConvViewProps component registered as the chat view. - */ -export function createChatView(deps: ChatViewDeps): FC { - const { toolviews, t } = deps +/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */ +export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { + const nodes = useSession((s) => s.nodes) + const runningCalls = useSession((s) => s.runningCalls) + const pending = useSession((s) => s.pending) + const openState = useSession((s) => s.openState) + const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) + const hasMore = useSession((s) => s.hasMore) + const loadingOlder = useSession((s) => s.loadingOlder) + const selectedCallId = useStore((s) => s.selection?.callId) - return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) { - const useSession = useSessionWide as UseConversation - const nodes = useSession((s) => s.nodes) - const runningCalls = useSession((s) => s.runningCalls) - const pending = useSession((s) => s.pending) - const openState = useSession((s) => s.openState) - const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) - const hasMore = useSession((s) => s.hasMore) - const loadingOlder = useSession((s) => s.loadingOlder) - const selectedCallId = useStore((s) => s.selection?.callId) + const items = useMemo(() => deriveChatFlow(nodes), [nodes]) - const items = useMemo(() => deriveChatFlow(nodes), [nodes]) + const listRef = useRef(null) + const atBottomRef = useRef(true) + const [atBottom, setAtBottom] = useState(true) + /** Paging anchor: height/position at click, compensated after the prepend lands. */ + const anchorRef = useRef<{ h: number; t: number } | null>(null) + const firstSeqRef = useRef(null) + const openedRef = useRef(false) + const lastKeyRef = useRef(null) - const listRef = useRef(null) - const atBottomRef = useRef(true) - const [atBottom, setAtBottom] = useState(true) - /** Paging anchor: height/position at click, compensated after the prepend lands. */ - const anchorRef = useRef<{ h: number; t: number } | null>(null) - const firstSeqRef = useRef(null) - const openedRef = useRef(false) - const lastKeyRef = useRef(null) + const firstSeq = nodes[0]?.seq ?? null + const lastItem = items[items.length - 1] - const firstSeq = nodes[0]?.seq ?? null - const lastItem = items[items.length - 1] - - const toBottom = (el: HTMLDivElement): void => { - el.scrollTop = el.scrollHeight - atBottomRef.current = true - setAtBottom(true) - } - - useLayoutEffect(() => { - const el = listRef.current - /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */ - if (el === null) return - // Open completed: jump to the bottom once. - if (openState === 'open' && !openedRef.current) { - openedRef.current = true - toBottom(el) - firstSeqRef.current = firstSeq - lastKeyRef.current = lastItem?.key ?? null - return - } - // Prepend (head seq decreased): compensate by the height delta. - if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) { - el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h) - anchorRef.current = null - firstSeqRef.current = firstSeq - /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ - lastKeyRef.current = lastItem?.key ?? null - return - } - firstSeqRef.current = firstSeq - // Own words must be visible: a new trailing user node force-scrolls - // (send lives in the composer, so arrival is detected here, not armed there). - const lastKey = lastItem?.key ?? null - const appendedUser = lastKey !== lastKeyRef.current - && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' - lastKeyRef.current = lastKey - if (appendedUser || atBottomRef.current) toBottom(el) - }) - - const onScroll = (): void => { - const el = listRef.current - /* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */ - if (el === null) return - const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 - atBottomRef.current = isAtBottom - setAtBottom(isAtBottom) - } - - // Follow streaming growth the parent never re-renders for (stable ref). - // The ref starts null and is assigned every render, so the placeholder - // initializer a function initial value would need never exists. - const followRef = useRef<(() => void) | null>(null) - followRef.current = () => { - const el = listRef.current - if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight - } - const onGrow = useRef(() => followRef.current?.()).current - - const loadOlder = (): void => { - const el = listRef.current - /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */ - if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop } - actions.loadOlder() - } - - const renderItem = (item: ChatFlowItem): ReactNode => { - if (item.kind === 'tool-group') { - const inGroup = selectedCallId !== undefined - && item.results.some((r) => r.callId === selectedCallId) - return ( - - ) - } - const node: ConversationNode = item.node - if (node.kind === 'assistant') { - return - } - /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ - if (node.kind === 'tool-result') return null - return - } - - return ( -
-
-
- {openState === 'loading' &&
载入历史…
} - {openState === 'error' &&
历史加载失败:{openErrorMessage}
} - {hasMore && ( -
- -
- )} - {items.map(renderItem)} - - {runningCalls.length > 0 && ( -
- {runningCalls.map((call) => ( - - ))} -
- )} - {pending.map((item) => )} -
-
- {!atBottom && ( - - )} -
- ) + const toBottom = (el: HTMLDivElement): void => { + el.scrollTop = el.scrollHeight + atBottomRef.current = true + setAtBottom(true) } + + useLayoutEffect(() => { + const el = listRef.current + /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */ + if (el === null) return + // Open completed: jump to the bottom once. + if (openState === 'open' && !openedRef.current) { + openedRef.current = true + toBottom(el) + firstSeqRef.current = firstSeq + lastKeyRef.current = lastItem?.key ?? null + return + } + // Prepend (head seq decreased): compensate by the height delta. + if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) { + el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h) + anchorRef.current = null + firstSeqRef.current = firstSeq + /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ + lastKeyRef.current = lastItem?.key ?? null + return + } + firstSeqRef.current = firstSeq + // Own words must be visible: a new trailing user node force-scrolls + // (send lives in the composer, so arrival is detected here, not armed there). + const lastKey = lastItem?.key ?? null + const appendedUser = lastKey !== lastKeyRef.current + && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' + lastKeyRef.current = lastKey + if (appendedUser || atBottomRef.current) toBottom(el) + }) + + const onScroll = (): void => { + const el = listRef.current + /* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */ + if (el === null) return + const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 + atBottomRef.current = isAtBottom + setAtBottom(isAtBottom) + } + + // Follow streaming growth the parent never re-renders for (stable ref). + // The ref starts null and is assigned every render, so the placeholder + // initializer a function initial value would need never exists. + const followRef = useRef<(() => void) | null>(null) + followRef.current = () => { + const el = listRef.current + if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight + } + const onGrow = useRef(() => followRef.current?.()).current + + const loadOlderAnchored = (): void => { + const el = listRef.current + /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */ + if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop } + loadOlder() + } + + const renderItem = (item: ChatFlowItem): ReactNode => { + if (item.kind === 'tool-group') { + const inGroup = selectedCallId !== undefined + && item.results.some((r) => r.callId === selectedCallId) + return ( + + ) + } + const node: ConversationNode = item.node + if (node.kind === 'assistant') { + return + } + /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ + if (node.kind === 'tool-result') return null + return + } + + return ( +
+
+
+ {openState === 'loading' &&
载入历史…
} + {openState === 'error' &&
历史加载失败:{openErrorMessage}
} + {hasMore && ( +
+ +
+ )} + {items.map(renderItem)} + + {runningCalls.length > 0 && ( +
+ {runningCalls.map((call) => ( + + ))} +
+ )} + {pending.map((item) => )} +
+
+ + {!atBottom && ( + + )} +
+ ) } diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 958a90526f..9b507e0662 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -1,13 +1,15 @@ -// GenericToolCard: the registry-miss fallback toolview — classifies the tool -// into one of the five figma row variants and renders the summary row. Also -// the shared base the bash sample builds on: any ToolViewProps consumer. +// GenericToolCard: the default tool row — classifies the tool into one of +// the five figma row variants and renders the summary row. Supplied by the +// chat view as the keyed toolview slot's render-site fallback (an +// unregistered tool name lands here); registrants may also compose it as a +// base, feeding the same owner payload through. import type { ReactNode } from 'react' import { IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ToolViewProps } from '../contract/toolview.ts' -import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts' +import type { ToolRowOwnerProps } from '../contract/slots.ts' +import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts' import { ToolRow } from './ToolRow.tsx' import { IconSparkle16 } from './IconSparkle16.tsx' @@ -22,8 +24,8 @@ const VARIANT_ICONS: Record = { others: , } -export function GenericToolCard({ toolName, block, actions }: ToolViewProps) { - const model = toolRowModel(toolName, block as ToolCallBlock) +export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { + const model = toolRowModel(toolName, block) return ( ) } diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index ebaa485117..d7211f2f91 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -1,14 +1,13 @@ // StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284 -// tokens · 45.2s · 5 turns · 32 steps"), mounted as the chat view's -// chrome.footer — the first chrome-attachment consumer. Duration has no data -// source in P-I (ledger). Subscribes to `nodes` only: chunk batches never swap -// that reference, so the row renders zero times during streaming (the RFC -// performance model's acceptance row). +// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow +// (part of the chat view body — the chrome attachment mechanism retired with +// the view ring). Duration has no data source in P-I (ledger). Subscribes to +// `nodes` only: chunk batches never swap that reference, so the row renders +// zero times during streaming (the RFC performance model's acceptance row). import { memo, useMemo } from 'react' import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import type { ChromeProps } from '../contract/views.ts' import css from './StatsLine.module.css' interface UsageTotals { @@ -55,8 +54,11 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals { } } -export const StatsLine = memo(function StatsLine({ useSession }: ChromeProps) { - const nodes = (useSession as SnapshotSelectorHook)((s) => s.nodes) +/** Props: the conversation-snapshot selector hook (handed down by ChatView). */ +export interface StatsLineProps { useSession: SnapshotSelectorHook } + +export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) { + const nodes = useSession((s) => s.nodes) const stats = useMemo(() => deriveStats(nodes), [nodes]) if (stats.steps === 0) return null const parts: string[] = [] diff --git a/packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx b/packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx deleted file mode 100644 index 9f376f593a..0000000000 --- a/packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx +++ /dev/null @@ -1,80 +0,0 @@ -// ToolViewOutlet: resolves the toolview for one call through ctx.toolviews -// (uSES over the registry version so unload falls back live) and renders it -// behind a per-row error boundary. GenericToolCard is the render-side -// fallback for both a registry miss and a crashed custom row. Pure props -// machinery, zero React context: a registrant inject factory receives the -// sessionId this outlet already holds, is called once per (registration x -// session) and cached, mirroring the slot injection discipline. - -import { Component, useSyncExternalStore, type ReactNode } from 'react' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts' -import { GenericToolCard } from './GenericToolCard.tsx' - -export interface ToolViewOutletProps { - registry: ToolViewResolver - sessionId: SessionId - toolName: string - viewProps: ToolViewProps -} - -/** Inject cache: per inject-factory (stable per registration) x session id. - * The inner Map lives and dies with its factory (WeakMap entry), so entries - * are bounded by the session count over the registration's lifetime. */ -const injectCache = new WeakMap, Map>() - -function cachedInject(inject: ToolViewInject, sessionId: SessionId): object { - let perSession = injectCache.get(inject) - if (!perSession) { - perSession = new Map() - injectCache.set(inject, perSession) - } - let props = perSession.get(sessionId) - if (!props) { - props = inject(sessionId) - perSession.set(sessionId, props) - } - return props -} - -class RowErrorBoundary extends Component< - { resetKey: unknown; fallback: ReactNode; children: ReactNode }, { failed: boolean } -> { - override state = { failed: false } - // Fallback state MUST flip here (render phase): a boundary whose derived - // state does not change re-renders the crashing children and React gives - // up after the second throw, escalating past the boundary. - static getDerivedStateFromError(): { failed: boolean } { - return { failed: true } - } - override componentDidCatch(error: unknown): void { - console.error('toolview row crashed:', error) - } - // A re-registration (resetKey bump) retries the custom row. - override componentDidUpdate(prev: { resetKey: unknown }): void { - if (this.state.failed && prev.resetKey !== this.props.resetKey) { - this.setState({ failed: false }) - } - } - override render(): ReactNode { - if (this.state.failed) return this.props.fallback - return this.props.children - } -} - -export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: ToolViewOutletProps) { - const version = useSyncExternalStore( - (fn) => registry.subscribe(fn), - () => registry.getVersion(), - ) - const resolved = registry.resolve(toolName, sessionId) - if (resolved === undefined) return - const Row = resolved.component - return ( - }> - {resolved.inject === undefined - ? - : } - - ) -} diff --git a/packages/client/ui-conversation/src/client/chat/register.ts b/packages/client/ui-conversation/src/client/chat/register.ts deleted file mode 100644 index b417ab4c0b..0000000000 --- a/packages/client/ui-conversation/src/client/chat/register.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Chat-side registration entry, called from the plugin apply (the assembly - * point): registers the chat view with the stats-line footer chrome. The - * chat domain touches the tool ring only through the contract resolver face; - * bash sample registration moved to apply (cross-domain assembly). - */ -import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConversationService } from '../service.ts' -import type { Translate } from '../contract/views.ts' -import type { ToolViewResolver } from '../contract/toolview.ts' -import { createChatView } from './ChatView.tsx' -import { StatsLine } from './StatsLine.tsx' - -/** Read face of the sessions list store (subscription not needed: the filter - * reads the latest snapshot at each resolve). */ -export interface SessionListReader { getSnapshot(): SessionListState } - -/** - * Default scoped-sample filter: the sub-session family. Sub-agent rows - * rendering differently is the registry's canonical product scenario, and - * forking gives W5 acceptance a real entry point to observe the differential. - * @param list - injected sessions list read face. - * @returns filter matching sessions with a parent. - */ -export function childSessionScope(list: SessionListReader): (sessionId: SessionId) => boolean { - return sessionId => list.getSnapshot().byId[sessionId]?.parentId !== undefined -} - -/** Assembly inputs for {@link registerChat} (resolved by apply, not here). */ -export interface RegisterChatDeps { - conversation: ConversationService - /** Toolview read face consumed by the chat rows' outlet. */ - toolviews: ToolViewResolver - /** Translator bound to the conversation namespace. */ - t: Translate -} - -/** - * Register the chat view (footer chrome included). - * @param deps - assembled service instances. - * @returns disposer removing the registration. - */ -export function registerChat(deps: RegisterChatDeps): () => void { - const { conversation, toolviews, t } = deps - return conversation.registerView({ - id: 'chat', - label: 'Chat', - order: 0, - component: createChatView({ toolviews, t }), - chrome: { footer: StatsLine }, - }) -} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a7001fb21e..eabe747f8f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,31 +1,101 @@ /** - * Slot-ring contract for the conversation package: the composed props shapes - * its registrants mount into the layout-owned slots (conversation / details / - * conversation.empty). Terminal slot design (§3): full component props are the - * automatic shares — PropsRuntime (framework standard kit) & PropsStore + * Slot-ring contract for the conversation package: the 'conversation.view' + * slot this package declares (the view ring — one list entry per conversation + * view tab), the chat view's per-tool row hole ('conversation.chat.toolview', + * keyed on the wire tool name), and the composed props shapes its registrants + * mount into the layout-owned slots (conversation / details / + * conversation.empty) plus its own slots. Terminal slot design (§3): full + * component props are the automatic shares — PropsRuntime (framework + * standard kit) & PropsRenderSlots (declared children) & PropsStore * (declared store's read/write faces) & the injected business face declared - * here. No renderSlot share: none of the three registrations declares - * children, so the zero-renderSlot inference applies. + * here. */ -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { createChatStore } from '../stores.ts' -import type { SelectionTarget, ViewEntry } from './views.ts' +import type { CallId, SelectionTarget, ViewTab } from './views.ts' -/** The shared chat store handle type (apply constructs one; conversation and details both declare it). */ +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** + * The conversation view ring: one list entry per view tab (chat here; + * trajectory/waterfall from ui-trajectory), rendered one-at-a-time by + * ConversationRoot via `only: `. Declared by this package's + * 'conversation' entry (declaring is claiming). Session scope: views read + * the conversation snapshot through the standard kit. + */ + '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. + */ + 'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps } + } +} + +/** + * View-slot owner share: deliberately empty — ConversationRoot supplies + * nothing at its renderSlot site (sessionId and the snapshot hook arrive as + * framework-standard props; tool rows go through each view's own declared + * toolview hole). Kept as the named owner seat so a future cross-view + * payload has a home. + */ +export interface ConvViewOwnerProps {} + +/** + * 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). + */ +export interface ToolRowOwnerProps { + /** Tool call identity (details linkage; stable across running → settled). */ + callId: CallId + /** Wire tool name (also the keyed dispatch key at the render site). */ + toolName: string + /** Frozen call slice: the running call or the settled result node. */ + block: ToolCallBlock + /** Open the details panel for this call (session-level facility, supplied by the view). */ + openDetails(): 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` 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'> + +/** + * Base props of a conversation view entry: the framework standard kit for the + * session-scope 'conversation.view' slot (useSession narrowed to the + * conversation snapshot by the runtime merge, sessionId, useSessions). + * Entries declaring the shared store or an inject face compose their shares + * on top (the chat entry's {@link ChatViewSlotProps}); store-less pure + * readers (ui-trajectory) take this base alone. + */ +export type ConvViewProps = PropsRuntime<'conversation.view'> + +/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */ export type ChatStore = ReturnType /** * Injected share of the conversation slot: plain data and callbacks only * (design §5 — hooks are framework-made). The store lines that used to ride - * here live in the declared {@link ChatStore} now; ancestry derives from the - * standard useSessions hook in-component; view rendering moved into the - * component, which holds every share a view needs. + * here live in the declared {@link ChatStore}; ancestry derives from the + * standard useSessions hook in-component; views render through the declared + * 'conversation.view' child slot, with this face projecting the tab strip. */ export interface ConversationInjected { - /** View registry read face (uSES triple from the conversation service). */ + /** View tab read face (uSES triple over the 'conversation.view' slot ledger). */ views: { - list(): readonly ViewEntry[] + list(): readonly ViewTab[] subscribe(fn: () => void): () => void version(): number } @@ -33,17 +103,29 @@ export interface ConversationInjected { send(text: string, mode: 'queue' | 'steer'): void /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ stop(): void - /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ - openDetails(target: SelectionTarget): void - /** Pull one older history page. */ - loadOlder(): void /** Navigate to another session (breadcrumb ancestors). */ open(id: SessionId): void } -/** Full conversation-slot component props: runtime share & store share & injected share. */ +/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */ export type ConversationSlotProps = - PropsRuntime<'conversation'> & PropsStore & ConversationInjected + PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore & ConversationInjected + +/** + * Injected share of the chat view entry: the two callbacks whose targets live + * outside the view (layout orchestration; the session object layer). + */ +export interface ChatViewInjected { + /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ + openDetails(target: SelectionTarget): void + /** Pull one older history page. */ + loadOlder(): void +} + +/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */ +export type ChatViewSlotProps = + PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview'> + & PropsStore & ChatViewInjected /** * Injected share of the details slot: the panel is otherwise a pure reader of diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index ea566eb248..1072b0cbbb 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -3,9 +3,12 @@ * one-line summary and expanded-body text from the frozen call slice. No * inline output ever — full results live in the details panel. */ -import type { ToolCallBlock } from './toolview.ts' +// The block union's defining home is runtime (fold-product types); this +// contract only forwards it (type-definition authority stays with the layer +// that produces the values). +import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' -export type { ToolCallBlock } from './toolview.ts' +export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' /** The frozen slice the chat view hands to toolview components as `block` * (both members are cache-stable references off ConversationSnapshot). */ diff --git a/packages/client/ui-conversation/src/client/contract/toolview.ts b/packages/client/ui-conversation/src/client/contract/toolview.ts deleted file mode 100644 index f79478a57e..0000000000 --- a/packages/client/ui-conversation/src/client/contract/toolview.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Tool-ring contract: the props surface handed to toolview components, the - * registry's resolve/registration shapes, and the tool-call block union. - * Shared face between the chat domain (ToolViewOutlet consumes resolve) and - * the toolviews domain (registry implementation + sample rows); domain - * implementation files import this, never each other. - */ -import type { FC } from 'react' -import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' -import type { CallId, Translate } from './views.ts' - -// The block union's defining home is runtime (fold-product types); the -// contract only forwards it (type-definition authority stays with the layer -// that produces the values). -export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' - -/** Props handed to registered toolview components. */ -export interface ToolViewProps { - callId: CallId - toolName: string - block: ToolCallBlock - useSession: UseSession - actions: { openDetails(): void } - t: Translate -} - -/** - * Toolview inject factory: produces the registrant's private injected share - * `I`, called once per (registration x session) and cached by the render - * outlet. Mirrors the slot inject shape (parameters derive from the - * declaration): toolviews are session-domain by nature, so the factory - * receives the session id only — service access goes through the - * registrant's own apply-closure ctx (design §5; binding objects retired). - */ -export type ToolViewInject = (sessionId: SessionId) => I - -/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */ -export interface ToolViewOptions { - /** Session filter; absent = global registration. */ - scope?: (sessionId: SessionId) => boolean - /** Private inject factory merged into the row's props by the render outlet. */ - inject?: ToolViewInject -} - -/** - * A resolved toolview registration. `I` is erased to `object` on the resolve - * read face (storage erases the per-registration parameter; the outlet merges - * injected props untyped — the register site already proved component ⊇ I). - */ -export interface ResolvedToolView { - component: FC - inject?: ToolViewInject -} - -/** The registry's read face consumed by render outlets (implementation lives in the toolviews domain). */ -export interface ToolViewResolver { - /** - * Resolve the renderer for a tool in a session. Order: scope match (later - * registration wins) > global > undefined (caller falls back to the - * generic card). - * @param tool - tool name. - * @param sessionId - session the row renders in. - * @returns resolved view, or undefined when nothing matches. - */ - resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined - /** - * Subscribe to registration changes (synchronous). - * @param fn - change callback. - * @returns unsubscribe. - */ - subscribe(fn: () => void): () => void - /** - * Monotonic version for uSES pairing. - * @returns current version. - */ - getVersion(): number -} diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index e201b67e20..da573f007a 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -1,89 +1,39 @@ /** - * View-ring contract: the typed conversation view table, the chat store state - * shared through it, and the props surfaces handed to registered views. - * Shared face between the skeleton domain (ConversationRoot renders views) - * and the chat domain (registers the chat view); domain implementation files - * import this, never each other. + * Shared conversation contract primitives: the view tab projection (slot + * entries in 'conversation.view' surface as tabs), the chat store state + * shared through the declared store, and the selection primitives every + * domain consumes. Shared face between the skeleton domain (tab strip + + * view outlet) and the chat domain; domain implementation files import this, + * never each other. The view ring itself IS the 'conversation.view' slot + * (contract in slots.ts) — the package-local view registry is retired, and + * so is the hand-threaded translate channel (framework-level per-slot i18n + * injection is the planned replacement). */ -import type { FC } from 'react' -import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' - -/** - * One ConversationViewMap entry: per-view props extension shapes (design - * ledger, view ring). `chromeProps` extends {@link ChromeProps} for the - * view's chrome attachments; `extraProps` extends {@link ConvViewProps} for - * the view component itself. Both optional — the common bases stay the floor. - */ -export interface ViewEntryDef { chromeProps?: object; extraProps?: object } - -/** - * Typed conversation view table; ui-trajectory merges {trajectory, waterfall}. - * The chat entry is declared inline here (self-merge from a sibling module - * trips TS6305 under tsc -b). - */ -export interface ConversationViewMap { chat: ViewEntryDef } - -/** View id constrained to registered ConversationViewMap keys (all string literals; chat is declared inline). */ -export type ViewId = keyof ConversationViewMap - -/** Per-view chrome props: the common base plus the entry's declared extension. */ -export type ChromePropsOf = - ChromeProps & (ConversationViewMap[Id] extends { chromeProps: infer C extends object } ? C : object) - -/** Per-view component props: the common base plus the entry's declared extension. */ -export type ConvViewPropsOf = - ConvViewProps & (ConversationViewMap[Id] extends { extraProps: infer E extends object } ? E : object) /** Tool call identity as carried on the wire (branded upstream in connection). */ export type CallId = string -/** Translate function bound to a namespace via i18n. */ -export type Translate = (key: string, params?: Record) => string - -/** One registered conversation view (props positions keyed by the entry's declared shapes). */ -export interface ViewEntry { - id: Id - label: string - order?: number - component: FC> - /** Per-view chrome attachments (chat mounts the stats line as footer). */ - chrome?: { header?: FC>; footer?: FC> } -} - -/** Props for view chrome attachments. */ -export interface ChromeProps { sessionId: SessionId; useSession: UseSession } - /** Selection target for the details linkage channel (toolcall is the step special case). */ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string } +/** + * One conversation view tab, projected from a 'conversation.view' slot + * entry's registration options (label falls back to the entry id). + */ +export interface ViewTab { id: string; label: string } + /** * Chat store state (slot terminal design §4): the per-session store shared by - * the conversation and details registrations. `createChatStore` implements - * this shape; views read it through {@link ConvViewProps}'s pass-through hook. - * `view` may carry a stale persisted id after a view plugin unloads — the - * registry is the runtime validator (unknown ids fall back to the first view). + * the conversation, chat-view, and details registrations. `createChatStore` + * implements this shape. `view` may carry a stale persisted id after a view + * plugin unloads — the slot ledger is the runtime validator (unknown ids fall + * back to the first registered view). */ export interface ChatStoreState { /** Details-linkage channel (conversation writes, details reads). */ selection: SelectionTarget | null /** Composer draft (persisted; survives session switches and reloads). */ draft: string - /** Active conversation view id; null falls back to the first registered view. */ - view: ViewId | null -} - -/** - * Props handed to registered conversation views. `useSession` and `useStore` - * are the framework hooks ConversationRoot received as a slot registrant, - * passed through unchanged (hook transfer is plain props passing; no - * business-made subscription exists on this path). No renderSlot share: the - * view ring delegates no sub-slots. - */ -export interface ConvViewProps { - sessionId: SessionId - useSession: UseSession - /** Chat store read face (selection is the only slice views consume today). */ - useStore: SnapshotSelectorHook - actions: { openDetails(t: SelectionTarget): void; loadOlder(): void } + /** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */ + view: string | null } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 5605971c54..23215f17d8 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,34 +1,31 @@ /** * Conversation domain plugin, browser half: skeleton (header/tabs/composer), - * typed view registry, scope-addressed ConversationService, named toolview - * registry, minimal details panel. Contract: api-contracts v3 section 7. - * Thin shell: type surfaces live in contract/, assembly in apply.ts; the - * three implementation domains (skeleton/chat/toolviews) never import each - * other — contract/ is their only shared face. + * the 'conversation.view' slot ring (chat entry here; other plugins + * contribute view tabs through ctx.slots), the chat view's keyed + * 'conversation.chat.toolview' row hole, scope-addressed ConversationService, + * minimal details panel. Contract: api-contracts v3 section 7. Thin shell: + * type surfaces live in contract/, assembly in apply.ts; the implementation + * domains (skeleton/chat) never import each other — contract/ is their only + * shared face. */ import type { ConversationService } from './service.ts' -import type { ToolViewRegistry } from './toolviews/registry.ts' export { apply, inject } from './apply.ts' export { ConversationService } from './service.ts' -export { ToolViewRegistry } from './toolviews/registry.ts' export type { - CallId, ChatStoreState, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps, - ConvViewPropsOf, SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId, + CallId, ChatStoreState, SelectionTarget, ViewTab, } from './contract/views.ts' +export type { ToolCallBlock } from './contract/tool-call-model.ts' export type { - ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver, -} from './contract/toolview.ts' -export type { - ChatStore, ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps, - EmptyStateInjected, EmptyStateSlotProps, + ChatStore, ChatViewInjected, ChatViewSlotProps, ConversationInjected, ConversationSlotProps, + ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, + EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. declare module 'cordis' { interface Context { conversation: ConversationService - toolviews: ToolViewRegistry } } diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 9c9ee639b8..94ebd59628 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -1,10 +1,10 @@ /** - * ConversationService implementation: scope-addressed send/cancel, view - * registry with a uSES read face, and the empty-state startSession chain. - * Contract: api-contracts v3 section 7. Selection/draft state moved to the - * declared chat store (slot terminal design §4) — the per-scope store maps, - * lazy construction, and prune bookkeeping this service used to carry are - * retired; what remains is the send/stop orchestration face. + * ConversationService implementation: scope-addressed send/cancel and the + * empty-state startSession chain. Contract: api-contracts v3 section 7. + * Selection/draft state moved to the declared chat store (slot terminal + * design §4); the view registry moved to the 'conversation.view' slot (slot + * ledger owns registration, ordering, and disposal) — what remains is the + * send/stop orchestration face. * * Scope addressing rides the cordis Service tracker: property access through * `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods @@ -23,23 +23,9 @@ import type { Context } from 'cordis' // in the browser while unit tests (single-instance path resolution) stay green. import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { ViewEntry, ViewId } from './index.ts' - -/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */ -interface ViewsState { - entries: Map - /** Sorted projection cache; null = rebuild on next read. */ - cache: readonly ViewEntry[] | null - tick: number - listeners: Set<() => void> -} /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ export class ConversationService extends Service { - private readonly viewsState: ViewsState = { - entries: new Map(), cache: null, tick: 0, listeners: new Set(), - } - /** * @param ctx - owning root context (the plugin apply context; the service * registers itself and follows that fiber's lifetime). @@ -68,60 +54,6 @@ export class ConversationService extends Service { if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`) } - /** - * Register a conversation view. Duplicate ids throw; the registration is an - * effect on the caller's fiber (plugin unload collects it). - * @param entry - the view entry. - * @returns disposer removing the view. - */ - registerView(entry: ViewEntry): () => void { - const views = this.viewsState - const dispose = this.ctx.effect(() => { - if (views.entries.has(entry.id)) { - throw new Error(`conversation view "${entry.id}" is already registered`) - } - views.entries.set(entry.id, entry) - bumpViews(views) - return () => { - views.entries.delete(entry.id) - bumpViews(views) - } - }, 'conversation.registerView()') - // The effect disposer settles asynchronously; the registry face stays a - // synchronous fire-and-forget disposer. - return () => { void dispose() } - } - - /** - * Registered views ordered by `order` (ties keep registration sequence). - * Stable array reference between mutations (uSES getSnapshot source). - * @returns the view entries. - */ - views(): readonly ViewEntry[] { - const state = this.viewsState - state.cache ??= [...state.entries.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - return state.cache - } - - /** - * Subscribe to view registry changes (synchronous, like the toolview registry). - * @param fn - change callback. - * @returns unsubscribe. - */ - subscribeViews(fn: () => void): () => void { - const { listeners } = this.viewsState - listeners.add(fn) - return () => { listeners.delete(fn) } - } - - /** - * Monotonic view registry version for uSES pairing. - * @returns current version. - */ - viewsVersion(): number { - return this.viewsState.tick - } - /** * Empty-state first-send chain (root-context method; does not read scope): * create the session, navigate to it, then send through the new scope. @@ -167,9 +99,3 @@ export class ConversationService extends Service { return sessions } } - -function bumpViews(state: ViewsState): void { - state.cache = null - state.tick += 1 - for (const fn of [...state.listeners]) fn() -} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index de970381ff..be37dfa9e8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -1,16 +1,17 @@ // ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 + // Tab_Group + view area + composer). Pure component — everything arrives via // props: the framework standard kit (useSession/sessionId/useSessions), the -// declared chat store's useStore/actions, and the injected business face. +// declared chat store's useStore/actions, the injected business face, and the +// renderSlot share for the declared 'conversation.view' child slot (views are +// slot entries; the active one renders via the list `only` filter). // Breadcrumbs derive from useSessions with a pure parentId walk; the active // view id lives in the chat store's `view` field (per-session by store scope). -import { useMemo, useSyncExternalStore, type ReactNode } from 'react' +import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps } from '../contract/slots.ts' -import type { ConvViewProps, ViewEntry } from '../contract/views.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' import css from './ConversationRoot.module.css' @@ -35,15 +36,15 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session } export function ConversationRoot({ - sessionId, useSession, useSessions, useStore, actions, - views, send, stop, openDetails, loadOlder, open, + sessionId, useSession, useSessions, useStore, actions, renderSlot, + views, send, stop, open, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) - const list = views.list() + const tabs = views.list() // The store's persisted view id may be stale (view plugin unloaded); the - // registry is the runtime validator — unknown ids fall to the first view. + // slot ledger is the runtime validator — unknown ids fall to the first view. const activeId = useStore(s => s.view) ?? 'chat' - const active = list.find(v => v.id === activeId) ?? list[0] + const active = tabs.find(v => v.id === activeId) ?? tabs[0] const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) const draft = useStore(s => s.draft) @@ -56,27 +57,6 @@ export function ConversationRoot({ ? null : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` } - // Views receive the shares this component already holds (hook transfer is - // plain props passing); the callback slice is referentially stable per - // injected identity so memoized view rows hold. - const viewProps = useMemo(() => ({ - sessionId, useSession, useStore, - actions: { openDetails, loadOlder }, - }), [sessionId, useSession, useStore, openDetails, loadOlder]) - - const renderView = (entry: ViewEntry): ReactNode => { - const Header = entry.chrome?.header - const Footer = entry.chrome?.footer - const View = entry.component - return ( - <> - {Header !== undefined &&
} - - {Footer !== undefined &&