Compare commits

..
10 Commits
Author SHA1 Message Date
figmar 0b79269b2c fix(apes-sidebar): inject 改空数组(修 DSH entry pending → 恢复模式)
根因:inject: ['logger'] 声明了 logger 为硬依赖 service,但 DSH host
没有 provider 注册 logger service → entry 永远 pending →
'1 entry did not activate' → 插件树加载失败 → 恢复模式循环。

对比 dsh-better-sidebar:其 inject 是 host 实际提供的 service
(webServer/sessions/webRuntime/tools)。apes-sidebar host half 只用
ctx.logger 做可选链调用(log?.info?.() 在 try/catch 里),logger 是
软便利不是硬依赖——不该出现在 inject 列表里。

修正:inject 改空数组 [],host half 不声明任何硬依赖 service。
同步更新 smoke.spec.ts 断言(inject toEqual([]))+
verify-mount.cjs 检查(inject === [])。

门禁:typecheck rc=0 · build rc=0 · test 29/29
2026-09-04 21:21:56 +08:00
figmar cfc9d3c884 fix(apes-sidebar): dsh 声明改嵌套格式(修 DSH 拒绝加载)
根因:package.json 用扁平键 dsh.bundle.patch / dsh.client.inject /
dsh.client.platform,但 DSH loadRecoveryFilteredProfile 只认嵌套
dsh 对象({ bundle: { patch }, client: { inject, platform } })。
扁平格式导致 DSH 报 'profile bundle apes-sidebar declares no
dsh.bundle' → 恢复模式循环。

修正为与 dsh-better-sidebar 0.18.0 一致的嵌套格式:
  dsh: {
    bundle: { patch: './cordis.patch.yml' },
    client: { inject: [betterSidebar, locale, sessions], platform: web }
  }

验证:node_modules/apes-sidebar 的 dsh.bundle 字段存在,
格式与 better-sidebar 逐键一致(Object.keys 比对 true)。

门禁:typecheck rc=0 · build rc=0 · test 29/29
2026-09-04 21:08:09 +08:00
figmar 4e60dde18d chore(apes-sidebar): 挂载验收脚本 scripts/verify-mount.cjs
模拟 DSH host 的 bundle 解析,验证 apes-sidebar 在 profile node_modules:
- package.json 可解析 (name=apes-sidebar v2.0.0)
- host entry 命名导出 { name, inject:['logger'], apply } 无 default
- invariant surface 可用 (assert)
- client entry 是 __ModuleLoader__ browser bundle(line 1 window)
- peer dsh-better-sidebar 满足 ^0.18.0

用法:node scripts/verify-mount.cjs(或 pnpm verify:mount)
依赖 profile 环境(~/.dsh/profiles/desktop 已装 apes-sidebar),
非单测门禁的一部分——CI 无 DSH profile 时跳过。

验收结果:14/14 通过(apes-sidebar 2.0.0 已挂载进
~/.dsh/profiles/desktop,dsh-better-sidebar 升级 ^0.17.1→0.18.0,
无新增 peer 冲突——所有 warning 为预存 runtime-provided peer)

门禁:typecheck rc=0 · build rc=0 · test 29/29
2026-09-04 20:54:53 +08:00
figmar a94c43343d docs(apes-sidebar): 修正 client 契约描述 + 记录 __ModuleLoader__ bundle 坑
- 规则 2 / Layout / Stack 段:client 入口实为命名 export function apply(ctx)
  (tsdown 打包为自包含 bundle,导出 { apply, ... }),非 AGENTS 旧述的
  'CJS closure factory — module.exports = (hostApi) => {}'——hostApi 描述失实,
  实际经 ctx.betterSidebar / ctx.effect 接线
- Gotchas 新增:lib/client.js 第 1 行是 window.__ModuleLoader__.load({...}),
  是 DSH browser 模块加载器格式,Node 侧 require 直接抛 'window is not
  defined'。这坐实 cordis scratch 挂载冒烟无法进单测门禁——client 产物
  按构造是 browser-only,不仅是缺 dsh CLI

门禁:typecheck rc=0 · build rc=0 · test 29/29
2026-09-04 20:20:09 +08:00
figmar 48e16c73c7 feat(apes-sidebar): Phase 2 — per-tab state(tasks showInactive 持久化)
- useTasksPrefs:ctx.settings.register('apesSidebar.tasks') namespace,
  uSES 观察 + update 写回,applies:'live' 跨 reload 持久化
- tasks 面板加 showInactive 开关:OFF 时只渲染 running 会话
- filterTasksRows 纯函数导出(hook 需渲染,过滤决策单测锁定)
- locales:tasks.showInactive zh/en
- tests/tabs.spec.ts:+4 个 per-tab state 过滤测试(25→29)
- AGENTS.md:Phase 2 更新(per-tab state 落地,scratch 冒烟 +
  apps·msgs·profile feed 延后——better-sidebar 无对应数据源 + 无 dsh CLI)

门禁:typecheck rc=0 · build rc=0 · test 29/29
2026-09-04 20:11:06 +08:00
figmar 856d744139 feat(apes-sidebar): Phase 2 — badge 计数 + auto-collapse(visible 门控暂停)
- apes:tasks badge = running 会话数;apes:agents badge = subagent 数
  (纯 (ctx)=>number|null 函数,0→null 隐藏,host 封顶 99+)
- useSessionList 接受 visible:非活动 tab 切 no-op subscribe,
  停止 live session-list churn 重渲染隐藏面板(better-sidebar
  'live views pause otherwise' 契约 = auto-collapse)
- tests/tabs.spec.ts:新增 5 个 badge 契约测试(6→11),mock
  sessions feed 直测 badge 纯函数
- AGENTS.md:Phase 2 标 partial(per-tab state / scratch 冒烟 /
  apps·messages·profile feed 延后,better-sidebar 0.18.0 无相应 API/数据源)

门禁:typecheck rc=0 · build rc=0(client.js 15.74→20K)· test 25/25
2026-09-04 20:04:47 +08:00
figmar e4dcd1118e docs(apes-sidebar): AGENTS.md Phase status 同步 — Phase 1 done 2026-09-04 17:47:02 +08:00
figmar f86c1d82e6 feat(apes-sidebar): Phase 1 — 5 个 apes:* tab 真实注册
- client/index.tsx 改用 dsh-better-sidebar 真实 TabDescriptor/
  TabComponentProps/Context(type-only,擦除不打包)
- 注册 5 个面板:apes:tasks(读 sessions,running/current/subagent
  标记)、apes:agents(subagent 过滤 + 按 parent 分组)、apes:apps /
  apes:messages / apes:profile(占位)
- 全部走 t() i18n,ctx.effect 包裹注册(HMR 安全 disposer)
- betterSidebar 缺失时优雅降级(bare DSH 不破坏 shell)
- context-types.ts 删除被取代的 Phase 0 占位镜像,保留 ApesLocaleService
- package.json devDeps 补 dsh-better-sidebar(dev-only 类型)
- tests/tabs.spec.ts: mock registry 验证 5 个 apes:* id 注册 +
  disposer 撤销 + 缺失降级 + namespace 锁

门禁全绿:typecheck / build / test(20/20)
cordis scratch 真实挂载冒烟留 Phase 2(需真实 DSH host 进程)
2026-09-04 17:42:57 +08:00
figmar 0e91e99f49 feat(apes-sidebar): Phase 0 工程骨架 — host/client 分裂 + i18n + 不变量面
从 0 重写的 v2.0.0 DSH 侧边栏插件第一阶段:
- 工程骨架:tsdown 双构建(host ESM + client CJS closure)、vitest、strict TS
- host 半:namespace 插件形状(name/inject/apply,无 default),minimal apply
- client 半:closure factory 注册 5 个 apes:* tab 占位 + zh/en i18n
- 不变量面:assert/semver/peer guard(dsh-better-sidebar >= 0.18.0)
- 依赖策略:dsh-better-sidebar=peerDependency(不打包),cordis=devDependency(仅类型)
- 门禁:pnpm typecheck + build + test 全绿(14 单测通过)
- AGENTS.md 工程约定 + .gitignore(排除 lib/node_modules/maxkb-integration)

模板:dsh-better-sidebar。作用域锁定信息层(tasks/agents/apps/messages/profile),
不含 file/terminal/browser(归 better-sidebar)、不含 team/room(归 background-agents)。
2026-09-04 17:14:22 +08:00
Hermes c319fb106e Initial commit 2026-09-02 20:50:00 +08:00
458 changed files with 2794 additions and 106723 deletions
-15
View File
@@ -1,15 +0,0 @@
# EditorConfig — https://editorconfig.org
# 仓库统一编辑器基线;缩进等风格细节以各文件的既有约定为准。
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
# Makefile 的 recipe 必须用真实 tab(GNU Make 硬性要求)。
[Makefile]
indent_style = tab
-101
View File
@@ -1,101 +0,0 @@
name: 📋 问题报告 / Issue Report
description: 报告 Bug、功能请求或建议(中英双语)| Report a bug, feature request or suggestion (bilingual)
body:
- type: markdown
attributes:
value: |
## 🙏 感谢反馈 / Thanks for reporting!
请尽量完整填写以下信息,能显著加快定位与处理速度。
Please fill out the following fields as completely as possible.
- type: checkboxes
id: checklist
attributes:
label: 提交前确认 / Pre-submit checklist
description: 请逐项确认 | Please confirm each item
options:
- label: 我已搜索现有 issue,确认没有**完全相同**的条目(只有完全一致才算重复)| I searched existing issues and confirmed there is no **exact duplicate** (only exactly identical issues count as duplicates)
required: true
- label: 我已确认使用最新版本 dsh-apes-sidebar / I confirmed I am on the latest dsh-apes-sidebar version
required: false
- type: dropdown
id: environment-type
attributes:
label: 运行环境 / Environment
description: 你使用的是哪种运行方式?| Which runtime are you using?
multiple: false
options:
- DSH Web(浏览器访问)/ DSH Web (browser access)
- Desktop 实现(如 deepseek-harness-desktop)/ Desktop build (e.g. deepseek-harness-desktop)
- 其他 / 不确定 (Other / Not sure)
validations:
required: true
- type: input
id: dsh-web-version
attributes:
label: DSH Web 版本 + 浏览器 / DSH Web version + Browser
description: |
例:DSH 0.1.1-rc.2 + Chrome 151(macOS)。
Example: DSH 0.1.1-rc.2 + Chrome 151 (macOS).
placeholder: "DSH 版本 / version + 浏览器 / browser(如 0.1.1-rc.2 + Chrome 151)"
validations:
required: false
- type: input
id: desktop-repo
attributes:
label: Desktop 实现仓库 / Desktop repo(源头 / upstream)
description: |
若使用 Desktop 实现,请附上源头仓库链接,方便对照宿主行为。
If using a Desktop build, please link the upstream Desktop repo for host-behavior reference.
placeholder: "https://github.com/anywhere-labs/deepseek-harness-desktop"
validations:
required: false
- type: input
id: plugin-version
attributes:
label: 插件版本 / Plugin version
description: dsh-apes-sidebar 的版本号 | The dsh-apes-sidebar version
placeholder: "如 0.13.1(`dsh plugin --profile web add dsh-apes-sidebar@latest` 安装的版本)"
validations:
required: false
- type: dropdown
id: category
attributes:
label: 类别 / Category
description: 请选择本 issue 的类型 | Please choose the type of this issue
multiple: false
options:
- 🐛 Bug(功能异常 / Something is broken)
- ✨ 功能请求(新功能 / Feature request)
- 💡 建议(体验改进 / Suggestion & improvement)
validations:
required: true
- type: textarea
id: description
attributes:
label: 描述 / Description
description: |
请详细描述:现象、期望行为、(Bug 必填)复现步骤、截图/日志等。
Describe in detail: what happened, expected behavior, reproduction steps (required for bugs), screenshots/logs, etc.
placeholder: |
现象 / What happened:
期望行为 / Expected behavior:
复现步骤(Bug 必填)/ Steps to reproduce (required for bugs):
截图或日志 / Screenshots or logs:
validations:
required: true
- type: textarea
id: additional
attributes:
label: 补充信息 / Additional context
description: 其他有助于定位的信息(可选)| Anything else that may help (optional)
placeholder: "操作系统 / OS、配置文件、报错堆栈、临时 workaround 等 / OS, config files, error stack, temporary workarounds, etc."
validations:
required: false
-211
View File
@@ -1,211 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# Cancel a superseded PR run (it is on a stale commit); never cancel a
# push run — it is already producing the post-merge signal. The mount job
# boots a real DSH + Chromium, so the savings matter there most.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
# Tests run git log against this repository and need the full history.
fetch-depth: 0
# pnpm version is pinned via packageManager in package.json; the action
# reads it from there (specifying both makes action-setup fail on
# purpose with ERR_PNPM_BAD_PM_VERSION).
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
# The plugin's git revert/cherry-pick spawn git without setting an
# identity (deliberate, see tests/smoke.spec.ts) and rely on ambient
# config; the runner has none by default.
- name: Configure git identity for tests
run: |
git config --global user.name "dsh-apes-sidebar-ci"
git config --global user.email "ci@dsh.invalid"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
- name: Build
run: pnpm build
# The consumer-facing declaration surface guard: a browser-only plugin
# (no @types/node, skipLibCheck: false) must type-check against the
# shipped lib/types. Runs AFTER build (it reads the built output).
- name: Check consumer type surface
run: pnpm check:consumer-types
# Windows parity lane for the pure-logic suite. The codebase carries real
# win32 branches (shell parsing, WSL path projection, the PowerShell
# installer) that mock-injection tests alone cannot validate — this lane
# runs the same typecheck / test / build / consumer-type-surface steps on
# a real Windows runtime. The plugin-mount lane (real DSH + Playwright
# Chromium) stays ubuntu-only and is deliberately NOT mirrored here.
ci-windows:
runs-on: windows-latest
timeout-minutes: 30
defaults:
run:
# The runner ships Git Bash: multi-line steps and
# scripts/check-consumer-types.sh work verbatim.
shell: bash
steps:
- uses: actions/checkout@v7
with:
# Tests run git log against this repository and need the full history.
fetch-depth: 0
# pnpm version is pinned via packageManager in package.json; the action
# reads it from there (specifying both makes action-setup fail on
# purpose with ERR_PNPM_BAD_PM_VERSION).
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
# Same rationale as the ci job: smoke tests spawn git without an
# identity and rely on ambient config, which the runner lacks.
- name: Configure git identity for tests
run: |
git config --global user.name "dsh-apes-sidebar-ci"
git config --global user.email "ci@dsh.invalid"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
# `test:windows` runs the whole suite in ONE fork (`--maxWorkers=1`),
# not `pnpm test`'s unbounded pool. Two distinct Windows-only failures
# motivated it, both recorded in docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md:
#
# 1. Six red runs were real 5000 ms vitest timeouts in specs that
# really start a process (tests/agent-pty:71/:106,
# tests/install-powershell:105 — a cold powershell.exe start
# measured 12.1 s, tests/git.spec.ts:85 at 9.6 s). The suite now
# declares its own budgets (global testTimeout 15 s, 30 s in the
# two heaviest describes), and serial execution stops the
# process-spawning specs starving each other on a 2-core runner.
# 2. Four runs died at teardown with `[vitest-pool]: Worker forks
# emitted error / Worker exited unexpectedly` while reporting
# N-1 of N files passed and zero FAIL lines — a worker recycling
# race, not a test failure. It survived --maxWorkers=2, so the
# lane now recycles nothing at all.
#
# No retries: they would hide a real regression instead of removing the
# race. The cost is wall-clock (the Windows step runs several minutes
# against a 30-minute job budget), not signal.
- name: Test
run: pnpm test:windows
- name: Build
run: pnpm build
- name: Check consumer type surface
run: pnpm check:consumer-types
# Pack the plugin as an npm tarball, mount it into a REAL DSH instance
# through the official `dsh plugin --profile web add` channel, and render
# the DSH web UI headlessly (Playwright Chromium) to prove the plugin
# mounts without crashing the shell. Drives `pnpm test:mount`, which boots
# a keyless `dsh web` against a scratch profile and sweeps every built-in
# sidebar tab. No API key needed — the whole lane is deterministic.
plugin-mount:
runs-on: ubuntu-latest
timeout-minutes: 30
# CI runs must never report to the production telemetry endpoint baked
# into the DSH composition (same guard DSH's own CI uses).
env:
DSH_TELEMETRY_DISABLED: '1'
steps:
- uses: actions/checkout@v7
# pnpm version is pinned via packageManager in package.json; the action
# reads it from there (specifying both makes action-setup fail on
# purpose with ERR_PNPM_BAD_PM_VERSION).
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Pin the DSH CLI the lane mounts into; the profile's bundle stack
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
# step with the plugin's @deepseek-ai/* peer range when bumping.
# Write an EXACT version, never a dist-tag: when 0.1.7-rc.1 shipped, the
# npm `alpha` tag still meant 0.1.7-alpha.2, rc.1 went out on `next`, and
# `latest` was still the 0.1.5 line — `@alpha` would mount a host this
# plugin no longer supports. The 0.2.0 line repeats the pattern: its
# first candidate is `next` while `latest` is still 0.1.7-rc.2.
# The lanes speak the slash-only RPC dialect
# (tests/e2e/host-protocol.ts).
#
# The larger heap is the fix for one of the two ways this step used to
# die (runs 34490912649 and 34363627184 on the alpha line): the pinned
# version's TRANSITIVES are declared as floating `^` ranges, so while
# upstream publishes a prerelease in stages (0.1.5-rc.2 went out
# 14:43-14:57 on 2026-09-10) npm resolved a mixed peer graph —
# 3062 ERESOLVE lines, then "Ineffective mark-compacts near heap limit"
# (exit 134). The other failure mode was a hard ETARGET on a sibling
# package that appeared minutes later; pinning a version that is fully
# published (not mid-flight) is the fix for that one.
#
# Peer resolution stays ON: `--legacy-peer-deps` was tried in the first
# cut of this fix and made the install broken rather than robust — it
# skips exactly the peers a global install has to supply, and
# @deepseek-ai/dsh-app-boot requires `@deepseek-ai/cordis-plugin-group`
# (a peer, not a dependency) at boot, so the CLI died with
# ERR_MODULE_NOT_FOUND before the smoke could even start. Verified
# locally that the plain install resolves it.
- name: Install dsh CLI (pinned)
env:
NODE_OPTIONS: --max-old-space-size=4096
run: npm install -g @deepseek-ai/dsh@0.2.0-rc.1
# lib/ is gitignored; the tarball is produced from this build.
- name: Build and pack the npm tarball
run: pnpm build && pnpm pack
- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium
- name: Mount + headless-render smoke
run: pnpm test:mount
# Aggregate double-mount regression: a fixture aggregate bundle mounts
# dsh-apes-sidebar under its own entry id BEFORE the plugin's bundle
# row; the plugin must back off (no duplicate /sidebar/api, boot OK).
# Uses the pinned DSH CLI installed above.
- name: Aggregate double-mount regression
env:
DSH_CMD: dsh
run: pnpm test:mount:aggregate
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
if-no-files-found: ignore
-116
View File
@@ -1,116 +0,0 @@
name: Release
# Publishes `dsh-apes-sidebar` to npm via npm Trusted Publishing (OIDC) —
# no NPM_TOKEN secret required, provenance is attached to the tarball.
#
# Trigger: a GitHub Release is published (tag vX.Y.Z). The tag MUST match
# package.json `version`; the workflow verifies this before publishing.
#
# Dist-tag policy: a PRERELEASE version (e.g. v0.18.0-alpha.0 — the DSH
# 0.1.2-alpha adapter track) publishes under the npm `alpha` dist-tag so the
# `latest` install target keeps pointing at the last stable cut (which
# supports the stable DSH line); stable versions publish as `latest`.
#
# One-time npm-side setup (manual, on npmjs.com):
# 1. package `dsh-apes-sidebar` -> Settings -> Publishing access
# -> Trusted Publishers -> "Add Trusted Publisher".
# 2. Provider: GitHub Actions
# Organization or user: omdsh-dev
# Repository: DSH-better-sidebar
# Workflow filename: release.yml
# Environment: (blank)
# 3. Save. Do NOT add an NPM_TOKEN secret — Trusted Publishing replaces it.
# Canonical reference: https://docs.npmjs.com/trusted-publishers
on:
release:
types: [published]
workflow_dispatch:
inputs:
dry_run:
description: Pack and verify everything, but do not publish
type: boolean
default: false
permissions:
contents: read
id-token: write # mint the OIDC token npm Trusted Publishing exchanges
concurrency:
group: npm-release
cancel-in-progress: false
jobs:
publish:
name: build · verify · publish
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
# Tests run git log against this repository and need full history.
fetch-depth: 0
# pnpm version is pinned via packageManager in package.json; the action
# reads it from there (specifying both makes action-setup fail on
# purpose with ERR_PNPM_BAD_PM_VERSION).
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
registry-url: https://registry.npmjs.org
cache: pnpm
# The plugin's git revert/cherry-pick spawn git without an identity
# (see tests/smoke.spec.ts); the runner has none by default.
- name: Configure git identity for tests
run: |
git config --global user.name "dsh-apes-sidebar-ci"
git config --global user.email "ci@dsh.invalid"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify release tag matches package.json version
if: github.event_name == 'release'
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
expected="v$(node -p "require('./package.json').version")"
echo "package.json version: ${expected#v}"
echo "release tag: ${RELEASE_TAG}"
if [ "${RELEASE_TAG}" != "${expected}" ]; then
echo "::error::Release tag ${RELEASE_TAG} does not match package.json version ${expected#v} (expected ${expected})."
exit 1
fi
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
- name: Build
run: pnpm build
# The consumer-facing declaration surface guard — mirrors the ci.yml
# verification order (typecheck → lint → test → build →
# check:consumer-types) so a release is gated on the same checks a PR
# runs.
- name: Check consumer type surface
run: pnpm check:consumer-types
- name: Dry-run publish (validation only)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: pnpm publish --dry-run --no-git-checks
- name: Publish to npm
if: github.event_name == 'release' || github.event.inputs.dry_run != 'true'
run: |
TAG=$(node -p "require('./package.json').version.includes('-') ? 'alpha' : 'latest'")
echo "publishing to npm dist-tag: ${TAG}"
pnpm publish --provenance --access public --no-git-checks --tag "$TAG"
+22 -147
View File
@@ -1,156 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Build output
lib/
dist/
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
# Dependencies
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
# Logs & caches
*.log
.vite/
.vitest/
coverage/
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
# vuepress v2.x temp directory
.temp
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Local env
.env
.env.local
.env.*.local
# pnpm
.pnpm-store
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
lib/
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
registry/
.npmrc
# stray npm cache scratch dir
.npm-cache-tmp/
# Playwright headless-render lane (tests/e2e) artifacts
playwright-report/
test-results/
pnpm-debug.log*
# Unrelated work that landed in this directory (not part of apes-sidebar)
maxkb-integration/
+6
View File
@@ -0,0 +1,6 @@
# apes-sidebar build config
# Allow node-pty (a transitive runtime dep of dsh-better-sidebar's terminal
# panel) to run its native build script. Phase 0 never exercises the terminal
# panel, but pnpm's ignored-builds gate hard-fails the preinstall dep check
# otherwise, which blocks `pnpm build`.
onlyBuiltDependencies=node-pty
+133 -102
View File
File diff suppressed because one or more lines are too long
-382
View File
@@ -1,382 +0,0 @@
# Changelog
> 本文档收录 dsh-apes-sidebar 的完整发布历史(最新版摘要见 [README](README.md);同步发布于 [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases))。
### v0.24.1
> 🎁 **本版包含从 v0.22.1 起的全部内容**(v0.23.0 的文件页 / 文件变动页 UI/UX 重构与性能打磨、v0.24.0 的 DSH 0.2.0-rc.1 支持线前移,以及本节的修复)。
>
> 🐞 **修复版**:文件树「一操作就整体刷新」的两处根因。① 原生承载面(`src/client/native/index.ts` 的 `sync()`)把 `files` 接管项当成多余注册——它按 KIND 记账、永远不在 `wanted` 里,于是**每次会话状态写入**都被 dispose + 重建;替换 slot 注册等于替换宿主的 slot entry,**整个插件 tab body 被卸载重挂**(展开/收起、切 tab、拖底部工作台都会触发)→ 文件树丢失层缓存、滚动位置与目录 watcher,并重新 `fs.trees([root, ...expanded])` 重列整棵可见树。② 目录实时刷新(`FileTree.tsx` 的 `retryDir`)**先把该层缓存删掉**再请求,行被占位替换后重建(构建 / 格式化 / 模型跑 bash 时整层闪空)。
- 🧪 判别性证据:真实挂载 lane 新增 `tests/e2e/zz-expand-refresh.e2e.ts`(展开只请求该层、tab body 存活、行数不归零;回退任一修复即红),配套两个 jsdom 单测(回退后 `registered` 6 → 12、`disposed` 全是 `dsh-apes-sidebar:files`;另一条断言重列期间旧行仍在屏)
- 📉 修复后实测(40 子项目录):展开 `fs.trees` payload 由 `[root, big]`(空缓存特征)变为 **`[big]`**,新开 WebSocket 1 → **0**,被删行 45 → **0**,外部写入由「删 40 建 40」变为 **只增 1 行**
- 🔧 `src/fs-watch.ts` / `use-dir-watch.ts` / `TreePanel.tsx` / `tab-adapter.tsx` 零改动(`fs.watch` 新建自身不投递事件,已实测证伪)
### v0.24.0
> 📦 **支持线前移**:仅适配 DSH **0.2.0-rc.1+**(peer 下限 `^0.2.0-rc.1`,CI 钉 `@deepseek-ai/dsh@0.2.0-rc.1`)。**0.1.7 线(含 npm `latest` 的 0.1.7-rc.2)请固定 v0.22.1(0.1.7 线最后发布的版本)**——宿主 0.2.0 的启动兼容性预检按 `semver.satisfies(宿主版本, peer范围, {includePrerelease:true})` 判定,caret 范围跨 minor 不成立(实测 `^0.1.7-rc.1` 对 `0.2.0-rc.1` 为 **false**,整行会被静默禁用),因此本版不保留任何 0.1.7 兼容分支。
- 📦 **基线抬到 `0.2.0-rc.1`**:14 条 DSH peer + 27 个 `@deepseek-ai/*` devDependencies 同步钉版;`dsh.plugin.json` 的 `engines.dsh` 随之前移;挂载 lane(`scripts/e2e-common.sh`)与 CI 钉版同步。
- 🔍 **适配前的实测结论(纯增量,故无运行时改动)**:本插件消费的 19 个宿主包里**零个值导出被删除**;类型面仅 `dsh-client-ui-primitives`(`DisclosureRow`/`TextShimmer`/`Tooltip` 新增可选 prop、overlay 顶部内距)、`dsh-session`(新增 `ToolCallRecovery`)、`dsh-api-remotes`(新增 product-analytics remote)有变化;会话格式仍 **v4**(无迁移包)、`SUBAGENT_DESCRIPTOR_VERSION` 仍 **3**、`dsh/lib/bin.js`(`dsh plugin` / `dsh web` / 启动行 / `--version`)与 `dsh-client-modules` 运行时**逐字节相同**、web profile 的客户端 inject 列表相同、`ui-primitives` 的裸 import 集合相同(无需新增测试期依赖)。
- 🧩 **宿主 web profile 组成变化与本插件无关但已核对**:新增 desktop-only 的 `product-analytics` / `desktop-product-telemetry`(web profile 上 `disabled`);原先就 `disabled` 的 `time-context` / `schedule` / `ui-schedule` 三行移入可选的 `dsh-experimental-schedule-bundle`。「宿主 tab 只有 terminal、browser 缺席」的挂载断言不受影响。
- 🧪 **测试与规则**:`tests/market-manifest.spec.ts` 的 peer 形状断言改为钉当前基线 tuple,并把「caret 跨 minor 必失效」写进注释(这条与 §3.4 第 12 条里「includePrerelease 更宽松」的经验不冲突:宽松只发生在同一 minor 内)。
- ⚠️ **唯一残留**:可选集成 `@huanlin/dsh-plugin-better-locale`(未被任何源码/测试 import,仅结构化运行时探测)的 peer 钉在 `^0.1.0-rc.8`,其最新 0.4.3 同样钉 `^0.1.5-rc.2`——两者在 0.2.0 上都会被宿主预检禁用,故 `pnpm peers check` 会报它的 5 条 unmet peer。这是上游未适配 0.2.0 的结果,与本插件自身 14 条 peer 无关。
### v0.23.0
> 🧭 **开发线版本(从未发布到 npm,全部内容随 v0.24.1 一并发布)**:文件页与文件变动页的整体 UI/UX 重构,加上一轮性能与交互打磨。文件页新增 **Ctrl/Cmd 与 Shift 多选**(VS Code 语义)、批量操作条、**Git 变更着色**(与 VS Code 同色的新增/修改/删除/冲突,目录带汇总点)、**新建文件夹**、拖拽上传体验重做;**多选可右键「压缩并下载」**,打包在宿主侧成为带进度的任务(`archive.build` / `archive.status`,逐条目回调,4 并发 + 5 分钟 TTL + sessionId 绑定)。「打开方式」改为**双源并存**:宿主探测到的本机关联应用(资源管理器 / VS Code / Cursor / Zed / 自定义编辑器,含 SSH 远端)与插件自研目标,可用设置开关 `openWithPluginTargets` 强制并存;右键菜单收敛层级(一级只留默认动作,应用列表进子菜单)。文件变动页**重构为层级树**,Git 视角(工作区 / 暂存 / 历史)与 Agent 视角(会话内改动)各自保留,目录行可暂存/取消暂存。交互与性能:`/sidebar/api/fs.tree` 实测 **22.8ms → 3.8ms**(10k 条目,readdir 一次取回 + 只为 cap 建行 + 小写比较排序 + 1.5s 目录级缓存,由写操作与 fs-watch 失效),新增批量路由 `fs.trees`(挂载/刷新的 N+1 请求变 1 个),右键菜单打开不再重列目录、行不再随 git 轮询重渲染。修掉「在侧边打开」没有效果(原来只写未展开的底部工作台,现按承载面分流到宿主 `openResource(preferNewPane)` 真正分栏)。
- ⚠️ **行为变更(安全相关)**:**删除工作区路径检测**——插件 fs 路由(tree/read/write/rename/remove/mkdir/媒体/HTML/upload/archive)不再做包含性检查,可读写宿主用户能访问的任意路径(仅受 OS 权限约束),`workspaceFence` 设置与围栏提示一并移除。
- 🧩 抽出可复用的全局组件与共享 store(`IconButton` / `Chip` / `SectionHeader` / `Notice` / `ConfirmDialog` / `StatusBadge` + 共享 git 状态 store),皮肤契约(`tests/theme.spec.ts`)继续守护;ZIP 打包器自研(无第三方依赖)并修掉非 latin1 文件名的下载头 500(RFC 5987)。
- 🐞 同时修掉:关闭文件后返回文件树时展开状态被重置(原生承载面的展开集合原先按 tab 记录,现改为**每会话状态**的投影)。
### v0.22.1
> 📦 **正式版**(npm `latest`):仅支持 **DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`)——**支持线不变**,0.21.1 / 0.22.0 的用户直接升级即可;**DSH 0.1.6-alpha.2 及更早请继续固定 v0.19.1**。这是一次补丁版:两个缺陷在真机上可稳定复现,而当时的单元测试**全绿**。
- 🐛 **`files` 接管 id 被孤儿化 → `native register files error: … already registered` 刷屏 + 文件树落到宿主空态(需刷新页面才恢复)**(社区 issue #770 / #771 / #766)。根因链由官方桌面壳日志逐字确认(`desktop.frontdesk.log` 2026-09-27 15:51:39.338):客户端条目替换(插件市场更新 / Plugins 页禁用→启用 / HMR 重打)期间,插件 teardown 逐个注销内置描述符 → `service.notify()`(同步内联)驱动 `sync()` 在**已经 inactive** 的插件上下文上运行;清理循环把**不是描述符**的 `files` 接管释放后又**在同一轮里重建**它——`tabs.register` 建在**宿主**上下文上(宿主上下文仍活着)因此照样取走 id,紧随的 `ctx.slots.inject` 建在插件上下文上抛 `cannot create effect on inactive context`,`live.set(FILES_KIND, …)` 永不执行,disposer 丢失 → 该 id 在**整个页面生命周期内不可再注册**。修复(社区 PR #777,@yanzhaohui1999):① 清理循环**跳过 `FILES_KIND`**——接管的寿命只由编辑器类型的 `wantsFiles/hasFiles` 开关与 seat disposer 决定;② **任何在宿主取走 id 之后失败的注册都回滚释放**该 type 与已建好的槽位,失败只留一个「下次通知可重试」的状态,绝不留下无处释放的 id;③ teardown / 清理循环逐个安全释放(一个释放抛错不再中断其余)。
- 🐛 **macOS 桌面版窗口拖拽「拖一次就失效」并且双击标题栏缩放也失效**(issue #772)。插件宿主是直挂 `body` 的子元素,宿主的 `html[data-platform=darwin] body > :not(#root) { -webkit-app-region: no-drag }`(选择器含 id,只有 `!important` 能压过)命中它,而 app-region **无视 `pointer-events`**——铺满视口的面板层把下面每条 `[data-window-drag]` 拖拽带一起抵消。修复(社区 PR #773 合并;随后 #786 补齐):`[data-dsh-apes-sidebar]`、`[data-dsh-panel-host]`(`> *` 保持 `no-drag`)与放大视图 `.mermaidModal` 用**中性值** `-webkit-app-region: initial !important` 退出计算——`initial` 的计算值 `none` **不**扣减拖拽区,而字面量 `none` 不是中性值(它计算成 `no-drag`);交互弹层(`.selectionPopup`、`FloatingWindow`、`AnchoredPopover`)**不**加 `initial`,否则按下会变成拖窗(重演 #103/#111)。
- ✅ **守护**:`tests/native-registration.spec.ts` 把两条不变量钉在注册表的**事件日志**上(在未修复代码上 4/4 红,且「失败后仍能重新注册」不再是非判别断言);槽位失败判据改为 `name::key`,让 `registerSlots` 的**部分回滚**释放路径真正被执行;新增**部署级回归门** `tests/e2e/native-reload.e2e.ts`(`utimesSync` 已安装的 `lib/client.js` → 页面内条目替换 → 断言无 `native register … error`、无诊断条、`files` 接管重新可用;npm 0.22.0 上连续 3 次运行全红、修复版连续 3 次全绿(该用例只有 1 个 test,重复跑三次))。拖拽契约由 `tests/panel-host-css.spec.ts` 在 Linux 的 `pnpm test` 里钉形状(无 macOS runner),并由挂载 lane 的**真实级联探针**按宿主规则读计算值(面板宿主/放大视图在未修复产物上都判红)。
- 📐 **基线不变**:`@deepseek-ai/dsh-*` 仍钉 `0.1.7-rc.1`,peer 下限 `^0.1.7-rc.1`;无公共 API / 契约 / 词典 / chunk 改动。事故记录与「为什么否决 #766 的吸收方案」见 [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](./docs/plans/2026-09-28-native-files-takeover-reload-leak.md)。
- 🧪 **验证**:`pnpm test` 122 files / 1293 passed / 9 skipped;`pnpm test:mount` 与 `DSH_CMD=… pnpm test:mount:aggregate` 绿;打包产物里 `app-region:initial!important` ×2、`no-drag` ×2。
### v0.22.0
> 📦 **正式版**(npm `latest`):仅支持 **DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`)——本版没有动支持线,0.21.1 的用户直接升级即可。**DSH 0.1.6-alpha.2 及更早仍请固定 v0.19.1**。主内容是把任务管理页从「子代理拓扑」重做成**工作流图**,并在随后几轮里按真机反馈打磨;期间 DSH 0.1.7 删掉了 Agent Teams 的 Remote 方法,团队与后台任务两个数据面随之改写。
- 🧩 **任务管理页 = 工作流图(全新主显示模式)**:会话树渲染为分层节点 + 贝塞尔连线的画布——拖拽平移、滚轮缩放到光标、内容包围盒居中适配(双击背景重新适配)、右下角控制条(图/树切换 + 折叠开关 + 缩放 + 适配)。**经典缩进树保留**(键盘可导航),两种模式共享同一个视图模型,折叠状态与团队富化不会视觉漂移;默认视图走设置里的偏好。
- 🃏 **双段式节点卡**:上段 = 类型徽章(主代理 / 子代理 / 成员 / 工作流 / 已完成聚合)+ 相位徽标 + 名称 + 元信息;下段小条 = 状态点 + 状态词 + **主 Agent 同款合并活动行**(并发工具按类别归并 + 计数 + 最新在跑那条的细节,措辞直接取宿主 `chat` 命名空间的词条)+ 已完成节点的折叠 chevron。运行中小条从左到右**完整扫过**(1.5s,令牌渐变,`prefers-reduced-motion` 下关闭)。卡片 8px 圆角、只用上段极淡底色表达层级、当前会话用加粗 accent 描边。
- 🔀 **工作流 run 入图**:宿主侧从 `tool-workflow/*` 会话事件折叠出 run(与官方 workflow-run 面板同一批事件),run 挂在发起代理下、成员 agent 重新挂到 run 节点下并按相位分框、同色相位徽标;catalog 里没有的成员用 run 自身数据合成占位节点,所以跑完的 run 仍能看到它的成员。
- 🗂 **折叠分两组,各自说清自己是什么**:`✓ N 已完成`(已完成 + 出错,失败会单独报 `出错 N`)与 `N 个待命`(跑完一轮、随时可被叫起来的 teammate)是**两行**;**手动 chevron 永远有效**(不设阈值),**自动聚合**只在「待命成员 ≥3」时收空闲成员——1~2 个待命成员就是这个团队的工作集,而且它们的卡上会挂任务行。聚合卡名字行写「前两个名字 + `+N`」,点聚合全部展开。
- 🪟 **两个常驻浮动窗**(抽出可复用的 `FloatingWindow`):后台任务输出与共享任务详情/编辑都在其中——可拖拽、可四边拉伸、内容区自滚动、只靠关闭按钮或 Escape 结束时消失(外部点击、失焦、锚点离屏都不关)。任务窗把窗口余量交给描述区(编辑态交给多行输入框),拉大窗口是给内容更多空间而不是留白;动作行固定在 footer,长描述不会把按钮推出视野。
- 👥 **Agent Teams 任务板(实验层)**:团队成员富化到对应节点上、常驻任务条列出成员与共享任务;任务状态机跟随宿主(待办 → 认领 → 进行中 → 完成 → 重开)+ 改派 / 编辑 / 两击删除,CAS 过期修订单独提示(别人改过 → 已刷新)。**成员活动由 `subagents.live` 的 running 叠加**,不是在客户端猜。
- 🔄 **后台任务改读宿主客户端服务**:0.1.7 的 web profile 挂载客户端 `ctx.jobs`(推送 roster + 非消费输出流 + kill),插件因此删掉自建的 `jobs.list` / `jobs.output` / `jobs.kill` 三条路由与事件回放镜像,也彻底不碰模型 `job_output` 游标(宿主把这层约束变成了能力)。输出改在常驻浮动窗里流式显示并尾随,代理数 ≥ 8 时抽屉自动折叠。
- 🛠 **DSH 0.1.7 的两处数据面重写**:① 团队读——0.1.6 的 `agentTeams.remoteView` 三个 Remote 方法被上游删除,插件改为读 Lead Session 的 **`agentTeam` Session projection**(与 `subagentCatalog` 同源、推送式),删掉 `teams.view` 路由与 5 秒轮询;写路径保留两条路由(`createTask` / `updateTask`),拒绝从「返回联合」变为「抛 `TeamError`」,`TEAM_TASK_STALE_REVISION` 映射成 409 `team-conflict`。② 后台任务——见上一条。**修掉的真实故障**:0.1.7 上团队条从来不渲染(路由报 `remoteView is not a function`,页面静默无提示)。
- 🐛 **真机抓到的四个缺陷**(单测都绿的):
- 逐节点折叠按钮点了没反应——它被**自动**折叠的守卫卡住了(`isAutoFoldable` 要求非成员且是叶子),而按钮是**手动**触发,现在两种触发各有各的守卫;
- 「待命」的卡片**从来不画**折叠按钮(chevron 的渲染条件写死成「已完成/出错」),现在「不在执行」即提供;
- 认领任务后标签变「阻塞」——`ready` 的语义只是「待办且无未完成 blocker 可认领」,对所有非 pending 任务都是 false,现在收敛成唯一规则 `taskBlocked/taskStatusLabel/taskTone/taskDotState`;
- 任务窗「完成」在队列中的任务上必失败——宿主状态机要求先认领,动作行现在跟随状态。
- 🎨 **窄屏与手机设置**:卡片/行距/字号按原生右侧栏的窄宽重新定档(内容包围盒居中、缩放夹取、平移边界、相位框配色、连线 stub 加粗、卡片高度预算重算);设置页新增**手机**分组——窄屏(≤768px)时新任务页不再自动弹出、任务管理页默认走树状图。
- 📐 **基线**:`@deepseek-ai/dsh-*` 钉版与支持线**不变**(0.1.7-rc.1,peer `^0.1.7-rc.1`)。新增词条若干(含 `teamTaskClaim` / `tasksFoldIdle` / `taskWindowCreate` / `tasksStateProvisioning` 等)×20 份词典;设计文档见 [docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md](docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md)、[2026-09-27-tasks-graph-polish.md](docs/plans/2026-09-27-tasks-graph-polish.md)、[2026-09-27-floating-window-mobile-settings.md](docs/plans/2026-09-27-floating-window-mobile-settings.md)、[2026-09-27-agent-teams-dsh-0.1.7-adaptation.md](docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md)(含实施偏差与已知限制)。
### v0.20.0(开发线,**从未发布**)
> 🚫 **这一版没有发布到 npm**(npm 上 `latest` 仍是 v0.19.1):它原本是 0.1.6-alpha.2 线的正式版,开发完成后被 0.21 线直接取代,所以下面这些变更实际随 **v0.21.1** 一起发布。原本的定位:仅支持 **DSH 0.1.6-alpha.2+**(peer 下限 `^0.1.6-alpha.2`,CI 钉 `@deepseek-ai/dsh@0.1.6-alpha.2`)。**这是一次破坏性变更版**:0.1.5-rc.* 用户请固定 `dsh-apes-sidebar@0.19.1`。适配记录见 [docs/plans/2026-09-21-dsh-0.1.6-alpha.2-adaptation.md](docs/plans/2026-09-21-dsh-0.1.6-alpha.2-adaptation.md)。
- 🖥️ **终端交给 DSH 内置**:DSH 0.1.6 自带右列终端(`ui-sidebar-terminal`),所以插件删掉了整套自研终端——`pty-manager` / `agent-pty` / `pty-deps` / 8 个 `terminal_*` 工具 / xterm 视图与字体链接 / 跨会话固定终端,以及 `node-pty` 依赖、它的 `allowBuilds` 放行与安装脚本的 `-Repair` 模式。**注意**:模型因而失去跨调用持久的终端(只剩一次性 `bash` / `pwsh`);上游 `@deepseek-ai/dsh-tool-terminal` 未被内置 bundle 挂载,需要时请自行在 profile 里启用。
- 🌐 **浏览器视图让给内置**:插件删掉 `BrowserView` / 沙箱状态条 / 嵌入性探测(含宿主 `browser.probe` 路由)与两个只配置自家 iframe 的设置项;**保留**聊天与界面的外链接管(按协议分流是宿主没有的能力),目标改为 DSH 内置的 `browser` tab 类型。
- 🧩 **产物行交回宿主**:DSH 0.1.6 把 `conversation.chat.turnTail` 从 chain 改成 list(上游有意让多个插件**追加**而非互相替换),插件原来的接管只能与内置产物卡重复,因此整体删除;点击行为不变(内置走宿主 `openFile` → 本插件编辑器)。代价是失去轮尾那一个「在文件树中显示」入口(树内右键仍在)。
- 🐛 **修复两个静默破坏**:`SidebarRightGuideEntry.id` 变成必填后,缺 id 会让**整个原生承载面静默空掉**(抛在 `ctx.inject` 回调里被吞);`turnTail` 改 list 后原注册直接抛错、产物行失效。两处都已修好并加测试守护,同时补了注册失败的上报口。
- 🎨 **空白面板卡片对齐内置卡风格**:`.paneCard` 改用 DSH 指南胶囊那套配方(0.5px l4 描边 / 24px 圆角 / layer-1 填充 / 56px 下限 / 14×20 内边距),并加测试守护其几何、令牌化配色,以及不复制上游那套**未定义**的 `--dsw-alias-bg-l1/-l2` 拼写。
- 📐 **基线**:`@deepseek-ai/dsh-*` 全部钉 `0.1.6-alpha.2`,`dsh-code-runtime` 整包消失已移除,`diff` / `simple-icons` 作为 primitives 的新裸 import 提升进 devDependencies;`ui-primitives` 本版三处破坏(`IconSendOutline16` 下线、`TerminalBlockLabels.noExitCode` 新增、`ConnectionIndicator.reconnectLabel` 移除)同步适配。
### v0.19.1
> 📌 **正式版**(npm `latest`,无 prerelease 后缀):钉版推进到 **DSH 0.1.5-rc.2**(npm `next`),**peer 下限仍是 `^0.1.5-rc.1`**——rc.2 的上游 delta 里没有任何触及本插件的面(零 `packages/api|host|session|agent` 变更,真实代码改动只有消息反馈弹窗、产物卡片 CSS 与 `CodeFileIcon` 的 SVG 数据拆分),因此 rc.1 用户无需升级 DSH 即可用本版。DSH 0.1.5-alpha.2 用户继续用 **v0.19.0-alpha.1**;0.1.2-rc.1 稳定线继续用 **v0.18.1**。
- 🎯 **适配 DSH 0.1.5-rc.2**:devDependency 钉版、CI 挂载车道与 `SIDEBAR_SERVICE_VERSION` 同步到 rc.2;插件侧**零代码改动**(上游 delta 未触及本插件,逐文件核对见 [docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md](docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md)——300 个变更文件里绝大多数只是各包 `package.json` 的版本号单行)。
- 🎨 **文件图标(#611)与内置 Tab 图标改为彩色**(#531 + #594 合并,实现按 rc.2 重做):
- **文件/文件夹图标走 DSH 官方图形**:回退链末端是宿主 `ui-primitives` 的 `FileTypeIcon`(48 类官方全彩代码/配置图形 + markdown/图片/PDF/Office/视频/文件夹的类目色板),插件**不再自带扩展名表,也不再需要图标懒加载 chunk**——`#429` 那份 563 条彩色数据与 `lib/client-file-icons.js`(255 kB)连同 `fileIconTheme` 设置开关一并删除,彩色就是唯一形态(核心 bundle 因此只增 10 kB)。
- **新增对外 `registerFileIcon` API**(能力 `'fileIcons'`):按扩展名(`exts`)、精确文件名(`names`)、目录名(`folderNames`)注册自己的图标,优先级降序、同级按注册序;**注意**:宿主分类器覆盖任意路径,所以注册 `exts: []` 的 catch-all 会接管所有未具体命中的行。
- **内置 tab 图标彩色**:文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器 六个类型与 diff 视图的 glyph 换成彩色版本(颜色全部来自 `--dsw-alias-*` 令牌,皮肤照旧全覆盖)。
- **原生右侧栏的 tab 芯片也带图标**:宿主的 tab 定义没有 icon 字段,但 `sidebar.right.pane.tab.title` 槽就是芯片内容——插件在该槽渲染「图标 + 标题」(编辑器 tab 带路径时显示该文件的图标),glyph 为 `aria-hidden`,芯片可访问名不变。
- 🛠 **CI 修复一:Windows lane 的真实超时**。`ci-windows` 在 2026-09-09/10 窗口内红了 8 次,其中 6 次是真起进程的用例撞上 vitest 默认 5000ms:`tests/agent-pty.spec.ts`(真起 PowerShell + ConPTY)与 `tests/install-powershell.spec.ts`(冷启 `powershell.exe` 实测 12.1s)现在各自声明 30s 预算,且 `vitest.config.ts` 的全局 `testTimeout` 从默认 5000ms 提到 **15s**(第一次真实 Windows 跑又在**第三个**文件上翻车:`tests/git.spec.ts:85` 耗时 9607ms——三个文件同一个根因,逐文件加超时是打地鼠);`waitForTranscript` 的内层轮询预算从 5000ms 降到 15s,**内层预算必须小于外层**(原先是同一个 5000ms,结构性必然超时)。`ci-windows` 的 `Test` 改跑 `pnpm test:windows`(`--maxWorkers=2`),在 2 核 runner 上不再让真起进程的 spec 互相抢占。
- 🛠 **CI 修复二:挂载车道的 npm 安装**。`plugin-mount` 的 `npm install -g @deepseek-ai/dsh@<ver>` 曾 4 次失败(2 次 `ETARGET`、2 次 `JavaScript heap out of memory` exit 134):钉版自身的传递依赖是浮动 `^` 范围,上游**分阶段发布**预发布版时(rc.2 于 09-10 的 14:43–14:57 逐包上线)npm 会组出 rc.1/rc.2 混合 peer 图(3062 条 ERESOLVE)。现在钉一个**已完整发布**的 rc.2(修 ETARGET)并加 `NODE_OPTIONS=--max-old-space-size=4096`(修 OOM)。中途试过 `--legacy-peer-deps`,被真实 CI 否掉:它跳过的正是全局安装必须提供的 peer,`dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` 是 peer 而非 dependency,加了它 CLI 直接 `ERR_MODULE_NOT_FOUND`。
### v0.19.0
> 📌 **正式版**(npm `latest`,无 prerelease 后缀):本版仅支持 **DSH 0.1.5-rc.1+**(peer 下限 `^0.1.5-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`)。DSH 0.1.5-alpha.2 用户请继续用 **v0.19.0-alpha.1**(npm `alpha` 标签仍指向它);0.1.2-rc.1 稳定线继续用 **v0.18.1**。
**✨ 新功能**
- 📝 **新建标签页列表恢复可选说明**(#613):DSH 0.1.5-rc.1 让 `SidebarRightGuideEntry.description` 回归(可选),插件随之恢复 `TabDescriptor.description`,六个内置类型各写回一条说明(原生指南的 文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器 六行都带上它),`guideDesc*` 词条回到 20 份词典。**宿主的原生指南只在列出的条目 ≤ 4 条时渲染说明**(更长的列表是整列丢弃,不是截断),而插件默认贡献 6 个 guide 条目——因此默认组合下说明不渲染,只有在插件设置页关掉足够多的 tab 类型、把 guide 压到 ≤ 4 条时才会出现。未声明说明的条目仍是「图标 + 标题」单行(插件不补通用兜底句)。
**🐛 修复**
- 无。rc.1 相对 alpha.2 的 delta 很小(373 个变更文件,绝大多数是上游各包版本号、原生右侧栏预览 UI 打磨与测试快照),除上述说明字段回归(并附带上游 `files` 类型改用自己的彩色文件夹图标作指南字形)外,没有需要插件适配的变更。
**🧰 CI 与内部**
- 基线推进到 **DSH 0.1.5-rc.1+**(#613):peer 下限、devDependency 钉版、CI 挂载车道与 `dsh.plugin.json` 的 `engines.dsh` 同步(rc.1 在 npm 上同时是 `latest` 与 `next`)。
- 明确未变、不必再核:全局主面板模型(`main` 槽 / `sidebar.panellist` / `ctx.layout` / 根级 `rightbar` + `rightbar.session`,插件仍不接入)、文件地址语法(`packages/util/workspace-path` 只动了版本号)、原生 tab 体宿主契约(`.paneBody` 仍是有确定高度的块级滚动容器)、core / agent / session / subagent 宿主 API 与 `ui-primitives` 导出面(仅 CodeBlock 渲染变化)。
- `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` 仍不声明 `dependencies` 而 bundle 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`——上一版提升进 devDependencies 的那组包因此保留,不得回退。
- 真机验证(DSH 0.1.5-rc.1 + 插件 0.19.0):门禁 `typecheck` / `lint` / `check:consumer-types` 全绿,单测 **124 files · 1296 passed · 9 skipped**,`pnpm peers check` 干净;挂载冒烟对真实 rc.1 **7 passed**(含 tab 体填充断言,以及新增的「指南 ≤4 条时说明才渲染」断言);本地 3080 实测:guide 六行仍是「图标 + 标题」(6 > 4,说明按上游规则不渲染;上游把胶囊 `min-height` 从 48px 调到 56px),文件树点击 `AGENTS.md` 落到插件编辑器(CodeMirror 就绪),sidechat 输入框贴底(宿主盒 962px == 面板体 962px,composer 底边距 8px),底部工作台与中心列左右边完全重合,`pageerror` 0(控制台仅有第三方 `dsh-tauri-worktree` 的 `/api/dsh-worktree/attach` 500,与本次改动无关)。
### v0.19.0-alpha.1
> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-apes-sidebar@latest`):本版仅支持 **DSH 0.1.5-alpha.2+**(peer 下限 `^0.1.5-alpha.2`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`)。0.1.5-alpha.1 请继续用 **v0.19.0-alpha.0**;0.1.2-rc.1 稳定线用 **v0.18.1**(npm `latest`)。
**✨ 新功能**
- 🪟 **每个 tab 体都填满面板**(#609):原生右侧栏的 tab 体宿主是「有确定高度的块级滚动容器」而非 flex 容器,此前插件各 tab 的根只写 `flex: 1`,在块容器里塌成内容高度——侧边对话的输入框因此贴不到面板底(转录一长就被推出可视区)。现在 native 适配层统一给每个 tab 体包一层 `height: 100%` 的列 flex 宿主,插件全部 tab(含第三方 `registerTab` 注册的 descriptor)恢复与底部工作台一致的填满语义。
**🐛 修复**
- 🧭 **跟随 DSH 0.1.5-alpha.2 的文件地址语法**:`fileAddressFor` 一律产出 session 作用域地址、绝对路径保留前导 `/`;`parseFileAddress` 改为前缀解析并忽略 `?`/`#` 后缀。
- 🪟 **底部工作台的中心列定位跟随 alpha.2 全局面板改动**:`conversation` 槽改为根级 `main` keyed 槽下的 `main.conversation`,定位器改认新 key 并跳过 `display: contents` 槽宿主(alpha.1 的旧 key 仍兼容)。
**🧰 CI 与内部**
- 基线推进到 DSH 0.1.5-alpha.2(#609):peer 下限、22 个 devDependency 钉版、CI 挂载车道与 `dsh.plugin.json` engines 同步;`pnpm peers check` 干净(按 §3-9 补提 `dsh-session-persistence` 传递 peer)。
- **移除 `TabDescriptor.description`**:alpha.2 的原生指南条目不再渲染第二行(改为「图标+标题」胶囊),该字段与 6 个 `guideDesc*` 词条(20 份词典)一并下线;新建标签页的默认页改由注册表选(恰好 1 个指南条目则直接打开它)。
### v0.19.0-alpha.0
> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-apes-sidebar@latest`):本版仅支持 **DSH 0.1.5-alpha.1+**(peer 下限 `^0.1.5-alpha.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.1`)。0.1.2-rc.1 稳定线请继续用 **v0.18.1**(npm `latest`)。
**✨ 新功能**
- 🖥️ **接入 DSH 原生右侧栏**(#604):右列改为 DSH 自己的右侧栏——插件的 7 个 tab 类型全部注册成原生 tab 类型 + 原生 tab 体;聊天里的文件打开统一走 `ctx.sidebarRight.openResource(dsh-resource://file/…)`;`editor` 类型以 `extension` 优先级认领文件资源(压过内置文本预览)并接管内置「文件」页 kind(注销即复位);跨会话打开在目标会话未上屏时排队重放。
- 🧩 **退役插件自绘右侧面板与自由窗口**(#605):右列归 DSH 后,插件只保留底部工作台(单分栏树、随会话持久化),开合按钮注册进 DSH 会话头 utilities 槽;浮窗 API(`floats` / `floatTab` / `dockFloat` / `raiseFloat` / 右键「移动到自由窗口」)、`features` 里的 `'floatWindows'`,以及 `openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项一并删除(旧持久化文档里的 `floats` 字段被忽略,不影响加载)。
- 🔗 **适配 DSH 0.1.5 宿主契约**(#603):`assistant/chunk` 事件删除 → 实时增量改由 `agent/assistant-stream` 帧折叠(侧边对话转录的 `live` 字段);`sessionPersistence.inspect` 删除 → 冷会话读取改走 `open(id,'read')`;会话头 `version` 用 `SESSION_FORMAT_VERSION`。
**🐛 修复**
- 侧边对话转录 / `jobs.output` 回放 / fork 继承等 8 处会话事件读取跟随 0.1.5 契约;自定义种子补齐 fork 标记对,避免继承父会话未领取的 inbox 输入。
**🧰 CI 与内部**
- 真机挂载冒烟门禁钉 0.1.5-alpha.1,e2e 增加「展开原生栏 → 经引导页逐个打开插件 tab 类型」的巡检;typecheck / lint / 单测 / 挂载车道全绿。
### v0.19.0(未发布)
- 🎨 **可选彩色图标主题**:设置页「文件 → 文件图标」可在**内置单色**与**彩色品牌图标**之间切换——彩色主题含 563 条规则(218 扩展名 + 197 精确文件名 + 148 目录名,如 `.tsx → React`、`package.json → npm`、`node_modules` 着色),数据在**懒加载 chunk**(`lib/client-file-icons.js`)里,只有选中时才下载,默认关闭时启动零开销。数据源自 [#429](https://github.com/omdsh-dev/DSH-better-sidebar/pull/429)(@fenter)。
- 🎨 **文件图标多样化 + 对外图标注册 API([file-icons.tsx](./src/client/file-icons.tsx))**:文件树与编辑器文件 tab 不再是单一 `VscFile`——markdown / 图片媒体 / PDF / JSON / 40+ 代码扩展 / 配置 / 数据库 / lock / 压缩包各有专属 glyph(VSCodicons 单色 `currentColor`,遵循皮肤契约),未知扩展回退通用图标。`ctx.betterSidebar` 新增 `registerFileIcon`(`features` 含 `'fileIcons'`):按扩展名 / 精确文件名(`names`)/ 目录名(`folderNames`)注册自定义图标(彩色 ReactNode 亦可,颜色责任在注册方),`exts: []` 为全局默认(只兜内置 glyph 没认领的扩展,不吞掉内置多样性),保留扩展名 `'folder'` / `'folder-open'` 可换目录行图标;`fileIcon` / `folderIcon` 为权威解析器(完整回退链 + 逐工厂崩溃隔离),注册/注销即时生效。接入示例见[外部插件指南 §7](./docs/external-plugin-guide.md)。
### v0.18.1
> 📌 **正式版**(npm `latest`):DSH 基线不变(**0.1.2-rc.1+**,peer 下限 `^0.1.2-rc.1`)——本版是 v0.18.0 之后的增量发布:变更面板预览能力增强、文件树可写,以及五项修复。
**✨ 新功能**
- 📄 **变更面板操作预览增强**(#499):`.md` 阅读模式(含 mermaid 渲染)、`.html` 与 `.pdf` 内嵌渲染预览;diff 语法高亮扩展到 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5;新增**密钥脱敏**层(预览默认开启,面板头部可切换)
- 🗂️ **文件树重命名 / 删除**(#550):行内重命名 + 确认式删除,右键菜单减重与子菜单视口钳制
- 🧩 **插件目录名与 shell 预设文案词典化**(#535):跟随宿主语言
**🐛 修复**
- 🔀 **git diff 折叠上下文真实展开**(#576,修复 #577):折叠行此前显示「n 行…点击展开」却点不动(`-U3` 裁剪使 gap 段没有行文本);现按需经 `git.show` 拉取两侧完整内容切片填充,带加载 / 失败降级三态与请求去重;顺带修复该路由的 `rev:path` 寻址(此前恒返空)
- 💬 **侧边对话种子不再继承父会话未领取的 inbox 消息**(#562):补 fork 标记对,消除「上下文很长时侧边对话先把之前的 User 消息发出去」的幽灵消息
- 🖼️ **Markdown 分栏渲染器内的本地图片**(#569):改写为可访问 URL,不再 404
**🧰 CI 与内部**
- ESLint flat config 接入 CI 与 Makefile(#536)、Makefile 命令面规范化(#526)、e2e 脚本加固(#527)、共享组件测试工具收敛样板(#524)
- 重构:Sidebar.tsx 按关注点拆分(#542)、四处轮询习语收敛到 `use-polling`(#541)、删除 rc.7 宿主的 `__DSH_MODULES__` 回退路径(#540)、One Dark/Light 语法色板单源化(#534)、重复实现收敛与死代码清理(#525)
### v0.18.0
> 📌 **正式版**(npm `latest`):本版仅支持 **DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请固定安装 `dsh-apes-sidebar@0.17.1`,停留在 0.1.2-alpha.x 的宿主继续用 `dsh-apes-sidebar@latest`(v0.18.0-alpha.0)。
**✨ 新功能**
- 🌿 **「文件变动」统一 tab**(#475):Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读/写/编辑实时追踪)双视角合一;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠)、底部可拖拽预览面板、一键展开独立 diff tab
- 💬 **侧边对话渲染升级**(#486):主对话级 Blocks 结构、turn 用量尾标、断线重连横幅
- ⚙️ **工作区路径围栏开关**(#458):新增 `workspaceFence` 声明式设置键,工作区外路径 403 时错误面一键关闭并指引
**⚡ 性能**
- 🚀 **核心包 -45%**(#489):19 个非中英词典 chunk 懒加载、渲染稳定化(转录行复用 / tree Sets / 批量 drag)、启动与轮询开销削减(单次 settings fetch、每 tick 单 git 进程);新增 perf 测量 lane;顺带修复底栏拖拽把面板宽度泄漏进宿主布局、原生左栏突跳的 bug
- 📉 会话列重复 DOM 查询削减(#456)
**🐛 修复(节选)**
- ✏️ 编辑器 / Markdown:SSH 远程链接客户端打开(#522)、预览/编辑切换保持阅读位置(#467)、预览隐藏 YAML frontmatter(#394)、TOC 外点关闭与层级修复(#461)、@-引用保留 basename(#417)
- 💻 终端 / 平台:Windows 自定义 shell 解析(#503)、resize 失败容错(#428)、字体解析兜底等宽(#366)、WSL 会话 Linux 绝对路径(#455)、Windows Explorer reveal 保留选中(#508)
- 🗂️ 布局 / 状态 / 文件树 / 对话:桌面 shell 布局与侧卡片共存(#398)、窄屏自动激活不强制抽屉(#373)、自由窗口 id 恢复冲突(#385)、窗口聚焦自动刷新文件树(#469)、引用提示固定行尾(#509)、划选弹窗关闭与草稿插入锚定(#427)、折叠态开关组对齐(#361)、旧引擎滚动跳变兼容(#448)、旧版 Git worktree 列表(#454)
**🧰 CI 与内部**
- Windows CI 车道(#520)、Makefile 命令面规范化(#526)、e2e 脚本加固 + 聚合双挂载回归(#527)、共享组件测试工具收敛样板(#524)、重复实现收敛与死代码清理(#525)
**🌐 生态收录**
- 新收录 10+ 插件:dsh-apes-sidebar-icons(#441)、dsh-sidenote(#451,原 dsh-sidechat #470)、dsh-github-workbench(#410)、dsh-bilingual-reader(#379)、dsh-server-deck(#413)、dsh-md-export(#405)、dsh-code-nav(#404)、dsh-suhuang-scroll(#392)、dsh-better-overleaf(#370)等(均含 18+ 语言 i18n 补齐)
<details>
<summary><b>历史版本(v0.12.0 – v0.15.2)</b></summary>
### v0.19.0-alpha.0(0.1.2-alpha.5 适配,未发布,内容并入 v0.18.0)
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`)。该版本号当时未单独发布,内容已并入 **v0.18.0** 正式版;v0.19.0-alpha.0 这个号后来被 0.1.5 适配线复用(见上)。
- 🔗 **适配 DSH 0.1.2-alpha.5(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版、`dsh.plugin.json` engines 下限与 `@deepseek-ai/*` peer / devDependencies 基线升至 0.1.2-alpha.5(真机挂载冒烟 14/14 验证)。`dsh-client-locale` 上游停在 0.1.2-alpha.3 未发新版,其 peer 下限 / devDep 保持并天然兼容 alpha.5 运行时(`pnpm peers check` 零失配,无需新增提升传递 peer)。代码适配了 alpha.4 的兼容性标记改动——`Session.events` 属性移除,迁移到按需读 API `snapshotEvents()`(sidechat 转录 live 读、fork 继承、`jobs.output` 回放、subagent 活跃度共 8 处),新建线程 meta 中宿主已删的 `seedLength` 一并移除;alpha.4 其余变化(双向 `send_message`、自定义模型发现复用 Profile 请求头、`SessionSeq`/`SessionLogOffset` 强类型)与 alpha.5(升级启动修复)经核实不触及本插件其余表面。
### v0.18.1-alpha.0
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.3`,npm dist-tag `alpha`,安装 `dsh-apes-sidebar@latest`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。
- 🔗 **适配 DSH 0.1.2-alpha.3(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版、`dsh.plugin.json` engines 下限与 `@deepseek-ai/*` peer / devDependencies 基线升至 0.1.2-alpha.3(真机挂载冒烟 14/14 验证)。逐点核查了 alpha.2 → alpha.3 全部 117 个 commit:插件依赖的宿主契约(token 鉴权、斜杠 RPC、`MarkdownText` labels、`sidechat.events` 所依赖的事件流与持久化 API、`SettingsNamespaceInput`、`SUBAGENT_DESCRIPTOR_VERSION`(仍为 3)、`dsh-client-store`、profile 加载器、node-pty 钉版)均无变化,无需代码适配;alpha.3 的破坏性改动(`BeginSubmissionInput.mode` 必填、subagent 错误码改名 `attachment-invalid`、SQLite 持久化后端移除、投影 change feed 收紧为 identity-gated)经核实均不触及本插件。
### v0.18.0-alpha.0
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.2`,npm dist-tag `alpha`,安装 `dsh-apes-sidebar@latest`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——stable DSH 用户请用 v0.17.1(npm `latest`)。
- 🔗 **适配 DSH 0.1.2-alpha.2(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版与 `@deepseek-ai/*` devDependencies 基线升至 0.1.2-alpha.2(真机挂载冒烟 14/14 验证)。适配点:`dsh-settings` 移除运行时导出 `settingsNamespace`(命名空间改为编译期校验,宿主侧直接传常量);`dsh-subagent` 描述符版本 2→3(由宿主包盖章,测试断言跟随 `SUBAGENT_DESCRIPTOR_VERSION` 常量);`SessionEvent.ignorable` 恢复与 Remote 网关 `RemoteError` 封装经核实对本插件无破坏。
- 🐛 **修复 DSH 0.1.2-alpha.1+ 上侧边对话转录空白**:转录轮询此前仍走 alpha.1 已移除的 `ctx.connection.api`(错误被静默吞掉,tab 永远渲染空转录),现改由插件自有 `sidechat.events` 路由供给(活线程读内存事件日志、冷线程读会话持久化,`afterSeq` 增量拉取,[sidechat-routes.ts](./src/sidechat-routes.ts))。
- 🧹 **删除对 0.1.1-rc.x 及更早的兼容层**:e2e 宿主 RPC 从点分/斜杠双方言收敛为斜杠单方言(token URL 必选,[host-protocol.ts](./tests/e2e/host-protocol.ts));`MarkdownText` labels 收敛为嵌套单形状([markdown-labels.tsx](./src/client/markdown-labels.tsx),不再双 prop 名);peerDependencies / devDependencies / `dsh.client.inject` / chunk externals 白名单四处同步清除已消亡的 `@deepseek-ai/dsh-client-runtime`。
### v0.17.1
- 🔗 **DSH 0.1.2-alpha.1 适配(双版本兼容)**:DSH 0.1.2-alpha.1 的 Remote gateway、一次性 token 浏览器鉴权与 `MarkdownText` labels 契约变更已全量适配,插件在 0.1.0-rc.8 ~ 0.1.1-rc.2 与 0.1.2-alpha.1 上一致工作(后者经 GitHub tag 源码构建的真机挂载冒烟 14/14 验证;alpha.1 至今未发布 npm,CI 钉版已在 v0.18.0-alpha.0 升至 npm 发布的 0.1.2-alpha.2)。要点:`MarkdownText` 四个渲染点统一改走双形状 labels helper([markdown-labels.tsx](./src/client/markdown-labels.tsx)),修复 alpha.1 上 markdown/mermaid 预览的 `reading 'code'` 崩溃;e2e 冒烟双协议化(token URL 换 cookie、`/api` 斜杠端点 + 按参数名包装 args,[tests/e2e/host-protocol.ts](./tests/e2e/host-protocol.ts));移除已在 0.1.2-alpha.1 消亡的 `@deepseek-ai/dsh-client-runtime` peer
### v0.16.1
自 v0.16.0 以来的全部更改:
**🐛 修复**
- 🧊 **Git 面板卡死 + 重启死循环**([#376](https://github.com/omdsh-dev/DSH-better-sidebar/pull/376),修复 [#369](https://github.com/omdsh-dev/DSH-better-sidebar/issues/369)):开启「源代码管理」面板可能整页冻结、重启后自动恢复冻结状态且无法退出——三层无上限操作叠加所致,现已全部设界:**① status 截断**——`git status --untracked-files=all` 响应上限 2000 条(超限置 `truncated`,面板显示截断提示,对齐 `fs.read` 截断语义;worktree 变更计数同步有界),海量未跟踪文件不再冻结浏览器主线程;**② 仓库发现限界**——cwd 非 Git 仓库(如家目录)时不再对每个可见子目录串行无界探测:探测超时 30s→5s、子目录探测上限 200 个、并发请求共享同一次扫描并按 60s TTL 缓存,家目录不再引发 `git rev-parse` 进程风暴;**③ 重置逃生通道**——带 `?dsh-sidebar-reset` 打开页面即丢弃持久化布局(含共享宽度)从默认布局启动,即使原页面已卡死也能自救,移除参数后恢复持久化;`statusTruncated` 文案同步全部 19 个词典
### v0.16.0
自 v0.15.2 以来的全部更改:
**✨ 新功能**
- 🪟 **自由窗口**([#354](https://github.com/omdsh-dev/DSH-better-sidebar/pull/354)):把标签栏的任意 tab(内置或插件注册)**拖到主会话区域**——会话列出现虚线提示浮层,松开即成为悬浮窗口(默认 390×780,手机竖屏比例,创建时按视口钳制后居中于松点);窗口支持头部拖动移动、右下角 SE 缩放(≥320×200)、点击任意处置顶、头部右键「回到侧边栏 / 关闭」、X 走 `closeTab` 正常关闭生命周期(释放终端等);拖到侧边栏 pane 上时该 pane 高亮、松开即**停靠**合并回该 pane;`floats` 随会话持久化(刷新原样恢复,宽容 sanitize + 几何钳入视口);服务语义:`features` 新增 `'floatWindows'`——`openTab` 的 dedupe/id 聚焦命中浮动 tab = **置顶窗口**(不重复开、不展开面板),`closeTab` / `activateTab` 对浮窗正常关窗 / 置顶并照常触发回调,浮窗内 tab `visible` 恒 true,agent 终端 reconcile 覆盖浮窗;tab 内容复用常规渲染、插件 tab 与 pane 完全同契约;附带文件二级页面 8px 网格间距规整([设计文档](docs/plans/2026-08-23-free-window-design.md))
- 📂 **模型主动打开(`sidebar_open` 工具)**([#353](https://github.com/omdsh-dev/DSH-better-sidebar/pull/353)):侧边栏新增全局设置 `agentOpenTools`(**默认关闭**),开启后向模型注入**一个**工具——模型可在调用方会话的侧边栏打开本地**文件**(editor tab,按 path 去重)、**文件夹**(全窗树窗口,以该目录为根,`meta.dir`)与 **HTTP(S) 网页**(browser tab,URL 预填);关闭设置即注销工具并清空未投递队列,已打开 tab 保留;非激活会话的打开排队、下次可见时重放(`/sidebar/ws/agent-opens` 推送,同一 trust fence);无新增公共 API、不改变 `BetterSidebarService`([设计文档](docs/plans/2026-08-23-agent-open-tools-design.md))
- 📝 **Markdown README 级内嵌 HTML + 目录大纲(TOC)**([#360](https://github.com/omdsh-dev/DSH-better-sidebar/pull/360)):Markdown 预览现在真实渲染**块级内嵌 HTML**——徽章墙 `<div align=center>`、`<details>` 折叠块内嵌 markdown、表格单元格 `<br/>`/`<sub>`/`<img>`、`<video>`/`<picture>` 全部经 DOMPurify 白名单消毒(`<script>` 等活性内容剥除、`<a>` 强制 `_blank rel=noopener`),本地媒体 src 重写为会话媒体路由;≥3 标题出现浮动**目录大纲**按钮,点击平滑滚动并自动展开折叠 `<details>`,HTML 段内标题同样收录;渲染器仍是宿主 `MarkdownText`(shiki / KaTeX / GFM 保留),纯 markdown(零 HTML)文档走原路径零回归([设计文档](docs/plans/2026-08-24-markdown-html-toc-design.md))
- 🌏 **第三语言覆盖(19 语言)**([#339](https://github.com/omdsh-dev/DSH-better-sidebar/pull/339)):接入可选 peer `@huanlin/dsh-plugin-better-locale`——ja / de / fr / pt / ko / ar / hi / id / tr / vi / th / ru / it / nl / sv / pl / zh-HK / zh-TW / zh-MO 全量词典(每种约 340 keys);覆盖**借用 DSH 英文槽位**(DSH active=en 时生效,zh 下完全惰性、界面不混语言);19 语言词典同时注册进 better-locale,外部 `ctx.locale.lookup('betterSidebar', key)` 调用者同样可拿覆盖文本;未安装时 `ctx.get('betterLocale')` 为 undefined、整段 no-op,zh/en 行为不变
- 🌿 **Git 多仓库选择 + linked worktree 变更发现**([#326](https://github.com/omdsh-dev/DSH-better-sidebar/pull/326) [#285](https://github.com/omdsh-dev/DSH-better-sidebar/pull/285)):会话 cwd 是工作区容器(非 Git 仓库)时自动发现直接子仓库并显示**仓库选择器**——status / 分支 / 历史 / diff / 暂存 / 提交 / 还原 / cherry-pick / 文件打开全部按所选仓库线程化;linked worktree 的变更发现与按工作树操作(含延迟分页响应的事务一致性),并拒绝过期 / 可修剪的 worktree 命令目标、对单库存取失败降级
- 🖥️ **浏览器本地回环允许清单**([#365](https://github.com/omdsh-dev/DSH-better-sidebar/pull/365)):新增侧边卡设置 `browserAllowedLoopback`(逗号分隔 host 或 host:port;裸 host 匹配任意端口、带有端口精确匹配)——显式信任的本地开发服务器(如 Vite)可导航,并额外获得 iframe `allow-same-origin` 令牌(模块 / HMR / fetch 管线需要真实 origin,否则白屏);页面相对 GUI 与其他站点仍是跨源;服务端 `browser.probe` 镜像同一允许清单,本地服务器不再被误拒
- 📝 **编辑器 Vue + 28 种 legacy 语言语法高亮**([#202](https://github.com/omdsh-dev/DSH-better-sidebar/pull/202)):`.vue` 映射 `@codemirror/lang-vue`(template / script / style 按 `lang` 属性分派、`<style lang="scss">` 预处理器);零新依赖用 legacy-modes 补齐 scss/sass/less/stylus/ruby/lua/perl/r/dart/scala/groovy/powershell/diff/protobuf/cmake/pug/tcl/haskell/clojure/erlang/julia/pascal/vb/vhdl/stex/objectivecpp;语言工厂抛错降级纯文本(console.warn),不再炸编辑器;`.v` / `.m` 跨语言歧义故意不映射
- 🔄 **编辑器预览刷新三件套**([#215](https://github.com/omdsh-dev/DSH-better-sidebar/pull/215) [#228](https://github.com/omdsh-dev/DSH-better-sidebar/pull/228),修复 [#167](https://github.com/omdsh-dev/DSH-better-sidebar/issues/167)):文本预览新增**手动刷新**按钮;编辑保存后切回预览自动重载(dirty 时抑制,草稿不丢);预览模式下保存成功边沿自动重载;移除自动轮询与 `fs.stat` 版本端点(后台 API 零流量)
- 🖼️ **Markdown 本地 / 相对图片**([#292](https://github.com/omdsh-dev/DSH-better-sidebar/pull/292)):`![alt](./img.png)`、`/cwd/img.png` 与引用式 `[id]: url` 目标重写为 `/sidebar/file` 媒体 URL(会话 cwd 边界不变)——预览不再只显示 alt 文本
- ➕ **推荐插件目录新增 ego-browser**([#340](https://github.com/omdsh-dev/DSH-better-sidebar/pull/340)):`@dsh-external/ego-browser` Agent 浏览器 Tab(会话侧边栏自动注册本机浏览器页,无 better-sidebar 时回退浮动浮窗);描述词典 19 语言补全([#371](https://github.com/omdsh-dev/DSH-better-sidebar/pull/371))
**🐛 修复**
- 🛒 **DSH 市场受管安装兼容**([#338](https://github.com/omdsh-dev/DSH-better-sidebar/pull/338)):移除 `peerDependencies` 里的公开版 `cordis`(市场预览硬拒依赖字段出现 `cordis`,optional 无效)——npm 包满足 [dsh-community-market 安装规范](https://github.com/anywhere-labs/deepseek-harness-desktop/blob/master/dsh-community-market/docs/install-and-uninstall.zh.md),dshfind / 1024Store 目录里的条目重新获得 `repository_backlink` 验证目标,可直接从 Desktop 市场受管安装
- 🔤 **类型基底迁移到 `@deepseek-ai/cordis`**([#338](https://github.com/omdsh-dev/DSH-better-sidebar/pull/338)):`Context` = 真实 vendored cordis Context 与结构化服务面的**交集**,`ctx.betterSidebar` 类型合并改挂 `@deepseek-ai/cordis`,公开版 cordis 不再被依赖。**消费者迁移**:`import type { Context } from 'cordis'` 改为 `import type { Context } from '@deepseek-ai/cordis'`(`import type {} from 'dsh-apes-sidebar'` 的类型合并方式不变);未使用该导入的插件无影响
- 🧩 **插件树内 `ctx.betterSidebar` 读取全面修复**([#357](https://github.com/omdsh-dev/DSH-better-sidebar/pull/357),修复 [#356](https://github.com/omdsh-dev/DSH-better-sidebar/issues/356)):npm 安装的 DSH 0.1.1-rc.x(web bundle)下侧边栏页面每次加载即崩(`cannot get property "betterSidebar" without inject`)——26 处内部直读 `ctx.betterSidebar` 改走 `ctx.get('betterSidebar')`(root reflect store 解析,不受 fiber 链影响);外部消费者 `inject: ['betterSidebar'] + ctx.betterSidebar` 契约不变
- 🔐 **文件 API 会话工作区边界**([#345](https://github.com/omdsh-dev/DSH-better-sidebar/pull/345),修复 [#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)):`fs.tree / fs.read / fs.write` 的 workspace 越界访问修复;媒体、HTML 预览与上传统一 real-path 符号链接校验;新增绝对路径 / 符号链接 / 上传 / 嵌套 Git 会话回归测试
- 🪟 **面板宿主层级与视口裁剪**([#330](https://github.com/omdsh-dev/DSH-better-sidebar/pull/330) [#278](https://github.com/omdsh-dev/DSH-better-sidebar/pull/278),修复 [#277](https://github.com/omdsh-dev/DSH-better-sidebar/issues/277)):面板宿主层 z-index 40→25——低于 DSH cordis 动态插件面板 30,工作台不再遮挡 cordis 清单 / 审批面(AppFrame 20 之上、100+ 浮层之下);宿主 `overflow: hidden` 裁剪视口边缘,收起的面板不再把文档撑出双向滚动(实测 `scrollWidth` 2289→1672 / `scrollHeight` 1280→1032,任意皮肤)
- 📐 **布局推挤加固**([#310](https://github.com/omdsh-dev/DSH-better-sidebar/pull/310) [#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130) [#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)):对话列补 `min-height: 0` + `overflow: hidden` + `overflow-wrap: anywhere`(长不可断 URL / OAuth 链接不再把 composer 与左侧设置按钮挤出视口);layout-push effect 拆「仅设置 + 仅卸载移除」并按 `panelOpen` 门控宽度 push——右栏关闭时拖底部高度不再挤压对话区、松手瞬间不再整页右铺再回弹;`useLayoutEffect` 消除跨 paint 全宽闪帧;松手 flush 最终帧 + `centerRect.right` 同步提交;底部高度按 `viewportHeight - PANEL_MIN` 封顶;拖拽手柄拖动中不再高亮
- 📱 **移动端无会话状态说明 + 1px 溢出修复**([#254](https://github.com/omdsh-dev/DSH-better-sidebar/pull/254)):无会话时开关改用 `aria-disabled` 保持不可执行语义、同时允许触摸 / 键盘聚焦显示「选择一个会话以使用侧边栏」提示;panel 改 `border-box`——移动端 `100vw` 含左边框,不再产生 1px 横向溢出
- 📏 **侧边栏宽度跨会话共享**([#36](https://github.com/omdsh-dev/DSH-better-sidebar/pull/36)):面板宽度是布局偏好而非会话内容——「最后一次拖拽胜出」写入全局 `dsh-sidebar:v1:width`,缓存会话切换与新建会话即时跟随;无全局键(首次运行 / 旧会话)时行为逐字节不变
- 🧹 **会话删除立即关闭该会话终端**([#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130)):新增 `PtyManager.closeSession()` + 订阅 DSH `session/disposed`——删除会话不再等 30s 重连宽限到期(agent 终端由 agent 生命周期管理,不受影响)
- 🔍 **文件名搜索跳过噪声目录**([#342](https://github.com/omdsh-dev/DSH-better-sidebar/pull/342)):`node_modules` / `.pnpm-store` / `.yarn` / `.turbo` / `.next` / `dist` / `build` / `coverage` 等黑名单(小写不敏感,`.git` 仍跳)——超大依赖树不再耗尽 10 万访问预算提前 `truncated`,`docs/` 等后序目录里的真实文件能搜到;不引入 `.gitignore` 语义,保持「文件名查找」
- 📝 **mermaid 全局错误渲染抑制**([#341](https://github.com/omdsh-dev/DSH-better-sidebar/pull/341)):开启 `suppressErrorRendering`——非法图表不再把大错误 SVG 注入 `document.body`;组件级错误回退与源码展示保留
- 🖥️ **终端 Nerd Font 图标字体回退**([#190](https://github.com/omdsh-dev/DSH-better-sidebar/pull/190)):starship / powerlevel10k 提示符的补充平面 PUA 图标(Nerd Fonts v3 Material 图标集)不再显示豆腐块——`withIconFontFallbacks()` 为胜出的基础字体追加 Nerd Font 图标族(插入首个通用族之前、按族名去重、过滤 CSS 全局关键字、不列彩色 emoji 字体)
- 🌐 **HTML 预览 UTF-8 声明**([#193](https://github.com/omdsh-dev/DSH-better-sidebar/pull/193),修复 [#170](https://github.com/omdsh-dev/DSH-better-sidebar/issues/170)):`/sidebar/html` 响应带 `charset=utf-8`(无 `<meta charset>` 的中文片段不再乱码),保留原始文件字节
- 🧪 **trust-fence Origin 改按 hostname 比较**([#182](https://github.com/omdsh-dev/DSH-better-sidebar/pull/182)):Edge 151 把非默认端口 loopback 页面的 Origin 序列化为无端口形式——`http://127.0.0.1` 对 `Host: 127.0.0.1:3080` 不再 403(对齐 DSH 官方网关栅栏);不同 hostname / opaque null origin 仍拒绝
- 🪟 **「在文件夹中显示」改为资源管理器揭示**([#94](https://github.com/omdsh-dev/DSH-better-sidebar/pull/94)):不再把目录当文件开进编辑器(`"..." is a directory`)——`revealInExplorer` 切到资源管理器 tab、面板折叠时自动展开、展开父目录并高亮滚动到本轮产出文件;产物行数据改读引擎 Turn deliverable(与 ui-deliverables 同源)
- 🖱️ **面板拖动布局闪烁**([#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)):右侧栏关闭时拖底部高度不再左移挤压对话区;松手瞬间不再整体右铺再回弹
- 🖥️ **PowerShell 安装脚本修复**([#47](https://github.com/omdsh-dev/DSH-better-sidebar/pull/47)):远程入口统一为「下载脚本 → 移除 UTF-8 BOM → 内存执行」,`-Version` / `-DryRun` 参数在 Windows PowerShell 5.1 下恢复生效(BOM 解析不再吃掉首行 `param(...)`);安装前校验 `pnpm --version`(主版本 <10 时明确报错并以退出码 1 结束,不再写一半 profile)
- 🔄 **浏览器嵌入探测 GET 兜底**([#69](https://github.com/omdsh-dev/DSH-better-sidebar/pull/69)):HEAD 响应同时缺 CSP 与 X-Frame-Options 时回退 GET 重试一次——阿里云百炼等只在 GET 回头发嵌入策略的站点不再显示误导性「拒绝连接请求」,而是正确显示「该站点拒绝嵌入」面板 + 「在浏览器中打开」
- 🔧 **git 源安装修复 `unrun` devDependency**([#336](https://github.com/omdsh-dev/DSH-better-sidebar/pull/336)):tsdown 0.22 经 `unrun` 加载配置而 pnpm 11 不自动装 peer——git-hosted 安装的 `prepare` 不再报 `Failed to import module "unrun"`(npm tarball 不受影响)
- 🍃 **`ctx.effect` 严格化顺手修了 4 处**:拦截注册失败时 effect 体返回 `undefined` 改为 no-op disposer(vendored cordis 的 effect 契约要求返回 disposer,返回 `undefined` 属非法形状)
### v0.15.2
自 v0.15.1 以来的全部更改:
**✨ 新功能**
- 🗂️ **文件树「在应用中打开」子菜单**([#334](https://github.com/omdsh-dev/DSH-better-sidebar/pull/334)):文件树右键菜单新增「在应用中打开 >」子菜单——内置打开方式(资源管理器显示/选中、VS Code、Cursor、Zed),每行右侧图钉可固定为右键菜单顶层直达项(再点取消);配置可选 SSH host 后 VSCode 系条目改用 `vscode-remote/ssh-remote+<host>/<path>` 协议打开,本地专用条目自动隐藏;支持自定义编辑器(名称 + URL 模板 `{path}` + 是否 VSCode 系,配置入口在 Files 卡片齿轮弹窗)。打开动作经新宿主路由 `POST /sidebar/api/open.external`(argv 数组 spawn,无 shell 注入)([设计文档](docs/plans/2026-08-22-open-with-menu-design.md))
- 📑 **Tab 右键菜单**([#331](https://github.com/omdsh-dev/DSH-better-sidebar/pull/331)):页签右键提供「关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签」,作用范围为当前 pane(标签组),无可关对象时置灰;仅打开菜单、不切换激活页签;批量关闭逐条走既有 `onClose` 路径,生命周期完整
- 📄 **Diff 文件默认折叠**([#270](https://github.com/omdsh-dev/DSH-better-sidebar/pull/270)):改动文件头部改为可访问的展开/折叠控件;识别出的源文件默认展开,测试 / 文档 / 生成文件 / lockfile 与未知类型默认折叠;保留现有 500 行上限
- 📖 **README 更新**:特性巡礼改为表格展示(每行两张图,节省空间);社区补全微信群 / QQ 群二维码([#325](https://github.com/omdsh-dev/DSH-better-sidebar/pull/325),QQ 群 577011007)
**🐛 修复**
- 🪟 **空分栏清理**([#268](https://github.com/omdsh-dev/DSH-better-sidebar/pull/268)):持久化的 split pane 在临时 diff tab 被清理后遗留全尺寸空分栏——`sanitizeState` 现在同时修剪空的 split leaf,并修复修剪后的失效激活 pane 指针;整个工作台为空时保留唯一空 pane
- 🖥️ **Windows 下隐藏 Git 子进程窗口**([#301](https://github.com/omdsh-dev/DSH-better-sidebar/pull/301),关闭 [#124](https://github.com/omdsh-dev/DSH-better-sidebar/issues/124)):`runGit()` 统一加 `windowsHide: true`,仓库状态轮询与操作不再闪现控制台窗口(其他平台行为不变)
- 📁 **未跟踪文件夹内文件差异**([#242](https://github.com/omdsh-dev/DSH-better-sidebar/pull/242)):`git status` 从 `--untracked-files=normal` 切换为 `--untracked-files=all`——新文件夹内每个文件独立成行、可正常加载差异(修正 `fs.read` 报 "is a directory",与 VSCode 默认行为一致)
- ⚡ **开关/拖拽每帧 React 重渲染消除**(关闭 [#315](https://github.com/omdsh-dev/DSH-better-sidebar/issues/315)):centerRect 改 ref + 底栏 DOM 直写(零 React 渲染);TabContent memo(显式比较器);新增 frame-batcher 对 Divider/dock 拖拽按帧合并;拖拽期跳过无意义 locate。4x CPU 节流 A/B:开关 >17ms 帧 collapse 19→6 / expand 24→4~6,p95 21ms→15ms;拖拽不变(非回归)
### v0.15.1
自 v0.15.0 以来的全部更改:
**✨ 新功能**
- 💬 **侧边对话 Codex 风格转录重构**([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)):转录改为**折叠行**——工具调用 / 思考 / 上下文注入统一为安静的单行 chrome(chevron + 标签 + 单行参数摘要,展开为 hairline 缩进正文,无卡片无填充),流式标签与创建 shimmer(shimmer = 生成中)、失败工具 danger、`prefers-reduced-motion` 停帧;**首条问题不再被边界提示吞掉**——上下文注入与首问拆分交付(边界 + 快照经 `agent.inject` 排队、问题唤醒驱动),转录把注入映射为可折叠注入行、真实用户消息(**含首问**)渲染为用户气泡,旧线程的首问同样拆分为独立气泡
- 📖 **README 重写**:功能导览(逐特性实机截图)、用户视角 DSH 兼容徽章、简化安装流程(`add` → `approve-builds` → `add`、node-pty 安全构建、粘贴到 DSH 安装提示)、插件生态 28+ 与分类折叠展示
**🐛 修复**
- 🖥️ **终端跨会话切换保活**([#323](https://github.com/omdsh-dev/DSH-better-sidebar/pull/323)):切到其他会话不再被当作瞬时掉线——客户端卸载时发送 `park` 控制帧,主机跳过 30s 重连宽限倒计时;切回会话(`open()` 取消 parked)或显式关闭恢复正常生命周期;agent 终端保持无限期存活
- 📂 **文件树上传遮罩不再拦截 Tab 拖拽**([#317](https://github.com/omdsh-dev/DSH-better-sidebar/pull/317)):拖拽 Tab(重排 / 跨 pane split)经过资源管理器时不再弹上传遮罩、不吞事件——统一按 `dataTransfer.types` 含 `Files` 门控(与面板宿主 shield 一致),Tab 正常落下;OS 文件拖拽行为不变
- 💬 **子代理自动展开去抖**([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)):Side Chat 线程创建不再误弹任务页——0→N 触发 500ms 重臂并对实时快照按原基线重评估,标题过滤器识别线程后才放行;真实子代理依然自动激活任务页(宽屏展开侧边栏,窄屏只准备 Tab、不强制展开抽屉)
### v0.15.0
自 v0.14.0 以来的全部更改:
**✨ 新功能**
- 💬 **侧边对话(beta) Tab**([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)):Codex 风格的侧边线程,**每个对话一个独立 Tab**——子会话继承主会话完整上下文(已完成回合 + 未回答消息 + 进行中回合的 assistant 输出与工具调用,以「interrupted」冻结标记诚实继承);同组合创建(同 preset / provider / model)复用前缀输入缓存;线程对主会话列表不可见、零子代理目录噪音;线程内可持续追问(重启后自动冷恢复);一键「保存为新会话」提升为顶层会话([设计文档](docs/plans/2026-08-20-sidechat-tab-design.md))
- 📤 **文件窗口上传**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):头部「上传文件 / 上传文件夹」按钮 + 拖放上传(拖到树区 = 工作区根,目录行 = 进该目录,文件行 = 进其所在目录,对齐 VSCode);上传时全屏模糊进度弹层(文件级进度 + 取消 / Esc);上传中按钮禁用、成功后文件树自动刷新
- 🧩 **桌面兼容四选项**([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)):位置兼容模式改为**主行下拉**——**自动检测**(默认,保守:仅使用标准的 Window Controls Overlay 几何,32/36px 等各壳差异自动跟随、最大化/还原实时更新,网页环境零修改)/ **DSH官方Web**(显式零适配)/ **壳兼容方案**(内置预设,手动启用;只收录 issue/PR 中出现过且 100+ star 的壳,命中环境带「已检测」提示)/ **自定义方案**(自定义 CSS + 下移距离)。旧版本已有兼容配置的用户自动落到自定义方案;交互控件统一退出桌面拖拽区(`no-drag`);底栏推挤锚点复合选择器双保险(`[data-pane]` 与 `:has(> [data-slot])`)
- 🎛️ **设置页 UI/UX 现代化**([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)):侧边卡片二级设置入口改为卡片底部「功能设置」设置条(替代右下角隐形齿轮,可发现性提升);协调双色启用态(brand 激活强调 + success 绿勾选徽标);全部颜色仍为 `--dsw-alias-*` 令牌派生,皮肤体系自动跟随
- ➕ **推荐插件目录新增**:`dsh-docs-panel` 全局文档面板([#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230))、`dsh-flowglass`([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261))、`dsh-git-forge` 与 `dsh-ssh-tunnel`([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204))、`dsh-turn-review`([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
**🐛 修复**
- ⚡ **子代理页实时预览批量接口**([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)):旧实现每个 running 子代理独立轮询 `subagents.history`,host 侧每次触发全量子代理枚举形成 O(N²) 放大、多子代理并发时页面卡顿——改为单个批量接口 `subagents.live`(一次枚举整棵子代理树)+ 客户端单轮询、单在途请求;展示逻辑与文案不变
- 🖱️ **拖拽中断 / 快速释放不再回滚**([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249),关闭 [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)):中断 / 快速释放提交最后已知位置;HMR 后中心列重定位兜底(修复热更新后底栏空白)
- 📐 **推挤变量挂载期持续有效**([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259),修复 [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)):拖拽松手后底边栏不再闪全宽
- 🔧 **适配 DSH 0.1.1-rc.1 / rc.2(@next)**([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)):无代码逻辑改动
- 🔒 **上传链路安全加固**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):`relativePath` 空段 / 绝对路径显式拒绝;临时文件唯一命名(并发上传互不干扰、崩溃不阻塞);写流错误监听(磁盘失败不崩溃进程);客户端错误码与服务端统一、413 本地化
- 🔐 **文件 API workspace 边界加固**([#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)):`fs.tree/read/write`、媒体、HTML 预览和上传统一按真实路径限制在会话 workspace 内,拒绝越界绝对路径与外链符号链接
### v0.14.0
> ⚠️ 本版起需要 DSH ≥ 0.1.0-rc.8。自 v0.13.1 以来的全部更改:
**✨ 新功能**
- 🖼️ **统一面板宿主注入重构**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):面板/开关簇迁入 `[data-dsh-panel-host]` 固定含块层(`fixed inset-0 z-40`),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;挂载自检(页面级 transform → `data-dsh-panel-host-degraded` 降级同步,按未修正几何判定、祖先变换消失才退出);推挤锚点改 `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc 宽度防桌面壳加性溢出;chunk 激活重验证(HEAD+ETag 保留未变 chunk,5s 超时兜底 fail-open);`visualViewport` 键盘 inset + `env(safe-area-inset-*)` 移动端适配
- 📂 **文件打开方式默认独立**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):`editorExplorer` 默认从「合并」改为「独立」——新会话树点击 / 打开文件按路径**新开**文件 tab,无路径窗口即纯资源管理器;合并模式保留为可选手动开启
- 🖥️ **终端 shell / shellArgs 设置页可配**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):终端卡齿轮二级页面新增「Shell 路径」「Shell 参数」两行配置(此前只能通过 `cordis.patch.yml` 配置)——设置页写入后对**之后打开的** UI 终端与模型终端(`terminal_create`)即时生效;留空保持 yaml → `$SHELL` / 登录 shell / `powershell.exe` 的既有解析顺序
- 🏷️ **设置页版本徽标**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):侧边卡片设置页顶部新增 `DSH-better-sidebar v0.14.0` 身份徽标(版本与服务实例同步,由测试守护)
- 🔍 **添加插件目录搜索 / 分组 / 独立滚动**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):为插件生态增长做准备——目录列表顶部加实时搜索(按名称 / id / 描述过滤),条目支持可选 `category` 分组渲染,列表独立滚动(弹窗不再随条目数无限增长)
**🐛 修复**
- 🧩 **rc.8 模块系统迁移**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):rc.8 不再暴露 `window.__DSH_MODULES__` 页面全局(改由 `ctx.modules` 服务提供),懒加载 chunk 的外部依赖解析全面失效——client 注入 `modules` 服务 + 插件自有全局共享给 chunk 副本(终端 / 编辑器 / Mermaid 恢复正常按需加载)
- 🧩 **chunk 重验证屏障健壮性**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):HEAD 重验证加 5s 超时兜底(路由挂起时 fail-open 重取,屏障不再可能无限期阻塞懒加载);`resetChunks` 清挂起的重验证屏障
- 🖱️ **拖拽健壮性**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):快速释放(浏览器合并 / 丢失 pointermove 突发)时提交最后已知拖动位置而非回退;`pointercancel` / 捕获丢失中断同样保留拖动结果;提交后立即重测中心列(消除底栏宽度中间帧抖动);HMR 重激活后中心列重定位兜底(`<html>` 样式观察 + 底栏打开重测),修复热更新后底栏空白 / 输入框位移
### v0.13.1
**✨ 新功能**
- 📊 **Markdown 预览安全渲染 Mermaid 图表**([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)):预览的 md 含 mermaid fence 时按需下发 `client-mermaid.js` chunk(~7MB,无 mermaid 文件零加载);纵深防御渲染——`securityLevel: 'strict'` + `htmlLabels: false`(节点文字走真实 SVG `<text>`)+ SVG 注入前二次清洗(删 `foreignObject`/`script`/外来 HTML 元素、剥 `@*`/`on*`/`href` 属性);点击图表在弹窗中放大(滚轮以鼠标为中心缩放、拖拽平移、工具栏与快捷键),深浅色跟随重渲、解析失败回退原码
- 🖥️ **终端 shell 与 shellArgs 可配置**([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)):`cordis.patch.yml` 的 `better-sidebar.config` 可指定 `shell` / `shellArgs`(`shellArgs` 非空时完全替换默认参数;未配置维持自动解析 `$SHELL` / 登录 shell / `powershell.exe` 原行为),UI 终端与 agent 终端(`terminal_create`)同时生效;终端 tab 标题改用 shell 名(bash / zsh / powershell),内部标识改 UUID,同 shell 可开多个终端
**🐛 修复**
- 🔗 **聚合双挂载自动退让**([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)):聚合包(如 dsh-web-ui-all)以独立条目 id 挂载同包时,`cordis.patch.yml` 的守卫表达式自动禁用自身 `better-sidebar` 行,不再重复注册 `/sidebar/api` 导致 `duplicate prefix route` 整个插件树启动失败(`dsh web` 崩溃);独立安装行为不变
- 🔧 **适配 DSH 0.1.0-rc.7**([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207),修复 [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)):修复 DSH 主框架升至 rc.7 后选模型 / 发消息报 `agent-presets: refusing to compose an unscoped context` 的问题
### v0.13.0
**✨ 新功能**
- 📁 **文件窗口与资源管理器二合一**([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)):新 `editorExplorer` 设置(编辑器卡齿轮)——文件 tab 增加路径输入框头部 + 可开关的右侧停靠文件树(每 tab 记忆展开/宽度,左缘拖拽调宽 160~480px,全局文件名搜索走 host `fs.search` 路由,预算封顶并跳过 `.git` / 符号链接目录);独立模式(默认)树点击 / 输入框 Enter **按路径新开**文件 tab,合并模式**原地切换**当前 tab;新会话默认 seed 空文件窗口(`Files`)替代 explorer tab,无路径窗口在独立模式为纯资源管理器、合并模式为带 chrome 的空文件窗口;树右键提供「在新 Tab 中打开」「在侧边打开」(split)
- 🎛️ **声明式设置 select 行**([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)):设置项新增 `type: 'select'`(`options` 支持 value/title/desc/icon,`multi` 多选存数组);带图标的选项渲染大图标选项卡、收起态同样显示图标;`editorExplorer` 改为图标化下拉(合并 / 独立);能力清单新增 `settingSelect`
- 🔀 **与 dsh-web-ui 家族右侧面板互斥**([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)):读取 `aionui-panel` 设置命名空间的提供方选择——当选择「使用 aionui-panel」时,整个 better-sidebar(右侧栏 / 底部面板 / 浮动入口 / 各类接管)不再挂载;选择 DSH-better-sidebar(或未安装 aionui)时正常。设置页保存后实时生效(settings-document 推送),无需刷新
### v0.12.3
**✨ 新功能**
- 🎨 **皮肤兼容(令牌驱动)**:全面消费 DSH 设计令牌,与 dsh-web-ui 皮肤中心 10 款皮肤兼容,换肤自动跟随;终端/编辑器表面在透明/半透明玻璃值下回退不透明底色,文字不叠在皮肤背景上([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110),修复 #106 #105 #90 #60,附带 #52 #57 #92)
- 🗂️ **统一路径处理**:UNC 路径 / 软链接分类(目录软链接可展开、失效链接标红)、HTML 路由平台守卫([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134),#65 #67 #43 #79 #115)
- 🖥️ **终端 shell 可配置**:设置项自定义 shell,Windows 自动探测 pwsh([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95))
- 📝 **编辑器新增语言**:C# / Kotlin / Swift 语法高亮([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120))
- 🧭 **设置页导航图标**:设置页导航图标与布局优化([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114))
- ➕ **推荐插件目录新增**:`dsh-git-remotes`——Git 远程 Tab(分支/上游/ahead-behind、fetch 可 prune、ff-only pull、确认后才 push,不替换内置暂存/提交)([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91));`dsh-video-preview`——视频内联预览(.mp4/.webm/.mov/.mkv/.avi 等,自带 /video 宿主路由支持 HTTP Range 206 拖进度条,不受 20MB mediaLimit 限制)([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126))
**🐛 修复**
- 🔧 **xterm 依赖迁移**:弃用的 xterm 迁移至 `@xterm/xterm`(Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122),[#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
- 📝 **Markdown 编辑器**:选区转对话弹窗恢复可用([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
- 🖼️ **Markdown 预览支持本地/相对路径图片**:预览 `.md` 时把指向本地文件的图片目标(相对/绝对路径、引用式 `[id]: url`)重写为 `/sidebar/file` 媒体 URL 并显示(此前仅绝对 http(s) 图片能渲染,相对路径只显示 alt 文本)
- 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过
- 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复
</details>
-379
View File
@@ -1,379 +0,0 @@
# Changelog
> Full release history of dsh-apes-sidebar (see the [README](README_EN.md) for the latest release; also mirrored on [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)).
### v0.24.1
> 🎁 **This release carries everything since v0.22.1** (the Files/Changes UI-UX rebuild and performance work of v0.23.0, the DSH 0.2.0-rc.1 support-line move of v0.24.0, and the fixes below).
>
> 🐞 **Fix release** for the file tree rebuilding itself on every interaction. (1) The native surface (`sync()` in `src/client/native/index.ts`) treated the `files` takeover as a surplus registration — it is keyed by KIND and never appears in `wanted`, so **every session-state write** disposed and re-created it. Replacing a slot registration replaces the host's slot entry, which **unmounts and remounts the whole plugin tab body** (folder toggles, tab switches, bottom-workbench drags all trigger it): the explorer lost its level cache, scroll position and directory watcher, then re-listed the entire visible tree via `fs.trees([root, ...expanded])`. (2) The live-refresh path (`retryDir` in `FileTree.tsx`) **deleted a level from the cache** before refetching, so its rows were replaced by a placeholder and rebuilt (a build, a formatter or the model's own bash made entire folders blink).
- 🧪 Discriminating evidence: a new real-host lane `tests/e2e/zz-expand-refresh.e2e.ts` (a toggle asks only for the toggled level, the tab body survives, rows never drop to zero; reverting either fix turns it red) plus two jsdom guards (reverting the surface fix: `registered` 6 → 12 with every `disposed` entry being `dsh-apes-sidebar:files`; the other asserts stale rows stay on screen during a re-list)
- 📉 Measured after the fix (40-child directory): the toggle's `fs.trees` payload goes from `[root, big]` (the empty-cache signature) to **`[big]`**, new WebSockets 1 → **0**, removed rows 45 → **0**, and an external write goes from "remove 40, add 40" to **one added row**
- 🔧 `src/fs-watch.ts` / `use-dir-watch.ts` / `TreePanel.tsx` / `tab-adapter.tsx` untouched (`fs.watch` does not emit on creation — measured and ruled out)
### v0.24.0
> 📦 **Support line moved**: DSH **0.2.0-rc.1+** only (peer floor `^0.2.0-rc.1`, CI pins `@deepseek-ai/dsh@0.2.0-rc.1`). **The 0.1.7 line (including npm `latest` = 0.1.7-rc.2) should pin v0.22.1 (the last published release on the 0.1.7 line)** — the 0.2.0 host's startup compatibility preflight evaluates `semver.satisfies(hostVersion, peerRange, {includePrerelease:true})`, and a caret never spans a minor bump (measured: `^0.1.7-rc.1` vs `0.2.0-rc.1` is **false**, so the whole row is disabled silently). This version therefore keeps no 0.1.7 compatibility branch.
- 📦 **Baseline lifted to `0.2.0-rc.1`**: 14 DSH peers and 27 `@deepseek-ai/*` devDependencies move together; `dsh.plugin.json`'s `engines.dsh` follows; the mount lane (`scripts/e2e-common.sh`) and CI pins follow.
- 🔍 **Measured before adapting (purely additive, hence no runtime change)**: across the 19 host packages this plugin consumes, **zero value exports were removed**; the only type-surface changes are `dsh-client-ui-primitives` (optional props on `DisclosureRow` / `TextShimmer` / `Tooltip`, overlay top inset), `dsh-session` (new `ToolCallRecovery`) and `dsh-api-remotes` (a new product-analytics remote); session format stays **v4** (no migration package), `SUBAGENT_DESCRIPTOR_VERSION` stays **3**, `dsh/lib/bin.js` (`dsh plugin` / `dsh web` / the ready line / `--version`) and the `dsh-client-modules` runtime are **byte-identical**, the web profile's client inject list is unchanged, and `ui-primitives`' bare imports are identical (no new test-time dependency).
- 🧩 **Host web-profile composition changes, checked though unrelated**: desktop-only `product-analytics` / `desktop-product-telemetry` rows are added (disabled on the web profile); the already-disabled `time-context` / `schedule` / `ui-schedule` rows move into the optional `dsh-experimental-schedule-bundle`. The mount assertion "the host owns exactly one tab entry (terminal) and browser is absent" is unaffected.
- 🧪 **Tests and rules**: the peer-shape assertion in `tests/market-manifest.spec.ts` now pins the current baseline tuple and records the "a caret never spans a minor bump" lesson (compatible with §3.4 item 12's "includePrerelease is more permissive" note — that permissiveness only holds inside one minor line).
- ⚠️ **Only residue**: the optional `@huanlin/dsh-plugin-better-locale` integration (imported by no source file or test; detected structurally at runtime) pins `^0.1.0-rc.8`, and even its newest 0.4.3 pins `^0.1.5-rc.2` — both fail the 0.2.0 preflight, so `pnpm peers check` reports its 5 unmet peers. That is upstream not having adapted to 0.2.0, unrelated to this plugin's own 14 peers.
### v0.23.0
> 🧭 **Development-line version (never published to npm; its content ships with v0.24.1)**: the Files and Changes pages rebuilt end to end, plus a round of performance and interaction polish. The Files page gains **Ctrl/Cmd and Shift multi-select** (VS Code semantics), a batch action bar, **Git change coloring** (VS Code's add/modify/delete/conflict tones, directories get a summary dot), **new-folder creation**, reworked drag-and-drop upload, and **"Zip and download" on a multi-selection** — archiving became a host-side task with progress (`archive.build` / `archive.status`, per-entry callbacks, 4 concurrent builds, 5-minute TTL, sessionId-bound). "Open with" now keeps **both sources side by side**: the local associated apps the host detects (file manager / VS Code / Cursor / Zed / custom editors, SSH remotes included) plus the plugin's own targets, with a setting (`openWithPluginTargets`) to force coexistence; the context menu was restructured (only default actions at level one, the app list moves into a submenu). The Changes page is **rebuilt as a hierarchy tree** with both the Git view (working tree / staged / history) and the Agent view (per-session edits), and directory rows stage/unstage. Interaction and performance: `/sidebar/api/fs.tree` measured **22.8ms → 3.8ms** (10k entries: one `readdir`, rows built only up to the cap, lowercase-comparison sort, 1.5s directory-level cache invalidated by writes and `fs-watch`), a new batch route `fs.trees` (mount/refresh goes from N+1 requests to one), opening the context menu no longer re-lists directories, and rows no longer re-render with every git poll. Also fixed: "Open to the Side" did nothing (it only wrote into the collapsed bottom workbench; it now routes by surface to the host's `openResource(preferNewPane)` and really splits).
- ⚠️ **Behavior change (security-relevant)**: the **workspace path fence was removed** — the plugin's fs routes (tree/read/write/rename/remove/mkdir/media/HTML/upload/archive) no longer enforce containment and can read or write any path the host user can reach (OS permissions are the only limit); the `workspaceFence` setting and its notice row are gone.
- 🧩 Reusable global components and shared stores were extracted (`IconButton` / `Chip` / `SectionHeader` / `Notice` / `ConfirmDialog` / `StatusBadge` plus a shared git-status store) under the skin contract (`tests/theme.spec.ts`); the ZIP writer is our own (no third-party dependency) and a non-latin1 download name no longer 500s (RFC 5987).
- 🐞 Also fixed: the tree's expansion was reset when a file preview was closed and the tree came back (the native surface kept the expansion set per tab; it is now projected from **per-session state**).
### v0.22.1
> 📦 **Stable release** (npm `latest`): DSH **0.1.7-rc.1+** only (peer floor `^0.1.7-rc.1`, CI pins `@deepseek-ai/dsh@0.1.7-rc.1`) — the support line is **unchanged**, so 0.21.1 / 0.22.0 users can upgrade straight away; **hosts on DSH 0.1.6-alpha.2 or earlier still pin v0.19.1**. A patch release: both defects reproduced reliably on a real host while the unit suite stayed **green**.
- 🐛 **The `files` takeover's tab-type id could be orphaned → `native register files error: … already registered` spam plus the Files window falling back to the host's empty state until a page refresh** (community issues #770 / #771 / #766). The chain was confirmed verbatim from the desktop shell's own log (`desktop.frontdesk.log`, 2026-09-27 15:51:39.338): during an in-page client entry replacement (plugin-market update, Plugins page disable→enable, HMR rebundle) the plugin's teardown unregisters each built-in descriptor one by one → `service.notify()` (synchronous, inline) drives `sync()` on an **already inactive** plugin context; the drop loop released the `files` takeover — which is **not a descriptor** — and re-created it **in the same pass**: `tabs.register` lives on the **host** context (still alive) so it took the id anyway, while the `ctx.slots.inject` right after it threw `cannot create effect on inactive context`, `live.set(FILES_KIND, …)` never ran and the disposer was lost — the id became **unregistrable for the rest of the page's life**. Fix (community PR #777, @yanzhaohui1999): ① the drop loop **skips `FILES_KIND`** — the takeover lives and dies by the editor type's `wantsFiles/hasFiles` switch and the seat disposer; ② **any registration that fails after the host took the id releases that type and the slots already installed**, leaving only a failure the next notification retries; ③ teardown / drop loops release entry by entry, so one throwing release no longer aborts the rest.
- 🐛 **macOS desktop: window drag worked once and then stopped, and the double-click-title zoom was gone** (issue #772). The plugin host is a direct `body` child, so the shell's `html[data-platform=darwin] body > :not(#root) { -webkit-app-region: no-drag }` (an id-bearing selector — only `!important` wins) applied to it, and app-region **ignores `pointer-events`** — the viewport-sized panel layer cancelled every `[data-window-drag]` strip beneath it. Fix (community PR #773 merged; #786 completed it): `[data-dsh-apes-sidebar]`, `[data-dsh-panel-host]` (with `> *` staying `no-drag`) and the zoom modal `.mermaidModal` opt out with the **neutral** `-webkit-app-region: initial !important` — `initial` computes to `none`, which does **not** subtract from the drag region, whereas the literal keyword `none` is not neutral (it computes to `no-drag`). Interactive popups (`.selectionPopup`, `FloatingWindow`, `AnchoredPopover`) deliberately do **not** get `initial`, or a press would start a window drag instead (the #103/#111 regression).
- ✅ **Guards**: `tests/native-registration.spec.ts` pins both invariants to the registry's **event log** (4/4 red on the unfixed code, and "a failed registration can still be retried" is no longer a non-discriminating assertion); slot failures are keyed by `name::key` so `registerSlots`' partial-release path is actually exercised; a **deployment-level regression gate** `tests/e2e/native-reload.e2e.ts` was added (`utimesSync` the installed `lib/client.js` → in-page entry replacement → assert no `native register … error`, no diagnostic strip, the Files takeover usable again; red on 3 consecutive runs against npm 0.22.0, green on 3 consecutive runs of the fixed build (it is ONE case, run repeatedly)). The drag contract is shape-pinned by `tests/panel-host-css.spec.ts` on Linux `pnpm test` (there is no macOS runner) and value-pinned by a real cascade probe in the mount lane that reads computed styles against the shell's own rules (both the panel host and the zoom modal red the unfixed artifact).
- 📐 **Baseline unchanged**: `@deepseek-ai/dsh-*` still pinned to `0.1.7-rc.1`, peer floor `^0.1.7-rc.1`; no public API, contract, dictionary or chunk changes. Incident write-up (and why #766's "absorb" approach was rejected): [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](./docs/plans/2026-09-28-native-files-takeover-reload-leak.md).
- 🧪 **Verification**: `pnpm test` 122 files / 1293 passed / 9 skipped; `pnpm test:mount` and `DSH_CMD=… pnpm test:mount:aggregate` green; the packed bundle carries `app-region:initial!important` twice and `no-drag` twice.
### v0.22.0
> 📦 **Stable release** (npm `latest`): DSH **0.1.7-rc.1+** only (peer floor `^0.1.7-rc.1`, CI pins `@deepseek-ai/dsh@0.1.7-rc.1`) — the support line is unchanged, so v0.21.1 users can upgrade straight away. **Hosts on DSH 0.1.6-alpha.2 or earlier still pin v0.19.1.** The headline is the Tasks page rebuilt from a "subagent topology" into a **workflow graph**, then polished over several rounds of real-host feedback; along the way DSH 0.1.7 deleted the Agent Teams Remote methods, so both the team and background-job data planes were rewritten.
- 🧩 **The Tasks page is now a workflow graph (the primary view)**: the session tree renders as layered nodes joined by bezier edges — drag to pan, wheel-zoom to the cursor, fit to the content box (double-click the background to refit), and a control cluster bottom-right (graph/tree toggle + fold switch + zoom + fit). The classic **indented tree is kept** (keyboard-navigable); both modes share one view model, so fold state and team enrichment never drift apart, and the default view is a setting.
- 🃏 **Two-segment node cards**: the top segment is the kind badge (main agent / subagent / teammate / workflow / completed aggregate) + phase badge + name + meta line; the bottom bar is the state dot + state word + **the same merged activity line the main agent shows** (concurrent tools grouped by category, counted, with the running call's detail — the wording comes straight from the host's `chat` namespace) + a fold chevron on finished nodes. A running bar is swept left-to-right across its **whole** width (1.5 s, token gradient, disabled under `prefers-reduced-motion`). Cards are 8px-rounded, hierarchy is carried by a faint top-segment tint only, and the session you are on wears a heavier accent border.
- 🔀 **Workflow runs enter the graph**: the host folds `tool-workflow/*` session events into runs (the same events the official workflow-run panel folds); a run hangs under its origin agent, its member agents are re-parented below it and boxed per phase with matching phase badges. Members with no catalog row are synthesized from the run's own data, so a finished run still shows who took part.
- 🗂 **Folding is split into two groups that say what they hold**: `✓ N completed` (finished work plus failures, with failures called out as `N failed`) and `N idle` (teammates that finished a turn and can be called back at any moment) are **two separate rows**. The **manual chevron always works** (no threshold); the **automatic** rule only sweeps idle members once there are 3 or more of them — one or two waiting members are the team's working set, and their cards are where a task line shows up. An aggregate's name line reads "first two names + `+N`", and clicking it expands everything.
- 🪟 **Two persistent floating windows** (extracted into a reusable `FloatingWindow`): background-job output, and the shared task's detail/edit surface — draggable, resizable from every edge, with a self-scrolling body, dismissed only by its close button or Escape (an outside click, a blur or the anchor leaving the viewport never closes them). The task window hands its spare height to the description (or the multi-line editor), so enlarging it gives the content more room rather than leaving a gap, and the action row is pinned to the footer so a long description cannot push it out of sight.
- 👥 **Agent Teams board (experimental layer)**: team members enrich their matching nodes and an always-visible strip lists the roster and shared tasks; the task state machine follows the host (pending → claim → in progress → complete → reopen) with reassign / edit / two-step delete, and a stale CAS revision gets its own notice ("changed elsewhere — refreshed"). **Member activity is overlaid from `subagents.live`'s `running` flag** rather than guessed on the client.
- 🔄 **Background jobs now read the host's client service**: DSH 0.1.7's web profile mounts a client-side `ctx.jobs` (a pushed roster plus a non-consuming output stream and `kill`), so the plugin deleted its own `jobs.list` / `jobs.output` / `jobs.kill` routes and the event-replay mirror — and never touches the model's `job_output` cursor at all (the host turned that constraint into a capability). Output streams in a persistent floating window with tail-follow, and the drawer auto-collapses once a tree has 8 or more agents.
- 🛠 **Two data planes rewritten for DSH 0.1.7**: ① team reads — upstream deleted the three `agentTeams.remoteView`-style Remote methods, so the plugin reads the Lead Session's **`agentTeam` Session projection** instead (the same snapshot `subagentCatalog` comes from, push-based), deleting the `teams.view` route and its 5-second poll; the write path keeps two routes (`createTask` / `updateTask`) whose rejections moved from a result union to a thrown `TeamError`, with `TEAM_TASK_STALE_REVISION` mapped to a 409 `team-conflict`. ② background jobs — see above. **The bug this fixed in the wild**: on 0.1.7 the team strip never rendered at all (the route answered `remoteView is not a function`, and the page degraded silently).
- 🐛 **Four defects caught on a real host** (every one of them green in unit tests): the per-node fold button did nothing because it answered to the **automatic** rule's guards (non-member + leaf) while the button is a **manual** trigger — the two triggers now have their own guards; an **idle** card never drew a fold chevron at all (its render condition was hard-coded to done/error) — "not executing" now qualifies; claiming a task flipped its tag to **blocked** because `ready` only means "queued with every blocker done, clear to claim" and is false for every non-pending task — the rule is now the single `taskBlocked` / `taskStatusLabel` / `taskTone` / `taskDotState` pair; and "complete" on a queued task always failed (the host state machine demands a claim first), so the action row now follows the state.
- 🎨 **Narrow panes and mobile settings**: type scale, row heights and spacing were re-tuned for the native right sidebar's narrow width (content-box centering, zoom clamping, pan bounds, phase-frame colours, thicker edge stubs, a recomputed card height budget); the settings page gained a **Mobile** group — on a narrow viewport (≤768px) the Tasks page no longer auto-opens and it defaults to the tree view.
- 📐 **Baseline**: the DSH pin and support line are **unchanged** (0.1.7-rc.1, peer `^0.1.7-rc.1`). New copy keys (including `teamTaskClaim` / `tasksFoldIdle` / `taskWindowCreate` / `tasksStateProvisioning`) across all 20 dictionaries; design docs: [docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md](docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md), [2026-09-27-tasks-graph-polish.md](docs/plans/2026-09-27-tasks-graph-polish.md), [2026-09-27-floating-window-mobile-settings.md](docs/plans/2026-09-27-floating-window-mobile-settings.md), [2026-09-27-agent-teams-dsh-0.1.7-adaptation.md](docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md) (implementation deviations and known limits included).
### v0.20.0 (dev line, **never published**)
> 🚫 **This version never reached npm** (npm `latest` is still v0.19.1): it was meant to be the stable release on the 0.1.6-alpha.2 line, but the 0.21 line replaced it before release, so everything below actually shipped in **v0.21.1**. Its original positioning: supports **DSH 0.1.6-alpha.2+** only (peer floor `^0.1.6-alpha.2`, CI pins `@deepseek-ai/dsh@0.1.6-alpha.2`). **This is a breaking-change release**: hosts on 0.1.5-rc.* should pin `dsh-apes-sidebar@0.19.1`. See [docs/plans/2026-09-21-dsh-0.1.6-alpha.2-adaptation.md](docs/plans/2026-09-21-dsh-0.1.6-alpha.2-adaptation.md) for the adaptation record.
- 🖥️ **Terminal handed to DSH's built-in**: DSH 0.1.6 ships its own right-column terminal (`ui-sidebar-terminal`), so the plugin deleted its whole terminal stack — `pty-manager` / `agent-pty` / `pty-deps` / the 8 `terminal_*` tools / the xterm view and font-linking / cross-session terminal pinning — along with the `node-pty` dependency, its `allowBuilds` grant and the installer's `-Repair` mode. **Note**: the model therefore loses a cross-call persistent terminal (only one-shot `bash` / `pwsh` remain); the upstream `@deepseek-ai/dsh-tool-terminal` is not mounted by any shipped bundle, so enable it yourself in the profile when you need it.
- 🌐 **Browser view handed to the built-in**: the plugin deleted `BrowserView` / the sandbox status bar / the embeddability probe (including the host's `browser.probe` route) and the two settings that only configured its own iframe; it **keeps** the external-link takeover in the chat and UI (protocol-based routing is the capability the host does not have), now targeting DSH's built-in `browser` tab type.
- 🧩 **The produced-files row goes back to the host**: DSH 0.1.6 turned `conversation.chat.turnTail` from a chain into a list (upstream deliberately lets several plugins **append** rather than replace each other), so the plugin's takeover could only duplicate the built-in deliverables card and was removed wholesale; click behaviour is unchanged (the built-in goes through the host's `openFile` → this plugin's editor). The cost is the one "reveal in the file tree" entry at the turn tail (the in-tree context menu remains).
- 🐛 **Two silent breakages fixed**: once `SidebarRightGuideEntry.id` became required, a missing id left the **whole native surface silently empty** (the throw happens inside an `ctx.inject` callback and is swallowed); once `turnTail` became a list the old registration threw outright and the row died. Both are fixed and guarded by tests, with a registration-failure reporting hook added alongside.
- 🎨 **The empty-pane card now matches the built-in card recipe**: `.paneCard` adopts DSH's guide-capsule recipe (0.5px l4 border / 24px radius / layer-1 fill / 56px minimum / 14×20 padding), with tests guarding its geometry, its tokenized colours, and that it does not copy upstream's **undefined** `--dsw-alias-bg-l1/-l2` spellings.
- 📐 **Baseline**: every `@deepseek-ai/dsh-*` package is pinned to `0.1.6-alpha.2`, the vanished `dsh-code-runtime` package is removed, `diff` / `simple-icons` are hoisted into devDependencies as primitives' new bare imports, and `ui-primitives`' three breakages this release (`IconSendOutline16` gone, `TerminalBlockLabels.noExitCode` added, `ConnectionIndicator.reconnectLabel` removed) are adapted.
### v0.19.1
> 📌 **Stable release** (npm `latest`, no prerelease suffix): the pinned baseline moves to **DSH 0.1.5-rc.2** (npm `next`) while the **peer floor stays `^0.1.5-rc.1`** — nothing in the rc.2 upstream delta touches this plugin (zero changes under `packages/api|host|session|agent`; the only real code edits are the message-feedback dialog, deliverables card CSS and the `CodeFileIcon` SVG data split), so rc.1 hosts need no DSH upgrade to run this version. Hosts on DSH 0.1.5-alpha.2 stay on **v0.19.0-alpha.1**; the 0.1.2-rc.1 stable line keeps using **v0.18.1**.
- 🎯 **Adapted to DSH 0.1.5-rc.2**: devDependency pins, the CI mount lane and `SIDEBAR_SERVICE_VERSION` all move to rc.2; there is **no plugin-side code change** (the upstream delta does not touch this plugin — file-by-file audit in [docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md](docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md): the vast majority of the 300 changed files are single-line `version` bumps).
- 🎨 **File icons (#611) and the built-in tab glyphs are now colored** (#531 + #594 merged, implementation redone for rc.2):
- **Files and folders use DSH's own artwork**: the fallback link is the host's `FileTypeIcon` from `ui-primitives` (48 full-color official code/config glyphs plus the category-colored sheets for markdown, images, PDF, Office, video and folders), so the plugin **ships no extension table and needs no icon chunk** — #429's 563-entry color dataset and `lib/client-file-icons.js` (255 kB) are gone, along with the `fileIconTheme` switch. Color is simply the behaviour now, and the core bundle grew by 10 kB.
- **New public `registerFileIcon` API** (capability `'fileIcons'`): register your own glyphs by extension (`exts`), exact file name (`names`) or directory name (`folderNames`), ranked by priority then registration order. **Caveat**: the host classifier covers every path, so a catch-all (`exts: []`) claims every row the specific rules miss.
- **Colored built-in tab glyphs**: Files / Changes / Tasks / Side chat / Terminal / Browser and the diff view all get a colored glyph, painted from `--dsw-alias-*` tokens so every skin still applies.
- **The native right Sidebar's tab chips carry a glyph too**: the host's tab definition has no icon field, but the `sidebar.right.pane.tab.title` slot *is* the chip's content — the plugin renders `[glyph][title]` there (an editor tab with a path shows that file's icon). The glyph is `aria-hidden`, so the chip's accessible name is unchanged.
- 🛠 **CI fix 1: real Windows-lane timeouts**. `ci-windows` went red eight times in the 2026-09-09/10 window; six of those were process-spawning cases hitting vitest's 5000 ms default: `tests/agent-pty.spec.ts` (real PowerShell + ConPTY per terminal) and `tests/install-powershell.spec.ts` (a cold `powershell.exe` start measured 12.1 s) now declare a 30 s budget each, vitest.config.ts raises the global `testTimeout` from its 5000 ms default to **15 s** (the first real Windows run then lost a *third* file to the same default — `tests/git.spec.ts:85` at 9607 ms; three files, one cause, so per-file patches were whack-a-mole), and `waitForTranscript`'s inner poll budget dropped from 5000 ms to 15 s — **the inner budget must sit below the outer one** (they used to be the same 5000 ms, a structurally guaranteed timeout). `ci-windows`'s `Test` step now runs `pnpm test:windows` (`--maxWorkers=2`) so the process-spawning specs stop starving each other on a 2-core runner.
- 🛠 **CI fix 2: the mount lane's npm install**. `plugin-mount`'s `npm install -g @deepseek-ai/dsh@<ver>` failed four times (two `ETARGET`, two `JavaScript heap out of memory` / exit 134): the pinned version declares its transitives as floating `^` ranges, so while upstream publishes a prerelease in stages (rc.2 appeared package by package between 14:43 and 14:57 UTC on 09-10) npm resolved a mixed rc.1/rc.2 peer graph (3062 ERESOLVE lines). The lane now pins an **already-complete** rc.2 (fixes ETARGET) and passes `NODE_OPTIONS=--max-old-space-size=4096` (fixes the OOM). `--legacy-peer-deps` was tried on the way and rejected by real CI: it skips exactly the peers a global install must supply, and `@deepseek-ai/cordis-plugin-group` — required at boot by `dsh-app-boot` — is a peer rather than a dependency, so the CLI died with `ERR_MODULE_NOT_FOUND`.
### v0.19.0
> 📌 **Stable release** (npm `latest`, no prerelease suffix): supports **DSH 0.1.5-rc.1+** only (peer floor `^0.1.5-rc.1`, CI pins `@deepseek-ai/dsh@0.1.5-rc.1`). Hosts on DSH 0.1.5-alpha.2 should stay on **v0.19.0-alpha.1** (the npm `alpha` tag still points there); the 0.1.2-rc.1 stable line keeps using **v0.18.1**.
**✨ New**
- 📝 **The new-tab list gets an optional description back**: DSH 0.1.5-rc.1 restored `SidebarRightGuideEntry.description` (optional), so the plugin restores `TabDescriptor.description` — each of the six built-in types declares a line again (all six guide rows — Files / Changes / Tasks / Side chat / Terminal / Browser — carry one), and the `guideDesc*` keys return to all 20 dictionaries. **The host's native guide renders descriptions only while the listed entries are ≤ 4** (a longer list drops every description rather than truncating), and the plugin contributes six guide entries by default — so in the default composition no description is rendered; they appear once the reader disables enough tab types (per-type switches live on the plugin's settings page) to bring the guide down to four entries or fewer. An entry that declares none stays a single icon + title line (the plugin ships no generic fallback sentence). (#613)
**🐛 Fixes**
- None. The rc.1 delta over alpha.2 is small (373 changed files, overwhelmingly upstream package version bumps, native-sidebar preview UI polish, and test snapshots); apart from the restored description field (plus upstream's `files` type now using its own coloured folder glyph as the guide glyph) nothing needed adapting.
**🧰 CI & internals**
- Baseline moved to **DSH 0.1.5-rc.1+** (#613): peer floor, devDependency pins, the CI mount lane, and `dsh.plugin.json`'s `engines.dsh` all follow (rc.1 is both the `latest` and `next` dist-tag on npm).
- Explicitly unchanged, no need to re-check: the global main-panel model (the `main` slot / `sidebar.panellist` / `ctx.layout` / root-scoped `rightbar` + `rightbar.session`, still not adopted), the file address grammar (`packages/util/workspace-path` only had its version bumped), the native tab-body host contract (`.paneBody` is still a block scroller with a definite height), the core / agent / session / subagent host APIs, and the `ui-primitives` export surface (only CodeBlock rendering changed).
- `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` still declares no `dependencies` while its bundle still bare-imports `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex` — the third-party devDependencies hoisted in the previous release therefore stay, and must not be reverted.
- Real-host verification (DSH 0.1.5-rc.1 + plugin 0.19.0): `typecheck` / `lint` / `check:consumer-types` green; unit suite **124 files · 1296 passed · 9 skipped**; `pnpm peers check` clean; the mount smoke lane against real rc.1 **7 passed** (including the tab-body fill assertion and the new "descriptions render only at ≤4 guide entries" assertion); on the local 3080 instance the guide still shows six icon+title capsules (6 > 4, so upstream renders no descriptions — upstream also raised the capsule `min-height` from 48px to 56px), clicking `AGENTS.md` in the file tree lands in the plugin editor (CodeMirror mounted), the side-chat composer sits on the pane floor (host box 962px == pane body 962px, composer 8px off the bottom), the bottom workbench's edges coincide with the centre column, and `pageerror` is 0 (the only console noise is the third-party `dsh-tauri-worktree` `/api/dsh-worktree/attach` 500, unrelated to this change).
### v0.19.0-alpha.1
> 🧪 **alpha channel** (npm dist-tag `alpha`, install `dsh-apes-sidebar@latest`): supports **DSH 0.1.5-alpha.2+** only (peer floor `^0.1.5-alpha.2`, CI pins `@deepseek-ai/dsh@0.1.5-alpha.2`). Stay on **v0.19.0-alpha.0** for 0.1.5-alpha.1; the 0.1.2-rc.1 stable line keeps using **v0.18.1** (npm `latest`).
**✨ New**
- 🪟 **Every tab body now fills its pane** (#609): the native sidebar's tab-body host is a block scroller with a definite height, not a flex container, and the plugin's tab roots declared only `flex: 1` — so they collapsed to content height and the side-chat composer sat right after the transcript instead of at the pane bottom (a long transcript pushed it out of view). The native adapter now wraps every tab body in a `height: 100%` column flex host, restoring the same fill semantics the bottom workbench has for all plugin tabs, third-party `registerTab` descriptors included.
**🐛 Fixes**
- 🧭 **File address grammar follows DSH 0.1.5-alpha.2**: `fileAddressFor` always produces a session-scoped address and absolute paths keep their leading `/`; `parseFileAddress` is prefix-based and ignores `?`/`#` suffixes.
- 🪟 **Center-column anchor follows alpha.2's global panels**: the `conversation` slot became `main.conversation` under the root-scoped keyed `main` slot, so the locator now resolves the new key and skips `display: contents` slot hosts (alpha.1's old key still works).
**🧰 CI & internals**
- Baseline moved to DSH 0.1.5-alpha.2 (#609): peer floor, 22 devDependency pins, the CI mount lane, and `dsh.plugin.json` engines all follow; `pnpm peers check` is clean (hoisted the `dsh-session-persistence` transitive peer per §3-9).
- **`TabDescriptor.description` removed**: alpha.2's native guide entries no longer render a second line (they are icon + title capsules), so the field and the six `guideDesc*` keys (20 dictionaries) are gone; a new pane's default page is now selected from the registry (exactly one guide entry opens that page directly).
### v0.19.0-alpha.0
> 🧪 **alpha channel** (npm dist-tag `alpha`, install `dsh-apes-sidebar@latest`): supports **DSH 0.1.5-alpha.1+** only (peer floor `^0.1.5-alpha.1`, CI pins `@deepseek-ai/dsh@0.1.5-alpha.1`). The 0.1.2-rc.1 stable line keeps using **v0.18.1** (npm `latest`).
**✨ New**
- 🖥️ **DSH's native right sidebar** (#604): the right column is now DSH's own sidebar — all seven plugin tab types register as native tab types with native tab bodies; every file open from the chat goes through `ctx.sidebarRight.openResource(dsh-resource://file/…)`; the `editor` type claims file resources at `extension` priority (outranking the built-in text preview) and takes over the built-in Files page kind (restored on unregister); cross-session opens queue until the target session is on screen.
- 🧩 **Own right panel and free windows retired** (#605): with the right column handed back to DSH, the plugin keeps only its bottom workbench (single split tree, per-session persistence) and registers its toggle into DSH's session-header utilities slot; the float API (`floats` / `floatTab` / `dockFloat` / `raiseFloat` / the "Move to Free Window" menu entry), the `'floatWindows'` feature string, and the `openByDefault` / `defaultWidthPercent` / `changesDiffFloat` settings are gone (a persisted `floats` field is ignored, so old documents still load).
- 🔗 **DSH 0.1.5 host contracts** (#603): the `assistant/chunk` event is gone — live deltas now fold from `agent/assistant-stream` frames (the side chat transcript's `live` field); `sessionPersistence.inspect` is gone — cold reads go through `open(id,'read')`; session headers use `SESSION_FORMAT_VERSION`.
**🐛 Fixes**
- Eight session-event read sites follow the 0.1.5 contracts (side-chat transcript, `jobs.output` replay, fork inheritance); custom seeds now carry the fork marker pair, so they no longer inherit the parent's unclaimed inbox input.
**🧰 CI & internals**
- The real-host mount smoke lane pins 0.1.5-alpha.1 and sweeps the native sidebar's guide page to open every plugin tab type; typecheck / lint / unit tests / mount lane all green.
### v0.18.1
> 📌 **Stable release** (npm `latest`): the DSH baseline is unchanged (**0.1.2-rc.1+**, peer floor `^0.1.2-rc.1`) — this is the incremental cut after v0.18.0: richer op previews in the changes panel, a writable file tree, and five fixes.
**✨ Features**
- 📄 **Op-preview upgrades in the changes panel** (#499): markdown reading mode with mermaid fences, plus inline `.html` and `.pdf` render previews; diff syntax highlighting extended to mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5; new **secret redaction** layer (on by default for previews, toggleable in the pane header)
- 🗂️ **File-tree rename / delete** (#550): inline rename plus confirmed delete, slimmer context menus and viewport-clamped submenus
- 🧩 **Plugin catalog names and shell preset text localized** (#535): they now follow the host language
**🐛 Fixes**
- 🔀 **Git diff gap folds really expand** (#576, fixes #577): the fold row promised "n lines… click to expand" but did nothing (`-U3` leaves gap segments without row text); the hidden rows now load on demand through `git.show`, sliced from both sides' contents, with loading / failure states and request deduplication — plus a fix to that route's `rev:path` addressing (it always returned empty before)
- 💬 **Side-chat seeds no longer inherit the parent's unclaimed inbox messages** (#562): the seed carries the fork markers, so the "side chat sends earlier User messages first on long contexts" ghost message is gone
- 🖼️ **Local images in the split markdown renderer** (#569): rewritten to reachable URLs instead of 404ing
**🧰 CI and internals**
- ESLint flat config wired into CI and the Makefile (#536), Makefile command surface (#526), hardened e2e scripts (#527), shared component-test utilities (#524)
- Refactors: Sidebar.tsx split by concern (#542), four polling idioms converged onto `use-polling` (#541), rc.7 `__DSH_MODULES__` fallback removed (#540), One Dark/Light syntax palettes single-sourced (#534), duplicated implementations converged and dead code removed (#525)
### v0.18.0
> 📌 **Stable release** (npm `latest`): this release targets **DSH 0.1.2-rc.1+ only** (peer floor `^0.1.2-rc.1`); 0.1.0-rc.8 ~ 0.1.1-rc.2 are not supported — stable-DSH users should stay pinned to `dsh-apes-sidebar@0.17.1`, hosts on 0.1.2-alpha.x keep `dsh-apes-sidebar@latest` (v0.18.0-alpha.0).
All changes since v0.17.1 (the two intermediate version numbers v0.18.1-alpha.0 / v0.19.0-alpha.0 were never published; their content is folded into this release):
**🔗 Host track graduation**
- **Onto the DSH 0.1.2 line and graduating to stable**: v0.18.0-alpha.0 dropped the 0.1.1-rc.x compatibility layer and fixed blank Side Chat transcripts on alpha.1+ (#472); the baseline then climbed through alpha.3 (#497) and alpha.5 (#516, `Session.events` → `snapshotEvents()`); the chat file-open funnel moved to `remote.session.openWorkspacePath` (#494) and the "Show in folder" reveal scroll was scoped to the tree body (#453). This release pins the baseline to **DSH 0.1.2-rc.1** (zero source delta vs alpha.5 — a pure version bump; `dsh-client-locale` resumed publishing, everything aligned to rc.1; real-host mount smoke 14/14 in CI)
**✨ Features**
- 🌿 **Unified "Changes" tab** (#475): one tab, two lenses — Git (real diff / history / stage·commit·revert / worktree & child-repo selection) and This Session (live tracking of every file the model reads / writes / edits); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab
- 💬 **Side Chat rendering upgrade** (#486): main-conversation-grade Blocks structure, per-turn usage tails, reconnect banner
- ⚙️ **Workspace path fence toggle** (#458): a new `workspaceFence` declarative settings key with a one-click off affordance and guidance on 403 error surfaces
**⚡ Performance**
- 🚀 **Core bundle -45%** (#489): 19 non-zh/en dictionary chunks lazy-loaded, render stabilization (transcript row reuse / tree Sets / batched drags), startup & polling cost cuts (single settings fetch, one git process per tick); a new perf measurement lane; also fixes the bottom-pane drag leaking panel width into the host layout and jumping the native left sidebar
- 📉 Fewer repeated center-column DOM queries (#456)
**🐛 Fixes (selection)**
- ✏️ Editor / Markdown: SSH remote editor links open on the client (#522), reading position kept across preview/edit switches (#467), YAML frontmatter hidden in preview (#394), TOC dismiss-on-outside-click + popover z-index (#461), file basename kept in @-reference links (#417)
- 💻 Terminal / platform: Windows custom shell executables resolve (#503), transient resize failures contained (#428), monospace fallback for unresolvable fonts (#366), Linux absolute paths in WSL workspaces (#455), Windows Explorer reveal selection preserved (#508)
- 🗂️ Layout / state / file tree / chat: desktop shell layout coexists with the side card (#398), no forced mobile drawer on auto-activation (#373), restored free-window id conflicts (#385), tree auto-refresh on window focus (#469), reference affordance pinned to row tail (#509), selection-popup dismissal + caret-anchored draft insert (#427), collapsed toggle cluster aligned (#361), old-engine scroll-jump compat (#448), worktree listing on older Git (#454)
**🧰 CI & internals**
- Windows CI lane (#520), Makefile command surface (#526), e2e script hardening + aggregate double-mount regression (#527), shared component test utils deduplicating boilerplate (#524), duplicate-implementation convergence + dead code removal (#525)
**🌐 Ecosystem**
- 10+ new curated plugins: dsh-apes-sidebar-icons (#441), dsh-sidenote (#451, formerly dsh-sidechat #470), dsh-github-workbench (#410), dsh-bilingual-reader (#379), dsh-server-deck (#413), dsh-md-export (#405), dsh-code-nav (#404), dsh-suhuang-scroll (#392), dsh-better-overleaf (#370), and more (each with 18+ language i18n coverage)
### v0.19.0-alpha.0 (0.1.2-alpha.5 adaptation, unpublished, folded into v0.18.0)
> 🧪 **Alpha track**: this release targeted **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.5`, npm dist-tag `alpha`). This number was never published separately — its content shipped in the **v0.18.0** stable release, and the same number was later reused for the 0.1.5 adaptation line (see above).
- 🔗 **Adapted to DSH 0.1.2-alpha.5 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin, the `dsh.plugin.json` engines floor, and the `@deepseek-ai/*` peer / devDependencies baseline moved up to 0.1.2-alpha.5 (verified by a real-host mount smoke 14/14). `dsh-client-locale` has no alpha.5 upstream (newest is 0.1.2-alpha.3), so its peer floor / devDep pin trail the baseline and already accept the alpha.5 runtime (`pnpm peers check` reports zero mismatches — no new transitive peers to hoist). The code adapts to alpha.4's compatibility-flagged change — the `Session.events` property was removed, migrated to the on-demand `snapshotEvents()` API (8 sites: sidechat transcript live reads, fork inheritance, `jobs.output` replay, subagent activity), and the `seedLength` meta field the host dropped was removed from thread creation; alpha.4's remaining changes (bidirectional `send_message`, custom-model discovery reusing Profile headers, `SessionSeq`/`SessionLogOffset` strong typing) and alpha.5's upgrade-startup fix were verified not to touch any other plugin surface.
### v0.18.1-alpha.0
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.3`, npm dist-tag `alpha`, install `dsh-apes-sidebar@latest`). This version number was never published separately; its content is folded into the **v0.18.0** stable release.
- 🔗 **Adapted to DSH 0.1.2-alpha.3 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin, the `dsh.plugin.json` engines floor, and the `@deepseek-ai/*` peer / devDependencies baseline moved up to 0.1.2-alpha.3 (verified by a real-host mount smoke 14/14). All 117 commits between alpha.2 and alpha.3 were audited point by point: every host contract this plugin relies on (token auth, slash RPC, `MarkdownText` labels, the event-stream and persistence APIs behind `sidechat.events`, `SettingsNamespaceInput`, `SUBAGENT_DESCRIPTOR_VERSION` (still 3), `dsh-client-store`, the profile loader, the node-pty pin) is unchanged, so no code adaptation was needed; alpha.3's breaking changes (the required `BeginSubmissionInput.mode`, the renamed `attachment-invalid` subagent error, the removed SQLite persistence backend, the identity-gated projection change feed) were all verified not to touch this plugin.
### v0.18.0-alpha.0
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.2`, npm dist-tag `alpha`, install `dsh-apes-sidebar@latest`); 0.1.0-rc.8 ~ 0.1.1-rc.2 are no longer supported — stable-DSH users should stay on v0.17.1 (npm `latest`).
- 🔗 **Adapted to DSH 0.1.2-alpha.2 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin and the `@deepseek-ai/*` devDependencies baseline moved up to it (verified by a real-host mount smoke 14/14). Adaptation points: `dsh-settings` dropped the runtime `settingsNamespace` export (namespaces are now validated at compile time — the host passes the constant directly); the `dsh-subagent` descriptor version went 2→3 (stamped by the host package; the test assertion follows the `SUBAGENT_DESCRIPTOR_VERSION` constant); the restored `SessionEvent.ignorable` and the Remote gateway's unified `RemoteError` wrapping were verified to not affect this plugin.
- 🐛 **Fixed blank Side Chat transcripts on DSH 0.1.2-alpha.1+**: transcript polling still called the `ctx.connection.api` face removed in alpha.1 (the error was silently swallowed, so the tab rendered an empty transcript forever). Transcripts now come from the plugin's own `sidechat.events` route (live threads read the in-memory event log, cold threads read session persistence, with `afterSeq` delta pulls, [sidechat-routes.ts](./src/sidechat-routes.ts)).
- 🧹 **Dropped the pre-alpha (0.1.1-rc.x and older) compatibility layer**: the e2e host RPC collapsed from the dot/slash dual dialect to slash-only (token URL required, [host-protocol.ts](./tests/e2e/host-protocol.ts)); `MarkdownText` labels collapsed to the nested single shape ([markdown-labels.tsx](./src/client/markdown-labels.tsx), no more dual prop names); peerDependencies / devDependencies / `dsh.client.inject` / the chunk externals allowlist all dropped the defunct `@deepseek-ai/dsh-client-runtime` (four synchronized spots).
### v0.17.1
- 🔗 **DSH 0.1.2-alpha.1 adaptation (dual-version compatible)**: fully adapted to DSH 0.1.2-alpha.1's Remote gateway, one-time-token browser authentication, and the `MarkdownText` labels contract change — the plugin works identically on 0.1.0-rc.8 ~ 0.1.1-rc.2 and 0.1.2-alpha.1 (the latter verified by a real-host mount smoke 14/14 against a source build of the GitHub tag; alpha.1 was never published to npm, and the CI pin moved to the npm-published 0.1.2-alpha.2 in v0.18.0-alpha.0). Highlights: all four `MarkdownText` render sites now go through the dual-shape labels helper ([markdown-labels.tsx](./src/client/markdown-labels.tsx)), fixing the `reading 'code'` crash in markdown/mermaid previews on alpha.1; the e2e mount smoke speaks both wire dialects (token-URL cookie exchange, slash `/api` endpoints with parameter-named args, [tests/e2e/host-protocol.ts](./tests/e2e/host-protocol.ts)); dropped the `@deepseek-ai/dsh-client-runtime` peer removed upstream in 0.1.2-alpha.1
### v0.16.1
All changes since v0.16.0:
**🐛 Fixes**
- 🧊 **Git panel freeze + restart loop** ([#376](https://github.com/omdsh-dev/DSH-better-sidebar/pull/376), fixes [#369](https://github.com/omdsh-dev/DSH-better-sidebar/issues/369)): opening the Source Control panel could freeze the whole page, and the frozen layout restored itself after every reload with no way out — three unbounded layers compounding, now all bounded: **① status truncation** — the `git status --untracked-files=all` response is capped at 2,000 entries (capped results carry `truncated` and the panel shows a notice, mirroring `fs.read`'s truncation semantics; worktree change counts inherit the bound), so a huge untracked set can no longer freeze the browser main thread; **② bounded repository discovery** — a non-repository cwd (e.g. the home directory) no longer probes every visible child directory serially without limit: probe timeout 30s→5s, at most 200 probed directories, concurrent requests share one in-flight scan plus a 60s TTL cache, ending the `git rev-parse` spawn storm under `~`; **③ reset escape hatch** — loading with `?dsh-sidebar-reset` drops the persisted layout (shared width included) and starts from the default, breaking the loop even when the page is already hung; persisting resumes once the param is gone; `statusTruncated` copy synced across all 19 dictionaries
### v0.16.0
All changes since v0.15.2:
**✨ New features**
- 🪟 **Free windows** ([#354](https://github.com/omdsh-dev/DSH-better-sidebar/pull/354)): drag any tab (built-in or plugin-registered) out of the tab bar **onto the main conversation area** — a dashed drop hint marks the landing zone and release turns it into a floating window (default 390×780, phone-portrait ratio, clamped to the viewport and centered at the drop point). Windows can be moved by dragging the header, resized from the SE corner (≥320×200), raised to the top by clicking anywhere, sent back with the header context menu ("Back to sidebar" / "Close"), and closed with X through the normal `closeTab` lifecycle (pty release etc.). Dragging over a sidebar pane highlights it and releasing **docks** the window back into that pane. `floats` persist per session (restored as-is after refresh, lenient sanitize + geometry clamping). Service semantics: `features` gains `'floatWindows'` — `openTab`'s dedupe/id focus on a floating tab **raises its window** (no duplicate open, no panel expansion), `closeTab` / `activateTab` work on floating tabs and still fire their lifecycle callbacks, `visible` is always true inside a float, and agent-terminal reconcile covers floating windows. Tab content reuses the regular render path; plugin tabs are fully supported. Also ships the 8px-grid spacing cleanup of file sub-pages ([design](docs/plans/2026-08-23-free-window-design.md))
- 📂 **Model-driven sidebar opens (`sidebar_open` tool)** ([#353](https://github.com/omdsh-dev/DSH-better-sidebar/pull/353)): a new global setting `agentOpenTools` (**off by default**) injects **one** tool letting the model open local **files** (editor tab, deduped by path), **folders** (full-window tree rooted there, `meta.dir`) and **HTTP(S) pages** (browser tab, URL prefilled) in the caller's sidebar. Turning the setting off unregisters the tool and clears the undelivered queue; already-open tabs stay. Opens targeting non-active sessions queue and replay when visible (`/sidebar/ws/agent-opens` push, same trust fence). No new public API and no new `/sidebar/api` routes ([design](docs/plans/2026-08-23-agent-open-tools-design.md))
- 📝 **README-level inline HTML + table of contents (TOC) in Markdown previews** ([#360](https://github.com/omdsh-dev/DSH-better-sidebar/pull/360)): previews now genuinely render **block-level inline HTML** — badge walls `<div align=center>`, `<details>` blocks nesting markdown, `<br/>`/`<sub>`/`<img>` inside table cells, `<video>`/`<picture>` — all sanitized through a DOMPurify allowlist (`<script>` and other active content stripped, `<a>` forced `_blank rel=noopener`), with local media `src` rewritten to the session media route. With ≥3 headings a floating **TOC** button appears: smooth scroll, auto-expanding folded `<details>` ancestors, collecting headings from HTML segments too. The renderer is still the host `MarkdownText` (shiki / KaTeX / GFM preserved); pure-markdown documents take the original path with zero regression ([design](docs/plans/2026-08-24-markdown-html-toc-design.md))
- 🌏 **Third-language coverage (19 languages)** ([#339](https://github.com/omdsh-dev/DSH-better-sidebar/pull/339)): optional peer `@huanlin/dsh-plugin-better-locale` adds full dictionaries for ja / de / fr / pt / ko / ar / hi / id / tr / vi / th / ru / it / nl / sv / pl / zh-HK / zh-TW / zh-MO (~340 keys each). Coverage **borrows the DSH English slot**: it is active only while DSH's active locale is `en` and is completely inert under `zh` (no mixed-language UI). The 19 dictionaries are also registered into better-locale so external `ctx.locale.lookup('betterSidebar', key)` callers get the override text. Without better-locale installed `ctx.get('betterLocale')` is undefined and the whole block is a no-op — zh/en behavior is unchanged
- 🌿 **Multi-repository Git selection + linked-worktree discovery** ([#326](https://github.com/omdsh-dev/DSH-better-sidebar/pull/326) [#285](https://github.com/omdsh-dev/DSH-better-sidebar/pull/285)): when the session cwd is a workspace container rather than a Git repository, the Git panel discovers direct child repositories and shows a **repository selector** — status / branch / history / diff / stage / commit / revert / cherry-pick / file opening are all threaded through the selected repository. Linked-worktree change discovery and per-worktree git operations are transactionally bound to the selected checkout (including delayed pagination responses), stale/prunable worktree targets are rejected, and individual inventory failures degrade safely
- 🖥️ **Browser loopback allowlist** ([#365](https://github.com/omdsh-dev/DSH-better-sidebar/pull/365)): a new side-card setting `browserAllowedLoopback` (comma-separated host or host:port entries; bare hosts match every port, host:port matches exactly) lets the sidebar browser navigate to explicitly trusted local addresses. Allowlisted loopback pages additionally get the `allow-same-origin` iframe sandbox token — local dev servers (Vite etc.) need a real origin for their module/HMR/fetch pipeline and would otherwise render blank. The page stays cross-origin to the GUI and to every other site; the server-side `browser.probe` route mirrors the same allowlist
- 📝 **Vue + 28 legacy languages in the editor** ([#202](https://github.com/omdsh-dev/DSH-better-sidebar/pull/202)): `.vue` maps to `@codemirror/lang-vue` (template / script / style dispatch on the `lang` attribute, `<style lang="scss">` preprocessors); zero new dependencies bring scss/sass/less/stylus/ruby/lua/perl/r/dart/scala/groovy/powershell/diff/protobuf/cmake/pug/tcl/haskell/clojure/erlang/julia/pascal/vb/vhdl/stex/objectivecpp via legacy-modes. A throwing language factory degrades to plain text (console.warn) instead of breaking the editor; ambiguous `.v` / `.m` extensions stay unmapped on purpose
- 🔄 **Editor preview refresh trio** ([#215](https://github.com/omdsh-dev/DSH-better-sidebar/pull/215) [#228](https://github.com/omdsh-dev/DSH-better-sidebar/pull/228), fixes [#167](https://github.com/omdsh-dev/DSH-better-sidebar/issues/167)): a **manual refresh** button for text previews; switching back to preview after an edit-save auto-reloads (suppressed while dirty so drafts survive); a successful save while in preview mode reloads on the saved edge. Automatic polling and the `fs.stat` version endpoint are gone (zero background API traffic)
- 🖼️ **Local / relative images in Markdown** ([#292](https://github.com/omdsh-dev/DSH-better-sidebar/pull/292)): `![alt](./img.png)`, `/cwd/img.png` and reference-style `[id]: url` destinations are rewritten to `/sidebar/file` media URLs (still bounded by the session cwd) — previews no longer show just the alt text
- ➕ **New recommended-catalog entry: ego-browser** ([#340](https://github.com/omdsh-dev/DSH-better-sidebar/pull/340)): `@dsh-external/ego-browser` Agent browser tab (registers a sidebar page automatically when better-sidebar is present, falls back to a floating bubble otherwise); description dictionary completed across 19 languages ([#371](https://github.com/omdsh-dev/DSH-better-sidebar/pull/371))
**🐛 Fixes**
- 🛒 **DSH marketplace managed-install compatibility** ([#338](https://github.com/omdsh-dev/DSH-better-sidebar/pull/338)): the public `cordis` entry was removed from `peerDependencies` (the market preview hard-rejects `cordis` in any dependency field — optional does not help), so the npm package satisfies the [dsh-community-market install rules](https://github.com/anywhere-labs/deepseek-harness-desktop/blob/master/dsh-community-market/docs/install-and-uninstall.zh.md) — catalog sources (dshfind / 1024Store) can re-issue the `repository_backlink` verified target and the plugin becomes installable through the Desktop market
- 🔤 **Type base migrated to `@deepseek-ai/cordis`** ([#338](https://github.com/omdsh-dev/DSH-better-sidebar/pull/338)): the declaration surface (`src/context-types.ts`) no longer depends on or restates the public `cordis` — `Context` is now an **intersection** of the real vendored cordis Context with the structural service faces, and the `ctx.betterSidebar` type merge lives on `@deepseek-ai/cordis`. **Consumer migration**: change `import type { Context } from 'cordis'` to `import type { Context } from '@deepseek-ai/cordis'` (the `import type {} from 'dsh-apes-sidebar'` merge path is unchanged); plugins that never used that import are unaffected
- 🧩 **All internal `ctx.betterSidebar` reads fixed** ([#357](https://github.com/omdsh-dev/DSH-better-sidebar/pull/357), fixes [#356](https://github.com/omdsh-dev/DSH-better-sidebar/issues/356)): on npm-installed DSH 0.1.1-rc.x (web bundle) the sidebar died on every page load (`cannot get property "betterSidebar" without inject`) — the 26 internal direct reads now go through `ctx.get('betterSidebar')` (root reflect-store resolution, immune to the fiber chain); external consumers keep `inject: ['betterSidebar'] + ctx.betterSidebar`
- 🔐 **Session-workspace boundary for file APIs** ([#345](https://github.com/omdsh-dev/DSH-better-sidebar/pull/345), fixes [#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)): fixed `fs.tree / fs.read / fs.write` workspace escapes; media, HTML preview and uploads share real-path symlink validation; regression tests for absolute paths, symlinks, uploads and nested Git sessions
- 🪟 **Panel-host layering and viewport clipping** ([#330](https://github.com/omdsh-dev/DSH-better-sidebar/pull/330) [#278](https://github.com/omdsh-dev/DSH-better-sidebar/pull/278), fixes [#277](https://github.com/omdsh-dev/DSH-better-sidebar/issues/277)): panel host z-index 40→25 — below the DSH cordis dynamic-plugin layer (30), so the workbench no longer covers cordis inventory/approval surfaces (still above AppFrame's 20 and below the 100+ float stack); `overflow: hidden` on the host clips at the viewport edge, so collapsed panels no longer stretch the document into bidirectional scrolling (measured `scrollWidth` 2289→1672 / `scrollHeight` 1280→1032, any skin)
- 📐 **Layout-push hardening** ([#310](https://github.com/omdsh-dev/DSH-better-sidebar/pull/310) [#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130) [#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)): conversation column gets `min-height: 0` + `overflow: hidden` + `overflow-wrap: anywhere` (long unbreakable URLs / OAuth links no longer push the composer and left-rail Settings out of the viewport); the layout-push effect is split into "set only" + "remove only on unmount" and the width push is gated on `panelOpen` — dragging the bottom height with the right panel closed no longer squeezes the conversation, and release no longer flashes full-width; `useLayoutEffect` removes cross-paint full-width frames; all three drags flush the final frame and sync `centerRect.right` before committing; bottom height caps at `viewportHeight - PANEL_MIN`; drag handles no longer highlight mid-drag
- 📱 **Mobile: unavailable-sidebar clarity + 1px overflow** ([#254](https://github.com/omdsh-dev/DSH-better-sidebar/pull/254)): with no session the toggle uses `aria-disabled` — keeping the non-executable semantics while allowing touch/keyboard focus so the "select a session to use the sidebar" tooltip is reachable; the panel uses `border-box` so mobile `100vw` includes the left border and no longer causes 1px horizontal overflow
- 📏 **Side-card width shared across sessions** ([#36](https://github.com/omdsh-dev/DSH-better-sidebar/pull/36)): panel width is a layout preference, not session content — "last drag wins" writes the global `dsh-sidebar:v1:width` key that fresh and cached session switches adopt; without a global key (first run / pre-existing sessions) behavior is byte-for-byte unchanged
- 🧹 **Terminals close immediately on session delete** ([#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130)): new `PtyManager.closeSession()` subscribing to DSH's `session/disposed` — deleted sessions no longer wait for the 30s reconnect grace to release their terminals (agent terminals are agent-lifecycle-owned and untouched)
- 🔍 **Filename search skips noise directories** ([#342](https://github.com/omdsh-dev/DSH-better-sidebar/pull/342)): `node_modules` / `.pnpm-store` / `.yarn` / `.turbo` / `.next` / `dist` / `build` / `coverage` etc. (case-insensitive, `.git` still skipped) — huge dependency trees no longer burn the 100k visit budget and return `truncated` early, so real files in later directories (`docs/`) are found; no `.gitignore` semantics, still filename lookup
- 📝 **Mermaid global error rendering suppressed** ([#341](https://github.com/omdsh-dev/DSH-better-sidebar/pull/341)): `suppressErrorRendering` enabled — invalid diagrams no longer inject a large error SVG into `document.body`; the component-level error fallback and source display remain
- 🖥️ **Terminal Nerd Font icon fallback** ([#190](https://github.com/omdsh-dev/DSH-better-sidebar/pull/190)): supplementary-plane PUA icons in starship / powerlevel10k prompts (Nerd Fonts v3 Material icon set) no longer render as tofu — `withIconFontFallbacks()` appends Nerd Font icon families to the winning base font (before the first generic family, deduped by family name, CSS global keywords filtered, no color-emoji fonts)
- 🌐 **HTML preview declares UTF-8** ([#193](https://github.com/omdsh-dev/DSH-better-sidebar/pull/193), fixes [#170](https://github.com/omdsh-dev/DSH-better-sidebar/issues/170)): `/sidebar/html` responses carry `charset=utf-8` (Chinese fragments without `<meta charset>` no longer mojibake); original file bytes preserved
- 🧪 **trust-fence Origin compared by hostname** ([#182](https://github.com/omdsh-dev/DSH-better-sidebar/pull/182)): some Chromium builds (Edge 151) serialize the Origin of non-default-port loopback pages without the port — `http://127.0.0.1` against `Host: 127.0.0.1:3080` no longer 403s (mirrors DSH's official gateway fence); different hostnames and opaque null origins are still rejected
- 🪟 **"Show in folder" now reveals in the explorer** ([#94](https://github.com/omdsh-dev/DSH-better-sidebar/pull/94)): the folder-reveal gesture no longer opens the directory as a file in the editor (`"..." is a directory`) — `revealInExplorer` switches to the explorer tab, auto-expands a collapsed panel, expands the parent folder and highlights/scrolls to the produced file; the produced-files selector now reads the engine's Turn deliverables (same source as ui-deliverables)
- 🖱️ **Panel-drag layout flash fixed** ([#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)): dragging the bottom height with the right panel closed no longer shifts the conversation left; release no longer flashes full-width before snapping back
- 🖥️ **PowerShell installer fixes** ([#47](https://github.com/omdsh-dev/DSH-better-sidebar/pull/47)): the remote entry is now "download the script → strip the UTF-8 BOM → run it in memory", so `-Version` / `-DryRun` work again under Windows PowerShell 5.1 (the BOM no longer eats the leading `param(...)`); `pnpm --version` is checked before touching the profile (major <10 fails with a clear error and exit code 1)
- 🔄 **Browser embed probing GET fallback** ([#69](https://github.com/omdsh-dev/DSH-better-sidebar/pull/69)): when a HEAD response omits both `Content-Security-Policy` and `X-Frame-Options`, probe retries once with GET — sites like Alibaba Bailian that only advertise their embed policy on GET no longer show a misleading "refused to connect"; they get the friendly "site refuses embedding" panel with an "open in browser" button
- 🔧 **git-source installs fixed via `unrun` devDependency** ([#336](https://github.com/omdsh-dev/DSH-better-sidebar/pull/336)): tsdown 0.22 loads its config through `unrun` and pnpm 11 does not auto-install peer deps — git-hosted `prepare` no longer fails with `Failed to import module "unrun"` (npm tarballs unaffected)
- 🍃 **Strict `ctx.effect` also fixed 4 latent sites**: interception / IME-guard effect bodies returned `undefined` on failure instead of a disposer (invalid shape under the vendored cordis effect contract) — now a no-op disposer
<details>
<summary><b>Older releases (v0.12.0 – v0.15.2)</b></summary>
### v0.15.2
All changes since v0.15.1:
**✨ New features**
- 🗂️ **"Open in app" submenu in the file tree** ([#334](https://github.com/omdsh-dev/DSH-better-sidebar/pull/334)): the file-tree context menu gains an "Open in app >" submenu — built-in openers (reveal/select in file manager, VS Code, Cursor, Zed), each with a pin button that promotes it to a top-level context-menu item (click again to unpin); with an optional SSH host configured, VSCode-family entries switch to the `vscode-remote/ssh-remote+<host>/<path>` protocol and local-only entries hide automatically; custom editors supported (name + URL template with `{path}` + "VSCode-family" flag, configured in the Files card gear popup). Open actions go through the new host route `POST /sidebar/api/open.external` (argv-array spawn, no shell injection) ([design](docs/plans/2026-08-22-open-with-menu-design.md))
- 📑 **Tab context menu** ([#331](https://github.com/omdsh-dev/DSH-better-sidebar/pull/331)): right-clicking a tab offers **Close / Close others / Close to the left / Close to the right**, scoped to the current pane (tab group); items grey out when there is nothing to close; the menu only opens — it never switches the active tab; bulk closes go through the existing per-tab `onClose` path so lifecycle callbacks, pty release and agent-terminal shutdown all stay intact
- 📄 **Diff files collapsed by default** ([#270](https://github.com/omdsh-dev/DSH-better-sidebar/pull/270)): each changed-file header is now an accessible expand/collapse control; recognized source files expand by default while tests, docs, generated files, lockfiles and unknown types stay collapsed; the existing 500-row cap for expanded content is preserved
- 📖 **README update**: feature tour converted to a table (two screenshots per row, saves space); community section now shows the WeChat group and QQ group QR codes ([#325](https://github.com/omdsh-dev/DSH-better-sidebar/pull/325), QQ group 577011007)
**🐛 Fixes**
- 🪟 **Prune empty restored panes** ([#268](https://github.com/omdsh-dev/DSH-better-sidebar/pull/268)): persisted split panes left empty when ephemeral diff tabs were dropped no longer survive as full-size blanks — `sanitizeState` now also prunes the empty split leaf and repairs the stale active-pane pointer; a fully empty workbench keeps its single empty pane
- 🖥️ **Hide spawned git windows on Windows** ([#301](https://github.com/omdsh-dev/DSH-better-sidebar/pull/301), fixes [#124](https://github.com/omdsh-dev/DSH-better-sidebar/issues/124)): the shared `runGit()` spawn options now set `windowsHide: true`, so repo-status polling and git actions no longer flash console windows on Windows (no behavior change on other platforms)
- 📁 **Untracked files inside new folders** ([#242](https://github.com/omdsh-dev/DSH-better-sidebar/pull/242)): `git status` switched from `--untracked-files=normal` to `--untracked-files=all` — each file inside a new folder now shows as its own row and its diff can be loaded (no more `fs.read` "is a directory" error), matching VSCode's default behavior
- ⚡ **Per-frame React re-renders eliminated for toggles/drags** (closes [#315](https://github.com/omdsh-dev/DSH-better-sidebar/issues/315)): `centerRect` moved to a ref with direct DOM writes to the bottom bar (zero React renders); `TabContent` memoized with an explicit comparator; a new frame-batcher coalesces Divider/dock drags per frame; meaningless locate passes skipped during drags. 4x CPU-throttled A/B: >17ms frames on toggle collapse 19→6 / expand 24→4~6, p95 21ms→15ms; drags unchanged (non-regression)
### v0.15.1
All changes since v0.15.0:
**✨ New features**
- 💬 **Codex-style transcript rework for Side Chat** ([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)): transcript rows became **collapsible** — tool calls, thinking and context injections share one quiet single-line chrome (chevron + label + one-line argument summary) that expands into an indented body on a hairline thread, no cards or fills; streaming labels and a creating hero shimmer (shimmer = generating), failed tools go danger, `prefers-reduced-motion` stills every loop; **the first question is no longer swallowed by the boundary prompt** — context injection and first contact are delivered as separate events (boundary + parked snapshot ride `agent.inject`, the question wakes the driver), so the transcript maps injections onto a collapsible injection row while genuine user messages — the first one included — render as user bubbles; legacy threads' first message is split out into its own bubble too
- 📖 **README rewrite**: feature tour (real UI screenshots per feature), user-facing DSH compatibility badges, simplified install (`add` → `approve-builds` → `add`, node-pty-safe build, paste-to-DSH install prompt), 28+ plugin ecosystem with per-category collapsed listings
**🐛 Fixes**
- 🖥️ **Terminal pty stays alive across conversation switches** ([#323](https://github.com/omdsh-dev/DSH-better-sidebar/pull/323)): switching sessions is no longer treated as a transient drop — the client sends a `park` control frame on unmount and the host skips the 30s reconnect-grace countdown; switching back (`open()` cancels parked) or explicitly closing the tab resumes the normal lifecycle; agent terminals keep their indefinite lifetime
- 📂 **File-tree upload overlay no longer intercepts Tab drags** ([#317](https://github.com/omdsh-dev/DSH-better-sidebar/pull/317)): dragging tabs (reorder / cross-pane split) across the explorer no longer shows the upload overlay or swallows the event — gated on `dataTransfer.types` containing `Files` (consistent with the panel-host shield), so tabs land normally; OS file drags behave as before
- 💬 **Subagent auto-open debounced** ([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)): Side Chat thread creation no longer pops the task page — the 0→N trigger rearms for 500ms and re-evaluates the original baseline against the live snapshot, by which time the title filter recognizes the thread; genuine subagents still auto-activate the Tasks page (wide viewports expand the sidebar, while narrow viewports prepare the tab without forcing the drawer open)
### v0.15.0
All changes since v0.14.0:
**✨ New features**
- 💬 **Side Chat (beta) tab** ([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)): Codex-style side threads, **one independent tab per conversation** — the child inherits the parent's full context (completed turns + pending messages + the in-progress turn's assistant output and tool activity, honestly frozen with an "interrupted" marker); created with an identical composition (same preset / provider / model) so the first request reuses the parent's input prefix cache; threads stay invisible in the main session list with zero subagent-catalog noise; follow-ups survive DSH restarts (auto cold-resume); one-click "Save as new session" promotes the thread to a top-level session ([design](docs/plans/2026-08-20-sidechat-tab-design.md))
- 📤 **Upload into the files window** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): header "upload file / upload folder" buttons plus drag-drop (drop on the tree body = workspace root, on a directory row = that directory, on a file row = its parent directory, VSCode semantics); full-window blurred progress overlay while uploading (per-file progress + cancel / Esc); buttons disabled while busy, tree refreshes after the upload settles
- 🧩 **Desktop compatibility in four options** ([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)): "Position compatibility mode" is now a main-row dropdown — **Auto-detect** (default, conservative: only the standard Window Controls Overlay geometry contributes; real 32/36px caption-overlay heights per shell, live on maximize/restore; zero modification on plain web) / **DSH official web** (explicitly no adaptation) / **Shell preset** (built-in, opt-in; only shells that appeared in this repo's issues/PRs with 100+ stars, "detected" badge when the environment matches) / **Custom** (free-form CSS + shift distance). Documents that already carried compatibility values migrate to the custom scheme; interactive chrome opts out of desktop drag regions (`no-drag`); the bottom-push anchor is a composite selector (`[data-pane]` and `:has(> [data-slot])`)
- 🎛️ **Settings page UI/UX modernization** ([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)): the side-card secondary-settings entry is now a full-width "Feature settings" strip at the card bottom (replacing the invisible corner gear — much easier to discover); coordinated two-tone enabled state (brand activation accent + success-green check badge); every color is still `--dsw-alias-*` token-derived so skins follow automatically
- ➕ **New entries in the recommended-plugin catalog**: `dsh-docs-panel` (global docs, [#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230)), `dsh-flowglass` ([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261)), `dsh-git-forge` and `dsh-ssh-tunnel` ([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204)), `dsh-turn-review` ([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
**🐛 Fixes**
- ⚡ **Batched live preview for the subagent page** ([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)): the old implementation polled `subagents.history` per running subagent, each poll triggering a full host-side enumeration — an O(N²) amplification that stalled the page with many concurrent subagents; now a single batch route `subagents.live` (one enumeration of the whole tree) plus one client poller with a single in-flight request; display logic and copy unchanged
- 🖱️ **Interrupted / fast-release drags no longer roll back** ([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249), closes [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)): interrupted or fast-released drags commit the last known position; HMR re-activation re-locates the center column (fixes the blank bottom panel after a hot reload)
- 📐 **Push variables stay effective while mounted** ([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259), fixes [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)): the bottom panel no longer flashes full-width after a drag is released
- 🔐 **Workspace boundary hardening for file APIs** ([#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)): `fs.tree/read/write`, media, HTML preview, and uploads now share real-path containment checks that reject outside absolute paths and symlink escapes
- 🔧 **Adapted to DSH 0.1.1-rc.1 / rc.2 (@next)** ([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)): no code changes needed
- 🔒 **Upload-chain hardening** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): empty and absolute `relativePath` segments are refused outright; uniquely named temp files (concurrent uploads stay independent, crashed processes never block later uploads); write-stream error listeners (a failing disk can no longer crash the host); client error codes unified with the wire (`too-large`), 413s localized
### v0.14.0
> ⚠️ This release requires DSH ≥ 0.1.0-rc.8. All changes since v0.13.1:
**✨ New features**
- 🖼️ **Unified panel-host injection refactor** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): panels/toggle clusters moved into a `[data-dsh-panel-host]` fixed containing block (`fixed inset-0 z-40`), immune to desktop-shell intermediate transforms hijacking `fixed`; mount self-check (page-level transform → `data-dsh-panel-host-degraded` degraded sync, judged on uncorrected geometry, exits only when the ancestor transform is gone); push anchor switched to `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc width against desktop-shell additive overflow; chunk revalidation on activation (HEAD+ETag keeps unchanged chunks, 5s timeout fails open); `visualViewport` keyboard inset + `env(safe-area-inset-*)` mobile adaptation
- 📂 **Separate file windows by default** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): `editorExplorer` now defaults to **separate** — tree clicks / file opens create a new tab per path and the path-less window is a pure file manager; merged mode stays available as an opt-in
- 🖥️ **Terminal shell / shellArgs configurable from the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the terminal card's gear popup gains "Shell path" and "Shell arguments" rows (previously yaml-only via `cordis.patch.yml`) — saved values take effect immediately for terminals opened afterwards (UI terminals and model `terminal_create` alike); empty keeps the existing yaml → `$SHELL` / login shell / `powershell.exe` resolution order
- 🏷️ **Version badge on the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the side-card settings section now opens with a `DSH-better-sidebar v0.14.0` identity badge (version synced with the service instance, test-guarded)
- 🔍 **Add-plugin catalog: search / grouping / independent scroll** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): built for a growing plugin ecosystem — a live search box (filters by name / id / description), optional `category` grouping for entries, and an independently scrolling list (the modal no longer grows unbounded with catalog size)
**🐛 Fixes**
- 🧩 **rc.8 module-system migration** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): rc.8 no longer exposes the `window.__DSH_MODULES__` page global (it moved to the `ctx.modules` service), which broke every lazy chunk's externals resolution — the client now injects the `modules` service and shares it with chunk-bundle copies through a plugin-owned global (terminal / editor / Mermaid on-demand loading restored)
- 🧩 **Chunk revalidation barrier hardening** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): HEAD revalidation gains a 5s timeout (fails open on a stuck route so the barrier can never wedge lazy loads); `resetChunks` clears a pending revalidation barrier
- 🖱️ **Drag robustness** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): fast releases (browsers merge/lose pointermove bursts) commit the last known dragged position instead of rolling back; `pointercancel` / lost-capture interruptions keep the drag result too; the center column is re-measured right after commit (no mid-frame bottom-panel width jump); HMR re-activation re-locates the center column via an `<html>` style observer plus a retry when the bottom panel opens (fixes the blank bottom panel / shifted input bar after a hot reload)
### v0.13.1
**✨ New features**
- 📊 **Safe Mermaid rendering in the Markdown preview** ([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)): when a previewed md file contains mermaid fences, a `client-mermaid.js` chunk (~7MB) is served on demand (zero load without mermaid); defense-in-depth rendering — `securityLevel: 'strict'` + `htmlLabels: false` (node labels use real SVG `<text>`) + a second sanitize pass before SVG injection (foreignObject/script/foreign HTML elements removed, `@*`/`on*`/`href` attributes stripped); click a diagram to zoom in a modal overlay (wheel zoom centered on the cursor, drag pan, toolbar & shortcuts), re-renders with light/dark theme, falls back to the raw code block on parse failure
- 🖥️ **Configurable terminal shell & shellArgs** ([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)): `cordis.patch.yml` `better-sidebar.config` can set `shell` / `shellArgs` (a non-empty `shellArgs` fully replaces the defaults; unset keeps the previous auto-resolution of `$SHELL` / login shell / `powershell.exe`), applied to both UI terminals and agent terminals (`terminal_create`); terminal tab titles now show the shell name (bash/zsh/powershell) and internal tab ids use UUIDs so the same shell can open multiple terminals
**🐛 Fixes**
- 🔗 **Aggregate double-mount auto-yield** ([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)): when an aggregate package (e.g. dsh-web-ui-all) mounts the same package under its own entry id, the guard expression in `cordis.patch.yml` disables the plugin's own `better-sidebar` row so `/sidebar/api` is no longer registered twice (`duplicate prefix route` crashing the whole plugin tree / `dsh web`); standalone installs behave as before
- 🔧 **Adapted to DSH 0.1.0-rc.7** ([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207), fixes [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): fixes the `agent-presets: refusing to compose an unscoped context` error when picking a model / sending a message after DSH moved to rc.7
### v0.13.0
**✨ New features**
- 📁 **Files window merged with the explorer** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): new `editorExplorer` setting (editor card gear) — file tabs gain a path-input header plus a toggleable right-docked file tree (per-tab open/width memory, drag-resize 160–480px from the left edge, global filename search via the host `fs.search` route with a hard budget, skipping `.git` and symlink dirs); in separate mode (default) tree clicks / Enter in the path input open each file **in its own new tab**, merged mode switches the current tab **in place**; fresh sessions seed an empty Files window instead of the explorer tab, and a path-less window is a bare file manager in separate mode / a chrome'd empty file window in merged mode; the tree context menu offers "Open in new tab" and "Open to the side" (split)
- 🎛️ **Select rows for declarative settings** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): settings rows gain `type: 'select'` (`options` with value/title/desc/icon, `multi` stores the picked values as an array); options with icons render big-icon option cards and keep the icon in the closed anchor; `editorExplorer` became an iconed select (merged vs separate); the capability list gained `settingSelect`
- 🔀 **Mutual exclusion with the dsh-web-ui family right panel** ([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)): reads the `aionui-panel` settings namespace's provider choice — when "Use aionui-panel" is selected, the whole better-sidebar (right sidebar / bottom panel / floating entry / all takeovers) does not mount; with DSH-better-sidebar (or no aionui installed) it behaves as before. Takes effect live after a settings save (settings-document push), no reload needed
### v0.12.3
**✨ New features**
- 🎨 **Skin compatibility (token-driven)**: fully consumes DSH design tokens and follows the dsh-web-ui skin center's 10 skins automatically; terminal/editor surfaces fall back to opaque backgrounds under transparent/translucent-glass token values so text never scrolls over the skin art ([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110), fixes #106 #105 #90 #60, also #52 #57 #92)
- 🗂️ **Unified path handling**: UNC / symlink classification (directory symlinks expandable, broken links highlighted) + HTML-route platform guards ([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134), #65 #67 #43 #79 #115)
- 🖥️ **Configurable terminal shell**: custom shell setting with Windows pwsh auto-probe ([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95))
- 📝 **Editor languages**: C# / Kotlin / Swift syntax highlighting ([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120))
- 🧭 **Settings nav icon**: settings-page navigation icon and layout polish ([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114))
- ➕ **Recommended-plugin catalog**: added `dsh-git-remotes` — Git Remotes tab (branches/upstream/ahead-behind, fetch with prune, ff-only pull, confirm-before-push; does not replace the built-in stage/commit tab) ([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91)); and `dsh-video-preview` — inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) backed by a /video host route with HTTP Range (206) scrubbing, not capped by the 20MB mediaLimit ([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126))
**🐛 Fixes**
- 🔧 **xterm migration**: deprecated xterm dependency migrated to `@xterm/xterm` (Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122), [#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
- 📝 **Markdown editor**: selection-to-conversation popup restored ([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
- 🖼️ **Markdown preview renders local/relative images**: image destinations that point at local files (relative/absolute paths, reference-style `[id]: url`) are rewritten to `/sidebar/file` media URLs and displayed in the preview (previously only absolute http(s) images rendered; relative paths showed just the alt text)
- 🐛 **node-pty load failure no longer crashes the server** ([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)): the host half now lazy-loads node-pty — when it is missing the plugin still mounts, the terminal shows a repair banner (copyable command + Retry button), and agent terminal tools are skipped
- 🧪 Test engineering: unit spec split (#141) + flaky smoke cleanup fix
</details>
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 dsh-external
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-44
View File
@@ -1,44 +0,0 @@
# dsh-apes-sidebar 命令面薄封装:目标仅转发 package.json scripts(唯一事实源),
# 新增/改名 script 不需要动这里;本文件只补目标发现(help)与 CI 门禁聚合
# (check / mount / mount-aggregate / registry)。
.DEFAULT_GOAL := help
help: ## 列出所有可用目标
@awk -F':.*## ' '/^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install: ## 安装依赖(pnpm install)
pnpm install
build: ## 构建(清 lib/ → tsc → tsdown)
pnpm build
typecheck: ## 类型检查(tsc --noEmit)
pnpm typecheck
lint: ## 代码检查(eslint .,flat config 最小规则集)
pnpm lint
test: ## 单元测试(vitest run)
pnpm test
check: ## 聚合校验门禁:typecheck → lint → build → test → check:consumer-types(对齐 CI)
pnpm typecheck && pnpm lint && pnpm build && pnpm test && pnpm check:consumer-types
clean: ## 清理构建产物与测试报告(lib/、*.tgz、playwright-report/、test-results/)
rm -rf lib playwright-report test-results
rm -f *.tgz
pack: ## 打 npm tarball(pnpm pack)
pnpm pack
mount: ## 真机挂载冒烟:build + pack → Playwright Chromium → pnpm test:mount
pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount
mount-aggregate: ## 聚合双挂载回归:build + pack → pnpm test:mount:aggregate
pnpm build && pnpm pack && pnpm test:mount:aggregate
registry: ## 组装 plugin-registry 暂存(registry/,不入库):build → node scripts/package-registry.mjs
pnpm build && node scripts/package-registry.mjs
.PHONY: help install build typecheck lint test check clean pack mount mount-aggregate registry
-437
View File
@@ -1,437 +0,0 @@
# dsh-apes-sidebar
> [!NOTE]
> **Fork notice** — 本项目是 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) 的 fork,
> 基线 **upstream v0.24.1**(commit `a2751cf`,MIT)。
>
> - **本 fork 的增量**:`src/client/apes/` 下的 3 个信息层 tab(`apes:agents` 智能体 / `apes:apps` 应用 /
> `apes:profile` 画像),按 DSH 标准 `TabDescriptor` 接口注册,自动获得上游原生适配层的全部待遇
> (进原生 guide、tab 芯片图标、开合与分栏)。
> - **宿主版本要求(与上游 v0.24.1 一致)**:DSH **0.2.0-rc.1+**(peer `^0.2.0-rc.1`)。装在更旧的宿主上会被
> 启动兼容性预检判定失败、**整行静默禁用**(既不报错也不加载)。
> - **自 v0.19.1 起的上游变化**:终端 / 浏览器交给宿主内置(DSH 0.1.6 起自带右列终端与浏览器)、工作区路径围栏取消
> (文件读写按进程权限)、右侧栏面板走 DSH 原生右列。
> - 版本线独立于上游:`1.3.0`(fork)。以下文档主体沿用上游 v0.24.1 原文,包名已替换为本 fork 名。
> - **📚 项目百科**(需求分析 → 系统架构 → 功能说明 → 部署指南 → 运维手册 → 故障排查 → 开发与测试):
> <https://gitea.apescale.com/figmar/apes-sidebar/wiki> 源文件在仓库 `docs/wiki/`,
> 改完用 `bash scripts/push-wiki.sh` 一条命令发布。
> [!IMPORTANT]
> **v0.19.0 起接入 DSH 原生侧边栏**:右列就是 DSH 自己的右侧栏,插件把每个 tab 类型注册为原生 tab(不再自绘右侧面板),只保留自绘的底部工作台与开放给所有插件的 `ctx.betterSidebar` 服务。
>
> **v0.24.1 起要求 DSH `0.2.0-rc.1+`**(peer 下限 `^0.2.0-rc.1`)。0.2.0 对本插件所用的全部宿主 API 是**纯增量**(零导出删除、会话格式仍 v4、CLI 与客户端运行时未变),所以这一版没有运行时兼容分支,只把支持线整体前移。**DSH 0.1.7 线的用户请固定 `dsh-apes-sidebar@0.22.1`——caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动预检静默禁用**;按 DSH 版本选插件版本的对照表见[安装](#-安装)。
<!-- Hero -->
<div align="center">
<b style="font-size: 1.15em;">一个服务化的侧边栏框架,一套开箱即用的完整工作台</b><br /><br />
<a href="https://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-apes-sidebar" /></a>
<a href="https://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-apes-sidebar" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml/badge.svg" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.1):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-apes-sidebar"><img alt="插件生态:GitHub topic dsh-apes-sidebar" src="https://img.shields.io/badge/%E6%8F%92%E4%BB%B6%E7%94%9F%E6%80%81-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="底部工作台" src="https://img.shields.io/badge/-底部工作台-4d6bfe" /> <img alt="文件变动" src="https://img.shields.io/badge/-文件变动-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="侧边对话" src="https://img.shields.io/badge/-侧边对话-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
通过 <code>registerTab</code> / <code>registerFileViewer</code> 注册新的侧边栏页面与文件预览器。
</div>
<div align="center">
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
</div>
<div align="center">
<img alt="dsh-apes-sidebar 工作台截图" src="https://github.com/user-attachments/assets/991c4b70-d45a-461f-a8c8-0a28b4218e60" />
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
</div>
## 📑 目录
- [✨ 功能一览](#-功能一览)
- [🚀 安装](#-安装)
- [🖼️ 特性巡礼](#-特性巡礼)
- [💬 社区](#-社区)
- [🆕 最近更新](#-最近更新)
- [⌨️ 快捷键](#-快捷键)
- [🔌 服务化扩展](#-服务化扩展)
- [🛠️ 开发与构建](#-开发与构建)
- [🔐 安全](#-安全) · [⚠️ 已知限制](#-已知限制) · [🖥️ 平台支持](#-平台支持)
- [🌐 插件生态](#-插件生态) · [🤝 参与贡献](#-参与贡献) · [👥 贡献者](#-贡献者) · [🔗 友情链接](#-友情链接)
## ✨ 功能一览
相比 DSH 官方侧边栏,本插件补上的关键能力:
- **🖥️ 可编辑的代码编辑器**:官方文档预览是**只读**的 → 插件保留**可编辑**的 CodeMirror 编辑器(保存、语法高亮、预览切换);Markdown / HTML 也走插件自有渲染(Mermaid 图表安全渲染 + 点击放大、README 级内嵌 HTML、浮动目录大纲、HTML 沙箱预览)
- **🗂️ 增强文件树**:接管内置「文件」页——懒加载目录树、**展开的目录实时 watch 自动刷新**、软链接识别、全局文件名搜索、拖拽上传、悬浮 `@文件` 一键引用进输入框;**Ctrl/Cmd 多选 + Shift 连选**(批量复制路径 / 批量删除)、**Git 变更着色 + 状态字母**(VS Code 同款)、**新建文件夹**、**多选右键「压缩并打包下载」**(服务端流式打 ZIP,无第三方依赖);右键「打开方式」= **DSH 自带 open-in-app**(宿主探测到的本机关联应用 + 文件管理器显示)**+ 插件自研打开方式**(资源管理器 / VS Code / Cursor / Zed / 自定义编辑器 URL 模板、SSH 远端、固定到菜单)两者并存
- **🌿 文件变动**(官方侧栏没有 Git 面板):Git 视角(暂存 / 提交 / 历史 / 工作树与子仓库)+ 本轮 AI 改动视角双合一,统一 diff 渲染(行内字符级高亮、语法着色、敏感内容脱敏);两视角共用一套 28px 行、单一空态/错误通道与吸底提交条
- **🧩 任务管理**(官方没有):子代理拓扑实时预览 + 后台任务清单(退出码 / 实时输出 / 强制终止)
- **💬 侧边对话**(官方没有,beta):Codex 风格侧边线程——继承主会话完整上下文独立运行,可持续追问,一键提升为顶层会话
- **🖥️ 底部工作台**(官方没有):右列交给 DSH 原生右侧栏,插件另加自绘底部工作台(拖拽分栏 / 按会话持久化),可与原生栏同时展开
- **📂 模型打开侧边栏(可选)**:`sidebar_open` 工具让模型主动在侧边栏打开文件 / 文件夹 / 网页
- **🔌 服务化扩展**:`ctx.betterSidebar` 向所有插件开放(`registerTab` / `registerFileViewer`),内置 5 tab + 3 viewer 走同一套 API,已有 **28+ 生态插件**(见「🌐 插件生态」)
- **⚡ 按需加载**:启动只拉 ~325KB 核心,编辑器 / Mermaid / 第三语言词典按需加载 · **🌏 多语言**跟随 DSH · **🔁 会话隔离**按会话持久化布局
## 🚀 安装
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
**支持的 DSH 版本**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.0):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
> 📌 **通道与支持线**:`v0.24.1` 适配 DSH **0.2.0-rc.1+**(0.2.0 首个候选版走 npm `next` 通道,`latest` 仍是 0.1.7-rc.2)。**装 DSH 请写精确版本号**:`npm i -g @deepseek-ai/dsh@0.2.0-rc.1`。**0.1.7 线的用户请固定 `dsh-apes-sidebar@0.22.1`**:0.2.0 是宿主 minor 变更,`^0.1.7-rc.1` 这类 caret 范围在 0.2.0 上会被宿主的启动兼容性预检判定失败、整行静默禁用。
> 🧭 **按你的 DSH 版本选插件版本**:
>
> | 你的 DSH 版本 | 安装命令 | 版本 / peer 声明 |
> | --- | --- | --- |
> | **0.2.0-rc.1+**(含之后的 0.2.0 正式版) | `dsh plugin --profile web add dsh-apes-sidebar@latest` | **0.24.1**,`^0.2.0-rc.1` |
> | **0.1.7-rc.1 ~ 0.1.7-rc.2**(含 0.1.7 正式版;npm `latest` 目前仍是 0.1.7-rc.2) | `dsh plugin --profile web add dsh-apes-sidebar@0.22.1` | **0.22.1**,`^0.1.7-rc.1` |
> | 0.1.7-alpha.1 / 0.1.7-alpha.2 | **没有可装版本**——先把 DSH 升到 rc.1,再跑上一行:<br>`npm i -g @deepseek-ai/dsh@0.1.7-rc.1` | — |
> | 0.1.6-alpha.2 及更早、`0.1.5-rc.*`(含 npm `latest` 的 0.1.5-rc.3) | `dsh plugin --profile web add dsh-apes-sidebar@0.19.1` | **0.19.1**,`^0.1.5-rc.1` |
> | `0.1.5-alpha.2` | `dsh plugin --profile web add dsh-apes-sidebar@0.19.0-alpha.1` | `^0.1.5-alpha.2` |
> | `0.1.2-rc.*` | `dsh plugin --profile web add dsh-apes-sidebar@0.18.1` | `^0.1.2-rc.1` |
> | `0.1.2-alpha.2` | `dsh plugin --profile web add dsh-apes-sidebar@0.18.0-alpha.0` | `^0.1.2-alpha.2` |
> | `0.1.0-rc.8` / `0.1.1` | `dsh plugin --profile web add dsh-apes-sidebar@0.17.1` | `^0.1.0-rc.8` |
>
> 命令里的 `web` 换成你自己的 profile 名即可。**旧版本一律写精确版本号**(`@0.19.1` 而不是 `@latest`),因为 `latest` 会随新正式版前移;反过来也**不要**在 0.1.7 的 alpha 上装 0.19.1,装上只会坏。
```sh
dsh plugin --profile web add dsh-apes-sidebar@latest
```
> 本版不依赖任何需要构建脚本的包(终端连同 `node-pty` 已整体交还 DSH),安装**一步到位**;装完后可在 DSH 自带的 **Plugins 页面**直接启停。
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
**方式二:让 DSH 自己装**——把下面这段提示词发给任意一个 DSH 会话:
```text
帮我安装 dsh-apes-sidebar 插件(DSH 侧边栏工作台),步骤:
1. 执行 dsh plugin --profile web add dsh-apes-sidebar@latest(latest 即当前正式版)
2. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R)
遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。
```
**方式三:一键脚本**——克隆本仓库后执行 `bash scripts/install.sh`(macOS / Linux / Windows Git Bash;Windows 原生环境用 `install.ps1`;`-h` 查看参数),自动完成安装 + bundle 注册(含幂等清理旧的手动挂载行)。
<details>
<summary><b>更新</b></summary>
```sh
dsh plugin --profile web add dsh-apes-sidebar@latest
```
也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
</details>
<details>
<summary><b>常见问题</b></summary>
| 现象 | 原因与解决 |
|---|---|
| 报 `Ignored build scripts` | pnpm 11 拦截了某个传递依赖的构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds` 按提示放行——本插件自身已无构建脚本依赖(终端删除后 `node-pty` 不在依赖里)。 |
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`)。 |
| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 |
| 页面出现**两个侧边栏** | 双挂载。旧的手动挂载行:`~/.dsh/profiles/web/cordis.patch.yml` 还留着 `- insert: ... better-sidebar ...`,删掉那段(同 id 重复挂载 loader 会直接报 `duplicate loader entry id`)。聚合包(如 `@linxin666/dsh-web-ui-all`)以**不同 id** 挂载本包时,0.13.x 起插件自身 bundle patch 会自动退让(检测到已有启用中的同包名挂载就不挂自己),无需手动处理;若仍双挂载,先确认聚合包的 bundle 顺序在 `dsh-apes-sidebar` 之前。 |
| 升级后设置页的值去哪了 | DSH 0.1.7 删除了插件可注册的设置命名空间:偏好现在写在 profile 里本插件的**挂载行**上(默认 entry id `better-sidebar`),不再是 `~/.dsh/settings.yaml`。插件会在首次启动时把旧 `settings.yaml`(已被宿主改名为 `settings.yaml.imported`)里 `dsh-apes-sidebar` 段一次性回迁,只迁移当前 schema 仍声明的字段、且只在该行还没有用户值时执行,不会覆盖升级后新设的值。 |
| 终端无法使用 / 提示 shell 启动失败 | 终端由 **DSH 自身的 `ui-sidebar-terminal`** 提供(本插件不再自带终端与 `node-pty`,也没有终端相关设置项)。遇到问题请查 DSH 侧文档;若报错提到构建脚本,见上一行。 |
| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-apes-sidebar@latest`。 |
</details>
<details>
<summary><b>从源码安装 / 开发(可选,替代 npm 方式)</b></summary>
调试本地改动或跟随开发分支时,把依赖指向本地克隆并自行构建:
```text
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-apes-sidebar": "link:<克隆目录绝对路径>"
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行(这一行的 `config` 就是本插件的设置表单:部署限额 `readLimit` / `mediaLimit` / `uploadLimit` / `listLimit` 加用户偏好字段,设置页写的就是它;不写则全部用 schema 默认值):
- insert:
- id: better-sidebar
name: 'dsh-apes-sidebar'
config:
readLimit: 524288
4. 在 ~/.dsh/profiles/web 执行 pnpm install
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
```
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 npm 上的对应版本(稳定线 `"^0.19.1"`;本线 `"^0.22.1"`)再 `pnpm install`。
</details>
<details>
<summary><b>通过 plugin-registry 安装(可选,与上述二选一)</b></summary>
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
```sh
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
pnpm install && pnpm build
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-apes-sidebar
```
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
</details>
## 🖼️ 特性巡礼
> 以下均为真实界面实拍(每行两张,点击可放大)。
| | |
|---|---|
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、**展开的目录由宿主按目录 watch、改动后自动重列**、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 新建文件夹 / 打开方式:**宿主探测到的系统关联应用**(open-in-app)+ **插件自研目标**(资源管理器 / VS Code / Cursor / Zed / 自定义编辑器,支持 SSH 远端与固定到菜单)/ 复制路径 / 重命名 / 删除)、**Ctrl/Cmd 多选与 Shift 连选**(批量复制路径 / 批量删除 / **压缩并下载**)、**Git 变更按状态着色并带 M/A/D/U 字母**、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · HTML 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移)、**README 级内嵌 HTML**(徽章墙 `<div align=center>`、`<details>` 折叠块内嵌 markdown、表格单元格内联标签——DOMPurify 白名单消毒真实渲染,`<script>` 等活性内容剥除,本地图片经会话媒体路由重写)与**浮动目录大纲**(≥3 标题出现,点击平滑跳转、自动展开折叠块);HTML 走插件自带的**沙箱预览**,并带 `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` 两个宿主没有的逃生门开关。**图片 / PDF / 表格 / Office 不再是插件能力**——那些格式由 DSH 自己的文档预览渲染。</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
| **🖥️ CodeMirror 代码编辑器**<br/><sub>**可编辑**的文本 / 代码编辑器(保存、语法高亮、预览切换)——宿主自己的文档预览是**只读**的,这是插件保留 catch-all viewer 的理由。</sub><br/><div align="center"><img width="420" alt="CodeMirror 代码编辑器" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ 图片 / PDF / 表格 / Office 预览(由 DSH 内置提供)**<br/><sub>这些只读格式由 DSH 自己的 `ui-sidebar-documentpreview` 渲染:宿主侧 Office→PDF 转换、电子表格 worker 表格、图片 / PDF 缩放视口,并**按目录自动刷新**。插件已删除自己的 image / pdf / 下载兜底 viewer,也不再认领这些扩展名。</sub><br/><div align="center"><img width="420" alt="图片内联预览" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
| **💻 终端(由 DSH 内置提供)**<br/><sub>右侧栏终端由 DSH 自己的 `ui-sidebar-terminal` 提供:shell 选择、双击重命名、断线重连、刷新后恢复、主题与对比度跟随。插件不再自带终端实现。<br/><br/>⚠️ **模型侧提示**:插件原来自带的 8 个 `terminal_*` 工具(默认关)是模型唯一的**跨调用持久**终端;上游等价物 `@deepseek-ai/dsh-tool-terminal` 未被任何内置 bundle 默认挂载,若你需要该能力,请在 profile 的 `cordis.patch.yml` 里自行插入一行 `tool-terminal`。</sub><br/><div align="center"><img width="420" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 文件变动:Git 视角 + 本轮文件**<br/><sub>双视角合一:**Git 视角**保留完整源代码管理(暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原、历史、worktree 与子仓库选择);**本轮文件视角**实时折叠会话事件日志,记录模型读 / 写 / 编辑的每个文件(按文件分组、按类型筛选、操作数角标)。点击任意改动在底部**可拖拽预览面板**查看统一 diff——删红 / 增绿 / 改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠——也可一键展开为 VSCode 式独立 diff tab(同一渲染栈)。两个视角共用 36px 头(宿主 SegmentedControl 切换)、28px 行、单一空态与吸底提交条;**Git 视角按目录层级(树)展示变更**——单子目录链压缩成一行、目录行可折叠并带下级变更数、文件行带状态字母与文件图标;git 状态与文件树共享同一份快照,暂存/提交后文件树着色同步刷新。</sub><br/><div align="center"><img width="420" alt="文件变动" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **🌐 外链接管(浏览器视图由 DSH 提供)**<br/><sub>网页 tab 是 DSH 自己的 `ui-sidebar-browser`(多开 / 后退前进刷新 / 地址栏 / 沙箱 iframe),**0.1.7 起只在 desktop profile 挂载**——Web profile 里没有这个 kind。插件保留宿主没有的那一半:**只认领有 tab 类型通过 `urlTarget` 明确声明的链接**(Ctrl/Cmd 点击始终放行),**其余一律放行给宿主**(正文链接的去向由宿主的用户设置 `linkOpening` 决定);按协议分流的三个外链接管设置项已删除,认领成功但目标类型此刻不可用时兜底到 `window.open`。</sub><br/><div align="center"><img width="420" alt="内嵌浏览器" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 任务页:子代理拓扑 + 后台任务**<br/><sub>子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动激活任务页,宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉(可关)。</sub><br/><div align="center"><img width="420" alt="任务页:子代理拓扑" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
| **💬 侧边对话(beta)**<br/><sub>Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。</sub><br/><div align="center"><img width="420" alt="侧边对话(beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div> | **🖥️ DSH 原生右侧栏 + 插件底部工作台**<br/><sub>右列是 DSH 自己的右侧栏:插件把每个 tab 类型注册成原生 tab(含接管内置「文件」页),聊天里的文件点击直接落到原生栏——**宿主自己的文档预览已覆盖的格式由宿主渲染**,插件只认领 Markdown / HTML / 可编辑代码;插件自有底部面板可与其同时展开,拖 Tab 到分栏边缘**拆分**、拖到中间**合并**,高度拖上缘调节;开合按钮在会话头右侧。</sub><br/><div align="center"><img width="420" alt="双工作台(右侧栏 + 底部面板)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div> |
| **⚙️ 声明式设置**<br/><sub>设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`,整份偏好则写在 profile 里本插件的**挂载行**上(DSH 0.1.7 起设置按 Loader entry id 寻址)。</sub><br/><div align="center"><img width="420" alt="声明式设置:侧边卡片" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div> | **📱 移动端**<br/><sub>窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。</sub><br/><div align="center"><img width="360" alt="移动端全宽抽屉" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div> |
## 💬 社区
推荐添加QQ群(577011007)
<div align="center">
<img width="220" alt="微信群二维码" src="https://github.com/user-attachments/assets/5d727d52-7fff-4526-8b36-fb7203fb1dce" />
<img width="220" alt="QQ群二维码" src="https://github.com/user-attachments/assets/9be34629-26ef-4537-aad4-1393c147f81c" />
</div>
## 🆕 最近更新
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.0):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### v0.24.1
> 🐞 **修复版**:修掉两处会让文件树「一操作就整体刷新」的缺陷——① 原生承载面在**每次会话状态写入**时都会重建 `files` 接管项的 slot 注册,宿主因此换掉 slot entry、把整个 tab body 卸载重挂(展开/收起、切 tab、拖底部工作台都会触发)→ 文件树丢失层缓存、滚动位置与目录 watcher 并重列整棵可见树;② 目录实时刷新的重列**先把该层缓存删掉**再请求,导致行被「Loading…」占位替换后重建(构建/格式化/模型跑 bash 时整层闪空)。现在展开只请求被展开那一层、tab body 不再重建、变更只增量更新行。
### v0.23.0
> 🧭 **开发线版本(从未发布到 npm,内容随 v0.24.1 一并发布)**:文件页与文件变动页的整体 UI/UX 重构。文件页新增 **Ctrl/Cmd 与 Shift 多选**、批量条、**Git 变更着色**、**新建文件夹**、拖拽上传重做,**多选可右键「压缩并下载」**(宿主侧带进度的打包任务);「打开方式」改为宿主本机关联应用与插件自研目标**双源并存**(可用设置 `openWithPluginTargets` 强制并存),右键菜单收敛层级;文件变动页**重构为层级树**(Git 视角 + Agent 视角,目录可暂存)。性能:`fs.tree` 实测 **22.8ms → 3.8ms**(10k 条目),新增批量路由 `fs.trees`(挂载/刷新从 N+1 请求变 1 个),菜单打开不再重列目录。⚠️ **行为变更(安全相关)**:**删除工作区路径检测**,插件 fs 路由可读写宿主用户能访问的任意路径(仅受 OS 权限约束)。详见 [CHANGELOG](./CHANGELOG.md#v0230)。
### v0.24.0
> 📦 **支持线前移**:仅适配 DSH **0.2.0-rc.1+**(peer 下限 `^0.2.0-rc.1`,CI 钉 `@deepseek-ai/dsh@0.2.0-rc.1`)。**0.1.7 线(含 npm `latest` 的 0.1.7-rc.2)请固定 v0.22.1(0.1.7 线最后发布的版本)**:caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动兼容性预检整行禁用(实测 `semver.satisfies('0.2.0-rc.1','^0.1.7-rc.1',{includePrerelease:true}) === false`)。
- 📦 **基线整体抬到 `0.2.0-rc.1`**:14 条 DSH peer 与 27 个 `@deepseek-ai/*` devDependencies 同步;`dsh.plugin.json` 的 `engines.dsh` 随之前移。
- 🔍 **实测确认是纯增量**:本插件用到的 19 个宿主包里**零个值导出被删除**;类型面只有 `ui-primitives`(`DisclosureRow` / `TextShimmer` / `Tooltip` 新增可选 prop、overlay 顶部内距)、`dsh-session`(新增 `ToolCallRecovery`)与 `dsh-api-remotes`(新增 product-analytics remote)变化;会话格式仍 v4、`SUBAGENT_DESCRIPTOR_VERSION` 仍 3、`dsh/lib/bin.js` 与 `dsh-client-modules` 运行时逐字未变。因此**没有为 0.1.7 保留任何兼容分支**。
- 🧪 **挂载 lane 与 CI 钉版同步到 `0.2.0-rc.1`**;`tests/market-manifest.spec.ts` 的 peer 形状规则改为钉当前基线 tuple,并记下「caret 跨 minor 必失效」这条教训。
- ⚠️ **生态连带**:`@huanlin/dsh-plugin-better-locale`(可选集成,非本插件依赖面)的 peer 钉在 `^0.1.x` 线上,在 0.2.0 上无法加载,其 5 条 unmet peer 是本次 `pnpm peers check` 唯一残留(上游未适配,与本插件的 14 条 peer 无关)。
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.22.1):0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### v0.22.1
> 📦 **正式版**(npm `latest`):支持线**不变**——仍仅支持 **DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`),0.21.1 / 0.22.0 的用户直接升级即可。修掉两个**真机可复现、单测却全绿**的缺陷;**DSH 0.1.6-alpha.2 及更早请继续固定 v0.19.1**。
- 🐛 **`files` 接管被孤儿化 → 报错刷屏 + 文件树空态**(社区 #770 / #771,官方桌面壳日志实证):客户端条目替换(插件市场更新 / Plugins 页禁用→启用 / HMR 重打)时,`sync()` 的清理循环会把**不属于描述符**的 `files` 接管释放掉、又在同一轮里重建——而重建发生在**已经 inactive** 的插件上下文上:`tabs.register` 建在宿主上下文上照样取走了 id,紧随的 `ctx.slots.inject` 却抛 `cannot create effect on inactive context`,于是 disposer 丢失、该 id 在**整个页面生命周期内不可再注册**(表现就是 `native register files error: … already registered` 刷屏 + 文件树落到宿主空态,只有刷新页面才恢复)。现在清理循环**跳过 `FILES_KIND`**(接管的寿命只由编辑器类型开关与 seat disposer 决定),并且**任何在宿主取走 id 之后失败的注册都会回滚释放**(含已建好的槽位),失败只留一个「下次通知可重试」的状态。修复取自社区 PR #777(@yanzhaohui1999)。
- 🖥️ **macOS 桌面版窗口拖拽 / 双击标题栏缩放失效**(#772):插件宿主是直挂 `body` 的子元素,宿主的 `html[data-platform=darwin] body > :not(#root) { -webkit-app-region: no-drag }` 命中它,而 app-region **无视 `pointer-events`**——铺满视口的面板层把下面每条拖拽带一起抵消(拖第一次还行、之后全失效)。现在 `[data-dsh-apes-sidebar]` / `[data-dsh-panel-host]` / 放大视图 `.mermaidModal` 都用中性值 `initial !important` 退出计算,层内的面板与控件保持 `no-drag`(点击不被吞);合并社区 PR #773 并补齐放大视图这最后一个铺满视口的 body 直挂层。
- ✅ **守住它们**:新增单元用例把「接管不得被通知拆建」「注册失败必须回滚已占用 id 与已建槽位」钉在注册表**事件日志**上(未修复代码上 4/4 红),并新增**部署级回归门** `tests/e2e/native-reload.e2e.ts`(在 npm 0.22.0 上连续 3 次运行全红、修复版连续 3 次全绿(1 个用例重复跑三次));拖拽契约由单元用例 + 挂载 lane 的**真实级联探针**(按宿主规则读计算值)守护。验证:`pnpm test` 122 files / 1293 passed / 9 skipped,`pnpm test:mount` 与 `test:mount:aggregate` 绿。事故记录见 [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](./docs/plans/2026-09-28-native-files-takeover-reload-leak.md)。
### v0.22.0
> 📦 **正式版**(npm `latest`):支持线**不变**——仍仅支持 **DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`),0.21.1 的用户直接升级即可。**DSH 0.1.6-alpha.2 及更早请继续固定 v0.19.1**。
- 🧩 **任务管理页重做成工作流图**(主显示模式):会话树渲染为分层节点 + 贝塞尔连线——拖拽平移、滚轮缩放到光标、内容包围盒居中适配、右下角控制条(图/树切换 + 折叠开关 + 缩放 + 适配);**经典缩进树保留**(键盘可导航),两种模式共享同一个视图模型,折叠状态与团队富化不会视觉漂移。
- 🃏 **双段式节点卡**:上段是类型徽章(主代理 / 子代理 / 成员 / 工作流 / 已完成聚合)+ 相位徽标 + 名称 + 元信息;下段小条是状态点 + 状态词 + **主 Agent 同款合并活动行**(并发工具按类别归并 + 计数 + 在跑那条的细节,措辞取宿主 `chat` 词条)+ 已完成节点的折叠按钮;运行中小条从左到右**完整扫过**(`prefers-reduced-motion` 下关闭)。8px 圆角、层级只用上段极淡底色表达、当前会话加粗 accent 描边。
- 🔀 **工作流 run 入图**:从 `tool-workflow/*` 事件折叠出 run(与官方面板同一批),run 挂在发起代理下、成员 agent 重挂到 run 下并按相位分框、同色相位徽标;catalog 里没有的成员用 run 数据合成占位节点,跑完的 run 仍能看到成员。
- 🗂 **折叠分两组、各自说清是什么**:`✓ N 已完成`(含出错,失败单独报 `出错 N`)与 `N 个待命`(跑完一轮、随时可被叫起来的 teammate)是**两行**;手动折叠按钮永远有效,自动聚合只在待命成员 ≥3 时收空闲成员;聚合卡名字行写「前两个名字 + `+N`」,点聚合全部展开。
- 🪟 **两个常驻浮动窗**(抽出可复用的 `FloatingWindow`):后台任务输出与共享任务详情/编辑——可拖拽、可四边拉伸、内容区自滚动,只靠关闭按钮或 Escape 结束(外部点击 / 失焦 / 锚点离屏都不关);任务窗把余量交给描述区,拉大是给内容更多空间而不是留白,动作行固定在底部。
- 👥 **Agent Teams 任务板(实验层)**:成员富化到对应节点、常驻任务条列出成员与共享任务;状态机跟随宿主(待办 → 认领 → 进行中 → 完成 → 重开)+ 改派 / 编辑 / 两击删除,CAS 过期修订单独提示;成员活动由 `subagents.live` 的 running 叠加。
- 🔄 **后台任务改读宿主客户端 `ctx.jobs`**(推送 roster + 非消费输出流 + kill):删掉自建的 `jobs.list` / `jobs.output` / `jobs.kill` 三条路由与事件回放镜像,彻底不碰模型 `job_output` 游标;输出在常驻浮动窗里流式显示并尾随,代理数 ≥8 时抽屉自动折叠。
- 🛠 **DSH 0.1.7 数据面重写**:上游删掉了 `agentTeams.remoteView` 三个 Remote 方法 → 团队改为读 Lead Session 的 **`agentTeam` Session projection**(推送式,删掉 `teams.view` 路由与 5 秒轮询);写路径两条路由保留,拒绝从「返回联合」变为「抛 `TeamError`」,过期修订映射 409 `team-conflict`。**修掉的真实故障**:0.1.7 上团队条从来不渲染(路由报 `remoteView is not a function`,页面静默无提示)。
- 🐛 **真机抓到、单测全绿的四个缺陷**:逐节点折叠按钮点了没反应(被自动折叠的守卫卡住);「待命」卡片从不画折叠按钮;认领后标签错显「阻塞」;队列任务上「完成」必失败(需先认领)。
- 🎨 **窄屏与手机设置**:按原生右侧栏窄宽重新定档卡片与行距;设置页新增**手机**分组——窄屏(≤768px)不自动弹出新任务页、任务页默认树状图。
> 📜 **更早版本**:完整发布历史见 [CHANGELOG.md](./CHANGELOG.md)(v0.21.1 → v0.12.3)与 [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)。
## ⌨️ 快捷键
| 操作 | 按键 |
|---|---|
| 保存编辑 | `Ctrl/Cmd + S` |
| Git 提交 | `Ctrl + Enter` |
| 关闭 Tab | 鼠标中键 |
| Tab 右键菜单 | 关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签(当前标签组) |
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
## 🔌 服务化扩展
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 5 tab + 3 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。v0.19.0 起新增文件图标注册:`registerFileIcon` 按扩展名(或保留的 `'folder'` / `'folder-open'` 目录扩展名、`exts: []` 全局默认)替换文件树与文件 tab 的图标,彩色 ReactNode 亦可——内置消费、注册即生效,无需自己接线。
完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、原生栏承载面与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。
### ➕ 添加插件(推荐插件目录)
设置页「侧边卡片」两个网格末尾的**虚线卡片**分别打开 Tab / 预览插件弹窗:声明扩展点、「**在 GitHub 上浏览更多插件**」按钮([GitHub topic `dsh-apes-sidebar`](https://github.com/topics/dsh-apes-sidebar))、推荐插件目录(名字 / 仓库 / 简介 / 安装脚本),每个条目「**跳转**」直达仓库、「**复制**」把安装命令写入剪贴板。
**收录新插件**:向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts)(Tab 注册)或 [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts)(文件预览注册)追加一条 `PluginEntry`,并把仓库打上 `dsh-apes-sidebar` topic;数据完整性由 `tests/plugin-list.spec.ts` 守护。
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.2.0-rc.1,走 npm `next` 通道),直接解析、无需令牌
pnpm typecheck # tsc --noEmit
pnpm lint # eslint .(flat config:js + typescript-eslint + react-hooks recommended)
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
pnpm watch # tsdown --watch
```
**Make 薄封装**(`make help` 查看全部目标;package.json 仍是唯一事实源):
```sh
make check # 聚合校验门禁:typecheck → build → test → check:consumer-types(对齐 CI)
make mount # 真机挂载冒烟:build + pack → 安装 Chromium → pnpm test:mount
make clean # 清理 lib/、*.tgz、playwright-report/、test-results/
```
`pnpm check:consumer-types`:对外类型声明面守卫——以浏览器-only 消费者(无 `@types/node`、`skipLibCheck: false`)的视角对构建出的 `lib/types` 做类型检查,需先 `pnpm build`。
**架构**:单 npm 包、host/client 双半结构——host(`src/index.ts`):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/html` 预览路由、`/sidebar/upload` 上传路由,以及两条 WebSocket(`/sidebar/ws/agent-opens` 模型打开推送、`/sidebar/ws/fs-watch` 文件树目录 watch;fs / git / 预览全部会话级 + 信任围栏);client(`src/client/index.tsx`):portal 侧边栏 + 各视图 + 链接接管;状态按会话持久化 localStorage。插件按 DSH 官方规范组织(无 default 导出、双 client bundle),运行期不依赖 npm / checkout(`@deepseek-ai/*` 由 web profile 提供)。
## 🔐 安全
- 路由受 Host 头信任围栏保护(与 `/api` 一致);`fs.write` 原子写入;git 只调 CLI、绝不设置身份
- ⚠️ **v0.23.0 起文件系统路由不再做工作区包含检查**:`fs.tree` / `fs.trees` / `fs.read` / `fs.write` / `fs.rename` / `fs.remove` / `fs.mkdir` / 媒体 / HTML 预览 / `/sidebar/upload` / `archive.build` 能读写**宿主用户可访问的任意路径**(只受 OS 权限约束,`workspaceFence` 开关与 403 分支已删除)——调用方不能再把这些路由当作被围栏保护的接口
- HTML 预览的内容在**不透明源沙箱 iframe** 中渲染(无 `allow-same-origin`/`allow-top-navigation`、`no-referrer`、权限策略全禁);`/sidebar/html` 路由带 CSP `sandbox` + 大小/路径边界
- 设置页可按功能关闭 HTML 预览的沙箱(`htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe`,默认关闭,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用。**网页 tab 的沙箱不再是插件的面**:浏览器视图由宿主提供(desktop profile),其沙箱与导航策略见 DSH 侧文档
## ⚠️ 已知限制
- Git 无 push/pull/fetch;Markdown 预览提供手动刷新按钮,刷新未保存编辑前会确认是否丢弃草稿;文件树只对**已展开**的目录做 watch(折叠的目录不订阅,也不做全工作区递归扫描);工具行内文件打开按钮不可拦截
- **只读预览的格式由宿主决定**:表格 / PDF / 图片 / Office 走 DSH 自己的 `ui-sidebar-documentpreview`,插件只渲染 Markdown / HTML 与可编辑的文本代码;宿主的实现(渲染细节、缩放、刷新时机)随 DSH 版本走
- **浏览器视图只在 desktop profile 存在**:Web profile 没有宿主 `browser` kind,插件也不再自带浏览器 tab,因此网页 tab 只在 desktop profile 可用;登录态 / 第三方 Cookie / `X-Frame-Options` 等限制随宿主实现
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用。未选中会话时,点按弱化开关会显示选择会话提示;选中会话后开关打开全宽抽屉
## 🖥️ 平台支持
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖)。插件不再包含原生依赖(终端与 `node-pty` 已整体交还 DSH 自身),构建只需 Node + pnpm,无需编译工具链。
## 🌐 插件生态
`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 5 tab + 3 viewer 与第三方插件走同一套 API,能力完全对等。
```ts
import type {} from 'dsh-apes-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}))
ctx.effect(() => ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchText(scope, path)),
component: ({ customData }) => <CsvGrid rows={customData} />,
}))
}
```
GitHub topic [`dsh-apes-sidebar`](https://github.com/topics/dsh-apes-sidebar) 下已有 **28+ 生态插件**(持续增长中):
<div align="center">
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="66%" alt="设置页「添加插件」弹窗:推荐插件目录 + 一键复制安装命令" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a><br />
<i>设置页「侧边卡片」内置「添加插件」弹窗:推荐目录 + 一键复制安装命令 + 直达 GitHub topic</i>
</div>
### 📑 Tab 插件(注册侧边栏页面)
<details>
<summary><b>24 个插件(点击展开)</b></summary>
| 插件 | ⭐ | 简介 |
|---|---|---|
| [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | <img alt="stars" src="https://img.shields.io/github/stars/ChenRuoT/dsh-sidebar-qa?style=flat&color=4d6bfe" /> | 划选追问侧边页:类 Codex 侧边提问 / Claude Code `/btw` |
| [fuhefei/dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) | <img alt="stars" src="https://img.shields.io/github/stars/fuhefei/dsh-sentinel?style=flat&color=4d6bfe" /> | 条件驱动唤醒系统:文件 / 命令 / HTTP / 进程 / Webhook 监视,到点唤醒 agent;dock + 侧栏分支 + 全局仪表盘 |
| [Fisfzy/ego-browser](https://github.com/Fisfzy/ego-browser) | <img alt="stars" src="https://img.shields.io/github/stars/Fisfzy/ego-browser?style=flat&color=4d6bfe" /> | Agent 浏览器:i18n 感知的本机浏览器 Tab(`@dsh-external/ego-browser`,装了 better-sidebar 自动注册侧边栏页,未装回退浮动浮窗观察) |
| [jiuge2467/dsh-studio](https://github.com/jiuge2467/dsh-studio) | <img alt="stars" src="https://img.shields.io/github/stars/jiuge2467/dsh-studio?style=flat&color=4d6bfe" /> | 全栈增强工作台:多源 MCP 可视化调试中枢、视觉思考引擎 |
| [Iwctwbh/dsh-flowglass](https://github.com/Iwctwbh/dsh-flowglass) | <img alt="stars" src="https://img.shields.io/github/stars/Iwctwbh/dsh-flowglass?style=flat&color=4d6bfe" /> | 流镜 Flowglass:会话流程图实时可视化(消息 / 工具组 / 子代理分支) |
| [FeatherHunter/dsh-mattpocock-skills-deck](https://github.com/FeatherHunter/dsh-mattpocock-skills-deck) | <img alt="stars" src="https://img.shields.io/github/stars/FeatherHunter/dsh-mattpocock-skills-deck?style=flat&color=4d6bfe" /> | mattpocock/skills 游戏化任务系统:地图拨迷雾、任务栏推进 |
| [GULI-lab/DSH-element-source](https://github.com/GULI-lab/DSH-element-source) | <img alt="stars" src="https://img.shields.io/github/stars/GULI-lab/DSH-element-source?style=flat&color=4d6bfe" /> | 点击页面任意 UI 元素直达 Vue / React / Svelte / Angular 源码并送入会话 |
| [Lzh3070/dsh-file-review-tab](https://github.com/Lzh3070/dsh-file-review-tab) | <img alt="stars" src="https://img.shields.io/github/stars/Lzh3070/dsh-file-review-tab?style=flat&color=4d6bfe" /> | 文件改动审查页:行级红绿 diff + 撤销 + chat 行深链 |
| [yq04/dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-git-remotes?style=flat&color=4d6bfe" /> | Git 远程页:分支 / 上游 / ahead-behind,fetch 可 prune、ff-only pull、确认后 push |
| [ztyhehe/dsh-apes-sidebar-svn](https://github.com/ztyhehe/dsh-apes-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-apes-sidebar-svn?style=flat&color=4d6bfe" /> | SVN 源码管理页:status / diff / log / commit / update / revert / 冲突解决,与内置 Git 面板对称 |
| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | <img alt="stars" src="https://img.shields.io/github/stars/Melody-max114/dsh-excel-panel?style=flat&color=4d6bfe" /> | Excel 编辑页:xlsx 预览 / 编辑、公式实时计算、合并单元格、保存回原文件 |
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch 搜索结果引用卡片:搜索词、来源摘要、关键词高亮 |
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | 全局文档面板:随身 Markdown 笔记,任何工作区随时可读 |
| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | <img alt="stars" src="https://img.shields.io/github/stars/lnyuqian/dsh-skill-sidebar?style=flat&color=4d6bfe" /> | 技能面板:扫描本机技能目录,4-6 字功能短语 + 一键复制调用 + 置顶 |
| [g-yixuan/dsh-sidenote](https://github.com/g-yixuan/dsh-sidenote) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidenote?style=flat&color=4d6bfe" /> | Codex 风格侧边对话 + 划选引用注释(轻量消费插件) |
| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-ssh-tunnel?style=flat&color=4d6bfe" /> | 多主机 SSH 隧道 + SSH 管理器页 |
| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-git-forge?style=flat&color=4d6bfe" /> | GitHub / Gitea 账号、项目授权与推送策略 |
| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | <img alt="stars" src="https://img.shields.io/github/stars/YesSanSan/dsh-conversation-outline?style=flat&color=4d6bfe" /> | 对话大纲页:按轮次结构化展示、一键跳转、LLM 一句话标题 |
| [Wulabalabo/dsh-sidebar-Explorer-Plus](https://github.com/Wulabalabo/dsh-sidebar-Explorer-Plus) | <img alt="stars" src="https://img.shields.io/github/stars/Wulabalabo/dsh-sidebar-Explorer-Plus?style=flat&color=4d6bfe" /> | 文件管理页:上传 / 移动 / 删除 / 重命名 / 新建文件夹(补全写操作) |
| [yq04/dsh-turn-review](https://github.com/yq04/dsh-turn-review) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-turn-review?style=flat&color=4d6bfe" /> | 本轮审查:逐回合审查 agent 改动 |
| [Ghz114514/dsh-refpics](https://github.com/Ghz114514/dsh-refpics) | <img alt="stars" src="https://img.shields.io/github/stars/Ghz114514/dsh-refpics?style=flat&color=4d6bfe" /> | Pinterest 风格参考图搜索:瀑布流、侧栏画板、下载与 Eagle 收藏 |
| [yzlin499/dsh-yzlin499-easy-plugins](https://github.com/yzlin499/dsh-yzlin499-easy-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/yzlin499/dsh-yzlin499-easy-plugins?style=flat&color=4d6bfe" /> | 实用小工具集(毛坯房 DSH 友好) |
| [dong-victor/dsh-apes-sidebar-starter](https://github.com/dong-victor/dsh-apes-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-starter?style=flat&color=4d6bfe" /> | 运行配置页:IDEA 式 Run/Debug 配置(npm / springboot / python / custom)——一键启动、历史保存、WebSocket 实时日志(ANSI 彩色)、多实例并行、进程树跨平台杀死 |
| [baosfeng/my-dsh-plugins](https://github.com/baosfeng/my-dsh-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/baosfeng/my-dsh-plugins?style=flat&color=4d6bfe" /> | 个人多插件合集(`dsh-file-activity`):侧边栏文件活动页——记录文件读取 / 新增 / 修改历史与统计,按文件夹平铺,点击用原生预览打开 |
| [Hoemr/dsh-better-overleaf](https://github.com/Hoemr/dsh-better-overleaf) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-better-overleaf?style=flat&color=4d6bfe" /> | Overleaf 标签页:直连 CDP 浏览器登录(支持第三方 Chromium)、项目切换、工作区下 overleaf/ 目录本地 git 镜像与双向同步 |
</details>
### 🖼️ 预览插件(注册文件预览器)
<details>
<summary><b>3 个插件(点击展开)</b></summary>
| 插件 | ⭐ | 简介 |
|---|---|---|
| [HuanLinOTO/dsh-plugin-better-sidebar-plugin-office](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) | <img alt="stars" src="https://img.shields.io/github/stars/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office?style=flat&color=4d6bfe" /> | Office 三件套预览(.docx / .xlsx / .pptx),独立 bundle 瘦身主体(官方推荐目录收录) |
| [zemul/dsh-video-preview](https://github.com/zemul/dsh-video-preview) | <img alt="stars" src="https://img.shields.io/github/stars/zemul/dsh-video-preview?style=flat&color=4d6bfe" /> | 视频内联预览:.mp4 / .webm / .mov / .mkv / .avi,自带 /video 路由支持 HTTP Range 拖进度条 |
| [dong-victor/dsh-apes-sidebar-jupyter](https://github.com/dong-victor/dsh-apes-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-jupyter?style=flat&color=4d6bfe" /> | `.ipynb` 可运行 Notebook 视图:懒启动 Python kernel、流式输出、保存回写 |
</details>
### 🧰 增强与工具
<details>
<summary><b>3 个插件(点击展开)</b></summary>
| 插件 | ⭐ | 简介 |
|---|---|---|
| [eg-bole/dsh-apes-sidebar-icons](https://github.com/eg-bole/dsh-apes-sidebar-icons) | <img alt="stars" src="https://img.shields.io/github/stars/eg-bole/dsh-apes-sidebar-icons?style=flat&color=4d6bfe" /> | VSCode 风格文件 / 文件夹图标主题:文件树与编辑器 Tab 换上熟悉的开发环境图标(vscode-icons 移植,纯 DOM 覆盖零侵入,安装 / 卸载零残留) |
| [dong-victor/dsh-apes-sidebar-terminal-plus](https://github.com/dong-victor/dsh-apes-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | 终端增强:内嵌 Nerd Font 图标字体、修复 xterm 图标渲染、稳定终端 cwd |
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | 预览划选增强:侧边栏预览里划选文本 → 浮动「发送到会话」 |
| [Hoemr/dsh-quicklook](https://github.com/Hoemr/dsh-quicklook) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-quicklook?style=flat&color=4d6bfe" /> | QuickLook 式空格预览:活动文件标签页按 Space 全尺寸查看图片 / PDF / 文本,Space 或 Esc 关闭 |
</details>
> 📣 **上架你的插件**:给仓库打上 `dsh-apes-sidebar` topic 即出现在 [topic 页](https://github.com/topics/dsh-apes-sidebar);再向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提一条 `PluginEntry` PR,即可进入设置页内置推荐目录(数据完整性由 `tests/plugin-list.spec.ts` 守护)。
## 🤝 参与贡献
- **代码改动走 PR**:`feat/*` / `fix/*` 分支开发 → `gh pr create`;纯文档改动可直接推 main
- **收录生态插件**:给仓库打 `dsh-apes-sidebar` topic + 向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提 PR
- **提交前自检**:`pnpm typecheck && pnpm build && pnpm test`(或 `make check` 一键聚合;CI 另有 npm 打包 → 真实挂载 → 无头渲染门禁 `pnpm test:mount`,及聚合双挂载回归 `pnpm test:mount:aggregate`)
- 仓库工作规范见 [`AGENTS.md`](./AGENTS.md)(含仓库硬约束与 CI 说明)
## 👥 贡献者
感谢每一位贡献者:
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/graphs/contributors">
<img src="https://contrib.rocks/image?repo=omdsh-dev/DSH-better-sidebar" alt="贡献者" />
</a>
## 🔗 友情链接
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI):Claude Code 风格全屏交互终端插件——像素鲸鱼顶栏、实时工作状态行、思考流式展开、双击 Esc 回滚、上下文进度条 + TPS 仪表,npm 一键安装
- [dshfind 插件超市](https://dshfind.com/zh/plugins):三方插件市场——GitHub topic `dsh-plugin` 下的公开仓库清单,每日同步 star、贡献者与增长数据
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop):为 DeepSeek Harness 生态打造的现代化桌面端——无需配置 Node.js 或执行命令即可启动和管理本地 Harness 服务;[官网](https://www.dshdesktop.cn)
---
<div align="center">
<sub>MIT License · Built for the <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> ecosystem · 在 <a href="https://github.com/topics/dsh-apes-sidebar">topic dsh-apes-sidebar</a> 发现更多生态插件</sub>
</div>
-429
View File
@@ -1,429 +0,0 @@
# dsh-apes-sidebar
> [!IMPORTANT]
> **Built on DSH's native sidebar API** (since v0.19.0): the right column *is* DSH's own sidebar — the plugin registers every tab type as a native tab (no right panel of its own anymore) and keeps only its self-drawn bottom workbench and the `ctx.betterSidebar` service open to every plugin.
>
> **Since v0.24.1 the host support floor is DSH `0.2.0-rc.1+`** (peer floor `^0.2.0-rc.1`). Everything this plugin consumes from 0.2.0 is **purely additive** (zero removed exports, session format still v4, CLI and client runtime byte-identical), so this version carries no runtime compatibility branch — it only moves the support line. **Hosts on the 0.1.7 line should pin `dsh-apes-sidebar@0.22.1` — a caret never spans a minor bump, and `^0.1.7-rc.1` is silently disabled by the 0.2.0 startup preflight**; the DSH-to-plugin version table is in [Installation](#-installation).
<!-- Hero -->
<div align="center">
<b style="font-size: 1.15em;">A service-oriented sidebar framework, and a complete workbench out of the box</b><br /><br />
<a href="https://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-apes-sidebar" /></a>
<a href="https://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-apes-sidebar" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml/badge.svg" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.24.1): 0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-apes-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-apes-sidebar" src="https://img.shields.io/badge/plugin%20ecosystem-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit &amp; preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Bottom workbench" src="https://img.shields.io/badge/-Bottom%20workbench-4d6bfe" /> <img alt="Changes" src="https://img.shields.io/badge/-Changes-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Side Chat" src="https://img.shields.io/badge/-Side%20Chat-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
register new sidebar pages and file viewers via <code>registerTab</code> / <code>registerFileViewer</code>.
</div>
<div align="center">
🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a>
</div>
<div align="center">
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
<img alt="dsh-apes-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
</div>
## 📑 Contents
- [✨ Features](#-features)
- [🚀 Installation](#-installation)
- [🖼️ Feature Tour](#-feature-tour)
- [💬 Community](#-community)
- [🆕 Recent Updates](#-recent-updates)
- [⌨️ Keyboard Shortcuts](#-keyboard-shortcuts)
- [🔌 Service API](#-service-api)
- [🛠️ Development & Build](#-development--build)
- [🔐 Security](#-security) · [⚠️ Known Limitations](#-known-limitations) · [🖥️ Platform Support](#-platform-support)
- [🌐 Plugin Ecosystem](#-plugin-ecosystem) · [🤝 Contributing](#-contributing) · [👥 Contributors](#-contributors) · [🔗 Friends](#-friends)
## ✨ Features
What this plugin adds on top of DSH's stock sidebar:
- **✏️ Editable editor**: the host's document preview is **read-only** → the plugin keeps an **editable** CodeMirror editor (save, syntax highlighting, preview toggle); Markdown / HTML also render through the plugin's own pipeline (Mermaid diagrams with safe rendering + click-to-zoom, README-level inline HTML, floating table of contents, sandboxed HTML preview)
- **🗂️ Enhanced file tree**: takes over the built-in Files page — lazy-loading tree, **expanded directories watched live and auto-refreshed**, symlink awareness, global filename search, drag-and-drop upload, hover `@file` to drop a reference into the input box; **Ctrl/Cmd multi-select and Shift range-select** (copy or delete a batch), **VS Code-style git status colors + M/A/D/U letters**, **new folder**, **multi-select “Zip and download”** (the host streams a ZIP built with no third-party dependency), and an “Open with” menu that COMBINES DSH’s own open-in-app (the host’s probed OS handlers + file-manager reveal) with the plugin’s own targets (file manager / VS Code / Cursor / Zed / custom editor URL templates, SSH remotes, pin-to-menu)
- **🌿 Changes** (no Git panel in the stock sidebar): two lenses in one tab — **Git** (diff / history / stage·commit·revert) and **This Session** (every file the model touched) — with a unified diff renderer (intra-line character highlights, syntax coloring, secret redaction); the two lenses now share one 36px header (host SegmentedControl), 28px rows, a single empty state and a sticky commit bar, and the git snapshot is shared with the file tree so staging recolors the tree too
- **🧩 Background Tasks** (absent upstream): agent topology preview + background task list (exit codes / live output / force-kill)
- **💬 Side Chat** (absent upstream, beta): Codex-style side threads — inheriting the full parent context, running independently, promotable to a top-level session
- **🖥️ Bottom workbench** (absent upstream): the right column belongs to DSH's native right sidebar; the plugin adds its own bottom workbench (drag-to-split panes, per-session persistence) that coexists with the native bar
- **📂 Model-driven sidebar opens (opt-in)**: the `sidebar_open` tool lets the model actively open files / folders / web pages in the sidebar
- **🔌 Service API**: `ctx.betterSidebar` is open to every plugin (`registerTab` / `registerFileViewer`); the built-in 5 tabs + 3 viewers go through the same API, and **28+ ecosystem plugins** already build on it (see "🌐 Plugin Ecosystem")
- **⚡ On-demand loading**: ~325KB core at startup, editor / Mermaid / third-language dictionaries load on demand · **🌏 i18n** follows DSH's language · **🔁 Session isolation** persists layout per session
## 🚀 Installation
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
**Supported DSH versions**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.24.0): 0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
> 📌 **Channel and support line**: `v0.24.1` targets DSH **0.2.0-rc.1+** (0.2.0's first candidate rides npm's `next` dist-tag; `latest` is still 0.1.7-rc.2). **Pin the DSH version exactly**: `npm i -g @deepseek-ai/dsh@0.2.0-rc.1`. **Hosts on the 0.1.7 line should pin `dsh-apes-sidebar@0.22.1`**: 0.2.0 is a host minor bump, and caret ranges such as `^0.1.7-rc.1` fail the host's startup compatibility preflight on 0.2.0, which disables the whole profile row silently.
> 🧭 **Pick the plugin version that matches your DSH**:
>
> | Your DSH | Install command | Version / peer declared |
> | --- | --- | --- |
> | **0.2.0-rc.1+** (including a later 0.2.0 stable) | `dsh plugin --profile web add dsh-apes-sidebar@latest` | **0.24.1**, `^0.2.0-rc.1` |
> | **0.1.7-rc.1 – 0.1.7-rc.2** (and a later 0.1.7 stable; npm `latest` is still 0.1.7-rc.2) | `dsh plugin --profile web add dsh-apes-sidebar@0.22.1` | **0.22.1**, `^0.1.7-rc.1` |
> | 0.1.7-alpha.1 / 0.1.7-alpha.2 | **nothing to install** — move DSH to rc.1 first, then run the row above:<br>`npm i -g @deepseek-ai/dsh@0.1.7-rc.1` | — |
> | 0.1.6-alpha.2 and earlier, `0.1.5-rc.*` (including the 0.1.5-rc.3 that is npm's `latest`) | `dsh plugin --profile web add dsh-apes-sidebar@0.19.1` | **0.19.1**, `^0.1.5-rc.1` |
> | `0.1.5-alpha.2` | `dsh plugin --profile web add dsh-apes-sidebar@0.19.0-alpha.1` | `^0.1.5-alpha.2` |
> | `0.1.2-rc.*` | `dsh plugin --profile web add dsh-apes-sidebar@0.18.1` | `^0.1.2-rc.1` |
> | `0.1.2-alpha.2` | `dsh plugin --profile web add dsh-apes-sidebar@0.18.0-alpha.0` | `^0.1.2-alpha.2` |
> | `0.1.0-rc.8` / `0.1.1` | `dsh plugin --profile web add dsh-apes-sidebar@0.17.1` | `^0.1.0-rc.8` |
>
> Swap `web` for your own profile name. **Older versions are pinned exactly** (`@0.19.1`, not `@latest`), because `latest` moves forward with each new stable cut; conversely, do **not** install 0.19.1 on a 0.1.7 alpha — it would simply break.
```sh
dsh plugin --profile web add dsh-apes-sidebar@latest
```
> The plugin depends on no package that needs a build script (the terminal and `node-pty` went back to DSH wholesale), so installing is **one step**; once installed you can enable / disable it on DSH's own **Plugins page**.
Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart).
**Or let DSH install it for you** — paste this prompt into any DSH session:
```text
Install the dsh-apes-sidebar plugin (a sidebar workbench for DSH):
1. Run: dsh plugin --profile web add dsh-apes-sidebar@latest (`latest` is the current stable)
2. When done, remind me to hard-refresh the browser (Cmd/Ctrl+Shift+R)
If anything fails, check the troubleshooting table in the README at https://github.com/omdsh-dev/DSH-better-sidebar
```
**Option 3: one-shot script** — from a clone of this repo, run `bash scripts/install.sh` (macOS / Linux / Windows Git Bash; native Windows uses `install.ps1`; `-h` for options) — it installs and registers the bundle in one go (including idempotent cleanup of an old manual mount row).
<details>
<summary><b>Updating</b></summary>
```sh
dsh plugin --profile web add dsh-apes-sidebar@latest
```
or bump the version in `~/.dsh/profiles/web/package.json` to the matching npm version (`"^0.22.1"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
</details>
<details>
<summary><b>Troubleshooting</b></summary>
| Symptom | Cause & fix |
|---|---|
| `Ignored build scripts` | pnpm 11 blocked a **transitive** dependency's build script. Run `pnpm approve-builds` (no `--all`) in the profile directory (`~/.dsh/profiles/web`) and follow its prompts — the plugin itself has no build-script dependency (`node-pty` left with the terminal). |
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`). |
| "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. |
| Two sidebars on the page | Double-mount. Old hand-written line: `~/.dsh/profiles/web/cordis.patch.yml` still has `- insert: ... better-sidebar ...` — delete it (a same-id duplicate mount makes the loader fail loudly with `duplicate loader entry id`). When an aggregate bundle (e.g. `@linxin666/dsh-web-ui-all`) mounts this package under a **different** id, the plugin's own bundle patch backs off automatically since 0.13.x (it detects an already-enabled mount of the same package name and does not mount itself) — no manual fix needed; if it still double-mounts, make sure the aggregate bundle precedes `dsh-apes-sidebar` in `dsh.profile.bundles`. |
| Where did my settings go after upgrading? | DSH 0.1.7 removed the registrable settings namespace: preferences now live on this plugin's **mount row** in the profile (entry id `better-sidebar` by default), not in `~/.dsh/settings.yaml`. On first boot the plugin imports the `dsh-apes-sidebar` section of the old `settings.yaml` (renamed `settings.yaml.imported` by the host) exactly once — only fields the current schema still declares, and only while that row has no user values yet, so it never overwrites values set after the upgrade. |
| Terminal unusable / shell fails to start | The terminal comes from **DSH's own `ui-sidebar-terminal`** (this plugin no longer ships a terminal or `node-pty`, and has no terminal settings). Consult DSH's own docs for problems; if the error mentions build scripts, see the row above. |
| `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-apes-sidebar@latest`. |
</details>
<details>
<summary><b>Install from source / develop (optional — alternative to the npm flow)</b></summary>
To debug local changes or track the dev branch, point the dependency at a local clone and build it yourself:
```text
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-apes-sidebar": "link:<absolute path of the clone>"
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml (this row's `config` IS this plugin's settings form: the deployment limits `readLimit` / `mediaLimit` / `uploadLimit` / `listLimit` plus the user-preference fields — that is what the settings page writes; omit it and every field falls back to its schema default):
- insert:
- id: better-sidebar
name: 'dsh-apes-sidebar'
config:
readLimit: 524288
4. Run pnpm install in ~/.dsh/profiles/web
5. Restart DSH and hard-refresh
```
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore the matching npm version (`"^0.22.1"`) and re-run `pnpm install`.
</details>
<details>
<summary><b>Install via plugin-registry (optional — use either this or the main flow)</b></summary>
Prerequisite: DSH with [plugin-registry](https://github.com/dsh-external/plugin-registry) integrated (`dsh registry` available). **Enabling both channels double-mounts** (the Node half loads twice, the page gets two sidebars).
```sh
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
pnpm install && pnpm build
node scripts/package-registry.mjs # assemble the registry/ staging (manifest + artifacts + README, not committed)
dsh registry install ./registry # install (disabled by default)
dsh registry enable dsh-external/dsh-apes-sidebar
```
Update: `git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`. Remove the other channel's mount before switching.
</details>
## 🖼️ Feature Tour
> Below are real UI screenshots (two per row; click to zoom).
| | |
|---|---|
| **🗂️ File Workbench: Explorer**<br/><sub>Two explorer modes: embedded in the file preview / standalone file tree. Lazy-loading directory tree whose **expanded directories the host watches per directory and re-lists on change**, symlinks classified by target kind (directory links expand, dangling links flagged), global filename search, file/folder upload buttons plus drag-drop upload, context menu (open in new tab / open to the side / new folder / Open with — the host’s probed OS handlers PLUS the plugin’s own targets (file manager / VS Code / Cursor / Zed / custom editors, SSH remotes, pin-to-menu) / copy paths / rename / delete), **Ctrl/Cmd multi-select with Shift range-select** (batch copy paths, batch delete, **zip and download**), **git changes colored by status with M/A/D/U letters**, and a hover `@file` button that references a file straight into the composer.</sub><br/><div align="center"><img width="420" alt="File explorer" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Inline Preview: Markdown · HTML**<br/><sub>The Markdown preview renders **Mermaid diagrams** (strict-mode safe rendering + a second sanitize pass; click a diagram for a zoom modal with wheel-zoom and drag-pan), **README-level inline HTML** (badge walls `<div align=center>`, `<details>` blocks nesting markdown, inline tags in table cells — DOMPurify-sanitized, `<script>` stripped, local media rewritten to the session media route) and a floating **table of contents** (appears with ≥3 headings, smooth-scroll jumping, auto-expanding folded blocks); HTML uses the plugin's own **sandboxed preview** with the two escape hatches the host does not have (`htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe`). **Images / PDF / spreadsheets / Office are no longer a plugin capability** — the host's own document preview renders those formats.</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid preview" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
| **🖥️ CodeMirror editor**<br/><sub>An **editable** text / code editor (save, syntax highlighting, preview toggle) — the host's own document preview is **read-only**, which is exactly why the plugin keeps its catch-all viewer.</sub><br/><div align="center"><img width="420" alt="CodeMirror editor" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ Images / PDF / spreadsheets / Office (provided by DSH)**<br/><sub>These read-only formats render in DSH's own `ui-sidebar-documentpreview`: host-side Office→PDF conversion, worker-backed spreadsheet tables, image / PDF zoom viewports, and **per-directory auto-refresh**. The plugin deleted its own image / pdf / download-fallback viewers and no longer claims those extensions.</sub><br/><div align="center"><img width="420" alt="Inline image preview" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
| **💻 Terminal (provided by DSH)**<br/><sub>The right-Sidebar terminal is provided by DSH's own `ui-sidebar-terminal`: shell picker, double-click rename, reconnect, restore after reload, theme and contrast following. The plugin no longer ships xterm + node-pty.<br/><br/>⚠️ **Model-side caveat**: the plugin's own 8 `terminal_*` tools (off by default) were the model's only **cross-call persistent** terminal; the upstream equivalent `@deepseek-ai/dsh-tool-terminal` is not mounted by any shipped bundle, so if you need that capability, add a `tool-terminal` row to your profile's `cordis.patch.yml` yourself.</sub><br/><div align="center"><img width="420" alt="Terminal" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Changes: Git lens + This-Session lens**<br/><sub>Two lenses on "what changed?": the **Git lens** keeps the full source-control surface (stage / unstage / commit (`Ctrl+Enter`) / revert, history, worktree and child-repo selectors); the **This Session** lens folds the session event log live, recording every file the model read / wrote / edited (grouped by file, kind-filtered, op-count badge). Both lenses now share one 36px header, 28px rows, a single empty state and a sticky commit bar; the **Git lens lists changes as a directory tree** (single-child chains collapsed into one row, collapsible folders with descendant counts, status letters and file icons on the leaves); the git snapshot is shared with the file tree, so staging recolors the tree as well. Clicking any change previews it in the **draggable bottom pane** with the unified diff — del red / add green / mod-blue pairing + intra-line character highlights + syntax coloring + context folding — or expands into a VSCode-style dedicated diff tab (same rendering stack).</sub><br/><div align="center"><img width="420" alt="Changes" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **🌐 External-link takeover (browser view provided by DSH)**<br/><sub>Web tabs are DSH's own `ui-sidebar-browser` (multiple tabs / back-forward-reload / address bar / sandboxed iframe), mounted **only in the desktop profile since 0.1.7** — the Web profile has no such kind. The plugin keeps the half the host does not provide: it takes over **only links a tab type explicitly claims through `urlTarget`** (Ctrl/Cmd-clicks always pass through) and **lets everything else through to the host** (whose `linkOpening` user setting decides where prose links go); the three protocol-routing settings are gone, and a claim whose target type is unavailable at open time falls back to `window.open`.</sub><br/><div align="center"><img width="420" alt="Browser" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 Tasks: Agent Topology + Background Jobs**<br/><sub>Live subagent-tree topology (run states, batched live previews) plus the background-jobs list (exit codes / live output / force-kill); new subagents / jobs can auto-activate the Tasks page, expanding the sidebar on wide viewports without forcing narrow full-screen drawers open (configurable).</sub><br/><div align="center"><img width="420" alt="Tasks: subagent topology" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
| **💬 Side Chat (beta)**<br/><sub>Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.</sub><br/><div align="center"><img width="420" alt="Side Chat (beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div> | **🖥️ DSH's native right sidebar + plugin bottom workbench**<br/><sub>The right column is DSH's own sidebar: the plugin registers every tab type as a native tab (including taking over the built-in Files page), so clicking a file in the chat lands there directly — **formats the host's own document preview already covers are rendered by the host**, and the plugin claims only Markdown / HTML / editable code; the plugin's own bottom panel can stay open alongside it — drag a tab to a pane edge to **split**, to the middle to **merge**, drag the top edge to resize; the toggle lives in the session header.</sub><br/><div align="center"><img width="420" alt="Dual workbench (right sidebar + bottom panel)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div> |
| **⚙️ Declarative Settings**<br/><sub>The "Side card" section in DSH settings: one small card per tab / viewer with an independent toggle (highlighted enabled state + brand switch); secondary settings open from the "Feature settings" strip at the card bottom (switch / text / number / select rows); plugin-owned settings persist under `pluginSettings`, while the whole preference set lives on this plugin's **mount row** in the profile (since DSH 0.1.7 settings are addressed by Loader entry id).</sub><br/><div align="center"><img width="420" alt="Declarative settings: side cards" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div> | **📱 Mobile**<br/><sub>On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.</sub><br/><div align="center"><img width="360" alt="Mobile full-width drawer" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div> |
## 💬 Community
WeChat / QQ group QR codes will live here. After uploading the QR images (drag them into any issue/comment to get a `user-attachments` link), replace `src` below and uncomment:
<div align="center">
<!-- WeChat group QR code
<img width="220" alt="WeChat group QR code" src="https://github.com/user-attachments/assets/REPLACE_ME" />
-->
<!-- QQ group QR code
<img width="220" alt="QQ group QR code" src="https://github.com/user-attachments/assets/REPLACE_ME" />
-->
</div>
## 🆕 Recent Updates
<div align="center">
<a href="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336"><img width="33%" alt="Sidebar" src="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336" /></a>
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="33%" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
</div>
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.24.0): 0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
### v0.24.1
> 🐞 **Fix release**: two defects made the file tree rebuild itself on every interaction — (1) the native surface re-created the `files` takeover’s slot registration on EVERY session-state write (folder toggles, tab switches, bottom-workbench drags), so the host swapped the slot entry and unmounted/remounted the whole tab body: the explorer lost its level cache, scroll position and directory watcher and re-listed the entire visible tree; (2) the live-refresh re-list deleted a level from the cache before refetching, replacing its rows with a loading placeholder (a build, a formatter or the model’s own bash made whole folders blink). Now a toggle asks only for the toggled level, the tab body survives, and disk changes update rows in place.
### v0.23.0
> 🧭 **Development-line version (never published to npm; its content ships with v0.24.1)**: the Files and Changes pages rebuilt. The Files page gains **Ctrl/Cmd and Shift multi-select**, a batch bar, **Git change coloring**, **new-folder creation**, reworked drag-and-drop upload and **"Zip and download" on a multi-selection** (a host-side archiving task with progress); "Open with" keeps the host-detected local apps and the plugin's own targets **side by side** (setting `openWithPluginTargets`), and the context menu was restructured; the Changes page is **a hierarchy tree** again (Git view + Agent view, directory staging). Performance: `fs.tree` measured **22.8ms → 3.8ms** (10k entries) with the new batch route `fs.trees` (mount/refresh: N+1 requests → one), and opening the context menu no longer re-lists directories. ⚠️ **Behavior change (security-relevant)**: the **workspace path fence was removed** — the plugin's fs routes can read and write any path the host user can reach (OS permissions only). Details in [CHANGELOG_EN](./CHANGELOG_EN.md#v0230).
### v0.24.0
> 📦 **Support line moved**: DSH **0.2.0-rc.1+** only (peer floor `^0.2.0-rc.1`, CI pins `@deepseek-ai/dsh@0.2.0-rc.1`). **The 0.1.7 line (including npm `latest` = 0.1.7-rc.2) should pin v0.22.1 (the last published release on the 0.1.7 line)**: a caret never spans a minor bump, so `^0.1.7-rc.1` fails the 0.2.0 host's startup compatibility preflight and the whole row is disabled (measured: `semver.satisfies('0.2.0-rc.1','^0.1.7-rc.1',{includePrerelease:true}) === false`).
- 📦 **Baseline lifted to `0.2.0-rc.1`**: 14 DSH peers and 27 `@deepseek-ai/*` devDependencies move together; `dsh.plugin.json`'s `engines.dsh` follows.
- 🔍 **Measured as purely additive**: across the 19 host packages this plugin consumes, **zero value exports were removed**; the only type-surface changes are `ui-primitives` (optional props on `DisclosureRow` / `TextShimmer` / `Tooltip`, plus the overlay top inset), `dsh-session` (new `ToolCallRecovery`) and `dsh-api-remotes` (a new product-analytics remote). Session format stays v4, `SUBAGENT_DESCRIPTOR_VERSION` stays 3, and `dsh/lib/bin.js` plus the `dsh-client-modules` runtime are byte-identical — so **no compatibility branch for 0.1.7 is kept**.
- 🧪 **Mount lane and CI pins follow to `0.2.0-rc.1`**; the peer-shape rule in `tests/market-manifest.spec.ts` now pins the current baseline tuple and records the "a caret never spans a minor bump" lesson.
- ⚠️ **Ecosystem side effect**: the optional `@huanlin/dsh-plugin-better-locale` integration pins the `^0.1.x` line and cannot load on 0.2.0; its 5 unmet peers are the only residue in `pnpm peers check` (upstream has not adapted; unrelated to this plugin's own 14 peers).
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.22.1): 0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
### v0.22.1
> 📦 **Stable release** (npm `latest`): the support line is **unchanged** — DSH **0.1.7-rc.1+** only (peer floor `^0.1.7-rc.1`, CI pins `@deepseek-ai/dsh@0.1.7-rc.1`), so 0.21.1 / 0.22.0 users can upgrade straight away. Two defects that were **reproducible on a real host while every unit test stayed green** are fixed; **hosts on DSH 0.1.6-alpha.2 or earlier still pin v0.19.1**.
- 🐛 **The `files` takeover could be orphaned → error spam + an empty tree** (community issues #770 / #771, proven from the desktop shell's own log): during an in-page client entry replacement (plugin-market update, Plugins page disable→enable, HMR rebundle), `sync()`'s drop loop released the `files` takeover — which is **not a descriptor** — and re-created it in the same pass. That re-creation ran on an **already inactive** plugin context: `tabs.register` lives on the HOST context and took the id anyway, while the `ctx.slots.inject` right after it threw `cannot create effect on inactive context`, so the disposer was lost and the id became **unregistrable for the rest of the page's life** (`native register files error: … already registered` spam plus the Files window falling back to the host's empty state until a page refresh). The drop loop now **skips `FILES_KIND`** (the takeover lives and dies by the editor-type switch and the seat disposer only), and **any registration that fails after the host took the id is rolled back** (including slots already installed), leaving only a failure the next notification can retry. Taken from community PR #777 (@yanzhaohui1999).
- 🖥️ **macOS desktop: window drag / double-click-title zoom stopped working** (#772): the plugin host is a direct `body` child, so the shell's `html[data-platform=darwin] body > :not(#root) { -webkit-app-region: no-drag }` applied to it — and app-region **ignores `pointer-events`** — leaving the viewport-sized panel layer cancelling every drag strip beneath it (the first drag worked, later ones did not). `[data-dsh-apes-sidebar]`, `[data-dsh-panel-host]` and the zoom modal `.mermaidModal` now opt out with the neutral `initial !important`, while panels and their controls stay `no-drag` so clicks are never swallowed. Merges community PR #773 and finishes the job for the zoom modal, the last viewport-sized body child.
- ✅ **They stay fixed**: new unit cases pin "a notification must not tear the takeover down" and "a failed registration must release the type and the slots it installed" to the registry's **event log** (4/4 red on the unfixed code), plus a **deployment-level regression gate** `tests/e2e/native-reload.e2e.ts` (red on 3 consecutive runs against npm 0.22.0, green on 3 consecutive runs of the fixed build (one case, run repeatedly)). The drag contract is guarded by unit cases and by a real cascade probe in the mount lane that reads computed values against the shell's own rules. Verification: `pnpm test` 122 files / 1293 passed / 9 skipped, `pnpm test:mount` and `test:mount:aggregate` green. Incident write-up: [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](./docs/plans/2026-09-28-native-files-takeover-reload-leak.md).
### v0.22.0
> 📦 **Stable release** (npm `latest`): the support line is **unchanged** — DSH **0.1.7-rc.1+** only (peer floor `^0.1.7-rc.1`, CI pins `@deepseek-ai/dsh@0.1.7-rc.1`), so v0.21.1 users can upgrade straight away. **Hosts on DSH 0.1.6-alpha.2 or earlier keep pinning v0.19.1.**
- 🧩 **The Tasks page is now a workflow graph** (the primary view): the session tree renders as layered nodes joined by bezier edges — drag to pan, wheel-zoom to the cursor, fit to the content box, a control cluster bottom-right (graph/tree toggle + fold switch + zoom + fit); the **classic indented tree is kept** (keyboard-navigable) and both modes share one view model, so fold state and team enrichment never drift apart.
- 🃏 **Two-segment node cards**: the top segment is the kind badge (main agent / subagent / teammate / workflow / completed aggregate) + phase badge + name + meta; the bottom bar is the state dot + state word + **the same merged activity line the main agent shows** (concurrent tools grouped and counted, with the running call's detail — wording from the host's `chat` namespace) + a fold button on finished nodes; a running bar is swept across its **whole** width (disabled under `prefers-reduced-motion`). 8px-rounded, hierarchy carried by a faint top-segment tint only, the session you are on wearing a heavier accent border.
- 🔀 **Workflow runs enter the graph**: runs folded from `tool-workflow/*` events (the same events the official panel folds) hang under their origin agent, with member agents re-parented below and boxed per phase with matching badges; members with no catalog row are synthesized from the run's own data, so a finished run still shows who took part.
- 🗂 **Folding split into two groups that say what they hold**: `✓ N completed` (including failures, called out as `N failed`) and `N idle` (teammates that finished a turn and can be called back at any moment) are **two separate rows**; the manual fold button always works, the automatic rule only sweeps idle members once there are 3 or more, and an aggregate's name line reads "first two names + `+N`".
- 🪟 **Two persistent floating windows** (extracted into a reusable `FloatingWindow`): background-job output and the shared task's detail/edit surface — draggable, resizable from every edge, a self-scrolling body, dismissed only by the close button or Escape (no outside click / blur / anchor observer); the task window hands its spare height to the description, so enlarging it gives the content room, and the action row is pinned to the bottom.
- 👥 **Agent Teams board (experimental layer)**: members enrich their nodes and an always-visible strip lists the roster and shared tasks; the state machine follows the host (pending → claim → in progress → complete → reopen) with reassign / edit / two-step delete, and stale CAS revisions get their own notice; member activity is overlaid from `subagents.live`'s `running` flag.
- 🔄 **Background jobs now read the host's client `ctx.jobs`** (a pushed roster plus a non-consuming output stream and `kill`): the plugin deleted its own `jobs.list` / `jobs.output` / `jobs.kill` routes and the event-replay mirror, never touching the model's `job_output` cursor; output streams in a persistent floating window with tail-follow, and the drawer auto-collapses at 8+ agents.
- 🛠 **DSH 0.1.7 data-plane rewrite**: upstream deleted the three `agentTeams.remoteView`-style Remote methods → team reads moved to the Lead Session's **`agentTeam` Session projection** (push-based; the `teams.view` route and its 5-second poll are gone); the two write routes stay, with rejections moved from a result union to a thrown `TeamError` and a stale revision mapped to a 409 `team-conflict`. **The bug this fixed in the wild**: on 0.1.7 the team strip never rendered at all (the route answered `remoteView is not a function`, and the page degraded silently).
- 🐛 **Four defects caught on a real host, all green in unit tests**: the per-node fold button did nothing (blocked by the automatic rule's guards); an idle card never drew a fold button; claiming a task mislabelled it "blocked"; and "complete" on a queued task always failed (a claim comes first).
- 🎨 **Narrow panes and mobile settings**: card and row metrics re-tuned for the native right sidebar's narrow width; the settings page gained a **Mobile** group — on a narrow viewport (≤768px) the Tasks page no longer auto-opens and defaults to the tree view.
> 📜 **Earlier versions**: full release history in [CHANGELOG_EN.md](./CHANGELOG_EN.md) (v0.21.1 → v0.12.3) and on [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases).
## ⌨️ Keyboard Shortcuts
| Action | Keys |
|---|---|
| Save edits | `Ctrl/Cmd + S` |
| Git commit | `Ctrl + Enter` |
| Close tab | Middle mouse button |
| Tab context menu (right-click) | Close / Close Other Tabs / Close Tabs to the Left / Close Tabs to the Right (current pane) |
| Split / merge panes | Drag tab to pane edge / middle |
| Reference file to input | Hover the `@file` button at end of line |
| Copy file path | Right-click row → copy relative/absolute path |
## 🔌 Service API
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 5 built-in tabs + 3 viewers register through the same service). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.).
Full integration docs (complete fields, matching algorithm, HMR pitfalls, declarative settings, version detection, the native-sidebar surface and the skinning contract): **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**; repository rules (hard constraints / CI / release) live in [`AGENTS.md`](./AGENTS.md).
### ➕ Add Plugins (recommended plugin catalog)
The dashed cards at the end of the "Sidebar content" / "File viewers" grids in the "Side Cards" settings section open the **Add tab plugins** / **Add preview plugins** modals: each declares its open extension point, offers a "**Browse more plugins on GitHub**" button (the [GitHub topic `dsh-apes-sidebar`](https://github.com/topics/dsh-apes-sidebar)), and lists the recommended catalog (name / repo / description / install script) — "**Open**" jumps to the repo, "**Copy**" writes the install command to the clipboard.
**Curating a new plugin**: append a `PluginEntry` to [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) (tab registrations) or [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) (file-previewer registrations) and tag your repo with the `dsh-apes-sidebar` topic; data integrity is guarded by `tests/plugin-list.spec.ts`.
## 🛠️ Development & Build
```sh
pnpm install # @deepseek-ai/* devDependencies resolve (baseline 0.1.7-rc.1, alpha dist-tag) — no token needed
pnpm typecheck # tsc --noEmit
pnpm lint # eslint . (flat config: js + typescript-eslint + react-hooks recommended)
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest (includes manifest consistency guard; build first)
pnpm watch # tsdown --watch
```
**Make thin wrappers** (`make help` lists every target; package.json stays the single source of truth):
```sh
make check # aggregate gate: typecheck → build → test → check:consumer-types (mirrors CI)
make mount # real-mount smoke: build + pack → install Chromium → pnpm test:mount
make clean # remove lib/, *.tgz, playwright-report/, test-results/
```
`pnpm check:consumer-types`: the consumer-facing declaration-surface guard — type-checks the built `lib/types` from a browser-only consumer's perspective (no `@types/node`, `skipLibCheck: false`); run `pnpm build` first.
**Architecture**: a single npm package with host/client halves — host (`src/index.ts`): `/sidebar/api/*` JSON API, `/sidebar/file` media route, `/sidebar/html` preview route, `/sidebar/upload` upload route, and two WebSockets (`/sidebar/ws/agent-opens` for model-driven opens, `/sidebar/ws/fs-watch` for the file tree's directory watch; fs / git / preview are all session-scoped behind a trust fence); client (`src/client/index.tsx`): portal sidebar + views + link takeover; state persisted per session in localStorage. Organized per DSH official conventions (no default export, dual client bundles); no dependency on npm / checkout at runtime (`@deepseek-ai/*` provided by the web profile).
## 🔐 Security
- Routes protected by a Host-header trust fence (same as `/api`); `fs.write` is atomic; git only shells out to the CLI and never sets identity
- ⚠️ **Since v0.23.0 the filesystem routes do NO workspace containment**: `fs.tree` / `fs.trees` / `fs.read` / `fs.write` / `fs.rename` / `fs.remove` / `fs.mkdir` / media / HTML preview / `/sidebar/upload` / `archive.build` reach **any path the host user can reach** (OS permissions are the only bound; the `workspaceFence` switch and the 403 branch are gone) — do not treat these routes as fenced
- HTML preview content renders in an **opaque-origin sandboxed iframe** (no `allow-same-origin`/`allow-top-navigation`, `no-referrer`, all permission policies disabled); the `/sidebar/html` route carries a CSP `sandbox` + size/path bounds
- The settings page can disable the HTML preview's sandbox per feature (`htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe`, off by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content. **The web tab's sandbox is no longer this plugin's surface**: the browser view comes from the host (desktop profile); see DSH's own docs for its sandbox and navigation policy
## ⚠️ Known Limitations
- Git has no push/pull/fetch; Markdown previews provide a manual refresh button with confirmation before discarding unsaved edits; the file tree only watches **expanded** directories (collapsed folders are unsubscribed, and there is no recursive whole-workspace scan); tool inline file-open buttons cannot be intercepted
- **Which read-only previews exist is the host's call**: spreadsheets / PDF / images / Office go to DSH's own `ui-sidebar-documentpreview`, while the plugin renders only Markdown / HTML and the editable text buffer; the host implementation (rendering details, zoom, refresh timing) follows the DSH version
- **The browser view exists only in the desktop profile**: the Web profile has no host `browser` kind and the plugin no longer ships a browser tab, so web tabs are desktop-only; login state / third-party cookies / `X-Frame-Options` limits follow the host implementation
- HTML preview renders the saved file (not unsaved drafts)
- No bottom panel on mobile (<768px): on narrow screens its tabs merge into the right sidebar once (after migrating back to desktop they stay in the right sidebar); the desktop bottom panel is only available on wide viewports. Without a selected session, tapping the subdued toggle shows the select-session message; with a selected session, it opens the full-width drawer
## 🖥️ Platform Support
Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests). The plugin carries no native dependencies (the terminal and `node-pty` went back to DSH wholesale), so building needs only Node + pnpm, with no compiler toolchain.
## 🌐 Plugin Ecosystem
The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 5 built-in tabs + 3 viewers register through the exact same API — fully equal capabilities.
```ts
import type {} from 'dsh-apes-sidebar' // triggers the ctx.betterSidebar type merge
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}))
ctx.effect(() => ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchText(scope, path)),
component: ({ customData }) => <CsvGrid rows={customData} />,
}))
}
```
The GitHub topic [`dsh-apes-sidebar`](https://github.com/topics/dsh-apes-sidebar) already hosts **28+ ecosystem plugins** (and growing):
<div align="center">
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="66%" alt="The built-in Add Plugins modal: recommended catalog + one-click install command" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a><br />
<i>The built-in "Add plugins" modal in settings: recommended catalog + one-click install command + a direct link to the GitHub topic</i>
</div>
### 📑 Tab Plugins (sidebar pages)
<details>
<summary><b>24 plugins (click to expand)</b></summary>
| Plugin | ⭐ | Description |
|---|---|---|
| [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | <img alt="stars" src="https://img.shields.io/github/stars/ChenRuoT/dsh-sidebar-qa?style=flat&color=4d6bfe" /> | Selection-based side Q&A — Codex-style side questions / Claude Code `/btw` |
| [fuhefei/dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) | <img alt="stars" src="https://img.shields.io/github/stars/fuhefei/dsh-sentinel?style=flat&color=4d6bfe" /> | Condition-driven wakeup: file / command / HTTP / process / webhook watches that wake the agent; dock + sidebar branch + global dashboard |
| [Fisfzy/ego-browser](https://github.com/Fisfzy/ego-browser) | <img alt="stars" src="https://img.shields.io/github/stars/Fisfzy/ego-browser?style=flat&color=4d6bfe" /> | Agent browser: a local browsing tab (`@dsh-external/ego-browser`, auto-registers the sidebar page when better-sidebar is present, floating-bubble fallback otherwise) |
| [jiuge2467/dsh-studio](https://github.com/jiuge2467/dsh-studio) | <img alt="stars" src="https://img.shields.io/github/stars/jiuge2467/dsh-studio?style=flat&color=4d6bfe" /> | Full-stack enhancement workbench: multi-source MCP visual debugging hub, visual thinking engine |
| [Iwctwbh/dsh-flowglass](https://github.com/Iwctwbh/dsh-flowglass) | <img alt="stars" src="https://img.shields.io/github/stars/Iwctwbh/dsh-flowglass?style=flat&color=4d6bfe" /> | Flowglass: live session flowgraph (messages / tool groups / subagent branches) |
| [FeatherHunter/dsh-mattpocock-skills-deck](https://github.com/FeatherHunter/dsh-mattpocock-skills-deck) | <img alt="stars" src="https://img.shields.io/github/stars/FeatherHunter/dsh-mattpocock-skills-deck?style=flat&color=4d6bfe" /> | Game-like mission system for mattpocock/skills: fog-of-war map + task bar |
| [GULI-lab/DSH-element-source](https://github.com/GULI-lab/DSH-element-source) | <img alt="stars" src="https://img.shields.io/github/stars/GULI-lab/DSH-element-source?style=flat&color=4d6bfe" /> | Click any UI element on your dev page to jump to its Vue / React / Svelte / Angular source, straight into the chat |
| [Lzh3070/dsh-file-review-tab](https://github.com/Lzh3070/dsh-file-review-tab) | <img alt="stars" src="https://img.shields.io/github/stars/Lzh3070/dsh-file-review-tab?style=flat&color=4d6bfe" /> | File-change review tab: line-level red/green diffs + undo + chat-line deep links |
| [yq04/dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-git-remotes?style=flat&color=4d6bfe" /> | Git remotes tab: branches / upstream / ahead-behind, fetch with prune, ff-only pull, confirm-before-push |
| [ztyhehe/dsh-apes-sidebar-svn](https://github.com/ztyhehe/dsh-apes-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-apes-sidebar-svn?style=flat&color=4d6bfe" /> | SVN source-control tab: status / diff / log / commit / update / revert / conflict resolution — symmetric to the built-in Git panel |
| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | <img alt="stars" src="https://img.shields.io/github/stars/Melody-max114/dsh-excel-panel?style=flat&color=4d6bfe" /> | Excel editing: xlsx preview/edit, live formula evaluation, merged cells, save back to the original file |
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch results as sidebar cards: query, source snippets, highlighted keywords |
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | Global docs panel: portable Markdown notes, readable from any workspace |
| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | <img alt="stars" src="https://img.shields.io/github/stars/lnyuqian/dsh-skill-sidebar?style=flat&color=4d6bfe" /> | Skills panel: scans local skill directories, one-click invocation copy, pinning |
| [g-yixuan/dsh-sidenote](https://github.com/g-yixuan/dsh-sidenote) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidenote?style=flat&color=4d6bfe" /> | Codex-style side chat + selection annotations (a thin consumer plugin) |
| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-ssh-tunnel?style=flat&color=4d6bfe" /> | Multi-host SSH tunnels + SSH manager tab |
| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-git-forge?style=flat&color=4d6bfe" /> | GitHub / Gitea accounts, project grants and push policy |
| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | <img alt="stars" src="https://img.shields.io/github/stars/YesSanSan/dsh-conversation-outline?style=flat&color=4d6bfe" /> | Conversation outline tab: per-turn structure, quick jump, one-line LLM titles |
| [Wulabalabo/dsh-sidebar-Explorer-Plus](https://github.com/Wulabalabo/dsh-sidebar-Explorer-Plus) | <img alt="stars" src="https://img.shields.io/github/stars/Wulabalabo/dsh-sidebar-Explorer-Plus?style=flat&color=4d6bfe" /> | File-manager tab: upload / move / delete / rename / new folder (write operations) |
| [yq04/dsh-turn-review](https://github.com/yq04/dsh-turn-review) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-turn-review?style=flat&color=4d6bfe" /> | Turn review: review agent changes turn by turn |
| [Ghz114514/dsh-refpics](https://github.com/Ghz114514/dsh-refpics) | <img alt="stars" src="https://img.shields.io/github/stars/Ghz114514/dsh-refpics?style=flat&color=4d6bfe" /> | Pinterest-style reference-image search: masonry wall, sidebar board, downloads, save-to-Eagle |
| [yzlin499/dsh-yzlin499-easy-plugins](https://github.com/yzlin499/dsh-yzlin499-easy-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/yzlin499/dsh-yzlin499-easy-plugins?style=flat&color=4d6bfe" /> | A handy utility bundle for a bare-bones DSH |
| [dong-victor/dsh-apes-sidebar-starter](https://github.com/dong-victor/dsh-apes-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-starter?style=flat&color=4d6bfe" /> | Run-configurations tab: IDEA-style Run/Debug configs (npm / springboot / python / custom) — one-click launch, history, WebSocket live logs (ANSI colors), parallel instances, cross-platform process-tree kill |
| [baosfeng/my-dsh-plugins](https://github.com/baosfeng/my-dsh-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/baosfeng/my-dsh-plugins?style=flat&color=4d6bfe" /> | Personal multi-plugin collection (`dsh-file-activity`): a sidebar file-activity tab recording read / added / modified history and stats, flat-browsed by folder, opened with the native preview |
| [Hoemr/dsh-better-overleaf](https://github.com/Hoemr/dsh-better-overleaf) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-better-overleaf?style=flat&color=4d6bfe" /> | Overleaf tab: direct-CDP browser login (third-party Chromium supported), project switching, local git mirrors under the workspace with two-way sync |
</details>
### 🖼️ Viewer Plugins (file previewers)
<details>
<summary><b>3 plugins (click to expand)</b></summary>
| Plugin | ⭐ | Description |
|---|---|---|
| [HuanLinOTO/dsh-plugin-better-sidebar-plugin-office](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) | <img alt="stars" src="https://img.shields.io/github/stars/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office?style=flat&color=4d6bfe" /> | Office-suite preview (.docx / .xlsx / .pptx) as a separate bundle to slim the core (in the official recommended catalog) |
| [zemul/dsh-video-preview](https://github.com/zemul/dsh-video-preview) | <img alt="stars" src="https://img.shields.io/github/stars/zemul/dsh-video-preview?style=flat&color=4d6bfe" /> | Inline video preview: .mp4 / .webm / .mov / .mkv / .avi with a /video host route supporting HTTP Range scrubbing |
| [dong-victor/dsh-apes-sidebar-jupyter](https://github.com/dong-victor/dsh-apes-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-jupyter?style=flat&color=4d6bfe" /> | Runnable `.ipynb` notebook view: lazy-start Python kernel, streaming outputs, save-back |
</details>
### 🧰 Enhancements & Tools
<details>
<summary><b>3 plugins (click to expand)</b></summary>
| Plugin | ⭐ | Description |
|---|---|---|
| [dong-victor/dsh-apes-sidebar-terminal-plus](https://github.com/dong-victor/dsh-apes-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | Terminal enhancement: bundled Nerd Font icons, xterm glyph fixes, stable terminal cwd |
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | Preview selection boost: select text in any sidebar preview → floating "send to session" |
| [Hoemr/dsh-quicklook](https://github.com/Hoemr/dsh-quicklook) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-quicklook?style=flat&color=4d6bfe" /> | QuickLook-style Space preview: press Space on the active file tab for a full-size image / PDF / text overlay; Space or Esc closes |
</details>
> 📣 **List your plugin**: tag your repo with the `dsh-apes-sidebar` topic to appear on the [topic page](https://github.com/topics/dsh-apes-sidebar); then PR one `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) to join the built-in recommended catalog (data integrity is guarded by `tests/plugin-list.spec.ts`).
## 🤝 Contributing
- **Code changes go through PRs**: develop on a `feat/*` / `fix/*` branch, then `gh pr create`; docs-only changes may be pushed to main directly
- **Curate an ecosystem plugin**: tag your repo with `dsh-apes-sidebar` + PR a `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts)
- **Before submitting**: `pnpm typecheck && pnpm build && pnpm test` (or `make check` for the one-shot aggregate; CI additionally gates on npm-pack → real-mount → headless-render via `pnpm test:mount`, plus the aggregate double-mount regression `pnpm test:mount:aggregate`)
- See [`AGENTS.md`](./AGENTS.md) for the repository rules (hard constraints, CI lanes, release flow)
## 👥 Contributors
Thanks to everyone who contributed:
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/graphs/contributors">
<img src="https://contrib.rocks/image?repo=omdsh-dev/DSH-better-sidebar" alt="Contributors" />
</a>
## 🔗 Friends
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI): a Claude Code-style fullscreen interactive TUI plugin — pixel-whale top bar, live working-status row, streaming thought expansion, double-Esc rollback, context progress bar + TPS meter; one-command npm install
- [dshfind Plugin Market](https://dshfind.com/zh/plugins): a third-party plugin marketplace — a listing of public repos under the GitHub topic `dsh-plugin`, with stars, contributors and growth data synced daily
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop): a modern desktop client for the DeepSeek Harness ecosystem — start and manage a local Harness service without configuring Node.js or running commands; [official site](https://www.dshdesktop.cn)
---
<div align="center">
<sub>MIT License · Built for the <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> ecosystem · discover more on the <a href="https://github.com/topics/dsh-apes-sidebar">dsh-apes-sidebar topic</a></sub>
</div>
+11 -40
View File
@@ -1,42 +1,13 @@
# dsh-apes-sidebar bundle patch
# apes-sidebar bundle patch: inserts the host plugin row into the profile
# roster. The client half (sidebar tabs: Tasks / Agents / Messages / Apps /
# Profile) loads through the dsh.client.inject declaration in package.json;
# this patch mounts the Node-side host entry only.
#
# This file is the `dsh.bundle.patch` layer of the published npm package: when
# the plugin is installed through the official CLI —
#
# dsh plugin --profile <name> add dsh-apes-sidebar@<version>
#
# — the command reconciles `dsh.profile.bundles` against installed packages
# and, seeing this declaration, appends `dsh-apes-sidebar` to the bundle
# stack. The profile boot then merges THIS patch (a single `insert` of the
# plugin row) exactly like the manual cordis.patch.yml mount line used
# before. No profile file edits needed — one command installs and mounts.
#
# If the profile still carries the old manual mount line in its own
# cordis.patch.yml, remove it before switching to the bundle channel to
# avoid double-mounting (two Node halves, two sidebars).
#
# The `config.shell` / `config.shellArgs` rows are gone with the plugin's own
# terminal (DSH 0.1.6 ships its own right-Sidebar terminal, and its shell is
# chosen from the shell menu there):
#
# - insert:
# - id: better-sidebar
# name: 'dsh-apes-sidebar'
#
# Aggregate double-mount guard: aggregate bundles (e.g. @linxin666/dsh-web-ui-all)
# may already mount this package under their own entry id (e.g.
# `web-ui-better-sidebar`). Two mounts register /sidebar/api twice and fail the
# whole plugin tree at boot ("duplicate prefix route"). The `!!js` disabled
# expression backs THIS row off when another *enabled* entry already mounts
# `dsh-apes-sidebar`; the aggregate instance then owns the sidebar. The
# loader evaluates it when this row is processed (entry-list order, only rows
# before this one are visible), so the aggregate bundle must precede this one
# in `dsh.profile.bundles` — the standard install order, since `dsh plugin add`
# appends new bundles last. The reverse order (this bundle before the
# aggregate) is a known limitation: the guard cannot see rows that come after
# it, and no runtime singleton guard is added. Same-id manual duplicates still
# fail loudly at the loader as before.
# Double-mount guard: if another *enabled* entry already mounts the same
# package (e.g. via an aggregate bundle), this row backs off.
- insert:
- id: better-sidebar
name: 'dsh-apes-sidebar'
disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.name === 'dsh-apes-sidebar' && e.options.id !== 'better-sidebar' && !e.disabled)"
- id: apes-sidebar
name: apes-sidebar
disabled: !!js >
[...ctx.loader.entries()].some((e) =>
e.options.name === 'apes-sidebar' && e.options.id !== 'apes-sidebar' && !e.disabled)
+362
View File
@@ -0,0 +1,362 @@
# apes-sidebar 重写方案(v2)— 冲突检查通过,从零开始
> 目标:重写现有 `apes-sidebar`(v0.4.0,单文件手写 JS client bundle),
> **代码规范/工程方案学习 [DSH-better-sidebar v0.18.0](https://github.com/omdsh-dev/DSH-better-sidebar)**,
> **需求功能/交互能力学习 [tutti-os/tutti](https://github.com/tutti-os/tutti)**。
>
> 分析基线:DSH-better-sidebar `v0.18.0`(浅克隆 /tmp/DSH-better-sidebar)· Tutti(本地 /Users/figmarlite/Desktop/…/APES/tutti)
> 生成时间:2026-09-03 · 用途:重构开工前对齐,供评审与拆单。
## 0. 冲突检查结论(2026-09-03)
**目标自洽,不冲突,无需推翻重规划。** 两个参考源互补:
- better-sidebar v0.18.0 = 「怎么建」(工程规范/构建/测试/i18n/token/服务契约)
- tutti-os/tutti = 「做什么」(面板模型/角标/agent 可编程驱动)
三处主动调整(借"从零开始"授权做干净化):
1. **Tab id 换前缀**:旧 `wb:*` → 新 `apes:*`(apes:tasks / apes:agents / apes:apps / apes:messages / apes:profile),不留 `wb` 历史包袱。
2. **定位收敛**:只做 **tasks/agents/apps/messages/profile** 五面板信息层;files/terminal/browser/editor 由 better-sidebar 宿主提供,**不重复造轮子**(复用 `openTab/openFile` + 宿主内置 tab 即可)。
3. **放弃团队房间**:tutti 的 rooms/team 依赖 `dsh-background-agents` 0.7.x,与 DSH 基座 loader id 冲突会崩(历史教训),v2 不做。
唯一硬约束:peer 钉 `@deepseek-ai/cordis ^4.0.2` + `dsh-better-sidebar ^0.18.0`(better-sidebar 发 npm `next` dist-tag,随 DSH rc.x 漂移;升级前必须跑挂载冒烟)。
---
## 1. 现状诊断(旧版 v0.4.0 的痛点)
### 1.1 现状结构
```
figmar/apes-sidebar (v0.4.0)
├── package.json # peer: cordis ^4.0.1 / better-sidebar ^0.16.0 / react 18
├── cordis.patch.yml # insert apes-sidebar(双挂载守卫)
├── lib/index.js # host 最小入口(~1KB,只打日志)
└── lib/client.js # 45KB 单文件,window.__ModuleLoader__.load 手写 bundle
# 纯 JS + React.createElement,无 JSX/TS/构建/测试
```
注册 4 个 Tab(全部基于 better-sidebar `ctx.betterSidebar.registerTab`):
| Tab id | 作用 | 参照 |
|---|---|---|
| `wb:tasks` | 工作区+时间分组会话列表、搜索、运行中徽标、运行中任务区 | WorkBuddy |
| `wb:agents` | 子代理/后台 agent 列表、状态、一键跳转 | Tutti multi-agent |
| `wb:apps` | 应用中心(Design/PPT/Video 网格) | Tutti App Center |
| `wb:profile` | Provider/模型信息 + 设置/帮助入口 | WorkBuddy 底部 |
配置:better-sidebar 设置页 `pluginSettings['apes-sidebar']`(`enableTasks`/`enableAgents`/`enableApps`/`enableProfile`/`displayName`/`showTime`/`showUngrouped`/`appsList`)。
### 1.2 主要痛点
1. **单文件 45KB 手写 JS bundle**:无类型、无模块拆分、无构建产物管理;
CSS 以字符串内联注入(`APPS_CSS` 拼接),每次改样式要手工编辑字符串。
2. **零测试**:无 spec,无 CI 挂载冒烟——任何 API 漂移(如 DSH rc 升级)静默崩。
3. **i18n 手写**:本地 zh/en 两字典,不跟随宿主 `ctx.locale`,第三方语言直接缺。
4. **皮肤脆弱**:CSS 硬编码 `#fff` 渐变等(`card-icon` 用 `#3370ff→#7c3aed`),不消费
`--dsw-alias-*` token 链,深色/浅色/品牌换肤会破。
5. **状态模型简单**:轮询/快照读取 sessions/workspaces,无订阅模型、无 memo、无懒加载。
6. **无 host 能力**:只有日志入口。需要 host 路由(读会话日志、子代理拓扑、job 回放)
时只能绕 DSH 网关或放弃。
7. **版本基线落后**:peer 只到 better-sidebar ^0.16.0;而 better-sidebar v0.18.0
已毕业正式版、只支持 DSH 0.1.2-rc.1+(协议、RPC 方言、client store 均大变)。
---
## 2. 参考源 A:DSH-better-sidebar v0.18.0 工程规范(「怎么建」)
### 2.1 包结构(published npm package)
- `name: dsh-better-sidebar` / `version: 0.18.0` / `type: module` / `pnpm@11.8.0`
- `main: lib/index.js`;exports 子路径:`.` / `./invariant` / `./client` / `./client/service` / `./client/api` / `./src/*` / `./package.json`
- `dsh.bundle.patch: ./cordis.patch.yml`(bundle 激活的**硬性资格**)
- `dsh.client.inject`: `[locale, ui-slots, ui-conversation, modules]`,`platform: web`
- `dsh.plugin.json`:市场 manifest 形态(id/version/main/engines.dsh/contributes/client.main)
- `files`: lib + src + cordis.patch.yml + scripts + README
### 2.2 构建与质量
- `build`: `tsc -p tsconfig.build.json && tsdown`(类型声明 + JS 产物)
- `typecheck` / `test`(vitest) / `lint:ts`(oxlint) / `playwright`(e2e)
- **懒加载 chunk**:重依赖(xterm/CodeMirror/mermaid)独立 bundle `lib/client-<name>.js`,
经 `/sidebar/bundle` 按需下发、`globalThis.__dshChunks__` 物化(`src/client/chunk-loader.ts`);
核心 bundle 禁止静态 import chunks。
- **CI 挂载冒烟**(`plugin-mount` job):`pnpm pack` → scratch profile 真实安装 →
`dsh web`(keyless)→ Playwright 断言 `[data-dsh-better-sidebar]` 挂载、无 pageerror、
+ 菜单逐 tab 打开、editor chunk 强制加载。本地:`pnpm build && pnpm pack && pnpm test:mount`。
### 2.3 仓库硬约束(AGENTS.md)
- 禁止修改 DSH 源码(零写入 `~/.dsh/source/current`);改动必须 PR(`feat/*`/`fix/*`)。
- 挂载只走 `cordis.patch.yml` + profile;**不反向侵入 DSH**。
- 市场约束:`dependencies/peerDependencies/optionalDependencies` **一律不得出现 `cordis`**;
`scripts` 不得含 `preinstall/install/postinstall/prepare`(`tests/market-manifest.spec.ts` 守护)。
- 缺能力时用 DSH 现成只读/公开 API 或插件自有路由(如 jobs.output 事件回放),不改 DSH。
### 2.4 架构:host half + client half
**host half(Node 侧,`src/index.ts` ~1300 行)**
- 导出 `export const name = 'dsh-better-sidebar'`、`inject = ['webServer','sessions','webRuntime','tools']`、`export function apply(ctx)`。
- 插件自有路由全走同源 fence:`/sidebar/api/*`(JSON:fs.tree/search/read/write、git.*、session.cwd、
changes.ops…)、`/sidebar/file`(媒体)、`/sidebar/html`(预览)、`/sidebar/bundle`(懒加载)、
`/sidebar/ws/*`(终端 WebSocket upgrade)。
- **信任围栏** `trust-fence.ts`:与 /api 网关同源(Host-header loopback / webRuntime trustedHosts),
逐请求读取;**路径安全** `path-security.ts`:会话 cwd 内解析、workspaceFence 守卫、写路径原子写(tmp+rename)。
- 会话隔离:所有请求带 sessionId,权威 cwd 来自 session store(header → client cwd → persistence → process.cwd 四级回退)。
- PTY 管理:`pty-manager.ts` + `agent-pty.ts`(agent 终端注册表、resize gate、按会话配额)。
- 工具注册:`registerTools`(agent 可调用)、`agent-opens.ts`(agent 主动开 tab 的 registry + open tool)。
**client half(浏览器侧,`src/client/index.tsx` ~446 行)**
- `inject = ['slots','sessions','locale','modules','connection']`
- `attachLocale(ctx.locale)` + `ctx.effect` 注册词典(zh/en 为主,第三语言走 better-locale 可选覆盖)。
- `createSidebarStore()` 单例 per activation → `ctx.provide('betterSidebar', service)`。
- `ctx.effect(() => registerBuiltins(ctx, service))`:内置 7 tab + 6 viewer **自己吃自己的狗粮**。
- 挂载:`document.body` 追加 host div → `createRoot` 渲染 `RenderBoundary > Sidebar`;
bodyObserver 防宿主移除;几何自检防页面 transform 漂移(degraded viewport sync)。
- 拦截层:turn-tail 拦截、open-path 拦截(`remote.session.openWorkspacePath` 遮蔽)、外链拦截
(urlTarget 认领,默认 browser tab)、**IME 守卫**(composition 键防第三方 UI 抢键)。
- 设置面:`ctx.slots.inject('settings.section', …)` 声明「Side card」设置分区。
### 2.5 服务契约(消费插件 API,也是 apes-sidebar 重写后要面向的基座)
`ctx.betterSidebar`(client-only):
```ts
registerTab(descriptor: TabDescriptor): () => void // 返回 disposer,HMR-safe
registerFileViewer(descriptor: FileViewerDescriptor): () => void
getTabs() / getFileViewers() / getTab(id)
isTabEnabled(id) / isViewerEnabled(id)
matchFileViewer(path, head?)
openTab(seed: OpenTabSeed, scope?) / closeTab(tabId, scope?)
openFile(scope, path, title?)
updateTab / activateTab
subscribe(listener) / getSnapshot() / subscribeState(listener)
```
`TabDescriptor` 关键字段(v0.18.0 全量):
```ts
interface TabDescriptor {
id: string
title: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
order?: number // + 菜单排序,默认 100
hidden?: boolean
available?: (ctx, scope, state) => boolean // + 菜单禁用谓词
single?: boolean // 单实例(dedupeKey: () => id 语法糖)
dedupeKey?: (tab) => string | undefined
createTab?: (state) => { tab, patch? } | null // 自定义 mint tab id
urlTarget?: (url: URL) => boolean // 外链认领
settings?: SidebarSettingsDeclaration // toggles / pluginToggles / render
badge?: (ctx, scope, state) => string | number | null | undefined // Tab 角标
onOpen? / onActivate? / onClose? // 生命周期(仅 service 路径触发)
component: (props: TabComponentProps) => ReactNode
}
```
声明式设置:`'switch' | 'text' | 'number' | 'select'`;select 支持 `multi` 与图标选项卡。
`TabComponentProps = { ctx, store, scope, tab, visible, expanded?, revealed?, onToggleDir?/onOpenFile?/… }`。
**接入规范(外部插件视角)**:
- peerDependencies: `@deepseek-ai/cordis` + `dsh-better-sidebar`(optional);
type-only import 触发 `declare module '@deepseek-ai/cordis'` 类型合并;
- 纯浏览器插件走 `import type {} from 'dsh-better-sidebar/client/service'`(零 Node 依赖图);
- client half `inject: ['betterSidebar', …]`,`ctx.effect(() => ctx.betterSidebar.registerTab(...))`;
- 宿主 half 无此服务——需要读状态走 better-sidebar 自己的 HTTP/WS 路由。
### 2.6 i18n / 皮肤 / 无障碍规范
- i18n:词典注册到 DSH `ctx.locale`(LOCALE_NS);新增 zh key 必须同步 ja(否则 ja 回退 en);
渲染 MarkdownText 必须经 `markdownTextProps()`(labels 嵌套契约)。
- 皮肤:**视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌**,无硬编码颜色;
硬编码 hex 只能做 token 链末端 fallback(`color:var(--x,#fff)`),并有 `tests/theme.spec.ts` 守护。
- a11y:role=tablist/tab/separator、aria-selected/valuenow、focus-visible ring、IME guard。
---
## 3. 参考源 B:Tutti 侧边栏能力(「做什么」)
### 3.1 面板模型(packages/agent/gui/workbench/tool-sidebar/model.ts)
```ts
export const agentToolPanelIds = ['files','terminal','browser','tasks','apps','messages','side'] as const
```
- 每面板有 label、可开多实例(resourceId 区分,如 browser tab 带 agent session id)。
- 每面板默认/最小/最大宽度(files 最大不限、side 默认 440 最大 600、其余默认 720/最小 420)。
- `mainContentMinWidth = 280`:主内容区最小宽度保护。
### 3.2 行为层(AgentToolSidebar.tsx + useAgentToolSidebarController)
- **多标签**:TabBar(role=tablist),每个 tab = panel + resourceId;关闭/激活/拖拽。
- **可编程 handle**(AgentToolSidebarHandle,agent/宿主可驱动):
`addPanel / ensurePanel / openPanel / closeTab / close / collapseForContainerConstraint`
- **容器约束自动折叠**:`shouldAutoCollapseAgentToolSidebar({containerWidth, mainContentMinWidth, sidebarWidth})`
→ 窗口变窄时 `collapseForContainerConstraint()`(不关 tab,只收起到 Dock)。
- **宽度记忆 + 动画**:`onLayoutWidthChange`、resize handle(键盘可操作 role=separator)、
width transition 260ms。
- **懒挂载渲染**:`contentReadyTabIds` + `renderLoading`(未就绪 tab 显示 loading,就绪才挂)。
- **空态 Picker**:`isEmptySidebar` → AgentToolSidebarPicker(从 + 菜单选面板)。
- **展开态**:expand 按钮让活动面板撑满(expanded panel 概念)。
### 3.3 信息架构与角标(StandaloneAgentToolSidebar.tsx)
- `panels` 条件组装:`side` 面板仅在有 side conversation identity 时出现(`canAdd: false`)。
- `quickActionPanels`: 有自动化 browser 时把 browser 提到 quick action;常驻 tasks/apps/messages。
- `reminders`(角标计数):
- `browser: automationBrowserCount`(运行中 automation browser tab 数)
- `messages: messageCenterWorkingCount`(工作会话中 working agent 消费者计数,订阅 sessionEngine)
- 对应旧版 apes-sidebar 的 badge 概念:tasks 徽标 = 运行中会话数;agents 徽标 = 运行子代理数。
### 3.4 各面板承载(渲染层职责划分)
| 面板 | 承载 | apes-sidebar 对应/借鉴 |
|---|---|---|
| files | 文件树/编辑器 | (better-sidebar 已有 editor)不重复 |
| terminal | 终端 | (better-sidebar 已有)不重复 |
| browser | 内嵌浏览器 | (better-sidebar 已有)不重复 |
| tasks | Issue Manager/任务编排 | **apes:wb:tasks 升级对象**:会话/任务拓扑 |
| apps | App Center | **apes:wb:apps 对齐对象**:应用网格+调起 |
| messages | 消息中心(审批/通知/后台工作汇总) | **apes 可新增 wb:messages** |
| side | Side Conversation(子会话并排) | 子代理会话展示可借鉴 |
Tutti 其余值得迁移的交互:控制中心(一个视图看所有 agent 对话+待审批+运行任务)、
goal→tasks 自动拆分、@ 深度引用、task 分配给人/agent。
### 3.5 进程/事件模型
- 会话与活动数据经 `sessionEngine`/activity 服务订阅(`subscribe + getSnapshot` 模式,
即 React 19 `useSyncExternalStore` 形状)——ape-sidebar 应把「轮询」升级为订阅 + memo。
- 子代理/后台工作 → `subagent-live-route` / jobs 回放(better-sidebar 已实现:
`jobs-routes.ts` 从会话事件日志回放 job 输出、`subagent-live-route.ts` 批量子代理状态)。
---
## 4. apes-sidebar v2 目标架构
### 4.1 定位
> **apes-sidebar v2 = 构建在 better-sidebar v0.18.0 之上的「工作空间指挥层」**,
> 不重复 files/terminal/browser/editor(better-sidebar 已有),聚焦 Tutti 风格
> **任务 / Agent / 消息 / 应用**四类信息面板 + host 侧路由与 agent 可编程驱动。
仍是一个 **client-half 注册型插件**(注册到 `ctx.betterSidebar`),但工程规范全面对齐 better-sidebar。
### 4.2 目标仓库布局
```
apes-sidebar/
├── package.json # peer: @deepseek-ai/cordis ^4.0.2 / dsh-better-sidebar ^0.18.0 / react ^18
├── cordis.patch.yml # insert apes-sidebar(双挂载守卫,抄 v0.18.0 写法)
├── tsconfig.json / tsconfig.build.json
├── tsdown.config.ts # 多产物:lib/index.js + lib/client.js + lib/client-<chunk>.js
├── vitest.config.ts / playwright.config.ts
├── scripts/e2e-mount.sh # scratch profile 真实挂载冒烟
├── AGENTS.md # 仓库硬约束(参照 better-sidebar)
├── src/
│ ├── index.ts # host half:路由 + 工具(若需要)
│ ├── context-types.ts # declare module '@deepseek-ai/cordis' 类型合并
│ ├── client/
│ │ ├── index.tsx # apply():attachLocale → 注册词典 → registerTabs
│ │ ├── locales.ts # zh/en(跟随 ctx.locale)
│ │ ├── api.ts # 插件自有路由 client
│ │ ├── tabs/
│ │ │ ├── TasksTab.tsx # 会话/任务导航(保留 wb:tasks id 兼容持久化)
│ │ │ ├── AgentsTab.tsx # Agent/子代理拓扑(wb:agents)
│ │ │ ├── AppsTab.tsx # 应用中心(wb:apps)
│ │ │ ├── MessagesTab.tsx # (新)消息中心/审批汇总
│ │ │ └── ProfileTab.tsx # 底部用户区(wb:profile)
│ │ └── builtins.ts # 统一注册(吃自己的狗粮)
│ └── (host routes 需要时) sidechat-routes.ts / subagent-routes.ts
└── tests/
├── tabs.spec.tsx / theme.spec.ts / market-manifest.spec.ts
└── e2e/mount.e2e.ts
```
### 4.3 关键工程决策
1. **TS + tsdown 双产物**:lib/index.js(host)、lib/client.js(client 入口)、
chunks 按需(若引入 mermaid/图表等重依赖);`files` 白名单发布。
2. **client inject 精简**:只声明实际 require 的宿主包
(`@deepseek-ai/dsh-client-ui-primitives` + 服务注入 betterSidebar/sessions/workspaces/locale 经 cordis inject,不塞 dsh.client.inject)。
3. **host half 保持最小**:纯展示可无 host;需要会话日志回放/子代理状态时新增
better-sidebar 风格的自有路由(同源 fence + 会话隔离),不碰 DSH 源码。
4. **Tab id 向后兼容**:`wb:tasks` / `wb:agents` / `wb:apps` / `wb:profile` 保持不变
(避免破坏已持久化 pluginToggles / 用户布局);样式 data 属性带 `apes-sidebar-` 前缀。
5. **配置迁移到声明式 settings**:沿用 `pluginToggles`(switch/select)机制,
在 better-sidebar 设置页渲染;新增 `displayName` select(apes-sidebar/feyOS/自定义)。
6. **i18n 对齐**:词典注册 `ctx.locale`(zh/en);新增 key 同步第三方语言或做 en 回退。
7. **皮肤对齐**:全部视觉消费 `--dsw-alias-*`/`--ds-*` token,硬编码 hex 仅作链尾 fallback。
8. **badge/reminders 对齐 Tutti**:TasksTab 徽标 = 运行中会话/任务数;AgentsTab 徽标 =
运行子代理数(订阅 sessionEngine 而非轮询)。若可经 host 路由读 agent consumer counts,
则消息中心徽标 = working count(对应 Tutti messageCenterWorkingCount)。
9. **懒挂载**:未激活 tab 不渲染内容;首次打开再挂(contentReady + loading),
与 better-sidebar TabContent 行为一致。
10. **测试门禁**:至少 tabs 注册/开关/渲染 smoke + 挂载 e2e + 皮肤 token 守护 +
market manifest 守护(cordis 不得出现在 dependencies)。
---
## 5. 功能清单(Tutti → apes-sidebar 映射)
| # | 能力 | Tutti 参照 | apes-sidebar v2 落地 | 优先级 |
|---|---|---|---|---|
| F1 | 会话/任务导航(工作区+时间分组、搜索、运行徽标) | Tasks | `wb:tasks` 升级:订阅式 + 运行中任务区 + 拓扑 | P0 |
| F2 | Agent 总览(子代理/后台 agent、状态、跳转) | multi-agent | `wb:agents` 升级:订阅式、subagent lineage | P0 |
| F3 | 应用中心(已装应用网格 + 调起) | App Center | `wb:apps`:appsList 可配置、viewId 调起多级降级 | P0 |
| F4 | 消息中心/审批/工作汇总(角标) | messages + messageCenterWorkingCount | `wb:messages`(新) | P1 |
| F5 | 任务自动拆分(goal→tasks) | Tutti goal 编排 | 规划:输入目标→子任务草稿→一键建会话(DSH 能力内简化版) | P2 |
| F6 | 显示名/开关/子设置 | — | 声明式 settings(pluginToggles) | P0 |
| F7 | agent 可编程开 tab | AgentToolSidebarHandle | host route + better-sidebar openTab/openFile 组合 | P1 |
| F8 | 自动折叠/宽度记忆 | shouldAutoCollapseAgentToolSidebar | 交 better-sidebar 容器(若支持)或本地监听 | P2 |
| F9 | Profile 用户区 | — | `wb:profile`:模型/provider/连接状态 | P0 |
| F10 | 深色/浅色/换肤 | skin tokens | 全 token 化 | P0 |
---
## 6. 分阶段实施
### Phase 0:工程骨架(0.5d)
- 建仓、tsconfig/tsdown/vitest/playwright、package.json(peer 钉 cordis ^4.0.2 + better-sidebar ^0.18.0)
- cordis.patch.yml(v0.18.0 守卫写法)、AGENTS.md、最小 host+client 空注册冒烟
- 通过 `pnpm test:mount` 在 scratch profile 验证挂载
### Phase 1:4 Tab 平移 + 质量化(1-1.5d)
- 把旧 client.js 的 4 视图用 TSX 重写,行为不变(保留 wb:* id)
- i18n → ctx.locale、皮肤 → token、样式 → CSS modules
- 配置 → pluginToggles 声明式
- 单测:注册数/开关/渲染/badge
### Phase 2:Tutti 能力增强(2-3d)
- `wb:tasks`:订阅式 sessionEngine 快照 + 运行中任务区(子代理拓扑优先排序)
- `wb:agents`:subagent lineage + 状态订阅
- 新增 `wb:messages`(若 host 有 working count 数据源)
- agent 可编程开 tab(F7)最小实现
### Phase 3:打磨与发布(1d)
- 皮肤 token 全检、a11y、IME guard、错误边界
- e2e 挂载冒烟入 CI、Gitea 发布 + npm 发版脚本
---
## 7. 验证清单(每个 Phase 完成时)
```bash
pnpm typecheck
pnpm test # vitest 单测
pnpm lint:ts
pnpm build && pnpm pack
pnpm test:mount # scratch profile 真实挂载(需 dsh + playwright chromium)
# 真机:dsh plugin --profile desktop add file:<tarball> → 硬刷新 → + 菜单见新 tab
```
---
## 8. 风险与注意
- **DSH rc 漂移**:better-sidebar 是 0.1.2-rc.1 线,DSH 官方 rc.x 发 next dist-tag;
重写期间锁定 devDeps peer,升级前跑 `pnpm peers check`。
- **不要装 dsh-background-agents 0.7.x**:与 DSH 基座 loader 条目 id 冲突 → 启动崩溃
(历史教训),团队房间类能力暂缓。
- **apps 调起无公开一键 API**:维持多级防御(uiConversation.activateView → slots.set → 提示)。
- **client 纯模块表**:运行时只能 require react/react-dom/primitives 等宿主提供模块;
其余依赖必须内联或走懒加载 chunk,否则构建纯度门会拦。
File diff suppressed because it is too large Load Diff
@@ -1,19 +0,0 @@
# Word 缩放与 PDF 拖拽修复设计
> 日期: 2026-08-10
> 状态: 已批准
## Word 缩放
- 默认 100%,范围 50%–200%,步进 10%。
- `Alt + 滚轮` 调整缩放并阻止 viewport 默认滚动。
- 底部固定 range slider,同步显示百分比。
- 使用 CSS `zoom` 缩放 docx-preview 专属容器,使滚动尺寸同步变化。
- 切换 Word 文件时恢复 100%。
## PDF 拖拽
- iframe 放入独立 stage。
- stage 内常驻透明 drag shield。
- Tab 拖拽或面板/分栏调整期间立即启用 shield;结束、取消或窗口失焦时关闭。
- shield 捕获 Chromium 内置 PDF viewer 吞掉的 dragover/pointer 流,正常状态不影响 PDF 交互。
@@ -1,366 +0,0 @@
# Office 三件套预览 — 设计文档
> 日期:2026-08-10
> 状态:已批准(待实现)
> 范围:`dsh-better-sidebar` 插件,`EditorView` 文件类型扩展
## 1. 背景与目标
当前 `EditorView.tsx` 对 `.docx/.xlsx/.pptx` 及旧格式 `.doc/.xls/.ppt` 一律显示 binary 占位(`EditorView.tsx:210`),因为 host 的 `readText` 检测到 NUL 字节就标记 binary(`src/index.ts:108`),而 Office 文件本质是 zip。
**目标**:为 Office 三件套新增有意义的预览能力,保持插件现有架构纪律(host 零改动、bundle 纯度门、跨平台、失败模式全覆盖)。
**非目标**:
- 不做 Office 文件编辑(只预览)
- 不做 `push/pull/fetch` 之类网络 Git 操作(与本次无关,沿用 v1 限制)
- 不做文件 watcher
## 2. 范围与选型
| 类型 | 方案 | 库 | 加载策略 | host 改动 |
|---|---|---|---|---|
| `.docx` | 保真渲染(保留样式/页眉页脚/图片/表格) | `docx-preview` | 静态 import(~500KB,小) | 零 |
| `.xlsx` | 完整电子表格(公式/图表/条件格式/多 sheet) | `@univerjs/*` 全家桶 + `xlsx`(SheetJS,导入) | `dynamic import()` 懒加载(~5MB,首次打开 .xlsx 才加载) | 零 |
| `.pptx` | 降级下载按钮 | — | — | — |
| `.doc/.xls/.ppt`(OLE 二进制) | 降级下载按钮 | 纯前端无成熟库 | — | — |
**决策依据**:
- 纯前端路线符合现有 bundle 纯度门(`tsdown.config.ts:128` 的 purity gate 只禁 `@deepseek-ai/*` value import,普通 npm 包内联不受限)与跨平台原则(无需 host 装重依赖)。
- `pptx` 纯前端无成熟渲染库( OOXML slides + layouts + masters + shapes 解析极复杂),YAGNI 降级。
- 旧 OLE 二进制格式(`.doc/.xls/.ppt`)纯前端基本无解,降级。
- Univer 选 dynamic import 懒加载:静态内联会让 client bundle 从 2MB→7MB(gzip 1.5MB→3MB),首屏负担过重。
## 3. 架构
### 3.1 host 半:零改动
复用现有 `/sidebar/file` media route(`src/index.ts:349-392`):
- 该路由已支持任意文件字节(不限扩展名,只检查 `isWithin(cwd, path)` + `isFile` + `size <= mediaLimit`)
- `MEDIA_TYPES`(`index.ts:44`)对 Office 扩展名 fallback 到 `application/octet-stream`,client 用 `fetch` 拿 ArrayBuffer,不依赖 content-type
- `mediaLimit` 默认 20MB,对 Office 文件足够(超限降级下载)
**不改 `fs.read`**:Office 文件不走文本读取路径(会被 NUL 探测当 binary 拒绝),直接走 media route。
### 3.2 client 半:EditorView 类型扩展
`EditorLoad` 类型(`EditorView.tsx:26`)扩展:
```ts
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx'; content: string; truncated: boolean }
| { status: 'binary' } // 现保留:未知二进制 / pptx / OLE 旧格式 → 下载按钮
```
加载流程(`EditorView.tsx:60` 的 `useEffect`):
1. `api.fsRead(scope, path)` 仍调(用于探测 binary/text)— 对 Office 文件会返回 `{kind:'binary'}`,据此分流
2. 识别扩展名 ∈ `{.docx, .xlsx}` → `setLoad({ status:'ready', kind:'docx'|'xlsx', content:'', truncated:false })`
3. 识别 `.pptx` / `.doc` / `.xls` / `.ppt` / 其他未知 binary → 保持 `{status:'binary'}`,但 binary 占位改为"下载查看"按钮(见 §3.5)
4. `IMAGE_EXT` / `MD_EXT` 分支不变
> 备注:step 1 的 `fsRead` 对 Office 文件是冗余往返(必定返回 binary)。可选优化:client 侧先按扩展名短路,Office 扩展名直接走 media route 不调 `fsRead`。实现时取短路方案,省一次请求。
### 3.3 文件类型分发(纯函数,易测)
新增 `src/client/office-types.ts`:
```ts
export const OFFICE_PREVIEWABLE = ['.docx', '.xlsx'] as const
export const OFFICE_DOWNLOAD_ONLY = ['.pptx', '.doc', '.xls', '.ppt'] as const
export type OfficeKind = 'docx' | 'xlsx' | 'download-only' | null
export function officeKindForExt(ext: string): OfficeKind {
if (ext === '.docx') return 'docx'
if (ext === '.xlsx') return 'xlsx'
if (['.pptx', '.doc', '.xls', '.ppt'].includes(ext)) return 'download-only'
return null
}
```
`EditorView` 的 `extOfPath` 已有(`EditorView.tsx:235`),复用。
### 3.4 DocxView / XlsxView 组件
新增 `src/client/office-view.tsx`,导出 `DocxView` 与 `XlsxView`,各自管理库生命周期。
#### DocxView
```tsx
export function DocxView(props: { scope: SessionScope; path: string; title: string }) {
const hostRef = useRef<HTMLDivElement>(null)
const [load, setLoad] = useState<{status:'loading'|'ready'|'error'; message?: string}>({status:'loading'})
useEffect(() => {
let cancelled = false
const container = hostRef.current
if (container === null) return
;(async () => {
try {
const buf = await fetch(mediaUrl(scope, path)).then(r => r.arrayBuffer())
if (cancelled) return
const { renderAsync } = await import('docx-preview') // 静态 import 亦可,此处用动态保持一致
await renderAsync(buf, container, null, { className: 'docx', inWrapper: true })
if (!cancelled) setLoad({status:'ready'})
} catch (error) {
if (!cancelled) setLoad({status:'error', message: error instanceof Error ? error.message : String(error)})
}
})()
return () => { cancelled = true; container.innerHTML = '' }
}, [scope.sessionId, scope.cwd, path])
// 渲染:host div + loading/error 占位
}
```
- `docx-preview` 的 `renderAsync(arrayBuffer, container, null, options)` 把 docx 渲染到 container,保留样式/页眉页脚/图片(图片库内部 base64 内联处理)
- 卸载清空 `container.innerHTML`(docx-preview 无 dispose API,DOM 清空即可)
#### XlsxView
```tsx
export function XlsxView(props: { scope: SessionScope; path: string; title: string }) {
const hostRef = useRef<HTMLDivElement>(null)
const univerRef = useRef<Univer | null>(null)
const [load, setLoad] = useState<{status:'loading'|'ready'|'error'; message?: string}>({status:'loading'})
useEffect(() => {
let cancelled = false
const container = hostRef.current
if (container === null) return
;(async () => {
try {
const buf = await fetch(mediaUrl(scope, path)).then(r => r.arrayBuffer())
if (cancelled) return
// 懒加载 Univer 全家桶(首次打开 .xlsx 才下载 ~5MB chunk)
const { createUniver, defaultLocale } = await import('@univerjs/core')
const { UniverSheetsCorePlugin } = await import('@univerjs/sheets-ui')
const { UniverSheetsFormulaPlugin } = await import('@univerjs/sheets-formula')
const { UniverSheetsConditionalFormattingPlugin } = await import('@univerjs/sheets-conditional-formatting')
const { UniverSheetsChartPlugin } = await import('@univerjs/sheets-chart')
const { UniverDocsPlugin } = await import('@univerjs/docs-ui')
// xlsx 导入:SheetJS 解析后转 Univer workbook data
const XLSX = await import('xlsx')
const wb = XLSX.read(buf, { type: 'array' })
const workbookData = xlsxWorkbookToUniver(wb) // 转换函数,见 §3.6
const univer = createUniver({
locale: defaultLocale,
plugins: [
UniverDocsPlugin,
UniverSheetsCorePlugin,
UniverSheetsFormulaPlugin,
UniverSheetsConditionalFormattingPlugin,
UniverSheetsChartPlugin,
],
})
univer.createUniverSheet(workbookData)
univerRef.current = univer
if (!cancelled) setLoad({status:'ready'})
} catch (error) {
if (!cancelled) setLoad({status:'error', message: ...})
}
})()
return () => {
cancelled = true
univerRef.current?.dispose() // 关键:防 canvas + worker 泄漏
univerRef.current = null
}
}, [scope.sessionId, scope.cwd, path])
// 渲染:host div + loading/error 占位
}
```
- **dispose 纪律**:卸载必须 `univer.dispose()`,否则 canvas + worker 泄漏(类比 `TerminalView` 的 `term.dispose()` + `observer.disconnect()`)
- Univer 的 UI 需要容器有尺寸(类似 xterm),复用 `ResizeObserver` 模式
### 3.5 binary 占位升级为下载按钮
`EditorView.tsx:210` 现状:`{t('binary')}` 纯文本占位。
改为:
```tsx
{load.status === 'binary' && (
<div className={css.editorBinary}>
<span>{t('binaryNoPreview')}</span>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
)}
```
- 复用现有 `downloadUrl(scope, path)`(`api.ts:140`),走 `/sidebar/file?download=1`
- 对所有 binary 统一(不只 Office)— PDF、zip、exe 等都受益
### 3.6 xlsx → Univer workbook data 转换
SheetJS 解析的 workbook 结构与 Univer 的 `IWorkbookData` 不同,需转换层 `src/client/xlsx-to-univer.ts`:
- SheetJS `wb.SheetNames[]` + `wb.Sheets[name]` → Univer `sheets: Record<sheetId, IWorksheetData>`
- 单元格:`ws[addr]` → `cellData[row][col]`,映射类型(s/n/b/e)、值、样式(对齐/字体/背景/边框)
- 合并单元格:`ws['!merges']` → `mergeData`
- 列宽/行高:`ws['!cols']` / `ws['!rows']` → `columnData` / `rowData`
- 公式:SheetJS `cell.f` → Univer 公式格式
- 图表/条件格式:SheetJS 不解析这些,需 Univer importer 或跳过(首版可只迁数据+样式+合并+公式)
**首版范围**:数据 + 基本样式(字体/颜色/对齐/边框/合并)+ 公式(文本形式即可,Univer 引擎计算)。图表/条件格式若 Univer importer 直接支持则用,否则跳过。
> 备选:用 Univer 官方的 `@univerjs/sheets-import-export`(基于 SheetJS 封装,直接产出 `IWorkbookData`),省去自写转换层。实现时优先评估此包,若版本耦合/体积可接受则用,否则自写。
### 3.7 EditorView 集成
`EditorView.tsx` 的 `renderTab` / `TabContent` 分发(`Sidebar.tsx:49` 的 switch)无需改 — editor tab 仍走 `EditorView`。
`EditorView` 内部 `useEffect`(`EditorView.tsx:60`)加 Office 分支:
```ts
const ext = extOfPath(path)
const officeKind = officeKindForExt(ext)
if (officeKind === 'docx' || officeKind === 'xlsx') {
setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false })
return // 不再走 fsRead(短路,省一次 binary 往返)
}
// 原有 IMAGE / MD / text / binary 逻辑不变
```
渲染区(`EditorView.tsx:220` 附近)加:
```tsx
{load.status === 'ready' && load.kind === 'docx' && (
<DocxView scope={scope} path={path} title={title} />
)}
{load.status === 'ready' && load.kind === 'xlsx' && (
<XlsxView scope={scope} path={path} title={title} />
)}
```
## 4. 错误处理
| 场景 | 检测 | 响应 |
|---|---|---|
| 文件损坏(非有效 zip) | 库抛错 | 显示错误信息 + 下载按钮 |
| `>20MB`(超 `mediaLimit`) | fetch 响应 / host 返回 400 | 提示"文件过大" + 下载按钮 |
| 加密文件(密码保护) | SheetJS/docx-preview 抛特定错 | "不支持加密文件" + 下载按钮 |
| Univer chunk 加载失败 | dynamic import reject | 重试按钮(类比 `TerminalView` 的 `FAILURE_LIMIT`) |
| 网络失败(媒体路由 403) | fetch 非 200 | 错误信息(信任围栏拒绝) |
| Univer dispose 后异步回调 | cancelled flag | 静默(类比现有 `TerminalView` 模式) |
**降级统一出口**:所有 Office 错误分支都附带"下载查看"链接,确保用户始终能拿到文件。
## 5. bundle 与构建
### 5.1 依赖新增(`package.json`)
`dependencies`(运行时,内联进 client bundle):
- `docx-preview`(~500KB)
- `xlsx`(SheetJS,~400KB,xlsx 导入)
- `@univerjs/core` + `@univerjs/sheets` + `@univerjs/sheets-ui` + `@univerjs/sheets-formula` + `@univerjs/sheets-conditional-formatting` + `@univerjs/sheets-chart` + `@univerjs/docs-ui` + `@univerjs/design` + `@univerjs/engine-render`(~5MB 合计)
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`CodeMirror` 也在 `dependencies`)。
> **实施偏差记录(#122,2026-08)**:上文"`xterm` 也在 `dependencies`"已过时——`xterm` 与 `@xterm/addon-fit` 自 v0.13.0 起从 `dependencies` 迁至 `devDependencies`:二者始终内联进 `lib/client-terminal.js`(tsdown `noExternal` 全内联 + CSS 构建期解析),运行时零依赖;同时代码与开发依赖已从已弃用的 `xterm` 迁移到官方新包名 `@xterm/xterm@^5.5.0`(addon-fit@0.10.0 的 peer 约束锁 `^5.0.0`,不用 6.x),消除用户安装时的 deprecated 与 missing-peer 警告。Office 预览若落地,其依赖按同一原则处理。
### 5.2 tsdown.config.ts 调整
现有 client bundle 配置(`tsdown.config.ts:103` 的 `clientBundle()`):
- `format: 'cjs'` + `banner`/`footer` 包成 `__ModuleLoader__.load` 闭包工厂
- `noExternal: (id) => (CLIENT_EXTERNALS.includes(id) ? undefined : true)` — 非 externals 全内联
**dynamic import 兼容性(最大实现风险)**:
Rollup 在 `format: 'cjs'` + `inlineDynamicImports: false` 时会拆 chunk,但 CJS chunk 在浏览器端无 `require`,动态导入链路可能断裂。需 spike 验证:
1. **首选**:保持 `format: 'cjs'`,让 rollup 拆 chunk,验证浏览器 `import()` 是否能加载子 chunk(rollup 会输出 `import()` 调用,浏览器原生支持 ESM dynamic import,但 CJS chunk 的 `module.exports` 形态需 wrapper)
2. **fallback A**:退回静态内联(放弃懒加载,接受 7MB bundle)
3. **fallback B**:Univer 单独打 IIFE bundle `lib/univer-bundle.js`,host 新增静态路由 `/sidebar/univer-bundle.js` serve,client 用 `<script>` 动态注入(破坏 host 零改动,最后手段)
**spike 必须是实现第一步**,结果决定后续构建配置。
### 5.3 CSS
Univer 自带样式,需确保:
- Univer 的 CSS 被 `dsh-css-inline` plugin 正确内联(走 `import '@univerjs/.../style.css'`)
- 或 Univer 的样式注入机制不与现有 `data-plugin-css` tag 冲突
docx-preview 的样式由 `className: 'docx'` 选项控制,可能需补 `src/client/office.module.css` 微调。
## 6. 测试
### 6.1 Unit(纯函数,加进 `tests/unit.spec.ts`)
- `officeKindForExt('.docx')` → `'docx'`
- `officeKindForExt('.xlsx')` → `'xlsx'`
- `officeKindForExt('.pptx')` → `'download-only'`
- `officeKindForExt('.doc')` → `'download-only'`
- `officeKindForExt('.txt')` → `null`
- `officeKindForExt('')` → `null`
### 6.2 xlsx → Univer 转换(若自写转换层)
- fixture xlsx → 解析 → 转换 → 断言 `IWorkbookData` 结构(sheet 名/单元格值/合并/公式)
- 用 `tests/fixtures/sample.xlsx` 作为输入
### 6.3 集成测试限制
- Univer 依赖 canvas API,jsdom 环境无法渲染 → 不测 Univer 渲染产物
- 只测加载逻辑:mock `fetch` 返回 ArrayBuffer,验证 loading → ready/error 状态转换
- docx-preview 同样依赖 DOM,jsdom 可跑 `renderAsync`(无 canvas 依赖)但渲染产物脆弱 → 只测"不抛错"
### 6.4 手动冒烟
- 真实 .docx(含图片/表格/页眉)→ 渲染保真度
- 真实 .xlsx(多 sheet/公式/条件格式)→ 渲染 + 公式计算
- .pptx → 下载按钮
- 加密 docx/xlsx → 错误提示 + 下载
- >20MB → 提示 + 下载
## 7. 国际化
`src/client/locales.ts` 新增 key:
- `downloadToView` — "下载查看" / "Download to view"
- `binaryNoPreview` — "此文件类型不支持预览" / "This file type cannot be previewed"
- `officeTooLarge` — "文件过大,无法预览" / "File too large to preview"
- `officeCorrupt` — "文件损坏或格式无效" / "File is corrupt or in an invalid format"
- `officeEncrypted` — "不支持加密文件" / "Encrypted files are not supported"
- `officeLoadFailed` — "Office 预览组件加载失败" / "Office preview component failed to load"
- `retry` — 复用现有 `terminalRetry` 或新增 `retry`
## 8. 文件改动清单
| 文件 | 改动 |
|---|---|
| `src/client/EditorView.tsx` | 加 docx/xlsx kind 分支 + 下载按钮 binary 占位 |
| `src/client/office-view.tsx`(新) | `DocxView` / `XlsxView` 组件 |
| `src/client/office-types.ts`(新) | 扩展名 → kind 纯函数 |
| `src/client/xlsx-to-univer.ts`(新,若自写转换) | SheetJS workbook → Univer IWorkbookData |
| `src/client/locales.ts` | 新文案 key |
| `src/client/sidebar.module.css` | `.editorBinary` / `.editorDownloadLink` 样式 |
| `package.json` | 加 docx-preview / @univerjs/* / xlsx 依赖 |
| `tsdown.config.ts` | dynamic import 配置(spike 后定) |
| `tests/unit.spec.ts` | officeKindForExt 单测 |
| `tests/fixtures/`(新) | sample.docx / sample.xlsx 测试夹具 |
## 9. 风险登记
| 风险 | 概率 | 影响 | 缓解 |
|---|---|---|---|
| tsdown CJS 闭包不兼容 dynamic import | 中 | 高(阻塞懒加载) | spike 优先;fallback A 静态内联 / fallback B 独立 bundle |
| Univer 体积超预期(>5MB) | 低 | 中 | 评估 tree-shaking;裁剪非必要插件(如 chart) |
| xlsx → Univer 转换层复杂度爆表 | 中 | 中 | 优先用 `@univerjs/sheets-import-export` 官方包 |
| Univer canvas 在某些浏览器渲染异常 | 低 | 中 | 错误边界捕获 + 下载降级 |
| docx-preview 图片 base64 内存爆炸(大 docx) | 低 | 低 | 文件 size cap 已 20MB,可接受 |
| Univer dispose 不彻底导致内存泄漏 | 中 | 中 | 严格 cleanup + 手动验证 Tab 切换/卸载 |
## 10. 实现里程碑(粗略,待 writing-plans 细化)
1. **Spike**:验证 tsdown CJS + dynamic import 兼容性(决定构建策略)
2. **基础**:office-types.ts + EditorView 分发 + binary 下载按钮(最小可用,pptx/旧格式先受益)
3. **docx**:DocxView + docx-preview 集成 + 错误处理
4. **xlsx**:XlsxView + Univer 集成 + xlsx 导入(优先官方 importer)
5. **测试**:unit + fixture + 手动冒烟
6. **收尾**:CSS 打磨 + 国际化 + 文档更新(README 功能一览)
@@ -1,31 +0,0 @@
# PDF 原生预览设计
> 日期: 2026-08-10
> 状态: 已批准
> 范围: `dsh-better-sidebar` 的 `EditorView` 文件预览扩展
## 目标
为 `.pdf` 增加侧边栏内预览,同时保持零新增前端依赖和现有下载降级出口。
## 方案
- Client 按扩展名识别 `.pdf`,短路 `fsRead` 的二进制探测。
- `PdfView` 通过 `fetch(mediaUrl(scope, path))` 读取字节,创建显式
`application/pdf` Blob URL 后交给浏览器原生 `<iframe>`,避免旧 host
或代理缓存 `application/octet-stream` 时触发直接下载。
- Host media route 为 `.pdf` 返回 `application/pdf`。
- PDF 预览工具条始终提供“下载查看”链接。
- 不引入 PDF.js,不实现缩略图、文本搜索或自定义翻页工具栏。
## 错误与限制
- 是否可渲染取决于浏览器内置 PDF 查看器。
- 文件仍受现有 `mediaLimit` 限制。
- 浏览器原生 PDF iframe 对 HTTP 错误没有统一可观测事件;下载链接作为稳定降级出口。
## 测试
- `.pdf` 扩展名识别为 PDF 预览。
- media type helper 对 `.pdf` 返回 `application/pdf`。
- Typecheck、bundle purity、manifest/plugin-shape 测试通过。
@@ -1,37 +0,0 @@
# PPTX 高保真预览设计
> 日期: 2026-08-10
> 状态: 已批准
> 范围: `dsh-better-sidebar` 的 `.pptx` 浏览器预览
## 目标
使用 `@aiden0z/pptx-renderer` 在侧边栏内预览 `.pptx`,保留图片、表格、
图表、SmartArt 与常见 OOXML 样式,同时保持下载降级出口。
## 架构
- `.pptx` 从 `download-only` 调整为独立 `pptx` 预览类型。
- `PptxView` 从现有 media route 获取 `ArrayBuffer`。
- 使用 `PptxViewer.open()` 渲染到库专属 DOM 容器。
- 开启 `lazyMedia`、`lazySlides` 与推荐 ZIP 安全限制。
- UI 提供上一页、下一页、页码和下载按钮。
- 卸载时销毁 viewer 并清空库专属容器。
- 旧 `.ppt/.doc/.xls` 继续仅下载。
## Bundle
- 先验证普通 bundler 入口不会遗留 Node builtin `require()`。
- 若普通入口不纯,切换包提供的 standalone browser ESM 入口。
- 不启用可选 PDF.js EMF fallback,避免额外体积。
## 错误处理
- 损坏、加密或超限文件显示错误信息和下载链接。
- viewer 初始化失败时清理部分创建的实例与 DOM。
## 测试
- `.pptx` 类型分发。
- bundle 模块表 require guard。
- Typecheck、manifest/plugin-shape 与构建通过。
@@ -1,162 +0,0 @@
# 侧边栏设置声明式化 + 内置支持"插头化"设计
**日期**:2026-08-11
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.4.1
## 1. 目标
1. 把 DSH 设置页的「侧边卡片」分区从硬编码 4 行改为**注册表驱动的声明式清单**:
- 展示当前注册的所有侧边栏内容(tab 类型)与文件查看器(viewer),每项可**直接打开/关闭**;
- 每项声明式元数据:**类型(id)、图标(SVG)、相关设置**(如子代理的"检测到子代理就自动打开")。
2. 内置支持(资源管理 / Git / 终端 / 子代理 / 编辑器 / 差异)与 8 个文件预览器继续以**插头(descriptor)形式**注册,并在描述符上携带上述声明。
## 2. 非目标
- 不改宿主路由 / pty / tools 的 gating 语义:`agentTerminalTools` 仍是独立布尔偏好(宿主 `syncToolsGate` 零改动),只是它的**设置行渲染位置**改由终端 tab 描述符声明。
- 不做"已打开 tab 随禁用即时关闭"(保留式语义:禁用只影响新开,已打开 tab 继续渲染)。
- 不实现外部插件自定义 settings 键的动态 schema(见 §6 限制)。
- 不迁移 bundle 形态、不加依赖、不动 peerDependencies。
## 3. 现状回顾
- `SideCardSection.tsx` 硬编码 4 行:`openByDefault` / `defaultWidthPercent` / `autoOpenSubagent` / `agentTerminalTools`。
- `PrefsSchema`(`config.ts`)为扁平对象,`parsePrefs`(`client/prefs.ts`)逐字段校验。
- 内置 tab/viewer 已通过 `registerBuiltins` 走同一注册表(v0.4.0),但描述符缺少设置面元数据:viewer 无 `title`/`icon`,tab 无相关设置声明。
- 服务无启用态概念:`getTabs`/`matchFileViewer`/`openTab` 不过滤。
## 4. 设计
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
```ts
interface SidebarPrefs {
openByDefault: boolean
defaultWidthPercent: number
autoOpenSubagent: boolean
agentTerminalTools: boolean
tabsEnabled: Record<string, boolean> // 按 tab descriptor id;缺省 = 启用
viewersEnabled: Record<string, boolean> // 按 viewer descriptor id;缺省 = 启用
}
```
- schema:`z.dict(z.boolean()).default({})`(schemastery `dict`);旧文档自动解析为 `{}`(全开),零迁移。
- `parsePrefs`:record-of-boolean 校验(非对象/非布尔值回退 `{}` / 丢弃该键)。
- 设置页开关写入 `tabsEnabled: { ...prefs.tabsEnabled, [id]: next }`(viewers 同理)。
### 4.2 声明式描述符(`client/service.ts`)
```ts
interface SidebarSettingToggle {
key: string // SidebarPrefs 字段名('autoOpenSubagent')
title: string | (() => string)
desc?: string | (() => string)
}
interface SidebarSettingsDeclaration {
toggles?: readonly SidebarSettingToggle[]
}
// TabDescriptor 增加:
settings?: SidebarSettingsDeclaration
// FileViewerDescriptor 增加(设置清单展示用):
title?: string | (() => string) // 缺省回退 id
icon?: ReactNode | ((size) => ReactNode)
settings?: SidebarSettingsDeclaration
```
服务新增:
```ts
isTabEnabled(id: string): boolean // store.getPrefs().tabsEnabled[id] !== false
isViewerEnabled(id: string): boolean
```
行为变化:
- `openTab`:类型被禁用 → no-op(`console.warn` 提示);`matchFileViewer`:跳过禁用 viewer(文件落到下一个匹配或下载按钮)。
- `getTabs()/getFileViewers()` 仍返回**全部**注册项(设置页清单必须看到已禁用的项)。
### 4.3 内置声明(`builtins/tabs.tsx` / `builtins/viewers.tsx`)
| tab | 相关设置声明 |
|---|---|
| subagent | `toggles: [{ key: 'autoOpenSubagent', ... }]` |
| terminal | `toggles: [{ key: 'agentTerminalTools', ... }]` |
| explorer/git/editor/diff | 无(仅开关) |
8 个 viewer 全部补 `title`(locales 新增 `viewerImage`…`viewerBinary`)+ `icon`(`icons.tsx` 新增 6 个手绘 outline SVG:image/pdf/docx/xlsx/pptx/markdown;code/binary-download 复用 `IconCodeOutline16` / `IconDownloadOutline16`)。
### 4.4 Store 快照携带 prefs(`client/state.ts`)
`SidebarSnapshot` 增加 `prefs` 字段;`setPrefs` 更新快照并 `notify()`(`setSession`/`update`/`reduce` 构造快照时带上当前 prefs)。效果:+ 菜单等 `useSyncExternalStore` 消费者在设置变更后**立即**重渲染(禁用 git → + 菜单马上消失)。
`makeDefaultState(width, panelOpen, seedExplorer = true)` 增加第三参;`loadState` 在 `tabsEnabled['explorer'] === false` 时传 `false`——新会话不再预置 explorer tab(与"已存在会话保持布局"语义并列:禁用只影响新布局)。
### 4.5 消费点 gating
| 消费点 | 行为 |
|---|---|
| + 菜单(`buildNewTabOptions`) | `filter(!d.hidden && service.isTabEnabled(d.id))` |
| 子代理自动展开 effect | 追加 `isTabEnabled('subagent') === false` 早退(禁用时不展开面板) |
| agent 终端 reconcile(WS 推送) | `isTabEnabled('terminal') === false` 时忽略推送(不自动补 tab;重新启用后下一次推送自愈) |
| 产出文件拦截(`intercept.tsx` select) | `tabsEnabled['editor'] === false` 时 select 返回 null(回落默认交付行) |
| `openTab` / `matchFileViewer` | 见 §4.2 |
| 新会话默认布局 | explorer 禁用 → 空 pane |
### 4.6 设置页声明式渲染(`SideCardSection.tsx`)
- `inject: () => ({ store, service })`(`index.tsx` 闭包直接捕获 service)。
- 注册表清单:本地 state + `service.subscribe` 同步(注册变化罕见,无需 external-store)。
- 行结构:
- **常规**:`openByDefault`、宽度(既有逻辑)。
- **侧边栏内容**:按 `order` 升序、`hidden` 置后遍历 tabs——图标(16) + 标题 + desc=`tab.id`(类型)+ 开关;`settings.toggles` 渲染为缩进子行(仅父级启用时显示,绑定 `prefs[toggle.key]`)。
- **文件预览**:按 priority 降序遍历 viewers——图标 + `title ?? id` + desc=`exts.join(' · ') || t('settingsViewerCatchAll')` + 开关。
- 提交机制不变:串行化 + revision 守卫 + 失败回滚 + `store.setPrefs` 同步。
## 5. 测试
- `service.spec.ts`:启用态缺省 true;`openTab` 拒绝禁用类型;`matchFileViewer` 跳过禁用 viewer(含兜底 code 被关 → undefined)。
- `builtins.spec.ts`:subagent/terminal 的 toggles 声明;全部 tab 有 icon;全部 viewer 有 title+icon。
- `unit.spec.ts`:`parsePrefs` 的 map 校验(非对象/非布尔回退)+ 新字段默认值;`loadState` explorer 禁用 → 空 pane。
- `plugin-shape.spec.ts` / `smoke.spec.ts`:schema 解析与 settings 路由断言补新字段。
- `side-card-section.spec.tsx`(新增):`renderToString` 断言声明式行(图标/标题/id/exts/开关/嵌套开关隐藏规则)。
## 6. 限制与失败模式
- **外部插件的自定义 settings 键**:`toggles[].key` 必须是宿主 `PrefsSchema` 的字段(内置:`autoOpenSubagent` / `agentTerminalTools`);未知键会被 settings seam 丢弃(schemastery object 非严格模式放行未知键但 schema 不含其校验)。文档注明。
- 设置路由失败 / 文档缺失:沿用既有降级(schema 默认 + 本地 state,开关默认全开)。
- 已打开 tab 的类型被禁用:tab 保留可继续使用;关闭后无法重开。
- 父级(子代理/终端)关闭时嵌套开关隐藏,其值保留在 prefs 不销毁。
## 7. 实施偏差记录
1. **嵌套开关的"父级关闭即隐藏"**:设计初稿曾考虑始终显示嵌套开关;实施决定仅在父级启用时渲染(避免"autoOpenSubagent 已开但子代理页面被关"的无效组合),值保留。
2. **宿主 `syncToolsGate` 不改**:`agentTerminalTools` 仅随自身开关走,不与 `tabsEnabled['terminal']` 联动(模型终端工具是模型面向能力,与侧边栏 UI 开关解耦)。
3. **`renderRow` 统一行渲染**:tab/viewer/嵌套开关共用一行渲染器(`sub` 参数区分缩进),CSS 新增 `.sectionHeading` / `.rowIcon` / `.titleLine` / `.subRow`。
4. **清单排序**:tabs 按 `hidden` 置后 + `order` 升序(editor/diff 殿后);viewers 按 priority 降序(code 兜底最后)。
5. **开关改为卡片点击(v0.4.1 追加)**:清单项与嵌套相关设置、`openByDefault` 全部从"复选框行"改为**可点击卡片**——`<button aria-pressed>`,整卡即开关,视觉状态即状态(启用 = 品牌描边 + 交互选中填充 + `IconCheckOutline16` 勾选徽标;禁用 = 中性 hairline + 文字降级)。嵌套相关设置渲染为更小的缩进卡片;宽度输入保持原生设置行(它是数值不是开关)。设置导航齿轮图标为宿主 shell 硬编码(`navIcon` 按 section id 映射,无插件注入点),用户选择不改宿主,保持现状。
6. **小卡片响应式网格 + 二级设置弹窗(v0.4.1 再追加)**:清单项改为**小卡片**,置于 `repeat(auto-fill, minmax(148px, 1fr))` 响应式网格(一行自适应多个,随宽度换行);卡片结构 = 顶行(图标 + 标题省略 + **勾选徽标钉在最右端**)+ 类型/扩展名描述行(省略号截断,`title` 属性给出完整值)。声明了 `settings.toggles` 的卡片右下角渲染**齿轮角标按钮**(`IconSettingsOutline16`,仅父级启用时显示),点击打开**原生 `Modal` 弹窗**,内含 DSH 原生设置行(标题/描述 + 品牌强调原生复选框 + hairline 分隔,末行去分隔线)——二级设置不再内嵌于网格。实现细节:Modal 采用条件挂载(`settingsFor !== null && <Modal open …>`),因为 Modal 原语无条件调用 hooks,常闭挂载在测试的 dual-react(react 18.2 server / 18.3 bundled)分裂环境下会崩;弹窗行体抽成 `FeatureSettingsRows` 供 renderToString 直测。
---
## 8. v0.12.0 设置 seam 开放记录(2026-08-14,feat/plugin-api-v012)
设计正文「key 必须是宿主 PrefsSchema 的字段」的限制在 v0.12.0 被打开(纯增量,`toggles` 的宿主 key 语义不变):
- **持久化**:`SidebarPrefs` 增加 `pluginSettings: Record<string, Record<string, unknown>>`(开放嵌套 map,schema `z.dict(z.dict(z.any())).default({})`,按 descriptor id 分桶)——外部插件的自有设置不再需要宿主 schema 字段、不再被 settings seam 丢弃。`parsePrefs` 增加 `pluginSettingsMapOf` 校验(非对象整体回退 `{}`)。
- **声明**:`SidebarSettingsDeclaration` 增加 `pluginToggles`(声明式行,控件同 toggles 的 switch/text/number,值须 JSON 可序列化)与 `render?: (props: SidebarSettingsRenderProps) => ReactNode`(自定义面板:store/service/prefs/pluginSettings/updatePluginSetting/close;抛错被吞并显示内联错误)。
- **渲染**:齿轮弹窗体抽为 `SettingsBody`(`render` 存在时优先,否则 toggles 行 + pluginToggles 行,后者把 pluginSettings 投影到 prefs 面复用 `FeatureSettingsRows`);`settingsFor` 状态从 `TabDescriptor | null` 扩为 `TabDescriptor | FileViewerDescriptor | null`——viewer 卡片 v0.12.0 起同样有齿轮设置弹窗(§4.6 原仅 tab)。
- **向后兼容**:旧设置文档无 `pluginSettings` 字段 → schema default `{}`;`toggles` 行为与 v0.11.0 完全一致。
---
## 9. 设置页 UI/UX 现代化(feat/settings-sidecard-uiux)
纯视觉/交互入口改造,无 API 与持久化语义变化(`settings.toggles` / `pluginToggles` / `render` / Modal 条件挂载契约全部不变):
- **二级设置入口:齿轮角标 → 卡片底部设置条**。原 `.cardGear`(16×16px 三级色透明幽灵按钮,`absolute; top:46px` 像素钉位 + `.cardWithGear` padding 耦合)可发现性不足;改为卡片底缘的**全宽设置条** `.cardSettings`(hairline 分隔、齿轮图标 12px + 「功能设置」文字标签 11px/500,hover 换 `--dsw-alias-interactive-bg-hover-accent` 品牌淡底 + brand 字色)。可见性条件不变(仅父级启用且声明 settings);aria-label 模式 `"${title} ${t('settingsPopup')}"` 不变(测试契约)。常规行齿轮 `.rowGear` 升级为 22×22 带 border-l2 边框的芯片按钮(hover 同样 accent 引导)。
- **启用态指示(后续迭代:勾选 → 紧凑开关滑块)**:启用卡片描边从全强度品牌色柔化为 `color-mix(button-primary-fill 45%)`。第一版曾把勾选徽标改为 `--dsw-alias-state-success-primary` 实心绿(绿 = 已开启确认,brand = 激活强调);按反馈再迭代为**紧凑品牌开关滑块** `.cardSwitch`(30×16 track + 10px thumb,开 = brand 填充 + thumb 反色,关 = 中性且不渲染滑块)——纯视觉指示,卡片本身仍是开关(`aria-pressed` 语义不变),不再使用 success 色。全部颜色仍为 `--dsw-alias-*` 令牌派生,零硬编码。
- **卡片等高**:`.card` 统一 `min-height: 106px` + `.cardMain { flex: 1 }` + `.cardTop { min-height: 28px }`——带设置条的卡片(天然更高)与不带设置条的卡片、无图标卡片全部等高;`prefers-reduced-motion` 覆盖新增的 `.cardSwitchTrack/Thumb` 过渡。
- **二级弹窗长列表**:`.popupRows` 增加 `max-height: min(56vh, 480px)` + 内部 `overflow-y: auto`(终端等声明 6 行设置的场景下弹窗壳体与 Done 按钮固定,仅行区滚动);行密度收紧(padding 12/14、gap 8)。
- **节奏**:section gap 16 / group padding 20 / 网格 `minmax(180px,1fr)` gap 12;计数徽章改为 `accent-soft` 淡底 pill(versionBadgeTag 同款令牌配方);开关关态 thumb 降一档(label-tertiary)+ track 底 layer-2。
- **动效纪律**:全部过渡 ≤160ms 且仅 background/border-color/color;`prefers-reduced-motion` 扩展覆盖 `.cardSettings` / `.rowGear` / `.popupRow`。
- **清理**:合并 `.pluginEntries` 重复定义(原 L598 与 L795 两处,保留带 max-height/overflow 的一处)。
@@ -1,137 +0,0 @@
# 内置 HTML 预览器 + 内嵌网页浏览器(沙箱化)设计
**日期**:2026-08-11
**状态**:已批准,已实施
**作者**:opencode + 用户
**当前版本**:v0.4.3(main `35cde82`)
**目标版本**:v0.5.0
## 1. 目标
在 `dsh-better-sidebar` 内按既有 builtins 模式新增两个内置能力:
1. **`html` 文件预览器**:`.html`/`.htm` 文件在侧边栏编辑器中默认以真实渲染预览(相对资源可加载),可切换代码编辑(CodeMirror HTML 高亮 + 保存)。
2. **`browser` 浏览器 tab**:`+` 菜单新增「浏览器」页面,地址栏输入 URL 在沙箱 iframe 中浏览,支持后退/前进/刷新,URL 与标题按 tab 持久化。
安全问题为第一优先级(用户显式要求"注意安全问题"):所有渲染走**不透明源沙箱 iframe**,host 侧新增 `/sidebar/html` 路由带 CSP + 信任围栏 + cwd 边界。另按用户追加要求提供**关闭沙箱的子设置**(默认关闭该设置;开启需明确勾选,设置项与界面都带安全警告文案)。
## 2. 复用门(dsh-reuse-first)
`NO EXISTING CAPABILITY`:已搜索 DSH checkout(docs/*-catalog、apps/web、packages 源码——sidecar/iframe/webview/html preview/CSP 均无等价物;`WorkspaceBrowser` 只是目录树)与 dsh-external org hub 目录(browser/html/webview/iframe/sidecar/viewer/preview 均 0 命中)。全新实现,落点为本仓库 builtins(用户已确认)。
## 3. 威胁模型与安全基线
| 威胁 | 对策 |
|---|---|
| 恶意 HTML/网页读取 GUI 数据(localStorage、`/sidebar/api`、同源 cookie) | iframe 一律 `sandbox` 且**不含 `allow-same-origin`** → 文档源为不透明源(null)。**禁止 srcdoc**(srcdoc 在无沙箱时继承父源),一律 route-src |
| 预览/浏览内容劫持 GUI 页面(top 导航) | 不含 `allow-top-navigation` / `allow-top-navigation-by-user-activation` |
| 表单 CSRF 打到本地服务 | html 预览器 iframe **不含 `allow-forms`**;浏览器 tab 含 `allow-forms`(登录流必需,残余风险见 §9) |
| 浏览器 tab 访问内部地址(GUI 自身、localhost、127.0.0.0/8) | 地址栏导航前客户端阻断(`browser.ts` 纯函数);`javascript:`/`data:`/`file:` 等非 http(s) 一律拒绝 |
| 预览页脚本请求 `/sidebar/api` | 三层:不透明源 fetch 被 CORS 拒读;信任围栏拒 `sec-fetch-site: cross-site`;`Origin: null` 无法伪造同源 |
| 敏感信息泄露给被浏览站点 | iframe `referrerPolicy="no-referrer"`;权限策略 `allow=""`(拒绝 camera/mic/geolocation 等一切策略特性) |
| 直接顶层打开 `/sidebar/html/*` URL(弹窗等) | 路由响应带 `Content-Security-Policy: sandbox ...; object-src 'none'`,顶层同样被沙箱化 |
| 路径逃逸 | 路由复用 `requireAbsolute` + `isWithin(cwd, path)` + `mediaLimit` 上限 + 信任围栏 |
| 关闭沙箱(用户主动选择) | 每功能独立子设置(`htmlViewerNoSandbox` / `browserNoSandbox`,默认 false);设置文案 + 渲染时持久警告条双警告 |
## 4. 变更清单
### 4.1 host:`/sidebar/html` 路由(src/html-route.ts 新 + src/index.ts)
URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相对引用会丢弃 base 的 query,query 式 URL 下 `./style.css` 会丢失 session 作用域:
```
/sidebar/html/<sessionId>/<绝对路径逐段 encodeURIComponent>
例:/sidebar/html/S/Users/me/proj/index.html
./style.css → /sidebar/html/S/Users/me/proj/style.css ✓
Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
```
- `src/html-route.ts`(无依赖纯模块,client 可导入不触发纯度门):`encodeHtmlUrl(sessionId, path)`、`decodeHtmlUrl(pathname)`(404 错前缀;400 空路径/双斜杠/畸形编码/缺 sessionId 或文件路径/空段)。
- `src/index.ts`:注册 `/sidebar/html` prefix 路由(fence → GET 校验 → decode → `requireAbsolute` → `isWithin(cwd)` → stat isFile + `mediaLimit` → 读文件),响应头:
`content-type`(新增 `.html`/`.htm` → `text/html` 到 MEDIA_TYPES)、`cache-control: no-cache`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: no-referrer`、`Content-Security-Policy: sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'`。
- cwd 语义与媒体路由一致:session store 权威;会话附着前的首请求回退 `process.cwd()`,通常被 `isWithin` 拒绝(可接受)。
### 4.2 client:`html` 预览器(TextEditor.tsx 扩展 + builtins/viewers.tsx)
- descriptor:`{ id:'html', exts:['html','htm'], priority:0, fetchStrategy:'fsRead', settings.toggles:[htmlViewerNoSandbox] }`。
- TextEditor:`markdown || html` 都显示预览/编辑切换;html 预览分支渲染:
`<iframe src={htmlUrl(scope,path)} sandbox="allow-scripts allow-popups allow-downloads allow-modals" referrerPolicy="no-referrer" allow="" />`
- 导出 `HTML_IFRAME_SANDBOX` 常量(测试钉死);沙箱关闭时 `sandbox` 属性整体移除 + 渲染 `sandboxWarning` 警告条。
- 预览显示**已保存文件**(route-src),不反映未保存草稿(有意取舍,文档化)。
### 4.3 client:`browser` tab(BrowserView.tsx 新 + browser.ts 新 + builtins/tabs.tsx + state.ts)
- state.ts:`SidebarState.nextBrowser`(默认 1;sanitize 对缺失/畸形值回退 1,旧持久化布局继续加载);新 reducer `patchTab(state, tabId, {title?, path?})`。
- tabs.tsx:`{ id:'browser', order:50, settings.toggles:[browserNoSandbox], createTab: 铸造 browser:<n> + nextBrowser 递增 }`。
- browser.ts(无依赖纯模块):`isLoopbackHostname`(localhost/::1/0.0.0.0/127/8);`normalizeBrowserUrl(input, selfOrigin)`:
- 无 scheme → 补 `https://`;`://` 完整 URL 走解析后协议兜底;已知危险 scheme 前缀(javascript/data/file/about/...)直接拒绝;`host:port` 形态(如 `example.com:8080`)不被误判为 scheme。
- 拒绝顺序:协议 → 自身 origin **放行**(GUI 自身可在侧边栏打开,沙箱不透明源兜底;须先于 loopback 判定,因其主机通常就是 loopback)→ loopback 阻止 → ok。
- BrowserView:地址栏(后退/前进/刷新/输入/前往)+ iframe + 状态行;URL 经 `patchTab` 持久化到 `tab.path`/`tab.title`(hostname);历史栈仅记录地址栏导航(iframe 内部跳转跨域不可见,已知限制);无 URL 时显示起始页(不自动加载任何站点)。
- iframe:`sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox"`(导出 `BROWSER_IFRAME_SANDBOX`;无 allow-same-origin / allow-top-navigation;allow-popups-to-escape-sandbox 供 OAuth 弹窗);沙箱关闭时移除属性 + 警告条。
- 常驻底部小字沙箱说明(`browserHint`)。
### 4.4 设置(prefs-shared.ts + config.ts + SideCardSection 声明式渲染)
- `SidebarPrefs` 新增 `htmlViewerNoSandbox` / `browserNoSandbox`(boolean,默认 false);`SIDEBAR_PREFS_DEFAULTS` 与 `PrefsSchema` 同步;`parsePrefs` 按字段校验回退。
- 声明式设置:html viewer 行与 browser tab 行各自 `settings.toggles`,设置页自动渲染(父级关闭时隐藏),文案含「(不安全)」+ 后果说明。
### 4.5 其他
- `api.ts`:`htmlUrl(scope, path)` = `encodeHtmlUrl(sessionId, path)`。
- `icons.tsx`:`IconHtmlOutline16`(文件 + `‹/›`)、`IconGlobeOutline16`(地球)。
- `locales.ts`:zh/en 双份新键(viewerHtml/browser/浏览器栏/阻止原因/沙箱提示/设置文案)。
- `sidebar.module.css`:`sandboxWarning`、`editorHtml`、`.browser*` 系列。
## 5. 测试
| 文件 | 覆盖 |
|---|---|
| tests/html-route.spec.ts(新) | 编码/解码往返(POSIX/Windows/特殊字符)、相对解析保持同路由同 session、404/400 拒绝、遍历段留给 isWithin |
| tests/browser.spec.ts(新) | 补 https、host:port 不误判、危险 scheme/loopback 拒绝、自身 origin 放行、invalid |
| tests/sandbox-views.spec.tsx(新) | iframe sandbox 常量钉死(无 allow-same-origin/top-navigation)、route-src 非 srcdoc、referrer/allow 属性、沙箱关闭 → 无 sandbox 属性 + 警告条、markdown 分支回归、浏览器起始页不自动加载 |
| tests/builtins.spec.ts | 7 tab / 9 viewer 清单、html 扩展名匹配、browser createTab 铸造与 nextBrowser、两个 toggles 声明 |
| tests/unit.spec.ts | nextBrowser sanitize(缺失/畸形回退 1)、patchTab 更新/无操作、parsePrefs 新字段默认与校验 |
| tests/smoke.spec.ts | fenced routes 清单 + `/sidebar/html` |
## 6. 版本与文档
- 版本 0.4.3 → 0.5.0(package.json + dsh.plugin.json 同步,manifest-consistency 守卫)。
- README/AGENTS 内置清单更新为 7 tab + 9 viewer。
## 7. 验收标准
1. `pnpm typecheck && pnpm build && pnpm test` 全绿(245 用例)。
2. 手动:打开 `.html` 默认预览渲染(相对资源可加载),可切编辑/保存;`+` 菜单浏览器 tab 多开、地址栏浏览、后退/前进/刷新、刷新页面恢复 URL 与标题。
3. 安全:预览/浏览 iframe 无 allow-same-origin/top-navigation;`javascript:`/`data:`/`file:`/localhost/127.x 均被阻止并提示(自身 origin 允许);`/sidebar/html` 越界 403、超限 400、跨站 403;预览页 fetch `/sidebar/api` 读不到(CORS),预览器表单被沙箱禁(无 allow-forms)。
4. 关闭沙箱开关:设置页勾选后界面出现持久警告条,iframe 无 sandbox 属性。
## 8. 实施偏差记录
- 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。
- `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。
- `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。
- **UNC 路径编码标记(PR #134 整合 #79/#115 时)**:`/sidebar/html` 对 `\\server\share\...` / `//server/share/...` 采用 sessionId 后的 `//` 标记,且解码统一重建为**前斜杠** `//server/share/...` 而非反斜杠——`node:path` 在 win32/POSIX 上分别解析为 `\\server\share\...` / `/server/share/...`,因此无需任何平台信号;原「400 双斜杠」约束改为「首个空段仅作 UNC 标记,其余空段仍 400」。
## 8.1 追加需求(2026-08-11,v0.5.0 内)
1. **设置页滚动**:`SideCardSection.module.css` 的 `.section` 填满设置壳 options 区并内部滚动(`height:100% + min-height:0 + overflow-y:auto`),7 tab + 9 viewer 卡片再多也不会被裁切。
2. **沙箱状态实时显示 + 临时解锁**:新增 `SandboxStatusBar`(HTML 预览与浏览器 tab 共用)——沙箱开启显示绿色状态 + 「临时解锁(不安全)」按钮;关闭(全局设置或本页临时解锁)显示**红色警示**(`state-error-primary` 色系 + 危险文案)与「恢复沙箱」按钮(仅临时态)。临时解锁是组件内 state,不写回全局设置,随页面/标签卸载失效;iframe 随沙箱翻转重挂载。
3. **聊天/界面外链在侧边栏打开**:新设置 `browserInterceptLinks`(默认开,受浏览器 tab 启用开关 gate)——`link-intercept.ts` 在 document capture 阶段拦截 http(s) **外部**链接点击(同源/非 http(s)/修饰键点击一律放行),经 `openTab({type:'browser', url, title: hostname})`(openTab seed 新增 `url` 字段:落地后 patchTab 预填 path)在侧边栏打开。浏览器 tab 地址栏新增「在浏览器中打开」按钮(`window.open(url,'_blank','noopener')`,用户手势触发,等价真实浏览器新标签)。
## 8.2 追加需求(2026-08-11 二轮,v0.5.0 内)
1. **HTML「默认非安全」二级设置**:`htmlViewerDefaultUnsafe`(默认 false)——打开 HTML 文件时预览**初始**处于非沙箱状态(localUnlock 初始值取自该 pref),状态行仍可一键「恢复沙箱」;与 `htmlViewerNoSandbox`(全局强制关闭,不可界面恢复)语义区分。
2. **沙箱状态行单行化并取代底部说明**:浏览器 tab 底部常驻 `browserHint` 条移除,说明并入 `SandboxStatusBar` 开启态文案(「沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用」),状态行 `nowrap + ellipsis + title` 恒为一行;关闭态同样单行红色警示。
3. **「在浏览器中打开」图标放大**:`IconRightUpOutline14` → `IconRightUpOutline16`(15px 渲染)。
## 8.3 追加需求(2026-08-11 三轮,v0.5.0 内)
1. **图标改 15px**:`<IconRightUpOutline16 size={15} />`。
2. **嵌入拒绝面板**:站点通过 `X-Frame-Options` / CSP `frame-ancestors` 禁止嵌入时,浏览器 tab 不再显示空白拒绝框,而是显示 `BrowserEmbedBlocked` 面板——「{host} 拒绝了嵌入请求」+ 原因说明 + 「在浏览器中打开」(`window.open`)+ 「仍然加载」两个按钮。检测:新增 host 路由 `browser.probe`(HEAD→GET 兜底,8s 超时,仅 http(s) 非 loopback,返回目标响应头的 `x-frame-options`/`frame-ancestors`,受信任围栏保护)+ 客户端 `embeddabilityOf` 判定(XFO DENY/SAMEORIGIN 或 frame-ancestors 不含 `*` → blocked;探测失败 → unknown 保持普通 iframe)。探测在每次导航(含恢复的 path)时经 `useEffect(url)` 触发;「仍然加载」以 `forceEmbed` 保留普通 iframe。
## 9. 已知限制与残余风险
- 浏览器沙箱无登录态/第三方 Cookie 受限(SameSite=Lax 在跨站 iframe 不发送),部分站点登录需走弹窗(allow-popups-to-escape-sandbox 已开)。
- iframe 内部链接跳转不进后退栈(跨域不可见);被 `X-Frame-Options`/`frame-ancestors` 拒绝的站点显示空白(站点策略,无法绕过)。
- 用户主动浏览的恶意站点仍可在沙箱内发起盲 GET(无法读取响应)到本地服务;站点重定向链可把 iframe 带到 localhost(内容仍沙箱化、受围栏保护)。
- 关闭沙箱后预览/浏览内容获得 GUI 同源权限(可读会话文件/本地存储/调内部接口)——仅建议对完全可信内容开启,设置与界面双重警告。
@@ -1,432 +0,0 @@
# DSH-better-sidebar 服务化改造设计
**日期**:2026-08-11
**状态**:已批准,待实施
**作者**:opencode + 用户
**当前版本**:v0.3.0(main `069c754`)
**目标版本**:v0.4.0
## 1. 目标
将 `dsh-better-sidebar` 从一个"纯消费方"插件改造为"服务提供者":
1. 允许其他 DSH 插件向 better-sidebar **注册新的侧边栏页面(tab 类型)**
2. 允许其他 DSH 插件向 better-sidebar **注册文件类型预览器**
3. 把现有 5 个内置 tab 和 6 种内置文件预览器**全部迁移到新注册表**(吃自己的狗粮)
## 2. 非目标(Out of Scope)
- **不动 host 半**(`src/index.ts` 的 HTTP 路由 / pty / tools / settings)
- **不迁移到标准 bundle 形态**(不加 `cordis.patch.yml`、不弃用 `dsh.plugin.json`、不放弃 `client-registry.js`)—— 作为独立工作
- 不动 server 端 `ctx.provide`:本次纯 client 端服务
## 3. 现状回顾
### 3.1 Tab 系统硬编码点(7 处)
| 文件:行 | 用途 |
|---|---|
| `src/client/state.ts:14` | `TabType` 联合类型(5 字面量) |
| `src/client/state.ts:295-297` | `isSingle(type)` 单实例判定 |
| `src/client/state.ts:555-561` | `sanitizeNode` 白名单校验 |
| `src/client/Sidebar.tsx:56-83` | `TabContent` switch 渲染 |
| `src/client/Sidebar.tsx:87-102` | `buildNewTabOptions` + 菜单项 |
| `src/client/Sidebar.tsx:341-364` | `onNewTab` 创建分支 |
| `src/client/icons.tsx:26-33` | `tabTypeIcon` 图标 switch |
### 3.2 文件预览硬编码点
- `src/client/EditorView.tsx:58-106`:顺序 if/else 选 `load.kind`
- `src/client/EditorView.tsx:251-272`:if 链按 `load.kind` 渲染组件
- 4 个预览组件(DocxView / XlsxView / PptxView / PdfView)静态 import 在 `EditorView.tsx:22-26`
- 3 个纯函数模块(`office-types.ts` / `image-types.ts` / `pdf-types.ts`)+ `lang.ts` 已半抽象,可作为内置注册器的实现细节复用
### 3.3 服务暴露现状
- `src/index.ts` / `src/client/index.tsx` 全文搜 `ctx.provide` / `Context.service` / `extends Service` —— **零命中**
- `package.json` 的 `exports` 没有暴露任何供外部 import 的注册 API
- README:262 已承认 portal 挂载的限制(整面板 slot 不可用),但未提服务化方案
### 3.4 平台约束
- **构建期纯度门**(`tsdown.config.ts:177-180`):client bundle 禁止 value-import 别的插件代码;**type-only import 会被擦除,不触发门禁**
- **双 cordis 实例**:外部插件解析不到 DSH monorepo 的 cordis augmentation,必须由 better-sidebar 自己导出 `declare module 'cordis'` augmentation
- **ModuleLoader 运行时支持跨 bundle require,但被构建门挡** → 协作只能走 cordis 服务或 slot
## 4. 方案选型
| 方案 | 机制 | 评估 |
|---|---|---|
| **A. `ctx.provide` + 服务对象(采纳)** | client `apply()` 内 `ctx.provide('betterSidebar', service)`,service 暴露 `registerTab/registerFileViewer` 返回 disposer | registry 语义自然;disposer 生命周期贴合 cordis fiber;precedent:`dsh/packages/client/locale/src/client/index.ts:362` |
| B. `ctx.slots.inject` 声明式 slot | 声明 `betterSidebar.tabType` 等 slot | 偏 UI 链替换,对带元数据 + disposer 的 registry 别扭 |
| C. `extends Service` 类形式 | 完整 Service 类 + Config schema | client 半不支持 `extends Service`;本次纯 client,没必要 |
**采纳方案 A**。
## 5. 服务接口设计
### 5.1 主服务接口
```ts
// 新文件 src/client/api.ts
export interface BetterSidebarService {
/** 注册一种 tab 类型;返回 disposer */
registerTab(descriptor: TabDescriptor): () => void
/** 注册一种文件预览器;返回 disposer */
registerFileViewer(descriptor: FileViewerDescriptor): () => void
/** 运行时快照(同步,供 React useSyncExternalStore 用) */
getTabs(): readonly TabDescriptor[]
getFileViewers(): readonly FileViewerDescriptor[]
/** 匹配文件的预览器(按 priority 降序、exts/includes、detect() 三段匹配) */
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
/** 工具方法:让外部 tab 也能开/关 tab */
openTab(tab: { type: string; title: string; path?: string }): void
closeTab(tabId: string): void
/** 订阅 registry 变化(注册/卸载时触发) */
subscribe(listener: () => void): () => void
}
```
### 5.2 TabDescriptor
```ts
export interface TabDescriptor {
id: string // 唯一,如 'explorer' / 'my-plugin:db'
title: string | (() => string) // i18n 友好
icon?: ReactNode | ((size: number) => ReactNode)
order?: number // + 菜单排序,默认 100
single?: boolean // 单实例(explorer/git/subagent)
hidden?: boolean // 不出现在 + 菜单(editor 用)
available?: (ctx: Context, scope: SessionScope) => boolean
component: (props: TabComponentProps) => ReactNode
}
export interface TabComponentProps {
ctx: Context
store: SidebarStore
scope: SessionScope
tab: SidebarTab
}
```
### 5.3 FileViewerDescriptor
```ts
export interface FileViewerDescriptor {
id: string // 'image' / 'pdf' / 'my-plugin:csv'
exts: readonly string[] // 小写无点,如 ['png','jpg']
priority?: number // 高优先;默认 0;内置默认 0;兜底 code 用 -100
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
detect?: (path: string, head: Uint8Array) => boolean // 内容嗅探(覆盖 exts)
load?: (path: string, scope: SessionScope) => Promise<unknown> // fetchStrategy='custom' 时
component: (props: FileViewerProps) => ReactNode
}
export interface FileViewerProps {
ctx: Context
store: SidebarStore
scope: SessionScope
path: string
title: string
content?: string // fetchStrategy='fsRead' 时
truncated?: boolean
mediaUrl?: string // fetchStrategy='mediaUrl' 时
customData?: unknown // fetchStrategy='custom' 时 load() 的返回值
}
```
## 6. Cordis 类型合并 outlet
```ts
// 新文件 src/types.ts (纯类型)
import type { BetterSidebarService } from './client/api'
declare module 'cordis' {
interface Context {
betterSidebar: BetterSidebarService
}
}
```
通过 `src/index.ts` 的 `export type * from './types.ts'` re-export 到主入口。Consumer 插件 `import type {} from 'dsh-better-sidebar'` 触发合并。
## 7. package.json exports 新增
```jsonc
"exports": {
".": { ... },
"./invariant": { ... },
"./client": { ... },
"./client/api": { // 新增,纯类型导出
"types": "./lib/types/client/api.d.ts",
"default": "./lib/client.js" // 运行时落点回到 client bundle(不会被 value import)
},
"./src/*": "./src/*",
"./package.json": "./package.json"
}
```
`files` 字段相应加 `lib/types/client/api.d.ts`。
## 8. 内置 tab 自我注册(吃狗粮)
`src/client/index.tsx` 的 `apply()` 内:
```ts
const service = createBetterSidebarService(store)
ctx.provide('betterSidebar', service)
registerBuiltins(service) // 5 个内置 tab + 内置 viewer 全部用 service.registerTab/registerFileViewer
```
### 8.1 5 个内置 tab
| id | order | single | hidden | available | 组件 |
|---|---|---|---|---|---|
| `editor` | -1 | false | true | always | `EditorView`(由 open-file 流程触发,不在 + 菜单) |
| `explorer` | 10 | true | false | always | `ExplorerView` |
| `git` | 20 | true | false | 检查 `.git` 存在 | `GitView` |
| `subagent` | 30 | true | false | always | `SubagentView` |
| `terminal` | 40 | false | false | 检查 terminal 配额 | `TerminalView` |
### 8.2 内置 file viewer
| id | exts | priority | fetchStrategy | 组件 |
|---|---|---|---|---|
| `image` | png/jpg/jpeg/gif/webp/svg/bmp/ico/avif | 0 | mediaUrl | `<img>` |
| `pdf` | pdf | 0 | mediaUrl | `PdfView` |
| `docx` | docx | 0 | mediaUrl | `DocxView` |
| `xlsx` | xlsx | 0 | mediaUrl | `XlsxView` |
| `pptx` | pptx | 0 | mediaUrl | `PptxView` |
| `markdown` | md/markdown | 0 | fsRead | `<MarkdownText>` |
| `code` | [](兜底) | -100 | fsRead | CodeMirror |
| `binary-download` | doc/xls/ppt + NUL 探测为 binary | -50 | binary-download | 下载按钮 |
`matchFileViewer(path, head?)` 算法:
1. 按 `priority` 降序遍历
2. 若 `descriptor.detect` 提供且 `head` 可用 → 调 `detect(path, head)`,true 则命中
3. 否则匹配 `exts`(小写无点)
4. 全部 miss → 返回 `undefined`(caller 走兜底 `binary-download`)
## 9. 7 处硬编码 switch 改造点
| 文件:行 | 原状 | 改造后 |
|---|---|---|
| `src/client/state.ts:14` | `TabType` 联合 5 字面量 | `type TabType = string`;保留 `SIDEBAR_BUILTIN_TAB_TYPES` 常量数组供向后兼容 |
| `src/client/state.ts:295-297` `isSingle` | switch 5 字面量 | `(type) => service.getTabs().find(t => t.id === type)?.single ?? false`;store 持有 service 引用 |
| `src/client/state.ts:555-561` `sanitizeNode` | 白名单 5 type | 未注册 type 标记 `orphaned: true` 不丢弃,渲染占位卡 |
| `src/client/Sidebar.tsx:56-83` `TabContent` | switch 5 分支 | 查 `service.getTabs().find(t => t.id === tab.type)`,调 `descriptor.component(props)`;未找到渲染 `<OrphanedTab/>` |
| `src/client/Sidebar.tsx:87-102` `buildNewTabOptions` | 硬编码 4 项 | `service.getTabs().filter(t => !t.hidden && (t.available?.(ctx, scope) ?? true)).sort(byOrder).map(...)` |
| `src/client/Sidebar.tsx:341-364` `onNewTab` | if optionId === 'explorer' 等 | `service.openTab({ type: optionId, title: descriptor.title() })` |
| `src/client/icons.tsx:26-33` `tabTypeIcon` | switch 5 分支 | 废弃;调用方直接读 `descriptor.icon` |
## 10. 文件预览路由改造
### 10.1 `src/client/EditorView.tsx:58-106`(选预览器)
```ts
const viewer = service.matchFileViewer(path, head?)
if (!viewer || viewer.fetchStrategy === 'binary-download') {
setLoad({ status: 'binary' })
return
}
switch (viewer.fetchStrategy) {
case 'mediaUrl': setLoad({ status: 'ready', kind: 'mediaUrl' }); break
case 'fsRead':
api.fsRead(scope, path).then(({ content, truncated }) =>
setLoad({ status: 'ready', kind: 'fsRead', content, truncated })
)
break
case 'custom':
viewer.load(path, scope).then(data =>
setLoad({ status: 'ready', kind: 'custom', customData: data })
)
break
case 'none': setLoad({ status: 'ready', kind: 'none' }); break
}
```
### 10.2 `src/client/EditorView.tsx:251-272`(渲染)
```ts
const viewer = service.matchFileViewer(path)
if (!viewer) return <BinaryDownload ... />
return <viewer.component {...props} />
```
4 个内置预览组件(DocxView/XlsxView/PptxView/PdfView)从 `EditorView.tsx` 静态 import 移到 `registerBuiltins` 内部 import + 注册。
## 11. 持久化与降级
- localStorage 里 `orphaned` tab:渲染 `<OrphanedTab title="..." hint="Plugin not loaded: ${tab.type}"/>` + 关闭按钮
- `sanitizeState` / `sanitizeNode` 不再丢弃未识别 type,改为标记 `orphaned`,保留 `title/path` 以备插件恢复时复用
## 12. Consumer 插件使用范例
### 12.1 Consumer `package.json`
```jsonc
{
"peerDependencies": {
"cordis": "^4.0.0-rc.8",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
### 12.2 Consumer client half
```ts
// my-plugin/src/client/index.tsx
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar', 'slots']
export function apply(ctx: Context): void {
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
icon: <DbIcon />,
order: 50,
component: ({ scope, store, tab }) => <DbView scope={scope} />,
})
)
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchCsv(scope, path)),
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
})
)
}
```
## 13. 测试策略
### 13.1 单元测试
- `tests/service.spec.ts`:`createBetterSidebarService` 的 register/dispose/subscribe 生命周期
- `tests/match-file-viewer.spec.ts`:`matchFileViewer` 的 priority + exts + detect 三段匹配算法
- `tests/builtins.spec.ts`:5 个内置 tab + 8 个内置 viewer 都正确注册
- 现有 `tests/state.spec.ts` / `tests/office-types.spec.ts` / `tests/image-types.spec.ts` 保持通过
### 13.2 集成测试
- `tests/orphaned-tab.spec.ts`:localStorage 里有未注册 type 的 tab → 渲染 `<OrphanedTab/>`,注册后恢复
### 13.3 现有 manifest 守卫
- `tests/manifest-consistency.spec.ts` 的 `contributes.tools/skills` 空数组守卫保持通过
- `CLIENT_EXTERNALS` 列表守卫保持通过(不新增外部 value import)
## 14. 风险与缓解
| 风险 | 缓解 |
|---|---|
| 双 cordis 实例导致 consumer 拿不到 `ctx.betterSidebar` 类型 | 由 dsh-better-sidebar 自己 `declare module 'cordis'`,consumer `import type {}` 触发合并 |
| 注册顺序:consumer 在 better-sidebar 之前激活导致 inject 等待 | cordis `inject` 自带就绪等待,consumer 不会在服务就绪前激活 |
| HMR 时 disposer 漏调导致注册项残留 | `ctx.effect(() => disposer)` 挂到 fiber,卸载时自动调 |
| localStorage 里旧版本的 `TabType` 字面量 tab | `sanitizeNode` 改为标记 `orphaned` 不丢弃,向后兼容 |
| `EditorView` 的 `kind` 字段类型从联合字面量变成 string | 内部 `load` 状态类型加 `kind: string` + `customData?: unknown`,TypeScript 收窄在渲染层做 |
| 兜底 `code` viewer priority -100 与外部 `binary-download` -50 冲突 | `matchFileViewer` 先尝试 `detect`(NUL 探测为 binary),再走 exts;外部 `binary-download` 的 `exts: ['doc','xls','ppt']` 优先于兜底 `code` 的 `exts: []` |
## 15. 版本与发布
- 版本号:`v0.4.0`(破坏性:`TabType` 从联合字面量改 string)
- peerDependencies 不变(不加新依赖)
- `dsh.plugin.json` 的 `version` 同步更新到 `0.4.0`
- 重建 `lib/` + 推送 main + profile 更新
## 16. 实施顺序(高层)
1. 拉取 main v0.3.0 到工作分支
2. 新建 `src/client/api.ts`(类型 + `createBetterSidebarService` 工厂)
3. 新建 `src/types.ts`(`declare module 'cordis'` augmentation)+ `src/index.ts` re-export
4. `src/client/index.tsx` 改造:`ctx.provide('betterSidebar', service)` + `registerBuiltins(service)`
5. 抽 `registerBuiltins.ts`:5 个内置 tab + 8 个内置 viewer 注册
6. 改造 7 处硬编码 switch(state.ts / Sidebar.tsx / icons.tsx)
7. 改造 `EditorView.tsx` 的选预览器 + 渲染两段
8. `package.json` 加 `./client/api` exports + `files` 字段
9. 加 `<OrphanedTab/>` 组件 + sanitize 降级
10. 写测试(service / matchFileViewer / builtins / orphaned)
11. typecheck + test + build 三件套
12. 重建 lib/,提交,推送,更新 profile web
详细实施计划由 writing-plans skill 生成。
---
## 17. 实施偏差记录(2026-08-11,feat/modular-registry)
实现(PR #3 + 本分支)与上文设计有意或被迫不同的点,逐条记录。文档以本节为准。
### 17.1 去重机制:`dedupeKey` 为唯一机制,`single` 是其语法糖
§5.2 的 `single?: boolean` 与实现的 `dedupeKey` 合并为单一机制:
- `dedupeKey?: (tab) => string | undefined` 是唯一去重规则(单实例 `() => id`、按 path、按 id 三种旧策略统一表达);
- `single: true` ≡ `dedupeKey: () => id`(语法糖,显式 dedupeKey 优先)。
### 17.2 `available` 三参签名
§5.2 的 `available?: (ctx, scope) => boolean` 实现为 `(ctx, scope, state) => boolean`(state 用于 terminal 配额等 UI 状态判定,是设计签名的超集)。
### 17.3 匹配算法:单趟 per-descriptor,非三趟全局
§8.2 的算法(priority 降序逐 descriptor,detect 或 exts 二选一)为准;实现确认单趟,并补充一条规则:**`exts: []` + `detect` 是纯嗅探模式**——无 head 时不认领任何文件(避免 magic-sniffer 吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。盲 catch-all(无 detect)行为不变。
### 17.4 编辑器拆分:EditorHost + TextEditor(code/markdown 成为注册 viewer)
§10 的 EditorView 职责一分为二:
- `EditorHost`('editor' tab 组件):标题栏 + `matchFileViewer` → 按 fetchStrategy 分发(纯逻辑在 `editor-load.ts` 的 `planFirstMatch`/`planFsReadOutcome`)→ 渲染 viewer.component;binary 无匹配渲染共享 `BinaryDownload`;
- `TextEditor`(viewer 组件):code/markdown 的 CodeMirror 编辑 + md 预览/编辑切换 + 保存/脏点,内容由 host 经 fsRead 传入,按 `viewerId === 'markdown'` 分支。
- 代码/保存按钮从标题行移到内容区第二行(VSCode 式工具栏,有意 UX 变更)。
### 17.5 `fs.read` 增加 `head` 字段(唯一 host 触碰)
§8.2/§14 的 detect 机制需要 head 字节:host `fs.read` 对二进制响应附加 `head`(base64,前 4KB,常量 `READ_HEAD_LIMIT`)。客户端在 fsRead 结果为二进制时用 head 重匹配一次(无额外 IO),NUL 探测因此成立。向后兼容(新字段)。纯文本文件不触发重匹配(文本嗅探请用 exts 或 custom 策略)。
### 17.6 导出路径:`./client/service` 为主,`./client/api` 为别名
§7 要求 `./client/api`,但 `api.ts` 已被既有的 fetch 助手占用;package.json exports 增加 `"./client/api"` 别名指向 `lib/types/client/service.d.ts`,公共导入路径满足设计,内部文件名不变。
### 17.7 + 菜单:disabled 行而非 filter
§9 的 `filter(!hidden && available)` 实现为"available 返回 false 显示 disabled 行"(与 v0.3.0 旧 UX 一致;终端配额满时用户能看到选项为何不可用)。行为有意保留,文档更新为现状。
### 17.8 保留的 store 级操作(非注册表职责)
- `openDiffTab`(state reducer):diff 的 split 树放置手术(粘性 diff pane、首次拆分下方),保留;其按 id 去重与 diff descriptor 的 `dedupeKey: (tab) => tab.id` 同规则(测试断言两者一致)。
- `reconcileAgentTerminals`:agent 终端列表同步(host 推送驱动),直接落 state(`openTabInActivePane`),不经过 service(terminal 的 createTab 语义不适用)。
- 命名:state.ts 的 `openTab` reducer 改名 `openTabInActivePane`(与 `service.openTab` 区分)。
### 17.9 内置清单
- tab 6 个不变(editor/explorer/git/subagent/terminal/diff),explorer/git/subagent 改用 `single: true`。
- viewer 8 个:image/pdf/docx/xlsx/pptx(mediaUrl)+ markdown(fsRead)+ code(fsRead,catch-all -100)+ binary-download(-50,exts doc/xls/ppt + NUL detect)。
- `SIDEBAR_BUILTIN_TAB_TYPES` 常量删除(零引用);`tabTypeIcon` 删除(图标归 descriptor.icon)。
### 17.10 测试
新增 `tests/builtins.spec.ts`(内置注册清单)、`tests/orphaned-tab.spec.tsx`(sanitize 保留未注册类型 + 占位渲染)、`tests/editor-load.spec.ts`(策略分发纯函数);`tests/browser-globals.ts` 为拉入 xterm/CodeMirror 的 spec 提供模块求值期浏览器全局 mock。`matchFileViewer` 相关断言按 17.3 语义重写。
---
## 18. v0.12.0 基座化扩展记录(2026-08-14,feat/plugin-api-v012)
§5 的服务接口在 v0.12.0 做了**纯增量**扩展(无任何签名删除 / 语义变更,向上兼容),全部记录于此,文档以本节为准:
- **类型导出补齐**:`./client/service` 入口 re-export 公共状态词汇——`SidebarTab` / `SidebarState` / `SidebarStore` / `SidebarSnapshot` / `SidebarDiffRef` / `TabType` / `SessionScope` / `SidebarPrefs` / `OpenTabSeed` / `SidebarSettingsRenderProps`。此前消费者无法命名这些类型(TS2459),只能靠推断。
- **client 声明图零 Node 依赖**:`context-types.ts` 的 `SidebarWebRoute` / `SidebarWebUpgradeRoute` 改用结构镜像类型(`SidebarHttpRequest` / `SidebarHttpResponse` / `SidebarUpgradeSocket` / `SidebarUpgradeHead`),宿主在 ws 边界显式转型(`src/index.ts` 两处)。外部消费插件无需 `@types/node`、`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 双层校验守护)。
- **新服务方法**:`version` / `features`(单调能力清单,只增不删);`getSnapshot` / `subscribeState`(状态订阅,委托 store);`updateTab(tabId, patch)`(title/path/meta);`activateTab(tabId)`(跨树激活,tab 栏点击路径);`openFile(scope, path, title?)`(editor 打开,id 按路径派生与内置拦截一致);`openTab(seed, scope?)`(定向到指定 session:`SidebarStore.reduceFor` 加载/变更/持久化目标 session 而不切换 snapshot,UI 不扰动;scope 指向当前 session 时走原 reduce 路径保证 notify)。
- **新 descriptor 字段**:`badge`(tab 角标:number 计数 99+ 封顶 / string 文本 / null 不显示,每次 tab 栏渲染调用,抛错吞掉);`onOpen` / `onActivate` / `onClose`(生命周期回调,service 路径派发——dedupe/id 安全网聚焦触发 onActivate 而非 onOpen;内置 diff 拆分与 agent 终端 reconcile 直接改 state 不触发,但只作用于内置类型;回调抛错只 console.error);`SidebarTab.meta`(JSON 可序列化自定义状态,`sanitizeNode` 宽松保留,跨刷新原样恢复)。
- **设置 seam 开放**:`PrefsSchema` 增加 `pluginSettings: z.dict(z.dict(z.any())).default({})` 开放嵌套 map;`settings.pluginToggles`(插件自有声明式行)与 `settings.render`(自定义面板,props 见 §5)替代「自定义 key 被 seam 丢弃」的限制;viewer 卡片 v0.12.0 起同样有齿轮设置弹窗。
- **`FileViewerDescriptor.load` 第三参 `signal?: AbortSignal`**:EditorHost 在卸载/重匹配时 abort(忽略 signal 的 load 照常工作)。
- 测试:`tests/consumer-types.ts`(消费面编译门)、`tests/api-surface.spec.ts`(client 图零 node 依赖守护 + 能力常量)、`tests/service.spec.ts` 新增生命周期 / 定向打开 / updateTab / activateTab / openFile / meta 持久化用例、`tests/unit.spec.ts` 新增 `reduceFor` 与 sanitize meta 用例。
实现偏差:§5.1 的 `openTab(seed)` 单参签名保留为 `openTab(seed, scope?)` 的可选参数(非破坏);`SidebarSnapshot` 类型 re-export 自 `./client/service` 而非新建独立文件。
@@ -1,65 +0,0 @@
# 跟随 DSH 的 i18n(ctx.locale)设计
> 2026-08-12 · 随 v0.8.2 发布
## 背景
better-sidebar 的界面文案(约 260 处 `t()` 调用、28 个文件)此前自带 zh/en 词典并直接读
`navigator.language`(`src/client/locales.ts` 头注释曾明言"renders outside the slot
system's locale seat, so it reads the browser language directly")。问题:
- DSH 的语言偏好是 Host-backed(`locale.preference`,存于 `$DSH_HOME/settings.yaml`),
用户设置与浏览器语言不一致时侧边栏不跟随(如偏好 zh、浏览器 en 时侧边栏仍为英文)。
- 词典游离在 DSH 的 i18n 注册表之外,无法参与公共命名空间回退等机制。
DSH 的 i18n 系统(`@deepseek-ai/dsh-client-locale`):
- `ctx.locale` 服务(client-only):`getSnapshot()/subscribe()` 是 LocaleFace
(uSES 安全,revision 递增),`locale/change` 事件在切换时触发;
- `ctx.locale.register(ns, {zh, en})` 命名空间词典注册(typed 变体要求合并
`LocaleNamespaceMap`);查询链 ns → common → zh → key;
- 所有 DSH 客户端包以 `inject = [..., 'locale']` 消费。
## 设计决策
1. **结构镜像服务**(仓库惯例,`src/context-types.ts` 头注释):本插件解析在 DSH
monorepo 单一 cordis 实例之外,上游 `declare module 'cordis'` augmentation 不达此
Context。新增 `SidebarLocaleService` 结构脸(只镜像用到的切片:
`getSnapshot` / `subscribe` / `register`),merge `Context.locale`。不引入
`@deepseek-ai/dsh-client-locale` 依赖、不做 value import(client bundle 纯度门)。
2. **保留模块级 `t()`**:`t()` 被 28 个文件使用,穿 props 不可行。改为模块级 holder:
`apply()` 调 `attachLocale(ctx.locale)`,`t()`/`isZh()` 从 holder 读活跃 locale;
无 holder 时回退既有 `navigator.language` 检测(行为与改动前完全一致,也是 jsdom
测试与非标准组合的降级路径)。
3. **根级重渲染触发**:侧边栏自行 `createRoot` 挂在 slot 系统之外,拿不到 `t` seat。
在 `Sidebar.tsx` 根组件加 `useSyncExternalStore(ctx.locale.getSnapshot/subscribe)`
(该文件已用此 API 订阅 sessions/store);树内无 `React.memo`,根重渲染即整树
重渲染,所有渲染期 `t()` 求值读到新语言。事件期调用(intercept、api、subagent-tasks)
在调用时刻读当前语言,天然正确。SideCardSection 由 settings shell 渲染,shell 自身
订阅 locale revision(ui-settings-general 的 section rows 缓存含 locale revision),
自动跟随。
4. **命名空间 `'betterSidebar'`**:`'sidebar'` 已被 DSH 的 ui-sidebar 占用。注册走
非类型化单语言重载 `register(ns, locale, dict)`(DSH 内 ui-permission 同款先例);
键型安全由本地 `const en: Record<keyof typeof zh, string>` + 测试键集相等断言保证。
5. **词典注册进 DSH 注册表**:`ctx.effect` 内注册 zh/en 并返回合并 disposer
(fiber 卸载时撤销,HMR 重激活不抛 "already registered")。
6. **xlsx 预览的 Univer locale pack**:`xlsx-view.tsx` 原内联 navigator 检测改走
`isZh()`(跟随 DSH 语言;Univer 实例在挂载时创建,语言切换不重建实例——可接受)。
## 实施偏差记录
- 无。计划与实施一致。
- 版本号随 v0.8.2 发版提交(`package.json` + `dsh.plugin.json`)。
- v0.8.2 后补遗:md 预览(`TextEditor.tsx`)的代码块复制按钮文案——DSH 的
`MarkdownText`/`CodeBlock` 是 cordis-free 组件,未传 `codeLabels` 时回退到硬编码中文
「复制/复制成功」。修复为渲染时传入 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`
(对齐 DSH 聊天区 `AssistantMarkdown` 的做法),新增 `tests/markdown-copy-labels.spec.tsx`
钉住 zh/en 跟随。
## 验收
- `pnpm typecheck` / `pnpm test` / `pnpm build`(纯度门)全绿;`tests/locales.spec.ts`
覆盖:无服务回退、attach 后跟随活跃 locale 实时切换、detach 恢复回退、占位符插值、
relativeTime 链路、命名空间名、zh/en 键集相等。
- 手动:设置页切换语言 → 侧边栏全部文案实时切换;`locale.preference: zh` 且浏览器 en
→ 侧边栏中文。
@@ -1,89 +0,0 @@
# 客户端懒加载分块(lazy chunks)设计
> 日期:2026-08-12
> 状态:已实现
> 范围:`dsh-better-sidebar` 插件,客户端 bundle 拆分
## 1. 背景与问题
改造前客户端是单文件 bundle(`lib/client.js`,约 24.6MB),页面启动时全部**下载 + 解析 + 执行**。实测构成(按 sourcemap 源字节):
| 依赖 | 体积 | 用途 |
|---|---|---|
| `@univerjs/*` 全家桶 + echarts/zrender 等 | ~18MB | .xlsx 预览 |
| `@codemirror/*` + `@lezer/*` | ~1.9MB | 文本编辑器高亮 |
| `xlsx`(SheetJS) | ~0.6MB | .xlsx 解析 |
| `@aiden0z/pptx-renderer` | ~0.5MB | .pptx 预览 |
| `xterm` | ~0.3MB | 终端 |
| `docx-preview` + `jszip` | ~0.2MB | .docx 预览 |
| 插件自身代码 | ~0.35MB | — |
虽然 `office-view.tsx` 用了动态 `import()`,但 `codeSplitting: false` 只保证**延迟执行**(`Promise.resolve().then(() => (init_docx_preview(), ...))`),下载与解析成本仍在启动时;且 `xlsx-to-univer.ts` 对 `@univerjs/presets` 的静态 value-import 让 Univer 核心实际在启动时执行。
**目标**:重依赖拆成独立脚本,首次打开对应功能才下载/解析/执行;启动只拉核心(~325KB)。
## 2. 机制
chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import()` 的物化路径——模块系统的 import() 只解析 seed word / shell-own 模块 / 已注册 factory / boot graph row,chunk id 四者皆非,解析行为依赖 DSH 版本(实测某些版本在 factory 已注册时仍抛 "cannot resolve")。因此改为插件自有的全局 factory 注册表:
1. **构建期**:每个 chunk 是独立 tsdown 浏览器 bundle(`lib/client-<name>.js`),脚本首行为:
```js
globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
globalThis.__dshChunks__["terminal"] = (require) => { /* CJS 闭包体 */ };
```
与主 bundle 相同的 externals(react 等走 module table)、纯度门、CSS 内联管线;`codeSplitting: false` 保持单文件。
2. **运行期** `loadChunk(name)`:
- 注入 `<script src="/sidebar/bundle/<name>.js">`(经典同源脚本;官方 `/plugins/<id>/client.js` 路由的路径白名单无法服务任意文件名,故走插件自有路由);
- 从 `globalThis.__dshChunks__[name]` 读取 factory(脚本执行即赋值,幂等——重复执行覆盖槽位,无 "duplicate factory registration" 错误类);
- 用**自定义 require** 物化:externals(CLIENT_EXTERNALS 清单)经 `__DSH_MODULES__.import(spec)` 的 **seed 分支**逐一解析(页面级记忆化;解析失败的 spec 标记未解析,仅当 chunk 实际 require 时才报错)——seed 分支是模块系统最稳定、跨版本一致的部分。
## 3. 分块清单
| chunk 名 | 入口 | 内容 | 构建产物 | 触发 |
|---|---|---|---|---|
| `xlsx` | `src/client/chunks/xlsx.tsx` | `XlsxView` + Univer 全家桶 + xlsx + Univer CSS | lib/client-xlsx.js(~20MB) | 打开 .xlsx |
| `docx` | `src/client/chunks/docx.tsx` | `DocxView` + docx-preview + jszip | lib/client-docx.js(~330KB) | 打开 .docx |
| `pptx` | `src/client/chunks/pptx.tsx` | `PptxView` + pptx-renderer | lib/client-pptx.js(~2.4MB) | 打开 .pptx |
| `terminal` | `src/client/chunks/terminal.tsx` | `TerminalView` + xterm + addon-fit + xterm.css | lib/client-terminal.js(~450KB) | 打开终端 tab |
| `editor` | `src/client/chunks/editor.tsx` | `TextEditor` + @codemirror/* + lang.ts + cm-themes | lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
| `mermaid` | `src/client/chunks/mermaid.tsx` | `MermaidBlocks` + mermaid + d3/dagre/cytoscape | lib/client-mermaid.js(~7MB) | 预览含 mermaid fence 的 md |
| 核心 | `src/client/index.tsx` | 其余全部 | lib/client.js(~325KB) | 启动 |
关键拆分:**`office-view.tsx` 拆为 `docx-view.tsx` + `xlsx-view.tsx` + `office-shared.tsx`**(共享 `LoadState`/`BinaryFallback`)。若不拆,docx chunk 会经共享文件静态拖入 `xlsx-to-univer` → Univer 14.5MB。原路径 `office-view.tsx` 保留为 re-export 垫片,兼容 `./src/*` 深导入。
## 4. 缓存契约(三层,各带失败路径)
| 层 | 机制 | 失败路径 |
|---|---|---|
| 内存 | `loadChunk` 每 chunk 一个 in-flight promise,并发去重,常驻到 `resetChunks` | 失败删除缓存项 → 下次调用重试 |
| 执行 | 脚本执行即覆盖全局注册表槽位(赋值幂等) | 物化失败清缓存 → 重试重新注入 + 重新执行脚本 |
| HTTP | `/sidebar/bundle` 路由 `cache-control: no-cache` + ETag(内容哈希,按 mtime/size 记忆化),`If-None-Match` 命中返回 304 | 页面刷新/HMR 重激活时 304 免重下载 17MB;文件变化 ETag 轮转返回 200 |
**HMR**:`apply()` 每次激活调用 `resetChunks()`(清内存缓存 + externals 记忆),热更新后重新拉取新代码。chunk 源码单独改动需手动刷新页面(HMR 轮询只盯 client.js)。
## 5. 对外 API 兼容性(硬约束)
- `ctx.betterSidebar` 服务方法签名与行为零变化;懒加载只作用于**内置** descriptor,外部插件注册的原样存储/渲染。
- `component` 契约 `(props) => ReactNode` 保持:懒包装是**纯渲染函数**(函数体无 hooks,hooks 在内部 `LazyChunkView`),兼容 Sidebar 的函数式调用与 EditorHost 的 `createElement` 渲染两种风格。
- 内置 viewer/tab 的 id、exts、priority、detect、icon、title、settings 全部不变 → `matchFileViewer` 语义、外部插件同扩展名高优先级覆盖、设置页清单不变。
- 公开类型面(`./client/service`、`./client/api`)不变;无新增 peerDeps。
- 契约测试:`tests/lazy-chunk.spec.tsx` 钉住"component 可直接函数调用不抛错"。
## 6. 边界与失败模式
- chunk 加载/执行/物化失败 → 视图级错误 + 重试 UI(`css.editorError` + `css.terminalRetry`),面板其余部分不受影响;
- host 旧版本无 `/sidebar/bundle` 路由(404) → 同样的错误 + 重试,升级 host 后重试即可;
- `window.__DSH_MODULES__` 缺失(异常环境) → 明确报错,不触网;
- chunk 注册 id 恒定 `dsh-better-sidebar/<name>`,两渠道共用(每页只活一个渠道——双渠道并存本就双挂载,属既有破损组合);
- 跨 chunk 复用:`t()`/`api.ts`/`SandboxStatusBar`/`clsx`/`sidebar.module.css` 等无状态小模块允许在各 chunk 内联(几十 KB 级重复),避免 chunk→主 bundle require 的脆弱耦合;
- CSS:`sidebar.module.css` 的 tagId 与官方渠道核心一致 → 幂等跳过注入;registry 渠道会多注入一份同内容样式(数据标签差异,无功能影响);xterm.css/Univer.css 随各自 chunk 注入。
## 7. 实施偏差记录
- 原设想 React.lazy + Suspense 被否决:错误处理需 error boundary,重试语义差;改用有状态包装组件(loading/error/retry 三态),与本库"每个视图自带 loading/error/retry"风格一致。
- 原设想 chunk 注册 id 按渠道区分(`__DSH_PLUGIN_ID__` define)被否决:chunk 文件两渠道共用,恒定槽位即可;避免重复构建 6 份产物。
- `resetChunks` 同时清空测试注册表(测试隔离需要)。
- `lazy-chunk.tsx` 内部渲染用 `ComponentType<any>` 状态成员规避 React 18 createElement 泛型重载限制(仅内部一处显式 any)。
- **v2(运行时修复)**:最初实现经 `window.__ModuleLoader__.load({id})` 注册 chunk factory、再 `__DSH_MODULES__.import(id)` 物化——真实环境报 `client-modules: cannot resolve "dsh-better-sidebar/terminal"`(某 DSH 版本的 import() 不解析已注册的非 boot 图 id)。改为全局 factory 注册表(`globalThis.__dshChunks__`) + 自定义 externals require(seed 分支),彻底不依赖模块系统的 factory 解析行为;`resetChunks` 不再需要 invalidate。
- **v3(运行时修复)**:terminal 懒包装把整个 `TabComponentProps` 原样透传,但 `TerminalView` 的 props 是 `{ scope, tabId, store }` 而 `TabComponentProps` 只有 `tab` 对象、没有 `tabId` → `tabId` 为 undefined → `isAgentTabId` 里 `undefined.startsWith` 崩溃(`dsh-better-sidebar: Cannot read properties of undefined (reading 'startsWith')`)。修复:descriptor 显式映射 `tabId={tab.id}`,懒包装的 props 泛型 = TerminalView 真实签名(`{ scope, tabId, store }`);回归测试钉住映射(lazy-chunk.spec.tsx)。**教训:懒包装的 props 泛型必须等于目标组件真实 props,而非描述符 props 全集**。
- **v2 附加**:bundled 产物经真实 `ClientModuleSystem`(带真实 react seed)端到端验证——5 个 chunk 均能物化出真实 React 组件;产物契约由 `tests/chunk-artifact.spec.ts` 长期钉住(脚本执行赋值槽位 + factory 可被 externals require 物化)。
@@ -1,77 +0,0 @@
# 移动端(窄视口 <768px)侧边栏布局设计
**日期**:2026-08-12
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.7.0
## 1. 目标
按**视口宽度**(<768px——"真正的移动端宽度",刻意不对齐宿主 1024 断点)切换移动端侧边栏体验:
1. 移动端**不提供底部面板按钮**(右上角按钮簇只剩一枚右侧面板开关)。
2. **合并显示 = 只显示右侧栏**:进入窄屏时底部面板的标签页**直接并入右侧栏**(一次性状态迁移),右侧栏成为全宽抽屉,底部面板浮层整体不存在。
3. 附带移动端优化:新会话默认收起抽屉、文件/外链打开自动展开、宽度拖动条禁用、布局不挤压(抽屉悬浮)、窄屏标签收窄。
## 2. 非目标
- 不改持久化 schema:`bottomOpen` / `bottomHeight` / `bottomSplits` 字段原样保留。
- 不改桌面行为:≥768px 时双面板布局与之前完全一致(含布局挤压、拐角、底部首展自动终端)。
- 不改公开服务 API、PrefsSchema、host 半。
- 不处理 DSH 详情栏(宿主让步链在 <~996px 自动关闭详情栏,与本改动无交互)。
## 3. 现状回顾
- `Sidebar.tsx` 渲染两个独立浮层:右侧面板(全高、宽度可拖、布局挤压 `--dsh-sidebar-width`)与底部面板(只挤中间列、高度可拖、右上角按钮簇双按钮、共享拐角)。
- 底部面板状态字段:`bottomOpen` / `bottomHeight`(钳制 `[BOTTOM_MIN, innerHeight-PANEL_MIN]`)/ `bottomOpenedOnce` / `bottomSplits`(独立 split 树,pane/tab id 全局唯一)。
- 首次展开底部面板自动开终端(`bottomPanelAutoTerminal` pref)。
## 4. 设计
### 4.1 断点模块(新 `src/client/breakpoints.ts`)
- `NARROW_MAX_WIDTH = 768`;`isNarrowWidth(width)` 纯函数;`useNarrowViewport()` hook(初始读 `window.innerWidth`,resize + rAF 节流,`typeof window` 守卫;不用 matchMedia——jsdom 未实现)。
- CSS 侧配对 `@media (max-width: 767px)`(767 ≡ <768),两端注释互指。
- 刻意**不对齐**宿主 `SIDEBAR_AUTO_COLLAPSE = 1024`:1024px 窗口(小笔记本、分屏)保留桌面双面板;只有手机/竖屏平板才进移动布局。
### 4.2 合并 = 底部标签并入右侧栏(状态迁移)
- **新 reducer `migrateBottomTabs(state)`**(`state.ts`):
- 底部树所有标签按深度优先序**追加到右侧树第一个 leaf**(`firstLeaf(splits)`)的标签条;
- 底部树清空(结构保留——桌面端回显欢迎卡片)、`bottomOpen=false`、`activePane` 指向右侧树第一个 leaf(保证迁移后新开的标签落在可见面板);
- 幂等:底部无标签且面板已关且 activePane 不在底部树时返回同一引用。
- **Sidebar 触发 effect**:`narrow && sessionId 已定义` 时执行(覆盖:窄屏挂载、会话切换、桌面→窄屏过渡);幂等性保证其自然收敛,不循环。
- 迁移是**永久**的(符合"直接丢到右侧栏中"):回桌面后标签仍留在右侧树,底部面板为空(欢迎卡片);用户可在桌面重新往底部面板开标签,再进窄屏会再次迁移。
- 迁移后右侧栏用**现有 `Workbench` 原样渲染**——零视图层特判、零 action 路由修补(标签真实住在右侧树,close/activate/drag 全部走原路径)。
### 4.3 窄屏渲染(`Sidebar.tsx`)
- 面板宽度 `100vw`(全宽抽屉);`--dsh-sidebar-*` 布局变量恒 0(抽屉悬浮,不挤压 AppFrame);宽度拖动条、底部浮层、`bottomClose`、拐角、底部面板按钮全部不渲染。
- `visible` 语义回到原始:底部树只在桌面底部面板场景(`bottomOpen && active`)——窄屏下底部树为空且不渲染。
- 底部首展自动终端 effect 加 `narrow` 守卫。
### 4.4 行为修正
- `state.ts` `loadState`:窄屏加载一律以 `panelOpen=false` 开始(包括持久化布局恢复);仅覆盖抽屉可见性,右侧/底部 pane、tab 与自由窗口状态原样恢复。宽屏仍按持久化值或新会话偏好决定展开状态。
- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。
### 4.5 样式(`sidebar.module.css`)
- `@media (max-width: 767px)`:开放面板标签条预留宽度 72px → 40px(按钮簇只剩一枚);`.tab` min/max 宽收窄(48/128px)。
### 4.6 测试
- `tests/breakpoints.spec.ts`:断点边界(767 窄 / 768 宽;1024 明确为宽)。
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起;`tests/state.spec.ts` 守护窄屏恢复强制收起且 pane / free-window 状态不丢,768px 宽屏仍恢复持久化展开值。
- `tests/service.spec.ts`:窄屏 auto-expand 5 例(390 < 768 仍窄)。
## 5. 边界情况
- 迁移触发:窄屏挂载 / 会话切换 / 桌面→窄屏过渡,均由同一 effect 覆盖;幂等保证收敛。
- 迁移后新开标签:`activePane` 已重指右侧树第一 leaf,`openTabInActivePane` 必落在可见面板(含"空底部树但 activePane 指向它"的陈旧指针场景)。
- 跨断点往返:迁移永久;桌面底部面板空显欢迎卡片;`bottomHeight` 不受影响。
- jsdom 兼容:hook 不用 matchMedia;既有测试 stub `innerWidth=1024` 自动走桌面路径,零行为变化。
## 6. 实施偏差记录
- **v1(已提交 b391ebf,用户反馈推翻)**:合并 = 单面板内上下堆叠两个工作台(`MobileWorkbench` + 可拖分隔条,高度复用 `bottomHeight`),断点 1024(对齐宿主)。用户明确:合并 = 只显示右侧栏、底部标签直接丢进右侧栏;窄屏 = 真窄屏、不对齐 1024。
- **v2(本文档,最终形态)**:删除 `MobileWorkbench`(组件 + CSS + spec),改为 `migrateBottomTabs` 状态迁移 + 现有 `Workbench` 渲染;断点 768。`bottomHeight` 不再参与移动端布局(仅桌面底部面板使用)。
@@ -1,43 +0,0 @@
# 任务管理页后台命令区块(查看输出 + 强制终止)设计
> 2026-08-12 · v0.8.0
## 目标
任务管理页(当前树的主代理拓扑,原「子代理」页)在拓扑树下方同页显示**当前树全部会话**(主代理 + 子代理)的后台任务:
- 点击任务行,输出显示在**侧边栏底部共享 Dock**(sticky 终端式,滚动时保持可见;运行中每 2s 轮询,页面不可见时暂停;自动跟随底部);
- 运行中的任务行带两击确认的终止按钮;
- **不破坏模型契约、不修改 DSH 源码**(本仓库规则:对官方源码 checkout 零写入,挂载只走 cordis.patch.yml + profile 机制):输出查看是**模型已读输出的回放**,绝不推进 `task_output` 的读取游标。
## 数据流
```
bash/tool run_in_background → ctx.tasks.start(owner = 执行 agent)
api-proxy session/tasks 帧(TaskView[],无输出)→ 客户端 tasksBySession 镜像
SubagentView 读镜像 → collectTreeTasks(树内会话) → 行渲染(单 Dock,选中行)
点行 → POST /sidebar/api/tasks.output(事件回放)→ 扫描属主会话 events 里
task_output 的 tool/call + tool/result 对 → 模型已读文本
终止 → POST /sidebar/api/tasks.kill → ctx.tasks.kill(DSH 现成 API)
```
## 关键决策
1. **输出 = 会话事件回放,零 DSH 写入**:`TaskService.read()` 是单一消费游标——UI 直接读会偷走模型 `task_output` 的字节(或读到空)。而 DSH 源码禁止修改,所以 `tasks.output` **不碰注册表**:扫描属主会话的 append-only 事件日志,取 `task_output` 调用的 `tool/call`(`arguments.task_id` 配对)与其 `tool/result`(finalize 后的文本,即模型实际收到的内容)按序拼接;`"(no new output)"` 增量与错误结果跳过;`read: false` 表示模型尚未读过。模型游标天然不被触碰。
- 语义代价(用户已确认接受):面板只在模型读过之后才有内容——没有实时流。
2. **列表不新增路由**:任务清单由 harness 的 `session/tasks` 推送镜像提供;只加 `tasks.output`(回放)与 `tasks.kill`(注册表原生 `kill`)两个路由(信任围栏与其余路由一致)。注册表缺席时 kill 降级 503(output 不需要注册表)。
3. **终止的访问围栏**:caller 由请求的 `sessionId` 现场解析 `ctx.agents.get(sessionId)`,注册表按 owner session id 拒绝 foreign 任务;未知/foreign 统一映射 404 `task-error`。
4. **展示范围 = 整棵树**:与页面作用域一致(拓扑即当前树);树跨多会话时行尾显示属主标题。
5. **单共享 Dock(大量任务的形态)**:行内展开会让列表随任务数膨胀、排序变化时面板跳动;改为一个底部 sticky Dock + 单一轮询实例,行保持紧凑、排序变动不惊吓,选中行高亮。
6. **两击确认终止**:首次点击进入「再次点击确认」态(3s 自动解除),防止误杀长任务。
7. **输出上限**:路由按 `readLimit`(默认 512KB)截断并置 `truncated` 标记。
## 实施偏差
- 曾实现 harness 侧非消费 peek 缝隙(`BashProcess.peekOutput` / `TaskService.peek`),因「禁止修改 DSH 源码」规则**整体回退**(harness 提交已 reset,checkout 恢复纯净快照),改用事件回放。
- 宿主不新增 `@deepseek-ai/*` 依赖(`ctx.tasks`/`ctx.agents` 走结构性镜像 + 运行时 `ctx.get`,可选服务降级 503);`ctx.sessions.get(id).events` 为只读访问。
## 验证
- 插件单测:tasks-routes(回放拼接/过滤/read 标志/截断/404/503 降级);subagent-tasks 纯函数;jsdom 视图(行渲染、单 Dock 切换、关闭、两击终止、read:false 提示、大量任务、隐藏不轮询、镜像清空 hook 顺序回归 #300)。
- 集成(真实 dsh web):启动 `run_in_background` bash → 模型 `task_output` 读取 → `/sidebar/api/tasks.output` 返回模型已读内容 → `/sidebar/api/tasks.kill` 后进程退出、任务结算 `killed (signal: SIGTERM)`。
@@ -1,69 +0,0 @@
# 「添加插件」弹窗 + 推荐插件列表 + 复制安装命令 设计
**日期**:2026-08-14
**状态**:已批准,已实施
**作者**:opencode + 用户
**当前版本**:v0.12.0(分支 `feat/plugin-api-v012`,已合入 main 后 rebase)
**目标版本**:v0.12.0(不 bump)
## 1. 目标
1. 为「侧边栏内容(Tab)」与「文件预览(viewer)」两类扩展点分别增加**添加入口**:DSH 设置页「Side card」分区的两个响应式网格(侧边栏内容 / 文件预览)末尾各新增一个与其他卡片同尺寸的**虚线边框**添加卡片(「添加 Tab 插件」/「添加预览插件」)。
2. 点击后弹出对应 kind 的原生 Modal:声明该类扩展点可由插件扩展(`ctx.betterSidebar` 服务),提供「在 GitHub 上浏览更多插件」**按钮**(打开 `https://github.com/topics/dsh-better-sidebar`,新标签页),并展示仓库**推荐**的同 kind 插件列表(名字 / url / 简介 / 安装脚本)。
3. 每个条目两个动作:**「跳转」按钮**(`window.open` 新标签页直达插件仓库,绕过链接接管)与**「复制」按钮**——点击只把安装脚本写入剪贴板(`writeClipboard`,按钮闪现「已复制」反馈),用户自行粘贴到 DSH 所在环境的终端执行。
4. **不关闭任何窗口、不打开终端、无失败路径**:复制是纯 client 动作,不依赖会话/终端/配额,弹窗保持打开可连续复制多个插件。
5. 推荐列表为仓库静态数据、**分为两个文件**(`plugins-tabs.ts` Tab 注册目录 / `plugins-viewers.ts` 文件预览注册目录),初始在 viewer 目录收录唯一真实三方插件 `@huanlin/dsh-plugin-better-sidebar-plugin-office`(简介不注版本前提);后续插件只加一条数据。
6. 内置 Office 预览(docx/xlsx/pptx)**从本体移除**——已迁至上述推荐插件(见偏差记录),`docx/xlsx/pptx` 文件在未装插件时落 code/下载兜底。
## 2. 非目标(Out of Scope)
- **不打开终端**:不做终端预填安装流(曾实现的 `OpenTabSeed.expand`、`TerminalSeedMeta` 预填/提示条、service meta 透传等已全部删除回滚)。
- 不加 + 菜单入口、不加编辑器空态入口(入口只在设置页)。
- 不做插件在线拉取 / 动态发现(列表为仓库推荐静态数据)。
- 不改 host 半的服务契约(`service.ts` 相对 v0.12.0 无 API 变化;host 半仅在 office 移除时缩减了 chunk 白名单)。
## 3. 实施要点
### 3.1 数据层:两个目录文件 + 共享词汇
- `src/client/plugins-shared.ts`:`PluginEntry { id, name, url, description, install }` + `PLUGIN_TOPIC_URL`。
- `src/client/plugins-tabs.ts`:`builtinTabPlugins`(收录 dsh-sentinel——条件驱动的 agent 唤醒系统,注册 `dsh-sentinel:watches` Tab;install 用官方 github: 源一键命令)。
- `src/client/plugins-viewers.ts`:`builtinViewerPlugins`(种子:office 插件,install = `cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office`)。
### 3.2 UI 层:`src/client/add-plugin-modal.tsx` + `SideCardSection`
- 两个网格末尾各一个虚线卡片(复用 `.card` 配方 + `IconPlusOutline16`),`addPluginsOpen: PluginKind | null` 状态;Modal 仅在打开时条件挂载(Modal 原语无条件跑 hooks,SSR 规则同 settingsFor)。
- `PluginListBody`(提取组件,直接可测):topic 按钮(`window.open`)+「推荐插件」分组(`groupHeading` + count)+ 条目(名字链接 / 简介 / mono 安装命令 / 跳转按钮 / 复制按钮)+ 空态。
- **复制**:`void writeClipboard(entry.install)` + `copiedId` 状态闪现 `t('copied')`(1.5s 后还原)。不关闭、不打开、不抛错。
### 3.3 i18n
新增键:`addPluginsTabCard/TabCardDesc/ViewerCard/ViewerCardDesc/TabDesc/ViewerDesc`、`addPluginsBrowseMore`、`addPluginsRecommended`(推荐插件)、`addPluginsEmpty`、`openPlugin`(跳转)、`copyInstall`(复制安装命令,按钮 aria-label);复用 `copy`/`copied`/`close`/`settingsDone`。已删除废弃键:`install`、`installNotice`、`installError*`、office 相关键(`viewerDocx/Xlsx/Pptx`、`officeTooLarge/Corrupt/Encrypted/LoadFailed`、`previousSlide/nextSlide/zoom/zoomHint`)。
### 3.4 内置 Office 预览移除(与推荐插件衔接)
删除 3 个 viewer 描述符 + 10 个源文件(docx-view/xlsx-view/PptxView/office-view/office-shared/office-types/xlsx-to-univer + chunks/docx|xlsx|pptx)+ 3 个图标 + office CSS + office locale 键;chunk 白名单 5→2(terminal/editor,同步 `bundle-route.ts` / `chunk-loader.ts` / `tsdown.config.ts` / `scripts/package-registry.mjs`);`tests/builtins.spec.ts` 断言 6 个 viewer 且不含 office id。`.docx`(zip、无 NUL)未装插件时落 code 兜底。
## 4. 边界情况与失败模式
- **复制失败**:`writeClipboard` 尽力而为(navigator.clipboard → execCommand 兜底),失败静默;按钮反馈照常闪现。无会话/无终端/配额等一切前置条件均不涉及——复制与它们无关。
- **空目录**:目录可为空(弹窗渲染空态 + topic 按钮);当前 Tab/Viewer 目录各有 1 条。
- **版本号**:v0.12.0(`package.json` / `dsh.plugin.json` / `service.ts` 的 `SIDEBAR_SERVICE_VERSION` 三处一致,manifest-consistency 测试守护)。
## 5. 实施偏差记录
- **评审修改(第一批)**:入口由 + 菜单/编辑器空态改为**仅设置页虚线卡片**;安装命令并入「安装按钮」单一交互。
- **评审修改(第二批)**:① topic 链接改为**按钮**(`window.open` 新标签页);② 添加按钮拆为**两个**(Tab 注册 / 文件预览注册),列表数据拆为**两个文件**;③ 安装错误只内联显示在设置内(预检 + try/catch);④ 终端提示条用品牌高亮色。
- **评审修改(第三批)**:内置 Office 预览整体移除(迁至推荐插件);「内置插件」全部改名**「推荐插件」**。
- **评审修改(第四批,最终形态)**:设置窗口关闭问题无法根治(openTab 的 store 涟漪会复活弹窗),且用户实测弹窗关闭不可靠——**放弃终端安装流**:安装按钮改为**只复制安装脚本**(`writeClipboard` + 「已复制」反馈),弹窗保持打开;随之删除整个终端预填流:`terminal-meta.ts`、`TerminalView` 的 meta/预填/提示条、`tabs.tsx` 的 meta 透传与 `uiTerminalCount` 导出、`service.ts` 的 `OpenTabSeed.expand` 与 createTab meta 透传(全部回滚到 v0.12.0 原始 API 面)、`.terminalNotice` CSS、相关测试与文档;`install`/`installNotice`/`installError*` 文案键删除,新增 `copyInstall`。
- 测试中 `AddPluginModal` 不能 renderToString(React 18 server renderer 拒绝 portal),改为 createRoot + act 挂载后断言 `document.body`(portal 落点)。
## 6. 验收清单
- [x] 设置页两个网格末尾各出现虚线边框添加卡片,尺寸与既有卡片一致
- [x] 两个弹窗(kind 区分):声明文案 + GitHub topic **按钮** + 对应推荐插件条目(名字/url/简介/安装命令/跳转按钮/复制按钮)
- [x] 点「复制」:安装脚本写入剪贴板,按钮闪现「已复制」,弹窗不关闭、不打开终端、无失败路径
- [x] 点「跳转」:`window.open` 新标签页直达插件仓库
- [x] 内置 Office 预览已移除;chunk 白名单 2 个;无「内置插件」残留文案
- [x] 版本号 v0.12.0 三处一致;`pnpm typecheck` / `pnpm test` / `pnpm build` 全绿
- [x] README/README_EN/AGENTS.md 更新到位
@@ -1,63 +0,0 @@
# Issue #42 诊断与收尾记录(底部面板首展自动终端 → 整面板空白)
**日期**:2026-08-14
**状态**:已诊断、已收尾(无新代码修复;回归测试 + 文档交付)
**关联**:issue #42、issue #25(同根因)、issue #31(放大器)、PR #27 / commit 37d91be、commit 9aeb046
**目标版本**:v0.11.0(根因修复已发布)
## 1. 问题报告
[issue #42](https://github.com/omdsh-dev/DSH-better-sidebar/issues/42)(Windows 11 / QQBrowser / dsh-launcher 宿主 / 插件 0.10.3):
1. 全新安装后重启 + 硬刷新,首次展开底部面板(`bottomPanelAutoTerminal` 默认开)→ **整个底部面板空白,连标签栏都没有**;
2. 控制台两条关键报错:
- `Uncaught TypeError: Cannot read properties of undefined (reading 'dimensions')`(`Viewport._innerRefresh`)
- `Uncaught TypeError: Cannot read properties of undefined (reading 'handleResize')`(`IdleTaskQueue._process`)
3. 关闭「底部面板首次展开自动开终端」后一切正常;右侧栏终端一直正常;
4. 附带问题:Windows 无控制台宿主下每次关闭终端,服务器日志出现 `Error: AttachConsole failed`(node-pty `conpty_console_list_agent.js`)。
## 2. 诊断结论
### 2.1 根因 = issue #25 的同一 bug
与 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25)(WKWebView 底部面板终端空白)完全同源,证据链:
- **触发路径一致**:`Sidebar.tsx` 的「底部面板首次展开自动开终端」effect(`bottomOpenedOnce` 过渡)在面板 slide-in transition 的**同一次 commit** 挂载 `TerminalView`。`docs/plans/2026-08-14-terminal-open-when-sized-design.md` §3 在设计 #25 修复时已明确把该路径列为零尺寸挂载路径之一。
- **崩溃签名逐字一致**:0.10.3 的 `TerminalView` 挂载 effect 同步执行 `term.open(host); fit.fit()`(无尺寸守卫)。xterm 5.3.0 在零尺寸容器 open 时渲染器创建失败,`RenderService._renderer` 保持 undefined → `Viewport._innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)、idle 任务读 `handleResize` 全部抛 "Cannot read properties of undefined"——与 #42 两条报错逐字对应。
- **平台差异只是时序**:#25 设计文档注明「Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现」。QQBrowser(Chromium fork)在 Windows + launcher 启动的组合下命中同一零尺寸窗口期,属边界时序,不是新缺陷。
- **版本差**:#42 报告针对 **0.10.3**(2026-08-13 发布);根因修复 37d91be(PR #27)于 2026-08-14 合并并随 **v0.11.0** 发布(早于 #42 提交约 1 小时,晚于 reporter 安装的版本)。
### 2.2 修复覆盖路径(无需新代码)
| 层 | 机制 | 位置 | 状态 |
|---|---|---|---|
| 根因(零尺寸 open) | `openWhenSized`:`term.open + fit + sendResize` 推迟到容器有尺寸后恰好执行一次;RO/字体订阅的 `fit()` 全部 try/catch 加固 | `src/client/open-when-sized.ts` + `TerminalView.tsx`(37d91be,v0.11.0) | 已发布 |
| 根因单测 | 手动步进 rAF 的 6 个用例(延迟/一次性/单维缺失/cancel/脱离文档/异常传播) | `tests/open-when-sized.spec.ts` | 已提交 |
| 「整面板空白」放大器 | per-tab `RenderBoundary` 崩溃隔离(单 tab 崩溃只显示该 pane 内条带,toggle 簇/其他 tab/面板存活)+ 根 boundary 兜底 + layout-push CSS 变量卸载清理 | `src/client/RenderBoundary.tsx`、`Sidebar.tsx`、`index.tsx`(9aeb046,#31) | 已提交 |
| 隔离测试 | 崩溃条带/重试恢复/布局变量清理 | `tests/sidebar-crash.spec.tsx` | 已提交 |
| **#42 触发链回归** | 底部面板首展自动开终端:恰好一个 terminal tab 落在底部工作树、`bottomOpenedOnce` 原子置位、pref / 类型禁用双门控、面板完整存活 | `tests/bottom-auto-terminal.spec.tsx`(本次新增,4 例) | 本次提交 |
三者合起来构成 #42 全链回归保护:触发链(本测试)→ 零尺寸延迟(openWhenSized 单测)→ 崩溃隔离(containment 测试)。
## 3. 附带问题:AttachConsole 报错(仅文档化)
`Error: AttachConsole failed`(node-pty `conpty_console_list_agent.js`)是 **node-pty 上游行为**:`kill()` 清理路径 fork 的 console-list 辅助进程在无控制台宿主(launcher/服务方式启动的 `dsh`)下 `AttachConsole(shellPid)` 失败。终端功能本身不受影响(spawn/输入输出正常),仅刷服务器日志。
- 插件侧不可消除:不改 DSH 源码、不 patch node-pty 依赖是仓库硬约束(见 AGENTS.md §0),且 `IPty.kill()` 是 node-pty 的唯一 kill 通道;
- 建议:保持现状(无功能影响),上游如提供无控制台规避选项再跟进;reporter 若在意日志噪音,可反馈 microsoft/node-pty。
> 交叉引用(issue #140):与本文档「仅刷日志」不同,**依赖缺失/损坏**(node-pty 安装不完整导致模块加载失败)已有插件侧降级处理——宿主半懒加载 node-pty,失败时终端 tab 显示可复制的修复命令(`scripts/install.sh --repair` / `install.ps1 -Repair`),详见 `src/pty-deps.ts` 与 `tests/pty-deps.spec.ts`。
## 4. 验证
- 全量测试:`pnpm test` → 34 文件 / 430 例全部通过(基线 425 + 同期 PR #43 的 html 盘符修复 1 例 + 本次新增 4 例);
- `pnpm typecheck`(`tsc --noEmit`)通过;
- `pnpm build`(`tsc -p tsconfig.build.json && tsdown`)通过。
## 5. Issue #42 回复草稿(供 maintainer 发布/关闭用)
> 感谢复现与详细报告。诊断结论:**这是 issue #25 同一根因在不同平台的复现**——底部面板首次展开自动开终端时,终端在容器尚为零尺寸(面板 slide-in 窗口期)就执行了 xterm `Terminal.open()`,渲染器创建失败导致后续 `Viewport._innerRefresh` / 空闲任务读取 `.dimensions` / `.handleResize` 崩溃(与 #25 的控制台报错逐字一致)。
>
> 该根因已在 **v0.11.0** 修复(终端延迟到容器有尺寸后再 open,PR #27);「整个面板空白」的放大器也已在最新 main 消除(单 tab 崩溃隔离 + 布局变量清理,issue #31)。**请升级到 v0.11.0 验证**:预期首次展开自动开终端不再空白;若仍复现,请提供 v0.11.0 下的控制台报错。
>
> 附带问题(`AttachConsole failed` 日志噪音)确认为 node-pty 在无控制台宿主(launcher 启动)下的上游行为,无功能影响,插件侧无法在不改动 node-pty/DSH 的前提下消除;已文档化(`docs/plans/2026-08-14-issue-42-verify-design.md`),并已补 #42 触发链回归测试。
@@ -1,117 +0,0 @@
# 终端自定义字体(设置页二级设置)设计
**日期**:2026-08-14
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.11.0
## 1. 目标
在 DSH 设置页「侧边卡片」分区中,终端 tab 卡片的二级设置弹窗(齿轮按钮,README 所称"终端卡片二级设置")新增两行:
1. **终端字体**(text 输入):自定义 CSS font-family stack,如 `"JetBrains Mono", monospace`;留空 = 跟随主题等宽字体(`--ds-font-family-code` token)。
2. **终端字号**(number 输入):9–32px,默认 13,带 `px` 后缀。
配置持久化到 `SidebarPrefs`(`dsh-better-sidebar` 命名空间,host 侧 PrefsSchema 校验),对**所有已打开终端实时生效**(UI tab 与 agent tab 共用 TerminalView):设置页提交 → `store.setPrefs` 通知 → xterm `options.fontFamily/fontSize` 更新 + 重排网格。
## 2. 非目标
- 不在终端视图内加菜单/按钮(用户明确选择仅设置页齿轮弹窗)。
- 不做 per-tab / per-terminal 隔离(全局偏好,与 SidebarPrefs 现有语义一致)。
- 不自动给字体族加引号、不做字体合法性嗅探(用户负责合法 CSS font-family;placeholder 示范格式)。
- 不改 pty / WS / 终端连接生命周期:字体变化不重连、不重启 shell。
## 3. 现状回顾
- 终端描述符(`builtins/tabs.tsx`)的 `settings.toggles` 只支持**布尔开关**(`SidebarSettingToggle` 无类型字段),`FeatureSettingsRows`(`SideCardSection.tsx`)只渲染 Switch 行。
- `SidebarPrefs` / `PrefsSchema` / `parsePrefs` 三处同步维护(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)。
- `TerminalView` 硬编码 `fontSize: 13` + `fontFamily: tokenValue('--ds-font-family-code') || 内置栈`,且不响应偏好变化。
## 4. 设计
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
```ts
interface SidebarPrefs {
// …
terminalFontFamily: string // '' = 跟随主题等宽字体
terminalFontSize: number // 9–32,默认 13
}
// prefs-shared.ts
export const TERMINAL_FONT_SIZE_MIN = 9
export const TERMINAL_FONT_SIZE_MAX = 32
export const TERMINAL_FONT_SIZE_DEFAULT = 13
export function clampTerminalFontSize(value: number): number // round + clamp(仿 clampWidthPercent)
```
- `PrefsSchema`:`terminalFontFamily: z.string().default('')`、`terminalFontSize: z.number().step(1).min(9).max(32).default(13)` —— 旧文档解析自动补默认,无迁移。
- `parsePrefs`:family 需 string 否则默认;size 需有限数并 `clampTerminalFontSize` 否则默认。
### 4.2 声明式 API 扩展(`client/service.ts`,向后兼容)
```ts
type SidebarSettingToggleType = 'switch' | 'text' | 'number'
interface SidebarSettingToggle {
key: string
title: string | (() => string)
desc?: string | (() => string)
type?: SidebarSettingToggleType // 缺省 'switch',外部插件现有用法不变
min?: number // number 行的下限(提交时钳制)
max?: number // number 行的上限
placeholder?: string // text 行的占位符
unit?: string // 输入框后的单位后缀(如 'px')
}
```
### 4.3 终端描述符声明(`builtins/tabs.tsx`)
`settings.toggles` 追加两行:`{ key: 'terminalFontFamily', type: 'text', placeholder: … }` 与 `{ key: 'terminalFontSize', type: 'number', min: 9, max: 32, unit: 'px' }`(标题/描述走 locales)。
### 4.4 设置行渲染(`SideCardSection.tsx` + CSS)
- `FeatureSettingsRows` 按 `toggle.type` 分支:`switch` 走现有 Switch 行;`text`/`number` 走新 `TypedRow`(局部 draft state,`Input` 原语,blur/Enter 提交,number 行带 unit 后缀)。
- `TypedRow` 的 `key` 含**已提交值**:提交失败 → prefs 回滚 → key 变化 → 行重挂载为存储值(输入中 draft 不因 key 变化被打断,因为 key 只随已提交值变)。
- 父级 `onCommitSetting(toggle, raw): string`:number 解析+钳制(非法/空 → 走 `Number('')=0` 钳到下限,与宽度行 `commitWidth` 先例一致);text 原样;乐观 `setPrefs` → `commit` → 失败 `applyOutcome` 回滚(复用既有 `applyPref` 管道,原 `togglePref` 更名)。
### 4.5 终端消费(`client/terminal-font.ts` 新模块 + `TerminalView.tsx`)
```ts
// terminal-font.ts(纯函数,可单测)
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
export function resolveTerminalFont(prefs, themeFontFamily):
{ fontFamily: 非空用户值 ?? themeFontFamily ?? 内置栈, fontSize: clampTerminalFontSize(prefs.terminalFontSize) }
```
- `TerminalView` 构造 Terminal 时用解析值;主 effect 内 `store.subscribe` 回调 diff `term.options.fontFamily/fontSize`,变化则更新 options + `fit.fit()` + `sendResize()`(网格尺寸随字体变化);cleanup 退订。懒加载 chunk 无新增依赖(store 为运行时 prop)。
> **实施偏差(2026-08-17)**:`resolveTerminalFont` 不再**逐字**返回胜出的基础字体,而是经 `withIconFontFallbacks()` 追加 Nerd Font 图标族后返回。
>
> 起因:starship / powerlevel10k 等提示符的图标取自 Nerd Font 私有区,Chromium 的隐式系统回退能覆盖 BMP PUA(`U+E000–U+F8FF`,如 `U+E0B0`)但覆盖不到补充平面 PUA-B(`U+F0000+`,Nerd Fonts v3 把 Material Design 图标集迁到了这里),导致后者渲染为豆腐块。显式列出字体族可使浏览器逐字符查询,两个平面都能命中。
>
> 三条约束:①**只追加不前置**——xterm 用字体栈首项测量单元格宽度;②插入到**第一个**通用族(`monospace` 等)之前——通用族是 catch-all,其后的字体永不被查询;③按族名去重(引号/大小写/空白不敏感),用户自列的字体保持其原有优先级。基础字体的优先级链(用户 pref > 主题 > 内置栈)不变,另新增 CSS 全局关键字(`inherit`/`initial`/`unset`/`revert`/`revert-layer`)过滤——这类值只在作为整条声明值时合法,追加后会得到非法 `font-family` 而被 CSSOM 静默丢弃。
### 4.6 i18n(`locales.ts`)
zh/en 各新增:`settingsFontFamilyTitle/Desc/Placeholder`、`settingsFontSizeTitle/Desc/Suffix`(px)。
## 5. 测试
- `tests/unit.spec.ts`:`parsePrefs`/`loadPrefs` 新字段(合法 string 直通、越界钳制 9/32、非 number 回退、缺省默认);全等字面量补字段。
- `tests/plugin-shape.spec.ts`:PrefsSchema 默认值 + overridden 全等。
- `tests/builtins.spec.ts`:终端 toggles keys = 4 个,后两个 type/min/max 断言。
- `tests/smoke.spec.ts`:settings.get 全默认字面量补字段。
- `tests/side-card-section.spec.tsx`(SSR):text/number 行渲染(value/min/max/placeholder/unit,非 checkbox)。
- `tests/side-card-section-rows.spec.tsx`(新,jsdom + createRoot):blur 提交回调、number 钳制、空输入钳到下限、提交失败回滚草稿(key 重挂载)。
- `tests/terminal-font.spec.ts`(新):`resolveTerminalFont` 回退链 + `clampTerminalFontSize`。
## 6. 限制与取舍
- 外部插件的自定义 toggle key 照旧被 host PrefsSchema 校验丢弃(现有 seam 行为);`type: 'text'/'number'` 是公开 API 扩展,外部插件可用但 key 仍须是宿主 schema 字段。
- number 输入的"非法值"在浏览器里只会以空串出现(number input 拒绝非数字字符),因此 `Number('')=0 → 钳到下限` 是唯一可达路径,与宽度行先例一致。
- 字号实时生效依赖 xterm 的 options setter + re-fit;若未来 xterm 版本行为变化,回退方案是重建 Terminal(不推荐,会丢滚动缓冲)。
## 7. 实施偏差记录
- 计划中的"非法输入回退当前存储值"用例在实现中发现不可达(浏览器 number input 的值清洗),改为断言"空输入钳到下限"(与宽度行一致)。
- `togglePref` 更名为 `applyPref`(它本就接受任意 patch,不只是布尔翻转)。
@@ -1,90 +0,0 @@
# 终端延迟初始化(零尺寸容器 xterm open 崩溃修复)设计
**日期**:2026-08-14
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.11.x(issue #25 修复)
## 1. 目标
修复 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25):WKWebView 下点击「展开底部面板」后底部面板内容空白、无终端,控制台报 `Cannot read properties of undefined (reading 'dimensions')` 与 `(reading 'handleResize')`。
根因:`TerminalView` 的挂载 effect 在容器可能为零尺寸时**同步**执行 `term.open(host); fit.fit()`。xterm 5.3.0 在零尺寸容器里 open 时渲染器创建失败,`RenderService._renderer.value` 保持 undefined,随后 Viewport 的 `_innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)崩溃。Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现。
修复:把 `term.open(host)` + `fit.fit()` 推迟到容器有实际尺寸之后再执行(xterm 在隐藏/零尺寸容器初始化的标准修法),open 恰好执行一次。
## 2. 非目标
- 不换 xterm 渲染器(打包产物只有 DomRenderer 是现状,不是本次根因;`rendererType` 保持默认)。
- 不改 pty / WS / 终端连接生命周期:推迟 open 不推迟连接、不重启 shell、不丢 transcript。
- 不改 host 半与 chunk 打包结构(新模块随 terminal chunk 打入 `lib/client-terminal.js`)。
- 不 bump 版本号(release 流程负责)。
## 3. 现状回顾
- `src/client/TerminalView.tsx` 第 99–101 行同步 `term.open(host); fit.fit()`。
- 零尺寸挂载路径真实存在且不止一条:
- 底部面板首次展开自动开终端(`Sidebar.tsx` 的 `bottomOpenedOnce` effect)在面板 slide-in transition 的同一次 commit 挂载 TerminalView,WKWebView 下此刻 host 高度为 0;
- 所有 tab 保持挂载(`split-pane.tsx`:非激活 tab 用 `.paneTabHidden { display: none }` 隐藏),面板折叠时保持挂载(`.panelHidden`)——任何 `display:none` / 零尺寸祖先都会踩同一坑。
- 前提已验证(xterm 5.3.0 与 @xterm/addon-fit 0.10.0 源码):
- `FitAddon.fit()` 在 open 之前是安全 no-op(`proposeDimensions` 先查 `this._terminal.element`,undefined 即返回)。
- `WriteBuffer.write()` 不依赖 open 状态:数据解析进 buffer,open 后渲染——推迟 open 不丢初始 transcript。
- `TerminalView` 已有的 ResizeObserver / 字体订阅里的 `fit.fit()` 在 open 前 no-op、open 后照常 re-fit,无需改动。
## 4. 设计
### 4.1 新模块 `src/client/open-when-sized.ts`(纯逻辑,可单测)
```ts
export function openWhenSized(
host: HTMLElement,
open: () => void,
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
caf: (id: number) => void = cancelAnimationFrame,
): () => void
```
- 单条代码路径:`raf(step)` → step 检查 `host.isConnected`(false 则停止,防卸载后空转)→ `clientWidth > 0 && clientHeight > 0` 则调 `open()` 并停止;否则再排一帧。
- 返回 cancel:`caf(frame)` 并置空(幂等),cleanup 调用。
- helper 不 try/catch(保持纯净),异常由调用方处理。
### 4.2 消费点 `src/client/TerminalView.tsx`
- 移除 effect 顶部的 `term.open(host); fit.fit()`(保留 `term.loadAddon(fit)`)。
- 在 effect 尾部(`sendResize` / `connect` 定义之后、`connect()` 调用之前)注册:
```ts
const cancelOpen = openWhenSized(host, () => {
try {
term.open(host)
fit.fit()
sendResize()
} catch (error) {
console.error('[dsh-better-sidebar] xterm open failed:', error)
}
})
```
- 回调里补 `sendResize()`:推迟路径下 socket 可能已先连上(`connect()` 在 effect 尾部同步调用),open+fit 后必须把真实 cols/rows 发出去,否则 pty 停留在默认 80x24 直到下次 resize;已尺寸快速路径下 socket 尚未 open,`sendResize` 内部守卫(`readyState === OPEN`)自然 no-op,无行为变化。
- cleanup 里在 `term.dispose()` 前加 `cancelOpen()`。
- 其余(connect / observer / fontSub / schemeSub / onData / 主题)不动。
## 5. 测试
`tests/open-when-sized.spec.ts`(jsdom + 注入手动步进的假 raf/caf;`Object.defineProperty` 桩 `clientWidth/clientHeight`;`appendChild/remove` 控制 `isConnected`):
1. 已尺寸 → tick 一次后 open 恰好一次,之后不再排帧;
2. 零尺寸 → 多次 tick 不 open,设尺寸后下一次 tick open 一次,后续 tick 不重复;
3. 只有一维缺失 → 补齐后 open;
4. cancel 后永不 open、无残留帧、cancel 幂等;
5. host 脱离文档 → 轮询停止、不 open;
6. open 抛错 → 异常向调用方传播(调用方负责 try/catch),不重复调用。
## 6. 限制与取舍
- rAF 轮询在 host 长期隐藏(`display:none` 祖先)期间持续排帧,每帧只读两个属性,开销可忽略;open 后立即停止。用 ResizeObserver 触发也可行,但轮询是 issue 中建议的标准修法,且不依赖 observer 对 transition 中间态的回调时序。
- 已尺寸快速路径比原行为晚一帧(rAF 回调时机),无感知差异。
- 若容器永远零尺寸(异常布局),终端永不 open、不崩溃——对不可见内容是可接受的降级。
## 7. 实施偏差记录
无(按设计实施)。
@@ -1,68 +0,0 @@
# 皮肤兼容(令牌驱动):面板表面、终端透明度回退与独立 CSS 修复
**日期**:2026-08-15
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:随发布 bump(本 PR 不改版本号、不更新 README——发布流程统一处理)
## 1. 目标
让 better-sidebar 与 **dsh-web-ui 的皮肤中心**兼容:用户切换皮肤时侧边栏自动跟随换肤,零每皮肤适配(KISS:不做 aionui 式"每款皮肤 remap 一套令牌"的重活)。附带修复同批 CSS 问题([#52](https://github.com/omdsh-dev/DSH-better-sidebar/issues/52) 弹出框遮挡、[#57](https://github.com/omdsh-dev/DSH-better-sidebar/issues/57) 前半图标尺寸、[#92](https://github.com/omdsh-dev/DSH-better-sidebar/issues/92) 拖拽回归防护),并覆盖 [#60](https://github.com/omdsh-dev/DSH-better-sidebar/issues/60)/[#105](https://github.com/omdsh-dev/DSH-better-sidebar/issues/105)/[#90](https://github.com/omdsh-dev/DSH-better-sidebar/issues/90) 的相关诉求。
## 2. 调研结论
dsh-web-ui(`zhu1090093659/dsh-web-ui`)的皮肤机制:
- 皮肤 = cordis 插件(`ui-skin-<id>`),皮肤中心通过 `cordis.patch.yml` 切换激活;皮肤插件 client half 在 `document.body` 上打 `data-dsh-<skin>` 属性并注入 CSS。
- **10 款皮肤全部覆盖两层令牌**:`--dsw-static-*` 基础色板 + `--dsw-alias-*` 语义层(`--dsw-alias-bg-layer-1`、`--dsw-alias-bg-base` 等做成半透明玻璃 rgba 0.16–0.7,`--dsw-specific-sidebar-fill` 也全部覆盖)。
- dsh-web-ui 自家右侧面板(aionui)用独立 `--aion-*` 令牌,每款皮肤单独 remap——**重**。
结论:better-sidebar 只要**消费 DSH 标准令牌**(alias 层),换肤就自动跟随,不需要任何每皮肤适配。此前围绕第三方皮肤插件(Aqua/deep-whale)设计的类名改名与 `data-bs-*` 语义钩子按 KISS 原则**全部回退**(见 §6)。
## 3. 设计
### 3.1 面板表面(`sidebar.module.css`)
```css
.panel { background: var(--dsw-alias-bg-layer-1); }
.bottomPanel { background: var(--dsw-alias-bg-layer-1); }
```
- 直接消费通用卡片表面令牌,**不新增自有令牌、不加属性钩子**。
- **绝不消费 `--dsw-specific-sidebar-fill`**:那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 半透明玻璃/主题色、Aqua `transparent`),面板消费它会失去填充或与标签令牌冲突。
- stock 外观色阶变化仅 ±8 RGB(bluish-50→75 / 900→875),视觉无差;dsh-web-ui 换肤后面板获得与宿主一致的玻璃效果。
### 3.2 终端/编辑器透明度回退(`theme.ts` / `TerminalView.tsx`)
`effectiveTokenValue` 现在把以下值视为"未设置"(触发调用方 `||` 回退):
- 空串与 CSS 重置关键字(`transparent`/`initial`/`inherit`/`unset`);
- **alpha < 0.9 的半透明颜色**(dsh-web-ui 皮肤的 `--dsw-alias-bg-base` 是 rgba 0.16–0.7 玻璃)。
新增 `colorAlpha()` 解析 computed 颜色(rgb()/rgba()/hsl()/hsla() 逗号与空格语法、`#rgb/#rgba/#rrggbb/#rrggbbaa`);无法解析的格式(命名色、`color()`)视为不透明放行。阈值 0.9:≥ 0.9 的近不透明值(如皮肤作用域内 0.96 瓷器玻璃)放行,皮肤仍能控制终端表面。
### 3.3 独立修复(与皮肤兼容无关,保留)
- **z-index(#52)**:`.toggleCluster` 55→45、`.panel`/`.bottomPanel` 50→40;角手柄移入面板后为面板内局部 `z-index: 2`。依据:DSH 浮层栈为 100(Menu/HoverCard/Tooltip/PopupSelect/Modal=1000),30–99 无任何 DSH 元素。
- **角手柄几何 CSS 化**:移入右面板 DOM,`position: absolute; left: -6px; bottom: calc(var(--dsh-sidebar-height, 0px) + 6px)`——复用拖拽逐帧写入的布局变量,删除 `cornerRef` JS 内联坐标。
- **刷新图标统一 `size={14}`(#57 前半)**:Explorer/Git/Diff 的 `IconRefreshOutline16` 显式 14px,与 Subagent/Browser 一致。
- **拖拽逐帧回归 e2e(#92)**:`tests/e2e/drag-layout.e2e.ts` rAF 采样,断言拖拽期间 `data-dsh-sidebar-dragging` 存在、`#root` transition 为 none、会话列与面板边单调 1:1 跟随(实测通过)。拖条以 `cursor: col-resize` 语义定位(无属性钩子可依赖)。
## 4. 测试
- `tests/theme.spec.ts`:`colorAlpha`(hex/rgb/hsl/不可解析)与 `effectiveTokenValue`(transparent/关键词/半透明回退/0.96 放行/缺失)。
- `tests/e2e/mount.e2e.ts`:追加 `--dsh-sidebar-width` 布局变量随面板挂载生效的断言(令牌驱动契约的挂载级守护)。
- `tests/e2e/drag-layout.e2e.ts`:拖拽逐帧回归。
- 门禁:`pnpm typecheck && pnpm test && pnpm build && pnpm pack && pnpm test:mount`。
## 5. 文档
- AGENTS.md §8「皮肤兼容(令牌驱动)」:面板表面令牌、透明度阈值、z-index 层级、根锚点 `[data-dsh-better-sidebar]`、类名非契约。
- README 不在本批更新(发布时随版本条目更新)。
## 6. 实施偏差记录
- **首版契约(已回退)**:初版围绕第三方皮肤插件设计了 `--dsh-sidebar-surface`/`--dsh-resize-strip-offset` 自有令牌(review 后已删)、panel 家族类名 kebab-case 改名(breaking)与 `data-bs-*` 语义钩子。确认主目标是 dsh-web-ui 皮肤中心后,按 KISS 全部回退:令牌驱动下这些都不需要,改名还引入无谓 breaking。最终契约 = 消费标准令牌 + 透明度阈值,无自有概念。
- **角手柄 `z-index` 语义变化**:移入面板后 z-index 为面板内层叠(2,与拖条一致),对外绝对层级由面板(40)决定;两面板同开时手柄盒与底面板上沿无重叠(≥6px 间隙),行为不变。
- **z-index 层级修正(2026-08-22)**:上文 "30–99 无任何 DSH 元素" 的假设不成立——DSH 的 ui-cordis 动态插件面板(`CordisPanel`,`position:fixed; z-index:30`,挂 `sidebar.footer.action` 槽)正落在该区间,且其清单/审批面会被 better-sidebar 的底部面板遮挡。修正:面板宿主层 40 → **25**(仍在 AppFrame overlayLayer 20 之上、ui-cordis 面板 30 与 DSH 浮层栈 100+ 之下),折叠按钮簇保持 host 内部 45(绝对层级随宿主 = 25)。
- **拖拽禁用断言修正**:`transition: none` 的 computed 值为 `transition-property: none`(非 `all`),已按实测修正。
- **面板默认表面色阶微调**:±8 RGB,未做像素级补偿(e2e 不依赖具体颜色)。
@@ -1,89 +0,0 @@
# 外链点击路由到插件 tab(urlTarget)+ 浏览器 HTTP/HTTPS 二级设置
**日期**:2026-08-15
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.13.0(API 兼容层,版本号未随本变更 bump,见 §7)
## 1. 目标
基于 [PR #74](https://github.com/omdsh-dev/DSH-better-sidebar/pull/74)(聊天区外链开关颗粒度讨论)向前兼容地拓展服务化 API:
1. **插件声明「点击打开的类型」**:`TabDescriptor` 新增可选 `urlTarget?: (url: URL) => boolean`——聊天区外链被拦截时,路由到第一个声明的、命中的、未被设置禁用的插件 tab 类型(`openTab({ type, url, title: hostname })`,URL 即全部载荷),内置浏览器 tab 退居隐式兜底。
2. **浏览器链接设置改为二级菜单**(浏览器卡片齿轮弹窗内):
- 侧边打开 HTTP 网页(默认开)
- 侧边打开 HTTPS 网页(默认关)
- `browserInterceptLinks` 保留为**总闸**(默认开,零迁移:旧文档显式 false 语义完整保留)。
## 2. 非目标
- 不实施 PR #74 的「外链开关提升到常规区」(用户确认:二级设置留在浏览器卡片齿轮弹窗,常规区不加行)。
- 不做 per-host / per-domain 路由规则(只有 per-protocol 开关 + 插件谓词)。
- 不改 + 菜单、不改手动打开的浏览器 tab、不改同源/修饰键点击放行规则。
- 不改 host 半逻辑(仅 PrefsSchema 加字段);不修改 DSH 源码。
- 不做版本号 bump(发布时决定;向前兼容机制 = `SIDEBAR_FEATURES` 追加 `'urlTarget'`)。
## 3. 现状回顾
- `link-intercept.ts` 的 `registerLinkInterception` 无参 `takeoverEnabled()` 只做布尔门;`index.tsx` 硬编码 `openTab({ type: 'browser', url, title: hostname })`。
- `browserInterceptLinks`(默认 true)与 `browserNoSandbox` 并列于浏览器卡片的 `settings.toggles`;拦截 gating = 该 pref && `tabsEnabled['browser']`。
- `openTab` seed 已有 `url` 字段(新建 tab 的 path 预填),插件 tab 无需新渲染管道。
## 4. 设计
### 4.1 Prefs(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
```ts
interface SidebarPrefs {
// …
browserInterceptLinks: boolean // 总闸,默认 true(保留,零迁移)
browserInterceptHttp: boolean // HTTP 外链侧边栏接管,默认 true
browserInterceptHttps: boolean // HTTPS 外链侧边栏接管,默认 false
}
```
三处同步维护(类型 + schemastery 默认 + parsePrefs 校验),旧文档无需迁移:总闸保留使显式关闭过的用户升级后仍不拦截。
### 4.2 服务 API(`service.ts`)
```ts
interface TabDescriptor {
// …
urlTarget?: (url: URL) => boolean
}
/** 注册顺序先到先得;谓词抛错吞掉(console.error)继续;返回 undefined 由调用方回退 browser。 */
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined
```
- 匹配函数为**模块级纯函数**(不进 `BetterSidebarService` 接口,KISS:不扩公开面);启用态过滤由调用方(`index.tsx`)按 `tabsEnabled` 过滤后传入。
- 内置 browser 不声明 `urlTarget`,永远不可能遮蔽插件声明;插件多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则 id 安全网聚焦既有 tab 不覆写 path。
- `SIDEBAR_FEATURES` 追加 `'urlTarget'`(消费者按能力 gate)。
### 4.3 拦截管线(`link-intercept.ts` / `index.tsx`)
- `shouldInterceptLink` 签名不变(协议/同源纯判定);`takeoverEnabled` 改为 `(url: URL) => boolean`,onClick 顺序:纯判定 → gate(持 URL)→ preventDefault → open。
- `index.tsx` gate:总闸 `browserInterceptLinks` → 协议开关(https 读 `browserInterceptHttps`,否则读 `browserInterceptHttp`)→ 目标可开(插件命中即目标,否则 browser 的 `tabsEnabled`)。open 时再次解析目标并 `openTab({ type, url, title: hostname })`。
### 4.4 设置 UI(`builtins/tabs.tsx` / `locales.ts`)
浏览器卡片 `settings.toggles` 收敛为四行:`browserNoSandbox` / `browserInterceptLinks`(总闸)/ `browserInterceptHttp` / `browserInterceptHttps`;新增 zh/en 文案(HTTP 默认开、HTTPS 默认关及理由)。
## 5. 行为变化
- 默认设置下:HTTP 外链 → 侧边栏(不变);**HTTPS 外链 → 系统浏览器新窗口(变化,符合「默认关闭」)**。
- 声明 `urlTarget` 的插件 tab 认领被拦截的链接(优先于浏览器兜底);插件被禁用/谓词抛错时跳过。
- 总闸关闭 → 一律不拦截(旧文档语义保留)。
## 6. 测试
- `tests/builtins.spec.ts`:浏览器 toggles 断言收敛为四行 + 各行 title/desc 存在。
- `tests/service.spec.ts`:`matchUrlTarget`(无声明 → undefined / 注册顺序 / 抛错吞掉 / browser 永不命中)。
- `tests/link-intercept.spec.ts`(新增 jsdom):`takeoverEnabled(url)` 收到解析后的 URL、gate 决定是否接管、同源/非 http(s) 不 consult gate、修饰键绕过。
- `tests/unit.spec.ts`:parsePrefs 新字段默认值(http true / https false / 非法回退);总闸与协议开关相互独立。
- `tests/plugin-shape.spec.ts` / `tests/smoke.spec.ts`:全默认字面量补两个字段。
## 7. 实施偏差记录
- 计划曾考虑把 `matchUrlTarget` 作为 `BetterSidebarService` 接口方法(镜像 `matchFileViewer`);按 KISS 收敛为**模块级导出纯函数**,启用态过滤由调用方完成(匹配函数不感知 prefs),公开 API 面只剩 `urlTarget` 字段 + `'urlTarget'` feature 字符串。
- 版本号未 bump(保持 0.12.1):`SIDEBAR_SERVICE_VERSION` 与 `package.json` 的配对断言继续成立;`'urlTarget'` feature 已入 `SIDEBAR_FEATURES`,发布时再决定版本。
- PR #74 的 diff(常规区移动 + 其测试改动)被本方案取代,未合入。
@@ -1,50 +0,0 @@
# Edge 151 无端口 Origin 导致的侧边栏 403 forbidden 修复
日期:2026-08-17
## 现象
Windows + Edge 151(151.0.4129.86)下,`dsh web` 主应用一切正常,但 better-sidebar 的文件管理(Explorer / 文件树)显示 `forbidden`。抓包确认:`POST /sidebar/api/fs.tree` 返回 `403`,响应体为 `{"ok":false,"error":{"code":"forbidden","message":"forbidden"}}`——命中插件自身信任栅栏的拒绝分支,而不是业务错误。
## 根因
`src/trust-fence.ts` 是 DSH `/api` 网关信任栅栏(`@deepseek-ai/dsh-client-connection` 的 `api-request-trust.ts`)的行为镜像。Origin 校验一段,镜像版仍按 `host`(含端口)比较:
```ts
return new URL(origin).host === hostUrl.host
```
DSH 官方栅栏后来修复为按 `hostname` 比较,理由是:**部分 Chromium 构建(Edge 151)会把非默认端口 loopback 页面的 Origin 序列化为不带端口的形式**(`http://127.0.0.1:3080` 的页面,fetch 携带 `Origin: http://127.0.0.1`)。
于是 Edge 151 下:
- 页面 `http://127.0.0.1:3080` 发出的请求,`Origin: http://127.0.0.1`,`Host: 127.0.0.1:3080`;
- 镜像栅栏比较 `new URL('http://127.0.0.1').host`(= `127.0.0.1`)与 Host `127.0.0.1:3080` → 不等 → 403;
- DSH 自己的 `/api` 栅栏比较 hostname → 相等 → 放行,所以主应用正常,只有 `/sidebar/*` 全部 403。
镜像与官方行为漂移,正好打破镜像文件头声明的「behaviorally identical」契约。
## 证据
- 本机 Edge 版本 151.0.4129.86(DSH 注释点名的构建)。
- curl 实测(对运行中的 `dsh web`,`/sidebar/api/fs.list` 用错方法名以区分 403 与方法分发):
- `Origin: http://127.0.0.1:3080`(带端口)→ 200 `unknown sidebar API method`(过了栅栏);
- `Origin: http://127.0.0.1`(Edge 151 形式)→ 403 `forbidden`。
- DSH 修复参考:`packages/client/connection/src/api-request-trust.ts`(注释点名 Edge 151)及测试 `tests/api-request-trust.host.spec.ts` 的「accepts an Origin that names the Host hostname without its port (Edge 151 serialization)」。
## 方案
1. `src/trust-fence.ts` 的 Origin 校验改为 `new URL(origin).hostname === hostUrl.hostname`,注释写明与 DSH 官方栅栏保持一致的语义与理由(Host 栅栏已绑定 authority,端口不再重决信任)。
2. `tests/trusted-hosts.spec.ts` 增加回归用例:loopback Host + 不带端口的 Origin → 200;同时保留带端口与跨域拒绝用例。
行为语义变化(有意为之,与 DSH 一致):Origin 与 Host 的 hostname 相同即认为同源,端口差异不再拒绝。安全前提不变:Host 栅栏仍要求 Host 为 loopback 或 `webRuntime.trustedHosts` 授权项,`sec-fetch-site: cross-site` 仍一律拒绝。
## 验证
1. `pnpm test`(vitest)新增用例通过,既有 trusted-hosts 用例不回归。
2. `pnpm typecheck`。
3. 本地挂载:`pnpm build` 后覆盖 web profile 的 node_modules 产物,重启 `dsh web`,curl 以 `Origin: http://127.0.0.1`(无端口)请求 `/sidebar/api/*` 应进入方法分发(404 unknown method)而非 403;浏览器硬刷新后文件管理恢复。
## 发布
按仓库 §0 流程:`fix/*` 分支 → PR → review 合并 → bump 版本打 tag 走 release workflow。即时修复可先本地覆盖 profile 产物验证,正式生效待 0.13.1 发布后 `dsh plugin add dsh-better-sidebar@latest`。
@@ -1,82 +0,0 @@
# Markdown 预览 Mermaid 安全渲染设计
> 日期:2026-08-17
> 状态:已实现
> 范围:`dsh-better-sidebar` 插件,Markdown 预览 + 新 `mermaid` 懒加载 chunk
> 基线:社区 PR #99(`feat/mermaid-md-preview`)的架构与安全姿势;借鉴 PR #75(`feat/mermaid-rendering`)的点击放大交互
## 1. 背景与问题
Markdown 预览(`TextEditor.tsx` 的 preview 模式)直接把全文交给 DSH 的 `MarkdownText`(`@deepseek-ai/dsh-client-ui-primitives`)渲染。该渲染器的 fenced code 一律走 `CodeBlock`(Shiki 高亮 + 复制按钮),且**不支持自定义 fence 渲染器**——```` ```mermaid ```` 块只会显示成一段无高亮的代码,不会渲染成图(DSH 宿主内 `mermaid` 字样出现 0 次,Shiki 别名表也无此语言)。
社区出现过两个 mermaid PR,安全姿势差异极大:
| | PR #75 | PR #99 |
|---|---|---|
| 加载 | 运行时 jsdelivr CDN(第三方供应链、离线不可用、无版本锁) | 自有懒加载 chunk(mermaid 11.16.1 本地打包,`/sidebar/bundle` 同源下发) |
| 安全 | `securityLevel: 'loose'`(标签可注入原始 HTML)+ 无清洗 + `innerHTML` 直插 | `securityLevel: 'strict'` + `htmlLabels: false` + SVG 二次清洗 + 不应用 `bindFunctions` |
| 测试/文档 | 无 | 单测 + e2e + 设计文档 |
| 亮点 | 点击放大弹窗(滚轮缩放/拖拽/快捷键) | 主题跟随、错误回退、CodeBlock 同款 chrome |
**结论:以 #99 为基线**。md 文件来自工作区(可能来自克隆仓库/下载),不可信;`loose` + 无清洗在 DSH GUI origin 下就是 XSS(可读会话数据)。**仅借鉴 #75 的点击放大交互**,在 #99 的已清洗 SVG 之上用 React 重写(克隆节点无事件面,弹窗不新增攻击面)。本地图片引用重写(#99 顺带功能)不在本 PR 范围,另行独立 PR。
## 2. 方案
### 2.1 检测:纯函数按 fence 切块
新增 `src/client/mermaid-blocks.ts`(`splitMermaidBlocks`,纯函数、无依赖),职责是**检测**预览是否需要 mermaid chunk:
- 逐行扫描,命中 info string 为 `mermaid` 的围栏(大小写不敏感,支持 `mermaid{...}` 属性后缀,缩进 ≤3 空格,CommonMark 语义)即"提出"为 `{ kind: 'mermaid', code }` 块;
- 其余行原样保留在 `{ kind: 'markdown', text }` 块中(含非 mermaid 围栏);
- 未闭合的 mermaid fence 吞掉文件剩余部分(与 CommonMark 对开 fence 的恢复一致);
- **CommonMark fence 规则完整**:开 fence 为 3+ 反引号**或波浪号**;闭 fence 必须同字符且长度 ≥ 开 fence;反引号 fence 的 info string 含反引号时该行不是开 fence。
`TextEditor` 预览时先 `splitMermaidBlocks`,**无 mermaid 块则走原 `MarkdownText` 直渲路径(零行为变化)**;有则渲染 `MermaidMarkdown`(mermaid chunk 内)。
### 2.2 渲染架构:单次解析 + 占位替换(CR 第 3 轮 P1 修正)
早期方案把文档切成多个独立 `MarkdownText` 片段,会破坏跨 fence 的 Markdown 语义(引用式链接定义在 fence 之后、脚注、有序列表连续性)。当前实现:
- **整篇文档只走一次 `MarkdownText`**(`MermaidMarkdown`),跨 fence 语义完整;
- 布局阶段(`useLayoutEffect`)扫描容器内 `.md-code-block`,识别 mermaid 块(双通道:shiki 路径的 `code.language-mermaid` 类,或 plain 路径的 banner infostring 文本 === `mermaid`——mermaid 无 shiki 语法必走 plain,语言只显示在 infostring 里),用 `createRoot` 把 `MermaidDiagram` 挂进替换后的子节点;**React 管理的 `.md-code-block` 宿主节点保留在树中**(仅替换其 children,CSS `display: contents` 抑制代码块自身盒子),React 协调不会丢失宿主;
- 块内容变化(编辑后重新预览)按源码 diff 重渲;块不再是 mermaid fence 时还原原 children(普通代码块恢复显示);fence 消失时卸载对应 root;
- DSH `MarkdownText` 无代码块渲染扩展入口(且仓库硬约束禁止改 DSH),这是约束内的最小侵入方案。
### 2.2 懒加载 chunk:`mermaid`
mermaid 及其图依赖(d3 / dagre-d3-es / cytoscape 等)打包进新的 `lib/client-mermaid.js`(~7MB),复用既有 chunk 机制(tsdown `CHUNKS` + `/sidebar/bundle` 路由 + `globalThis.__dshChunks__` 注册表 + `lazyChunkComponent` 懒包装),仅当预览的 md 文件实际含 mermaid fence 时才下载/执行。chunk 名在 4 处同步:`tsdown.config.ts` `CHUNKS`、`src/bundle-route.ts` `CHUNK_NAMES`、`src/client/chunk-loader.ts` `ChunkName`、`package.json` `files`(及各 spec 的硬编码清单)。构建侧对 mermaid chunk 注入 `mermaidChunkAliases`:把裸 `uuid` 说明符解析到浏览器入口(`uuid/dist/index.js`,Web Crypto 实现),绕过其 node 入口的 `node:crypto` 触发客户端纯度门。
### 2.3 渲染与安全(纵深防御)
- `mermaid.render(id, code)` 客户端渲染 → SVG 字符串注入;每个渲染调用用单调递增 id(文档内唯一);
- `mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', htmlLabels: false, theme })`——strict 下标签被转义、`click` 指令失效;`htmlLabels: false` 强制节点文字走真实 SVG `<text>`(默认的 `foreignObject` HTML 标签通道会被清洗器整体删除,导致文字丢失),同时把 HTML 标签注入面关死;`bindFunctions` **不**应用(静态图,无点击交互);订阅 `subscribeColorScheme`,深浅色切换自动按新主题重渲;
- **SVG 注入前二次清洗**(`src/client/mermaid-sanitize.ts`,独立纯函数、可单测):`DOMParser`(`image/svg+xml`)解析,删除 `foreignObject` 与 `script`/外来 HTML 元素(`img`/`iframe`/`object`/`embed`/`video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`),剥离 `@*`/`on*` 属性,**移除全部 `href`/`xlink:href`**(静态图不需要链接,杜绝 javascript:/data: 乃至普通外链把整个 GUI 导航走);仅接受根元素为 `<svg>` 的文档;解析失败(parsererror/非 SVG 根)返回空串,调用方显示错误回退,**原始字符串永不过清洗器**;
- 渲染失败(语法错误/清洗拒绝)展示错误条 + 原始代码块(便于对照修改),错误全文挂 tooltip;空 fence 不渲染;
- 块头保留与 `CodeBlock` 一致的 chrome:info string「mermaid」+ 复制按钮(`writeClipboard`,沿用 `copy`/`copied` 字典)。块体带稳定锚点 `data-mermaid-diagram`(e2e 断言用)。
### 2.4 点击放大弹窗(借鉴 PR #75,React 重写)
点击 `[data-mermaid-diagram]` 内 SVG → 克隆节点(**已清洗**,无事件属性)→ portalled fixed overlay(`data-mermaid-modal`):
- 滚轮缩放(以鼠标位置为不动点,被动标志关闭防止页面滚动);
- 拖拽平移(mousedown/mousemove/mouseup on window);
- 工具栏按钮:− / + / ⟳ / ✕(i18n title);
- 键盘:`+`/`=` 放大、`-` 缩小、`0` 重置、`Esc` 关闭;
- 背景点击关闭;卸载时移除全部监听;
- 样式全部走 CSS Module + DSH 令牌(遮罩 `--dsw-alias-bg-mask-1` + `blur(2px)`,与 DSH `Modal` 一致;z-index 1000 落在 DSH 浮层栈)。
## 3. 验证
- 单测:`tests/mermaid-blocks.spec.ts`(14 例:fence 识别大小写/属性/缩进、4 反引号/波浪号、同字符且不短于开 fence 的闭 fence、非 mermaid 围栏不动、交错顺序、开 fence 恢复、空文件);`tests/mermaid-sanitize.spec.ts`(10 例 XSS:foreignObject/script/外来元素整节点剥离(含混合大小写元素名与属性名)、`on*`/`@*` 剥离、href/xlink:href 全删、畸形 XML → 空串、非 SVG 根 → 空串、良性图保真);`tests/mermaid-markdown.spec.tsx`(3 例架构回归:mermaid 块被交换为图、**跨 fence 引用式链接在单次解析下解析**、非 mermaid 围栏不动);
- 产物契约:`chunk-artifact.spec.ts`(mermaid chunk 执行 + registry 槽位 + 平台 externals require,`addEventListener` stub)/ `manifest-consistency.spec.ts` / `bundle-route.spec.ts` 的 chunk 清单同步加入 `mermaid`;
- 挂载冒烟:`tests/e2e/mount.e2e.ts` 新增 seed `diagram.md`,打开后强制 `client-mermaid.js` 往返、断言 `[data-mermaid-diagram] svg` 含节点文字(真实 `<text>`)、**跨 fence 引用式链接解析为真实锚点(单次解析证明)**、点击弹窗出现且 Esc 关闭、预览模式编辑器隐藏;缺 chunk、文字丢失、语义破坏、弹窗失效任一即红;
- 现有 `markdown-copy-labels.spec.tsx` 不回归(无 mermaid 文件走原路径)。
## 4. 风险与取舍
- 编辑器 chunk 之外新增 ~7MB 懒 chunk(mermaid + d3/dagre/cytoscape/katex 图依赖);仅预览含 mermaid fence 的 md 时下载,启动与普通文本打开路径零成本;
- `splitMermaidBlocks` 对"mermaid 围栏嵌在其他代码块内"的极边缘情况会误提(只影响是否走 mermaid 路径,不影响渲染正确性)——预览级工具接受该取舍(与常见 md 渲染器行为一致);
- 占位替换依赖 DSH `MarkdownText` 的 DOM 输出结构(`.md-code-block` + `language-mermaid`):宿主节点保留在 React 树中、仅换 children,协调安全;若未来 DSH 渲染器结构变化,`data-mermaid-processed` 扫描点需要同步(有 e2e 兜底);
- 清洗器删除全部 `href`/`xlink:href`:`<use href>` 类图标引用可能不显示(纯装饰性损失),换取确定性的无导航/无脚本面;
- mermaid 官方核心包持续演化,锁 `^11.16.1`,升级靠 Renovate 常规流程;
- 回滚:删除 chunk 三处声明 + `files` 条目 + `TextEditor` 分支即可回到纯代码块展示。
@@ -1,27 +0,0 @@
# 编辑器预览刷新三件套(issue #167)
日期:2026-08-19
## 问题(#167,无评论无修复)
`.md` 文件编辑保存后,切到预览不立即渲染新内容,需关闭文件重新打开。根因:`EditorHost` 的加载 effect 只依赖 `[sessionId, cwd, path, ctx, showEmpty]`——保存(fsWrite)与模式切换(setMode)都不重跑加载,且工具栏没有手动刷新入口。
## 方案(最小三件套,全部在 EditorHost.tsx)
| 编号 | 行为 | 实现 |
|------|------|------|
| A | 手动刷新按钮(文本类 viewer,即 toolbar 非空时显示) | 新增 `reloadSeq` state;effect 依赖加 `reloadSeq`;header 刷新按钮 `setReloadSeq(s => s + 1)` |
| B | 编辑保存后切回预览自动刷新 | header「预览」按钮 onClick:`toolbar.mode === 'edit'` 且非 dirty 且 saveState 非 failed → 先 `setReloadSeq(+1)` 再 `setMode('preview')`;dirty 时不刷新(草稿只在 CodeMirror 内,重载会丢) |
| C | 预览模式下保存成功后刷新 | 监听 `toolbar.saveState` 从非 `'saved'` 变为 `'saved'` 且 `mode === 'preview'` → `setReloadSeq(+1)` |
刷新语义:重载走既有 load effect(AbortController 会中止旧加载;重挂载后 TextEditor 内部 state 重置为 preview 模式——与「关闭重开」等价,但保留 tab 与面板状态)。
## 范围外(记录,不实现)
- LLM 改文件后自动刷新(磁盘指纹对比 + silent 不闪屏)——分形有先例,另行立项;
- image/pdf/binary viewer 的手动刷新(toolbar 为空,无入口;内容静态,低优先级)。
## 验证
- 单测:刷新按钮渲染 + 点击触发重载(mock fsRead 调用次数);B 的 dirty 抑制;C 的 saveState 边沿触发;
- 手工:编辑 md → 保存 → 切预览即时渲染;预览模式 Ctrl+S 后刷新;刷新按钮任意 viewer 可用。
@@ -1,262 +0,0 @@
# 侧边栏面板注入方案设计:统一面板宿主(插件侧) + 官方槽位路线(上游侧)
**日期**:2026-08-19
**状态**:评审中(设计文档先行,实现待批准)
**作者**:DSH 工程模式会话
**当前版本**:v0.13.0(main)
**目标版本**:v0.13.x(不 bump 大版本)
## 1. 目标
1. 解决侧边栏面板的**注入方法**问题:找到一个同时兼容「纯浏览器 / Electron 套壳(DSH Desktop)/ 各类 Tauri/WebView 套壳 / 移动 webview」且**性能良好**的统一注入方案。
2. 消灭现有症状级补丁 PR 簇(#226 desktop 布局推挤、#135 dsh-web-mobile、#82/#108/#111/#138/#153 桌面标题栏)背后的结构性根因:**插件选择器深入宿主 DOM 形状(nth-child 锚点)+ 桌面壳依赖手动设置(titleBarCompat)+ 几何写路径分散(拖拽/状态两条)+ 懒加载缓存每次激活清空(重注入重执行)**。
3. 探索并文档化「官方槽位路线」:参考 DSH 内置插件 `ui-sidebar`(官方左侧边栏本身就是以插件方式占用 `sidebar` 槽),评估外部插件接管官方面板槽的可行性与代价,作为中期北极星;上游未落地前以插件侧方案为主线。
## 2. 非目标(Out of Scope)
- **不修改 DSH 官方源码**(仓库硬约束,`~/.dsh/source/current` 零写入);官方槽位路线只产出 issue/设计建议给上游。
- 不做 shadow DOM / iframe 全隔离注入(令牌穿透、事件与 portal 代价高,仅在宿主样式污染实测出现时作为兜底启用)。
- 不重构 tab/viewer 扩展点本身(`ctx.betterSidebar` 服务契约不变)。
- 不承诺禁用官方 `ui-sidebar` 的行为由插件自动完成——那是 **profile 层用户决策**,插件只文档化。
- 不处理 #69(browser 探测,独立域)。
## 3. 已验证事实(证据先行,2026-08-19 核对)
### 3.1 当前注入实现(本仓库)
| 事实 | 证据 |
|---|---|
| 注入 = body 直下 `div[data-dsh-better-sidebar]` + **第二个 React root**(非 portal) | `src/client/index.tsx:120-141` |
| 宿主推挤 = html 上 `--dsh-sidebar-width/height` + `#root{margin-right}` + **`#root > [data-slot=root] > div > div:nth-child(2)` 锚点** | `src/client/layout.css:22-32` |
| 拖拽:pointer 事件 + rAF 单帧写面板尺寸与 html 变量(无强制 reflow 读) | `src/client/Sidebar.tsx:494-518` |
| 懒加载 chunk:每次激活 `resetChunks()` 清内存缓存 → 下次懒打开**重注入+重执行**脚本;HTTP 层已有 ETag 304(**不重下载**) | `src/client/index.tsx:120` 注释、`chunk-loader.ts` |
| 会话切换:只换 snapshot,面板 DOM 不重建(保持) | `Sidebar.tsx` store 驱动 |
| **已具备(实施勿重做)**:拖拽 rAF 批处理(`scheduleDrag` 单帧一次 `applyDrag`,无强制 reflow 读);窄屏不推挤宿主(narrow 时宽/高变量写 0);`measureCenter` ResizeObserver 已 rAF 节流;header 推挤已用弱锚点 `[data-slot="conversation.session.header"]` | `Sidebar.tsx:368-424,487-533,563-585`、`layout.css:45` |
### 3.2 DSH 官方架构(`~/.dsh/source/current`,只读)
| 事实 | 证据 |
|---|---|
| 外壳 = `#root` 单挂载点 + `__DSH_BOOT__` 引导;真实 UI 树全挂在 `root` 槽 | `apps/web/index.html:10-12`、`packages/client/web/src/app.tsx:38-43` |
| `ui-layout` 声明 `root` 槽的 children:`sidebar`/`conversation`/`details` 三个 **single 独占槽**;single 槽重复声明**抛错** | `ui-layout/src/client/index.ts:80-96`、`ui-slots/src/index.ts:747` |
| **官方左侧边栏 = 内置插件 `ui-sidebar`**:注册 `name:'sidebar'`,渲染在 AppFrame 的 grid 列内;几何经 layout store → `gridTemplateColumns` 内联传导(非 CSS 变量);**官方不持久化宽度/折叠**;折叠按钮在 SidebarRoot 内自渲染并调 `ctx.layout.toggleSidebar()`;拖拽把手由 AppFrame 免费提供 | `ui-sidebar/src/client/index.ts:39`、`ui-layout/src/client/AppFrame.tsx:164-196` |
| `details` 槽(右侧面板)被 `ui-conversation` 的 DetailsPanel 占用;禁 ui-conversation = 会话列整体空置,代价极高 | `ui-conversation/src/client/apply.ts:445-458` |
| 官方浮层(Menu/Modal/Toast/HoverCard)全部 `createPortal(document.body)`——**body 级 portal 是官方姿势** | `ui-primitives/Menu.tsx:299`、`Modal.tsx:55-85` |
| 槽位注入消费者经 `slots.inject` 注入;槽未声明时**静默跳过**(禁用 ui-sidebar 不会炸树,但接管者需自实现会话/设置入口) | `runtime/src/client/slots.ts:130-192` |
| profile 可禁用内置插件行:`cordis.patch.yml` `- id: <plugin-id>` + `disabled: true`(皮肤 10 款禁用先例) | `~/.dsh/profiles/web/cordis.patch.yml` |
| 性能参考:拖拽 rAF 合并最新 delta 单帧应用 + `[data-dragging]` 禁过渡 + 纯函数让步结算 + 「关闭=宽 0 保活不卸载」 | `AppFrame.tsx:59-62`、`AppFrame.module.css:8-17`、`columns.ts:62-77` |
### 3.4 实证核对(真实 dsh web 页面,Playwright 探针,2026-08-19)
对 http://127.0.0.1:3080/(本机 profile,better-sidebar 已挂载)实测 DOM 与推挤行为:
| 事实 | 实测结果 |
|---|---|
| 外壳 DOM 链 | `#root > div[data-slot="root"]`(**display:contents**)`> div[data-dsh-frame]`(grid,CSS module 哈希类名) |
| frame 子项 | **5 个**:`[data-pane=sidebar]` / `[data-pane=conversation]` / `[data-pane=details]` / `[data-shell-overlay]`(浮层)/ 拖拽 handle——`nth-child(2)` 当前仍命中 centerCol,但**位置脆弱性实锤**(浮层/handle 已混入子项序列) |
| 稳定锚点 | centerCol 带 `data-pane="conversation"`;frame 带 `data-dsh-frame` → 弱锚点 = `#root [data-dsh-frame] > [data-pane="conversation"]` |
| ~~`[data-slot="conversation"]` 锚点候选~~ | **作废**:该元素 `display: contents`(无盒,margin 无效,rect 0×0) |
| 推挤无溢出(web) | 设 `--dsh-sidebar-width:300px` 后:`#root` margin-right 生效(过渡中 92px),`scrollWidth == clientWidth == 1440`,**无水平溢出** → `width: calc(100% - var)` 改动在 web 无副作用;桌面壳溢出修复(#226 目标)保留并标注「web 已验证无害、Desktop 待实测」 |
| 当前 host 本体 | `[data-dsh-better-sidebar]` 为 `position: static` 的普通块(fixed 在**内层面板**上)→ 新增 `data-dsh-panel-host` 固定含块层是宿主内新元素,外部契约不变 |
| 页面错误 | 0 个 pageerror(探针期间) |
### 3.3 桌面套壳(本机 DSH Desktop.app,Electron)
| 事实 | 证据 |
|---|---|
| 渲染 URL 注入信号:`?dsh-desktop-mode=<compatibility\|advanced>&dsh-desktop-platform=<…>` | `app.asar.unpacked/lib/src-B5373ech.js` |
| win32:`titleBarStyle:"hidden"` + `titleBarOverlay({height:32})`(右上角 32px 为窗口控制钮) | `electron-runtime-C_i38SKA.js:338-348` |
| darwin:`titleBarStyle:"hiddenInset"` + `trafficLightPosition(16,16)`(左上红绿灯区 ~78px) | 同文件 `:325-333` |
| 安全基线:`contextIsolation/sandbox:true`、`nodeIntegration:false`;preload 仅暴露 `__DSH_DESKTOP_FILE_PATH__` | `preload.cjs:5-9` |
| 社区 Tauri 套壳(kyorakuyk/dsh-desktop、Lotus-c/DSH-Desktop)用**原生系统标题栏**,fixed 语义与纯浏览器一致 | 子代理调研(来源 URL 见 §10) |
| 套壳最大注入风险:祖先 `transform/will-change` 劫持 fixed 包含块、标题栏 overlay 遮顶、drag 区吞事件、移动键盘盖 `bottom:0` | 子代理调研 |
## 4. 方案总览:双路线
```
路线 A(主线,本仓库可控)┐
统一面板宿主:body 固定含块包裹层 ├─ 现在 → v0.13.x(已实施,分支 feat/injection-unified-host)
+ 桌面信号自动适配 + 几何写路径统一 │
路线 B(已关闭)┘
ui-sidebar 模式:占用官方槽位(grid 内渲染) ─ 2026-08-19 用户决策关闭:无法向官方提 issue,
纯插件侧自己优化注入方式(调研结论保留于 §6)
```
- **路线 A 已实施**:不依赖 DSH 新能力,纯插件侧,直接吸收桌面/移动/性能问题。
- **路线 B 已关闭**(2026-08-19 用户决策):不提交上游 issue;§6 的槽位可行性调研保留为后续参考,但不再推进。
## 5. 路线 A 详细设计:统一面板宿主
### 5.1 挂载层(兼容性根因修复)
**A1.1 固定含块包裹层**:保留 body 级锚点(官方 portal 姿势),但结构升级为:
```
document.body
└─ div[data-dsh-better-sidebar] ← 唯一锚点(属性不变,兼容既有契约/皮肤)
└─ div[data-dsh-panel-host] ← 新增:position:fixed; inset:0; pointer-events:none; z-index:25(v0.15.x 起;初版为 40,2026-08-22 下调:ui-cordis 动态插件面板固定层 30,须在其上)
├─ 右侧面板(absolute; right:0; pointer-events:auto)
└─ 底部面板(absolute; bottom:0; pointer-events:auto)
```
- 面板在**自己的固定含块**内 absolute 定位 → 宿主中间层(套壳注入的 wrapper)transform 不再劫持面板 fixed 语义。
- 折叠按钮簇 z-index 45 不变(skin 契约 §8 同步)。
- **挂载自检(一次)**:挂载后对比 `getBoundingClientRect()` 与 `window.innerWidth/Height`;若 html/body 本身被套壳 transform/偏移(罕见),自动降级为「absolute + 每帧 rAF 同步宿主 rect」模式并 console.warn 一次。
- **MutationObserver 守卫(默认开,可关)**:body childList 变化检测锚点被宿主清空/挪位时自动重挂(SPA 导航/套壳重排兜底);观察器廉价(仅 childList、无 subtree)。
**A1.2 桌面信号自动探测**(替代手动 titleBarCompat):
- 信号源:URL `dsh-desktop-mode` / `dsh-desktop-platform`(Electron 官方注入)+ `window.__DSH_DESKTOP_FILE_PATH__` 存在性(preload 标记)+ UA 兜底。
- 适配表:
| 探测结果 | 自动适配 |
|---|---|
| win32 + advanced | 右上角保留 32px 标题栏 overlay 区:折叠按钮簇/设置按钮下移,面板顶部内边距 +32px |
| darwin | 左上角保留 ~78px 红绿灯区:左侧贴边控件(若存在)避让 |
| compatibility / 无信号 | 原生 frame,不避让(现状行为) |
| 移动 webview | 窄屏 overlay 抽屉 + `visualViewport` 键盘监听 + `env(safe-area-inset-*)` 内边距 |
- 保留现有手动开关(titleBarCompat)作为覆盖位,但默认值改为「自动」。
**A1.3 移动端**:窄屏(断点沿用 `breakpoints.ts`)面板从「推挤宿主」切「overlay 抽屉」:不写 `#root` margin、面板 transform 滑入滑出;`visualViewport` 变化(键盘弹起)时底部面板上移。
### 5.2 几何层(性能)
**A2.1 单一几何写入器**:现状有两条写路径——拖拽的 `applyDrag`(Sidebar.tsx:487-505)与打开态 effect(:563-585),都写同一组变量但互不感知。统一为 `writeGeometry({width,height})` 单函数,两条路径共用(拖拽仍走 `scheduleDrag` 的 rAF 单帧批写);折叠/会话恢复等非拖拽变更也收敛到它。**不改变对外契约**:`--dsh-sidebar-width/height` 变量与 `#root` margin 推挤机制不变。
**A2.2 拖拽**(现状已达标,微调即可):
- pointer capture + 最新 delta + 单帧 rAF 应用(**已具备**,保留);
- 拖拽期 `body[data-dsh-sidebar-dragging]` 禁过渡(**已具备**,`layout.css:49`);
- 新增:拖拽期面板根挂 `will-change: transform`;面板根常驻 `contain: layout style`(CSS 一处,隔离内部布局回流)。
**A2.3 chunk 缓存精确化**:HTTP 层已 304 不重下载;缺口只在「激活后内存缓存清空 → 重注入+重执行」。改为:缓存键 = chunk 名 + 脚本内容哈希;激活时若哈希未变则保留内存缓存(跳过重注入),仅 HMR 核心 bundle 变化(版本变化)才清。保留失败路径(失败清缓存重试)。
**A2.4 保活策略(保持现状并文档化)**:面板 DOM 跨会话不重建;tab 内容按 `visible` prop 懒挂载/卸载;树/终端等重组件不随会话切换重挂。
### 5.3 契约层(可维护性)
- **锚点弱化**:`layout.css:31` 的 `#root > [data-slot=root] > div > div:nth-child(2)` 替换为 `#root [data-dsh-frame] > [data-pane="conversation"]`(稳定 data 属性锚定,实证见 §3.4;`[data-slot="conversation"]` 为 display:contents 不可用);推挤逻辑收敛进几何写入器(一处实现)。
- **推挤可关闭**:`SidebarPrefs` 新增(或复用既有)「宿主推挤」开关,默认开;桌面套壳/移动端自动切 overlay 时关闭推挤(修 #226 根因:不再依赖 `#root{width:100%}` 之外的宿主形状)。
- **契约文档化**:挂载点(`data-dsh-better-sidebar` / `data-dsh-panel-host`)、z-index 栈、CSS 变量(`--dsh-sidebar-width/height` 保持对外)、桌面信号(URL/全局标记)、推挤锚点策略 —— 同步 AGENTS.md §8 与本文档。
- **服务契约不变**:`ctx.betterSidebar` API 零变化;#135 的 service 读取问题(dsh-web-mobile 下 `ctx.betterSidebar` 直接读抛错)由「注入层与服务的解耦」顺带收敛(面板渲染不再假设服务存在即可读)。
### 5.4 性能清单(验收口径 + 实测基线)
基线(2026-08-19 真实页面探针,本机 3080,拖拽 12 步 ≈192ms):**40 帧、帧均 8.36ms / p95 10.2ms、1 帧超 16ms;62 次 `setProperty`(5 种几何属性:panel width / bottom height / bottom right / 两个 html 变量)**——即每帧 5 次写同一组元素,浏览器合并为单次样式重算。
| 指标 | 现状 | 目标(v0.13.1 实施后) |
|---|---|---|
| 拖拽期宿主 layout 次数 | 每帧 1 次批写(`applyDrag` rAF),帧均 8.36ms | 保持帧率 ≥ 基线;面板 `contain: layout style` 隔离内部重排;拖拽期 `will-change` 提升合成层 |
| 几何写路径 | 拖拽(`applyDrag`)与打开态(effect)两条路径写同一组变量 | 统一 `writeGeometry` 单写入器(已实施) |
| 懒加载 chunk 二次打开 | 不重下载(304)但重注入+重执行 | ETag 未变 → 内存缓存命中,0 脚本执行(已实施) |
| 会话切换 | 面板 DOM 不重建(已满足) | 保持 + 重组件懒挂载 |
| 面板显隐动画 | transform+width/margin 过渡 | transform 优先(overlay 模式),推挤模式过渡保留(`#root` width/margin 锁步过渡) |
| 观察器 | ResizeObserver 已 rAF 节流 | 保持;挂载自检/MutationObserver 守卫均为一次性廉价检查 |
## 6. 路线 B 详细设计:官方槽位迁移(ui-sidebar 模式)
### 6.1 可行性结论(已验证)
- **`sidebar` 槽:可接管**。条件:profile 层 `cordis.patch.yml` 禁用 `ui-sidebar`(`disabled: true`),`ui-layout` 必须保留;接管者需自实现 `sidebar.workspaces`/`sidebar.settings` 子槽内容或留空(`slots.inject` 静默跳过,不炸树);折叠按钮自渲染 + `ctx.layout.toggleSidebar()`;宽度/折叠持久化**自做**(官方不持久化)。
- **`details` 槽:不可行(当前)**。唯一占用者 ui-conversation 的 DetailsPanel 与其会话 UI 同包,禁用即空置会话列;需上游把 DetailsPanel 拆为可替换槽位或提供优先级机制。
- **几何传导**:槽位渲染在 AppFrame grid 列内,官方拖拽把手/折叠/让步结算免费生效;session scope 槽的 sessionId 由框架注入为标准 prop。
### 6.2 上游 issue 建议(本仓库只出建议,不改源码)
1. 将 `details` 槽的占用者改为**可替换**(如 single 槽支持「替换注册」或 DetailsPanel 拆独立包),使外部插件能接管右侧面板。
2. 新增(或声明)`bottom-panel` 槽,供底部面板类插件使用。
3. ui-sidebar 宽度/折叠持久化下沉到 layout store(官方侧目前不持久化)。
**issue 草案(PR3 提交时使用,已备好)**:
> **Title**: External plugins cannot take over the `details` panel slot
> **Repo**: deepseek-ai/dsh(或官方插件仓库)
> **Body**:
> - Context: `ui-layout` declares `root` → `{sidebar, conversation, details}` as single-exclusive child slots. `ui-conversation`'s DetailsPanel is the sole occupant of `details`; an external panel plugin (e.g. dsh-better-sidebar) cannot register into it without disabling `ui-conversation` (which empties the conversation column). Meanwhile `ui-sidebar` (the official left sidebar) is itself a plugin and CAN be replaced from the profile layer (`cordis.patch.yml` `disabled: true`), which proves the pattern.
> - Ask: make the `details` occupant replaceable — either (a) allow single-slot "replacement registration" with explicit opt-out by the default occupant, (b) extract DetailsPanel into a standalone optional plugin so profiles can disable it without losing the conversation column, or (c) declare a new `bottom-panel` slot for bottom panel plugins.
> - Reference implementation to mirror: `ui-sidebar/src/client/index.ts:39` (register `name:'sidebar'`), `ui-layout/src/client/AppFrame.tsx:164-196` (grid geometry + drag handles come free), `runtime/src/client/slots.ts:130-192` (`slots.inject` consumers skip silently when a slot is undeclared).
### 6.3 迁移路径(上游落地后)
1. better-sidebar 增加「槽位渲染后端」:注册 `details`/`bottom-panel` 槽,`Sidebar` 树渲染进 grid(同一套 store/组件,仅挂载点不同)。
2. body 浮层收敛为纯浮层:菜单、弹窗、右键、拖出浮窗(与官方 ui-primitives portal 同姿势)。
3. 保留路线 A 的固定含块层作为浮层宿主;推挤/几何写入器退役(grid 接管)。
### 6.4 收益与代价
| 收益 | 代价 |
|---|---|
| 零 body 注入、零宿主 DOM 依赖 | 契约绑定上游 SlotMap,DSH 升级需跟随 |
| 官方几何/拖拽/折叠/让步结算免费 | 官方不持久化宽度,需自做持久化 |
| 任何套壳天然一致(grid 与套壳无关) | 上游排期不可控(issue 周期) |
| 拖拽性能与官方同源 | 迁移期间双后端并存(维护成本) |
## 7. 兼容矩阵(注入策略 × 场景)
| 场景 | body 固定含块(路线 A) | 官方槽位(路线 B) | shadow DOM | iframe |
|---|---|---|---|---|
| 纯浏览器 | ✅ 推荐 | ✅ | ✅(令牌穿透) | ⚠️ 令牌/交互断裂 |
| Electron 套壳(DSH Desktop) | ✅ + 信号自动避让 | ✅ | ✅ | ⚠️ |
| Tauri 套壳(原生标题栏) | ✅ | ✅ | ✅ | ⚠️ |
| 移动 webview | ✅ + overlay 抽屉/visualViewport | 需上游槽位 | ✅ | ⚠️ |
| 页面内嵌 iframe | ⚠️ 需宿主放行 | ❌ | ✅ | ✅(天然隔离) |
| 祖先 transform 劫持 | ✅ 自检降级 | ✅ 天然免疫 | ✅ | ✅ |
| 性能(拖拽/显隐) | rAF 批写 + contain | 官方 grid | 中 | 低 |
## 8. 实施拆分与验证
### 8.1 拆分(文件级改动清单)
**实施方式(2026-08-19 定稿)**:单分支 `feat/injection-unified-host`、按步骤顺序提交(每步 CI 绿);原 PR1/PR2 拆分为内部步骤,合并为**一个 PR** 交付(改动彼此耦合,且路线 B 关闭后无外部依赖)。各步骤:
1. **挂载层**(`src/client/index.tsx`、`src/client/Sidebar.tsx`、`src/client/sidebar.module.css`):`Sidebar` 渲染根包 `data-dsh-panel-host`(`position:fixed; inset:0; pointer-events:none; z-index:25`,初版 40,2026-08-22 起 25);`.panel/.toggleCluster/.bottomPanel` 由 fixed 改 absolute(层 inset:0 即视口坐标)+ `pointer-events:auto`;挂载后一次性几何自检(偏差 >8px → `data-dsh-panel-host-degraded` + rAF 视口同步 + warn 一次);`MutationObserver`(body childList)守卫锚点被清空时重挂。✅ 已实施(commit 9a4c160)
2. **几何层**(`Sidebar.tsx`、`sidebar.module.css`):`writeGeometry` 统一拖拽/打开态两条推挤写路径;面板 `contain: layout style`;拖拽期(`body[data-dsh-sidebar-dragging]`)`will-change: transform`。✅ 已实施(commit 553e650)
3. **chunk 缓存**(`chunk-loader.ts`、`index.tsx`):`revalidateChunksOnReactivate()`——激活时 HEAD 每个已加载 chunk 对比 ETag,未变保留已解析 exports(跳过重注入/重执行),变更/不可达 fail-open 重取;测试夹具与孤儿缓存不跨激活存活。✅ 已实施(commit 0264625)
4. **锚点与防溢出**(`layout.css`):底部推挤锚点 `nth-child(2)` → `#root [data-dsh-frame] > [data-pane="conversation"]`(§3.4 实证);`#root` 加 `width: calc(100% - var(...))` 与 margin 锁步过渡(实测 1140+300=1440 无溢出;#226 桌面溢出根因吸收)。✅ 已实施(commit 9c9515d)
5. **桌面/移动适配**(新增 `desktop-env.ts`、`Sidebar.tsx`、CSS):解析 `dsh-desktop-mode/platform` + `__DSH_DESKTOP_FILE_PATH__`;win32 advanced 自动 `titleBarCompat`(32px overlay,手动 pref 覆盖);`visualViewport` 键盘 inset(底部面板/窄屏抽屉上移);`env(safe-area-inset-*)`。✅ 已实施(commit a1906c5,单测 7 例)
6. **测试与文档**:`tests/desktop-env.spec.ts`(7 例)、chunk-loader 重验证 4 例、`mount.e2e.ts` 增 `data-dsh-panel-host` 断言 + 桌面参数场景、AGENTS.md §8 契约同步、本文档更新。🔄 进行中
7. **交付**:CI 全绿(含 plugin-mount)→ `gh pr create`。⏳ 待执行
### 8.2 验证
- **单元**:几何写入器 rAF 批处理(fake timers 断言单帧单写);桌面信号解析(URL param × platform × 全局标记);挂载自检降级路径;chunk 缓存命中/失效。
- **e2e**:`tests/e2e/mount.e2e.ts` 深扫保持不变并新增断言:`data-dsh-panel-host` 存在、锚点属性不变;新增 desktop-mode URL 参数场景(模拟 win32 advanced → 顶栏避让类生效)。
- **skin 契约**:`tests/theme.spec.ts` 令牌读取不变(§8 契约同步守护)。
- **CI**:全绿门禁(build/typecheck/test/consumer-types/plugin-mount);#180 的 plugin-mount flake 与本次改动无关,重跑确认。
- **度量**:拖拽期 PerformanceObserver 记录 layout 次数;懒加载二次打开网络计数为 0。
## 9. 风险与回滚
| 风险 | 缓解 |
|---|---|
| 固定含块改变既有 fixed 行为(罕见宿主) | 挂载自检 + 降级模式;锚点属性/CSS 变量不变,可一键回退旧实现 |
| 几何写入器集中化引入回归 | PR1 内保留旧写入路径开关(feature-flag),e2e 深扫覆盖 |
| 桌面自动探测误判(套壳改 URL 格式) | 信号解析白名单 + 手动覆盖位保留 |
| 路线 B 上游不落地 | 路线 A 完整自洽,不阻塞 |
| 禁用 ui-sidebar 属 profile 用户决策 | 插件仅文档化,不自动执行 |
## 10. 参考来源
- DSH 源码:`~/.dsh/source/current`(只读):`ui-layout/src/client/{index.ts,AppFrame.tsx,columns.ts,stores.ts,AppFrame.module.css}`、`ui-slots/src/index.ts`、`ui-sidebar/src/client/index.ts`、`ui-conversation/src/client/apply.ts`、`ui-primitives/Menu.tsx`、`runtime/src/client/slots.ts`
- DSH Desktop 运行时(本机 .app,只读):`lib/src-B5373ech.js`、`lib/electron-runtime-C_i38SKA.js`、`lib/preload.cjs`
- 社区套壳:kyorakuyk/dsh-desktop(https://github.com/kyorakuyk/dsh-desktop)、Lotus-c/DSH-Desktop(https://github.com/Lotus-c/DSH-Desktop)
- 本仓库:`src/client/index.tsx`、`src/client/Sidebar.tsx`、`src/client/layout.css`、`docs/plans/2026-08-15-skin-compat-design.md`
- 相关 PR:#226、#135、#82、#108、#111、#138、#153(症状级补丁,本方案吸收其目标);#208/#90(issue)
## 11. 决策记录与实施偏差记录
- 2026-08-19(第一轮):方案定稿——路线 A(统一面板宿主)为主线、路线 B(官方槽位)并行提 issue;先出设计文档,实现拆 PR1/PR2 待批准。
- 2026-08-19(第二轮,实施就绪打磨):对照源码核实现状——拖拽 rAF 批处理/窄屏不推挤/观察器节流/header 弱锚点**已具备**(§3.1 已标注「实施勿重做」);chunk 缺口精确为「激活后内存缓存清空重注入」(HTTP 已 304);§8.1 落到文件级改动清单;§6.2 备好上游 issue 草案。
- 2026-08-19(第三轮,实证核对):真实 dsh web 页面 Playwright 探针(§3.4)——frame 子项已含 overlay/handle(nth-child 脆弱性实锤);`data-pane="conversation"` 为新弱锚点(`[data-slot=conversation]` display:contents 作废);推挤在 web 无溢出(calc 改动无害);host 本体 static(fixed 在内层面板,含块层为新元素)。
- 2026-08-19(第四轮,用户决策):**路线 B 关闭**——无法向官方提 issue,纯插件侧自己优化注入方式;实现合并为单 PR(分支 `feat/injection-unified-host`),按 §8.1 步骤顺序落地。
- **实施偏差记录**:
- 挂载自检降级实现为「`data-dsh-panel-host-degraded` 属性 + rAF 视口同步」(非独立 absolute 模式,见 §5.1 注释)。
- chunk 缓存实现为 **ETag HEAD 重验证**而非内容哈希(bundle 路由已发 ETag,HEAD 更廉价);`resetChunks()` 保留(测试隔离),激活路径改用 `revalidateChunksOnReactivate()`。
- `#root` 防溢出采用 `width: calc(100% - var)` + width/margin **锁步过渡**(避免动画断裂),并实测 1140+300=1440。
- 桌面避让复用既有 `titleBarCompat/titleBarStripPx` 通道(未新增 `--dsh-desktop-top-inset` 变量);darwin 红绿灯区仅预留(当前无左侧贴边控件)。
- 测试环境为 Node + 手写 shim(非 jsdom):`browser-globals.ts` 补 `location`;desktop-env 剥除 `location.search` 前导 `?`(真实 bug,顺带修复)。
- 本地 Node 24 跑 `test:mount` 需 `node --expose-internals` 启动 dsh CLI(HMR 服务要求,CI Node 22 无此问题)——本地用 DSH_CMD 包装,不改仓库脚本。
- **CR #232 修复(2026-08-19)**:① 降级同步改为按**未修正几何**判定(跟踪自身补偿量,祖先 transform 消失才退出循环);② chunk 重验证成为 `loadChunk` 屏障(重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports);③ 键盘 inset 公式补 `visualViewport.offsetTop` 并监听 `scroll`。
- **z-index 40→25(2026-08-22)**:设计初稿的"30–99 无任何 DSH 元素"(源自 #52 修复记录)低估了官方 ui-cordis 的动态插件面板(fixed z-index 30,挂 `sidebar.footer.action` 槽)——better-sidebar 底部面板曾盖住其清单/审批面。下调宿主层至 25(AppFrame overlayLayer 20 之上、ui-cordis 30 与 DSH 浮层栈 100+ 之下),折叠按钮簇 45 保留为 host 内部层叠;`AGENTS.md` §8 与 `2026-08-15-skin-compat-design.md` 已同步。
- **桌面布局判定修正(2026-08-26)**:`#root` 的 width/margin 推挤会缩小 AppFrame 的 border box,触发 Harness `<1024px` 窄屏判定,使桌面左侧栏自动折叠、Session Log 退化为 fixed icon,并挤乱后台任务操作区。右面板宽度改为 AppFrame 内部 `padding-right`(新版 `[data-dsh-frame]` 与 rc.8 root-slot direct child 双锚点),Frame 与 `#root` 保持视口宽度;details 拖拽手柄按同一变量平移。`desktop-layout.e2e.ts` 守护 frame 宽度、桌面 Session Log 文本按钮与面板不重叠,`drag-layout.e2e.ts` 继续守护拖拽 1:1。
@@ -1,79 +0,0 @@
# 桌面端兼容统一方案:四选项下拉(自动检测 / DSH官方Web / 壳预设 / 自定义 CSS)
> 2026-08-20 · 对应分支 `feat/desktop-scheme-settings` · 关联 issue #257、#208、#103/#111;外部 PR #82/#89/#111/#153/#226/#243 处置见文末。
## 背景与问题
better-sidebar 的桌面端适配历史上是"每壳一条补丁":右上角开关簇与原生标题栏按钮重叠(#87/#101)、点击被拖拽区吞掉(#103/#111)、推挤锚点随 DSH DOM 版本漂移(#208/#226/#262)、WCO 高度硬编码 32px 与实际不符(#257,Electron 版本间 32/36px 不一)。这些补丁全部针对**具体某个壳的 DOM/类名/数值**,换一个壳(Tauri、Pake、新壳)就失效或出错。
**全仓证据盘点**(121 issues + 159 PRs 全量 + 三个候选壳克隆取证):
| 壳 | ⭐ | issue/PR 提及 | 形态 | 需要什么 |
|---|---|---|---|---|
| anywhere-labs/deepseek-harness-desktop | 16.3K | ✅ 大量 | Electron;advanced: win32 WCO 32px / darwin caption 20px;compat 原生框 | win32 已被标准 WCO 覆盖;darwin 20px 让位 |
| dataelement/dsh-desktop | 1.3K | ❌ 零提及 | Electron;win32 WCO 36px | 已被标准 WCO 覆盖 |
| Deepseek-Harness-EAC | 1.0K | ❌ 零提及 | Electron Win-only;自绘 36px 玻璃栏(私有标记 `data-dsh-title-bar-height`) | 无 WCO,需 36px 让位(文档示例) |
| hairyf Tauri 版 | 715 | ✅ pr#229 | Tauri2;44px 导航栏在 iframe **之上** | **无冲突**,无需适配 |
平台分布结论:兼容问题**不是 Windows 独有**(macOS 6 条、Web 3 条),但当前 main 上未闭环的 chrome 兼容基本就是 **Windows 顶栏**(WCO 几何 + 通用 no-drag 兜底);macOS 的坏 bug(#25/#208)已修复。
## 决策
1. **核心只留 Web 标准机制**:WCO(`navigator.windowControlsOverlay` + `geometrychange`)是唯一"不是适配、是标准"的信号;`-webkit-app-region: no-drag` 是标准属性(普通浏览器/无拖拽区壳惰性无害)。核心代码 grep 不到任何壳类名。
2. **壳专属适配进用户空间**:「位置兼容模式」升级为**主行下拉**四选项:**自动检测(默认,保守,web 零修改)/ DSH官方Web(显式零适配)/ 壳兼容方案(内置预设,opt-in)/ 自定义方案(自由 CSS + 下移 px,保留齿轮弹窗)**。
3. **预设准入规则**:issue/PR 中被提及(链接或明确名称)且 GitHub ⭐>100 的壳;v1 仅 anywhere-labs(唯一同时满足者)。预设只做**标题栏 strip 让位**,不做布局推挤/壳类名(尊重"不特意兼容 DSH Desktop 高级模式")。Tauri 无冲突只写文档;EAC 未提及但提供 custom CSS 示例。
4. 不向 anywhere-labs 提跨仓库 PR;不修改 DSH 源码。
## 实施
### prefs 模型(`src/prefs-shared.ts` / `src/config.ts` / `src/client/prefs.ts`)
- 新增 `titleBarScheme: 'auto' | 'web' | 'preset' | 'custom'`(默认 `'auto'`)、`titleBarPresetId: string`、`customCss: string`。`web` = 显式「DSH官方Web」,取值链最优先强制 0(连 WCO 也不适用)。
- schema 中三个新字段**无 default**(schemastery 验证:缺失字段不出现在解析结果里)→ `parsePrefs` 在字段缺失时由旧 `titleBarCompat` 派生:`true` → `custom`(保留 `titleBarStripPx`);`false`/缺失 → `auto`。写路径镜像写旧字段(`titleBarCompat = scheme !== 'auto'`)供降级。
### 标准件(`src/client/wco.ts` + `src/client/titlebar-strip.ts`)
- `wco.ts`:模块级反应式 store(subscribe/getSnapshot,`useSyncExternalStore` 消费),特性检测 `navigator.windowControlsOverlay`,订阅 `geometrychange` 重读矩形(最大化/还原实时更新);最后订阅者退订时解绑原生监听;`setWcoSourceForTests` 注入测试源。API 抛错视为 present+0(绝不崩布局)。
- `titlebar-strip.ts`(唯一决策点,纯函数):**⓪ `web` 方案强制 0 → ① WCO 真实高度(权威,0 也权威;`visible=false` 幽灵 API 视为缺失)→ ② URL `dsh-desktop-titlebar-inset`(0–120 clamp)→ ③ 预设 `stripFor`(仅 preset 方案)→ ④ 手动 `titleBarStripPx`(仅 custom 方案)→ ⑤ 0**。结果驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`(CSS 契约不变)。
- `desktop-env.ts`:只做环境报告(desktop/mode/platform/titlebarInset),SSR 安全;移除旧 `win32OverlayTop` 硬编码。
### 预设与用户 CSS(`src/client/shell-presets.ts` + `Sidebar.tsx` effect)
- `ShellPreset { id; title; desc; stripFor?; css?; detect? }`;v1 一条 `dsh-desktop`:darwin advanced 20px、win32 advanced 32px(无 WCO 兜底)、其余 undefined;`detect` 仅用于设置页「已检测」徽标。
- 注入:预设 css → `<style data-dsh-preset-css="<id>">`;自定义 css → `<style data-dsh-custom-css="custom">`;追加到 `document.head` 末尾(后写胜出),fiber 卸载/变更即移除。
- 稳定寻址面:`[data-dsh-toggle-cluster]`、`[data-dsh-panel]`、`[data-dsh-bottom-panel]` data 属性。
### no-drag 兜底(`sidebar.module.css`)
`.toggleCluster` / `.toggleButton` / `.tabBar` 统一 `-webkit-app-region: no-drag`(吸收 #111/#153,补 #103 插件侧缺口)。
### Web 推挤锚点加固(`layout.css`)
复合选择器(同元素双保险):`#root [data-dsh-frame] > [data-pane="conversation"]` 与 `#root :has(> [data-slot="conversation"])`;拖拽过渡/prefers-reduced-motion 同步;`drag-layout.e2e.ts` 新增两选择器同元素断言。
### 设置 UI(`SideCardSection.tsx` + locales)
「位置兼容模式」行改为**主行下拉**:自动检测(默认)/ DSH官方Web / 各壳预设(命中环境带「已检测」后缀,仅提示)/ 自定义方案(选中后行内出现齿轮,弹窗 = 下移距离 + CSS textarea(Cmd/Ctrl+Enter 提交))。
### 测试
- 单测:`wco.spec.ts`(7 例:检测/读取/取整/geometrychange 通知/抛错降级/换源)、`titlebar-strip.spec.ts`(取值链 5 例)、`shell-presets.spec.ts`(注册表完整性 + dsh-desktop strip 值)、`desktop-env.spec.ts`(重写:inset 参数 clamp + 移除 win32 硬编码)、`prefs.spec.ts`(新字段 + 旧字段迁移)、`plugin-shape.spec.ts`(schema 无默认值语义)、`smoke.spec.ts`(解析结果不含新字段)。
- e2e(`mount.e2e.ts` 重写桌面场景):保守 auto(stamp 不触发修改 + data 属性 + no-drag 规则存在)、WCO mock 驱动 strip 且 geometrychange 反应、预设注入(settings.update 写入 scheme=preset → 32px + style 标签)、自定义 CSS 注入(marker 生效);`drag-layout.e2e.ts` 复合锚点。
## 验证
- `pnpm typecheck` / `pnpm test`(691 passed)/ `pnpm test:mount` 全绿。
- 普通浏览器零行为变化(auto 无 WCO → 无 strip)。
- Electron WCO win32(mock 实测):strip=真实高度(32/36),最大化/还原跟随。
- anywhere-labs darwin advanced:启用预设后 20px 让位。
- 核心代码 grep 无壳类名(`.dshDesktopFrame` 等仅存在于预设数据字符串与测试断言之外——v1 预设无 CSS,纯 strip 数据)。
## 实施偏差记录
- 无。计划与实施一致;仅补充:schema 新字段必须无 default 才能让旧文档迁移(否则 settings 服务会把 default 填进解析结果、吞掉迁移),`plugin-shape.spec.ts` 断言了这一点。
## 存量 PR/issue 处置(合并后执行,均含哲学声明)
- 实现后关闭:#257(WCO 标准件)。
- 关闭:#111/#153(no-drag 吸收)、#226(复合锚点吸收,main 已修)、#243(布局推挤不采纳;标题栏部分进 dsh-desktop 预设)、#108(#232 已吸收 + WCO)、#82/#89/#138(不采纳,指向自定义 CSS/预设路径)。
- 回复:#208(已修+回归)、#221(自定义 CSS 可微调)、#233(git.ts windowsHide 独立问题)、#167/#240(非 chrome 范畴/文档已有)。
@@ -1,100 +0,0 @@
# 侧边对话(Side Chat)Tab 设计
**日期**:2026-08-20
**状态**:已批准,实施中
**作者**:opencode + 用户
**当前版本**:v0.14.1(分支 `feat/sidechat-tab`)
**目标版本**:v0.14.1(不 bump)
## 1. 目标
1. 对标 Codex 的 side 对话,为 better-sidebar 新增内置 Tab **sidechat**:在当前主会话下开一个**继承主会话完整上下文**的独立子会话线程,线程在 Tab 内可续聊、可查看完整时间线(user / assistant / reasoning / tool)。
2. **继承必须是结构化的**:线程种子 = 父会话点击时刻的全部事件——已完成回合 + 未回答的 user 消息 + **进行中回合的 assistant 流式输出与工具调用**(原样复制,合成 `step/end`+`turn/end{reason:'interrupted'}` 诚实闭合,子会话看到的是「被切断的进行中回合」而非完整定稿)。
3. **前缀缓存复用**:子会话与父会话同组合(同 preset / 同 provider/model、无 persona/toolFilter、非 continuable 无 report 增量)→ 子会话首个请求的 token 前缀与父会话当前请求一致(到未回答 user 消息为止),命中 provider 前缀缓存。
4. **即用即销语义 + 保存为新会话**:线程以 `origin:'subagent'` 创建(主会话列表不可见、子代理目录零噪音);用户可一键「保存为新会话」——`session.fork` 提升为顶层会话(自动命名、挂工作区、打开)。
5. 零 dsh-subagent 依赖;dsh-subagent 缺失时功能不受影响。
## 2. 非目标(Out of Scope)
- 不做 `/btw` 一次性侧问(只做持续可续聊线程)。
- 不做合并回主会话 / 重命名线程。
- **不改 DSH 源码**:不加 promote RPC、不改 fork 边界、不改 continuation 机制。
- 工具执行中途(悬挂 tool/call)的事件级继承不可行(provider 拒绝悬挂 assistant 调用)→ 该窗口回退为「截断 + 结构化文本转储」(见 §4)。
## 3. 为什么自建机制(重实现)
用户明确拍板:不走 `ctx.subagents.startContinuable`。原因(已核实源码):
1. **种子粒度受限**:fork provider 的 `completedTurnPrefix` 固定切在最后一个 `turn/end`(`subagent-fork-in-process/src/index.ts`),`session.fork` RPC 显式拒绝 open-turn 锚点(`core/session` `OPEN_TURN`)——进行中回合永远进不了种子。
2. **continuable 组合破坏前缀复用**:continuable 子会话的请求头部在继承历史之前插入 `report` 工具 + `tool:report` 提示段(`.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md`,issue #2124)——前缀从请求头即分叉,重发整个转录却零 cache 收益。DSH 官方因此把 fork 绑定为 one-shot。
3. **无法注入自定义种子**:`ContinuableStartSpec` 无 seed 字段,创建路径无注入点。
自建机制只复用 DSH 公开 host 能力:`ctx.agents.create/resume`(AgentRegistry 公开方法,api-proxy fork 与 subagent driver 同用)、`agent.followup/cancel`(公开方法)、`ctx.get('agentPresets')`、`ctx.get('sessionTitle')`、`ctx.get('sessionPersistence')`。客户端复用 `ctx.sessions.list / fork / binding / open` 与通用 `session.history` RPC。
## 4. 种子算法(`src/sidechat-core.ts`)
1. 复制父会话**全部事件**(点击时刻快照;live 日志 seq === 数组下标,保持连续)。
2. 日志结束于 turn 外(最后 turn 边界是 turn/end 或无 turn):种子 = 全部复制事件(可能以未回答 user 消息结尾,合法)。
3. 结束于 open turn 内:
- **当前 step 存在无配对 tool/call(悬挂)** → 回退:切到该 turn 的 `turn/start` 之前(保留已完成回合 + 未回答 user 消息);进行中内容(含执行中的工具调用,标注 executing)走 `buildOpenTurnSnapshot` 结构化文本转储并入 boundary 消息。原因:validator 不拒悬挂调用(step/end 清空 pendingCalls),但 **provider 拒绝悬挂 assistant 调用**(`core/session/src/repair.ts:108`)。
- 否则:追加合成 `step/end{turn,step}`(若 step 未闭合)+ `turn/end{turn, reason:{kind:'interrupted'}}`(编号取自已复制事件的 turn/start、step/start)→ 种子平衡合法(种子逐事件过同一不变式检查器,`core/session/src/index.ts:517-541`;`turn/end` schema 要求 reason,`interrupted` 是合法持久化变体,语义即「回合未完成被切断」——诚实标记,客户端对冻结局部渲染「已停止」)。
子会话首个请求(常见路径)= [已完成回合][未回答 user][冻结的进行中回合:chunk 文本 + 已完成工具调用/结果][boundary+question]。前缀复用不受影响:共享前缀到未回答 user 消息为止,冻结回合位于共享前缀之后。
## 5. 线程生命周期(host 路由,`/sidebar/api/sidechat.*`,既有信任 fence)
- **start** `{sessionId, question}`:父 Agent 必须 live;`buildSidechatInheritance` 出种子 + 转储;`ctx.agents.create({meta:{cwd,parentSession,seedLength,origin:'subagent',delegationDepth:父+1,agentPreset}, seed, agentOptions:父 provider/model, setup:preset 组合})`;`agent.followup(createUserMessage({content:[boundary+转储?+question], source:{kind:'user'}}))`;`sessionTitle.rename(session,'Side: '+截断(question,48))` 钉死线程标签;返回 `{childId}`。
- **prompt** `{childId, text}`:live → `followup`;absent(重启/关闭后)→ `ctx.agents.resume`(`resolvePresetId` 从 persisted header/events 解析 preset,`agentPresets.resolve/mount` 组合)→ `followup`。
- **cancel** `{childId}`:`agent.cancel({kind:'user'},{keepInbox:true})`。
- **dispose** `{childId}`:释放创建/恢复时保存的 AgentHandle disposer(会话与历史保留)。
为什么全走自有路由:`origin:'subagent'` 的会话被通用 RPC 的 agent-lookup 所有权 fence 全面封锁(`api/remotes/agent-lookup.ts` `hasApiRemoteSubagentOwner`),`subagents.prompt` 对我们的线程也无法寻址——自有路由直接调 registry/agent 绕开 fence,且只依赖公开方法。~~线程没有 subagent/descriptor(无目录注册、零噪音:`list-children.ts` 投影为 undefined 即丢弃)~~ **修正(v3 起)**:该判断只对 live 线程成立——cold 线程无 descriptor 时 `resolveColdIdentity` 确定性产出 `corrupt` 诊断行(`list-children.ts:405`),用户实测在 Subagent 页看到「目录损坏」。修复 = 线程种子尾部追加合法的 `subagent/descriptor`(`snapshotSubagentDescriptor`,version 2 / continuable / provider `'sidechat'` / label 即 `Side: ` 标题),线程成为目录里的健康行;SubagentView 与 `subagent-detect.ts` 按 `Side: ` 标签/标题前缀过滤,拓扑 UI 与自动打开触发保持零噪音(模型侧 `list_agents` 也会列出带标签的健康行,而非 corrupt 垃圾)。
## 6. 客户端 Tab(`src/client/SideChatView.tsx`)
- 内置 descriptor:`id:'sidechat'`、`order:35`、`single:true`、图标 `IconNewChatOutline16`。
- 两栏布局:左线程列表(`sideThreadRows`:`origin==='subagent' && parentId===当前 && displayTitle 前缀 'Side: '`;订阅 `ctx.sessions.list`);右详情(时间线 + 输入框 + 保存/停止/关闭按钮)。
- 时间线:轮询 `connection.api.sessions.history`(通用 RPC,未 fence;`session/end-seed` 截断 + boundary 行丢弃 + chunk 流式累加 + 工具配对——`src/client/sidechat-transcript.ts` 纯映射);可见且运行中 ~2s,seq 去重缓存;`visible=false` 停轮询。
- 保存为新会话:`ctx.sessions.fork({sessionId: threadId, increaseTitle:true})` → `binding(newId).session.rename(标题去前缀)` → `ctx.sessions.open(newId)`。无已完成回合 → 禁用 + 提示;末条追问未完成 → 提示不包含(fork 边界=最后 turn/end)。
- 选中线程持久化:`tab.meta.threadId`(既有 per-session localStorage 通道)。
## 7. 边界情况与失败模式
| 场景 | 行为 |
|---|---|
| 父会话无 live agent | 409 `sidechat-error` 内联提示 |
| 父会话进行中(流式/思考/工具已返回) | 完整事件入种子 + interrupted 合成闭合 |
| 父会话工具执行中途点击新建 | 悬挂 tool/call → 截断 + 结构化转储(工具标注 executing) |
| seam 缺失(agentPresets/sessionTitle/sessionPersistence) | 降级:默认组合 / 自动标题 / 无 preset 组合的冷恢复 |
| DSH 重启后续聊 / 线程关闭后追问 | `ctx.agents.resume`(persisted preset 组合) |
| 线程无已完成回合 | 保存禁用 + 提示 |
| 线程末条追问未完成 | 保存前提示不包含 |
| 与 dsh-sidechain 并存 | 各自独立;双方线程都有 descriptor(目录健康行),SubagentView 按 `Side: ` 前缀过滤均不可见;双方共享 `Side: ` 标签约定,线程互见 |
## 8. 测试与自验
- 单测:`sidechat-core.spec.ts`(种子全分支:turn 外 / 空 / 仅 pending user / open turn 无工具 / 成对工具 / 悬挂回退 / 多 step 闭合 / seq 连续性;转储保真;线程过滤;保存资格;preset 解析;boundaryDelivered)、`sidechat-seed-validation.spec.ts`(真实 `Session.create` 校验器接受种子产物)、`sidechat-transcript.spec.ts`(种子截断、boundary 丢弃、chunk 流式、工具配对、孤儿失败工具)、`sidechat-routes.spec.ts`(start 带问/空问立即创建、首条 prompt 包裹边界+快照并改名、prompt live+cold、cancel、dispose、info live/cold/未知)、`builtins.spec.ts` 7 tab + sidechat 每线程一 Tab 铸造/去重。
- e2e 挂载冒烟:+ 菜单深扫含 Side Chat;**host 路由级自验**(真实 keyless dsh web 中 sidechat.start/prompt/cancel/dispose/info 全链路 + 空问题立即创建流程,种子会话为父)。
- 真实环境手测(实施后勾选):新建线程 → 追问往返 → 流式渲染 → 工具行 → 主会话流式中新建(冻结继承)→ 工具执行中途新建(回退转储)→ 重启后续聊 → 保存为新会话 → 刷新持久。
## 9. 复用门记录
搜索面:DSH checkout `docs/` + `packages/`(关键词 side conversation / sidetrack / thread / promote)+ dsh-external hub catalog(`search-org.mjs`,323 repos)。命中:`dsh-external/dsh-sidechain`(Codex 风格侧会话,/side & /btw,临时 fork + 侧栏面板),但**无 better-sidebar Tab、无保存为新会话**;用户拍板独立实现(「我们做独立实现」),sidechain 保持不动。本文档与 PR 记录该结论。
## 10. 实施偏差记录
- 路由键命名:`buildSidechatApi` 返回对象的键必须是**完整 wire 方法名**(`'sidechat.start'` 等,/sidebar/api 分发器按 `api[method]` 查找),而非短名 `start/prompt/…`。首次实现用了短名,单测直接调对象方法未暴露,真实挂载冒烟以 404 抓出(该冒烟因此新增了路由级自验,见 §8)。
- 种子函数入参类型:sidechat-core 使用宽松结构类型 `SidechatLogEvent {type, seq, time, data: unknown}`(同时接受 host 真实 `SessionEvent` 与 client 的 `SidebarSessionEvent` 镜像),避免两半的类型图互相污染。
- keyless 挂载冒烟无法验证 boundary 消息落盘:`agent.followup` 的消息先入 inbox,被 turn claim 后才写入 session 日志——keyless(无模型路由)下 turn 不会 claim。挂载冒烟的深度断言因此改为 **session.list 成员校验**(证明子会话真实创建,provider 无关);boundary 落盘与流式/回答在真实 provider 环境手测(§8 清单)。
- **UI 两轮返工(用户实测驱动)**:
1. 种子事件信封字段丢失(用户实测报 `invalid seed event … requires a surfaceOp marker`):`copyEvents` 重建事件时只保留 `{type,seq,time,data}`,剥掉了 `surfaceOp`/`sourceEventSeqs`/`ignorable`,而种子校验器要求 surface-eligible 事件必带 `surfaceOp`。修复 = 信封字段原样透传 + 新增 `sidechat-seed-validation.spec.ts`(用真实 `@deepseek-ai/dsh-session` 的 `Session.create` 校验器跑种子产物,回归守护)。
2. 交互模型从「单 Tab + 内嵌线程列表 + 首问表单」改为 **Codex app 对齐的「每线程一 Tab」**:`createTab` 铸造 `sidechat:new-<uuid>`(meta `autoCreate`,视图挂载即调 `sidechat.start` 空问题立即建线程)或消费 `parkSidechatReopen` 停泊的 `sidechat:<threadId>` 重连 Tab;`dedupeKey = meta.threadId` 保证同线程聚焦不重复;`onClose` 释放 live agent(历史保留,头部菜单可重开)。配套后端变化:`sidechat.start` 的 `question` 变为可选(空 = 仅建线程,快照停泊在 `pendingSnapshots`);`sidechat.prompt` 检测边界未送达时(`boundaryDelivered` 扫日志)自动包裹边界 + 停泊快照并用首条消息改名;新增 `sidechat.info` 路由(live 状态 + provider/model/preset 身份,供头部 Agent 徽标)。UI 对齐主对话区:用户右对齐气泡(`--dsw-specific-bubble`)、assistant 通栏 markdown、胶囊 composer(`--dsw-specific-input-major` + 圆形 accent 发送/停止钮 + 自动增高 textarea)、运行扫光状态行与工具行(`StateDot` + shimmer),动效全部 ≤200ms 交叉淡化并受 `prefers-reduced-motion` 收敛。
- (预留)实施过程中如有与本文档的其他偏差,在此记录。
- **第三轮实测修复(4 bug)**:
1. 「保存为新会话」报 `Cannot read properties of undefined (reading 'list')`:`handleSave` 把 `ctx.sessions.fork` 解构成自由函数再调用,丢失 `this`——client-runtime 的 `service.fork` 内部读 `this.list.getSnapshot()`(标题递增)。修复 = 保持方法调用形态(`ctx.sessions.fork({...})`)。
2. 图标语义:线程切换菜单改自绘 `IconHistoryOutline16`(逆时针时钟)、保存按钮改自绘 `IconSaveOutline16`(软盘),沿用 1.5px 描边规范(primitives 无现成 history/save 图标)。
3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。
4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。
- **转录传输迁至自有路由(DSH 0.1.2-alpha.1 适配)**:§6 原设计的时间线轮询走通用 `connection.api.sessions.history` RPC——该客户端面(`ctx.connection.api`)在 0.1.2-alpha.1 的 Remote-gateway 迁移中被整体移除,且其继任者(`session/follow`/`session/page`)对 `origin:'subagent'` 会话强制 subagent 地址寻址(普通 `{kind:'session'}` 地址被 `agent-busy` 拒绝),纯分页也拿不到最新尾页(`throughSeq` 不得超当前游标)。修复 = 新增 **`sidechat.events`** 路由(`src/sidechat-routes.ts`):host 侧读线程日志(live 走 `agent.session.events`,冷线程走 `ctx.get('sessionPersistence').inspect`——两版 DSH 同形,alpha.1 的 inspect 返回展开后的逻辑日志而非磁盘上的打包 chunk-row),服务端切 `session/end-seed`(`threadOwnLogEvents`,`collectOwnEvents` 的 walk 语义下沉),`afterSeq` 增量 + 8000 事件尾部帽(对齐旧 walk 上限 40×200)。客户端 `fetchThread` 单路径化,删除 `collectOwnEvents` 与 `ctx.connection` 镜像/inject。种子切割仍由 `transcriptRows` 客户端二次防御。挂载冒烟补 live+冷读两条断言。
- **种子缺 fork 标记 → 子会话继承父会话未领取的 inbox 消息(2026-09-05,用户实测「上下文很长时侧边对话会把之前的 User msg 先发送出去」)**:`sidechat.start` 原只传 `seed` 不传 `meta.isSeeded: true` + `inheritedEventCount`。dsh-session 契约:**只给 seed 不标 isSeeded,种子算重放历史而非继承前缀** → `Session.ownEvents()` 含整个种子 → 子会话 `Inbox` 构造器(重放 `ownEvents()` 中的 `agent/inbox/spliced`)继承父会话切割时刻**任何未领取的 inbox 输入**。两个触发窗口(均以真实 0.1.2-rc.1 包 `Session.create` + `Inbox` 实验证实):① next-turn——父会话排队中的用户消息(长回合运行中补发);② **next-step(用户实测场景:父会话运行中、用户队列已清空)**——长回合里工具结果上下文/steering/运行时快照在步骤边界之间插入 next-step、尚未领取;上下文越长回合越久窗口越宽。后果:首条 `sidechat.prompt` 的 claim 顺序为 `[幽灵消息, boundary, question]`——幽灵消息排在 boundary 之前最先发给模型(工具上下文常为「工具完成,接下来做 X」,模型先继续父会话任务),并在子会话日志落成 `user/message`。修复 = create 补 `meta.isSeeded: true` + `inheritedEventCount: SessionLogOffset(seed.length)`(宿主自己的 `session.fork`——api-session-controller——的规范调用形态;`createAgent` 工厂逐字段透传 `sessions.prepare`)。`threadOwnLogEvents` 按 end-seed 标记切割,标记位置不变,转录行为不变;冷恢复按持久化 header 切割,一致。回归测试进 `tests/sidechat-seed-validation.spec.ts`(真实 Session + Inbox 双向锁定:旧形态继承幽灵 / 新形态两队列皆空)。用户拍板:不迁移既有线程、不加 inbox.clear() 兜底(修复后新线程为无操作;旧「从未发过消息」空线程首次使用可能仍先答一次旧消息,之后自愈)。
- **「更优雅 API」复评(2026-09-05,0.1.2-rc.1 = npm latest)**:无 sidechat/sidechain 宿主 API;`ContinuableStartSpec`(`ctx.subagents.startContinuable`)仍无 seed 字段;`session/create` RPC 只建全新会话。§3 的自建结论继续成立,`AgentRegistry.create` 接缝即宿主 fork 自用的公开接缝——补齐 fork 标记即为规范用法。
@@ -1,116 +0,0 @@
# 文件树「打开方式」子菜单设计(2026-08-22)
> 状态:已实现(分支 `feat/open-with-menu`)。本文档记录功能设计、关键取舍与实施偏差。
## 目标
文件树(`FileTree`)右键菜单新增「在应用中打开 >」子菜单:
- 内置 **资源管理器(在文件管理器中显示)、VS Code、Cursor、Zed** 四个打开方式;每个子菜单行右侧有**图钉**,点击可把该方式固定为右键菜单的**顶层直达项**(再点取消固定)。
- **远端 SSH**:设置中配置可选 SSH host(`user@host` 或 `~/.ssh/config` 别名)。非空即整个工作区视为远端:VSCode 系条目(VS Code / Cursor / 自定义且勾选"VSCode 系")改用 `<scheme>://vscode-remote/ssh-remote+<host>/<path>` 打开;本地专用条目(资源管理器 / Zed / 非 VSCode 系自定义)从菜单隐藏。
- **自定义编辑器**:名称 + URL 模板(`{path}` 占位符,如 `cursor://file/{path}`)+ 「是否 VSCode 系」开关;配置入口在**侧边栏设置 Files 卡片齿轮**(与既有 `editorExplorer` 下拉同一弹窗)。
- 打开动作经宿主新路由 `POST /sidebar/api/open.external` 执行(argv 数组 spawn,无 shell 注入),浏览器模式与 DSH Desktop 行为一致;**不经过** `ctx.workspaces.openPath`(该入口已被 `wrapOpenPath` 劫持到侧边栏编辑器,见 `src/client/openpath-intercept.ts`)。
## 调研结论(reuse gate)
- DSH checkout(`~/.dsh/source/current`)无"文件树 open-with"、无 SSH 远程会话、无 `vscode://` 开启器;`host.openPath` 只能"默认应用打开",无法指定编辑器、无 reveal/select 语义 → 需要新增。
- dsh-external hub 无同类成品;`dsh-open-in-vscode` 是工作区行菜单、依赖外部 slot,不适用。
- primitives `Menu` 原生支持 `submenu?: readonly MenuItem[]`(子项点击走 `onSelect(child.id)`),`label: ReactNode` 可内嵌图钉;无现成图钉/品牌图标 → 引入 `react-icons`(`vsc` codicons 图钉 `VscPin`/`VscPinned` 与文件树 `VscFile`/`VscFolder(Opened)`、`si` 品牌剪影 Cursor/Zed、菜单父行 `VscLinkExternal`),VS Code 品牌剪影因 simple-icons 后期版本受微软商标政策下架而内嵌自 simple-icons@11.0.0(CC0,`IconVscode16`);菜单行图标统一 16px 与 DSH 原生菜单一致,子菜单父行的 `>` chevron 由 label 内右对齐元素提供(基元不渲染)。⚠️ react-icons 的 exports 把 `require` 排在 `import` 前,tsdown 共享 conditionNames 会选中不可树摇的 CJS 入口(bundle +6.4MB)——`tsdown.config.ts` 用 `resolve.alias` 钉死 `si`/`vsc` 的 ESM 入口,树摇后仅 +12KB。
## 数据模型
持久化在 `pluginSettings['editor'].openWith`(宿主 schema 的开放 map,无需改 PrefsSchema):
```json
{
"openWith": {
"sshHost": "",
"customEditors": [
{ "id": "custom:<uuid>", "name": "Windsurf", "urlTemplate": "windsurf://file/{path}", "isVscodeFamily": false }
],
"pinned": ["vscode", "custom:<uuid>"]
}
}
```
- `parseOpenWithConfig`(`src/client/open-with.ts`)容错解析:坏行丢弃、未知 pinned 在解析目标时剪枝;**结构合法但未填完的行(空 name/模板)保留**——设置面板正在编辑的行必须能在弹窗往返中存活,由菜单侧的 `isValidCustomEditor` 过滤。
- 目标 id:内置 `explorer` / `vscode` / `cursor` / `zed`;自定义 `custom:<uuid>`。
- URL 构建:
- 本地:模板原文替换 `{path}`(`vscode://file//home/u/f.ts`——POSIX 绝对路径自带前导 `/`,与 VS Code 官方文档 `vscode://file/c:/…` 的约定一致;反斜杠归一化为 `/`)。
- SSH(VSCode 系):`<scheme>://vscode-remote/ssh-remote+<host>/<path>`,路径保留前导 `/`(如 `…+dev/home/u/f.ts`,与社区约定一致)。
- 模板无 `scheme://` 或无 `{path}` → 返回 undefined(不打开)。
## 菜单结构(文件行与目录行一致)
```
在新 Tab 中打开 ← 既有
在侧边打开 ← 既有
----------------------------
VS Code (SSH) ← pinned 直达项(按内置顺序 + 自定义追加)
Cursor (SSH)
----------------------------
在应用中打开 > ← 子菜单父项(始终在 pinned 之后)
资源管理器 📌
VS Code 📌(激活)
Cursor 📌(激活)
Zed 📌
Windsurf 📌
下载 ← 既有(仅文件行)
复制相对地址 / 复制绝对地址 ← 既有
```
- 图钉是 `label` 内的 `<span role="button" tabIndex={-1}>`(Menu 行本身是 `<button>`,嵌套原生 button 不合法);`stopPropagation` + `preventDefault` 使其**不关闭菜单、不触发行选择**,点击后经响应式 blob 重渲染立即翻转。
- 未接线(props 缺省)或 SSH 过滤后无目标 → 整个区块隐藏,旧行为/旧测试不变。
## 设置面板:`settings.render` seam 扩展
**实施偏差(与最初"render 独占"契约不同,需同步文档)**:`SettingsBody`(`src/client/SideCardSection.tsx`)原先"有 `render` 就整个替换行列表"。本功能需要**行列表(`editorExplorer` 下拉)与自定义面板共存**,因此把 seam 改为:
> `render` 给定时**追加**渲染在行列表(`toggles` / `pluginToggles`)之后;未声明行列表时与旧行为一致(单独渲染)。
- 仓库内无既有 render 使用者,`tests/side-card-section.spec.tsx` 无"render 独占"断言,风险可控;`AGENTS.md` §3.1/§5 与本文档同步更新。
- 面板组件 `OpenWithSettings`:SSH host 输入 + 自定义编辑器行(名称/模板/VSCode 系/删除)+「添加」;每次变更整体提交 `updatePluginSetting('openWith', next)`(含 pinned 原样保留)。
- 图钉写入走 `src/client/plugin-settings.ts` 的 `updatePluginSettings`:Promise 链串行化 + `api.settingsUpdate` 后 `store.setPrefs(parsePrefs(view.value))`,避免快速连点丢写。(设置弹窗内部有自己独立的串行 commit,二者极少重叠。)
## 宿主路由
`POST /sidebar/api/open.external`(`src/index.ts` `buildApi`,与其他方法同 fence + 错误包裹):
```ts
{ action: 'reveal', path } // 在文件管理器中显示/选中
{ action: 'url', url } // 交给注册的协议处理器
```
- `src/open-external.ts`:纯函数 `revealCommand` / `urlCommand`(platform 可注入,单测覆盖三平台)+ `launchExternal`(`spawn(..., { detached: true, stdio: 'ignore' })` + `unref`)。
- `reveal`:darwin `open -R`;win32 `explorer.exe /select,`;linux `xdg-open <父目录>`(无统一 select 协议,KISS 打开所在目录)。
- `url`:darwin `open`;win32 `rundll32 url.dll,FileProtocolHandler`(备选 `cmd /c start ""`);linux `xdg-open`。
- 校验:`reveal` 路径必须绝对(`requireAbsolute`);`url` 必须是 `scheme://` 自定义协议(拒绝 http/https)。
- **Windows 平台命令为约定实现,需在 Windows 实机验证**(本机为 macOS;CI 覆盖 linux)。
**实施偏差(2026-09,#517 / PR #522)**:SSH 远程编辑器链接**不再经宿主路由执行**。DSH 部署在无头远端服务器时,宿主侧 `xdg-open` 无 DISPLAY/无编辑器,`vscode://` 静默失败。`api.openExternal`(`src/client/api.ts`)现把 `<scheme>://vscode-remote/ssh-remote+…` 形态的 URL 在浏览器客户端同步触发 `window.location.assign`(处于用户点击链内,外部协议交给本机编辑器经 Remote-SSH 打开远端文件);reveal 与本地编辑器 URL 仍走本节宿主路由。普通浏览器可处理自定义协议;禁止/未处理 `vscode://` 的 WebView 壳客户端需各自适配(见 #517 补充信息)。
**实施偏差(2026-09,右键菜单减重与子菜单视口钳制)**:本菜单(及 TabBar / GitLens / FreeWindow 右键菜单、「+」菜单)整体切到 primitives `Menu` 的 **`compact` 密度**(行高 26px / 12px 字 / 164px 卡片 / 7px 圆角),菜单图标随槽位 16→14、子菜单图钉 20→16。同时发现宿主子菜单**不受视口钳制**(`bottom:-4px` 从父行向上生长、`left:calc(100%+10px)` 向右展开,主列表的钳制与 `max-height` 收缩均不覆盖它):树顶部右键时本子菜单伸出视口上沿,窄面板(280–400px)时伸出右沿。修复为插件侧方案 `src/client/menu-flip.ts`——菜单打开期间在 `<body>` 发布 `data-dsh-sidebar-submenu="down|left"` 方向 token(`y < vh/2` → down;`x + 400 > vw` → left,400 镜像宿主几何 218 卡片 + 10 间隙 + 165 子菜单 + 12 边距),`layout.css` 据此翻转子菜单生长方向与 `::before` 悬停桥;**属性的存续期即作用域**(仅我们的菜单打开时存在),宿主自有菜单不受影响。**不做** `max-height` + 滚动钳制:overflow 裁剪会切掉子菜单 `::before` 悬停桥(跨 10px 间隙会触发 mouseLeave 关卡)。残留见「已知限制」末两条。
## 已知限制
- 路径含 `#`/`?` 的文件名经 URL 打开可能被浏览器当作 fragment/query(不处理,注释说明)。
- 编辑器未安装/协议未注册:由 OS 弹提示或静默失败,不做安装检测。SSH 客户端分支同理——浏览器端 `location.assign` 无法探测协议 handler 是否存在,仍返回 `{started: true}`。
- 自定义编辑器仅支持 URL 模板式,不支持 CLI 命令式;无 `{dir}` 占位符。
- Linux reveal 退化为打开所在目录(精确 select 需要各文件管理器私有协议)。
- SSH 模式为**全局**(非每会话):DSH 无远程会话概念,文件树路径即宿主路径;该模型对应"DSH 跑在远端开发机上"的场景。
- 子菜单钳制为方向翻转而非高度裁剪:约 >15 个打开方式目标 + 矮视口 + 不利光标位仍可能溢出(compact 密度已把常规 4–8 目标场景收进安全区)。
- 翻转阈值镜像宿主 `Menu.module.css` 几何常量(218/10/165/12):宿主未来改尺寸时阈值偏保守(多翻一次方向),不会产生新越界;菜单打开期间窗口 resize 不重算 token,该次打开最坏退化为原始行为。
## 测试
- `tests/open-with.spec.ts`:解析容错、目标解析与 SSH 过滤、URL 构建(本地/SSH/custom/坏模板)、校验器。
- `tests/open-external.spec.ts`:三平台命令表、URL 校验、spawn 前校验。
- `tests/open-external-client.spec.ts`(#522):SSH remote URL 客户端分流(不触 fetch)、本地/reveal/http(s) 留宿主、导航抛错 reject。
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏;翻转向量(down / down left / 卸载清理)。
- `tests/menu-flip.spec.tsx`:方向 token 纯函数(四象限、边界、窄面板)+ hook 属性生命周期 + layout.css 选择器与属性名的跨文件一致性。
- `tests/tab-bar-context-menu.spec.tsx`:追加翻转向量(顶部条 down、右缘 down left、下半空 token、卸载清理)。
- `tests/e2e/mount.e2e.ts`:「+」菜单首行 computed 高度 ≤30px(compact 密度的布局级证据)。
- `tests/open-with-settings.spec.tsx`:SSH 输入、添加/删除(删除剪枝 pinned)、无效提示、VSCode 系开关。
- `tests/side-card-section.spec.tsx`:`SettingsBody` 行列表 + render 共存、仅 render 时不变。
- `tests/builtins.spec.ts`:editor 描述符断言补 `settings.render`。
- 全量 `pnpm test`(810 passed)、`pnpm typecheck`、`pnpm build` 全绿;未跑挂载冒烟(由 CI plugin-mount 门禁覆盖)。
@@ -1,96 +0,0 @@
# 设计:模型主动在侧边栏打开(`sidebar_open` 工具 + `agentOpenTools` 设置)
> 日期:2026-08-23 · 状态:已实现 · 仓库:omdsh-dev/DSH-better-sidebar
## 需求
新增一个侧边栏**全局设置**(Side card 设置页「常规」分组):`agentOpenTools`,**默认关闭**。
开启后向模型注入**一个**工具 `sidebar_open`,允许模型主动在**调用方会话**的侧边栏中打开:
- 本地**文件** → 内置 editor tab(按 path 去重,聚焦既有 tab)
- 本地**文件夹** → 内置 editor tab 的**文件夹窗口**(文件树以该目录为根)
- **HTTP(S) 网页** → 内置 browser tab(URL 预填)
## 关键决策
### 1. 作用域 = 调用方 agent 会话
与 `terminal_*` 工具一致:工具只绑定 `exec.agent.session.id`,模型不传 sessionId。
子代理(或未激活会话)调用时若无客户端连接,打开请求排队,该会话侧边栏下次可见时重放。
工具结果通过 `delivered` 如实报告「当前是否已推送到已连接的侧边栏视图」。
### 2. 投递通道:host→client WS 推送(无 ack 协议)
- 新增 WS 升级路由 `/sidebar/ws/agent-opens?sessionId=...`(与 `/sidebar/ws/agent-terminals` 同模式,过同一 trust fence)。
- host 侧 `AgentOpenRegistry` 维护会话级队列 + 已连接视图集合:
- `enqueue`:有订阅者 → 即时推送并**消费**(`delivered: true`);无订阅者 → 保留队列(`delivered: false`)。
- `attach`:注册订阅者并**重放**队列(消费即出队),返回 disposer(close/error 时退订)。
- `drainAll`:特性关闭时丢弃未投递队列。
- **消费即出队**是关键:browser tab 无 per-URL 去重,重连重放会铸造重复 tab;消费语义保证每个请求恰好推送一次。
- 不加 ack 回执、不加 `/sidebar/api` 路由——结果的最佳努力语义(`delivered`)已足够,符合 KISS 与 terminal 先例。
### 3. `sidebar_open` 工具(唯一新增工具)
- 参数:`target`(必填:绝对/相对路径,或 http(s) URL)、`title`(可选:tab 标题)。
- 执行流程:`throwIfAborted` → 分类 → 目标 tab 启用检查 → 入队 → 返回规范结果。
- 分类规则:
- `http://` / `https://`(大小写不敏感)→ `url`,默认标题 = hostname;
- 其他 scheme(`file:` / `javascript:` / `vscode:` 等)→ 报错(Windows 盘符前缀 `C:\`/`C:/` 除外,它是路径不是 scheme);
- 本地路径 → 相对路径以会话 cwd(`sessionCwdOf`)解析为绝对路径,`stat` 定 `file`/`folder`;不存在/不可读 → 报错;
- 目标 tab 类型被用户禁用(`tabsEnabled['editor'|'browser'] === false`)→ 报错并提示启用(避免客户端静默 no-op)。
- 输出(canonical JSON,`render` 纯文本投影):`{ kind, target, title, delivered }`。
- 工具描述/错误消息为英文(与 8 个 `terminal_*` 一致)。
### 4. 文件夹窗口:editor tab 的 `meta.dir`
- 打开文件夹 = `openTab({ type: 'editor', path: <dir>, id: 'editor:'+<dir>, title, meta: { dir: true } })`。
- EditorHost 识别 `meta.dir === true`:不进入 viewer 加载流程,渲染**全窗 TreePanel**(`cwd = tab.path`);
FileTree 根部即 `cwd`(`fs.tree` 接受绝对路径),搜索结果/上传仍受会话工作区约束(已知限制,见下)。
- `meta` 为可持久化的 tab 状态(v0.12.0+),`sanitizeState` 原样保留——刷新后文件夹窗口恢复。
- 折叠/展开、右键打开、拖拽上传等全部复用 TreePanel 既有能力。
### 5. 设置与门控
- 新 prefs 字段 `agentOpenTools: boolean`(`SidebarPrefs` + `PrefsSchema` + 客户端 `parsePrefs`),默认 `false`。
- SideCardSection「常规」分组新增一行 Switch(标题/描述走新 locale key)。
- host 门控完全拷贝 `agentTerminalTools` 的 `syncToolsGate` 模式:
- 开启 → `registerOpenTool(...)`;关闭 → 注销工具 + `agentOpenRegistry.drainAll()`;
- 一次 `scope.watch` 同时驱动两个门控(各持有自己的 disposer,互不干扰);
- 工具不依赖 node-pty,降级模式下也可用。
- 客户端防御:Sidebar 的 agent-opens 推送订阅在 `prefs.agentOpenTools !== true` 时忽略消息(host 已门控,此为双保险)。
### 6. 与既有拦截开关的关系
- URL 打开**不受** `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps` 约束——那是用户点击外链的接管开关;
模型显式调用 `sidebar_open` 只受 browser tab 自身启用开关约束(openTab 既有 gating)。
- 文件打开**不受** `interceptOpenPath` 影响(那是聊天点击路径的接管开关)。
## 既有模式复用(零新公共 API)
| 新事物 | 复用/镜像的既有模式 |
|---|---|
| `agentOpenTools` 门控 | `agentTerminalTools` 门控(settings seam `watch` → 注册/注销) |
| `/sidebar/ws/agent-opens` | `/sidebar/ws/agent-terminals` 推送(升级注册、fence、每会话 attach) |
| 客户端推送订阅 | Sidebar 的 agent-terminals effect(重连上限 `FAILURE_LIMIT`、防抖/清理) |
| 文件/URL 打开 | `service.openFile` / `openTab({ type:'browser', url })`(link-intercept 同款) |
| 文件夹窗口 | path-less explorer 的全窗 TreePanel 分支(仅根目录改为 `tab.path`) |
不新增 `BetterSidebarService` 方法;不改变任何现有路由协议。
## 已知限制
- **多浏览器窗口**:推送按「有订阅者即发送并消费」执行,多窗口同时连接时后连接的窗口不会收到已消费的请求
(其本体状态由 localStorage 恢复——与 agent-terminals 的全量列表 fan-out 不同,属尽力而为语义)。
- **文件夹窗口的全局搜索/上传**:TreePanel 的搜索盒仍以**会话 cwd** 为根(`fs.search` 是有意的工作区限定),
目录内上传经 host 会话工作区校验(目录在 cwd 内可上传,cwd 外拒绝)。
- **无 ack**:`delivered: true` 只表示「推送已发出」,客户端处理失败(罕见)不重试。
- **关闭特性不清已打开 tab**:与 tab 启用开关语义一致,只注销工具并清空未投递队列。
## 测试覆盖
- `tests/agent-opens.spec.ts`(新):注册表语义(排队/重放/消费/作用域隔离/drain/dispose)+ 工具行为
(分类、相对路径、缺路径、非 http(s) scheme、tab 禁用、schema 校验、无 agent 错误)。
- `tests/smoke.spec.ts`:WS 升级路由清单 + `settings.get` 默认值 + 独立 gating 测试(默认 off → 0 工具;on → 1;off → 注销)。
- `tests/plugin-shape.spec.ts` / `tests/prefs.spec.ts`:schema/parse 默认值 + 全量 prefs 字面量。
- `tests/editor-host.spec.tsx`:`meta.dir` 文件夹窗口(树以目录为根、无编辑器 chrome、不触发 viewer 加载)。
- `tests/locales.spec.ts`:zh/en/ja 词典 key 集相等(自动覆盖新键)。
-408
View File
@@ -1,408 +0,0 @@
# Bug PR 一次性合并方案(merge train + 并行 subagent)
> 仓库:`omdsh-dev/DSH-better-sidebar`(本地 clone:`D:\Projects\deepseek-harness\DSH-better-sidebar`)
> 基准:main `d6d8db0` (= 0.15.2 + #339 locale ja)
> 目标:把 12 个 bug PR 一次性合入 main,**保留每个原作者的贡献归属**
> 输入:`bug-pr-review.md` 中 12 个 NOT_FIXED PR
> 日期:2026-08-23
---
## 0. 设计原则
| 原则 | 说明 |
|---|---|
| 保留作者归属 | 用 `git merge --no-ff`(**不 squash**),每个 PR 的原始 commits 全部进 main 历史;GitHub 通过 commit author email 匹配到 12 位作者 |
| 跨平台 commit trailer | #285 作者邮箱 `fix@local.invalid` 不匹配 GitHub → 在 merge commit message 加 `Co-authored-by: xxxg0001 <xxxg0001@users.noreply.github.com>` |
| 零源码 patch | train 分支所有改动都来自 PR 本身或冲突解决;**不修改任何 PR 的 commits** |
| 并行加速 | 4 个 subagent 各自 worktree 独立合并簇内 PR;跨簇冲突由主 agent 串行解决 |
| 每步验证 | 每个 subagent 完成簇内合并后跑 `pnpm typecheck`;主 agent 跨簇合并后跑 `pnpm typecheck && pnpm test` |
---
## 1. PR 清单与作者
| PR | 标题 | 作者 | author email |
|---|---|---|---|
| #345 | 限制文件 API 访问会话工作区 | wang-kaopu | 1970138194@qq.com |
| #342 | 搜索跳过 node_modules 等噪声目录 | sunrain520 | sunrain520@users.noreply.github.com |
| #341 | mermaid 抑制全局错误渲染 | duyiliu | 278950578@qq.com |
| #338 | 移除公开版 cordis peer,迁移类型基底 | Menghuan1918 | menghuan2003@outlook.com |
| #336 | tsdown 配置加载缺 unrun devDependency | MilchstraBe-Endless10 | milchstrasse.endless10@users.noreply.github.com |
| #330 | panel host 降到 DSH dynamic-plugin 层下 | ZgblKylin | 13114351+ZgblKylin@users.noreply.github.com |
| #326 | 支持选择子 git 仓库 | loeanxi | 2012065136@qq.com |
| #310 | layout push 下保 composer 在视口内 | Zzy-min | zzy19812007@gmail.com |
| #292 | markdown 预览本地/相对路径图片 | huzilin | huzilinha@gmail.com |
| #285 | linked worktrees 移植到 RC8 | xxxg0001 | fix@local.invalid ⚠ |
| #278 | 面板宿主裁剪视口、收起面板不再撑出双向滚动 | ch3n4y | ch3n4y@gmail.com |
| #254 | mobile 说明不可用侧边栏并防溢出 | SparkElf | laoyh@chinatelecom.cn |
⚠ #285 author email 不匹配 GitHub 账号,需在 merge commit 加 `Co-authored-by` trailer 补 credit。
---
## 2. 文件冲突矩阵(跨簇)
> 仅列出被 ≥2 个 PR 修改、且**跨簇**的文件。簇内冲突由 subagent 解决。
| 文件 | 涉及 PR(按簇) |
|---|---|
| `README.md` / `README_EN.md` | A: #338 #345;B: #292;C: #254 |
| `AGENTS.md` | A: #338;B: #342;C: #330 |
| `docs/external-plugin-guide.md` | A: #338 #345(簇内解决) |
| `package.json` | A: #338 #336;C: #254;D: #326 |
| `dsh.plugin.json` | A: #338;C: #254 |
| `src/index.ts` | A: #338 #345;D: #285 #326 |
| `src/client/sidebar.module.css` | C: #330 #278 #254(簇内解决);D: #285 |
| `src/client/Sidebar.tsx` | B: #310;C: #254 |
| `src/client/service.ts` | A: #338;C: #254 |
| `src/client/DiffTab.tsx` / `GitView.tsx` / `api.ts` / `state.ts` / `src/git.ts` | D: #285 #326(簇内解决) |
---
## 3. 分簇(簇内串行,跨簇并行)
### Cluster A — 基础设施(package.json / cordis / path security)
**分支**:`train/base`
**worktree**:`D:\Projects\deepseek-harness\dsb-train-base`
**合并顺序**:
1. `#338` (cordis peer 迁移) — 改 package.json/dsh.plugin.json/src/index.ts/service.ts/AGENTS.md/README/docs
2. `#336` (unrun devDep) — 只改 package.json
3. `#345` (path security) — 改 README/docs/src/index.ts/fs-operations/html-route/path-security 新增
**簇内冲突点**:
- #336 vs #338:package.json(#338 移除 cordis peer,#336 加 unrun devDep,行不冲突但邻近)
- #345 vs #338:README.md / README_EN.md / docs/external-plugin-guide.md / src/index.ts(#338 迁移基底,#345 加 path-security 引用,可能 src/index.ts 注册新路由撞)
### Cluster B — 独立修复
**分支**:`train/misc`
**worktree**:`D:\Projects\deepseek-harness\dsb-train-misc`
**合并顺序**:
1. `#341` (mermaid suppressErrorRendering) — 改 src/client/mermaid.tsx + 测试
2. `#310` (layout-push composer clip) — 改 Sidebar.tsx + layout-push.ts + breakpoints.ts + layout.css + 测试
3. `#342` (fs-search skip dirs) — 改 fs-search.ts + AGENTS.md + 测试
4. `#292` (markdown images) — 改 TextEditor.tsx + markdown-images.ts + README + 测试
**簇内冲突点**:基本无(彼此文件不重叠)。#342 的 AGENTS.md 和 #292 的 README 是跨簇冲突,本 worktree 内基于 main,无冲突。
### Cluster C — CSS 簇(sidebar.module.css)
**分支**:`train/css`
**worktree**:`D:\Projects\deepseek-harness\dsb-train-css`
**合并顺序**:
1. `#330` (z-index drop) — 改 sidebar.module.css:35 + AGENTS.md + docs/plans + e2e 测试
2. `#278` (overflow hidden + panelHidden transform) — 改 sidebar.module.css + e2e 测试
3. `#254` (mobile no-session feedback + overflow) — 改 Sidebar.tsx + sidebar.module.css + README + dsh.plugin.json + package.json + service.ts
**簇内冲突点**:
- #278 vs #330:sidebar.module.css(#330 改 z-index 行,#278 改 overflow/transform 行,可能行邻近但不直接冲突)
- #254 vs #330/#278:sidebar.module.css(mobile media query 区域,可能撞 e2e 测试)
### Cluster D — git 簇
**分支**:`train/git`
**worktree**:`D:\Projects\deepseek-harness\dsb-train-git`
**合并顺序**:
1. `#285` (worktree RC8 forward-port) — 改 DiffTab/GitView/api/state/git.ts/index.ts/builtins/tabs.tsx/locales.ts/sidebar.module.css + 测试
2. `#326` (multi-repo selector) — 改 DiffTab/GitView/api/state/git.ts/index.ts/package.json + 截图 + 测试
**簇内冲突点**(严重):
- #326 vs #285:5 个核心文件全撞(DiffTab/GitView/api/state/git.ts)+ src/index.ts
- #326 应建立在 #285 的 worktree 支持基础上(多仓选择是 worktree list 的扩展)
- 合并 #326 时需要手工缝合:保留 #285 的 worktree list 逻辑 + #326 的子仓选择 UI
---
## 4. 执行流程
### Phase 0 — 准备(主 agent,串行)
```powershell
$repo = "D:\Projects\deepseek-harness\DSH-better-sidebar"
# 0.1 拉所有 PR 分支到本地 ref(避免 subagent 重复 fetch)
$prs = 345,342,341,338,336,330,326,310,292,285,278,254
foreach ($n in $prs) {
git -C $repo fetch origin "pull/$n/head:pr-$n"
}
# 0.2 创建 4 个 worktree(每个独立目录,独立分支基于 origin/main)
git -C $repo worktree add "D:\Projects\deepseek-harness\dsb-train-base" -b train/base origin/main
git -C $repo worktree add "D:\Projects\deepseek-harness\dsb-train-misc" -b train/misc origin/main
git -C $repo worktree add "D:\Projects\deepseek-harness\dsb-train-css" -b train/css origin/main
git -C $repo worktree add "D:\Projects\deepseek-harness\dsb-train-git" -b train/git origin/main
# 0.3 每个 worktree 都 pnpm install(独立 node_modules,避免锁文件竞争)
foreach ($wt in @("dsb-train-base","dsb-train-misc","dsb-train-css","dsb-train-git")) {
Push-Location "D:\Projects\deepseek-harness\$wt"
pnpm install --prefer-offline
Pop-Location
}
```
### Phase 1 — 簇内并行合并(4 个 subagent 同时跑)
每个 subagent 接收:
- 自己的 worktree 路径
- 簇内 PR 编号和合并顺序
- PR 的预期改动文件清单(见 §3)
- 验证命令:`pnpm typecheck`(必跑)+ `pnpm test`(如簇内有相互依赖的测试)
#### Subagent A(base)
```
worktree: D:\Projects\deepseek-harness\dsb-train-base
合并顺序: #338 → #336 → #345
命令:
git merge --no-ff pr-338 -m "merge #338: 移除公开版 cordis peer,迁移类型基底到 @deepseek-ai/cordis"
# 解决冲突(README/docs/package.json/src/index.ts)
pnpm typecheck
git merge --no-ff pr-336 -m "merge #336: add unrun devDependency for tsdown config loading"
# 解决 package.json 冲突
pnpm typecheck
git merge --no-ff pr-345 -m "merge #345: 限制文件 API 访问会话工作区"
# 解决 README/docs/src/index.ts 冲突
pnpm typecheck
完成后输出:
- train/base 的 HEAD SHA
- 解决的冲突文件清单
- 跨簇预期冲突点(README.md / package.json / src/index.ts / docs/external-plugin-guide.md / AGENTS.md / dsh.plugin.json / src/client/service.ts)
```
#### Subagent B(misc)
```
worktree: D:\Projects\deepseek-harness\dsb-train-misc
合并顺序: #341 → #310 → #342 → #292
命令:
git merge --no-ff pr-341 -m "merge #341: fix(mermaid): suppress global error rendering"
pnpm typecheck
git merge --no-ff pr-310 -m "merge #310: keep conversation composer in viewport under layout push"
pnpm typecheck
git merge --no-ff pr-342 -m "merge #342: fs-search skip node_modules and other noise dirs"
# AGENTS.md 可能无冲突(基于 main,A 簇的 AGENTS.md 改动在另一个 worktree)
pnpm typecheck
git merge --no-ff pr-292 -m "merge #292: markdown preview local/relative images"
pnpm typecheck
完成后输出:
- train/misc 的 HEAD SHA
- 跨簇预期冲突点(AGENTS.md 来自 #342;README.md 来自 #292;Sidebar.tsx 来自 #310)
```
#### Subagent C(css)
```
worktree: D:\Projects\deepseek-harness\dsb-train-css
合并顺序: #330 → #278 → #254
命令:
git merge --no-ff pr-330 -m "merge #330: drop panel host below DSH cordis dynamic-plugin layer"
pnpm typecheck
git merge --no-ff pr-278 -m "merge #278: panel host clip viewport, hidden panel no longer causes双向 overflow"
# sidebar.module.css 可能冲突
pnpm typecheck
git merge --no-ff pr-254 -m "merge #254: mobile explain unavailable sidebar and prevent overflow"
# sidebar.module.css 可能冲突;其他文件基于 main 无冲突
pnpm typecheck
完成后输出:
- train/css 的 HEAD SHA
- 跨簇预期冲突点(sidebar.module.css 来自 #285;README/package.json/dsh.plugin.json/Sidebar.tsx/service.ts 来自 #254)
```
#### Subagent D(git)
```
worktree: D:\Projects\deepseek-harness\dsb-train-git
合并顺序: #285 → #326
命令:
git merge --no-ff pr-285 -m "merge #285: forward-port linked worktrees to RC8
Co-authored-by: xxxg0001 <xxxg0001@users.noreply.github.com>"
# 注意:#285 author email = fix@local.invalid,必须加 Co-authored-by trailer
pnpm typecheck
git merge --no-ff pr-326 -m "merge #326: support selecting child git repositories"
# 严重冲突:DiffTab/GitView/api/state/git.ts/src/index.ts
# 缝合策略:保留 #285 的 worktree list 逻辑 + #326 的子仓选择 UI
pnpm typecheck
完成后输出:
- train/git 的 HEAD SHA
- 跨簇预期冲突点(sidebar.module.css 来自 #285;src/index.ts 与 base 簇撞;package.json 与 base 簇撞)
- #326 缝合的关键决策(哪些函数来自 #285,哪些来自 #326)
```
### Phase 2 — 跨簇串行合并(主 agent)
```powershell
$repo = "D:\Projects\deepseek-harness\DSH-better-sidebar"
# 切回主 worktree
# 2.1 创建 mega-train 分支
git -C $repo checkout -b merge/bug-pr-train origin/main
# 2.2 merge train/base(应该 ff,因为 base 直接基于 main)
git -C $repo merge --no-ff train/base -m "integrate train/base: cordis peer migration + unrun devDep + path security"
# 2.3 merge train/css
git -C $repo merge --no-ff train/css -m "integrate train/css: panel z-index + overflow clip + mobile sidebar"
# 解决冲突:
# - sidebar.module.css(train/base 没改,应该无冲突)
# - README.md(train/base 改了,train/css 的 #254 也改了 → 冲突)
# - package.json(train/base 加了 unrun devDep,train/css 的 #254 可能加 mobile deps → 冲突)
# - dsh.plugin.json(train/base 改了,train/css 的 #254 也改了 → 冲突)
# - src/client/service.ts(train/base 迁移了 cordis,train/css 的 #254 可能改了 mobile 检测 → 冲突)
pnpm install # 合并 package.json 后必须重装
pnpm typecheck
# 2.4 merge train/git
git -C $repo merge --no-ff train/git -m "integrate train/git: worktree RC8 forward-port + multi-repo selector"
# 解决冲突:
# - src/index.ts(train/base 改了 cordis 迁移,train/git 改了 worktree 注册 → 冲突)
# - src/client/sidebar.module.css(train/css 改了 z-index/overflow/mobile,train/git 的 #285 可能改了 git 面板样式 → 冲突)
# - package.json(train/git 的 #326 可能加了依赖 → 冲突)
pnpm install
pnpm typecheck
# 2.5 merge train/misc
git -C $repo merge --no-ff train/misc -m "integrate train/misc: mermaid + layout-push + fs-search + markdown-images"
# 解决冲突:
# - AGENTS.md(train/base 改了,train/misc 的 #342 也改了 → 冲突)
# - README.md(train/base + train/css 都改了,train/misc 的 #292 又改 → 三方合并冲突)
# - src/client/Sidebar.tsx(train/misc 的 #310 改了 layout,train/css 的 #254 改了 mobile → 冲突)
pnpm typecheck
# 2.6 全量验证
pnpm typecheck
pnpm test
```
### Phase 3 — 推送与开 PR(主 agent)
```powershell
# 3.1 推 mega-train 到 fork(不是 origin,因为 origin 是 omdsh-dev 上游)
git -C $repo push fork merge/bug-pr-train
# 3.2 开 mega-PR(base = main,head = HuanLinOTO:merge/bug-pr-train)
gh pr create `
--repo omdsh-dev/DSH-better-sidebar `
--base main `
--head HuanLinOTO:merge/bug-pr-train `
--title "merge: bug PR train (12 PRs, --no-ff to preserve authors)" `
--body "Closes #345, #342, #341, #338, #336, #330, #326, #310, #292, #285, #278, #254
一次性合并 12 个 bug PR,使用 --no-ff 保留每个 PR 的原始 commits 和作者归属。
合并顺序与分簇:
- Cluster A (base): #338 → #336 → #345
- Cluster B (misc): #341 → #310 → #342 → #292
- Cluster C (css): #330 → #278 → #254
- Cluster D (git): #285 → #326
并行 subagent 在 4 个独立 worktree 中合并簇内 PR,主 agent 串行合并 4 个 train 子分支。
#285 author email 不匹配 GitHub,merge commit 加了 Co-authored-by: xxxg0001 trailer。
详细方案:docs/plans/2026-08-23-bug-pr-merge-train.md"
# 3.3 PR 创建后,13 个 PR(12 个原始 + 1 个 mega)会显示在 PR 列表
# 等 CI 通过后用 --merge 合并(不要 squash):
gh pr merge <mega-PR-number> --repo omdsh-dev/DSH-better-sidebar --merge
# 合并后 12 个原始 PR 因 Closes 关键字自动 close
```
---
## 5. 验证清单
### Phase 1 完成后(每个 subagent 自查)
- [ ] 簇内所有 PR 已 `--no-ff` 合并
- [ ] `pnpm typecheck` 通过
- [ ] git log 显示每个 PR 的原始 commits 保留
- [ ] 输出 train 子分支的 HEAD SHA 给主 agent
- [ ] 输出解决的冲突文件清单
- [ ] 输出跨簇预期冲突点
### Phase 2 完成后(主 agent 自查)
- [ ] 4 个 train 子分支全部并入 `merge/bug-pr-train`
- [ ] `pnpm install` 成功
- [ ] `pnpm typecheck` 通过
- [ ] `pnpm test` 通过(win32 平台已知失败项可记录,不算回归)
- [ ] `git log --oneline d6d8db0..HEAD` 显示 12 个 merge commit + 各 PR 的原始 commits
### Phase 3 完成后
- [ ] mega-PR 在 omdsh-dev/DSH-better-sidebar 创建成功
- [ ] PR body 包含 12 个 `Closes #N`
- [ ] CI 通过(或失败项是已知非回归)
- [ ] 用 `--merge` 合并(**不是 squash**)
- [ ] 合并后 12 个原始 PR 全部 close
- [ ] main 上 `git log --author=<每位作者>` 都能查到 commits
---
## 6. 风险点与应对
| 风险 | 概率 | 应对 |
|---|---|---|
| Phase 1 某个 subagent 簇内冲突解决失败 | 中 | 主 agent 介入协助;最坏退回该簇重做 |
| Phase 2 跨簇冲突比预期复杂(特别是 README 三方合并) | 高 | 手工缝合;以 #338 的 cordis 迁移为基底,叠加其他 PR 的章节 |
| Phase 2 #326 缝合 #285 后逻辑不一致(worktree list + 子仓选择) | 中 | 优先保 #285 的 RC8 forward-port 完整性;#326 的 UI 适配其 API |
| `pnpm test` 在 win32 平台已知失败 | 中 | 记录为已知非回归,不阻塞合并(参考 AGENTS.md "上游测试可能有 win32 平台假设失败") |
| GitHub SSL 抖动(fetch 失败) | 低 | 重试 / 用 `gh pr diff` 替代 fetch / 走 fork remote |
| `--merge` 合并 mega-PR 后 contributors 列表未立即更新 | 低 | GitHub 异步计算 contributors,等 24h;只要 commit author email 匹配即可 |
| #285 的 `fix@local.invalid` 邮箱即使加 Co-authored-by 也不被 GitHub 识别 | 中 | 已用 noreply 邮箱作为 Co-authored-by,GitHub 会识别;最坏在 PR body 注明 |
---
## 7. 时间预估
| 阶段 | 串行耗时 | 并行耗时 |
|---|---|---|
| Phase 0 准备 | 5 min | 5 min |
| Phase 1 簇内合并 | 60 min | **20 min**(4 个 subagent 并行,取最慢的 git 簇) |
| Phase 2 跨簇合并 | 30 min | 30 min(必须串行) |
| Phase 3 推送 + PR | 5 min | 5 min |
| **总计** | 100 min | **60 min** |
并行加速比 ≈ 1.7×(受 Phase 2 串行合并限制)。
---
## 8. 回滚
```powershell
# 任意阶段失败,回滚到 main
git -C $repo checkout main
git -C $repo branch -D merge/bug-pr-train train/base train/misc train/css train/git
# 清理 worktree
git -C $repo worktree remove "D:\Projects\deepseek-harness\dsb-train-base" --force
git -C $repo worktree remove "D:\Projects\deepseek-harness\dsb-train-misc" --force
git -C $repo worktree remove "D:\Projects\deepseek-harness\dsb-train-css" --force
git -C $repo worktree remove "D:\Projects\deepseek-harness\dsb-train-git" --force
# 删除本地 PR ref
$prs = 345,342,341,338,336,330,326,310,292,285,278,254
foreach ($n in $prs) { git -C $repo branch -D "pr-$n" }
```
如果 mega-PR 已合并到 main 但发现严重回归:
```powershell
# Revert mega-PR 的 merge commit(保留历史,不删除 PR 记录)
gh pr merge <mega-PR-number> --repo omdsh-dev/DSH-better-sidebar --revert
```
@@ -1,82 +0,0 @@
# 自由窗口(Free Window)设计文档
- 日期:2026-08-23
- 版本:v0.16.0
- 分支:`feat/free-window`
- 状态:已实现(本文含实施偏差记录)
## 1. 背景与目标
侧边栏的 tab 只能活在右面板 / 底面板的 pane 里。用户在主会话区域工作的同时想盯一个终端、一个文件或一个插件页面时,只能挤压面板宽度或来回切换 tab。
**目标**:把任一 tab 拖到主会话区域(conversation 列)上松开,它变成一个可自由移动/缩放的**自由窗口**,悬浮在会话区之上;随时可拖回侧边栏停靠或一键返回。
**非目标(KISS 排除项)**:
- 无四边/四角缩放(只有 SE 角)、无双击最大化;
- 浮窗单 tab(无内嵌迷你标签栏);
- 停靠不支持 edge split(只 center 合并进目标 pane);
- 无窗口数量上限、无磁吸/网格对齐;
- 窄视口(合并抽屉)禁用拖出手势(抽屉覆盖会话区,无处可放);右键菜单入口仍可用。
## 2. 交互设计(用户视角)
| 手势/入口 | 行为 |
|---|---|
| 按住 tab 拖到主会话区域 | 会话列出现虚线提示浮层「松开以在自由窗口中打开」;松开 → tab **移动**(pane 中移除)为浮窗,默认 390×780(手机竖屏比例;创建时按视口钳制)居中于松点、钳入视口 |
| tab 右键菜单「移动到自由窗口」 | 同上,落点为会话列中心(右面板与底面板的 TabBar 共用组件,一处生效) |
| 浮窗头部按住拖动 | 移动窗口(pointer capture + rAF 直写 DOM + 松手提交 store);拖到侧边栏 pane 上方时该 pane 高亮,松开即停靠(center 合并) |
| 浮窗头部右键 | 菜单:「回到侧边栏」(停靠到激活 pane)/「关闭」 |
| 浮窗右下角拖动 | SE 角缩放(≥320×200,锚定左上角,钳入视口) |
| 点击浮窗任意处 | 置顶(层叠顺序 = `floats` 数组序,末位最上) |
| 浮窗头部 X | 关闭该 tab(走 `closeTab` 正常流程:触发 `onClose`、释放终端 pty) |
刷新页面:浮窗随会话状态从 localStorage 原样恢复(tab + 几何)。
## 3. 状态模型(`src/client/state.ts`)
```ts
interface FloatWindow {
id: string // uid('float'),与 pane/tab/split 共享计数器
tab: SidebarTab // 移动语义:浮窗拥有 tab,pane 不再持有
x: number; y: number // 视口坐标(左上角)
w: number; h: number
}
// SidebarState.floats: FloatWindow[](数组序 = 层叠序)
```
新增纯 reducer:`floatTab`(两棵树定位 + 移除 + 清空折叠 + activePane 回落)、`moveFloat` / `resizeFloat`(钳制,no-op 返回原引用避免持久化搅动)、`raiseFloat`(移末尾,幂等)、`dockFloat`(目标 pane 缺省 activePane,stale 回落右树首叶)、`closeFloatByTab`、查找助手 `floatWithTab` / `floatById`。
**既有查找路径全部扩展到 floats**(一致性关键):`tabOpenIn`、`patchTab`(updateTab,浏览器浮窗导航持久化 URL)、`openTabInActivePane` 的 id 安全网(命中 → raise)、`reconcileAgentTerminals`(浮窗 agent 终端随宿主列表消失则整窗移除)、`maxCounterId`(`float:N` 前缀参与 uid 播种,防 reload 碰撞)。
**sanitize**:`floats` 缺省 → `[]`;逐条宽容校验(id/tab 复用与叶子共享的 `sanitizePersistedTab`;几何非有限数或重复 id → 丢弃该条,不动整体布局);几何钳入当前视口;diff/ephemeral tab 不持久化;`explorer` 旧类型在浮窗内同样迁移为 editor home。
## 4. 实现要点
- **拖出检测**(`Sidebar.tsx`,document 捕获层):`dragover`/`drop`/`dragend` 捕获监听,以 `body[data-dsh-tab-dragging']`(TabBar 维护的 tab 拖拽标记)门控;目标在 `[data-dsh-panel-host]` 内则无视(pane 的 split/merge 自治);在 conversation 列(`#root [data-slot="conversation"]` 父列实时 `getBoundingClientRect()`)内 `preventDefault()`(武装 drop)+ 显示提示浮层(同值 rect 跳过 setState,避免 60Hz 重渲染);`drop` 读 `application/x-dsh-tab` payload 调 `floatTab(tabId, clientX, clientY)`。
- **FreeWindow**(新文件 `FreeWindow.tsx`):头部/SE 角 pointer capture 拖拽——照抄面板拖拽的「rAF 直写 `style.left/top/width/height` + 松手提交 store + pointercancel/lostpointercapture 取 last-applied 兜底」模式;拖动中每帧对 `[data-dsh-pane]` 矩形命中检测,命中 pane 直接 DOM 打 `data-dsh-float-dock-over` 高亮(不经 React state,避免拖拽期全树重渲染);松手命中 → `dockFloat(paneId)`,未命中 → `moveFloat`。jsdom 无 pointer capture API,取 `setPointerCapture?.()` 容忍模式(与 `EditorHost.tsx` 同款)。
- **服务层**(`service.ts`):`closeTab`/`activateTab` 的浮窗分支;`openTab` 的 dedupeKey/id 匹配与生命周期分类纳入 floats——**聚焦浮动 tab = 置顶窗口**,且内容型打开不触发面板自动展开(浮窗与面板开合无关,恒可见);`features` 追加 `'floatWindows'`。
- **渲染**:浮窗渲染在 `[data-dsh-panel-host]` 内(视口坐标、免疫壳层 transform 劫持),z-index 42(面板 40 之上、开关簇 45 与 DSH 浮层之下);内容复用 `renderTab`/`TabContent`(每 tab RenderBoundary、memo),`visible` 恒 true。
- **样式**(`sidebar.module.css`):全令牌驱动(`--dsw-alias-bg-layer-1` 表面、`--dsw-alias-border-l2` 边、`--dsw-shadow-lv3` 阴影、accent 虚线提示);窗口盒无 transition(拖拽逐帧直写);头部 `-webkit-app-region: no-drag`。
- **i18n**:`moveToFreeWindow` / `floatDropHint` / `dockToSidebar` 三键,19 语言词典全量同步(zh/en/ja 一致性由 `tests/locales.spec.ts` 守护)。
## 5. 测试
- `tests/state.spec.ts`(free windows 块):reducer 语义(移动/清空折叠/钳制/幂等/停靠回退/未知 id 严格 no-op)、查找路径扩展、agent 终端浮窗移除、sanitize(缺省/逐条丢弃/几何钳制/uid 播种/round-trip)。
- `tests/service.spec.ts`(free windows 块):dedupe 聚焦浮窗 = 置顶不展开面板、closeTab 关窗 + onClose、activateTab 置顶 + onActivate。
- `tests/free-window.spec.tsx`(新,jsdom 全 Sidebar 挂载):拖出检测(提示出现/消失、drop 落位、无武装 drop 不浮动)、右键菜单浮动落点、头部拖动(rAF 直写 + 提交)、SE 缩放、拖到 pane 停靠(高亮断言)、X 关闭、reload 恢复。
- `tests/e2e/float-window.e2e.ts`(新,Playwright 真实 `dsh web`):右键浮动 → 拖动移动 → reload 恢复 → 右键停靠回归;HTML5 拖出(提示浮层中拖断言)→ 终端浮窗 + 标签栏移除;全程无 crash 标记。
## 6. 已知取舍
- 浮窗头部同时承担「移动」与「拖回停靠」——统一为一种 pointer 手势(落点命中 pane 即停靠,否则移动),未用 HTML5 DnD(浏览器接管后 pointer 流中断,与顺滑移动冲突)。
- 停靠只支持 center 合并:从浮窗拖回不提供 edge split(KISS;pane 内拖拽仍支持全部分区操作)。
- 浮窗内 tab 被 `openTab` 再次打开时聚焦的是**浮窗**(置顶),不会把 tab 拉回 pane——窗口就是 tab 的「pane」。
- 窄视口下拖出手势禁用,但右键菜单入口保留(窗口钳入小视口仍可用)。
## 7. 实施偏差记录
- **portal 事件劫持拖拽(e2e 发现,jsdom 不复现)**:浮窗头部内嵌 `Menu`(portal 渲染到 `document.body`),但其 React 合成事件仍沿 React 树冒泡进头部的 `onPointerDown`——右键菜单行的**左键点击**会被误判为头部拖拽:`preventDefault` 吞掉行的兼容 mousedown,`setPointerCapture` 把后续 pointer 事件重定向到头部,行的 click 永不触发(「回到侧边栏」与 X 关闭在真实浏览器中双双失效)。修复:头部/缩放把手的 `onPointerDown` 加**同源守卫**——`event.currentTarget.contains(event.target)` 为假(portal 后代)或目标在按钮内(X)时直接返回。回归测试:`tests/free-window.spec.tsx`「a pointerdown on the portaled header menu must not start a header drag」。教训:任何承载 pointer 拖拽的表面,若其 React 子树里有 portal 覆盖层(菜单/弹窗),必须做 DOM 包含守卫——jsdom 合成事件不走这条路径,只有真实浏览器 e2e 能抓到。
- **e2e tarball 选择修复**(`scripts/e2e-mount.sh`):缺省 tarball 解析原为 `ls | head -1`(字典序),仓库根残留旧版本号 tarball 时会把冒烟挂到**过期产物**上(本次 0.15.0 劫持了 0.16.0 的 lane,菜单项整体消失)。改为 `ls -t` 取 mtime 最新并在多候选时告警。
- e2e 细节修正:conversation 列须取 `[data-slot="conversation"]` 的**父列**(slot 包裹器零尺寸,`boundingBox()` 返回 null);reload 后需重跑 DSH 首启 takeover 清场(takeover 在 settings join 后才挂载,须先有界等待再清);提示浮层断言按 2 计(`[class*="floatDropHint"]` 子串命中浮层与其文案 label 两个节点);拖拽后 reload 前留 500ms 让 store 的 200ms 防抖持久化落盘。
- 其余与本文一致。
@@ -1,58 +0,0 @@
# Markdown 预览内嵌 HTML 渲染 + 目录大纲(TOC)设计
> 日期:2026-08-24
> 状态:已实现
> 范围:`dsh-better-sidebar` 插件,Markdown 文件预览(`TextEditor.tsx` preview 模式);Sidechat 与其他渲染面**零改动**
> 基线:复用 mermaid 预览(PR #164)确立的「检测切分 + 单次 MarkdownText 解析 + commit 后 DOM 手术」架构与安全姿势
## 1. 背景与问题
DSH 宿主的 `MarkdownText` 出于聊天安全把 raw HTML 按字面文本渲染(协议白名单、相对链接禁用、图片仅绝对 http(s))。这对聊天是对的,但对**文件预览**是灾难:GitHub 风格的 README(本仓库自己的 README 即典型)大量使用内嵌 HTML——`<div align="center">` 徽章墙、`<details>` 折叠块内嵌 markdown、表格单元格里的 `<br/>`/`<sub>`/`<img>`、`<video>`/`<picture>`——预览时全部显示为源码文本。
用户需求(确认记录):覆盖 Markdown 文件预览;块级 + 内联 HTML 都做;安全模型选 DOMPurify 白名单内联渲染(非沙箱 iframe);TOC 选浮动按钮 + 弹出面板;Sidechat 暂不动;**不更换渲染器**(保住 MarkdownText 的 shiki 高亮 + KaTeX 数学 + GFM 表格)。
## 2. 方案
### 2.1 检测与切分:`src/client/markdown-html.ts`(纯函数)
- `splitHtmlBlocks(text)`:行扫描器把文档切成 `markdown | html` 段。HTML 段 = 围栏外的**块级标签行**(CommonMark type-6 名单 + `<summary>`,开或闭标签)开头、延伸到空行的连续行;`<!-- -->` 注释段在含 `-->` 的行结束。**围栏识别复用 `mermaid-blocks.ts` 导出的 fence 助手**——```html 围栏内的 `<div>` 示例不会被误切。段边界的空行是分隔符,被丢弃。
- `analyzeHtmlSegment(source)`:段内标签 tokenizer(跳过注释,void/自闭合标签不进栈,错配闭标签按 HTML 解析器的隐式闭合弹出)归约为有序 parts:`html`(平衡的原始片段,渲染为消毒叶子)/ `open`(未闭合开标签 + 原始 attrs,成为包裹后续段的容器)/ `close`(多余闭标签,弹出一层)。
- `collectReferenceDefinitions(segments)`:从 markdown 段收集 `[label]: dest` 行。**每个 markdown 段渲染时追加密全文档定义**——跨 HTML 段的 `[text][id]` 语义保真(追加副本在已含定义的段内因 first-match-wins 而惰性)。这是 mermaid 预览「单次解析」约束(引用定义跨 fence 解析,e2e 钉死)在切分渲染下的等价物。
- `analyzeMarkdownHtml(text)`:整体入口,含 `hasInlineHtml` 廉价正则门(fence 内误报只多走增强路径,内联 pass 本身跳过代码块,无行为差异)。
### 2.2 渲染:`src/client/MarkdownHtml.tsx`
- **安全管线**(三个入口共用):块叶子 / 内联文本 / 包裹开标签 attrs 全部过 DOMPurify(显式 `FORBID_TAGS: script/style/iframe/object/embed/form/input/button/select/textarea/meta/link/base/frame/frameset/applet` + `FORBID_ATTR: srcdoc/formaction`,叠加在默认白名单之上);消毒后 DOM 遍历强制 `<a>` → `target=_blank rel=noopener noreferrer`;本地 `img/video/audio/source` 的 `src` 经 `resolveLocalMediaDest`(从 `markdown-images.ts` 抽出的共享助手)重写为 `/sidebar/file` 会话媒体 URL——与 markdown 图片重写同一信任围栏。包裹标签的 attrs 走「迷你文档消毒再读回」;`class/for` 映射 React 名,`style` 丢弃(React 需对象,不值得引入 CSS 解析器);整标签被禁时容器透明(子内容照常渲染)。
- **`MarkdownDocument` 段渲染状态机**:markdown 段 → `MarkdownSegment`;平衡 html 段 → `dangerouslySetInnerHTML` 叶子(`[data-dsh-html-segment]`);`open`/`close` 部件驱动一个**跨段持久的帧栈**——`<details>` 开段把后续段降为自己的子元素,`</details>` 段弹栈(GitHub 线性 HTML 输出的同款嵌套)。顶层多余闭标签渲染为无(消毒器/解析器本就会丢弃);文档结束仍开着的帧按浏览器解析器语义收口。消毒在 `useMemo` 内按段缓存。
- **`MarkdownSegment` 内联替换**:段内含 mermaid fence 走现有 `LazyMermaidMarkdown`(chunk 逻辑不变),否则 `MarkdownText`;`useLayoutEffect` + **MutationObserver**(微任务去抖、幂等、跳过 `code/pre/.md-code-block/[data-mermaid-processed]/[data-html-inline]`)把含标签样式文本的文本节点整体消毒后包进 `<span data-html-inline>` 替换——纯散文(`a < b`)消毒后无元素子节点则原样保留;observer 覆盖懒 chunk 迟挂载、shiki 异步高亮等迟到内容。与 mermaid 换装同款「React 持有宿主树、只动叶子」姿势。
- **接线(`TextEditor.tsx`)**:`analyzeMarkdownHtml` 发现块级或内联 HTML 才走 `MarkdownDocument`;**纯 markdown 文档走原路径原样**(无 HTML → 单次 `MarkdownText`/mermaid chunk,现有 e2e「单次解析」守卫继续成立——种子 md 文件不含 HTML,断言一字未改)。
### 2.3 TOC:`src/client/md-toc.tsx`
- 零高度 sticky 条(`position: sticky; top: 0; height: 0; pointer-events: none`,不占布局、滚动常驻)+ 右上角按钮 `[data-dsh-md-toc]`(≥3 个标题才出现)+ 弹出面板 `[data-dsh-md-toc-panel]`(按层级缩进、独立滚动、Esc 关闭)。
- 标题从**渲染后的 DOM** 收集(h1–h6,含 HTML 段内标题),MutationObserver 重扫(签名比对防重渲染循环)。
- 跳转:展开 `closest('details:not([open])')` → `scrollIntoView({behavior:'smooth'})` → CSS 动画高亮 1.2s。
- **⚠️ 关键实现陷阱(本次踩坑)**:MdToc **不能**通过传入父容器 ref 定位——React 的 layout 阶段子先于父,子组件的 `useLayoutEffect` 执行时父 div 的 ref **尚未挂上**(实测 `containerRef.current === null`,且依赖数组恒定导致 effect 永不重跑,按钮永不出现;jsdom 单测传预填 ref 对象会掩盖此 bug,只有真实挂载 e2e 能抓到)。修复:MdToc 用**自身 bar div 的 ref**(自身 effect 前必然已挂)取 `parentElement` 作为容器——挂载契约「渲染为目标滚动容器的直接子元素」写入组件文档;bar 常驻渲染(零高度无副作用)以保证 ref 恒在。
### 2.4 依赖与产物
- `dompurify ^3.4`(已是 mermaid 的传递依赖,提升为直接依赖后 pnpm 去重单版本,零额外体积)随 editor chunk 内联,未新增 chunk。
- 市场清单守卫合规(dependencies 无 `cordis`、无 install 脚本)。
## 3. 测试
- `tests/markdown-html.spec.ts`:切分器(21 例:块级标签成段/空行终止/注释段/闭标签段/围栏免疫/CommonMark 恢复/README details 三段形)+ tokenizer 平衡分析(9 例)+ 引用定义收集 + 内联门。
- `tests/markdown-html-render.spec.tsx`:真实 `MarkdownText` 渲染——消毒叶子(script 剥除、a 加固、本地 src 重写)、details 跨段嵌套(fence 在 details 元素内、后续段是兄弟)、跨段引用定义解析、禁用标签容器透明、内联替换(表格单元格 `<br/>` 生效、代码块与 `a < b` 不动、内联本地 img 重写)。
- `tests/md-toc.spec.tsx`:阈值门、标题收集、跳转(mock `scrollIntoView`、折叠祖先展开、高亮清除)、observer 迟到标题、Esc 关闭。
- `tests/e2e/mount.e2e.ts`:新增 `readme-style.md` 种子(徽章 div 含 script、details 包 fence+标题、表格内联 `<br/>`、跨段引用定义)+ 探针(徽章 img 真实元素、无 script、details 嵌套、内联 br、引用解析、TOC 按钮→面板→跳转展开)。**现有 mermaid 探针与断言一字不动**(纯 markdown 文档零回归证明)。
## 4. 已知取舍(明确不做)
- GFM 脚注跨段不保(宿主本就把脚注渲染为纯文本);`srcset` 不重写(实际只有远程 URL);HTML 实体不解码;标题锚点 permalink、TOC 滚动联动高亮不做;Sidechat 不动;不换渲染器。
- CommonMark HTML 块类型 1(script/pre/style/textarea 按闭标签终止)与类型 7(单行完整标签)不做专门处理——块级名单 + 空行终止已覆盖 README 级真实用法。
## 5. 后续修正记录(bug 反馈)
- **代码块表头在预览中悬浮过长**:DSH `CodeBlock` 的 `bannerWrap` 是 `position: sticky; top: 0; z-index: 6`——在聊天里每个消息块拥有自己的滚动容器,表头粘住是对的;但在文件预览中所有 fence 共享一个 `.editorMd` 滚动容器,表头会钉在视口顶部直到整个块滚过,读作悬浮在文档上。修正:预览容器内对非 mermaid 的 `.md-code-block` 首子元素(即 `bannerWrap`)恢复 `position: static; z-index: auto`(mermaid 块已被 `data-mermaid-processed` 排除,其自身 chrome 取代了表头)。见 `src/client/sidebar.module.css` `.editorMd :global(.md-code-block:not([data-mermaid-processed])) > :global(div):first-child`。
- **TOC 面板被代码块表头压住**:`.tocBar` 原 `z-index: 3`,低于代码块表头的 `6`,弹出面板(位于 bar 的层叠上下文内)会被 sticky 表头盖住。修正:`.tocBar` 提升到 `7`(仍低于传送门选择弹窗的 `60` 与 mermaid 模态的 `1000`)。
- **TOC 只能再点按钮收起**:新增文档级 `pointerdown` 监听,点击落在按钮与面板之外(预览空白处)即收起;按钮/面板豁免,保证按钮仍可翻转。见 `src/client/md-toc.tsx`(与 Esc 关闭共用同一 effect)。
@@ -1,173 +0,0 @@
# 固定终端(Pinned Terminals)设计
> 2026-08-26 · 状态:设计已评审,待实施
> 需求:现所有 Tab 跟随会话,切换会话后终端仍留在原会话(不可见但 PTY 保活)。
> 希望右键终端 Tab 可「固定」→ 二级菜单「固定到工作区 / 固定到全局」,固定后切换会话不消失。
## 0. 决策记录(已确认)
| 决策点 | 结论 |
|---|---|
| 可 pin 的终端类型 | **UI 终端 + Agent 终端均可**(Agent 终端被 reconcile 移除时保留为「已断开」tab) |
| 跨会话呈现形态 | **独立「固定区」列表**(PinnedRail,面板顶部紧凑条),不投影进目标会话 tab 条 |
| Menu 二级菜单 | `ui-primitives` 的 `Menu` **原生支持 `submenu`**(`MenuItem.submenu`,hover/focus 展开右侧子卡),零改动可用 |
## 1. 现状关键事实(实施依据)
- **状态模型**(`src/client/state.ts`):`SidebarStore.bySession: Map<sessionId, SidebarState>`;`setSession()` 整体换快照。`SidebarTab = { id, type, title, path?, diff?, meta? }`。终端 tab:`type: 'terminal'`。
- **终端两类**(id 前缀判别,无需新字段):
- `terminal:<uuid>` —— UI 终端(用户 + 菜单开,`createTab` 铸造)
- `agent:<uuid>` —— Agent 终端(模型 `terminal_create` 工具,`reconcileAgentTerminals()` 跟宿主 WS 列表同步;`isAgentTabId()` / `agentUuidOf()` / `agentTabId()` 已存在)
- **PTY 保活**(`src/client/TerminalView.tsx` 卸载三分支):tab 关闭 → `{type:'close'}` 杀 PTY;**切会话 → `{type:'park'}` 宿主机无限保活**;同会话重挂载 → bare drop(30s 重连宽限)。Agent 终端 bare drop 已无限保活,无需 park。
- **关闭路径**(`src/client/Sidebar.tsx` `actions.closeTab`):service `closeTab` + 终端额外 `api.ptyClose({sessionId,cwd},tabId)` / `api.agentPtyClose(uuid)`。
- **持久化**:`localStorage` `dsh-sidebar:v1:<sessionId>`,`sanitizeState` 白名单校验;`sanitizePersistedTab` 逐 tab 校验(未知 type → OrphanedTab 占位)。
- **TabBar 右键菜单**(`src/client/TabBar.tsx`):flat `items`,`onSelect(id)` 分发。Menu `MenuEntry = MenuItem | MenuSeparator | MenuLabel`。
- **宿主会话切换**:Sidebar 只读 `ctx.sessions.list` 的 `current`(`store.setSession(current)` 跟随);「跳转到子代理会话」先例 = `subagentJumpRef` 记录目标 + 宿主列表侧点击导航,effect 在 `sessionId === pending` 时执行后续动作(PinnedRail 点击沿用此模式)。
- **cwd 来源**:`sessionList.byId[sessionId]?.cwd`(`Sidebar.tsx:303` 先例)。
## 2. 数据模型
```ts
// state.ts — SidebarTab 新增可选字段
interface SidebarTab {
// ...现有字段
/** 固定标记(v0.x+)。scope=工作区|全局;homeCwd=pin 时刻会话 cwd(workspace 可见性判据)。 */
pin?: { scope: 'workspace' | 'global'; homeCwd?: string }
}
```
- `sanitizePersistedTab` 白名单校验 `pin`(`scope` 枚举值校验,`homeCwd` string 可选;非法值降级为无 pin,不丢 tab)。缺字段 = 未 pin,旧持久化状态向后兼容。
- pin 元数据**随宿主会话 state 持久化**,无独立 localStorage 注册表(YAGNI)。
- **权威副本唯一**:pinned tab 只存在于宿主会话(home session)的 state 里,其他会话仅跨会话渲染——现有 park/保活语义零改动。
## 3. 跨会话解析层(新模块 `src/client/pinned.ts`,纯函数)
```ts
/** 该 pinned tab 对 viewer 会话是否可见。cwd 未知(undefined)时保守可见,避免水合期闪烁消失。 */
export function pinnedVisibleTo(
tab: SidebarTab,
viewer: { sessionId: string; cwd: string | undefined },
): boolean
/** 从所有缓存会话的 state 收集对 viewer 可见的 pinned 终端 tab。
* 返回 { tab, homeSessionId }[],按 pin 先后稳定排序;排除 viewer 自己会话的
* (那些本就在 tab 条上,避免双渲染)。 */
export function collectPinnedTabs(
bySession: ReadonlyMap<string, SidebarState>,
viewer: { sessionId: string; cwd: string | undefined },
): { tab: SidebarTab; homeSessionId: string }[]
```
可见性规则:
| pin.scope | 可见条件 |
|---|---|
| `global` | 任意会话(不同 cwd 也可见) |
| `workspace` | `viewer.cwd === tab.pin.homeCwd`(两者皆 undefined 视为匹配;仅 viewer.cwd 未知时保守可见) |
`SidebarStore` 新增只读方法:
```ts
/** 当前所有会话 state 的只读视图(供 collectPinnedTabs)。 */
getSessionStates(): ReadonlyMap<string, SidebarState>
```
渲染期解析,不写跨会话投影——每会话 state 保持纯净,reduce/reduceFor 语义不变。store 任意会话更新已全局 notify,rail 随 uSES 自动重渲染。
## 4. UI:PinnedRail(新组件 `src/client/PinnedRail.tsx`)
- **挂载**:`Sidebar.tsx` 内、右面板 tab 区之上(右/底面板共享一条,pinned tab 不归属任何树)。仅当可见 pinned ≥ 1 时渲染。稳定寻址面 `[data-dsh-pinned-rail]`、`[data-dsh-pinned-tab="<tabId>"]`。样式走 CSS Modules + `--dsw-alias-*` 令牌(§8 皮肤契约),hairline 分隔。
- **条目**:pin 图标(📌 风格 SVG,icons.tsx 新增 `IconPinOutline16`)+ 终端类型图标(Agent 终端用机器人/思考图标,UI 终端用终端图标)+ 标题 + tooltip(`类型 + 范围 + 来源 cwd`,如「Agent 终端 · 固定到工作区 · D:\proj\x」)。
- **交互**:
- **点击**:当前会话含该 tab id → `activateTab`;否则记录 `pinnedJumpRef`(沿用 subagentJumpRef 模式)→ 触发宿主导航到 home 会话 → effect 命中后 `reduceFor` 激活 tab(面板折叠则展开)。
- **右键菜单**(复用 Menu portal):`取消固定` / `关闭终端`。
- **中键**:关闭(同右键关闭)。
- **关闭语义**:走 home 会话路径——`reduceFor(homeSessionId, closeTab)` + `api.ptyClose({sessionId: homeSessionId, cwd: homeCwd}, tabId)` / `api.agentPtyClose(uuid)`。正处 home 会话时退化为现有 closeTab 路径。关闭同时清 pin(tab 消失即 pin 消失,无需单独解 pin 步骤)。
## 5. TabBar 右键菜单扩展(`src/client/TabBar.tsx`)
终端类型 tab(`tab.type === 'terminal'`,含 UI 与 Agent)菜单项变为:
```
移动到自由窗口
固定 ▸ ← 未 pin;MenuItem.submenu
├ 固定到工作区
└ 固定到全局
取消固定 ← 已 pin(替换「固定 ▸」行)
─────────────
关闭 / 关闭其他页签 / 关闭左侧 / 关闭右侧
```
- 菜单 label 按终端类型区分:`固定终端`(UI)/ `固定 Agent 终端`(Agent)。
- TabBar 新增 props:`onPinTab(tabId: string, scope: 'workspace' | 'global' | null): void`(null = 取消)。`isPinned` 从 tab 对象自身读(`tab.pin !== undefined`),无需新 prop。
- pin 实现(Sidebar 层):`store.reduce(s => patchTab(s, tabId, { meta: ... }))` 不够——`patchTab` 只 patch title/path/meta。**新增 reducer `setTabPin(state, tabId, pin | null)`**(纯函数,walk 两树 + floats,与 patchTab 同构),pin 时从当前会话 cwd 快照 homeCwd。
## 6. 生命周期与安全
| 场景 | 行为 |
|---|---|
| 关闭 pinned tab(tab 条 X 或 rail 关闭) | 清 pin + 正常关闭 + 杀 PTY(home 路径) |
| Agent 终端被 reconcile 移除(宿主 uuid 消失) | `reconcileAgentTerminals` **豁免 pinned tab**:保留 tab,标题渲染时追加 `(已断开)`(不改持久化 title,reconcile uuid 匹配不受影响)。xterm 重连失败走现有 fatal banner |
| UI 终端 PTY 死亡 | 现有行为(xterm 显示退出输出);pinned 不改变重连/重开语义 |
| 取消固定 | 仅清 `tab.pin`;tab 留在 home 会话 tab 条,切走恢复旧行为 |
| 刷新/重载 | pin 随会话 state 持久化恢复;rail 从各会话缓存 state 重建 |
| 底部面板/窄屏迁移 | pinned tab 若在 bottomSplits,`migrateBottomTabs` 照常迁移(pin 语义不受影响) |
| 自由窗口中的 pinned tab | 允许;rail 点击切回 home 会话后 `raiseFloat`(floatWithTab 命中走置顶,openTabInActivePane 已有此分支) |
## 12. 实施偏差记录
### M3:断开信号收敛
设计 §6 原表「Agent 终端被 reconcile 移除」行提到「标题渲染时追加 `(已断开)`」。实施时收敛为:**M3 只做豁免,断开的用户可见信号由 xterm 现有重连失败 banner 承担**。原因:rail/tab 标题渲染处无法知道宿主列表(`reconcileAgentTerminals` 的 uuid 匹配是 state 级的,渲染层不持有该信息),改 meta 会写持久化(违反「reconcile uuid 匹配不受影响」)。xterm 的 WS 连接 1011/超时已有 fatal UI,足以承担断开信号。设计 §6 表相应行实施时修正为「banner 承担断开信号」。
### M4:内联虚拟 Tab(取代 PinnedRail)
设计 §3 原描述「PinnedRail 组件——面板顶部的紧凑条,渲染其他会话的 pinned tab」。实施时改为**内联虚拟 Tab**:pinned tab 作为虚拟 `SidebarTab` 注入到当前会话 split tree 的第一个 leaf 的 `tabs` 数组尾部,与普通 tab 并排在 TabBar 中渲染。
**变更原因**:用户反馈「固定的终端是永远显示在 Tabs 栏处,而不是单独有一个地方让我点击后回到那个会话」。PinnedRail 是面板顶部的独立条,点击跳回宿主会话——这两个设计点都被否定。
**新设计**:
- **虚拟 Tab 注入**:`injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId)` 将虚拟 tab 追加到第一个 leaf,并在 `activePinnedTabId` 设置时覆盖 leaf 的 `active`
- **就地激活**:点击虚拟 tab 设 `activePinnedTabId`(本地 state),TerminalView 用 home session 的 scope(sessionId + cwd)连接宿主 PTY 的 WS(`/sidebar/ws/terminal?sessionId=<home>&tab=<originalId>`),不跳转会话
- **effectiveTabId**:`TabContent` 新增 `effectiveTabId` prop,将虚拟 tab 的原始 id 传给 descriptor component(TerminalView 的 `tabId` 参数),虚拟 id 仅作 React key
- **不可拖拽**:TabBar 检测 `isPinnedVirtualTab(tab)` → `draggable={false}`,跳过 drop handler
- **右键菜单精简**:pinned 虚拟 tab 右键只有 Unpin / Close(无 float / close-others / close-left / close-right)
- **reduceFor 不 notify**:`pinnedRevision` state bump 替代 `railRevision`,force `pinnedEntries` useMemo 重算
## 7. 测试计划(Unit + 组件,vitest)
| 文件 | 覆盖 |
|---|---|
| `tests/pinned.spec.ts`(新) | `pinnedVisibleTo` 全组合(global/workspace × cwd 匹配/不匹配/双方 undefined/单方 undefined);`collectPinnedTabs` 跨会话收集、排除 viewer 自身会话、稳定排序、home 会话 id 携带 |
| `tests/state.spec.ts`(追加) | `sanitizePersistedTab` 接受合法 pin / 拒绝非法 scope 降级 / 缺字段兼容;`setTabPin` reducer(设置/取消/未知 tabId no-op/浮动窗口 tab);`reconcileAgentTerminals` 豁免 pinned agent tab(uuid 消失仍保留)+ 未 pin 的照旧移除 |
| `tests/tab-bar-context-menu.spec.tsx`(追加) | 终端 tab 出现「固定 ▸」子菜单(UI 与 Agent 文案区分);非终端 tab 不出现;已 pin 显示「取消固定」;onPinTab 回调参数正确 |
| `tests/pinned-rail.spec.tsx`(新) | rail 渲染条件(0 个不渲染);类型图标与 tooltip;点击切回 home 会话(reduceFor 激活);右键取消固定/关闭;「已断开」后缀渲染 |
回归:`pnpm typecheck && pnpm test`;终端相关改动加跑 `tests/agent-terminal-reconcile.spec.ts`、`tests/terminal-*.spec.ts`、`tests/bottom-auto-terminal.spec.tsx`。
## 8. i18n(19 语言词典同步,遵循 locales-*.ts 模式)
新增 key:`pinTerminal` / `pinAgentTerminal` / `pinToWorkspace` / `pinToGlobal` / `unpinTerminal` / `pinnedTerminalTooltip`(类型/范围/cwd 插值)/ `terminalDisconnected`。
zh-CN 基准 + en;ja 必须同步(better-locale 契约:缺 ja key 回退 en)。其余语言沿用现有词典文件的同步方式。
## 9. 文档同步
- `README.md` / `README_EN.md`:功能清单加「固定终端」
- `AGENTS.md` §3.4 内置 tab 表:terminal 行补充 pin 能力说明;§7 陷阱表视情况加「pinned tab 跨会话解析在渲染期,勿在 reducer 里投影」
- 实施偏差记录在本文档追加
## 10. YAGNI(明确不做)
- ❌ 独立 localStorage pin 注册表
- ❌ pinned tab 投影进目标会话 tab 条(独立固定区已选定)
- ❌ pin 非终端 tab
- ❌ 宿主机(host 半)改动——PTY 保活现有逻辑完备(park + agent 无限保活)
- ❌ pin 数量上限(终端本身有 `TERMINAL_LIMIT` 配额兜底)
## 11. 风险与缓解
| 风险 | 缓解 |
|---|---|
| rail 点击需触发**宿主**会话切换(非 sidebar store) | 沿用 subagentJumpRef 模式;PinnedRail 点击项渲染为宿主会话列表可识别的导航(或经 `ctx.sessions` 可用 API),effect 在 sessionId 命中后激活 tab。实施时先验证该链路(PoC 先行) |
| 「已断开」标题与后续 reconcile push 兼容性 | 后缀在**渲染期**追加,不改持久化 title;reconcile 按 uuid 匹配不受影响 |
| bySession 未缓存的会话(从未加载)其 pinned tab 不可见 | 接受:会话 state 在首次切换时 loadState 进缓存;rail 只覆盖本次运行内访问过的会话 + 当前会话。如需全覆盖需扫 localStorage 全量 key(复杂且慢),YAGNI |
@@ -1,108 +0,0 @@
# 固定终端(Pinned Terminals)实施计划
> 2026-08-26 · 设计文档:[2026-08-26-pinned-terminal-design.md](2026-08-26-pinned-terminal-design.md)
> 仓库:dsh-better-sidebar · 分支策略:`feat/pinned-terminal`(仓库硬约束:代码改动必须走 PR)
## 里程碑总览
| # | 里程碑 | 产出 | 验证 |
|---|---|---|---|
| M1 | 数据模型 + 解析层 | `SidebarTab.pin`、`setTabPin` reducer、`sanitizePersistedTab` 校验、`src/client/pinned.ts`、`SidebarStore.getSessionStates()` | `tests/state.spec.ts` + `tests/pinned.spec.ts` 绿 |
| M2 | TabBar 右键菜单 | 「固定 ▸」二级菜单(UI/Agent 文案区分)、「取消固定」、`onPinTab` prop 接线 | `tests/tab-bar-context-menu.spec.tsx` 绿 |
| M3 | reconcile 豁免 + 断开标注 | pinned agent tab 豁免移除 + 渲染期「已断开」后缀 | `tests/agent-terminal-reconcile.spec.ts` 追加用例绿 |
| M4 | PinnedRail 组件 | rail 渲染、类型图标、tooltip、点击切回 home 会话、右键取消固定/关闭 | `tests/pinned-rail.spec.tsx` 绿 |
| M5 | i18n + 文档 + 收尾 | 19 语言 key、README/AGENTS.md、全量回归 | `pnpm typecheck && pnpm test` 全绿 |
## M1:数据模型 + 解析层
**改动文件**:
- `src/client/state.ts`:`SidebarTab.pin?: { scope: 'workspace' | 'global'; homeCwd?: string }`;`sanitizePersistedTab` 白名单校验 pin(非法 scope → 降级无 pin,不丢 tab);新 reducer `setTabPin(state, tabId, pin | null)`(walk splits + bottomSplits + floats,同构 `patchTab`;未知 tabId 严格同引用 no-op)
- `src/client/pinned.ts`(新):`pinnedVisibleTo(tab, viewer)` + `collectPinnedTabs(bySession, viewer)`(排除 viewer 自身会话、按收集顺序稳定)
- `src/client/state.ts` `SidebarStore`:新增 `getSessionStates(): ReadonlyMap<string, SidebarState>`(暴露 `bySession` 只读视图)
**测试**:
- `tests/state.spec.ts`:pin 字段 sanitize(合法/非法 scope/缺字段)、`setTabPin`(设置/取消/未知 id/浮动窗口 tab/同引用 no-op)
- `tests/pinned.spec.ts`(新):`pinnedVisibleTo` 矩阵(global × 任意 cwd;workspace × cwd 匹配/不匹配/双方 undefined/仅 viewer undefined);`collectPinnedTabs`(多会话收集、排除自身、空 map)
**提交**:`feat: pin data model + cross-session resolver`
## M2:TabBar 右键菜单
**改动文件**:
- `src/client/TabBar.tsx`:新增 prop `onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void`;终端 tab(`tab.type === 'terminal'`)菜单插入 pin 行——未 pin 用 `MenuItem.submenu`(`固定到工作区` / `固定到全局`),已 pin 用单行「取消固定」;label 经 t() 且按 `isAgentTabId` 区分文案
- `src/client/Sidebar.tsx`:实现 `onPinTab` —— `store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))`(cwd 取当前会话 summaryCwd);传入 WorkbenchActions → 各 TabBar 实例
- 检查 `FreeWindow.tsx` 头部右键菜单是否也需 pin 项(与 TabBar 保持一致;若 FreeWindow 菜单独立实现则同步加)
**测试**(`tests/tab-bar-context-menu.spec.tsx` 追加):
- 终端 tab 菜单含「固定 ▸」且 hover 展开两项;点击回调 `(tabId, 'workspace' | 'global')`
- 已 pin tab 显示「取消固定」,回调 `(tabId, null)`
- 非终端 tab 无 pin 行;Agent 终端 label 为「固定 Agent 终端」
**提交**:`feat: tab context menu pin submenu`
## M3:reconcile 豁免 + 断开标注
**改动文件**:
- `src/client/state.ts` `reconcileAgentTerminals`:`toRemove` 过滤掉 `tab.pin !== undefined` 的 tab(豁免移除)
- 渲染期「已断开」:pinned agent tab 的标题后缀在**读取侧**追加——`TabBar`/rail 渲染处无法知道宿主列表,故在 `reconcileAgentTerminals` 豁免时给 tab meta 打标?**否**(改 meta 会写持久化)。方案:rail/tab 标题渲染保持原 title;「已断开」状态由 TerminalView 现有重连失败 banner 承担(WS 连接 1011/超时已有 fatal UI)。**收敛**:M3 只做豁免,断开的用户可见信号 = xterm banner,不加标题后缀(避免渲染期需要宿主列表的耦合)。设计文档 §6 表相应行实施时修正为「banner 承担断开信号」。
**测试**(`tests/agent-terminal-reconcile.spec.ts` 追加):
- pinned agent tab 在 uuid 消失后保留;未 pin 的照旧移除;豁免不影响 toAdd 路径
**提交**:`feat: reconcile exempts pinned agent terminals`
## M4:PinnedRail 组件
**改动文件**:
- `src/client/PinnedRail.tsx`(新):props `{ entries: { tab, homeSessionId }[], currentSessionId, onFocus(homeSessionId, tabId), onUnpin(tabId), onClose(homeSessionId, tabId) }`;`[data-dsh-pinned-rail]` 锚点;pin 图标 + 类型图标 + tooltip;右键 Menu(portal);中键关闭(沿用 TabBar middlePressed 模式)
- `src/client/PinnedRail.module.css`(新):紧凑条样式,`--dsw-alias-*` 令牌,hairline 分隔,面板折叠时隐藏
- `src/client/Sidebar.tsx`:
- 渲染期 `collectPinnedTabs(store.getSessionStates(), { sessionId, cwd: summaryCwd })`(useMemo on snapshot + sessionList)
- `pinnedJumpRef`(沿用 subagentJumpRef 模式)+ effect:`sessionId === pending` 时 `reduceFor` 激活 tab(`activateTab` 命中 float 则 raiseFloat)+ 面板折叠则 `togglePanel`
- onClose:home 路径 `reduceFor(home, s => closeTab(...))` + `api.ptyClose({ sessionId: home, cwd: homeCwd }, tabId)` / `api.agentPtyClose(uuid)`;当前会话退化为现有 `actions.closeTab`
- rail 挂载点:右面板内、第一个 TabBar 之上;窄视口(合并抽屉)同样渲染
- `src/client/icons.tsx`:`IconPinOutline16`(若图标库无现成 pin)
**宿主会话切换链路(关键风险,先 PoC)**:rail 点击 → 宿主会话列表导航。调研 `ctx.sessions` 客户端 API 是否有主动切换方法(参考 subagent 跳转实际触发方式);若无公开 API,用 `location.hash` 导航(与宿主路由一致)。PoC 验证后再写组件。
**测试**(`tests/pinned-rail.spec.tsx` 新):
- 0 条目不渲染;≥1 渲染条目(图标/tooltip/标题)
- 点击非当前会话条目 → 记录 jump + 导航调用(stub);effect 命中后 reduceFor 激活
- 右键取消固定(onUnpin)、关闭(onClose 参数 home 路径);中键关闭
- 浮动 pinned tab 点击 → raiseFloat 分支
**提交**:`feat: pinned rail component + cross-session focus`
## M5:i18n + 文档 + 收尾
**i18n**(`src/client/locales.ts` + 19 个 `locales-*.ts`):
- 新 key:`pinTerminal` / `pinAgentTerminal` / `pinToWorkspace` / `pinToGlobal` / `unpinTerminal` / `pinnedTerminalTooltip`(插值:类型/范围/cwd)
- zh-CN 基准 + en 必写;ja 必同步(better-locale 契约);其余语言沿用既有同步惯例
- `tests/locales.spec.ts` 守护同步性
**文档**:
- `README.md` / `README_EN.md` 功能清单加「固定终端」
- `AGENTS.md` §3.4 内置 tab 表 terminal 行补 pin 说明
- 设计文档追加「实施偏差」节(如 M3 的断开信号收敛)
**回归门禁**:
```powershell
pnpm typecheck; pnpm test
# 重点回归:agent-terminal-reconcile / terminal-* / bottom-auto-terminal / tab-bar-* / state / prefs
```
**版本**:`package.json` minor bump(新功能);`src/client/service.ts` `features` 数组若对外暴露 pin 能力则加 `'pinnedTerminals'`(消费插件 gate 用)——本功能不开放服务 API,**不加**。
**提交**:`feat: pinned terminal i18n + docs` → PR `feat/pinned-terminal`
## 依赖与顺序
M1 →(M2 ∥ M3)→ M4 → M5。M2/M3 互不依赖可并行;M4 依赖 M1 的 pin 字段与 reducer;M5 最后。
## 风险登记
| 风险 | 缓解 | 状态 |
|---|---|---|
| 宿主会话切换无公开 API | M4 开工先做 PoC(读 subagent 跳转链路 + `ctx.sessions` 面);实在不行用 hash 导航 | 待验证 |
| rail 在窄视口/底面板合并态的布局 | 复用现有面板 CSS 变量;`tests/breakpoints*.spec.*` 观察 | 实施期 |
| bySession 未缓存会话的 pin 不可见 | 设计已接受(YAGNI 全量扫 localStorage) | 已确认 |
@@ -1,55 +0,0 @@
# 设计:工作区路径围栏开关(`workspaceFence` 设置 + 错误面一键关闭)
> 日期:2026-08-29 · 状态:已实现 · 仓库:omdsh-dev/DSH-better-sidebar
## 需求
侧栏所有文件系统路由(`fs.tree` / `fs.read` / `fs.write` / `/sidebar/file` 媒体 / `/sidebar/html` 预览 / `/sidebar/upload`)默认强制工作区包含检查:客户端路径经 realpath 解析后必须落在会话工作区内,否则 403 `path "..." is outside workspace`(`src/path-security.ts`,由 `tests/smoke.spec.ts` 的穿越/符号链接用例守护)。
两个真实痛点:
1. 打开工作区外的合法文件被拒——全局 `~/.dsh/AGENTS.md`、会话 cwd 之外的 linked worktree(GitView 为此隐藏了「在编辑器中打开」菜单项)。
2. 拒绝时直接暴露原始 wire 文本 `path "/tmp/xxx.sh" is outside workspace`,用户不知道原因也不知道怎么办。
方案(用户拍板):
1. 新增**持久化**内置布尔设置 `workspaceFence`(默认 `true`),挂在设置页「文件」卡片的「功能设置」二级弹窗(`settings.toggles`)。
2. 编辑器加载失败与文件树列目录失败两处错误面:命中围栏拒绝时不再显示原始文本,改为**本地化原因 + 「关闭工作区检测」按钮**——点击全局关闭并**自动重试**失败的操作。
## 关键决策
### 1. 全局开关而非 allowlist(取舍记录)
评估过「受控额外根 allowlist(如 `~/.dsh`)」方案:边界更细,但需要路径管理模式、UI 管理界面与 per-root 读写语义,超出本次需求。用户明确选择单一全局开关贯穿**全部**文件路由(树/读/写/媒体/HTML/上传一体放行),KISS 优先。
**安全代价已明示**:关闭期间,页面内任意同源脚本(包括第三方消费插件)都能通过 `/sidebar` 路由读写主机任意文件。默认 `true`、设置描述文案与接入指南 §8.1 均写明该风险。
### 2. host 侧读取:每次调用经 settings seam 同步读
`fenceEnabledOf(getSettings)`(仿 `shellOverridesOf`):`record.workspaceFence !== false`——**缺省即武装**(无 settings 服务 / 字段缺失 / 值非法一律 `true`,安全默认不依赖设置面可达性)。`ensureWorkspacePath` / `ensureWorkspaceWritePath` 增加可选 `fence = true` 参数:`false` 时跳过包含断言,**realpath 解析保留**(调用方拿到的 canonical 路径语义不变,符号链接仍被解析)。上传 `writeWorkspaceUpload` 以可选 `fence` 入参同语义透传。
### 3. 错误识别:wire 不变,客户端按消息片段匹配
不新增 wire 错误码:`/sidebar/api` 信封内 `code: 'forbidden'` 有两个来源——请求信任 fence(消息恒为 `forbidden`)与路径围栏(消息含 `outside workspace`)。客户端 `isOutsideWorkspaceError(e)` / `isOutsideWorkspaceMessage(msg)`(`src/client/api.ts`)以消息片段区分,既有 403 断言零改动。
### 4. 关闭按钮:写设置 → 更新 store → 自动重试
`FenceErrorNotice`(新组件):按钮点击 → `api.settingsUpdate({ workspaceFence: false })`(revision-free,同 `plugin-settings.ts` 队列的 last-write-wins 语义)→ `store.setPrefs(parsePrefs(view.value))`(GitView 的 open 预判、设置页等所有 prefs 读者随之翻转)→ `onDisabled()` 回调:
- EditorHost:`setReloadSeq(seq + 1)` 复用既有重载机制;
- FileTree:`retryDir(dir)` 删该层缓存后重载。
失败路径:保持按钮可用并 console 记录(无乐观翻转)。
### 5. 客户端预判同步放行
GitView 两处 `isWithinWorkspace` 预判(菜单项隐藏 :578、纵深防御 no-op :601)改为仅在 `workspaceFence !== false` 时限制,组件新增 `store` prop。`fs.search` 不动——它锚定 cwd 向下走,本来就不会越界。
## 实施清单
- 字段三处声明 + 解析:`prefs-shared.ts`(含默认 `true`)/ `config.ts` PrefsSchema / `client/prefs.ts` parsePrefs。
- host:`path-security.ts` fence 参数;`index.ts` `fenceEnabledOf` + 六个调用点(fs.tree/fs.read/fs.write 走 buildApi 的 `getSettings` 闭包,媒体/HTML/上传走模块级 `settingsFace`);`fs-operations.ts` 上传入参。
- client:`api.ts` 识别 helper;`FenceErrorNotice.tsx`;EditorHost 错误分支;FileTree `retryDir` + 错误分支(store 经 EditorHost → TreePanel → FileTree 穿透);GitView 预判放行;tabs.tsx editor 卡片 toggles 追加开关行。
- i18n:4 个 key(`settingsFenceTitle` / `settingsFenceDesc` / `fenceErrorReason` / `fenceDisableAction`)× 全部 21 个词典(zh/en/ja + 18 第三语言,`locales.spec` 强制 key 集相等)。
- 测试:smoke「disarms the workspace fence…」(先拒后放,覆盖 tree/read/write);prefs 解析专测(缺省/非法 → `true`,显式 `false` 透传);plugin-shape schema 默认;builtins editor toggles 清单。
- 文档:接入指南 §8 内置键清单 + 新增 §8.1(本特性对消费插件的可感知行为与风险)。
@@ -1,72 +0,0 @@
# 聊天文件打开拦截的 0.1.2-alpha 适配(openPath 漏斗迁移)
日期:2026-08-31 分支:`fix/openpath-intercept-alpha`
## 问题
0.1.2-alpha.x 宿主上,聊天里点击文件链接(工具行、产物行、正文行内代码路径)直接落到系统默认应用(Linux 即 `xdg-open`),「聊天区文件在侧边栏打开」(`interceptOpenPath`,默认开)形同虚设。
**根因**:alpha 架构把聊天打开文件的漏斗从客户端服务换成了远程调用——`ui-chat/src/client/apply.ts` 注入 ChatView 的 `openFile` 直连 `ctx.remote.session.openWorkspacePath({ path })` → 宿主 `session-controller` → `openNativePath`。插件的 `wrapOpenPath` 包的是旧漏斗 `ctx.workspaces.openPath`,而 alpha 的 `IWorkspaces`(`api/workspace-controller/src/client/service.ts`)**已删除 `openPath` 方法**——wrap 只是给 service 实例挂了个永无调用方的新属性,死代码。这是 #435/#472 适配的漏网项(挂载冒烟不点聊天文件链接,抓不到)。
**连带影响**:#158 的聊天路径/行号超链接(`owner.openFile` → openPath 假设)同样失效。(#171 的「以默认应用打开」旁路 `openPathWithSystem` 已随重构消失,现行走插件自有 `open.external` 路由,不受影响。)
**版本前提**:插件已进 alpha 通道(#472,peer `^0.1.2-alpha.2`,pre-alpha 兼容层已删)——本修复只面向 alpha 宿主,**整体替换**旧漏斗,不做双版本兼容。
## 新漏斗的事实(宿主源码核实)
1. **唯一生产调用方**:`ui-chat` apply 的 `openFile` 注入(工具行 / 产物行 / 正文提及 / 行内代码路径全部经 ChatView 的 `requestOpenFile` 汇入)。调用前路径已被 `resolveWorkspacePath(cwd, path)` 解析成绝对路径——与旧漏斗收到的形态一致。`canOpenWorkspacePath` 仅被 ui-deliverables 用于按钮可见性,无打开语义,不拦。
2. **`remote.session` 是 cordis 服务**(gateway client 的 `RemoteNamespaceService`,service key `remote.session`,经 `ctx.plugin` 注册):
- 方法以 `Object.defineProperty(service, method, { configurable: true, enumerable: true, get })` 安装,getter 在**访问时**读 `methods` 表返回调用闭包(因此直接赋值包装无效——无 setter);
- 命名空间整组方法与 service 构造在**同一同步窗口**安装——`ctx.inject(['remote.session'], cb)` 回调触发时方法必已就位;
- contribution 卸载/重挂载时服务 dispose/重建,inject 回调随之重跑——包装天然自愈。
3. 宿主端成功返回 `{ opened: true }`;失败抛 TypertRemoteFailure(ChatView 弹错误框)。接管方必须返回同形成功值。
## 方案:`wrapOpenWorkspacePath`(沿用「包单漏斗」模式,defineProperty 版)
改写 `src/client/openpath-intercept.ts`:
```ts
// 伪代码
const descriptor = Object.getOwnPropertyDescriptor(service, 'openWorkspacePath')
const original = service.openWorkspacePath // 访问 getter,捕获当前调用闭包
Object.defineProperty(service, 'openWorkspacePath', {
configurable: true, enumerable: true, writable: true,
value: (request: { path: string }, signal?: AbortSignal) => {
if (takeover()) { /* 接管 */ return Promise.resolve({ opened: true }) }
return original.call(service, request, signal) // 放行 = 宿主 native open
},
})
// dispose:descriptor 存在则原样 defineProperty 恢复,否则 delete —— HMR 安全
```
- **接管语义不变**(复用现有 deps):`!suspended && interceptOpenPath !== false && tabsEnabled.editor !== false && 有 current session`;`isFolderRevealPath` → `revealInExplorer`;其余 → `openInSidebar`(`:line` 拆分在既有链路内,#158 功能随之恢复)。
- **进入时机**:`registerOpenPathInterception` 改为 `ctx.inject(['remote.session'], ...)` 内包装;宿主裁掉该服务时回调永不触发 = 不拦截不报错。
- **已知取舍**:包装期间若 contribution 重挂方法记录,捕获的 `original` 闭包指向旧记录(getter 访问期绑定语义决定);session 命名空间实际常驻,可接受,代码注释记录。
## 改动清单
| 文件 | 变更 |
|------|------|
| `src/client/openpath-intercept.ts` | 重写为 `wrapOpenWorkspacePath`(defineProperty 包装/恢复);`isFolderRevealPath` 保留 |
| `src/client/intercept.tsx` | `registerOpenPathInterception` 改走 `ctx.inject(['remote.session'], …)`;deps 不变 |
| `src/context-types.ts` | `SidebarWorkspacesService{openPath}` 替换为 `SidebarRemoteSessionService{openWorkspacePath(request): Promise<{opened: boolean}>}` |
| `src/client/index.tsx` | inject 数组移除 `'workspaces'`(全仓唯一消费者是旧拦截,typecheck 验证后删) |
| `tests/openpath-intercept.spec.ts` | 重写:getter 形态假服务、接管/放行/恢复、inject 晚到场景 |
| `docs/external-plugin-guide.md` | §10 平台陷阱补「聊天文件打开拦截走 remote.session.openWorkspacePath」一条 |
| AGENTS.md §3 | 新增一条 alpha 宿主契约记录(对齐 #463 的做法) |
## 验证
- 单测:接管 / 放行 / 文件夹揭示手势三态;dispose 恢复原 descriptor;inject 延迟出现。
- 门禁:`pnpm typecheck` → `pnpm test` → `pnpm build` → `pnpm test:mount`(alpha.2 真机 14/14 不回归;mount lane 不驱动聊天点击,拦截正确性靠单测 + 手测)。
- 真机手测:让模型在回复里输出一个工作区文件路径(行内代码)→ 点击落侧边栏编辑器;关闭「聊天区文件在侧边栏打开」后点击 → 系统默认应用;`path:line` 链接 → 侧边栏跳行(#158 回归)。
## 实施偏差记录
- **接管返回值必须是 typert `RemoteResult` 包络**(初版实现踩坑修正):plan 中「接管返回 `{ opened: true }`」有误——gateway client 的每个 remote 方法都把业务值折叠成 `{ ok: true, value }` / `{ ok: false, error }`,ChatView 的 `openFile` 按 `result.ok` 分支、falsy 时读 `result.error.message`。初版返回裸业务值导致「文件已在侧边栏打开,但仍弹『无法打开文件: Cannot read properties of undefined (reading 'message')』」。修正为接管时返回 `{ ok: true, value: { opened: true } }`(`OpenWorkspacePathResult` 类型钉住,单测断言包络形状)。
## 不做
- pre-alpha 双版本兼容(alpha-only 通道,#472 已删兼容层)。
- `canOpenWorkspacePath` 包装(仅按钮可见性)。
- DSH 侧任何改动(仓库硬约束 §1)。
@@ -1,87 +0,0 @@
# 侧边栏性能优化 + 底栏拖拽布局突变修复
**日期**:2026-08-31(2026-09-01 rebase 到 main c199212 并在 alpha.3 上复测)
**状态**:已实施(分支 `feat/sidebar-perf`,单 PR 多 commit)
**目标宿主**:DSH v0.1.2-alpha.2 起开发,宿主参考 `/Users/menghuan/Code/deepseek-harness`(只读);main 已切 alpha.3(v0.18.1-alpha.0),rebase 后在 alpha.3 真机全量复测(§3.1)
**测量环境**:macOS arm64 本机,真实 `dsh web`(npx 钉版)+ scratch profile 挂载 tarball,Playwright Chromium 无头,每指标 3 次取中位数(`tests/e2e/perf.e2e.ts` 输出 `PERF_JSON`)。
## 1. 修复的 bug:底栏拖拽引发原生左侧边栏突变
**症状**:右侧面板关闭时拖动底栏 resizer,DSH 原生左侧边栏突然折叠/跳动;松手后弹回。插件自己的右侧边栏不受影响。
**根因**(源码级定位,`Sidebar.tsx`):底栏拖拽的 `applyDrag` 把「持久化的右栏宽度偏好」(如 400px)无条件写进 `--dsh-sidebar-width`,不看 `panelOpen`;而静止时的 push effect 走 `layoutPushSize` 门控(面板关闭 → 0)。于是拖拽首帧该变量 0 → ~400px:
1. `layout.css` 的 `#root { margin-right; width: calc(100% - …) }` 瞬间挤掉宿主 400px(拖拽期间 transition 被 `body[data-dsh-sidebar-dragging]` 关闭——瞬跳而非动画);
2. 宿主 `AppFrame` 的 ResizeObserver → `setViewport` → viewport 跌破 1024px 断点 → `computeColumns` 把原生左栏折叠成 56px rail(0.3s grid 轨道动画);
3. 松手 `commitDrag` → store 提交 → push effect 又写回 0 → `#root` 弹回 → 左栏再展开。
插件右栏在 `#root` 之外的 `document.body` fixed 层(`[data-dsh-panel-host]`),几何上碰不到——这正是「左栏突变、右栏无恙」的不对称来源。底栏是**唯一**在 `panelOpen === false` 时可达的拖拽(宽度/角点拖拽都要求面板开着),所以只有它触发。`abortDrag` 路径此前已修过(走 `layoutPushSize` 门控),move/up 路径漏网。
**修复**:`applyDrag` 里 `writeGeometry` 的 width 实参加上与 push effect 一致的门控(`!narrow && panelOpen ? min(width, vw) : 0`);面板 DOM 写入(`panelRef`/`bottomRef`/`lastDragSize`)保持原始 width(底栏 `right` 定位派生自它)。
**验证**:e2e 回归(`drag-layout.e2e.ts`)——右面板关闭 + 底栏打开拖 strip,逐帧断言 pushed width 恒 0、宿主 grid 模板 / 左栏渲染宽度 / `#root` 右边缘全程不变。旧代码上实测泄漏 504px(红),修复后通过(绿)。
## 2. 性能改动清单
| # | 改动 | 类别 | commit |
|---|---|---|---|
| 1 | boot 路径 `loadPrefs` + `loadExternalDisable` 合并为一次 `settingsGet`(原来同文档串行拉两次,第二个无超时——settings RPC 挂起则侧边栏永不出现) | 启动 | perf: cut the boot and poll overhead |
| 2 | GitLens 2s silent 轮询不再每 tick 重列 worktrees(每 tick 少 spawn 1 个 git 进程;每 15 tick ≈30s 才重列+自动选择一次) | 运行时开销 | 同上 |
| 3 | ChangesTab 的 `extractFileOps` fold 从「每 poll 两遍(badge 一遍 + render 一遍)」改为每 poll 一遍、结果进 ref 共享(4000 条事件 × 每条 JSON.parse) | CPU | 同上 |
| 4 | SideChat 转录行引用复用:`transcriptRows` 接受上次结果,位置对齐内容相同的行复用旧对象——2s 轮询只重渲染变化的尾部(原来全部行连同 markdown 重解析每轮重渲染) | 渲染 | perf: stabilize renders |
| 5 | FileTree `expanded`/`revealed` 数组 → Set(原来每行 `includes()` 线性扫描,整树 O(rows×expanded)) | 渲染 | 同上 |
| 6 | `buildNewTabOptions` 从两处 JSX 内联调用改为共享 memo(原来每次 render 两个新数组,击穿 LeafView 的 props 比较) | 渲染 | 同上 |
| 7 | DiffPane 高度拖拽、TerminalView ResizeObserver 走每帧批处理(原来每 pointer/resize 事件一次 setState / 一次含字形度量的 fit());SessionLens 的 `new Blob().size`(整段编辑内容的 UTF-8 编码)从每行每 render 移到每变化一次的 memo | 渲染 | 同上 |
| 8 | 19 个非 zh/en 字典移入 lazy `locale` chunk(`lib/client-locale.js`,仅安装 better-locale 时按需拉取;核心包只留 zh/en——`t()` 本来也只查 zh/en + override store) | 启动体积 | perf: move the 19 dictionaries |
| 9 | 底栏推挤锚点从 `:has(> [data-slot=conversation])` 换成 shell 定位器打的 `[data-dsh-center-col]` 标注(`:has()` 匹配缓存随流式聊天的 #root 子树变动不断失效,每次布局推挤帧全文档重估) | 拖拽帧成本 | perf: tag the center column |
| 10 | TextEditor 不再每键 `doc.toString()` 全文字符串化进 React state(O(docLength)/键);listener 只翻 dirty,进预览/换文件时从 live view 快照一次;保存本就现读 | 编辑 | 同上 |
## 3. Before / After 对比
测量说明:`mount-sweep` = 冷导航 → 插件挂载 → 展开面板 → 依次打开全部 6 个内置 tab;`bottom-drag` = 面板关闭 + 底栏打开时拖 12 步的 rAF 帧间隔。同机同夜连续跑,数字用于相对比较而非绝对承诺。
| 指标 | before(main d70db8f) | after(本分支) | 变化 |
|---|---|---|---|
| 挂载时延(navigationStart → host attach,中位) | 376.9 ms | 272.9 ms | **-28%** |
| 挂载+扫 tab 期间 longtask 数 / 总时长 / 最长 | 2 / 161 ms / 96 ms | 2 / 146 ms / 84 ms | 总时长 -9%,最长 -13% |
| 核心 client.js 体积(未压缩) | 1.46 MB | 0.80 MB | **-45%** |
| 宿主聚合 client bundle 传输量(含全部插件,gzip) | 1,436,478 B | 1,233,778 B | **-203 KB / -14%** |
| 宿主聚合 client bundle 解码体积 | 5,136,894 B | 4,497,363 B | -640 KB(即字典总量,吻合) |
| 侧栏 shape 的资源请求数 / 其中 settings.get | 20 / ×4 | 18 / ×2 | boot 合并生效 |
| 底栏拖拽帧间隔 p50 / p95 / max | 8 / 10 / 11 ms | 8 / 10 / 11 ms | 持平(本就不掉帧;修复的是布局突变) |
| 底栏拖拽 pushed width 泄漏(右面板关闭时) | **504 px**(bug) | **0 px** | 修复 |
### 结构性收益(不随单次测量波动)
- **核心 bundle**:19 个字典(源码 ~640KB)出核心包,`client.js` 1.46MB → 822KB(构建产物实测)。绝大多数会话(未装 better-locale)永远不拉 `client-locale.js`(gzip 171KB);装了的用户按需拉一次(HTTP 缓存 + ETag 复用)。
- **boot RPC**:settings 文档从 2 次串行 → 1 次;`loadExternalDisable` 有了 2s 超时兜底。
- **GitLens 轮询的 git 进程 spawn**:每分钟 60 个(2 个/tick × 30 tick)→ ~34 个(1 个/tick + 每 15 tick 多 1 个),-43%。
- **ChangesTab fold**:每 2.5s poll 的 fold 次数 2 → 1(每次 fold 对至多 4000 条事件逐条处理、tool/call 还要 `JSON.parse`)。
- **SideChat 转录**:内容未变的行不再重建对象 → 下游 React reconcile + markdown 解析 + DOMPurify 全部跳过(引用稳定性由 `sidechat-transcript.spec.ts` 契约钉住)。
- **TextEditor**:每次按键的 O(文档长度) 字符串化 + shell 重渲染 → 0(仅 dirty 布尔)。
### 3.1 alpha.3 复测(rebase 到 main c199212 之后)
main 在本分支开发期间合入了 alpha.3 适配(peer `^0.1.2-alpha.3`,v0.18.1-alpha.0)与 sidechat 渲染升级(新增 `turnSummary` 行 kind 与 tool `card`——`rowsEqual` 已补齐两者,card 只在 tool/result 落地时变化、被 `executing`/`failed`/`resultText` 的比较覆盖,无需值比较)。rebase 后在 alpha.3 真机重测(同法 3 次中位,before 侧 = main c199212 代码 + 本分支的测量工具):
| 指标 | before(main c199212) | after(本分支) | 变化 |
|---|---|---|---|
| 挂载时延(中位) | 372.3 ms | 316.5 ms | **-15%** |
| longtask 总时长 / 最长 | 170 / 100 ms | 165 / 96 ms | -3% / -4% |
| 宿主聚合 bundle 传输 | 1,456,668 B | 1,246,595 B | **-210 KB / -14%** |
| boot settings.get | ×4 | ×2 | 合并生效 |
| 底栏拖拽 pushed width 泄漏 | **504 px**(复现) | **0 px** | 修复 |
全部收益在新 main 基线上成立;完整 mount lane(17 测试)在 alpha.3 真机全绿。§3 主表为 alpha.2 上的原始测量记录(两端同宿主,对比自洽),保留作历史基线。
## 4. 守护
- e2e:`drag-layout.e2e.ts` 新增底栏回归(宿主布局全程静止)与 center-col 标注断言(恰一节点、为 conversation slot 的父级);`perf.e2e.ts` 的 width-leak 断言在每次测量 lane 里复检。
- 单测:`prefs.spec.ts`(boot 决策单次 fetch)、`sidechat-transcript.spec.ts`(行引用复用契约)、`locales.spec.ts`(chunk 内全部字典 key-set 与 zh 一致)、`layout-css.spec.ts`(锚点选择器契约,禁 `:has` 回潮)、`bundle-route.spec.ts`(chunk 清单含 locale)。
- 完整 mount lane(17 测试)在本分支全绿(alpha.2 与 alpha.3 真机各跑一轮)。
## 5. 明确不做(取舍)
- `pinnedEntries` 的 store 版本号改造、连续 `store.reduce` 合并:侵入 store API,收益不明确,KISS 取舍。
- FileTree / SideChat 列表虚拟化:Set 化 + 引用复用后若真实负载仍不达标再立项。
- `e2e-mount.sh` 的 `command -v "$DSH_CMD"` 对带空格命令失效(回退未钉版本的 npx)是**宿主侧脚本的既有行为**,本地以 `DSH_CMD=/tmp/dsh-alpha2.sh` 包装脚本绕过,不改脚本(CI 全局安装钉版本,不受影响)。
@@ -1,76 +0,0 @@
# 2026-09-03 全方位代码防腐 / 质量优化审查报告
> 三路探索子代理对 src / tests / scripts / CI / 文档做全方位摸底后,经用户批准按「P0 速修 + P1 行为不变去重 + P2 深度项全做」执行,共 10 个变更流、11 个 PR,全部走分支 + PR + 三 job CI 门禁合入。本文记录审查结论、落地清单、过程中的重要发现与遗留 backlog。
## 1. 审查结论总览
底子健康,无大面积腐化:
- 类型纪律严格:全仓 0 `as any`、0 `@ts-ignore`、0 TODO/FIXME。
- 模块边界零违规:懒加载 chunk 隔离、client externals 白名单、tsdown purity gate 全部干净。
- 错误 envelope 一致:宿主侧 `wire.ts` / 客户端侧 `api.ts` 双层归一。
- 测试规模:118 个 vitest spec(约 3300 断言)+ 6 条 Playwright e2e lane。
压力点集中在:少数巨型组件、若干字面重复、脚本间复制粘贴、以及缺 lint / task-runner 基础设施。
## 2. 已落地的变更流
| PR | 主题 | 要点 |
|---|---|---|
| #524 | feat/test-utils | 共享 `tests/test-utils.ts`(`setupReactAct` + `renderRoot`),迁移 28 个 spec 的重复样板;`chunk-artifact` / `manifest-consistency` 加 lib 缺失 skipIf 守卫 |
| #525 | feat/dedup-core | api envelope 解析、`extOf` 双实现、lineage-walk 四份、GitLens 错误归一化六处收敛;删零引用死代码 `sidechatSeed`;console 分级统一。测试零改动 |
| #526 | feat/dev-commands | Makefile(11 目标薄封装,`check` 聚合对齐 CI);package.json 补 `test:mount:aggregate` / `clean` / `packageManager: pnpm@11.8.0`;release.yml 补 consumer-types 并统一步骤序;.editorconfig;双语 README 补文档 |
| #527 | fix/scripts-hardening | aggregate 脚本 tarball 改 mtime 选取(修真 bug);DSH_CMD 默认值统一;package-registry.mjs 补 mermaid chunk;抽 `scripts/e2e-common.sh` 消除两 e2e 脚本约 40% 重复(函数级等价性探针 + 双 lane 真机验证) |
| #534 | feat/palette-single-source | One Dark/Light 语法色板单源化(`one-dark-palette.ts`),迁移前后映射机器证明逐字节相等 |
| #535 | feat/i18n-catalog-names | 目录项 name / shell 预设 desc 词典化(20 key × 21 词典文件,`string | (() => string)` 镜像 desc 模式);兜底错误条改 `var(--dsw-alias-*, 原hex)` 皮肤令牌链 |
| #536 | feat/eslint | ESLint 10 flat config(typescript-eslint recommended + 经典 react-hooks 规则),存量 141 错误/21 警告清零(含 24 处真实死代码删除、`_` 前缀 mock 约定入 config);`pnpm lint` / `make lint` / CI 三 job 全部接入;`make check` 同步含 lint |
| #540 | feat/drop-rc7-fallback | 删 rc.7 宿主 `window.__DSH_MODULES__` 回退(peer 基线 rc.1 下不可达);测试 fixture 切换到与生产同路径;附带修复 main 上 #535/#536 语义合并冲突导致的 add-plugin-modal spec 断裂 |
| #541 | feat/unify-polling | `use-polling.ts` 原语(83 行)统一四处轮询习语(SubagentView 自排程 3s / SideChatView fixed 2s / GitLens fixed 2s / ChangesTab fixed 2.5s),守护 spec 断言零改动 |
| #542 | feat/split-sidebar | Sidebar.tsx 1831 → 1176 行,抽 `src/client/sidebar/` 五模块(TabContent 74 / use-center-column 171 / use-host-feeds 269 / use-pinned-tabs 153 / free-windows 167);面板拖拽机约 330 行因 `sidebar-layout-push.spec` 的源码文本计数断言钉住而有意保留主文件 |
过程中同步发生的外部事件:DSH 0.1.2-rc.1 适配(PR #537)合入后,Wave 2 三个 PR 全部 rebase 到新 main 并重跑门禁再合并。
## 3. 过程中的重要发现(记录在案)
### 3.1 Windows agent-pty PTY 超时 flaky(既有,非本轮引入)
`tests/agent-pty.spec.ts` 的真实 PTY spawn 用例在 ci-windows 上间歇性 5s 超时。证据链:main@`ceed6b2`(Wave 1 首个合并的 push run)挂 agent-pty ×4 + git.spec ×1;#535、#536、#541 的 ci-windows 各挂过 1-2 个同类用例,**重跑均绿**;失败组合在多次运行间漂移。建议后续对该 describe 块在 win32 上提升 testTimeout 或加有界重试。
### 3.2 desktop-layout.e2e.ts 间歇 flaky(上游时序敏感)
该用例测「右面板保持桌面 session 头部位置」,区域与上游适配提交 `ab6f960`(toggle cluster 重定位动画)重叠。本机(钉版 rc.1 CLI)稳定因宿主 onboarding 浮层 / workspace 选择对话框不可达而失败,且 main 原样产物 A/B 对照同样失败;CI 上 aa01140 的 push run 也挂过一次(同内容分支 run 与后续 main 验证 run 均绿)。属宿主时序敏感的既有 flaky,建议上游动画稳定后加等待收敛。
### 3.3 跨分支语义合并冲突的流程教训
#535 新增了 `builtinTabPlugins` 的使用(依赖既有 import),#536 的 eslint 清理在其各自基线上把当时未使用的同名 import 删除——两个各自全绿的 PR 文本合并成功但语义断裂(typecheck TS2304 + spec 加载即挂),且被并发 run 取消掩盖后带病合入 main,由 Wave 3 两个子代理独立发现并热修(#540 `8820efd`)。教训:**并行分支触碰同一文件时,每合入一个后其余分支需重验(rebase + typecheck/test),不能只信各自 PR 的绿勾**;unused-import 类清理与功能分支并行时尤其如此。
### 3.4 GitHub push run 丢触发一例
#541 合并产生的 main push run(`68839aa`)从未被创建(workflows 均 active,按 SHA 查询 0 runs),经 `workflow_dispatch` 手动补跑三 job 全绿。原因不明,遇到「merge 后 main 无 push run」时可按此法补验。
## 4. 基础设施变化速查
- **Makefile**:`help`(默认目标)/ `install` / `build` / `typecheck` / `lint` / `test` / `check`(typecheck+build+test+consumer-types+lint 聚合)/ `clean` / `pack` / `mount` / `mount-aggregate` / `registry`。package.json scripts 仍是唯一事实源,Makefile 只做薄封装与依赖链。
- **Lint**:`pnpm lint` = `eslint .`(flat config,`reportUnusedDisableDirectives: error`);CI 三处(ci / ci-windows / release)在 Typecheck 后执行。
- **本地工具钉版**:`packageManager: pnpm@11.8.0`(CI workflow 的显式 `version:` 输入已移除,统一由该字段供版)。
- **e2e 本地跑法**:`make mount` / `make mount-aggregate`;本机无 rc.1 CLI 时 `npm install --prefix /tmp/dsh-rc1-bin @deepseek-ai/dsh@0.1.2-rc.1` 后置 PATH。
## 5. Backlog(本次仅记录)
1. mount.e2e 的固定 `waitForTimeout` 与英文文案选择器(`[title="Files"]` 等)——宿主文案/语言变更即碎。
2. CI 无 Playwright 浏览器缓存(每 run 重装 Chromium)。
3. `state.spec`(1498 行)/ `smoke.spec`(1208)/ `service.spec`(1124)三个千行 spec 拆分。
4. FreeWindow / TreePanel / split-pane / DiffPane / DiffRows 等组件缺直接渲染 spec(现靠 state 纯函数 spec + e2e 间接守护)。
5. coverage 工具引入评估(当前零覆盖率度量)。
6. CHANGELOG / CONTRIBUTING 文件(README「🆕 最近更新」小节手工充当 changelog)。
7. Sidebar.tsx 剩余拖拽机(~330 行)拆分——前置条件是先重构 `sidebar-layout-push.spec.tsx` 的源码文本计数断言为行为断言。
8. `JobOutputPane` 的 2s 轮询未迁移 `use-polling`(独立形状,见 #541 PR body)。
9. install.sh / install.ps1 / check-consumer-types.sh 内部的 say/warn/die 重复——install.sh 必须自包含随 npm 包分发,属有意设计,除非改分发方式否则不动。
10. §3.1 / §3.2 两个 flaky 的根治。
## 6. 验证基线(全部 PR 合并后于 main 复核)
- `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test`:119 files / 1243 passed / 9 skipped。
- `pnpm check:consumer-types` 通过。
- 真机挂载 lane:mount 5/5、drag-layout 7/7、float-window 2/2、toggle-layout 1/1、perf 2/2(desktop-layout 见 §3.2)。
- aggregate 双挂载回归 lane 通过。
@@ -1,62 +0,0 @@
# 文件树重命名 / 删除设计(2026-09-04)
> 状态:已实现。本文档记录功能设计、关键取舍与已知限制。
## 背景
文件树右键菜单此前只有打开/复制/上传下载等只读+导出操作,缺少资源管理器最基本的两个变更操作。本次补全:
- **重命名**:行内编辑(VS Code/Finder 惯例,用户已确认)——菜单点「重命名」后行内名字变输入框(预选中、Enter/失焦提交、Esc 取消)。
- **删除**:弹窗确认(用户指定)——复用 git lens 的 Cancel/Confirm Modal 范式,文件/目录文案区分,确认后永久删除。
同批修复二级菜单的视觉关系(见下节)。
## 服务端
- `src/fs-operations.ts` 新增 `renameWorkspaceEntry` / `removeWorkspaceEntry`(与 `writeWorkspaceUpload` 同层):
- **链路感知(link-aware)**:存在性与围栏校验对**完全解析后的目标**进行(fence 开启时指向工作区外的 symlink 一律拒绝),但操作本身作用于**词法行路径**——重命名/删除一个 symlink 行只改/删**链接本身**,绝不动目标(lstat 判定,link-to-dir 不会递归进目标)。与 VS Code 语义一致:行显示什么就操作什么。
- `renameWorkspaceEntry`:`name` 必须单段(非空、非 `.`/`..`、无分隔符——重命名不是移动);目标已存在直接 409 拒绝(POSIX rename 会静默覆盖);工作区根本身不可重命名;同名 no-op 直接成功(不触发 409)。返回**规范路径**(realpath 解析后的祖先链)供 tab 改址。
- `removeWorkspaceEntry`:目录递归删除、文件/symlink 解链;根拒绝。无回收站(宿主无此 API;等同 git lens discard 的永久语义)。
- `src/index.ts` 路由 `'fs.rename'`(`{path, name}`)/ `'fs.remove'`(`{path}`),走 `cwdOf` + `fenceEnabledOf(getSettings)`,与 `fs.write` 同一围栏开关。
- `src/client/api.ts`:`fsRename` / `fsRemove`。
## 客户端(FileTree)
- 菜单尾部(复制项之后):`separator + 重命名(IconEditOutline16) + 删除(IconTrashOutline16, danger)`;**根行**(`rowMenu.path === cwd`)不渲染这两项(服务端双保险)。
- 行内编辑:命中的行换渲染为编辑行(同缩进/图标/行高),**无 role="button"**(编辑器不是点击目标,也避免 button 嵌套 interactive 的非法结构)。裸 `<input>`(primitives `Input` 不转发 ref,而 focus+select 正是要点);ref 回调 `useCallback([])` 稳定身份——内联箭头每渲染重跑会把正在输入的缓冲区重新全选。Enter/失焦提交、Esc 取消;`isImeComposition` 守卫(IME 组合期的 Enter/Esc 属于输入法,不提交不取消——顺手把该守卫的入参 `isComposing` 放宽为可选,React 合成事件类型未声明该字段)。
- 删除确认:primitives `Modal`(git lens 同款 outline Cancel + primary Confirm)。
- 失败面:树顶一条可点消失的错误条(`role="alert"`),展示服务端原文(与 FenceErrorNotice 同策略);客户端单段校验失败也走这里(`renameInvalid`)。
- **落定收尾 `pruneTree(prefix)`**:清掉旧路径前缀的所有缓存层 → `onToggle` 收起 stale 的 expanded 项 → `retryDir(parent)` 单层重载(无需整树 refresh tick)。重命名已展开目录后新目录折叠展示(expanded 不迁移,VS Code 同样)。
## tab 调解(tree-mutations.ts + EditorHost)
- `retargetPathTabs(ctx, store, old, new)`:`tab.path === old` → `service.updateTab(id, {path: new, title: baseName(new)})`(编辑内容存活,后续保存落到新路径)。
- `closePathTabs(ctx, store, target)`:`tab.path === target || isWithinWorkspace(target, tab.path)` → `service.closeTab(id)`(目录连整个子树的打开文件一起关)。
- tab 枚举覆盖 `state.splits` / `state.bottomSplits` / `state.floats`(浮动窗口的 tab 同样算打开)。走 service 路径(而非直改 state)使注册的生命周期回调照常触发。
- FileTree/TreePanel 透传 `onPathRenamed?` / `onPathDeleted?`(可选,测试可不接线);EditorHost 两处 `<TreePanel>` 接线。
## 二级菜单视觉关系(layout.css,同批)
针对「二级菜单没有视觉关系」的反馈,两条纯 CSS 规则(仍以 `data-dsh-sidebar-submenu` body 属性限定作用域,宿主默认不受影响):
1. **父行持续高亮**:宿主 Menu 在子菜单打开期间给父行按钮标 `aria-expanded="true"`(宿主已渲染、稳定),用同一 hover token 重新着色——指针移入侧卡后父行不再熄灭。
2. **卡片贴近**:子菜单与父卡间距 10px → 4px,`::before` 悬停走廊同步收窄;翻转规则(left/down)的间距一并 4px。规则排序:tether 基础规则在前、翻转覆盖在后(同特异性下源序决胜)。
`tests/menu-flip.spec.tsx` 的 layout.css 契约断言扩展覆盖这三条规则。
## i18n
新增 7 key(rename / renameInvalid / delete / deleteTitle / deleteDescFile / deleteDescDir / dismiss),zh/en/ja 手写;其余 18 个已发第三语言按 `locales.spec.ts` 的 key-set-parity 门禁同步补齐。
## 测试
- `tests/fs-operations.spec.ts`:rename(文件/目录/no-op 同名/单段违规/目标已存在 409/根拒绝/缺源/symlink 行改链接不改目标)、remove(文件/目录递归/symlink 只解链目标存活/根拒绝与缺路径)。
- `tests/file-tree-rename-delete.spec.tsx`(jsdom,mock api):菜单项出现条件(文件/目录有、根无);重命名流(预填→Enter 提交→`fsRename`+`onPathRenamed`、Esc 取消、非法名走错误条);删除流(弹窗文案文件/目录区分、确认→`fsRemove`+`onPathDeleted`、取消、服务端失败进错误条)。
- 真机挂载冒烟(`pnpm test:mount`)不触达这些交互,由 CI 门禁与手动验收覆盖。
## 已知限制
- 无回收站:删除即永久(fence 开关不改变这一点,只改变越界拒绝)。
- 重命名不迁移 expanded/revealed 状态:改名已展开目录后新目录折叠、reveal 高亮不跟随(一次性视觉状态,成本/收益不划算)。
- 并发窗口竞争:存在性检查与 rename/remove 之间有微小 TOCTOU 窗口(单用户侧边栏场景可忽略;服务端错误会进错误条)。
- 「在其他编辑器里改名后本树 stale」既有行为不变(refresh tick / focus 刷新机制管)。
@@ -1,61 +0,0 @@
# 变更页操作预览增强设计(2026-09-06)
> 状态:已实现(PR #499)。本文档记录六个特性的设计、关键取舍与已知限制,含 2026-09-06 规范化审查后的整改记录。特性移植自 dsh-file-trace v0.2.0/v0.2.1 会话(PDF 渲染为作者后续追加)。
## 背景
变更页(Changes tab)底部预览面板此前只渲染统一 diff / 逐行读取 / 错误文本。本批扩展为:markdown 文档可切「阅读模式」、mermaid 围栏随阅读模式渲染、预览载荷做「敏感内容脱敏」、`.html` / `.pdf` 操作可切「渲染模式」,并把 diff 引擎的语法着色表扩到此前无色/错配的一批语言。全部特性共用同一预览面板与头部 toggle 交互。
## 1. Markdown 阅读模式
- 适用:`.md / .markdown / .mdx` 的 read / write / edit 操作(非错误)→ 头部「阅读 / 原文」toggle。
- 渲染走共享 `MarkdownText`(与编辑器预览、Side Chat 同一栈,labels 契约经 `markdownTextProps()`,见 AGENTS §3.3);本地图片目的地经 `rewriteLocalImageUrls` 改写到会话域 `/sidebar/file` 媒体路由。
- 内容来源:read → `parseReadContent()`(见下);write → 写入内容;edit → 已知 prior 时 `prior.replace(oldString, newString)` 重建结果文档,否则仅展示 newString(设计上「prior 已知才重建」)。
- `parseReadContent`(`src/client/changes/ops.ts`):与 `parseReadLines` 共享 `contentLines()` envelope 核心(`<content>` 提取 + "(Showing lines …)" 过滤);区别在前者剥 `<n>: ` 前缀后**保留空行**(markdown 块结构依赖空行)并修剪首尾空行(渲染不可见),后者解析行号、丢弃空行。
## 2. Mermaid 围栏渲染
- 阅读模式检测到 ```mermaid 围栏时,走编辑器同款懒加载渲染器(`LazyMermaidMarkdown`,mermaid chunk 经 `/sidebar/bundle` 按需下发);无围栏文档保持单次 MarkdownText 直渲染(字节不变)。
- `LazyMermaidMarkdown` 的定义从 `MarkdownHtml.tsx` 移到轻量模块 `src/client/mermaid-lazy.tsx`:变更页(核心 bundle)要引用这个 stub 而不把 `MarkdownHtml` 的 DOMPurify/HTML 分析机械拖进核心 bundle;`MarkdownHtml.tsx` 改为 re-export。
- 已知边界:无引号节点标签内含被遮密钥(`[REDACTED]` 字面)时 mermaid 解析失败,回落到「错误条 + 已遮源码」——该回落是 `mermaid.tsx` 既有机制(catch → 错误 + 源码),本批未新增代码,亦未为该路径加测试(README 已注明规避方式:标签加引号)。
## 3. 语法着色扩展(`src/client/diff/highlight.ts`)
- `mjs/cjs → JS_WORDS`、`mts/cts → TS_WORDS`(此前无映射 → 纯文本);`tsx/jsx` 同用共享词表(规范化整改:原先保留内容相同的内联副本)。
- `css/scss/less → CSS_LANG`:slash-star 块注释跨行、连字符属性词(`background-color` 整词着色)、字符串、十六进制数;此前误用 `#` 行注释配置导致不着色。
- `html/htm/xml/svg/vue → MARKUP_LANG`:`<!-- -->` 注释 + 常见标签/属性;`graphql/gql`、`jsonc/json5` 映射。
- 着色是启发式(词表驱动单遍扫描);CSS/MARKUP 词表混排选择器/属性/标签是刻意为之——`LangConfig` 只有一个 keywords 集,再分类属过度设计。
## 4. 敏感内容脱敏(`src/client/redact.ts`)
- 动机:追踪器把 read/write 工具结果重放进 DOM;模型读取凭据文件时明文不应上屏。**仅显示层**:会话日志与 fs 层字节不动;不是对同上下文恶意插件的安全边界。
- 两层启发式:
1. **路径层**:文件名含凭据形态(`.env`、`secret(s)`、`credential(s)`、`token`、`api-key`、`password(s)`、私钥/证书扩展名…)→ 整文件逐行 `[REDACTED]`。匹配带**右边界**(模式后不得继 `[a-z0-9]`),且复数只收「密钥仓库」义(credentials/secrets/passwords),不收 `tokens`(api-tokens.md 通常是文档)——避免 `tokenizer.ts` / `dev.environment.ts` / `style.keys.ts` / `api-tokens.md` 被整文件误伤。
2. **内容层**:普通文件按行遮值——赋值形(`api_key:` / `TOKEN=` / `"password":`,**字段名保留**)、`Bearer` 头、`sk-`/`AKIA`/`ghp_`/`gho_`/`xox*`/`ya29.` 前缀 token、PEM 私钥块头。字段表**不含裸词 `key`/`auth`/`token`/`pass`**(规范化整改:这些词标注的普通内容远多于密钥——键盘键名、CSS 自定义属性、词法 token;Bearer 场景由 `BEARER_RE` 单独覆盖)。
- 开关:默认开,头部一键切换,localStorage 记忆(key `dsh-sidebar:v1:redaction`,仓库既有前缀)。所有载荷消费方(diff 行、读取行、阅读模式源、错误文本、prior)统一从**遮蔽后**形状渲染;命中时头部显示「已脱敏」条。
## 5. HTML 渲染模式
- 适用:`.html / .htm` 操作(与编辑器 html viewer 的扩展集对齐;规范化整改:去掉越权的 `.xhtml`)→ 头部「渲染 / 原文」toggle。
- 实现:`HtmlRenderPreview`(`DiffPane.tsx` 导出,供沙箱 spec 钉契约)以 route-src iframe 加载 `/sidebar/html`——相对资源(`./style.css`、`img/x.png`)在路由内解析;分段读取也能渲染完整文档。
- **语义边界(刻意)**:路由服务的是**盘上已保存文件**而非操作快照——失败/未落盘的写入会渲染旧内容(README 已声明)。共享常量 `HTML_IFRAME_SANDBOX` 提到 `src/client/html-preview.ts`(编辑器与变更页共用);此面**恒定沙箱**(opaque origin + CSP 头),编辑器 tab 才持有带警告的去沙箱逃生门。
## 6. PDF 渲染模式(作者 2026-09-06 追加)
- 适用:`.pdf` 操作(非错误)→ 头部「渲染 / 原文」toggle,复用编辑器 `PdfView`(媒体路由字节流 + 显式类型 Blob → 浏览器原生 PDF 查看器内嵌,附下载入口)。规范化整改:扩展名正则补 `\.` 转义(原 `/.pdf$/i` 会误命中任何以 pdf 结尾的路径)、toggle 并入 `PaneToggle`。无独立单测(PdfView 行为由编辑器侧既有测试覆盖)。
## 规范化整改(2026-09-06 审查后)
- **皮肤契约**:`.htmlFrame` 背景硬编码 `#fff` → `var(--dsw-alias-bg-base)`(对齐 `.editorHtml`);`.redactBanner` 原用的 `--dsw-alias-state-warning-primary` 在 DSW 词表中**不存在**(实际为 `state-warn-*`),var() 恒回落到硬编码 `#b8860b` → 改用真实令牌 `var(--dsw-alias-state-warn-primary)`。
- **约定对齐**:localStorage key 并入 `dsh-sidebar:v1:` 前缀;`tests/redact.spec.ts` 源码 import 由 `.js` 改 `.ts`(仓库惯例);DiffPane 三份同形 toggle 按钮提取 `PaneToggle`,阅读视图提取 `MdReadingView`(吸收 mermaid 分支与 codeLabels 计算);遮蔽 useMemo 单遍化(每字段一次 `redactText`,此前 hit 扫描 + mask 双遍)。
- **去重**:`tsx/jsx` 共享 `TS_WORDS`/`JS_WORDS`;`parseReadContent`/`parseReadLines` 共享 `contentLines()`;CSS 词表去重(`hidden`/`inherit` 重复项)并去掉 CSS 没有的 `true false` 常量。
## 已知限制
- 脱敏是启发式:右边界规则放行 `mytoken.yaml`(命中遮蔽)而拒绝 `api-tokens.md`(文档);字段级遮蔽不覆盖未知命名的密钥字段——按 token 形态(`sk-` 等前缀)兜底。一键关闭可在受信任环境看原文。
- HTML 渲染读盘上文件(见 §5);mermaid 无引号标签含遮蔽密钥时回落源码(见 §2)。
- 阅读模式 edit 重建依赖窗口内已知 prior;窗口外旧内容不可知时仅展示新串(面板本就以「已知内容」为限)。
## 测试
`tests/redact.spec.ts`(两层遮蔽 + 边界/裸词误伤回归)、`tests/diff-highlight.spec.ts`(后缀映射、CSS 注释穿行/属性、markup 注释/标签、mjs 关键字)、`tests/changes-ops.spec.ts`(`parseReadContent` 空行保留)、`tests/changes-tab.spec.tsx`(阅读模式端到端:h1/GFM 表格/本地图片改写/切回原文)、`tests/sandbox-views.spec.tsx`(HTML 渲染 iframe 恒定沙箱 + toggle 出现条件)。
@@ -1,217 +0,0 @@
# Git 折叠行真实展开——修复「点击展开」无反应
> 日期:2026-09-07 · 状态:计划已定,待实施 · 分支(拟):`fix/diff-fold-expand`
> 关联:上游 issue(待提)、上游 PR(待提,Closes issue)· 上游仓库 `omdsh-dev/DSH-better-sidebar`,本地 fork `LaoYueHanNi/DSH-better-sidebar`(main `6fbfeed` 与上游零差异)
## 1. 背景与 bug 确认(已核实)
**现象**:changes 组件点击 git 提交(或工作区 diff)后,diff 视图中 hunk 之间的折叠行显示「n 行…点击展开」,点击无反应(无 pointer/hover)。session(会话文件操作)lens 的折叠行可正常展开——仅 git lens 永远不可展开。
**证据链**:
- `src/client/diff/rows.ts` L407-433(`unifiedSegments`):git `-U3` 不输出 hunk 之间的未改动行,gap fold 段**只有行号范围、没有 `rows` 字段**(注释明确 "rows are unknown (non-expandable)");
- `src/client/diff/DiffRows.tsx` L193-228:`expandable = segment.rows !== undefined && segment.count >= MIN_FOLD`;`rows === undefined` 走静态分支(L197-202,**无 onClick、无 data-expandable**),但文案复用 `t('changesFold')` = zh「{count} 行…点击展开」/ en「{count} lines…click to expand」——承诺了不存在的交互;
- `src/client/diff/diff.module.css` L50-51:仅 `[data-expandable='true']` 有 pointer/hover,静态分支视觉上也不可点,唯独文字撒谎;
- session 路径(`diffLines` + `buildDiffSegments` → `foldOf`)的 fold 总有 `rows`,可正常 toggle 展开。
**上游调研结论**(2026-09-07,9 组中英文关键词 + PR 列表 + `git fetch` 源码比对):无 issue 报告过此问题(最接近的 #346 是「文件级默认折叠要多点一下」,方向相反),无已合并/进行中的修复——**全新发现**。
**已排除的轻量方案**:
- 纯改文案(原方案 A):不解决「看不到被折叠内容」的本质,用户拍板不做;
- 调大 `-U` 上下文:大 gap 依旧折叠且依旧不可展开,治标且 diff 变吵。
## 2. 目标与成功标准
点击 git 折叠行时**按需拉取两侧完整内容、按行号切片填充真实上下文行**,让「点击展开」名副其实。
1. git 提交/工作区 diff 折叠行点击后展开未改动上下文行(带双侧行号),再点可折叠;
2. session lens 行为完全不变;二进制/untracked 文件不受影响;
3. 内容拉取失败降级为无点击暗示的中性文案;
4. `pnpm typecheck` / `pnpm test` 全绿,CI 三 job(挂载冒烟/typecheck/test)绿;
5. 上游 issue(问题+预计方案)与 PR(实际修复,`Closes #N`)按仓库规范挂接。
## 3. 已确认的实现地基
- **服务端 `git.show` 路由已存在**(`src/index.ts` L474-480):payload `{rev, path}`(rev 可为 `:0` / `HEAD` / `<hash>^` / `<hashFull>`),返回 `{content: string | null}`,失败(null)内建于 `git.ts show()` 的 try/catch → **服务端零改动**;
- `src/git.ts` `diff()`(L382-388):unstaged = `git diff -- path`(**index vs worktree**),staged = `git diff --cached`(**HEAD vs index**)——决定展开时两侧 rev 取法;
- 渲染链:`DiffPane`(底部预览)/ `DiffTab`(独立 tab)→ `DiffFiles`(per-file `unifiedSegments`)→ `DiffRows`;op 路径直接 `DiffRows`(`buildDiffSegments`)。客户端 `api.ts` 尚无 `git.show` 封装(需补一行 call);
- `SidebarDiffRef`(`src/client/state.ts` L24-26):`worktree{path, staged, untracked?, worktree?, repoRoot?}` | `commit{hash, hashFull, subject, worktree?, repoRoot?}`;
- spawn 数组参数不经 shell,`<hash>^` 的 `^` 无需转义;`git show :0:<path>` 取 index 版本合法。
## 4. 实现设计(按子系统)
### 4.1 `src/client/api.ts` — gitShow 客户端封装
仿 `gitCommitDiff`(L317-318):
```ts
gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal)
```
走既有 RPC 通道,不触构建纯度门。
### 4.2 `src/client/diff/rows.ts` — 切片纯函数
```ts
export function foldRowsFromContents(fold: FoldSegment, oldContent: string, newContent: string): readonly DiffRow[]
```
- 以 old 侧驱动 `[oldStart..oldEnd]` 逐行生成 `{kind:'context', oldLine, newLine?, text}`,`newLine = oldLine - oldStart + newStart`(仅当落在 `[newStart..newEnd]` 内才带);再以 new 侧驱动补纯新增段(old 映射不到的行,只带 `newLine`);
- 内容 `split('\n')` 切;空内容 = 空数组;行尾 `\r` 保留(与 git diff ctx 行行为一致)。
### 4.3 `src/client/diff/DiffRows.tsx` — 异步展开状态机
- 新 prop:`resolveFold?: (segment: FoldSegment) => Promise<readonly DiffRow[]>`(不传 = 现状,op 路径不变);
- 新 state:`foldData: Map<number, readonly DiffRow[]>`、`foldLoading: Set<number>`、`foldFailed: Set<number>`,随现有 useEffect(L119-123)在 segments 变化时整体重置;
- expandable 判定放宽:`(segment.rows !== undefined || resolveFold !== undefined) && segment.count >= MIN_FOLD`;
- 点击未填充的 git fold:置 loading → `resolveFold(segment)` → 成功存 `foldData`(渲染优先于 `segment.rows`)、失败标 `foldFailed`;
- 渲染三态:加载中 `t('changesFoldLoading')`;失败 `t('changesFoldUnavailable')`(无 onClick/pointer);正常沿用 `changesFold`(此时承诺为真);已展开的 fold 渲染 rows,再点折叠回。
### 4.4 `src/client/diff/DiffFiles.tsx` — loader 下传 + Promise 缓存
- 新可选 prop `resolveFold?: (file: DiffFile, segment: FoldSegment) => Promise<readonly DiffRow[]>`,转发 `DiffRows`;
- 实例级 `useRef<Map<string, Promise<readonly DiffRow[]>>>`,key = `displayPath(newPath)|oldStart|newStart`,同文件多 fold 共享一次内容拉取、并发点击天然去重;`parsed` 变化时重置。
### 4.5 `src/client/changes/DiffPane.tsx` / `src/client/DiffTab.tsx` — lens loader
按 `SidebarDiffRef` 构造传给 `DiffFiles`:
- **commit lens**:旧侧 `gitShow(scope, '<hashFull>^', displayPath(file.oldPath))`(root commit → null → ''),新侧 `gitShow(scope, hashFull, displayPath(file.newPath))`;与 `commitDiff` 的 `-m --first-parent` 语义一致;rename 由 `oldPath/newPath` 天然覆盖;
- **worktree lens**:`staged=true` → 旧 `HEAD` / 新 `:0`;`staged=false` → 旧 `:0` / 新侧 `api.fsRead` 工作区(`resolveSidebarPath(repoRoot ?? worktree ?? cwd, path)`,同现有 untracked fallback L196/L68 的写法);
- **staged 侧漂移修正(关键边缘)**:DiffPane L182-188 / DiffTab L49-57 在请求侧 diff 为空时 fallback 渲染另一侧,但 `gitRef.staged` 不变——把实际生效侧记为 state(如 `effectiveStaged`),loader 依它取数,否则切片行号错位;
- untracked 文件走 `untrackedFile` 全加渲染,无 fold,loader 不涉及。
### 4.6 i18n — 两个新 key,18 个语言文件全补
- `changesFoldLoading`:zh `展开中…` / en `Expanding…`
- `changesFoldUnavailable`:zh `上下文未加载` / en `Context unavailable`
- `locales.ts` 为基准;`locales-ja.ts` 同步是 AGENTS 硬性底线;其余 15 个 `locales-*.ts` 照 `changesFold` 现状全量补译;纯 `t()` 通道,不涉 `markdownTextProps()`。
### 4.7 测试(vitest,沿用现有模式)
- `tests/diff-rows.spec.ts` 增补 `foldRowsFromContents`:对称区间 / 纯删除 gap(old 有 new 无)/ 纯新增 gap / `\r\n` 行尾 / 空内容 / 行号越界截断;
- 新组件测试(参照 `tests/diff-files-collapse.spec.tsx`):mock `api.gitShow` → 点击 git fold 断言展开行渲染、再点折叠、失败降级文案、session fold 路径不受影响;
- 回归:`diff-rows` / `diff-files-collapse` / `diff-highlight` / `builtins` 全绿。
## 5. 边缘 case 清单(实现时逐条对照)
| 场景 | 处置 |
|---|---|
| root commit 无父 | `git.show` 返 null → 空串(旧侧视为空) |
| rename | `parseUnifiedDiff` 保留 oldPath/newPath,两侧各取各的 |
| 二进制文件 | 无 hunks,不渲染 DiffRows,不涉及 |
| untracked | `untrackedFile` 全加渲染,无 fold |
| 无换行文件 | `count` 与切片行数可差 1,展开后以实际行为准 |
| `\r\n` 行尾 | blob 行与 diff ctx 行同样保留 `\r`,一致 |
| staged fallback 侧漂移 | `effectiveStaged` state(§4.5) |
| 并发/重复点击 | Promise 缓存去重 |
| refresh / 切目标 | tick + segments 变化重置展开态 |
| 大文件 | 按需拉取不预取,失败降级兜底 |
## 6. 流程步骤(issue / PR 全文草稿)
### 步骤 1:向上游提 issue
```bash
gh issue create --repo omdsh-dev/DSH-better-sidebar \
--title "[Bug] Git 提交/工作区 diff 折叠行显示「n 行…点击展开」,点击无反应" \
--body-file <下方正文>
```
模板字段:环境 = `DSH Web(浏览器访问)`;类别 = `🐛 Bug`;DSH 版本可留空;插件版本 = `main(6fbfeed)`。
**描述字段正文**:
> **现象**:在「变更」面板点击历史提交(或查看工作区 diff)后,diff 视图中 hunk 之间的折叠行显示「n 行…点击展开」,但点击无任何反应(无 pointer 光标、无 hover);而会话文件操作(文件追踪)lens 的折叠行可以正常展开——仅 git lens 永远不可展开。
>
> **期望行为**:要么点击真实展开被折叠的上下文行,要么不显示「点击展开」字样。
>
> **复现步骤(Bug 必填)**:打开侧边栏 → 变更 → 历史 → 点击任一含多个 hunk 的提交 → 观察文件 diff 中 hunk 之间的折叠行 → 点击。
>
> **根因(代码定位)**:git 的 unified diff(`-U3`)不输出 hunk 之间的未改动行,`unifiedSegments`(`src/client/diff/rows.ts`)为此产生的 fold 段只有行号范围、没有 `rows`;`DiffRows`(`src/client/diff/DiffRows.tsx`)对 `rows === undefined` 渲染静态分支(无 onClick),但复用了含「点击展开」的 `changesFold` 文案,形成「承诺了不存在的交互」。
**补充信息字段正文**:
> **预计修正方案**:不采用纯改文案或调大 `-U` 上下文(治标),按需真实展开——点击折叠行时经既有 `git.show` 服务路由拉取该文件两侧完整内容(commit:`<hash>^` 与 `<hash>`;worktree staged:`HEAD` 与 `:0`;unstaged:`:0` 与工作区文件),按 fold 已知行号范围切片为 context 行填充渲染;per 文件 Promise 缓存,失败降级为中性文案。将在 fork 修好后提 PR(Closes 本 issue)。
>
> 截图:<用户提供的截图,展示「11 行…点击展开」行>
### 步骤 2:分支与实现
```bash
git checkout -b fix/diff-fold-expand # 基于与上游一致的 main(6fbfeed)
```
按 §4 落地;同时新增本设计文档的正式版(本文件即底稿,文末补「实施偏差」节,惯例参照 `docs/plans/2026-09-06-changes-op-previews-design.md`)。
**提交拆分**(oyw-commit-style:`type(scope): 中文概括` + 无序列表 body + 背景动机段 + 决策记录伴随行;禁 AI 署名、禁 `--no-verify`):
1. `docs(diff): git 折叠上下文按需展开设计文档`
2. `feat(api): 暴露 gitShow 单文件版本内容读取`
3. `feat(diff): 折叠行异步展开状态机与内容切片`(rows.ts + DiffRows + DiffFiles + 测试)
4. `fix(diff): git lens 折叠行接入按需展开,修复点击无反应`(DiffPane/DiffTab + i18n + 组件测试)
每个 commit 保持 typecheck/test 可绿。
### 步骤 3:验证
```bash
pnpm typecheck && pnpm test # 全量门禁
pnpm build && pnpm pack && pnpm test:mount # 挂载冒烟(CI 同款)
```
真机点开含多 hunk 提交,验证展开/折叠/失败降级,截图用于 PR。
### 步骤 4:发 PR
```bash
git push -u origin fix/diff-fold-expand
gh pr create --repo omdsh-dev/DSH-better-sidebar --head LaoYueHanNi:fix/diff-fold-expand \
--title "fix(diff): git 折叠上下文按需展开,修复「点击展开」无反应" --body-file <下方正文>
```
**PR body**:
> ## 问题
> Closes #<N>。git 提交/工作区 diff 的折叠行显示「n 行…点击展开」但点击无反应:`-U3` 裁剪使 `unifiedSegments` 的 gap fold 无行文本,渲染分支复用了含点击承诺的文案。
>
> ## 方案
> 点击时按需经 `git.show` 拉取两侧完整内容,按 fold 行号切片为 context 行填充;不预取(大 lockfile 场景零开销),per 文件 Promise 缓存去重并发,失败降级为「上下文未加载」中性文案。session lens 行为不变,服务端零改动(`git.show` 路由已存在)。
>
> ## 改动清单
> - `src/client/api.ts`:gitShow 封装
> - `src/client/diff/rows.ts`:`foldRowsFromContents` 纯函数
> - `src/client/diff/DiffRows.tsx`:`resolveFold` prop + 展开/加载/失败状态机
> - `src/client/diff/DiffFiles.tsx`:loader 下传 + Promise 缓存
> - `src/client/changes/DiffPane.tsx` / `src/client/DiffTab.tsx`:两种 lens 的取数映射(含 staged fallback 侧修正)
> - `src/client/locales*.ts` × 18:`changesFoldLoading` / `changesFoldUnavailable`
> - `tests/diff-rows.spec.ts` + 新组件测试
> - `docs/plans/…`:设计文档
>
> ## 测试
> - 新增:切片纯函数单测(对称/纯删/纯增/\r\n/越界);组件测试(mock gitShow:展开、折叠、降级)
> - 回归:`pnpm typecheck`、`pnpm test` 全绿;`pnpm test:mount` 冒烟通过
> - 截图:<展开前后对比>
### 步骤 5:review 跟进
按上游惯例(#499 经验)预判 review 关注点:皮肤令牌(本改动无颜色,不涉)、约定对齐(prop 命名/状态机形状)、i18n 完整性;整改意见在同分支追加 commit,重跑门禁。
## 7. 明确不做
- 不做纯文案拆分的「方案 A」单独 PR(失败降级文案已含在 B 内);
- 不调大 `-U` 上下文;
- 不改服务端;
- 不动 `changesFold` 现有文案(B 落地后「点击展开」对 git fold 也为真)。
## 8. 仓库规范备忘(实施时遵守)
- 代码改动必须走 PR(AGENTS §1);fork 侧开发,push 到 `LaoYueHanNi` 后向上游发 PR;
- 风格对齐:prop 命名、状态机形状、组件拆分与 CSS 模块组织以 `src/client/diff/` 现有实现为准,不引入新依赖/新工具/新交互模式;有歧义时参照上游最近合入的同类改动;
- i18n:zh 新 key 必须同步 ja(其余语言全补);
- 皮肤契约:无硬编码颜色(本改动无颜色);
- 提交信息遵循 oyw-commit-style,正文无序列表列改动点 + 背景动机段;
- 仅在用户明确说「提交」时才 commit;PR 创建前等用户批准。
---
## 实施偏差记录
> 实施过程中与本文设计的任何偏离,逐条记录在此(惯例:文档以本节为准)。
1. **语言文件数量**:§4.6 / §6 的「18 个语言文件」计数有误——实际变更点为 **20 个文件 21 处**(`locales.ts` 内 zh/en 双块 + 19 个 `locales-*.ts`:ar/de/fr/hi/id/ko/it/ja/pl/nl/pt/ru/sv/th/tr/vi/zh-HK/zh-MO/zh-TW),已全量补齐,PR body 计数随之更正。
2. **缓存拆为两层**:§4.4 把「同文件多 fold 共享一次内容拉取」归给 DiffFiles 的 fold 级 Promise 缓存,但 fold 级 key 含行号(`path|oldStart|newStart`),无法去重内容请求;实施时把内容拉取缓存上移到 lens 层(`DiffPane`/`DiffTab` 的 `foldContents`,key = `displayPath`),DiffFiles 的 fold 级缓存保留(价值:文件头折叠再展开导致 DiffRows 重挂载后秒开、并发点击去重,reject 时摘除)。两层各司其职。
3. **loading 态交互与竞态防护**:§4.3 三态渲染之外,加载中的 fold 不响应点击且无 pointer(`data-expandable` 移除),防 loading 期间重复触发;另以 `foldEpoch` ref 使 segments 重置后 still-in-flight 的 resolve 回调失效,防「旧 fold 的迟到结果写进新 segments 同下标」竞态(§4.3 未展开)。
4. **unstaged 新侧读取失败的宽容降级**:§4.5 未细化——工作区侧 `fsRead` 失败(如文件已删除)或返回二进制时降级为空串(catch → null → `''`)而非整体 failed,让旧侧(index)上下文依然可展开。
5. **DiffPane 组件级 gitScope**:fold loader 需要 SessionScope,新增组件级 `gitScope`(useMemo);load effect 的局部 `paneScope` 保持原样不动,最小侵入。
6. **本机测试基线**:Windows 本机全量跑 `tests/git.spec.ts` / `tests/git-worktree.spec.ts` 中 3 个真实 git 子进程集成用例会并发抖动失败(stash 后基线复现同样失败,与本次改动无关;CI Linux 绿)。diff 相关 18/18 全绿(新增 `foldRowsFromContents` 6 用例 + 组件测试 4 用例)。
7. **本地挂载冒烟未执行**:Windows 本地 Git Bash 下 `e2e-mount.sh` 将 tarball 规范为 MSYS 风格 `/d/...`,`file:` scheme 前缀使 MSYS 自动参数转换失效,Windows node 解析为 `C:\d\...` 而 ENOENT(经 `DSH_CMD` 注入 cygpath wrapper 可绕过,再因依赖下载网络受限放弃);`pnpm build` 产物构建已绿,挂载冒烟由 CI `plugin-mount` job 同款覆盖。
8. **服务端零改动前提被证伪(真机修正)**:§3/§5 的「git.show 路由已存在、服务端零改动」不成立——该路由此前无真实调用方,其 path 经 `resolveGitPath` 解析为绝对文件系统路径(工作区命令语义),而 `git show <rev>:<path>` 只接受仓库相对路径,恒 fatal 返 null,两侧空串切片 0 行,展开表现为「marker 消失、零行渲染」(link 真机首测发现)。修正:路由透传仓库相对路径(diff 输出剥前缀的形态,暴露面与 git.diff/git.log 一致,仅本仓库版本树),另在两 lens 加「两侧皆空 → 降级『上下文未加载』」防御,取数异常不再静默空白展开。
@@ -1,79 +0,0 @@
# 终端 Shell 配置加固(引号解析 + 双平台预检 + 友好报错)设计
日期:2026-09-08 | 状态:已确认(用户选定方案二) | 分支:`feat/terminal-shell-quoted-args-probe`
## 背景与现状
配置启动终端命令的功能已存在于 main(`fd9544f` #95 部署级 `config.shell`/`shellArgs` + pwsh 探测;`c973d6e` #125 设置页 `terminalShell`/`terminalShellArgs` 用户偏好,覆盖顺序:设置页 > yaml > 平台自动,UI 标签页与模型 `terminal_*` 工具两终端面共用)。本次不是新增功能,而是加固三处已知缺口:
1. **参数解析硬切**:`shellOverridesOf`(`src/index.ts:257`)用 `args.split(/\s+/)`,引号内空格被切碎(`-File "C:\my script\init.ps1"` 得到 3 个参数)。
2. **POSIX 无预检**:`resolveShellExecutable`(`src/pty-manager.ts:351`)仅 win32 做存在性探测(PATHEXT/PATH/System32/pwsh 目录,找不到抛 `SidebarError('pty-error', 'shell executable not found: …')` → WS catch `ws.close(1011, message)`);POSIX 直接透传 node-pty,配错路径只得到 `[process exited with code N]`。
3. **报错不可操作**:客户端 `TerminalView.tsx:247` 对任意 `1011 + reason` 显示原始英文 reason 横幅,没有本地化说明与"去设置改"指引;粘贴带引号路径(`"C:\Program Files\…\pwsh.exe"`)会因引号参与路径解析而失败;`shell.get` 返回启动时解析的 shell,设置页覆盖后新终端标签标题不跟随。
## 方案取舍
- **方案一(最小)**:只修引号参数解析。POSIX 配错仍不可诊断,引号路径仍炸——否。
- **方案二(选定)**:解析 + 双平台预检 + 本地化友好报错 + 标题跟随。不碰声明式设置契约。
- **方案三(全家桶)**:方案二 + 设置页实时 `shell.probe` inline 校验。校验型输入行需扩展 declarative settings 契约(`service.ts` + `docs/external-plugin-guide.md`),收益/成本比低(保存后开终端立即可见)——YAGNI,不做。
## 设计
### 1. 引号感知参数解析 `splitShellArgs`(`src/pty-manager.ts`)
- 空白分词;`'…'` / `"…"` 分组,组内字符**全部字面**——`\` 不是转义符(Windows 路径场景直接可用)。代价:参数值本身含引号无法表达,注释注明该取舍。
- 未闭合引号宽容处理:剩余输入并入当前 token(不抛错——设置页输入体验优先)。
- 空引号对 `""` 丢弃,不产生空参数。
- `shellOverridesOf` 中 `args.split(/\s+/).filter(Boolean)` 替换为 `splitShellArgs(args)`。
### 2. 路径去引号 `unquotePath`(`src/pty-manager.ts`)
- 长度 ≥2 且首尾为成对 `"` 或 `'` 时剥掉,否则原样返回。
- 统一入口:`shellOverridesOf` 与 `resolveShellExecutable` 首行均走它(单一实现防两处漂移)。
### 3. POSIX 预检(泛化 `resolveShellExecutable`)
- POSIX 分支:配置值含 `/` → `existsSync` 直查;裸名 → 沿 `env.PATH`(`:` 分隔,缺省回退 `/usr/bin:/bin`)逐目录探测。
- 找不到统一抛 `SidebarError('shell-not-found', 'shell executable not found: "…"')`(新错误码;消息保持英文技术文案——模型侧 `terminal_create` 工具报错复用同一消息,模型读英文更稳)。
- `ShellExecutableResolutionOptions` 的 `platform/env/exists` 注入口径不变,win32 逻辑不动。
### 4. 友好报错(host ↔ client 线协议)
- `SidebarError` 增加可选 `meta` 载荷,`shell-not-found` 时携带配置的 shell 原文。
- WS 升级 handler catch(`src/index.ts` 终端 upgrade)识别 `error.code === 'shell-not-found'` → close reason 发机器标记 `shell-not-found:<shellDisplayName(shell)>`(受 WS close reason 123 字节上限约束,超长截断显示名)。
- `TerminalView.tsx` `onclose` 在通用 1011 分支**之前**识别 `reason.startsWith('shell-not-found:')` → 本地化 fatal 横幅:`terminalShellNotFound`(「未找到配置的 Shell:<名>」)+ `terminalShellNotFoundHint`(「请检查 设置 → 侧边卡片 → 终端 的 Shell 路径」)。
- locale 新 key 落点:`locales.ts`(zh + en 两块)+ `locales-ja.ts`(仓库规则强制同步),其余语言文件走既有回退链。
### 5. 标题跟随
- `shell.get` 路由改为 `overrides.shell ?? 启动时解析值`,`shellDisplayName` 随之;仅影响新开标签的标题,不触碰已存在 pty。
## 错误流总览
```
设置页 terminalShell/Args(或 yaml config.shell/Args)
→ shellOverridesOf:unquotePath(shell) + splitShellArgs(args)
→ ptyManager.open / registry.create
→ resolveShellExecutable:win32 既有探测 ∥ POSIX 新探测
→ 找不到:SidebarError('shell-not-found', msg, meta:{shell})
├─ WS 路径:catch → close(1011, 'shell-not-found:<name>') → TerminalView 本地化横幅 + 设置指引
└─ 工具路径:terminal_create 抛英文消息 → 模型可见,自行纠正
```
## 测试计划
- `tests/pty-helpers.spec.ts`:`splitShellArgs` 语义矩阵(普通分词 / 单双引号 / 含空格路径 / 反斜杠字面 / 未闭合宽容 / 空引号丢弃 / 连续空白);`unquotePath`(成对剥、不成对留);POSIX 探测(fake `options.env.PATH` + `options.exists`:命中解析、绝对路径缺失抛 `shell-not-found`);win32 既有用例保持绿。
- `shellOverridesOf` 引号行为与 `shell.get` 跟随:就近扩展现有 spec,无宿主则新建。
- 客户端横幅为纯展示逻辑,已有 TerminalView spec 则扩展,否则不强行 e2e。
- 门禁:`pnpm typecheck` + `pnpm test`;不改 chunk 边界与 exclude 配置,mount e2e 不受影响。
## 实施偏差记录
- **报错文案合并为单 key**:设计时的 `terminalShellNotFound` + `terminalShellNotFoundHint` 两 key 合并为单个 `terminalShellNotFound`(含 `{name}` 占位符,一句话内含指引),避免跨语言的句子拼接问题。
- **POSIX 旧契约用例被有意改写**:`tests/pty-helpers.spec.ts` 原用例 `keeps POSIX bare shell resolution delegated to execvp` 锁定「POSIX 裸名直通 execvp」,与本设计冲突,已删除并由 `POSIX: resolves bare names along PATH…` 等新用例替代。
- **locale 落点为全部 21 词典**:设计原计划只写 zh/en/ja,实施时受 `tests/locales.spec.ts` 的「第三方词典键集必须与 zh 相等」门禁约束,`terminalShellNotFound` 实际落入 `locales.ts`(zh+en 双块)+ 19 个 `locales-*.ts`(共 21 文件 22 处),`chunks/locale.tsx` 的 `Record<CopyKey, string>` 类型检查兜底。
- **`resolveShellExecutable` 的 JSDoc 同步**:win32 错误码从 `pty-error` 变更为 `shell-not-found` 后,函数上方文档一并更新(commit `8704994`)。
- **本机全量测试基线**:`pnpm test` 在本机(Windows)存在 29 个**预存**环境性失败(5 个 client spec 的 jsdom `reading 'clear'` ×24、smoke 的 git 身份 ×2、git.spec 截断用例、agent-pty 的 node-pty「Signals not supported on windows」unhandled error)。经与 `origin/main` 同法全量对比确认**零新增失败**(main 30 个,feat 29 个——main 上多出的 `git-worktree` 用例为 flaky)。CI 以 CI 环境为准。
## 交付
- AGENTS 硬约束:`feat/*` 分支开发,commit 后 `gh pr create`,review 合并进 main;不直推。
@@ -1,645 +0,0 @@
# 终端 Shell 配置加固 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 设置页/yaml 配置的终端 shell 支持引号参数与带引号路径,POSIX 与 Windows 一致地预检可执行文件,配置错误时终端标签显示本地化的可操作报错,且终端标题跟随设置覆盖。
**Architecture:** 全部改动在插件仓库内:`pty-manager.ts` 新增两个纯函数(`unquotePath`/`splitShellArgs`)并把 `resolveShellExecutable` 的探测泛化到 POSIX(统一抛新错误码 `shell-not-found`,`SidebarError` 加可选 `meta`);`index.ts` 的 `shellOverridesOf` 改用新解析器、`shell.get` 返回覆盖后的有效 shell、`attachTerminal` 的 catch 把错误映射为机器可读 WS close reason `shell-not-found:<名>`;`TerminalView.tsx` 识别该前缀渲染本地化横幅(全部 21 个语言词典同步新 key)。
**Tech Stack:** TypeScript(tsdown 构建,vitest 测试,无新依赖)
**Spec:** `docs/plans/2026-09-08-terminal-shell-config-hardening-design.md`(本计划与其同读;偏差回填其「实施偏差记录」节)
## Global Constraints
- 禁止修改 DSH 源码(`~/.dsh/source/current` 零写入)。
- 代码改动走 `feat/*` 分支 + `gh pr create`,不直推 main。
- 不新增任何依赖;不改 `dependencies`/`peerDependencies`/`scripts`。
- **i18n 键集相等是硬门禁**:新 key 必须同时加进 `locales.ts`(zh + en 两块)与全部 19 个第三方词典(`locales-ja/-de/-fr/-pt/-ko/-ar/-hi/-id/-tr/-vi/-th/-ru/-it/-nl/-sv/-pl/-zh-HK/-zh-TW/-zh-MO.ts`);`chunks/locale.tsx` 的 `Record<CopyKey, string>` 类型检查 + `tests/locales.spec.ts` 双重拦截。
- 不改 vitest `exclude`;不改 chunk 边界(不新增 core bundle 的静态 import)。
- 测试命令一律 `pnpm exec vitest run <file>`(仓库用 vitest + tsconfig `allowImportingTsExtensions`)。
- 每个任务以 commit 结束,conventional commits(`feat(terminal): …` / `test(terminal): …`),正文可中文。
---
### Task 0: 建分支 + 提交设计与计划文档
**Files:**
- Create: 分支 `feat/terminal-shell-quoted-args-probe`
- 已存在待提交: `docs/plans/2026-09-08-terminal-shell-config-hardening-design.md`、`docs/plans/2026-09-08-terminal-shell-config-hardening-plan.md`
- [ ] **Step 1: 确认工作树干净并建分支**
```bash
git status --porcelain # 期望为空(main 已与 origin/main 同步)
git checkout -b feat/terminal-shell-quoted-args-probe
```
- [ ] **Step 2: Commit 设计与实施计划**(文档随 feat 分支走 PR,保持 PR 自洽)
```bash
git add docs/plans/2026-09-08-terminal-shell-config-hardening-design.md docs/plans/2026-09-08-terminal-shell-config-hardening-plan.md
git commit -m "docs(plans): 终端 shell 配置加固设计与实施计划"
```
---
### Task 1: `unquotePath` + `splitShellArgs` 纯函数(TDD)
**Files:**
- Modify: `src/pty-manager.ts`(文件末尾 `shellSpawnArgs` 之后追加)
- Test: `tests/pty-helpers.spec.ts`
**Interfaces:**
- Produces: `unquotePath(value: string): string`、`splitShellArgs(input: string): string[]`(均导出,Task 3 的 `shellOverridesOf` 消费)
- [ ] **Step 1: 写失败测试** — `tests/pty-helpers.spec.ts` 顶部 import 补两个名字(第 19-25 行的现有 import 块):
```ts
import {
defaultShell,
ensureSpawnHelper,
resolveShellExecutable,
shellDisplayName,
shellSpawnArgs,
splitShellArgs,
unquotePath,
} from '../src/pty-manager.ts'
```
在 `describe('pty helpers', () => {` 内、`'restores the spawn-helper executable bit idempotently'` 用例之后追加:
```ts
it('unquotes a paired surrounding quote from a configured shell path', () => {
expect(unquotePath('"C:\\Program Files\\PowerShell\\7\\pwsh.exe"'))
.toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
expect(unquotePath("'/usr/bin/my shell'")).toBe('/usr/bin/my shell')
// Unpaired or single characters stay verbatim.
expect(unquotePath('"mismatched')).toBe('"mismatched')
expect(unquotePath('pwsh.exe')).toBe('pwsh.exe')
expect(unquotePath('"')).toBe('"')
expect(unquotePath('')).toBe('')
})
it('splits shell args with quote-aware grouping', () => {
expect(splitShellArgs('-NoLogo -File "C:\\my init\\init.ps1"'))
.toEqual(['-NoLogo', '-File', 'C:\\my init\\init.ps1'])
// Single quotes group too, and preserve inner double quotes verbatim.
expect(splitShellArgs("-c 'echo \"hi\"'")).toEqual(['-c', 'echo "hi"'])
expect(splitShellArgs(' -l ')).toEqual(['-l'])
expect(splitShellArgs('')).toEqual([])
expect(splitShellArgs(' ')).toEqual([])
})
it('keeps backslashes literal inside quotes and tolerates an unclosed quote', () => {
// Backslash is NOT an escape (Windows paths): "C:\a\" ends with a slash.
expect(splitShellArgs('"C:\\a\\"')).toEqual(['C:\\a\\'])
// An unclosed quote folds the remainder into one token instead of erroring.
expect(splitShellArgs('"unclosed quote')).toEqual(['unclosed quote'])
// Empty quote pairs produce no empty-string argument.
expect(splitShellArgs('"" x')).toEqual(['x'])
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
Expected: FAIL — `splitShellArgs`/`unquotePath` 不是导出成员(`SyntaxError` 或 `undefined is not a function`)。
- [ ] **Step 3: 最小实现** — `src/pty-manager.ts` 末尾(`shellSpawnArgs` 函数之后)追加:
```ts
/**
* Strip ONE pair of surrounding quotes from a configured shell path. Users
* paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the
* quotes are shell-input syntax, not part of the path. Unpaired quotes and
* shorter values stay verbatim.
*/
export function unquotePath(value: string): string {
if (value.length >= 2) {
const first = value[0]
const last = value[value.length - 1]
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1)
}
return value
}
/**
* Split a settings-page shell-arguments string into argv with quote-aware
* grouping: `'…'` / `"…"` group whitespace, and characters inside quotes are
* LITERAL — a backslash is never an escape, so Windows paths survive intact
* (`-File "C:\my init\init.ps1"` → three tokens, the last containing spaces).
* The price is that an argument containing a literal quote character cannot
* be expressed; shell startup arguments never need one. An unclosed quote
* folds the remainder into the current token (settings input stays
* forgiving); an empty quote pair yields no argument.
*/
export function splitShellArgs(input: string): string[] {
const args: string[] = []
let current = ''
let quote: '"' | "'" | null = null
let started = false
for (const ch of input) {
if (quote !== null) {
if (ch === quote) quote = null
else current += ch
continue
}
if (ch === '"' || ch === "'") {
quote = ch
started = true
continue
}
if (/\s/.test(ch)) {
if (started) {
args.push(current)
current = ''
started = false
}
continue
}
current += ch
started = true
}
if (started) args.push(current)
return args.filter(arg => arg !== '')
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
Expected: PASS(全部用例)。
- [ ] **Step 5: Commit**
```bash
git add src/pty-manager.ts tests/pty-helpers.spec.ts
git commit -m "feat(terminal): 引号感知的 shell 参数解析与路径去引号纯函数"
```
---
### Task 2: POSIX 预检 + `shell-not-found` 错误码(TDD)
**Files:**
- Modify: `src/wire.ts:10-36`(错误码联合 + 构造器 meta)
- Modify: `src/pty-manager.ts:351-400`(`resolveShellExecutable` POSIX 分支)
- Test: `tests/pty-helpers.spec.ts`
**Interfaces:**
- Consumes: Task 1 的 `unquotePath`
- Produces: `SidebarErrorCode` 新值 `'shell-not-found'`;`SidebarError` 构造器第 4 参 `meta?: Record<string, string>`;`resolveShellExecutable` 在 POSIX 找不到时抛 `SidebarError('shell-not-found', 'shell executable not found: "<configured>"', 400, { shell: configured })`(Task 3 的 WS catch 消费)
- [ ] **Step 1: 写失败测试** — `tests/pty-helpers.spec.ts` 追加(import 区补 `SidebarError`:`import { SidebarError } from '../src/wire.ts'`):
```ts
it('POSIX: resolves bare names along PATH and names a missing configured shell', () => {
const options = {
platform: 'linux' as const,
env: { PATH: '/usr/local/bin:/usr/bin' },
exists: (path: string) => path === join('/usr/bin', 'zsh'),
}
expect(resolveShellExecutable('zsh', options)).toBe(join('/usr/bin', 'zsh'))
const thrown = (() => {
try {
resolveShellExecutable('nope', options)
return undefined
} catch (error) {
return error
}
})()
expect(thrown).toBeInstanceOf(SidebarError)
expect((thrown as SidebarError).code).toBe('shell-not-found')
expect((thrown as SidebarError).message).toBe('shell executable not found: "nope"')
expect((thrown as SidebarError).meta?.shell).toBe('nope')
})
it('POSIX: checks an absolute path exists and passes it through verbatim', () => {
expect(resolveShellExecutable('/explicit/zsh', { platform: 'linux', env: {}, exists: () => true }))
.toBe('/explicit/zsh')
expect(() => resolveShellExecutable('/missing/zsh', { platform: 'linux', env: {}, exists: () => false }))
.toThrow('shell executable not found: "/missing/zsh"')
})
it('unquotes the configured shell before resolving (Windows probe included)', () => {
const options = {
platform: 'win32' as const,
env: { PATH: 'C:\\Tools' },
exists: (path: string) => path.replaceAll('\\', '/') === 'C:/Tools/pwsh.exe',
}
expect(resolveShellExecutable('"pwsh.exe"', options).replaceAll('\\', '/')).toBe('C:/Tools/pwsh.exe')
})
```
**同时改写旧契约用例**(第 115-117 行 `'keeps POSIX bare shell resolution delegated to execvp'`)——该用例锁定的正是本任务**有意变更**的行为,整段替换为上面第一个新用例即可(删除旧用例)。
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
Expected: FAIL — `'nope'` 原样返回而非抛错(旧 POSIX 直通行为),且 `SidebarError` 无 `meta` 属性。
- [ ] **Step 3: wire.ts 加错误码与 meta** — `src/wire.ts`:
```ts
/** Machine-readable error codes of the sidebar API. */
export type SidebarErrorCode =
| 'bad-request'
| 'not-found'
| 'forbidden'
| 'method-error'
| 'too-large'
| 'fs-error'
| 'git-error'
| 'pty-error'
| 'pty-deps-missing'
| 'shell-not-found'
| 'job-error'
| 'sidechat-error'
| 'subagents-unavailable'
| 'settings-rejected'
| 'settings-conflict'
| 'internal'
/** One API failure with its wire code and HTTP status. */
export class SidebarError extends Error {
constructor(
readonly code: SidebarErrorCode,
message: string,
readonly status = 400,
/** Optional structured context (e.g. `{ shell }` for shell-not-found). */
readonly meta?: Record<string, string>,
) {
super(message)
}
}
```
- [ ] **Step 4: pty-manager.ts 泛化探测** — `resolveShellExecutable` 整体替换为:
```ts
export function resolveShellExecutable(
shell: string,
options: ShellExecutableResolutionOptions = {},
): string {
const configured = unquotePath(shell.trim())
const platform = options.platform ?? process.platform
if (configured === '') return configured
const env = options.env ?? process.env
const exists = options.exists ?? existsSync
const notFound = (): SidebarError =>
new SidebarError('shell-not-found', `shell executable not found: "${configured}"`, 400, { shell: configured })
if (platform === 'win32') {
const rawPathext = windowsEnv(env, 'PATHEXT')
const executableExts = (rawPathext ?? '.COM;.EXE')
.split(';')
.map(extension => extension.trim())
// node-pty ultimately calls CreateProcess; batch files need an
// intermediate cmd.exe and therefore are not valid shell executables.
.filter(extension => /^\.(?:com|exe)$/i.test(extension))
if (executableExts.length === 0) executableExts.push('.EXE', '.COM')
const hasExtension = win32Path.extname(configured) !== ''
const names = hasExtension
? [configured]
: executableExts.map(extension => configured + extension.toLowerCase())
const hasPath = win32Path.isAbsolute(configured) || /[\\/]/.test(configured)
const candidates: string[] = []
if (hasPath) {
candidates.push(...names)
} else {
const path = windowsEnv(env, 'PATH')
if (path !== undefined) {
for (const dir of path.split(';').map(entry => entry.trim()).filter(Boolean)) {
for (const name of names) candidates.push(win32Path.join(dir, name))
}
}
const systemRoot = windowsEnv(env, 'SystemRoot')
if (systemRoot !== undefined && systemRoot.trim() !== '') {
for (const name of names) candidates.push(win32Path.join(systemRoot, 'System32', name))
}
if (/^pwsh(?:\.exe)?$/i.test(configured)) {
for (const dir of windowsPwshCandidateDirs(env)) {
candidates.push(win32Path.join(dir, 'pwsh.exe'))
}
}
}
for (const candidate of [...new Set(candidates)]) {
if (exists(candidate)) return candidate
}
throw notFound()
}
// POSIX: the previous pass-through delegated a wrong name to execvp and the
// pty died with a bare "[process exited with code N]". Probe like Windows:
// a path with a separator must exist; a bare name is searched along PATH
// (the colon form is fixed by the platform). A miss is a clear, actionable
// error instead of a cryptic exit code.
if (configured.includes('/')) {
if (!exists(configured)) throw notFound()
return configured
}
const path = env.PATH ?? '/usr/bin:/bin'
for (const dir of path.split(':').map(entry => entry.trim()).filter(Boolean)) {
const candidate = join(dir, configured)
if (exists(candidate)) return candidate
}
throw notFound()
}
```
确认 `src/pty-manager.ts` 已有 `join`(node:path)与 `SidebarError` import(`SidebarError` 来自 `./wire.ts`——若尚无此 import 则补 `import { SidebarError } from './wire.ts'`)。
- [ ] **Step 5: 跑测试确认通过**
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
Expected: PASS。随后跑全量确认无连带破坏:`pnpm exec vitest run tests/smoke.spec.ts tests/tools.spec.ts`(POSIX 预检改变了裸名缺失时的行为——若 smoke/tools 有依赖直通的用例,按新契约修正其期望值并在 commit 正文注明)。
- [ ] **Step 6: Commit**
```bash
git add src/wire.ts src/pty-manager.ts tests/pty-helpers.spec.ts
git commit -m "feat(terminal): POSIX 也预检 shell 可执行文件,统一 shell-not-found 错误码"
```
---
### Task 3: host 接线——覆盖解析、shell.get 跟随、WS close reason(TDD)
**Files:**
- Modify: `src/index.ts:248-259`(`shellOverridesOf`)、`:562-566`(`shell.get`)、`:1306-1308`(`attachTerminal` catch)、新增导出函数 `wsCloseReasonOf`
- Test: `tests/smoke.spec.ts:823-881`(fake settings)+ 新用例
**Interfaces:**
- Consumes: Task 1 `unquotePath`/`splitShellArgs`;Task 2 `SidebarError.meta` + `'shell-not-found'`
- Produces: `shellOverridesOf` 返回 `{ shell?: string; shellArgs?: string[] }`(引号已处理);`wsCloseReasonOf(error: unknown): string`(导出,`shell-not-found` → `shell-not-found:<displayName≤100字符>`,其余 → message/String);WS close reason 线协议前缀 `shell-not-found:`(Task 4 客户端镜像)
- [ ] **Step 1: 写失败测试** — `tests/smoke.spec.ts`:
(a) fake settings 的 `register` 保留预置值(第 837-840 行)——替换为:
```ts
register(ns: string, schema: unknown) {
// Preserve a pre-seeded value: tests stage prefs through the `pre`
// map before the plugin mounts and registers the same namespace.
const existing = namespaces.get(ns)
namespaces.set(ns, { schema, value: existing?.value ?? undefined, revision: 0 })
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
},
```
(b) `'side card settings routes'` describe 内追加(`invoke`/`mountWithSettings` 复用):
```ts
it('shell.get reflects the settings-page override with the quotes stripped', async () => {
const route = mountWithSettings(createFakeSettings({
'dsh-better-sidebar': {
terminalShell: '"C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
terminalShellArgs: '-NoLogo',
},
}))
const result = await invoke(route, 'shell.get', {})
expect(result.ok).toBe(true)
expect(result.value).toMatchObject({
shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
name: 'pwsh',
})
})
it('maps a shell-not-found failure to the machine-readable close reason', async () => {
expect(wsCloseReasonOf(new SidebarError(
'shell-not-found',
'shell executable not found: "C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
400,
{ shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' },
))).toBe('shell-not-found:pwsh')
expect(wsCloseReasonOf(new Error('boom'))).toBe('boom')
expect(wsCloseReasonOf('plain')).toBe('plain')
})
```
import 区补:`wsCloseReasonOf` 取自 `'../src/index.ts'`(该文件已 import `apply`——并入同一 import 语句),`SidebarError` 取自 `'../src/wire.ts'`。
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/smoke.spec.ts`
Expected: FAIL — `shell.get` 返回启动时 shell 而非覆盖值;`wsCloseReasonOf` 未导出。
- [ ] **Step 3: 实现 index.ts 三处**
(a) `shellOverridesOf`(第 248-259 行)——解析走新函数:
```ts
function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { shell?: string; shellArgs?: string[] } {
const settings = getSettings()
const value = settings?.get().value
if (value === null || typeof value !== 'object') return {}
const record = value as Record<string, unknown>
const shell = typeof record.terminalShell === 'string' ? unquotePath(record.terminalShell.trim()) : ''
const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : ''
return {
shell: shell === '' ? undefined : shell,
shellArgs: args === '' ? undefined : splitShellArgs(args),
}
}
```
(`unquotePath`/`splitShellArgs` 并入第 48 行现存的 `'./pty-manager.ts'` import。)
(b) `shell.get`(第 562-566 行)——注释与返回值更新:
```ts
// The effective terminal shell and its display name: the settings-page
// override when set (quotes stripped), else the boot-time resolution.
// The client titles terminal tabs with the name, so a changed setting is
// visible on the next opened tab without a plugin restart.
'shell.get': () => {
const effective = shellOverridesOf(getSettings).shell ?? terminalShell
return { shell: effective, name: shellDisplayName(effective) }
},
```
(c) `attachTerminal` 的 catch(第 1306-1308 行)改用映射函数,并在 `attachTerminal` 上方新增导出:
```ts
/**
* The WS close reason for a failed terminal attach. A missing configured
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
* capped — a WS close reason allows at most 123 bytes) that the client maps
* to a localized, actionable banner; every other failure keeps the raw
* message (the model-side tool errors read it verbatim).
*/
export function wsCloseReasonOf(error: unknown): string {
if (error instanceof SidebarError && error.code === 'shell-not-found') {
const name = shellDisplayName(String(error.meta?.shell ?? '')).slice(0, 100)
return `shell-not-found:${name}`
}
return error instanceof Error ? error.message : String(error)
}
```
catch 体改为:
```ts
} catch (error) {
ws.close(1011, wsCloseReasonOf(error))
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/smoke.spec.ts`
Expected: PASS(含既有 `'serves the effective terminal shell and its display name'`——无设置时行为不变)。
- [ ] **Step 5: Commit**
```bash
git add src/index.ts tests/smoke.spec.ts
git commit -m "feat(terminal): shell 覆盖去引号进解析、标题跟随覆盖、close reason 机器标记"
```
---
### Task 4: 客户端本地化横幅 + 全语言词典(typecheck 守门)
**Files:**
- Modify: `src/client/TerminalView.tsx:62`(镜像常量)、`:224-250`(onclose 分支)
- Modify: `src/client/locales.ts`(zh 块 `:120` 后、en 块 `:569` 后各 1 key)
- Modify: 19 个第三方词典各 1 key:`locales-ja.ts:108`、`locales-de.ts`、`-fr`、`-pt`、`-ko`、`-ar`、`-hi`、`-id`、`-tr`、`-vi`、`-th`、`-ru`、`-it`、`-nl`、`-sv`、`-pl`、`-zh-HK`、`-zh-TW`、`-zh-MO`(均插在各自 `terminalDepsProfile` 行后)
**Interfaces:**
- Consumes: Task 3 的线协议前缀 `shell-not-found:`
- Produces: locale key `terminalShellNotFound`(占位符 `{name}`);`TerminalView` 的 fatal 横幅文本
- [ ] **Step 1: TerminalView 识别前缀** — `src/client/TerminalView.tsx` 第 62 行 `PTY_DEPS_MISSING` 常量后追加:
```ts
/**
* The WS close-reason prefix the host sends when the CONFIGURED shell was
* not found (mirror of src/index.ts wsCloseReasonOf; wire contract, keep the
* literal in lockstep). The view renders a localized, actionable banner.
*/
const SHELL_NOT_FOUND_PREFIX = 'shell-not-found:'
```
`socket.onclose`(第 224 行起)在 `PTY_DEPS_MISSING` 分支之后、通用 1011 分支(第 247 行)之前插入:
```ts
// The configured shell could not be found (settings page or yaml):
// a localized banner beats the raw English close reason.
if (event.code === 1011 && event.reason.startsWith(SHELL_NOT_FOUND_PREFIX)) {
setFatal(t('terminalShellNotFound', { name: event.reason.slice(SHELL_NOT_FOUND_PREFIX.length) || '?' }))
return
}
```
- [ ] **Step 2: 加全语言 key**(21 个词典文件,各在 `terminalDepsProfile` 行后插 1 行;键集由 `chunks/locale.tsx` 的 `Record<CopyKey, string>` 与 `tests/locales.spec.ts` 双重把关,漏一个文件 typecheck 即红):
```ts
// locales.ts zh 块:
terminalShellNotFound: '未找到配置的 Shell:{name},请到 设置 → 侧边卡片 → 终端 检查 Shell 路径',
// locales.ts en 块:
terminalShellNotFound: 'Configured shell not found: {name} — check the shell path under Settings → Side card → Terminal',
// locales-ja.ts:
terminalShellNotFound: '設定されたシェルが見つかりません:{name}(設定 → サイドカード → ターミナル のシェルパスを確認してください)',
// locales-de.ts:
terminalShellNotFound: 'Konfigurierte Shell nicht gefunden: {name} — Shell-Pfad unter Einstellungen → Side card → Terminal prüfen',
// locales-fr.ts:
terminalShellNotFound: 'Shell configuré introuvable : {name} — vérifiez le chemin du shell dans Réglages → Side card → Terminal',
// locales-pt.ts:
terminalShellNotFound: 'Shell configurado não encontrado: {name} — verifique o caminho do shell em Configurações → Side card → Terminal',
// locales-ko.ts:
terminalShellNotFound: '설정된 셸을 찾을 수 없습니다: {name} — 설정 → 사이드 카드 → 터미널에서 셸 경로를 확인하세요',
// locales-ar.ts:
terminalShellNotFound: 'لم يتم العثور على الصدفة المُعدَّة: {name} — تحقق من مسار الصدفة في الإعدادات → Side card → Terminal',
// locales-hi.ts:
terminalShellNotFound: 'कॉन्फ़िगर किया गया shell नहीं मिला: {name} — Settings → Side card → Terminal में shell पथ जाँचें',
// locales-id.ts:
terminalShellNotFound: 'Shell yang dikonfigurasi tidak ditemukan: {name} — periksa path shell di Pengaturan → Side card → Terminal',
// locales-tr.ts:
terminalShellNotFound: 'Yapılandırılan kabuk bulunamadı: {name} — Ayarlar → Side card → Terminal altındaki kabuk yolunu denetleyin',
// locales-vi.ts:
terminalShellNotFound: 'Không tìm thấy shell đã cấu hình: {name} — kiểm tra đường dẫn shell trong Cài đặt → Side card → Terminal',
// locales-th.ts:
terminalShellNotFound: 'ไม่พบ shell ที่กำหนดไว้: {name} — ตรวจสอบพาธ shell ใน การตั้งค่า → Side card → Terminal',
// locales-ru.ts:
terminalShellNotFound: 'Настроенная оболочка не найдена: {name} — проверьте путь к оболочке в Настройки → Side card → Terminal',
// locales-it.ts:
terminalShellNotFound: 'Shell configurata non trovata: {name} — verifica il percorso della shell in Impostazioni → Side card → Terminal',
// locales-nl.ts:
terminalShellNotFound: 'Geconfigureerde shell niet gevonden: {name} — controleer het shell-pad onder Instellingen → Side card → Terminal',
// locales-sv.ts:
terminalShellNotFound: 'Konfigurerat skal hittades inte: {name} — kontrollera skalsökvägen under Inställningar → Side card → Terminal',
// locales-pl.ts:
terminalShellNotFound: 'Nie znaleziono skonfigurowanej powłoki: {name} — sprawdź ścieżkę powłoki w Ustawienia → Side card → Terminal',
// locales-zh-HK.ts:
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
// locales-zh-TW.ts:
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
// locales-zh-MO.ts:
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
```
- [ ] **Step 3: typecheck 守门**
Run: `pnpm typecheck`
Expected: PASS —— 若漏改任何一个第三方词典,`chunks/locale.tsx` 的 `checked(dict: Record<CopyKey, string>)` 直接编译失败。
- [ ] **Step 4: 跑 locale 契约测试**
Run: `pnpm exec vitest run tests/locales.spec.ts`
Expected: PASS(含 `keeps every shipped third-language dictionary key-set-equal to zh`)。
- [ ] **Step 5: Commit**
```bash
git add src/client/TerminalView.tsx src/client/locales.ts src/client/locales-*.ts
git commit -m "feat(terminal): 配置 shell 未找到时显示本地化可操作横幅(全语言)"
```
---
### Task 5: 全量门禁 + 设计文档偏差回填 + PR
**Files:**
- Modify: `docs/plans/2026-09-08-terminal-shell-config-hardening-design.md`(「实施偏差记录」节回填)
- [ ] **Step 1: 全量门禁**
Run: `pnpm typecheck && pnpm test`
Expected: PASS。若 `tests/tools.spec.ts` / e2e 有依赖旧 POSIX 直通行为的用例失败,按新契约修期望值并单独 commit(`test(terminal): 跟随 POSIX 预检新契约`)。
- [ ] **Step 2: 文档核对与偏差回填**
Run: `rg -n "terminalShell|shellArgs" docs/ README.md`
Expected: 仅设计/计划文档命中(`docs/external-plugin-guide.md` 不记载设置页 shell 行,无需同步;若出现命中则按 AGENTS §5 同步该文档)。回填设计文档「实施偏差记录」:① `terminalShellNotFoundHint` 并入单 key `terminalShellNotFound`(含 `{name}` 占位);② 旧用例 `keeps POSIX bare shell resolution delegated to execvp` 被有意改写;③ 新增 locale 键实际落点为全部 21 词典(不止 zh/en/ja)。Commit:
```bash
git add docs/plans/2026-09-08-terminal-shell-config-hardening-design.md
git commit -m "docs(plans): 回填终端 shell 配置加固实施偏差"
```
- [ ] **Step 3: 推送并建 PR**
```bash
git push -u origin feat/terminal-shell-quoted-args-probe
gh pr create --title "feat(terminal): 终端 shell 配置加固——引号解析、POSIX 预检、本地化报错" --body-file - <<'EOF'
## 概要
配置启动终端命令的功能已存在(#95/#125),本 PR 加固三处缺口:
1. 设置页 shell 参数支持引号分组(`-File "C:\my init\init.ps1"` 正确成 3 个 argv);shell 路径自动剥成对引号
2. POSIX 预检可执行文件(对齐 Windows 既有探测),统一 `shell-not-found` 错误码
3. 配置错误时终端标签渲染本地化可操作横幅(21 语言),终端标题跟随设置覆盖
## 测试
- `tests/pty-helpers.spec.ts`:splitShellArgs/unquotePath 语义矩阵 + POSIX 探测
- `tests/smoke.spec.ts`:shell.get 覆盖跟随 + wsCloseReasonOf 映射
- `pnpm typecheck` + `pnpm test` 全绿
EOF
```
Expected: PR URL 返回。
- [ ] **Step 4: 汇报** — 向用户回报 PR 链接、改动文件清单、行为对照(改前/改后)。
@@ -1,90 +0,0 @@
# DSH 0.1.5-alpha.2 适配(v0.19.0-alpha.1 基线)
日期:2026-09-09 分支:`feat/dsh-0.1.5-alpha.2`
## 背景
DSH `dsh-v0.1.5-alpha.1..dsh-v0.1.5-alpha.2` 的宿主契约里有一批会实际波及本插件的变更。本计划记录哪些值得处理、哪些只做兼容、以及每一处的子系统级落点。插件对外的全部行为差异以 [docs/external-plugin-guide.md](../external-plugin-guide.md) 为准;仓库级适配要点(基线、CI 钉版、宿主契约)同步到 [AGENTS.md](../AGENTS.md) §3。
## 上游 delta(alpha.1 → alpha.2,影响本插件的六项)
| # | 上游变更 | 对插件的影响 |
|---|---|---|
| 1 | `SidebarRightGuideEntry` 去掉 `description` 字段;指南条目变为「图标 + 标题」胶囊 | 删除 `TabDescriptor.description` 与 6 个 `guideDesc*` 词典键(连同 `nativeGuideDesc` 兜底句——不再有第二行说明) |
| 2 | 新增**全局面板**:根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`;`rightbar` 改根级并新增会话级 `rightbar.session` 子槽 | 插件**不接入**(兼容 only) |
| 3 | 原生 tab 体宿主 `.paneBody`(`dsh-client-ui-dockkit`)是**有确定高度的块级滚动容器**,不是 flex 容器;DSH 自己的 tab 体(`ui-sidebar-documentpreview` 的文档预览、`ui-sidebar-files` 的 FilesBody)在根上声明 `height: 100%` | 原生适配层给每个 tab 体包一层 `height: 100%` 的列 flex 宿主(`sidebar.module.css` `.nativeTabHost`,带 `data-dsh-native-tab-host`),修复 sidechat 输入框贴不到面板底的根塌陷 |
| 4 | 文件地址语法:`fileAddressFor` 一律返回 session 作用域地址(绝对路径保留前导 `/`);`parseFileAddress` 前缀解析并去 `?`/`#`;`absoluteFileAddress` 仍在 | 镜像 alpha.2 语法(插件自实现于 `src/client/resource-address.ts`——客户端 bundle 纯度门禁禁 value-import 未列名的 `@deepseek-ai/*`,不能直接引 DSH 工具包) |
| 5 | `ui-sidebar-textpreview` 被 `ui-sidebar-documentpreview` 取代(同 `text` kind、`fallback` 带、`dsh-resource://file/**` 模式) | 插件 `editor` 仍以 `extension` 优先级认领 `dsh-resource://file/**`,压过其 `text` 的 `fallback` 带 |
| 6 | 基线升到 0.1.5-alpha.2(peer 下限 / manifest / CI 钉版) | 插件基线 = alpha.2 only;alpha.1 用户留在 v0.19.0-alpha.0 |
> 后续(2026-09-10):`SidebarRightGuideEntry.description` 在 DSH 0.1.5-rc.1 回归(可选,且宿主只在 guide 列出的条目 ≤ 4 条时渲染),插件已在 **v0.19.0** 恢复该字段——见 [2026-09-10-dsh-0.1.5-rc.1-adaptation.md](./2026-09-10-dsh-0.1.5-rc.1-adaptation.md)。
>
> 后续(2026-09-10):退役自绘右侧面板(#605)时,`use-host-feeds.ts` 的三处自动激活被误改成 `target: 'bottom'`,任务页因此落到插件自己的底部工作台而非 DSH 原生右侧栏。修复见 [2026-09-10-auto-activation-native-landing-fix.md](./2026-09-10-auto-activation-native-landing-fix.md)。
## 决策
- **基线 = alpha.2 only**:peer 下限 `^0.1.5-alpha.2`、manifest `>=0.1.5-alpha.2`、CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`;不再对 alpha.1 做运行时兼容。
- **全局面板:兼容 only,不接入**——不迁移到 `main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`,仅保证在 alpha.2 宿主上不破坏。
- **删除 `TabDescriptor.description`**:宿主不再渲染第二行,字段留着只会在类型上撒谎;同步删 6 个 `guideDesc*` 与 `nativeGuideDesc` 兜底句。
- **修复 tab 体填充**:native 适配层为每个 tab 体(含 orphaned 回退)包 `height:100%` 列 flex 宿主 `.nativeTabHost`;tab 组件根遵循 `flex:1` / `height:100%` + `min-height:0` 契约。
- **镜像 alpha.2 地址语法**:`fileAddressFor` 不再产生 `absolute` 作用域(但仍解析旧地址与第三方地址),`parseFileAddress` 前缀解析并去 `?`/`#`。
- **中心列锚点跟随 alpha.2**:`center-column.ts` 的 `CENTER_COLUMN_SELECTOR` 改为 `#root [data-slot="main.conversation"], #root [data-slot="conversation"]`,`resolveCenterColumn` 从对话槽宿主向上跳过 `display: contents` 祖先取真实列;alpha.1 的 `[data-slot="conversation"]` 依旧认可。
## 改动清单(子系统级)
| 子系统 | 文件 | 变更 |
|---|---|---|
| 基线 | `package.json` | 版本 → `0.19.0-alpha.1`;dsh-* peer 全部 `^0.1.5-alpha.2`;devDeps 同步钉 `0.1.5-alpha.2` |
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 alpha.2 |
| 基线 | `dsh.plugin.json` | `version` → `0.19.0-alpha.1`;`engines.dsh` → `>=0.1.5-alpha.2` |
| 基线 | `.github/workflows/ci.yml` | mount lane 钉 `@deepseek-ai/dsh@0.1.5-alpha.2` |
| 基线 | `package.json` | 按 §3-9 补提 `dsh-session-persistence`(`pnpm peers check` 唯一新失配项) |
| 基线 | `package.json` | **上游打包缺陷**:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 的 manifest 不再声明任何 `dependencies`(alpha.1 声明了 19 个),但其 `lib/*.js` 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`。宿主自身由预构建前端 bundle 满足,独立安装的消费方(本仓库的 dev/test 树)必须自备,故把 alpha.1 声明的同一组版本提升进 devDependencies(`@shikijs/langs`、`@types/mdast`、`anser`、`katex`、`mdast-util-from-markdown/gfm/math`、`micromark-core-commonmark/extension-gfm/extension-math/factory-space/util-character/util-classify-character/util-sanitize-uri/util-symbol/util-types`、`shiki`;`clsx` 已在 dependencies) |
| 基线/文档 | `AGENTS.md` | §2/§3 基线戳、§10 补 alpha.2 行为差异(本 workstream 回填) |
| Tab 契约 | `src/client/service.ts` | `TabDescriptor` 删除 `description` 字段;`SIDEBAR_SERVICE_VERSION` → `0.19.0-alpha.1` |
| Tab 契约 | `src/client/builtins/tabs.tsx` | 6 个内置 tab 删除 `description` 行 |
| Tab 契约 | `src/client/native/index.ts` | native 注册的 guide 条目去掉 `description`(删 `descriptionOf` 与通用兜底);`guide` 条目只剩 `order`/`title`/`icon` |
| 词典 | `src/client/locales.ts` + 各 `locales-*.ts` | 删除 `nativeGuideDesc` + 6 个 `guideDesc*` 词条(zh/en + 全部第三方语言) |
| Tab 高度 | `src/client/native/tab-adapter.tsx` | `NativeTabBody` 把 live 组件与 orphaned 回退都包进 `css.nativeTabHost`(带 `data-dsh-native-tab-host`) |
| Tab 高度 | `src/client/sidebar.module.css` | 新增 `.nativeTabHost`(`display:flex; flex-direction:column; height:100%; min-height:0`) |
| 地址语法 | `src/client/resource-address.ts` | 重写为镜像 alpha.2 文件地址语法(`fileAddressFor` 不再产 `absolute` 作用域、`parseFileAddress` 前缀去 `?`/`#`、保留 `absolute` 解析) |
| 中心列锚点 | `src/client/center-column.ts` | `CENTER_COLUMN_SELECTOR` 加 `main.conversation`;新增 `columnOfSlotHost` 跳过 `display: contents` 祖先 |
| 中心列锚点 | `src/client/sidebar/use-center-column.ts` | 宿主查找逻辑对齐 alpha.2 |
| 中心列锚点 | `src/client/layout.css` | 选择器/注释同步 `main.conversation` 与 `display: contents` 跳过 |
| 测试 | `tests/builtins.spec.ts` | 指南断言从 `description` 改成 `title`(图标+标题胶囊) |
| 测试 | `tests/native-surface.spec.ts` | 注册/guide 断言去 `description`;断言 guide 条目不再携带 `description` |
| 测试 | `tests/center-column-locator.spec.ts` | 覆盖 `main.conversation` 解析与 `display: contents` 跳过 |
| 测试 | `tests/resource-address.spec.ts` | 覆盖 alpha.2 地址语法 |
| 测试 | `tests/e2e/mount.e2e.ts` | guide 页断言跟随胶囊形态;新增 tab 体填充断言(可见 tab 的 `[data-dsh-native-tab-host]` 盒高 == 其带盒祖先高 ±2px,且 sidechat composer 底边距面板底 ≤12px) |
| 文档 | `docs/external-plugin-guide.md` | §0 行为差异表、§4.1 删 `description`、§10 加 tab 体高度契约、§12 中心列锚点、`ui-sidebar-documentpreview` |
| 文档 | `docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md` | 本计划 |
## 已知差距
- **side-chat 线程仍只携带 `subagent/descriptor`,不携带 alpha.2 的 `subagent/catalog`**:DSH 自己的直接子代理发现现在优先读 `catalog`,而我们沿用的 `subagents.listDescendants`(`src/subagent-live-route.ts`)与转录种子的 `type: 'subagent/descriptor'`(`src/sidechat-routes.ts`)路径不变。catalog 只作为新增读面出现,不构成对现有路径的破坏,本期不跟进。
## 验证
- **门禁**(本分支 HEAD):`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm vitest run --exclude '**/*.e2e.ts'` = 124 files / 1295 passed / 9 skipped;`pnpm peers check` = No peer dependency issues found。
- **真机挂载冒烟**(`DSH_CMD` 指向 npm 安装的 DSH 0.1.5-alpha.2,`scripts/e2e-mount.sh`):7 passed。新增的 tab 体填充断言(可见 tab 的 `[data-dsh-native-tab-host]` 盒高 == 其带盒祖先 ±2px,sidechat composer 底边距面板底 ≤12px)是这次修复的回归守护——它在修复前失败(`gap -862px`,因为断言最初漏跳过 `display: contents` 槽宿主)。
- **真机 3080(web profile 升到 alpha.2 + 插件 0.19.0-alpha.1)**:
- 原生 guide 六行均为 48px「图标+标题」胶囊,无第二行:文件 / 文件变动 / 任务管理 / 侧边对话(beta) / 终端 / 浏览器。
- 文件树点击 `AGENTS.md` → tab 标题 `AGENTS.md` + CodeMirror 挂载(`editor` 接管成立)。
- **sidechat 输入框贴底**:原生右侧栏 `hostHeight 962 == paneBodyHeight 962`(fillGap 0),composer 底边距面板底 8px(即它自己的 margin);底部工作台同样 8px。
- 底部工作台中心列锚点:`[data-dsh-bottom-panel]` 与 `[data-dsh-center-col]` 左右边完全重合(gap 0)——证明 `main.conversation` + 跳过 `display: contents` 的解析在真机成立;真机 `[data-slot]` 清单同时确认 `main` / `main.conversation` / `rightbar.session` 已存在、旧 `conversation` 槽已消失。
- `pageerror` 0 条;控制台仅 `POST /api/dsh-worktree/attach` 500(第三方 dsh-tauri-worktree 插件,与本次改动无关)。
- 未覆盖:**聊天里的文件链接点击**——当前可见会话没有可解析的文件提及(内联 `code` 是纯文本,不是链接),该漏斗(`ctx.sidebarRight.openResource(fileAddressFor(...))`)由挂载车道与 `tests/resource-address.spec.ts` 覆盖,本实例未实测。
- 仓库没有守护指南文档章节的测试;`tests/builtins.spec.ts` / `tests/native-surface.spec.ts` 守护的是 guide 行形态而非文档文本。
- 早期本分支相关测试曾整体收集失败:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 在模块加载时裸 `import 'anser'`,而该包 manifest 已不再声明依赖。补齐 devDependencies 后恢复(见改动清单)。
## 不做
- 不接入 alpha.2 全局面板(`main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`)。
- 不引入 `subagent/catalog` 读取面。
- 不改 DSH 源码(仓库硬约束 §1)。
## 落地
- #603 → #604 → #605 → #609 依序合入 `main`(各自 retarget 到 main 后以 merge commit 合并);main 的 `package.json` 为 `0.19.0-alpha.1`、`engines.dsh` 为 `>=0.1.5-alpha.2`。main 的 CI 首轮 `ci-windows` 因既有的 pty/git 5s 超时抖动失败,重跑后三 job 全绿。
- 发布 [v0.19.0-alpha.1](https://github.com/omdsh-dev/DSH-better-sidebar/releases/tag/v0.19.0-alpha.1):`release.yml` 通过全门禁后经 npm Trusted Publishing 发布,npm `alpha` dist-tag → `0.19.0-alpha.1`(带 provenance)。发布产物核对:`lib/index.js` 与本地已验证构建逐字节一致;`lib/client.js` 仅差打包非确定性(已知);`package.json` 仅少 dev-only 字段(`packageManager` / `prepare` / `prepublishOnly`,npm 发布时剥离)。
- 注意:新版本在 registry 上可见有约 5 分钟传播延迟(版本元数据先到、tarball 后到),期间 `npm view`/tarball 可能 404——不是发布失败。
@@ -1,192 +0,0 @@
# 终端等待 banner(wait banner + 跳过)设计
> 2026-09-09 · 目标 v0.18.2
## 背景
`terminal_wait_for`(v0.18.1,PR #595 起 needle 支持 regex)会阻塞 agent 的工具调用直到
needle 出现 / 超时 / 终端退出。等待期间用户在侧边栏看不到任何"agent 正在等什么"的信号,
也无法干预——只能等 agent 自己超时(默认 10s,长等待可设到分钟级)。
需求:
1. agent 开始 `wait for needle` 时,在对应终端的顶部显示 banner:
**「Agent 正在等待 {needle}」**,右侧「跳过等待」按钮;
2. 点击跳过后,`terminal_wait_for` 立即返回,agent 明确知道是用户跳过(而非超时);
3. 终端 tab 未激活时也能感知等待 → tab 标题加 ⏳ 徽章。
## 方案对比
| 方案 | 思路 | 取舍 |
|---|---|---|
| **A. 搭现有推送车(采纳)** | 等待状态并入 `AgentTerminalSnapshot`,经现有 `/sidebar/ws/agent-terminals` 推送;跳过走新 `/sidebar/api` 方法 | 改动集中;零新 WS 生命周期;推送时机由 registry `notify()` 天然覆盖 |
| B. 独立 waits 推送 WS | 新开 `/sidebar/ws/agent-waits` | 重连/订阅/teardown 全套翻倍,YAGNI |
| C. 客户端轮询 | 定时拉 terminal 列表 | 延迟与开销差,违背现有推送架构 |
已确认的两个决策:
- **skip 语义 = 新增第四种返回 `kind:'skipped'`**(不复用 timeout、不走 abort——abort
语义属于工具调用取消,用户语义只是"不等了");
- **banner 主体在终端卡片顶部 + tab 标题 ⏳ 徽章**(复用 v0.12+ 的 `TabDescriptor.badge`
通道,git tab 有先例)。
## 设计
### 1. Host:`AgentPtyRegistry`(src/agent-pty.ts)
- `AgentTerminalHandle` 增活动等待记录数组 `waits: Array<{ needle, since, skipped }>`;
并发 `wait_for` 同 uuid 时各登记一条。
- `waitFor()`:快速路径(已 exited / needle 已在 transcript)不登记、不推 banner;
进入轮询前登记记录并 `notify()`(banner 出现),`finally` 移除记录并 `notify()`(banner
消失——found/timeout/exited/skipped/工具 abort 五条出路统一走 finally)。
- 轮询每圈检查本记录的 `skipped` → 返回 `{ kind:'skipped', needle }`;50ms 轮询
天然承载跳过延迟,无需事件唤醒。与终端退出在同一 tick 竞争时**退出优先**(见实施
偏差记录)。
- 新方法 `skipWait(uuid): number`:标记该 uuid 全部活动记录并返回条数;uuid 未知 →
`not-found` 404(与 `expect` 一致)。
- `AgentTerminalSnapshot` 增 `waiting?: { needle: string; since: number }`(取最新登记
的一条;并发等待显示最新 needle,跳过跳过全部)。
- `AgentTerminalWaitResult` 增 `skipped` 变体。
### 2. 工具层:`terminal_wait_for`(src/tools.ts)
- output schema `oneOf` 增 `{ kind: 'skipped', needle }` 分支;
- render:`Skipped by user while waiting for "<needle>" — the wait ended early; call terminal_read to inspect the transcript if needed.`;
- 工具描述补一句:用户可从侧栏 banner 跳过等待(返回 `skipped`)。
### 3. API:`buildApi`(src/index.ts)
- 新方法 `'agent-pty.skip-wait'`(命名对齐现有 `'agent-pty.close'`):
`requireString uuid` → `registry.skipWait(uuid)`;degraded 模式(registry 为 null)
幂等返回 `{ ok: true, skipped: 0 }`。
### 4. 推送零改动
`attachAgentList` 发的就是 `registry.list(sessionId)`,snapshot 新增的 `waiting` 字段
自动随车;等待开始/结束由 `notify()` 触发推送。兼容性:
- 旧客户端 + 新 host:多余字段被忽略;
- 新客户端 + 旧 host:永远收不到 `waiting` → 无 banner,安全降级。
### 5. 客户端状态(src/client/state.ts)
- `SidebarState` 增 `agentWaits: Record<uuid, { needle: string; since: number }>`(默认
`{}`;等待结束即删除键);
- `reconcileAgentTerminals` 从同一推送同步该映射——需解除"无 tab 增删即早退"的短路
(仅 waiting 变化也要产出新状态);
- **不持久化**:`sanitizeState` 逐字段重建、不恢复该字段 → 刷新后自然清空;WS attach
即发首条推送,立刻纠正。
### 6. Banner(src/client/TerminalView.tsx)
- 仅 `agent:` tab 渲染;位置在终端区顶部(现有 fatal/deps banner 之上);
- 内容:`t('terminalWaitBanner', { needle })`(zh「Agent 正在等待 {needle}」)+ 右侧
`t('terminalSkipWait')`(zh「跳过等待」)按钮 → `api.agentSkipWait(uuid)`;
- needle 超 ~80 字符截断显示,`title` 悬浮显全文;
- banner 消失完全由推送驱动(skipped/exited/abort 均经 `notify`),点击不做乐观更新。
### 7. Tab ⏳ 徽章(src/client/builtins/tabs.tsx)
- terminal 描述符增 `badge: (_ctx, scope, state) => ...`:`agent:` tab 且
`state.agentWaits[uuid]` 存在时返回 `'⏳'`,否则 null——复用 v0.12+ 徽章通道
(git changes tab 先例),不碰 `TabBar`。
### 8. i18n / 样式
- 词典键:`terminalWaitBanner`、`terminalSkipWait`;zh/en 同步,**ja 翻译必须同步
`src/client/locales-ja.ts`**(仓库规则);
- `.terminalWaitBanner` 全部消费 `--dsw-alias-*` / `--ds-*` 令牌,无硬编码颜色
(皮肤契约,`tests/theme.spec.ts` 守护)。
### 9. 测试
- `tests/agent-pty.spec.ts`:跳过返回 `skipped`;活动等待期间 snapshot 含 `waiting`、
结束后消失;`skipWait` 未知 uuid → 404、无活动等待 → 0;等待开始/结束触发 `notify`;
- `tests/tools.spec.ts`:FakeRegistry 增 skipped 形状;schema/render 断言;
- `tests/agent-terminal-reconcile.spec.ts`:`agentWaits` 设置/清除、仅 waiting 变化也
触发状态更新;
- badge / banner 组件测试视现有 jsdom 基建可行性补齐。
## 边缘情况
- **并发多个 `wait_for` 同一 uuid**:banner/⏳ 显示最新登记的 needle;跳过一次跳过全部;
- **工具调用取消(abort)**:`finally` 移除记录 → banner 消失并推送;
- **终端退出 / 用户关 tab(close frame 杀 pty)**:轮询下一圈返回 `exited`,记录移除;
- **UI-tab 终端**:无等待概念,`agentWaits` 永不含有其条目。
## 不做的事
- 不新增独立 waits WebSocket(方案 B 否决);
- 不改 `ctx.betterSidebar` 服务面(`external-plugin-guide.md` 无需同步);
- 不做乐观 UI(banner 消失以服务端推送为准)。
## 实施偏差记录
- **⏳ tab 徽章不走 `TabDescriptor.badge`**:实现时核实该 API 是 type-keyed
(`badge(ctx, scope, state)`,无 tab 参数、external 插件共享),无法定位单个
tab。改为 Sidebar 壳层 `tabBadgeOf` 的 sidebar-internal 特例(agent: 前缀判断
+ `state.agentWaits` 查表),`service.ts` 与 external-plugin-guide.md 零改动。
- **banner 抽为独立组件 `TerminalWaitBanner.tsx`**:设计原文写在 TerminalView
内;抽出后无 xterm 依赖,jsdom 单测可直接渲染(TerminalView 仍负责订阅 store
并把 onSkip 接到 `api.agentSkipWait`)。
- **词典同步扩到全部 21 个语言文件**:设计只写了 zh/en/ja,但
`tests/locales.spec.ts` 断言每个第三方词典与 zh 键集相等——只同步三语会让该
守护测试挂掉。按仓库惯例(全语言同步,如 e8761ee)补齐其余 18 语言同两键。
- **banner 配色**:仓库 CSS 无 state-info 令牌,沿用 `.terminalBanner` 的 warn
对(`--dsw-alias-state-warn-label/-tertiary`),符合皮肤契约。
- **设计文档与计划随 feat 分支进 PR**:main 受分支保护,纯文档直推被 hook 拒绝
(`4a6a77a` / `2448068` 随本分支合并)。
- **测试断言以文本计数取代 CSS 类名**:CSS module 类名在测试变换下不稳定,
⏳ 数量断言改从 `textContent` 计数;长 needle 截断断言改为计量 needle 自身
贡献字符数(本地化句子包裹 needle,整体长度断言与语言相关,原计划内部矛盾)。
- **新测试的 localStorage 兜底**:Node 26 + jsdom 29 组合下测试环境可能无全局
`localStorage`(上游 `bottom-auto-terminal.spec.tsx` 在同环境即失败,属已知
win32/环境非回归类);新 spec 的 afterEach 加守卫 + try/catch(jsdom opaque
origin 下 localStorage 可以是 throwing accessor,裸 typeof 也会炸),
上游测试一律未动。
- **已知限制:跨会话 pinned 虚拟 tab 收不到实时 banner/⏳**(Copilot review
指出):等待状态 feed 按 viewer 会话订阅(`/sidebar/ws/agent-terminals` 按
当前 sessionId 连接),钉到其他会话的 pinned 虚拟 agent tab
(`pinned:<homeSessionId>:agent:<uuid>`)在其 home 会话非当前会话时收不到
实时等待状态。修复需跨会话 wait feed 多路复用(连 home 会话推送或聚合
推送),超出本特性范围,留待后续设计;跳过 API 按 uuid 全局寻址,跨会话
跳过本身可达。
- **Copilot review 第三轮修复(合并 rc.1 后扫描)**:
1. `truncateNeedle` 改按 Unicode 码点截断(`Array.from`),原 UTF-16 码元
截断会在截断点劈开代理对(emoji 渲染为替换字符);
2. banner 消息加 `role="status"` + `aria-live="polite"`,完整未截断的
本地化消息经 `aria-label` 提供给读屏器(不新增词典键);
3. `terminal` tab 类型禁用时推送处理从整体跳过改为 `mirrorAgentWaits`
——仍镜像权威 `waiting` map(等在禁用窗口内 resolve 也清 banner 状态),
只跳过 tab 增删 reconcile,否则重启用后已开终端残留 stale banner/⏳
直到无关推送到达;
4. badge 测试跟踪挂载的 root 并在 `afterEach` 里先 unmount 再清 DOM
(裸 `innerHTML = ''` 不触发 React effect 清理,store 订阅/host-feed
effect 会泄漏进后续测试);
5. 补并发双 `waitFor` 同 uuid 测试:快照显示最新 needle(`waits.at(-1)`)、
`skipWait` 返回 2、两个 promise 都以各自 needle `skipped` 收敛、快照
清空且 skipWait 幂等归零。
- **轮询优先级:终端退出优先于用户跳过**(v0.19.0 发版前 review 发现):设计原文
写「每圈优先检查 `skipped`」,实现是 `aborted → handle.exited → record.skipped`,
即 pty 退出与用户点跳过落进同一个 50ms tick 时返回 `exited`。**维持实现现状**,
设计原文已同步为「退出优先」:两种顺序下用户可见结果完全相同(banner 消失、
`skipWait` 返回 0、等待结束),只有工具结果的 `kind` 文案不同;而进程已死是
客观事实,报 `exited` 不撒谎,工具结果本身也引导 agent 用 `terminal_read` 复核。
若日后要让「用户意图优先」,改动点是循环内两行先后 + 一条同 tick 竞态测试。
- **两个已知限制的补充记录**(同一次 review):
1. **pinned tab 的 banner 与 ⏳ 不对称**:pinned 虚拟 id 是
`pinned:<homeSessionId>:agent:<uuid>`,`isAgentTabId` 只认 `agent:` 前缀,因此
钉住的 agent tab **不会误挂 ⏳**(比"收不到"更强的保证);但 Workbench 渲染
pinned tab 时给 TerminalView 传的是**原 tab id + home 作用域**,所以 home 会话
正在等待时该视图**会**显示 banner,而它的 strip 徽章仍不出现。两个信号数据源
不同(banner 读 home store,徽章读 viewer 的 `agentWaits`)。
2. **⏳ 到不了 DSH 原生右侧栏**:徽章经 `getTabBadge` 渲染,唯一消费点是插件
自绘的 `TabBar`;原生栏的 `NativeTabTitle` 契约只带 title,不带 badge。agent
终端标签页走 `openTabInBottomPane`(底部工作台),故 agent 流程可见;这是原生
承载面契约决定的上限,非本特性缺陷。
- **注释随实现同步**(v0.19.0 发版前 review):`use-host-feeds.ts` effect 头部原写
「terminal tab 类型禁用时 pushes are ignored」,与上面第 3 项的 `mirrorAgentWaits`
改法矛盾,已改为「不增删 tab,但仍镜像权威 wait map」;`src/index.ts` 的
`agent-pty.skip-wait` 注释原文把「无活动等待 → 0」泛化成了「幂等」,已收窄为
「无活动等待返回 `{skipped:0}`;未知 uuid 走 `expect` → 404(已退出的终端即属此
列),客户端 `.catch` 容忍并等下一次推送收敛」。
@@ -1,980 +0,0 @@
# 终端等待 banner + 跳过等待 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** agent 阻塞在 `terminal_wait_for` 时,对应终端卡片顶部显示「Agent 正在等待 {needle}」banner(右侧「跳过等待」按钮),tab 标题加 ⏳ 徽章;跳过后工具返回新增的 `{kind:'skipped'}`。
**Architecture:** 等待状态登记在 `AgentPtyRegistry` 的 handle 上,随现有 `/sidebar/ws/agent-terminals` 推送快照(`waiting` 字段)自然下发;跳过走新增 `/sidebar/api/agent-pty.skip-wait` 路由。客户端把推送镜像进 `state.agentWaits`,banner 组件与 tab 徽章从 store 读。
**Tech Stack:** TypeScript / React 18 / vitest(node 环境 pty 测试 + jsdom Sidebar 壳测试)/ ws 推送 / CSS Modules(皮肤令牌)。
**Spec:** `docs/plans/2026-09-09-terminal-wait-banner-design.md`(与本计划同分支,执行者两份都读)。
## Global Constraints
- 分支:`feat/wait-banner`(已含设计文档提交 `4a6a77a`);所有代码改动走该分支 → PR,禁止直推 main(分支保护)。
- 皮肤契约:新增 CSS 只允许 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌;本项目复用现成 warn 对 `--dsw-alias-state-warn-label` / `--dsw-alias-state-warn-tertiary`(仓库 CSS 无 state-info 令牌,已核实)。
- i18n:新增 zh key 必须同步 en 词典与 `src/client/locales-ja.ts` 的 ja 翻译。
- client bundle 纯度门:新文件只准 import 本插件模块(locales / css / api),禁止 value-import `@dsh-external/*` 或非白名单 `@deepseek-ai/*`。
- 不改 `src/client/service.ts` / `docs/external-plugin-guide.md`(⏳ 徽章走 shell 层特例,不动 TabDescriptor API)。
- 上游测试(含 win32 已知 27 个非回归失败)一律不改。
- 每个任务用 `pnpm exec vitest run <file>` 验证;最终任务必须 `pnpm lint` + `pnpm typecheck` + `pnpm test` + `pnpm build` 全绿才 push(CI 曾因漏 lint 挂过)。
- 提交信息风格:`feat(terminal): ...` / `test(...)`,仓库惯例小写 scope。
---
### Task 1: Registry 等待登记 + `skipped` 结果 + `waiting` 快照
**Files:**
- Modify: `src/agent-pty.ts`
- Test: `tests/agent-pty.spec.ts`
**Interfaces:**
- Consumes: 现有 `AgentPtyRegistry.waitFor` / `notify` / `subscribe` / `snapshotOf`。
- Produces:
- `type AgentTerminalActiveWait = { needle: string; since: number; skipped: boolean }`
- `AgentTerminalHandle.waits: AgentTerminalActiveWait[]`(create 时初始化为 `[]`)
- `AgentTerminalSnapshot.waiting?: { needle: string; since: number }`(最新一条活动等待)
- `AgentTerminalWaitResult` 新增 `{ kind: 'skipped'; needle: string }` 变体
- `registry.skipWait(uuid: string): number`(标记全部活动等待,返回条数;未知 uuid 抛 not-found)
- [ ] **Step 1: 写失败测试(4 个新用例,加在 `describe('AgentPtyRegistry')` 内、现有 `waitFor` 用例之后)**
在 `tests/agent-pty.spec.ts` 的 import 块不需要新增(`AgentPtyRegistry` / `snapshotOf` 已导入)。追加用例:
```ts
it('waitFor returns skipped when the user skips from the sidebar', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'skip-test', 'echo skip-ready', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'skip-ready')
// waitFor registers its record synchronously (before the first poll
// await), so the skip can fire immediately after the call.
const waitPromise = registry.waitFor(uuid, 'NEVER_APPEARS_XYZ', 30_000)
expect(await registry.skipWait(uuid)).toBe(1)
const result = await waitPromise
expect(result.kind).toBe('skipped')
if (result.kind === 'skipped') expect(result.needle).toBe('NEVER_APPEARS_XYZ')
// Idempotent: nothing left to skip once the wait resolved.
expect(registry.skipWait(uuid)).toBe(0)
} finally {
registry.disposeAll()
}
})
it('snapshot exposes waiting while a wait is active and clears after it ends', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'wait-snap', 'echo snap-ready', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'snap-ready')
const waitPromise = registry.waitFor(uuid, 'LATER_MARK_9', 30_000)
// Registration happens synchronously before waitFor's first await.
expect(registry.list('s1')[0]?.waiting?.needle).toBe('LATER_MARK_9')
expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number')
expect(registry.skipWait(uuid)).toBe(1)
const result = await waitPromise
expect(result.kind).toBe('skipped')
expect(registry.list('s1')[0]?.waiting).toBeUndefined()
} finally {
registry.disposeAll()
}
})
it('fires change listeners when a wait starts and ends', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'watched-wait', 'echo notify-ready', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'notify-ready')
let changes = 0
const unsubscribe = registry.subscribe(() => { changes += 1 })
const waitPromise = registry.waitFor(uuid, 'NEVER_NOTIFY_1', 30_000)
const afterStart = changes
expect(afterStart).toBeGreaterThanOrEqual(1)
expect(await registry.skipWait(uuid)).toBe(1)
expect(await waitPromise).toEqual({ kind: 'skipped', needle: 'NEVER_NOTIFY_1' })
expect(changes).toBeGreaterThan(afterStart)
unsubscribe()
} finally {
registry.disposeAll()
}
})
it('skipWait rejects an unknown uuid with not-found', () => {
const registry = new AgentPtyRegistry(testShell())
try {
expect(() => registry.skipWait('missing-uuid')).toThrow(/not found/)
} finally {
registry.disposeAll()
}
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/agent-pty.spec.ts`
Expected: FAIL — `waiting` 为 undefined、`skipWait` 不存在、skipped 分支不存在(TS 编译错误或断言失败)。
- [ ] **Step 3: 实现 `src/agent-pty.ts`**
(a) 在 `AgentTerminalReadResult` 之后、`AgentTerminalWaitResult` 之前新增:
```ts
/** One active wait_for registration on a handle (banner + skip bookkeeping). */
export interface AgentTerminalActiveWait {
/** The needle being awaited (shown on the sidebar wait banner). */
needle: string
/** Epoch ms when the wait registered (age display / debugging). */
since: number
/** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */
skipped: boolean
}
```
(b) `AgentTerminalWaitResult` union 末尾追加变体:
```ts
| {
/** The user skipped the wait from the sidebar banner. */
kind: 'skipped'
/** The needle that was awaited. */
needle: string
}
```
(c) `AgentTerminalSnapshot` 追加字段(`exitSignal` 之后):
```ts
/**
* The model's active `terminal_wait_for` on this terminal (the sidebar
* renders the wait banner from it). Present only while a wait is
* registered; carries the LATEST wait when several overlap.
*/
waiting?: { needle: string; since: number }
```
(d) `AgentTerminalHandle` 追加字段(`exitSignal` 之后):
```ts
/** Active wait_for registrations (skip bookkeeping; empty while idle). */
waits: AgentTerminalActiveWait[]
```
(e) `create()` 的 handle 字面量中 `exited: false,` 之后加 `waits: [],`。
(f) `snapshotOf` 在 `return out` 前追加:
```ts
const active = handle.waits.at(-1)
if (active !== undefined) out.waiting = { needle: active.needle, since: active.since }
```
(g) 新方法(放在 `waitFor` 之后):
```ts
/**
* Mark every active wait on one terminal as skipped (the sidebar banner's
* skip button). Each waiting poll loop observes its record's flag within
* one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
* is waiting (a stale banner racing a wait that already resolved).
* @returns the number of waits that transitioned to skipped.
*/
skipWait(uuid: string): number {
const handle = this.expect(uuid)
let count = 0
for (const record of handle.waits) {
if (!record.skipped) {
record.skipped = true
count += 1
}
}
return count
}
```
(h) 重写 `waitFor` 中段(`const firstHit = ...` 快速路径之后到方法结尾),登记记录 + 轮询检查 + `finally` 清理:
```ts
const firstHit = locateNeedle(handle.transcript, needle, re)
if (firstHit !== undefined) {
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start }
}
// Register the active wait so the sidebar can show the wait banner (the
// snapshot's `waiting` field rides the agent-terminals push). Registered
// only after the fast paths: a wait that resolves instantly never
// flashes the banner. The notify() makes every push subscriber (the
// sidebar view) converge on the new waiting state immediately.
const record: AgentTerminalActiveWait = { needle, since: start, skipped: false }
handle.waits.push(record)
this.notify()
try {
while (true) {
if (signal?.aborted) signal.throwIfAborted()
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
if (record.skipped) {
return { kind: 'skipped', needle }
}
const hit = locateNeedle(handle.transcript, needle, re)
if (hit !== undefined) {
return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start }
}
if (Date.now() >= deadline) {
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
}
await new Promise(resolve => {
const t = setTimeout(resolve, 50)
// Allow the Node process to exit even if the timer is pending.
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
})
}
} finally {
const index = handle.waits.indexOf(record)
if (index !== -1) handle.waits.splice(index, 1)
this.notify()
}
```
(i) `waitFor` 的 JSDoc:`@returns one of `found` / `timeout` / `exited`.` 改为 `@returns one of `found` / `timeout` / `exited` / `skipped`.`;并在 `@param signal` 行后补一行 `* A wait can also be skipped by the user from the sidebar banner (`skipWait`), which resolves it with `{kind:'skipped'}`.`
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/agent-pty.spec.ts`
Expected: PASS(全部用例,含既有 425 行文件内的用例)。
- [ ] **Step 5: Commit**
```bash
git add src/agent-pty.ts tests/agent-pty.spec.ts
git commit -m "feat(terminal): registry 支持等待登记、skipWait 与 skipped 结果"
```
---
### Task 2: 工具层 `skipped` 分支 + 宿主 `agent-pty.skip-wait` 路由
**Files:**
- Modify: `src/tools.ts`(terminal_wait_for 定义,约 287-377 行)
- Modify: `src/index.ts`(buildApi 表,`'agent-pty.close'` 之后,约 545 行)
- Test: `tests/tools.spec.ts`
**Interfaces:**
- Consumes: Task 1 的 `AgentTerminalWaitResult` skipped 变体、`registry.skipWait(uuid): number`。
- Produces: `/sidebar/api/agent-pty.skip-wait` 路由(payload `{uuid}` → `{ok:true, skipped:number}`);工具 output schema 第四分支;Task 4 的客户端 `api.agentSkipWait` 调它。
- [ ] **Step 1: 写失败测试**
`tests/tools.spec.ts`:(a) `FakeRegistry` 增加注入口 —— 在 `readonly terminals = ...` 行后加属性,并把 `waitFor` 改为:
```ts
/** When set, waitFor resolves with the skipped shape instead of the found default. */
nextWaitResult: { kind: 'skipped' } | undefined
waitFor(_uuid: string, needle: string): Promise<
{ kind: 'found'; needle: string; line: number; column: number; match: string; elapsedMs: number }
| { kind: 'skipped'; needle: string }
> {
// Mirrors the registry's empty-needle rejection (the tool layer no
// longer duplicates this check).
if (needle === '') return Promise.reject(new Error('needle must be a non-empty string'))
if (this.nextWaitResult !== undefined) return Promise.resolve({ kind: 'skipped', needle })
return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, match: needle, elapsedMs: 1 })
}
```
(b) 在 `describe('agent terminal tools')` 内、现有 `terminal_wait_for returns the registry result...` 用例后追加:
```ts
it('terminal_wait_for returns skipped (schema-valid) when the registry reports a user skip', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_wait_for')
registry.nextWaitResult = { kind: 'skipped' }
const value = await tool.execute({ uuid: 'uuid-1', needle: 'BUILD_OK', timeout_ms: 1000 }, exec('s1'))
expect(value).toEqual({ kind: 'skipped', needle: 'BUILD_OK' })
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_wait_for render describes the user skip and the needle', () => {
const { captured } = mount()
const tool = toolOf(captured, 'terminal_wait_for')
const blocks = tool.output.render({}, { kind: 'skipped', needle: 'BUILD_OK' }) as Array<{ type: string; text: string }>
expect(blocks[0]!.type).toBe('text')
expect(blocks[0]!.text).toContain('Skipped by user')
expect(blocks[0]!.text).toContain('BUILD_OK')
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/tools.spec.ts`
Expected: FAIL(schema 拒绝 skipped / render 走到 exited 分支输出不符)。
- [ ] **Step 3: 实现**
(a) `src/tools.ts` terminal_wait_for:
描述末尾(`'The wait is cooperative: ...'` 句之后)追加一句:
```ts
+ 'The user can skip the wait from the sidebar ( a banner on the terminal\'s tab shows the needle and a skip button ) — the tool then returns `skipped`.',
```
output.schema 的 `oneOf` 数组末尾(exited 分支对象之后)追加:
```ts
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'skipped' },
needle: { type: 'string', required: true },
},
},
```
render 的 value 类型与分支 —— `const v = value as { kind: 'found' | 'timeout' | 'exited' | 'skipped'; ... }`(原行加 `'skipped'`),并在 timeout 分支后插入:
```ts
if (v.kind === 'skipped') {
return [{ type: 'text', text: `Skipped by user while waiting for "${v.needle}" — the wait ended early. Call terminal_read to inspect the transcript and decide how to proceed.` }]
}
```
(b) `src/index.ts` buildApi 表,`'agent-pty.close'` 条目(约 541-545 行)之后追加:
```ts
// The sidebar wait banner's skip button: abort every active
// terminal_wait_for on one agent terminal. Idempotent — 0 when nothing
// is waiting (a stale banner racing a wait that already resolved).
// Degraded mode (node-pty unavailable) has no registry and no waits: an
// honest ok.
'agent-pty.skip-wait': (payload) => {
const uuid = requireString(payload, 'uuid')
return { ok: true, skipped: agentPtyRegistry?.skipWait(uuid) ?? 0 }
},
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/tools.spec.ts`
Expected: PASS。
- [ ] **Step 5: Commit**
```bash
git add src/tools.ts src/index.ts tests/tools.spec.ts
git commit -m "feat(terminal): terminal_wait_for 增 skipped 结果与 agent-pty.skip-wait 路由"
```
---
### Task 3: 客户端状态 `agentWaits` 镜像 + 推送类型
**Files:**
- Modify: `src/client/state.ts`(SidebarState 接口 ~121 行、makeDefaultState ~221 行、sanitizeState ~1283 行、reconcileAgentTerminals ~1017 行)
- Modify: `src/client/sidebar/use-host-feeds.ts`(~66 行解析类型)
- Test: `tests/agent-terminal-reconcile.spec.ts`
**Interfaces:**
- Consumes: 宿主推送数组(Task 1 的 snapshot 含 `waiting`)。
- Produces:
- `SidebarState.agentWaits: Record<string, { needle: string; since: number }>`(uuid → 活动等待;瞬态,不持久化)
- `reconcileAgentTerminals(state, list)` 的 list 元素增 `waiting?: { needle: string; since: number } | null`,并把等待状态镜像进 `agentWaits`(仅等待变化也产出新状态)。
- [ ] **Step 1: 写失败测试**
`tests/agent-terminal-reconcile.spec.ts` 追加两个用例(沿用文件现有 import,无需新增):
```ts
it('mirrors the pushed waiting state into agentWaits and clears it when it ends', () => {
let s = makeDefaultState()
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'busy', waiting: { needle: 'READY_1', since: 42 } }])
expect(s.agentWaits['aaa-111']).toEqual({ needle: 'READY_1', since: 42 })
// The next push without waiting drops the entry (the push is authoritative).
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
expect(s.agentWaits['aaa-111']).toBeUndefined()
})
it('produces a new state when ONLY the wait state changes (no tab add/remove)', () => {
let s = makeDefaultState()
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
const before = s
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable', waiting: { needle: 'N', since: 1 } }])
expect(next).not.toBe(before)
expect(next.agentWaits['aaa-111']).toBeDefined()
// Idempotent: the same push again returns the same reference.
expect(reconcileAgentTerminals(next, [{ uuid: 'aaa-111', title: 'stable', waiting: { needle: 'N', since: 1 } }])).toBe(next)
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/agent-terminal-reconcile.spec.ts`
Expected: FAIL(`agentWaits` 不存在 → TS 错误 / 断言失败)。
- [ ] **Step 3: 实现**
(a) `SidebarState` 接口 `floats: FloatWindow[]` 之后追加:
```ts
/**
* Live agent-terminal wait state (uuid → the wait the model currently
* blocks on in `terminal_wait_for`), mirrored from the host's
* agent-terminals push. Transient by design: sanitizeState never restores
* it, so a reload starts clean and the next push (sent immediately on WS
* attach) repopulates it.
*/
agentWaits: Record<string, { needle: string; since: number }>
```
(b) `makeDefaultState` 的返回字面量 `floats: [],` 之后加 `agentWaits: {},`。
(c) `sanitizeState` 的返回字面量 `floats,` 之后加(带注释):
```ts
// The agent wait state is TRANSIENT (like revealed): never restored from
// storage — the host's first push after attach repopulates it.
agentWaits: {},
```
(d) `reconcileAgentTerminals`:参数类型改为
```ts
agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
```
在 `const toRemove = ...` 之后、`if (toAdd.length === 0 && toRemove.length === 0) return state` 处,替换为:
```ts
// Mirror the live wait state from the push (authoritative: a vanished
// waiting field simply drops the entry). A waits-only change must still
// produce a new state — the tab add/remove no-change check alone would
// swallow banner updates.
const serverWaits: Record<string, { needle: string; since: number }> = {}
for (const terminal of agentTerminals) {
if (terminal.waiting !== undefined && terminal.waiting !== null) {
serverWaits[terminal.uuid] = { needle: terminal.waiting.needle, since: terminal.waiting.since }
}
}
if (toAdd.length === 0 && toRemove.length === 0 && sameAgentWaits(state.agentWaits, serverWaits)) return state
```
文件末尾的 `return next` 改为 `return { ...next, agentWaits: serverWaits }`。
在 `reconcileAgentTerminals` 前方新增辅助函数:
```ts
/** Shallow equality of two agent-wait maps (same keys, same needle+since). */
function sameAgentWaits(
a: SidebarState['agentWaits'] | undefined,
b: Record<string, { needle: string; since: number }>,
): boolean {
if (a === undefined) return Object.keys(b).length === 0
const aKeys = Object.keys(a)
if (aKeys.length !== Object.keys(b).length) return false
for (const key of aKeys) {
const av = a[key]
const bv = b[key]
if (av === undefined || bv === undefined) return false
if (av.needle !== bv.needle || av.since !== bv.since) return false
}
return true
}
```
(e) `src/client/sidebar/use-host-feeds.ts` 66 行的解析类型改为:
```ts
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean; waiting?: { needle: string; since: number } | null }>
```
- [ ] **Step 4: 跑测试确认通过 + 全仓 typecheck(状态字段新增会暴露遗漏的构造点)**
Run: `pnpm exec vitest run tests/agent-terminal-reconcile.spec.ts && pnpm typecheck`
Expected: PASS。若 typecheck 报其他 `SidebarState` 字面量缺 `agentWaits`(tests 里的手搓状态),补 `agentWaits: {}` —— **只许改测试里测试自己构造的字面量,不许改上游断言逻辑**。
- [ ] **Step 5: Commit**
```bash
git add src/client/state.ts src/client/sidebar/use-host-feeds.ts tests/agent-terminal-reconcile.spec.ts
git commit -m "feat(sidebar): 镜像 agent 终端等待状态到 agentWaits(瞬态)"
```
---
### Task 4: 等待 banner 组件 + TerminalView 接线 + api 方法 + i18n + CSS
**Files:**
- Create: `src/client/TerminalWaitBanner.tsx`
- Modify: `src/client/TerminalView.tsx`(render 区 ~387-408 行 + 组件顶部订阅)
- Modify: `src/client/api.ts`(`agentPtyClose` 之后 ~348 行)
- Modify: `src/client/locales.ts`(zh ~117 / en ~567,紧随 `terminalRetry`)
- Modify: `src/client/locales-ja.ts`(~105,紧随 `terminalRetry`)
- Modify: `src/client/sidebar.module.css`(`.terminalBanner` 块之后 ~2202 行)
- Test: `tests/terminal-wait-banner.spec.tsx`(新建)
**Interfaces:**
- Consumes: Task 2 的 `agent-pty.skip-wait` 路由;Task 3 的 `state.agentWaits`;`api` 对象、`t()`、`css` module。
- Produces: `TerminalWaitBanner({ needle: string; onSkip: () => void })` + `truncateNeedle(needle: string): string`(80 字符截断);词典键 `terminalWaitBanner` / `terminalSkipWait`。
- [ ] **Step 1: 写失败测试(新文件 `tests/terminal-wait-banner.spec.tsx`)**
```tsx
/**
* TerminalWaitBanner unit tests: localized text with the needle, the skip
* button wiring, and the long-needle display cap (full text stays on the
* title attribute). The component imports no xterm — renderable in plain
* jsdom (the repo's jsdom pattern, see bottom-auto-terminal.spec.tsx head).
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
import { TerminalWaitBanner } from '../src/client/TerminalWaitBanner.tsx'
describe('TerminalWaitBanner', () => {
const mounted: Array<{ root: Root; container: HTMLDivElement }> = []
function mount(needle: string, onSkip: () => void): HTMLDivElement {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => { root.render(createElement(TerminalWaitBanner, { needle, onSkip })) })
mounted.push({ root, container })
return container
}
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => { root.unmount() })
container.remove()
}
})
it('shows the localized waiting text with the needle and a working skip button', () => {
let skipped = 0
const container = mount('BUILD_OK', () => { skipped += 1 })
// jsdom navigator.language is en-US → the en dictionary applies.
expect(container.textContent).toContain('Agent is waiting for')
expect(container.textContent).toContain('BUILD_OK')
expect(container.textContent).toContain('Skip wait')
const button = container.querySelector('button')
expect(button).not.toBeNull()
act(() => { button!.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(skipped).toBe(1)
})
it('truncates a long needle for display but keeps the full text on the title', () => {
const long = 'X'.repeat(200)
const container = mount(long, () => {})
const span = container.querySelector('span[title]')
expect(span).not.toBeNull()
expect(span!.getAttribute('title')).toBe(long)
expect((span!.textContent ?? '').length).toBeLessThanOrEqual(80)
})
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/terminal-wait-banner.spec.tsx`
Expected: FAIL(模块不存在)。
- [ ] **Step 3: 实现**
(a) 新建 `src/client/TerminalWaitBanner.tsx`:
```tsx
/**
* The "Agent 正在等待 {needle}" wait banner: rendered at the top of an
* agent-owned terminal's view while the model blocks in terminal_wait_for.
* The skip button asks the host to abort every active wait on the terminal
* (`agent-pty.skip-wait`); the banner disappears when the host's next
* agent-terminals push drops the waiting field — no optimistic UI. Kept in
* its own module (no xterm imports) so jsdom tests can render it directly.
*/
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** Cap the needle shown inline; the full text rides the title tooltip. */
const NEEDLE_DISPLAY_CAP = 80
/** Truncate one needle for inline display (title attr carries the full text). */
export function truncateNeedle(needle: string): string {
return needle.length > NEEDLE_DISPLAY_CAP ? `${needle.slice(0, NEEDLE_DISPLAY_CAP - 1)}…` : needle
}
export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) {
const { needle, onSkip } = props
return (
<div className={css.terminalWaitBanner}>
<span className={css.terminalWaitNeedle} title={needle}>
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
</span>
<button type="button" className={css.terminalRetry} onClick={onSkip}>
{t('terminalSkipWait')}
</button>
</div>
)
}
```
(b) `src/client/api.ts`:`agentPtyClose` 条目之后追加:
```ts
/** Skip every active terminal_wait_for on one agent terminal (the wait
* banner's skip button). Idempotent: {skipped:0} when none is active. */
agentSkipWait: (uuid: string) =>
call<{ ok: true; skipped: number }>('agent-pty.skip-wait', { uuid }),
```
(c) `src/client/locales.ts`:zh 词典 `terminalRetry: '重试',` 行后加:
```ts
terminalWaitBanner: 'Agent 正在等待 {needle}',
terminalSkipWait: '跳过等待',
```
en 词典 `terminalRetry: 'Retry',` 行后加:
```ts
terminalWaitBanner: 'Agent is waiting for {needle}',
terminalSkipWait: 'Skip wait',
```
(d) `src/client/locales-ja.ts`:`terminalRetry: '再試行',` 行后加(仓库规则:新 zh 键必须同步 ja):
```ts
terminalWaitBanner: 'エージェントが {needle} を待機中',
terminalSkipWait: '待機をスキップ',
```
(e) `src/client/sidebar.module.css`:`.terminalBannerUrl` 块之后加:
```css
/* The "Agent 正在等待 {needle}" wait banner: a warn-toned strip above the
terminal surface while the model blocks in terminal_wait_for; the needle
ellipsizes inline and the full text rides the title tooltip. */
.terminalWaitBanner {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 3px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.terminalWaitNeedle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
```
(f) `src/client/TerminalView.tsx`:
imports 区追加:
```ts
import { TerminalWaitBanner } from './TerminalWaitBanner.tsx'
```
组件体内(`const [lastUrl, setLastUrl] = ...` 之后)加等待订阅:
```ts
// Agent terminals only: the model's active terminal_wait_for (mirrored
// from the host's agent-terminals push into the store) drives the wait
// banner. Read + subscribe like the font prefs above; the banner vanishes
// when the host's push drops the waiting field (skip / exit / abort all
// converge through the same push). getSnapshot() is {sessionId, state?,
// prefs} — the state may be briefly undefined around session switches.
const agentUuid = isAgentTabId(tabId) ? agentUuidOf(tabId) : null
const [waiting, setWaiting] = useState<{ needle: string; since: number } | undefined>(undefined)
useEffect(() => {
if (agentUuid === null) return
const read = (): void => {
const next = store.getSnapshot().state?.agentWaits?.[agentUuid]
setWaiting(prev => {
const nextValue = next === undefined ? undefined : { needle: next.needle, since: next.since }
if (prev?.needle === nextValue?.needle && prev?.since === nextValue?.since) return prev
return nextValue
})
}
read()
return store.subscribe(read)
}, [agentUuid, store])
```
return JSX 中 `<div className={css.terminalWrap}>` 的第一个子元素位置(`depsFatal` 条目之前)加:
```tsx
{agentUuid !== null && waiting !== undefined && (
<TerminalWaitBanner
needle={waiting.needle}
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
/>
)}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/terminal-wait-banner.spec.tsx`
Expected: PASS。
- [ ] **Step 5: Commit**
```bash
git add src/client/TerminalWaitBanner.tsx src/client/TerminalView.tsx src/client/api.ts src/client/locales.ts src/client/locales-ja.ts src/client/sidebar.module.css tests/terminal-wait-banner.spec.tsx
git commit -m "feat(sidebar): agent 终端等待 banner(needle + 跳过等待按钮)"
```
---
### Task 5: tab 标题 ⏳ 徽章(shell 层特例)+ jsdom 链路测试
**Files:**
- Modify: `src/client/Sidebar.tsx`(`tabBadgeOf` ~828-841 行)
- Test: `tests/agent-wait-badge.spec.tsx`(新文件,harness 照抄 `tests/bottom-auto-terminal.spec.tsx` 1-97 行的模式)
**Interfaces:**
- Consumes: Task 3 的 `state.agentWaits`;`isAgentTabId` / `agentUuidOf`(Sidebar.tsx 已 import,第 38-39 行)。
- Produces: agent tab 的 ⏳ 徽章 pill(复用 `css.tabBadge`);推送 → 状态 → 徽章全链路。
- [ ] **Step 1: 写失败测试(新文件 `tests/agent-wait-badge.spec.tsx`)**
完整文件(FakeWebSocket 记录 url 与实例,驱动 `/sidebar/ws/agent-terminals` 推送;`registerStubTerminal` 防止懒加载 chunk 在 jsdom 里拉真实 bundle):
```tsx
/**
* The agent-terminal wait badge chain, end to end in jsdom: the host's
* agent-terminals push (mirroring the real /sidebar/ws/agent-terminals
* frames) reconciles tabs AND the wait map; the shell's tabBadgeOf renders
* the hourglass pill on the agent tab while a wait is live and drops it
* when the push clears. Harness mirrors tests/bottom-auto-terminal.spec.tsx
* (real Sidebar shell + fake context + stubbed WebSocket).
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
import { Sidebar } from '../src/client/Sidebar.tsx'
import { createSidebarStore, openTabInActivePane } from '../src/client/state.ts'
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
class FakeWebSocket {
static instances: FakeWebSocket[] = []
onmessage: ((event: { data: unknown }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
close = (): void => {}
constructor(readonly url: string) {
FakeWebSocket.instances.push(this)
}
}
let sessionSeq = 0
function mountSidebar(): { container: HTMLDivElement; store: ReturnType<typeof createSidebarStore>; service: BetterSidebarService; unmount: () => void } {
vi.stubGlobal('WebSocket', FakeWebSocket)
FakeWebSocket.instances = []
const container = document.createElement('div')
document.body.append(container)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// Replace the terminal descriptor with a stub: the real one lazy-loads the
// xterm chunk, which jsdom cannot fetch — the badge under test renders in
// the TAB STRIP, independent of the view component.
service.registerTab({
id: 'terminal',
title: () => 'Terminal',
component: () => null,
})
const sessionId = `s1-${++sessionSeq}`
store.setSession(sessionId)
// Stub the terminal descriptor: the real one lazy-loads the xterm chunk,
// which jsdom cannot fetch (the bottom-auto-terminal harness does the same).
service.registerTab({
id: 'terminal',
title: () => 'Terminal',
component: () => null,
})
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = {
current: sessionId,
byId: { [sessionId]: { cwd: '/tmp' } },
}
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
betterSidebar: service,
get: (name: string) => name === 'betterSidebar' ? service : undefined,
}
const root: Root = createRoot(container)
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
return {
container,
store,
service,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
let sessionSeq = 0
afterEach(() => {
document.body.innerHTML = ''
localStorage.clear()
vi.unstubAllGlobals()
})
/** The agent-terminals push socket the shell opened for the current session. */
function feedsSocket(): FakeWebSocket {
const socket = FakeWebSocket.instances.find(candidate => candidate.url.includes('/sidebar/ws/agent-terminals'))
if (socket === undefined) throw new Error('agent-terminals socket not connected')
return socket
}
describe('agent terminal wait badge (push → state → tab pill)', () => {
it('shows ⏳ on the agent tab while a wait is active and drops it when it ends', () => {
const { container } = mountSidebar()
const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false }
// First push: the agent tab lands in the active pane.
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
expect(container.textContent).toContain('dev server')
expect(container.textContent).not.toContain('⏳')
// Second push carries a live wait → the hourglass pill appears.
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) })
expect(container.textContent).toContain('⏳')
// The wait resolved (skip/exit/abort all converge to a waiting-less push).
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
expect(container.textContent).not.toContain('⏳')
})
it('shows ⏳ only on agent tabs, never on UI-owned terminal tabs', () => {
const { container, store } = mountSidebar()
// A UI-owned terminal tab (id NOT agent:) lives in the strip too; the
// badge lookup keys on the agent uuid, so it never badges.
act(() => { store.reduce(s => openTabInActivePane(s, { id: 'terminal:manual-1', type: 'terminal', title: 'UI terminal' })) })
const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false }
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) })
// Exactly ONE hourglass: the agent tab. (Counted from textContent — CSS
// module class names are not stable under the test transform.)
expect((container.textContent.match(/⏳/g) ?? []).length).toBe(1)
// The wait resolved → the pill disappears entirely.
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
expect(container.textContent).not.toContain('⏳')
})
})
```
> 实现注意:`sessionSeq` 声明放 `mountSidebar` 之前(与 bottom-auto-terminal.spec.tsx 相同的模块级写法);`createSidebarStore` 返回类型直接用 `ReturnType<typeof createSidebarStore>`,勿手写接口。
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/agent-wait-badge.spec.tsx`
Expected: FAIL(⏳ 从不出现)。
- [ ] **Step 3: 实现 Sidebar.tsx 的 `tabBadgeOf` 特例**
`src/client/Sidebar.tsx` 828 行 `const tabBadgeOf = (tab: SidebarTab): ReactNode => {` 函数体开头(descriptor 查找之前)插入:
```ts
// Agent-terminal wait indicator (sidebar-internal, deliberately NOT a
// TabDescriptor.badge — that API is type-keyed and shared with external
// plugins, and cannot address one tab): the agent-terminals push mirrors
// the model's live terminal_wait_for into state.agentWaits; an agent tab
// whose uuid is waiting shows the hourglass pill.
if (isAgentTabId(tab.id)) {
const wait = state.agentWaits?.[agentUuidOf(tab.id)]
if (wait !== undefined) return <span className={css.tabBadge}>{'⏳'}</span>
}
```
(`isAgentTabId` / `agentUuidOf` 已在 38-39 行 import;`css` / `state` 均在作用域内。)
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/agent-wait-badge.spec.tsx`
Expected: PASS(2 个用例)。
- [ ] **Step 5: Commit**
```bash
git add src/client/Sidebar.tsx tests/agent-wait-badge.spec.tsx
git commit -m "feat(sidebar): agent 终端 tab 标题在等待时显示 ⏳ 徽章"
```
---
### Task 6: 设计文档偏差记录 + 全量验证 + 推送 + PR
**Files:**
- Modify: `docs/plans/2026-09-09-terminal-wait-banner-design.md`(实施偏差记录节)
- 无代码改动。
- [ ] **Step 1: 更新设计文档**
在 `docs/plans/2026-09-09-terminal-wait-banner-design.md` 末尾追加:
```markdown
## 实施偏差记录
- **⏳ tab 徽章不走 `TabDescriptor.badge`**:实现时核实该 API 是 type-keyed
(`badge(ctx, scope, state)`,无 tab 参数,external 插件共享),无法定位单个
tab。改为 Sidebar 壳层 `tabBadgeOf` 的 sidebar-internal 特例(agent: tab 前缀
判断 + `state.agentWaits` 查表),`service.ts` 与 external-plugin-guide.md
零改动。
- **banner 抽为独立组件 `TerminalWaitBanner.tsx`**:设计原文写在
TerminalView.tsx 内;抽出后无 xterm 依赖,可直接被 jsdom 单测渲染
(TerminalView 仍负责订阅 store 并接线 onSkip → api.agentSkipWait)。
- **设计文档随 feat 分支进 PR**:main 受分支保护,纯文档直推被 hook 拒绝
(`4a6a77a` 随本分支合并)。
- **banner 配色**:仓库 CSS 无 state-info 令牌,沿用 `.terminalBanner` 的
warn 对(`--dsw-alias-state-warn-label/-tertiary`),符合皮肤契约。
```
- [ ] **Step 2: 全量验证**
Run(依次,全绿才继续):
`pnpm lint` → `pnpm typecheck` → `pnpm test` → `pnpm build`
Expected:
- lint: 0 error(历史上 CI 因 no-useless-assignment 挂过——新代码避免「先声明后必然覆盖」的写法);
- test: 除已知的 win32 非回归失败集(bottom-auto-terminal / free-window / sidebar-auto-activation / sidebar-crash 等既有失败,对照 main 基线)外全绿,新增 spec 全部 PASS;
- build: lib/ 产物含新 chunk `client.js` 变化(TerminalWaitBanner 打进 client bundle)。
- [ ] **Step 3: 提交并推送**
```bash
git add docs/plans/2026-09-09-terminal-wait-banner-design.md
git commit -m "docs(plans): 终端等待 banner 实施偏差记录"
git push -u origin feat/wait-banner
```
- [ ] **Step 4: 建 PR**
```bash
gh pr create --repo omdsh-dev/DSH-better-sidebar --base main --head feat/wait-banner \
--title "feat(terminal): 等待 banner + 跳过等待(terminal_wait_for skipped)" \
--body "设计:docs/plans/2026-09-09-terminal-wait-banner-design.md(随本 PR)。agent 阻塞在 terminal_wait_for 时:对应终端卡片顶部显示「Agent 正在等待 {needle}」banner,右侧跳过按钮走 agent-pty.skip-wait;tab 标题 ⏳ 徽章;工具新增 {kind:'skipped'} 返回。"
```
- [ ] **Step 5: 等待 CI(用 terminal_wait_for 盯 `(ci|ci-windows|plugin-mount).*\((pass|fail)\)` 之类的多结局 needle,勿 sleep)**
Expected: 三个 job 全 pass。
@@ -1,60 +0,0 @@
# 自动激活落点修复:任务页回到 DSH 原生右侧栏
日期:2026-09-10 分支:`fix/auto-activation-native-landing`
## 背景
用户报告:**自动打开的侧边栏是底栏,而不是右侧边栏**。两个自动激活开关(`autoOpenSubagent` / `autoOpenJobs`)与子代理拓扑跳回在 v0.19.0 上把任务页(`subagent` tab)开进了插件自绘的**底部工作台**,底栏随之展开;而产品文案描述的是侧边栏落点——README 特性表「新子代理 / 新任务可自动激活任务页,**宽屏同时展开侧边栏**,窄屏不强制展开全屏抽屉」,`settingsSubagentDesc` / `settingsJobsDesc` 及全部 20 份词典同样措辞,README 的 #314 条目也写着「宽屏展开侧边栏,窄屏只准备 Tab」。
## 根因
`223b254`(v0.19.0-alpha.0「退役插件自绘右侧面板与自由窗口」)改写了 `src/client/sidebar/use-host-feeds.ts` 的三处触发:
| | 改动前(v0.18.x) | 改动后(v0.19.0) |
|---|---|---|
| 展开 | `store.reduce(s => s.panelOpen ? s : togglePanel(s))`(宽屏才展开) | 无(由落点决定) |
| 落点 | `activePane = firstLeaf(s.splits).id` + `openTab({ type:'subagent', title })` —— **无 `target`** | `openTab({ type:'subagent', title, target:'bottom' })` |
`service.ts:683` 的判据是「装了原生面 + 非 `bottom` → 落 DSH 原生右侧栏」,`target: 'bottom'` 直接绕过它走 `land = openTabInBottomPane`(`service.ts:744` → `state.ts:453`,置 `bottomOpen: true`)。也就是说:退役右侧面板时,`splits`(插件自己的右面板)消失,落点被改成了插件自己的**底**面板,而正解是当时同文件其它打开早已默认落上的新宿主——DSH 原生右侧栏。
## 决策
| # | 决策 | 理由 |
|---|---|---|
| 1 | 三处触发改回默认落点(`'right'`) | 宿主 `openContent` 内部 `planSetExpanded(state, true)`(`dsh-client-ui-sidebar-right`),打开即在同一步展开原生栏;这才是文案里的「侧边栏」 |
| 2 | 后台活动(子代理 / 新任务)在窄屏**停放**,不展开 | 宿主 `RightbarSeat` 有 `autoFullscreen = viewportWidth < 768`,该宽度下原生栏以 `position: fixed; inset: 0` 全屏绘制——展开即整屏抢占,正是 #373 修掉的行为。宿主没有「只放 tab、不展开」的公开选项,故开前读 `ctx.sidebarRight.isExpanded()`、开后 `toggleExpanded()` 收回;两次提交落在同一 React 批内,中间态不会渲染 |
| 3 | 停放仅在目标会话在屏时执行 | `isExpanded` / `toggleExpanded` 作用于**已挂载会话**(`mounted()` = `binding.surfaces[binding.sessionId]`);目标不在屏时执行会误动用户当前那一栏 |
| 4 | 拓扑跳回(用户点击)一律展开 | 它是显式手势,不是后台活动;对齐 v0.18.x 跳回无窄屏门禁的语义 |
| 5 | 底部工作台自有流程保持 `target: 'bottom'` | 底栏 `+` 菜单(`Sidebar.tsx:628`)与首次展开自动终端(`:336`)本就该落底栏;`OpenTabSeed.target` API 不变 |
阈值一致性:插件既有 `NARROW_MAX_WIDTH = 768`(`width < 768`,`src/client/breakpoints.ts`)与宿主 `viewportWidth < 768` 的全屏规则逐字对齐,不引入第二个断点。
窄屏语义与 v0.18.x 等价:**tab 已就位、抽屉不展开**(用户点开会话头右侧的展开控件即可看到任务页)。
## 改动清单(子系统级)
| 子系统 | 文件 | 变更 |
|---|---|---|
| 触发 | `src/client/sidebar/use-host-feeds.ts` | 新增模块内 `activateTasksPage(ctx, sessionId, { background })`(落点 + 停放判定集中在此);三处调用改为它(子代理 / 后台任务 `background: true`,跳回 `false`);重新引入 `isNarrowWidth`;三段 docblock 与文件头同步 |
| 测试 | `tests/sidebar-auto-activation.spec.tsx` | 断言原生面收到 `openTab { kind:'subagent', revealIfOpened:true }`、底部工作台保持原状(未展开、未新增 tab)、窄屏停放次数(1 / 0)、宽屏与已展开栏不停放、去抖触发时读视口、跳回不停放 |
无 API / 持久化 / i18n / README 行为文案改动:文案本就在描述修复后的行为。
## 验证
- **回归守护(确定性)**:新 spec 10 passed;把 `src/client/sidebar/use-host-feeds.ts` 回退到 `HEAD` 后同 10 条**全红**(10 failed),证明它测的正是这次修复。
- **门槛**:`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 干净;`pnpm test` = **124 files / 1299 passed / 9 skipped**(较基线 +3 条,即本 spec 的净增)。
- **打包挂载**:`pnpm build && pnpm pack && pnpm test:mount` = **7 passed**(真实 DSH 0.1.5-rc.1,scratch profile,不触碰用户 `~/.dsh`)。
- **环境事实(踩过一次)**:本机 PATH 上的 `dsh` 是 **0.1.2-rc.1**,用它跑挂载车道会在 `dsh plugin add` 后报 `dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册`(该版本不会把 `file:` tarball 注册进 profile bundles,且插件 peer 要求 `^0.1.5-rc.1`)。CI 装的是钉版 0.1.5-rc.1;本地需用同版本的 shim / `DSH_CMD` 才能复现车道结果。
- **真机**:见 PR 描述(3080 实例,web profile 换装新 tarball 后人工触发子代理 / 后台任务确认)。
## 未覆盖(诚实记录)
- 挂载车道是 keyless 的(无模型),无法真派生子代理或后台任务,因此**该行为只有单测是确定性证据**;宿主 `session/create` 的请求体不接受 `origin` / `parentId`,也无法用 RPC 造出 `origin: 'subagent'` 的子会话,所以真机断言只能依赖人工触发或非确定性的 prompt。
- 宿主若在窄屏自行折叠(`shown && !fullscreen && !canShow` 的 room rule),停放不会与之冲突(两者都指向「不展开」)。
## 不做
- 不新增设置项、不改变 `OpenTabSeed.target` 的语义、不把任务页改到别处承载。
- 不动底栏 `+` 菜单 / 首次展开自动终端(它们是底部工作台自己的流程)。
- 不改 DSH 源码(仓库硬约束 §1)。
@@ -1,86 +0,0 @@
# DSH 0.1.5-rc.1 适配(v0.19.0 正式版基线)
日期:2026-09-10 分支:`feat/dsh-0.1.5-rc.1`
## 背景
DSH `dsh-v0.1.5-alpha.2..dsh-v0.1.5-rc.1`(上游 commit `183f08e9c`,npm 发布 2026-09-10T03:12Z)的 delta 很小:373 个变更文件,绝大多数是各包 `package.json` 版本号、原生右侧栏预览 UI 打磨与测试快照。触及本插件的只有一处契约回归——`SidebarRightGuideEntry.description` 回来了。本计划记录该 delta、插件对它的取舍、以及 v0.19.0 正式版的基线决策。上一版(alpha.2)的适配记录见 [2026-09-09-dsh-0.1.5-alpha.2-adaptation.md](./2026-09-09-dsh-0.1.5-alpha.2-adaptation.md);插件对外的全部行为差异以 [docs/external-plugin-guide.md](../external-plugin-guide.md) 为准;仓库级适配要点(基线、CI 钉版、宿主契约)同步到 [AGENTS.md](../AGENTS.md) §2/§3。
## 上游 delta(alpha.2 → rc.1,影响本插件的三项)
| # | 上游变更 | 对插件的影响 |
|---|---|---|
| 1 | `SidebarRightGuideEntry.description?: () => string` 回归(可选):原生指南**只在列出条目 ≤ 4 条时**渲染它(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢弃说明;没有 `icon` 的条目改由宿主补方块占位 | 恢复 `TabDescriptor.description`(六个内置类型声明它,`guideDesc*` 词条一并回来);**不恢复**旧的通用兜底句 `nativeGuideDesc`——宿主自己没有兜底,通用句在所有页面上长得一样、纯噪音;未声明该字段的 descriptor 就不发 `description` 字段 |
| 2 | 内置 `files` 类型重新声明指南说明,且改用彩色的 `FileTypeIcon kind="folder"` 作指南字形 | 仅背景信息:`files` 页面 kind 由插件接管,该行现在随插件渲染(`editor` descriptor 的图标与说明) |
| 3 | `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` **仍不声明 `dependencies`**,而其 bundle 继续裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex` | 上一版提升进 devDependencies 的那组第三方包**不得回退**(回退即 vitest 一加载 primitives 就 `Cannot find package 'anser'`);`ui-primitives` 的导出面未变,只有 CodeBlock 渲染变化 |
**明确未变**(不必再核):
- 全局主面板模型——根级 keyed `main` 槽、`sidebar.panellist`、`ctx.layout.selectPanel` / `beginNavigation`、根级 `rightbar` + `rightbar.session`;插件仍不接入。
- 文件地址语法——`packages/util/workspace-path` 只动了 `package.json` 的版本号。
- 原生 tab 体宿主契约——`dsh-client-ui-dockkit` 的 `.paneBody` 仍是「有确定高度的块级滚动容器」,只加了两行 z-index。
- core / agent / session / subagent 宿主 API——仅版本号变化。
## 决策
- **插件版本 0.19.0(无 prerelease 后缀)**:`release.yml` 按「版本号是否含 `-`」自动选 dist-tag,因此本版发 npm **`latest`**;npm 的 `alpha` 标签保持指向 `0.19.0-alpha.1`(上游 rc.1 自己的 GitHub Release 仍标 pre-release,与本插件的通道无关)。
- **基线 = DSH 0.1.5-rc.1+ only**:peer 下限 `^0.1.5-rc.1`、manifest `engines.dsh >=0.1.5-rc.1`、CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`。DSH **0.1.5-alpha.2 用户留在 v0.19.0-alpha.1**,DSH **0.1.2-rc.1 用户留在 v0.18.1**。
- **恢复 `description`,不恢复通用兜底句**:宿主渲染第二行时读 descriptor 自己的说明;`nativeGuideDesc` 兜底句不再存在——没写说明的条目就是单行「图标 + 标题」。
- **接受 ≤4 渲染规则,不做额外工程**:插件默认贡献 6 个 guide 条目 > 4,说明在默认组合下不渲染(见下节)。不为此裁剪内置类型、不改条目数、不把关键信息只放进说明里。
- **升级本地 3080 实例**到 DSH 0.1.5-rc.1 + 插件 0.19.0,作为真机观察点。
## ≤4 渲染规则的后果(重要)
上游只在 guide 列出的条目 **≤ 4 条**时渲染 `description`(`MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明——注意是「整列丢弃」,不是截断到前四条。插件默认贡献 6 条 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),**因此在默认组合下,插件与内置类型的说明都不会出现**,六条仍是「图标 + 标题」。
说明只在读者于插件设置页关掉足够多的 tab 类型、把 guide 压到 ≤ 4 条时才出现(逐类型开关;关掉 `editor` 会同时移除接管出去的「文件」行)。这是上游行为而非插件缺陷:插件恢复字段是为了让「关到 ≤ 4 条」的读者拿到真实说明,而不是为了在默认组合里显示什么。指南 §0 与 §4.1 都写明了这条规则,消费插件不应把关键信息只放在 `description` 里。
## 改动清单(子系统级)
| 子系统 | 文件 | 变更 |
|---|---|---|
| 基线 | `package.json` | 版本 → `0.19.0`(无后缀);dsh-* peer 全部 `^0.1.5-rc.1`;devDeps 同步钉 `0.1.5-rc.1`;`dsh-client-locale` 跟随基线到 `0.1.5-rc.1`(该包已回到与 DSH 同 tuple 发版,§3-9 的落后例外保持消除) |
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 rc.1 |
| 基线 | `dsh.plugin.json` | `version` → `0.19.0`;`engines.dsh` → `>=0.1.5-rc.1` |
| 基线 | `.github/workflows/ci.yml` | mount lane 钉 `@deepseek-ai/dsh@0.1.5-rc.1`(rc.1 在 npm 上同时是 `latest` 与 `next`) |
| 基线 | `package.json` | 上一版提升的第三方 devDependencies(anser / shiki / `@shikijs/langs` / `mdast-util-*` / `micromark-*` / katex)保留不回退——`ui-primitives@0.1.5-rc.1` 仍无 `dependencies` |
| Tab 契约 | `src/client/service.ts` | `TabDescriptor` 恢复可选 `description`;`SIDEBAR_SERVICE_VERSION` 跟随插件版本(`0.19.0`) |
| Tab 契约 | `src/client/builtins/tabs.tsx` | 六个内置类型(editor / git / subagent / sidechat / terminal / browser)重新声明 `description` |
| Tab 契约 | `src/client/native/index.ts` | guide 条目在 descriptor 声明了说明时带上 `description`;未声明就不发该字段(不补通用句) |
| 词典 | `src/client/locales.ts` + 19 份 `locales-*.ts` | 恢复 6 个 `guideDesc*` 词条(zh / en / ja 与其余第三方语言) |
| 测试 | `tests/builtins.spec.ts` | 指南断言从「仅 `title`」改回「`title` + `description`」 |
| 测试 | `tests/native-surface.spec.ts` | 断言 guide 条目携带 `description`;`editor` 类型仍不单独占行、接管行承载其图标与说明 |
| 文档 | `docs/external-plugin-guide.md` | 顶部基线行(v0.19.0 / DSH 0.1.5-rc.1+ / 两个钉版旧版本)、§0 「新建标签页列表」行、§4.1 恢复 `description` 字段与 ≤4 规则 |
| 文档 | `docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md` | 加一行前向链接(该版删掉的 `description` 在 rc.1 回归) |
| 文档 | `docs/plans/2026-09-10-dsh-0.1.5-rc.1-adaptation.md` | 本计划 |
| 文档 | `AGENTS.md` | §2 CI 基线戳、§3 标题与基线 prose、§3 第 9/10 条(devDeps 不回退 / `description` 回归) |
| 文档 | `README.md` / `README_EN.md` | 顶部与安装节的「支持的 DSH 版本」徽章、安装节正式版说明、新增 `### v0.19.0` 更新段落 |
## 验证
- **本工作流(文档)未执行任何门禁或真机验证**,只做了与工作树现状的交叉核对:`package.json` 为 `0.19.0`(dsh-* peer 与 devDep 均 `0.1.5-rc.1`)、`dsh.plugin.json` 的 `engines.dsh` 为 `>=0.1.5-rc.1`、`.github/workflows/ci.yml` 钉 `@deepseek-ai/dsh@0.1.5-rc.1`、`SIDEBAR_SERVICE_VERSION` 为 `0.19.0`、`src/client/builtins/tabs.tsx` 六个类型与 `src/client/native/index.ts` 的 guide 条目(含 `files` 接管行)重新带上 `description`、`src/client/locales*.ts` 恢复 6 个 `guideDesc*` 键。
- 仓库**没有守护指南文档章节的测试**——`tests/builtins.spec.ts` / `tests/native-surface.spec.ts` 守护的是 guide 行的形态,不是文档文本,因此文档改动没有对应的测试命令可跑。
- **待补(由主代理在实施完成后回填,本文件不预填未经执行的结论)**:门禁数字(`pnpm typecheck` / `pnpm lint` / `pnpm vitest run --exclude '**/*.e2e.ts'` / `pnpm peers check`)、`scripts/e2e-mount.sh` 真机挂载车道的通过数,以及本地 3080 实例(DSH 0.1.5-rc.1 + 插件 0.19.0)上的实测结果——默认组合下 6 条 guide 行仍是「图标 + 标题」、关掉 tab 类型至 ≤ 4 条后说明出现、以及 `pageerror` / 控制台状况。
### 回填(主代理实测)
- **门禁**:`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm vitest run --exclude '**/*.e2e.ts'` = **124 files / 1296 passed / 9 skipped**;`pnpm peers check` = No peer dependency issues found(rc.1 未新增失配的传递 peer,因此没有再提升任何 devDep)。
- **挂载冒烟**(`DSH_CMD` 指向 npm 安装的 0.1.5-rc.1,`scripts/e2e-mount.sh`):**7 passed**,含 tab 体填充断言与新增的 ≤4 条描述断言。
- **≤4 断言的真实形态**:默认 6 条下永远拿不到说明文本,所以该用例先用插件自己的 `settings.get` / `settings.update` 把启用集合压到 3 个类型(files / sidechat / browser),断言 `files` 胶囊文本含英文说明片段 `workspace tree`,并断言被禁用的三个条目离开 guide。
- **踩坑(已修)**:恢复设置时最初只把原始 `tabsEnabled`(scratch profile 里是 `{}`)原样发回——而该路由的 patch 是**逐键合并**,空对象清不掉刚写下的三个 `false`,于是同车道的 `perf.e2e.ts` 扫 guide 时找不到 `git` 条目而失败。改为对本次动过的每个键显式恢复(原值缺省即 `true`)后全绿;这条经验对任何"临时改设置再恢复"的 e2e 都适用。
- **真机 3080**(DSH 0.1.5-rc.1 + 插件 0.19.0):guide 六行胶囊均为「图标 + 标题」、**无说明**(6 > 4,符合上游规则);胶囊高度 56px(上游把 `min-height` 从 alpha.2 的 48px 调到 56px);文件树点击 `AGENTS.md` → tab 标题 `AGENTS.md` + CodeMirror 挂载;sidechat 宿主盒 962px == `.paneBody` 962px(fillGap 0),composer 底边距 8px;底部工作台与 `[data-dsh-center-col]` 左右边完全重合(gap 0);`pageerror` 0;控制台仅第三方 `dsh-tauri-worktree` 的 `/api/dsh-worktree/attach` 500(alpha.2 期同样存在,与本次无关)。rc.1 真机的 `[data-slot]` 清单与 alpha.2 完全一致(`main` / `main.conversation` / `rightbar` / `rightbar.session`),印证"全局面板模型未变"。
## 不做
- 不为 ≤4 渲染规则裁剪内置 tab 类型、调整条目数或改写图标。
- 不恢复 `nativeGuideDesc` 通用兜底句。
- 不接入 alpha.2 的全局面板(`main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`)——沿用上一版结论,rc.1 上这些面同样只是兼容保留。
- 不回退提升进 devDependencies 的第三方包。
- 不改 DSH 源码(仓库硬约束 §1)。
## 落地
- [#613](https://github.com/omdsh-dev/DSH-better-sidebar/pull/613) 合入 `main`(merge commit `754974a`);main 的 `package.json` 为 `0.19.0`、`engines.dsh` 为 `>=0.1.5-rc.1`。PR 的 `ci` / `ci-windows` / `plugin-mount` 三个 check 全绿。
- 发布 [v0.19.0](https://github.com/omdsh-dev/DSH-better-sidebar/releases/tag/v0.19.0):`release.yml` 通过全门禁后经 npm Trusted Publishing 发布到 **`latest`**(带 provenance);实测 dist-tags:`latest = 0.19.0`、`alpha = 0.19.0-alpha.1`(不变)、`beta` 不变。
- 产物核对:`lib/index.js` 与本地已验证构建**逐字节一致**,且与 `v0.19.0-alpha.1` 的同名文件同哈希(宿主侧代码在这两版之间确实没动);`lib/client.js` 仅差已知的打包非确定性;发布包的 client bundle 内含恢复后的说明文案。
- **npm 传播延迟仍是 ~4–5 分钟**:06:49:10 工作流报 `Published package dsh-better-sidebar@0.19.0`,06:53:53 dist-tags 才可见 `latest = 0.19.0`(版本元数据与 tarball 依次到达,期间 `npm view` / tarball 会 404)。这不是发布失败,别重发。
@@ -1,107 +0,0 @@
# DSH 0.1.5-rc.2 适配(v0.19.1 基线)+ CI 三类失败修复
日期:2026-09-10(复核 2026-09-11) 分支:`feat/dsh-0.1.5-rc.2`
## 背景
DSH `dsh-v0.1.5-rc.1..dsh-v0.1.5-rc.2`(上游 4 个 commit / 300 个变更文件,npm 发布 2026-09-10T14:57Z,dist-tag **`next`**;`latest` 仍是 rc.1)是 0.1.5 线的第二个候选版。本计划记录该 delta、插件对它的结论(**无需任何运行时/类型层适配**,只推进钉版),以及同一时间窗内 `ci-windows` / `plugin-mount` 两个车道真实存在的三类 CI 失败与修法。上一版(rc.1)记录见 [2026-09-10-dsh-0.1.5-rc.1-adaptation.md](./2026-09-10-dsh-0.1.5-rc.1-adaptation.md);仓库级适配要点同步到 [AGENTS.md](../../AGENTS.md) §2/§3。
## 上游 delta(rc.1 → rc.2,影响本插件的部分:无)
300 个文件里绝大多数是各包 `package.json` 的 `version` 单行改动(`1+1-`)。逐文件过完后的真实代码改动只有四处:
| # | 上游变更 | 文件 | 对插件的影响 |
|---|---|---|---|
| 1 | 消息反馈改为对称提交(弹窗、动作按钮、控制器、槽位、i18n) | `packages/client/ui-message-feedback/src/client/*` | 无——插件不注册反馈槽、不读该控制器 |
| 2 | 产物卡片内边距与呈现微调 | `packages/client/ui-deliverables/src/client/*`、`ui-chat/src/client/chat/TurnTailNodeView.module.css` | 无——插件不消费这两个包的组件,也不做会话尾迹布局 |
| 3 | `CodeFileIcon` 的 477 行内联 SVG 拆进新增的 `code-file-icon-artwork.ts`(另有 `.manifest.json` 记录设计导出摘要) | `packages/client/ui-primitives/src/CodeFileIcon.tsx` | 无——`CodeFileIcon` 的导出面与 props 未变,且插件**本来就不用它**(插件文件树用 `IconCodeOutline16`;`#611` 的文件图标主题还在 PR #594,未进 main) |
| 4 | 其余全部为版本号 | 各 `package.json` | 无 |
**明确未变(不必再核)**:
- `packages/api/*`(session-controller / workspace-controller / gateway / remotes)、`packages/host/*`、`packages/session/*`、`packages/agent/*`——**零**变更;除 `packages/feedback/message-feedback/src/types.ts`(1 行)外**零**类型文件变更。
- 斜杠 RPC 方言、一次性 token 鉴权、会话事件模型、`sidebarRight` / `sidebarRightTabs` 承载面、`SidebarRightGuideEntry.description` 的 ≤4 渲染规则:全部沿用 rc.1 结论。
- `@deepseek-ai/dsh-client-ui-primitives` 仍不声明 `dependencies`(bundle 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`)——提升进 devDependencies 的那组包继续不得回退。
## 决策
- **peer 下限保持 `^0.1.5-rc.1` 不动**:rc.2 无契约变化,`^0.1.5-rc.1` 天然容纳它,rc.1 用户无需升级即可用新版插件。因此本版是**纯 patch 的 0.19.1**,只推进 devDependency 钉版、CI 挂载钉版与 `SIDEBAR_SERVICE_VERSION`。
- **`engines.dsh` 保持 `>=0.1.5-rc.1`**:与 peer 下限同源(同一个支持范围,两处不能分叉)。
- **CI 挂载车道钉 rc.2**:真机证据要落在最新版上;`latest` 仍是 rc.1 只是上游的发布节奏,与本插件支持范围无关。
- **不为此版写运行时兼容层**:上游 delta 里没有本插件触达的面,写兼容层就是无据的复杂度。
## CI 三类失败(2026-09-09/10 窗口,60 次已完成 run 的取证)
窗口内 `ci-windows|Test` 失败 8 次、`plugin-mount|Install dsh CLI (pinned)` 失败 4 次。逐条读日志后归为三类,其中两类是**真实缺陷**、一类是**运维噪音**:
### A. Windows 真实超时(6/8 次 `ci-windows` 失败,主因)
- 证据:run `34494370187`、`34490215638`、`34459499945`(**3 次 attempt 全红**)、`34372999975`、`34322271074`、`34319666611` 均为 `Error: Test timed out in 5000ms`,位置 `tests/agent-pty.spec.ts:71:3` / `:106:3`;`tests/install-powershell.spec.ts:105:41` 的 `powershell.exe` 单测自身耗时 **12151ms** 后才超时。
- 根因:`vitest.config.ts` 没有任何 `testTimeout` 配置(默认 5000ms),而这些 spec 每个用例都要真起一个 `PowerShell + ConPTY` 对;`tests/agent-pty.spec.ts` 的 `waitForTranscript` 默认预算**也**是 5000ms——内层轮询吃掉整个对外预算,结构上必然超时。
- 修法:**全局** `testTimeout: 15_000`(`vitest.config.ts`)+ 两个最重的 describe 显式 `{ timeout: 30_000 }`(vitest 4 的 describe 级选项,已实测生效)+ `waitForTranscript` 默认预算降到 15s。**为什么是全局而不是逐文件**:本 PR 第一次跑真实 Windows 时,同一个 5000ms 默认又在**第三个文件**上翻车——`tests/git.spec.ts:85`「caps status entries at the truncation limit」耗时 **9607ms**(构造病态 untracked 集合 + 真实 git 调用)。三个不同文件、同一个根因,逐文件加超时是打地鼠;15s 是默认值的 3 倍、也是 Windows 上实测最大单测耗时的 ~1.5 倍,而 pty / powershell 两类最重的用例各有自己的 30s 预算。
### B. Windows worker 静默死亡(2 次,非确定性)
- 证据:run `34500290681`(295615abb)、`34448911025`(bbf953b8a) 报 `[vitest-pool]: Worker forks emitted error / Caused by: Error: Worker exited unexpectedly`(`Errors 1 error`),同一份日志里 `0 FAIL`、125 个文件全过;后者静默吞掉一个文件的 10 条结果。同一 commit `8ae527170` 的另一次 run 里 `ci-windows` 是**绿的** → 该步骤可证伪地非确定。
- 归因(**推断,非确证**):真起进程的 spec 有 5 个(`agent-pty` / `pty-deps` / `pty-helpers` / `install-powershell` / `smoke`),node-pty 的 ConPTY 辅助进程与冷启 `powershell.exe` 都绑定控制台,在默认**无界 fork 池**下并行抢占;本地 macOS 无法复现该模式,因此不做无证据的猜测性改动(不加 `retry`,不改默认 pool)。
- 修法(两次迭代,第一次被真实结果证否):先上 `test:windows`(`vitest run --maxWorkers=2`)——**真实 Windows 上仍复现**(run `34574847774`:126/127 文件全过、`0 FAIL`、`Unhandled Error: Worker exited unexpectedly`,丢结果的那个 worker 正是跑 `tests/native-surface.spec.ts` 的)。该次日志里 `native-surface.spec.ts` **零结果**,与历史两次崩溃同形(`34500290681` 125/126、`34448911025` 123/124——都是「N-1 个文件有结果 + teardown 期 worker 静默退出」)。
- 迭代后的修法:`--maxWorkers=1`(整个套件跑在一个 fork 里,**零 worker 回收**)。理由:跨 4 次崩溃的一致形态是「worker 回收/退出竞态」,而不是某个测试失败;单 worker 从根上取消这个竞态,代价只是墙钟时间(本地 14s → 41s,Windows 步骤仍在 30 分钟 job 预算内)。**仍不加 retry**:retry 会把真实回归一起吞掉。
### C. `plugin-mount` 的 npm 安装失败(4 次:2× ETARGET + 2× OOM)
- 证据:run `34492160123` / `34491256626` 报 `npm error notarget No matching version found for @deepseek-ai/dsh-tool-bash-persistent@^0.1.5-rc.2` / `@deepseek-ai/dsh-sandbox-windows-acl@^0.1.5-rc.2`;run `34490912649` 先刷 **3062 条 ERESOLVE**、9 分钟后 `FATAL ERROR: Ineffective mark-compacts near heap limit` exit 134。同一机制在 2026-09-09 的 alpha 线也复现过一次(run `34363627184`,1808 条 ERESOLVE → OOM);同期成功的安装是 `added 521 packages in 19s`、**0 条 ERESOLVE**。
- 根因:**钉版并没有真的钉住**。`@deepseek-ai/dsh@0.1.5-rc.1` 的传递依赖声明为浮动 `^0.1.5-rc.1`,会被上游**分阶段发布**中的 rc.2 满足(rc.2 的 14:43–14:57 逐个包上线),于是 npm 组出 rc.1/rc.2 混合 peer 图 → 解析器爆炸(OOM),或对尚未发布的兄弟包硬报 ETARGET(两个 ETARGET 分别在该包上线前 5.5 分钟与 25 秒)。
- 修法(`.github/workflows/ci.yml`):钉一个**已完整发布**的 rc.2(修 ETARGET),并加 `NODE_OPTIONS=--max-old-space-size=4096`(修 OOM)。
- **一次被真实结果否掉的中间方案(值得记)**:第一版修复还加了 `--legacy-peer-deps`(想法是「跳过 peer 协商即与发布窗口解耦」)。真实 Windows 结果表明它只是把安装**弄坏**而不是弄稳:它跳过的正是全局安装必须提供的 peer,而 `@deepseek-ai/dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` **是 peer 而不是 dependency**,于是 CLI 在冒烟开始前就以 `ERR_MODULE_NOT_FOUND` 死掉(run 34560785101 的 `plugin-mount`)。本地复现并验证:同一命令去掉该 flag 后安装树完整(`dsh/node_modules/@deepseek-ai/cordis-plugin-group` 存在),且 `DSH_CMD` 指向这个 classic 安装的 `test:mount` **7 passed**——宿主自带依赖,插件侧看不到差别。
- 顺带修掉一处过时注释(原文断言「rc.1 同时是 `latest` 与 `next`」——现在 `latest=rc.1`、`next=rc.2`);这条注释正是浮动范围隐患长期不可见的来源。
## 改动清单(子系统级)
| 子系统 | 文件 | 变更 |
|---|---|---|
| 基线 | `package.json` | `version` → `0.19.1`;dsh-* **devDependencies** 全部钉 `0.1.5-rc.2`(含 `dsh-client-locale`,上游仍与 DSH 同 tuple 发版);**peerDependencies 一律保持 `^0.1.5-rc.1`** |
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 rc.2 |
| 基线 | `dsh.plugin.json` | `version` → `0.19.1`;`engines.dsh` 保持 `>=0.1.5-rc.1`(与 peer 下限同源) |
| 基线 | `src/client/service.ts` | `SIDEBAR_SERVICE_VERSION` → `0.19.1`(`tests/service.spec.ts` 断言它 === `package.json` 的 version) |
| CI | `.github/workflows/ci.yml` | mount 车道钉 `@deepseek-ai/dsh@0.1.5-rc.2` + `NODE_OPTIONS=--max-old-space-size=4096`(C 类;**不用** `--legacy-peer-deps`,理由见 C 节);`ci-windows` 的 `Test` 改跑 `pnpm test:windows`(B 类);注释同步事实 |
| CI | `package.json` | 新增 `scripts.test:windows`(`vitest run --maxWorkers=2`) |
| CI | `vitest.config.ts` | 全局 `testTimeout: 15_000`(A 类;覆盖 git / powershell / pty 三类真起进程的 spec,逐文件打地鼠的做法在真实 Windows 上被证否) |
| CI | `tests/agent-pty.spec.ts` | `AgentPtyRegistry` describe 显式 `{ timeout: 30_000 }`;`waitForTranscript` 默认预算 5000 → 15_000(A 类) |
| CI | `tests/install-powershell.spec.ts` | `PowerShell installer entry points` describe 显式 `{ timeout: 30_000 }`(A 类) |
| 文档 | `docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md` | 本计划 |
| 图标 | `src/client/file-icons.tsx` / `src/client/builtins/tab-icons.*` / `src/client/native/tab-adapter.tsx` | 文件图标改用宿主官方 `FileTypeIcon`;内置 tab 与原生芯片图标彩色——独立记录见 [2026-09-11-file-icons-and-tab-glyphs.md](./2026-09-11-file-icons-and-tab-glyphs.md) |
| 文档 | `AGENTS.md` | §2 CI 基线戳与 mount 车道说明、§3 标题/基线段(rc.2 仍无契约变化,只推进钉版) |
| 文档 | `README.md` / `README_EN.md` | 支持版本徽章与说明、新增 `### v0.19.1` 段 |
| 文档 | `docs/external-plugin-guide.md` | 顶部基线行 → v0.19.1 / DSH 0.1.5-rc.1+(已在 rc.2 上验证) |
| 文档 | `tests/native-surface.spec.ts`、`tests/builtins.spec.ts`、`tests/e2e/mount.e2e.ts`、`src/client/service.ts`、`src/client/native/index.ts` | 注释里的 `DSH 0.1.5-rc.1` → `0.1.5-rc.1+`(同一行为在 rc.2 未变) |
## 验证
- **未执行**的门禁与真机步骤都留空,由主代理实施后回填(本文件不预填未经执行的结论)。
### 回填(主代理实测)
- **门禁(rc.2 钉版,macOS arm64)**:`pnpm install` 成功且锁文件对齐 rc.2;`pnpm peers check` = `No peer dependency issues found`;`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm test` = **126 files / 1312 passed / 9 skipped**(与 rc.1 基线同数,无新增 skip)。
- **真机挂载冒烟(rc.2)**:`DSH_CMD` 指向 `npx -y --package @deepseek-ai/dsh@0.1.5-rc.2 dsh`;见"落地"节的实测计数。
- **真实 Windows 收敛**:`ci-windows` **连续两次全绿**(同一 commit `5451f5c`)——`pull_request` 自动触发的 run [34561189320](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34561189320) 与随后 `workflow_dispatch` 的 run [34561457067](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34561457067),两次都是 **126 files / 1318 passed / 3 skipped / 0 errors**(`Unhandled Errors` 与 `Worker exited unexpectedly` 出现次数均为 **0**;同环境的 2026-09-09/10 窗口里这两行出现过 2 次)。同一分支的 `plugin-mount` 两次都绿:`added 521 packages in 24s`(**0 条 ERESOLVE**,与历史 OOM run 的 3062 条对比)、`test:mount` **7 passed**、聚合双挂载通过。
- **首次真实结果的证伪(已修正)**:本 PR 的第一版修复在同一 commit 上仍红两处,`.github/workflows/ci.yml` 的改动因此被真实证据改过一轮——见 C 节的「一次被真实结果否掉的中间方案」与 A 节第三个文件(`tests/git.spec.ts:85`,9607ms)。
- **B 类的实测结论(已按"升级而非掩盖"走完两轮)**:`--maxWorkers=2` 下仍崩溃(run `34574847774`),故升级为 `--maxWorkers=1`(单 fork、零回收)。这**不等于证明了根因**(4 次崩溃的一致形态是「N-1 文件有结果 + teardown 期静默退出」,只能说明是 worker 回收竞态而非测试失败);收敛判据是单 worker 下连续两次 `ci-windows` 全绿——**已达成**:run [34575721734](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34575721734)(PR 触发)与 run [34576146970](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34576146970)(同 commit `4fe67e3` 的 `workflow_dispatch`)都是 **127 files / 0 errors**,`Unhandled Errors` 与 `Worker exited unexpectedly` 出现 0 次。
- **本地 3080 真机**(DSH 0.1.5-rc.2 + 插件 0.19.1):部署与路由探针已完成(见"落地"节),GUI 人工验证由用户执行。
## 不做
- 不为 rc.2 写任何运行时/类型层兼容层(上游 delta 未触及本插件的面)。
- 不把 peer 下限抬到 `^0.1.5-rc.2`(会无谓地把 rc.1 用户挡在上一版)。
- 不为 B 类做无证据的猜测性改动:不加 vitest `retry`、不改默认 pool、不关掉真实 pty 测试。
- 不改 DSH 源码(仓库硬约束 §1);不改 `release.yml`(0.19.1 无 prerelease 后缀 → 自动发 `latest`,符合 patch 语义)。
## 落地
- PR:[#626](https://github.com/omdsh-dev/DSH-better-sidebar/pull/626)(`feat/dsh-0.1.5-rc.2`)。
- 本地真机挂载冒烟(rc.2,macOS arm64):`pnpm test:mount` **7 passed**(含 tab 体填充断言、指南 ≤4 条说明渲染断言、mermaid / README 预览与 sidechat 路由烟测);`pnpm test:mount:aggregate` 通过(聚合双挂载下插件自动退让,`/sidebar/api` 未知方法 404 与 `terminal.deps` 契约均符合)。
- 本地 3080 实例部署(`~/.dsh/profiles/web` + `~/.dsh/dsh-web.sh`):profile 依赖从 `dsh-better-sidebar-0.19.0.tgz` 改指 0.19.1 tarball 并 `pnpm install`(`node_modules/dsh-better-sidebar/package.json` = **0.19.1**);`dsh-web.sh` 的 npx 钉版改 `@deepseek-ai/dsh@0.1.5-rc.2`,旧进程(PID 99521/99509/99507)定向 kill 后由 LaunchAgent `com.pm2.dshweb.resurrect` 自动拉起(新 PID 36025,npx 缓存 hash 因版本变更从 `1da1392061ab1944` 变为 `c40503fdf38a82ea`)。**已核**:新缓存里的 `@deepseek-ai/dsh` = **0.1.5-rc.2**,`/sidebar/api/shell.get` → `{"ok":true,"value":{"shell":"zsh","name":"zsh"}}`、`settings.get` 回默认设置、`terminal.deps` → `{"ok":true}`(三条插件路由在生产实例上工作);GUI 人工验证由用户执行,结论回填到本节的下一步。
- PR:[#626](https://github.com/omdsh-dev/DSH-better-sidebar/pull/626)(`feat/dsh-0.1.5-rc.2`)。PR 三个 check(`ci` / `ci-windows` / `plugin-mount`)在 run 34561189320 与 run 34561457067 上均绿。
- 3080 人工验证:通过(用户确认)。文件树/编辑器 tab 的官方彩色图标、指南六行彩色、原生右侧栏芯片图标、任务(层叠)与终端(缩小、无描边)两处返工后的观感均确认无问题。
- **合并**:[#626](https://github.com/omdsh-dev/DSH-better-sidebar/pull/626) 合入 main(merge commit `817ee8f`)。main 的首轮 CI 变红——不是本 PR 的改动,而是 `tests/agent-pty.spec.ts` 自身的隐患(skip 类用例的命令里含等待 needle,交互式 shell 回显后 needle 提前进 transcript,`waitFor` 走快路径):修复见 [#628](https://github.com/omdsh-dev/DSH-better-sidebar/pull/628)(bare shell + `waitForShellReady`,merge commit `8753096`),main 随后三个 check 全绿。
- **发版**:[v0.19.1](https://github.com/omdsh-dev/DSH-better-sidebar/releases/tag/v0.19.1)(GitHub Release → `release.yml` → npm Trusted Publishing)。`latest = 0.19.1`、`alpha = 0.19.0-alpha.1`(不变);带 provenance;发布元数据核对:peer 下限仍 `^0.1.5-rc.1`、`dependencies` 无 `cordis`、无 install 生命周期脚本。npm 传播延迟约 5 分钟(08:49:47 报 Published,08:56 前后 dist-tag 才可见 0.19.1)——这是既有现象,不是发布失败。
- **图标 PR 收尾**:#531 已被 GitHub 标为 MERGED(其合并提交 `1d05e39` 在 main 上);#594 的 base 分支被删除后无法自动标记,已附说明(哪些保留、哪些被宿主官方图形替换)后关闭。
@@ -1,92 +0,0 @@
# 文件图标与彩色 Tab 图标(v0.19.1,#531 + #594 合并后的重做)
日期:2026-09-11 分支:`feat/dsh-0.1.5-rc.2`(并入 PR [#626](https://github.com/omdsh-dev/DSH-better-sidebar/pull/626))
## 背景与决策
[#611](https://github.com/omdsh-dev/DSH-better-sidebar/issues/611) 希望文件树按类型显示图标(「仿照官方做法」),[#429](https://github.com/omdsh-dev/DSH-better-sidebar/pull/429)(@fenter)为此做了 563 条彩色图标数据,落成两个叠着的 PR:
- [#531](https://github.com/omdsh-dev/DSH-better-sidebar/pull/531)(`feat/file-icons-api`,base=main):文件树/文件 tab 按扩展名分图标 + 对外 `registerFileIcon` API;
- [#594](https://github.com/omdsh-dev/DSH-better-sidebar/pull/594)(`feat/file-icon-theme`,base=#531 分支):在其上叠「内置单色 / 彩色品牌图标」可选主题,彩色数据做成懒加载 chunk(255 kB)+ 设置页开关。
两者在 2026-09-08 都绿,但都基于 v0.18.1、落后 main 55 个 commit,且与 DSH 0.1.5-rc.2 的一个新事实冲突:**rc.2 的 `ui-primitives` 已经导出官方文件类型图形** `FileTypeIcon` / `CodeFileIcon`(48 类全彩代码/配置图形 + 12 类类目色板图形 + `classifyFileType` 分类器)。也就是 #611 想要的东西上游自己做了。
用户决策(2026-09-11):**两个 PR 都合、并进 v0.19.1 一个 PR;文件图标改用宿主官方 `FileTypeIcon`;内置 tab 图标彩色 + 原生右侧栏芯片也加图标;删掉主题开关,彩色即唯一行为。**
## 上游事实(实测,非推断)
| 事实 | 证据 |
|---|---|
| 宿主导出官方图形 | `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.2` 的 `lib/types/index.d.ts` 导出 `FileTypeIcon`、`classifyFileType`、`fileExtension`、`CodeFileIcon`、`isCodeFileType` 与 `CodeFileType` 类型;`FileTypeIcon` 声明 `size` 默认 28 并透传给内部 `FileGlyph`/`CodeFileIcon`(14px 树行可用) |
| 全彩图形保留自己的调色板 | `CodeFileIcon.d.ts` 的文档原话「its identifying palette intact」;`kind:'folder'` 落宿主的 `IconFolderClose16`(单色,随 `currentColor`) |
| 分类器不返回文件夹 | `classifyFileType` 只走 `classifyCodeFileType ?? NAME_TYPES ?? EXTENSION_TYPES ?? 'other'`,`'folder'` 只能由调用方以 `kind` 显式指定 |
| 官方图形在 rc.1 就有 | `npm pack @deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` 的 tarball 内含 `lib/FileTypeIcon.module.css` 与 `lib/types/{FileTypeIcon,CodeFileIcon,code-file-types}.d.ts`(rc.2 只是把 SVG 数据拆进 `code-file-icon-artwork.ts`) |
| 原生 tab 定义没有 icon 字段 | `dsh-client-ui-sidebar-right` 的 `SidebarRightTabDefinition` 只有 `id/kind/patterns/priority/canOpen/title/guide`;`icon` 只在 `SidebarRightGuideEntry` 上 |
| 但 title 槽就是芯片内容 | 宿主 `contract/slots.d.ts`:`'sidebar.right.pane.tab.title'` —— 「A tab's title as its chip (and a floating panel's header) shows it」 |
## 合并(3 处冲突,均为注释/列表并集)
| 文件 | 冲突 | 解法 |
|---|---|---|
| `src/client/service.ts` | ① import 区(main 保留 `extOf`,PR 侧已改成 `baseName, extOf` + 自己的 `isNarrowWidth`)② `SIDEBAR_FEATURES` 注释与列表(main 写「v0.19.0 删除了 floatWindows」,PR 侧在旧清单上加 `'fileIcons'`) | ① 取 `baseName, extOf`:`isNarrowWidth` 属于 PR 自己的「新建会话按视口折叠」逻辑,main 已把那套 seeding 重写掉,合并后该 import 是死代码(实测 grep 确认后删除)。② 并集:保留「已移除 floatWindows」措辞 + 追加 `'fileIcons'` |
| `src/client/Sidebar.tsx` | `tabIconOf` 上方注释(两侧各改一句) | 并集:保留 main 的「The tab icon from the tab-type registry.」并补 PR 侧「带 path 的编辑器 tab 显示文件图标」的说明 |
| `docs/external-plugin-guide.md` | `features` 清单注释 | 并集:main 的新措辞(只增不删,唯一例外 v0.19.0 删除 floatWindows)+ `'fileIcons'` |
#594 的其余冲突(`tests/plugin-shape.spec.ts`、`tests/prefs.spec.ts`)**取本线一侧**:main 已删除这两个文件里的 store-seeding 用例并重写了 schema 断言(`SIDEBAR_PREFS_DEFAULTS` 解构),PR 侧重新加回的块描述的是 main 不再存在的行为——把旧块带回来只会测到死代码。
## 重做(把 #594 的彩色实现换成宿主官方图形)
### 删除
- `src/client/chunks/file-icons.tsx`(563 条数据)、`src/client/file-icon-theme.ts`(chunk loader)、`tests/file-icon-theme.spec.tsx`;
- `fileIconTheme` pref 三处(`prefs-shared.ts` / `src/config.ts` / `src/client/prefs.ts`)、设置页「文件图标」select 行、20 份词典里的 6 个 `settingsFileIconTheme*` key(共 126 行);
- chunk 注册三处(`src/bundle-route.ts` 的 `CHUNK_NAMES`、`src/client/chunk-loader.ts` 的 `ChunkName`、`tsdown.config.ts` 的 `CHUNKS` 与 react-icons alias)与对应测试期望(`bundle-route.spec.ts` / `manifest-consistency.spec.ts`);
- `src/client/index.tsx` 里驱动主题 loader 的 effect。
### 新增/改写
| 位置 | 内容 |
|---|---|
| `src/client/file-icons.tsx` | `builtinFileIcon(path, size)` → `<FileTypeIcon path size />`;`builtinFolderIcon` → `<FileTypeIcon kind="folder" size />`(宿主只有一个文件夹图形,开/合两态不另画);`fallbackFileIcon(size)` → `<FileTypeIcon kind="other" size />` |
| `src/client/service.ts` 的 `fileIcon` 链 | 具体注册 → **catch-all 全局默认 → 宿主图形**(原先 built-in map 排在 catch-all 之前) |
| `src/client/builtins/tab-icons.tsx` + `.module.css` | 六个内置类型 + diff 视图的彩色 glyph;颜色全部 `var(--dsw-alias-*)`(files=brand、changes=success、tasks=warn、sidechat/browser=business、terminal=label-primary,各带 fallback 链) |
| `src/client/native/tab-adapter.tsx` 的 `NativeTabTitle` | 渲染 `[glyph][title]`:编辑器 tab 带 path 时用 `service.fileIcon(path, 14)`,其余用 `descriptor.icon(14)`;glyph `aria-hidden`;`NativeTitleInjected` 增加 `service` 与 `descriptorId`,`registerSlots` 的 title 注入同步 |
| `src/client/sidebar.module.css` | 新增 `.chipIcon`(芯片 glyph 的排版,颜色继承) |
| `tests/theme.spec.ts` | 豁免段整体删除,改为三条守护:图标模块零颜色字面量、`tab-icons.module.css` 里每条 `color` 都解析到 `var(--dsw-`、`src/client/chunks/*` 不含 6 位颜色字面量(图标数据不得再做回 chunk) |
| `tests/file-icons.spec.tsx` | 内置断言从 codicon 身份改为宿主 `FileTypeIcon`;新增「path/size 透传」「文件夹用 `kind:'folder'`」;catch-all 用例改写为「注册了 catch-all 就接管全部未具体命中的行」 |
| `tests/native-surface.spec.ts` | 新增 3 例芯片 glyph:descriptor glyph 在标题前、编辑器 tab 用文件图标而非类型图标、类型注销后只剩标题 |
## 语义后果(必须写进文档)
**宿主分类器覆盖任意路径**,因此「插件自己已经能画这个扩展名」不再是拦住 catch-all 的理由:注册 `exts: []` 的插件现在会接管所有未具体命中的文件行(原先会被内置 map 拦下)。旧版彩色主题那种「整表注册、靠内置 map 兜底」的形态不再成立——只想补几个类型的插件用 `exts`/`names`。已写进 [指南 §7](../external-plugin-guide.md) 与 [AGENTS.md](../../AGENTS.md) §5。
## 验证
**已执行(macOS arm64,DSH 0.1.5-rc.2 钉版)**
- `pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;
- `pnpm test` = **127 files / 1343 passed / 9 skipped**(v0.19.1 基线 126/1312 + 合并与新增的 31 例,失败数 0;返工后 +1 例 glyph 身份断言);
- `pnpm build`:`lib/client.js` **0.85 → 0.86 MB**(+10 kB),**没有** `lib/client-file-icons.js`;核心 bundle 内不含图标数据(只有 6 个被引用的 glyph 组件);
- 真机挂载冒烟(`DSH_CMD` 指向 `@deepseek-ai/dsh@0.1.5-rc.2`):`pnpm test:mount` **7 passed**(含 guide 深扫、tab 体填充断言、mermaid/README 预览、sidechat 路由),`pnpm test:mount:aggregate` 通过。
### 真机反馈的两处返工(用户人工验证后)
| 反馈 | 结论与改动 |
|---|---|
| 「任务管理」图标不对——该页是 subagent + 后台 jobs,不是 todo | `VscTasklist`(清单)→ **`VscLayers`**(多层堆叠 = 后台在跑的工作)。`tests/builtins.spec.ts` 用 glyph 身份把这条读法钉住 |
| 终端图标「非常怪」 | 根因是**观感权重**:`VscTerminal` 是整套里最宽的实心矩形,在 14px 下比邻座重。**只缩小一档**(`0.85 × size`,下限 10px)——形状与绘制方式保持图标集原样 |
| 追加反馈:上一版给全部 tab glyph 加的 `currentColor` 发丝描边(`strokeWidth: 1` + `non-scaling-stroke`)「太粗」 | **整体回退**:不描边,glyph 只用包裹类的令牌着色。“描边”是我对「怪」的过度解读,反馈的本意只是终端尺寸。`tests/builtins.spec.ts` 现在同时钉住「终端尺寸更小」与「glyph 不带 style 覆盖」,防止描边再溜回来 |
### 真实 CI(已收敛)
`ci` / `plugin-mount` 每轮都绿;`ci-windows` 的 worker 回收竞态在 `--maxWorkers=1`(单 fork、零回收)下**连续三次全绿**:run [34575721734](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34575721734)、[34576146970](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34576146970)、[34576493202](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34576493202),均 **127 files / 0 errors**。两轮迭代(`--maxWorkers=2` 被真实结果证否)的取证见 [rc.2 计划 §B](./2026-09-10-dsh-0.1.5-rc.2-adaptation.md)。
**待验证**
- 3080 人工验证(返工后第二轮):树行与编辑器 tab 的彩色图标、指南六行彩色、原生右侧栏芯片图标、14px 下官方 48 类图形的可读性、以及这次返工后的任务/终端两个图标(用户此前明确选择用官方图形;若细节折损不可接受,回退方案是「树行用官方图形、编辑器 tab 保留插件 glyph 着色」,改动只在 `file-icons.tsx` 一处)。
## 不做
- 不改 DSH 源码;不把 #429 的 563 条数据搬回仓库(要走按需加载也应按懒加载 chunk 走,而不是常驻数据);
- 不为彩色图标新增皮肤契约豁免(插件侧零硬编码颜色);
- 不恢复 `floatWindows`;不动已合入的 rc.2 基线结论与 CI 修复。
@@ -1,216 +0,0 @@
# 任务管理页重构:工作流图 + Agent Teams + 后台任务抽屉(v0.20 设计)
日期:2026-09-14 分支:`feat/tasks-graph-workflow-teams`
## 背景与目标
DSH 0.1.5-rc.2 引入两个新的可观测面:**workflow**(`dsh-tool-workflow` 的 run/agent 生命周期事件)与 **Agent Teams**(实验层,`ctx.agentTeams` 的共享任务板)。任务管理页(原「子代理拓扑」)系统性地接纳两者,并按用户确认的「简洁后现代主义」方向重构为**工作流图**:分层节点 + 贝塞尔连线、拖拽平移、滚轮缩放、右下角控制条、已完成节点折叠聚合、点击浮窗取代页内 dock。
用户确认的关键交互决策(问答摘要):
1. 数据范围 = 当前会话树(主代理 + 全部后代)。
2. 节点点击 = 跳转转录;ⓘ = 详情浮窗。
3. 后台任务输出 = 锚定浮窗(替换页内底部 dock)。
4. 自动折叠**只**作用于后台任务抽屉,阈值 8 个代理;图节点的折叠是「已完成」语义的折叠聚合,点击展开、控制条可再折叠。
5. 图/树切换按钮在**两种模式下都可用**(早期设计稿把 toggle 藏在画布容器内、树模式下无法切回——真实实现把控制条移出滚动面)。
6. 默认视图由设置 select 决定(默认工作流图),页内切换为临时态。
7. 任务板 v1 只做基础操作(完成/重开/删除/改派/新建/编辑,CAS)。
## 数据源事实(侦察结论)
### Workflow
- **没有**服务注册表 / 持久化 / HTTP 接口;只有调用方会话日志里的 4 个会话事件:`tool-workflow/run-start | agent-start | agent-end | run-end`。
- 只有**顶层 run** 落事件(`exec.parent === undefined`),嵌套 run 不可见——与官方 `dsh-client-ui-workflow-run` 面板折叠的恰好是同 4 类事件,因此插件折叠结果与官方面板一致。
- run 的 member agent 是普通子会话(catalog 里有行),`childId` 即子会话 id——图模式把它们**重挂**到 run 节点下;catalog 里没有的 member 用 run 自带数据合成占位节点。
### Agent Teams(实验层)
- 只在 `~/.dsh-web` 类 profile 的实验 bundle 里存在;`ctx.get('agentTeams')` 缺席时返回 `undefined`(不抛)——**结构性降级**的支点。
- TeamId ≡ lead SessionId;`tryMembership(agent)` 命中后 `remoteView(agent) → {members, tasks}`;`remoteCreateTask` / `remoteUpdateTask`(CAS,`expectedRevision`,冲突走 `team-task-conflict` union 返回而非抛错)。
- 成员上限 8、任务上限 256;teammate = lead 的可续接子会话(`member.id` = 子会话 id,与拓扑树天然关联)。
- `~/.dsh/task-board/ledger-v2.json` 与 Agent Teams **无关**(名字误导,已核证)。
- 官方团队面板是 read-mostly + 手动刷新——客户端 5s 轮询(仅页面可见时)不是过度设计。
### Jobs
沿用既有:`jobsBySession` push 镜像 + `jobs.output` 事件回放(不碰模型游标)+ 两击终止。
## 架构
```
host 半(src/)
workflow-runs.ts 纯折叠 foldWorkflowRuns(events, originSessionId) → WorkflowRunView[]
workflow-routes.ts workflows.list:树枚举 + 存储日志与 live 镜像按 seq 去重合并
team-routes.ts teams.view / taskCreate / taskUpdate:结构镜像 Remote 词汇,三分支降级
client 半(src/client/)
tasks-model.ts 统一视图模型(图/树共用):catalog 走树、run 重挂、team 富化、fold 聚合
tasks-graph-layout.ts 免依赖 tidy-tree 分层布局(<100 节点无需虚拟化;bundle 纯度禁图库)
TasksGraph.tsx 画布:pan / wheel-zoom-to-cursor / 相位虚线框 / 控制条(图树切换+折叠+缩放)
TasksTree.tsx 树模式:缩进行 + 键盘导航(官方 catalog 配方)
TasksPopovers.tsx 节点详情 / run 详情 / 团队任务板(CAS 操作)
JobsDrawer.tsx 底部抽屉(≥8 代理自动折叠,手动优先)+ 输出浮窗(回放,尾钉)
AnchoredPopover.tsx 锚定浮窗:geometry + 关闭契约(沿用 selection-popup 的 #425 修复)
tool-icons.tsx 工具字形映射(宿主 primitives 图标,currentColor)
```
### 降级矩阵(可证伪)
| 环境 | workflows.list | teams.view | 页面表现 |
|---|---|---|---|
| 无 workflow/无 team(3384 桌面 profile r) | `{runs: []}`(200,空而非错) | `{available:false}` | 图/树照常,无 run 节点、无团队 chip |
| 有 team 无 workflow(3080) | `{runs: []}` | `{available:true, team:{...}}` | roster 富化节点 + chip + 任务板 |
| 有 workflow | 折叠结果 | — | run 节点 + 相位框 + member 重挂 |
| subagents 服务缺席 | 仅折叠 root | 不影响 | 旧快照退化行为 |
## 关键取舍
- **零实验包 import**:`context-types.ts` 结构镜像 `TeamService` 的 Remote 词汇;`ctx.get('agentTeams')` 探活在路由内。实验层缺席时 mutation 抛 503 `team-error`(与 `subagents-unavailable` 同形)。
- **workflow 无事件 = 空列表不是错误**:旧会话/无 workflow 会话静默为空(jobs 镜像同款语义)。
- **fold 规则**:per-parent 的「已完成/出错的**叶子** agent」折叠为一个聚合节点;当前会话、teammate、有子节点的、run 节点**永不折叠**(折叠父节点会藏住活跃分支)。
- **树/图同一模型**:fold 状态、团队富化、run 重挂两种模式共享——不会视觉漂移。
- **控制条移出滚动面**:图模式的缩放按钮与图/树切换都在视图容器级(绝对定位右下),树模式下缩放按钮不渲染但切换在(早期实现把它放进滚动面、树模式下切不回去——有测试守护)。
- **页内视图切换是临时态**,默认视图走 `tasksViewMode` pref(schemastery `z.union([z.const('graph'), z.const('tree')])`——schemastery 无 `z.enum`)。
- **AnchoredPopover 不复用 primitives 的 HoverCard**:浮窗需要持久交互(任务板表单、输出滚动),HoverCard 是悬停语义;关闭契约(外部 mousedown / Escape / anchor 离屏的 IntersectionObserver)沿用 selection-popup 已验证的模式。
- **主题硬阴影用 `color-mix(in srgb, var(--dsw-alias-border-l1) 55%, transparent)`**:后现代硬投影但颜色全部出自令牌(theme.spec 只守 `color:`,皮肤契约的精神照旧满足)。
## 实施偏差记录
1. **诊断行(diagnostic catalog entries)从模型中省略**:旧树逐 parent 渲染 corrupt/unavailable 行;新模型跳过它们,目录加载失败改由页头横幅「N 个分支加载失败 + 重试(逐个 refresh)」统一承载。理由:诊断行罕见(通常是 side-chat 遗产),逐 parent 内联会破坏图的 tidy 布局;横幅保留可达性与重试。
2. **catalog `state:'error'` 不再逐层内联**,同上进横幅。
3. **loadin rows**:图模式无「加载中占位卡」;树模式保留 `summaryBackedLoading` 的 loading 行。图模式靠 5s 轮询自然收敛。
4. **子代理 keyboard 导航保留在树模式**;图模式节点 `tabIndex=-1`(平移/缩放下 tab 序无意义,树模式是可达性面)。
5. **本地 `pnpm test:mount` 需要绕过桌面 shim**:桌面版 `~/.local/bin/dsh` shim 回退到桌面捆绑 CLI 且**不尊重 `DSH_HOME`**(`dsh plugin add` 会写到真实 `~/.dsh` profile——本次亲历,把真实 profile web 的 better-sidebar 指到了本分支 tarball)。正确姿势与 CI 一致:`DSH_CMD="npx -y --package @deepseek-ai/dsh@0.1.5-rc.2 dsh" pnpm test:mount`。
6. **测试断言从 `container.textContent` 迁到 `document.body`**:输出浮窗 portal 到 body(#425 契约),jobs-view 套件相应更新。
7. **fold 聚合节点的 aria-label 带计数文本**:与控制条的 fold 切换按钮消歧(两者同文案会导致 a11y 选择器歧义)。
## 复审返工(2026-09-14 晚,真机截图反馈)
用户在 3384(`DSH_HOME=~/.dsh`,profile web)看到首版实现后给出四条反馈,逐条定位并修复:
| 反馈 | 根因 | 修法 |
|---|---|---|
| 「按钮和文本的颜色不对,不够后现代」 | 新 CSS **17 处裸用 `var(--dsw-alias-accent)`——该令牌在 DSH 主题里不存在**(旧文件的用法都带 fallback 才没暴露),声明被浏览器整条丢弃;容器边框用的 `border-l1` 只有 4% 黑,几乎不可见 | accent → `--dsw-alias-state-business-primary`;容器边 → `--dsw-alias-border-l4`(16%);整体回到设计稿语言:ink 边框、3px 硬投影、mono 微型字 + 字距大写标签、点阵画布、横向缩放条 |
| 「点 ⓘ 没反应 / 图里点不动(树能点)」 | 画布 `pointerdown` 里对容器 `setPointerCapture`,捕获把派生 click 重定向到容器,节点永远收不到点击(jsdom 不模拟捕获语义,所以单测全绿——**只有真实浏览器能暴露**) | 去掉 capture,pan 仅从背景起手(`closest('[data-graph-node]'/'[data-graph-controls]')` 直接返回),监听挂 window |
| 「图很小 / 不在中间 / 任务板没显示」 | fit 在容器为 0 尺寸时静默放弃且不再重试;只居中横轴、上限 1.0;窄面板里 5 个兄弟节点挤成 770px 宽 → 缩到 35% | ResizeObserver + 首次非零尺寸补 fit、双轴居中、`FIT_MIN_SCALE=0.78` 可读性下限;**按容器宽度求解排布**(`layoutTasksGraphForWidth`:先取仍满足可读性预算的最宽排布,只有窄到 ≤1 列才允许 20% 横向溢出);任务板改为**常驻可见条**(不再藏在 chip 后) |
| 「卡片信息过多过杂,都被省略看不见」 | 卡片同时塞 displayTitle + 模式 + 状态 + 模型 + live 文本,132px 宽(窄面板的目标宽度)下全部省略号 | 卡片只留三层:标题(1 行)/ mono 元信息(模式或模型 · 状态)/ live 行(仅运行中);其余(会话标题、team 角色、模型全名、最新文本、跳转)进 ⓘ 浮窗 |
| 「非常窄,非常挤」 | 设计按宽画布做,未以原生右侧栏窄宽为目标 | 度量全部改按窄栏基准(卡片 132×46、行距 112);行高/间距/字号显式声明(宿主 body 行高曾把行撑高);`user-select: none` 防拖拽选中文本 |
回归面:`tests/tasks-page.spec.tsx` 增 3 例(背景 pointerdown 后节点仍可激活、节点上的手势绝不启动 pan、团队任务板无需点击即常驻可见);`tests/tasks-graph-layout.spec.ts` 重写 9 例(band 换行、换行 band 不得压到兄弟子树行、宽度求解两段式、运行时预留 live 行)。
### 本地可视化自检 harness(未入库,`tmp-visual/`,git-excluded)
真实浏览器里的组件级回归无法靠 jsdom 覆盖(点击捕获、fit/居中、主题令牌解析都是浏览器行为)。harness 用 esbuild 把**真实组件** + 与 `buildTasksModel` 同形状的 fixture(走同一个模型,因此折叠/重挂/富化都真实生效)打包进一个页面,注入从 `dsh-client-ui-theme` 抽出的令牌 CSS,`@deepseek-ai/dsh-client-ui-primitives` 别名到轻量桩(避免把 katex/shiki 资源拖进截图包),再由 Playwright 在 360px / 720px 两档宽度截图并断言:节点点击回调、ⓘ 回调、背景拖拽平移量。本次返工的四条反馈里有三条正是它先复现、修完再确认的。
## 第二轮返工(2026-09-14 深夜,13 条反馈)
用户第二轮反馈三条,逐条落地:
| 反馈 | 做法 |
|---|---|
| 任务板「太窄太小」,且任务应显示到**对应节点**上 | 任务按 `ownerName → 成员名 → 成员会话 id` 映射进模型(`TasksNodeTask`),节点第 4 行渲染「☑ 主题 · 状态 +N」;任务板改为:成员 Pill 行**兼当负责人筛选**、任务行 = 状态点 + 主题 + 负责人 + 状态 Tag + **一个**溢出菜单;新建/编辑改为 `Modal` + `Input`(真正的宽表单),负责人用 Pill 选择 |
| 所有组件用自建、不用原生 HTML | 任务板全量换成宿主 primitives:`Menu`(含子菜单的改派)/`Modal`/`Input`/`Button`/`Pill`/`Tag`/`Switch`/`StateDot`;后台任务行的 kind/状态用 `Tag`、终止用 `Button`、输出浮窗的复制/跟随/终止同理。页面仅剩结构性 div(卡片、树行)不是表单控件 |
| Agent 用图标而非自绘 SVG;优化工具展示 | `◉/◇/▶/✓/⇪/⇩/i/☰/⌗/⌂` 等字形全部删除,改用宿主图标:`IconAgentPresetOutline16`(代理)、`IconUserOutline16`(teammate)、`IconBranchOutline16`(workflow run)、`IconChecklistOutline14`(折叠聚合 / 任务行 / 折叠按钮)、`IconTreeCorner8x10`(树视图)、`IconFullscreenOutline16`(适应)、`IconEllipsisOutline16`(节点详情)、`IconCopyOutline16`/`IconStopFill16`(作业)。live 行改为工具自身图标 + 工具名 + 参数(去掉手绘方框) |
| 后台任务弹窗要能拖动 + 体验 | `AnchoredPopover` 增加 `draggable`(拖动整体、按钮/输入/`pre` 不拦截、双击复位、视口内钳制)与 `width` 参数;输出浮窗宽 380 且可拖,新增「复制输出」「跟随最新」开关(关闭后不再尾钉)、kind/状态 Tag、拖动提示;抽屉行高亮当前打开的作业 |
i18n:本轮新增 19 条文案,zh/en/ja + 18 份第三语言词典同步(`tests/locales.spec.ts` 16/16)。
## 第三轮返工(2026-09-14 深夜,交互统一)
用户第三轮反馈两点,都指向「减少控件、统一入口」:
| 反馈 | 做法 |
|---|---|
| 图卡片去掉详情按钮,「统一改为弹出详细」 | 删除每张卡片右上角的 `…` 按钮(树行同理):**卡片/行本身就是详情入口**,点击即弹详情窗口;跳转转录改为详情窗口内的主按钮。折叠聚合节点仍保留「点击=展开/再折叠」的语义 |
| 任务所有入口统一为「点击弹出非全屏可拖动编辑(兼查看)窗口」,含 编辑owner + 编辑 + 重开 + 删除;默认多行 markdown 预览,点编辑才进入多行编辑 | 新增单一组件 `TaskWindow.tsx`:`TaskWindow`(内容)+ `TaskPopover`(可拖动外壳,宽 430)+ `MultilineField`(自建多行输入)+ `OwnerPicker`(Pill 即点即改派)+ `TaskCreateButton`。三处入口全部复用它——任务板行、节点任务行、节点详情里的任务清单;「新建」也走同一个窗口(create 模式直接进入编辑态)。任务板由此**删掉了溢出菜单与 Modal**,只留筛选 Pills、任务行与新建按钮 |
新文案 3 条(任务详情 / 暂无描述 / 被阻塞),zh/en/ja + 18 份第三语言同步。
组件复用清单(本轮):
| 组件 | 复用点 |
|---|---|
| `TaskPopover` | 任务板行、节点任务行、节点详情任务清单、新建按钮(4 处) |
| `MultilineField` | 任务描述编辑(宿主 primitives 无多行输入,故自建) |
| `AgentGlyph` / `WorkflowGlyph` / `FoldGlyph` / `TaskLine` | 图与树两种模式共用 |
> **回退后修订**:上表与上一行提到的 `TaskCreateButton` 已删除——它唯一的调用方就是任务板,包一层没有收益;任务板的「新建」现在直接是宿主 outline `Button` + `IconPlusOutline16`(同一文案键 `teamTaskCreate`)。`TaskWindow` / `TaskPopover` / `MultilineField` / `OwnerPicker` 不变。
## 验证结果(2026-09-14)
PR:[#680](https://github.com/omdsh-dev/DSH-better-sidebar/pull/680)(分支 `feat/tasks-graph-workflow-teams`)。
- `pnpm typecheck` ✅;`pnpm vitest run` **133 文件 / 1400 用例通过**(新增:host 路由 29、模型 7、布局 9、页面交互 9);`pnpm build` ✅(皮肤契约 / 市场清单 / chunk 纯度守卫全绿)。
- `pnpm test:mount`(`DSH_CMD="npx -y --package @deepseek-ai/dsh@0.1.5-rc.2 dsh"`,本地必须绕开桌面 shim,见实施偏差 5)**7/7 通过**:真实挂载 + 无头 tab 全扫。其 PERF_JSON 里可读到 scratch profile(**无实验层**,等价 3384 的 profile)上 `/sidebar/api/workflows.list → {runs:[]}`、`/sidebar/api/teams.view → {available:false}` —— 即降级矩阵左列的实测证据。
- 3080(`~/.dsh-web`,含实验层)实景协议验证:`teams.view → {available:true, team:null}`、`workflows.list → {runs:[]}`、`teams.taskCreate → 404 team-error "the tree root leads no team"`(三分支里「层在、无团队」这条)。
- 本地可视化自检 harness(真实组件 + 真实主题令牌 + Playwright,360px/720px 两档):节点点击、ⓘ 浮窗、背景平移(Δ+60/+40)、折叠聚合、任务板常驻全部通过;本轮四条真机反馈中的三条由它先复现。
- 复现步骤与陷阱(令牌提取、primitives 桩、shim 与 tarball 重装坑)已沉淀到 `.workspace-docs/notes/dsh/09-14-dsh-better-sidebar任务管理页重构(工作流图与Teams任务板).md`。
未在本机自动化验证、留给用户实机确认的一项:3384 桌面应用**重启后**的 UI 级复看(新 bundle 已装进 `~/.dsh/profiles/web`,与仓库构建产物 SHA-256 一致)。
---
## 回退记录与结论(2026-09-21:任务页回到插件自有体系)
> 本节是 2026-09-17 – 09-21 那次视觉基座尝试的**唯一**记录:本文档曾用 250 余行详述它的依赖版本、Tailwind 接入方式、令牌映射表、vendored 组件清单、体积与真机排障,那套基座已整体回退,正文随之删除,只留「为什么回退」与「回退后的结论」。**本节出现的 shadcn / Tailwind / radix 字样都是对已废弃方案的引用**,不代表本仓库或消费插件的建议做法。
任务页 2026-09-17 换成 shadcn/ui + Tailwind v4 视觉基座(组件源码 vendoring 进 `src/client/ui/`,样式由工具类产出,颜色经 shadcn 语义令牌桥接到 `--dsw-*`),09-20 又按「对齐上游 stock」回调一轮;09-21 判定该基座与本插件的体系不可调和,整体回退。
**改动面**:8 个页面文件(`TasksGraph` / `TasksTree` / `TaskWindow` / `TeamBoard` / `JobsDrawer` / `TasksPopovers` / `SubagentView` / `tasks-shared`)以 `12b4716` 的原生实现(CSS Modules + 宿主 `@deepseek-ai/dsh-client-ui-primitives`)为基线重建,`src/client/ui/**`(23 个文件)、`components.json`、`scripts/ui-css.mjs` 与其 `ui:css` 脚本删除;`radix-ui` / `class-variance-authority` / `tailwind-merge` / `tailwindcss` / `@tailwindcss/postcss` / `postcss` 六个依赖下线;`tasks` 懒加载 chunk 与 `CHUNK_NAMES` 的 `tasks` 项一并取消(回到 `terminal` / `editor` / `mermaid` / `locale` 四项)。**回退只换视觉基座:09-17–09-21 期间的行为修复逐条重新落地**(见下「保留的行为修复」)。
### 回退根因
1. **默认尺度与插件体系冲突(最直接的一条)**:stock shadcn 的正文是 `text-sm`(14px)、meta `text-xs`(12px)、控件按 `h-9`(36px) 起;本插件的语言是正文 12px / meta 11px / 微标 10px、控件 28px(`sidebar.module.css` 的图标按钮即为 28px 方)。要贴合就得把 stock 的每个尺寸逐处改回去——那等于不用 shadcn,只是多背一层工具类。09-20 的「stock 回调」正是这条冲突的产物:字号一放开,`GRAPH_NODE_W` 132px 的图节点标题只剩约 7 个汉字(“图形重布重写”被截成“图…”),两轮下来只能把宽度 132→150→176 并改两行标题救场。
2. **层级语义冲突**:shadcn 给静态组件默认带投影(card `shadow-sm`,input / textarea / toggle / button-outline `shadow-xs`);本插件的语言是**静态面板无阴影**(层级 = 1px `--dsw-alias-border-l2` + hairline `--dsw-alias-border-l1` + 表面阶梯 + 墨色三档),只有浮层用 `--dsw-shadow-lv2/3`。第一轮把静态阴影全删了,stock 回调又把它们恢复成 stock——两次改的都是同一份 vendored 源码,每次跟上游 `--diff` 都要重新施加。
3. **彩色语义是第二套词汇表**:shadcn 的 `primary` / `secondary` / `muted` / `accent` / `destructive` 与 DSH 的 `--dsw-alias-state-*` / `--dsw-alias-interactive-bg-hover` 并非一一对应,任务状态(进行中 / 阻塞 / 完成 / 错误)还要再自造 `--success` / `--warning` 两个非 stock 令牌,整张映射表 22 条必须逐条维护:`--border` 先桥到 `--dsw-alias-border-l4`、09-20 又改成 `l2`,只因为 shadcn 的 1px 边框语义变了——而 `l2`(1px 边框)/ `l1`(hairline)的分工本来就是本插件自己的契约,不必经过第三层命名。
4. **不引 preflight 就得自己补一套**:插件注入的是全局 `<style data-plugin>`(无 shadow DOM),引 Tailwind 完整入口会重置整张宿主页面,所以只能引 `theme` + `utilities`;表单控件因此保留 UA 外观,真机反馈「任务页看起来完全没有 CSS,边框非常模糊」的根因即在此。补救是在 `theme.css` 写限定 `.dsw-tasks` 作用域的 preflight 子集 + 显式 `@layer theme, base, components, utilities;` 层序声明 + 页面根类。**这整套补偿只为承载 Tailwind 而存在**,原生 CSS Modules 不需要。
5. **架构代价**:radix 浮层栈 + `tailwind-merge` 把核心包撑到 1452623 bytes(+457.6 KiB,超 +250 KiB 预算),处置是把任务页整体下沉为懒加载 chunk(`lib/client-tasks.js` 706.7 KiB,`CHUNK_NAMES` 加 `tasks`、`package.json#files` 补 glob)。原生实现下这些负载不存在,`tasks` chunk 随回退下线。
6. **vendored 源码的持续负担**:每个 vendored 组件的本地适配(`cn` 改 `./utils`、图标换宿主 `IconXxx`、删 `dark:` 覆写、`text-white` → 令牌、React 18 的 `forwardRef`)都要跟着上游逐次重放;`popover.tsx` / `skeleton.tsx` 自始至终没有消费者。
### 结论(写给消费插件)
**嵌入式插件沿用宿主体系**:样式用 CSS Modules + `--dsw-*` 令牌(参考 `src/client/sidebar.module.css` / `src/client/changes/changes.module.css` / `src/client/SideChatView.module.css`),控件与图标用宿主 `@deepseek-ai/dsh-client-ui-primitives` 的 `Button` / `Menu` / `Modal` / `Pill` / `Tag` / `Switch` / `Input` / `Tooltip` / `StateDot` / `MarkdownText` / `DisclosureRow` 与 `IconXxx`;不引 Tailwind / radix / CVA / lucide,不自绘 svg(连线几何除外)。插件样式表是**全局作用域**的,任何自带 reset 或工具类的方案都要额外处理与宿主无层级样式表的优先级关系——这是本插件选择自有体系的直接原因。
### 保留的行为修复(与视觉基座无关,已在原生实现上重新落地)
| 修复 | 位置 | 守护 |
|---|---|---|
| 节点 id 去重:workflow member 的 `childId` 不在 run 发起者 catalog 里、但存在于树中别处时不再合成同 id 的第二张卡片;返回前兜底去重 | `src/client/tasks-model.ts` | `tests/tasks-model.spec.ts` |
| 任务窗口 CAS:编辑 / 改派携带 `expectedRevision`,冲突不吞 | `src/client/TaskWindow.tsx` | `tests/tasks-page.spec.tsx` |
| 后台任务终止两击确认(首击 arm、二击才发 `jobs.kill`) | `src/client/JobsDrawer.tsx` | `tests/subagent-jobs-view.spec.tsx` |
| 后台任务抽屉 ≥8 代理自动折叠,条形按钮 `aria-expanded` 可展开 | `src/client/JobsDrawer.tsx` | `tests/tasks-page.spec.tsx` |
| 输出浮窗可拖动;稳定 `data-*` 钩子(`data-graph-node` / `data-graph-controls`)不变 | `AnchoredPopover.tsx` / `TasksGraph.tsx` | `tests/tasks-page.spec.tsx` |
### 测试与文档清扫
- 删除三个守 shadcn 层的 spec:`tests/ui-foundation.spec.ts`(Tailwind 入口 / 令牌桥 / 层序 / 作用域 reset)、`tests/ui-bundle.spec.ts`(核心包与 chunk 的体积与产物断言)、`tests/ui-shadows.spec.ts`(静态面板阴影白名单)。
- `tests/theme.spec.ts` 去掉指向 `src/client/ui/**` 与令牌桥的一节,保留「图标模块零颜色字面量」与「每条 `color:` 解析到主题令牌」,后者从 tab 图标单表扩展到 `src/client/**/*.module.css` 全量(`color-mix()` / `var()` 链同样按令牌逐个校验,`inherit` / `currentcolor` / `transparent` 视为「不自己上色」)。
- `tests/bundle-route.spec.ts` 的 `CHUNK_NAMES` 期望回到 4 项;`tests/manifest-consistency.spec.ts` 的「每个 chunk 都被 `package.json#files` 覆盖」断言保留,列表继续从 `CHUNK_NAMES` 派生(不手抄)。
- `README.md` 两处「视觉基座为 shadcn/ui + Tailwind v4」描述删除;`docs/external-plugin-guide.md` 删除 §12.3「在插件里用 Tailwind / shadcn/ui」整节(含 preflight 规避、令牌桥、静态面板零阴影三条)。
## 本轮打磨(2026-09-27,`feat/tasks-graph-polish`)
图模式(主显示模式)按用户 brief 换成两段式卡片:上段是类型徽章 + 相位徽标 + 名称 + 元信息,
下段小条是状态点 + 状态词 + **主 Agent 同款合并活动行** + 已完成节点的折叠 chevron;
运行中小条从左到右扫光;层级/分支与 workflow 相位各用一套令牌色阶分组;后台任务从插件自建的
三条路由改成宿主客户端 `ctx.jobs`(推送 roster + 非消费观察流 + kill,`src/jobs-routes.ts` 已删除);
窄栏可读性按 a–f 六项逐一处理(卡片高度预算重算、内容包围盒居中、缩放手感、平移夹取、连线 stub 加粗、
相位框配色)。**树模式保持原样**(只随共享数据类型做最小适配)。
完整设计与偏差记录见 [2026-09-27-tasks-graph-polish.md](2026-09-27-tasks-graph-polish.md)。
## ⚠️ 0.1.7 复审(2026-09-27):本文的团队一半已成历史
本文档的数据源侦察与路由设计写于 **DSH 0.1.5-rc.2 / 0.1.6-alpha.2** 时代。团队那一半——`remoteView` /
`remoteCreateTask` / `remoteUpdateTask` 三个 Remote 方法、`teams.view` 路由、`team-task-conflict`
冲突联合、以及只有 `listMembers` 才带的成员 `status` / `model`——**在 0.1.7 上全部失效**:上游删掉了
那三个方法,把团队的读路径改到 Lead Session 的 `agentTeam` Session projection(写路径保留
`createTask` / `updateTask`,但拒绝语义从「返回联合」改成「抛 `TeamError`」,冲突码
`TEAM_TASK_STALE_REVISION`)。结果是 0.1.7 上团队条从来不渲染。
改写、真机证据与实施偏差见 [2026-09-27-agent-teams-dsh-0.1.7-adaptation.md](2026-09-27-agent-teams-dsh-0.1.7-adaptation.md);
本文档其余章节(workflow 折叠、图/树布局、fold 规则、jobs 数据源)仍然有效。
## 验收(回退后,2026-09-21)
`pnpm typecheck` ✅ 0 错误;`pnpm lint` ✅ 0 错误;`pnpm vitest run` **133 文件通过 / 1410 用例通过 / 9 skipped / 0 失败**(回退前:4 个文件失败,其中 `theme.spec.ts`、`ui-foundation.spec.ts` 在收集期就因 `src/client/ui/theme.css` 缺失报 ENOENT,`ui-shadows.spec.ts` 2 例、`bundle-route.spec.ts` 1 例断言失败)。
@@ -1,65 +0,0 @@
# DSH 0.1.6-alpha.2 适配(devDeps/CI 钉版推进,peer 下限不动)
日期:2026-09-17 分支:`feat/tasks-graph-workflow-teams`(PR #680)
## 背景
用户实机(桌面 3384)已在 **DSH 0.1.6-alpha.1** 上运行,而插件此前钉的是 `0.1.5-rc.2`(`latest`/`next` 仍是 rc.2,`alpha` 线已到 **0.1.6-alpha.2**)。本次把插件的 **devDependencies 与 CI 挂载钉版**推到 `0.1.6-alpha.2`,**peer 下限保持 `^0.1.5-rc.1` 不动**——rc.2 用户不受影响,插件同时兼容两条线。
同时按要求核对了「rebase 上游」:插件仓库 `origin/main` tip `1fcf43ccbedd`(2026-09-11)**是当前分支的祖先**(13 ahead / 0 behind),git 层面无可 rebase 的内容;本文件记录的是 DSH 上游适配。
## 上游 delta(rc.2 → 0.1.6-alpha.2,只列触达本插件的面)
`@deepseek-ai/dsh-client-ui-primitives` 逐模块 `diff`(.d.ts)+ 全量 `lib/**/*.js` 裸 import 扫描的结论:
| # | 变更 | 影响 | 处置 |
|---|---|---|---|
| 1 | **移除** `IconSendOutline16`(该版本唯一被移除的图标;新增 7 个) | `SideChatView` 编译失败 | 换成 `IconSendOutline14`(编译器建议的继任者) |
| 2 | `TerminalBlockLabels` **新增必需字段 `noExitCode: string`**(语义:命令结束时拿不到退出码——被未知信号终止或从未启动) | 标签对象类型不符 | 补 `noExitCode` 并新增词条 `sideChatBlockNoExitCode`(20 份词典同步) |
| 3 | `ConnectionIndicator` **移除 `reconnectLabel`**(停用态文案本身已命名重连动作) | 传了不存在的 prop | **跨线兼容 shim**:rc 线里该 prop 是必需且是重连按钮的 hover/焦点动作文案,peer 范围仍覆盖 rc,因此把组件类型加宽一次(`ConnectionIndicatorCompat`)并继续传该 prop——rc 渲染它、alpha.2 忽略多余键(`src/client/SideChatView.tsx` 有注释说明何时删掉这个 shim) |
| 4 | `Menu` / `Modal`:**仅文档 + 键盘语义细化**(Tab 在列表内 settle、Escape 归属、菜单打开时 Escape 先给菜单) | 无类型破坏 | 不改代码;e2e/harness 覆盖既有交互 |
| 5 | `index.d.ts` 新增导出:`Checkbox`、`isDarwinDesktop`、`MarkdownDelegateProvider`/`MarkdownDelegate`/`MarkdownExternalLinkHandler`、`UserTextReferences`;模块新增 `SiteGlyph` | 纯增量 | 不使用,不改代码 |
| 6 | `dsh-tools` 在本线**不再 peer `dsh-code-runtime`** | 旧 hoist 失去理由,且上游从未发布该包 alpha.2 | **从 devDependencies 删除 `dsh-code-runtime`**(保留会造出「22 个 alpha.2 + 1 个 rc.2」的混合树) |
| 7 | primitives **仍声明零 `dependencies`**,但 `lib/index.js` **新增两个裸 import**:`diff`(`DiffBlock` 的 `structuredPatch`)与 `simple-icons`(品牌字形) | 缺失会让**所有** import primitives 的 spec 在 vite 解析期直接 `Failed to resolve import`(6 个 jsdom 套件实测挂掉) | `diff@^9.0.0`(对齐桌面实机 9.0.0)与 `simple-icons@^15.0.0` 进 `dependencies`;并在 AGENTS.md §3.9 记录「先扫裸 import 与 node_modules 的差集」 |
上游发布完整性核查:**23 个 `@deepseek-ai/*` devDeps 中 22 个有 alpha.2**,唯一缺失的 `dsh-code-runtime` 正是第 6 条里被上游放弃 peer 的那个;`dsh-tool-bash-persistent` / `dsh-sandbox-windows-acl` / `dsh-app-boot` 等历史上出过 ETARGET 的包**都已发布**,因此把 CI 钉到 alpha.2 没有踩到「分阶段发布」的坑(仍保留 `NODE_OPTIONS=--max-old-space-size=4096`)。
## 改动清单
| 文件 | 变更 |
|---|---|
| `package.json` | 22 个 `@deepseek-ai/*` devDeps `0.1.5-rc.2` → `0.1.6-alpha.2`;删除 `@deepseek-ai/dsh-code-runtime`;新增 `diff@^9.0.0`、`simple-icons@^15.0.0` |
| `pnpm-lock.yaml` | 随装更新 |
| `src/client/SideChatView.tsx` | `IconSendOutline16` → `IconSendOutline14`;标签对象补 `noExitCode`;删 `reconnectLabel` |
| `src/client/locales.ts` / `locales-ja.ts` + 18 份第三语言词典 | 新增 `sideChatBlockNoExitCode`(20 份 key 集同步) |
| `.github/workflows/ci.yml` | 挂载车道**保持 `0.1.5-rc.2`**(见下「CI 钉版为什么留在 rc.2」),注释里记入探针证据 |
| `AGENTS.md` | §2 钉版说明、§3 新增 #680 适配段、§3.9 追加 `diff`/`simple-icons` 两个 hoist |
| 本文件 | 适配记录 |
peer 范围、`dsh.client.inject`、chunk externals 白名单、`SIDEBAR_SERVICE_VERSION`(`0.19.1`)**均未改动**——alpha.2 对宿主契约只做了第 4/5 条的文档级与增量变更。
## 验证(真实输出)
- `pnpm install`:`+ diff 9.0.0`、`+ simple-icons 15.0.0`,`Done`;`pnpm peers check` → **No peer dependency issues found**。
- `pnpm typecheck` → 0 错误(修完上述 3 处后)。
- `pnpm lint` → 0。
- `pnpm vitest run` → **136 文件 / 1503 用例通过 / 9 skipped**(其中 `locales.spec` 16/16)。
- `pnpm build` → 成功;`ui-bundle` 守卫仍绿(核心包 835.6 KiB、chunk 709.8 KiB、无 preflight/oklch/lucide)。
- `pnpm test:mount`(`DSH_CMD="npx -y --package @deepseek-ai/dsh@0.1.6-alpha.2 dsh"`,真实挂载 + 无头 tab 全扫)→ **7/7**;`PERF_SUMMARY mount=480ms longtasks=2/221ms`(预算 ≤8);资源清单里 `/sidebar/bundle/tasks.js` **真实下载**(`decodedBodySize 726905`),懒加载路径在 alpha 线上同样成立。
> **注(2026-09-21 回退后)**:上面两条引用的 `ui-bundle` 守卫与 `tasks` 懒加载 chunk(`/sidebar/bundle/tasks.js`)都是已废弃的 shadcn/ui 视觉基座时期的产物,该基座已整体回退(见 [2026-09-14 任务页设计文档](./2026-09-14-tasks-graph-workflow-teams-design.md) 的「回退记录与结论」):`src/client/ui/**`、`ui-bundle` spec 与 `tasks` chunk 均已删除,任务页回到核心包 + 原生 CSS Modules。以上数字是当时的实测记录,原样保留。
## CI 钉版为什么留在 rc.2(含证伪过程)
第一版把挂载车道也钉到 `0.1.6-alpha.2`,CI 的 `plugin-mount` **确定性失败**(重跑同样红):`mount.e2e.ts:216` 等 `[data-dsh-better-sidebar] [title]`(插件底部工作台 tab 条)90s 不出现,`perf.e2e.ts:182` 同样卡在底部拖拽条。失败截图显示外壳正常但**没有活动会话**(会话面板停在空态占位),按 e2e 自己的注释「没有 session scope → 工作台没有 pane」推断:alpha 线在 CI 上不再自动激活 scratch profile 的种子会话。
关键一步是**证伪「这是本分支引入的」**:从 `origin/main` 拉探针分支 `probe/dsh-alpha2-ci-pin`,**只改这一行钉版**(零插件改动),用 `gh workflow_dispatch --ref` 触发(ci.yml 的 push 只对 main 生效,探针分支没有 PR 就不会自动跑)——`plugin-mount` **同样红**、同一断言、同一处(run `35484961070`)。本地(macOS)同一钉版 `pnpm test:mount` 却 7/7,说明这是 **alpha 线 + Linux CI 车道**的组合问题,与插件代码无关。
按仓库硬约束(不改 DSH 源码)与 KISS,处置是:**devDeps 继续跟 alpha.2(对齐维护者实机),车道回到已发布的 rc.2**,并在 ci.yml 注释与 AGENTS.md §2/§3 写明证据与「车道学会在 alpha 线激活种子会话之前,alpha 只做本地验证」。把车道适配 alpha 线(例如断言前显式点击种子会话,或等外壳自动选中)留作**独立事项**:那是 e2e 基础设施改动,不该混进依赖钉版推进。
## 不做 / 留待发版决定
- **不改 peer 下限**(用户确认):`^0.1.5-rc.1` 继续容纳 rc.1/rc.2 用户;本适配对他们是无感的。
- **不改 README 的版本徽章与安装策略文案**:它们描述的是**已发布**的 v0.19.1("已在 0.1.5-rc.2 上验证"),把分支上的 alpha.2 验证写进去会让已发布版声称未做过的验证。发版时(若该分支进入某个 alpha 版本)再一并更新。
- **不清理 `sideChatConnReconnect`**:跨线 shim 仍在用它(rc 线必需),删它要动 20 份词典且会让 rc 用户少文案。
- **源码编译需要 alpha.2 的类型**:标签对象里 `noExitCode` 在 rc 线类型上不存在(多余属性会被 TS 拒绝),所以本分支的 `pnpm typecheck` 以 alpha.2 为准;运行时两线都安全(多余键被忽略)。
@@ -1,130 +0,0 @@
# DSH 0.1.6-alpha.2 适配(v0.20.0 基线)
日期:2026-09-21 分支:`feat/dsh-0.1.6-alpha.2`
## 背景
DSH `dsh-v0.1.5-rc.2..dsh-v0.1.6-alpha.2`(上游 **1687 个 commit**,中间还有 `dsh-v0.1.6-alpha.1`;`git diff --shortstat` = 5420 个变更文件 / +878 484 / −63 637)是 0.1.6 线的第二个预发布版,也是本插件自 rc.1 以来第一次遇到**真实破坏**的 delta。两件关键的事实构成这版全部工作:
1. **两条插件面向的硬契约收紧,且都是静默失败**(§上游 delta 1/2);三条 `ui-primitives` 破坏是编译期可见的。
2. **宿主内置了插件原本自研的两个面**(右列终端、右列浏览器)。继续保留就是同一面存在两套实现,并且遮蔽产品正在开发中的宿主类型。
本计划记录该 delta、逐项结论与子系统级落点;上一版记录见 [2026-09-10-dsh-0.1.5-rc.2-adaptation.md](./2026-09-10-dsh-0.1.5-rc.2-adaptation.md),仓库级适配要点同步到 [AGENTS.md](../../AGENTS.md) §3(本版整体重写,是该节的权威摘要)。
本版由三条 commit 组成(`git log --oneline -4`,`git show --stat <sha>`):
| # | commit | 主题 | 规模 |
|---|---|---|---|
| 1 | `58ba17d` | `fix(native): meet DSH 0.1.6-alpha.2 guide-id and turn-tail contracts` | 17 files,+135 / −611 |
| 2 | `d608998` | `chore(baseline): target DSH 0.1.6-alpha.2 and drop 0.1.5-rc support` | 27 files,+448 / −350 |
| 3 | `5912274` | `feat: yield the terminal and browser to DSH 0.1.6's own sidebar types` | 79 files,+381 / −9 464 |
## 上游 delta(rc.2 → alpha.2,影响本插件的部分)
所有 alpha.2 结论都用 tag 取证(`git -C /tmp/dsh-0162 diff dsh-v0.1.5-rc.2 dsh-v0.1.6-alpha.2 -- <path>` / `git show dsh-v0.1.6-alpha.2:<path>`),不引用工作树、不引用记忆。
| # | 上游变更 | 上游文件(alpha.2) | 对插件的影响 |
|---|---|---|---|
| 1 | `SidebarRightGuideEntry.id` 变**必填**且同一 provider 内唯一;注册时 `new Set(entries.map(e => e.id)).size !== entries.length` 抛 `duplicate guide entry id` | `packages/client/ui-sidebar-right/src/client/tab-registry.ts`(抛点 `:251`,接口定义 `:61` 起;单测 `tests/tab-registry.client.spec.ts:302`) | **破坏(静默)**:插件两个 guide 条目都在 `undefined` 上相撞 → `register` 抛错 → 抛点在本插件 `ctx.inject(['sidebarRightTabs'], cb)` 的**回调体内**被 cordis 吞掉 → 整个原生承载面空掉(指南无条目、`openTab` 全拒)。→ §决策 6 |
| 2 | `conversation.chat.turnTail` 由 `chain` 改 `list`;list 槽要求 `options.id`,缺则抛 `list slot "…" requires options.id` | `packages/client/ui-chat/src/client/contract/slots.ts:213`;抛点在 `packages/client/ui-slots/src/index.ts:1230` | **破坏(静默)**:插件传 `select` 直接抛(同样被吞)。语义也变了——chain 的 `select` 让一个条目**替换**宿主产物行,list 只能**追加**。→ §决策 5 |
| 3 | `ui-primitives` 三处导出面变化:`IconSendOutline16` 不再导出(只剩 `IconSendOutline14`);`TerminalBlockLabels` 新增**必填** `noExitCode`;`ConnectionIndicator` 删掉 `reconnectLabel` | `packages/client/ui-primitives/src/icons/index.tsx`(`IconSendOutline16` 在 rc.2 有、alpha.2 无)、`src/**`(`git diff … -- packages/client/ui-primitives/src` 里 `−reconnectLabel` / `+noExitCode`) | **破坏(编译期)**:适配见 §改动清单「基线」 |
| 4 | 宿主自带右列终端:`ui-sidebar-terminal` + `api-terminal-controller`;模型侧等价物 `packages/terminal/tool-terminal`(包名 `@deepseek-ai/dsh-tool-terminal`)**未被任何 shipped bundle 挂载** | `packages/client/ui-sidebar-terminal/**`、`packages/api/terminal-controller/**`、`packages/terminal/tool-terminal/**` | **让位** → 插件整套 PTY 栈删除。证据:`git grep -rn tool-terminal -- packages/bundle/` 在 alpha.2 只有 **1 处命中且是注释**(`packages/bundle/web-app/cordis.patch.yml:404`,讨论后台 job 注册表的生产者),没有任何 bundle 行挂载该包 |
| 5 | 宿主自带 `browser` kind,且 `ui-chat` 已内置把正文 http(s) 链接委派给它 | `packages/client/ui-sidebar-browser/**`;`packages/client/ui-chat/src/client/apply.ts:147`(`ctx.get('sidebarRightTabs')?.get('browser')` 守卫后 `ctx.sidebarRight.openTab('browser', { params: { url } })`) | **让位** → 插件 `browser` tab 类型与宿主 `browser.probe` 路由删除;DOM 层链接接管保留(宿主不做按协议分流) |
| 6 | 原生右列布局**现在持久化**:`localStorage` 的 `dsh.sidebar-right.v1.<sessionId>`(zod 校验,tab 体渲染前恢复) | `packages/client/ui-sidebar-right/src/client/persistence.ts`(`sidebarPersistence = 'dsh.sidebar-right.v1'`)、`README.zh.md:72` | §3.10 里「原生布局只在内存,不做持久化」的旧说法对 0.1.6 起**已失效**;插件不接入、只用现成面 |
| 7 | `sidebar-right` 新增面:`SidebarRightTabDefinition.multiple?`、`SidebarRightPlacement.preferNewPane?`、class 上的 `openTabs` / `tabsIn(sessionId)` / `registerCloseHandler(kind, handler)`(**不在** `ISidebarRight` 接口里)、keyed 槽 `sidebar.right.tab.guide.entry` | `tab-registry.ts:103`、`contract/slots.ts:117`、`service.ts:218/228/261/387` | 插件只用到 `multiple`;其余仅保证不破坏 |
| 8 | 官方管控面:新增 `packages/boot/plugin-manager`(`ctx.pluginManager` + `plugin_manager` 工具)与 `packages/client/ui-plugin-manager`(Web Plugins 页);`dsh.profile.patchReload` 在 alpha.2 已从**代码与文档整体消失**(rc.2 由 `apps/cli/src/plugin.ts` / `profile-boot.ts` 读取 + 4 份 `apps/cli/**` 文档 10 处说明;alpha.2 在 `packages/` + `apps/` 上 **0 命中**,只剩 `.agents/notes` 的陈旧文本);HMR 归 `packages/boot/hmr` | 同上 | **零额外声明**:两者都会接管任何已声明 `dsh.bundle.patch` 的包(本插件 `package.json` 的 `dsh.bundle.patch` = `./cordis.patch.yml`),本插件无需适配。`patchReload` 本插件**从未消费**(`grep -rn patchReload src tests scripts` 零命中),因此删除对本插件零影响 |
| 9 | 依赖形状:`ui-primitives` 仍**不声明** `dependencies`(`peerDependencies` 只有 `@deepseek-ai/cordis`),bundle 新增裸 import `diff`(`src/DiffBlock.tsx:3`)与 `simple-icons`(`src/SiteGlyph.tsx:44`,全新 3 万余品牌图标集);`packages/code-runtime` **整包消失**(rc.2 还在,npm 上停在 0.1.5-alpha.2) | `packages/client/ui-primitives/package.json`、`packages/client/ui-primitives/src/*` | devDependencies 提升 `diff` / `simple-icons`;移除 `@deepseek-ai/dsh-code-runtime` |
**明确未变(不必再核)**:
- 指南「列出的条目 ≤ 4 条才渲染说明」的规则仍在:`MAX_DESCRIBED_ENTRIES = 4`(`packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx:40`)。
- 一次性 token 鉴权、斜杠 RPC 方言、`MarkdownText` labels 嵌套契约、会话事件模型(`assistant/assistant-stream` 瞬时帧):全部沿用 0.1.5 线结论,本版零改动。
- 插件的承载面用法不变:仍由 `ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(**不能**等槽声明),仍只经 `ctx.sidebarRight.openTab/openResource/close` 与具体类上的 `openTabIn/…` 操作。
- 宿主仍有 chain 槽(如 `conversation.composer`、`sidebar.right.tab.guide`),但本插件**从未**注册它们——插件的槽面只有三处:`conversation.session.header.utilities`(底部工作台开关)、`settings.section`、原生 `sidebar.right.pane.tab` / `.title`;唯一用过的「整行接管」型贡献(轮尾产物行)随 `turnTail` 改 list 而删除(#2)。
- `dsh.plugin.json` **不是** DSH 契约:`git grep -n 'dsh\.plugin\.json'` 在 `dsh-v0.1.5-rc.2` 与 `dsh-v0.1.6-alpha.2` 上**均 0 命中**。它只服务第三方 registry(社区市场)通道,`lib/client-registry.js` 同理。
## 决策
1. **peer 下限必须动:`^0.1.5-rc.1` → `^0.1.6-alpha.2`**。semver 的预发布规则要求比较子与候选版本同 `[major,minor,patch]` 元组,`^0.1.5-rc.1` 在数学上**永远容纳不下任何 `0.1.6-*` 预发布版**。实测(用仓库树内的真实 `semver@7.8.5`):
| range \ candidate | `0.1.5-rc.2` | `0.1.6-alpha.1` | `0.1.6-alpha.2` | `0.1.6-alpha.3` | `0.1.6-rc.1` |
|---|---|---|---|---|---|
| `^0.1.5-rc.1` | true | false | false | false | false |
| `^0.1.5-rc.2` | true | false | false | false | false |
| `*`(裸) | false | false | false | false | false |
| `^0.1.6-alpha.2` | false | false | **true** | **true** | **true** |
即:0.1.5 的下限不是「保守」而是**结构上不可能**容纳 0.1.6 预发布;`^0.1.6-alpha.2` 天然容纳 0.1.6 的 alpha/rc 全线并排除 0.1.5。
2. **支持线取舍:0.1.5-rc.\* 用户停留在 v0.19.1**。rc.2 → alpha.2 有真实破坏(#1/#2),按本仓库先例(v0.19.0 抬到 0.1.5-rc.1 时直接桥掉 0.1.2 线)**不写运行时兼容层**;0.1.5-alpha.2 及更早同样停留在旧版。`engines.dsh` 与 peer 下限同源(同一个支持范围,两处不能分叉)。
3. **终端整体让位**:删除整套 PTY 栈(见改动清单),宿主 `ui-sidebar-terminal` 成为唯一提供者。**接受模型侧代价并写进 README**:插件原有的 8 个 `terminal_*` 工具(`terminal_create/list/send/read/wait/resize/signal/close`,默认关)是模型唯一的**跨调用持久**终端,而上游等价物 `@deepseek-ai/dsh-tool-terminal` 未被任何 shipped bundle 默认挂载,删除后模型只剩一次性 `bash` / `pwsh`——这是「不维护第二套 PTY」的既定代价,不是意外,所以不补自家的替代实现。
4. **浏览器让位,但保留 DOM 层链接接管**。宿主不做按协议分流(https 默认交系统浏览器),那是插件唯一还能提供的能力,因此 `src/client/link-intercept.ts` 与已发布的 `urlTarget` 机制(v0.13.0+,第三方消费插件按它声明)不动;接管目标改指**宿主的** `browser` kind(`ctx.sidebarRight.openTab('browser', { params: { url } })`),并保留 `window.open` 兜底——宿主没装该包时不至于点了没反应。
5. **轮尾接管整体删除**。list 只能追加,插件再注册一行只会与宿主的产物卡**重复**;上游是有意选 list(「An additive list lets both plugins contribute without either knowing the other's data or rendering」,`.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md:27`)。产物行交回宿主,它开文件走宿主 `openFile` → 本插件的 editor,**行为不变**。文件打开的那一半(`sidebar-file.ts`)与路径助手(`paths.ts` 的 `resolveSidebarPath`)保留,因为文件变动 tab 与编辑器宿主仍在用。
6. **guide 条目 id 作为类型契约落进插件侧**:两个条目各自带 `id`(descriptor id / `'files'`),并加测试守护「每个 guide 条目 id 非空且互不重复」。**不做运行时兜底**(自动补 id 会把契约破坏变成永久沉默,正是这次最难查的形态)。
7. **加一条可见的失败接缝**:`registerNativeSurface` 新增可选 `reportFailure(phase, error)`,接到 `src/client/index.tsx` 的 `fail()` 可见诊断条;单个 descriptor 注册失败不再带走其余注册。理由就是这次的症状——整个原生承载面静默空掉,除了一行缺失的指南之外没有任何提示。
8. **卡片采用宿主胶囊配方,但字重走令牌阶梯**:几何/描边/填充/圆角照抄宿主 `GuideBody.module.css` 的 `.entry`(0.5px `--dsw-alias-border-l4` / 24px 圆角 / `--dsw-alias-bg-layer-1` / `min-height: 56px` / `padding: 14px 20px`),标签字号**不抄**宿主那里的裸 `font-size: 15px`(它背后没有令牌),改用阶梯最近一档 `--dsw-font-s-strong-14`(14px medium)。皮肤契约要求插件侧零豁免面(AGENTS.md §5)。
9. **不接入本版新增的宿主面**:`openTabs` / `tabsIn` / `registerCloseHandler` / `preferNewPane` / keyed `guide.entry` 槽 / 全局面板——插件只用到 `multiple` 与既有面。
## 改动清单(子系统级)
| 子系统 | 文件 | 变更 |
|---|---|---|
| 基线 | `package.json` | `version` → `0.20.0`;15 个 `@deepseek-ai/*` peer 下限 `^0.1.5-rc.1` → `^0.1.6-alpha.2`;devDependencies 同步钉 `0.1.6-alpha.2`;移除 `@deepseek-ai/dsh-code-runtime`(上游整包消失);新增 `diff` `^9.0.0`、`simple-icons` `^16.32.0`(`ui-primitives` 裸 import,不走 devDependencies 就 `Cannot find package`) |
| 基线 | `dsh.plugin.json` | `version` → `0.20.0`;`engines.dsh` → `>=0.1.6-alpha.2` |
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 alpha.2(646 行变动) |
| 基线 | `.github/workflows/ci.yml` | mount 车道钉 `@deepseek-ai/dsh@0.1.6-alpha.2`;注释改为「alpha.2 是 npm 的 `alpha` dist-tag,`latest` 仍是 0.1.5 线」 |
| 基线 | `scripts/e2e-common.sh` | 回退 npx **显式钉版**:新增 `DSH_NPX_SPEC`(缺省 `@deepseek-ai/dsh@0.1.6-alpha.2`)——不钉就会解析 `latest`,而 `latest` 仍是插件已不支持的 0.1.5 线 |
| 基线 | `src/client/service.ts` | `SIDEBAR_SERVICE_VERSION` → `0.20.0`(`tests/service.spec.ts` 断言它 === `package.json` 的 version) |
| 基线 | `src/client/SideChatView.tsx` | `IconSendOutline16` → `IconSendOutline14 size={16}`(保住原 16px 视觉尺寸);`TerminalBlockLabels` 补必填 `noExitCode: t('sideChatBlockNoExitCode')`;`ConnectionIndicator` 去掉 `reconnectLabel` |
| 基线 | `src/client/locales.ts` + 19 份 `locales-*.ts`(共 20 份词典) | 新增 `sideChatBlockNoExitCode`(zh「已结束(无退出码)」/ en「Ended without an exit code」+ 各语言);`sideChatConnDisconnected` 改口径为「连接已断开,点击立即重连」——断线标签现在**自身**命名重试动作,跟随宿主文案 |
| Tab 契约 | `src/client/native/index.ts` | 两个 guide 条目各带 `id`(descriptor id / 接管条目 `'files'`);`NativeSurfaceDeps` 新增可选 `reportFailure?: (phase: string, error: unknown) => void`,wrapper 内 try/catch 上报(单个 descriptor 失败不再带走其余注册) |
| Tab 契约 | `src/client/index.tsx` | `fail()` 诊断条上移到 `apply()` 前部,并以 `reportFailure: (phase, error) => fail(\`native ${phase}\`, error)` 注入 `registerNativeSurface` |
| Tab 契约 | `tests/native-surface.spec.ts` | +15 行:遍历注册到的全部 guide 条目,断言每个 `id` 是 string、总数 > 0、`new Set(ids).size === ids.length` |
| 轮尾 | `src/client/intercept.tsx`(−131)、`src/client/produced-files.ts`(−120)、`tests/turn-tail-intercept.spec.ts`(−200)、`tests/produced-files.spec.ts`(−56) | 轮尾接管整体删除;`src/client/sidebar.module.css` 的接管样式随之删(−46) |
| 轮尾 | `src/client/sidebar-file.ts`(新增 30)、`src/client/paths.ts`(+13) | 文件打开的那一半存活(文件变动 tab 与编辑器宿主仍用);`resolveSidebarPath` 移进同胞路径助手模块(绝对路径判定镜像宿主:POSIX 根 / 盘符 / UNC 都不拼 cwd) |
| 终端 | 删除 `src/pty-manager.ts`(−548)、`src/agent-pty.ts`(−722)、`src/pty-deps.ts`(−240)、`src/tools.ts`(−500)、`src/client/TerminalView.tsx`(−477)、`src/client/TerminalWaitBanner.tsx`、`src/client/terminal-font.ts`(−255)、`src/client/terminal-links.ts`(−194)、`src/client/chunks/terminal.tsx`、`src/client/pinned.ts`(−177)、`src/client/sidebar/use-pinned-tabs.ts`(−147) | 整套 PTY 栈、xterm 视图、字体/链接助手、chunk 入口与跨会话固定终端全部下线 |
| 终端 | `src/index.ts`(+14 / −560)、`src/config.ts`(−48)、`src/prefs-shared.ts`、`src/client/prefs.ts`(−26)、`src/client/api.ts`(−40)、`src/client/state.ts`(+11 / −244)、`src/client/TabBar.tsx`(+20 / −76)、`src/client/Sidebar.tsx`(+16 / −91)、`src/client/icons.tsx`(−11) | 终端设置项(`terminalShell` / `terminalShellArgs` / `terminalFontFamily` / `terminalFontSize` / `agentTerminalTools` / `bottomPanelAutoTerminal`)、宿主路由 `terminal.deps`、`clampTerminalFontSize`,以及被删特性留下的死状态(`SidebarTab.pin` / `nextTerminal` / `agentWaits` / `bottomOpenedOnce` / agent-tab helpers / pinned-tab memo key)一并清除 |
| 终端 | `package.json` | `node-pty` `^1.1.0` 离开 `dependencies`;`@xterm/xterm`、`@xterm/addon-fit` 离开 devDependencies;`files` 去掉 `lib/client-terminal.js` |
| 终端 | `pnpm-workspace.yaml` | 原 `allowBuilds: {node-pty: true, protobufjs: true}` 收缩(**块未整块删除**,见「收尾偏差」2) |
| 终端 | `tsdown.config.ts`、`src/bundle-route.ts`、`src/client/chunk-loader.ts`、`scripts/package-registry.mjs`、`package.json` 的 `files`、`tests/{bundle-route,chunk-loader,chunk-artifact,manifest-consistency,lazy-chunk}.spec.*` | chunk 名由 `['editor','mermaid','locale','terminal']` 收敛为 `['editor','mermaid','locale']`(AGENTS.md §5 列出的镜像处全部同步) |
| 终端 | 删除 13 个 spec | `agent-pty` / `agent-terminal-reconcile` / `agent-wait-badge` / `bottom-auto-terminal` / `open-when-sized` / `pinned` / `pty-deps` / `pty-helpers` / `terminal-deps-banner` / `terminal-font` / `terminal-links` / `terminal-wait-banner` / `tools` |
| 浏览器 | 删除 `src/client/BrowserView.tsx`(−289)、`src/client/browser.ts`(−167)、`src/browser-probe.ts`(−26)、`tests/browser.spec.ts`、`tests/browser-probe.spec.ts`;`src/client/builtins/tabs.tsx`(+13 / −175)删 `browser` descriptor | 插件 `browser` tab 类型下线,宿主 `browser` kind 独占嵌入页(两条 commit 合计删 15 个 spec:终端 13 + 浏览器 2) |
| 浏览器 | `src/index.ts`(删宿主 `browser.probe` 路由)、`src/prefs-shared.ts` / `src/client/prefs.ts`(删 `browserNoSandbox` / `browserAllowedLoopback`)、`src/config.ts` | 只配置插件自家 iframe 的两个设置项随它们喂养的宿主路由一并删除(宿主 `browser` kind 不进插件注册表) |
| 浏览器 | `src/client/link-intercept.ts`、`src/client/sidebar/use-host-feeds.ts:128` | 保留 DOM 层 http(s) 接管,目标改宿主 `browser` kind(`ctx.sidebarRight.openTab('browser', { params: { url } })` + `window.open` 兜底);`urlTarget` 机制与匹配顺序(`matchUrlTarget`)不变 |
| 卡片 | `src/client/sidebar.module.css` `.paneCard` | 采用宿主 guide 胶囊配方:`min-height: 56px` / `padding: 14px 20px` / `border: 0.5px solid var(--dsw-alias-border-l4)` / `border-radius: 24px` / `background: var(--dsw-alias-bg-layer-1)` / `font: var(--dsw-font-s-strong-14)` / hover 用 `--dsw-alias-interactive-bg-hover` |
| 卡片 | `tests/theme.spec.ts`(+23) | 守护 `.paneCard` 的几何与「每条 paint 都解析到 `var(--dsw-`」,并断言**不得**出现上游专有拼写 `--dsw-alias-bg-l1` / `--dsw-alias-bg-l2`(证据:alpha.2 全树 4 处声明、**0 处定义**,全在 `packages/client/ui-sidebar-terminal/src/client/*.module.css`,故上游那 4 条 `background` 实际被丢弃) |
| 挂载冒烟 | `tests/e2e/mount.e2e.ts` | 指南 sweep 拆成「插件自有 4 条(files / git / subagent / sidechat)+ 宿主自有 2 条(terminal / browser,各**恰好 1 条**——多 1 条即插件又在遮蔽宿主)」;说明表断言从「关 3 个 tab 类型」改「关 2 个」(宿主终端条目已不在插件注册表内) |
| 聚合冒烟 | `scripts/e2e-aggregate-mount.sh` | 真方法哨兵从已删的 `terminal.deps` 改为 `settings.get`(理由写进脚本注释:`/sidebar/api` 是插件自己 fence 的 prefix 路由,未注册方法统统 404,`{"ok":true}` 只可能来自真实注册的 handler) |
| 文档 | `AGENTS.md` §3 | 重写为「DSH 0.1.6-alpha.2 适配要点(0.1.6-alpha.2+ 基线)」:支持线 + 两条硬契约 + 两处让位 + 「本版还需知道的」9 条 |
| 文档 | `README.md` | v0.20.0 段落、终端/浏览器「交还宿主」说明、支持版本徽章;终端卡片内写明模型侧代价与自行插入 `tool-terminal` 行的指引 |
| 文档 | `docs/plans/2026-09-21-dsh-0.1.6-alpha.2-adaptation.md` | 本计划 |
### 收尾偏差(核对实际文件后修正的三处口径)
这三处不是计划项,而是**核对文件时发现的「提交信息与产物不符」**;写在这里以免被当作已完成,也给下一版留证据:
1. **安装脚本的 `-Repair` 模式**:`5912274` 的提交信息与 `README.md` 都称它已随终端删除,但三条 commit **都没有触碰这两个文件**——`git diff --stat HEAD~3..HEAD -- scripts/` 只有 `e2e-aggregate-mount.sh` / `e2e-common.sh` / `package-registry.mjs` 三个。`scripts/install.sh`(`--repair`,改动前行号 15/19/54/57/75/82/180)与 `scripts/install.ps1`(`-Repair`,改动前行号 22/26/51/175)当时**完整保留**了 node-pty 修复流程与 issue #140 文案。**该偏差已在本分支工作树修掉**(两个安装脚本 + `e2e-common.sh` 里 scratch profile 的 `allowBuilds` 块 + `package-registry.mjs` 的 node-pty 注释),截至本文档写作时**尚未提交**(见「落地」)。
2. **`pnpm-workspace.yaml` 的 `allowBuilds` 块仍未收干净**:现状是 `node-pty: set this to true or false`(pnpm 自己写的待决占位行);原条目 `node-pty: true` 被换成该占位、`protobufjs: true` 被移除。块本身仍在,且仍以**已删除的依赖名**出现——**本版未处理**。
3. **`browserNoSandboxWarning` 成了死键**:20 份 `src/client/locales*.ts`(词典 `locales.ts` + 19 份第三方语言)里仍在,而它描述的 `browserNoSandbox` 设置项已删,代码侧除词典外零消费——**本版未处理**。
## 验证
- **门禁(本版分支 HEAD `5912274`,macOS arm64)**:`pnpm install --registry=https://registry.npmjs.org`(**必须显式指定 registry**——机器默认的 npmmirror 源滞后,对 `@deepseek-ai/dsh-code-runtime` 报 404);`pnpm peers check` = `No peer dependency issues found`;`pnpm typecheck` 干净;`pnpm lint` 干净;`pnpm check:consumer-types` OK;`pnpm test` = **110 files / 1085 passed / 9 skipped**;`pnpm build` 恰好产出三个 chunk(`client-editor.js` / `client-mermaid.js` / `client-locale.js`)。
- **写本文档时复核过的两条**:`pnpm peers check` = `No peer dependency issues found`、`pnpm test` = **110 files / 1085 passed / 9 skipped**,与上条一致(复核时工作树只有文档改动,测试文件与 HEAD 一致,因此数字对应 HEAD 的测试面;此后工作树又改动了 `tests/theme.spec.ts` 等收尾文件,**未重跑**)。`typecheck` / `lint` / `check:consumer-types` / `build` **未重跑**(本节记录的是门禁那一次的结果)。
- **真机挂载冒烟(DSH 0.1.6-alpha.2):未执行**。`pnpm test:mount` 与 `pnpm test:mount:aggregate` 在本版分支上**没有跑过**——这是本版最大的未验证面:指南「插件 4 条 + 宿主 terminal/browser 各恰好 1 条」、聚合哨兵改 `settings.get`、卡片新几何,目前只有单测与 e2e 代码,没有真机结论。
- **3080 实例 GUI 人工验证:未执行**。
- **上游事实的取证方式**:本文每条 alpha.2 结论都来自 tag(`git -C /tmp/dsh-0162 diff dsh-v0.1.5-rc.2 dsh-v0.1.6-alpha.2 -- <path>` / `git show dsh-v0.1.6-alpha.2:<path>`),关键抛点原文为 `sidebarRight: duplicate guide entry id in "<id>"`(`tab-registry.ts:251`)与 `list slot "<name>" requires options.id`(`ui-slots/src/index.ts:1230`)。semver 表用仓库树内的 `semver@7.8.5` 实测。
- **未验证的推断**(明确标注,不作为结论):「模型只剩一次性 `bash` / `pwsh`」是按两条**已验证**事实推出的——① `@deepseek-ai/dsh-tool-terminal` 未被任何 shipped bundle 挂载(`packages/bundle/` 零挂载行);② 插件删除的 `src/tools.ts` 确实注册了 8 个 `terminal_*` 工具。**没有**在真机上对模型可见工具清单做过实测。
## 落地
- 分支:`feat/dsh-0.1.6-alpha.2`,三条 commit(HEAD `5912274`)之上还有一批**未提交**的收尾改动:`AGENTS.md` §3 重写、`README.md` / `README_EN.md`、`docs/external-plugin-guide.md`、`scripts/install.sh` / `install.ps1`(删 `-Repair` / `--repair`)、`scripts/e2e-common.sh` / `e2e-aggregate-mount.sh` / `package-registry.mjs`(node-pty 残迹)、`tests/theme.spec.ts`(卡片守护加强,见「收尾偏差」1)。
- 其余(PR、真机挂载冒烟、3080 GUI 验证、发版结论):**待回填**——本节不预填未经执行的结论。
## 不做
- 不为 0.1.5-rc.\* 写运行时/类型层兼容层(本版是**有意**的支持线断裂,与 v0.19.0 桥掉 0.1.2 线同一先例)。
- 不把 peer 下限留在 `^0.1.5-rc.1` 或写成「宽松范围」——它在数学上容不下任何 0.1.6 预发布版(§决策 1 的表),留着只会让消费方装上一个必然崩的宿主。
- **不补自家的终端替代实现**(不上第二套 PTY、不为模型侧持久终端另造工具);上游 `tool-terminal` 未被 bundle 挂载是上游的取舍,README 给指引即可。
- 不恢复被删的轮尾产物行:list 槽只能追加,重新注册只会与宿主产物卡重复。
- 不接入本版新增宿主面(`openTabs` / `tabsIn` / `registerCloseHandler` / `preferNewPane` / keyed `guide.entry` 槽 / 全局面板)。
- 不改 DSH 源码(仓库硬约束 §1);不改 `release.yml`(0.20.0 无 prerelease 后缀 → 自动发 `latest`)。
@@ -1,144 +0,0 @@
# DSH 0.1.7-alpha.1 适配记录
> 适配对象:`dsh-better-sidebar` v0.20.0 开发线(支持 DSH 0.1.6-alpha.2)→ **v0.21.0-alpha.1(仅支持 DSH 0.1.7-alpha.1+)**。
> 基线标签:`dsh-v0.1.6-alpha.2`(`ddefc45fbc`)→ `dsh-v0.1.7-alpha.1`(`c36a83ff6b`),相隔 **1299 个 commit / 4754 个文件变更**。
> 本文记录**为什么这么改**与**改完的证据**;规则性结论沉淀在仓库根 `AGENTS.md` §3。
>
> ⚠️ **后续**:`v0.21.0-alpha.1` **从未发布到 npm**——这条开发线随后又适配到 **0.1.7-rc.1**,peer 下限收到 `^0.1.7-rc.1`,整个 0.21 线的变更一并随 **v0.21.1** 出货。本文保留为 alpha.1 阶段的原始记录(结论仍然有效),最新状态见 [2026-09-23-dsh-0.1.7-rc.1-adaptation.md](2026-09-23-dsh-0.1.7-rc.1-adaptation.md)。
## 1. 怎么做的(方法与边界)
先用一次 8 路 workflow 侦察把 1299 个 commit 收敛到「与本插件相交的 surface」:sidebar-right 契约、ui-chat 槽、ui-primitives 导出面、boot/plugin-manager、宿主内置侧栏能力盘点、peer 依赖图、会话格式与 sidechat 依赖面、插件触点全量清单;随后一个独立的交叉验证子代理逐条复核,**证伪了 8 条**(例如「ui-primitives 只需补 2 个 devDep」被推翻为 4 个,「聊天正文里的 http 链接什么都不开」被推翻为会回落 `window.open`)。
三条纪律贯穿全程:
1. **结论必须带证据**:每条破坏都给出 `git show <tag>:<path>` / 实测命令与行号,不采信记忆。
2. **区分「硬报错」与「静默失效」**:后者的代价高得多——本版一共 6 处静默失效(见 §3)。
3. **不采信子代理报告**:所有关键结论由主代理独立复跑命令核对后才进入实现。
**没有做的事**:不改 DSH 源码;不写 0.1.6/0.1.7 双基线兼容层;不接入 `ctx.documentPreviews`;不恢复终端。
## 2. 硬破坏与适配(不修就崩)
### 2.1 `dsh-settings` 整体重写 —— 本版最大的一处
0.1.6 的 `SettingsProvider.register(ns, schema)` 给出 `{get, watch, update, replace}`;0.1.7 只剩 `SettingsForms` 的 `describe/update/replace/mutate/configure`,且**表单按插件 Loader 行的 `entry.options.id` 寻址**,schema 取自 `entry.fiber.runtime.Config`。
后果链条(全部实测):`register` 不存在 → 插件 `ctx.inject(['settings'])` 回调体内 `TypeError` → **被 cordis 吞掉** → `settingsFace` 恒 `undefined` → 侧边卡片设置页回落到 schema 默认值、`agentOpenTools` 永远打不开、`aionui-panel` 互斥失效。
适配:
- `src/config.ts` 把用户偏好**并入导出的 `Config`**(原先只是 4 个部署限额),每个偏好字段标为 volatile,并且 **schemastery 必须换成 DSH 自己的 `@deepseek-ai/schemastery`**。这一点是计划里标记为「未验证」的风险,真机上被证实为**必须**:`meta.volatile` 只是标记,DSH 的构建额外在解析期把 volatile 字段包成 cosmokit `Volatile` 引用,而 Loader 的 `_commitVolatile` 正是提交那些引用。用公开包时 `settings.update` 返回 200、`revision` 也递增,**但有效值永远不变**——设置写入被静默丢弃(`--dsh-e2e-marker` 为空、title-bar 方案不生效就是它的症状)。另有第二个坑:`.volatile()` 经 `extra()` **返回副本而非原地修改**,写成 `for (const f of ...) f.volatile()` 会把标记丢掉,宿主随后报 `Plugin entry "better-sidebar" has no volatile fields`。
- `src/index.ts` 新增 `ownEntryId(ctx)` **运行时自发现**行 id:按 `options.name === 'dsh-better-sidebar'` + `entry.fiber === ctx.fiber` 匹配,回退到第一个启用的同名行。硬编码 `better-sidebar` 会在聚合包(`web-ui-better-sidebar` 之类)下静默失配。
- `viewOf()` = `describe({redactSecrets:true}).find(d => d.ns === ns)`;写入 `update(ns, patch, expectedRevision)`(`SettingsConflictError` 仍导出、构造签名未变,409 分支保留)。
- `configure({ auto: false }, ctx.fiber)` 关掉 DSH 的自动表单,避免同一批字段渲染两遍。
- **一次性 legacy 导入**(见 §2.2)。
- **放弃 `settings/document-updated` 监听**:cordis 的事件只向上冒泡到该 ctx 的祖先,而 settings 服务是在它自己的 context 上 `emit`,插件的 fiber 是兄弟,监听必然不触发。改用「客户端每次重新读取表单时重新求值」覆盖同样的触发点。
### 2.2 用户设置会静默丢失 —— 已定位并修复
0.1.7 删除了 file-backed settings provider。迁移时它把 `$DSH_HOME/settings.yaml` **先改名成 `.imported`**,再逐段导入,导入键是「同名 section → 同名 entry id」。插件的 section 键是**包名** `dsh-better-sidebar`,而它的行 id 是 `better-sidebar`,`LEGACY_SECTION_ENTRIES` 里也没有这个例外——**导入必然 warn 失败,section 只留在被改名的文件里**。
这不是理论问题:本机 `~/.dsh/settings.yaml`(63 KB)里就有一份真实的 `dsh-better-sidebar` 段,含 `tabsEnabled`(关掉了三个第三方 tab)、`viewersEnabled`、`titleBarScheme`、`titleBarStripPx`、`editorExplorer` 等。
修复:插件自己在 `ctx.inject(['settings','profileContext'])` 里读 `join(profileContext.home, 'settings.yaml.imported')`(退化再读 `settings.yaml`),取出该段,**按当前 `Config` 声明的字段过滤**(段里还留着 `terminalFontSize` / `browserInterceptHttp` / `defaultWidthPercent` 等已删字段,原样转发会让整个 patch 校验失败),再 `update(entryId, section)`。**只在行的用户层为空时执行**,所以幂等,且永不覆盖升级后的新值。
### 2.3 `ui-primitives` 图标整族改名
`Icon<Name><14|16>` → `Icon<Name>Regular`(1px)/ `Icon<Name>Medium`(1.3px)。具名导出 81 → 184,**旧尺寸后缀名一个不剩**。插件 26 个具名导入消失、17 个文件受影响、107 处替换。
两处容易踩空的地方:
1. **不能全局替换**:`src/client/icons.tsx` 里有 13 个**插件自绘**的同风格 glyph(`IconImageOutline16` / `IconVscode16` …)。改名集合是从「`import ... from '@deepseek-ai/dsh-client-ui-primitives'` 的具名列表 ∩ 已不存在于该包」算出来的,机械替换 26 个符号,13 个自绘 glyph 原样保留。
2. **失败模式是 render 期**:插件 client bundle 是 CJS + external,产物是裸属性读,缺导出 = `undefined` → React 抛 `Element type is invalid`,不是 ESM link error;而 `pnpm typecheck` 会直接报 `no exported member`,所以**类型检查是这一层唯一的守护**(此前测试对图标名零断言,本次补了 `tests/primitives-exports.spec.ts`)。
**静默尺寸回归**:0.1.6 的 `IconXxx14` 默认 `size = 14`,0.1.7 的 `XxxRegular` 一律默认 16。5 处原先依赖默认值的调用点(`TabBar.tsx` / `Sidebar.tsx` / `FileTree.tsx` 的 `IconCloseFillRegular`、`SubagentView.tsx` 两处 `IconRefreshOutlineRegular`)已显式传 `size={14}`,否则会静默变大 2px。另有 `StateDot` 的 ongoing 态被整图重画(10px 像素矩阵 → 14px 圆环 spinner,默认尺寸 10 → 14),插件选择跟随宿主观感。
### 2.4 会话格式 v3 → v4
**`source.kind === 'plugin'` 被硬拒**(`format v4 message requires a producer-owned source kind`),而插件的 sidechat 边界注入正是那个形状。改用 **`plugin:dsh-better-sidebar`**——这不是随手取的字符串:DSH 自己的 v3→v4 迁移对未知插件名产出的正是 `` `plugin:${plugin}` ``,用同一个值意味着**新写的数据与迁移后的历史数据形状统一**,`assertV4MessageSources` 通过,历史 sidechat 会话也不会丢。类型层面另需一条 `declare module '@deepseek-ai/dsh-llm'` 的 `MessageSourceMap` 增强(0.1.7 把它改成合并可扩展的封闭联合,没有共享 catch-all)。
**tool 结果消息重写**:`role: 'user'` + 嵌套 `type: 'tool-result'` → `role: 'tool'` + **顶层** `content` / `isError` / `toolCallId`。插件 4 个解析器原先靠 `candidate.type === 'tool-result'` 命中,在新形状下会 continue 到底、恒返回空串——**工具输出、jobs 回放、转录、文件变动四处一起静默空白**。现在四处都同时接受新旧形状(旧分支必须保留:历史日志里就是旧形状)。
### 2.5 客户端会话快照换字段
`SessionListState.subagentsByParent` / `jobsBySession` → `projectionsBySession`。并且 **`setSubagentCatalogOpen` 这个按需观察 API 被整体删除**——0.1.7 在连接建立时一次性加载所有 Session 的 projection,所以那套 observe/unobserve 机制应当**删除**而不是改名;这是本版唯一一处「适配反而简化了代码」。
- 子代理目录改读 `projectionsBySession[sid]?.values.subagentCatalog`(`{id, createdAt, mode: 'one-shot'|'continuable'|'unknown', label?}`)。旧 catalog 条目带的 `activity` / `hasChildren` 不在新投影里:`hasChildren` 由「该子会话自己的投影是否有非空 catalog」推导,`activity` 由已有的 live 通道(`subagents.live`)提供。`mode: 'unknown'` 是新值,凡穷举 mode 处都要容纳。
- 后台任务列表**没有替代字段**,新增插件路由 `jobs.list`(读 `ctx.jobs.list(sessionId)`)。这比旧的推送镜像更权威:镜像是 last-wins 的帧折叠,注册表是状态本身。
- 同一版里 `ctx.jobs.list/get/read/kill` 的 caller 从 `Agent` 变成 `SessionId`,插件因此可以**彻底不碰 `ctx.agents`** 来调注册表(`src/jobs-routes.ts` 删掉了整个 `agents` 查找)。
### 2.6 依赖与测试 harness
- peer 下限 `^0.1.6-alpha.2` → `^0.1.7-alpha.1`(semver 预发布规则:前者数学上不含任何 `0.1.7-*`);`@deepseek-ai/cordis` peer `^4.0.2` → `^4.0.3`。
- devDependencies 补 **4 个**:`@deepseek-ai/dsh-client-store`、`@deepseek-ai/dsh-util-workspace-path`(`ui-primitives@0.1.7` 产物新裸 import 的两个包)、以及**这两个包自己**裸 import 的 `zustand@~4.4.7` 与 `immer@^10.1.1`(它们同样不声明 `dependencies`)。只补前两个,vitest 一加载就 `Cannot find package 'zustand'`。反过来**没有**预加 `dsh-workspace` / `dsh-agent-preset-registry`:实测 `dsh-subagent@0.1.7` 的 28 个 `lib/*.js` 从不 import 它们。
- `tests/browser-globals.ts` 的假 `window` 补 `addEventListener` / `removeEventListener` / `dispatchEvent` / `matchMedia`。0.1.7 的 `ui-primitives` 在**模块求值期**就 `window.addEventListener('scroll'|'resize'|'blur')`,缺它会让**任何 import `service.ts` 的 spec 一个用例都跑不起来**(`TypeError: window.addEventListener is not a function`)——用 HEAD 干净 worktree 复现过,确认不是本次删除造成的。
- `package.json.files` 补 `lib/client-locale.js`(chunk 名在 8 处镜像,`files` 一直漏这一个,locale chunk 从来没进过 npm tarball)。
## 3. 静默失效清单(本版的真实风险面)
| # | 症状 | 根因 | 守护 |
|---|---|---|---|
| 1 | 侧边卡片设置页只剩默认值、`agentOpenTools` 打不开 | `settings.register` 抛错被 cordis 吞 | `smoke.spec.ts` 的 settings 段 + `configure({auto:false})` |
| 2 | 用户既有设置全部丢失 | legacy 段键名与行 id 不一致,导入静默 warn 失败 | 新增两条 legacy 迁移用例(含「已有用户值时不动」) |
| 3 | 工具输出 / jobs 回放 / 转录 / 文件变动四处空白 | `type === 'tool-result'` 在新形状下不再命中 | 4 个解析器各有真实生产者 round-trip 用例 |
| 4 | 任务管理页空列表 | `subagentsByParent` / `jobsBySession` 被删且读处全带 `?.` | 新 spec 断言从投影推导拓扑 + jobs 基线 |
| 5 | 5 处图标静默变大 2px | 图标默认尺寸 14 → 16 | 显式 `size={14}` |
| 6 | 插件自绘 markdown 里 http 链接点了没反应 | 上一版交还浏览器时兜底仍指向已不存在的 `'browser'` 类型 | `link-intercept.spec.ts` 新增 12 例 |
| 7 | 文件树一直陈旧 | 插件接管 `files` kind,宿主自己的 watch 覆盖不到 | `fs-watch.spec.ts` 5 例 |
第 6 条是**上一轮自己引入的回归**,本次一并修掉:`openInSidebar` 只认领声明了 `urlTarget` 的链接,其余一律放行给宿主(DSH 0.1.7 起目的地由用户设置 `linkOpening` + 宿主决定),认领成功但目标不可用时 `window.open(..., 'noopener,noreferrer')` 兜底。
## 4. 取舍(用户拍板,逐条记录)
| 议题 | 选择 | 代价 |
|---|---|---|
| 支持线 | 只支持 `^0.1.7-alpha.1`,不写兼容层 | 0.1.6 及更早用户停在 v0.19.1 |
| 发版 | `0.21.0-alpha.1`,dist-tag `alpha` | npm `latest` 仍是 0.19.1,新用户默认装不到 |
| 设置架构 | 迁到 entry-config 模型 **+** 自己做 legacy 导入 | 约 60 行新增,换回「用户设置不丢」 |
| 只读预览 | 让出 image / pdf / 电子表格 / Office 给内置 | 插件设置页少 3 行 viewer 开关;换来宿主的 Office→PDF、表格 worker、缩放 |
| 文件树 | 继续接管 **+** 本次实现目录 watch | 新增宿主 WS 路由 + 客户端订阅(约 300 行) |
| 外链 | 收敛为「只认领 urlTarget」 | 删掉 3 个偏好键(含 20 份词典 key) |
| 视觉 | 尺寸钉回 14,`StateDot` 跟随宿主新观感 | 与宿主其他进行中状态一致,少一处硬编码 |
**保留在插件侧的三件**(内置做不了的):Markdown 自带渲染器、HTML 沙箱预览(`htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` 宿主没有)、以及 `code` catch-all 的**可编辑 + 保存**能力。未知二进制(`.zip` / `.wasm`)仍走 `code` 认领 → 宿主 fsRead 判 binary → head 重匹配无 sniffer → `EditorHost.tsx` 的 `binary` 兜底渲染下载面板,**功能不回归**(`builtins.spec.ts` 用 `planFsReadOutcome` 把这条链钉住)。
## 5. 落地偏差(与计划的差异,逐条说明)
1. **`MessageSourceMap` 模块增强是计划外的必需品**。计划只写了「改 kind 值」,实测仅改值会留下 `TS2322`:0.1.7 把消息 source 改成合并可扩展的封闭联合,没有共享 catch-all。按上游 `dsh-time-context` / `dsh-tmux-context` 的同款接缝在 `src/sidechat-routes.ts` 里加了 `declare module '@deepseek-ai/dsh-llm'`。
2. **图标改名范围比侦察报告小**:报告说 39 个值符号里 26 个消失,实际要做改名的是 26 个(`IconProps` 是类型,13 个是插件自绘 glyph)。
3. **`ui-primitives` 的裸 import 闭包比报告深一层**:报告给的是 2 个 `@deepseek-ai` 包,实际还要补 `zustand` / `immer`(第二层)。这是交叉验证阶段证伪的 8 条之一。
4. **`setSubagentCatalogOpen` 是「删除」而不是「替代」**:计划预期有替代 API,实际 0.1.7 改成全量加载投影,那套机制整体不需要了。
5. **`jobs.list` 是计划外的新路由**:计划里只写了「改数据源」,实际旧的 jobs 镜像在 0.1.7 没有任何替代,只能自己开路由。
6. **`exchangeLaunchCookie` 换成 Playwright request context**:计划里列为「已知 e2e 阻塞」,落地时确认宿主静态 fallback 对纯 Node 的 GET 回 **空体 405 且无 `Set-Cookie`**(0.1.5-rc.2 与 0.1.7-alpha.1 都复现),改用 `request.newContext()` + `maxRedirects: 0`。
7. **schemastery 必须换成 DSH 的构建(计划里标为「未验证」的风险,真机证实为必须)**:原计划认为「宿主只鸭子判定 `meta.volatile`,不必换依赖」。真机上 `settings.update` 返回 200、`revision` 递增,但 `describe().value` 仍是旧值——写入被静默丢弃。根因在 DSH 的 schemastery 构建:它在解析期把 volatile 字段包成 cosmokit `Volatile` 引用,Loader 的 `_commitVolatile` 提交的正是那些引用;公开的 `schemastery` 包只设标记、产普通值,于是「提交」无事可做。附带第二个坑:`.volatile()` 经 `extra()` 返回副本,写成 `for (const f of ...) f.volatile()` 会丢掉标记,宿主随即报 `Plugin entry "better-sidebar" has no volatile fields`。
8. **`tests/browser-globals.ts` 的修复不在原计划里**:这是「跑起来才发现」的阻塞,由执行子代理用 HEAD 干净 worktree 复现归因后修的。
9. **README/AGENTS 里「v0.20.0 已是 npm latest」的表述是错的**:实测 npm `latest` 是 `0.19.1`,0.20.0 从未发布。文档已按事实改写。
## 6. 验证证据
**门禁**(见本仓库 CI:`plugin-mount` 用真实 0.1.7-alpha.1 scratch profile 跑 Playwright 无头渲染):
- `pnpm peers check` 干净;`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 通过。
- `pnpm test` 全绿(含本次新增的 `fs-watch.spec.ts`、`primitives-exports.spec.ts`,以及 `smoke.spec.ts` 的两条 legacy 迁移用例)。
- `pnpm build` 只产出 `client-editor.js` / `client-mermaid.js` / `client-locale.js`。
- `pnpm test:mount` 在真实 `@deepseek-ai/dsh@0.1.7-alpha.1` 上通过。
**子代理的三处反证**(比正向通过更有说服力):
1. tool 结果解析:把新形状分支改回旧行为 → **16 failed / 88 passed**,4 条真实生产者 round-trip 全挂、jobs 回放 5 条挂;恢复后 104 全绿。
2. 让出的预览格式:`builtins.spec.ts` 用 26 个扩展名逐条断言 `editor` **不**认领,同时断言 `md` / `html` / 代码扩展名**仍然**认领。
3. 链接接管:新增用例断言「未命中 `urlTarget` 时 `defaultPrevented === false` 且不调用任何打开函数」,以及「命中但被禁用时走 `window.open` 兜底」。
## 7. 真机升级经验(把 3080 的 web 实例从 0.1.5-rc.2 升到 0.1.7-alpha.1)
这段是本仓库「下一次升 DSH 时先读这里」的部分——四条都不是插件代码的问题,但都会让升级看起来像插件坏了。
1. **profile 的解析层会留着上一代的痕迹**。`$DSH_HOME/profiles/node_modules/@deepseek-ai/` 下曾有 **240 个指向旧 npx 缓存**(`~/.npm/_npx/c40503fdf38a82ea`,即 0.1.5-rc.2)的符号链接——那是 0.1.5 的 fallback 机制,而 0.1.7 已把它换成进程内 runtime interception,**既不更新也不清理**这些链接。结果是 0.1.5 的包在世界里占着名字,`settings` 插件直接 `TypeError: this.load is not a function` 起不来。清掉该目录即可(已留 `profiles/node_modules.bak-<stamp>-legacy-links.txt` 记录每条链接的原目标)。
2. **profile 里会有一个陈旧的 `@deepseek-ai/dsh-settings@0.0.1-rc.1` 影子包**:一个早已被禁用的第三方插件声明了 `^0.0.1-rc.1` peer,pnpm 就把整张图解析到那个远古版本,而它会**盖住宿主的 settings 服务**。修法是在 `profiles/web/pnpm-workspace.yaml` 加 `overrides:` 把它钉到宿主版本(pnpm 11 已不再读 package.json 的 `pnpm.overrides`,写了会**静默忽略**并只打一行 WARN)。
3. **`dsh plugin --profile web install` 会短路**:即使 `node_modules` 已被删掉、tarball 内容已变,它仍报 "Already up to date" 而什么都不装(`--force` 也一样)。改依赖后请直接在 profile 目录跑 `pnpm install`。
4. **`disabled-plugins.json` 在 0.1.7 是死文件**(全树 `git grep disabled-plugins` 零命中)。禁用 bundle 现在只能改 `dsh.profile.bundles`;保留 `dependencies` 里的声明,重新启用就是一行的事。
顺带修掉一个**真实的升级阻塞**:`pm2` 的 `dsh-web` 曾经重启 **22321** 次——一个上一轮会话遗留的游离进程占着 3080,pm2 的实例每次启动都 `EADDRINUSE`(错误日志涨到 121 MB)。定向 kill 掉占端口那个进程、改完版本再 `pm2 restart` 即恢复。
**插件侧在真机上暴露的一个真 bug**(已修,见 `99892b2`):一次性 legacy 设置迁移从没跑过。它在 `ctx.inject(['settings'], …)` 回调里启动,而那个回调在 settings **服务**出现的瞬间就触发——此时 Loader 还在挂载各行,`describe()` 里还没有本插件那一行,于是查找返回 undefined、迁移以「no form」静默收场。上游自己的迁移会等 `loader.await()`,现在照做。**这也是为什么所有早退分支都必须可观测**:迁移现在返回具名 outcome 并由调用方记录,否则这类「什么都不发生」永远查不出来。
@@ -1,115 +0,0 @@
# DSH 0.1.7-rc.1 适配记录
> 适配对象:`dsh-better-sidebar` v0.21.0-alpha.1(支持 DSH 0.1.7-alpha.1)→ **v0.21.1 正式版(仅支持 DSH 0.1.7-rc.1+)**(中途曾在 `alpha` 通道切过一版 `0.21.0-rc.1`,见 §8)。
> 基线标签:`dsh-v0.1.7-alpha.1`(`c36a83ff6b`)→ `dsh-v0.1.7-rc.1`(`46a7f68b09`),相隔 **318 个 commit / 1361 个文件变更**(中间还跨过一个没适配的 `0.1.7-alpha.2`)。
> 本文记录**为什么这么改**与**改完的证据**;规则性结论沉淀在仓库根 `AGENTS.md` §3(新增 §3.4 第 12 条)。上一阶段见 [2026-09-22-dsh-0.1.7-alpha.1-adaptation.md](2026-09-22-dsh-0.1.7-alpha.1-adaptation.md)。
## 1. 怎么做的(方法与边界)
先 `git fetch` 把 `dsh-v0.1.7-alpha.2` / `dsh-v0.1.7-rc.1` 两个 tag 取进本地 DSH 克隆,然后按「与本插件相交的 surface」分五路并行侦察:启动预检与展示元数据、`ui-primitives` 导出面、会话/会话快照/流式契约、平台契约(file-address / settings / webserver / gateway / 客户端 bundle 装载)、宿主文档预览能力盘点。**五路的结论一致指向「本版是发行候选收敛版,不是新一轮重写」**:绝大多数「变更文件」是 `package.json` 的版本号与 `workspace:^` → `workspace:*|~` 改写,`src/` 逐字未变。
侦察子的报告一律当线索处理,本轮**亲自复核**了四件事:`DiffBlockLabels` 的字段增删(读两个 tag 的 `DiffBlock.tsx`)、宿主渲染器覆盖的扩展名集合(读 `documentpreview/src/client/*/index.ts` 与 `document/unviewable.ts`)、`MAX_DESCRIBED_ENTRIES` 的真实归属(三 tag 逐字比对 `GuideBody.tsx`)、以及 icon 具名导入的真实条数(脚本提取导入面,得 25 而不是文档里记的 26)。
## 2. 本版到底改了什么
**零 `src/` 变化**(只有版本号与依赖面改写):`ui-sidebar-right`、`ui-slots`、`ui-settings`、`dsh-settings` 与 loader 的 volatile 机制、`webserver` + `client/connection` 的鉴权、`packages/session` 全线(**会话格式仍是 v4**,没有 v4→v5)、`llm` 的 `MessageSourceMap`、`subagent`(descriptor 仍是 v3)、`ui-conversation` 的槽锚点与 `turnTail`、客户端 externals 白名单(9 个 specifier 逐字不变)、`util/workspace-path` 的 `fileAddressFor`/`parseFileAddress`、`plugin-artwork.tsx` / `package-meta.ts` / `scripts/verify-package-meta.ts`(后两者逐字相同,所以图标与 locale 的三条硬规则、以及「没有任何尺寸校验」都仍然成立)。
真实变化只有两类:
1. **`ui-primitives` 的代码卡**(唯一会编译失败的一处):新增 `CodeToolbar.tsx`(alpha.1 上不存在),其 `CodeToolbarLabels` 的 `codeLabel` / `wrapLabel` / `unwrapLabel` **三个必填**;`DiffBlockLabels` / `ReadBlockLabels` 改为继承它,且**删除了 `DiffBlockLabels.files`**。icon 模块只**新增**了 `IconUsersOutline{Artwork,Regular,Medium}`,无删除、无改名、既有 icon 默认尺寸也没动。
2. **启动期 peer 预检**(rc.1 新文件 `packages/boot/app-boot/src/plugin-compatibility.ts`):见 §4。
另外 rc.1 把 **vendor 补丁**一起抬了:`@deepseek-ai/cordis` 4.0.3 → 4.0.4、`schemastery` 3.18.3 → 3.18.4、`cordis-plugin-group` 1.0.3 → 1.0.4、`cordis-plugin-include` 1.0.8 → 1.0.9(loader 已是 1.0.5)。这是上游 workspace-release-ranges 政策的直接后果——包内 DSH 引用发布成**精确版本**,vendor 用 `~`。
## 3. 插件侧改了什么
### 3.1 版本与依赖面
`package.json` / `dsh.plugin.json` / `src/client/service.ts` 的 `SIDEBAR_SERVICE_VERSION` 同步到 `0.21.0-rc.1`;14 个 DSH peer 从 `^0.1.7-alpha.1` 收到 `^0.1.7-rc.1`;28 个 devDependency 钉 `0.1.7-rc.1`。
**只改这两处不够**:`pnpm peers check` 一次报出四类 unmet peer——`cordis-plugin-group` / `-include` 是 `dsh-app-boot` / `dsh-config-editor` 的**传递 peer**,pnpm 会继续沿用 lockfile 里的旧副本。按 AGENTS.md §3.4 第 8 条的既有做法,把 `@deepseek-ai/cordis@4.0.4`、`@deepseek-ai/cordis-plugin-group@1.0.4`、`@deepseek-ai/cordis-plugin-include@1.0.9` 三个**显式钉进 devDependencies**(并抬 `schemastery` 到 `^3.18.4`)后收敛。
### 3.2 代码卡标签(唯一的编译破坏)
`src/client/SideChatView.tsx` 的 diff / read 两个字典补上三个新键、删掉 `files`;`sideChatBlockFiles` 词条从 20 份词典中删除。**不放进 `shared`**:`TerminalBlockLabels` 不继承 `CodeToolbarLabels`,而 `terminal: { ...shared }` 是新鲜对象字面量,会触发多余属性检查。
**红证据**:切换 devDeps 到 rc.1 后 `pnpm typecheck` 报
`Type '{ files: …; copy: string; … }' is missing the following properties from type 'DiffBlockLabels': codeLabel, wrapLabel, unwrapLabel`(`src/client/SideChatView.tsx(337,40)`);改完转绿。
### 3.3 全面采用宿主的新代码卡
`src/client/markdown-labels.tsx` 的 `labels.code` 补 `toolbarLabels`,措辞照抄宿主(`codeBlock.title|wrap|unwrap`),四个 markdown 渲染点一并从旧的 `data-code-block-banner` info banner 切到新的 code card。`MarkdownCopyLabels` 由 2 字段扩到 5 字段,三个构造点(`TextEditor.tsx` / `DiffPane.tsx` / `SideChatView.tsx`)与两个测试 fixture 同步。
**这个改动带出一个真实回归,且当场被测试抓到**:新 card 对没有语法高亮的语言显示 `codeLabel`,而 mermaid 正是没有 shiki grammar 的语言——**info string 不再进 DOM**,于是 `src/client/mermaid.tsx` 里那条「读 banner 第一个元素的文本是不是 `mermaid`」的探测恒假,三个 `tests/mermaid-markdown.spec.tsx` 用例立刻红(`the mermaid fence must be swapped for a diagram: expected null not to be null`)。改为按**围栏正文**匹配:`splitMermaidBlocks(text)` 取出 mermaid 正文集合,与 `block.querySelector('code')` 的文本两侧 `trimEnd()` 后比对;`language-mermaid` 类名作为第一探针保留。
### 3.4 收回 9 个过度让出的扩展名
上一版把 27 个扩展名整体让给宿主,但其中 9 个宿主**根本没有渲染器**:`avif` / `ods` 落在 `document/unviewable.ts` 的「已知二进制、无渲染器」表里直接走空状态,`xlsb` / `xlt` / `xltx` / `xltm` / `ots` / `dot` / `dotx` 落回 text 兜底后按二进制判定失败、同样是空状态。**这些文件在让出之前是走插件兜底显示下载面板的**,所以这是上一版自己引入的用户可见回归(rc.1 没有修复它)。现从 `HOST_OWNED_EXTS` 移除这 9 个,剩 18 个;`fods` 继续让出(宿主用纯文本显示这段扁平 XML,比下载面板有用)。
`canOpen` 此前**零测试覆盖**,这正是回归没被发现的原因;补了双向守护(`tests/native-surface.spec.ts`)。**红证据**:把旧的 27 项清单临时还原后,新用例报 `AssertionError: macro.xlsb: expected false to be true`。
### 3.5 peer 范围守护
`tests/market-manifest.spec.ts` 增加一条:每个 `@deepseek-ai/dsh*` peer 必须是 `^0.1.7-<预发布>` 形式的浮动范围。理由见 §4——**失败是静默的**,而 `pnpm peers check` 看不到它(它校验解析出来的 dev 树,不校验宿主会读的范围形状)。
### 3.6 钉版与文档
CI 的 `npm install -g` 与 `scripts/e2e-common.sh` 的 `DSH_NPX_SPEC` / `DSH_EXPECT_VERSION` 都钉到 `0.1.7-rc.1`,并在注释里写明**必须写精确版本号**:rc.1 上线时 npm `alpha` 指的是 **0.1.7-alpha.2**,rc.1 走的是 `next`。README / README_EN 的 6 处 shields badge、支持线段落、让出扩展名清单与「基线」段同步;指南头部版本行同步;AGENTS.md 见 §6。
## 4. rc.1 新增的启动期 peer 预检(本版最值得记住的一处)
`plugin-compatibility.ts` 遍历 profile 行的 `peerDependencies`,对名字为 `@deepseek-ai/dsh` 或以 `@deepseek-ai/dsh-` 开头的条目跑 `semver.satisfies(宿主版本, range, { includePrerelease: true })`;不满足就把整行 `row.disabled = true`,只在 stderr 留一行。四个要点:
- **只读 peer**,`engines.dsh` 与 `dsh.manifestVersion` 仍然零读者(`package-manifest` 的 README 在 rc.1 逐字未变,它自己的措辞已经落后于代码);
- `peerDependenciesMeta.optional` **没有任何豁免**——我们那条 optional 的 `@deepseek-ai/dsh-client-ui-sidebar-right` 照样参与判定;
- `includePrerelease` 被强制打开,判定比 npm **宽松**(`^0.1.6-alpha.2` 也能满足 `0.1.7-rc.1`),但**精确版本号或没有预发布子句的范围(如 `^0.1.7`)一定不过**;
- 豁免只能写 profile 自己的 `compatibility.json`,没有全局开关也没有环境变量。
**因此本版的 peer 下限是刻意收紧的**:`0.21.0-alpha.1` 从未发布到 npm(真实用户为零),而修掉 `DiffBlockLabels` 之后同一份代码在 alpha.1 上会崩(alpha.1 的 `DiffBlock` 仍读已被上游删除的 `labels.files`)。声明与真机验证过的宿主一致,比声明一条跑不通的支持线更安全;rc.1 起「声明过宽」的代价还从「可能报错」变成了「静默不加载」。
## 5. 真机升级(把 3080 的 web 实例从 0.1.7-alpha.1 升到 0.1.7-rc.1)
改动四处,全部带 `.bak-20260923-141956-pre-rc1` 备份:启动器 `~/.dsh/dsh-web.sh` 的 npx 钉版、profile 的 `dsh-better-sidebar` tarball 指向、`@deepseek-ai/dsh-experimental-agent-team-profile` 0.1.7-alpha.1 → 0.1.7-rc.1、profile 的 `pnpm-workspace.yaml` override(`@deepseek-ai/dsh-settings` → 0.1.7-rc.1)。随后在 profile 目录跑**纯 `pnpm install`**(不能走 `dsh plugin install`,它对内容变化会报「Already up to date」短路),再重启 pm2 app `dsh-web`。
**`@deepseek-ai/dsh-experimental-agent-team-web-profile` 保持 0.1.6-alpha.2 不动**——计划阶段曾打算移除它,核对发布元数据后推翻:它唯一的 peer 是 `@deepseek-ai/cordis ^4.0.2`,被预检的**名称过滤器直接跳过**,不会被 disable;其 `dependencies` 的 `^0.1.6-alpha.2` 今天就已经在解析一份独立的 0.1.6-alpha.2 副本,与 alpha.1 时的状态同构。实测 boot 日志确认它没有被跳过。
**boot 日志的实测结论**:只有一行 stderr,且不是我们的插件——
```
dsh: skipping profile bundle "@huanlin/dsh-plugin-better-sidebar-plugin-office": Error: Plugin
@huanlin/dsh-plugin-better-sidebar-plugin-office@0.1.2 is incompatible with dsh 0.1.7-rc.1:
peerDependencies {"@deepseek-ai/dsh-client-runtime":"^0.0.1-rc.1"}. …
```
`@deepseek-ai/dsh-client-runtime` 是 0.1.7 早就删掉的包,所以这个三方 Office 预览插件在 alpha.1 上本来就是「加载了但跑不对」(上一轮真机排查时那个拖进旧 `dsh-settings@0.0.1-rc.1` 的影子副本也来自它)。rc.1 现在把它**干净地跳过**而不是带着坏 peer 加载,属于状态改善;**它没有 `allow-version` 豁免之外的自愈路径**。npm 上已有 `0.2.0`,其 peer 面(`react` / `dsh-better-sidebar ^0.17.0` / `@deepseek-ai/cordis ^4.0.1`)不含死包,升级即可恢复——**这一条留待用户拍板,本轮未动**。
重启计数 22334 → 22335(只 +1,无崩溃循环)。
## 6. 验证证据
**仓库门禁**(全部退出码 0):`pnpm peers check`(No peer dependency issues found)、`pnpm typecheck`、`pnpm lint`、`pnpm build`、`pnpm check:consumer-types`、`pnpm test` = **1132 passed | 9 skipped / 113 文件**。
`tests/fs-watch.spec.ts` 的 `treats a repeat add as already-watched` 在并行跑全量时超时过一次;单跑与该 spec 连跑三次都是 ~1.2s 通过(预算 15s),判为 FSEvents 在满负载下的既有抖动,随后重跑全量即绿。
**挂载冒烟**:`DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount` → 真实 rc.1 宿主上 **7/7 通过**(含「挂载 + 内建 tab 全扫」「无错误条 / pageerror / console 错误」「经原生指南逐个打开插件 tab」「经文件树打开 seed 文件强制加载 editor chunk」「mermaid / README 预览」「sidechat 宿主路由」以及两条 perf 度量)。
⚠️ **这一条差点变成假证据**:`scripts/e2e-common.sh` 的 `e2e_resolve_tarball` 是从仓库根 glob 里**选 mtime 最新的 tarball**、而不是自己打包;第一次跑时根目录还留着上一版的 `dsh-better-sidebar-0.21.0-alpha.1.tgz`,于是那一轮其实是**拿旧产物做的冒烟**(输出里 `tarball:` 一行会写出版本号,现在知道要看它)。删掉旧包、`pnpm build && pnpm pack` 出 `0.21.0-rc.1` 的包后重跑,才是上面这份证据。
**CI**:`ci` / `ci-windows` / `plugin-mount` 三个 job 全绿(`35874792197`)。这是本分支**第一次** CI 全绿——上一轮那两份提交(09-22 与 09-23)在 `Test` 步红,而本地 `pnpm test` 一直是绿的。根因是上一版新增的 `tests/primitives-exports.spec.ts` 用 `execFileSync('rg', …)` 取文件清单:**ripgrep 在开发机上装了、CI runner 上没有**,于是整个 spec 在**收集阶段**就 `spawnSync rg ENOENT`。已换成 `readdirSync` 走一遍 `src/` + `tests/`(几百个小文件,无外部二进制依赖)。
**真机(3080)**:宿主跑 DSH 0.1.7-rc.1、插件 0.21.0-rc.1;一次性 token 交换 303 + 签名 cookie 正常;`[data-dsh-better-sidebar]` 挂载;**零 pageerror**;浏览器实际加载的 bundle 里含新的 `codeBlockUnwrap` 且**不含**已删除的 `sideChatBlockFiles`(证明是真机在跑新产物,不是旧缓存)。
**用户设置的 volatile 提交在 rc.1 上仍然成立**(这是上一轮最难的一处,而本轮抬了 schemastery / cordis):贴边设置表单读到 15 个偏好键、9 键 `tabsEnabled`、用户自己的 `pluginSettings.editor.openWith.pinned = ["vscode"]` 全部保留;把 `autoOpenJobs` 由 `false` 翻成 `true` → `settings.update` 200、revision 0 → 1、重新 `settings.get` 读到新值,再写回 `false` 复原。**一个真机观察(未改)**:表单里仍有 `viewersEnabled.{pdf,binary-download}` 这两个已被删除的 viewer 的键,属于历史遗留的配置面。
## 7. 落地偏差与未做
- **回收扩展名的条数**:侦察报告一处写「9 个」、列举却是 10 项;亲自比对宿主渲染器表后确认是 **9 个**(`fods` 会被宿主当纯文本显示,不算死路),最终按 9 个回收、`fods` 保留让出。
- **`@deepseek-ai/dsh-experimental-agent-team-web-profile` 未移除**(计划里曾打算移除,核对发布元数据后推翻,理由见 §5)。
- **未采用** rc.1 新增的 markdown `fileImages` 委托 / `ImageLightbox` / 本地图片悬浮预览:插件自绘的图片 URL 一律带 `?sessionId=`,而 `parseFileLink` 拒绝含 `?` 的目标,宿主这条新分支拦不到我们的 URL;要用就是新做 UI,不属于适配。
- **未把宿主 primitives 抄进插件**:rc.1 新增的官方插件实践文档(`packages/preset/agent-preset/skills/cordis-plugin-development/references/practices.md`)明确建议插件不要 import 宿主 client 包、而是「抄一份并只依赖设计令牌」。我们**有意保留 import**(25 个 icon + 13 个组件)并在 AGENTS.md §3 记下这条分歧:该建议的理由之一是「纯 JS 插件没有类型检查、组件抛错会静默清空槽位」,而本仓库是 TS + `pnpm typecheck` + `check:consumer-types`,并且 `tests/primitives-exports.spec.ts` 已经把「符号消失」从静默失败变成红测试。
- **未按同一份实践文档把 `jobs` 路由重构成 `ctx.sessionProjections` 单元**:那是行为等价重构,超出本轮范围。
- **`src/client/conversation-draft.ts` 的锚点缺陷未动**:它只认 `[data-slot="conversation"]`(`center-column.ts` 已经是 `main.conversation` 优先的双臂选择器)。经核对这是 **0.1.7-alpha.1 起就存在**的既有缺陷、不是 rc.1 引入,且与本轮变更不同源;记在这里待单独处理。
- **未重跑真机 Plugins 页的图标/文案断言**:那条路径(`readPluginMeta` + `ui-plugin-manager` 渲染)在本轮逐字未变,且上一轮已真机验证过;本轮的真机证据集中在「升级后插件仍挂载、无错误、跑的是新产物、设置 volatile 提交仍成立」。
## 8. 正式版 v0.21.1
rc.1 适配完成后先切过一版 `0.21.0-rc.1`(含 `-` → 只进 npm `alpha` 通道,GitHub Release 标为 Pre-release)。但 **DSH 0.1.7-rc.1 本身就是上游的正式发布批次**,适配它的插件版本不该停留在预发布通道上,因此重切 **`v0.21.1`**(不含 `-` → npm dist-tag `latest`):
- 版本号只改字面量:`package.json` / `dsh.plugin.json` / `SIDEBAR_SERVICE_VERSION`;**代码零改动**(`tests/service.spec.ts` 仍然守着 `SIDEBAR_SERVICE_VERSION === pkg.version`)。
- peer 下限仍是 `^0.1.7-rc.1`(不变),支持线仍是 **DSH 0.1.7-rc.1 及之后的 0.1.7 线**。
- npm `latest` **从 `0.19.1` 前移到 `0.21.1`**;`0.21.0-rc.1` 继续留在 `alpha` 通道上作为历史版本,未撤回。
- 因此 README 与 Release 的「按 DSH 版本选插件版本」对照表里,**旧 DSH 一律改成精确钉版**(`dsh plugin --profile web add dsh-better-sidebar@0.19.1` 之类)——`@latest` 从此只服务 0.1.7-rc.1+ 这条线。
@@ -1,121 +0,0 @@
# Agent Teams 适配 DSH 0.1.7:数据源改 projection、写路由对接真实服务、任务窗复用 FloatingWindow
日期:2026-09-27 分支:`feat/tasks-graph-polish`(承接 PR #680 的打磨线)
## 背景(用户原话)
> 目前是不是没有适配智能体团队功能?包括其任务等功能。注意之前我们另一个分支是有适配的,注意复用我们之前的那个弹窗组件
> 保持 KISS 原则,如有任意不清楚的地方,都向我详细确认
用户确认的三个决策:
| # | 问题 | 结论 |
|---|---|---|
| 1 | 团队数据源 | 改读宿主 **Session projection `agentTeam`**,读路径只剩 1 条路由(写) |
| 2 | 任务弹窗 | 复用上一轮的 **`FloatingWindow`**(可拖拽 + 可拉伸,只在关闭/Esc 时消失) |
| 3 | 验证深度 | **真机跑到有团队为止**:临时 DSH 环境里造真团队会话,看板/任务 CRUD 全验 |
## 诊断:为什么「没有适配」
不是没做,是**按 0.1.6 的服务 API 接的,而 0.1.7 把那套 API 删了**。真机(3080)curl 打我们自己的路由:
```
POST /sidebar/api/teams.view {"rootSessionId":"212f7157-…"} → 200 {"available":true,"team":null} 无团队,正常降级
POST /sidebar/api/teams.view {"rootSessionId":"session-b98c…"} → 400 team-error "membership.svc.remoteView is not a function"
```
`src/team-routes.ts` 当初镜像的是 0.1.6 的 **Remote 词汇**(`npm pack @deepseek-ai/dsh-experimental-agent-team@0.1.6-alpha.2` 解包核对过:`remoteView` / `remoteCreateTask` / `remoteUpdateTask` 三个方法都在,README 明说它们服务于 `./remote` 导出):
| | 0.1.6-alpha.2 | 0.1.7-rc.1(实际安装的包) |
|---|---|---|
| 读 | `remoteView(agent): TeamView` | `listMembers(agent)` / `listTasks(caller)`;官方 Web UI 改读 **Session projection `agentTeam`** |
| 写 | `remoteCreateTask` / `remoteUpdateTask` → 返回 `TeamTaskMutationResult` 联合 | `createTask` / `updateTask` → 返回已提交的 `TeamTaskView`,**拒绝改为抛 `TeamError`** |
| 冲突 | `{ok:false, error:{code:'team-task-conflict'}}` | 抛 `TeamError`,`code = 'TEAM_TASK_STALE_REVISION'` |
官方 README(0.1.7)原文:
> The Web UI reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools.
**后果**:`TeamBoard` 整条团队条从不渲染(`useTeamView` 落进错误分支,页面静默无提示),团队节点富化、任务行、任务弹窗全部连带失效;客户端判的冲突码也是 0.1.6 的 `team-task-conflict`,所以「别人改过、已刷新」永远不会出现。
## 架构(改后)
```
host(src/)
team-routes.ts 只留两条写路由:taskCreate / taskUpdate
├ ctx.get('agentTeams') 缺席 → 503 team-error
├ ctx.agents.get(root) 未命中 → 404 team-error(团队按定义是 live-led)
├ tryMembership 未命中 → 404 team-error
├ TEAM_TASK_STALE_REVISION → 409 team-conflict
└ 其余 TeamError → 400 team-error
wire.ts SidebarErrorCode += 'team-conflict'
context-types.ts agentTeams 只镜像写半边;新增 SidebarTeamProjection /
SidebarTeamMemberProjection 镜像;projection snapshot 加 agentTeam
client(src/client/)
team-projection.ts 新:读 projectionsBySession[root].values.agentTeam + 派生成员运行状态
SubagentView.tsx 删 useTeamView 及其 5s 轮询;team/teamMembers 改为 useMemo 派生
tasks-model.ts 节点富化改吃 roster 行(role/name/phase/status/diagnostics)
TeamBoard.tsx 成员点、任务 Tag 走 tasks-shared 的统一状态规则;根加 data-team-board
TaskWindow.tsx 外壳换 FloatingWindow;动作行下沉到 footer;状态机跟随宿主
tasks-shared.tsx taskBlocked / taskStatusLabel / taskTone / taskDotState(任务状态唯一规则)
api.ts 删 teamsView;写路由经 writeTask 把失败信封还原成 {ok:false, code, message}
```
### 为什么删掉 `teams.view` 而不是修它
官方 0.1.7 把「读团队」明确交给 projection,而插件**已经在读同一个快照**(`subagentCatalog` 就是同一个 `projectionsBySession`)。再留一条轮询路由等于把同一份数据读两遍、并继续维护两套降级语义(`{available:false}` / `{team:null}`)。改为一次查表后:
- `available:false` 与 `team:null` 合并成**一个**状态(projection 缺席 = 这里没有团队),页面行为不变(团队条不渲染、无横幅、无 console error);
- 团队数据**跟着 snapshot 推送自动更新**,不需要手动刷新(刷新按钮上的 `team.refresh()` 一并删除);
- `subagents.live` 本来就在给每个 child 报 `running`,成员运行状态直接复用,不新增任何请求。
## 真机验证(可复现)
### 造一个真团队会话
host 侧的 Agent Teams 实验层是 profile 层的(`dsh-experimental-agent-team-profile`),headless 通道默认不挂,用 `--patch` 注入同一份 patch 即可,不动 profile:
```bash
DSH_HOME=/Users/menghuan/.dsh-web npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 \
dsh headless --patch /tmp/dsh-selfcheck/agent-team-layer.yml --json "<spawn 一个 teammate + 建一条共享任务>"
```
`/tmp/dsh-selfcheck/agent-team-layer.yml` 是 `@deepseek-ai/dsh-experimental-agent-team-profile/cordis.patch.yml` 的逐字复制(三条 insert + 四条 disable)。
### 证据链(本轮真实产出)
1. **写路由直连真实服务**(3080,新建构建):
- 未 live 的 root → `404 team-error "the tree root is not live in this process"`(**不再是 `remoteView is not a function`**)
- `teams.view` → `404 not-found`(路由已删)
2. **看板从 projection 渲染**(Playwright,真实浏览器):`[data-team-board]` 出现,文本 `团队任务板团队 2 成员 · 1 任务全部leadprobe-writerlive board probe待办新建任务`。
3. **任务窗 = FloatingWindow**:`aria-label=任务详情`、`[data-window-footer]` = `编辑 完成 删除`、**外部点击不关**、Escape 关。
4. **claim → complete 全链路**(浏览器点击 → 宿主路由 → 真实 `TeamService`):
- 队列中任务点「认领」→ 窗口与看板同时变 `负责人 lead` / `进行中`(Tag 不再是 阻塞)
- 再点「完成」→ `已完成`,动作行变 `编辑 重开 删除`
- 截图:`/tmp/dsh-selfcheck/22-live-turn.png`、`23-team-board.png`、`24-task-window.png`、`25-after-write.png`
5. **单元/集成**:`pnpm typecheck` / `lint` 通过;`pnpm test` **1276 passed / 9 skipped**(131 个 spec,新增 9 例:team-routes 重写 12 例、team-projection 9 例、任务窗状态机 2 例、状态规则 4 例)。
### 真机抓到的两个单测抓不到的问题(已修)
| # | 现象 | 根因 | 修法 |
|---|---|---|---|
| 1 | 队列中任务点「完成」→ 窗口报 `only an in-progress task can complete` | 宿主的任务是状态机(`pending → claim → in_progress → complete → completed → reopen`,`release`/`reassign` 回退为 pending),而窗口只提供「完成」 | 动作行跟随状态:pending 出「认领」(`ready:false` 时禁用,理由是标签已写`阻塞`)、in_progress 出「完成」、completed 出「重开」;新增 `teamTaskClaim` 词条 ×20 |
| 2 | 认领之后 Tag 变「阻塞」 | `ready` 的语义只是「pending 且所有 blocker 已完成 → 可以认领」,**对所有非 pending 任务都是 false**,用 `!ready` 判「阻塞」把正在做的任务标成了阻塞 | 抽出唯一规则 `taskBlocked(status, ready) = status === 'pending' && !ready`,`taskStatusLabel` / `taskTone` / `taskDotState` 共用;四处展示点(看板行、任务窗、节点任务行、图卡片)全部改走它 |
第 2 条是「修源头」而不是补丁:三处各自写了一遍 `ready ? … : 'teamTaskBlocked'`,所以把规则收敛成一个函数,未来只有一个地方会错。
## 实施偏差记录
1. **成员 `model` 与 `diagnostics` 从此缺失**:`agentTeam` projection 只带 `{id,name,role,phase,error?}`(已核对 `lib/types/types.d.ts`),0.1.6 的 `listMembers` 才带 `status/model/description/provider/context`。不做额外 host 路由去补(官方面板同样不显示模型):节点详情的「模型」行删除;卡片 meta 行在成员没有 catalog mode 时回退到 `成员` / `部署中` / `出错`。
2. **运行状态改为派生**:projection 的 `phase` 是**持久生命周期**(`provisioning | active | failed`),turn 活动由 `subagents.live` 的 `running` 覆盖(官方 README 的 “overlays activity from Session status” 就是这个意思),Lead 行回退到 session list 的 `running`。规则写在 `teamMembersOf` 一处。
3. **不做 0.1.6 回退**:不保留 `remote*` 探测。插件这一版本来就要求 0.1.7+,且 0.1.6 的 Web UI 自己在这台机器上已经是重复插入(`ui-agent-team` 被 `-profile` 与 `-web-profile` 两个 bundle 各插一次,见 `dsh --profile web --dump-config` 第 1779/1782 行)——这是既有 profile 事实,本轮不动。
4. **`api.teamsTaskUpdate` 返回结果联合而不是抛异常**:`readEnvelope` 对失败信封是**抛** `SidebarApiError`,所以任务窗原本永远走不到 `{ok:false}` 分支(这是本轮唯一一个先写错、被真机证据纠正的地方)。现在 `writeTask()` 只在这一处把失败信封还原成 `{ok:false, code, message}`,让窗口能对 `team-conflict` 说自己的话。
5. **动作行从窗口体内挪到 shell 的 footer**:FloatingWindow 的 body 是滚动容器,长描述会把动作推出视野;footer 是固定行。`jobPopActions` 因此去掉自己的上边框(footer 已经画了分隔)。
6. **看板开关未改**:团队条的折叠箭头仍是旋转的 chevron(上一轮「折叠/展开分向」的结论只应用于图卡片簇),本轮不动以免扩大改动面。
## 已知限制
- **写路由需要 live 的 lead**:冷会话 / 另一个进程持有的会话 → 404。团队按定义是 live-led 的(服务要求把 live Agent 当权限凭证),这是结构性事实而非缺陷;页面在这种情况下仍能显示看板(projection 是持久的),只是动作会被拒。
- projection 对「本客户端从未加载过的会话」是否可用:真机上验证过**新会话 + 重启后**两条路径都可用(浏览器是从 `projectionsBySession` 读的,与 `subagentCatalog` 同源);但如果将来宿主改成按需加载 projection,这块会退化成空看板而不是报错。
- 每个成员一行、每个任务一行都在 DOM 里(上限 8 成员 / 256 任务),未做虚拟化。
-496
View File
@@ -1,496 +0,0 @@
# 文件页 / 文件变动页 UIUX 重构(2026-09-27)
用户需求(原文要点):
- 文件页:Ctrl 多选、Shift 多选;复用 DSH 自带的"使用应用打开";像 VS Code 一样给 Git 变更上色;优化性能;优化拖拽上传体验/样式;新增新建文件夹。
- 文件变动页:彻底优化全部 UIUX 交互与功能,更简洁易用;保留 Git 视角与 Agent 视角。
- 抽取出可复用的全局组件;设计风格与样式参照新的任务管理页;保持 KISS。
本文件是本次重构的**冻结契约**:所有并行实现必须遵守下面的接口与命名,不得自行改动。
---
## 1. 设计基线:任务管理页的原生尺度
`src/client/tasks-graph.module.css:1-15` 已声明插件原生尺度,本次重构把文件页与文件变动页对齐到它:
| 维度 | 值 |
|---|---|
| 正文 | `--dsw-font-xxs-12`(12px);强调 `--dsw-font-xxs-strong-12` |
| 元信息 | `--dsw-font-xxxs-11`;微标签 10px |
| 行高 | 列表行 **28px**(原 34px);密集行 24px |
| 控件 | 28px(宿主 `Button sm` 同高) |
| 边 | 结构边 `--dsw-alias-border-l2` 1px;分隔线 `--dsw-alias-border-l1` |
| 悬停 | `--dsw-alias-interactive-bg-hover` |
| 选中 | `--dsw-alias-interactive-bg-active` + 2px 内嵌强调条(`::before`,上下内缩 4px) |
| 圆角阶梯 | 2px 徽标/内联 chip,3px 控件簇,4px 行,8px 卡片 |
| 唯一强调色 | `--dsw-alias-state-business-primary`(当前项、关系、live) |
| 状态色 | 成功 `--dsw-alias-state-success-primary`、错误 `--dsw-alias-state-error-primary`、警告 `--dsw-alias-state-warn-primary` |
| 页面留白 | 12px 横向 gutter;竖直节拍 4 / 6 / 8px |
| 头带 | 36px 高,`padding: 0 8px 0 12px`,底 `1px solid --dsw-alias-border-l1` |
| 阴影 | 流内面板**无阴影**;仅浮层(popover/window)用 `--dsw-shadow-lv2/3` |
| 皮肤契约 | `tests/theme.spec.ts` 逐条扫描 `src/client/**/*.module.css`:任何 `color:` 必须至少含一个 `var(--dsw-*|--ds-*)`,禁止字面色 |
不可触碰的既有断言(改了会红):
- `tests/theme.spec.ts` 硬编码 `src/client/tasks-graph.module.css` 里的 `.cardBar[data-running='true']::after`、`@keyframes dsh-tasks-bar-sweep`、`.barActivity`;以及 `src/client/sidebar.module.css` 里的 `.paneCard`。
- `tests/file-tree-reference-tail.spec.ts` 要求 `.explorerRef, .explorerCopied { margin-left: auto }` 留在 `sidebar.module.css`。
- `tests/panel-host-css.spec.ts` 要求 `sidebar.module.css` 的 `:global([data-dsh-panel-host])` 规则顺序不变。
- `tests/locales.spec.ts:109,118` 要求 **20 份词典 key 集合完全相等**:新增 key 必须同时进入全部词典。
---
## 2. 全局组件(`src/client/ui/`,由 Lead 冻结)
新目录 `src/client/ui/`:
```
src/client/ui/index.ts 桶导出(唯一对外入口)
src/client/ui/kit.tsx 原子组件
src/client/ui/kit.module.css 样式(token-only)
src/client/ui/git-status.ts 共享 git 状态存储 + hook
```
### 2.1 `kit.tsx` 导出面
```ts
/** 28/24px 方形图标按钮(toolbar / 行尾操作统一用它)。 */
export function IconButton(props: {
icon: ReactNode
label: string // aria-label + title 默认值
title?: string
onClick(event: MouseEvent<HTMLButtonElement>): void
size?: 'md' | 'sm' // md=28px(默认),sm=24px
active?: boolean
disabled?: boolean
danger?: boolean
className?: string
}): ReactNode
/** 过滤/切换 chip(带可选计数)。 */
export function Chip(props: {
children: ReactNode
active?: boolean
count?: number
title?: string
disabled?: boolean
onClick(): void
className?: string
}): ReactNode
/** 28px 节标题带(左侧标签 + 可选计数 + 右侧动作)。 */
export function SectionHeader(props: {
label: ReactNode
count?: number
action?: ReactNode
className?: string
children?: ReactNode
}): ReactNode
/** 空/加载/错误/警告/提示 单行态(替代 14 个重复类)。 */
export function Notice(props: {
kind: 'empty' | 'loading' | 'error' | 'warn' | 'hint'
tone?: 'page' | 'well' | 'inline' // page=16px 居中,well=4px 12px 8px(默认),inline=10px 微文案
role?: string
className?: string
children: ReactNode
}): ReactNode
/** 危险操作确认弹窗(宿主 Modal + 安静描述段)。 */
export function ConfirmDialog(props: {
open: boolean
title: string
description: string
confirmLabel: string
cancelLabel: string
danger?: boolean
busy?: boolean
onConfirm(): void
onClose(): void
}): ReactNode
/** 状态字母/词徽标(Git 变更、op 类型、diff 类型共用一套 tone)。 */
export type StatusTone =
| 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
| 'conflict' | 'read' | 'write' | 'edit' | 'neutral'
export function StatusBadge(props: {
tone: StatusTone
title?: string
children: ReactNode
className?: string
}): ReactNode
```
实现约束:
- 颜色/尺寸全部来自 `kit.module.css` 的 token;`IconButton` 的 `size` 只切 class,不写内联样式。
- `ConfirmDialog` 用宿主 `Modal`(`@deepseek-ai/dsh-client-ui-primitives`),footer = 取消(`Button variant="outline"`)+ 主按钮(`variant="primary"`,`disabled={busy}`),描述段用 `.confirmDesc`。
- 现有页面的等价类(`.explorerConfirmDesc` / `.gitConfirmDesc` / `.gitEmpty` / `.gitPlaceholder` / `.gitError` / `.filterChip` / `.gitBadge` / `.opKind` …)在各自页面重构时迁移到 kit;迁移期两者可并存,但**不得修改被测试钉住的类**(见 §1)。
### 2.2 `git-status.ts` 导出面
共享的 git 状态存储:一个 `sessionId + cwd + worktree` 键一份快照、一个轮询器,供文件树(着色)与文件变动页(列表)同时消费,避免重复 `git status`。
```ts
export type GitTone =
| 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed' | 'copied' | 'conflict'
/** 一行 porcelain XY 的语义(纯函数,可单测)。 */
export interface GitFileStatus {
letter: string // 展示字母:X 侧优先,其次 Y,'??' → 'U'
tone: GitTone
staged: boolean
unstaged: boolean
}
export function statusOfXY(xy: string): GitFileStatus | undefined
export interface GitStatusView {
/** null = 尚未拿到首个快照。 */
snapshot: GitStatusResult | null
loading: boolean
error: boolean
/** 强制重新拉取(在飞时排队一次,不丢请求)。 */
refresh(): void
/** 绝对路径 → 状态;干净/未知 → undefined。 */
statusOf(absolutePath: string): GitFileStatus | undefined
/** 该目录(含其下任意层级)是否有变更。 */
dirHasChanges(absoluteDir: string): boolean
}
export function useGitStatus(
scope: { sessionId: string; cwd?: string },
options?: { worktree?: string; visible?: boolean; pollMs?: number },
): GitStatusView
```
实现要点:模块级 `Map<key, Slot>`;`useSyncExternalStore` 订阅;仅当存在 `visible` 订阅者时轮询(默认 2500ms,`mode:'self-scheduling'` 式单飞);`statusOf` 首次调用时按快照惰性构建 `Map<abs, GitFileStatus>` 与"含变更目录" `Set`(O(n·depth) 一次);路径拼接复用 `resolveSidebarPath`。
---
## 3. 文件页(`src/client/FileTree.tsx` / `TreePanel.tsx` / `sidebar.module.css`)
### 3.1 多选(Ctrl/Cmd 多选、Shift 连选)
- 状态:`selected: Set<string>`(绝对路径)+ `anchor: string | null`(Shift 起点),二者都是 FileTree 内部 state(不持久化)。
- 交互(**必须保持现有单击语义不变**,`tests/file-tree-rename-delete.spec.tsx`、`tests/file-tree-drop.spec.tsx` 不得改断言):
- 无修饰键单击:文件行 → 打开(并清空选择);目录行 → 展开/收起(并清空选择)。
- Ctrl/Cmd + 单击:切换该行选中态,`anchor` 设为该行。
- Shift + 单击:以 `anchor` 为起点,在**当前可见行序**(深度优先、展开态决定)上连选区间;`anchor` 不变。
- 点击树空白处 / Esc:清空选择。
- 右键:目标行未选中时先把选择收敛为该行(VS Code 语义),已选中则保留整个选区。
- 视觉:选中行 `--dsw-alias-interactive-bg-active` + 2px 内嵌强调条;与"reveal 高亮"(`--dsw-alias-state-business-tertiary`)区分。
- 批量操作条:选区非空时贴在树体顶部(不遮行):`已选择 N 项` + 复制路径 / 删除 / 取消选择。
- 删除走 `ConfirmDialog`(`deleteSelectedTitle` / `deleteSelectedDesc`),逐个 `api.fsRemove`(顺序执行,失败停下并进错误条),成功后 `pruneTree` + `onPathDeleted`。
- 复制路径写全部选中行的绝对路径(换行分隔)。
- 键盘:行保持 `role="button" tabIndex={0}`;`ArrowUp/Down` 可移动焦点(可选,若实现须不破坏现有 Enter/Space)。
### 3.2 Git 变更着色(VS Code 风格)
- 数据:`useGitStatus(scope)`(§2.2);不做第二次 `git.status` 拉取。
- 文件行:名字按 tone 上色(modified=warn、added/untracked/copied=success、deleted/conflict=error、renamed=business),行尾加 `StatusBadge` 字母(M/A/D/U/R)与 `title`=状态词。**实施更正**:初稿把 conflict 写成 business,落地时按语义改为 error(冲突是需要先处理掉的阻塞态;`StatusBadge` 的 conflict 同样落 error 家族)。
- 目录行:其下任意层级有变更时,目录名同样上色(`dirHasChanges`),不显示字母。
- Git 不可用/非仓库:完全不上色、不加徽标(静默降级)。
- 新增 i18n:`gitStatusModified|Added|Deleted|Untracked|Renamed|Conflict`。
### 3.3 新建文件夹
- 宿主新增路由 `fs.mkdir`(`src/index.ts`)+ `mkdirWorkspaceEntry`(`src/fs-operations.ts`);客户端 `api.fsMkdir(scope, path, name)` → `{ path }`。
- UI:目录行与根行右键菜单新增"新建文件夹";触发后在**该目录层级顶部**插入内联输入行(与 rename 同一套交互:Enter 提交、Esc 取消、blur 提交、IME 守卫、非法名 `newFolderInvalid`),提交成功即 `retryDir(dir)` 并打开该目录。
- 名称规则与 rename 一致:非空、非 `.`/`..`、不含路径分隔符;服务端二次校验;目标已存在 → 报错进错误条。
### 3.4 性能
必须落地的四条(有证据的痛点):
1. **行组件 memo 化**:抽出 `FileRow` / `DirRow`(`React.memo`),把每行的回调与数据做成稳定引用(`useCallback` + ref 读 `dataRef`),使"复制态/拖拽目标/选区/菜单"变化只重渲染受影响的行,而不是整棵树。
2. **搜索不再卸载树**:`TreePanel` 用 `hidden` 属性/样式切换结果面板与树(原来 `needle !== ''` 直接卸载 `FileTree`,丢失 level 缓存并在清空时全量重拉,见 `TreePanel.tsx:245`)。
3. **level 拉取加世代守卫 + AbortController**:`loadDir` 丢弃过期响应;`refreshTick` 清缓存后并发重拉(不再串行等待);tick 触发时只重拉"根 + 已展开"级别。
4. **`fs.tree` 的 `truncated` 不再被丢弃**:该级别底部渲染 `Notice kind="hint"`(新 key `filesTruncated`)。
另外:`useDirectoryWatch` 每次渲染重建 key/数组的问题一并修掉(入参用 memo 化数组)。
### 3.5 拖拽上传体验
- 目标:与任务页同一套视觉(token-only、无硬编码色)。当前 `FileTree.tsx:83-110` 的 SVG 用 `#3964FE` / `#9CE5ED`,改为 `currentColor` + CSS class 上色。
- 结构保持(`tests/file-tree-drop.spec.tsx` 断言 `uploadDropZone`、pill 文案、`busy` 抑制、内存拖拽穿透):仍是"树区高亮框 + 左侧聊天邀请卡",但:
- 高亮框:2px 虚线 `--dsw-alias-border-l2` + 8px 圆角 + 极轻强调色内衬,去掉大范围遮罩阴影(改为 `--dsw-alias-bg-base` 半透明蒙层);
- pill:28px 高、`--dsw-alias-bg-layer-1`、1px `-l2` 边、`--dsw-font-xxxs-11`;
- 悬浮到目录行时,目录行整体高亮(现有 `explorerRowDropTarget` 换成新风格)。
- 上传进度卡(`UploadOverlay.tsx` / `.uploadOverlay*`):同样对齐新尺度(28px 行、12px 文案、token 色)。
### 3.6 使用应用打开:复用 DSH 自带能力
- **删除**插件自研的 open-with 全链路:配置(`sshHost` / `customEditors` / `pinned`)、`src/client/open-with.ts`、`src/client/open-with-settings.tsx`、宿主 `src/open-external.ts` 与 `open.external` 路由、`api.openExternal`、EditorHost 的 openWith props、`builtins/tabs.tsx` 的 `settings.render`(OpenWithSettings),以及对应 20 份词典里的 `openWith*` key 与相关测试。
- **改为**消费 DSH 自带的 open-in-app(见 §4):文件行显示宿主探测到的系统关联应用;目录行显示宿主应用目录(VS Code / Cursor / Zed / Finder…)+ 文件管理器显示;宿主不可用时整段隐藏(与宿主自身行为一致)。
新增/改用的 i18n:`openInApp`(打开方式)、`openInAppDefault`(用默认应用打开)、`revealInFileManager`(在文件管理器中显示)、`openInAppFailed`、`openInAppEmpty`。
---
## 4. 宿主接入层(由 Lead 冻结并先行落地)
### 4.1 `src/client/open-in-app.ts`(新)
```ts
/** 一个可打开当前路径的应用(文件=系统关联;目录=宿主应用目录)。 */
export interface OpenInAppEntry {
id: string
name: string
icon: string | null // 文件:PNG/SVG data URL;目录:宿主图标路由
isDefault: boolean
}
/** 宿主 open-in-app 能力的客户端封装(不可用时全部降级为 no-op / null)。 */
export interface OpenInApp {
/** 宿主是否能把路径交给原生桌面;null = 尚在探测。 */
available(): boolean | null
/** 目录可用的应用目录(宿主 menu 顺序)。 */
directoryApps(): Promise<readonly OpenInAppEntry[]>
/** 某个文件当前注册的系统应用;失败 → null。 */
fileApps(path: string): Promise<readonly OpenInAppEntry[] | null>
/** 用默认应用或指定应用打开(文件或目录)。 */
open(path: string, application?: string): Promise<boolean>
/** 在系统文件管理器中显示。 */
reveal(path: string): Promise<boolean>
}
/** 从客户端 ctx 构造(内部使用 ctx.remote.session.* 与宿主 /open-in-app/* 路由)。 */
export function createOpenInApp(ctx: { get(name: string): unknown }): OpenInApp
```
宿主 API(DSH 0.1.7-rc.x 自带,**不得自研替代**):
- Remote:`ctx.get('remote').session.canOpenWorkspacePath()` / `workspacePathApplications({path})` / `openWorkspacePath({path, action?:'reveal', application?})`(返回值 `{ok, value}`)。
- HTTP(同源、带 cookie):`GET <prefix>/open-in-app/apps` → `{apps: string[]}`;`GET <prefix>/open-in-app/icon/<id>` 图标;`POST <prefix>/open-in-app/open` `{app, path}`(**仅目录**)。
- 应用名字/图标:宿主 `open-in-app` 命名空间(`app.vscode` 等)经 locale bind 读取,失败回退到 id 本身;图标走宿主 icon 路由。
### 4.2 FileTree 的接线(冻结 prop)
`FileTree` 新增可选 prop:
```ts
/** 宿主 open-in-app 句柄(EditorHost 构造并注入;缺省时不渲染该段)。 */
openInApp?: OpenInApp
```
右键菜单"打开方式"段的新语义:
- 文件行:`fileApps(path)` 异步拉取 → 默认应用(`openInAppDefault`,点击 `open(path)`)+ 其余应用各一行(`open(path, id)`)+ 分隔线 + `revealInFileManager`;无应用 → `openInAppEmpty`(禁用行);宿主不可用 → 整段隐藏。
- 目录行:`directoryApps()` → 每应用一行(`open(path, id)`)+ `revealInFileManager`。
- 失败 → 树顶部错误条(`openInAppFailed`,带路径)。
### 4.3 `fs.mkdir`
- 宿主:`src/fs-operations.ts` 新增 `mkdirWorkspaceEntry({cwd, path, name, fence})`(fence 与存在性校验同 rename),`src/index.ts` 注册 `'fs.mkdir'`。
- 客户端:`api.fsMkdir(scope, path, name)`。
- smoke 测试的路由清单(`tests/smoke.spec.ts:84-90`)**不含方法索引**,无需改;但要为 `mkdirWorkspaceEntry` 增单测。
---
## 5. 文件变动页(`src/client/changes/*`)
目标:**更简洁易用**,保留 Git 与 Agent 双视角。重构后结构:
```
ChangesTab
├─ header (36px): 宿主 SegmentedControl[Git | 本轮文件] + 右侧:当前视角的刷新按钮
├─ Git 视角 (GitLens)
│ ├─ 顶部一行:分支/仓库/工作树选择(仅在有多个候选时出现)+ 状态摘要
│ ├─ 变更列表:更改(n) / 已暂存(n) 两组(SectionHeader + 单一 ChangeRow 渲染器)
│ └─ 提交条(吸底):Input + 提交按钮 + 单条状态/错误行
│ └─ 历史:SectionHeader + log 行 + 加载更多(空态明确)
└─ Agent 视角 (SessionLens):Chip 过滤 + 按文件分组行 + 预览
└─ DiffPane(共享预览,保留现有能力:redact / md 阅读 / html 渲染 / pdf / 展开为 diff 页签 / 拖拽高度)
```
必须修掉的既有缺陷(来自 §6 证据,逐条给通过/拒绝检查):
1. 干净的仓库只出现**一处**空态(不再两组各印一次 `noChanges`);历史区有自己的空态与失败态。
2. 单一错误通道:暂存/取消暂存失败必须被捕获并显示(不再 unhandled rejection);分支切换错误不再显示在提交框下面,而是统一状态行。
3. 手动刷新不再静默丢弃(在飞时排队一次);仓库切换只触发**一次**完整刷新;工作树清单带上 `repoRoot`。
4. 工作树不再在用户未操作时被自动切走(仅在首次挂载且用户未选择时自动选一次)。
5. 预览按 `callId` 从最新 ops 重新派生(running → 结算后自动更新);恢复高度时同时应用上限 clamp。
6. 轮询与共享:git 状态改由 `useGitStatus` 提供(同 session 的文件树共用一份),session ops 仍由本页轮询,但**仅在本视角可见时**轮询。
7. `DiffPane` 与 `DiffTab` 的重复加载逻辑抽成一个共享 hook(`useGitDiffTarget`),行为不变(两者都要继续工作)。
8. 视觉:全部对齐 §1 的尺度(行 28px、12px 正文、4px 行圆角、token-only),图标按钮/徽标/空态/确认弹窗改用 `src/client/ui/kit`。
保留能力(不得回归):Git 的暂存/取消暂存/全部操作、提交、分支切换、历史分页、放弃更改、revert、cherry-pick、复制哈希/路径、在工作树/子仓库间切换;Agent 视角的 read/write/edit 过滤、按文件分组、相对时间、字节数、运行中/出错标记;预览面板的全部渲染开关。
---
## 6. 验收(每条都要有实际证据)
| # | 检查 | 通过判据 |
|---|---|---|
| 1 | 类型/规范 | `pnpm typecheck` + `pnpm lint` 通过 |
| 2 | 单元/组件测试 | `pnpm test` 全绿(新增测试覆盖:多选、mkdir、git 着色映射、kit 组件、重构后的 changes 页) |
| 3 | 构建 | `pnpm build` 通过(含 CSS module 编译) |
| 4 | 皮肤契约 | `tests/theme.spec.ts` 绿;新增 CSS module 无字面色 |
| 5 | i18n | `tests/locales.spec.ts` 绿(20 份词典 key 集合相等) |
| 6 | 多选 | 组件测试:Ctrl 单击切换、Shift 连选区间、Esc 清空;单击语义不变(既有测试不动仍绿) |
| 7 | 着色 | 组件测试:mock `git.status` 后行名带 tone class、目录带 `dirHasChanges` 着色、非仓库无着色 |
| 8 | 新建文件夹 | host 单测(fence/已存在/非法名)+ 客户端组件测试(菜单 → 输入 → `api.fsMkdir` 调用参数) |
| 9 | open-in-app | 组件测试:宿主可用时渲染应用行、点击调 `open(path, id)`、不可用段隐藏 |
| 10 | 性能 | 组件测试或结构性验证:搜索不再卸载树(缓存保留)、level 过期响应被丢弃、`truncated` 有提示 |
| 11 | 文件变动 | 组件测试:空仓库只一处空态、暂存失败显示错误、预览随结算更新、视角切换仍保留预览 |
| 12 | 真实挂载 | `pnpm build && pnpm pack && pnpm test:mount`(e2e lane)通过 |
---
## 7. 写入范围(并行实现不得越界)
| 范围 | 归属 |
|---|---|
| `src/client/ui/**`、`docs/plans/2026-09-27-files-changes-uiux.md`、`src/client/locales.ts`(zh/en 新 key) | Lead |
| `src/client/FileTree.tsx`、`src/client/TreePanel.tsx`、`src/client/sidebar.module.css`、`src/client/UploadOverlay.tsx`、`src/client/use-dir-watch.ts`、`tests/file-tree-*.spec.tsx`、`tests/editor-host.spec.tsx` | 文件页实现者 |
| `src/client/changes/**`、`src/client/diff/**`、`tests/changes-*.spec.*`、`tests/git-view-worktree.spec.tsx`、`tests/sandbox-views.spec.tsx` | 文件变动页实现者 |
| `src/index.ts`、`src/fs-operations.ts`、`src/client/api.ts`、`src/client/open-in-app.ts`、`src/client/EditorHost.tsx`、`src/client/builtins/tabs.tsx`、`src/open-external.ts`(删)、`src/client/open-with*.ts(x)`(删) | Lead(宿主接入层,先行落地) |
| `src/client/locales-*.ts`(19 份第三方词典) | 词典传播实现者(单一写者) |
`src/client/locales.ts` 的 zh/en 由 Lead 追加;实现者若确需新 key,必须回报 Lead,不得自行添加(避免 20 份词典冲突)。
---
## 8. 实施记录(落地后的偏差与事实)
### 8.1 交付面
- **全局组件**:新增 `src/client/ui/`(`kit.tsx` + `kit.module.css` + `git-status.ts` + `index.ts`)与 `tests/ui-kit.spec.tsx`;文件页与文件变动页均已真实复用(IconButton / Chip / SectionHeader / Notice / ConfirmDialog / StatusBadge / useGitStatus)。
- **文件页**(`FileTree.tsx` / `TreePanel.tsx` / `sidebar.module.css` / `UploadOverlay.tsx` / `use-dir-watch.ts`):多选(Ctrl/Cmd 切换、Shift 按可见行序连选、Esc/空白/右键收敛)、批量条(复制路径 / 删除所选 / 取消选择)、git 着色(文件行名字上色 + M/A/D/U 徽标,目录行仅着色,非仓库静默降级)、新建文件夹(菜单 + 内联输入 + `api.fsMkdir`)、`FileRow`/`DirRow` memo、level 拉取世代守卫 + AbortController、`fs.tree` truncated 提示、搜索改为 `hidden` 停车不再卸载树、拖拽上传视觉 token 化(原 `#3964FE`/`#9CE5ED` 清零)。
- **宿主接入**:`fs.mkdir` 路由 + `mkdirWorkspaceEntry`(`tests/fs-mkdir.spec.ts` 6 例);新增 `src/client/open-in-app.ts`(宿主 Remote `canOpenWorkspacePath`/`workspacePathApplications`/`openWorkspacePath` + 宿主路由 `/open-in-app/apps|icon|open` 的客户端适配,`tests/open-in-app.spec.ts` 10 例)。
- **文件变动页**(`changes/**`、`diff/**`、`DiffTab.tsx`):36px 头(宿主 `SegmentedControl` + 刷新 IconButton)、单一 `ChangeRow`、SectionHeader 分组、吸底提交条 + 单一状态行、历史区独立空态、`useGitStatus` 共享状态、刷新排队、工作树只自动选一次、预览按 callId 重派生、高度恢复 clamp、session ops 轮询按视角门控、`DiffPane`/`DiffTab` 共用新 hook `src/client/diff/use-git-diff.ts`(并修掉 DiffPane 的 untracked 预览从不渲染的既有缺陷)。
### 8.2 相对冻结契约的偏差(均有理由)
1. **open-with 全链路删除**(而非并存回退):`src/open-external.ts`、`src/client/open-with.ts`、`src/client/open-with-settings.tsx`、`src/client/plugin-settings.ts`(随之成为死代码)、宿主 `open.external` 路由、`api.openExternal`、editor descriptor 的 `settings.render` 面板与 20 个 `openWith*` 词条全部移除;外部打开改为 DSH 自带 open-in-app。**取舍**:原「SSH 远端 + 自定义编辑器 URL 模板」能力随之消失——它服务的场景是 DSH 宿主在远端、浏览器在本地,此时宿主 open-in-app 本就不可用(`canOpenWorkspacePath()` 为 false,整段隐藏,与宿主自身行为一致)。若需要该能力应回到 DSH 侧或单独插件实现。
2. **`useSubmenuFlip` 一并删除**:FileTree 的「打开方式」子菜单消失后,`src/client/menu-flip.ts` + `layout.css` 的子菜单翻转规则 + `tests/menu-flip.spec.tsx` 已无消费者,按死代码清除。
3. **词典同步为 25 个新键**(非 26):原清单最后一项是插入锚点说明;zh/en 与 19 份词典最终同为 486 键。
4. **`visible` 逐层下传**:文件树的 git 轮询必须随页签可见性暂停(工作台会保留所有 tab body 挂载),因此 `EditorHost → TreePanel → FileTree` 新增可选 `visible`,`useGitStatus(..., { visible })` 在页签隐藏时退订。这是实施期补的性能修正(不在原 §3 清单里)。
5. **计划 §5 的「状态摘要」行**未单独实现:干净时用 `changesClean` 空态 + 两个 SectionHeader 计数 + 分支标签表达,避免第三处重复计数文案(KISS)。
6. **未做浏览器级视觉验证**:拖拽/多选的观感只有 token 与尺度层面的代码证据 + 行为测试;真实挂载 lane(`pnpm test:mount`)见 §8.3。
### 8.3 验证证据
- `pnpm typecheck` / `pnpm lint` / `pnpm test`(126 文件、1318 通过、9 跳过)/ `pnpm build` 全绿(§8.6 是修正后的最终数字:1322 通过)。
- 新增测试:`tests/ui-kit.spec.tsx`(14)、`tests/fs-mkdir.spec.ts`(6)、`tests/open-in-app.spec.ts`(10)、`tests/file-tree-multiselect.spec.tsx`(8)、`tests/file-tree-git-status.spec.tsx`(4)、`tests/file-tree-new-folder.spec.tsx`(7)、`tests/file-tree-open-in-app.spec.tsx`(8)、`tests/file-tree-row-memo.spec.tsx`(2)、`tests/file-tree-search-persist.spec.tsx`(3)、`tests/file-tree-dir-watch.spec.tsx`(5)、`tests/diff-git-target.spec.tsx`(4),以及 changes-tab / git-view-worktree 的大幅扩充。
- 真实挂载 lane:`DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount`(本机 PATH 上的 dsh 是 rc.2,脚本按基线拒绝直接运行)→ **7/7 通过**。
### 8.4 真实挂载 lane 抓到的缺陷(单测全绿也漏掉的)
`sidbar` 客户端 bundle 缺少 `remote` / `remote.session` 的 cordis inject 声明时,`ctx.get('remote').session` 会抛
`dsh-better-sidebar: cannot get property "remote.session" without inject`——**这一抛发生在渲染路径上,会把整个「文件」面板替换成错误条 + Retry**(首轮挂载 lane 的截图与 trace 均记录在案,整个 tab sweep 因此失败)。
修法两条,缺一不可:
1. `src/client/index.tsx` 的 `inject` 补上 `'remote'` 与 `'remote.session'`(嵌套属性访问也要单独声明);
2. `src/client/open-in-app.ts` 的 `remoteOf()` 加 try/catch:没有该服务组合的部署降级为「无打开方式菜单」,而不是把面板整块打崩(`tests/open-in-app.spec.ts` 新增一条「ctx 抛错时全部降级」用例钉子)。
这条正好说明为什么 §6 的验收表里 `pnpm test:mount` 不能省:单元/组件测试全部是绿的,只有真实宿主挂载才暴露了这个 inject 契约。
### 8.5 独立验证后的修正(verifier 报告 → 已全部落地)
| 编号 | 问题(verifier 证据) | 处置 |
|---|---|---|
| P1(高) | `open-in-app` 的 availability 是 tri-state(初始 `null`),但生产路径**没有任何地方调用 `probe()`**,于是 `available() !== true` 恒成立、整段菜单永不渲染——「复用 DSH 自带打开方式」实际未交付 | `FileTree` 挂载时按 handle 探测一次:已知答案(`true`/`false`)直接采用,`null` 才 `probe()`,结果进 state 驱动菜单重渲染。新增两条钉死该生命周期的测试(含**真实 adapter + 假 Host Remote** 的端到端用例)。真实挂载截图确认:菜单出现「Open with default app / Visual Studio Code / QuickTime Player / Reveal in File Manager」(宿主探测到的本机关联应用与真实图标) |
| P2(中) | `EditorHost` 新增的 `visible` 从未被传入(`builtins/tabs.tsx` 的文件 tab 未解构 `visible`)→ 隐藏页签仍在轮询 git | 文件 tab 的 component 解构并传入 `visible` |
| P3(中) | 文件树与文件变动页没有共用同一把 slot 键(GitLens 总把自动选中的 **primary** checkout 当 `worktree` 传)→ 同仓库两条 2.5s 轮询 | GitLens 只在选中 **linked** checkout 时才传 `worktree`(primary 的答案是同一份);`tests/ui-kit.spec.tsx` 增补「同键两个消费者只发一次请求」用例 |
| P4(中低) | session 视角每个 tick 重折 ops → 新数组击穿下游 memo → 每次都重新 Blob 编码每行正文 | `pull()` 在「无新事件且游标未前进」时**保留上一次 fold 的引用**,直接返回 |
| P5(低) | `noChanges` 成为死键(20 份词典各一条) | 全部删除(`tests/locales.spec.ts` 仍绿) |
| P6(低) | 冻结文档写 conflict=business,实现为 error | 以实现为准修正文档(冲突是阻塞态,归 error 家族更合理) |
| P7(低) | 新增 `fs.mkdir` 未进接入文档的 fence 路由清单 | 指南 §路由安全边界补上 `fs.mkdir`(并补齐原先漏列的 `fs.rename`/`fs.remove`) |
| P8(低) | 行有 `data-dsh-selected` 但无 `aria-pressed` | 文件/目录行按选中态输出 `aria-pressed` |
| P9(低) | 手动刷新与定时 tick 可重叠、重复追加同一 delta | `pull()` 单飞(在飞时新请求是 no-op,在飞的那次已覆盖到当下) |
### 8.6 最终验证(修正之后重跑)
- `pnpm typecheck` / `pnpm lint` / `pnpm test`:126 文件、**1322 通过**、9 跳过;`pnpm build` 通过。
- 真实挂载 lane:`DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount` → **7/7 通过**(含 tab sweep、perf lane)。
- 截图级证据(临时 Playwright 取证脚本,已删除,不留在仓库):真实宿主上文件树行显示 **M/U 字母与状态色**、右键菜单出现宿主探测到的应用列表、文件变动页显示 36px `Git | Session` 头 + 未暂存/已暂存分组 + 吸底提交条 + 历史。
- 仍属未验证:拖拽上传的视觉观感与手动多选手感(只有 token/尺度证据与行为测试)、Windows 宿主差异。
---
## 9. 第二轮:open-with 与宿主能力并存 + 打包下载 + 文件变动树(用户追加需求)
用户追加三条:
1. **打开方式 = 宿主探测到的本机关联应用 + 插件自研 open-with**(两者并存,不是二选一);
2. **文件页多选时右键新增「压缩打包并下载」**;
3. **文件变动页再次重构:更美观 + 以层级(树)方式显示更改**。
### 9.1 冻结接口
#### A. open-with 恢复并与 open-in-app 并存
- 恢复 `src/client/open-with.ts`(`OpenWithConfig` / `OpenWithTarget` / `parseOpenWithConfig` / `resolveOpenWithTargets` / `openWithUrl` / `openWithSshActive` / `newCustomEditorId` / `isValidCustomEditor`,与 main 逐字一致)、`src/client/open-with-settings.tsx`、`src/client/plugin-settings.ts`、宿主 `src/open-external.ts` 与 `open.external` 路由、`api.openExternal`、editor descriptor 的 `settings.render`,以及 20 个 `openWith*` 词条。
- `FileTree` / `TreePanel` 恢复 main 的可选 props:`openWithTargets` / `openWithPinned` / `openWithSsh` / `onOpenWith` / `onToggleOpenWithPin`,与既有 `openInApp` **并存**。
- 菜单形状(`打开方式` 段):
1. 宿主行(`openInApp` 可用时):`用默认应用打开` + 各系统关联应用;
2. 插件行:pinned 目标直达 + `在应用中打开 >` 子菜单(含图钉,main 原样);**当宿主可用时隐藏插件的 `explorer` 目标**(宿主已有「在文件管理器中显示」,避免重复);
3. 分隔线 + 宿主 `在文件管理器中显示`(宿主不可用时该行不出现,插件的 `explorer` 目标保留,保证仍能 reveal)。
- 宿主完全不可用(`available() === false` / 无 handle)时:宿主行整段隐藏,插件行照旧可用(SSH 远端场景因此保持可用)。
#### B. 压缩打包并下载
- 新增 `src/zip.ts`:`buildZip(entries, opts)` 产出 ZIP(store + `zlib.deflateRaw` 两种方法按压缩收益选择;UTF-8 名称标志位;目录条目;CRC32;无第三方依赖)。
- 新增宿主 GET 路由 `/sidebar/archive?sessionId=&cwd=&name=<archive.zip>&path=<p>&path=<p>…`:每个 `path` 经既有 workspace fence 校验,文件/目录(递归)都接受;上限(条目数 / 总字节)超限返回 JSON 错误;成功时 `content-type: application/zip` + `content-disposition: attachment`。
- 客户端 `api.archiveUrl(scope, paths, name)`(与 `downloadUrl` 同形的 GET URL)。
- 交互:多选(≥2)时右键菜单出现 `压缩并下载({count} 项)`;单选且为目录时同样出现(单文件不出,避免与「下载」重复)。
- 新词条:`zipDownload` / `zipDownloadCount` / `zipFailed`。
#### C. 文件变动页层级显示
- Git 视角的两个分组(未暂存 / 已暂存)各自渲染为**目录树**:单子目录压缩(`a/b/c` 一行显示为 `a/b`),目录行有 disclosure chevron + 文件夹图标,文件行有文件图标 + 状态字母 + 名称 + 弱化目录;行尾仍可暂存/取消暂存;默认展开。
- 纯函数 `buildChangeTree(entries)`(`src/client/changes/change-tree.ts`)产出树 + 压缩规则,独立单测。
- 视觉打磨:分组头吸顶、计数胶囊、hover 与选中态、缩进参考线、提交条与历史行间距统一到 §1 尺度。
### 9.2 分工与写入范围
| 范围 | 归属 |
|---|---|
| 计划文档、验证裁决、集成、最终门禁 | Lead |
| `src/index.ts`、`src/zip.ts`、`src/client/api.ts`、`src/client/open-with.ts`、`src/client/open-with-settings.tsx`、`src/client/plugin-settings.ts`、`src/open-external.ts`、`src/client/EditorHost.tsx`、`src/client/builtins/tabs.tsx`、相关测试 | 宿主接入实现者 |
| `src/client/FileTree.tsx`、`src/client/TreePanel.tsx`、`src/client/sidebar.module.css`、文件页测试 | 文件页实现者 |
| `src/client/changes/**`、`src/client/diff/**`、变动页测试 | 文件变动页实现者 |
| `src/client/locales-*.ts`(19 份) | 词典实现者 |
| 独立验证 | 验证者 |
### 9.3 实施记录(第二轮)
- **打开方式并存**(落地形状):菜单段无标题行 —— 宿主行(`用默认应用打开` + 系统关联应用,仅宿主可用)→ 分隔线 → 插件 pinned 直达行 + `在应用中打开 >` 子菜单(图钉 / SSH 后缀原样)→ 分隔线 → 宿主 `在文件管理器中显示`。宿主可用时过滤插件的 `explorer` 目标;宿主不可用/无 handle 时只留插件行(含 `explorer`,reveal 不丢)。
- 自研链路逐字恢复:`src/client/open-with.ts`、`open-with-settings.tsx`、`plugin-settings.ts`、`src/open-external.ts`、`open.external` 路由、`api.openExternal`、editor descriptor 的 `settings.render`,以及 20 个 `openWith*` 词条(19 份词典从 `git show main:` 逐字取回)。
- 因菜单去掉标题行,`openInApp` 词条成为死键 → 已从 21 份词典删除(zh 507 键)。
- 子菜单需要 `useSubmenuFlip`:第一轮删除的 `src/client/menu-flip.ts` + `layout.css` 翻转规则 + `tests/menu-flip.spec.tsx` 一并恢复。
- **压缩打包下载**:新增 `src/zip.ts`(自研 ZIP 写入器,无第三方依赖)+ `src/archive-route.ts`(下载名净化 + 递归收集)+ `GET /sidebar/archive` + `api.archiveUrl`;`FileTree` 多选右键「压缩并下载(N 项)」(单选目录亦可,单文件不出),下载走 `fetch → blob → objectURL`,**路由错误(超限 / fence 拒绝 / 非 GET)现在会以 `zipFailed` 显示在错误条**。
- 上限:10 000 条目 / 256 MiB 未压缩负载(收集期即中止)。
- 实施中由系统 `unzip` 抓到的真实缺陷:central directory 只写 MS-DOS 位而 version-made-by 声称 Unix → Info-ZIP 把每个文件解成 `mode 000`;改为 Unix 语义(`0o100644 * 0x10000`,注意 `<< 16` 溢出 int32)。复验:mode 644/755、UTF-8 名、`unzip -t` 无错。
- **文件变动层级树**:新增 `src/client/changes/change-tree.ts`(`buildChangeTree`:'/' 分段、单子目录链压缩成一行、目录先于文件、大小写不敏感排序、文件带 `GitFileStatus`、目录带下级变更数),两个分组各自渲染树;折叠状态按「组 + 路径」独立保存、默认展开;分组头吸顶 + 计数胶囊;`Stage all`/`Unstage all` 改为带 aria-label 的 IconButton;选中 = `interactive-bg-active` + 2px 内嵌强调条;12px/级缩进 + `-l1` 参考线;历史行 hash 徽标 / ref 胶囊 / 作者·时间右对齐。
- 契约偏差(已批准):文件行不再重复渲染目录文本(树已用父行表达层级,重复是噪声);`ChangeDir` 额外带 `changes` 计数以免每行重扫子树。
### 9.4 第二轮验证证据
- `pnpm typecheck` / `pnpm lint` / `pnpm test`(**134 文件、1422 通过、9 跳过**)/ `pnpm build` 全绿。
- 真实挂载 lane:`DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount` → **7/7 通过**。
- 截图取证(临时 Playwright 脚本,已删除):真实宿主上确认
① 文件树多选 5 项 + 右键菜单同时含 **宿主探测应用**(Open with default app / Visual Studio Code / QuickTime Player)与 **插件 `Open with >` 子菜单** + **Zip and download (5 items)**;
② 变动页 Git 视角以树显示:`src/client/changes`(单子目录链压缩成一行,带计数胶囊 1)→ `GitLens.tsx`(M 徽标),已暂存组 `src` → `app.ts`。
- 单文件页回归:14 个 spec / 91 用例绿(含新增 `file-tree-archive.spec.tsx` 9 例:出现条件、成功链、三条失败路径、in-flight 守卫)。
- 仍未验证:真实浏览器里 ZIP 的落盘行为(jsdom spy 证据)、拖拽/多选手感、Windows 宿主差异。
### 9.5 第三轮(verifier 二轮报告后的修复,全部完成)
| 编号 | 问题 | 修法 | 判别性验证 |
|---|---|---|---|
| F1【高】 | 中文等非 latin1 下载名 → 真实宿主 **500**(`content-disposition` 直接塞原始名,Node `writeHead` 拒绝 >U+00FF) | `archive-route.ts` 新增 `contentDispositionOf`:ASCII 回退 + `filename*=UTF-8''…`(无可用 ASCII stem 时回退 `download.zip`) | 临时回退 → 真 `node:http` 用例 500;对照断言钉住 `validateHeaderValue` 必抛 |
| F2【中】 | 多选同名不同目录 → 归档条目重名(解压互相覆盖) | `disambiguateArchiveNames`:只对冲突项逐级上溯父目录,必要时回退完整路径 | 临时回退 → 条目重名断言变红 |
| F3【中低】 | `maxBytes` 在读完源文件后才判定(与注释相反);且 `budget===0` 时 0 字节 FIFO 仍会 `open()` 永久阻塞 | `prepareFile` 先 `stat`,`budget <= 0 || size > budget` 立即抛,再读 | FIFO 探针:回退后 5s 超时(阻塞),修复后 44ms 通过(Windows 跳过该探针) |
| F4【中低】 | SSH 模式下宿主不可用时 reveal 整条消失 | SSH 过滤保留 `kind === 'reveal'` 的目标,并注明理由 | 更新 open-with.spec 断言(`['explorer','vscode','cursor','custom:e2']`,reveal 恰 1 条) |
| F5【低】 | 图钉是 `<span role="button" tabIndex={-1}>` 嵌在 menuitem 按钮内(语义非法 + 键盘不可达) | 去掉 role/tabIndex,改成诚实的鼠标热区;注释写明键盘入口是设置面板 | 临时加回 role → 用例变红 |
| F6【低】 | 变更树行无 memo,空闲 tick 全量重渲染 | ① 行 `memo` + 恒定 props(图标解析搬进行内当探针)② 内容键 `entriesKey` 派生 tree ③ (Lead)共享 store 的 `sameStatus` 保住快照身份 | 三处各自临时回退 → 探针变红 |
| F7【低】 | 排序依赖运行时 locale collation | `localeCompare(other, 'en', { sensitivity: 'base' })` + 码点兜底 | 反向输入顺序不变 + 瑞典 collation 对照断言 |
| F8【低】 | POSIX 文件名里的 `\` 被静默转成层级 | `archiveName` 不再替换 `\` | 实探条目 `a\b.txt` 保持单条成员 |
| F9【低】 | 打包中重复点击静默吞掉、大选择无反馈 | `archiveBusy` state + 树顶 `Notice kind="loading" role="status"` | deferred fetch 断言忙态行出现/消失、重复点选 fetch 仍 1 次 |
| — | 新增交互:目录行支持「暂存/取消暂存该目录」 | `DirRow` hover IconButton(`gitStage`/`gitUnstage` 均接受目录 pathspec) | 断言 `gitStage(scope,'src/deep',MAIN)` 且点击不触发折叠 |
最终门禁(修复后重跑):`pnpm typecheck` / `pnpm lint` / `pnpm test`(**134 文件 / 1441 通过 / 9 跳过**)/ `pnpm build` 全绿;
`DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount` → **7/7 通过**(mount=372ms,p95 帧间隔 16ms,无宽度泄漏)。
@@ -1,122 +0,0 @@
# 任务输出常驻浮动窗 + 节点卡去竖线 + 手机设置
日期:2026-09-27 分支:`feat/tasks-graph-polish`(承接 PR #680 的打磨线)
## 背景(用户原话)
> 1. 后台任务弹窗没有高度限制,输出很长会导致看不到。同时让其变为常驻的全屏弹窗(带关闭/拉伸,建议抽取为全局组件方便后续复用)
> 2. 优化节点UI,加上圆角,同时取消左侧装饰条,让整个节点看起来更为统一
> 3. 设置中新增手机设置,里面放各种和手机端的适配设置,目前加上这么几项:检测到处于手机状态时,有新的后台任务或Agent任务时不会自动弹出,任务管理的默认状态切换为树状图
## 用户确认的决策(问答摘要)
| # | 问题 | 结论 |
|---|---|---|
| 1 | 弹窗形态 | 可拖拽 + 可拉伸的**常驻浮动窗**(默认约视口 70%×60%,最小 320×240) |
| 2 | 复用范围 | 抽成**全局组件**,本轮只把后台任务输出换过去(节点/工作流详情、任务窗保持现状) |
| 3 | 常驻边界 | 只靠**关闭按钮 / Escape**;外部点击、失焦、锚点离屏都不关;切会话/关标签、任务结算后都保留 |
| 4 | 左侧竖条 | 「就是左侧的竖条,这个去掉」 |
| 5 | 层级组色 | 「这个可以留着,只是那个竖线不要了」 |
| 6 | 当前会话标记 | 「边框加粗高亮即可」 |
| 7 | 圆角 | **8px**,并**保留类型描边**(成员 accent 描边、工作流 accent 描边 + 底色) |
| 8 | 手机设置 | **自动检测(沿用 768px 窄屏判定)+ 两个独立开关(默认都开)**,分组放在设置页 |
## 架构与文件
```
新增
src/client/FloatingWindow.tsx 常驻浮动窗(拖拽 + 拉伸 + 关闭,内容区自滚动)
src/client/FloatingWindow.module.css 窗框/标题栏/内容区/底部行/三个拉伸柄样式
tests/floating-window.spec.tsx 9 例:结构、尺寸与定位、关闭契约、两个手势
改写
src/client/JobsDrawer.tsx JobOutputPopoverContent → JobOutputWindow
src/client/SubagentView.tsx 任务分支改渲染窗口;默认视图加窄屏覆盖
src/client/tasks-graph.module.css 卡片:去两条左侧竖条、圆角 8px、当前会话 1.5px 描边
src/client/sidebar/use-host-feeds.ts 两个自动弹出 gate 加手机压制
src/client/SideCardSection.tsx 新增「手机」分组(两行开关)
src/prefs-shared.ts / src/config.ts / src/client/prefs.ts
两个新偏好 mobileNoAutoOpen / mobileDefaultTree
src/client/locales*.ts(20 份) 5 条手机设置词条
```
## 关键设计
### 1. 常驻浮动窗(`FloatingWindow.tsx`)
- **为什么不是复用 `AnchoredPopover`**:那是 popover —— 锚定元素、按内容定尺寸、外部 mousedown / window blur / 锚点离开视口都会关它。被**持续观察**的面(一个跑几分钟的任务输出)需要的恰好相反的生命周期:只有关闭按钮与 Escape 结束它,而且必须能挪开、能拉伸。
- **内容区就是高度上限**:`data-window-body` 自己 `overflow: auto`,所以「输出很长看不到」这条被结构性修掉(旧 popover 完全没有高度约束,`.popPre` 那个 160px 滚动井在改用 `TerminalBlock` 之后已成死规则 —— 本轮一并删掉)。
- **手势复用既有范式**:移动走 window 级 `pointermove`(同 `AnchoredPopover` 的拖拽,容器捕获会重定向 click、杀掉窗内按钮),拉伸走 `setPointerCapture` + `createFrameBatcher()`(同 `EditorHost` 的面板拉伸;逐事件 setState 是 #315 的拖拽卡顿)。
- **几何**:`position: fixed`;尺寸在**挂载时量一次**(之后每条流式输出帧都不会挪动读者摆好的窗);拖拽保证标题栏至少 48px 留在视口内;拉伸夹取到 `minSize` 与视口;window `resize` 时把窗夹回视口。
- **常驻**:不注册外部 mousedown / blur / IntersectionObserver;只注册 Escape(捕获阶段)。
- **皮肤契约**:令牌全量、8px 圆角与节点卡一致、浮动层保留 `--dsw-shadow-lv3`。
### 2. 任务输出接入
- `JobOutputWindow`:标题栏(任务 label + 复制按钮 + 关闭)/内容区(状态行 + `TerminalBlock`,`maxLines: Infinity` 保留「看完整输出」)/底部行(跟随最新 + 两击终止)。
- **顺带修掉的 bug**:「跟随最新」原先滚的是包在 `TerminalBlock` 外面的普通 `div`(永不滚动)——现在滚窗口内容区。
- 页面侧:`popover.kind === 'job'` 不再走 `AnchoredPopover`;节点/工作流详情仍是 280px 锚定小卡,任务窗不变。
- 任务从宿主 roster 消失时窗口关闭(无处再观察);**结算不掉行,窗口继续**。
### 3. 节点卡(`tasks-graph.module.css`)
| 变更 | 说明 |
|---|---|
| 删除 `.cardTop::before` | 层级竖条;层级改由上段极淡底色承担,并把色调从 7/5/3.5/2.5% 抬到 **9/7/5/3.5%** 补偿对比度损失 |
| 删除 `.nodeCurrent::before` | 当前会话竖条;`.nodeCurrent` 改为 **2px accent 描边**(「边框加粗高亮」) |
| `.node` 圆角 4px → **8px** | 与新的浮动窗同一圆角,卡与窗成为一个家族 |
| `.cardTop` 左内边距 10px → 8px | 竖条消失后的回正 |
| `.nodeTeam` / `.nodeWorkflow` / `.nodeFold` / `.nodeError` | 按用户选择**保留**类型区分;只继承新圆角 |
| 删除 `.popPre` / `.jobPopPre` | 随旧弹窗一起死掉的规则 |
**几何安全性**:`.node` 是 `box-sizing: border-box` + 布局给的 `min-height`,加粗描边被吸收在盒内,卡片外框尺寸不变 —— `tasks-graph-layout.ts` 的高度/行距预算**不需要改**,`tests/tasks-graph-layout.spec.ts` 保持全绿。
### 4. 手机设置
| 键 | 默认 | 语义 |
|---|---|---|
| `mobileNoAutoOpen` | true | 窄屏时**同时**压制子代理与后台任务两个自动弹出(既有两个开关的值不被改写;宽屏行为不变) |
| `mobileDefaultTree` | true | 窄屏时任务页默认树状图(页内切换仍是临时覆盖,优先级最高) |
- 判定沿用插件既有的 `isNarrowWidth`(768px 断点,与侧边栏手机布局、park 逻辑同源),在**触发时**读视口。
- 设置 UI:DSH 设置页「侧边卡片」分区新增「手机」分组(两行 title/desc + 宿主 `Switch`,走既有 `applyPref` 写入)。
## 实施偏差记录(写给复审)
1. **窗口尺寸只在挂载时量一次**(`useEffect(..., [])` + 一条 eslint-disable):若跟着 props/内容重算,每条流式输出帧都会把读者拖好的窗口弹回去。视口变化仍会夹取。
2. **`aria-label` 用 `<title>` 而不是 `<h1>` 之类**:与既有 `AnchoredPopover`/`Modal` 的可访问名做法一致(`role="dialog"` + 可访问名)。
3. **拉伸只给三个柄**(右、下、右下):实际用法是「日志变长 → 往右下长」,两轴各自还可单独拉;八个柄属于没被要求的能力。
4. **没有做焦点陷阱**:与插件现有的两个浮层(`AnchoredPopover` / 宿主 `Modal` 之外的弹窗)一致;Escape 是唯一键盘出口。
5. **窄屏自动激活的既有两个 spec 显式关掉了手机开关**:它们钉的是「窄屏也激活 + 收回列(park)」这条更早的承诺,而压制行为有自己的新用例;把 harness 的默认值改成「关」会让 spec 脱离真实默认,所以选择在用例里显式声明。
6. **`mobileDefaultTree` 只在有 store 时生效**:`SubagentView` 的 prefs 来自 `store` prop,spec harness 里多数用例不传 store(读作 `graph`);视图默认的新用例显式传入 `createSidebarStore()`。
7. **当前会话的加粗描边用 2px 而不是 1.5px**:真机自测发现 Chromium 在 device-pixel ratio 1 下**向下取整**亚像素边框宽度 —— 1.5px 的计算值直接是 1px,"加粗"完全看不出来(recon 实测 `borderTopWidth: "1px"`,而 class 与 accent 颜色都在)。改成整数 2px 后在 DPR 1 与 2 上都成立。
8. **`.popPre` / `.jobPopPre` 被删除**:旧 `jobs` 弹窗改用 `TerminalBlock` 之后它们就没有消费者了,本轮顺手清掉(与上次「drop the dead rules」同类)。
## 验证证据(2026-09-27)
| 项 | 结果 |
|---|---|
| `pnpm typecheck` / `pnpm lint` | ✅ 0 错误 |
| `pnpm test` | ✅ **1254 passed / 9 skipped / 0 failed**(120 文件;新增 14 例:窗口 9 / prefs 1 / 设置分组 1 / 自动激活压制 2 / 视图默认 1) |
| 真机挂载 lane | ✅ **7 passed**(真实 DSH 0.1.7-rc.2,scratch profile + 无头渲染 + 全 tab 扫描 + 性能测量;日志 `/tmp/e2e-win.log`) |
| 3080 真机自测 | ✅ 见下表(真实工作:长输出后台任务 + 子代理;截图 `/tmp/dsh-selfcheck/10-13-*.png`) |
### 3080 真机证据(逐项)
| 项 | 实测 |
|---|---|
| 卡片去竖条 + 圆角 + 加粗描边 | `radius: 8px`;`::before` 的 `content: none`(层级条与当前会话条都不存在了);当前会话 `borderTopWidth: 2px` + `rgb(65,118,230)`(accent);卡片外框 **247×134** 与布局预算逐像素一致(去掉竖条/加粗描边都不改几何) |
| 窗口限高(原始 bug) | 240 行输出的任务:窗口 1008×540 全在 1440×900 视口内,内容区 `scrollHeight 5459 > clientHeight 449` —— **真的在窗内滚动**;滚到底能看到末行 `THE-END` |
| 窗口结构 | 标题栏(任务名 + 复制 + 关闭)、内容区、底部行(跟随最新 + 终止)与三个拉伸柄(x / y / both)都在 |
| 拖拽与拉伸 | 拖标题栏:(424,352) → (304,352);拖右下角:1008×540 → **918×480** |
| 常驻语义 | 外部点击(窗口上方面板区、左侧会话列)后窗口仍在(count 仍为 1);Escape 关闭 |
| 手机适配 | 窄屏(700×900)起后台任务 + 子代理后,右侧栏**未被打开**(`data-sidebar-right-panel` 不存在);手动打开任务页 → `tree: 2 / graph: 0`(默认树状图) |
### 真机自测发现并修掉的两处
1. **1.5px 的「加粗」边框在 DPR 1 下等于 1px**:Chromium 向下取整亚像素边框宽度,真机上完全看不出加粗(recon 实测 `borderTopWidth: "1px"`,而 class 与 accent 颜色都在)。改成整数 **2px**(DPR 1 与 2 都成立)。
2. **拖拽只保证标题栏可达,会把窗口的右下角推出视口**:第一次真机拖拽后窗口底部落到 y=932(视口 900),拉伸柄随之点不到。改为「放得下就整窗留在视口内,放不下才退化成保留 48px 可达」——两个轴都套用,视口变化时同样处理。
### 未在真机复现 / 已知限制
- 关掉两个手机开关后的真机行为(真机只验了默认开启的一侧;关闭一侧由单测覆盖,且既有窄屏 park 用例显式关掉该开关后仍然全绿)。
- **切会话会关掉窗口**:窗口状态属于该会话的 Tasks 页组件,宿主在切走时卸载该会话视图(实测点击左侧会话列表即窗口消失)。设计文档已列为限制:若要真正跨会话常驻,需要把开启状态提到 `SidebarStore`(本轮范围外)。
-222
View File
@@ -1,222 +0,0 @@
# 任务管理页「工作图」打磨:双段式卡片 + 主 Agent 同款合并活动 + 折叠聚合 + 窄栏可读性
日期:2026-09-27 分支:`feat/tasks-graph-polish`(基于 `feat/tasks-graph-workflow-teams`,接 PR #680)
## 背景:为什么不是「再做一版卡片列表」
上一轮(`feat/tasks-page-card-redesign`)把任务页当成「树 + 卡片」重做了一遍,方向被用户否掉:**主显示模式仍然是工作流图**(`feat/tasks-graph-workflow-teams` 里那一版图),树模式保留即可;图模式才是与官方做出差异化的地方。用户随后给出这一轮的 brief(原文):
> 参考图分支,将其 UI 等合并到之前我说的现代美观干净的风格:
> - 改为组合的双段式的结构,即上方为显示节点类型和节点详细内容,而下方是一个小条,用于显示当前的状态、执行的及执行的工具,已完成、折叠展开等操作。
> - 执行时有一个从左到右扫光的特效,同时执行的工具逻辑和主 Agent 中合并显示的逻辑一样。
> - 当为已完成的状态时,要有一个可点击的折叠按钮,在下方的小横条中。
> - 优化排布逻辑,考虑有很多节点/阶段的情况,考虑窄屏的特点。
因此这一轮 = **图分支的骨架 + 上一轮验证过的视觉/交互语言**。上一轮的卡片 UI 与树改造整体丢弃,只把钱花在已验证的数据层上(见「移植与丢弃」)。
## 用户确认的决策(问答摘要)
| # | 问题 | 结论 |
|---|---|---|
| 1 | 打磨范围 | ② 运行中节点的扫光/执行态视觉;③ 后台任务改读 `ctx.jobs`;④ 图布局与窄栏可读性;⑤ 折叠聚合打磨 |
| 2 | 「阶段」要不要做成可展示列表 | **不要**:「阶段做到卡片徽标 + 颜色标明分组即可」 |
| 3 | 阶段徽标针对哪种分组 | **两者都要**:层级/分支分组 + workflow 相位分组 |
| 4 | 图节点双段式怎么落地 | 图上也是**真两段**(宽度保持现有量级,高度按新结构重算) |
| 5 | 树模式要不要一起换新语言 | **不要**:树模式保持现状 |
| 6 | 图节点小条上的折叠按钮 | **收进「已完成聚合」**(不是收起上段) |
| 7 | 窄栏具体痛点 | a 卡片太窄文字被截 / b 偏小、不居中、缩放手感 / c 节点内信息太密 / d 连线与相位框 / e 拖拽与缩放手感 / f 折叠聚合 —— 全选 |
| 8 | 代码落点 | 本工作区从图分支开新分支;上一轮版本丢弃 |
## 架构与文件
```
host 半(src/)
process-activity.ts 新增:宿主 ui-chat 的 process group 算法移植(分类 / live 详情 /
范围切割),裁剪掉逐阶段列表(阶段改由徽标表达)
subagent-live-route.ts 改为:树内每个子节点(含已结算)与拓扑根都回一帧
SidebarChildLiveView { running?, text?, summary?, lastEventTime? }
context-types.ts SidebarChildLiveView / SidebarClientJobsService / SidebarJobsSnapshot /
SidebarObservedJob;SidebarJobView 补 owner、progress
删 jobs-routes.ts 自建三条路由退出历史(见下)
client 半(src/client/)
process-labels.ts 新增:宿主 chat 命名空间措辞(liveActivityLabel / doneActivityTitle)
tasks-card.tsx 新增:卡片两段(CardTop / CardBar)+ 类型徽章 + 深度/相位色阶
jobs-client.ts 新增:ctx.jobs 读取层(watchRows 引用计数 / observe / collectRows)
block-labels.ts 新增:TerminalBlock 的 labels(零新增词条)
TasksGraph.tsx 三个节点渲染器改双段式;fit / 缩放 / 平移 / 连线重做
tasks-graph.module.css 卡片两段 / 徽标 / 小条 / 扫光 / 相位框配色
tasks-graph-layout.ts 卡片高度预算按新行重算,宽度 176 → 190
SubagentView.tsx foldedIds(手动折叠集)+ 树内 jobs 改读客户端服务
sidebar/use-host-feeds.ts 任务自动激活改骑推送 roster(detectNewJob 加观测起点时钟)
```
## 关键设计
### 1. 卡片两段(图模式)
```
┌────────────────────────────────┐
│▎[类型徽章] [相位徽标] │ ← 上段:身份 + 组色左描边(2px)+ 极淡底色
│ 名称(最多两行 clamp) │
│ mode/model · 角色(mono) │
│ ☑ 任务主题 · 状态 +N(团队成员) │
├────────────────────────────────┤
│ ● 运行中 · 正在运行命令 · npm… ⌄ │ ← 小条 22px:状态点 + 状态词 + 合并活动 + 折叠
└────────────────────────────────┘
```
- 类型徽章用页面自己的词汇:`subagentMainAgent`(主代理,复用)+ 新增 `tasksKindSubagent` / `tasksKindTeammate` / `tasksKindWorkflow`;聚合节点用 `tasksFoldCompleted`(`✓N 已完成`)。
- **组色 = 树深度**:4 档令牌色阶(`color-mix(in srgb, var(--dsw-alias-state-business-primary) 100/72/48/28%, transparent)`),作用于左描边与上段底色;更深层级钳到最后一档。
- **相位徽标 = workflow 相位**:走 ink 色族(`--dsw-alias-label-primary` 的 100/78/58/42% 色阶),与组色族分开,避免两套分组撞色;虚线相位框保留(空间分组),改用同色极淡描边。
- **皮肤契约约束**:宿主不提供图表调色板,插件也不允许硬编码颜色,所以「颜色分组」只能做成令牌色阶 —— 这是本轮唯一被契约限死的设计点,不是取舍。
### 2. 合并活动(主 Agent 同款)
`foldProcess(events, { live })` 把子会话日志折成宿主 process group 的形状:分类计数(`activityOf` 表逐条照抄)、最新未结算调用(含 `preparing` = 模型宣布但未派发)、`runningDetail`(宿主键优先级 + 160 字 grapheme 截断)、范围边界(`user/message` 与带文本的 `assistant/message`)。客户端 `liveActivityLabel` / `doneActivityTitle` 用宿主 `chat` 命名空间的 `message.stepProcess.*` 词条合成措辞(`chatT` 失败即回退插件文案),所以卡片上的字与主对话完全一致。
**一条刻意偏差**:最新范围为空(子代理刚回复完)时回退到上一个「做过事」的范围 —— 否则已结算卡片的小条只会显示「已完成分析」。回退范围永不报告运行中。
### 3. 扫光(执行态)
小条 `data-running="true"` 时叠加一条 45% 宽的令牌渐变带,`background-position 0% → 100%`,1.5s `cubic-bezier(0.33,0,0.67,1)` 循环、末 1/3 停顿;`prefers-reduced-motion: reduce` 下关闭动画(状态词与活动文本已承载同样信息)。方向从左到右(用户确认;宿主的文本 shimmer 是反向,这里刻意不同)。
### 4. 折叠聚合
- 页面持有 `foldedIds`(读者从卡片小条逐个收起)与 `folded`(控制条的全局规则),模型里两者走**同一组守卫**:只折已结算叶子、永不折当前会话、永不折有子节点的、永不折团队成员。
- 聚合节点(`fold:<parentId>`)点击 = **全部展开**(清空手动集 + 关闭全局折叠);控制条按钮只切全局规则。
- 折叠/展开不重置读者当前的缩放与平移(自动 fit 只在读者未触碰视图时生效)。
### 5. 后台任务改读宿主客户端服务
0.1.7 的 web profile 挂载 `ctx.jobs`(`@deepseek-ai/dsh-api-job-controller/client`):`state` 是整份快照(按被观察会话的 roster + 按任务 id 的保留输出),`watchRows` 是按会话引用计数的**推送** roster,`observe` 是**非消费**输出流(返回释放函数),`kill` 是注册表准入。插件因此删掉自建的三条路由与事件回放:
- 删除 `src/jobs-routes.ts`、`index.ts` 的 `jobs.list/output/kill`、`api.jobsList/jobOutput/jobKill`、`SidebarJobsService`、页面的 `jobs.list` 轮询与 `collectTreeJobs`;
- 抽屉的行来自推送 roster(状态/时长/owner/进度)、输出面换宿主 `TerminalBlock` 显示观察流(`gapBefore` → 截断提示、`error` → 失败提示)、kill 走 `jobs.kill(owner, jobId)`(两击确认不变);
- 无 `ctx.jobs` 的部署**整段不渲染**(结构性降级),插件的其它面不受影响。
**旧路由为什么必须死**:它读的是「模型已读到的输出」(事件回放),因为注册表当时只有模型的**消费**游标可读;宿主服务把这条约束变成了能力(观察流不碰游标),继续维护回放既多余又永远落后于流。
### 6. 布局与窄栏(a–f 全项)
- 卡片高度预算重算:徽章 16 + 两行标题 36 + 元信息 16 + 小条 22 + 内边距 11 + 边框 2 = **103px**(团队成员 +18 任务行);宽度 176 → **190px**(360px 侧栏仍两列,缩放 ~0.86)。**live 行不再占高度** —— 合并活动在小条里,任何状态的行高一致。
- fit 按**内容包围盒**(而非含 padding 的整张画布)双轴居中,`FIT_MAX_SCALE` 1.15 → 1.30;空画布**双击**重新适配并恢复自动 fit。
- 缩放范围 0.3–1.5 → **0.4–2.0**,并按 `deltaMode` 归一化滚轮刻度(行模式鼠标不再比触控板慢 30 倍)。
- 平移加**边界夹取**(至少 72px 内容留在视口内),拖不出视野。
- 连线改「垂直出线 stub(8px)+ 贝塞尔」,线宽 1 → **1.5px**(0.78–0.86 的 fit 缩放下 1px 会落到一个设备像素以下,整棵层级会糊掉)。
## 移植与丢弃
| 上一轮产物 | 处置 |
|---|---|
| `src/process-activity.ts` + 44 例 | **移植并裁剪**(删逐阶段机制,保留分类/详情/范围切割);新增「空范围回退」一条并补测 |
| `src/client/process-labels.ts` + 10 例 | 原样移植 |
| `chatT` + 4 例回归(含「不许解绑 bind」) | 原样移植 |
| `src/client/block-labels.ts` | 原样移植(jobs 输出面复用) |
| `ctx.jobs` 读取层(`clientJobs` / `useJobsSnapshot` / `collectRows`) | 移植数据层;展示组件丢弃(本分支有自己的抽屉) |
| `subagents.live` 新载荷 + `SidebarChildLiveView` | 移植(去掉 `expand`/`stages`) |
| 卡片 UI(`subagent-card.tsx` / `subagent-runs.ts` / 树重写 / 5 个词条) | **丢弃** |
## 后续变更(同日,`2026-09-27-floating-window-mobile-settings.md`)
本文描述的卡片语言随后被两处调整:**左侧竖条(层级条 + 当前会话条)删除**,层级改由上段极淡底色承担、
当前会话改用加粗 accent 描边;**圆角 4px → 8px**。任务输出的锚定浮窗也换成了常驻浮动窗。
以新文档为准。
## 后续修正(2026-09-27,用户反馈)
用户反馈两条,都在真机上定位并修掉:
### 1. 扫光只有「部分」→ 改成整条完整扫过
第一版用 `background-size: 45%` 的窄带 + `background-position: 0% → 100%`:亮带确实在移动,
但它始终只覆盖小条的一部分(峰值从 22.5% 走到 77.5%,两端永远扫不到),看起来像中间游走的
一小块光斑。现改为**与小条等宽的渐变带 + `transform: translateX(-100%) → translateX(100%)`**:
光带从左边外侧进入、中途覆盖整条、再从右边外侧离开,行程就是用条宽本身表达的(任意条宽都成立),
末 1/3 周期停在右侧外侧(两轮之间留白)。`tests/theme.spec.ts` 新增一条守护钉住这个形状。
真机证据(242px 宽的小条,每 90ms 采样一次 `::after` 的 `matrix` 平移量):
`[-23, 32, 85, 131, 163, -139, -94, -46, 9, 64, 114, 152, 179, 188]` —— 光带在小条左侧
(-139)到右侧(+188)之间被连续观察到,单调推进、跨度超过一个条宽;并截到一张行程中的帧。
### 2. 折叠按钮与展开按钮长得一样 → 两者分向
三处控件原来共用向下箭头(`IconChevronDownOutlineRegular`):卡片的逐节点「收进已完成聚合」按钮、
聚合卡片的「展开」、以及控制条的折叠开关(后者更糟:无论将要折叠还是展开都画同一个清单图标)。
现在按**动作方向**区分:
| 控件 | 折叠方向(chevron up) | 展开方向(chevron down) |
|---|---|---|
| 卡片小条的逐节点按钮 | ✅ `收进已完成聚合` | — |
| 聚合卡片的小条 | — | ✅ `展开已完成的节点` |
| 控制条折叠开关 | ✅(当前已展开时) | ✅(当前已折叠时) |
真机证据(比对三处 `<svg>` 内容):`expandVsCollapse: true`(两个方向确实不同)、
`clusterMatchesExpand: true`(控制条在折叠态画的就是聚合卡的展开箭头)、
`clusterFlips: true`(状态切换时图标跟着换)、`clusterCollapseMatchesCard: true`
(控制条的折叠箭头与卡片逐节点按钮一致)。组件级守护见 `tests/tasks-page.spec.tsx`
的「draws COLLAPSE and EXPAND with different glyphs」。
### 3. 小条里「运行中」与「正在分析请求」没对齐 → 两者统一字体
字体度量问题,不是布局问题:两者的行盒完全一致(都是 11px/14px、`align-items: center` 下
同一位置),但 11px 下 sans 字族的 ascent/descent 是 **11/2**,而 mono 字族是 **10/3** ——
基线因此低 1px,蓝字看起来就是没对齐。而中文在这个 mono 栈里本来就走同一个 CJK 回退字族
(PingFang SC),mono 只改到句子里的拉丁片段,等于「半句等宽、整体低一像素」。
修法是让两者共用小条自己的字族(`.barActivity` 不再声明 `font-family`);机器文本的等宽语气
留在它该在的地方(上方 meta 行、后台任务行、终端输出井)。`tests/theme.spec.ts` 增加守护:
`.barActivity` 不得声明 `font-family`。
真机证据:真实运行中卡片的小条(3× DPR 截图 `/tmp/dsh-selfcheck/17-bar-real.png`)两段文字
同处一行,且两个 span 的 `font-family` 计算值完全相同(断言 `new Set(fonts).size === 1` 通过)。
## 实施偏差记录(写给复审)
1. **`process-activity.ts` 是裁剪版而不是原样搬运**:本轮「阶段」= 徽标 + 颜色,逐阶段列表没有消费者,留着就是死代码。裁剪后 44 例 → 30 例(阶段相关用例随机制一起删除)。
2. **`CardBar` 的折叠 chevron 用新词条 `tasksFoldOne`(收进已完成聚合)而不是复用 `tasksFoldCollapse`(折叠已完成的节点)**:后者已经是控制条全局开关的可访问名,同一个 aria-label 出现在两个按钮上会让屏幕阅读器与测试都无法区分「折一个」和「折全部」。
3. **`TasksAgentNode.depth` 是为此新增的字段**:卡片组色需要层级,而模型的递归本来就知道深度;顺带给图节点加 `data-depth`(用户 CSS 与测试的可寻址面,和树行的 `aria-level` 同性质)。
4. **`detectNewJob` 加了第三个参数(观测起点时钟)**:roster 改成推送后,宿主在「该会话没有任务」时会**删掉这个 key**,于是空的首页帧与「还没送到」不可区分 —— 页面挂载时已经在跑的任务会被 id 差集误判成新工作并弹任务页。用 `job.startedAt >= 观测开始时刻` 过滤;自动激活 spec 里两条用例钉住这个语义。
5. **两个测试期望被改而不是改代码**:`subagent-live-polling.spec.tsx` 原先断言卡片上有「思考中…」/「hello」文本行 —— 那是旧卡片的 live 行,现在文本行只在小条的活动行里;`subagent-jobs-view.spec.tsx` 的「多任务仍紧凑」用例原先假设抽屉默认折叠,而折叠阈值数的是**代理数**(1 个代理 → 默认展开)。两处都是断言跟随设计,不是放宽。
## 验证证据(2026-09-27)
| 项 | 结果 |
|---|---|
| `pnpm typecheck` | ✅ 0 错误 |
| `pnpm lint` | ✅ 0 错误 |
| `pnpm test` | ✅ **1240 passed / 9 skipped / 0 failed**(119 文件) |
| `pnpm build` + `pnpm pack` | ✅ tarball `dsh-better-sidebar-0.21.1.tgz`,`lib/client.js` 含 `dsh-tasks-bar-sweep` 与卡片类 |
| 新增/改写的守护 | `process-activity`(30)/ `process-labels`(10)/ `tasks-model`(depth、phase、foldedIds)/ `tasks-graph-layout`(新高度预算与带宽)/ `tasks-page`(双段结构、`data-running`、chevron→聚合、聚合→全展开、相位徽标)/ `subagent-jobs-view`(14,假 `ctx.jobs`)/ `sidebar-auto-activation`(12,假 `ctx.jobs`)/ `locales`(新词条 ×20 词典) |
| `pnpm test:mount` | 见下节(真机挂载 lane) |
| 3080 真机自测 | 见下节(Playwright 驱动真实工作 + 截图) |
### 真机挂载 lane
`DSH_CMD=/tmp/dsh-rc-home-shim.sh DSH_EXPECT_VERSION=0.1.7-rc.2 bash scripts/e2e-mount.sh` → **7 passed**(日志 `/tmp/e2e-polish.log`)。本地的 shim 是必需的:桌面版 `~/.local/bin/dsh` 把 `DSH_HOME` 硬编码成 `~/.dsh`,lane 的 scratch profile 会写进真实配置目录。lane 同时证明宿主侧挂了 `@deepseek-ai/dsh-api-job-controller/client.js` 与 `@deepseek-ai/dsh-client-ui-jobs/client.js`(本轮 `ctx.jobs` 依赖的服务确实在 web profile 里)。
### 3080 真机自测(真实工作 + Playwright)
装法:`cp` 到一个**新文件名**再 `dsh plugin --profile web add file:...` —— 同路径同版本号的 tarball 会被 pnpm 当成已装而不换内容(实测:旧的 client bundle 仍在,页面渲染的还是上一轮的卡片版)。装完 `pm2 restart dsh-web`,从 pm2 日志取一次性 token。
驱动真实工作(两个后台任务 + 一个子代理),逐项核验:
| 目标 | 真机证据 |
|---|---|
| G1 双段式 | 根卡 innerText = `主代理 / 并行启动后台任务与统计子代理 / 运行中 / 正在分析请求`(徽章 + 名称 + 小条),每张卡都有 `[data-card-bar]` |
| G2 扫光 | 运行中小条的 `::after` computed style:`animationName` 含 `dsh-tasks-bar-sweep`、`1.5s`、`cubic-bezier(0.33, 0, 0.67, 1)`、`infinite`、`background-size: 45% 100%`,采样时正处在 `6.03%` 的行程中 |
| G3 合并活动 | 小条文案 `正在分析请求`(宿主 `chat` 词条的运行时输出,不是插件自造词);结算后读数为 `已调用工具` / `已完成分析` |
| G4 折叠聚合 | 一次点击 `button[aria-label="收进已完成聚合"]` 后该节点从画布消失、聚合卡出现(`✓ 1 已完成 / Count files in directory`);点聚合后节点回归 |
| G5 徽标与组色 | 卡片徽章 `主代理` / `✓ 1 已完成`;上段左侧 2px 组色描边在截图中可见;相位徽标**未在真机复现**(该实例没有 workflow run) |
| G6 窄栏 | 视口收到 820px 时宿主把面板给到 **364px**:面板 `scrollWidth == clientWidth == 364`(无横向溢出)、文档两轴都不可滚、画布 363×672、卡片 **247×134**(= 190×1.30 / 103×1.30,与布局预算逐像素一致)、小条 30px(= 22×1.30)、fit 读数 130% |
| G7 后台任务 | 抽屉显示两个真实任务(`运行中 · 0 秒` / 结算后 `已完成 · exit code: 0 · 30 秒`);点开一行后输出浮窗用宿主 `TerminalBlock` 流式显示 `tick 1`(`ctx.jobs.observe` 的非消费流),并带 `终止`(两击)与 `跟随最新` |
| 无崩溃 | 无 pageerror、无插件错误条 |
截图:`/tmp/dsh-selfcheck/01-running-graph.png`(运行态)、`02-job-stream.png`(后台任务输出流)、`03-settled-graph.png`、`04-folded-one.png`、`05-final.png`、`06/08-narrow*.png`(364px 窄栏)。
**未在真机复现的两项**(如实记录):① 相位徽标/相位框的真机观感(该实例没有 workflow run,无法在不造数据的前提下产生);② 扫光的**像素级**运动(只验证了 computed style 与行程采样,没有做逐帧像素 diff)。另外宿主面板的宽度手柄对本轮的合成拖拽不响应,窄栏是通过视口宽度达成的。
### 已知取舍(复审可见)
- 组色只能做成令牌色阶(宿主无图表调色板,皮肤契约禁止字面色),超过 4 档的深度钳到最后一档:颜色不再区分,徽章文本仍区分。
- 结算节点的合并摘要只反映「最近一个做过事的范围」;再往前的历史只能进节点详情浮窗。
- `Session.snapshotEvents()` 在 0.1.7 标了 `@deprecated`(实现是缓存冻结快照,成本可接受),折叠过程只扫到第一个可汇报的范围边界。
@@ -1,87 +0,0 @@
# DSH 0.2.0-rc.1 适配记录(v0.24.0)
> 状态:已实施。支持线从 `^0.1.7-rc.1` 整体前移到 `^0.2.0-rc.1`(**单线**:0.1.7 用户停在 v0.23.0)。
> 上游发布:[dsh-v0.2.0-rc.1](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.0-rc.1)(npm dist-tag `next`;`latest` 仍是 0.1.7-rc.2)。
## 0. 结论先行
**0.2.0-rc.1 对本插件所消费的全部宿主 API 是纯增量**:19 个被用到的宿主包**零值导出删除**,会话格式与子代理描述符版本未动,CLI 与客户端运行时逐字节相同。因此本版**没有为 0.1.7 写任何运行时兼容分支**——唯一必须改的是 peer 下限(否则宿主启动预检会静默禁用整行)。
## 1. 硬阻塞:caret 跨 minor 不成立
`packages/boot/app-boot/src/plugin-compatibility.ts`(0.1.7-rc.1 起)遍历 profile 行的 `peerDependencies`,对名字为 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` 的条目跑
`semver.satisfies(宿主版本, 范围, { includePrerelease: true })`,不满足就 `row.disabled = true`,只在 stderr 留一行 `disabling profile plugin …`。
实测(semver 7,`includePrerelease: true`):
| range | 对 `0.2.0-rc.1` |
| --- | --- |
| `^0.1.7-rc.1` | **false**(caret 上界展开为 `<0.2.0-0`) |
| `^0.2.0-rc.1` | true |
| `>=0.1.7-rc.1 <0.3.0` | true(显式比较器不带 `-0` 上界) |
| `>=0.1.1-rc.2 <0.2.0` | true(同上,`dsh-whale-report` 正因此仍能加载) |
**教训**:`AGENTS.md` §3.4 第 12 条(c) 那条「`includePrerelease` 让判定更宽松(`^0.1.6-alpha.2` 也能满足 `0.1.7-rc.1`)」**只在同一 minor 内成立**;跨 minor 时必须同步抬 peer 下限。用户已选择**单线**方案,不做「同时覆盖两条线」的放宽范围。
## 2. 适配前实测证据(决定了「零运行时改动」)
| 检查面 | 方法 | 结果 |
| --- | --- | --- |
| 值导出删除 | 对插件 import 的 19 个宿主包,提取 `lib/*.js` 里 `export {…}` / `exports.x=` 的名字集合做差 | **removed = none**;唯一新增 `ui-primitives` 的 `pointerModality` |
| 类型面变化 | `lib/types/**` 逐文件 hash + diff | 仅 3 处:`ui-primitives`(`DisclosureRow` / `TextShimmer` / `Tooltip` 新增可选 prop、overlay 顶部内距改用 frame overlay inset)、`dsh-session`(新增 `ToolCallRecovery`;`interruptedTurnClosers` 仍在)、`dsh-api-remotes`(新增 product-analytics remote 行) |
| 会话格式 | 查 `SESSION_FORMAT_VERSION` 与 `session-format-*` 迁移包清单 | 仍 **v4**,无 `session-format-v4-to-v5` |
| 子代理描述符 | `grep SUBAGENT_DESCRIPTOR_VERSION` | 仍 **3** |
| CLI 契约 | `diff dsh/lib/bin.js` | **字节相同**(`dsh plugin` / `dsh web` / 就绪行 / `--version` 全不变) |
| 客户端运行时 | `diff dsh-client-modules/lib/index.js`、客户端包 `provide(...)` 名字、web-app 的 `dsh.client.inject` | 全部相同 |
| 测试期依赖 | 对比 `ui-primitives` 产物的裸 import 集合 | 相同 → **无需新增 devDependency**(不重演 0.1.7 §3.3 第 12 条) |
| agent-team | 四个包的 `lib/types` hash | 逐字相同 |
| web profile 组成 | `web-app/cordis.patch.yml` diff | 新增 desktop-only `product-analytics` / `desktop-product-telemetry`(web 上 `disabled`);`time-context` / `schedule` / `ui-schedule`(本就 `disabled`)移入可选 `dsh-experimental-schedule-bundle`。挂载 lane 的「宿主 tab 恰 1 条(terminal)、browser 缺席」断言不受影响 |
## 3. 实施内容
1. `package.json`:14 条 DSH peer → `^0.2.0-rc.1`;27 个 `@deepseek-ai/*` devDependencies 精确钉版 → `0.2.0-rc.1`(`@deepseek-ai/cordis` 4.0.4 / `cordis-plugin-group` 1.0.4 / `cordis-plugin-include` 1.0.9 不变);`version` → `0.24.0`。
2. `dsh.plugin.json`:`engines.dsh` → `>=0.2.0-rc.1`(零读者的元数据,但保持诚实)。
3. `scripts/e2e-common.sh`:`DSH_NPX_SPEC` 与 `DSH_EXPECT_VERSION` → `0.2.0-rc.1`。
4. `.github/workflows/ci.yml`:`npm install -g @deepseek-ai/dsh@0.2.0-rc.1` + 注释补「0.2.0 首个候选版只挂在 `next` 上」。
5. `tests/market-manifest.spec.ts`:peer 形状正则钉当前基线 tuple(`/^\^0\.2\.0-[a-z0-9.]+$/`),并把「caret 跨 minor 必失效」写进注释。
6. 文档:`README.md` / `README_EN.md`(支持线横幅、shields 徽章、安装对照表新增 0.2.0 行并保留 0.1.7 行的 v0.23.0 指引、最近更新新增 v0.24.0)、`CHANGELOG.md` / `CHANGELOG_EN.md`(v0.24.0 条目)、`AGENTS.md`(§2 钉版句、§3 改为「DSH 版本适配要点」并新增 §3.0 当前基线 + 0.1.7 历史分节)、`docs/external-plugin-guide.md`(当前版本与 peer 下限)。
7. **未改任何 `src/**` 运行时代码**——证据见 §2。
## 4. 已知残留(非本插件可解)
- `@huanlin/dsh-plugin-better-locale`(**可选**集成;没有任何源码或测试 import 它,运行时靠 `ctx.get('betterLocale')` 结构化探测)的 peer 钉在 `^0.1.0-rc.8`,其最新 0.4.3 也钉 `^0.1.5-rc.2` → 在 0.2.0 上都会被宿主预检禁用。因此 `pnpm peers check` 会报它的 **5 条 unmet peer**;本插件自身的 14 条 peer 全部满足。属上游未适配 0.2.0。
- 本机 pnpm 的 registry 是 `registry.npmmirror.com`,对新预发布滞后(本次实测:镜像上 `@deepseek-ai/dsh-attachment@0.2.0-rc.1` 不存在 → `ERR_PNPM_NO_MATCHING_VERSION`)。安装用一次性覆盖:`pnpm install --registry https://registry.npmjs.org`。
- `@deepseek-ai/dsh-experimental-agent-team-web-profile` 仍无 0.1.7 / 0.2.0 发布(停在 0.1.6-alpha.2)→ 本机实例升级时该行保持旧版,运行时兼容性属**未验证**(见 §5)。
## 5. 本机 3080 实例升级(与仓库适配分开执行)
升级前的 profile 预检(18 个依赖,按宿主同一套 semver 语义逐个判定):
| 依赖 | DSH peer | 0.2.0-rc.1 |
| --- | --- | --- |
| `dsh-better-sidebar@0.23.0` | `^0.1.7-rc.1` | **会被禁用** → 本版 v0.24.0 修复 |
| `dshmarket@1.35.0` | `^0.1.0-rc.7 \|\| ^0.1.1-rc.2`(最新 1.66.3 的 `dsh-settings` peer 仍是 `^0.1.x`) | **会被禁用**(上游未适配,无法绕过) |
| `dsh-whale-report@0.5.4` | `>=0.1.1-rc.2 <0.2.0` | 可加载(显式比较器) |
| `dsh-notification@0.1.4` | `*` | 可加载 |
| `dsh-feishu-notify` / `dsh-flowglass` / `dsh-lan-access` / `dsh-apollo` / `dsh-tauri*` | 无 DSH peer | 不被预检拦(运行时未验证) |
| `@deepseek-ai/dsh-experimental-agent-team-profile` | 无 DSH peer | 升级到 0.2.0-rc.1 |
| `@deepseek-ai/dsh-experimental-agent-team-web-profile@0.1.6-alpha.2` | 无 DSH peer | 保持旧版(无 0.2.0 发布),运行时未验证 |
### 5.1 实测踩到的坑:profile 自己的 `pnpm-workspace.yaml` overrides 必须与宿主同步
升级后 3080 出现「加载提供商目录失败: settings service is absent: mount @deepseek-ai/dsh-settings with @deepseek-ai/dsh-config-editor in the profile composition」。宿主日志给出了根因链路:
```
dsh: disabling profile plugin row "config-editor": Plugin @deepseek-ai/dsh-config-editor@0.1.7-rc.2
is incompatible with dsh 0.2.0-rc.1: peerDependencies {"@deepseek-ai/dsh-hmr":"0.1.7-rc.2",
"@deepseek-ai/dsh-app-boot":"0.1.7-rc.2"}
settings (@deepseek-ai/dsh-settings): pending (waiting for service: configEditor)
```
profile 的 `pnpm-workspace.yaml` 里有一条上一轮升级留下的 `overrides: '@deepseek-ai/dsh-settings': 0.1.7-rc.2`(它自己的注释就写着「Keep this in lockstep with the host version」)。这条 pin 让 profile 树里的 `dsh-settings` 停在 0.1.7-rc.2,而 `dsh-settings` 用**精确版本**声明 `dsh-config-editor` / `dsh-util-values` → profile 顶层出现 0.1.7-rc.2 的 `dsh-config-editor`,它**遮蔽**了宿主 bundle 自带的 0.2.0-rc.1,于是 0.2.0 的启动预检把 `config-editor` 整行禁用,`settings` 行永远等不到 `configEditor`,设置/模型页的 provider 目录随之失败。
修法:把该 override 与注释一起抬到 `0.2.0-rc.1` → `pnpm install`(profile 内)→ 重启。修复后 profile 顶层全部收敛到 0.2.0-rc.1(`dsh-atomic-write` / `dsh-brand` / `dsh-config-editor` / `dsh-util-values` / agent-team 三包),`.pnpm` 里 0.1.7-rc.2 的副本归零。
**教训(对宿主版本升级通用)**:升级 DSH 时,除了 launcher 的 npx 钉版与 profile 依赖,**profile 自己的 `overrides` / `pnpm-workspace.yaml` 里任何 `@deepseek-ai/dsh-*` pin 都必须同批抬版本**;否则 profile 树里会出现旧宿主包并遮蔽 bundle 自带的那一份,而预检只报「某行被禁用」,症状(设置服务缺失)与原因(一条 override)隔着好几层。
升级步骤(含回滚)见最终交付说明:备份 `~/.dsh/dsh-web.sh` 与 `profiles/web/package.json` → launcher 的 npx 钉版 → `0.2.0-rc.1` → 升 agent-team profile → 装 v0.24.0 tarball → `pm2 restart dsh-web` → 核对路由与 `pm2 logs` 的 `disabling profile plugin` 行。
@@ -1,51 +0,0 @@
# `files` 接管 id 泄漏:客户端条目替换后报错刷屏 + 文件树空态
日期:2026-09-28 分支:`fix/native-registration-leak-v2`(PR #777,已合并)→ 后续 `fix/native-leak-guard-tests`
关联 issue:[#771](https://github.com/omdsh-dev/DSH-better-sidebar/issues/771)(同串报错的独立复现与定位)、[#770](https://github.com/omdsh-dev/DSH-better-sidebar/issues/770)(同串报错,另一条 inject re-prove 猜测)、[#766](https://github.com/omdsh-dev/DSH-better-sidebar/pull/766)(「吸收」方案,已按本文结论关闭)
## 现象
```
[dsh-better-sidebar] native register files error: sidebarRight: tab type id "dsh-better-sidebar:files" is already registered
```
红色诊断条钉在页面左下角、触发时**同一毫秒刷出多条**;不致命,但 `files`(文件树)接管会**静默缺位**——原生右侧栏的「文件」入口渲染成宿主的空态,直到**刷新页面**才恢复。0.21.1 与 0.22.0(npm `latest`)都带此缺陷。
## 根因(真机日志 + 代码双向验证)
官方桌面壳日志 `~/Library/Application Support/dsh-tauri/logs/desktop.frontdesk.log:2370`(2026-09-27 15:51:39.338)第一条就是完整栈:
```
native register files error: Error: cannot create effect on inactive context
assertActive ← effect ← inject(slots) ← registerSlots ← registerFilesKind ← sync ← notify
```
随后同一毫秒(.339–.340)**7 条** `already registered`(栈:`register ← registerFilesKind ← sync ← notify`),13 秒后新激活的 `_reload` 路径又报 2 条(该 burst 合计 9 条,与「7 个内置描述符注销 → 7 次 notify」一一对应)。链条:
1. `service.notify()` 是同步内联(`src/client/service.ts:696`),而 `registerNativeSurface` 把 `sync()` 订阅到它和 store 上(`src/client/native/index.ts:353`;本文行号按 v0.22.0,main 因 #777 的插入而顺移)。
2. `sync()` 的清理循环把 `live` 里**两类归属不同的条目**一视同仁:描述符类型(键 = descriptor id)与 `files` 接管(键 = `FILES_KIND`,**永远不在 `wanted` 里**)。于是**每次**通知都会释放接管、并在同一轮里重建它。
3. 插件 teardown 时,内置描述符逐个注销 → 每次注销 notify → `sync()` 在**已经 inactive** 的插件 ctx 上运行。此时 `tabs.register` 建在**宿主** ctx 上(宿主 ctx 仍活着)→ id 被取走且返回了 disposer;紧随的 `ctx.slots.inject` 建在**插件** ctx 上 → cordis `assertActive()` 抛 `INACTIVE_EFFECT`(`@deepseek-ai/cordis@4.0.4` `effect()` 首行)→ `live.set(FILES_KIND, …)` 永不执行 → **该 id 在本页生命周期内再也不能注册**。
4. 之后每次 notify(同一 burst 里还有 6 次)都重试同一个被占用的 id → `already registered` 刷屏;新一次激活同样注册不上 → 文件树空态。
**两条一般化的不变量**(已写进 [AGENTS.md](../../AGENTS.md) §3.4 第 9 条):
- 清理循环必须**跳过 `FILES_KIND`**;接管只由编辑器类型的 `wantsFiles/hasFiles` 分支与 seat disposer 管理。
- 「注册成功一半」是**可达状态**(宿主 ctx 与插件 ctx 的寿命不同),因此任何在宿主取走 id **之后**失败的注册必须**回滚释放该 type**(并释放已建槽位),插件侧只留一个下次通知可重试的失败。
## 修复
PR #777(贡献者 @yanzhaohui1999)落地上面两条:`sync()` 的清理循环跳过 `FILES_KIND`;`registerSlots` 失败时释放已建槽位;`registerDescriptor` / `registerFilesKind` 在槽位注册失败后回滚已占用的 type;teardown / 清理循环逐个安全释放(一个释放抛错不再中断其余)。
**否决备选方案**(PR #766 的「吸收」):把宿主抛出的 `already registered` 当作「宿主已持有该行」继续注册槽位。它能让日志安静,但会在 `live` 里记下一份**不拥有**的注册(空 disposer)——旧持有者随后释放该 id 时无人补注册,`files` 接管可能永久缺位;而且在 `tabs.register` 抛错(id 守卫,原子,未取走任何东西)与「取走后才失败」两种情形之间不做区分,掩盖了真正的账本错误。
## 验证
- **单测红→绿(实测)**:`tests/native-registration.spec.ts` 在 v0.22.0 源码上 4/4 红,在修复分支上 4/4 绿;`pnpm typecheck` / `pnpm lint` / `pnpm test` 全绿(修复分支的 base 早于 #781,故为 114 files / 1136 passed;合入 main 后为 122 files / 1293 passed / 9 skipped)。
- **部署级红→绿(实测)**:`tests/e2e/native-reload.e2e.ts` 在 scratch profile 里对 npm 上的 `dsh-better-sidebar@0.22.0` 触发页面内条目替换(`utimesSync` 已安装的 `lib/client.js`),连续 3 次运行**全红**且每次都是本文那两条栈;换成本地打包的修复版连续 3 次**全绿**(该用例只有 1 个 test,重复跑三次)。该用例已随本 PR 进入挂载 lane(CI `plugin-mount` 绿),触发方式依赖钉住的宿主 0.1.7-rc.1 的 rev 规则(`mtime/ctime/size` 的 sha1)——pin 上调时需同步复核。
- 首版用例的两处断言(「失败后还能重新注册」)写在**活着的 id 集合**上,在未修复代码上会**同样通过**(泄漏的 id 恰好构成同一个集合)。跟进 PR 把它们改写到注册表的**事件日志**上,并把槽位失败判据从 `key` 改成 `name::key` —— 两个槽位共用一个 `key`,按 `key` 失败时**第一个**槽就抛,回收循环虽然进了、但手里的 disposer 列表永远是空的(即 `registerSlots` 的部分回滚**释放动作**从未被执行);改后可以让某个描述符的**第二个**槽失败,回收列表非空、释放路径真正被覆盖,且 4 例在 v0.22.0 上仍全部红。
## 已知限制 / 未做
- **跨激活的瞬时重复**仍未消除:页面内条目替换时,新激活可能先于旧激活释放 id 而撞上 `already registered`(一次日志噪音,随后自愈)。要消掉它需要「吸收 + 旧持有者释放后补注册」的机制,与上面否决的方案同源,暂不做。
- `disposeSafely` 的释放失败只写 `console.error`、**不弹诊断条**(teardown 阶段弹条是噪音;真被占住的 id 会在下一次注册尝试里经 `reportFailure` 显式报出)。理由写在源码注释里。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

-185
View File
@@ -1,185 +0,0 @@
# 01 · 需求分析:官方原生右列 vs 装了 apes-sidebar
> 本篇只回答一个问题:**一个在 DSH 里干活的人,从"官方原生"换到"装了这个包",他的需求和处境发生了什么变化?**
> 对比的两端只有两个:**官方原生的右侧栏**,和**装了 apes-sidebar 之后的右侧栏**(那个包是一个整体,九个 tab 一起算)。
> 机制与根因只在[附录 A](#附录-a--证据与实现索引)留索引,细节见 [02-系统架构](02-系统架构)、[06-故障排查](06-故障排查)。
## 一、先钉住"谁在用、他在哪儿干活"
在 DSH 里,**右侧栏是使用者一天里盯得最久的一块地方**。它不是"偶尔打开的设置页",而是"工作台 + 仪表盘"。
一个典型使用者(在 DSH 里做项目的人):
- 中间是对话主线——他和模型一起干活;
- 右边是这块地方——看文件、看改动、看子代理、顺手追问、跑命令、看预览;
- 他往往同时开着好几个会话、好几个项目。
这决定了本页所有需求的共同特征:**这些信息"看一眼就够,但要频繁看"。**
频繁看的东西必须常驻在视野里——不能要求他"跳出去看一眼,再跳回来"。
## 二、官方原生的右列:一个「只读的文件浏览器 + 预览器」
本机宿主(`dsh@0.1.5-rc.2`)里,官方真正放进右列的东西只有三件:
| 官方包 | 它给用户什么 | 官方原话 |
| --- | --- | --- |
| `dsh-client-ui-sidebar-right` | **容器**:每会话一块可停靠面板、两种形态(贴靠 / 全屏)、展开按钮在会话 header 角落、最多左右两格、拖拽与浮窗、tab 菜单、内置撤销历史 | "为每个会话持有一个停靠面……拥有导航控制器(`ctx.sidebarRight`)、tab 类型注册表" |
| `dsh-client-ui-sidebar-files` | **唯一的页**:「文件」——把会话工作区根目录画成一棵**只读**的树 | "把会话的工作区根目录画成一棵树,逐层经线上列出,并把文件打开到 Sidebar 里" |
| `dsh-client-ui-sidebar-documentpreview` | **唯一的查看器**(kind = `text`):预览 Markdown / 代码 / 图片 / PDF / HTML,认不出的扩展名走**纯文本兜底** | "在右侧 Sidebar 预览**可读文件**,无需另开 tab 即可切换已注册的渲染器" |
一个容易被忽略、但对"用户体验"影响很大的细节:
> 引导页(罗盘)**只在注册了两个以上入口时才出现**——官方随包只有一个入口(Files),
> 所以官方用户的右列**默认直接就是那棵文件树,罗盘从不露面**。
> (原文:"恰好一个入口时直接打开对应页面(随包组合中为 Files)"。)
**一句话概括官方原生的取向:它假设你只是想「看」。**
它给你树和预览,都是只读的;**它不假设你要在这块地方「干活」**。
于是,官方原生用户在右列里**做不到**这些事(每一条都是他能直接感知的缺失):
| 他想做的事 | 官方原生右列 | 他只能怎么办 |
| --- | --- | --- |
| 改一个文件 | ❌ 只有预览,没有可写的编辑器 | 跳出去,用外部编辑器打开 |
| 知道刚才改了哪些东西 | ❌ 没有变更清单、没有 diff | 切终端敲 `git status` / `git diff`,或另开 diff 工具 |
| 看某个子代理干了什么 | ❌ 右列里没有这个入口 | 翻会话树、翻聊天记录去认人 |
| 追问一句又不想弄脏主线 | ❌ 没有旁路对话 | 硬着头皮在主对话里问,或另开一个窗口 |
| 跑一条命令 | ❌ 没有终端 | 切到系统终端,还得先 `cd` 到对的目录 |
| 看页面 / HTML 出来什么样 | ❌ 没有内嵌浏览器 | 切浏览器标签,或手动打开产物文件 |
| 知道谁在替我干活、跑到哪了 | ❌ 完全没有 | 只能往上翻记录,翻到一半又忘了上下文 |
| 知道这摊活是在哪儿干的 | ❌ 界面不写 | 靠脑子记,或敲 `pwd` 确认 |
| 知道这个会话现在什么状态 | ❌ 界面不写 | 靠界面上的小细节猜 |
## 三、装了 apes-sidebar 之后:同一块地方,从「看」变成「干 + 心里有数」
apes-sidebar 把这九件事一次性放进右列。它们**是一个整体需求**:把右列从"文件浏览器"变成"工作台 + 仪表盘"。
| tab | 他真正想要的是什么 | 官方原生时他被迫怎么做 | 装了之后 | 需求被改变成 |
| --- | --- | --- | --- | --- |
| 文件(编辑器) | 看**和改**代码,而不丢掉当前上下文 | ⌘Tab 跳到外部编辑器;回来要重新找位置 | 右列里直接看、直接改 | 「看文件不该让我离开现场」 |
| 文件变动(git) | 刚才到底改了哪些东西 | 切终端敲命令,或另开 diff 工具 | 变更清单 + 单文件 diff,就在旁边 | 「改了什么必须当场看得见」——**这是信任的前提** |
| 子代理 | 进某个子代理,看它到底干了什么 | 翻会话树、翻记录去认人 | 右列里直接进入该子代理 | 「子代理是可进入的,不是只能干等的」 |
| 侧边对话 | 追问一句,但不想污染主线 | 硬着头皮在主对话里问,或另开窗口 | 旁路开一个小对话,两边互不干扰 | 「提问不该有副作用」——**性质变了** |
| 终端 | 跑一条命令 | 切到系统终端,常常还得先 `cd` | 右列里跑,工作目录天然就是对的 | 「命令的上下文应该跟着会话走」 |
| 浏览器 / 预览 | 看这个页面出来是什么样 | 切浏览器标签,或手动打开产物 | 右列内嵌,当场看 | 「产出物要能当场验收」 |
| **智能体** | 谁在替我干活、跑到哪了、哪个停了 | **基本没办法**——只能翻记录猜 | 主代理在最上,直系子代理一人一行,带运行/结束状态 | 「派了活之后,我要的是**扫一眼**,不是盯着守」(见 §四·洞察 4) |
| **应用** | 这摊活到底是在哪儿干的 | **基本没办法**——靠脑子记或敲 `pwd` | 打开就看到当前会话锚定的工作区 | 「界面应该告诉我我在哪,而不是要我自己记住」 |
| **画像** | 这个会话现在是什么状态 | 靠界面小细节猜(在跑?停了?哪一次?) | 标题 / 运行或空闲 / 工作区,写清楚 | 「离席回来,不用重建心智模型」 |
连**顺序**都在讲同一件事:`10…50` 是操作面(看/改/查/问/跑/验),`60…70` 是态势面(谁在干、在哪儿干、什么状态)。
先让"活能在这儿干完",再让"我心里有数"——这是这个包对用户的两级承诺。
## 四、深度分析:需求到底发生了什么变化
### 洞察 0 · 官方原生假设"你只是看",而真实使用是"在这儿干活"
这是整个需求空间的起点。官方原生不是做得不好,而是**取向不同**:只读预览是"查看器"的思路,
而使用者把它当"工位"用——他会在这里改一个文件、跑一条命令、瞄一眼改动。
**当一块地方被当成工位,只读就会立刻变成绕路。** 这就是这个包存在的全部理由。
### 洞察 1 · 从「只读」到「可写」:补的不是功能,是主权的归还
官方只给预览,意味着**每一个"改一下"都要离开这块地方**。装上之后,改、查改动、跑、验四件事都在原地完成。
用户省下的不是几次点击,而是**每次离开都要付出的"上下文重建"成本**——回来要重新找到刚才的位置、重新想起来自己在干嘛。
需求因此从「我要能看」变成「我要在这儿把活干完」。
### 洞察 2 · 「留在原地」会变成默认预期(棘轮效应)
一旦右列里能改文件、能跑命令、能看预览,他对"跳出去一下"的容忍度会**永久下降**。
他会开始说「为什么这个还要我切窗口?」——而这句话在他用官方原生时是说不出口的(因为原生本来就没有可切的东西)。
**这类需求只前进、不后退**,所以值得一次做对:它一旦被填好,就变成基础设施。
### 洞察 3 · 消除副作用之后,他会开始"问"(侧边对话)
侧边对话的价值不在"多一个对话框",而在**把提问的代价从"可能污染主线"降到"零"**。
需求的性质变了:以前他的问题是「要不要问?」(一个决策),现在变成「顺手问一下」(一个动作)。
**当一件事的代价趋近于零,用户的行为频率会跳一个数量级**——这是需求分析里最值得警惕、也最值得利用的一条规律。
### 洞察 4 · 看得见 → 敢放手(智能体 tab 的核心价值)
这是整个包里最值钱的一条。官方原生在这一点上是**彻底空白**:
- 官方没有子代理视图,也没有任何"队伍"概念 → 他派了活之后**只能等**,或者不停往上翻记录确认;
- 这个包的「子代理」让他能**进入**某一个;「智能体」让他**不必进去就看得到全局**——主代理 + 直系子代理,谁在跑、谁停了。
用户真正的痛点不是"我进不去子代理",而是**"我不敢派太多活"**:
因为看不见,他只能用"盯着"来对冲风险,而盯着就失去了并行的意义。
> **看得见 → 敢放手 → 真的并行 → 才配得上"多智能体"。**
> 需求从「我要能看见」升级为「我要能放心」。
### 洞察 5 · 心智模型从脑子里搬到界面上(应用 / 画像)
多项目、多会话的人切换时要重建一次"我这是在哪儿"——标题差不多的会话最容易看错,
而看错的代价是**写错文件**。官方原生在右列里一个字都不写这件事。
「应用」「画像」做的就是**把这份"我记得"外化成"界面写着"**。
需求的性质:从「我要记性好」变成「界面不该让我记」。
### 洞察 6 · "引导页"第一次有了意义:从单一工具到工作台
官方原生只有一个入口(Files),所以它**默认直接开文件树,罗盘界面根本不会出现**。
入口一多,罗盘才登场——用户第一次能"一屏看见这块地方能干什么"。
这是**用户层面的分水岭**:官方是"一个工具",装了之后是"一个工作台的目录页"。
(对照 §二那个细节:入口数决定了这块地方的形态。)
### 洞察 7 · 价值随"并行度"放大;而成本几乎为零
- 单会话、单项目的用户:「应用」「画像」几乎用不上,「智能体」也只有一个主代理;
- 一旦同时开几个会话、派几个子代理、换几个项目:这三个 tab 的边际价值**陡增**。
所以这套东西**不是人人需要,但接到的人离不开**。
同时它**不新增数据源、不新增进程、不引入新的失败面**——只是把宿主已经投影出来的信息换个视角呈现
(逐条列在 [03-功能说明](03-功能说明))。对使用者意味着:**低风险地换到确定性**,这是它最该守住的边界。
## 五、由以上分析推出的需求(U1–U6)
| # | 需求(用户口吻) | 怎么算他满意了 | 现状 |
| --- | --- | --- | --- |
| U1 | 「我要在这儿把活干完,别老让我跳出去」 | 改文件、查改动、跑命令、看预览、顺手追问,都在右列完成 | ✅ 操作面六个 tab |
| U2 | 「我要看得见谁在替我干活」 | 右列点一下就有队伍表;人数/状态一眼可辨;没有子代理时明确说"没有",不是空白 | ✅ 智能体 tab |
| U3 | 「我要知道我是在哪儿干的活、这会话什么状态」 | 打开就看到工作区与运行/空闲,不用敲 `pwd`;拿不到应用信息时如实说明 | ◑ 工作区/状态 ✅ · 应用层等宿主 |
| U4 | 「我点开 HTML 就该看得见」 | 自包含 HTML 完整显示,而不是一行 `forbidden`;**但不许为此拆掉沙箱** | ✅(多文件需我本人点「临时解锁」) |
| U5 | 「我的界面不许自己乱套」 | 右列只有一套侧边栏、一个开合入口;弹窗完整盖在面板之上、按钮点得到 | ✅ |
| U6 | 「我敢升级,也退得回去」 | 有"宿主版本 ↔ 插件版本"对照表;每步升级都有分支/tag 可回滚 | ✅ 两条版本线;新宿主线待实机验证 |
## 六、我们不做的事(省得期待错位)
- **不重造官方已有的东西。** 官方给了容器与文件树,我们在它里面长东西,不另起一套侧边栏。
- **不为"看得见"放宽安全边界。** 需要放宽必须由用户**本人显式点**「临时解锁」。
- **不做团队/房间/多智能体协作编排。** 那是宿主的另一类职责。
- **不承诺"装上立刻见效"。** 单会话用户可能几乎感觉不到差异(见洞察 7)。
## 七、已知的「暂时满足不了」(诚实交代)
| 他想要的 | 现状 | 为什么(一句话) | 何时会有 |
| --- | --- | --- | --- |
| 知道这摊活属于哪个**应用**、有哪些入口 | 「应用」只显示工作区 | 宿主目前没有向插件提供应用清单的通道 | 宿主提供 feed 后即可接上 |
| 一次摊开**完整**子代理树(子代理的子代理) | 只显示**直系** | 宿主投影只给一层 | 同上 |
| 沙箱状态下多文件 HTML 的相对资源 | 需点「临时解锁」 | 不透明源沙箱下子请求拿不到宿主信任头(安全边界的必然结果) | 不打算改——这是刻意保留的边界 |
> 这三条说明同一件事:**需求跑在宿主能力前面**。写出来不是免责,是让读的人知道
> "不能用"和"还没做"是两回事。
## 附录 A · 证据与实现索引
开发与排障看这里;其余读者可跳过。
**"官方原生到底有什么"的证据**(本机宿主 `dsh@0.1.5-rc.2`,官方包自带文档):
- 容器:`@deepseek-ai/dsh-client-ui-sidebar-right/README.zh.md` —— 停靠面、两种形态、扩展席位、引导页。
- 唯一的页:`@deepseek-ai/dsh-client-ui-sidebar-files/README.zh.md` —— "文件树 tab 类型……把文件打开到 Sidebar 里"。
- 唯一的查看器:`@deepseek-ai/dsh-client-ui-sidebar-documentpreview/README.zh.md` —— "Sidebar tab 的 kind 为 `text`",未知扩展名纯文本兜底。
- "官方只有一个入口 ⇒ 罗盘不出现":同上 sidebar-right 文档「引导页」一节。
**本包一侧的对应实现**(`editor`/`git`/`subagent`/`sidechat`/`terminal`/`browser` 为随包操作面;
`agents`/`apps`/`profile` 是本 fork 自增的态势面——对内维护用,对外它是一个包):
| 需求 | 机制(去哪看) | 关键证据 |
| --- | --- | --- |
| U5-a 右列只有一套侧边栏 | 每个 tab 注册成原生 tab 类型(`kind = descriptor.id`)渲染进官方右列;自绘面板已移除 | [02-系统架构](02-系统架构)、[05-运维手册](05-运维手册) |
| U5-b 弹窗不被面板切 | 宿主容器上的层叠上下文(`isolation`)导致;挂载点改挂 `#root` 而不再挂 `document.body` | [06-故障排查](06-故障排查) |
| U4 HTML 预览 | 主框架 `fetch` → `srcDoc`,**`sandbox` 属性未动**;失败回退路由 URL | [06-故障排查](06-故障排查) |
| 与官方 `text` 预览的关系 | 本包的编辑器类型以更高优先级接管 `dsh-resource://file/**`,卸载即恢复官方预览 | `docs/external-plugin-guide.md` §「内置类型接管」 |
| U6 版本线 | 宿主版本不匹配时宿主**静默禁用**整行(不报错、无日志);`^` 跨 minor 判 false | [04-部署指南](04-部署指南)、[06-故障排查](06-故障排查) |
<!-- 本页只讲"为什么需要"。实现细节、提交号、测试名一律留在 02–07 与附录索引里。 -->
-154
View File
@@ -1,154 +0,0 @@
# 02 · 系统架构
> 本篇说明插件的**分层、接口面、层叠契约与关键不变量**。
> 与源码一一对应,可直接当作改动前的「雷区地图」使用。
## 一、总体分层
```
┌──────────────────────────────────────────────────────────────────────────┐
│ DSH 宿主(Desktop Electron 壳 / Web profile) │
│ ├─ 原生右侧栏 ctx.sidebarRight / sidebarRightTabs(tab 类型注册表) │
│ ├─ 会话与任务 ctx.sessions.list(会话摘要、子代理、投影)、ctx.tools │
│ ├─ webServer 路由挂载点(插件用 /sidebar/* 前缀注册) │
│ └─ 桌面壳层 webserver 外层的请求围栏 + #root 的 content-viewport 标记 │
└───────────────┬──────────────────────────────────────────────────────────┘
│ ① 宿主 → 插件:cordis 服务注入(inject)
│ ② 插件 → 宿主:原生 tab / 资源打开 / 路由
┌───────────────┴──────────────────────────────────────────────────────────┐
│ 插件 HOST 半(Node,lib/index.js) │
│ · src/index.ts 路由注册(/sidebar/*)、信任围栏、会话/文件数据、HTML 交付 │
│ · src/trust-fence.ts 请求信任围栏(Host 头 / 令牌) │
│ · src/invariant.ts 依赖零的断言与 semver 工具(host/client 共用) │
└───────────────┬──────────────────────────────────────────────────────────┘
│ 浏览器侧模块(__ModuleLoader__ bundle,非 CJS)
┌───────────────┴──────────────────────────────────────────────────────────┐
│ 插件 CLIENT 半(浏览器,lib/client.js + 懒加载 chunk) │
│ · src/client/index.tsx 应用入口:装配 store/service/原生适配/信息层 │
│ · src/client/native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题) │
│ · src/client/builtins/ 内置面板:文件 / 文件变动 / 子代理 / 侧边对话 / │
│ 终端 / 浏览器 + 文件预览器 │
│ · src/client/sidebar/ 自绘底部工作台与开合控件(原生侧边栏之外的部分) │
│ · src/client/apes/ 信息层三 tab(本 fork 的增量) │
│ · src/client/service.ts ctx.betterSidebar 服务(供其他插件注册) │
└──────────────────────────────────────────────────────────────────────────┘
```
## 二、构建产物
| 产物 | 角色 | 备注 |
| --- | --- | --- |
| `lib/index.js` | host 半(ESM,Node) | 路由、围栏、数据面 |
| `lib/invariant.js` | 不变量面(零依赖) | host/client 都要 import,**不得**引入 Node 内置或宿主包 |
| `lib/client.js` | client 主 bundle | 浏览器 `window.__ModuleLoader__.load({...})` 形态,**不是** CJS/ESM,Node 里 `require` 会因 `window is not defined` 抛错 |
| `lib/client-registry.js` | 懒加载 chunk | 高开销能力(图标/预览库)按需加载 |
| `lib/client-editor.js` | 懒加载 chunk | 编辑器(CodeMirror 系) |
| `lib/client-locale.js` | 懒加载 chunk | 语言包 |
| `lib/client-mermaid.js` | 懒加载 chunk | Mermaid 渲染 |
| `lib/client-terminal.js` | 懒加载 chunk | 终端 |
| `lib/types/**/*.d.ts` | 类型声明 | 供消费方类型检查 |
> 懒加载动机:主 bundle 不应静态 import 重型预览库;chunk 通过宿主 `ctx.modules` 解析外部依赖
> (入口在 `src/client/index.tsx` 里先 `setChunkModuleSystem(ctx.modules)`)。
## 三、三层接口面
### 3.1 宿主 → 插件(cordis 注入)
```ts
// src/index.ts
export const name = 'dsh-apes-sidebar'
export const inject = ['webServer', 'sessions', 'webRuntime', 'tools']
export function apply(ctx: Context, config?: SidebarConfig): void
```
客户端侧读取的宿主面(结构化镜像,运行时由宿主提供):
`ctx.sessions.list`(`getSnapshot/subscribe`)、`ctx.sidebarRight`(原生右列与 `openResource`)、
`ctx.slots`、`ctx.settings`(挂载行 `config`)、`ctx.modules`(懒加载 chunk 的模块系统)。
### 3.2 插件 → 宿主
| 方向 | 手段 |
| --- | --- |
| 注册 tab 类型与 tab 体 | `src/client/native/registerNativeSurface()` → 原生右侧栏注册表 |
| 打开会话内文件 | `ctx.sidebarRight.openResource('dsh-resource://file/…')` |
| 暴露 HTTP 能力 | `/sidebar/` 前缀路由:`api` · `file` · `html` · `upload` · `ws/terminal` · `ws/agent-opens` · `ws/agent-terminals` |
### 3.3 插件 → 其他插件(对外服务)
`ctx.betterSidebar`(`src/client/service.ts`)对外开放 `registerTab` / `registerFileViewer` 等,
并按「能力清单」做前向兼容门控(能力只增不减)。对外契约见仓库内
[docs/external-plugin-guide.md](../blob/main/docs/external-plugin-guide.md)。
服务版本常量 `SIDEBAR_SERVICE_VERSION` 必须与 `package.json` / `dsh.plugin.json` 的版本三方一致
(有单元测试断言这一对)。
## 四、面板宿主与层叠契约(重点雷区)
插件的所有面板渲染在一个 **body 级(或壳层 viewport 级)的宿主层**里:
```css
[data-dsh-panel-host] { position: fixed; inset: 0; z-index: 25; pointer-events: none }
```
`z-index: 25` 不是随手取的,它要同时满足:**在 app 内容与壳层 overlay 之上、在宿主交给插件用的
overlay 槽之下**。完整取义:
| 层级 | 对象 |
| --- | --- |
| `z-index: auto` | app 的流内内容、`.dshDesktopFrame` |
| **25** | **本插件的面板宿主** |
| 30 | 宿主的动态插件面板(ui-cordis) |
| 100+ | app 的 overlay 栈(Toast / onBoarding 等) |
| 1000 | `.dshDesktopOverlay[data-shell-overlay]`(宿主给 `shell.overlay` 槽的容器,第三方弹窗在此) |
**陷阱(已修,`4422001`)**:DSH 桌面壳的有框模式会给上游 `#root` 打标
`data-dsh-desktop-content-viewport` 并施加 `isolation: isolate`,使 `#root` 成为一个**层叠上下文**。
宿主自己的 overlay 层就在这个上下文内;若插件宿主挂在 `document.body`(上下文之外),
它是「带正 z-index 的定位元素」,会画在整个上下文**之上**——把宿主 overlay 里的弹窗(如社区插件市场)盖住。
```ts
// src/client/desktop-env.ts —— 修法:挂进壳层的 content viewport
export function panelHostParent(doc: Document = document): HTMLElement {
return doc.querySelector<HTMLElement>('#root[data-dsh-desktop-content-viewport]') ?? doc.body
}
```
进入该上下文后,25 恢复原意(在 app frame 之上、在 overlay 层 1000 之下);无该标记的部署
(纯网页 / 未打标的壳)仍挂 `document.body`,行为不变。重新锚定守卫同时监听
`document.body` 的 `data-dsh-desktop-mode`(壳层打标与它是同一个 effect),因此壳层在插件挂载**之后**
才框住 `#root` 的情况也能被搬到正确位置。
## 五、数据流示例
**会话 → 信息层**
```
ctx.sessions.list.getSnapshot() → { byId, projectionsBySession? }
useSyncExternalStore(subscribe) (uSES 安全订阅)
├─ apes:agents → rootAncestor(byId, scope.sessionId) 找主代理
│ → projectionsBySession[rootId].values.subagentCatalog 取直系子代理
│ (无投影时回退 byId 里的 durable 子会话)
└─ apes:profile / apes:apps → byId[scope.sessionId](标题 / cwd / running)
```
**文件 → 预览**(HTML 为例)
```
编辑器/diff 面板 → /sidebar/html/<session>/<path> (路由负责路径围栏与体积上限)
主框架 fetch 取回文档文本 → 注入 <base href="<该文件的路由目录>">
→ iframe srcDoc + sandbox(不透明源)
取失败时回退为 iframe src=路由 URL,把路由的答复原样透出
```
## 六、关键不变量(改动前必读)
1. **host / client 拆分是结构性的**:client bundle 在浏览器运行,不得 import Node 内置;
`src/invariant.ts` 两侧共用,必须保持零依赖。
2. **`dsh-better-sidebar` 是 peer,永不打包**:一旦被内联,client 会带上第二份侧边栏注册表,
tab 注册进死实例,界面上永不出现。
3. **tab id 前缀归本 fork**:信息层用 `apes:*`;不得占用上游/宿主内部 id。
4. **HTML 预览的沙箱令牌不得放宽**(不加 `allow-same-origin` 等);交付方式只能是「主框架取文档 + `srcDoc`」。
5. **颜色只用宿主设计令牌**(`--dsw-alias-*`),不写硬编码色值;几何量(字号/间距/圆角)无公开令牌,内联即可。
6. **所有面向用户的字符串走 i18n**,`zh` / `en` 必须同时存在。
7. **变更后必须重新同步到 profile**:构建产物非字节确定性,且 profile 的 `file:` 依赖是复制语义,
不能假定旧副本可用(见 [04-部署指南](04-部署指南))。
-109
View File
@@ -1,109 +0,0 @@
# 03 · 功能说明
> 本篇是**功能清单与行为契约**:有哪些面板、数据从哪来、文案与配色怎么走、安全边界在哪、能配什么。
## 一、面板与标签页总表
「来源」列:**上游** = 来自 `dsh-better-sidebar`;**宿主** = DSH 自身提供并占用该槽位;**本 fork** = 本项目的增量。
| id | 名称 | order | 来源 | 数据 / 行为 |
| --- | --- | --- | --- | --- |
| `editor` | 文件 | 10 | 上游 | 文件树 + 编辑器(CodeMirror 系,懒加载 `client-editor`) |
| `git` | 文件变动 | 20 | 上游 | 工作区变更、单个 diff 视图 |
| `subagent` | 子代理 | 30 | 上游 | 会话的子代理视图 |
| `sidechat` | 侧边对话 | 35 | 上游 | 会话内旁路对话 |
| `terminal` | 终端 | 40 | 上游 | PTY 终端(懒加载 `client-terminal`;v0.24.1 线起由宿主内置终端承担) |
| `browser` | 浏览器 | 50 | 上游 | 内嵌浏览器/HTML 预览宿主 |
| `diff` | (内部) | -1 | 上游 | 非用户可见,供内部打开 diff |
| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑 |
| **`apes:apps`** | **应用** | **65** | **本 fork** | 当前会话所属工作区(宿主暂无「应用」feed) |
| **`apes:profile`** | **画像** | **70** | **本 fork** | 当前会话摘要(标题 / cwd / 运行态) |
> order 10–50 是上游/宿主既定带宽,apos 信息层取 60/65/70 留出余量,避免与原生面板抢占位置。
## 二、信息层三 tab(本 fork 的增量)
三个 tab 全部是**单实例**(`single: true`),且都按 DSH 标准 `TabDescriptor` 注册,
因此自动获得原生侧边栏的全部待遇:进入原生 guide、tab 芯片带图标、可开合与分栏。
| tab | 展示内容 | 数据来源 | 空态 |
| --- | --- | --- | --- |
| `apes:agents` | 根(主代理)卡片 + 其直系子代理卡片;每行显示标签、`one-shot`/`continuable`、运行态圆点 | ① `ctx.sessions.list` 的 `byId`(durable 会话摘要);② 宿主的会话投影 `projectionsBySession[sessionId].values.subagentCatalog`(直系子代理清单) | 「当前会话没有子代理」提示 |
| `apes:apps` | 当前会话的工作区(cwd) | `byId[scope.sessionId].cwd`,回退 `scope.cwd` | 明确写出「宿主今日无应用 feed」 |
| `apes:profile` | 会话标题、运行/空闲、cwd | `byId[scope.sessionId]` | 「无 cwd」提示 |
**实现要点**:两个来源被归一化成同一种行形状 `{ id, label, mode }`,**活动态统一从 `byId` 读**,
于是「投影已就绪」与「仅有 durable 摘要」两种情况下渲染一致;这也是上游 0.1.6→0.1.7 会话 feed
迁移(`subagentsByParent` → `projectionsBySession`、`list.current` 删除)后的落地方式。
## 三、文件预览器
| id | 适用 | 说明 |
| --- | --- | --- |
| `image` | 图片 | 走媒体路由,受 `mediaLimit` 限制 |
| `pdf` | PDF | 内嵌查看 |
| `markdown` | Markdown | 渲染(Mermaid 走懒加载 chunk,可手动刷新) |
| `html` | HTML | **不透明源沙箱 iframe**,见下节 |
| `code` | 代码 | 高亮查看(编辑器懒加载) |
| `binary-download` | 二进制 | 只提供下载 |
### 3.1 HTML 预览的交付方式(重要)
```
路由 /sidebar/html/<session>/<path> ← 路径围栏 + 体积上限(readLimit)
│
├─ 主框架 fetch(唯一同时被宿主围栏信任、又能带上 Electron 头的角色)
│
├─ 注入 <base href="<该文件的路由目录>"> ← 让 ./style.css 等相对引用解析回本文件所在目录
│
└─ iframe srcDoc=<文档> + sandbox=HTML_IFRAME_SANDBOX
```
| 状态 | 自包含 HTML | 多文件 HTML(外链 CSS/JS/图片) |
| --- | --- | --- |
| 沙箱态(默认) | ✅ 完整可用 | 正文可用,**相对资源不可用**(iframe 源为 `null`,子请求同样被宿主围栏拒绝) |
| 临时解锁(无 sandbox 属性) | ✅ | ✅ **相对资源可用**(iframe 继承 GUI 源,成为普通同源请求) |
设计取舍:**沙箱令牌一个字符都不放宽**。多文件 HTML 想要完整效果需用户显式点「临时解锁」——
这是把「放宽边界」的决定权留给用户的显式动作,而不是插件默认降级。
## 四、安全边界(三层)
| 层 | 机制 | 说明 |
| --- | --- | --- |
| 预览内容 | `sandbox="allow-scripts allow-popups allow-downloads allow-modals"` | **不含 `allow-same-origin`**:预览文档运行在不透明源里。宿主路由另有 CSP `sandbox` 兜底 |
| 插件路由 | 请求信任围栏(`src/trust-fence.ts`) | 校验 Host 头/令牌,拒绝跨站或非授权来源对 `/sidebar/*` 的访问 |
| 桌面壳 | Electron 注入的 `x-dsh-desktop-renderer` 头 | 扩展模式下所有路由的前置围栏;只发给主框架与同源子框架。这正是「沙箱 iframe 发不出请求」的根源 |
## 五、i18n 与主题
- **语言包**:`src/client/locales.ts` + 19 个 `src/client/locales-*.ts` 覆盖文件;默认 `zh`,缺省回退 `en`。
- **本 fork 文案**:集中在 `src/client/apes/locales.ts`,每个 key 同时提供 `zh` / `en`。
- **硬性约定**:界面里**不得出现裸字符串**,一律 `t('key')`。
- **配色**:只用宿主设计令牌 `--dsw-alias-*`(bg / border / label / state / interactive 系列),
写法为 `var(--dsw-alias-xxx, <字面回退>)`,保证在非 DSH 环境(单元测试)也能渲染。
**无公开的 `--ds-*` 几何令牌**,字号/间距/圆角内联即可。
## 六、配置面(挂载行 `config`)
配置写在插件挂载行上(profile 的 `cordis.patch.yml` 或 bundle patch 的 insert 行),设置页写的也是它:
| 字段 | 默认 | 含义 |
| --- | --- | --- |
| `readLimit` | `512 KiB` | 单文件读取上限(超出截断并标记 `truncated`) |
| `mediaLimit` | `20 MiB` | 媒体(图片等)读取上限 |
| `uploadLimit` | `128 MiB` | 上传上限 |
| `listLimit` | `1000` | 目录列举条目上限 |
| `terminalsPerSession` | `3` | 每会话终端数上限 |
| `shell` / `shellArgs` | 自动解析 | 终端 shell 与其启动参数(`shellArgs` 非空时替换默认 `-l`) |
例:在挂载行里指定 shell
```yaml
- insert:
- id: better-sidebar
name: 'dsh-apes-sidebar'
config:
shell: /bin/zsh
shellArgs: ['--noprofile', '--no-rc']
```
-163
View File
@@ -1,163 +0,0 @@
# 04 · 部署指南
> 目标:在一台机器上把本插件挂进 DSH,并确认它**真的在跑**。
> 关键前提:**先确认宿主版本,再选版本线**——选错线的唯一症状是「静默失效」。
## 一、前置条件
| 项 | 要求 |
| --- | --- |
| Node | ≥ 20(`engines.node`);本机验证用 22.x |
| pnpm | 11.x(`pnpm install` 会自动触发 `prepare: tsdown` 构建) |
| DSH | 见下面的版本矩阵 |
| 终端可用性 | 仅 v0.19.1 线需要额外放行 `node-pty` 原生构建(见 §五) |
### 1.1 宿主版本矩阵(先读这张表)
| 版本线 | 分支 | 插件版本 | peer 声明 | 适用宿主 |
| --- | --- | --- | --- | --- |
| 旧宿主线 | `main` | 1.2.0 | `^0.1.5-rc.1` | DSH `0.1.5-rc.1+`(含 `0.1.5-rc.2`,即 **DSH Desktop 2.0.10** 内置版本) |
| 新宿主线 | `sync/v0.24.1` | 1.3.0 | `^0.2.0-rc.1` | DSH `0.2.0-rc.1+` |
**怎么查宿主版本**(扩展模式桌面版):
```bash
# 内置的 dsh 版本
grep -m1 '"version"' "/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh/package.json"
# 或看 profile 里装的宿主包
grep -m1 -A2 '"@deepseek-ai/dsh"' ~/.dsh/profiles/desktop/package.json
```
**选线决策**
```
宿主 ≥ 0.2.0-rc.1 ? ── 是 ──▶ sync/v0.24.1(1.3.0)
└─ 否 ──▶ main(1.2.0)
```
> ⚠️ **peer 不匹配不会报错。** 宿主以 `semver.satisfies(host, peerRange, { includePrerelease: true })` 预检,
> caret 跨 minor 判 false 后**整行静默禁用**:不加载、无日志、界面上「像没装」。
> 因此「装完没反应」的第一件事永远是核对宿主版本 ↔ peer 范围。
## 二、安装方式(本 fork 未发布 npm 包)
`npm view dsh-apes-sidebar` 返回 404 —— **本 fork 不在 npm 上**(README 里的 npm 徽章/命令是沿用上游文档)。
实际部署使用「本地源码 + `file:` 链接依赖」:
### 步骤 1:构建
```bash
cd <插件仓库绝对路径>
git checkout main # 或 sync/v0.24.1(按宿主版本选)
pnpm install # 会顺带跑 prepare: tsdown
pnpm typecheck && pnpm build && pnpm test # 门禁三连
```
产物(`lib/`):`index.js` / `invariant.js`(host)+ `client.js` + 懒加载 chunk
(`client-registry` / `client-editor` / `client-locale` / `client-mermaid` / `client-terminal`)+ `types/`。
### 步骤 2:接进 profile
编辑 `~/.dsh/profiles/<profile>/package.json`(桌面版为 `desktop`,网页版通常为 `web`):
```jsonc
{
"dependencies": {
"dsh-apes-sidebar": "file:/绝对路径/dsh-apes-sidebar" // ← 必须是绝对路径
},
"dsh": {
"profile": {
"bundles": [
"…",
"dsh-apes-sidebar" // ← 加入 bundle 栈
]
}
}
}
```
> 也可以在 profile 自己的 `cordis.patch.yml` 里手写挂载行(老方式)。**两种方式不要同时用**:
> 会双挂载成两个 Node 半、两个侧边栏。包内自带的 `cordis.patch.yml` 是 `dsh.bundle.patch` 层,
> 由官方 CLI 安装时自动追加 bundle,并带「聚合包双挂载退让」守卫。
### 步骤 3:让 profile 重新安装依赖
```bash
cd ~/.dsh/profiles/<profile> && pnpm install
```
**为什么必须重装**:pnpm 对 `file:` 依赖是**复制**语义(不是软链)。源码 `pnpm build` 之后,
profile 的 `node_modules/dsh-apes-sidebar/lib` **仍是旧副本**——这就是「改了代码但界面没变」的头号原因。
DSH 启动时也会 reconcile profile 的 pnpm workspace,但不要假定它一定帮你刷新。
### 步骤 4:完整重启
**⌘Q 完全退出 DSH,再重新启动**(不是关窗口)。client 半有热加载,但 host 半与依赖变更必须走完整重启。
## 三、验收清单
重启后逐条核对(每条都对应一个可观察事实):
- [ ] **右列只有一套侧边栏**、一个开合控件(不再出现自绘的第二套开关)
- [ ] 原生 guide 里能看到本插件的 tab 芯片,且**带图标**
- [ ] 三个信息层 tab 存在:**智能体 / 应用 / 画像**(order 在原生面板之后)
- [ ] `apes:agents` 打开后:有主代理卡片;有子代理时列出直系子代理(标签 + 模式 + 运行态)
- [ ] 打开一个 HTML 文件:**沙箱态下自包含 HTML 能正常渲染**(不再是 `forbidden`)
- [ ] 打开「社区插件市场」:弹窗**完整盖在**本插件面板之上(面板不切弹窗角)
- [ ] 页面左下角**没有**红色诊断条(有的话说明 client 生命周期某处抛错)
- [ ] 开发者控制台**没有** `[dsh-apes-sidebar] … error` 前缀的错误
## 四、网页 profile 与桌面 profile 的差异
| 项 | 桌面版(DSH Desktop) | 网页版 |
| --- | --- | --- |
| profile 目录 | `~/.dsh/profiles/desktop` | 通常 `~/.dsh/profiles/web` |
| 外壳 | Electron 壳,有「扩展模式/兼容模式」与请求围栏 | 浏览器直连宿主端口 |
| 预览 iframe | 受渲染器围栏约束(因此必须走 `srcDoc` 交付) | 无该围栏,但仍受路由 CSP/沙箱约束 |
| 面板宿主层级 | 壳层会给 `#root` 打 content-viewport 标记 → 宿主挂进 `#root` | 无标记 → 宿主挂 `document.body` |
| 刷新方式 | ⌘Q 完整退出再启动 | 硬刷新(Cmd/Ctrl+Shift+R) |
## 五、v0.19.1 线特有:放行 node-pty 原生构建
`main`(上游 v0.19.1 基线)在 `package.json` 中仍有 `node-pty` 依赖;pnpm 11 默认**拦截依赖的构建脚本**,
于是首次安装会失败或终端不可用:
```bash
cd ~/.dsh/profiles/<profile>
pnpm approve-builds # 选中 node-pty,回车确认
pnpm rebuild node-pty # 必要时
```
`sync/v0.24.1`(1.3.0)**不含** `node-pty`(终端交由宿主内置承担),无需此步。
(`main` 与 `sync/v0.24.1` 的 `node-pty` 引用数:1 / 0。)
## 六、切换与卸载
同一条线只能装一个(两条线都注册 `ctx.betterSidebar`,entry id 冲突,第二个被丢弃)。
仓库自带切换脚本:
```bash
bash scripts/switch.sh --status # 只看当前是哪条线
bash scripts/switch.sh aps # 切到本 fork 线(本地 file: 安装)
bash scripts/switch.sh upstream # 切回上游 dsh-better-sidebar
bash scripts/switch.sh upstream 0.18.0 # 指定上游版本
bash scripts/switch.sh aps --profile web # 指定 profile
```
**完全卸载**:从 profile 的 `dependencies` 与 `dsh.profile.bundles` 中移除 `dsh-apes-sidebar`,
`pnpm install`,⌘Q 完整重启。若曾手写过 `cordis.patch.yml` 挂载行,一并删除(否则会留下悬空 entry)。
> 若同时装了聚合包(如 `@linxin666/dsh-web-ui-all`)且它内部也挂载了本包,
> 包内 bundle patch 的守卫会让**本行退让**;此时请确认聚合包在 bundle 栈中位于本行之前。
## 七、变更后的重新部署
```bash
# 插件仓库
pnpm typecheck && pnpm build && pnpm test
# profile
cd ~/.dsh/profiles/<profile> && pnpm install
# ⌘Q 退出 DSH 再启动
```
改完代码**必须**走完这三步:产物构建非字节确定性,profile 的 `file:` 副本也不会自动跟随。
-133
View File
@@ -1,133 +0,0 @@
# 05 · 运维手册
> 本篇是**日常运维、升级与回滚**的操作手册。所有命令都以「先留锚点、再动手、改完回读验证」为原则。
## 一、版本线与分支策略(当前远端实况)
| ref | 指向 | 含义 / 用途 |
| --- | --- | --- |
| `main` | `4422001` | **旧宿主线**:插件 1.2.0,上游基线 v0.19.1,peer `^0.1.5-rc.1`。本机 DSH Desktop 2.0.10 正在跑这条 |
| `sync/v0.24.1` | `e5bbef1` | **新宿主线**:插件 1.3.0,上游基线 v0.24.1,peer `^0.2.0-rc.1`。待 ≥0.2.0 宿主实机验证 |
| `backup/pre-sync-v0.19.1` | `96cc334` | v0.19.1 re-base **之前**的状态(旧 fork 三角色 tab 版) |
| `backup/pre-sync-v0.24.1` | `4422001` | v0.24.1 re-base 之前的 `main`(等价于当前 main,作为下次升级的锚点) |
| `tags/apes-v1.1.0-pre-sync` | `96cc334` | 与上面第一支 backup 同一提交,tag 形式的锚点 |
| `tags/apes-v1.2.0` | `4422001` | 当前已验收的 1.2.0 线 |
**纪律**:任何 re-base / 大改之前,先建 `backup/pre-sync-<新基线>` 分支 + `apes-v<版本>` tag,
并**推送**到远端(本地锚点在多人/多机场景等于不存在)。
## 二、回滚剧本
### 场景 A:某次提交引入回归(单点回退)
```bash
cd <插件仓库>
git log --oneline -5 # 定位可疑提交
git revert <sha> # 保留历史的回退(推荐)
# 或整体丢弃:git reset --hard <上一提交>
pnpm typecheck && pnpm build && pnpm test
cd ~/.dsh/profiles/<profile> && pnpm install # file: 是复制语义,必须重装
# ⌘Q 完全退出 DSH → 重启
```
### 场景 B:整条线回退(例如新线在真机上不可用)
```bash
git checkout main # 或 backup/pre-sync-v0.24.1
pnpm install && pnpm build
cd ~/.dsh/profiles/<profile> && pnpm install
# ⌘Q 重启
```
### 场景 C:profile 依赖/config 被改坏
```bash
cd ~/.dsh/profiles/<profile>
cp package.json package.json.bak.$(date +%s) # 先备份
bash <插件仓库>/scripts/switch.sh aps # 或 upstream,脚本会重写依赖与 bundle 行
pnpm install
# ⌘Q 重启
```
**回滚验收**:重启后按 [04-部署指南](04-部署指南) §三 的验收清单逐条核对(至少「右列一套侧边栏」
与「三个信息层 tab 存在」两条)。
## 三、发布 / 变更纪律
1. **版本三元组必须一致**:
- `package.json` 的 `version`
- `dsh.plugin.json` 的 `version`
- `src/client/service.ts` 的 `SIDEBAR_SERVICE_VERSION`
三者不一致会被单元测试(`tests/service.spec.ts`)直接拦下——**不要改其中一个而忘了另一个**。
2. **门禁三连必过**:`pnpm typecheck && pnpm build && pnpm test`。当前基线:`main` 131 个测试文件 /
1367 通过;`sync/v0.24.1` 141 个文件 / 1515 通过(另有 9 个 skip)。
3. **产物非字节确定性**:两次构建的字节数可能相同但 **sha 不同**。因此「改完代码 → 让 profile 用旧副本」
永远不成立;必须重新 `pnpm build` + 让 profile 重新安装。
4. **发布方式**:本 fork 未发布 npm 包,发布 = 推分支/tag + 目标机器 `git fetch && checkout && pnpm build`
+ profile 重装。发布前把 `README.md` 与 `docs/wiki/` 一并更新(wiki 见 §六)。
5. **不要顺手改无关文件**:re-base 时保持「只带自己的增量」,避免下次升级冲突面扩大。
## 四、日常巡检清单
| 频率 | 检查项 | 命令 / 判据 |
| --- | --- | --- |
| 每次部署后 | 版本三元组一致 | 见 §三.1 |
| 每次部署后 | 产物标记齐全 | `grep -c 'apes:agents' lib/client.js`(>0)、`grep -c 'dsh-desktop-content-viewport' lib/client.js`(>0) |
| 每次部署后 | profile 副本已刷新 | 比对 `lib/client.js` 与 `~/.dsh/profiles/<p>/node_modules/dsh-apes-sidebar/lib/client.js` 的 sha256 |
| 每次升级宿主/插件 | 宿主版本 ↔ peer 范围 | 见 [04-部署指南](04-部署指南) §1.1(不匹配 = 静默禁用) |
| 每月/大改前 | 锚点是否已推远端 | `git ls-remote <remote> \| grep -E 'heads/(main\|sync)\|tags/apes'` |
| 异常时 | 诊断条与控制台 | 页面左下角红条;控制台 `[dsh-apes-sidebar]` 前缀 |
## 五、备份与配置铁律
- **改任何 profile 配置前先备份**,改完**回读验证**(不要假定写入即生效):
```bash
cp ~/.dsh/profiles/<profile>/package.json{,.bak.$(date +%s)}
# 改完:
python3 -c "import json;json.load(open('$HOME/.dsh/profiles/<profile>/package.json'));print('JSON OK')"
```
- **DSH 应用配置的类型陷阱**:部分字段(如材质/透明度类的 `macosMaterial`)必须是**字符串**
(`"off"` / `"transparent"`)。若被写成 YAML 布尔(`false`),DSH 会启动进**恢复模式**。
改完务必回读校验类型:
```bash
python3 -c "import yaml;d=yaml.safe_load(open('$HOME/.dsh/settings.yaml'));v=d.get('macosMaterial');print(v, type(v).__name__)"
```
- **不要回滚/手工编辑** profile 的 `pnpm-lock.yaml`:由 pnpm 重建;手工改会导致解析错乱。
- **凭据不进仓库**:远端推送用系统凭据助手(`git credential fill`),仓库内不写 token。
## 六、百科(wiki)本身的维护
百科源文件版本化在仓库 `docs/wiki/`,通过脚本推送到 Gitea wiki:
```bash
bash scripts/push-wiki.sh # 推送/更新全部页面(幂等:有则 PATCH,无则 POST)
bash scripts/push-wiki.sh 05-运维手册 # 只推一页
```
凭据来自 `git credential fill`(与推送代码同一套,不落盘)。修改文档的推荐流程:
**改 `docs/wiki/*.md` → 提交到仓库 → 跑脚本推送**,这样 wiki 与代码同源可回溯。
## 七、诊断与日志
| 信号 | 含义 | 位置 |
| --- | --- | --- |
| 左下角红色诊断条 `[dsh-apes-sidebar] <phase> error: …` | client 生命周期某阶段抛错(mount / native / 懒加载等) | 页面左下角(`z-index: 2147483000`),同时 `console.error` 打前缀 |
| 控制台 `[dsh-apes-sidebar] …` | 同上,且带上具体阶段名 | 开发者工具 Console |
| 「插件像没装」且**无任何日志** | 宿主兼容性预检失败(peer 不匹配)→ **静默禁用** | 只能靠版本矩阵排查,见 [06-故障排查](06-故障排查) §1 |
| 界面正常但面板层级错乱 | 面板宿主挂载点/层叠上下文问题 | 见 [02-系统架构](02-系统架构) §四 |
## 八、性能与容量
| 项 | 观察值 | 说明 |
| --- | --- | --- |
| `lib/client.js` | ≈ 0.9 MB | 主 bundle,始终加载 |
| `lib/client-mermaid.js` | ≈ 7 MB(+12 MB map) | 懒加载,仅在需要渲染 Mermaid 时拉取 |
| `lib/client-editor.js` | ≈ 2 MB | 懒加载,仅打开编辑器时 |
| `lib/client-terminal.js` | ≈ 0.5 MB | 懒加载,仅打开终端时 |
| 全量测试 | 约 3–4 分钟 | 变更后按需跑全量,日常可只跑相关 spec |
主 bundle 不应静态引入上述重型库;新增重型依赖一律走 `src/client/chunks/`(懒加载 chunk)。
-208
View File
@@ -1,208 +0,0 @@
# 06 · 故障排查
> 每条都给出**症状 → 根因(机制)→ 处置 → 验证**。标注 ✅ 的条目是在真机上定位并验证过的案例。
## 速查表
| 症状 | 最可能根因 | 跳到 |
| --- | --- | --- |
| 装完「像没装」,界面无变化且**无任何日志** | peer 范围与宿主版本不匹配 → 静默禁用 | §1 |
| 页面出现**两个侧边栏** / 两套开合按钮 | 双挂载(两条线同装,或残留挂载行) | §2 |
| HTML 预览只显示 `forbidden` | 桌面壳渲染器围栏 + 不透明源 iframe | §3 |
| 插件面板盖住宿主/第三方弹窗(如插件市场) | 面板宿主挂在层叠上下文之外 | §4 |
| 终端打不开 / `node-pty` 加载失败 | pnpm 拦截原生构建(仅 v0.19.1 线) | §5 |
| 改了代码,界面没变 | 产物未重建 / profile 副本未刷新 / 浏览器缓存 | §6 |
| DSH 拒绝加载插件、entry pending | 插件声明(`dsh.plugin.json` / bundle patch)不合法 | §7 |
| DSH 启动进**恢复模式** | profile 配置被写成错误类型(如 YAML 布尔) | §8 |
---
## §1 装完「像没装」:静默禁用 ✅
**症状**:profile 里依赖装了、文件也在,但界面没有任何变化;日志里也没有插件相关报错。
**根因**:宿主启动时做兼容性预检,判定 `peerDependencies` 与自身版本是否满足:
```
semver.satisfies(hostVersion, peerRange, { includePrerelease: true })
```
caret 范围**跨 minor 必然为 false**(例如 `^0.1.5-rc.1` 对 `0.2.0`)。判定失败后宿主的处理是
**跳过这一行**——不抛错、不打日志、不加载。
**处置**:核对宿主版本,改用匹配的版本线(见 [04-部署指南](04-部署指南) §1.1)。
```bash
grep -m1 '"version"' "/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh/package.json"
grep -n 'dsh-client-ui-sidebar-right' <插件仓库>/package.json # 看 peer 范围
```
**验证**:重启后右列出现插件 tab;`lib/client.js` 里能 grep 到 `apes:agents`。
**教训**:这一条没有日志可查,因此**部署前先看版本矩阵**是唯一可靠的预防手段。
## §2 两个侧边栏 / 两套开合按钮 ✅
**症状**:窗口里出现两套右侧面板与两组开合控件。
**根因**:两个方向都可能:
1. **旧版遗留**:v0.18.0 基线时插件自绘右侧面板与开关(`toggleCluster`)。升级后自绘部分应已移除。
2. **双挂载**:同一条线被挂两次(profile 自己的 `cordis.patch.yml` 里还留着旧挂载行,
与 bundle patch 同时生效),或者本 fork 线与上游线同时安装(两者都注册 `ctx.betterSidebar`,
entry id 冲突,第二个被丢弃)。
**处置**:
```bash
# 确认产物里已经没有自绘开关
grep -c toggleCluster <插件仓库>/lib/client.js # 期望 0
# 检查是否双挂载:profile 里是否同时存在 bundle 行 与 手写挂载行
grep -n "dsh-apes-sidebar\|dsh-better-sidebar" ~/.dsh/profiles/<profile>/cordis.patch.yml ~/.dsh/profiles/<profile>/package.json
```
- 有残留手写挂载行 → 删除它,只保留一条通道。
- 两条线同装 → `bash scripts/switch.sh aps`(或 `upstream`)二选一。
- 装了聚合包(如 `@linxin666/dsh-web-ui-all`)→ 确认聚合包在本包之前,让包内守卫生效。
**验证**:右列只剩一套面板与一个开合控件。
## §3 HTML 预览显示 `forbidden` ✅
**症状**:预览 HTML 文件时 iframe 里只有一行裸文本 `forbidden`;而「临时解锁」后又能看。
**根因(三层)**:
1. DSH Desktop 在**扩展模式**下给所有路由套了一层请求围栏:`ordinaryBrowserEnabled` 恒为 false,
不带 Electron 注入的 `x-dsh-desktop-renderer` 头的请求一律 `403 + "forbidden"`;
2. 该头只注入给 **mainFrame** 与**同源**子框架;
3. 预览 iframe 的 sandbox 令牌不含 `allow-same-origin`,请求时其源为 `null` → 拿不到头 → 403。
与「是否升级了插件基线」无关(v0.18.0 起行为一致)。
**处置**:插件已在 1.2.0+ 用 **`srcDoc` 交付**修复(主框架取文档 → 注入 `<base>` → 注入 iframe),
`main` 上对应提交 `4c242ac`。**不要**通过给 iframe 加 `allow-same-origin` 来"修"——那是安全降级。
**验证**:
- 沙箱态下自包含 HTML(含内联脚本)能正常渲染;
- 有外链资源的 HTML 在沙箱态下缺资源属**已知限制**,点「临时解锁」后应完整显示。
**排查命令**(确认宿主围栏在起作用):
```bash
lsof -nP -iTCP -sTCP:LISTEN | grep -i dsh # 找宿主端口(每次启动可能不同)
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:<端口>/" # 无渲染器头 → 403
```
## §4 插件面板盖住宿主/第三方弹窗 ✅
**症状**:打开「社区插件市场」(宿主自带 `dsh-community-market`)时,弹窗左下角被本插件的
文件面板整齐切掉一块。
**根因**:桌面壳的有框模式会给上游 `#root` 打标 `data-dsh-desktop-content-viewport` 并施加
`isolation: isolate`,使 `#root` 成为**层叠上下文**;宿主的 overlay 层(`.dshDesktopOverlay`,
`z-index: 1000`)位于其中。插件面板宿主若挂在 `document.body`(该上下文之外)且是定位元素,
就会画在整个上下文之上,把 overlay 里的弹窗盖住。
**处置**:已修(`4422001`)——面板宿主改为挂进 `#root[data-dsh-desktop-content-viewport]`,
无标记时回退 `document.body`。**不要**通过调大 `z-index` 解决(25 的取义见
[02-系统架构](02-系统架构) §四;乱调会破坏与宿主其他层级的关系)。
**验证**:插件市场弹窗完整盖住本插件面板,同时本插件面板仍在对话内容之上。
## §5 终端不可用 / `node-pty` 加载失败(仅 v0.19.1 线)✅
**症状**:终端面板报 shell 启动失败或提示 `node-pty` 加载失败;`pnpm install` 阶段报
"ignored build scripts"。
**根因**:pnpm 11 默认**拦截依赖的构建脚本**,`node-pty` 的原生二进制没被编译。
(`main` 依赖 `node-pty`;`sync/v0.24.1` 已移除该依赖,终端由宿主内置承担。)
**处置**:
```bash
cd ~/.dsh/profiles/<profile>
pnpm approve-builds # 选中 node-pty
pnpm rebuild node-pty # 必要时
# ⌘Q 完整退出 DSH 再启动
```
**验证**:终端能开出 shell;`node_modules/node-pty/build/Release/*.node` 存在。
## §6 改了代码,界面没变 ✅
**症状**:源码已改、`pnpm build` 也跑了,但界面行为仍是旧的。
**根因(三个叠加因素)**:
1. 产物**非字节确定性**,且 profile 的 `file:` 依赖是**复制**语义 → profile 里仍是旧副本;
2. `lib/client.js` 是浏览器的 `window.__ModuleLoader__.load({...})` bundle,**不是 CJS/ESM**,
在 Node 里 `require` 会因 `window is not defined` 报错——因此不能用「在 Node 里试跑」来验证前端改动;
3. host 半的改动必须重启进程(client 半有热加载,host 半没有)。
**处置**:
```bash
cd <插件仓库> && pnpm build
cd ~/.dsh/profiles/<profile> && pnpm install # 刷新 file: 副本
# ⌘Q 完整退出 DSH → 重启
```
**验证**:比对两边产物 sha:
```bash
shasum -a 256 <插件仓库>/lib/client.js \
~/.dsh/profiles/<profile>/node_modules/dsh-apes-sidebar/lib/client.js
```
## §7 DSH 拒绝加载插件 / entry pending
**症状**:插件目录存在,但宿主不加载,或 loader 报 entry pending / 恢复模式。
**根因**:插件的声明面不合法或不被接受,常见于:
- `dsh.plugin.json` 的 `engines.dsh` 与宿主不符;
- bundle patch / 挂载行的 `insert` 结构不合法(例如把声明写成宿主无法解析的形态);
- 同 id 重复挂载(`duplicate loader entry id` / `duplicate prefix route`)。
**处置**:
```bash
cat <插件仓库>/dsh.plugin.json # 检查 id / engines / main
cat <插件仓库>/cordis.patch.yml # 检查 insert 结构与守卫表达式
grep -n "cordis.patch\|dsh-apes-sidebar" ~/.dsh/profiles/<profile>/package.json
```
确保只有一条挂载通道、`name` 与实际包名一致(本 fork 为 `dsh-apes-sidebar`),
`engines.dsh` 与宿主版本相容。改完 `pnpm install` + **完整重启**。
**验证**:启动日志无 loader 报错;右列出现插件 tab。
## §8 DSH 启动进「恢复模式」
**症状**:DSH 启动后进入恢复/安全模式,插件与侧边栏都不正常。
**根因**:profile 或应用配置被写成**错误类型**。典型是材质/透明度字段要求**字符串**
(`"off"` / `"transparent"`),但被 YAML 序列化成了布尔 `false` —— 宿主按类型校验失败即进恢复模式。
**处置与验证**:
```bash
python3 -c "import yaml;d=yaml.safe_load(open('$HOME/.dsh/settings.yaml'));v=d.get('macosMaterial');print(repr(v), type(v).__name__)"
# 期望:'off' str(或 'transparent' str);若为 False bool → 改回字符串
```
改配置前**先备份**,改完**回读校验类型**(见 [05-运维手册](05-运维手册) §五)。
---
## 附:定位手法(本次会话验证有效)
1. **先按用户可见文案 grep 找归属**:`grep -rl "<界面上的中文短语>" <app>/lib <profile>/node_modules`。
若两边都搜不到,说明它来自**运行时下载/缓存**(例如市场插件),再按 `NSUserData` 缓存或
`desktop-market/state.json` 之类线索去找提供方。
2. **再读提供方自己的 CSS/DOM 结构**:拿到它声明的 z-index、定位方式与所在槽位
(如 `renderSlot("shell.overlay")`),据此判断是「它太高」还是「我们太高」。
3. **层叠问题先找 `isolation` / `transform` / `filter` / `contain`**:这些才是层叠上下文与包含块的真正来源,
比 z-index 数值更关键。
4. **像素证据要用原图量**:交给视觉模型的截图会被缩放(1920→1280),裁剪坐标 ≠ 屏幕坐标;
用 PIL 在原图上量边界、比对 RGB 剖面,再下结论。
-164
View File
@@ -1,164 +0,0 @@
# 07 · 开发与测试
> 面向改代码的人(与 agent)。**先读 [02-系统架构](02-系统架构) §六「关键不变量」**,
> 再动手改,能省掉大部分回退。
## 一、工程约定
| 项 | 约定 |
| --- | --- |
| 语言 | TypeScript **strict**(含 `noUncheckedIndexedAccess`)、ESM、导入写 `.ts` 扩展名 |
| 构建 | `tsdown`(rolldown),host/client 双产物;懒加载 chunk 走 `src/client/chunks/` |
| 包管理 | `pnpm`(11.x);`pnpm install` 会触发 `prepare` 构建 |
| 测试 | `vitest`;默认 node 环境,需要 DOM 的 spec 用 `// @vitest-environment jsdom` |
| 宿主 peer | `dsh-better-sidebar`(本仓库已改名)**永不打包**,必须留在 peer/neverBundle 一侧 |
| 命名 | 信息层 tab id 前缀 `apes:`;插件名 `dsh-apes-sidebar` |
| 颜色 | 只用 `--dsw-alias-*` 设计令牌(`var(--token, <回退>)`),不写死色值 |
## 二、目录结构(`main` 实况)
```
src/
index.ts HOST 入口:name / inject / apply(ctx, config),路由与围栏装配
invariant.ts 零依赖不变量面(host/client 共用)
context-types.ts cordis 服务面契约(ctx.sessions / sidebarRight / …)
trust-fence.ts 请求信任围栏(Host 头 / 令牌)
html-route.ts /sidebar/html 路由(路径围栏 + 体积上限)
config.ts 挂载行配置 schema 与默认值(readLimit / mediaLimit / …)
fs-*.ts git.ts … 文件系统、Git、会话、任务、终端等 host 能力
client/
index.tsx CLIENT 入口(apply):装配 store/service/native/apes
service.ts ctx.betterSidebar 服务实现(对外 API + 能力清单)
state.ts 侧边栏状态存储(store)
native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题)
builtins/ 内置面板:editor / git / subagent / sidechat / terminal / browser + 预览器
sidebar/ 自绘底部工作台与开合控件
apes/ 信息层三 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts)
changes/ diff/ 文件变动与 diff 视图
chunks/ 懒加载 chunk:editor / locale / mermaid / terminal
locales*.ts i18n(`locales.ts` + 19 个语言覆盖文件)
*.module.css 组件样式(含 sidebar.module.css:面板宿主层契约)
tests/ vitest specs(131 个文件,含 e2e/ 的挂载脚本型用例)
scripts/ install / switch / e2e 挂载 / 发布辅助
docs/ external-plugin-guide.md(对外契约)+ plans/(上游历史设计)+ wiki/(本百科源文件)
```
## 三、门禁(提交前必跑)
```bash
pnpm typecheck # tsc --noEmit(strict)
pnpm build # rm -rf lib && tsc(d.ts) && tsdown
pnpm test # vitest run
```
当前基线(本次会话实测):`main` = 131 文件 / **1367 通过** + 9 skip;
`sync/v0.24.1` = 141 文件 / **1515 通过** + 9 skip。全量 test 约 3–4 分钟。
**任何一条不过就不要提交**;三条全绿才算完成。
## 四、测试体系(按层次)
| 层次 | 环境 | 手法 | 代表 spec |
| --- | --- | --- | --- |
| 宿主插件形状 / 对外契约 | node | 断言导出面与语义(含与 `package.json` 的版本一致性) | `smoke.spec.ts` · `plugin-shape.spec.ts` · `service.spec.ts` · `consumer-types.ts` |
| 原生侧边栏适配 | node | 用「类型为真、运行时为假」的 mock 注册表 | `native-surface.spec.ts` |
| 面板宿主与层叠契约 | node + jsdom | CSS 断言 + DOM 挂载点解析 | `panel-host-css.spec.ts` · `panel-host-parent.spec.ts` |
| 预览交付 | node + jsdom | 纯逻辑(`<base>` 注入)+ 客户端挂载(stub `fetch`,`createRoot` + `act`) | `html-preview.spec.ts` · `html-preview-frame.spec.tsx` |
| 沙箱契约 | node | 对渲染结果做**字符串断言**(服务端渲染把 `srcDoc` 输出为 camelCase `srcDoc=""`,与客户端设置的真实属性 `srcdoc` 不同 —— 断言方式随之不同) | `sandbox-views.spec.tsx` |
| 信息层 tab | node | mock `ctx.sessions.list` 与投影 | `apes-tabs.spec.ts` |
| 桌面环境探测 | node | stub 全局(URL stamp / preload 标记) | `desktop-env.spec.ts` |
约定:
- **不引入 jsdom 到默认环境**:需要 DOM 的 spec 在文件头写 `// @vitest-environment jsdom`。
- **不依赖真实 DSH 宿主**:需要真宿主的用例归到 `tests/e2e/`(脚本型挂载),不进单元门禁。
- 新增「契约」必须**同时新增 pin 它的 spec**(例如本文档提到的 25 层级、沙箱令牌、`srcDoc` 交付方式,
都有对应断言)。改契约 = 改代码 + 改 spec,两者必须一起提交。
## 五、扩展点(对外服务)
其他插件通过 `ctx.betterSidebar` 接入:
```ts
// 注册一个 tab 类型
await ctx.betterSidebar.registerTab(descriptor)
// 注册一个文件预览器
await ctx.betterSidebar.registerFileViewer(viewer)
```
- 服务按**能力清单**做前向兼容门控(能力只增不减),消费方应据此判断可用性而非版本号比较。
- 完整契约、字段表与示例见仓库内 [docs/external-plugin-guide.md](../blob/main/docs/external-plugin-guide.md)。
- 改服务面时:**只能新增**;删除或改语义需同步更新能力清单、指南与本百科。
## 六、上游同步:测量式 re-base(本项目的方法论)
**不 merge 上游**。上游每个版本差距可达数百文件,直接合并会引入无关变更并放大冲突面;
本项目改用「先测量、再重建、后补增量」:
### 步骤
**① 测出自己的增量(把改名归一化掉)**
```python
# 对每个变更文件:取出两边 blob,把改名归一化后比较
old = git('show', f'{base}:{path}'); new = git('show', f'HEAD:{path}')
rename_only = old.replace('dsh-better-sidebar', 'dsh-apes-sidebar') == new
```
→ 得到三张清单:**仅改名**(自动处理)/ **真改动** / **新增**。数量级参考:v0.19.1→v0.24.1 为
78 / 11 / 10。
**② 确定改名的精确范围(不要重新推断)**
```bash
# 仍保留上游名字的文件 = 有意保留(本仓库为 README.md、package.json、docs/plans/*)
comm -12 <(git grep -l 'dsh-better-sidebar' <旧基线> | cut -d: -f2 | sort) \
<(git grep -l 'dsh-better-sidebar' HEAD | cut -d: -f2 | sort)
```
其余文件只做子串替换 `dsh-better-sidebar` → `dsh-apes-sidebar`;**camelCase 服务名 `betterSidebar` 必须保留**。
**③ 造一个"改名归一化基座",让增量与改名解耦**
```bash
git checkout -B tmp/norm-<旧基线> <旧基线 tag>
# → 套同一口径改名 → commit
git diff tmp/norm-<旧基线> HEAD -- . ':(exclude)pnpm-lock.yaml' > /tmp/apes-delta.patch
# 校验:这条 diff 的文件清单必须恰好等于 ① 的「真改动 + 新增」
```
**④ 在新基线上重建 + 三点合并**
```bash
git checkout -B sync/<新基线 tag> <新基线 tag>
# → 套同一口径改名 → commit("rename only")
git apply -3 /tmp/apes-delta.patch # 冲突会以 <<<<<<< ours / >>>>>>> theirs 留存(命令本身不失败)
```
**⑤ 逐条判读冲突(只有五种形态)**
| 形态 | 判定 |
| --- | --- |
| 版本/身份 JSON | 取**新基线**侧,再改成本 fork 的版本;两侧各自新增的元数据取**并集**(依赖、files 条目、`forkedFrom` 块) |
| 上游搬走了某个 import | 取上游的新路径,保留本 fork 的新 import |
| 上游新环境里的注册块 | 保留本 fork 的块,放进上游的新上下文 |
| 同一函数两边都定义(上游上移/下移了它) | 删除本 fork 那份;用 `grep -c 'const <name>'` 断言只剩 1 处 |
| 长文档(README) | 整体取上游原文,再机械套上本 fork 的两处增量:顶部 fork notice + 包名替换(**跳过** `github.com/<上游 org>` 与徽章域名行) |
**⑥ 迁移检查(API 漂移)+ 门禁**
- 先**解完所有冲突标记再** `pnpm install`(`prepare: tsdown` 会因为语法错误直接失败),
并**删掉 lockfile 重新生成**(不要把旧 lockfile 带过来)。
- 逐条跑 `pnpm typecheck` 修类型漂移。经验:漂移通常集中在**本 fork 自己新增的模块**里。
上游会在 `context-types.ts` 的注释里写清迁移路径(例:0.1.6 的 `subagentsByParent`
→ 0.1.7 的 `projectionsBySession[id].values.subagentCatalog`,`list.current` 被删除),照注释做即可。
- 最后跑门禁三连,并且**用 `grep` 核对关键标记**(tab id、`srcDoc`、`dsh-desktop-content-viewport` 等)。
**⑦ 收尾(纪律)**
- 安全锚点:`backup/pre-sync-<新基线>` 分支 + `apes-v<版本>` tag,**推送远端**。
- **不要把新线同步进正在使用的 profile**:peer 范围可能已跨 minor,同步过去只会「静默失效」,
同时把本机环境弄脏。保持 profile 在已验证的线上。
## 七、提交与分支纪律
- 提交信息用 `type(scope): 摘要`(`feat` / `fix` / `chore` / `docs` / `refactor`),正文说明**为什么**与验证方式。
- 一个提交一件事;re-base 类改动拆成「rename only」+「增量移植」两个提交,便于回溯与回滚。
- 分支命名:`sync/<上游 tag>`(跟随上游)、`backup/pre-sync-<基线>`(锚点)、其余按需。
- 提交前:门禁三连 + 关键标记 grep + (若涉及部署)提醒 profile 重装与完整重启。
-82
View File
@@ -1,82 +0,0 @@
# apes-sidebar 项目百科
> DeepSeek Harness(DSH)的**服务化右侧边栏工作台** + **apes 信息层**。
> 本项目是 [`dsh-better-sidebar`](https://github.com/omdsh-dev/DSH-better-sidebar)(MIT)的 fork,
> 在其「文件 / 文件变动 / 任务 / 侧边对话 / 终端 / 浏览器」工作台之上,追加三个信息层标签页:
> **智能体(agents)/ 应用(apps)/ 画像(profile)**。
## 一、项目速览
| 项 | 值 |
| --- | --- |
| 仓库 | `figmar/apes-sidebar`(公开) |
| 默认分支 | `main` |
| 许可证 | MIT(fork 自 `omdsh-dev/DSH-better-sidebar`) |
| 语言 / 构建 | TypeScript(strict)· `tsdown`(rolldown)· `pnpm` |
| 宿主 | DSH Desktop(Electron 壳)或 DSH Web profile |
| 上游跟随方式 | 不合并上游提交,而是**测量式 re-base**(见 [07-开发与测试](07-开发与测试)) |
## 二、两条版本线(重要)
本仓库同时维护两条互相独立、**不能同时挂载**的版本线(两条线都注册 `ctx.betterSidebar`,
cordis entry id 冲突,第二个会被丢弃):
| 分支 | 版本 | 上游基线 | peer / 宿主要求 | 状态 |
| --- | --- | --- | --- | --- |
| **`main`** | 1.2.0 | `dsh-better-sidebar` **v0.19.1**(`8753096`) | `^0.1.5-rc.1`(DSH **0.1.5-rc.1+**) | 已在本机 DSH Desktop 2.0.10(`dsh 0.1.5-rc.2`)实机运行 |
| **`sync/v0.24.1`** | 1.3.0 | `dsh-better-sidebar` **v0.24.1**(`a2751cf`) | `^0.2.0-rc.1`(DSH **0.2.0-rc.1+**) | 构建与测试全绿,**待具备 0.2.0 宿主实机验证** |
> ⚠️ **宿主的兼容性预检是「静默禁用」,不报错、不加载、无日志。**
> 宿主用 `semver.satisfies(host, peerRange, { includePrerelease: true })` 判定;caret 范围跨 minor
> 不成立(`^0.1.5-rc.1` 在 `0.2.0` 宿主上为 false),于是整行被跳过。
> **现象 = 插件"好像没装"**。选线请先看宿主版本,见 [04-部署指南](04-部署指南)。
## 三、文档地图
| 页面 | 内容 |
| --- | --- |
| [01-需求分析](01-需求分析) | **官方原生 vs 装了本包**:官方右列只有「文件树 + 只读文本预览」(附官方包证据),九个 tab 是一个整体需求;7 条需求变化洞察、U1–U6、已知限制 |
| [02-系统架构](02-系统架构) | host/client 拆分、三层接口面、面板宿主层级契约、关键不变量 |
| [03-功能说明](03-功能说明) | 全部标签页与预览器、信息层数据来源、i18n、主题令牌、安全边界 |
| [04-部署指南](04-部署指南) | 环境矩阵、选线、profile 接线、构建、验收清单、卸载 |
| [05-运维手册](05-运维手册) | 版本线与分支策略、回滚剧本、发布纪律、巡检、备份铁律 |
| [06-故障排查](06-故障排查) | 症状 → 根因 → 处置(含全部实机验证过的案例) |
| [07-开发与测试](07-开发与测试) | 工程约定、目录、门禁、测试体系、扩展点、上游 re-base 方法 |
## 四、快速开始(TL;DR)
```bash
# 1) 构建(Node ≥ 20,pnpm)
pnpm install && pnpm typecheck && pnpm build && pnpm test
# 2) 接到 DSH profile(以桌面 profile 为例,使用 file: 链接依赖)
# 在 ~/.dsh/profiles/desktop/package.json 中:
# "dsh-apes-sidebar": "file:/绝对路径/dsh-apes-sidebar"
# 并在 dsh.profile.bundles 列表里加入 "dsh-apes-sidebar"
# 3) 让 profile 重新安装依赖,然后 ⌘Q 完整退出 DSH 再启动
```
完整步骤与坑见 [04-部署指南](04-部署指南)。
## 五、仓库内的事实源
| 位置 | 说明 |
| --- | --- |
| `README.md` | 面向用户的功能与安装说明(主体沿用上游文档,顶部有 fork notice) |
| `AGENTS.md` | 面向 agent/协作者的工程约定(构建、布局、硬性规则、测试约定)⚠️ 注意:本文件当前描述的是**另一套 from-scratch 实现**,与 `main` 的 fork 树不一致,以源码为准 |
| `docs/external-plugin-guide.md` | 插件接入指南:`ctx.betterSidebar.registerTab / registerFileViewer` 的对外契约 |
| `docs/plans/` | 上游历史设计文档(保持上游原样,未随 fork 改名) |
| `docs/wiki/` | **本百科的源文件**(Gitea wiki 页面的可版本化副本,由 `scripts/push-wiki.sh` 推送) |
| `scripts/switch.sh` | 两线切换脚本(`aps` ↔ `upstream`),含 `--status` |
## 六、谱系说明(如实交代)
`figmar/apes-sidebar` 这个仓库先后承载过同一产品族的三代实现:
1. **v1**:早期 JS bundle 版侧边栏插件(仓库描述里提到的「可配置显示名 / 预设 feyOS」即出自那一代);
2. **v2**:from-scratch 重写(`src/index.ts` + `src/client/index.tsx` 结构,五个 `apes:*` tab),
即 `AGENTS.md` 所描述的那一套,其产物不在当前 `main` 上;
3. **当前线(v0.19.1 / v0.24.1 fork)**:即 `main` 与 `sync/v0.24.1` 上的代码——本百科描述的对象。
若需要把本百科改写成描述 v2 重写版,请告知,源文件在 `docs/wiki/` 可直接替换。
-16
View File
@@ -1,16 +0,0 @@
{
"id": "dsh-external/dsh-apes-sidebar",
"version": "1.3.0",
"main": "./lib/index.js",
"description": "更好用的右侧边栏——文件树、编辑器、文件变动、任务和侧边对话都收在这一栏里,每个会话互不干扰。暴露服务供其他插件注册侧边栏页面与文件预览器",
"engines": {
"dsh": ">=0.2.0-rc.1"
},
"contributes": {
"tools": [],
"skills": []
},
"client": {
"main": "./lib/client-registry.js"
}
}
-88
View File
@@ -1,88 +0,0 @@
// ESLint flat config(最小规则集):
// - 基础:@eslint/js recommended + typescript-eslint recommended(非 type-checked 档,
// 全量 type-checked 需要项目服务且更慢,当前收益不足以引入);
// - react-hooks recommended:client 侧是 React 重镇,依赖数组豁免注释
// (react-hooks/exhaustive-deps)由此规则消费;
// - globals 按运行域分治:client bundle → browser,host/脚本 → node,tests → 混合。
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import tseslint from 'typescript-eslint'
export default tseslint.config(
// 构建产物 / 工具目录不参与 lint(node_modules 与点开头目录默认也不进)。
{
ignores: [
'lib/',
'dist/',
'coverage/',
'registry/',
'playwright-report/',
'test-results/',
'node_modules/',
'.worktrees/',
],
},
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
// react-hooks v7 的 recommended 捆绑了 React Compiler 语义规则(purity /
// immutability / refs / set-state-in-effect 等):本仓库对 CodeMirror / xterm
// 等命令式集成大量依赖 render 期读 ref、effect 内同步 setState 的既有模式,
// 57 处存量报错均为噪音级误报,先整体关闭该档,只保留经典双规则
// (rules-of-hooks / exhaustive-deps——后者消费代码里带理由的豁免注释)。
{
rules: Object.fromEntries(
[
'config',
'error-boundaries',
'gating',
'globals',
'immutability',
'incompatible-library',
'preserve-manual-memoization',
'purity',
'refs',
'set-state-in-effect',
'static-components',
'unsupported-syntax',
'use-memo',
].map(rule => [`react-hooks/${rule}`, 'off']),
),
},
// 孤儿 eslint-disable 注释(规则未实际触发)直接报错,防止豁免残留。
{ linterOptions: { reportUnusedDisableDirectives: 'error' } },
// no-unused-vars 对齐仓库既有约定:下划线前缀 = 刻意不用的占位(mock 桩等)。
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
},
},
// client bundle 运行在浏览器(portal 侧边栏 / 各视图 / 拦截层)。
{
files: ['src/client/**/*.{js,mjs,cjs,ts,tsx,jsx}'],
languageOptions: { globals: globals.browser },
},
// host 侧插件入口与构建/运维脚本跑在 Node。eslint.config.js 自身不 ignore:
// 它就是一个普通 ESM 脚本,走 js recommended + node globals 正常通过。
{
files: ['src/*.{js,mjs,cjs,ts}', 'scripts/**/*.{js,mjs,cjs,ts}', '*.config.ts', 'eslint.config.js'],
languageOptions: { globals: globals.node },
},
// tests:Node 里跑(vitest / fs fixtures),jsdom 组件测试又摸浏览器全局。
{
files: ['tests/**/*.{js,mjs,cjs,ts,tsx,jsx}'],
languageOptions: { globals: { ...globals.node, ...globals.browser } },
},
)
-14
View File
@@ -1,14 +0,0 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6.5" y="9" width="23" height="18" rx="2.5" fill="url(#a)" />
<rect x="21.5" y="9" width="8" height="18" rx="2.5" fill="url(#b)" />
<defs>
<linearGradient id="a" x1="6.5" y1="9" x2="29.5" y2="27" gradientUnits="userSpaceOnUse">
<stop stop-color="#69B9FF" />
<stop offset="1" stop-color="#324DE2" />
</linearGradient>
<linearGradient id="b" x1="25.5" y1="9" x2="25.5" y2="27" gradientUnits="userSpaceOnUse">
<stop stop-color="#45E7A4" />
<stop offset="1" stop-color="#05909D" />
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 663 B

-6
View File
@@ -1,6 +0,0 @@
{
"meta": {
"title": "Better Sidebar",
"description": "A better right sidebar — file tree, editor, changes, tasks and side chat in one column, separate for every conversation."
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"meta": {
"title": "Better Sidebar",
"description": "更好用的右侧边栏——文件树、编辑器、文件变动、任务和侧边对话都收在这一栏里,每个会话互不干扰。"
}
}
+48 -221
View File
@@ -1,41 +1,52 @@
{
"name": "dsh-apes-sidebar",
"version": "1.3.0",
"description": "APE fork of dsh-better-sidebar (base v0.24.1): session-isolated right sidebar — file tree, editor, changes, tasks and side chat — built on the host's native sidebar, plus apes:* information-layer tabs (agents / apps / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
"name": "apes-sidebar",
"version": "2.0.0",
"description": "WorkBuddy/Tutti 风格右侧边栏增强(构建在 dsh-better-sidebar 之上:任务 / Agent / 消息 / 应用 / Profile 五面板信息层)",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"publishConfig": {
"access": "public"
},
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./client/service": {
"types": "./lib/types/client/service.d.ts",
"default": "./lib/client.js"
},
"./client/api": {
"types": "./lib/types/client/service.d.ts",
"default": "./lib/client.js"
},
".": "./lib/index.js",
"./invariant": "./lib/invariant.js",
"./client": "./lib/client.js",
"./src/*": "./src/*",
"./package.json": "./package.json",
"./locale/*.json": "./locale/*.json"
"./package.json": "./package.json"
},
"files": [
"lib",
"src",
"cordis.patch.yml",
"scripts",
"AGENTS.md",
"README.md"
],
"scripts": {
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"verify:mount": "node scripts/verify-mount.cjs",
"lint:ts": "oxlint src tests tsdown.config.ts vitest.config.ts",
"dev": "tsdown --watch",
"pack:plugin": "pnpm build && pnpm pack --pack-destination dist"
},
"peerDependencies": {
"dsh-better-sidebar": "^0.18.0"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": {
"optional": true
}
},
"devDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"dsh-better-sidebar": "^0.18.0",
"lightningcss": "^1.32.0",
"oxlint": "^0.15.0",
"react": "~18.3.1",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=20"
@@ -47,196 +58,12 @@
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar-right",
"@deepseek-ai/dsh-client-modules"
"betterSidebar",
"locale",
"sessions"
],
"platform": "web"
},
"manifestVersion": 1
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/client-registry.js",
"lib/client-editor.js",
"lib/client-locale.js",
"lib/client-mermaid.js",
"lib/types/**/*.d.ts",
"src",
"scripts/install.sh",
"scripts/install.ps1",
"cordis.patch.yml",
"README.md",
"README_EN.md",
"LICENSE",
"icon.svg",
"locale/*.json",
"scripts/switch.sh"
],
"scripts": {
"build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit",
"bundle": "tsdown",
"watch": "tsdown --watch",
"prepare": "tsdown",
"test": "vitest run",
"test:windows": "vitest run --maxWorkers=1",
"test:mount": "bash scripts/e2e-mount.sh",
"test:mount:aggregate": "bash scripts/e2e-aggregate-mount.sh",
"clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"",
"check:consumer-types": "bash scripts/check-consumer-types.sh",
"lint": "eslint .",
"switch": "bash scripts/switch.sh",
"switch:status": "bash scripts/switch.sh --status",
"switch:aps": "bash scripts/switch.sh aps",
"switch:upstream": "bash scripts/switch.sh upstream"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.4",
"@deepseek-ai/dsh-agent": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
"@deepseek-ai/dsh-invariants": "^0.2.0-rc.1",
"@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
"@deepseek-ai/dsh-session": "^0.2.0-rc.1",
"@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
"@deepseek-ai/dsh-subagent": "^0.2.0-rc.1",
"@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependenciesMeta": {
"@huanlin/dsh-plugin-better-locale": {
"optional": true
},
"@deepseek-ai/dsh-client-ui-sidebar-right": {
"optional": true
}
},
"dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8",
"@deepseek-ai/schemastery": "^3.18.4",
"@lezer/highlight": "^1.2.3",
"clsx": "^2.1.1",
"dompurify": "^3.4.14",
"mermaid": "^11.16.1",
"react-icons": "5.7.0",
"rxjs": "^7.8.2",
"ws": "^8.18.0",
"yaml": "^2.9.1"
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/cordis": "4.0.4",
"@deepseek-ai/cordis-plugin-group": "1.0.4",
"@deepseek-ai/cordis-plugin-include": "1.0.9",
"@deepseek-ai/dsh-agent": "0.2.0-rc.1",
"@deepseek-ai/dsh-agent-loop": "0.2.0-rc.1",
"@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-store": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.1",
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
"@deepseek-ai/dsh-invariants": "0.2.0-rc.1",
"@deepseek-ai/dsh-llm": "0.2.0-rc.1",
"@deepseek-ai/dsh-ptc-runtime": "0.2.0-rc.1",
"@deepseek-ai/dsh-sandbox": "0.2.0-rc.1",
"@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.1",
"@deepseek-ai/dsh-scope": "0.2.0-rc.1",
"@deepseek-ai/dsh-session": "0.2.0-rc.1",
"@deepseek-ai/dsh-session-persistence": "0.2.0-rc.1",
"@deepseek-ai/dsh-session-projection": "0.2.0-rc.1",
"@deepseek-ai/dsh-settings": "0.2.0-rc.1",
"@deepseek-ai/dsh-subagent": "0.2.0-rc.1",
"@deepseek-ai/dsh-system-prompt": "0.2.0-rc.1",
"@deepseek-ai/dsh-tools": "0.2.0-rc.1",
"@deepseek-ai/dsh-user-approval": "0.2.0-rc.1",
"@deepseek-ai/dsh-util-code-language": "0.2.0-rc.1",
"@deepseek-ai/dsh-util-time": "0.2.0-rc.1",
"@deepseek-ai/dsh-util-workspace-path": "0.2.0-rc.1",
"@eslint/js": "^10.0.1",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
"@shikijs/langs": "^4.3.1",
"@types/mdast": "^4.0.4",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10",
"anser": "^2.3.5",
"cordis": "^4.0.0-rc.8",
"diff": "^9.0.0",
"eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"immer": "^10.1.1",
"jsdom": "^29.1.1",
"katex": "^0.16.47",
"lightningcss": "^1.32.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"mdast-util-math": "^3.0.0",
"micromark-core-commonmark": "^2.0.3",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-math": "^3.1.0",
"micromark-factory-space": "^2.0.1",
"micromark-util-character": "^2.1.1",
"micromark-util-classify-character": "^2.0.1",
"micromark-util-sanitize-uri": "^2.0.1",
"micromark-util-symbol": "^2.0.1",
"micromark-util-types": "^2.0.2",
"react": "^18.2.0",
"react-dom": "18.2.0",
"shiki": "^4.3.1",
"simple-icons": "^16.32.0",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"typescript-eslint": "^8.69.0",
"unrun": "^0.2.39",
"vitest": "^4.1.8",
"zustand": "~4.4.7"
},
"icon": "./icon.svg",
"forkedFrom": {
"name": "dsh-better-sidebar",
"version": "0.24.1",
"commit": "a2751cf",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
}
"license": "MIT"
}
-39
View File
@@ -1,39 +0,0 @@
/**
* Playwright config for the headless-render mount lane (tests/e2e).
*
* This lane does NOT spawn the server itself: `scripts/e2e-mount.sh` boots a
* real `dsh web` instance (with the npm-packed plugin mounted through the
* official `dsh plugin add` channel) and injects the base URL through
* `DSH_E2E_URL`. The spec's job is to render that URL in a headless Chromium
* and prove the plugin mounts without crashing the shell.
*
* Specs are named `*.e2e.ts` (not `*.spec.ts`) so vitest's default include
* never collects them; vitest.config.ts excludes the directory as well.
*/
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
// The lane's specs are named *.e2e.ts (so vitest's default include never
// collects them); tell Playwright to pick exactly that shape.
testMatch: '**/*.e2e.ts',
// A deterministic gate: no retries, one worker, one browser. The whole lane
// is a crash probe against one server instance, so serial execution keeps
// the assertions meaningful (a crash would trip the very next check).
retries: 0,
workers: 1,
fullyParallel: false,
timeout: 120_000,
reporter: [
['list'],
// Local debugging artifact; CI uploads the report dir on failure.
['html', { open: 'never' }],
],
use: {
browserName: 'chromium',
headless: true,
viewport: { width: 1440, height: 900 },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
})
+906 -3103
View File
File diff suppressed because it is too large Load Diff
+3 -67
View File
@@ -1,68 +1,4 @@
packages:
- .
# The @deepseek-ai/* packages declare their dsh-* siblings as peerDependencies
# (the DSH host bundle provides them all at mount time). This repo installs
# every peer the dev tree needs as a direct devDependency pinned to the
# baseline (see package.json), so pnpm resolves them instead of floating to
# stale copies — e.g. dsh-attachment 0.1.1-rc.1 predates the
# `admitPromptContent` export that dsh-subagent's artifacts import. When
# bumping the DSH baseline, audit with `pnpm peers check` and hoist any
# newly-unmet transitive peers into devDependencies too.
allowBuilds:
node-pty: true
minimumReleaseAgeExclude:
- '@deepseek-ai/*'
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1 || 1.0.6'
- '@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1 || 1.0.2'
- '@deepseek-ai/cordis@4.0.1-rc.1 || 4.0.1'
- '@deepseek-ai/cosmokit@1.8.2-rc.1 || 1.8.2'
- '@deepseek-ai/dsh-agent-default-model@0.0.1-rc.1'
- '@deepseek-ai/dsh-agent@0.0.1-rc.1'
- '@deepseek-ai/dsh-api-remotes@0.0.1-rc.1'
- '@deepseek-ai/dsh-atomic-write@0.0.1-rc.1'
- '@deepseek-ai/dsh-attachment@0.0.1-rc.1'
- '@deepseek-ai/dsh-brand@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-connection@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-locale@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-schema-form@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-conversation@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-primitives@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-settings@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-slash@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-slots@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-web-react@0.0.1-rc.1'
- '@deepseek-ai/dsh-commands@0.0.1-rc.1'
- '@deepseek-ai/dsh-compact@0.0.1-rc.1'
- '@deepseek-ai/dsh-credentials@0.0.1-rc.1'
- '@deepseek-ai/dsh-goal@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-apiproxy@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-directory-picker@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-webserver@0.0.1-rc.1'
- '@deepseek-ai/dsh-invariants@0.0.1-rc.1'
- '@deepseek-ai/dsh-llm-retry@0.0.1-rc.1'
- '@deepseek-ai/dsh-llm@0.0.1-rc.1'
- '@deepseek-ai/dsh-native-command@0.0.1-rc.1'
- '@deepseek-ai/dsh-paths@0.0.1-rc.1'
- '@deepseek-ai/dsh-scope@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-persistence@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-projection-cache@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-projection@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-query@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-title@0.0.1-rc.1'
- '@deepseek-ai/dsh-session@0.0.1-rc.1'
- '@deepseek-ai/dsh-settings@0.0.1-rc.1'
- '@deepseek-ai/dsh-skill@0.0.1-rc.1'
- '@deepseek-ai/dsh-storage-domain@0.0.1-rc.1'
- '@deepseek-ai/dsh-storage@0.0.1-rc.1'
- '@deepseek-ai/dsh-subagent@0.0.1-rc.1'
- '@deepseek-ai/dsh-system-prompt@0.0.1-rc.1'
- '@deepseek-ai/dsh-timeout@0.0.1-rc.1'
- '@deepseek-ai/dsh-token-meter@0.0.1-rc.1'
- '@deepseek-ai/dsh-tools@0.0.1-rc.1'
- '@deepseek-ai/dsh-type-meta@0.0.1-rc.1'
- '@deepseek-ai/dsh-typert-registry@0.0.1-rc.1'
- '@deepseek-ai/dsh-user-approval@0.0.1-rc.1'
- '@deepseek-ai/dsh-user-interaction@0.0.1-rc.1'
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
- '@deepseek-ai/schemastery@3.18.1-rc.1 || 3.18.1'
- '@huanlin/dsh-plugin-better-locale@0.1.0'
- dsh-better-sidebar@0.18.0
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env bash
# Consumer-facing type surface check: build a tiny standalone consumer that
# imports `dsh-apes-sidebar/client/service` WITHOUT @types/node and with
# skipLibCheck: false, then type-check it with the repo's tsc.
#
# This is the exact scenario the v0.12.0 API work cares about: the shipped
# declaration graph (service.d.ts -> context-types.d.ts -> state/api) must
# resolve and type-check for a browser-only plugin author. Run AFTER
# `pnpm build` (the check reads lib/types).
set -euo pipefail
cd "$(dirname "$0")/.."
# Git Bash (MSYS) `ln -s` deep-copies directories by default — copying this
# repo (node_modules + full .git history) would be catastrophic. Force real
# native symlinks instead: the GitHub Windows runners (Developer Mode, admin
# user) can create them, and nativestrict makes ln fail loudly rather than
# silently falling back to a copy. Inert on Linux/macOS.
export MSYS="winsymlinks:nativestrict"
# The check reads the BUILT declaration surface (lib/types) — fail loudly
# instead of silently passing when the build output is missing.
if [ ! -f lib/types/client/service.d.ts ]; then
echo "[check-consumer-types] FAIL: lib/types/client/service.d.ts not found — run 'pnpm build' first." >&2
exit 1
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/node_modules"
# Resolve link targets to their PHYSICAL path first: under pnpm,
# node_modules/@deepseek-ai/cordis is itself a symlink/junction into .pnpm,
# and on Windows a native symlink pointing AT a junction does not traverse
# for tsc (TS2307 on a link that visibly exists). Node's NATIVE realpath
# (GetFinalPathNameByHandle on Windows) expands junctions, which bash-level
# resolution may leave untouched; node is guaranteed here (tsc runs below).
resolve_dir() { node -e 'process.stdout.write(require("node:fs").realpathSync.native(process.argv[1]))' "$1"; }
ln -s "$(resolve_dir "$(pwd)")" "$WORK/node_modules/dsh-apes-sidebar"
# The vendored cordis base (and, through its realpath, cosmokit / standard-schema)
# must resolve so the consumer can type `Context` against the DSH runtime scope.
mkdir -p "$WORK/node_modules/@deepseek-ai"
ln -s "$(resolve_dir "$(pwd)/node_modules/@deepseek-ai/cordis")" "$WORK/node_modules/@deepseek-ai/cordis"
# Loud guard: both links must resolve to a package root, or tsc below would
# report misleading module-resolution errors.
for pkg_json in "$WORK/node_modules/dsh-apes-sidebar/package.json" "$WORK/node_modules/@deepseek-ai/cordis/package.json"; do
if [ ! -f "$pkg_json" ]; then
echo "[check-consumer-types] FAIL: $pkg_json does not resolve (broken link). Link layer:" >&2
ls -la "$WORK/node_modules" "$WORK/node_modules/@deepseek-ai" >&2 || true
exit 1
fi
done
cat > "$WORK/check.ts" <<'EOF'
import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-apes-sidebar/client/service'
import type {} from 'dsh-apes-sidebar/client/service'
import type {
BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps,
TabComponentProps, TabDescriptor,
} from 'dsh-apes-sidebar/client/service'
import type { SessionScope, SidebarSnapshot, SidebarState, SidebarStore, SidebarTab } from 'dsh-apes-sidebar/client/service'
import type { SidebarPrefs } from 'dsh-apes-sidebar/client/service'
// The vendored-cordis path: `Context` from '@deepseek-ai/cordis' plus the
// side-effect type import above must expose `ctx.betterSidebar` — the consumer
// never needs to import this package's own Context type.
import type { Context as VendoredContext } from '@deepseek-ai/cordis'
declare const ctx: { betterSidebar: BetterSidebarService }
const tab: TabDescriptor = {
id: 'x:tab',
title: 'X',
single: true,
badge: (_ctx, _scope, state: SidebarState) => state.expanded.length,
onOpen: (_tab: SidebarTab, _scope: SessionScope) => {},
onClose: (_tab: SidebarTab, _scope: SessionScope) => {},
createTab: (state: SidebarState) => ({ tab: { id: 'x:1', type: 'x:tab', title: 'X', meta: {} } }),
settings: {
toggles: [{ key: 'autoOpenSubagent', title: 'A' }],
pluginToggles: [{ key: 'k', title: 'K', type: 'number', min: 0, max: 9 }],
render: (props: SidebarSettingsRenderProps) => { props.updatePluginSetting('a', 1); return null },
},
component: (props: TabComponentProps) => null,
}
ctx.betterSidebar.registerTab(tab)
const viewer: FileViewerDescriptor = {
id: 'x:csv', exts: ['csv'], fetchStrategy: 'custom',
load: (_path, _scope, signal?: AbortSignal) => { void signal; return Promise.resolve([]) },
component: () => null,
}
ctx.betterSidebar.registerFileViewer(viewer)
const seed: OpenTabSeed = { type: 'x:tab', title: 'X', meta: { a: 1 } }
ctx.betterSidebar.openTab(seed, { sessionId: 's1' })
ctx.betterSidebar.openFile({ sessionId: 's1' }, '/p/a.csv')
ctx.betterSidebar.updateTab('t', { meta: { b: 2 } })
const snap: SidebarSnapshot = ctx.betterSidebar.getSnapshot()
const prefs: SidebarPrefs = snap.prefs
const store: SidebarStore = null as unknown as SidebarStore
void prefs; void store; void ctx.betterSidebar.version; void ctx.betterSidebar.features
void SIDEBAR_SERVICE_VERSION; void SIDEBAR_FEATURES
// Vendored-cordis augmentation path.
declare const vctx: VendoredContext
vctx.betterSidebar.registerTab(tab)
vctx.betterSidebar.registerFileViewer(viewer)
void vctx.betterSidebar.getSnapshot()
EOF
cat > "$WORK/tsconfig.json" <<'EOF'
{
"compilerOptions": {
"target": "ES2023", "module": "esnext", "moduleResolution": "bundler",
"strict": true, "noEmit": true, "skipLibCheck": false, "types": [],
"jsx": "react-jsx", "lib": ["ES2023", "DOM"]
},
"include": ["check.ts"]
}
EOF
echo "[check-consumer-types] type-checking a browser-only consumer (no @types/node)..."
echo "[check-consumer-types] pass 1: skipLibCheck: true (the realistic consumer experience) — must be fully clean"
./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck true
echo "[check-consumer-types] pass 2: skipLibCheck: false — OUR declaration surface (lib/types/**) must be clean"
echo "[check-consumer-types] (upstream cordis/cosmokit declaration noise is filtered out; it predates this plugin)"
set +e
./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck false > "$WORK/strict.log" 2>&1
STATUS=$?
set -e
# Fail only on errors that mention OUR package / declarations / the fixture.
# Upstream noise (vendored cordis / cosmokit declaration warnings under the
# repo's pinned TS lib) lives anywhere under node_modules and is filtered.
if grep -E "dsh-apes-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -vE "node_modules/[^ ]*(cordis|cosmokit)"; then
echo "[check-consumer-types] FAIL: errors in the dsh-apes-sidebar declaration surface:" >&2
grep -E "dsh-apes-sidebar|lib/types|check\.ts" "$WORK/strict.log" >&2
exit 1
fi
if [ "$STATUS" -ne 0 ]; then
echo "[check-consumer-types] pass 2 note: strict mode only reports upstream declaration noise:"
grep -v "dsh-apes-sidebar\|lib/types\|check\.ts" "$WORK/strict.log" | head -5 || true
fi
echo "[check-consumer-types] OK: the client/service declaration surface is node-free and self-contained."
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-apes-sidebar 聚合双挂载冒烟(CI + 本地):
#
# 场景:一个聚合 bundle(tests/fixtures/aggregate-better-sidebar)先以独立
# 条目 id 挂载 dsh-apes-sidebar,插件自身的 bundle patch 后到 —— 旧行为
# 两个实例都注册 /sidebar/api,整个插件树启动失败(duplicate prefix
# route);新行为插件行检测到已有启用中的同包名挂载后自动 disabled,由
# 聚合行接管 sidebar。
#
# 流程:
# 1. 打包 fixture 聚合包;
# 2. 用官方 CLI 把 fixture 先装进全新 scratch profile,再把插件 tarball
# 装进同一 profile(复刻真实安装顺序:聚合先行、dsh plugin add 后到);
# 3. 启动真实 `dsh web`(keyless,--port 0);
# 4. 断言:日志出现启动 URL、无 "duplicate prefix route"、/sidebar/api
# 路由存活(POST 未知方法应返回 404 JSON 而非崩溃)。
#
# 用法:
# bash scripts/e2e-aggregate-mount.sh
#
# 环境变量(均可省略):
# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包
# (同 e2e-mount.sh)
# TARBALL 插件 tarball;缺省仓库根 dsh-apes-sidebar-*.tgz(须已 pack)
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
# DSH_HOME_BASE scratch 根目录(默认系统临时目录)。脚本始终在其下新建
# 本调用拥有的独立子目录,只写入/删除该子目录;调用方提供
# 的目录本身(可能是真实 ~/.dsh)绝不写入或删除。
# KEEP_HOME 非空时保留 scratch 目录(调试用)
#
# 退出码 = 0 通过;非 0 失败。服务器与 scratch 目录由 trap 兜底清理。
# 日志/前置校验/DSH_CMD 与 tarball 解析/scratch profile 三件套/清理 trap/
# dsh web 启动与就绪轮询的共享骨架见 scripts/e2e-common.sh。
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FIXTURE_DIR="$ROOT/tests/fixtures/aggregate-better-sidebar"
E2E_TAG=e2e-aggregate-mount
source "$SCRIPT_DIR/e2e-common.sh"
DSH_CMD="${DSH_CMD:-dsh}"
TARBALL="${TARBALL:-}"
PORT="${PORT:-0}"
KEEP_HOME="${KEEP_HOME:-}"
e2e_require_cmd node "DSH 运行需要 Node.js >= 20"
e2e_require_cmd pnpm "dsh plugin 转发给 pnpm"
e2e_require_cmd npm "打包 fixture 需要"
e2e_require_cmd curl
e2e_resolve_dsh_cmd
e2e_resolve_tarball || die "未找到插件 tarball:先在本仓库跑 pnpm build && npm pack,或用 TARBALL 指定"
# ── scratch home ────────────────────────────────────────────────────────────
# DSH_HOME 直接用 $SCRATCH(mount lane 用子路径 home/——两 lane 既有差异);
# DSH_HOME_BASE 语义与清理 trap 见 e2e-common.sh。
e2e_make_scratch dsh-e2e-agg
export DSH_HOME="$SCRATCH"
LOG_DIR="$DSH_HOME/logs"; mkdir -p "$LOG_DIR"
OUT_LOG="$LOG_DIR/dsh-web.out.log"; ERR_LOG="$LOG_DIR/dsh-web.err.log"
SERVER_PID=""
TMP_DIR=""
trap e2e_cleanup EXIT
# ── 打包 fixture 聚合包 ─────────────────────────────────────────────────────
TMP_DIR="$(mktemp -d)"
( cd "$FIXTURE_DIR" && npm pack --silent --pack-destination "$TMP_DIR" >/dev/null )
FIXTURE_TGZ="$(ls "$TMP_DIR"/*.tgz 2>/dev/null | head -1 || true)"
[ -n "$FIXTURE_TGZ" ] || die "fixture 打包失败"
# ── 引导 scratch profile(web 模板)────────────────────────────────────────
# 三件套 heredoc 及 pnpm-workspace 的 minimumReleaseAgeExclude
# 理由见 e2e-common.sh 的 e2e_write_profile。
PROFILE_DIR="$DSH_HOME/profiles/web"
e2e_write_profile "$PROFILE_DIR"
# ── 组装 profile:聚合先行,插件后到 ──────────────────────────────────────
say "安装 fixture 聚合包(先行)…"
$DSH_CMD plugin --profile web add "file:$FIXTURE_TGZ"
say "安装插件 tarball(后到)…"
$DSH_CMD plugin --profile web add "file:$TARBALL"
# ── 启动真实 dsh web ───────────────────────────────────────────────────────
say "启动 dsh web(--port ${PORT},日志 ${LOG_DIR})…"
e2e_start_dsh_web "$OUT_LOG" "$ERR_LOG"
# 等待启动 URL 或进程退出(最多 120s)。这里有意只取 origin:0.1.2-alpha.1+
# 的就绪行是 `…/?token=<43字符>` 鉴权 URL,但下方的探活全部打插件的
# /sidebar/api/* 路由——webserver carrier 不做鉴权(只有 /api、index 与
# remote.mux 升级在 browser auth 之后),origin 拼路径即正确且两版通用。
E2E_READY_RE='http://127\.0\.0\.1:[0-9]+'
E2E_READY_PICK=tail
e2e_wait_dsh_web_ready "$OUT_LOG" || true
if [ -z "$URL" ]; then
warn "out log 尾部:$(tail -5 "$OUT_LOG" 2>/dev/null || true)"
warn "err log 尾部:$(tail -5 "$ERR_LOG" 2>/dev/null || true)"
die "dsh web 未在 120s 内启动"
fi
say "已启动:$URL"
# ── 断言 ────────────────────────────────────────────────────────────────────
if grep -q "duplicate prefix route" "$ERR_LOG" "$OUT_LOG" 2>/dev/null; then
die "检测到 duplicate prefix route(双挂载未退让)"
fi
# 真实方法探活:settings.get 是插件侧栏卡偏好的读取端(writeOk → HTTP 200 +
# {"ok":true,...};未挂 settings 服务的部署也照回 ok,值取缺省)。选它当哨兵
# 的理由:/sidebar/api 是插件自己 fence 的 prefix 路由,未注册的方法统统 404,
# 所以 {"ok":true} 只可能来自插件真实注册的 handler —— generic missing-route
# 404 无法冒充(P2: Probe a real sidebar API method)。
RESP="$(curl -s -X POST "$URL/sidebar/api/settings.get" 2>/dev/null || true)"
if ! printf '%s' "$RESP" | grep -q '"ok":true'; then
die "/sidebar/api/settings.get 未返回 ok:true(响应:$(printf '%s' "$RESP" | head -c 200))"
fi
say "/sidebar/api/settings.get → ok:true(真实 handler 存活)"
STATUS="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$URL/sidebar/api/__e2e_unknown__" 2>/dev/null || true)"
say "/sidebar/api POST 未知方法 → HTTP ${STATUS}(期望 404)"
[ "$STATUS" = "404" ] || die "/sidebar/api 未知方法未按预期返回 404(HTTP ${STATUS})"
say "通过:聚合双挂载场景下插件自动退让,侧边栏由聚合行接管。"
-200
View File
@@ -1,200 +0,0 @@
# =============================================================================
# scripts/e2e-common.sh — e2e-mount.sh / e2e-aggregate-mount.sh 的共享骨架:
# 日志三件套、前置校验、DSH_CMD / tarball 解析、scratch home 与清理 trap、
# scratch profile 三件套、dsh web 后台启动与就绪轮询。
#
# 纯开发侧库文件,不进 npm 包(package.json files 白名单无它);
# install.sh / install.ps1 随 npm 包独立分发,必须自包含,绝不 source 本文件。
#
# 用法:调用方先设 E2E_TAG(say/warn/die 的日志前缀)再 source:
# E2E_TAG=e2e-mount
# source "$SCRIPT_DIR/e2e-common.sh"
#
# 依赖调用方已 `set -euo pipefail` 并定义 ROOT(仓库根绝对路径)。
# 两侧脚本有意保留的差异(行为等价,靠参数 / 调用点区分):
# - DSH_HOME 子路径:mount 用 $SCRATCH/home,aggregate 直接用 $SCRATCH;
# - 就绪行 grep 正则:mount 要带 token 的完整 URL,aggregate 只取 origin
# (两侧的理由见各自脚本的就绪行注释);
# - 额外清理项:aggregate 的 fixture 打包 TMP_DIR(mount 不设即跳过)。
# =============================================================================
: "${E2E_TAG:?必须先设置 E2E_TAG(日志前缀)再 source e2e-common.sh}"
# ── 日志三件套 ───────────────────────────────────────────────────────────────
say() { printf '\033[32m[%s]\033[0m %s\n' "$E2E_TAG" "$*"; }
warn() { printf '\033[33m[%s]\033[0m %s\n' "$E2E_TAG" "$*" >&2; }
die() { printf '\033[31m[%s]\033[0m %s\n' "$E2E_TAG" "$*" >&2; exit 1; }
# ── 前置校验 ─────────────────────────────────────────────────────────────────
# e2e_require_cmd <cmd> [用途]:不在 PATH 上即 die(沿用两脚本原有报错文案)。
e2e_require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
if [ $# -ge 2 ]; then
die "未找到 $1($2)"
else
die "未找到 $1"
fi
fi
}
# ── DSH_CMD 解析 ─────────────────────────────────────────────────────────────
# PATH 上的 dsh 优先,否则 npx 拉官方包(同 scripts/install.sh)。DSH_CMD
# 缺省值(`dsh`)由调用方从环境变量取好传入。
#
# 回退的 npx 版本是显式钉死的:不钉就会解析 npm 的 `latest` dist-tag,而
# 0.2.0 线目前只有预发布(`latest` 仍是 0.1.7-rc.2,0.2.0-rc.1 走 `next`),
# 冒烟会静静地挂到一个插件已不支持的宿主上。**必须写精确版本,不能写
# `@next`/`@alpha` 之类的 dist-tag**:0.1.7-rc.1 上线时 `alpha` 指的是
# 0.1.7-alpha.2,0.2.0-rc.1 又只挂在 `next` 上。钉版必须与 package.json 的
# peer 下限同步(caret 跨 minor 不成立:`^0.1.7-rc.1` 实测不满足 0.2.0-rc.1)。
DSH_NPX_SPEC="${DSH_NPX_SPEC:-@deepseek-ai/dsh@0.2.0-rc.1}"
e2e_resolve_dsh_cmd() {
local explicit="${DSH_CMD}"
if ! command -v "$DSH_CMD" >/dev/null 2>&1; then
if command -v npx >/dev/null 2>&1; then
say "PATH 上无 ${DSH_CMD},回退 npx -y --package ${DSH_NPX_SPEC}"
DSH_CMD="npx -y --package ${DSH_NPX_SPEC} dsh"
else
die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定"
fi
fi
e2e_check_dsh_version "$explicit"
}
# 解析出的 CLI 版本必须与 peer 下限同一条支持线。这不是洁癖:宿主只比
# 基线早一个预发布时,`dsh plugin add` 的 bundle 协调会静默不写
# `dsh.profile.bundles`——CLI 退出码仍是 0,lane 却挂在「挂载未注册」上,
# 排查方向完全被带偏(本机 PATH 上的 dsh 一直是上一版,0.1.6-alpha.1 与
# 0.1.6-alpha.2 各实测过一次)。DSH_CMD 显式给出时不拦(调用方明确知道自
# 己在挂什么),只告警。
e2e_check_dsh_version() {
local explicit="$1" actual expected
expected="${DSH_EXPECT_VERSION:-0.2.0-rc.1}"
# `dsh --version` 冷启动要走 npx,给足预算;拿不到版本就只告警。
actual="$($DSH_CMD --version 2>/dev/null | tr -d '[:space:]' | head -c 64 || true)"
case "$actual" in
"$expected"|"$expected"*) return 0 ;;
esac
if [ "$explicit" = "dsh" ] && [ -n "$actual" ]; then
die "PATH 上的 dsh 是 ${actual},本仓库的基线是 ${expected}(peer 下限同一支持线)。请改用 DSH_CMD='npx -y --package @deepseek-ai/dsh@${expected} dsh',或安装该版本。"
fi
warn "DSH_CMD 版本为 ${actual:-未知},基线是 ${expected}——真机证据落在非基线宿主上。"
}
# ── tarball 解析 ─────────────────────────────────────────────────────────────
# TARBALL 已显式给出时原样使用;否则从仓库根 glob 里选。多个候选时取 mtime
# 最新——`ls | head -1` 的字典序会拿到旧版本号的历史 tarball,把冒烟挂到
# 过期产物上。未找到候选时返回非零,由调用方按各自文案 die。
e2e_resolve_tarball() {
if [ -z "${TARBALL:-}" ]; then
TARBALL="$(ls -t "$ROOT"/dsh-apes-sidebar-*.tgz 2>/dev/null | head -1 || true)"
local count
count="$(ls "$ROOT"/dsh-apes-sidebar-*.tgz 2>/dev/null | wc -l | tr -d ' ' || true)"
[ "$count" -le 1 ] || warn "发现 $count 个 tarball,按 mtime 选用最新:$(basename "$TARBALL")(建议清理其余)"
fi
[ -n "$TARBALL" ] && [ -f "$TARBALL" ]
}
# ── scratch home 与清理 ──────────────────────────────────────────────────────
# 始终在本调用拥有的全新目录里运行:调用方给了 DSH_HOME_BASE(可能是真实
# ~/.dsh)时,只在其下新建子目录并只删除该子目录;缺省时直接用系统临时
# 目录。调用方提供的目录本身绝不写入或删除。
# $1 = mktemp 模板短名(dsh-e2e-mount / dsh-e2e-agg,便于区分两 lane 的残留)。
# DSH_HOME 用 $SCRATCH 还是其子路径由调用方自行 export(两 lane 的既有差异)。
e2e_make_scratch() {
if [ -n "${DSH_HOME_BASE:-}" ]; then
SCRATCH="$(mktemp -d "$DSH_HOME_BASE/$1.XXXXXX")"
else
SCRATCH="$(mktemp -d /tmp/$1.XXXXXX)"
fi
}
# EXIT trap 兜底清理:杀 dsh web 后台进程;清 aggregate lane 的 fixture 打包
# 目录(TMP_DIR——mount lane 不设该变量即整段跳过);最后按 KEEP_HOME 决定
# 是否删除 scratch。结尾显式 exit 回传 trap 触发时的退出码。
e2e_cleanup() {
local code=$?
if [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [ -n "${TMP_DIR:-}" ] && [ -d "${TMP_DIR:-}" ]; then
rm -rf "$TMP_DIR"
fi
if [ -z "${KEEP_HOME:-}" ]; then
if [ -d "${SCRATCH:-}" ]; then
rm -rf "$SCRATCH"
fi
else
warn "KEEP_HOME 已设置,保留 $SCRATCH"
fi
exit "$code"
}
# ── scratch profile 三件套 ───────────────────────────────────────────────────
# 引导 scratch profile(web 模板,镜像 dsh initProfile);先写
# pnpm-workspace.yaml 的 minimumReleaseAgeExclude,避免 pnpm 11 拒绝 <24h 新版本
# ——同 install.sh。@deepseek-ai/* 通配与仓库根 pnpm-workspace.yaml 同策:钉的
# DSH alpha 常在发布后 24h 内跑 lane,没有豁免会被 minimumReleaseAge 直接拒装。
e2e_write_profile() {
mkdir -p "$1"
cat > "$1/package.json" <<EOF
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
}
}
}
EOF
printf '[]\n' > "$1/cordis.patch.yml"
cat > "$1/pnpm-workspace.yaml" <<'EOF'
packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
minimumReleaseAgeExclude:
- dsh-apes-sidebar
- '@deepseek-ai/*'
EOF
}
# ── dsh web 启动 + 就绪轮询 ──────────────────────────────────────────────────
# 后台启动 dsh web(--port 0 = OS 分配,避免端口冲突;keyless 可起)。
# $1 = stdout 日志;$2 = stderr 日志(缺省合并进 $1:mount lane 单文件,
# aggregate lane 分 out/err 两个文件)。写入全局 SERVER_PID。
e2e_start_dsh_web() {
if [ $# -ge 2 ]; then
$DSH_CMD web --port "$PORT" >"$1" 2>"$2" &
else
$DSH_CMD web --port "$PORT" >"$1" 2>&1 &
fi
SERVER_PID=$!
}
# 轮询日志等待就绪行(最多 120s)。$1 = 参与轮询的日志文件;就绪行 ERE 与
# 多条命中的取舍(head/tail)由 E2E_READY_RE / E2E_READY_PICK 传入——这是
# 两条 lane 有意保留的差异(token URL vs 裸 origin,理由见各自脚本的就绪
# 行注释)。URL 提取统一为「命中串按空白分段取末段」:mount 的模式带
# `dsh web: ` 前缀(两段)故末段即完整 URL;aggregate 的模式不含空白故末段
# 即 origin——与原先两侧的 awk '{print $3}' / 直接取整行等价。
# 返回:0 = URL 已写入全局 URL;1 = 120s 超时;2 = 服务进程提前退出。
e2e_wait_dsh_web_ready() {
URL=""
local _
for _ in $(seq 1 120); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
return 2
fi
if URL="$(grep -oE "$E2E_READY_RE" "$1" | "${E2E_READY_PICK:-head}" -1 | awk '{print $NF}')" && [ -n "$URL" ]; then
return 0
fi
sleep 1
done
return 1
}
-119
View File
@@ -1,119 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-apes-sidebar 挂载冒烟编排(CI + 本地):
#
# 1. 用官方 CLI 把 npm 打包产物(tarball)真实挂载进一个全新 scratch
# profile(`dsh plugin --profile web add file:<tarball>`,触发
# dsh.profile.bundles 协调,与用户安装路径一致);
# 2. 启动真实 `dsh web`(keyless,--port 0 取 OS 分配端口);
# 3. 运行 tests/e2e 无头渲染 lane(Playwright Chromium):断言外壳与
# 插件挂载、无崩溃标记,并驱动内置 tab 深扫。
#
# 用法:
# bash scripts/e2e-mount.sh [--grep <playwright-filter>]
#
# 环境变量(均可省略):
# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包
# TARBALL 插件 tarball;缺省仓库根 dsh-apes-sidebar-*.tgz(须已 pack)
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
# DSH_HOME_BASE 覆盖 scratch 根目录(默认系统临时目录)。脚本始终在其下
# 新建本调用拥有的独立子目录,只写入/删除该子目录;调用方
# 提供的目录本身(可能是真实 ~/.dsh)绝不写入或删除。
# KEEP_HOME 非空时保留 scratch home(调试用)
#
# 退出码 = playwright 的退出码;服务器与 scratch 目录由 trap 兜底清理。
# 日志/前置校验/DSH_CMD 与 tarball 解析/scratch profile 三件套/清理 trap/
# dsh web 启动与就绪轮询的共享骨架见 scripts/e2e-common.sh。
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
E2E_TAG=e2e-mount
source "$SCRIPT_DIR/e2e-common.sh"
DSH_CMD="${DSH_CMD:-dsh}"
PORT="${PORT:-0}"
TARBALL="${TARBALL:-}"
GREP_FILTER=""
if [ "${1:-}" = "--grep" ]; then GREP_FILTER="${2:?--grep 需要参数}"; fi
e2e_require_cmd node "DSH 运行需要 Node.js >= 20"
e2e_require_cmd pnpm "dsh plugin 转发给 pnpm"
e2e_resolve_dsh_cmd
e2e_resolve_tarball || die "找不到 tarball(TARBALL 或 \$ROOT/dsh-apes-sidebar-*.tgz)——先运行 pnpm build && pnpm pack"
TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")"
say "tarball: $TARBALL"
# scratch home(每次全新,绝不触碰真实 ~/.dsh;DSH_HOME_BASE 语义见
# e2e-common.sh)。DSH_HOME 用子路径 home/(aggregate lane 直接用 $SCRATCH,
# 两 lane 既有差异):playwright lane 还需要独立的 workspace 目录,与
# DSH_HOME 的 profiles 布局分开。
e2e_make_scratch dsh-e2e-mount
export DSH_HOME="$SCRATCH/home"
WORKSPACE_DIR="$SCRATCH/workspace"
LOG_DIR="$SCRATCH"
WEB_LOG="$LOG_DIR/web.log"
mkdir -p "$DSH_HOME/profiles/web" "$WORKSPACE_DIR"
say "scratch home: ${DSH_HOME}(DSH_HOME=${DSH_HOME})"
SERVER_PID=""
trap e2e_cleanup EXIT
# 步骤 1:引导 scratch profile(三件套 heredoc 及其理由见 e2e-common.sh
# 的 e2e_write_profile)
PROFILE_DIR="$DSH_HOME/profiles/web"
e2e_write_profile "$PROFILE_DIR"
# 步骤 2:官方 CLI 安装 tarball + bundle 协调(真实挂载路径)
say "执行 dsh plugin --profile web add file:$TARBALL ..."
$DSH_CMD plugin --profile web add "file:$TARBALL"
# 步骤 3:校验挂载生效(dsh.profile.bundles 含 dsh-apes-sidebar)
if ! node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const bundles = p.dsh?.profile?.bundles ?? [];
process.exit(bundles.includes("dsh-apes-sidebar") ? 0 : 1);
' "$PROFILE_DIR/package.json"; then
warn "dsh-apes-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册"
cat "$PROFILE_DIR/package.json"
exit 1
fi
say "挂载已注册:dsh.profile.bundles 包含 dsh-apes-sidebar"
# 步骤 4:启动 dsh web(--port 0 = OS 分配,避免端口冲突;keyless 可起)
say "启动 dsh web(port=${PORT})..."
e2e_start_dsh_web "$WEB_LOG"
# 就绪行解析:DSH 0.1.2-alpha.1+ 打印的是带一次性 token 的鉴权 URL
# (`dsh web: http://127.0.0.1:<port>/?token=<43字符>`,页面导航用它换取
# 浏览器 cookie;干净 URL 只会得到 401);0.1.1-rc.x 及更早是裸 origin。
# 匹配必须延伸到空白(`[^ ]*`)——在 `/` 处截断会丢掉 token,alpha.1+
# 宿主上的整条 lane 都会挂在首屏 401。LAN 后缀 `(LAN: …)` 是下一个
# 空格分段,不会被并进来。
E2E_READY_RE='dsh web: http://127\.0\.0\.1:[0-9]+[^ ]*'
E2E_READY_PICK=head
WAIT_RC=0
e2e_wait_dsh_web_ready "$WEB_LOG" || WAIT_RC=$?
if [ "$WAIT_RC" -eq 2 ]; then
echo "=== dsh web 提前退出,日志尾部 ===" >&2
tail -30 "$WEB_LOG" >&2 || true
exit 1
fi
if [ "$WAIT_RC" -ne 0 ]; then
echo "=== 120s 内未等到 dsh web 就绪,日志尾部 ===" >&2
tail -40 "$WEB_LOG" >&2 || true
exit 1
fi
say "dsh web 就绪:${URL}(pid ${SERVER_PID})"
# 步骤 5:运行无头渲染 lane
say "运行 Playwright 无头渲染 lane..."
DSH_E2E_URL="$URL" DSH_E2E_WORKSPACE="$WORKSPACE_DIR" \
pnpm exec playwright test ${GREP_FILTER:+--grep "$GREP_FILTER"}
say "通过:插件挂载到真实 DSH 后无头渲染未崩溃"
-263
View File
@@ -1,263 +0,0 @@
## =============================================================================
# dsh-apes-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-apes-sidebar@<version>
#
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
# 独立包被 profile 引用。
#
# 用法(任选其一):
# # 默认最新版
# $script = (irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1').TrimStart([char]0xFEFF)
# & ([scriptblock]::Create($script))
# # 指定版本 / 装完重启
# $script = (irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1').TrimStart([char]0xFEFF)
# & ([scriptblock]::Create($script)) -Version 0.10.2 -Restart
# # 本地保存后运行
# powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.10.2 -DryRun
#
# 参数:
# -Version npm 版本号/范围,缺省 latest(自动解析为最新)。
# -Profile 目标 profile 名(缺省 web);安装与修复模式均适用。
# -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。
# -DryRun 只打印将要执行的操作,不写任何文件。
#
# 环境变量(均可省略):
# DSH_HOME 默认 %USERPROFILE%\.dsh
# REGISTRY 默认 https://registry.npmjs.org
# DSH_CMD 默认优先 PATH 上的 dsh,缺省回退 npx -y --package @deepseek-ai/dsh
#
# 说明:
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载。
# 脚本会幂等移除 better-sidebar 挂载行。
# =============================================================================
param(
[string]$Version = '',
[switch]$Restart,
[switch]$DryRun,
[string]$Profile = 'web'
)
$PKG = 'dsh-apes-sidebar'
$REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
# DSH_HOME:DSH_HOME 环境变量 > %USERPROFILE% > $HOME
if ($env:DSH_HOME) {
$DSH_HOME = $env:DSH_HOME
} elseif ($env:USERPROFILE) {
$DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
} else {
$DSH_HOME = Join-Path $HOME '.dsh'
}
$PROFILE_DIR = Join-Path $DSH_HOME "profiles\$Profile"
$WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
$PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
function Say([string]$m) { Write-Host "[install] $m" -ForegroundColor Green }
function Warn([string]$m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
function Die([string]$m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
# 解析版本 -> npm spec("x.y.z" / "^x.y.z" / latest)
function Resolve-Spec {
param([string]$Given)
if ([string]::IsNullOrWhiteSpace($Given) -or $Given -eq 'latest') {
$v = $null
foreach ($tool in @('npm', 'pnpm')) {
if (Get-Command $tool -ErrorAction SilentlyContinue) {
$v = (& $tool view $PKG version "--registry=$REGISTRY" 2>$null | Select-Object -Last 1)
if ($v) { break }
}
}
if ($v) { return ([string]$v).Trim() }
Warn '无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。'
Warn '若已知版本号,可显式传入:-Version 0.10.2'
return 'latest'
}
return $Given
}
# 组装 dsh CLI:优先 PATH 上的 dsh,缺省 npx 拉官方包
function Get-DshCli {
if ($env:DSH_CMD) { return $env:DSH_CMD }
if (Get-Command dsh -ErrorAction SilentlyContinue) { return 'dsh' }
if (Get-Command npx -ErrorAction SilentlyContinue) { return 'npx' }
return $null
}
# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截
# 放行本插件新版本(0.20.0 起插件不再依赖需要构建脚本的包,allowBuilds 无需再动)
function Ensure-WorkspaceSettings {
$wsScript = @'
const fs = require("fs");
const p = process.argv[2];
let t = fs.readFileSync(p, "utf8");
const before = t;
if (!/^\s*-\s+dsh-apes-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-apes-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-apes-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
'@
# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被
# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。
# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。
$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js")
Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
$wsOut = node $wsJs "$WS_YML" 2>&1
$wsCode = $LASTEXITCODE
Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
$wsResult = (($wsOut | Out-String)).Trim()
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
if ($wsResult -eq 'updated') {
Say "已确保 $WS_YML:minimumReleaseAgeExclude($PKG)"
} else {
Say 'workspace 设置已就绪,跳过'
}
}
# 前置校验
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。'
}
if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) {
Die '未找到 pnpm;安装脚本需要 pnpm >= 10。请先安装或升级 pnpm 并加入 PATH。'
}
$pnpmVersion = (& pnpm --version 2>$null | Select-Object -Last 1)
if ($LASTEXITCODE -ne 0 -or -not $pnpmVersion) {
Die '无法读取 pnpm 版本;安装脚本需要 pnpm >= 10。'
}
$pnpmVersionText = ([string]$pnpmVersion).Trim()
$pnpmMajor = 0
if (-not [int]::TryParse(($pnpmVersionText -split '\.')[0], [ref]$pnpmMajor)) {
Die "无法解析 pnpm 版本(输出:$pnpmVersionText);安装脚本需要 pnpm >= 10。"
}
if ($pnpmMajor -lt 10) {
Die "安装脚本需要 pnpm >= 10;当前版本为 $pnpmVersionText。请升级 pnpm 后重试。"
}
if (-not (Test-Path $PROFILE_DIR)) {
Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)"
}
if (-not (Test-Path $WS_YML)) {
Die "找不到 $WS_YML(请先初始化 $Profile profile)"
}
$SPEC = Resolve-Spec $Version
$CLI = Get-DshCli
if (-not $CLI) {
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
}
Say "目标:$CLI plugin --profile $Profile add $PKG@$SPEC(profile: $PROFILE_DIR)"
if ($DryRun) {
Say "[dry-run] 步骤 1:确保 $WS_YML 含 minimumReleaseAgeExclude($PKG)"
Say "[dry-run] 步骤 2:执行 $CLI plugin --profile $Profile add $PKG@$SPEC(安装 + bundle 自动注册)"
Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' }
exit 0
}
# 步骤 1:预写 workspace 设置(幂等),放行本插件新版本
Ensure-WorkspaceSettings
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
if ($CLI -eq 'dsh') {
$cliArgs = @('plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
} else {
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
}
Say "执行 $CLI plugin --profile $Profile add $PKG@$SPEC ..."
$addOut = & $CLI @cliArgs 2>&1
$addCode = $LASTEXITCODE
$addOut | ForEach-Object { $_ }
if ($addCode -ne 0) {
Warn 'dsh plugin add 失败。已预写 minimumReleaseAgeExclude,仍失败的可能原因:'
Warn ' - 网络/登录问题:npm registry 不可达或需要登录。'
Warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR; pnpm install。"
exit 1
}
# 步骤 3:校验 bundle 已注册(挂载生效的判据)
$pkgJson = Get-Content -Raw (Join-Path $PROFILE_DIR 'package.json') | ConvertFrom-Json
$bundles = $pkgJson.dsh.profile.bundles
if ($bundles -notcontains $PKG) {
Warn 'dsh-apes-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。'
Warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本(0.20.0 起本插件自身不再有构建脚本依赖)。"
exit 1
}
Say "bundle 已注册:dsh.profile.bundles 包含 $PKG(下次启动自动挂载)"
# 步骤 4:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
$mountScript = @'
const fs = require("fs");
const p = process.argv[2];
const lines = fs.readFileSync(p, "utf8").split("\n");
const out = [];
let i = 0;
let removed = false;
while (i < lines.length) {
const line = lines[i];
if (/^[ \t]*- insert:\s*$/.test(line)) {
const block = [line];
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
block.push(lines[j]);
j++;
}
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
i = j;
removed = true;
continue;
}
}
out.push(line);
i++;
}
if (!removed) {
console.log("none");
} else {
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
fs.writeFileSync(p, t);
console.log("removed");
}
'@
$mountJs = Join-Path $env:TEMP ("dshbs-mount-" + [guid]::NewGuid().ToString("N") + ".js")
Set-Content -LiteralPath $mountJs -Value $mountScript -Encoding UTF8
$mountOut = node $mountJs "$PATCH_YML" 2>&1
$mountCode = $LASTEXITCODE
Remove-Item -LiteralPath $mountJs -Force -ErrorAction SilentlyContinue
$mountResult = (($mountOut | Out-String)).Trim()
if ($mountCode -ne 0) { Die "处理 $PATCH_YML 失败(node 退出码 $mountCode):$mountResult" }
if ($mountResult -eq 'removed') {
Say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)"
} else {
Say '无旧手动挂载行,跳过'
}
Say "安装完成:$PKG@$SPEC"
# 步骤 5:重启提示
if ($Restart) {
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
Say '重启 dsh-web(pm2)...'
pm2 restart dsh-web
if ($LASTEXITCODE -ne 0) { Warn 'pm2 restart 失败,请手动重启 DSH' }
} else {
Warn '未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)'
}
} else {
Say '下一步:重启 DSH 并硬刷新(Ctrl+Shift+R / Cmd+Shift+R)使新副本生效。'
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
Say '本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)'
}
}
-247
View File
@@ -1,247 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-apes-sidebar 一键安装脚本(官方 CLI 方式,macOS / Linux / Windows Git Bash)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-apes-sidebar@<version>
#
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
# 独立包被 profile 引用。
#
# 用法:
# bash scripts/install.sh [版本] [--restart] [--dry-run]
#
# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
# 示例:0.10.2、^0.10.2、~0.10.2、latest
# --profile 目标 profile 名(缺省 web)。
# --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅打印提示)。
# 注意:重启会断开当前 DSH 页面会话,默认不自动重启。
# --dry-run 只打印将要执行的操作,不写任何文件。
# -h/--help 打印本帮助。
#
# 环境(均可省略,脚本会自动探测):
# DSH_HOME 默认 ~/.dsh(Windows Git Bash 下回退 $USERPROFILE/.dsh)
# REGISTRY 默认 https://registry.npmjs.org(发布源;装依赖仍走 pnpm 配置)
# DSH_CMD 默认优先用 PATH 上的 `dsh`,缺省回退 npx -y --package @deepseek-ai/dsh
#
# 说明:
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载
# (Node 半挂两次、页面两个侧边栏)。脚本会幂等移除 better-sidebar 挂载行。
# - 回滚:dsh plugin --profile web remove dsh-apes-sidebar,或把 profile 依赖
# 改回 "dsh-apes-sidebar": "link:<路径>" 再 pnpm install。
# =============================================================================
set -euo pipefail
# 帮助请求优先处理
for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
cat <<'EOF'
dsh-apes-sidebar 一键安装脚本
用法:
bash scripts/install.sh [版本] [--restart] [--dry-run] [--profile <名>]
版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest
--profile 目标 profile 名(缺省 web)
--restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)
--dry-run 只打印将要执行的操作,不写任何文件
环境变量(可省略):DSH_HOME(默认 ~/.dsh)、REGISTRY(npm 源)、DSH_CMD(dsh 命令)
EOF
exit 0
fi
done
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
PKG="dsh-apes-sidebar"
DSH_CMD="${DSH_CMD:-dsh}"
RESTART=false
DRY_RUN=false
VERSION_SPEC=""
PROFILE_NAME="web"
while [ $# -gt 0 ]; do
case "$1" in
--restart) RESTART=true ;;
--dry-run) DRY_RUN=true ;;
--profile)
if [ $# -lt 2 ]; then echo "--profile 需要一个 profile 名(如 web)" >&2; exit 2; fi
PROFILE_NAME="$2"; shift ;;
-h|--help) : ;; # 已在上面处理
-*) echo "未知参数: ${1}(用 -h 查看用法)" >&2; exit 2 ;;
*) VERSION_SPEC="$1" ;;
esac
shift
done
PROFILE_DIR="$DSH_HOME/profiles/$PROFILE_NAME"
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
say() { printf '\033[32m[install]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
# 步骤 1:预写 workspace 设置(幂等),放行本插件新版本
# (0.20.0 起插件不再依赖任何需要构建脚本的包,allowBuilds 无需再动)
ensure_workspace_settings() {
WS_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
let t = fs.readFileSync(p, "utf8");
const before = t;
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
if (!/^\s*-\s+dsh-apes-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-apes-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-apes-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
' "$WS_YML")"
[ "$WS_RESULT" = "updated" ] \
&& say "已确保 ${WS_YML}:minimumReleaseAgeExclude(${PKG})" \
|| say "workspace 设置已就绪,跳过"
}
# 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest)
resolve_spec() {
local given="${1:-latest}"
case "$given" in
latest)
local v=""
if command -v npm >/dev/null 2>&1; then
v="$(npm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
fi
if [ -z "$v" ] && command -v pnpm >/dev/null 2>&1; then
v="$(pnpm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
fi
if [ -n "$v" ]; then
printf '%s' "$v"
else
warn "无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。"
warn "若已知版本号,可显式传入:bash scripts/install.sh 0.10.2"
printf 'latest'
fi
;;
*) printf '%s' "$given" ;;
esac
}
# 组装 dsh CLI 调用:优先 PATH 上的 dsh,缺省 npx 拉官方包
dsh_cli() {
if command -v "$DSH_CMD" >/dev/null 2>&1; then
printf '%s' "$DSH_CMD"
elif command -v npx >/dev/null 2>&1; then
printf 'npx -y --package @deepseek-ai/dsh@0.1.7-alpha.1 dsh'
else
die "未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定 dsh 路径。"
fi
}
# 前置校验
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20),请先安装 Node.js 并加入 PATH。"
[ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先安装并运行过一次 dsh web)"
[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 ${PROFILE_NAME} profile)"
SPEC="$(resolve_spec "$VERSION_SPEC")"
CLI="$(dsh_cli)"
say "目标:$CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(profile: ${PROFILE_DIR})"
if [ "$DRY_RUN" = true ]; then
say "[dry-run] 步骤 1:确保 $WS_YML 含 minimumReleaseAgeExclude(${PKG})"
say "[dry-run] 步骤 2:执行 $CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(安装 + bundle 自动注册)"
say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if [ "$RESTART" = true ]; then say "[dry-run] 步骤 5:pm2 restart dsh-web"; else say "[dry-run] 步骤 5:提示用户手动重启 DSH"; fi
exit 0
fi
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略
ensure_workspace_settings
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
say "执行 $CLI plugin --profile $PROFILE_NAME add $PKG@$SPEC ..."
if ! $CLI plugin --profile "$PROFILE_NAME" add "$PKG@$SPEC" 2>&1 | tail -n +1; then
warn "dsh plugin add 失败。已预写 minimumReleaseAgeExclude,仍失败的可能原因:"
warn " - 网络/登录问题:npm registry 不可达或需要登录。"
warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。"
exit 1
fi
# 校验 bundle 已注册(挂载生效的判据)
if ! node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const bundles = p.dsh?.profile?.bundles ?? [];
process.exit(bundles.includes(process.argv[2]) ? 0 : 1);
' "$PROFILE_DIR/package.json" "$PKG"; then
warn "dsh-apes-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。"
warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本(0.20.0 起本插件自身不再有构建脚本依赖)。"
exit 1
fi
say "bundle 已注册:dsh.profile.bundles 包含 ${PKG}(下次启动自动挂载)"
# 步骤 3:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
MOUNT_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
const lines = fs.readFileSync(p, "utf8").split("\n");
const out = [];
let i = 0;
let removed = false;
while (i < lines.length) {
const line = lines[i];
if (/^[ \t]*- insert:\s*$/.test(line)) {
const block = [line];
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
block.push(lines[j]);
j++;
}
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
i = j;
removed = true;
continue;
}
}
out.push(line);
i++;
}
if (!removed) {
console.log("none");
} else {
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
fs.writeFileSync(p, t);
console.log("removed");
}
' "$PATCH_YML")"
[ "$MOUNT_RESULT" = "removed" ] \
&& say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)" \
|| say "无旧手动挂载行,跳过"
say "安装完成:$PKG@$SPEC"
# 步骤 4:重启提示
if [ "$RESTART" = true ]; then
if command -v pm2 >/dev/null 2>&1; then
say "重启 dsh-web(pm2)..."
pm2 restart dsh-web || warn "pm2 restart 失败,请手动重启 DSH"
else
warn "未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)"
fi
else
say "下一步:重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)使新副本生效。"
if command -v pm2 >/dev/null 2>&1; then
say "本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)"
fi
fi
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env node
/**
* Assemble the plugin-registry installable root (`registry/`) from the built
* `lib/` output. Run after `pnpm build`:
*
* pnpm build
* node scripts/package-registry.mjs
* dsh registry install ./registry
* dsh registry enable dsh-external/dsh-apes-sidebar
*
* The registry install copies the whole source directory (`cp -r`), so
* installing the repo root would drag in `node_modules/` (machine-local
* `link:` devDependency symlinks) and `.git/`. This
* staging directory carries exactly the files the manifest references plus
* the docs — same layout as the dsh-subagent-tree `registry/` pattern. It is
* gitignored and rebuilt from scratch on every run.
*/
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(fileURLToPath(import.meta.url), '..', '..')
const out = join(root, 'registry')
/** Files copied into registry/, preserving relative paths (lib/ stays lib/). */
const files = [
'dsh.plugin.json',
'lib/index.js',
'lib/invariant.js',
'lib/client-registry.js',
'lib/client-registry.js.map',
// Lazy chunks: the client fetches these from the host's /sidebar/bundle
// route on first use (see src/client/chunk-loader.ts / src/bundle-route.ts).
// EVERY `CHUNK_NAMES` entry belongs here — the route reads the file out of
// the installed copy, so a chunk left out of this list 404s on the registry
// channel (`locale` was). tests/manifest-consistency.spec.ts guards the list
// against src/bundle-route.ts.
'lib/client-editor.js',
'lib/client-editor.js.map',
'lib/client-mermaid.js',
'lib/client-mermaid.js.map',
'lib/client-locale.js',
'lib/client-locale.js.map',
'README.md',
'LICENSE',
]
rmSync(out, { recursive: true, force: true })
mkdirSync(out, { recursive: true })
for (const file of files) {
const source = join(root, file)
if (!existsSync(source)) {
console.error(`missing ${file} — run \`pnpm build\` first`)
process.exit(1)
}
const target = join(out, file)
mkdirSync(dirname(target), { recursive: true })
cpSync(source, target)
}
console.log(`registry/ assembled (${files.length} files)`)
console.log('next: dsh registry install ./registry && dsh registry enable dsh-external/dsh-apes-sidebar')

Some files were not shown because too many files have changed in this diff Show More