Compare commits

..
7 Commits
Author SHA1 Message Date
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
380 changed files with 2590 additions and 83968 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-better-sidebar / I confirmed I am on the latest dsh-better-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-better-sidebar 的版本号 | The dsh-better-sidebar version
placeholder: "如 0.13.1(`dsh plugin --profile web add dsh-better-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
-167
View File
@@ -1,167 +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-better-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-better-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
- 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.
# DSH 0.1.5-rc.1 is the `latest` (and `next`) dist-tag on npm — the
# 0.1.5 line's first release candidate; the lanes speak the slash-only
# RPC dialect (tests/e2e/host-protocol.ts).
- name: Install dsh CLI (pinned)
run: npm install -g @deepseek-ai/dsh@0.1.5-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-better-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-better-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-better-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-better-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
+134 -49
View File
@@ -1,72 +1,157 @@
# dsh-better-sidebar 仓库规则(AGENTS)
# apes-sidebar — engineering conventions
> 本文只含**项目全局开发规则**(面向贡献者与 agent)。
> 消费插件接入 API 全参考(`ctx.betterSidebar` 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、原生右侧栏承载面、皮肤契约等)→ [docs/external-plugin-guide.md](docs/external-plugin-guide.md);逐特性设计史(含实施偏差记录)→ [docs/plans/](docs/plans/)。
A DSH (DeepSeek Harness) **sidebar plugin** that registers five information-layer
tabs into the `dsh-better-sidebar` host: `apes:tasks`, `apes:agents`,
`apes:apps`, `apes:messages`, `apes:profile`.
---
This repo is a **from-scratch v2.0.0 rewrite** of the legacy JS-bundle sidebar.
The engineering template is `dsh-better-sidebar` (TS + tsdown + host/client
split). If a convention here conflicts with `dsh-better-sidebar`, follow
better-sidebar and file a note in `docs/`.
## 1. 仓库硬约束(必须遵守)
## Stack
- **禁止修改 DSH 源码**:对官方 checkout(`~/.dsh/source/current`)零写入。
- **代码改动必须走 PR**:非文档改动在 `feat/*` / `fix/*` 分支开发,`gh pr create` 发起,review 合并后进 main;**仅纯文档改动**(README / AGENTS.md / docs/)允许直推 main。
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件作为独立包被 profile 引用,不反向侵入 DSH。
- **市场受管安装约束**:`dependencies` / `peerDependencies` / `optionalDependencies` **一律不得出现 `cordis`**(按名硬拒,optional 无效),`scripts` 不得含 `preinstall` / `install` / `postinstall` / `prepare`。由 `tests/market-manifest.spec.ts` 守护。
- 缺能力时用 DSH 现成只读/公开 API 或插件自有路由(如 `jobs.output` 事件回放:读会话事件日志而非动注册表);做不到先向用户说明取舍,不改 DSH。
- **Language**: TypeScript (strict, `noUncheckedIndexedAccess`, ESM, `.ts` extension imports)
- **Build**: `tsdown` (rolldown). Dual output — `lib/index.js` + `lib/invariant.js`
(host, ESM) and `lib/client.js` (client, self-contained bundle with a named
`apply` export)
- **Type check**: `tsc --noEmit -p tsconfig.json`
- **Unit tests**: `vitest` (node environment, no DOM)
- **Package manager**: `pnpm` (Node ≥ 22.13 required — `node:sqlite` + pnpm 11)
- **Peer**: `dsh-better-sidebar` ≥ 0.18.0 (host-provided, never bundled)
- **Dev-only types**: `@deepseek-ai/cordis` (types; host provides at runtime)
---
## Commands
## 2. CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`)
```bash
pnpm install # install deps (preinstall re-checks on build)
pnpm typecheck # tsc --noEmit (strict)
pnpm build # rm -rf lib && tsc (d.ts) && tsdown (bundles)
pnpm test # vitest run (unit only; no e2e in Phase 0)
pnpm build && pnpm test && pnpm typecheck # the full gate
```
「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add <tarball>`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开插件 tab 类型(含终端懒加载 chunk),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。
Run the **full gate** before pushing. All three must be green.
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`(在 npm 上同时是 `latest` 与 `next`;peer 下限 `^0.1.5-rc.1`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
## Layout
---
```
src/
index.ts # HOST entry: export name/inject/apply (namespace plugin shape)
invariant.ts # invariant surface (assert, semver, peer guard) — dep-free
context-types.ts # service face contracts (ApesContext = CordisContext & {...})
client/
index.tsx # CLIENT entry: named `apply(ctx)` registering apes:* tabs
locales.ts # i18n (zh default, en fallback) + useApesLocale hook
tests/
smoke.spec.ts # host plugin shape + apply + invariant surface
locales.spec.ts # i18n contract against a mock ApesLocaleService
tsdown.config.ts # dual build config (host ESM + client CJS)
vitest.config.ts
cordis.patch.yml # plugin registration into the DSH profile
```
## 3. DSH 0.1.5 适配要点(0.1.5-rc.1+ 基线)
## Hard rules
**v0.19.0(正式版,npm `latest`)起仅支持 DSH 0.1.5-rc.1+**(peer 下限 `^0.1.5-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`)。0.1.5-alpha.2 用户停留在 **v0.19.0-alpha.1**(npm `alpha`)——rc.1 的 delta 很小,插件不再对 alpha 线做运行时兼容;0.1.2-rc.1 稳定线用户继续用 **v0.18.1**——0.1.5 的会话事件模型与文件打开漏斗都变了,插件不再对 0.1.2 做运行时兼容。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag(0.19.0 无后缀 → `latest`;npm `alpha` 标签仍指向 `0.19.0-alpha.1`)。
1. **Namespace plugin export shape.** `src/index.ts` MUST export
`{ name, inject, apply }` as named exports — **never** a `default` export.
A stray `default` makes the cordis `Loader.unwrapExports` discard
`name`/`inject`/`apply` and the plugin silently fails to mount.
`tests/smoke.spec.ts` pins this.
宿主契约(均经真机挂载冒烟 14/14 验证):
2. **Host / client split is load-bearing.**
- The **host** half (`lib/index.js`) runs in Node. It may use Node builtins.
- The **client** half (`lib/client.js`) runs in the DSH browser shell. Its
entry is a **named `export function apply(ctx: ApesClientContext)`** (not a
default export); tsdown bundles it to a self-contained module whose export
face is `{ apply, ... }`. The host wires it via `ctx.betterSidebar` /
`ctx.effect` (read from `ctx`, not a separately-injected `hostApi`).
React is external (host-provided). Never `import` a Node builtin into
`src/client/**`.
- `src/invariant.ts` is reachable from BOTH halves — it must stay
dependency-free (no Node builtins, no @deepseek-ai imports).
1. **一次性 token 鉴权**:就绪行 `dsh web: http://127.0.0.1:<port>/?token=<43字符>`(导航换签名 cookie,干净 URL 401)。`e2e-mount.sh` 的 URL grep 必须延伸到空白(`[^ ]*`,在 `/` 截断丢 token);e2e 统一走 `tests/e2e/host.ts`(token 必选:`parseLaunchUrl` 对裸 origin 直接抛错),带 stamps 导航走 `gotoPage()`(先 addCookies 再直达——token 换 cookie 的 303 会丢弃同 URL 其它 query 参数)。插件 `/sidebar/*` 路由不受影响,同源 fetch 照旧。
2. **Remote gateway 斜杠 RPC(唯一方言)**:`POST /api/workspace/create`,payload 恰为 `{args: {...}}`,**args 按控制器 TS 参数名包装**(`workspace/create`、`session/create` → `{args:{request:{...}}}`;`session/list` 参数名 `_request` **不可省略**——`{}` 也被拒 `args fields do not match the descriptor`);envelope `method` 与路径一致,点分路径 404。请求由 `tests/e2e/host-protocol.ts` 的 `rpcAttempt` 构造、`tests/e2e-host-protocol.spec.ts` 锁定;要调新方法先在真机验参数名再进 `RPC_ARGS_KEY`。
3. **`MarkdownText` labels 嵌套契约**:必填 `labels: { code: { copyLabel, copiedLabel }, footnotes }`(漏传回退硬编码中文)。四个渲染点(mermaid.tsx / MarkdownHtml.tsx / TextEditor.tsx / SideChatView.tsx)统一走 `src/client/markdown-labels.tsx` 的 `markdownTextProps()`。
4. **侧边对话转录走自有路由,不碰客户端宿主 RPC**:`ctx.connection.api`(含 `sessions.history`)在 alpha.1 整体移除,继任 `session/follow|page` 又对 `origin:'subagent'` 会话强制 subagent 地址(普通 `{kind:'session'}` 被 `agent-busy` 拒)且分页 `throughSeq` 不得超当前游标。转录因此由 **`sidechat.events`** 插件路由供给(`src/sidechat-routes.ts`:live 读 `agent.session.snapshotEvents()`、冷读 `sessionPersistence.inspect`,服务端 `session/end-seed` 切割 + `afterSeq` 增量);`ctx.connection` 镜像与 inject 已删。**0.1.5 起实时增量不在日志里**:`assistant/chunk` 事件被删除,进行中的模型增量改由 `agent/assistant-stream` 瞬时帧发布(`start` / `chunk` / `end`,不入会话日志),结算时才落 `assistant/message`(内嵌 `stream`)或 `assistant/attempt`(失败尝试,内嵌 `stream`)。插件在宿主侧 `src/assistant-live.ts` 折叠这些帧成有界缓冲,`sidechat.events` 的 `live` 字段按「当前 attempt 全量、每次轮询替换」下发(不是增量),转录映射与继承快照都读它。设计见 [docs/plans/2026-08-20-sidechat-tab-design.md](docs/plans/2026-08-20-sidechat-tab-design.md) §10。
5. **`dsh-settings` 无运行时 `settingsNamespace`**:命名空间合法性校验转为编译期模板字面量 `SettingsNamespaceInput`(小写字母开头 + `[a-z0-9-]` 尾部),`'dsh-better-sidebar'` 字面量直接过——宿主侧直接传常量(`src/index.ts` 的 settings inject)。
6. **`dsh-subagent` 的 `SUBAGENT_DESCRIPTOR_VERSION` 2 → 3**:sidechat 种子的 `subagent/descriptor` 版本由宿主包盖章,插件不硬编码;测试断言跟随常量(`tests/sidechat-routes.spec.ts`),勿钉字面量。
7. **`@deepseek-ai/dsh-client-runtime` 包已消亡**(继任 seed 是裸名 `dsh-client-store`,无 `/client` 子路径):peerDependencies、devDependencies、`dsh.client.inject`、chunk externals 白名单(`src/client/chunk-loader.ts` / `tsdown.config.ts` / `tests/chunk-loader.spec.ts` / `tests/manifest-consistency.spec.ts` 四处同步)均已无该条目。
8. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。
9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,该包回到与 DSH 同 tuple(0.1.2-rc.1 → 0.1.5-rc.1,peer 下限 `^0.1.5-rc.1`),该例外消除。**alpha.2 起还有一类:上游包自身的 `dependencies` 丢失**——`@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 树不会——vitest 一碰 primitives 就 `Cannot find package 'anser'`。此时把 alpha.1 声明的同一组版本提升进 devDependencies(`clsx` 已在 dependencies 无需重复)。**rc.1 复查:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` 仍不声明任何 `dependencies`,bundle 仍裸 import 同一组包**——这组提升不得回退,`pnpm peers check` / 单测一旦报 `Cannot find package 'anser'` 即是有人回退了。
10. **右侧栏是 DSH 原生栏,插件只提供 tab 类型**:聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点),`remote.session.openWorkspacePath` 在 0.1.5 客户端已无调用者,插件的 openpath 拦截随之删除。插件的每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`,`extension` 带)+ 原生 tab 体(`sidebar.right.pane.tab` keyed 槽,key = `dsh-better-sidebar:<id>`);`editor` 类型同时认领 `dsh-resource://file/**`(压过内置 `text` 的 `fallback`),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件文件树,注销即复位)。`ctx.sidebarRight.openTab/openResource/close` 只对**在屏会话**写入;跨会话用具体类上的 `openTabIn/openResourceIn/closeIn`(不在 `ISidebarRight` 接口里,需结构化探测),目标会话未挂载时排队到上屏重放(`src/client/native/surface.ts`)。原生 tab 的插件侧状态(合成 `SidebarTab`、树展开集合、实例编号)在 `src/client/native/tab-adapter.tsx`;原生布局只在内存,不做持久化。**注册必须等服务、不能等槽声明**:原生栏先声明 `sidebar.right.pane.tab` 再 `provide('sidebarRightTabs')`,真机 profile(web,0.1.5-alpha.1)上槽声明回调里 `ctx.get('sidebarRightTabs')` 仍是 undefined(实测 3 秒后才出现),按槽触发注册会静默什么都不注册且永不重试——用 `ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(`tests/native-surface.spec.ts` 以「槽先触发、服务后到达」的顺序守护),槽声明只用来挂 tab 体。指南 §0 列了全部行为差异。**alpha.2 起(v0.19.0-alpha.1)**:指南条目一度改成「图标+标题」胶囊、`TabDescriptor.description` 随之下线,新建标签页的默认页改为从注册表选(恰好 1 个指南条目 → 直接开它,0 或 ≥2 → 开指南),`revealIfOpened` 对「页面」在同一 pane 内强制去重。原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**(非 flex 容器),native 适配层因此给每个 tab 体包一层 `height:100%` 的列 flex 宿主(`sidebar.module.css` 的 `.nativeTabHost`),tab 组件根继续用 `flex:1`/`height:100%`——否则根盒塌成内容高度,sidechat 的输入框就贴不到面板底。全局面板(根级 `main` keyed 槽 + `sidebar.panellist` + `ctx.layout.selectPanel/beginNavigation`、`rightbar` 改根级并新增 `rightbar.session`)插件**不接入**,只做兼容。底部工作台的中心列锚点改认 `[data-slot="main.conversation"]`(并跳过 `display: contents` 祖先,同时保留 alpha.1 的 `[data-slot="conversation"]`);文件地址语法跟随 alpha.2(`fileAddressFor` 一律 session 作用域、绝对路径保留前导 `/`、`parseFileAddress` 前缀解析并去 `?`/`#`)。**rc.1 起(v0.19.0)**:`SidebarRightGuideEntry.description` 回归(可选),插件恢复 `TabDescriptor.description`(六个内置类型各声明一条说明,`guideDesc*` 词条回到 20 份词典),但**宿主的原生指南只在列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`;更长的列表是整列丢弃,不是截断)——插件默认贡献 6 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),所以**默认组合下说明不渲染**,只有读者在插件设置页关掉足够多 tab 类型、把 guide 压到 ≤ 4 条时才出现;插件**不恢复**旧的 `nativeGuideDesc` 通用兜底句(宿主自己没有兜底,通用句是噪音),没声明说明的 descriptor 就不发 `description` 字段;条目缺 `icon` 时由宿主补方块占位。
11. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折(`inboxProjectionDefinition`,0.1.5 起 `@deepseek-ai/dsh-agent` 只导出 `Inbox` 接口,实现是该投影)重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**(排队用户消息 next-turn、长回合中工具结果上下文/steering next-step——后者即「上下文很长时侧边对话先把之前的 User msg 发出去」的根因,幽灵消息排在 boundary 之前最先发给模型)。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折)守护。0.1.5 复评:无更优雅的 sidechat 宿主 API(`ContinuableStartSpec` 仍无 seed 字段),`AgentRegistry.create` 接缝补齐标记即为规范用法;`Session.create` 的 header `version` 必须用 `SESSION_FORMAT_VERSION`(0.1.5 是 `3` 字面量类型),勿钉 `0`。
3. **`dsh-better-sidebar` is a peerDependency, not a bundled dep.** The tsdown
config lists it in `deps.neverBundle`. If a build inlines it, the client
bundle ships a second copy of the sidebar registry — tabs register into a
dead instance and never appear.
---
4. **`@deepseek-ai/cordis` is devDependency (types only).** The host provides
the real cordis context at runtime. Do NOT add cordis to `dependencies` or
to `deps.neverBundle` — that would force the client to resolve it.
## 4. npm 发版(GitHub Release → npm publish)
5. **Tab IDs are `apes:*`.** Never `wb:*` (legacy prefix) or any better-sidebar
internal id. The five Phase 1 tabs: `apes:tasks`, `apes:agents`,
`apes:apps`, `apes:messages`, `apes:profile`.
`.github/workflows/release.yml` 在 GitHub Release(tag `vX.Y.Z`)发布时自动发 npm:
6. **Scope lock.** This plugin is the **information layer only**:
tasks / agents / apps / messages / profile. Do NOT add file/terminal/browser
panels — those belong to `dsh-better-sidebar` itself. Do NOT add team/room
features — those belong to `dsh-background-agents`.
1. **前置**:`package.json` 版本 bump 到 `X.Y.Z`,CI 全绿后打 tag;tag 与版本不匹配直接失败。
2. **流程**:`pnpm build` / `typecheck` / `test` → 校验 tag → `pnpm publish --provenance --access public`。
3. **认证**:npm **Trusted Publishing(OIDC)**,不配 `NPM_TOKEN`。一次性配置(npmjs.com package → Settings → Trusted Publishers):Provider `GitHub Actions`、Org `omdsh-dev`、Repo `DSH-better-sidebar`、Workflow filename `release.yml`、Environment 留空。
4. **调试**:`workflow_dispatch` + `dry_run=true` 只打包不发版。
7. **Bilingual i18n.** Every user-facing string goes through `t(key)` in
`src/client/locales.ts` with BOTH a `zh` and an `en` entry. No raw strings in
JSX.
---
8. **pnpm build-script approval.** `node-pty` (a transitive runtime dep of
better-sidebar's terminal panel) needs its native build approved once:
`pnpm approve-builds` (pick `node-pty`, `y`). Without this, `pnpm build`'s
preinstall dep-check hard-fails. It is approved in the committed
`pnpm-lock.yaml` state — don't reset it.
## 5. 开发规则速查
## Testing conventions
- **构建纯度门**:client bundle 禁止 value-import `@dsh-external/*` 或非白名单 `@deepseek-ai/*`(`tsdown.config.ts` 拦截);`import type {}` 被擦除不触发——类型可共享,运行时符号不行;跨插件交互走 `ctx.betterSidebar` 方法调用。
- **懒加载 chunk**:重依赖(xterm/CodeMirror/mermaid)在独立 bundle(`lib/client-<name>.js`),经 `/sidebar/bundle` 按需下发、`globalThis.__dshChunks__` 物化(`src/client/chunk-loader.ts`),**核心 bundle 禁止静态 import `src/client/chunks/*`**。
- **i18n**:词典在 `betterSidebar` 命名空间,跟随 DSH `ctx.locale`;**新增 zh key 必须同步 `src/client/locales-ja.ts` 的 ja 翻译**(否则 ja 下回退 en)。渲染 `MarkdownText` 必须经 `markdownTextProps()`(§3 第 3 条)。
- **皮肤契约**:视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌,无硬编码颜色;契约全文与 titleBar 四方案模型见[指南 §12](docs/external-plugin-guide.md),改动必须同步该节与 `tests/theme.spec.ts`。
- **契约反向引用**:皮肤契约被 `src/client/shell-presets.ts`、`tests/e2e/mount.e2e.ts` 的注释以「指南 §12」引用——调整指南章节结构时同步检查这两处。
- **接入 API 即文档**:`src/client/service.ts` 与 `src/client/builtins/` 的任何行为变更,必须同步 [docs/external-plugin-guide.md](docs/external-plugin-guide.md)(唯一权威接入文档,不再双份维护)。
- Unit tests run in a **node** vitest environment. No DOM, no jsdom (the
client React components are not rendered in Phase 0 — a full cordis scratch
mount smoke lands in Phase 1).
- The host `apply()` is tested against a **minimal fake context** (see
`tests/smoke.spec.ts`) — never against a live DSH shell in CI.
- The locale contract is tested against a **mock `ApesLocaleService`** (see
`tests/locales.spec.ts`).
- A test that needs the real DSH host is a **Phase 1+** concern (cordis
scratch profile); do not add such tests to Phase 0.
---
## Gotchas (learned, don't rediscover)
## 6. 文档与测试地图
- `tsdown` 0.22 deprecated `external`/`noExternal` in favour of
`deps.neverBundle`/`deps.alwaysBundle`. **`external` and `deps.neverBundle`
cannot coexist** — using both throws at build. Use only the `deps` API.
- `allowImportingTsExtensions` (for `.ts`-extension imports) is set in
`tsconfig.json`. `tsconfig.build.json` inherits it but emits `d.ts`, which is
compatible (tsdown handles the strip). If `tsc` build complains, the
declaration emit needs `emitDeclarationOnly` + `noEmit: false` — that is the
current `tsconfig.build.json` setup.
- `node-pty` build approval is **per-machine state** in `pnpm-lock.yaml` + the
pnpm store. A fresh `pnpm install` on a clean checkout will re-prompt.
- The workspace path contains spaces and Chinese characters
(`文稿 - figmarLite的MacBook Pro/...`) — always quote it in shell commands.
- **`lib/client.js` is a DSH browser `__ModuleLoader__` bundle, NOT plain
CJS/ESM.** Line 1 is `window.__ModuleLoader__.load({...})` — it is consumed
by the DSH browser shell's module loader and **cannot be `require()`d in
Node** (top-level `window` reference throws `window is not defined`). This is
why the cordis scratch mount smoke cannot run in the unit gate: the client
artifact is browser-only by construction, not just because the `dsh` CLI is
absent. Unit specs test the **source** `src/client/index.tsx` (`apply`
contract), never the built bundle.
- **接入 API 全参考**:[docs/external-plugin-guide.md](docs/external-plugin-guide.md)(消费插件开发者向;§0 原生栏承载面 / §4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 已移除的自由窗口 / §12 皮肤契约 / §15 真实案例)。
- **设计文档**:[docs/plans/](docs/plans/)(30+ 份逐特性设计,含实施偏差记录)。
- **关键测试守护**:`tests/service.spec.ts` / `builtins.spec.ts`(注册表与内置清单:7 tab + 6 viewer)/ `market-manifest.spec.ts`(市场约束)/ `e2e-host-protocol.spec.ts`(RPC 双协议)/ `native-surface.spec.ts`(原生右侧栏承载面)/ `theme.spec.ts`(皮肤契约)/ `plugin-list.spec.ts`(推荐插件目录)/ `fs-search.spec.ts`(host 文件名搜索)。
## Phase status
- **Phase 0 (done)**: engineering skeleton, host/client split, i18n, invariant
surface, build gate green, unit smoke green.
- **Phase 1 (done)**: real tab registration for the five `apes:*` panels
against a mock host registry (type-only real `TabDescriptor` /
`TabComponentProps` / `Context` from `dsh-better-sidebar`), i18n for all
five views, `ctx.effect`-wrapped registration with HMR-safe disposer, and
graceful degrade when `betterSidebar` is absent. Unit smoke: `tests/tabs.spec.ts`
(6 tests). The cordis scratch mount smoke (real DSH host process) is deferred
to Phase 2 — it needs a live host and is outside the unit gate.
- **Phase 2 (partial)**: badge counts + auto-collapse (visible-gated
subscription pause) + per-tab state landed for the information panels.
`apes:tasks` / `apes:agents` show live count pills (running / subagent);
`apes:tasks` persists a `showInactive` preference through the settings
namespace (`apesSidebar.tasks`, `applies: 'live'`). The cordis scratch mount
smoke and wiring `apps` / `messages` / `profile` to real feeds are **deferred**
— better-sidebar 0.18.0 exposes no workspace-apps / message-stream /
model-provider host feeds for those three panels, and a live scratch mount
needs a `dsh` CLI (absent locally) plus a DOM harness the Phase 0 convention
deliberately avoids. See docs/ for the deferral rationale.
-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-better-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
-668
View File
@@ -1,668 +0,0 @@
# dsh-better-sidebar
<!-- Hero -->
<div align="center">
<b style="font-size: 1.15em;">一个服务化的侧边栏框架,一套开箱即用的完整工作台</b><br /><br />
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-better-sidebar" /></a>
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-better-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.19.0 正式版):0.1.5-rc.1+" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="插件生态:GitHub topic dsh-better-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" /> <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-better-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>
## 📑 目录
- [✨ 功能一览](#-功能一览)
- [🚀 安装](#-安装)
- [🖼️ 特性巡礼](#-特性巡礼)
- [🌐 插件生态](#-插件生态)
- [🆕 最近更新](#-最近更新)
- [⌨️ 快捷键](#-快捷键)
- [🔌 服务化扩展](#-服务化扩展)
- [🛠️ 开发与构建](#-开发与构建)
- [🔐 安全](#-安全) · [⚠️ 已知限制](#-已知限制) · [🖥️ 平台支持](#-平台支持)
- [💬 社区](#-社区) · [🤝 参与贡献](#-参与贡献) · [⭐ Star History](#-star-history) · [🔗 友情链接](#-友情链接)
## ✨ 功能一览
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(落进工作台的 diff 分栏);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由;含 ```mermaid 围栏时走编辑器同款懒加载 mermaid 渲染器(图可点击缩放 / 平移);**敏感内容脱敏**——凭据形态路径整文件遮罩、普通文件按内容形态遮值(api_key: / Bearer / sk- / AKIA / ghp_ / PEM 等,字段名保留),默认开启、预览面板一键开关(localStorage 记忆),仅影响显示、不改会话数据。已知边界:mermaid 无引号节点标签含被遮密钥时,图回退源码(规避:标签加引号);`.html` 操作(读 / 写 / 编辑)预览头部可切换**渲染模式**——复用编辑器同款 `/sidebar/html` 路由 iframe,相对资源(./style.css、img/x.png)同路由解析,分段读取也渲染完整文档,恒定沙箱(opaque origin + CSP 头,无逃生门);`.pdf` 操作(读 / 写 / 编辑)同样可切换**渲染模式**——复用编辑器同款 PDF 预览(媒体路由字节流 + 显式 Blob,浏览器原生查看器内嵌,附下载入口)
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
- **🖥️ 原生右侧栏 + 底部工作台**:右列交给 DSH 0.1.5 的原生右侧栏——插件把每个 tab 类型注册成原生 tab(文件打开走 `dsh-resource://file/**`,并接管内置「文件」页 / 文件树),插件自己只保留底部工作台(分栏 / 终端 / 随会话持久化),开合按钮挂在会话头右侧
- **📌 固定终端**:右键终端 Tab 可「固定到工作区 / 固定到全局」——固定后切换会话不消失,在 TabBar 内联呈现(跨会话虚拟 Tab,点击就地激活,PTY 按 home 会话 id+tab 直连宿主 PTY,无需切回宿主会话);Agent 终端被 reconcile 移除时豁免保留
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换;安装 `@huanlin/dsh-plugin-better-locale` 后支持日语(ja)等第三语言覆盖(见下方「🌏 第三语言覆盖」)
> 🔌 **核心理念**:服务优先——内置的 8 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **28+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
## 🚀 安装
**前置**:已装好 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.19.0 正式版):0.1.5-rc.1+" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B-4d6bfe" /></a>
> 📌 **正式版**:`v0.19.0` 起适配 DSH **0.1.5-rc.1+**(npm dist-tag `latest`)。仍停在 DSH 0.1.5-alpha.2 的用户请固定安装 `dsh-better-sidebar@0.19.0-alpha.1`;0.1.2-rc.1 稳定线用户继续用 `dsh-better-sidebar@0.18.x`;DSH ≤ 0.1.1-rc.2 请用 `dsh-better-sidebar@0.17.1`。
```sh
dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入)
cd ~/.dsh/profiles/web && pnpm approve-builds --all # 放行构建脚本(自动重跑安装)
dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
```
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
**方式二:让 DSH 自己装**——把下面这段提示词发给任意一个 DSH 会话:
```text
帮我安装 dsh-better-sidebar 插件(DSH 侧边栏工作台),步骤:
1. 执行 dsh plugin --profile web add dsh-better-sidebar@latest(首次会被 pnpm 11 拦截 node-pty 构建脚本而失败,属正常)
2. 在 ~/.dsh/profiles/web 下执行 pnpm approve-builds --all(放行构建脚本,会自动重跑安装)
3. 再次执行 dsh plugin --profile web add dsh-better-sidebar@latest
4. 完成后提醒我硬刷新浏览器(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` 查看参数),自动完成 add → 放行构建脚本 → 重跑安装。
<details>
<summary><b>更新</b></summary>
```sh
dsh plugin --profile web add dsh-better-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 --all`。 |
| 报 `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-better-sidebar` 之前。 |
| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 |
| 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(在 `~/.dsh/profiles/web` 下 `pnpm approve-builds --all && pnpm rebuild node-pty`),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 |
| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-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-better-sidebar": "link:<克隆目录绝对路径>"
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行(需要指定终端 shell 时,在行内加 `config.shell`;`config.shellArgs` 可带参启动,非空时替换默认的 `-l`。不填则自动解析 `$SHELL` / 登录 shell / powershell.exe):
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
config:
shell: /bin/zsh
shellArgs:
- --noprofile
- --no-rc
4. 在 ~/.dsh/profiles/web 执行 pnpm install
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
```
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.16.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-better-sidebar
```
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
</details>
## 🖼️ 特性巡礼
> 以下均为真实界面实拍(每行两张,点击可放大)。
| | |
|---|---|
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · 图片 · PDF 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移)、**README 级内嵌 HTML**(徽章墙 `<div align=center>`、`<details>` 折叠块内嵌 markdown、表格单元格内联标签——DOMPurify 白名单消毒真实渲染,`<script>` 等活性内容剥除,本地图片经会话媒体路由重写)与**浮动目录大纲**(≥3 标题出现,点击平滑跳转、自动展开折叠块);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。</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/><div align="center"><img width="420" alt="CodeMirror 代码编辑器" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ 图片内联预览**<br/><div align="center"><img width="420" alt="图片内联预览" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
| **💻 真实终端**<br/><sub>xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。</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(同一渲染栈)。</sub><br/><div align="center"><img width="420" alt="文件变动" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **🌐 内嵌浏览器**<br/><sub>多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。</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(含接管内置「文件」页与文件预览),聊天里的文件点击直接落到原生栏;插件自有底部面板可与其同时展开,拖 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`。</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> |
## 🌐 插件生态
`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 8 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。
```ts
import type {} from 'dsh-better-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-better-sidebar`](https://github.com/topics/dsh-better-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-better-sidebar-svn](https://github.com/ztyhehe/dsh-better-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-better-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-better-sidebar-starter](https://github.com/dong-victor/dsh-better-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-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-better-sidebar-jupyter](https://github.com/dong-victor/dsh-better-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-jupyter?style=flat&color=4d6bfe" /> | `.ipynb` 可运行 Notebook 视图:懒启动 Python kernel、流式输出、保存回写 |
</details>
### 🧰 增强与工具
<details>
<summary><b>3 个插件(点击展开)</b></summary>
| 插件 | ⭐ | 简介 |
|---|---|---|
| [eg-bole/dsh-better-sidebar-icons](https://github.com/eg-bole/dsh-better-sidebar-icons) | <img alt="stars" src="https://img.shields.io/github/stars/eg-bole/dsh-better-sidebar-icons?style=flat&color=4d6bfe" /> | VSCode 风格文件 / 文件夹图标主题:文件树与编辑器 Tab 换上熟悉的开发环境图标(vscode-icons 移植,纯 DOM 覆盖零侵入,安装 / 卸载零残留) |
| [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-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-better-sidebar` topic 即出现在 [topic 页](https://github.com/topics/dsh-better-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` 守护)。
## 🆕 最近更新
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.19.0 正式版):0.1.5-rc.1+" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### 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-better-sidebar@alpha`):本版仅支持 **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-better-sidebar@alpha`):本版仅支持 **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.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-better-sidebar@0.17.1`,停留在 0.1.2-alpha.x 的宿主继续用 `dsh-better-sidebar@alpha`(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-better-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-better-sidebar@alpha`)。该版本号未单独发布,内容已并入 **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-better-sidebar@alpha`);不再支持 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-better-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>
## 💬 社区
推荐添加QQ群(577011007)
<div align="center">
<img width="220" alt="微信群二维码" src="https://github.com/user-attachments/assets/cbf211c6-e5b8-49c3-a412-7210c0b33d73" />
<img width="220" alt="QQ群二维码" src="https://github.com/user-attachments/assets/9be34629-26ef-4537-aad4-1393c147f81c" />
</div>
## ⌨️ 快捷键
| 操作 | 按键 |
|---|---|
| 保存编辑 | `Ctrl/Cmd + S` |
| Git 提交 | `Ctrl + Enter` |
| 关闭 Tab | 鼠标中键 |
| Tab 右键菜单 | 关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签(当前标签组) |
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
## 🔌 服务化扩展
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、原生栏承载面与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。
### ➕ 添加插件(推荐插件目录)
设置页「侧边卡片」两个网格末尾的**虚线卡片**分别打开 Tab / 预览插件弹窗:声明扩展点、「**在 GitHub 上浏览更多插件**」按钮([GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar))、推荐插件目录(名字 / 仓库 / 简介 / 安装脚本),每个条目「**跳转**」直达仓库、「**复制**」把安装命令写入剪贴板。
**收录新插件**:向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts)(Tab 注册)或 [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts)(文件预览注册)追加一条 `PluginEntry`,并把仓库打上 `dsh-better-sidebar` topic;数据完整性由 `tests/plugin-list.spec.ts` 守护。
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.1.2-rc.1,next dist-tag),直接解析、无需令牌
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/ws/terminal` WebSocket(fs / git / pty / 预览,全部会话级 + 信任围栏);client(`src/client/index.tsx`):portal 侧边栏 + 各视图 + 拦截;状态按会话持久化 localStorage。插件按 DSH 官方规范组织(无 default 导出、双 client bundle),运行期不依赖 npm / checkout(`@deepseek-ai/*` 由 web profile 提供)。
## 🔐 安全
- 路由受 Host 头信任围栏保护(与 `/api` 一致);`fs.write` 原子写入;媒体/预览路由仅限会话 cwd 内文件;git 只调 CLI、绝不设置身份
- HTML 预览与浏览器 tab 的内容在**不透明源沙箱 iframe** 中渲染(无 `allow-same-origin`/`allow-top-navigation`、`no-referrer`、权限策略全禁);`/sidebar/html` 路由带 CSP `sandbox` + 大小/路径边界;地址栏拒绝 `javascript:`/`data:`/`file:` 与 localhost 等本机地址
- 界面实时显示沙箱状态(关闭时红色警示),可临时解锁当前页面;设置页可按功能关闭沙箱(默认关闭该设置,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用
## ⚠️ 已知限制
- Git 无 push/pull/fetch;Markdown 预览提供手动刷新按钮,刷新未保存编辑前会确认是否丢弃草稿;无文件 watcher/自动轮询;工具行内文件打开按钮不可拦截
- 终端 Tab 拖到另一分栏会重挂载(shell 重开)
- Office 三件套预览(.docx/.xlsx/.pptx)已移至「推荐插件」(Office 预览插件,见设置页「添加插件」弹窗);未安装时此类文件走代码/下载查看兜底
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发。未选中会话时,点按弱化开关会显示选择会话提示;选中会话后开关打开全宽抽屉
## 🖥️ 平台支持
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
## 🤝 参与贡献
- **代码改动走 PR**:`feat/*` / `fix/*` 分支开发 → `gh pr create`;纯文档改动可直接推 main
- **收录生态插件**:给仓库打 `dsh-better-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 说明)
## ⭐ Star History
<a href="https://star-history.com/#omdsh-dev/DSH-better-sidebar&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date&theme=dark" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date" />
</picture>
</a>
## 👥 贡献者
感谢每一位贡献者:
<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-better-sidebar">topic dsh-better-sidebar</a> 发现更多生态插件</sub>
</div>
-682
View File
@@ -1,682 +0,0 @@
# dsh-better-sidebar
<!-- 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-better-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-better-sidebar" /></a>
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-better-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.19.0): 0.1.5-rc.1+" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-better-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="Embedded browser" src="https://img.shields.io/badge/-Embedded%20browser-4d6bfe" /> <img alt="Real terminal" src="https://img.shields.io/badge/-Real%20terminal-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-better-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
</div>
## 📑 Contents
- [✨ Features](#-features)
- [🚀 Installation](#-installation)
- [🖼️ Feature Tour](#-feature-tour)
- [🌐 Plugin Ecosystem](#-plugin-ecosystem)
- [🆕 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)
- [💬 Community](#-community) · [🤝 Contributing](#-contributing) · [⭐ Star History](#-star-history) · [🔗 Friends](#-friends)
## ✨ Features
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / HTML / PDF
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
- **📂 Model-driven sidebar opens (opt-in)**: with the global setting on, the `sidebar_open` tool lets the model actively open files / folders (tree rooted there) / HTTP(S) pages in the sidebar
- **🌿 Changes**: 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, grouped by file with kind filters); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring incl. mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5 + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab; `.md` ops (read / write / edit) offer a **reading-mode** toggle in the preview header — the shared MarkdownText renders GFM tables / task lists / strikethrough / footnotes / math, with local images rewritten through the /sidebar/file media route; documents with ```mermaid fences render through the editor's lazy mermaid renderer (click-to-zoom / pan diagrams); **secret redaction** — credential-shaped paths mask whole files and ordinary files mask secret-shaped values (api_key: / Bearer / sk- / AKIA / ghp_ / PEM …, field names kept), on by default with a one-click preview-pane toggle (persisted in localStorage), display-only — session data untouched. Known edge: an unquoted mermaid label containing a redacted secret breaks the diagram (falls back to source); workaround: quote the label. `.html` ops (read / write / edit) gain a **Render** toggle in the preview head — the editor's `/sidebar/html` route iframe, with relative assets resolving inside the route and segmented reads rendering the full document; always sandboxed (opaque origin + CSP header, no escape hatch). `.pdf` ops (read / write / edit) gain the same **Render** toggle — the editor's PDF preview reused verbatim (media-route bytes + explicit Blob, the browser's native viewer inline, with a download fallback)
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
- **💬 Side Chat (beta)**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session
- **🖥️ Native right sidebar + plugin bottom workbench**: the right column belongs to DSH 0.1.5's own sidebar — the plugin registers every tab type as a native tab (file opens go through `dsh-resource://file/**`, and the built-in Files page / file tree is taken over), keeping only its own bottom workbench (split panes / terminals / per-session persistence) whose toggle sits in the session header
- **📌 Pinned Terminals**: right-click a terminal tab to "Pin to Workspace / Pin Globally" — pinned terminals survive session switches and surface inline in the TabBar as virtual tabs (click activates in-place, PTY connects directly to the home session's PTY via WS, no session jump needed); agent terminals exempted from reconcile removal
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor / mermaid diagrams) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching; with the optional `@huanlin/dsh-plugin-better-locale` peer, 19 third-language overlays (ja / de / fr / …) are available
> 🔌 **Core principle**: service-first — the 8 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins (**28+ ecosystem plugins** already — see "🌐 Plugin Ecosystem" below). See "🔌 Service API" and the [external plugin guide](./docs/external-plugin-guide.md).
## 🚀 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.19.0): 0.1.5-rc.1+" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B-4d6bfe" /></a>
> 📌 **Stable release**: starting with `v0.19.0` the plugin targets DSH **0.1.5-rc.1+** (npm dist-tag `latest`). Hosts still on DSH 0.1.5-alpha.2 should stay pinned to `dsh-better-sidebar@0.19.0-alpha.1`; the 0.1.2-rc.1 stable line keeps using `dsh-better-sidebar@0.18.x`; DSH ≤ 0.1.1-rc.2 should use `dsh-better-sidebar@0.17.1`.
```sh
dsh plugin --profile web add dsh-better-sidebar@latest # first run fails: pnpm 11 blocks node-pty build scripts (the dependency is still written)
cd ~/.dsh/profiles/web && pnpm approve-builds --all # allow the build scripts (re-runs the install automatically)
dsh plugin --profile web add dsh-better-sidebar@latest # re-run succeeds
```
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-better-sidebar plugin (a sidebar workbench for DSH):
1. Run: dsh plugin --profile web add dsh-better-sidebar@latest (the first run fails because pnpm 11 blocks node-pty build scripts — that's expected)
2. In ~/.dsh/profiles/web run: pnpm approve-builds --all (allows the build scripts and re-runs the install)
3. Run the add command again: dsh plugin --profile web add dsh-better-sidebar@latest
4. 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 automates add → approve-builds → re-run.
<details>
<summary><b>Updating</b></summary>
```sh
dsh plugin --profile web add dsh-better-sidebar@latest
```
or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.16.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 build scripts. Run `pnpm approve-builds --all` in the profile directory (`~/.dsh/profiles/web`). |
| `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-better-sidebar` in `dsh.profile.bundles`. |
| Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. |
| Terminal shows "node-pty failed to load" | The `node-pty` install is missing or broken (e.g. pnpm skipped its build script). The terminal banner shows a repair command — copy it into a terminal/cmd on the DSH machine and run it (in `~/.dsh/profiles/web`: `pnpm approve-builds --all && pnpm rebuild node-pty`), then restart DSH and click Retry. The plugin and DSH core share the same `node-pty@^1.1.0`, so the repair restores both. |
| `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-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-better-sidebar": "link:<absolute path of the clone>"
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml (to pick the terminal shell, add `config.shell`; `config.shellArgs` starts it with explicit args — when non-empty they replace the default `-l`. When omitted the host resolves `$SHELL` / the login shell / powershell.exe):
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
config:
shell: /bin/zsh
shellArgs:
- --noprofile
- --no-rc
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 `"dsh-better-sidebar": "^0.16.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-better-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, 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 / copy paths), 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 · Images · PDF**<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); images / PDFs display inline via the media route; the Office suite is covered by an ecosystem plugin.</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/><div align="center"><img width="420" alt="CodeMirror editor" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ Inline image preview**<br/><div align="center"><img width="420" alt="Inline image preview" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
| **💻 Real Terminal**<br/><sub>xterm.js + node-pty real shell (not an emulator): transcript replay on reconnect, configurable shell / shellArgs (settings page or `cordis.patch.yml`), and optional `terminal_*` model tools so the agent can open terminals and run commands itself.</sub><br/><div align="center"><img width="420" alt="Real 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). 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> |
| **🌐 Embedded Browser**<br/><sub>Multiple web tabs with back / forward / reload / address bar; content runs in an **opaque-origin sandboxed iframe** (live sandbox status in the UI, per-page temporary unlock available); external-link clicks in the chat can be taken over into the sidebar (protocol-based routing, configurable).</sub><br/><div align="center"><img width="420" alt="Embedded 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 the built-in Files page and file previews), so clicking a file in the chat lands there directly; 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`.</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> |
## 🌐 Plugin Ecosystem
The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 8 built-in tabs + 6 viewers register through the exact same API — fully equal capabilities.
```ts
import type {} from 'dsh-better-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-better-sidebar`](https://github.com/topics/dsh-better-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-better-sidebar-svn](https://github.com/ztyhehe/dsh-better-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-better-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-sidechat](https://github.com/g-yixuan/dsh-sidechat) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidechat?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-better-sidebar-starter](https://github.com/dong-victor/dsh-better-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-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-better-sidebar-jupyter](https://github.com/dong-victor/dsh-better-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-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-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-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-better-sidebar` topic to appear on the [topic page](https://github.com/topics/dsh-better-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`).
## 🆕 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.19.0): 0.1.5-rc.1+" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
### 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-better-sidebar@alpha`): 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-better-sidebar@alpha`): 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-better-sidebar@0.17.1`, hosts on 0.1.2-alpha.x keep `dsh-better-sidebar@alpha` (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-better-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-better-sidebar@alpha`). 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-better-sidebar@alpha`); 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-better-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>
## ⌨️ 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 8 built-in tabs + 6 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-better-sidebar`](https://github.com/topics/dsh-better-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-better-sidebar` topic; data integrity is guarded by `tests/plugin-list.spec.ts`.
## 🛠️ Development & Build
```sh
pnpm install # @deepseek-ai/* devDependencies resolve (baseline 0.1.2-rc.1, next 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/ws/terminal` WebSocket (fs / git / pty / preview, all session-scoped with a trust fence); client (`src/client/index.tsx`): portal sidebar + views + interception; 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; media/preview routes only serve files inside the session cwd; git only shells out to the CLI and never sets identity
- HTML preview and browser tab content render in **opaque-origin sandboxed iframes** (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 address bar rejects `javascript:`/`data:`/`file:` and local addresses like localhost
- The UI shows the sandbox status live (red warning when off) and can temporarily unlock the current page; the settings page can disable the sandbox per feature (disabled by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content
## ⚠️ Known Limitations
- Git has no push/pull/fetch; Markdown previews provide a manual refresh button with confirmation before discarding unsaved edits; no file watcher or automatic polling; tool inline file-open buttons cannot be intercepted
- Dragging a terminal tab to another pane remounts it (shell restarts)
- Office-suite preview (.docx/.xlsx/.pptx) moved to the recommended office plugin (see the "Add plugins" modals in settings); without it these files fall through to the code/download fallbacks
- Browser sandbox has no login state / third-party cookies are restricted; some sites need popup login; sites that refuse embedding via `X-Frame-Options`/`frame-ancestors` (e.g. arxiv.org) show a reason panel (with "Open in browser"); in-iframe navigation does not enter the back stack
- 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; auto-open terminal on first bottom-panel expand does not trigger on mobile. 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); `node-pty` prefers prebuilt binaries, otherwise a build toolchain is required (Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT).
## 💬 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>
## 🤝 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-better-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)
## ⭐ Star History
<a href="https://star-history.com/#omdsh-dev/DSH-better-sidebar&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date&theme=dark" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date" />
</picture>
</a>
## 👥 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-better-sidebar">dsh-better-sidebar topic</a></sub>
</div>
+11 -47
View File
@@ -1,49 +1,13 @@
# dsh-better-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-better-sidebar@<version>
#
# — the command reconciles `dsh.profile.bundles` against installed packages
# and, seeing this declaration, appends `dsh-better-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).
#
# To choose the terminal shell, add `config.shell` to the inserted plugin
# row (or to the profile's manual cordis.patch.yml mount line). When omitted,
# the host keeps resolving $SHELL / the login shell / powershell.exe.
# `config.shellArgs` optionally supplies explicit startup arguments; when
# non-empty they replace the automatic POSIX `-l` login flag:
#
# - insert:
# - id: better-sidebar
# name: 'dsh-better-sidebar'
# config:
# shell: /bin/zsh
# shellArgs:
# - --noprofile
# - --no-rc
#
# 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-better-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-better-sidebar'
disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.name === 'dsh-better-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,否则构建纯度门会拦。
-921
View File
@@ -1,921 +0,0 @@
# 外部插件接入指南:基于 dsh-better-sidebar 实现新页面
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
>
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.19.0**(正式版,npm `latest`,仅支持 DSH **0.1.5-rc.1+**;**0.1.5-alpha.2 及更早不再支持**——alpha.2 用户请用 v0.19.0-alpha.1,那是它的最后一版;0.1.2-rc.1 稳定线请用 v0.18.x)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11)。
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 8 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
> 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。
---
## 0. 承载面:DSH 原生右侧栏 + 插件底部工作台(v0.19.0-alpha.0 起)
从 v0.19.0-alpha.0 起,**右列完全属于 DSH**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。插件自己只保留**底部工作台**(分栏树、终端、会话内持久化)。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`:
- `registerTab` / `registerFileViewer` 签名不变;
- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台);
- `updateTab` / `closeTab` / `activateTab` 认识原生 tab id(插件为每个原生 tab 维护一条合成 `SidebarTab` 记录,`tab.meta` / `tab.path` 的写入照旧生效)。
行为差异(写在这里以免踩坑):
| 事项 | 说明 |
|---|---|
| 生命周期回调 | 原生面只有「一次打开」,不区分新建/聚焦,因此只触发 `onOpen`(`onActivate` 仅在插件自己的底部工作台里触发) |
| 去重 | 原生按 `(kind, 地址)` 去重:有 `createTab` 的类型每次新开一个 tab(terminal / browser / sidechat / diff),其余聚焦已有 tab;`dedupeKey` 的自定义语义不参与原生面 |
| 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 |
| 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 |
| 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `ui-sidebar-documentpreview` 的 `text` 预览——即 `fallback` 带),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位 |
| 终端上限 | 终端 tab 的数量上限只统计插件自己底部工作台里的终端;原生栏里的终端不计入 |
| 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `openTab` 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(`conversation.session.header.utilities`),不在插件自己的宿主里 |
| 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title` + 图标取 `icon`(缺图标时宿主补一个方块占位),说明取可选的 `description`——**宿主只在 guide 列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明;未声明 `description` 的条目渲染成单行「图标 + 标题」(rc.1 起 `description` 回到宿主契约,但**宿主与插件都没有兜底句**,所以插件恢复字段而不恢复旧的通用句);`hidden: true` 的类型不占行。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图)。**注意默认组合看不到说明**:插件贡献 6 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),已超过 4 条上限——要让说明出现,需在插件设置页关掉足够多的 tab 类型把 guide 压到 ≤ 4 条 |
| 新建面板的种子(alpha.2) | 在新会话打开原生新面板时,宿主从已注册的 guide 条目里播种:恰好 1 个条目 → 直接打开那一页;0 或 ≥2 个条目 → 打开指南。`revealIfOpened` 打开的「页面」在**同一 pane 内**强制去重(已在该 pane 就不再新建);由已有 tab 地址驱动的打开不受该去重影响 |
| alpha.2 全局面板(不接入) | 插件**不采用** alpha.2 引入的全局主面板模型——根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`,以及改根级并新增会话级 `rightbar.session` 子槽的 `rightbar`——这些只作兼容保留,不向其迁移 |
| 已移除 | 插件自绘右侧面板(含宽度拖拽 / 新会话默认宽度)与**自由窗口**(`features` 里的 `'floatWindows'` 已删除,v0.18.x 及更早版本的消费者请勿再 gate 该能力);`openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项同步删除(旧文档里的键会被忽略) |
---
## 1. 总览:你能扩展什么
better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
- **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面;
- **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。
内置的 7 个 tab(editor / git——「文件变动」统一 tab(Git 视角 + 本轮文件视角)/ subagent / sidechat / terminal / browser / diff)和 6 个 viewer(image / pdf / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。
> ⚠️ **服务只在 client half**:`ctx.betterSidebar` 只存在于浏览器侧。你的插件 **host 半没有这个服务**;host 半需要读 better-sidebar 状态时,走它自己的 HTTP/WS 路由(`/sidebar/api/*`、`/sidebar/file`、`/sidebar/ws/*`),不走服务。
---
## 2. 前置:类型合并与依赖声明
### 2.1 类型合并(统一 `@deepseek-ai/cordis`)
DSH 运行时和生态的类型正主是 vendored `@deepseek-ai/cordis`。你的插件解析到它的 `Context` 时,`ctx.betterSidebar` 不会自动出现——由 better-sidebar 用 `declare module '@deepseek-ai/cordis'` 补上:
```ts
import type {} from 'dsh-better-sidebar' // 触发 declare module '@deepseek-ai/cordis' 类型合并
```
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §10)。
### 2.2 package.json 声明
```jsonc
{
"name": "my-plugin",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
- `dsh-better-sidebar` 必须是 **peerDependency**(不是 dependency),避免两份实例;
- `optional: true`:better-sidebar 未安装时你的插件照常加载,注册代码因为 `ctx.betterSidebar` 为 undefined 而安全跳过。
> ⚠️ **计划上架 DSH 插件市场**的插件还须遵守市场 manifest 约束(`dependencies`/`peerDependencies`/`optionalDependencies` 一律不得出现 `cordis`、`scripts` 不得含 install 类钩子),详见 [AGENTS.md](../AGENTS.md) 硬约束一节。
### 2.3 类型导入路径
```ts
// 方式一:主入口(推荐,src/index.ts 已 re-export 全部描述符类型)
import type {
BetterSidebarService,
TabDescriptor,
TabComponentProps,
FileViewerDescriptor,
FileViewerProps,
FileFetchStrategy,
} from 'dsh-better-sidebar'
// 方式二:子路径(与主入口等价)
import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api
```
v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
```ts
import type {
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
} from 'dsh-better-sidebar/client/service'
```
> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module '@deepseek-ai/cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。
---
## 3. 最小骨架(client half)
```ts
// my-plugin/src/client/index.ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
import type { Context } from '@deepseek-ai/cordis'
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需)
export function apply(ctx: Context): void {
// 注册一个 sidebar tab:ctx.effect 包裹 → 卸载时自动撤销注册(HMR-safe)
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
icon: <DbIcon />,
order: 50,
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 fetchCsvBytes(scope, path)),
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
})
)
}
```
要点:
- **注册必须包在 `ctx.effect(...)` 里**。`registerTab` / `registerFileViewer` 返回 `() => void` disposer,Cordis fiber 卸载时自动调用;不包 effect,HMR / 插件禁用后注册残留,下次激活会抛 `"already registered"`。
- `inject = ['betterSidebar']` 让 Cordis 在 better-sidebar 激活后才激活你的插件,注册时机无忧(顺序无关)。
- 注册在 `apply` 内任意时刻都行;服务在 better-sidebar 的 `apply()` 开头就绪。
---
## 4. 新页面(Tab)注册 API
### 4.1 `TabDescriptor` 完整字段
```ts
// 设置行控件形状(settings.toggles / settings.pluginToggles 共用;text/number v0.11.0+,select v0.13.0+):
interface SettingRow {
key: string // toggles: SidebarPrefs 字段名;pluginToggles: 插件局部 key
title: string | (() => string)
desc?: string | (() => string)
type?: 'switch' | 'text' | 'number' | 'select' // 缺省 'switch'
min?: number; max?: number // number 行提交钳制
placeholder?: string; unit?: string // text 行占位符 / 单位后缀(如 'px')
options?: readonly { // select 行选项
value: string | number | boolean
title: string | (() => string)
desc?: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
}[]
multi?: boolean // select 多选(缺省 false;存 value 数组,按 options 顺序提交)
}
// text/number 行 blur/Enter 提交;select 任一项带 icon 时渲染大图标选项卡,否则单行文本。
interface TabDescriptor {
/** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */
id: string
/** 标题(i18n 友好:传字符串或返回字符串的函数) */
title: string | (() => string)
/**
* 一行说明,渲染在标题下方(DSH 原生右侧栏的新建标签页 / guide 列表)。
* **宿主只在 guide 列出的条目 ≤ 4 条时渲染它**(上游 `MAX_DESCRIBED_ENTRIES = 4`),
* 更长的列表整列丢掉所有说明——也就是说这是一行「锦上添花」,别把关键信息只放这里。
* 不声明就不发 `description` 字段:宿主没有兜底句,插件也不补(通用句在所有页面上
* 长得一样,纯噪音),条目就渲染成单行「图标 + 标题」。函数形式在渲染时求值,跟随语言。
*/
description?: string | (() => string)
/** 图标:ReactNode 或 (size: number) => ReactNode(不声明时宿主补一个方块占位) */
icon?: ReactNode | ((size: number) => ReactNode)
/** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, sidechat=35, terminal=40, browser=50 */
order?: number
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
hidden?: boolean
/** + 菜单禁用判定(如 terminal 配额满)。返回 false 只影响菜单 disabled,不拦截 openTab(只有设置页禁用开关会)。 */
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
/**
* 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
* 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
*/
single?: boolean
/**
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
* 内置策略:git/subagent 用 single;editor 用 tab.path;diff 用 tab.id。
* 必须纯函数:每次 open 求值两次,抛错向外传播。
*/
dedupeKey?: (tab: SidebarTab) => string | undefined
/**
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* 外链认领(v0.13.0+,`features.includes('urlTarget')` gate):外链被接管
* (`browserInterceptLinks` 总闸 + 协议开关均开)时,第一个 urlTarget 命中且未禁用的
* 类型以 openTab({ type, url, title: hostname }) 打开,URL 预填 tab.path。先到先得;
* 谓词抛错被吞。内置 browser 不声明、永远隐式兜底。
* 多 URL 并存需 createTab 铸造 per-URL id,否则二次点击被 id 安全网聚焦、不覆写 path。
*/
urlTarget?: (url: URL) => boolean
/**
* 声明式设置(v0.4.1+):见 §8。v0.12.0 起增加 `pluginToggles`(插件自有
* 设置行,key 无需宿主 schema 字段)与 `render`(自定义设置面板)。
*/
settings?: SidebarSettingsDeclaration
/**
* tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶),
* string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价;
* 抛错会被吞掉(不显示角标,不影响渲染)。
*/
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
/**
* 生命周期回调(v0.12.0+),只由 SERVICE 路径触发:
* - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦不算打开);
* - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活);
* - onClose:closeTab 关闭 tab 后。
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发
* 回调——但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走
* service 路径。回调抛错只 console.error,绝不打断打开/关闭流程。
* openTab 回调 scope 携带调用者传入的 { sessionId, cwd? };
* closeTab/activateTab 仅显式传 scope 时带 cwd。
*/
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
onClose?: (tab: SidebarTab, scope: SessionScope) => void
/** 渲染函数 */
component: (props: TabComponentProps) => ReactNode
}
/** 声明式设置声明(行控件形状见上方 SettingRow)。 */
interface SidebarSettingsDeclaration {
/** 宿主 prefs 字段行(key 必须是宿主 PrefsSchema 的字段,内置键清单见 §8)。 */
toggles?: readonly SettingRow[]
/** 插件自有设置行(v0.12.0+):key 插件局部,
* 持久化在 pluginSettings[<descriptor id>],无需宿主 schema 字段。 */
pluginToggles?: readonly SettingRow[]
/** 自定义设置面板(v0.12.0+):追加渲染在行列表之后,可单独存在;
* 抛错被吞并显示内联错误。 */
render?: (props: SidebarSettingsRenderProps) => ReactNode
}
/** settings.render 收到的 props(v0.12.0+)。 */
interface SidebarSettingsRenderProps {
store: SidebarStore
service: BetterSidebarService
prefs: SidebarPrefs
/** 本 descriptor 自己的持久化设置 blob(pluginSettings[id])。 */
pluginSettings: Record<string, unknown>
/** 持久化一条本 descriptor 的插件设置(值须 JSON 可序列化)。 */
updatePluginSetting(key: string, value: unknown): void
/** 关闭设置弹窗。 */
close(): void
}
```
### 4.2 `TabComponentProps`(你的页面组件收到的 props)
```ts
interface TabComponentProps {
ctx: Context // client cordis context
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
scope: SessionScope // { sessionId, cwd? } —— 会话标识,调用 /sidebar API 必带
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?/meta?)
visible: boolean // 是否当前激活 tab 且面板打开(不可见时暂停轮询等)
// 以下由内置 tab 使用,外部 tab 可忽略:
expanded?: string[] // 文件树的展开目录集
onToggleDir?: (path: string) => void
// 在会话 composer 插入一条 @ 引用。isDir=true 为目录:纯文本 `@dir/`,
// 保留宿主文件夹装饰与补全;false 为文件:走宿主结构化引用 chip
// (显示 @basename、序列化为完整 @path),宿主拒绝时回退纯文本。
onReferenceFile?: (path: string, isDir: boolean) => void
onOpenFile?: (path: string) => void
onOpenDiff?: (tab: SidebarTab) => void
onSubagentJump?: (childSessionId: string) => void
}
```
实践建议:
- **用 `visible` 做性能门**:subagent 内置页在 `visible === false` 时暂停轮询;你的页面若有轮询/订阅,同样处理。
- **用 `scope.sessionId`(+ `scope.cwd`)访问会话数据**:所有 `/sidebar/api/*` 请求都要带这两个字段(见 §6)。
### 4.3 注册示例
**最简单实例 tab**(+ 菜单可见):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:notes',
title: 'Notes',
icon: <NoteIcon />,
order: 50,
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
})
)
```
**多实例 tab + 外部触发打开**(每次新开,带自定义 id):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:doc',
title: 'Doc',
icon: <DocIcon />,
order: 60,
// 不设 dedupeKey:每次 openTab 都新开
component: ({ tab, scope }) => <DocView docId={tab.id} sessionId={scope.sessionId} />,
})
)
// 外部触发打开(你的插件其他流程、甚至用户操作):
ctx.betterSidebar.openTab({ type: 'my-plugin:doc', title: 'Spec.md', id: 'doc:spec' })
```
**条件可见**(仅满足条件时 + 菜单可用;返回 false 显示为 disabled 行而非隐藏):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:commits',
title: 'Commits',
icon: <CommitIcon />,
order: 70,
available: (ctx, scope, state) => hasGitRepo(state),
dedupeKey: () => 'my-plugin:commits',
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
})
)
```
**自定义创建**(mint 自增 id,terminal 内置页同款):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:console',
title: 'Console',
order: 80,
createTab: (state) => ({
tab: { id: `console:${state.nextTerminal}`, type: 'my-plugin:console', title: `Console ${state.nextTerminal}` },
patch: { nextTerminal: state.nextTerminal + 1 }, // 借内置计数器;也可自建 state 字段
}),
component: ({ tab, scope }) => <ConsoleView tabId={tab.id} sessionId={scope.sessionId} />,
})
)
```
**认领外链点击**(v0.13.0+,`urlTarget` + `createTab` 组合):
```ts
ctx.effect(() => {
if (!ctx.betterSidebar.features.includes('urlTarget')) return // 老版本优雅降级
return ctx.betterSidebar.registerTab({
id: 'my-plugin:web-docs',
title: () => 'Docs',
order: 80,
urlTarget: (url) => url.hostname === 'docs.my-site.com',
createTab: (state) => ({ // per-URL id,多 URL 并存
tab: { id: `my-plugin:web-docs:${state.nextBrowser}`, type: 'my-plugin:web-docs', title: 'Docs' },
patch: { nextBrowser: state.nextBrowser + 1 },
}),
component: ({ tab, scope }) => <WebDocsView url={tab.path} sessionId={scope.sessionId} />,
})
})
```
### 4.4 内置 tab 清单(不可重复注册)
| id | order | single | hidden | 用途 |
|---|---|---|---|---|
| `editor` | 10 | 否(按 path 去重) | 否 | 唯一「文件窗口」(编辑/预览 + 资源管理)。chrome 恒合并形态:路径输入框 + 编辑器控件 + 可开关内嵌文件树(全局搜索 `fs.search`;状态存 `tab.meta.treeOpen/treeWidth`)。`editorExplorer`:关(默认)= 按 path 新开,无路径窗口 = 纯资源管理器;开 = 树点击/Enter 经 `updateTab` 原地切换(id/meta 不变),无路径窗口 = 带 chrome 空窗口。树右键「在新 Tab 中打开」「在侧边打开」(pane 右侧 split)。新会话 seed 空文件窗口(`title:'Files'`);旧 `explorer` tab 经 `sanitizeState` 迁移 |
| `git` | 20 | 是 | 是(本轮文件操作数) | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按文件分组、类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),badge 读 tab 轮询写入的同步缓存。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab(落进工作台的 diff 分栏) |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `sidechat` | 35 | 否(`sidechat:<uuid>`,按 `meta.threadId` 去重) | 否 | 侧边对话(每对话一 Tab):打开即建空线程(首条消息赢得标签并同步标题);线程 = 插件自建子会话(种子继承父会话上下文,进行中回合以 `interrupted` 闭合;种子带合法 `subagent/descriptor`,SubagentView 按 `Side: ` 前缀过滤),`origin:'subagent'` 隐藏于主列表;走 `/sidebar/api/sidechat.*` 路由;头部菜单切换/重开(`parkSidechatReopen` + 确定性 id),关 Tab 释放 live agent;重开经 `collectOwnEvents` 回源到种子边界;「保存为新会话」= `session.fork`(`this` 敏感)。[设计文档](plans/2026-08-20-sidechat-tab-design.md) |
| `terminal` | 40 | 否(`terminal:<n>`) | 否 | 终端。v0.17.0+ 右键「固定到工作区/全局」:跨会话不消失,TabBar 内联虚拟 Tab(`pinned:<homeSessionId>:<tabId>`),就地按 home scope 连 PTY;global 全会话可见、workspace 仅同 cwd;`tab.pin = { scope, homeCwd? }` 随会话持久化,渲染期解析(`collectPinnedTabs` → `createPinnedVirtualTab` → `injectPinnedIntoTree`) |
| `browser` | 50 | 否(`browser:<n>`) | 否 | 内嵌浏览器(沙箱 iframe,可设置关沙箱) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(changes tab 的预览面板「展开为独立页签」触发,同一渲染栈) |
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
---
## 5. 文件预览器(FileViewer)注册 API
### 5.1 `FileViewerDescriptor` 完整字段
```ts
interface FileViewerDescriptor {
/** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
id: string
/** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */
title?: string | (() => string)
/** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */
icon?: ReactNode | ((size: number) => ReactNode)
/** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级盲命中有效) */
exts: readonly string[]
/** 优先级(高优先);默认 0。内置:image/pdf/markdown/html=0,binary-download=-50,code=-100 */
priority?: number
/** 字节获取策略 */
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer
* 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings(§4.1 的
* toggles/pluginToggles/render;v0.12.0 起 viewer 卡片也有齿轮按钮) */
settings?: SidebarSettingsDeclaration
/** 渲染函数 */
component: (props: FileViewerProps) => ReactNode
}
```
### 5.2 `FileViewerProps`
```ts
interface FileViewerProps {
ctx: Context
store: SidebarStore
scope: SessionScope
path: string
title: string
viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
content?: string // fetchStrategy='fsRead' 时
truncated?: boolean // fetchStrategy='fsRead' 时
mediaUrl?: string // fetchStrategy='mediaUrl' 时
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
// 以下为内置文本编辑器与 EditorHost 内部协作字段,外部 viewer 忽略:
toolbar?: 'self' | 'host'
onToolbarState?: (state: EditorToolbarState) => void
onToolbarControls?: (controls: EditorToolbarControls | null) => void
}
```
### 5.3 `fetchStrategy` 对照
| 策略 | 字节来源 | 传给 component 的字段 | 适用 |
|---|---|---|---|
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) |
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
### 5.4 匹配算法(`matchFileViewer`)
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领);
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)。
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead;内嵌 HTML 支持:DOMPurify 白名单消毒、`<details>` 跨段嵌套、本地媒体 src 重写走 `/sidebar/file`;≥3 标题时浮动目录大纲。实现 `markdown-html.ts` / `MarkdownHtml.tsx` / `md-toc.tsx`,[设计文档](plans/2026-08-24-markdown-html-toc-design.md)) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),以相同 id 注册。
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
### 5.5 注册示例
**CSV 预览器**(自定义加载 + 渲染):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => {
const text = await fetchText(scope, path)
return parseCsv(text)
},
component: ({ customData, path }) => <CsvGrid rows={customData as string[][]} path={path} />,
})
)
```
**覆盖内置 image viewer**(如自定义 SVG 优化渲染):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:svg-pro',
exts: ['svg'],
priority: 10, // 高于内置 image 的 0
fetchStrategy: 'mediaUrl',
component: ({ mediaUrl }) => <OptimizedSvg src={mediaUrl} />,
})
)
```
**内容嗅探**(按 magic bytes 路由,忽略扩展名):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:magic-parquet',
exts: [], // catch-all,但 priority 高 + detect 精确命中
priority: 100,
fetchStrategy: 'custom',
detect: (_path, head) => head.length >= 4
&& head[0] === 0x50 && head[1] === 0x41
&& head[2] === 0x52 && head[3] === 0x31, // 'PAR1'
load: async (path, scope) => parseParquet(await fetchBytes(scope, path)),
component: ({ customData }) => <ParquetTable data={customData} />,
})
)
```
---
## 6. 页面内如何访问数据(/sidebar API)
你的 tab / viewer 组件运行在浏览器里,与内置视图同源同权。访问文件/会话数据直接 `fetch` better-sidebar 的 JSON API(内置 `src/client/api.ts` 的封装就是干这个的,你可以在自己插件里复制这个 fetch 模式):
```ts
// POST /sidebar/api/<method>,body 带 sessionId + cwd(可选)
const res = await fetch('/sidebar/api/fs.read', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: scope.sessionId, path }),
})
const { value } = await res.json() // 错误时 { ok: false, error: { code, message } }
```
常用方法(完整清单见 `src/client/api.ts`):
| 方法 | 说明 |
|---|---|
| `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) |
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型) |
| `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) |
| `fs.write` | 原子写文件 |
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
| `pty.close` / `agent-pty.close` | 释放终端(外部 tab 一般用不到) |
| `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫) |
> **文件路径安全边界**:`fs.tree`、`fs.read`、`fs.write`、`/sidebar/file`、`/sidebar/html` 和 `/sidebar/upload` 都以请求对应 session 的权威 `cwd` 作为 workspace 根目录。路径会按真实文件系统路径检查,越界绝对路径、`..` 解析结果和指向 workspace 外部的符号链接都会被拒绝;消费插件不应把 `cwd` 当作可由用户扩大权限范围的参数。
媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`):
```ts
// 媒体 URL(图片等直接 <img src>):/sidebar/file?sessionId=...&path=...
const url = `/sidebar/file?${new URLSearchParams({ sessionId: scope.sessionId, path })}`
```
> 注:内置的 `api.ts` 是 better-sidebar 内部模块,外部插件 **不要** value-import 它(构建纯度门会挡);按上表模式自己 fetch 即可。所有路由带与 `/api` 相同的 Host 头信任围栏,浏览器同源访问天然通过。
---
## 7. 服务方法完整清单
```ts
interface BetterSidebarService {
/** 注册 tab 类型;返回 disposer */
registerTab(descriptor: TabDescriptor): () => void
/** 注册文件预览器;返回 disposer */
registerFileViewer(descriptor: FileViewerDescriptor): () => void
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
getTabs(): readonly TabDescriptor[]
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
getFileViewers(): readonly FileViewerDescriptor[]
/** 按 id 查 tab 描述符 */
getTab(id: string): TabDescriptor | undefined
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
isTabEnabled(id: string): boolean
/** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
isViewerEnabled(id: string): boolean
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
/**
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
* url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子);
* 聚焦既有 tab 时 url 不会覆写其 path。
* 被设置禁用的类型是 no-op(console.warn 提示)。注意:available 不拦截 openTab。
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
* 定向打开不自动展开目标 session 的面板;缺省或指向当前 session 时行为
* 与之前完全一致。
* 内容型打开(带 path/url seed)在落点面板折叠时自动展开,保证落点可见;
* 类型型打开(+ 菜单等)不展开。
*/
openTab(seed: OpenTabSeed, scope?: SessionScope): void
/** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+)
* 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */
closeTab(tabId: string, scope?: SessionScope): void
/** 订阅注册表变化(register/dispose 时触发) */
subscribe(listener: () => void): () => void
// ── v0.12.0+ ──────────────────────────────────────────────────────────
/** 插件版本(如 '0.17.1';与 package.json 同步,测试守护) */
readonly version: string
/** 能力清单(只增不删,唯一例外:v0.19.0 删除了 'floatWindows'):
* 'badge' | 'tabLifecycle' | 'updateTab' | 'openFile' | 'targetedOpen' |
* 'stateSubscription' | 'tabMeta' | 'pluginSettings' | 'urlTarget' |
* 'settingSelect' ——用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
* session 未激活时 state/sessionId 为 undefined。 */
getSnapshot(): SidebarSnapshot
/** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */
subscribeState(listener: () => void): () => void
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate;
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
activateTab(tabId: string, scope?: SessionScope): void
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
* id 按路径派生(`editor:` + path),与内置 open-path 拦截一致,不同文件可并排打开)。
* 注意:path 派生 id 只对 openFile/openSidebarFile 成立;editorExplorer 合并模式的
* 原地切换经 updateTab 重写 path/title,tab id 保持稳定、不再对应 path。 */
openFile(scope: SessionScope, path: string, title?: string): void
}
/** openTab 的 seed(v0.12.0 起导出命名类型)。 */
interface OpenTabSeed {
type: string
title?: string
path?: string
diff?: SidebarTab['diff']
id?: string
url?: string
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复);
* undefined = 不改,null = 显式清除 */
meta?: unknown
}
```
**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:
```ts
if (ctx.betterSidebar.features.includes('badge')) {
// 使用 TabDescriptor.badge
}
if (ctx.betterSidebar.version >= '0.12.0') { /* 字符串比较即可:minor 只增 */ }
```
**生命周期示例**(v0.12.0+):打开时启动资源、关闭时释放——组件卸载 ≠ tab 关闭(会话切换也会卸载),所以释放资源要用 `onClose`:
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: 'Database',
single: true,
badge: (_ctx, _scope, state) => /* 比如打开的连接数,每次 tab 栏渲染调用,保持廉价 */,
onOpen: (tab, scope) => { startWatcher(scope.sessionId) },
onClose: (tab, scope) => { stopWatcher(scope.sessionId) },
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
})
)
```
---
## 8. 声明式设置(v0.4.1+)
每个注册的 tab / viewer **自动**出现在 DSH 设置页「侧边卡片」分区(`SideCardSection` 按注册表驱动渲染,无硬编码):
- 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。
- 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。
- 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。
- `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片底部「功能设置」条在原生弹窗中编辑。行控件形状见 §4.1 的 `SettingRow`:`type: 'switch' | 'text' | 'number'`(v0.11.0+;text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)与 `type: 'select'`(v0.13.0+;`options` 支持 value/title/desc/icon,`multi` 多选存数组并按 options 顺序提交;任一项带 icon 时渲染大图标选项卡)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools` + 自定义字体行、editor tab 的 `editorExplorer` 图标化下拉与 `workspaceFence` 开关(工作区路径围栏,见 §8.1)。
- `settings.pluginToggles`(可选,v0.12.0+):**插件自有设置行**,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有设置条)。
- `settings.render`(可选,v0.12.0+):**自定义设置面板**——追加渲染在行列表之后,可单独存在。props 含 store/service/prefs、本 descriptor 的 `pluginSettings` blob、`updatePluginSetting(key, value)` 与 `close()`;抛错会被吞掉并显示内联错误。
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: 'Database',
order: 50,
settings: {
// 宿主 prefs 字段行:key 必须是宿主 PrefsSchema 的字段(仅此限制)
toggles: [{
key: 'autoOpenSubagent', // 宿主内置键
title: 'Auto-open',
desc: 'Open when a subagent appears',
}],
// 插件自有设置行:key 插件局部,持久化在 pluginSettings['my-plugin:db']
pluginToggles: [{
key: 'pageSize',
title: 'Page size',
type: 'number',
min: 1,
max: 100,
unit: 'rows',
}],
// 或完全自定义面板(追加在行列表之后)
render: ({ store, service, prefs, pluginSettings, updatePluginSetting, close }) => (
<MySettingsPanel
values={pluginSettings}
onChange={(key, value) => { updatePluginSetting(key, value) }}
onDone={close}
/>
),
},
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
})
)
```
> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `agentOpenTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `workspaceFence` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps` / `changesDiffFloat`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
### 8.1 内置键 `workspaceFence`(工作区路径围栏)
所有侧栏文件系统路由(`fs.tree` / `fs.read` / `fs.write` / `/sidebar/file` 媒体 / `/sidebar/html` 预览 / `/sidebar/upload`)默认强制**工作区包含检查**:客户端提供的路径经 realpath 解析后必须落在会话工作区内,否则 403(wire 错误码 `forbidden`,消息 `path "..." is outside workspace`)。`workspaceFence: false`(默认 `true`)解除该包含检查——路径仍会解析符号链接得到 canonical 路径,但不再拒绝工作区外的目标(如全局 `~/.dsh/AGENTS.md`、会话 cwd 之外的 linked worktree)。
- 开关位置:设置页「文件」卡片 →「功能设置」二级弹窗;编辑器加载失败与文件树列目录失败两个错误面在触发围栏拒绝时会显示原因 + 一键全局关闭按钮(`FenceErrorNotice`,写 `workspaceFence: false` 后自动重试失败的操作)。
- ⚠️ **安全代价**:关闭期间,页面内任意同源脚本(**包括第三方消费插件**)都能通过上述路由读写主机上任意文件——开关文案已明示该风险,用完建议重新打开。
---
## 9. 生命周期与 HMR
- **disposer 必须返回并被 fiber 持有**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。
- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,你的 `inject = ['betterSidebar']` 保证你激活时服务已就绪;注册顺序无关紧要。
- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `<OrphanedTab/>` 占位卡("插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复(`sanitizeNode` 保留未注册类型而非丢弃)。
- **`visible` 语义**:面板折叠或非激活 tab 的 `visible` 为 false;你的页面应借此暂停轮询/订阅,激活时恢复。
---
## 10. 平台约束与陷阱
| 陷阱 | 说明 |
|---|---|
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`(`tsdown.config.ts` 的 `dsh-client-bundle-purity` 插件);**类型 `import type {}` 会被擦除,不触发门禁**——类型可自由共享,运行时符号不行。所有跨插件交互走 `ctx.betterSidebar` 方法调用 |
| **统一 cordis 分支(v0.15.2+)** | 类型基底与 augmentation 全部在 DSH 运行时正主 `@deepseek-ai/cordis` 上(`src/context-types.ts`:真实 cordis `Context` 与结构化服务面做**交集**,不再重述 `declare module 'cordis'`——host/client 包对同名成员声明不同会 TS2717)。消费者 `import type {} from 'dsh-better-sidebar'` 即拿到 `ctx.betterSidebar`,或直接 `import type { Context } from 'dsh-better-sidebar'`。公开版 `cordis` 不再被依赖 |
| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 |
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
| **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import |
| **client 声明图零 Node 依赖(v0.12.0+)** | `dsh-better-sidebar/client/service` 的可达声明面(含 `Context`)不引用 `node:*` / `Buffer`——纯浏览器侧插件无需 `@types/node`,`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 守护;主入口含宿主声明,宿主消费者本就处于 Node 环境) |
| **家族右面板互斥(v0.13.0+)** | `aionui-panel` 设置的 `rightPanel` 解析为 `'aionui-panel'` 时整个侧边栏不挂载(`settings.get` 返回 `externalDisable: true`,挂载门 + 接管停用;`settings/document-updated` 推送实时生效,无 `remote` 服务回退启动时判定)。未安装 aionui 不受影响 |
| **i18n 跟随** | 文案跟随 DSH `ctx.locale`(词典在 `betterSidebar` 命名空间;Host-backed `locale.preference` 优先于浏览器语言并实时切换;缺失回退浏览器)。消费插件**不要**依赖内部 `t()`——标题传字符串或 `() => string` |
| **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(optional)提供 ja/ko 覆盖,**借用 DSH 英文槽位**(仅 DSH=en 时生效,zh 下惰性)。经 `ctx.get('betterLocale')` 注入 `t()`;未安装整段 no-op |
| **懒加载 chunk** | 重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`),经 `/sidebar/bundle` 按需下发;factory 赋到 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 物化,**不经** `__ModuleLoader__`。对消费插件透明 |
| **聊天文件打开漏斗(0.1.5 宿主)** | 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 DSH 原生右侧栏的 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点);`remote.session.openWorkspacePath` 在 0.1.5 客户端**已无调用者**,better-sidebar 的 openpath 拦截随之删除。你的插件若要观测/旁路聊天文件打开,注册原生 tab 类型认领 `dsh-resource://file/**`(见 [AGENTS.md §3](AGENTS.md) 第 10 条);不要假设 `ctx.workspaces.openPath`(已删除)或旧 `remote.session` 漏斗存在 |
| **原生 tab 体的高度契约** | 你的 tab 组件被挂在**全高列 flex 宿主**里:native 适配层给每个 tab 体(含 orphaned 回退)包一层 `height:100%` 的 `sidebar.module.css` `.nativeTabHost`(带 `data-dsh-native-tab-host`)——根用 `flex: 1` / `height: 100%` 并加 `min-height: 0` 填满面板。宿主的面板体 `.paneBody`(`dsh-client-ui-dockkit`)是**有确定高度的块级滚动容器,不是 flex 容器**,因此只靠外层 flex 容器支撑的根会塌成内容高度(sidechat 的输入框就贴不到面板底);DSH 自己的 tab 体(`ui-sidebar-documentpreview` 的文档预览、`ui-sidebar-files` 的 FilesBody)正是因此在根上声明 `height: 100%` |
---
## 11. 自由窗口(v0.16.0 – v0.18.x,**v0.19.0 已移除**)
自由窗口(tab 拖出侧边栏成为可移动/缩放的悬浮窗,`SidebarState.floats`、`floatTab`/`dockFloat`/`raiseFloat`、`features` 里的 `'floatWindows'`、右键「移动到自由窗口」、`src/client/FreeWindow.tsx`)在 **v0.19.0-alpha.0 整体删除**:右列交给 DSH 原生右侧栏后,插件只剩底部工作台,浮窗既没有语义归属也没有可用手势(拖出目标区域现在是原生栏的属地)。
- 升级到 v0.19.x 后请**不要**再 gate `features.includes('floatWindows')`,也不要引用 `floatTab` 等符号(类型声明里已不存在);
- 旧文档里持久化的 `floats` 字段会被 `sanitizeState` 直接忽略,会话不会因此加载失败;
- 需要「同一个 tab 在别处看」的场景,改用原生栏打开(`openTab` 默认落点)或底部工作台分栏(`OpenTabSeed.target: 'bottom'`)。
---
## 12. 皮肤兼容(令牌驱动)
> better-sidebar 所有视觉值消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),**不做每皮肤适配**。已与 dsh-web-ui 皮肤中心兼容(10 款皮肤全覆盖 `--dsw-alias-*` 层;`tests/theme.spec.ts` 守护)。你的 tab/viewer 组件遵循同样的令牌规则即可自动兼容全部皮肤。
### 12.1 规则
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。
- **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none; overflow:hidden+clip`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。`overflow` 级联是**契约**(`hidden` 兜底 + `clip` 收尾,`tests/panel-host-css.spec.ts` 守护):`hidden` 盒子仍是滚动容器,脚本滚动或浏览器 scroll-into-view 修正(焦点移入视口外区域、嵌套 iframe/工作台加载时抢焦点、面板滑出动画中 focus() 落点)会沿最近可滚祖先滚走整层——面板与开关簇集体偏离视口角(computed left/right 仍"正确",偏移藏在盒子自身 scroll offset 里);`clip` 裁剪语义相同但不产生滚动盒,任何路径都滚不动这层。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为对话槽宿主的父级——alpha.2 起 shell 把 `#root [data-slot="main.conversation"]` 解析进列并跳过 `display: contents` 祖先(`center-column.ts` 的 `CENTER_COLUMN_SELECTOR` 同时认 `main.conversation` 与 alpha.1 的 `conversation`),定位器从槽宿主向上取第一个非 `contents` 的祖先;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。
- **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数):
- 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。
- **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。
- **四方案**:`auto`(默认,只信 WCO——"为某壳做的兼容在另一个壳会再坏",核心不做壳专属分支)/ `web`(强制 0)/ `preset`(`src/client/shell-presets.ts`,准入:issue/PR 提及且 GitHub ⭐>100;命中环境显示「已检测」后缀,绝不自动启用)/ `custom`(用户 CSS + 手动 px,齿轮弹窗)。
- **迁移**:`titleBarScheme` 无默认值;旧文档已有值(`titleBarCompat === true` 或 `titleBarStripPx` 非 40)→ 迁 `custom`;干净文档 → `auto`。
- **用户空间 CSS**:预设/自定义 css 注入 `<style data-dsh-preset-css|data-dsh-custom-css>` 到 head 末尾(后写胜出;覆盖 JS 内联需 `!important`),fiber 卸载即移除。稳定寻址面:`[data-dsh-toggle-cluster]` / `[data-dsh-panel]` / `[data-dsh-bottom-panel]`。
- **拖拽区退出**:交互 chrome(`.toggleCluster` / `.toggleButton` / `.tabBar`)统一 `-webkit-app-region: no-drag`(无边框壳拖拽带吞点击,#103/#111)。
- **z-index**:面板宿主层 25、按钮簇 45——低于 DSH ui-cordis 插件面板(30)与浮层栈(100/1000+),浮层天然盖住侧边栏。
### 12.2 注意事项
- 类名是 CSS Modules 哈希,**不是契约**;精确命中用 `[data-dsh-better-sidebar]` + 子串类名(`[class*='panel']`)或 DOM 结构。
- 改动本契约必须同步本文档、设计文档与 `tests/theme.spec.ts`。
---
## 13. 完整最小示例
假设插件 `my-plugin` 要加一个 "Database 浏览器" tab + `.csv` 文件预览器。
**`my-plugin/package.json`**:
```jsonc
{
"name": "my-plugin",
"version": "0.1.0",
"main": "lib/index.js",
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"dsh-better-sidebar": "workspace:*",
"react": "^18.2.0"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
**`my-plugin/src/client/index.tsx`**(CSV viewer 的 `custom` load 直取 `/sidebar/api/fs.read`,注意响应 envelope 是 `{ value }`):
```tsx
import { createElement } from 'react'
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
import type { Context } from '@deepseek-ai/cordis'
export const inject = ['betterSidebar']
export function apply(ctx: Context): void {
// Database tab(单实例,+ 菜单可见)
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
order: 50,
dedupeKey: () => 'my-plugin:db',
component: ({ scope }) => createElement(DbView, { sessionId: scope.sessionId }),
})
)
// CSV viewer(custom 策略:自己拉字节 + 解析)
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => {
const res = await fetch('/sidebar/api/fs.read', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: scope.sessionId, path }),
})
const { value } = await res.json()
return parseCsv(value.content)
},
component: ({ customData, path }) =>
createElement(CsvGrid, { rows: customData as string[][], path }),
})
)
}
function DbView(props: { sessionId: string }): React.ReactNode { /* ... */ }
function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /* ... */ }
function parseCsv(text: string): string[][] { /* ... */ }
```
**注册到 profile**:
1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`;
2. `~/.dsh/profiles/web/cordis.patch.yml` 追加挂载行(`- insert: - id: my-plugin / name: 'my-plugin'`);
3. 在 profile 目录 `pnpm install`;
4. 浏览器硬刷新(Cmd/Ctrl+Shift+R)即可看到效果(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
---
## 14. 参考实现与调试
better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"),调试时直接读:
- **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 6 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts);Office 预览见 plugins-viewers.ts
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
- **`src/client/Sidebar.tsx`**:底部工作台外壳 + `TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)
- **`src/client/native/`**:原生右侧栏接入(tab 类型注册、合成 `SidebarTab` 适配、资源地址、跨会话打开排队)
- **`src/client/sidebar/bottom-toggle.tsx`**:底部工作台开合按钮(注册进 DSH 会话头 utilities 槽)
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + 嵌套设置行 + 开关持久化)
- **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件)
- **`src/client/plugins-tabs.ts`** / **`plugins-viewers.ts`**:推荐插件目录(「添加插件」弹窗数据源;加一条数据即上架,`tests/plugin-list.spec.ts` 守护)
- **`src/client/FileTree.tsx`** / **`TreePanel.tsx`** / **`src/fs-search.ts`**:文件树 / 树面板 / host 文件名搜索(`fs.search`;`tests/fs-search.spec.ts`)
- **`src/client/markdown-html.ts`** / **`MarkdownHtml.tsx`** / **`md-toc.tsx`**:markdown 内嵌 HTML 管线与目录大纲(注意 `md-toc.tsx` 头注释的「子组件读父 ref 为 null」时序陷阱)
- **`src/agent-opens.ts`** / **`/sidebar/ws/agent-opens`**:模型主动打开(`sidebar_open` 工具 + `agentOpenTools` 设置,默认关闭);文件夹窗口 = `meta.dir: true` 的 editor tab([设计文档](plans/2026-08-23-agent-open-tools-design.md))
- **`tests/service.spec.ts`** / **`tests/builtins.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating;内置清单断言(7 tab + 6 viewer + 声明式元数据)
- **`docs/plans/`**:逐特性设计文档(含实施偏差记录,以现状为准);入口如 `2026-08-11-service-registry-design.md` / `2026-08-11-declarative-sidebar-settings-design.md`
---
## 15. 真实接入案例
第一个通过 `ctx.betterSidebar` 接入的三方插件:[dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) —— 条件驱动的 agent 唤醒系统(文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话)。
- **接入方式**:可选软依赖——client half 本地重述最小服务契约(`registerTab`),未安装 better-sidebar 时注册静默跳过,插件原有表面不受影响;
- **注册内容**:`dsh-sentinel:watches` tab(order 60,单实例):全服务器监控表 + 最近触发历史;
- **类型处理**:未 value-import `dsh-better-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
- **实测**:v0.3.0 起,真实 web profile 验证通过。
通过 `ctx.betterSidebar` 的三方插件 [dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) —— 基于 better-sidebar 的划选提问:对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题);快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档。
更多插件接入后欢迎在此登记(一句话 + 链接)。
@@ -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,88 +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)。
## 决策
- **基线 = 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,79 +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)。
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

-16
View File
@@ -1,16 +0,0 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.19.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
"dsh": ">=0.1.5-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 } },
},
)
+49 -202
View File
@@ -1,215 +1,62 @@
{
"name": "dsh-better-sidebar",
"version": "0.19.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"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"
},
"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",
"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"
},
"packageManager": "pnpm@11.8.0",
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"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"
],
"platform": "web"
}
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/client-registry.js",
"lib/client-terminal.js",
"lib/client-editor.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"
"dsh.bundle.patch": "./cordis.patch.yml",
"dsh.client.inject": [
"betterSidebar",
"locale",
"sessions"
],
"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: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 ."
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.5-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.1",
"@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
"@deepseek-ai/dsh-session": "^0.1.5-rc.1",
"@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
"@deepseek-ai/dsh-subagent": "^0.1.5-rc.1",
"@deepseek-ai/dsh-tools": "^0.1.5-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",
"@lezer/highlight": "^1.2.3",
"clsx": "^2.1.1",
"dompurify": "^3.4.14",
"mermaid": "^11.16.1",
"node-pty": "^1.1.0",
"react-icons": "5.7.0",
"rxjs": "^7.8.2",
"schemastery": "^3.18.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "0.1.5-rc.1",
"@deepseek-ai/dsh-agent-loop": "0.1.5-rc.1",
"@deepseek-ai/dsh-attachment": "0.1.5-rc.1",
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.1",
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.1.5-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.1",
"@deepseek-ai/dsh-code-runtime": "0.1.5-rc.1",
"@deepseek-ai/dsh-host-webserver": "0.1.5-rc.1",
"@deepseek-ai/dsh-invariants": "0.1.5-rc.1",
"@deepseek-ai/dsh-llm": "0.1.5-rc.1",
"@deepseek-ai/dsh-scope": "0.1.5-rc.1",
"@deepseek-ai/dsh-session": "0.1.5-rc.1",
"@deepseek-ai/dsh-session-persistence": "0.1.5-rc.1",
"@deepseek-ai/dsh-session-projection": "0.1.5-rc.1",
"@deepseek-ai/dsh-settings": "0.1.5-rc.1",
"@deepseek-ai/dsh-subagent": "0.1.5-rc.1",
"@deepseek-ai/dsh-system-prompt": "0.1.5-rc.1",
"@deepseek-ai/dsh-tools": "0.1.5-rc.1",
"@deepseek-ai/dsh-user-approval": "0.1.5-rc.1",
"@deepseek-ai/dsh-util-time": "0.1.5-rc.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",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"anser": "^2.3.5",
"cordis": "^4.0.0-rc.8",
"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",
"shiki": "^4.3.1",
"react": "^18.2.0",
"react-dom": "18.2.0",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"unrun": "^0.2.39",
"vitest": "^4.1.8",
"@eslint/js": "^10.0.1",
"eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"typescript-eslint": "^8.69.0"
}
"dsh.client.platform": "web",
"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',
},
})
+794 -2732
View File
File diff suppressed because it is too large Load Diff
+1 -75
View File
@@ -1,78 +1,4 @@
packages:
- .
# node-pty's install script copies its shipped prebuilt binaries into place
# (no source compilation on supported platforms); pnpm 10+ blocks dependency
# build scripts unless approved.
allowBuilds:
node-pty: true
protobufjs: true
# 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.
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-presets@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-runtime@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-code-runtime@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-better-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-better-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-better-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-better-sidebar/client/service'
import type {} from 'dsh-better-sidebar/client/service'
import type {
BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps,
TabComponentProps, TabDescriptor,
} from 'dsh-better-sidebar/client/service'
import type { SessionScope, SidebarSnapshot, SidebarState, SidebarStore, SidebarTab } from 'dsh-better-sidebar/client/service'
import type { SidebarPrefs } from 'dsh-better-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-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -vE "node_modules/[^ ]*(cordis|cosmokit)"; then
echo "[check-consumer-types] FAIL: errors in the dsh-better-sidebar declaration surface:" >&2
grep -E "dsh-better-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-better-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."
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 聚合双挂载冒烟(CI + 本地):
#
# 场景:一个聚合 bundle(tests/fixtures/aggregate-better-sidebar)先以独立
# 条目 id 挂载 dsh-better-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-better-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 的 allowBuilds / 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
# 真实方法探活:terminal.deps 是插件自己的 handler(writeOk → HTTP 200 +
# {"ok":true,...})。成功响应证明至少一个实例真正注册了 /sidebar/api 路由
# ——generic missing-route 404 无法冒充(P2: Probe a real sidebar API method)。
DEPS="$(curl -s -X POST "$URL/sidebar/api/terminal.deps" 2>/dev/null || true)"
if ! printf '%s' "$DEPS" | grep -q '"ok":true'; then
die "/sidebar/api/terminal.deps 未返回 ok:true(响应:$(printf '%s' "$DEPS" | head -c 200))"
fi
say "/sidebar/api/terminal.deps → 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 "通过:聚合双挂载场景下插件自动退让,侧边栏由聚合行接管。"
-175
View File
@@ -1,175 +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`)由调用方从环境变量取好传入。
e2e_resolve_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 @deepseek-ai/dsh"
DSH_CMD="npx -y --package @deepseek-ai/dsh dsh"
else
die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定"
fi
fi
}
# ── tarball 解析 ─────────────────────────────────────────────────────────────
# TARBALL 已显式给出时原样使用;否则从仓库根 glob 里选。多个候选时取 mtime
# 最新——`ls | head -1` 的字典序会拿到旧版本号的历史 tarball,把冒烟挂到
# 过期产物上。未找到候选时返回非零,由调用方按各自文案 die。
e2e_resolve_tarball() {
if [ -z "${TARBALL:-}" ]; then
TARBALL="$(ls -t "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
local count
count="$(ls "$ROOT"/dsh-better-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 的 allowBuilds / minimumReleaseAgeExclude,避免 pnpm 11
# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <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
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- dsh-better-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-better-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-better-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-better-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-better-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-better-sidebar") ? 0 : 1);
' "$PROFILE_DIR/package.json"; then
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册"
cat "$PROFILE_DIR/package.json"
exit 1
fi
say "挂载已注册:dsh.profile.bundles 包含 dsh-better-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 后无头渲染未崩溃"
-306
View File
@@ -1,306 +0,0 @@
## =============================================================================
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-better-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
# # 修复 node-pty 依赖(终端提示「node-pty 加载失败」时用,见 issue #140)
# powershell -ExecutionPolicy Bypass -File install.ps1 -Repair
#
# 参数:
# -Version npm 版本号/范围,缺省 latest(自动解析为最新)。
# -Repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
# node-pty。
# -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 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
# `dsh plugin add` 非零退出。脚本会先把这两个构建许可写进 profile 的
# pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载。
# 脚本会幂等移除 better-sidebar 挂载行。
# =============================================================================
param(
[string]$Version = '',
[switch]$Restart,
[switch]$DryRun,
[switch]$Repair,
[string]$Profile = 'web'
)
$PKG = 'dsh-better-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 不拦截
# node-pty/protobufjs 构建脚本、放行本插件新版本
function Ensure-WorkspaceSettings {
$wsScript = @'
const fs = require("fs");
const p = process.argv[2];
let t = fs.readFileSync(p, "utf8");
const before = t;
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-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:allowBuilds(node-pty/protobufjs: true)+ 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)"
}
# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ────────────
# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑
# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。
if ($Repair) {
if ($DryRun) {
Say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)"
Say "[dry-run] 修复:cd $PROFILE_DIR; pnpm install; pnpm rebuild node-pty"
exit 0
}
Say "修复模式:重装 node-pty(profile: $Profile,$PROFILE_DIR)..."
Ensure-WorkspaceSettings
Push-Location $PROFILE_DIR
try {
pnpm install
if ($LASTEXITCODE -ne 0) { Die "pnpm install 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上、网络可用,然后重试。" }
pnpm rebuild node-pty
if ($LASTEXITCODE -ne 0) { Die "pnpm rebuild node-pty 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上,然后重试。" }
} finally {
Pop-Location
}
Say '修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。'
exit 0
}
$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 含 allowBuilds(node-pty/protobufjs: true)与 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 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本
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 失败。已预写 allowBuilds 与 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-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。'
Warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
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(会短暂断开当前页面会话)'
}
}
-287
View File
@@ -1,287 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,macOS / Linux / Windows Git Bash)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-better-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]
# bash scripts/install.sh --repair [--profile <名>] [--dry-run]
#
# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
# 示例:0.10.2、^0.10.2、~0.10.2、latest
# --repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
# node-pty(终端提示「node-pty 加载失败」时用它,见 issue #140)。
# --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 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
# `dsh plugin add` 非零退出(bundle 协调因此被跳过)。脚本会先把这两个
# 构建许可写进 profile 的 pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载
# (Node 半挂两次、页面两个侧边栏)。脚本会幂等移除 better-sidebar 挂载行。
# - 回滚:dsh plugin --profile web remove dsh-better-sidebar,或把 profile 依赖
# 改回 "dsh-better-sidebar": "link:<路径>" 再 pnpm install。
# =============================================================================
set -euo pipefail
# 帮助请求优先处理
for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
cat <<'EOF'
dsh-better-sidebar 一键安装 / 依赖修复脚本
用法:
bash scripts/install.sh [版本] [--restart] [--dry-run] [--profile <名>]
bash scripts/install.sh --repair [--profile <名>] [--dry-run]
版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest
--repair 修复模式:确保 profile 放行 node-pty 构建脚本并重装 node-pty(终端提示依赖加载失败时用)
--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-better-sidebar"
DSH_CMD="${DSH_CMD:-dsh}"
RESTART=false
DRY_RUN=false
REPAIR=false
VERSION_SPEC=""
PROFILE_NAME="web"
while [ $# -gt 0 ]; do
case "$1" in
--restart) RESTART=true ;;
--dry-run) DRY_RUN=true ;;
--repair) REPAIR=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 设置(幂等),保证 pnpm 不拦截
# node-pty/protobufjs 构建脚本、放行本插件新版本
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;
// allowBuilds:把 node-pty/protobufjs 归位为 true
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
' "$WS_YML")"
[ "$WS_RESULT" = "updated" ] \
&& say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)+ 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 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)"
# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ────────────
# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑
# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。
if [ "$REPAIR" = true ]; then
if [ "$DRY_RUN" = true ]; then
say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)"
say "[dry-run] 修复:cd $PROFILE_DIR && pnpm install && pnpm rebuild node-pty"
exit 0
fi
say "修复模式:重装 node-pty(profile: ${PROFILE_NAME},${PROFILE_DIR})..."
ensure_workspace_settings
if ! (cd "$PROFILE_DIR" && pnpm install && pnpm rebuild node-pty); then
die "修复失败:pnpm install / pnpm rebuild node-pty 非零退出。请确认 pnpm 在 PATH 上、网络可用,然后重试。"
fi
say "修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。"
exit 0
fi
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 含 allowBuilds(node-pty/protobufjs: true)与 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 失败。已预写 allowBuilds 与 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-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。"
warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
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
-57
View File
@@ -1,57 +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-better-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, node-pty binaries) 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).
'lib/client-terminal.js',
'lib/client-terminal.js.map',
'lib/client-editor.js',
'lib/client-editor.js.map',
'lib/client-mermaid.js',
'lib/client-mermaid.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-better-sidebar')
-265
View File
@@ -1,265 +0,0 @@
/**
* The model-facing `sidebar_open` tool and its delivery registry.
*
* One tool lets the model actively open a local file, a local folder (as a
* tree rooted there), or an HTTP(S) page in the CALLING session's sidebar.
* Mirroring the agent-terminal tools, the tool binds to the calling agent's
* session through `exec.agent.session.id` — the model never passes a
* sessionId, and opens for non-active sessions are queued until that
* session's sidebar view is next connected.
*
* Delivery is a host→browser push over the dedicated `/sidebar/ws/agent-opens`
* endpoint (the same pattern as `/sidebar/ws/agent-terminals`): the registry
* keeps a per-session queue; a push is consumed on send (`delivered: true`
* means a sidebar view was attached at call time), otherwise the request
* stays queued and is replayed when a view for that session attaches.
*
* Conventions (per plugin-development-guide.md §3):
* C1 — parameters schema-validated before `execute` runs.
* C4 — `execute` returns one canonical JSON value; `render` is a separate
* pure text projection.
* C6 — `exec.signal.throwIfAborted()` before any fs work.
* C10 — no UI/transport vocabulary in the canonical value.
*/
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import { isAbsolute, join, resolve } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Context } from './context-types.ts'
import type { SidebarPrefs } from './prefs-shared.ts'
/** What the model asked to open. */
export type AgentOpenKind = 'file' | 'folder' | 'url'
/** One pending/broadcast open request (the wire face over the push socket). */
export interface AgentOpenRequest {
/** Opaque id (host-generated; the client ignores it beyond dedupe/debug). */
id: string
/** The session whose sidebar the open is targeted at. */
sessionId: string
kind: AgentOpenKind
/** Absolute local path (file/folder) or http(s) URL. */
target: string
/** Tab title the client should use (basename / hostname / caller-supplied). */
title: string
}
/** One subscribed sidebar view's sender. */
type Sender = (request: AgentOpenRequest) => void
/**
* Per-session queue of open requests plus the connected sidebar views.
*
* Lifecycle: `enqueue` adds a request and — when at least one view for the
* session is attached — pushes it immediately and removes it from the queue
* (consume-on-send: a reconnect must never replay an open the client already
* applied, and the browser tab type has no per-URL dedupe, so replaying
* would mint duplicate tabs). With no attached view the request stays queued
* and `attach` replays it on connect. `drainAll` drops every queued request
* (the feature was turned off); `dispose` also drops every subscriber.
*/
export class AgentOpenRegistry {
private pending = new Map<string, AgentOpenRequest[]>()
private subscribers = new Map<string, Set<Sender>>()
/** Queue one open and deliver it immediately when a view is attached.
* @returns the request id and whether a connected view received it now. */
enqueue(sessionId: string, kind: AgentOpenKind, target: string, title: string): { id: string; delivered: boolean } {
const request: AgentOpenRequest = { id: randomUUID(), sessionId, kind, target, title }
const list = this.pending.get(sessionId) ?? []
list.push(request)
this.pending.set(sessionId, list)
const views = this.subscribers.get(sessionId)
if (views !== undefined && views.size > 0) {
for (const send of views) send(request)
this.pending.delete(sessionId)
return { id: request.id, delivered: true }
}
return { id: request.id, delivered: false }
}
/** Attach one sidebar view (replays queued requests; consume-on-send).
* @returns the disposer detaching the view. */
attach(sessionId: string, send: Sender): () => void {
let views = this.subscribers.get(sessionId)
if (views === undefined) {
views = new Set()
this.subscribers.set(sessionId, views)
}
views.add(send)
const queued = this.pending.get(sessionId) ?? []
if (queued.length > 0) {
for (const request of queued) send(request)
this.pending.delete(sessionId)
}
return () => {
const current = this.subscribers.get(sessionId)
current?.delete(send)
if (current !== undefined && current.size === 0) this.subscribers.delete(sessionId)
}
}
/** Drop every queued request (the feature was turned off mid-session). */
drainAll(): void {
this.pending.clear()
}
/** Drop the queue and every subscriber (plugin teardown). */
dispose(): void {
this.pending.clear()
this.subscribers.clear()
}
}
/** Extract the calling agent or throw the canonical "no agent" error. */
function requireAgent(agent: ToolRunContext['agent']): NonNullable<ToolRunContext['agent']> {
if (agent === undefined) {
throw new Error('sidebar_open requires an initiating agent')
}
return agent
}
/** Resolve the calling agent's session id (the queue scope + ownership key). */
function sessionIdOf(exec: ToolRunContext): string {
return requireAgent(exec.agent).session.id
}
/** Pure text projection helper (the canonical value is already structured). */
function textRender<T>(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] {
return (_args, value) => [{ type: 'text', text: fn(value as T) }]
}
/** Classify a raw target: http(s) URL or a local path (stat-driven). */
async function classifyTarget(raw: string, cwd: string): Promise<{ kind: AgentOpenKind; target: string; title: string }> {
if (/^https?:\/\//i.test(raw)) {
let parsed: URL
try {
parsed = new URL(raw)
} catch {
throw new Error(`"${raw}" is not a valid URL`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('sidebar_open only accepts http:// and https:// URLs')
}
const title = parsed.hostname !== '' ? parsed.hostname : raw
return { kind: 'url', target: raw, title }
}
// Any other scheme (`file:`, `javascript:`, `vscode:`, ...) is refused —
// the model must use the plain URL form or a local path. A Windows drive
// prefix (`C:\` / `C:/`) is a PATH, not a scheme, and must not be caught.
if (!isWindowsDrivePrefix(raw) && /^[a-z][a-z0-9+.-]*:/i.test(raw)) {
throw new Error('sidebar_open only accepts http:// and https:// URLs; use a local path for files')
}
const target = resolve(isAbsolute(raw) ? raw : join(cwd, raw))
let info
try {
info = await stat(target)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') throw new Error(`"${raw}" does not exist (resolved to "${target}")`, { cause: error })
if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`, { cause: error })
throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`, { cause: error })
}
const title = basenameOf(target)
return { kind: info.isDirectory() ? 'folder' : 'file', target, title: title === '' ? raw : title }
}
/** The last path segment (mirror of the client's FileTree baseName). */
function basenameOf(path: string): string {
const trimmed = path.replace(/[\\/]+$/, '')
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return at === -1 ? trimmed : trimmed.slice(at + 1)
}
/** Whether a raw target starts with a Windows drive prefix (`C:\` / `C:/`). */
function isWindowsDrivePrefix(raw: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(raw)
}
/**
* Register the `sidebar_open` tool against the host tool registry. The tool
* is gated by the side-card setting `agentOpenTools` (the caller registers
* and unregisters it); `readPrefs` supplies the live prefs so a disabled
* target tab type (editor/browser) is reported to the model instead of
* silently no-oping on the client. `resolveCwd` threads the calling
* session's live cwd so relative paths resolve the same way the sidebar's
* own routes do.
* @param ctx - host plugin context (carries the tools service).
* @param registry - the open-request registry (per-session queue + views).
* @param resolveCwd - async cwd resolver for one session id. Resolves through
* the session header, the client-supplied cwd, and the persistence index
* before falling back to the host process cwd (production always provides
* persistence, so the fallback is reached only in tests / stripped-down hosts).
* @param readPrefs - live resolved side card prefs (for tab enable gates).
* @returns a disposer that unregisters the tool.
*/
export function registerOpenTool(
ctx: Context,
registry: AgentOpenRegistry,
resolveCwd: (sessionId: string) => Promise<string>,
readPrefs: () => SidebarPrefs,
): () => void {
return ctx.tools.register(defineTool({
name: 'sidebar_open',
description:
'Open a local file, a local folder, or an HTTP(S) page in the sidebar of the calling conversation. '
+ 'A file opens in the sidebar editor (per-path dedupe: an already-open file is focused); a folder opens a file window whose tree is rooted at that folder; '
+ 'a URL opens in the sidebar browser (sandboxed iframe). '
+ 'The panel auto-expands for content opens and the tab title defaults to the file/folder name or the URL hostname. '
+ 'The path may be absolute or relative to the session working directory. '
+ 'The open lands in the CALLING session\'s sidebar: while that session\'s sidebar view is not connected '
+ '(e.g. the session is not the active one), the open is queued and delivered when the session sidebar is next shown — '
+ 'the result reports `delivered` so you know whether it is visible right now. '
+ 'The side card setting "model opens files/folders/pages in the sidebar" must be on, and the target tab type must be enabled in that session\'s settings.',
parameters: {
target: {
type: 'string',
required: true,
description: 'Absolute or session-cwd-relative local path, or an http:// / https:// URL.',
},
title: {
type: 'string',
description: 'Optional tab title (defaults to the file/folder name or the URL hostname).',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, description: 'What was opened: file | folder | url.' },
target: { type: 'string', required: true, description: 'The absolute path or URL the open was requested for.' },
title: { type: 'string', required: true, description: 'The tab title used (provided title, basename, or hostname).' },
delivered: {
type: 'boolean',
required: true,
description: 'Whether the open was pushed to a connected sidebar at call time (false = queued until the session sidebar is next shown).',
},
},
},
render: textRender((v: { kind: AgentOpenKind; target: string; title: string; delivered: boolean }) =>
v.delivered
? `Opened ${v.kind} "${v.title}" (${v.target}) in the sidebar.`
: `Requested opening ${v.kind} "${v.title}" (${v.target}) in the sidebar — the session sidebar is not connected yet, so the open is queued and will appear when it is next shown.`,
),
},
execute: async (args: { target: string; title?: string }, exec: ToolRunContext) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
const cwd = await resolveCwd(sessionId)
const { kind, target, title: defaultTitle } = await classifyTarget(args.target, cwd)
// A disabled target tab type would make the client no-op the open:
// report the real cause to the model instead of a silent success.
const prefs = readPrefs()
const tab = kind === 'url' ? 'browser' : 'editor'
if (prefs.tabsEnabled[tab] === false) {
throw new Error(`the built-in ${tab} tab is disabled in the side card settings; ask the user to enable it (or disable this tool)`)
}
const title = args.title !== undefined && args.title.trim() !== '' ? args.title : defaultTitle
const { delivered } = registry.enqueue(sessionId, kind, target, title)
return { kind, target, title, delivered }
},
}))
}
-653
View File
@@ -1,653 +0,0 @@
/**
* Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
* sessions created by the model through the `terminal_create` tool. Each
* handle survives across tool calls (and across WebSocket disconnects from
* the sidebar view) until the model calls `terminal_close` or the user
* closes the corresponding sidebar tab — tmux semantics, scoped per agent
* session.
*
* This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
* `${sessionId}:${tabId}` and capped per session, while agent terminals are
* keyed by uuid and uncapped (the model is trusted to close unused ones).
* Both registries share the same shell resolver and spawn-helper fix.
*/
import { randomUUID } from 'node:crypto'
import type { IPty } from 'node-pty'
import { ensureSpawnHelper, resolveShellExecutable, shellSpawnArgs } from './pty-manager.ts'
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
import { SidebarError } from './wire.ts'
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
const TRANSCRIPT_LIMIT = 1 << 20
/** POSIX signals the registry forwards to a live pty. */
export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const
/** Signal name accepted by `signal()`. */
export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number]
/** Default read page size (lines) when the caller omits `count`. */
export const DEFAULT_READ_COUNT = 500
/** Smallest pty dimension the registry accepts (mirrors the tool contract). */
export const TERMINAL_DIM_MIN = 2
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
export const TERMINAL_DIM_MAX = 1024
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
const clamp = (value: number): number =>
Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
return { cols: clamp(cols), rows: clamp(rows) }
}
/**
* Per-pty Windows resize-gate state (see {@link armPtyResizeGate}).
*/
interface ResizeGateState {
/** Whether the pty has produced any output yet (ConPTY flush completed). */
sawData: boolean
/** Latest dims requested before the first output; replayed once it arrives. */
pending?: { cols: number; rows: number }
}
/**
* node-pty's Windows terminal queues resize calls that arrive before the
* ConPTY control socket's first data flush (`_deferNoArgs` in
* windowsTerminal.js). If the pty exits before the queue flushes, the
* deferred resize throws inside the socket's 'data' handler — uncatchable
* by any caller and fatal to the host process. POSIX terminals have no such
* queue (resize is synchronous), so the gate is armed on Windows only:
* {@link tryResizePty} parks dims requested before the first output and
* replays them after the flush, when node-pty executes resizes
* synchronously (and the throw for an exited pty is catchable).
*/
const ptyResizeGates = new WeakMap<object, ResizeGateState>()
/**
* Arm the Windows pre-ready resize gate for one freshly spawned pty.
* No-op on POSIX and for injected ptys without `onData`.
*/
export function armPtyResizeGate(pty: IPty): void {
if (process.platform !== 'win32') return
if (typeof pty.onData !== 'function' || ptyResizeGates.has(pty)) return
const state: ResizeGateState = { sawData: false }
ptyResizeGates.set(pty, state)
pty.onData(() => {
if (state.sawData) return
state.sawData = true
const dims = state.pending
state.pending = undefined
if (dims === undefined) return
// node-pty's deferred flush shares this very 'data' dispatch; hop past
// it so the replayed resize executes synchronously (catchable) instead
// of being queued into the same deferred list we are draining.
setImmediate(() => { tryResizePty(pty, dims.cols, dims.rows) })
})
}
/**
* Best-effort resize for WebSocket-driven terminal views. Layout animation
* can briefly produce unusable dimensions, and node-pty can reject a resize
* after the socket setup's outer try/catch has returned. Ignore that one
* frame so the host stays alive and a later valid measurement can retry.
* Returns whether node-pty accepted the resize (or parked it for replay on
* the first output — the Windows pre-ready window).
*/
export function tryResizePty(
pty: Pick<IPty, 'resize'>,
cols: number,
rows: number,
): boolean {
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false
const dims = clampDims(cols, rows)
const gate = ptyResizeGates.get(pty)
if (gate !== undefined && !gate.sawData) {
gate.pending = dims
return true
}
try {
pty.resize(dims.cols, dims.rows)
return true
} catch {
return false
}
}
/**
* Serializable snapshot of one agent terminal — the shape the model sees
* through `terminal_list` and the sidebar sees through the push endpoint.
* Carries no pty reference and no transcript (those are reached through
* dedicated read/attach paths), and no sessionId: ownership is registry
* internals, scoped by the caller (list filters by session, the push
* endpoint scopes by its query param), never part of the serialized view.
*/
export interface AgentTerminalSnapshot {
/** Stable opaque handle the model passes back to other terminal_* tools. */
uuid: string
/** Display title the model chose at create time. */
title: string
/** The command the model asked to run at create time (verbatim). */
command: string
/** Whether the top-level process has exited. */
exited: boolean
/** Exit code if exited normally; absent until the process exits. */
exitCode?: number | null
/** Exit signal name if the process was killed by a signal; null otherwise. */
exitSignal?: string | null
}
/** Map a POSIX signal number to its conventional name (best-effort). */
const SIGNAL_NAMES: Record<number, string> = {
1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT',
9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM',
17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP',
}
/** Convert a raw signal number to a name (or null when absent/unknown). */
function signalNameOf(signal: number | null | undefined): string | null {
if (signal === null || signal === undefined) return null
return SIGNAL_NAMES[signal] ?? `signal ${signal}`
}
/**
* Compile a wait needle into a RegExp. The needle is treated as a JavaScript
* regular expression; a pattern that fails to compile ( e.g. an unbalanced
* group typed as a literal ) degrades to verbatim substring matching so
* legacy literal needles keep working.
*/
function compileNeedle(needle: string): RegExp | null {
try {
return new RegExp(needle)
} catch {
return null
}
}
/**
* Locate the first occurrence of `needle` in `transcript`, returning its
* line/column plus the actual matched text — for alternation patterns
* ( e.g. `(BUILD_OK|BUILD_FAIL)` ) the match tells which alternative hit.
* `re` is the precompiled form of `needle` (from {@link compileNeedle});
* `null` means verbatim substring matching.
*/
function locateNeedle(
transcript: string,
needle: string,
re: RegExp | null,
): { line: number; column: number; match: string } | undefined {
if (needle === '') return undefined
// Regex hit → use the match index/text; literal fallback → indexOf with
// the needle itself as the matched text.
let hit: { index: number; text: string } | undefined
if (re !== null) {
re.lastIndex = 0
const m = re.exec(transcript)
hit = m === null ? undefined : { index: m.index, text: m[0] }
} else {
const idx = transcript.indexOf(needle)
hit = idx === -1 ? undefined : { index: idx, text: needle }
}
if (hit === undefined) return undefined
// Walk the transcript up to the match index, counting newlines to derive
// the 0-based line; the column is the offset within that line.
let line = 0
let lineStart = 0
for (let i = 0; i < hit.index; i += 1) {
if (transcript.charCodeAt(i) === 0x0a /* \n */) {
line += 1
lineStart = i + 1
}
}
return { line, column: hit.index - lineStart, match: hit.text }
}
/** One live agent terminal. */
export interface AgentTerminalHandle {
/** Stable opaque handle. */
uuid: string
/** Owning conversation id. */
sessionId: string
/** Display title. */
title: string
/** The command written to stdin right after spawn. */
command: string
/** The working directory the process was spawned with. */
cwd: string
/** The live pty process. */
pty: IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
exited: boolean
/** Exit code once known. */
exitCode?: number | null
/** Exit signal number once known (POSIX only; undefined on Windows). */
exitSignal?: number | null
}
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
export interface AgentTerminalReadResult {
/** The slice of transcript text for the requested page. */
text: string
/** Total lines in the retained transcript (the page may be a subset). */
totalLines: number
/** 0-based index of the first line in `text` (inclusive). */
lineBegin: number
/** 0-based index of the last line in `text` (exclusive). */
lineEnd: number
}
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
export type AgentTerminalWaitResult =
| {
/** The needle was found in the transcript. */
kind: 'found'
/** The pattern that was awaited. */
needle: string
/** 0-based line index (in the retained transcript) where the needle first appeared. */
line: number
/** 0-based column index within that line where the match starts. */
column: number
/**
* The text that actually matched — for multi-outcome patterns
* ( e.g. `(BUILD_OK|BUILD_FAIL)` ) this tells which alternative matched.
*/
match: string
/** Elapsed wall-clock milliseconds from the wait start to the match. */
elapsedMs: number
}
| {
/** The needle did not appear before the timeout. */
kind: 'timeout'
/** The needle that was awaited. */
needle: string
/** The configured timeout in milliseconds. */
timeoutMs: number
/** Total lines retained when the timeout fired (call terminal_read to inspect). */
totalLines: number
}
| {
/** The terminal exited before the needle appeared. */
kind: 'exited'
/** The needle that was awaited. */
needle: string
/** The exit code, if known. */
exitCode?: number | null
/** The exit signal name, if the process was killed by a signal. */
exitSignal?: string | null
}
/** Snapshot projection of a handle (drops the pty reference and transcript). */
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
const out: AgentTerminalSnapshot = {
uuid: handle.uuid,
title: handle.title,
command: handle.command,
exited: handle.exited,
}
if (handle.exited) {
out.exitCode = handle.exitCode ?? null
out.exitSignal = signalNameOf(handle.exitSignal)
}
return out
}
/**
* The agent terminal registry. The constructor takes the resolved shell
* binary (the same `defaultShell()` the UI-tab registry uses) and runs the
* spawn-helper chmod fix once at construction so the first agent terminal
* does not race a lazy fixer.
*/
export class AgentPtyRegistry {
private readonly sessions = new Map<string, AgentTerminalHandle>()
private readonly changeListeners = new Set<() => void>()
constructor(
private readonly shell: string,
private readonly shellArgs: string[] = [],
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {
ensureSpawnHelper()
}
/**
* Spawn one agent terminal: start the shell in `cwd`, then write
* `command + '\n'` to stdin so the command runs in the fresh shell. The
* terminal stays alive after the command exits — the model can send more
* input through `terminal_send` until it calls `terminal_close` or the
* user closes the sidebar tab. An empty `command` spawns a bare shell.
* @returns the new handle's uuid (the model-facing opaque id).
*/
create(
sessionId: string,
title: string,
command: string,
cwd: string,
cols = 80,
rows = 24,
shell?: string,
shellArgs?: string[],
): string {
const uuid = randomUUID()
const dims = clampDims(cols, rows)
const executable = resolveShellExecutable(shell ?? this.shell)
const pty = this.nodePty.spawn(executable, shellSpawnArgs(shellArgs ?? this.shellArgs), {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.rows,
cwd,
env: { ...process.env },
})
armPtyResizeGate(pty)
const handle: AgentTerminalHandle = {
uuid,
sessionId,
title,
command,
cwd,
pty,
transcript: '',
exited: false,
}
pty.onData((data) => {
handle.transcript += data
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
}
})
pty.onExit(({ exitCode, signal }) => {
handle.exited = true
handle.exitCode = exitCode
handle.exitSignal = signal
this.notify()
})
if (command !== '') {
// Write the command + Enter so it runs in the freshly spawned shell.
// Use \r (carriage return) — the actual character a terminal sends for
// the Enter key — not \n (line feed). PowerShell treats a bare \n as a
// soft line break (continuation prompt ">>") rather than a command
// submit; \r is the cross-shell Enter semantics on both POSIX and Windows.
try {
pty.write(`${command}\r`)
} catch {
// A spawn that failed between onData and onExit surfaces its own
// exit; the create call still returns the uuid so the model can
// read the transcript and see the failure.
}
}
this.sessions.set(uuid, handle)
this.notify()
return uuid
}
/** All live agent terminals belonging to one conversation. */
list(sessionId: string): AgentTerminalSnapshot[] {
const out: AgentTerminalSnapshot[] = []
for (const handle of this.sessions.values()) {
if (handle.sessionId === sessionId) out.push(snapshotOf(handle))
}
return out
}
/** Resolve a live handle by uuid, or throw `not-found`. */
private expect(uuid: string): AgentTerminalHandle {
const handle = this.sessions.get(uuid)
if (handle === undefined) {
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
}
return handle
}
/**
* Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
* The model-facing tools call this before every uuid-keyed operation: a
* uuid from another session is indistinguishable from an unknown one, so a
* model can never reach (or probe) a terminal it does not own.
*/
assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
const handle = this.expect(uuid)
if (handle.sessionId !== sessionId) {
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
}
return handle
}
/** Resolve a handle's snapshot, or undefined if it does not exist. */
snapshot(uuid: string): AgentTerminalSnapshot | undefined {
const handle = this.sessions.get(uuid)
return handle === undefined ? undefined : snapshotOf(handle)
}
/** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
send(uuid: string, text: string): void {
const handle = this.expect(uuid)
if (handle.exited) {
throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400)
}
handle.pty.write(text)
}
/**
* Read one bounded page of the retained transcript. `offset` is a 0-based
* line index from the start of the retained transcript (default 0);
* `count` caps the page size (default 500). A negative `offset` reads
* from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
* so the model can paginate.
*/
read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult {
const handle = this.expect(uuid)
const lines = handle.transcript.split('\n')
const totalLines = lines.length
const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT))
let start: number
if (offset === undefined || offset === 0) {
start = 0
} else if (offset < 0) {
// Negative offset: read from the end (e.g. -50 → last 50 lines).
start = Math.max(0, totalLines + offset)
} else {
start = Math.min(offset, totalLines)
}
const end = Math.min(start + pageSize, totalLines)
const slice = lines.slice(start, end).join('\n')
return {
text: slice,
totalLines,
lineBegin: start,
lineEnd: end,
}
}
/**
* Resize a terminal's pty, clamped to the 2..1024 sane range.
* @returns the dimensions actually applied (the caller echoes these, so the
* reported value always matches the pty).
*/
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
const handle = this.expect(uuid)
const dims = clampDims(cols, rows)
// Through tryResizePty: node-pty throws on a just-exited pty (the exit
// event may not have flipped `handle.exited` yet), and on Windows a
// pre-ready resize must go through the gate instead of node-pty's own
// deferred queue (which flushes uncatchably inside its socket handler).
if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows)
return dims
}
/**
* Wait for `needle` to appear in a terminal's transcript, or for the
* terminal to exit, or for the timeout to elapse — whichever happens
* first. The wait polls the live transcript every ~50ms and short-circuits
* on `signal` abort (re-thrown as the abort reason so the tool layer
* surfaces cancellation).
*
* The match scans the FULL retained transcript on each poll, not just the
* delta since the last poll — a needle that scrolled past the most recent
* chunk but is still within the ~1 MiB bound is still a match. The
* returned line/column locate the FIRST occurrence (oldest), which is what
* a user watching the terminal would have seen first.
*
* The implementation uses polling (not pty onData subscription) because
* node-pty's onData fires before the registry's own onData listener
* updates the transcript (listener order is not guaranteed), and on
* Windows ConPTY output can arrive in bursts with batching delays that
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
* interactive use and simple enough to be obviously correct.
* @param uuid - terminal to watch.
* @param needle - JavaScript regular expression to search for
* (case-sensitive); a pattern that fails to compile falls back to
* verbatim substring matching. May cover several outcomes at once
* ( e.g. `(BUILD_OK|BUILD_FAIL)` for build success vs failure ) — the
* returned `match` reports the text that actually matched, so callers
* can tell which outcome hit.
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
* @returns one of `found` / `timeout` / `exited`.
*/
async waitFor(
uuid: string,
needle: string,
timeoutMs = 10_000,
signal?: AbortSignal,
): Promise<AgentTerminalWaitResult> {
if (needle === '') {
throw new SidebarError('bad-request', 'needle must be a non-empty string', 400)
}
const handle = this.expect(uuid)
const timeout = Math.max(100, Math.floor(timeoutMs))
const start = Date.now()
const deadline = start + timeout
// Compile the needle once (regex semantics; invalid patterns degrade to
// verbatim matching) and reuse the compiled form across every poll.
const re = compileNeedle(needle)
// Fast path: already exited, or the needle is already in the transcript
// (a `terminal_send` may have produced the expected output before this
// call even started).
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
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 }
}
// Poll loop: check the transcript every 50ms, exit on match / exit /
// abort / timeout. The handle is read live each iteration (its transcript
// and exited fields mutate as the pty produces output).
while (true) {
if (signal?.aborted) signal.throwIfAborted()
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
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()
})
}
}
/**
* Send a POSIX signal to a terminal's foreground process.
*
* Two delivery paths, by signal kind:
* - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
* writing the corresponding control character to the pty stdin. This is
* how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
* line discipline (POSIX ISIG mode) or the ConPTY input pipeline
* (Windows), which translates it into a SIGINT/SIGTSTP for the
* foreground process group. This works on every platform — calling
* `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
* but writing `\x03` is universally correct.
* - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
* which maps to the platform's process-termination path (POSIX
* `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
* control characters.
*/
signal(uuid: string, signal: AgentTerminalSignal): void {
const handle = this.expect(uuid)
if (handle.exited) return
// Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty
// master is the cross-platform way to deliver the signal to the
// foreground process group through the terminal's input pipeline.
if (signal === 'SIGINT' || signal === 'SIGTSTP') {
const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a'
try {
handle.pty.write(ctrlByte)
} catch {
// A pty that rejects writes is already tearing down; the next
// onExit will mark it exited. Not an error from the caller's view.
}
return
}
// SIGKILL / SIGTERM / SIGHUP: use the process-termination path.
try {
handle.pty.kill(signal)
} catch {
// node-pty on Windows rejects named signals other than the default;
// fall back to the default kill (TerminateProcess on Windows,
// SIGKILL-equivalent on POSIX) so the signal still takes effect.
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
}
}
/**
* Close a terminal and drop its state. Idempotent: a second close of the
* same uuid is a no-op. Returns true iff a live handle was actually
* dropped.
*/
close(uuid: string): boolean {
const handle = this.sessions.get(uuid)
if (handle === undefined) return false
this.sessions.delete(uuid)
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
this.notify()
return true
}
/** Resolve a live handle by uuid (for the WS attach path). */
get(uuid: string): AgentTerminalHandle | undefined {
return this.sessions.get(uuid)
}
/**
* Subscribe to registry changes (create / close / exit). The sidebar push
* endpoint uses this to forward snapshots to the connected view. Returns
* the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.changeListeners.add(listener)
return () => { this.changeListeners.delete(listener) }
}
/** Close every agent terminal (plugin teardown). */
disposeAll(): void {
for (const uuid of [...this.sessions.keys()]) this.close(uuid)
}
/** Fire every change listener (callers wrap in try/catch if needed). */
private notify(): void {
for (const listener of [...this.changeListeners]) {
try {
listener()
} catch {
// A listener throwing must not break the others or the registry.
}
}
}
}
-140
View File
@@ -1,140 +0,0 @@
/**
* Process-local live assistant stream buffer (DSH 0.1.5+).
*
* 0.1.2 appended a durable `assistant/chunk` session event for every model
* delta, so the side-chat transcript and the inherited in-progress snapshot
* could read streaming text straight out of the session log. 0.1.5 removed
* that event: an in-flight attempt now publishes `agent/assistant-stream`
* frames (start / chunk / end) that are NOT part of the log, and the durable
* record lands only at settlement — `assistant/message` (with the exact
* `stream` embedded) or `assistant/attempt` (a failed attempt that committed
* no message, also with its `stream`).
*
* This module folds those frames into a bounded per-session buffer of
* normalized chunks — the same information the old `assistant/chunk` events
* carried — so the plugin keeps a live transcript and an honest in-progress
* snapshot. The buffer is cleared when an attempt ends, so a settled step
* never duplicates its durable message.
*/
import type { Context } from './context-types.ts'
/** One normalized live delta, keyed by its attempt and dense position. */
export interface AssistantLiveChunk {
/** The attempt these chunks belong to (DSH `LlmAttemptId`). */
readonly attemptId: string
/** The turn the attempt belongs to. */
readonly turn: number
/** The step the attempt belongs to. */
readonly step: number
/** Dense zero-based position within the attempt (DSH's own index). */
readonly index: number
/** Safe-integer timestamp of the frame (reused by the durable stream). */
readonly time: number
/** The raw model stream chunk (`text-delta` / `reasoning-delta` / …). */
readonly chunk: Record<string, unknown>
}
/** The plugin's read face over the live frames of every session. */
export interface AssistantLiveBuffer {
/**
* The live chunks of one session's active attempt, in index order.
* @param sessionId - the session whose attempt is streaming.
* @returns the chunks; empty when nothing is streaming for that session.
*/
chunksFor(sessionId: string): readonly AssistantLiveChunk[]
/** Stop observing the agent stream. */
dispose(): void
}
/** Per-attempt buffer ceiling; beyond it the oldest deltas are dropped. */
export const LIVE_CHUNK_CAP = 4000
/** How many sessions may hold a live buffer at once (the oldest is dropped). */
export const LIVE_SESSION_CAP = 64
/** One session's active attempt as it accumulates. */
interface Attempt {
readonly attemptId: string
readonly turn: number
readonly step: number
readonly chunks: AssistantLiveChunk[]
/** The next dense frame index this attempt accepts (independent of the
* window below, which drops the oldest chunks once the cap is reached). */
nextIndex: number
}
/** A frame's `chunk` payload must be a JSON record to be worth buffering. */
function chunkOf(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
}
/** A finite non-negative integer, or undefined. */
function countOf(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
}
/**
* Fold `agent/assistant-stream` frames into per-session live buffers.
*
* Frames arrive for every attached agent, so the buffer is keyed by the
* emitting session and ignores anything it cannot identify. An out-of-order
* or mismatched chunk drops the attempt rather than splicing a gap: a
* transcript with a hole is worse than one that settles at the next durable
* event.
* @param ctx - host plugin context (its `on` subscribes the session feed).
* @param cap - per-attempt chunk ceiling.
* @returns the read face and a disposer unbinding the listener.
*/
export function createAssistantLiveBuffer(ctx: Context, cap: number = LIVE_CHUNK_CAP): AssistantLiveBuffer {
const attempts = new Map<string, Attempt>()
const off = ctx.on('agent/assistant-stream', (payload) => {
const record = payload as { agent?: unknown; frame?: unknown } | null
const frame = record?.frame as Record<string, unknown> | undefined
if (frame === undefined) return
const session = (record?.agent as { session?: { id?: unknown } } | undefined)?.session
const sessionId = typeof session?.id === 'string' ? session.id : undefined
if (sessionId === undefined) return
const type = frame['type']
if (type === 'end') {
attempts.delete(sessionId)
return
}
const attemptId = typeof frame['attemptId'] === 'string' ? frame.attemptId : undefined
if (attemptId === undefined) return
if (type === 'start') {
const turn = countOf(frame['turn'])
const step = countOf(frame['step'])
if (turn === undefined || step === undefined) return
const known = attempts.has(sessionId)
attempts.delete(sessionId)
// Only a NEW session may evict the oldest tracked one.
if (!known && attempts.size >= LIVE_SESSION_CAP) {
const oldest = attempts.keys().next().value
if (oldest !== undefined) attempts.delete(oldest)
}
attempts.set(sessionId, { attemptId, turn, step, chunks: [], nextIndex: 0 })
return
}
if (type !== 'chunk') return
const attempt = attempts.get(sessionId)
if (attempt === undefined || attempt.attemptId !== attemptId) return
const index = countOf(frame['index'])
const chunk = chunkOf(frame['chunk'])
// Dense positions only: a gap means the buffer missed frames (a
// replacement attempt, a reconnect) and its text would be wrong.
if (index === undefined || chunk === undefined || index !== attempt.nextIndex) {
attempts.delete(sessionId)
return
}
attempt.nextIndex = index + 1
const time = countOf(frame['time']) ?? 0
attempt.chunks.push({ attemptId, turn: attempt.turn, step: attempt.step, index, time, chunk })
if (attempt.chunks.length > cap) attempt.chunks.splice(0, attempt.chunks.length - cap)
})
return {
chunksFor: sessionId => attempts.get(sessionId)?.chunks ?? [],
dispose: () => { off() },
}
}
-26
View File
@@ -1,26 +0,0 @@
/**
* Pure helpers for the `browser.probe` route (sidebar browser): the host
* fetches the response HEADERS of a URL the user is browsing and the client
* decides whether the target site forbids being embedded (X-Frame-Options /
* CSP frame-ancestors are exactly the signals the browser enforces when it
* refuses an iframe load). Kept dependency-free so the parser is
* unit-testable.
*/
/**
* Extract the `frame-ancestors` source list of a Content-Security-Policy
* header, or undefined when the directive is absent (or empty). The
* directive is the only one with a source list; sources are space-separated
* tokens (`'none'`, `'self'`, `*`, or origins).
*/
export function extractFrameAncestors(csp: string | null): string[] | undefined {
if (csp === null) return undefined
for (const directive of csp.split(';')) {
const parts = directive.trim().split(/\s+/)
if (parts[0] === 'frame-ancestors') {
const sources = parts.slice(1).filter(source => source !== '')
return sources.length === 0 ? undefined : sources
}
}
return undefined
}
-129
View File
@@ -1,129 +0,0 @@
/**
* Lazy chunk route: serves the client bundle's chunk scripts
* (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
* cannot serve arbitrary file names, so the plugin serves its own split
* bundles (lib/client-<name>.js) here; the client injects the script on
* first use of the feature that needs it (see src/client/chunk-loader.ts).
*
* Caching contract: every response carries `cache-control: no-cache` plus an
* ETag (content hash, memoized per file by mtime/size) and honors
* If-None-Match — the browser revalidates each fetch, but a 304 avoids
* re-downloading multi-MB chunks that did not change (page refresh, HMR
* re-activation). Same browser-trust fence as every other /sidebar route;
* only allowlisted chunk names are servable (no path traversal).
*/
import { createHash } from 'node:crypto'
import { stat, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid', 'locale'] as const
export type ChunkName = (typeof CHUNK_NAMES)[number]
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
const LIB_DIR = dirname(fileURLToPath(import.meta.url))
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
function shortHash(input: string | Buffer): string {
return createHash('sha1').update(input).digest('hex').slice(0, 12)
}
interface ChunkEtag {
mtimeMs: number
size: number
etag: string
}
/** ETag memo: recompute the content hash only when the file's stat changed. */
const etags = new Map<string, ChunkEtag>()
/**
* The chunk file's ETag (quoted hash), or undefined when the file is
* missing. Hash is recomputed only when mtime/size changed (hashing a
* multi-MB chunk per request is wasteful).
*/
async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undefined> {
const path = join(chunkDir, `client-${name}.js`)
const key = `${chunkDir}:${name}`
try {
const info = await stat(path)
const memo = etags.get(key)
if (memo !== undefined && memo.mtimeMs === info.mtimeMs && memo.size === info.size) {
return memo.etag
}
const etag = `"${shortHash(await readFile(path))}"`
etags.set(key, { mtimeMs: info.mtimeMs, size: info.size, etag })
return etag
} catch {
return undefined
}
}
/**
* Build the /sidebar/bundle route handler. `fence` is the shared browser-
* trust check every /sidebar route applies; `chunkDir` is the directory the
* chunk scripts live in (overridable for tests).
*/
export function createBundleRouteHandler(
fence: (req: SidebarHttpRequest) => boolean,
chunkDir: string = LIB_DIR,
): (req: SidebarHttpRequest, res: SidebarHttpResponse) => Promise<void> {
return async (req, res): Promise<void> => {
if (!fence(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const match = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)
const name = match?.[1] as ChunkName | undefined
if (name === undefined || !(CHUNK_NAMES as readonly string[]).includes(name)) {
res.writeHead(404)
res.end('not found')
return
}
const etag = await etagOf(name, chunkDir)
if (etag === undefined) {
// Registered name but unreadable (bundle not built yet): loud 404.
res.writeHead(404)
res.end('not found')
return
}
if (req.headers['if-none-match'] === etag) {
// Revalidation hit: unchanged chunk, no body — avoids re-downloading
// multi-MB scripts on page refresh / HMR re-activation.
res.writeHead(304, { 'cache-control': 'no-cache', etag })
res.end()
return
}
try {
const body = await readFile(join(chunkDir, `client-${name}.js`))
res.writeHead(200, {
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
etag,
})
res.end(body)
} catch {
// Read raced a delete/rebuild between the stat and the read.
res.writeHead(404)
res.end('not found')
}
}
}
/** Register the /sidebar/bundle route (disposed with the fiber). */
export function registerBundleRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void {
return ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/bundle',
handler: createBundleRouteHandler(fence),
})
}
-289
View File
@@ -1,289 +0,0 @@
/**
* The built-in browser tab: an address bar plus a sandboxed iframe.
*
* Security model (see browser.ts + the sandbox tokens below): the iframe is
* ALWAYS sandboxed without `allow-same-origin` (opaque origin — the visited
* page can never sit on the GUI's origin, read its storage, or reach
* /sidebar/api) and without `allow-top-navigation` (a page must not hijack
* the GUI). The address bar only accepts http(s) and refuses loopback /
* the GUI's own origin. The side card setting "关闭浏览器沙箱" drops the
* sandbox attribute entirely for fully trusted sites — the visited page then
* runs with the GUI's own origin and full session access, so a persistent
* warning bar renders while it is off.
*
* The URL is persisted onto the tab (path/title via the patchTab reducer)
* so a reload restores the visited page; the back/forward stack only tracks
* address-bar navigations (in-frame link clicks are cross-origin and
* invisible — a documented limitation).
*/
import { useEffect, useState } from 'react'
import {
IconChevronLeftOutline14,
IconChevronRightOutline14,
IconLinkOutline14,
IconRefreshOutline14,
IconWarningOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { VscLinkExternal } from 'react-icons/vsc'
import { api } from './api.ts'
import { embeddabilityOf, isAllowedLoopbackUrl, normalizeBrowserUrl } from './browser.ts'
import { patchTab } from './state.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { t } from './locales.ts'
import type { TabComponentProps } from './service.ts'
import css from './sidebar.module.css'
/**
* The browser iframe sandbox tokens. NO allow-same-origin (opaque origin —
* no GUI storage/API access), NO allow-top-navigation (a browsed page must
* not hijack the GUI). allow-forms/allow-popups/allow-downloads/allow-modals
* keep login flows working; allow-popups-to-escape-sandbox lets OAuth
* popups open as normal tabs (they are cross-origin to the GUI either way).
*/
export const BROWSER_IFRAME_SANDBOX =
'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox'
/** allow-same-origin appended for explicitly allowlisted local addresses. */
const BROWSER_IFRAME_SANDBOX_SAME_ORIGIN =
`${BROWSER_IFRAME_SANDBOX} allow-same-origin`
/**
* The sandbox tokens for one URL: allowlisted loopback addresses (local dev
* servers the user explicitly trusts) additionally get `allow-same-origin`
* so Vite/module/HMR pipelines that need a real origin work; every other
* site keeps the opaque-origin sandbox. `allow-same-origin` does NOT give
* the page access to the GUI — it stays cross-origin to it and to every
* other site — but it does give it its OWN origin privileges (localStorage,
* fetch without CORS), so it is only granted for the explicit allowlist.
*
* The GUI itself is the one hard exception: even when its own host is
* allowlisted (a bare-host entry covers every port, so the GUI origin
* matches), a page at the GUI's exact origin must never get
* `allow-same-origin` — that would make it same-origin with its parent and
* hand it the GUI's storage/API (and the ability to shed the sandbox). The
* GUI keeps the opaque-origin sandbox no matter what the allowlist says.
*/
export function iframeSandboxFor(url: string | undefined, allowedLoopback: string, selfOrigin?: string): string | undefined {
if (url === undefined) return undefined
if (selfOrigin !== undefined) {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return BROWSER_IFRAME_SANDBOX
}
if (parsed.origin === selfOrigin) return BROWSER_IFRAME_SANDBOX
}
return isAllowedLoopbackUrl(url, allowedLoopback)
? BROWSER_IFRAME_SANDBOX_SAME_ORIGIN
: BROWSER_IFRAME_SANDBOX
}
export function BrowserView(props: TabComponentProps) {
const { store, tab } = props
// The current address (initialized from the persisted tab.path so a
// reload restores the visited page).
const [url, setUrl] = useState<string | undefined>(tab.path)
const [input, setInput] = useState<string>(tab.path ?? '')
/** Blocked/invalid hint shown under the address bar (null = none). */
const [message, setMessage] = useState<string | null>(null)
/** Address-bar navigation history (in-frame clicks are not tracked). */
const [history, setHistory] = useState<string[]>(tab.path !== undefined ? [tab.path] : [])
const [cursor, setCursor] = useState<number>(tab.path !== undefined ? 0 : -1)
/** Bumped on reload to remount the iframe (also remounts on sandbox flip). */
const [reloadKey, setReloadKey] = useState(0)
/** TEMPORARY sandbox unlock for THIS surface only (never writes the global
* side card setting; lasts until the tab unmounts or the user restores). */
const [localUnlock, setLocalUnlock] = useState(false)
const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock
/** A site that refuses to be embedded (X-Frame-Options / frame-ancestors):
* the probe verdict shown instead of the blank iframe. */
const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
/** The user asked to load the refused site anyway (keeps the plain iframe). */
const [forceEmbed, setForceEmbed] = useState(false)
// Probe every navigation (address bar, history, restored path): when the
// target forbids embedding, show the reason + open-in-browser instead of
// the browser's cryptic "refused to connect" blank frame. A failed probe
// (unreachable) keeps the plain iframe.
useEffect(() => {
if (url === undefined) return
let cancelled = false
setEmbedBlocked(null)
setForceEmbed(false)
void api.browserProbe(url).then((probe) => {
if (!cancelled && embeddabilityOf(probe) === 'blocked') setEmbedBlocked(url)
}).catch(() => { /* unreachable: keep the plain iframe */ })
return () => { cancelled = true }
}, [url])
const persist = (nextUrl: string): void => {
let host = nextUrl
try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ }
store.reduce(state => patchTab(state, tab.id, { path: nextUrl, title: host }))
}
const navigateTo = (raw: string): void => {
const result = normalizeBrowserUrl(raw, window.location.origin, store.getPrefs().browserAllowedLoopback)
if (result.kind === 'ok') {
const next = result.url
setUrl(next)
setInput(next)
setMessage(null)
// Push onto the stack, dropping any stale forward entries.
setHistory(previous => [...previous.slice(0, cursor + 1), next])
setCursor(previous => previous + 1)
setReloadKey(key => key + 1)
persist(next)
return
}
setMessage(result.kind === 'invalid'
? t('browserInvalid')
: result.reason === 'scheme' ? t('browserBlockedScheme')
: t('browserBlockedLoopback'))
}
const goBack = (): void => {
if (cursor <= 0) return
const next = history[cursor - 1]!
setCursor(cursor - 1)
setUrl(next)
setInput(next)
setReloadKey(key => key + 1)
}
const goForward = (): void => {
if (cursor >= history.length - 1) return
const next = history[cursor + 1]!
setCursor(cursor + 1)
setUrl(next)
setInput(next)
setReloadKey(key => key + 1)
}
return (
<div className={css.browser}>
<div className={css.browserBar}>
<button
type="button"
className={css.iconButton}
aria-label={t('browserBack')}
title={t('browserBack')}
disabled={cursor <= 0}
onClick={goBack}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('browserForward')}
title={t('browserForward')}
disabled={cursor >= history.length - 1}
onClick={goForward}
>
<IconChevronRightOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { setReloadKey(key => key + 1) }}
>
<IconRefreshOutline14 />
</button>
<input
className={css.browserInput}
value={input}
placeholder={t('browserPlaceholder')}
spellCheck={false}
onChange={event => { setInput(event.target.value) }}
onKeyDown={event => {
if (event.key === 'Enter') navigateTo(input)
}}
/>
<button
type="button"
className={css.iconButton}
aria-label={t('browserGo')}
title={t('browserGo')}
onClick={() => { navigateTo(input) }}
>
<IconLinkOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('browserOpenExternal')}
title={t('browserOpenExternal')}
disabled={url === undefined}
onClick={() => {
if (url !== undefined) window.open(url, '_blank', 'noopener')
}}
>
<VscLinkExternal size={15} />
</button>
</div>
{message !== null && <div className={css.browserMessage}>{message}</div>}
<SandboxStatusBar
sandboxed={!noSandbox}
local={localUnlock}
dangerCopy={t('browserNoSandboxWarning')}
onUnlock={() => { setLocalUnlock(true) }}
onRestore={() => { setLocalUnlock(false) }}
/>
{url === undefined ? (
<div className={css.browserStart}>{t('browserStart')}</div>
) : embedBlocked !== null && !forceEmbed ? (
<BrowserEmbedBlocked
url={embedBlocked}
onOpenInBrowser={() => { window.open(embedBlocked, '_blank', 'noopener') }}
onLoadAnyway={() => { setForceEmbed(true) }}
/>
) : (
<iframe
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
className={css.browserFrame}
src={url}
sandbox={noSandbox ? undefined : iframeSandboxFor(url, store.getPrefs().browserAllowedLoopback, window.location.origin)}
referrerPolicy="no-referrer"
allow=""
title={url}
/>
)}
</div>
)
}
/**
* The embed-refusal panel: shown when the probed site forbids being
* displayed inside other pages (X-Frame-Options / frame-ancestors) — the
* iframe would only show the browser's "refused to connect" blank. Explains
* the reason and offers the real-browser open plus a load-anyway escape.
* Exported so the copy and the actions are testable without a DOM.
*/
export function BrowserEmbedBlocked(props: {
url: string
onOpenInBrowser: () => void
onLoadAnyway: () => void
}) {
const { url, onOpenInBrowser, onLoadAnyway } = props
let host = url
try { host = new URL(url).hostname } catch { /* keep the raw URL */ }
return (
<div className={css.browserBlocked}>
<IconWarningOutline16 size={16} />
<div className={css.browserBlockedTitle}>{t('browserEmbedBlocked', { host })}</div>
<div className={css.browserBlockedDesc}>{t('browserEmbedBlockedDesc')}</div>
<div className={css.browserBlockedActions}>
<button type="button" className={css.browserBlockedButton} onClick={onOpenInBrowser}>
{t('browserOpenExternal')}
</button>
<button type="button" className={css.browserBlockedButton} onClick={onLoadAnyway}>
{t('browserEmbedAnyway')}
</button>
</div>
</div>
)
}
-190
View File
@@ -1,190 +0,0 @@
/**
* The diff tab: one change opened from the changes tab, like VSCode's diff
* editor. A worktree ref loads the file's unified diff (`git diff`, staged or
* not; untracked files — which git diff never covers — render as a full-file
* addition from their content), a commit ref loads the commit's full patch
* (`git.show`-style). The header carries a refresh button because the tab
* stays mounted while the changes tab's staging/discard operations change the
* very content it shows. Rendering goes through the shared {@link DiffFiles}
* renderer — the same one the changes tab's inline preview uses.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from './api.ts'
import { api } from './api.ts'
import type { SidebarDiffRef } from './state.ts'
import { DiffFiles } from './diff/DiffFiles.tsx'
import { displayPath, foldRowsFromContents, type DiffFile, type DiffRow, type FoldSegment } from './diff/rows.ts'
import { t } from './locales.ts'
import { resolveSidebarPath } from './produced-files.ts'
import css from './sidebar.module.css'
/** The loaded diff surface (untracked content rendered as a full addition). */
interface DiffData {
diff: string
untracked?: string
}
export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) {
const { sessionId, cwd, diff } = props
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<DiffData | null>(null)
const [tick, setTick] = useState(0)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
useEffect(() => {
let cancelled = false
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
setLoading(true)
setError(null)
setData(null)
setEffectiveStaged(null)
const load = async (): Promise<void> => {
try {
if (diff.kind === 'commit') {
const result = await api.gitCommitDiff(scope, diff.hashFull, diff.worktree)
if (!cancelled) setData({ diff: result.diff })
return
}
let result = await api.gitDiff(scope, diff.path, diff.staged, diff.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once: the ref
// may predate the staged-flag fix, or the change moved sides (a
// file staged after its tab opened). Both sides empty means the
// file genuinely has no text changes.
const other = await api.gitDiff(scope, diff.path, !diff.staged, diff.worktree)
if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!diff.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setData({ diff: result.diff })
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition; anything else is a genuine no-text-change.
if (diff.untracked === true && !diff.staged) {
// A child-repo path is relative to diff.repoRoot, not the session
// cwd or the linked-worktree root; resolve against whichever the
// diff ref carries so the untracked fallback reads the right file.
const text = await api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, diff.path))
if (!cancelled) {
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
}
return
}
if (!cancelled) setData({ diff: '' })
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [sessionId, cwd, diff, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the ref or a refresh tick. ──────────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [diff, tick])
const foldLoader = useMemo(() => {
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
// Both sides empty cannot cover a non-empty fold — treat it as a failed
// fetch so the fold degrades to the unavailable marker instead of
// silently expanding to nothing (the symptom of a bad rev or path
// reading null on both sides).
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
return { old: oldContent ?? '', new: newContent ?? '' }
}
const fetchSides = async (): Promise<{ old: string; new: string }> => {
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
if (diff.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, `${diff.hashFull}^`, displayPath(file.oldPath), diff.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, diff.hashFull, displayPath(file.newPath), diff.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? diff.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, 'HEAD', displayPath(file.oldPath), diff.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, ':0', displayPath(file.newPath), diff.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, ':0', displayPath(file.oldPath), diff.worktree),
api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, displayPath(file.newPath))).catch(() => null),
])
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [sessionId, cwd, diff, effectiveStaged])
return (
<div className={css.gitDiffTab}>
<div className={css.gitDiffTabHeader}>
<span className={css.gitDiffTabTitle} title={diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}>
{diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}
</span>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={refresh}
>
<IconRefreshOutline16 size={14} />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
{!loading && error !== null && <div className={css.gitError}>{t('diffLoadError')}: {error}</div>}
{!loading && error === null && data !== null && (
<>
{data.untracked !== undefined
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffFiles diff={data.diff} resolveFold={foldLoader} />}
{data.diff === '' && data.untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
</>
)}
</div>
)
}
-581
View File
@@ -1,581 +0,0 @@
/**
* The editor tab host: the single FILES WINDOW. It resolves a file's
* previewer through the sidebar registry (`matchFileViewer`), fetches bytes
* per the matched viewer's fetch strategy, and renders its component — or
* the shared download pane when nothing can render the file. A tab without
* a path (the seeded "Files" home) renders an empty-state hint instead of
* the viewer loading flow; that path-less window IS the file explorer.
*
* The chrome depends on the `editorExplorer` mode (read reactively so
* toggling it re-renders without a reload):
* - merged (in-place): tree click / path-input Enter switch the CURRENT
* tab in place (updateTab rewrites path/title; the tab keeps its id and
* meta, so treeOpen/treeWidth survive the switch);
* - split: they open through `openSidebarFile` (a per-path dedupe tab),
* and a PATH-LESS window is the standalone explorer — it renders ONLY
* the tree panel (search + FileTree, full-window), no editor chrome.
* Editor tabs (with a path) keep the full chrome in both modes.
* The tree's context menu offers the explicit escapes in both modes: open
* in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh
* rightward split of the current pane).
*
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
* editor-load.ts); this component only wires it to the host APIs.
*/
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createElement } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { api, isOutsideWorkspaceMessage, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
import { baseName } from './FileTree.tsx'
import { createFrameBatcher } from './frame-batcher.ts'
import { openSidebarFile } from './intercept.tsx'
import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
import { updatePluginSettings } from './plugin-settings.ts'
import { TreePanel } from './TreePanel.tsx'
import { t } from './locales.ts'
import { relativeTo } from './paths.ts'
import { resolveSidebarPath } from './produced-files.ts'
import { closePathTabs, retargetPathTabs } from './tree-mutations.ts'
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, type SidebarStore, type SidebarTab } from './state.ts'
import css from './sidebar.module.css'
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
| { status: 'binary' }
/** The docked tree panel's width bounds (drag-resize clamps into them). */
const TREE_WIDTH_DEFAULT = 240
const TREE_WIDTH_MIN = 160
const TREE_WIDTH_MAX = 480
/** Stable empty blob for the editor pluginSettings read (a fresh `?? {}`
* would change identity every snapshot and loop useSyncExternalStore). */
const EMPTY_PLUGIN_BLOB: Record<string, unknown> = {}
/** The tab's persisted meta object (a malformed meta reads as empty). */
function metaOf(tab: SidebarTab): Record<string, unknown> {
return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta)
? tab.meta as Record<string, unknown>
: {}
}
/** Read the persisted tree-panel flag of one editor tab: an explicit
* boolean meta wins; otherwise path-less tabs (the seeded home) default
* open and file tabs default closed. */
function treeOpenOf(tab: SidebarTab): boolean {
const treeOpen = metaOf(tab).treeOpen
return typeof treeOpen === 'boolean' ? treeOpen : (tab.path === undefined || tab.path === '')
}
/** Read the persisted tree-panel width (clamped; default 240). */
function treeWidthOf(tab: SidebarTab): number {
const width = metaOf(tab).treeWidth
return typeof width === 'number' && Number.isFinite(width)
? Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width)))
: TREE_WIDTH_DEFAULT
}
/** Merge a patch into the tab's persisted meta (rides the layout). */
function patchMeta(ctx: Context, tab: SidebarTab, patch: Record<string, unknown>): void {
ctx.get('betterSidebar')?.updateTab(tab.id, { meta: { ...metaOf(tab), ...patch } })
}
/** Clamp one dock width into the contract range. */
function clampTreeWidth(value: number): number {
return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(value)))
}
export function EditorHost(props: {
ctx: Context
store: SidebarStore
scope: SessionScope
tab: SidebarTab
expanded: string[]
revealed: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
}) {
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
const path = tab.path ?? ''
const title = tab.title
// A folder window: the model's `sidebar_open` (or any caller) opens a
// directory as an editor tab carrying `meta.dir: true` with the directory
// as its path. It renders the file tree rooted at that folder instead of
// the viewer loading flow (a directory is not a file).
const isDir = metaOf(tab).dir === true
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
// Manual refresh (issue #167): bumping the sequence re-runs the load effect
// with the same path/scope — the only reload entry besides open/close.
const [reloadSeq, setReloadSeq] = useState(0)
// Manual refresh (issue #167 + PR #228): a dirty draft is dropped by the
// reload (the editor instance remounts), so confirm before discarding it.
const refreshFile = (): void => {
if (toolbar?.dirty === true) {
const confirmed = typeof window.confirm === 'function'
? window.confirm(t('refreshUnsavedConfirm'))
: false
if (!confirmed) return
}
setReloadSeq(sequence => sequence + 1)
}
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
// reload. The snapshot is the bare boolean so unrelated store churn never
// re-renders the editor.
const inPlace = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
)
// The file tree's "open with" configuration (pluginSettings['editor']): a
// blob subscription, so a pin click or a settings-page edit re-renders the
// menu immediately. The parsed config also drives which targets are shown
// (SSH mode hides the host-local ones).
const editorBlob = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot().prefs.pluginSettings['editor'] ?? EMPTY_PLUGIN_BLOB, [store]),
)
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
// A path-less tab shows the empty-state hint in merged mode — and in split
// mode it is the standalone explorer (tree-only, see the render below). A
// folder tab is a folder window in BOTH modes: the tree rooted at the
// folder, no editor chrome.
const showEmpty = path === ''
const treeOnly = showEmpty && !inPlace
const folderRoot = isDir ? path : undefined
/**
* Open a file from THIS window (tree click / search row / path input):
* merged mode switches this tab in place (stable id, meta survives);
* split mode opens a per-path dedupe tab through openSidebarFile.
*/
const openFile = (absolute: string): void => {
if (inPlace) {
ctx.get('betterSidebar')?.updateTab(tab.id, { path: absolute, title: baseName(absolute) })
} else {
openSidebarFile(ctx, store, scope.sessionId, absolute)
}
}
/** The context menu's explicit "new tab" escape (per-path dedupe). */
const openFileNewTab = (absolute: string): void => {
openSidebarFile(ctx, store, scope.sessionId, absolute)
}
/**
* The context menu's "open to the side": a fresh editor tab (uid id — the
* `'editor:' + path` convention would clash with the id safety net on a
* second side-open of the same file) in a rightward split of THIS pane.
*/
const openFileSide = (absolute: string): void => {
store.reduce((state) => {
const pane = leafWithTab(state.bottomSplits, tab.id) ?? firstLeaf(state.bottomSplits)
const fresh: SidebarTab = {
id: mintTabId(),
type: 'editor',
title: baseName(absolute),
path: absolute,
meta: { treeOpen: false },
}
const { node, leafId } = insertLeafAt(state.bottomSplits, pane.id, 'row', fresh, false)
return { ...state, bottomSplits: node, activePane: leafId }
})
}
/** The context menu's "open with" action: reveal the path in the OS file
* manager, or hand the target's URL to its opener — local `file` URLs go
* to the host's external opener, while the SSH-remote form for
* VSCode-family editors launches on the browser/client machine (see
* api.openExternal). Failures are logged only — a missing handler is the
* OS's/browser's dialog, not a sidebar error. */
const openWith = (targetId: string, absolute: string): void => {
const target = openWithTargets.find(item => item.id === targetId)
if (target === undefined) return
if (target.kind === 'reveal') {
void api.openExternal({ action: 'reveal', path: absolute }).catch(
(error: unknown) => { console.error('open external failed', error) },
)
return
}
const url = openWithUrl(target, absolute, openWithConfig)
if (url === undefined) return
void api.openExternal({ action: 'url', url }).catch(
(error: unknown) => { console.error('open external failed', error) },
)
}
/** Toggle one target's pinned state. The write is serialized (see
* plugin-settings.ts) and the menu re-renders when the store prefs land. */
const toggleOpenWithPin = (targetId: string): void => {
updatePluginSettings(store, 'editor', (blob) => {
const config = parseOpenWithConfig(blob.openWith)
const pinned = config.pinned.includes(targetId)
? config.pinned.filter(id => id !== targetId)
: [...config.pinned, targetId]
return { ...blob, openWith: { ...config, pinned } }
})
}
// Tree mutations reconcile the OPEN tabs (both split trees, the bottom
// panel, free windows): a rename retargets its tab to the new path; a
// delete closes tabs at or under the removed path. See tree-mutations.ts.
const onPathRenamed = (oldPath: string, newPath: string): void => {
retargetPathTabs(ctx, store, oldPath, newPath)
}
const onPathDeleted = (path: string): void => {
closePathTabs(ctx, store, path)
}
// The viewer's toolbar, hoisted into THIS header: the text editor reports
// its state and registers its commands (both null/absent for viewers
// without a toolbar — image, pdf, binary download).
const [toolbar, setToolbar] = useState<EditorToolbarState | null>(null)
const controlsRef = useRef<EditorToolbarControls | null>(null)
const onToolbarState = useCallback((next: EditorToolbarState) => {
setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)
}, [])
const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => {
controlsRef.current = controls
}, [])
// The docked panel's drag-resize: pointer capture on the handle itself
// (no window listeners — the captured pointer keeps tracking even off the
// handle). Local width while dragging, persisted into meta.treeWidth on
// release. The panel docks right, so dragging LEFT widens it.
// Moves are BATCHED per frame (createFrameBatcher): applying every
// pointermove is a setState that re-renders this host AND the editor
// viewer below it (CodeMirror re-lays out on the width change) at event
// cadence — the visible drag lag on slower CPUs (#315). The batch applies
// the latest width once per frame; release flushes it and commits.
const [dragWidth, setDragWidth] = useState<number | null>(null)
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const pendingWidthRef = useRef(0)
const dragBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
const treeWidth = dragWidth ?? treeWidthOf(tab)
const onResizeStart = (event: React.PointerEvent): void => {
event.preventDefault()
// jsdom lacks setPointerCapture — the tests dispatch plain MouseEvents.
event.currentTarget.setPointerCapture?.(event.pointerId)
dragRef.current = { startX: event.clientX, startWidth: treeWidth }
}
const onResizeMove = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
pendingWidthRef.current = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
dragBatcher.schedule(() => setDragWidth(pendingWidthRef.current))
}
const onResizeEnd = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
// Flush the last pending frame (a release can land with the final move
// still queued; without the flush a stray frame would re-apply the
// drag width AFTER the null below). Both setStates batch into this same
// event, so the committed treeWidth wins visually.
dragBatcher.flushNow()
dragRef.current = null
setDragWidth(null)
const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth })
}
useEffect(() => {
// A (re)load or a path-less tab clears any hoisted toolbar state — the
// fresh viewer re-registers its own.
setToolbar(null)
// The seeded home tab (no path) never loads a viewer — the empty-state
// hint renders until the user picks a file. A folder tab never loads a
// viewer either — its tree is rooted at the folder.
if (showEmpty || isDir) return
let cancelled = false
// Aborts the matched viewer's `load` when the editor tears down (tab
// closed, path changed, session switched) or re-matches the viewer.
const controller = new AbortController()
setLoad({ status: 'loading' })
const mediaUrlOf = (): string => mediaUrl(scope, path)
const apply = (action: EditorLoadAction): void => {
if (cancelled) return
switch (action.kind) {
case 'binary':
setLoad({ status: 'binary' })
return
case 'render':
setLoad({
status: 'ready',
viewer: action.viewer,
content: action.content,
truncated: action.truncated,
mediaUrl: action.mediaUrl,
customData: action.customData,
})
return
case 'customLoad':
void action.viewer.load?.(path, scope, controller.signal).then((data) => {
if (cancelled) return
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return
case 'fetchFsRead':
api.fsRead(scope, path).then((result) => {
if (cancelled) return
// Binary reads carry the head bytes for the detect re-match.
const outcome = planFsReadOutcome(action.viewer, {
binary: result.kind === 'binary',
content: result.kind === 'text' ? result.content : '',
truncated: result.truncated,
head: result.kind === 'binary' ? result.head : undefined,
}, (head) => ctx.get('betterSidebar')?.matchFileViewer(path, head), mediaUrlOf)
apply(outcome)
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return
}
}
apply(planFirstMatch(ctx.get('betterSidebar')?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true; controller.abort() }
// The deps are deliberately granular: the scope object's identity churns,
// only its sessionId / cwd fields gate the (re)fetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty, isDir, reloadSeq])
// Save-then-refresh in preview mode (issue #167 part C): the edge into
// 'saved' (never a lingering 'saved' state) triggers exactly one reload, so
// a preview-mode Ctrl+S shows the fresh content immediately. Edit mode is
// left alone — reloading would remount the editor and drop the caret.
const prevSaveState = useRef<EditorToolbarState['saveState'] | undefined>(undefined)
useEffect(() => {
const current = toolbar?.saveState
if (prevSaveState.current !== 'saved' && current === 'saved' && toolbar?.mode === 'preview') {
setReloadSeq(sequence => sequence + 1)
}
prevSaveState.current = current
}, [toolbar?.saveState, toolbar?.mode])
const treeOpen = treeOpenOf(tab)
/** Persist the panel flag on the tab (survives reloads with the layout). */
const toggleTree = (): void => { patchMeta(ctx, tab, { treeOpen: !treeOpen }) }
const saveLabel = toolbar === null ? ''
: toolbar.saveState === 'saving' ? t('loading')
: toolbar.saveState === 'saved' ? t('saved')
: toolbar.saveState === 'failed' ? t('saveFailed') : ''
// Split mode: the path-less window IS the standalone explorer — the tree
// panel fills the whole tab (search + FileTree, full form), no editor
// chrome. File opens land in new per-path tabs through openFile above.
// A folder window (meta.dir, any mode) renders the SAME surface rooted
// at the folder instead of the session cwd.
if (treeOnly || folderRoot !== undefined) {
return (
<div className={css.editor}>
<TreePanel
full
store={store}
sessionId={scope.sessionId}
cwd={folderRoot ?? scope.cwd}
expanded={expanded}
revealed={revealed}
onToggle={onToggleDir}
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
/>
</div>
)
}
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<EditorPathInput key={path} path={path} cwd={scope.cwd} onOpen={openFile} />
{toolbar?.modes === true && (
<div className={css.editorModeToggle}>
<button
type="button"
className={clsx(css.editorModeButton, toolbar.mode === 'preview' && css.editorModeActive)}
onClick={() => {
// Issue #167 part B: returning from edit to preview reloads so
// the preview renders the just-saved content. A dirty draft
// (or a failed save) suppresses the reload — the draft only
// lives in the editor instance and a remount would drop it.
if (toolbar.mode === 'edit' && toolbar.dirty !== true && toolbar.saveState !== 'failed') {
setReloadSeq(sequence => sequence + 1)
}
controlsRef.current?.setMode('preview')
}}
>
{t('preview')}
</button>
<button
type="button"
className={clsx(css.editorModeButton, toolbar.mode === 'edit' && css.editorModeActive)}
onClick={() => { controlsRef.current?.setMode('edit') }}
>
{t('edit')}
</button>
</div>
)}
{toolbar?.dirty === true && <span className={css.dirtyDot} title={t('unsaved')} />}
{toolbar?.editable === true && (
<button
type="button"
className={css.iconButton}
aria-label={t('save')}
title={`${t('save')} (Ctrl/Cmd+S)`}
onClick={() => { controlsRef.current?.save() }}
>
<IconCheckOutline16 size={14} />
</button>
)}
{saveLabel !== '' && (
<span className={clsx(css.editorStatus, toolbar?.saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>
)}
{toolbar !== null && (
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={refreshFile}
>
<IconRefreshOutline14 size={14} />
</button>
)}
<button
type="button"
className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
aria-label={t('editorTreeToggle')}
title={t('editorTreeToggle')}
aria-pressed={treeOpen}
onClick={toggleTree}
>
<IconFolderOpen16 size={14} />
</button>
</div>
<div className={css.editorBody}>
<div className={css.editorMain}>
{showEmpty && <div className={css.editorPlaceholder}>{t('editorEmptyHint')}</div>}
{!showEmpty && load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{!showEmpty && load.status === 'error' && (isOutsideWorkspaceMessage(load.message)
? <FenceErrorNotice store={store} onDisabled={() => { setReloadSeq(sequence => sequence + 1) }} />
: <div className={css.editorError}>{load.message}</div>)}
{!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
{!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
ctx, store, scope, path, title,
viewerId: load.viewer.id,
content: load.content,
truncated: load.truncated,
mediaUrl: load.mediaUrl,
customData: load.customData,
// The viewer's toolbar always hoists into this host's header.
toolbar: 'host',
onToolbarState,
onToolbarControls,
})}
</div>
{treeOpen && (
<div className={css.editorTreeDock} style={{ width: treeWidth }}>
<div
className={css.editorTreeResize}
role="separator"
aria-orientation="vertical"
aria-label={t('editorTreeToggle')}
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
/>
<TreePanel
store={store}
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded}
revealed={revealed}
onToggle={onToggleDir}
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
/>
</div>
)}
</div>
</div>
)
}
/**
* The header's path input: shows the current file relative to the session
* cwd (absolute when outside it). Enter resolves the typed path (relative
* input joins onto the cwd — the same resolution `openSidebarFile` uses)
* and opens it through the parent's mode-aware open (in-place switch or a
* per-path dedupe tab); Escape/blur restores the current value. The parent
* keys it by `path` so an in-place switch remounts and reseeds the draft.
*/
function EditorPathInput(props: { path: string; cwd: string | undefined; onOpen: (path: string) => void }) {
const { path, cwd, onOpen } = props
const display = path === '' ? '' : relativeTo(cwd ?? '', path)
const [value, setValue] = useState(display)
const commit = (): void => {
const input = value.trim()
if (input === '' || input === display) {
setValue(display)
return
}
onOpen(resolveSidebarPath(cwd, input))
// Split mode: the open lands in a NEW/deduped editor tab — THIS tab's
// path stays, so the input falls back to its own display value. (Merged
// mode remounts this input on the new path; the reset is harmless.)
setValue(display)
}
return (
<input
className={css.editorPathInput}
value={value}
placeholder={t('editorPathPlaceholder')}
title={path}
spellCheck={false}
onChange={(event) => { setValue(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commit()
} else if (event.key === 'Escape') {
setValue(display)
}
}}
onBlur={() => { setValue(display) }}
/>
)
}
-40
View File
@@ -1,40 +0,0 @@
/**
* The workspace-fence refusal surface. The raw wire text (`path "..." is
* outside workspace`) is never shown as-is: the editor / file-tree error
* slots render the localized reason plus a one-click global off — the click
* flips the `workspaceFence` pref through the settings route, adopts the
* returned document into the store (so every prefs reader — the changes tab's open
* guard, the settings page — flips with it), and calls `onDisabled` so the
* caller retries the failed operation immediately.
*/
import { useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import { t } from './locales.ts'
import { parsePrefs } from './prefs.ts'
import css from './sidebar.module.css'
import type { SidebarStore } from './state.ts'
export function FenceErrorNotice(props: { store: SidebarStore; onDisabled: () => void }) {
const { store, onDisabled } = props
const [busy, setBusy] = useState(false)
const disable = (): void => {
if (busy) return
setBusy(true)
api.settingsUpdate({ workspaceFence: false }).then((view) => {
store.setPrefs(parsePrefs(view.value))
onDisabled()
}).catch((error: unknown) => {
// The fence stays armed on failure (the notice remains, button
// re-enabled); the console carries the cause.
console.error('workspace fence disable failed', error)
setBusy(false)
})
}
return (
<div className={css.fenceError}>
<span>{t('fenceErrorReason')}</span>
<Button variant="outline" disabled={busy} onClick={disable}>{t('fenceDisableAction')}</Button>
</div>
)
}
-900
View File
@@ -1,900 +0,0 @@
/**
* The controlled file tree behind the files window's tree panel (TreePanel
* wraps it with the search box): a lazy VSCode-style tree rooted at the
* session's working directory. Levels load on expansion (one API call per
* directory), directories sort first, hidden entries render dimmed. The
* expansion set lives in the per-session state (owned by the caller); the
* caller also owns the refresh affordance — a `refreshTick` bump wipes the
* level cache so the visible set reloads.
*
* Row actions: hovering a row reveals an @-reference button on the far
* right (appends `@<relative path>` to the composer draft), and right-click
* opens a context menu: file rows offer the caller's open escapes
* (new tab / to the side, only when the callbacks exist) and a download
* action (the host serves raw bytes, binary-safe); directory rows offer
* "upload here"; every row can copy the relative or absolute path (with a
* brief "copied" label replacing the button after a successful write).
*
* Uploads start here (drag-drop or the context menu picker) but run in the
* caller: every request is reported through `onUploadRequest(dir, items)`
* (VSCode semantics — a drop on a file row targets its parent directory),
* and `busy` gates new drags while one upload is in flight.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import {
Button, IconChevronRightOutline14, IconCloseFill14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { SiCursor, SiZedindustries } from 'react-icons/si'
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
import { isImeComposition } from './ime-guard.ts'
import { useSubmenuFlip } from './menu-flip.ts'
import type { OpenWithTarget } from './open-with.ts'
import { relativeTo } from './paths.ts'
import { t } from './locales.ts'
import type { SidebarStore } from './state.ts'
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
import css from './sidebar.module.css'
interface LevelData {
entries?: FsEntry[]
error?: string
}
/** Root label: the last path segment (mirror of the host rootLabel). */
export function baseName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, '')
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return at === -1 ? trimmed : trimmed.slice(at + 1)
}
/** The containing directory of an absolute row path (never the root edge here). */
function parentOf(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at <= 0 ? path : path.slice(0, at)
}
/** Only OS file drags belong to the upload surface; in-app drags (tab reorder,
* split zones) must pass through untouched to the pane's tab-drop handling
* (mirror of Sidebar.tsx's panel-host shield gate). */
function isFileDrag(event: DragEvent): boolean {
return event.dataTransfer?.types.includes('Files') ?? false
}
/** How long the row's "copied" label stays after a successful write. */
const COPIED_MS = 1200
/**
* The drop overlay's hero art: an arrow rising out of a notched tray
* (upload zone — the same glyph family as the toolbar's upload icon) and a
* tilted pair of photo cards (chat zone). Hand-drawn, colored in the
* palette of DSH's own native drop illustration (#3964FE / #9CE5ED) so the
* two zones read as one family; the drop overlay is this flow's one brand
* moment, so it gets color the rest of the UI never does.
*/
const UploadDropIllustration = () => (
<svg width="64" height="56" viewBox="0 0 64 56" fill="none" aria-hidden="true">
<path d="M32 28V11" stroke="#3964FE" strokeWidth="5" strokeLinecap="round" />
<path d="M23 20l9-9 9 9" stroke="#3964FE" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M10 40a4 4 0 0 1 4-4h7l3.2 4.6a5 5 0 0 0 4.1 2.2h7.4a5 5 0 0 0 4.1-2.2L43 36h7a4 4 0 0 1 4 4v2a10 10 0 0 1-10 10H20A10 10 0 0 1 10 42v-2z"
fill="#9CE5ED"
/>
</svg>
)
/** The chat zone's art: two tilted photo cards, each with its own
* sun-over-mountains motif (the back card carries detail too, so it never
* reads as a bare blob). */
const ChatDropIllustration = () => (
<svg width="96" height="76" viewBox="0 0 96 76" fill="none" aria-hidden="true">
<g transform="rotate(-12 24 34)">
<rect x="6" y="16" width="36" height="36" rx="10" fill="#9CE5ED" />
<circle cx="16" cy="27" r="3.5" fill="white" />
<path d="M11 44l8-9 6 6 4-4 8 9" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</g>
<g transform="rotate(8 61 35)">
<rect x="40" y="12" width="42" height="46" rx="10" fill="#3964FE" />
<circle cx="55" cy="27" r="5" fill="white" />
<path d="M46 50l10-13 7 8 6-6 9 11" stroke="white" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
</g>
</svg>
)
export function FileTree(props: {
sessionId: string
cwd: string | undefined
/** The sidebar store: the fence-refusal notice writes the `workspaceFence` pref through it. */
store: SidebarStore
expanded: string[]
/** Files highlighted by a "Show in folder" reveal (absolute paths). */
revealed: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
/** Context-menu "open in a new tab" (file rows; absent → no entry). */
onOpenFileNewTab?: (path: string) => void
/** Context-menu "open to the side" (file rows; absent → no entry). */
onOpenFileSide?: (path: string) => void
/**
* The "open with" menu: resolved external targets (already SSH-filtered
* and in menu order). Absent → the whole section is hidden.
*/
openWithTargets?: OpenWithTarget[]
/** Ids of targets pinned to the menu's top level (subset of the ids). */
openWithPinned?: string[]
/** Whether the workspace is remote (appends the SSH hint to target labels). */
openWithSsh?: boolean
/** Open one target externally (reveal or URL — the caller decides). */
onOpenWith?: (targetId: string, path: string) => void
/** Toggle one target's pinned state (the submenu row's pushpin). */
onToggleOpenWithPin?: (targetId: string) => void
/** Insert `@<relative path>` into the composer draft (file vs directory). */
onReferenceFile: (path: string, isDir: boolean) => void
/** A rename landed (old row path → new path): the caller retargets open tabs. */
onPathRenamed?: (oldPath: string, newPath: string) => void
/** A delete landed: the caller closes tabs at or under the removed path. */
onPathDeleted?: (path: string, isDir: boolean) => void
/** Bump to wipe the level cache and reload the visible set. */
refreshTick: number
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
onUploadRequest: (dir: string, items: UploadItem[]) => void
/** True while an upload is in flight (drops are ignored). */
busy: boolean
}) {
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy } = props
const [data, setData] = useState<Record<string, LevelData>>({})
const dataRef = useRef(data)
/** The row whose path was just copied ("copied" label replaces its button). */
const [copiedPath, setCopiedPath] = useState<string | null>(null)
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
// The row menu's "open with" submenu is the one submenu that can tower past
// the viewport; publish its flip geometry for layout.css while it is open.
useSubmenuFlip(rowMenu)
/** The row being renamed inline: its path plus the edit buffer. */
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null)
/** The delete awaiting the confirmation modal's yes. */
const [confirmDelete, setConfirmDelete] = useState<{ path: string; isDir: boolean; name: string } | null>(null)
/** The last mutation failure (dismissable strip above the tree). */
const [actionError, setActionError] = useState<string | null>(null)
/** Whether a file drag hovers the tree (drives the portaled drop zone). */
const [dropOver, setDropOver] = useState(false)
/** The directory a drag is hovering right now (null = body, drop to root). */
const [dropTarget, setDropTarget] = useState<string | null>(null)
/**
* Enter/leave depth under the tree body. dragenter/dragleave fire per
* element along the drag path (and bubble), so a counter — DSH InputBar's
* own pattern — is the flicker-free signal; relatedTarget is unreliable
* across engines for drag events.
*/
const dropDepth = useRef(0)
/** Explorer body element; its viewport rect anchors the portaled drop zone. */
const bodyRef = useRef<HTMLDivElement>(null)
/** The body's viewport rect captured at drag entry (null = not measured). */
const [dropRect, setDropRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null)
/** Context-menu "upload here" target directory. */
const pendingUploadDir = useRef<string | undefined>(undefined)
const fileInputRef = useRef<HTMLInputElement>(null)
/** Reset all drag state (drop landed, the drag left, or a new drag begins). */
const resetDrop = (): void => {
dropDepth.current = 0
setDropOver(false)
setDropTarget(null)
setDropRect(null)
}
/**
* Drop handlers: always swallow the event (a dropped file must never open
* in the browser), then report the target directory to the caller. A drop
* ends the drag without further leave events, so the depth resets here.
* The payload collection is async (dropped folders are traversed through
* their entry handles — captured synchronously inside uploadItemsFromDrop
* while the dataTransfer is still live), so the request rides a then.
*/
const reportDrop = (dir: string, data: DataTransfer | undefined): void => {
if (busy) return
void uploadItemsFromDrop(data).then((items) => {
if (items.length > 0) onUploadRequest(dir, items)
})
}
const handleBodyDrop = (event: DragEvent): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
resetDrop()
if (cwd !== undefined) reportDrop(cwd, event.dataTransfer)
}
const handleDirDrop = (event: DragEvent, dir: string): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
resetDrop()
reportDrop(dir, event.dataTransfer)
}
const handleFileDrop = (event: DragEvent, path: string): void => {
// VSCode semantics: dropping onto a file uploads into its directory.
handleDirDrop(event, parentOf(path))
}
const handleBodyDragEnter = (event: DragEvent): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
dropDepth.current += 1
if (busy) return
// First entry: anchor the portaled drop zone to the body's rect.
if (dropDepth.current === 1) {
const rect = bodyRef.current?.getBoundingClientRect()
setDropRect(rect === undefined ? null : { top: rect.top, left: rect.left, width: rect.width, height: rect.height })
}
setDropOver(true)
}
const handleBodyDragLeave = (): void => {
dropDepth.current = Math.max(0, dropDepth.current - 1)
if (dropDepth.current > 0) return
setDropOver(false)
setDropTarget(null)
setDropRect(null)
}
const handleBodyDragOver = (event: DragEvent): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
if (busy) return
// Rows stop propagation, so this only fires over non-row regions: the
// drag targets the workspace root. dragover fires continuously, making
// it the authoritative (flicker-free) place to clear the row target.
setDropTarget(null)
}
const handleRowDragOver = (event: DragEvent, dir: string): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
if (busy) return
setDropTarget(dir)
}
const storeLevel = useCallback((path: string, level: LevelData) => {
dataRef.current = { ...dataRef.current, [path]: level }
setData(dataRef.current)
}, [])
const loadDir = useCallback((dir: string) => {
if (dataRef.current[dir] !== undefined) return
storeLevel(dir, {})
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
storeLevel(dir, { entries: listing.entries })
}).catch((error: unknown) => {
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
})
}, [sessionId, cwd, storeLevel])
/** Drop one level from the cache and reload it (the fence notice's retry). */
const retryDir = useCallback((dir: string) => {
delete dataRef.current[dir]
setData({ ...dataRef.current })
loadDir(dir)
}, [loadDir])
/**
* Settle the tree after one rename/delete landed at `prefix`: drop every
* cached level at or under the old path, collapse the now-stale expanded
* directories below it, and reload the immediate parent so the new shape
* shows without a full refresh tick.
*/
const pruneTree = (prefix: string): void => {
const parent = parentOf(prefix)
const under = (key: string): boolean => key === prefix || key.startsWith(`${prefix}/`) || key.startsWith(`${prefix}\\`)
for (const key of Object.keys(dataRef.current)) {
if (under(key)) delete dataRef.current[key]
}
if (parent !== prefix) delete dataRef.current[parent]
setData({ ...dataRef.current })
for (const dir of expanded) {
if (under(dir)) onToggle(dir)
}
if (parent !== prefix) retryDir(parent)
}
/** Client-side twin of the server's name rule (the server re-validates). */
const validRename = (name: string): boolean =>
name !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
/** Commit the inline rename: trim, no-op guard, then fs.rename + settle. */
const commitRename = (path: string, raw: string): void => {
setRenaming(null)
const name = raw.trim()
if (cwd === undefined || name === baseName(path) || !validRename(name)) {
if (name !== baseName(path) && !validRename(name)) setActionError(t('renameInvalid'))
return
}
api.fsRename({ sessionId, cwd }, path, name)
.then((result) => {
setActionError(null)
pruneTree(path)
onPathRenamed?.(path, result.path)
})
.catch((error: unknown) => {
setActionError(error instanceof Error ? error.message : String(error))
})
}
/** Run the confirmed delete: fs.remove + settle + close affected tabs. */
const performDelete = (target: { path: string; isDir: boolean }): void => {
if (cwd === undefined) return
api.fsRemove({ sessionId, cwd }, target.path)
.then(() => {
setActionError(null)
pruneTree(target.path)
onPathDeleted?.(target.path, target.isDir)
})
.catch((error: unknown) => {
setActionError(error instanceof Error ? error.message : String(error))
})
}
// The caller's refresh tick wipes the cache (declared BEFORE the load
// effect so the reload below sees the empty cache).
const lastTick = useRef(refreshTick)
useEffect(() => {
if (lastTick.current === refreshTick) return
lastTick.current = refreshTick
dataRef.current = {}
setData({})
}, [refreshTick])
useEffect(() => {
// Load the visible set; already-loaded levels (kept in the cache) are
// not refetched. Only the refresh tick wipes the cache.
const root = cwd
if (root === undefined) return
loadDir(root)
for (const dir of expanded) loadDir(dir)
}, [cwd, expanded, refreshTick, loadDir])
// Bring a "Show in folder" reveal into view: the ancestors expand above
// (revealPaths), but the row may not be scrolled into sight — a reveal on
// a long tree should surface the highlighted file. Re-runs when the tree
// data or reveal set changes (the row appears after its level loads).
// Scrolls ONLY this tree's body: scrollIntoView would scroll every
// scrollable ancestor, and the clipping panel host
// ([data-dsh-panel-host]) is still programmatically scrollable — a deep
// reveal shifted the whole panel, tab bar included, out of the viewport.
useEffect(() => {
if (revealed.length === 0) return
const body = bodyRef.current
if (body === null) return
const row = body.querySelector<HTMLElement>('[data-dsh-revealed]')
if (row === null) return
const bodyTop = body.getBoundingClientRect().top
const rowRect = row.getBoundingClientRect()
const target = body.scrollTop + (rowRect.top + rowRect.height / 2) - (bodyTop + body.clientHeight / 2)
const max = Math.max(body.scrollHeight - body.clientHeight, 0)
body.scrollTo({ top: Math.min(Math.max(target, 0), max), behavior: 'smooth' })
}, [revealed, data])
/** Copy `text`; on success flip the row's copied label for a moment. */
const copyPath = useCallback((text: string, path: string): void => {
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopiedPath(path)
window.setTimeout(() => {
setCopiedPath(current => current === path ? null : current)
}, COPIED_MS)
})
}, [])
/** The row's trailing actions: the @-reference button, or the copied label. */
const rowActions = (entry: FsEntry): ReactNode => {
if (copiedPath === entry.path) {
return <span className={css.explorerCopied}>{t('copied')}</span>
}
return (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(entry.path, entry.isDir)
}}
>
{t('referenceFile')}
</button>
)
}
const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
event.preventDefault()
event.stopPropagation()
setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
}
/** Download a file through the host route (raw bytes, binary-safe). */
const downloadFile = (path: string): void => {
const url = downloadUrl({ sessionId, cwd }, path)
const anchor = document.createElement('a')
anchor.href = url
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
/** The menu label of one open target: a locale key for the built-ins, the
* user's own name for custom editors, plus the SSH hint in remote mode. */
const openWithLabelOf = (target: OpenWithTarget): string => {
const name = target.nameKey !== undefined ? t(target.nameKey) : target.name
return openWithSsh === true && !target.localOnly ? `${name}${t('openWithSshSuffix')}` : name
}
/**
* The "open with" menu entries: the pinned targets as DIRECT rows, then
* the parent row with every target as a nested submenu. Both only render
* when the caller wired the feature and at least one target is visible.
*/
const openWithEntries = (): MenuEntry[] => {
if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
const pinnedIds = openWithPinned ?? []
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
* reveal gets the folder glyph, custom editors a generic code mark.
* The compact menu's icon slot is 14px, so every mark renders at 14. */
const itemIcon = (target: OpenWithTarget): ReactNode => {
if (target.kind === 'reveal') return <VscFolderOpened size={14} />
if (target.id === 'vscode') return <IconVscode16 size={14} />
if (target.id === 'cursor') return <SiCursor size={14} />
if (target.id === 'zed') return <SiZedindustries size={14} />
return <IconCodeOutline16 size={14} />
}
const pinned = openWithTargets
.filter(target => pinnedIds.includes(target.id))
.map<MenuItem>(target => ({
id: `open-with:${target.id}`,
label: openWithLabelOf(target),
icon: itemIcon(target),
}))
const submenu = openWithTargets.map<MenuItem>(target => {
const pinnedNow = pinnedIds.includes(target.id)
return {
id: `open-with:${target.id}`,
label: (
<span className={css.openWithLabel}>
<span className={css.openWithName}>{openWithLabelOf(target)}</span>
{/* The pushpin: a span (never a button — the Menu row itself is
a button, so a nested interactive element would be invalid).
Clicking it pins/unpins the target at the menu's top level
WITHOUT selecting the row: the pin stops propagation, so the
menu stays open and the icon flips on the next render. */}
<span
role="button"
tabIndex={-1}
className={clsx(css.openWithPin, pinnedNow && css.openWithPinActive)}
aria-label={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
title={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onToggleOpenWithPin?.(target.id)
}}
>
{pinnedNow ? <VscPinned size={12} /> : <VscPin size={12} />}
</span>
</span>
),
icon: itemIcon(target),
}
})
return [
...pinned,
...(pinned.length > 0 ? [{ id: 'open-with-sep', type: 'separator' } as MenuEntry] : []),
{
id: 'open-with-menu',
// The primitives Menu renders no chevron for submenu parents — the
// trailing arrow is supplied inside the label (full-width flex row,
// right-aligned), matching how the submenu rows right-align the pin.
label: (
<span className={css.openWithLabel}>
<span className={css.openWithName}>{t('openWithMenu')}</span>
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
</span>
),
icon: <VscLinkExternal size={14} />,
submenu,
},
]
}
const root = cwd
// Membership is tested per rendered row (deep trees run this thousands of
// times per render): includes() made every row O(expanded), the whole tree
// O(rows × expanded). One Set per render keeps it O(1) per row.
const expandedSet = useMemo(() => new Set(expanded), [expanded])
const revealedSet = useMemo(() => new Set(revealed), [revealed])
/**
* Focus + select the rename editor once, on mount. The callback identity
* must stay STABLE (useCallback []): an inline arrow re-runs on every
* render (detach null → attach el), and focus()/select() mid-keystroke
* would reselect the whole buffer while typing. A plain <input>, not the
* primitives Input — that one forwards no ref, and focus+select is the
* entire point here.
*/
const renameInputRef = useCallback((el: HTMLInputElement | null): void => {
if (el !== null) {
el.focus()
el.select()
}
}, [])
/** The inline rename editor replacing one row (dirs and files alike):
* same indent/icon/height for a seamless swap, Enter/blur commits,
* Escape cancels, IME composition keys never reach the handlers (the
* shared isImeComposition guard). */
const renderRenameRow = (entry: FsEntry, depth: number): ReactNode => (
<div
key={entry.path}
className={clsx(css.explorerRow, css.explorerRenaming)}
style={{ paddingLeft: depth * 22 + 6 }}
>
{entry.isDir
? (expandedSet.has(entry.path) ? <VscFolderOpened size={14} /> : <VscFolder size={14} />)
: <VscFile size={14} />}
<input
ref={renameInputRef}
className={css.explorerRenameInput}
value={renaming?.value ?? ''}
aria-label={t('rename')}
spellCheck={false}
onChange={(event) => {
setRenaming(prev => prev === null ? prev : { ...prev, value: event.target.value })
}}
onKeyDown={(event) => {
if (isImeComposition(event)) return
if (event.key === 'Enter') {
event.preventDefault()
commitRename(entry.path, renaming?.value ?? '')
} else if (event.key === 'Escape') {
event.preventDefault()
setRenaming(null)
}
}}
onBlur={() => { commitRename(entry.path, renaming?.value ?? '') }}
/>
</div>
)
const renderLevel = (dir: string, depth: number): ReactNode => {
const level = data[dir]
if (level === undefined) {
return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
}
if (level.error !== undefined) {
// The fence refusal becomes the friendly notice (reason + one-click
// global off + immediate retry of this directory), never the raw
// `path "..." is outside workspace` wire text.
if (isOutsideWorkspaceMessage(level.error)) {
return (
<div style={{ paddingLeft: depth * 22 + 6 }}>
<FenceErrorNotice store={store} onDisabled={() => { retryDir(dir) }} />
</div>
)
}
return (
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
{level.error}
</div>
)
}
const entries = level.entries ?? []
return entries.map(entry => {
// The row being renamed renders as its editor (no button semantics:
// an editor is not a click target — and a nested interactive inside
// role="button" would be invalid anyway).
if (renaming?.path === entry.path) return renderRenameRow(entry, depth)
if (entry.isDir) {
const isOpen = expandedSet.has(entry.path)
return (
<div key={entry.path}>
<div
role="button"
tabIndex={0}
className={clsx(
css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
dropTarget === entry.path && css.explorerRowDropTarget,
revealedSet.has(entry.path) && css.explorerRowRevealed,
)}
data-dsh-revealed={revealedSet.has(entry.path) ? 'true' : undefined}
style={{ paddingLeft: depth * 22 + 6 }}
onClick={() => { onToggle(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onToggle(entry.path)
}
}}
onDragOver={(event) => { handleRowDragOver(event, entry.path) }}
onDrop={(event) => { handleDirDrop(event, entry.path) }}
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
>
{isOpen ? <VscFolderOpened size={14} /> : <VscFolder size={14} />}
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
</div>
{isOpen && renderLevel(entry.path, depth + 1)}
</div>
)
}
return (
<div
key={entry.path}
role="button"
tabIndex={0}
className={clsx(
css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken,
dropTarget === parentOf(entry.path) && css.explorerRowDropTarget,
revealedSet.has(entry.path) && css.explorerRowRevealed,
)}
data-dsh-revealed={revealedSet.has(entry.path) ? 'true' : undefined}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
onClick={() => { onOpenFile(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onOpenFile(entry.path)
}
}}
onDragOver={(event) => { handleRowDragOver(event, parentOf(entry.path)) }}
onDrop={(event) => { handleFileDrop(event, entry.path) }}
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
>
<VscFile size={14} />
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
</div>
)
})
}
return (
<div
ref={bodyRef}
className={css.explorerBody}
onDragEnter={handleBodyDragEnter}
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
>
{root === undefined ? (
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
{/* The last mutation failure (rename/delete): dismissable, raw
server text — the same show-the-truth policy the fence notice
uses. Any later action or a fresh attempt clears it. */}
{actionError !== null && (
<div className={css.explorerActionError} role="alert">
<span className={css.explorerActionErrorText}>{actionError}</span>
<button
type="button"
className={css.explorerActionErrorClose}
aria-label={t('dismiss')}
onClick={() => { setActionError(null) }}
>
<IconCloseFill14 />
</button>
</div>
)}
<div
className={clsx(css.explorerRow, dropTarget === root && css.explorerRowDropTarget)}
style={{ paddingLeft: 6 }}
onDragOver={(event) => { handleRowDragOver(event, root) }}
onDrop={(event) => { handleDirDrop(event, root) }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<VscFolderOpened size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root, true)
}}
>
{t('referenceFile')}
</button>
)}
</div>
{data[root] !== undefined && renderLevel(root, 1)}
</>
)}
{dropOver && dropRect !== null && createPortal(
/*
* The sidebar's drop surface, portaled to document.body at z-1001 —
* above DSH's own whole-page drop mask (z-1000, see InputBar's
* document-level intake) so the two never compete. It dims the WHOLE
* viewport (the giant box-shadow spread on the zone frame is the
* mask; the zone rect itself stays clear), teaching the zone split:
* the dimmed conversation column still takes drops into the chat
* natively (this layer is pointer-inert), while the clear frame
* marks the tree as the workspace-upload zone. Deliberate exception
* to the "panel stays below the DSH float stack" rule: transient,
* and the drop always lands on the element beneath. The hint pill
* docks at the TOP edge of the zone — right under the search row,
* the first thing the eye meets — keeping the rows aimable.
*/
<>
<div
className={css.uploadDropZone}
style={{
top: dropRect.top + 2,
left: dropRect.left + 2,
width: dropRect.width - 4,
height: dropRect.height - 4,
}}
>
<div className={css.uploadDropHero}>
<UploadDropIllustration />
<div className={css.uploadDropZonePill}>
<IconUploadOutline16 size={14} />
<span className={css.uploadDropZoneText}>
{dropTarget !== null ? t('uploadTo', { dir: dropTarget }) : t('uploadDropHint')}
</span>
</div>
</div>
</div>
{/* The left zone's invitation, centered in the space beside the
tree; skipped when that space is too narrow to hold it. */}
{dropRect.left >= 200 && (
<div className={css.uploadDropChatHint} style={{ width: dropRect.left }}>
<div className={css.uploadDropChatCard}>
<ChatDropIllustration />
<span>{t('uploadDropChat')}</span>
</div>
</div>
)}
</>,
document.body,
)}
{/*
The one shared context menu, positioned at the right-click cursor
(portal so the tree's overflow clip cannot crop it).
*/}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(event) => {
const dir = pendingUploadDir.current ?? root
pendingUploadDir.current = undefined
if (dir !== undefined && !busy) onUploadRequest(dir, uploadItemsFromFiles(event.target.files ?? []))
event.target.value = ''
}}
/>
<Menu
open={rowMenu !== null}
onClose={() => { setRowMenu(null) }}
items={[
// The open escapes head the FILE menu (dirs only get copy).
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
: []),
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={14} /> }]
: []),
...openWithEntries(),
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
: []),
// Upload into a directory (incl. the workspace root row).
...(rowMenu?.isDir === true
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
: []),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
// Explorer mutations close the menu; the workspace ROOT row is the
// session itself — never renamable or deletable (server double-guards).
...(rowMenu !== null && rowMenu.path !== cwd
? [
{ id: 'mutate-sep', type: 'separator' } as MenuEntry,
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 size={14} /> },
{ id: 'delete', label: t('delete'), icon: <IconTrashOutline16 size={14} />, danger: true },
]
: []),
]}
onSelect={(id) => {
const target = rowMenu
if (target === null) return
setRowMenu(null)
if (id === 'open-new-tab') {
onOpenFileNewTab?.(target.path)
return
}
if (id === 'open-side') {
onOpenFileSide?.(target.path)
return
}
if (id.startsWith('open-with:')) {
onOpenWith?.(id.slice('open-with:'.length), target.path)
return
}
if (id === 'download') {
downloadFile(target.path)
return
}
if (id === 'upload-here') {
pendingUploadDir.current = target.path
fileInputRef.current?.click()
return
}
if (id === 'rename') {
setRenaming({ path: target.path, value: baseName(target.path) })
return
}
if (id === 'delete') {
setConfirmDelete({ path: target.path, isDir: target.isDir, name: baseName(target.path) })
return
}
copyPath(
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
target.path,
)
}}
portal
compact
align="start"
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
anchor={<span />}
/>
{/* The delete confirmation: destructive and permanent (no host trash),
so it always lands here first — the same Cancel/Confirm shape the
git lens uses for discard/revert/cherry-pick. */}
<Modal
open={confirmDelete !== null}
onClose={() => { setConfirmDelete(null) }}
title={confirmDelete === null ? '' : t('deleteTitle', { name: confirmDelete.name })}
closeLabel={t('cancel')}
footer={(
<>
<Button variant="outline" onClick={() => { setConfirmDelete(null) }}>{t('cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const pending = confirmDelete
if (pending === null) return
setConfirmDelete(null)
performDelete(pending)
}}
>
{t('delete')}
</Button>
</>
)}
>
<p className={css.explorerConfirmDesc}>
{confirmDelete === null ? '' : t(confirmDelete.isDir ? 'deleteDescDir' : 'deleteDescFile')}
</p>
</Modal>
</div>
)
}
-300
View File
@@ -1,300 +0,0 @@
/**
* The markdown preview's raw-HTML renderer. `markdown-html.ts` lifts HTML
* runs out of the markdown stream; this module renders them as sanitized DOM
* alongside the markdown runs (which keep flowing through the shared
* `MarkdownText`), nests markdown into unclosed block elements the way
* GitHub's linear HTML output does (`<details>` … fence … `</details>`), and
* runs an inline pass that turns literal tag text inside rendered markdown
* (table cells with `<br/>`, `<sub>`, `<img>`) back into elements.
*
* Security posture: every HTML string (block leaves, inline text, wrapper
* open-tag attributes) goes through DOMPurify with an explicit denylist on
* top of its defaults (no script/style/iframe/forms), anchors are forced to
* open in a new tab with noopener, and local media goes through the
* session-scoped `/sidebar/file` media route — markdown-syntax image
* destinations are rewritten via `markdown-images.ts` and `src` attributes
* through `resolveLocalMediaDest` (the same trust fence).
*/
import { useLayoutEffect, useMemo, useRef } from 'react'
import { createElement, type ReactNode } from 'react'
import DOMPurify from 'dompurify'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts'
import {
analyzeHtmlSegment,
type AnalyzedMarkdownHtml,
} from './markdown-html.ts'
import { splitMermaidBlocks } from './mermaid-blocks.ts'
import type { SessionScope } from './api.ts'
import css from './sidebar.module.css'
/** The chunk-resident markdown renderer (mermaid lazy chunk), shared with the
* legacy no-HTML preview path in TextEditor. Defined in mermaid-lazy.tsx (a
* light module) so core-bundle consumers can import the stub without
* dragging DOMPurify along. */
export { LazyMermaidMarkdown } from './mermaid-lazy.tsx'
import { LazyMermaidMarkdown } from './mermaid-lazy.tsx'
/** Everything the sanitizers need to resolve local media + scope the route. */
export interface MarkdownHtmlMedia {
scope: SessionScope
path: string
origin: string
}
/** Tag-like text in a rendered text node — the inline pass gate. */
const TAGLIKE_TEXT_RE = /<\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]/
/** Explicit denylist on top of DOMPurify's defaults: no active content, no
* form chrome, no document-level elements inside a preview. */
const PURIFY_FORBID_TAGS = [
'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button',
'select', 'textarea', 'meta', 'link', 'base', 'frame', 'frameset', 'applet',
]
const PURIFY_FORBID_ATTR = ['srcdoc', 'formaction']
/**
* Post-sanitize hardening on a detached element tree: anchors open in a new
* tab (never navigate the GUI), and local media sources go through the media
* route so they render instead of being dropped by protocol allowlists.
*/
function postProcessSanitized(root: Element, media: MarkdownHtmlMedia): void {
for (const anchor of root.querySelectorAll('a[href]')) {
anchor.setAttribute('target', '_blank')
anchor.setAttribute('rel', 'noopener noreferrer')
}
for (const element of root.querySelectorAll('img, video, audio, source')) {
const src = element.getAttribute('src')
if (src === null) continue
element.setAttribute('src', resolveLocalMediaDest(src, media.scope, media.path, media.origin))
}
}
/** Sanitize one balanced HTML span into markup for dangerouslySetInnerHTML. */
function sanitizeHtmlBlock(source: string, media: MarkdownHtmlMedia): string {
const holder = document.createElement('div')
holder.innerHTML = DOMPurify.sanitize(source, {
FORBID_TAGS: PURIFY_FORBID_TAGS,
FORBID_ATTR: PURIFY_FORBID_ATTR,
})
postProcessSanitized(holder, media)
return holder.innerHTML
}
/**
* Sanitize literal tag text from a rendered markdown text node. Returns null
* when nothing real survived (pure prose like `a < b` — the DOMPurify output
* has no element children), so the caller leaves the text node untouched.
*/
function sanitizeInlineHtml(text: string, media: MarkdownHtmlMedia): string | null {
const holder = document.createElement('span')
holder.innerHTML = DOMPurify.sanitize(text, {
FORBID_TAGS: PURIFY_FORBID_TAGS,
FORBID_ATTR: PURIFY_FORBID_ATTR,
})
if (holder.firstElementChild === null) return null
postProcessSanitized(holder, media)
return holder.innerHTML
}
/**
* Sanitize a wrapper open tag (`<details open>`) into React props. Returns
* null when DOMPurify dropped the whole tag (denied element) — the renderer
* then treats the wrapper as transparent. `class`/`for` map to their React
* names; `style` is dropped (React needs an object; wrappers with inline
* styles are vanishingly rare and not worth a CSS parser).
*/
function sanitizeTagProps(tag: string, attrs: string): Record<string, string> | null {
const probe = DOMPurify.sanitize(`<${tag}${attrs}></${tag}>`, {
FORBID_TAGS: PURIFY_FORBID_TAGS,
FORBID_ATTR: PURIFY_FORBID_ATTR,
})
const holder = document.createElement('div')
holder.innerHTML = probe
const element = holder.firstElementChild
if (element === null || element.tagName.toLowerCase() !== tag) return null
const props: Record<string, string> = {}
for (const attr of element.attributes) {
if (/^on/i.test(attr.name) || !/^[a-zA-Z][a-zA-Z0-9:._-]*$/.test(attr.name)) continue
if (attr.name === 'style') continue
props[attr.name === 'class' ? 'className' : attr.name === 'for' ? 'htmlFor' : attr.name] = attr.value
}
return props
}
/**
* The inline pass: walk the rendered markdown's text nodes and swap any that
* contain tag-like text for a sanitized `<span data-html-inline>`. Rendered
* code (inline `code`, `pre`, the host `.md-code-block`, mermaid mounts, and
* spans this pass already produced) is skipped. The same commit-then-operate
* pattern the mermaid swap uses: React keeps owning the host tree, only leaf
* text nodes are replaced, and a text change re-renders the subtree fresh.
*/
function runInlineHtmlPass(container: HTMLElement, media: MarkdownHtmlMedia): void {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = (node as Text).parentElement
if (parent === null) return NodeFilter.FILTER_REJECT
if (parent.closest('code, pre, .md-code-block, [data-mermaid-processed], [data-html-inline]')) {
return NodeFilter.FILTER_REJECT
}
return TAGLIKE_TEXT_RE.test((node as Text).data)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT
},
})
const targets: Text[] = []
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) targets.push(node as Text)
for (const node of targets) {
const html = sanitizeInlineHtml(node.data, media)
if (html === null) continue
const span = document.createElement('span')
span.setAttribute('data-html-inline', '')
span.innerHTML = html
node.replaceWith(span)
}
}
interface MarkdownSegmentProps {
text: string
hasMermaid: boolean
media: MarkdownHtmlMedia
codeLabels: { copyLabel: string; copiedLabel: string }
}
/**
* One markdown run of a split document: the shared MarkdownText pass (or the
* mermaid chunk renderer when the run contains a mermaid fence), plus the
* inline HTML pass. The pass runs after every text change and is re-armed by
* a MutationObserver so it also catches content that appears late (the lazy
* mermaid chunk mounting, shiki highlighting settling) — it is idempotent and
* skips its own output, so mutation feedback settles after one extra pass.
*/
function MarkdownSegment({ text, hasMermaid, media, codeLabels }: MarkdownSegmentProps): ReactNode {
const containerRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const container = containerRef.current
if (container === null) return
runInlineHtmlPass(container, media)
let scheduled = false
const observer = new MutationObserver(() => {
if (scheduled) return
scheduled = true
queueMicrotask(() => {
scheduled = false
runInlineHtmlPass(container, media)
})
})
observer.observe(container, { childList: true, subtree: true })
return () => { observer.disconnect() }
}, [text, media])
return (
<div ref={containerRef}>
{hasMermaid
? <LazyMermaidMarkdown text={text} codeLabels={codeLabels} />
: <MarkdownText {...markdownTextProps(text, codeLabels)} />}
</div>
)
}
/** A sanitized, balanced HTML span rendered as its own block. */
function HtmlLeaf({ html }: { html: string }): ReactNode {
return (
<div className={css.editorHtmlBlock} data-dsh-html-segment dangerouslySetInnerHTML={{ __html: html }} />
)
}
/** One fully-prepared HTML run: sanitized leaves + wrapper opens/closes. */
type PreparedHtmlPart =
| { kind: 'html'; html: string }
| { kind: 'open'; tag: string; props: Record<string, string> | null }
| { kind: 'close' }
type PreparedSegment =
| { kind: 'markdown'; text: string; hasMermaid: boolean }
| { kind: 'html'; parts: PreparedHtmlPart[] }
interface MarkdownDocumentProps {
info: AnalyzedMarkdownHtml
media: MarkdownHtmlMedia
codeLabels: { copyLabel: string; copiedLabel: string }
}
/**
* The split-document renderer: markdown runs render through MarkdownSegment,
* HTML runs render as sanitized leaves, and unclosed block elements lower the
* following runs into themselves until their close part pops the frame (the
* renderer's frame stack persists across segments). Stray closes at the top
* level render nothing (the sanitizer/parser would drop them anyway), and
* frames still open at the end of the document are closed like a browser
* parser would. Sanitization runs once per prepared change, in a memo.
*/
export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentProps): ReactNode {
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
if (segment.kind === 'markdown') {
const defs = info.referenceDefinitions
const raw = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
// MarkdownText drops non-http(s) image destinations (chat-security
// stance), so rewrite local ones into /sidebar/file media URLs first —
// the same trust fence the sanitized HTML leaves below go through.
// Idempotent: already-absolute media URLs pass through untouched.
const text = rewriteLocalImageUrls(raw, media.scope, media.path, media.origin)
return {
kind: 'markdown',
text,
hasMermaid: splitMermaidBlocks(text).some((block) => block.kind === 'mermaid'),
}
}
return {
kind: 'html',
parts: analyzeHtmlSegment(segment.text).parts.map((part): PreparedHtmlPart => {
if (part.kind === 'html') return { kind: 'html', html: sanitizeHtmlBlock(part.html, media) }
if (part.kind === 'open') return { kind: 'open', tag: part.tag, props: sanitizeTagProps(part.tag, part.attrs) }
return { kind: 'close' }
}),
}
// `media` is a memoized object in the host (TextEditor); identity tracks
// scope/path/origin changes so sanitization re-runs exactly when needed.
}), [info, media])
const nodes: ReactNode[] = []
const frames: { tag: string; props: Record<string, string> | null; children: ReactNode[] }[] = []
const emit = (node: ReactNode): void => {
const frame = frames[frames.length - 1]
if (frame !== undefined) frame.children.push(node)
else nodes.push(node)
}
let key = 0
for (const segment of prepared) {
if (segment.kind === 'markdown') {
emit(<MarkdownSegment key={`md-${key += 1}`} text={segment.text} hasMermaid={segment.hasMermaid} media={media} codeLabels={codeLabels} />)
continue
}
for (const part of segment.parts) {
if (part.kind === 'html') {
if (part.html.trim() === '') continue
emit(<HtmlLeaf key={`html-${key += 1}`} html={part.html} />)
} else if (part.kind === 'open') {
frames.push({ tag: part.tag, props: part.props, children: [] })
} else {
const frame = frames.pop()
if (frame === undefined) continue
if (frame.props === null) {
for (const child of frame.children) emit(child)
} else {
emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
}
}
}
}
while (frames.length > 0) {
const frame = frames.pop()!
if (frame.props === null) {
for (const child of frame.children) emit(child)
} else {
emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
}
}
return <>{nodes}</>
}
-27
View File
@@ -1,27 +0,0 @@
/**
* Placeholder shown when a persisted tab's type is not registered (the
* contributing plugin is not installed / not yet loaded). The tab is kept
* in state so it can recover if the plugin loads later; the user can close
* it through the tab bar's X button as usual. This is the graceful-
* degradation path for the open tab-type set: {@link Sidebar} renders this
* instead of crashing on an unknown type.
*/
import type { ReactNode } from 'react'
import { t } from './locales.ts'
import css from './sidebar.module.css'
import type { TabComponentProps } from './service.ts'
export function OrphanedTab(props: TabComponentProps): ReactNode {
const { tab } = props
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<span className={css.editorTitle} title={tab.type}>{tab.title}</span>
</div>
<div className={css.editorPlaceholder}>
<span>{t('pluginNotLoaded')}</span>
<code className={css.orphanedType}>{tab.type}</code>
</div>
</div>
)
}
-113
View File
@@ -1,113 +0,0 @@
/** Browser-native PDF preview with an always-available download fallback. */
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
export function PdfView(props: { scope: SessionScope; path: string; title: string }) {
const { scope, path, title } = props
const [load, setLoad] = useState<
| { status: 'loading' }
| { status: 'ready'; url: string }
| { status: 'error'; message: string }
>({ status: 'loading' })
const [interactionBlocked, setInteractionBlocked] = useState(false)
const frameRef = useRef<HTMLIFrameElement>(null)
const shieldRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const controller = new AbortController()
let objectUrl: string | undefined
setLoad({ status: 'loading' })
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const bytes = await response.arrayBuffer()
if (controller.signal.aborted) return
// A direct iframe navigation may download when an old host process or
// proxy cached application/octet-stream. The Blob URL owns an explicit
// PDF MIME and therefore consistently opens the browser PDF viewer.
objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
setLoad({ status: 'ready', url: objectUrl })
} catch (error) {
if (controller.signal.aborted) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
})()
return () => {
controller.abort()
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
}
// Granular scope fields: the scope object's identity churns, only its
// sessionId / cwd fields gate the fetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scope.sessionId, scope.cwd, path])
useEffect(() => {
const block = (): void => {
setInteractionBlocked(true)
if (frameRef.current !== null) frameRef.current.style.pointerEvents = 'none'
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'auto'
}
const unblock = (): void => {
setInteractionBlocked(false)
if (frameRef.current !== null) frameRef.current.style.pointerEvents = ''
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'none'
}
const blockForResize = (event: PointerEvent): void => {
const target = event.target
if (target instanceof Element
&& target.closest(`.${css.panelResize}, .${css.divider}`) !== null) {
block()
}
}
// An iframe is a separate browsing context and otherwise swallows the
// dragover/pointer stream as soon as the cursor crosses it. Temporarily
// remove it from hit-testing while a tab drag or pane resize is active.
document.addEventListener('dragstart', block, true)
document.addEventListener('dragend', unblock, true)
document.addEventListener('drop', unblock, true)
window.addEventListener('pointerdown', blockForResize, true)
window.addEventListener('pointerup', unblock, true)
window.addEventListener('pointercancel', unblock, true)
window.addEventListener('blur', unblock)
return () => {
document.removeEventListener('dragstart', block, true)
document.removeEventListener('dragend', unblock, true)
document.removeEventListener('drop', unblock, true)
window.removeEventListener('pointerdown', blockForResize, true)
window.removeEventListener('pointerup', unblock, true)
window.removeEventListener('pointercancel', unblock, true)
window.removeEventListener('blur', unblock)
}
}, [])
return (
<div className={css.editorPdf}>
<div className={css.editorPdfToolbar}>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
<div className={css.editorPdfStage}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{load.status === 'ready' && (
<iframe
ref={frameRef}
className={clsx(css.editorPdfFrame, interactionBlocked && css.editorPdfFrameBlocked)}
src={load.url}
title={title}
/>
)}
<div
ref={shieldRef}
className={clsx(css.editorPdfDragShield, interactionBlocked && css.editorPdfDragShieldActive)}
aria-hidden="true"
/>
</div>
</div>
)
}
-49
View File
@@ -1,49 +0,0 @@
/**
* The generic render error boundary for the sidebar tree: a render error in
* the wrapped subtree shows a dismissible error strip (retry re-renders the
* children) instead of blanking the shell. Used at two scopes:
*
* - ROOT (index.tsx, `css.boundaryError`): last-resort containment for
* errors in the sidebar shell itself (Workbench, drag layout, …) — a full
* swap keeps the page alive.
* - PER-TAB (Sidebar.tsx TabContent, `css.tabBoundaryError`): a crashing
* viewer/editor shows a strip inside ITS OWN pane; the workbench shell, the
* other tabs, and the panel itself stay alive (issue #31 — a tab crash
* must never take down the whole sidebar).
*
* The className prop selects the strip's geometry: the root's full-height
* fixed rail vs. the tab's pane-filling block.
*/
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { t } from './locales.ts'
import css from './sidebar.module.css'
export class RenderBoundary extends Component<{ children?: ReactNode; className?: string }, { error: string | null }> {
state = { error: null as string | null }
static getDerivedStateFromError(error: unknown): { error: string } {
return { error: error instanceof Error ? error.message : String(error) }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
}
render(): ReactNode {
if (this.state.error !== null) {
return (
<div className={this.props.className}>
<span>dsh-better-sidebar: {this.state.error}</span>
<button
type="button"
className={css.terminalRetry}
onClick={() => { this.setState({ error: null }) }}
>
{t('terminalRetry')}
</button>
</div>
)
}
return this.props.children
}
}
-60
View File
@@ -1,60 +0,0 @@
/**
* The live sandbox status row of the two built-in web surfaces (HTML
* preview and the browser tab): a green "sandbox on" state with a one-tap
* TEMPORARY unlock, or a RED "sandbox off" state (global setting or the
* temporary unlock) with a restore action.
*
* The temporary unlock is component state only — it never writes the
* global side card setting (`htmlViewerNoSandbox` / `browserNoSandbox`);
* it lasts until the surface unmounts (tab switch / file switch) or the
* user restores the sandbox from the row. When the global setting already
* drops the sandbox, no unlock/restore action is offered (changing the
* global setting is the settings page's job) — the red warning stands.
*/
import clsx from 'clsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
export function SandboxStatusBar(props: {
/** The effective sandbox state (global pref OR the local temporary unlock). */
sandboxed: boolean
/** Whether the sandbox is off due to the LOCAL temporary unlock (shows the restore action). */
local: boolean
/** The red-state explanation (e.g. "the page runs with full GUI privileges"). */
dangerCopy: string
onUnlock: () => void
onRestore: () => void
}) {
const { sandboxed, local, dangerCopy, onUnlock, onRestore } = props
if (sandboxed) {
const copy = t('sandboxStatusOn')
return (
<div className={clsx(css.sandboxStatus, css.sandboxStatusOn)}>
<span className={css.sandboxDot} />
<span className={css.sandboxStatusText} title={copy}>{copy}</span>
<button
type="button"
className={css.sandboxAction}
onClick={onUnlock}
>
{t('sandboxUnlock')}
</button>
</div>
)
}
return (
<div className={clsx(css.sandboxStatus, css.sandboxStatusOff)}>
<span className={css.sandboxDot} />
<span className={css.sandboxStatusText} title={dangerCopy}>{dangerCopy}</span>
{local && (
<button
type="button"
className={css.sandboxAction}
onClick={onRestore}
>
{t('sandboxRestore')}
</button>
)}
</div>
)
}
-960
View File
@@ -1,960 +0,0 @@
/**
* Side card settings section chrome (DSH-native tokens throughout):
* - the section is a column of GROUP CONTAINER cards (the PluginCard recipe:
* l2 hairline, 16px radius, layer-3 fill) with a heading + inventory count
* badge (the settings catalogHeading recipe), opening with a one-line
* intro (the DSH section heading+intro recipe);
* - 常规 holds full-width settings rows (the DSH settings-row recipe:
* title/desc left + control right, hairline separators) whose toggles are
* custom switches — a real checkbox (native semantics and focus) driving
* a styled track/thumb;
* - the feature inventories are responsive GRIDS of small toggle cards
* (auto-fill columns — several cards per row, wrapping): icon chip +
* title + type id, the card's on/off state IS its visual state
* (enabled = softened brand border + tinted fill + a compact brand
* switch knob at the far right — the card itself is the toggle;
* disabled = neutral hairline + dimmed, no knob); every card shares a
* uniform min-height so rows stay equal whether or not they carry a
* settings strip;
* - features that declare related settings carry a labeled SETTINGS STRIP
* across the card's bottom edge (gear icon + text, hairline-separated)
* opening a native Modal (wider than the primitive default) whose rows
* follow the same row recipe with switch controls and a Done footer;
* - the width input stays a native settings row (a value, not a switch).
*/
.section {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 760px;
/* The DSH settings shell's `.options` area owns vertical scrolling. Keep
this page a plain content column like Plugins → Plugin list so its
scrollbar stays at the shell edge instead of being inset by the options
area's right padding. */
}
/* One-line section intro (the DSH section heading+intro recipe). */
.intro {
margin: 0;
padding: 0 2px;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
}
/* Group container: the DSH card recipe (PluginCard) — l2 hairline, r16,
layer-3 fill; holds its own heading + stacked rows / card grid. */
.group {
flex: none;
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
background: var(--dsw-alias-bg-layer-3);
}
/* Group heading: 13/600 + the inventory count badge (the settings
catalogHeading recipe: count in tabular-nums, tertiary). */
.groupHeading {
display: flex;
align-items: baseline;
gap: 7px;
padding: 0 2px 6px;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-primary);
}
/* The inventory count: a soft-accent pill (the versionBadgeTag token recipe)
so the count reads as metadata, not as part of the heading text. */
.count {
padding: 1px 8px;
border-radius: 999px;
background: var(--dsw-alias-accent-soft, var(--dsw-alias-bg-layer-2));
font-size: 11px;
line-height: 16px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
font-variant-numeric: tabular-nums;
}
/* Responsive inventory: as many small cards per row as the width allows. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
/* One small toggle card (visual chrome; the interactive part is .cardMain).
A uniform min-height keeps every inventory card the same height across
rows whether or not it carries a settings strip (strip cards are
naturally taller); overflow:hidden clips the strip to the card's radius. */
.card {
position: relative;
display: flex;
flex-direction: column;
min-height: 106px;
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
font: inherit;
color: inherit;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
/* Hover affordance on disabled cards only (enabled cards are already lit). */
.card:not(.cardOn):hover {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-label-dimmed);
}
/* Enabled = highlighted: a SOFTENED brand border (color-mix, the icon-chip
precedent) + tinted fill — full-strength brand reads harsh at card scale. */
.cardOn {
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 45%, transparent);
background: var(--dsw-alias-interactive-bg-active);
}
/* The card's switch: the whole main area (top row + desc) flips the feature.
flex:1 lets the main area absorb the extra height of the uniform card
(cards without a settings strip stretch to match the strip cards). */
.cardMain {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
width: 100%;
padding: 12px;
border: 0;
border-radius: inherit;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.cardMain:focus-visible,
.cardSettings:focus-visible,
.rowGear:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
.cardTop {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 28px;
}
/* The icon sits in a rounded chip: brand-tinted while the card is on,
neutral (layer-2 fill + hairline) while off. */
.cardIconChip {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-tertiary);
}
.cardOn .cardIconChip {
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
background: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 12%, transparent);
color: var(--dsw-alias-button-primary-fill);
}
.cardTitle {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cardOn .cardTitle {
color: var(--dsw-alias-label-primary);
}
/* The compact on/off switch knob at the card's far right (enabled only):
the card itself is the toggle (aria-pressed on .cardMain), so this is a
PURELY VISUAL switch — same track/thumb geometry as the row switches,
one size down. Off cards show no knob (neutral card = off). */
.cardSwitch {
display: inline-flex;
align-items: center;
flex: none;
}
.cardSwitchTrack {
display: inline-flex;
align-items: center;
width: 30px;
height: 16px;
padding: 2px;
box-sizing: border-box;
border-radius: 8px;
background: var(--dsw-alias-bg-layer-2);
border: 1px solid var(--dsw-alias-border-l2);
transition: background 0.15s ease, border-color 0.15s ease;
}
.cardSwitchThumb {
display: block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--dsw-alias-label-tertiary);
transition: transform 0.15s ease, background 0.15s ease;
}
.cardOn .cardSwitchTrack {
border-color: var(--dsw-alias-button-primary-fill);
background: var(--dsw-alias-button-primary-fill);
}
.cardOn .cardSwitchThumb {
transform: translateX(14px);
background: var(--dsw-alias-bg-layer-3);
}
.cardDesc {
font-size: 11px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The "add plugin" card: same grid cell / chrome as the inventory cards
(it shares .card), but a DASHED border — the repo's first dashed surface;
the hover accent follows the existing drag-drop dashed outlines
(sidebar.module.css .dropCenter / .tabBarDrop). */
.addCard {
align-items: flex-start;
padding: 12px;
border-style: dashed;
border-color: var(--dsw-alias-border-l2);
text-align: left;
}
.addCard:hover {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-interactive-bg-hover-accent);
color: var(--dsw-alias-label-primary);
}
.addCard:hover .cardTitle {
color: var(--dsw-alias-label-primary);
}
.addCard:hover .cardIconChip {
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
color: var(--dsw-alias-button-primary-fill);
}
.addCard:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
.cardOn .cardDesc {
color: var(--dsw-alias-label-secondary);
}
/* The settings strip: a labeled full-width footer across the card's bottom
edge (gear icon + "功能设置" text), hairline-separated from the toggle
area. This is the SECONDARY settings entry — it must be discoverable at
rest (not a hover-only ghost), so it keeps a steady secondary-ink label
and answers hover with the brand-accent tint + brand ink. */
.cardSettings {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
border: 0;
border-top: 1px solid var(--dsw-alias-border-l1);
background: transparent;
color: var(--dsw-alias-label-secondary);
font: inherit;
font-size: 11px;
line-height: 16px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.cardOn .cardSettings {
border-top-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 18%, transparent);
}
.cardSettings:hover {
background: var(--dsw-alias-interactive-bg-hover-accent);
color: var(--dsw-alias-brand-primary);
}
/* The gear icon button on a general settings ROW (the position-compat
row's "customize the strip height" entry): a bordered chip (not a ghost)
so it reads as a control at rest; hover answers with the accent tint. */
.rowGear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.rowGear:hover {
border-color: var(--dsw-alias-interactive-bg-hover-accent);
background: var(--dsw-alias-interactive-bg-hover-accent);
color: var(--dsw-alias-brand-primary);
}
/* Settings-row recipe (the DSH General section rows): title/desc left,
control right, hairline separators (stripped on the last row). */
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 2px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.row:last-child {
border-bottom: none;
}
.rowText {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.title {
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.desc {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* Custom switch: a real checkbox (native semantics + focus, visually
hidden) driving a styled 36x20 track with a sliding thumb. */
.switch {
position: relative;
display: inline-flex;
flex: none;
cursor: pointer;
}
.switchInput {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
.switchTrack {
display: inline-flex;
align-items: center;
width: 36px;
height: 20px;
padding: 2px;
box-sizing: border-box;
border-radius: 10px;
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-2);
transition: background 0.15s ease, border-color 0.15s ease;
}
.switchThumb {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--dsw-alias-label-tertiary);
transition: transform 0.15s ease, background 0.15s ease;
}
.switch:hover .switchTrack {
border-color: var(--dsw-alias-label-dimmed);
}
.switchInput:checked + .switchTrack {
border-color: var(--dsw-alias-button-primary-fill);
background: var(--dsw-alias-button-primary-fill);
}
.switchInput:checked + .switchTrack .switchThumb {
transform: translateX(16px);
background: var(--dsw-alias-bg-layer-3);
}
.switchInput:focus-visible + .switchTrack {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
.control {
flex: none;
display: flex;
align-items: center;
gap: 6px;
}
.percentInput {
width: 76px;
}
/* Text/number rows in the settings popup: the free-form input (e.g. the
terminal font family) gets a generous width; numeric rows (font size)
keep the compact width of the general number inputs. */
.typedInput {
width: 200px;
}
.typedInputNumber {
width: 76px;
}
/* Select rows: the closed anchor is a compact button (selected option's
icon when the options carry icons, plus title and a chevron); the dropdown
itself is the primitives Menu (portal). */
.selectAnchor {
display: flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 4px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 20px;
cursor: pointer;
}
.selectAnchor:hover {
border-color: var(--dsw-alias-label-dimmed);
}
.selectAnchorIcon {
display: inline-flex;
flex: none;
}
.selectAnchorText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* One big-icon option card inside the select dropdown. */
.selectOption {
display: flex;
align-items: center;
gap: 10px;
min-width: 200px;
}
.selectOptionIcon {
display: inline-flex;
flex: none;
color: var(--dsw-alias-label-secondary);
}
.selectOptionText {
display: flex;
flex-direction: column;
min-width: 0;
}
.suffix {
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
}
/* The multi-line custom-CSS input (scheme `custom`): monospace, generous
height, resizable — same surface tokens as the typed inputs. */
.cssTextArea {
width: 100%;
box-sizing: border-box;
min-height: 120px;
padding: 8px 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code, monospace);
font-size: 12px;
line-height: 1.6;
resize: vertical;
}
.cssTextArea:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
/* Secondary settings popup: a wider dialog than the primitive default (the
dialog portals to document.body; the doubled class keeps the width rule
above the primitive's .dialog regardless of stylesheet order). */
.popupDialog.popupDialog {
width: min(460px, 100%);
}
/* Popup rows: each setting gets the same outer chrome as a plugin-list
PluginCard (l2 hairline, 12px radius, layer-3 fill). The row's title,
description and control geometry stay unchanged inside that frame.
The body scrolls INTERNALLY once a feature declares many rows (e.g.
Terminal's six) — the dialog shell and its Done footer stay put; a thin
token-driven scrollbar makes the scrollable region discoverable.
box-sizing:border-box keeps width:100% + the scrollbar gutter from
overflowing horizontally (a content-box width would add the 4px pad and
show a horizontal scrollbar); overflow-x:hidden is the belt-and-braces
guarantee that rows always FILL the width and never slide sideways. */
.popupRows {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
box-sizing: border-box;
max-height: min(52vh, 440px);
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: var(--dsw-alias-scrollbar-bg-l2, transparent) transparent;
}
.popupRows::-webkit-scrollbar {
width: 6px;
}
.popupRows::-webkit-scrollbar-thumb {
border-radius: 3px;
background: var(--dsw-alias-scrollbar-bg-l2, var(--dsw-alias-border-l2));
}
.popupRows::-webkit-scrollbar-thumb:hover {
background: var(--dsw-alias-scrollbar-hover-l2, var(--dsw-alias-label-dimmed));
}
.popupRow {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 12px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
transition: border-color 0.16s, background 0.16s;
}
.popupRow:hover {
border-color: var(--dsw-alias-label-dimmed);
}
/* The popup's Done button: the settings primary-button recipe
(PluginCard .save: label-primary fill, inverted surface text). */
.done {
appearance: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 5px 14px;
font: inherit;
font-size: 13px;
line-height: 1.5;
cursor: pointer;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
}
.done:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 1px;
}
.error {
padding: 10px 0 2px;
font-size: 12px;
line-height: 17px;
color: var(--dsw-alias-state-error-primary);
}
/* ── "Add plugin" modal ──────────────────────────────────────────────── */
/* A wide dialog like the settings popup (doubled class keeps the width
rule above the primitive's .dialog regardless of stylesheet order). */
.pluginModal.pluginModal {
width: min(560px, 100%);
}
.pluginList {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
/* The GitHub topic entry: a full-width secondary BUTTON (not a link — it
opens a real new browser tab via window.open, bypassing the sidebar
link takeover). */
.pluginTopicBtn {
width: 100%;
appearance: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 6px 12px;
font: inherit;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
cursor: pointer;
}
.pluginTopicBtn:hover {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-interactive-bg-hover-accent);
color: var(--dsw-alias-label-primary);
}
.pluginTopicBtn:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 1px;
}
.pluginEmpty {
padding: 20px 2px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* One catalog entry: name + install button on the head row, description,
then the install command in a mono block (the exact script that gets
pre-typed into the terminal — review it here before installing). */
.pluginEntry {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-1);
}
.pluginEntryHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pluginEntryActions {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
}
/* The per-entry "jump" button: opens the plugin repo in a REAL new browser
tab (window.open — a button, so the sidebar link takeover cannot reroute
it). Secondary style, quieter than the Install button. */
.pluginJumpBtn {
flex: none;
appearance: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 3px 12px;
font: inherit;
font-size: 12px;
line-height: 1.5;
cursor: pointer;
background: transparent;
color: var(--dsw-alias-label-secondary);
}
.pluginJumpBtn:hover {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-interactive-bg-hover-accent);
color: var(--dsw-alias-label-primary);
}
.pluginJumpBtn:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 1px;
}
/* The plugin name: a BUTTON (not an anchor — an anchor would be caught by
the sidebar link takeover at document capture, ignoring target=_blank)
styled like a link; opens the repo in a real new browser tab. */
.pluginName {
appearance: none;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
font: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-primary);
text-align: left;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.pluginName:hover {
text-decoration: underline;
color: var(--dsw-alias-button-primary-fill);
}
.pluginDesc {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
}
.pluginInstall {
display: block;
padding: 6px 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 16px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-2);
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 8px;
overflow-x: auto;
white-space: nowrap;
}
/* The per-entry copy button: copies the install script to the
clipboard (the settings primary-button recipe, smaller than .done). */
.pluginCopyBtn {
flex: none;
appearance: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 3px 12px;
font: inherit;
font-size: 12px;
line-height: 1.5;
cursor: pointer;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
}
.pluginCopyBtn:hover {
background: var(--dsw-alias-button-primary-hover);
}
.pluginCopyBtn:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 1px;
}
@media (prefers-reduced-motion: reduce) {
.card,
.cardSettings,
.cardSwitchTrack,
.cardSwitchThumb,
.rowGear,
.popupRow,
.switchTrack,
.switchThumb {
transition: none;
}
}
/* The managing plugin's identity badge: name + version pill, token-driven
(labels via --dsw-alias-label-*, surface via --dsw-alias-bg-layer-2). */
.versionBadge {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 4px 12px 4px 14px;
border-radius: 999px;
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-2);
font-size: 12px;
line-height: 18px;
}
.versionBadgeName {
color: var(--dsw-alias-label-primary);
font-weight: 600;
}
.versionBadgeTag {
padding: 1px 8px;
border-radius: 999px;
background: var(--dsw-alias-accent-soft, var(--dsw-alias-border-l2));
color: var(--dsw-alias-label-secondary);
font-variant-numeric: tabular-nums;
}
/* Catalog search input: a compact text field above the list (token-driven).
The entries list below it scrolls INDEPENDENTLY — with many plugins the
modal stays navigable instead of growing unbounded. */
.pluginSearch {
width: 100%;
box-sizing: border-box;
appearance: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 6px 10px;
font: inherit;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
}
.pluginSearch::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.pluginSearch:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 1px;
}
/* The entries area scrolls within the modal once the catalog grows past
the viewport (max-height + own scrollbar keeps the modal shell fixed). */
.pluginEntries {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 46vh;
overflow-y: auto;
overflow-x: hidden;
padding-right: 2px;
}
/* One catalog group: optional category heading above its stacked entries. */
.pluginGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.pluginGroupHeading {
font-size: 12px;
line-height: 18px;
font-weight: 600;
color: var(--dsw-alias-label-secondary);
padding: 2px 2px 0;
}
/* Open-with settings panel: one custom editor row (name + URL template +
VSCode-family checkbox + remove) and the row-validity hint. */
.openWithEditorRow {
display: grid;
grid-template-columns: 1fr 1.5fr auto auto;
gap: 8px;
align-items: center;
min-width: 0;
}
.openWithEditorInput,
.openWithEditorTemplate {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 5px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 13px;
line-height: 20px;
}
.openWithEditorInput:focus-visible,
.openWithEditorTemplate:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.openWithFamily {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
cursor: pointer;
}
.openWithRemove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
border-radius: 8px;
background: none;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.openWithRemove:hover {
color: var(--dsw-alias-state-error-primary);
background: var(--dsw-alias-bg-layer-2);
}
.openWithHint {
font-size: 12px;
line-height: 17px;
color: var(--dsw-alias-state-error-primary);
padding: 0 2px;
}
File diff suppressed because it is too large Load Diff
-466
View File
@@ -1,466 +0,0 @@
/* Side Chat tab: ONE side conversation per tab (Codex-style). Visual
* language mirrors the main conversation pane: right-aligned user bubbles
* on --dsw-specific-bubble, full-width assistant markdown, a capsule
* composer card on --dsw-specific-input-major with a circular accent
* send/stop button. Tool calls, thinking and context injections share one
* collapsible-line chrome (no cards); shimmer is reserved for generating
* states (running status line, streaming rows, the creating hero). Motion
* is crossfade-only (≤200ms entrances, no springs); reduced-motion disables
* every loop. All values consume DSW tokens (fallbacks point at older alias
* tokens for rc-era hosts). */
.sidechat {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* ── thread header (status dot · agent badge · threads menu · save) ─── */
.sidechatDetailHeader {
flex: none;
display: flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 4px 8px 4px 12px;
border-bottom: 1px solid var(--dsw-alias-hairline);
}
.sidechatHeaderDot {
flex: none;
}
.sidechatHeaderSpacer {
flex: 1;
min-width: 0;
}
.sidechatAgentBadge {
flex: none;
max-width: 55%;
padding: 1px 8px;
border: 1px solid var(--dsw-alias-hairline);
border-radius: 999px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidechatIconBtn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
transition: background-color 100ms ease-out, color 100ms ease-out;
}
.sidechatIconBtn:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.sidechatIconBtn:disabled {
opacity: 0.4;
cursor: default;
}
/* ── hero (unbound tab: the one brand moment) ───────────────────────── */
.sidechatHero {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 20px;
color: var(--dsw-alias-label-tertiary);
text-align: center;
animation: sidechatFadeIn 200ms ease-out;
}
.sidechatHeroTitle {
font: var(--dsw-font-s-14);
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.sidechatHeroDesc {
max-width: 300px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
line-height: 1.6;
}
.sidechatPrimaryBtn {
flex: none;
margin-top: 4px;
padding: 6px 14px;
border: none;
border-radius: 999px;
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
font: var(--dsw-font-s-13);
cursor: pointer;
transition: opacity 100ms ease-out;
}
.sidechatPrimaryBtn:hover:not(:disabled) {
opacity: 0.88;
}
.sidechatPrimaryBtn:disabled {
opacity: 0.4;
cursor: default;
}
.sidechatHint {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.sidechatError {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-danger);
}
/* ── transcript ─────────────────────────────────────────────────────── */
.sidechatScroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Rows crossfade in on append (keys are stable, so only NEW rows play). */
.sidechatScroll > * {
animation: sidechatRowIn 180ms ease-out;
}
/* User message: right-aligned bubble, same surface as the main pane. */
.sidechatUser {
align-self: flex-end;
max-width: 88%;
padding: 8px 14px;
border-radius: 18px;
background: var(--dsw-specific-bubble, var(--dsw-alias-bg-base));
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
}
/* Assistant message: full-width narration, no bubble — the main pane way. */
.sidechatAssistant {
align-self: stretch;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
}
/* ── collapsible context rows (tool / thinking / injection, Codex-style) ──
* One quiet line — chevron + label + one-line summary — that expands into
* an indented body hung on a hairline thread. No cards, no fills: hierarchy
* rides ink weight alone. Streaming rows shimmer the label (shimmer =
* generating); failed rows go danger; reduced-motion stills every loop. */
.sidechatRow {
align-self: stretch;
}
.sidechatRowLine {
display: flex;
align-items: center;
gap: 6px;
padding: 1px 0;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.sidechatRowSummary {
cursor: pointer;
list-style: none;
user-select: none;
}
.sidechatRowSummary::-webkit-details-marker {
display: none;
}
.sidechatRowSummary:hover {
color: var(--dsw-alias-label-secondary);
}
.sidechatRowStatic {
cursor: default;
}
.sidechatRowChevron {
flex: none;
display: inline-flex;
align-items: center;
transition: transform 100ms ease-out;
}
.sidechatRow[open] .sidechatRowChevron {
transform: rotate(90deg);
}
/* Tool-kind leading glyph (the main conversation's 16px slot; the icon
* itself renders at 14). Failed rows pass a StateDot instead. */
.sidechatRowIcon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--dsw-alias-label-tertiary);
}
.sidechatRowLabel {
flex: none;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
}
.sidechatRowMono {
font-family: var(--dsw-font-mono);
}
.sidechatRowMeta {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--dsw-font-mono);
color: var(--dsw-alias-label-tertiary);
}
.sidechatRowFailed .sidechatRowLabel,
.sidechatRowFailed .sidechatRowMeta {
color: var(--dsw-alias-danger);
}
.sidechatRowBody {
margin: 2px 0 4px 7px;
padding: 2px 0 2px 10px;
border-left: 1px solid var(--dsw-alias-hairline);
}
.sidechatRowProse {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 240px;
overflow-y: auto;
}
.sidechatRowCode {
margin: 0;
padding: 4px 0;
font: var(--dsw-font-xxs-12);
font-family: var(--dsw-font-mono);
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 220px;
overflow-y: auto;
}
.sidechatRowCode + .sidechatRowCode {
border-top: 1px solid var(--dsw-alias-hairline);
}
/* Host Block atoms (terminal/diff/read) inside a row body draw their own
* chrome; only a hair of breathing room is added on top. */
.sidechatRowBody > * {
margin-top: 2px;
}
/* Turn-tail metrics line: quiet, right-aligned, ink-weight only. */
.sidechatTurnSummary {
align-self: flex-end;
max-width: 88%;
padding: 0 2px;
font: var(--dsw-font-xxs-12);
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-tertiary);
user-select: none;
}
/* Shimmer sweep text: generating (streaming row labels, creating hero). */
.sidechatShimmerText {
background-image: linear-gradient(
90deg,
var(--dsw-alias-label-tertiary) 0%,
var(--dsw-alias-label-primary) 50%,
var(--dsw-alias-label-tertiary) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sidechatSweep 2.6s linear infinite;
}
/* ── running status line (above the composer, main-pane style) ──────── */
.sidechatStatus {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 2px 14px 6px;
animation: sidechatFadeIn 160ms ease-out;
}
.sidechatStatusText {
font: var(--dsw-font-xxs-12);
background-image: linear-gradient(
90deg,
var(--dsw-alias-label-tertiary) 0%,
var(--dsw-alias-label-primary) 50%,
var(--dsw-alias-label-tertiary) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sidechatSweep 2.6s linear infinite;
}
/* ── composer (capsule card, InputBar-aligned) ──────────────────────── */
.sidechatComposer {
flex: none;
display: flex;
flex-direction: column;
gap: 4px;
margin: 0 8px 8px;
padding: 8px 8px 6px 14px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-hairline));
border-radius: 16px;
background: var(--dsw-specific-input-major, var(--dsw-alias-bg-base));
box-shadow: var(--dsw-shadow-lv2, none);
}
.sidechatComposerInput {
width: 100%;
box-sizing: border-box;
padding: 2px 0;
border: none;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-s-14);
line-height: 22px;
outline: none;
resize: none;
max-height: 132px;
}
.sidechatComposerInput::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.sidechatComposerBar {
flex: none;
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
}
.sidechatComposerMeta {
flex: 1;
min-width: 0;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidechatSendBtn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
cursor: pointer;
/* send ⇄ stop swap: keyed remount plays this, hover only fades. */
animation: sidechatBtnIn 120ms ease-out;
transition: opacity 100ms ease-out;
}
.sidechatSendBtn:hover:not(:disabled) {
opacity: 0.88;
}
.sidechatSendBtn:disabled {
opacity: 0.35;
cursor: default;
}
/* ── motion vocabulary (crossfade only, ≤200ms; loops reduced-motion off) */
@keyframes sidechatRowIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sidechatFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes sidechatBtnIn {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
@keyframes sidechatSweep {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.sidechatScroll > *,
.sidechatHero,
.sidechatStatus,
.sidechatSendBtn {
animation: none;
}
.sidechatStatusText,
.sidechatShimmerText {
animation: none;
background-image: none;
color: var(--dsw-alias-label-tertiary);
}
.sidechatRowChevron {
transition: none;
}
}
-812
View File
@@ -1,812 +0,0 @@
/**
* Side Chat page: Codex-style side conversations for the current session.
*
* EVERY side conversation is its own sidebar tab (侧边对话1/2/3 …): the
* descriptor's createTab mints a fresh tab flagged `autoCreate` and this
* view creates the EMPTY thread on mount (one click = one conversation,
* exactly like the Codex app); the composer owns the first message (the
* host wraps it with the side boundary + the in-progress snapshot parked
* at creation, and the thread earns its real label — and the tab its
* title — from that first message). Closing the tab releases the thread's
* live agent (its history stays persisted); the header menu reopens any
* existing thread into a tab (deduped by threadId).
*
* Each side thread is a child session the plugin created itself with a
* custom seed (the parent's full log up to the click moment — see
* sidechat-core.ts). Transport: EVERY thread operation — creation,
* follow-up, cancel, dispose, info, and the transcript itself — goes
* through the plugin's own /sidebar/api sidechat.* routes (subagent-origin
* identities are fenced from the generic session RPCs, and DSH
* 0.1.2-alpha.1's Remote-gateway migration removed the client
* session-history face the transcript used to poll). The transcript route
* cuts the inherited seed host-side and answers afterSeq deltas; the
* mapping (boundary row dropped, chunk streaming accumulated) lives in
* sidechat-transcript.ts.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
ConnectionIndicator,
DiffBlock,
IconApiOutline14,
IconBrowseOutline16,
IconChevronRightOutline14,
IconEditOutline16,
IconNewChatOutline16,
IconPlusOutline16,
IconSearchOutline16,
IconSendOutline16,
IconSparkle16,
IconStopFill16,
MarkdownText,
Menu,
ReadBlock,
StateDot,
TerminalBlock,
type DiffBlockLabels,
type MenuEntry,
type ReadBlockLabels,
type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
import type { Context, SidebarHistoryEntry, SidebarSessionEvent } from '../context-types.ts'
import {
SIDE_LABEL_PREFIX,
SIDE_NEW_THREAD_TITLE,
sideThreadRows,
threadHasCompletedTurn,
threadTrailingPending,
type SidechatLiveEvent,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import {
formatDurationMs,
formatTokens,
toolArgsSummary,
transcriptRows,
type SidechatToolCard,
type SidechatTranscriptRow,
} from './sidechat-transcript.ts'
import { api } from './api.ts'
import { usePolling } from './use-polling.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import type { SidebarTab } from './state.ts'
import css from './SideChatView.module.css'
/** Poll cadence while the selected thread is running and the tab visible. */
const POLL_MS = 2000
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
const COMPOSER_MAX_HEIGHT = 132
/** The thread a tab is bound to (durable in tab.meta across refreshes). */
export function sidechatThreadIdOf(tab: SidebarTab): string | undefined {
const meta = tab.meta as { threadId?: unknown } | undefined
return typeof meta?.threadId === 'string' ? meta.threadId : undefined
}
/** The parked reopen target consumed by the descriptor's createTab (the
* service's createTab receives no seed, so a thread-switch parks the id
* here and openTab picks it up synchronously — exactly one consume per
* park). */
let parkedReopen: string | undefined
/** Park a thread id for the NEXT sidechat openTab to reattach. */
export function parkSidechatReopen(threadId: string): void {
parkedReopen = threadId
}
/** Consume the parked reopen target (undefined = mint a fresh thread tab). */
export function consumeSidechatSeed(): string | undefined {
const value = parkedReopen
parkedReopen = undefined
return value
}
/** In-flight thread creations keyed by tab id (double-mount guard: React
* StrictMode / HMR must not mint two threads for one tab). */
const inFlightStarts = new Set<string>()
/** Per-thread transcript cache: thread-own events merged by seq (polls ride
* the afterSeq delta and never re-download what they already hold). */
interface ThreadCache {
entries: SidebarHistoryEntry[]
/** The CURRENT attempt's live deltas (replaced every pull, never merged). */
live: SidechatLiveEvent[]
}
/** Row-render labels (locale-dependent, memoized once per mount). */
interface RowLabels {
copyLabel: string
copiedLabel: string
thinkLabel: string
injectionLabel: string
terminal: TerminalBlockLabels
diff: DiffBlockLabels
read: ReadBlockLabels
}
/** Merge history entries by event seq (newest wins), log order preserved. */
function mergeBySeq(
previous: readonly SidebarHistoryEntry[],
incoming: readonly SidebarHistoryEntry[],
): SidebarHistoryEntry[] {
const bySeq = new Map<number, SidebarHistoryEntry>()
for (const entry of previous) bySeq.set(entry.event.seq, entry)
for (const entry of incoming) bySeq.set(entry.event.seq, entry)
return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq)
}
/** The display title of a thread: the durable label minus the 'Side: '
* prefix, with the fresh-thread placeholder localized. */
function threadDisplayTitle(title: string): string {
if (title === SIDE_NEW_THREAD_TITLE) return t('sideChatUntitled')
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
}
/**
* One collapsible context row — the shared Codex-style chrome of tool
* calls, thinking and context injections: a single quiet line (chevron +
* label + one-line summary) that expands into an indented body hung on a
* hairline thread. Rows with nothing to reveal render as a static line.
*/
function CollapsibleRow(props: {
label: string
meta?: string
mono?: boolean
streaming?: boolean
failed?: boolean
/** 16px leading glyph (tool-kind icon, the main conversation's row head). */
icon?: React.ReactNode
children?: React.ReactNode
}): React.ReactNode {
const leading = props.icon === undefined ? null : (
<span className={css.sidechatRowIcon}>{props.icon}</span>
)
const label = (
<span
className={clsx(
css.sidechatRowLabel,
props.mono === true && css.sidechatRowMono,
props.streaming === true && css.sidechatShimmerText,
)}
>
{props.label}
</span>
)
const meta = props.meta !== undefined && props.meta !== ''
? <span className={css.sidechatRowMeta}>{props.meta}</span>
: null
if (props.children === undefined) {
return (
<div className={clsx(css.sidechatRowLine, css.sidechatRowStatic, props.failed === true && css.sidechatRowFailed)}>
{leading}
{label}
{meta}
</div>
)
}
return (
<details className={css.sidechatRow}>
<summary
className={clsx(
css.sidechatRowLine,
css.sidechatRowSummary,
props.failed === true && css.sidechatRowFailed,
)}
>
<span className={css.sidechatRowChevron}>
<IconChevronRightOutline14 size={12} />
</span>
{leading}
{label}
{meta}
</summary>
<div className={css.sidechatRowBody}>{props.children}</div>
</details>
)
}
/** The host Block body for a structured tool card (main-conversation atoms:
* terminal surface, diff hunks, line-numbered read window). */
function toolCardBody(card: SidechatToolCard, executing: boolean, labels: RowLabels): React.ReactNode {
if (card.type === 'terminal') {
return (
<TerminalBlock
command={card.command}
cwd={card.cwd}
output={card.output}
exitCode={card.exitCode}
signal={card.signal}
running={executing}
labels={labels.terminal}
/>
)
}
if (card.type === 'diff') {
return <DiffBlock diffs={card.diffs} labels={labels.diff} />
}
return <ReadBlock label={card.label} lines={card.lines} totalLines={card.totalLines} lang={card.lang} labels={labels.read} />
}
/** The tool row's 16px leading slot, the way the main conversation draws it
* (GenericToolCard's variant table): the tool-kind glyph at 14, replaced by
* an error StateDot on failed rows. */
function toolLeading(name: string, failed: boolean): React.ReactNode {
if (failed) return <StateDot state="error" />
switch (name) {
case 'bash':
case 'pwsh':
return <IconApiOutline14 size={14} />
case 'read':
case 'web_fetch':
return <IconBrowseOutline16 size={14} />
case 'edit':
case 'write':
return <IconEditOutline16 size={14} />
case 'grep':
case 'glob':
case 'web_search':
return <IconSearchOutline16 size={14} />
default:
return <IconSparkle16 size={14} />
}
}
/** One row renderer (React keys ride the source event seq). */
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
switch (row.kind) {
case 'user':
return (
<div key={`${row.kind}:${row.seq}`} className={css.sidechatUser}>
<MarkdownText {...markdownTextProps(row.text, labels)} />
</div>
)
case 'assistant':
return (
<div key={`${row.kind}:${row.seq}`} className={css.sidechatAssistant}>
<MarkdownText {...markdownTextProps(row.text, labels)} />
</div>
)
case 'reasoning':
return (
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
label={labels.thinkLabel}
streaming={!row.settled}
>
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'injection':
return (
<CollapsibleRow key={`${row.kind}:${row.seq}`} label={labels.injectionLabel}>
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'turnSummary': {
// Quiet turn-tail metrics: token usage + wall duration, main-pane
// StatsLine formatting. Parts appear only when computable.
const parts: string[] = []
if (row.inputTokens !== undefined && row.outputTokens !== undefined) {
parts.push(t('sideChatTurnUsage', { input: formatTokens(row.inputTokens), output: formatTokens(row.outputTokens) }))
}
if (row.durationMs !== undefined) parts.push(formatDurationMs(row.durationMs))
if (parts.length === 0) return null
return (
<div key={`${row.kind}:${row.seq}`} className={css.sidechatTurnSummary}>
{parts.join(' · ')}
</div>
)
}
case 'tool': {
const body = row.card !== undefined
? toolCardBody(row.card, row.executing === true, labels)
: (
<>
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
</>
)
return (
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
label={row.name}
meta={toolArgsSummary(row.args)}
icon={toolLeading(row.name, row.failed)}
mono
streaming={row.executing === true}
failed={row.failed}
{...(row.args === undefined && row.resultText === undefined && row.card === undefined ? {} : { children: body })}
/>
)
}
}
}
/** One side conversation tab (one thread per tab, Codex-style). */
export function SideChatView(props: {
ctx: Context
scope: SessionScope
tab: SidebarTab
visible: boolean
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
const rowLabels = useMemo<RowLabels>(() => {
// Shared Block chrome: copy buttons reuse the sidebar's copy pair, the
// collapse/expand family is common to every Block kind.
const shared = {
copy: t('copy'),
copied: t('copied'),
collapse: t('sideChatBlockCollapse'),
collapseAria: t('sideChatBlockCollapseAria'),
expand: (hidden: number) => t('sideChatBlockExpand', { hidden }),
expandAria: (hidden: number) => t('sideChatBlockExpandAria', { hidden }),
}
return {
copyLabel: t('copy'),
copiedLabel: t('copied'),
thinkLabel: t('sideChatThink'),
injectionLabel: t('sideChatInjection'),
terminal: {
...shared,
signal: (signal: string) => t('sideChatBlockSignal', { signal }),
exitCode: (exitCode: number) => t('sideChatBlockExitCode', { code: exitCode }),
running: t('sideChatBlockRunning'),
failed: t('sideChatBlockFailed'),
done: t('sideChatBlockDone'),
noOutput: t('sideChatBlockNoOutput'),
},
diff: { ...shared, files: (count: number) => t('sideChatBlockFiles', { count }) },
read: { ...shared, window: (shown: number, total: number) => t('sideChatBlockWindow', { shown, total }) },
}
}, [])
// The session list feed: thread rows (the header menu) + running states.
const list = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
)
const threads = useMemo(
() => sideThreadRows(list.byId, scope.sessionId),
[list, scope.sessionId],
)
// The thread this tab is bound to rides tab.meta (refresh-restored).
const threadId = sidechatThreadIdOf(tab)
const autoCreate = (tab.meta as { autoCreate?: unknown } | undefined)?.autoCreate === true
const [composer, setComposer] = useState('')
const [busy, setBusy] = useState<'starting' | 'sending' | 'saving' | null>(null)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [revision, setRevision] = useState(0)
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ entries: [], live: [] })
// The previous poll's rows (see the mapping's reuse pass below).
const prevRowsRef = useRef<SidechatTranscriptRow[]>([])
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
const summary = threadId === undefined ? undefined : list.byId[threadId]
const running = summary?.running === true
// Connection recovery state (DSH 0.1.2-alpha.2+): drives the disconnect
// banner and an immediate catch-up pull when the wire comes back. The poll
// loop itself stays silent on wire failures; absent service (older host)
// reads as `undefined` = never show the banner.
const connectionState = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.connection?.state.subscribe(callback) ?? (() => {}), [ctx]),
useCallback(() => ctx.connection?.state.getSnapshot(), [ctx]),
)
/** The agent-identity badge of the thread header (preset · model). */
const agentBadge = useMemo(() => {
if (info === null) return ''
return [info.preset, info.model ?? info.provider].filter(Boolean).join(' · ')
}, [info])
/** Create this tab's thread (immediate-create tabs and hero retries). */
const startThread = useCallback(async (): Promise<void> => {
if (inFlightStarts.has(tab.id)) return
inFlightStarts.add(tab.id)
setBusy('starting')
setError(null)
try {
const { childId } = await api.sidechatStart(scope.sessionId)
ctx.get('betterSidebar')?.updateTab(tab.id, { meta: { threadId: childId } })
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
inFlightStarts.delete(tab.id)
setBusy(null)
}
}, [ctx, scope.sessionId, tab.id])
// Codex-style immediate create: an autoCreate tab spawns its thread as
// soon as it first renders.
useEffect(() => {
if (threadId !== undefined || !autoCreate || !visible) return
void startThread()
}, [threadId, autoCreate, visible, startThread])
// The tab title follows the thread's durable label (the first prompt
// renames the thread; the strip picks it up here).
useEffect(() => {
const display = summary?.displayTitle
if (display === undefined) return
const title = threadDisplayTitle(display)
if (title !== '' && title !== tab.title) {
try {
ctx.get('betterSidebar')?.updateTab(tab.id, { title })
} catch {
// A stale title is cosmetic; the thread keeps working.
}
}
}, [summary, tab.id, tab.title, ctx])
/** One transcript pull: the thread's own events beyond the cached tail
* (first attach = the whole seed-cut slice; polls = afterSeq deltas),
* merged by seq. */
const fetchThread = useCallback(async (childId: string): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
try {
const cache = cacheRef.current
const afterSeq = cache.entries.at(-1)?.event.seq
const { events, live } = await api.sidechatEvents(childId, afterSeq, controller.signal)
// The live set is the CURRENT attempt, so it replaces whatever the
// previous pull held; a settled step drops out of the host buffer and
// its durable assistant/message takes over in the mapping.
cache.live = live ?? []
if (events.length > 0) {
// Wire events arrive as parsed JSON; the mirror narrows data to the
// record the mapping reads.
const incoming = events.map(event => ({ event: event as SidebarSessionEvent }))
cache.entries = mergeBySeq(cache.entries, incoming)
}
setRevision(value => value + 1)
} catch {
// Aborted by a newer pull or a wire failure: keep the last rows.
}
}, [])
/** The thread header badge pull (live state + preset/model identity). */
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
try {
setInfo(await api.sidechatInfo(childId))
} catch {
// The badge is decorative; a wire failure keeps the last value.
}
}, [])
// Reset the transcript cache whenever the binding changes, then focus
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { entries: [], live: [] }
prevRowsRef.current = []
controllerRef.current?.abort()
setError(null)
setSaved(false)
setInfo(null)
if (threadId !== undefined) {
void fetchInfo(threadId)
window.setTimeout(() => composerRef.current?.focus(), 0)
}
}, [threadId, fetchInfo])
// One transcript pull on every input change (attach, visibility flip,
// run-state flip — the last one catches a thread's terminal state once it
// stops running).
useEffect(() => {
if (!visible || threadId === undefined) return
void fetchThread(threadId)
// `running` is not read here, but re-triggering this pull on run-state
// flips is load-bearing (see above); the badge fetch rides the ticks.
}, [visible, threadId, running, fetchThread])
// Poll while the tab is visible and the thread runs: transcript deltas +
// badge refresh on a fixed cadence. Each pull self-guards (fetchThread
// aborts its predecessor; a late settle keeps the last rows).
const pollTick = useCallback(async (): Promise<void> => {
if (threadId === undefined) return
void fetchThread(threadId)
void fetchInfo(threadId)
}, [threadId, fetchThread, fetchInfo])
usePolling(visible && running && threadId !== undefined, pollTick, { intervalMs: POLL_MS })
useEffect(() => () => { controllerRef.current?.abort() }, [])
// When the wire recovers (disconnected → connected), pull immediately
// instead of waiting for the next poll tick — or, on an idle thread that
// stopped polling, forever.
const prevConnectionRef = useRef(connectionState)
useEffect(() => {
const previous = prevConnectionRef.current
prevConnectionRef.current = connectionState
if (previous === 'disconnected' && connectionState === 'connected' && threadId !== undefined) {
void fetchThread(threadId)
void fetchInfo(threadId)
}
}, [connectionState, threadId, fetchThread, fetchInfo])
// The previous poll's rows ride into the mapping so unchanged rows keep
// their object identity (see reuseRows): the 2s poll re-renders only the
// changed tail instead of re-parsing markdown for the whole transcript.
// The live deltas are appended AFTER the durable events (they are the
// in-flight tail), and a delta whose step already settled durably is
// dropped: the durable message is authoritative and would otherwise render
// twice.
const rows = useMemo(() => {
const cache = cacheRef.current
const settled = new Set(
cache.entries
.filter(entry => entry.event.type === 'assistant/message')
.map(entry => {
const data = entry.event.data as { turn?: unknown; step?: unknown }
return `${String(data.turn)}:${String(data.step)}`
}),
)
const live = cache.live
.filter(event => !settled.has(`${String(event.data.turn)}:${String(event.data.step)}`))
.map(event => ({ event: event as SidebarSessionEvent }))
const next = threadId === undefined ? [] : transcriptRows([...cache.entries, ...live], prevRowsRef.current)
prevRowsRef.current = next
return next
},
// The cache is a ref; revision bumps on every successful pull.
// eslint-disable-next-line react-hooks/exhaustive-deps
[threadId, revision],
)
const canSave = threadId !== undefined && threadHasCompletedTurn(cacheRef.current.entries)
const trailingPending = threadId !== undefined && threadTrailingPending(cacheRef.current.entries)
const freshThread = threadId !== undefined && rows.length === 0
// Follow the stream: stick to the bottom while the log grows.
useEffect(() => {
const scroller = scrollRef.current
if (scroller === null) return
scroller.scrollTop = scroller.scrollHeight
}, [rows.length, threadId])
/** Open a NEW thread tab (createTab mints the autoCreate tab; its view
* creates the thread on mount). */
const openNewThread = (): void => {
setMenuOpen(false)
ctx.get('betterSidebar')?.openTab({ type: 'sidechat' }, scope)
}
/** Switch to an existing thread: parked for createTab, deduped to the
* already-open tab when there is one. */
const openExistingThread = (id: string): void => {
setMenuOpen(false)
if (id === threadId) return
parkSidechatReopen(id)
ctx.get('betterSidebar')?.openTab({ type: 'sidechat' }, scope)
}
const menuItems = useMemo<MenuEntry[]>(() => {
const items: MenuEntry[] = [
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutline16 /> },
]
if (threads.length > 0) {
items.push({ type: 'separator', id: '$sep' })
for (const row of threads) {
items.push({
id: row.id,
label: threadDisplayTitle(row.title),
...(row.running ? { icon: <StateDot state="ongoing" size={8} /> } : {}),
})
}
}
return items
}, [threads])
const growComposer = (): void => {
const field = composerRef.current
if (field === null) return
field.style.height = '0px'
field.style.height = `${Math.min(field.scrollHeight, COMPOSER_MAX_HEIGHT)}px`
}
const handleSend = async (): Promise<void> => {
const text = composer.trim()
if (text === '' || threadId === undefined || busy !== null) return
setBusy('sending')
setError(null)
try {
await api.sidechatPrompt(threadId, text)
setComposer('')
const field = composerRef.current
if (field !== null) field.style.height = ''
void fetchThread(threadId)
void fetchInfo(threadId)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
setBusy(null)
}
}
const handleCancel = async (): Promise<void> => {
if (threadId === undefined || busy !== null) return
try {
await api.sidechatCancel(threadId)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
}
}
const handleSave = async (): Promise<void> => {
if (threadId === undefined || !canSave || busy !== null) return
setBusy('saving')
setError(null)
setSaved(false)
try {
// NOTE: fork must stay a METHOD call — `ctx.sessions.fork` is the
// client-runtime sessions service, and an unbound reference loses
// `this` (its fork reads this.list for the title bump).
if (ctx.sessions.fork === undefined) throw new Error('session fork is unavailable')
const newId = await ctx.sessions.fork({ sessionId: threadId, increaseTitle: true })
const title = summary === undefined ? '' : threadDisplayTitle(summary.displayTitle).trim()
const binding = ctx.sessions.binding?.(newId)
if (binding !== undefined && title !== '') {
await binding.session.rename(title)
}
ctx.sessions.open?.(newId)
setSaved(true)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
setBusy(null)
}
}
// ── unbound tab: the hero (fresh autoCreate tabs flash a creating state
// until the thread lands; legacy persisted tabs offer a manual start) ──
if (threadId === undefined) {
return (
<div className={css.sidechat}>
<div className={css.sidechatHero}>
<IconNewChatOutline16 />
<div
className={clsx(
css.sidechatHeroTitle,
busy === 'starting' && css.sidechatShimmerText,
)}
>
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
</div>
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
{busy !== 'starting' && (
<button
type="button"
className={css.sidechatPrimaryBtn}
onClick={() => void startThread()}
>
{error === null ? t('sideChatNew') : t('sideChatRetry')}
</button>
)}
</div>
</div>
)
}
return (
<div className={css.sidechat}>
<div className={css.sidechatDetailHeader}>
{running && <StateDot state="ongoing" size={8} className={css.sidechatHeaderDot} />}
{agentBadge !== '' && <span className={css.sidechatAgentBadge}>{agentBadge}</span>}
<span className={css.sidechatHeaderSpacer} />
<Menu
open={menuOpen}
anchor={(
<button
type="button"
className={css.sidechatIconBtn}
onClick={() => { setMenuOpen(value => !value) }}
title={t('sideChatThreads')}
>
<IconHistoryOutline16 />
</button>
)}
items={menuItems}
selectedId={threadId}
onSelect={(id) => { if (id === '$new') openNewThread(); else openExistingThread(id) }}
onClose={() => { setMenuOpen(false) }}
align="end"
portal
dense
/>
<button
type="button"
className={css.sidechatIconBtn}
onClick={() => void handleSave()}
disabled={!canSave || busy !== null}
title={`${t('sideChatSave')} — ${t('sideChatSaveTitle')}`}
>
<IconSaveOutline16 />
</button>
</div>
{connectionState !== undefined && connectionState !== 'connected' && (
<ConnectionIndicator
state={connectionState}
disconnectedLabel={t('sideChatConnDisconnected')}
reconnectLabel={t('sideChatConnReconnect')}
connectingLabel={t('sideChatConnConnecting')}
recoveredLabel={t('sideChatConnRecovered')}
reconnectActionLabel={t('sideChatConnReconnectAction')}
restartActionLabel={t('sideChatConnRestartAction')}
onReconnect={() => { ctx.connection?.reconnect() }}
/>
)}
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
{canSave && trailingPending
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}
{saved && <div className={css.sidechatHint}>{t('sideChatSaved')}</div>}
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
<div ref={scrollRef} className={css.sidechatScroll}>
{rows.map(row => renderRow(row, rowLabels))}
</div>
{running && (
<div className={css.sidechatStatus}>
<StateDot state="ongoing" size={8} />
<span className={css.sidechatStatusText}>{t('sideChatThinking')}</span>
</div>
)}
<div className={css.sidechatComposer}>
<textarea
ref={composerRef}
className={css.sidechatComposerInput}
value={composer}
placeholder={freshThread ? t('sideChatFirstPlaceholder') : t('sideChatComposerPlaceholder')}
rows={1}
onChange={event => {
setComposer(event.target.value)
growComposer()
}}
onKeyDown={event => {
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
event.preventDefault()
void handleSend()
}}
/>
<div className={css.sidechatComposerBar}>
<span className={css.sidechatComposerMeta}>
{running ? '' : agentBadge}
</span>
{running ? (
<button
key="stop"
type="button"
className={css.sidechatSendBtn}
onClick={() => void handleCancel()}
disabled={busy !== null}
title={t('sideChatCancelTitle')}
>
<IconStopFill16 />
</button>
) : (
<button
key="send"
type="button"
className={css.sidechatSendBtn}
onClick={() => void handleSend()}
disabled={composer.trim() === '' || busy !== null}
title={t('sideChatSend')}
>
<IconSendOutline16 />
</button>
)}
</div>
</div>
</div>
)
}
-814
View File
@@ -1,814 +0,0 @@
/**
* The sidebar shell: ONE surface — the bottom workbench docked to the
* conversation column.
*
* DSH 0.1.5 owns the right column: its native Sidebar hosts every tab type
* this plugin registers (client/native/), so this shell renders no right
* panel of its own. What remains plugin-owned is the bottom workbench: it
* spans ONLY the AppFrame's center column (the agent output area), from the
* app shell's own left sidebar to the details column's left edge, so neither
* sidebar gives up any position. Its height drags from its top edge, and the
* expand/collapse control is a header button registered into DSH's
* `conversation.session.header.utilities` list slot
* (sidebar/bottom-toggle.tsx) — the header's right corner belongs to the
* native sidebar's own expand control.
*
* The panel is mounted inside the unified panel host — a fixed,
* viewport-sized containing block ([data-dsh-panel-host]) appended to
* document.body — instead of a fixed-position element, so a desktop shell's
* intermediate wrapper transforms can never hijack its containing block.
* The whole layout lives in the per-session store, so switching
* conversations swaps the workbench.
*
* The shell binds the workbench actions to the store and dispatches tab
* content to the views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer).
*/
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { referenceInChat as referenceInChatShared } from './reference-in-chat.ts'
import {
BOTTOM_MIN, CONVERSATION_MIN, agentUuidOf, firstLeaf, isAgentTabId,
leafWithTab, moveTab, moveTabToEdge, openDiffTab, resizeSplitIn,
setBottomHeight, setTabPin, toggleBottomPanel, toggleExpanded,
type DropZone, type SidebarStore, type SidebarTab,
} from './state.ts'
import { getPinnedHomeScope } from './pinned.ts'
import { IconPanelBottomOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { useViewportSize } from './breakpoints.ts'
import { bottomPushHeight } from './layout-push.ts'
import { parseDesktopEnv } from './desktop-env.ts'
import { getWcoSnapshot, subscribeWco } from './wco.ts'
import { getShellPreset } from './shell-presets.ts'
import { computeTitleBarStrip } from './titlebar-strip.ts'
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
import { useCenterColumn } from './sidebar/use-center-column.ts'
import { useHostFeeds } from './sidebar/use-host-feeds.ts'
import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts'
import type { TabDragPayload } from './TabBar.tsx'
import { t } from './locales.ts'
import { api } from './api.ts'
import css from './sidebar.module.css'
/**
* OS file drags over the sidebar belong to the sidebar, not to the chat:
* DSH's composer (InputBar) listens for file drags on the DOCUMENT and
* answers with a full-screen "drop image here" mask plus image intake on
* drop. The panel host swallows the whole event quartet —
* enter/over/leave/drop — so the region is a black hole to that document
* listener. All four must be stopped: InputBar keeps an enter/leave depth
* counter, and a leave that escapes without its matching enter unbalances
* the count (this was the full-screen mask flickering over the sidebar).
* The conversation column keeps DSH's native overlay and intake untouched;
* gated on the 'Files' type so in-app drags (tab reorder, split zones)
* propagate exactly as before.
*/
const swallowOsFileDrag = (event: ReactDragEvent): void => {
if (!(event.dataTransfer?.types.includes('Files') ?? false)) return
event.preventDefault()
event.stopPropagation()
}
/** The four drag events a file drag must never carry past the panel host. */
const osFileDragShield = {
onDragEnter: swallowOsFileDrag,
onDragOver: swallowOsFileDrag,
onDragLeave: swallowOsFileDrag,
onDrop: swallowOsFileDrag,
}
/**
* Append one user-space stylesheet (preset or custom CSS) as a tagged
* `<style>` element. The tag attribute carries the source identity so the
* running configuration is inspectable in DevTools; the returned tag is
* removed by the caller's effect cleanup.
*/
function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElement {
const tag = document.createElement('style')
tag.setAttribute(attr, id)
tag.textContent = cssText
document.head.appendChild(tag)
return tag
}
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props
// Copy freshness: re-render the whole tree when the DSH locale switches.
// The module-level t() reads the active locale at call time, so a root
// re-render alone re-localizes every panel (no memo barriers below).
const localeRevision = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
)
void localeRevision
// better-locale override freshness: when @huanlin/dsh-plugin-better-locale
// is installed and the user picks an override language (e.g. ja), the
// store's `active` changes but the DSH locale's `active` does NOT —
// better-locale keeps the dsh active value (zh/en) unchanged and only
// patches `LocaleRuntime.prototype.lookup`. The localeRevision uSES
// above reads `getSnapshot().active`, so it sees no change and skips
// re-render. This second uSES reads the better-locale store's `active`
// directly, so an override switch fires a full re-render and t() picks
// up the new override text. Optional: ctx.get returns undefined when
// better-locale is absent (or when ctx is a minimal test mock without
// a `get` method), in which case this is a no-op uSES.
type BetterLocaleStore = {
readonly active: string | undefined
subscribe(listener: () => void): () => void
}
const betterLocaleStore = typeof ctx.get === 'function'
? (ctx as unknown as {
get(name: 'betterLocale'): BetterLocaleStore | undefined
}).get('betterLocale')
: undefined
const betterLocaleActive = useSyncExternalStore(
useMemo(() => {
const store = betterLocaleStore
if (store === undefined) return (_cb: () => void) => () => {}
return (callback: () => void) => store.subscribe(callback)
}, [betterLocaleStore]),
useMemo(() => {
const store = betterLocaleStore
if (store === undefined) return () => undefined
return () => store.active
}, [betterLocaleStore]),
)
void betterLocaleActive
// Tab-registry revision: TabContent memo cells must pick up a descriptor
// a plugin registers/disposes after mount (the + menu / icons already read
// the registry at render). Rare events (plugin (un)mount), so one full
// re-render per change is fine — this is what keeps the memoized cells
// from going stale, mirroring the localeRevision mechanism above.
const [tabsVersion, setTabsVersion] = useState(0)
useEffect(() => {
const service = ctx.get('betterSidebar')
if (service === undefined) return
return service.subscribe(() => setTabsVersion(version => version + 1))
}, [ctx])
const viewport = useViewportSize()
// On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
// when the visual viewport shrinks below the layout viewport, bottom-
// anchored panels would hide under the keyboard. Track the inset and
// offset the bottom-anchored surfaces by it. The obscured bottom strip is
// innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
// the visual viewport is scrolled/zoomed under browser chrome, so
// omitting it would over-lift the panel (CR #232 P2). offsetTop changes
// through the viewport's scroll event too, so both events are listened.
// Guarded: browsers without visualViewport (older WebViews, jsdom) stay
// at 0. rAF-throttled, same pattern as useNarrowViewport.
const [keyboardInset, setKeyboardInset] = useState(0)
const [visualViewportHeight, setVisualViewportHeight] = useState<number | null>(null)
useEffect(() => {
const vv = window.visualViewport
if (vv === null || vv === undefined) return
let frame: number | null = null
const measure = (): void => {
frame = null
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
setVisualViewportHeight(Math.max(0, Math.round(vv.height)))
}
const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
vv.addEventListener('resize', onResize)
vv.addEventListener('scroll', onResize)
measure()
return () => {
vv.removeEventListener('resize', onResize)
vv.removeEventListener('scroll', onResize)
if (frame !== null) cancelAnimationFrame(frame)
}
}, [])
// The bottom workbench is offset above the on-screen keyboard. Cap its
// height against that same visible area, not the taller layout viewport,
// so the conversation keeps CONVERSATION_MIN even on wide touch devices.
const layoutViewportHeight = visualViewportHeight ?? viewport.height
// Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
)
const current = sessionList.current
// Per-session sidebar state.
const snapshot = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot(), [store]),
)
useEffect(() => { store.setSession(current) }, [current, store])
const state = snapshot.state
const sessionId = snapshot.sessionId
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
// Title-bar / shell compatibility (the "位置兼容模式" scheme):
// auto — CONSERVATIVE: only the standard Window Controls Overlay
// geometry contributes (the real caption-overlay height,
// reactive to maximize/restore). No URL stamp, no preset, no
// guess — plain browsers see zero modification.
// preset — an opt-in built-in shell preset (shell-presets.ts) adds its
// per-shell strip as the no-WCO fallback.
// custom — the user's own CSS (injected below) + the legacy manual
// strip px.
// The resolved strip drives the SAME body attribute + CSS variable as the
// legacy boolean did, so the CSS contract is unchanged (layout.css /
// sidebar.module.css); only the value source changed. The cleanup removes
// both on unmount/boundary swap so a crashed sidebar never leaves them
// behind.
const desktopEnv = parseDesktopEnv()
const wco = useSyncExternalStore(
useMemo(() => subscribeWco, []),
getWcoSnapshot,
)
const scheme = snapshot.prefs.titleBarScheme
const preset = scheme === 'preset' ? getShellPreset(snapshot.prefs.titleBarPresetId) : undefined
const titleBarStrip = computeTitleBarStrip(
desktopEnv, wco, scheme, preset, snapshot.prefs.titleBarStripPx,
)
const titleBarCompat = titleBarStrip > 0
useEffect(() => {
const root = document.documentElement
if (titleBarCompat) {
document.body.setAttribute('data-dsh-title-bar-compat', '')
root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`)
} else {
document.body.removeAttribute('data-dsh-title-bar-compat')
root.style.removeProperty('--dsh-title-bar-strip')
}
return () => {
document.body.removeAttribute('data-dsh-title-bar-compat')
root.style.removeProperty('--dsh-title-bar-strip')
}
}, [titleBarCompat, titleBarStrip])
// User-space CSS injection (the escape hatch): preset CSS (scheme
// `preset`) and free-form custom CSS (scheme `custom`) are appended AFTER
// the plugin's own styles — later in the cascade wins ties, and
// `!important` can override the JS-written inline strip variable. Each
// source gets its own tagged <style> so the running configuration stays
// inspectable; tags are removed on change/unmount so a stale stylesheet
// never outlives its fiber (HMR-safe).
const presetCss = scheme === 'preset' ? preset?.css ?? '' : ''
const customCss = scheme === 'custom' ? snapshot.prefs.customCss : ''
useEffect(() => {
const tags: HTMLStyleElement[] = []
if (presetCss !== '') tags.push(injectUserCss('data-dsh-preset-css', preset?.id ?? '', presetCss))
if (customCss !== '') tags.push(injectUserCss('data-dsh-custom-css', 'custom', customCss))
return () => { for (const tag of tags) tag.remove() }
}, [presetCss, customCss, preset?.id])
// While the session's header is still hydrating (or the session is blank),
// the list summary may carry no cwd; ask the host once (it falls back to
// the process cwd) so the explorer root and terminal cwd are real from
// first paint instead of showing "no session".
const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
useEffect(() => {
setFetchedCwd(undefined)
if (sessionId === undefined || summaryCwd !== undefined) return
let cancelled = false
api.sessionCwd({ sessionId })
.then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
.catch(() => { /* the explorer/git rows surface their own errors */ })
return () => { cancelled = true }
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
// The + menu options ride a memo so the workbench does not rebuild the
// array identity across renders that did not change the store (drag state,
// viewport resize): fresh arrays per render re-rendered every LeafView's
// + affordance whether or not anything tab-related moved.
const newTabOptions = useMemo(
() => (state === undefined || sessionId === undefined ? [] : buildNewTabOptions(state, ctx, { sessionId, cwd })),
// state is the whole session state — every field it wraps is fair game
// for the descriptors' available() callbacks. (The render's own guard
// sits below every hook; this memo must handle the no-session case
// itself.)
[state, ctx, sessionId, cwd],
)
// Host feeds (sidebar/use-host-feeds.ts): the agent-terminals / agent-opens
// WebSocket pushes and the subagent / background-job auto-activation
// triggers, all keyed on the current session. The jump-back ref is the one
// piece the render side consumes (renderTab's onSubagentJump arms it).
const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId })
// Center-column tracking (sidebar/use-center-column.ts): the bottom
// workbench spans ONLY the app shell's center column ("squeezes the agent
// output area"); the hook locates the AppFrame's center column DOM (host
// anchor observers + slow retry), measures its edges into a ref, and
// writes them straight to the bottom panel element — per-frame tracking
// without re-rendering the shell (the comments live with the hook now).
const bottomRef = useRef<HTMLDivElement | null>(null)
const { centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen)
/**
* Bottom-panel first-expansion auto terminal: the FIRST time the user
* expands the bottom panel in a session, try to open a fresh terminal tab
* there. "Try" is literal — the terminal's own quota and enable switch
* gate the attempt (a full quota or a disabled terminal type makes it a
* no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's
* nested settings toggle, default on). Only a false→true TRANSITION fires
* (a panel persisted open never counts as an expansion), and the session's
* bottomOpenedOnce flag is set atomically with the first fire so later
* expansions never repeat it.
*/
const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
useEffect(() => {
if (state === undefined) return
const wasOpen = bottomWasOpenRef.current
bottomWasOpenRef.current = state.bottomOpen
if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
if (state.bottomOpenedOnce) return
if (store.getPrefs().bottomPanelAutoTerminal === false) return
if (ctx.get('betterSidebar')?.isTabEnabled('terminal') === false) return
// Land the tab in the bottom panel's first pane; the once-flag is set
// atomically so later expansions never repeat the auto-open.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
ctx.get('betterSidebar')?.openTab({ type: 'terminal', target: 'bottom' })
}, [state, store, ctx])
// The bottom panel's height drag (top edge strip). Drags write the size
// DIRECTLY to the DOM (panel style + the layout CSS variable) instead of
// round-tripping the store on every pointer move — a store reduce
// re-renders the workbench (terminals, editors…) per move, which is the
// visible drag lag. The store is committed once on pointer up
// (clamping + persistence).
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
const [draggingBottom, setDraggingBottom] = useState(false)
// Pause center-column measurement while dragging, and re-measure once the
// drag settles at its committed size. The store commit lands on release and
// the final height equals the last drag height, so no ResizeObserver event
// fires to refresh centerRect — this explicit re-measure covers that gap.
useEffect(() => {
draggingRef.current = draggingBottom
if (!draggingBottom) measureCenter()
// draggingRef is a stable ref object from useCenterColumn (same
// provenance note as the hook's own comments).
}, [draggingBottom, measureCenter, draggingRef])
// Clamp mirror of setBottomHeight for mid-drag values (the store re-clamps
// on commit; this keeps the panel from overshooting mid-drag).
const clampHeight = (height: number): number =>
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - CONVERSATION_MIN))
/** Single writer for the layout-push variable: the app shell gives up the
* workbench's height while it is open (0 while collapsed) through
* layout.css's margin on the center column. Every size change — drag
* frames and committed state — flows through here so the push never
* forks between paths. The right column is DSH's native Sidebar, so no
* width is written. */
const writeGeometry = (height: number): void => {
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
}
/** Last height a drag actually applied to the DOM (updated by applyDrag).
* When a pointer stream dies without any position info (issue #247: an
* ultra-fast flick whose release events carried no usable coordinates),
* the abort path adopts this instead of rolling back to the pre-drag
* value — the DOM's current size is the only truthful record left. */
const lastDragHeight = useRef<number | null>(null)
/** Apply a drag height to the DOM without touching React state or the
* store. The layout push rides the shared writer (writeGeometry). */
const applyDrag = (height: number): void => {
lastDragHeight.current = height
bottomRef.current?.style.setProperty('height', `${height}px`)
const push = state?.bottomOpen === true ? height + keyboardInset : 0
writeGeometry(push)
}
// Drags write at most once per frame: pointer events fire several times
// faster than the display refresh, and each write reflows the app shell
// (the layout push) plus the panel — batching to one write per frame is
// what keeps the drag smooth. The store is still committed once on release.
const dragFrame = useRef<number | null>(null)
const pendingDrag = useRef<number | null>(null)
const scheduleDrag = (height: number): void => {
pendingDrag.current = height
if (dragFrame.current !== null) return
dragFrame.current = requestAnimationFrame(() => {
dragFrame.current = null
const pending = pendingDrag.current
if (pending !== null) {
pendingDrag.current = null
applyDrag(pending)
}
})
}
/** Flush any pending drag write and stop scheduling (the store commit on
* pointer up applies the final clamped values). */
const stopDragScheduling = (): void => {
if (dragFrame.current !== null) {
cancelAnimationFrame(dragFrame.current)
dragFrame.current = null
}
pendingDrag.current = null
}
/**
* Finalize the drag on pointer up: flush the LAST drag frame to the DOM
* synchronously, then commit the SAME clamped value to the store. A fast
* release cancels the rAF before it ran — without the flush the DOM would
* sit at the pre-drag size until React re-renders with the committed
* value, and a value that never made it into a move handler would never
* be applied at all. The measurement pause ends here too: the center
* column is re-measured BEFORE the committed re-render lands, so the
* panel's React-rendered edges already reflect the new height.
*/
const commitDrag = (height: number): void => {
stopDragScheduling()
applyDrag(height)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(s, height))
}
/** Set once a drag's pointerup handler commits — premature capture loss
* (pointercancel / lostpointercapture without pointerup) must then be told
* apart from a normal release. */
const dragCommitted = useRef(false)
/**
* Abort a drag whose pointer stream was interrupted (pointercancel, or
* capture lost before pointerup): no pointerup will arrive, so without
* this the dragging state would stick true and center-column measurement
* would stay paused forever — the panel freezes at stale edges and stops
* tracking layout changes.
*
* A FAST release is the common trigger: browsers merge pointermove bursts,
* and an ultra-fast flick can cancel the stream before ANY move lands.
* The commit order is therefore: the LAST KNOWN dragged size (the rAF
* pending value) first, then the interrupting event's own pointer
* position (only pointercancel is trusted to carry coordinates —
* lostpointercapture's coordinates are not guaranteed, so the handlers
* pass the event only from pointercancel), and finally the size the drag
* last APPLIED to the DOM (lastDragHeight). A drag that produced none of
* those (pure down+up at the same spot) commits the store's own height —
* a no-op, never an explicit rollback (issue #247).
*
* Every commit path marks the drag committed, so the interrupt
* double-fire (pointercancel → lostpointercapture) cannot commit once
* and then roll the same drag back.
*/
const abortDrag = (reset: () => void, event?: { clientY: number }): void => {
if (dragCommitted.current) return
const pending = pendingDrag.current
let height: number | undefined
if (pending !== null) {
height = pending
} else if (event !== undefined) {
// No move ever landed: the cancel position is all we have — commit it
// (clamped) instead of rolling back the flick.
height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))
}
if (height !== undefined) {
dragCommitted.current = true
stopDragScheduling()
applyDrag(height)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(s, height))
} else {
// No pending write and no usable event coordinates: keep the height the
// drag last applied instead of rolling back to the pre-drag value (the
// flick's moves may have been consumed by the rAF just before the
// stream died — the DOM already shows the dragged size).
dragCommitted.current = true
stopDragScheduling()
const adopted = clampHeight(lastDragHeight.current ?? state?.bottomHeight ?? BOTTOM_MIN)
applyDrag(adopted)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(s, adopted))
}
reset()
}
// Layout push: the app shell gives up the workbench's height while it is
// open (0 while collapsed), so the conversation and input bar are squeezed
// instead of covered. The margin is capped at the viewport so a stale
// persisted height (e.g. fullscreen on a bigger window) can never crush the
// app shell to zero. Dragging disables the layout transition.
useEffect(() => {
const height = bottomPushHeight({
open: snapshot.state?.bottomOpen === true,
height: snapshot.state?.bottomHeight ?? 0,
viewportHeight: layoutViewportHeight,
})
writeGeometry(snapshot.state?.bottomOpen === true ? height + keyboardInset : 0)
}, [snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, layoutViewportHeight, keyboardInset])
// Unmount must release the push (issue #31): when the boundary swaps the
// whole sidebar after a render crash (or the plugin fiber is disposed /
// HMR), the CSS variable would otherwise stay on <html> and layout.css
// keeps squeezing #root with a stale margin — "the sidebar cannot be
// hidden" until a full reload. This lives in an UNMOUNT-ONLY effect, NOT
// in the push effect's cleanup: React can yield between a passive
// effect's cleanup and setup phases, and removing the variable on a
// dependency change used to paint the push-less layout for a frame.
useEffect(() => {
return () => {
document.documentElement.style.removeProperty('--dsh-sidebar-height')
}
}, [])
useEffect(() => {
if (draggingBottom) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('data-dsh-sidebar-dragging')
}, [draggingBottom])
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => {
// A closed terminal releases its pty immediately — including when its
// socket is mid-reconnect, where the unmount close frame never reaches
// the host and the process would hold the quota until the grace ends.
// Agent terminals (tabId `agent:<uuid>`) close through a different
// host route: the WS close frame is the primary path (sent by
// TerminalView on unmount), and the agent-pty.close HTTP route is the
// fallback when the WS is down.
const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.bottomSplits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
// Route through the service: the tab-bar close is the canonical close
// path (finds the pane itself, fires descriptor.onClose); the session
// scope (with its cwd) rides to the callback.
ctx.get('betterSidebar')?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
if (tab?.type === 'terminal') {
if (isAgentTabId(tabId)) {
const uuid = agentUuidOf(tabId)
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
} else if (sessionId !== undefined) {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
}
}
},
activateTab: (paneId, tabId) => {
// Route through the service: same reducer (finds the pane, sets the
// active pane) and fires descriptor.onActivate; the session scope
// (with its cwd) rides to the callback.
ctx.get('betterSidebar')?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
},
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
},
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
store.reduce((s) => {
let index = -1
const source = leafWithTab(s.bottomSplits, beforeTabId)
if (source !== undefined && source.id === toPane) {
index = source.tabs.findIndex(tab => tab.id === beforeTabId)
}
return moveTab(s, payload.paneId, payload.tabId, toPane, index)
})
},
resizeSplit: (splitId, index, deltaFrac) => {
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
},
// Pin/unpin a terminal tab (v0.17.0+): the home cwd is snapshotted at
// pin time so a workspace-scoped pin only resurfaces in sessions whose
// cwd matches. Unpin passes null — the tab stays open in its home
// session, just unmarked.
pinTab: (tabId, scope) => {
store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))
},
}), [store, sessionId, cwd, ctx])
// Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned
// tabs inject into the bottom workbench's first leaf, and the actions are
// wrapped so pinned virtual ids route to the HOME session (reduceFor +
// revision bump).
const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions })
/**
* The explorer's @-reference button. Directories append the folder mention
* (`@dir/`) as plain text so DSH's folder decoration and completion keep
* working; files insert a structured chip like the native `@` picker, so
* the whole reference stays one link instead of decorating only the
* leading folder. Resolves the session-scope ctx and the conversation
* input service at click time; a missing service or scope degrades to a
* logged no-op, never a crash. Defined above the no-session early return
* — a hook must never sit behind a conditional return (React counts hooks
* per render).
*/
const referenceInChat = useCallback((path: string, isDir: boolean): void => {
if (sessionId === undefined) return
referenceInChatShared(ctx, sessionId, cwd, path, isDir)
}, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) {
// No conversation yet: the host stays mounted (the drag shield keeps
// covering the region) but nothing is rendered — the toggle button lives
// in DSH's session header, which does not exist without a session.
return <div data-dsh-panel-host {...osFileDragShield} />
}
const bottomPanelHeight = bottomPushHeight({
open: true,
height: state.bottomHeight,
viewportHeight: layoutViewportHeight,
})
const onNewTab = (optionId: string): void => {
const service = ctx.get('betterSidebar')
const descriptor = service?.getTab(optionId)
if (service === undefined || descriptor === undefined) return
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
// The session scope rides along: lifecycle callbacks receive it. The
// open is bottom-targeted so the tab lands in THIS workbench, not in the
// native right Sidebar.
service.openTab({ type: optionId, title, target: 'bottom' }, { sessionId, cwd })
}
/** The tab icon from the tab-type registry. */
const tabIconOf = (tab: SidebarTab): ReactNode => {
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}
/**
* The tab badge from the tab-type registry: a count (99+ capped) or a
* short text pill. A throwing badge is swallowed (no pill) — the tab
* strip must never break because a plugin's badge computation failed.
*/
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor?.badge === undefined) return null
let value: string | number | null | undefined
try {
value = descriptor.badge(ctx, { sessionId, cwd }, state)
} catch (error) {
console.error('[dsh-better-sidebar] tab badge error:', error)
return null
}
if (value === null || value === undefined || value === '') return null
const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value)
return <span className={css.tabBadge}>{text}</span>
}
/**
* Render one tab's content. `active` (from the workbench) tells whether
* this tab is the active one in its pane; combined with the panel's
* open/closed state it gates live views (the Subagent topology pauses its
* polling while the page is not actually visible). The pane id travels
* with the tab so diff tabs can split below their source pane.
*/
const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => {
// Pinned virtual tabs: pass the home session's scope (sessionId + cwd) so
// TerminalView's WS URL resolves to the home PTY, and effectiveTabId so
// the descriptor component receives the ORIGINAL tab id (the virtual id
// is only a display key). Regular tabs: effectiveTabId is undefined (no
// override), scope is the current session's.
const home = getPinnedHomeScope(tab)
return (
<TabContent
tab={tab}
effectiveTabId={home?.tabId}
paneId={paneId}
sessionId={home?.sessionId ?? sessionId}
cwd={home?.cwd ?? cwd}
expanded={state.expanded}
revealed={state.revealed ?? []}
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={state.bottomOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
localeRevision={localeRevision}
tabsVersion={tabsVersion}
/>
)
}
return (
<div data-dsh-panel-host {...osFileDragShield}>
{/*
The bottom workbench: it squeezes ONLY the center column (the agent
output area): it starts at the app shell's own left sidebar and ends
at the details column's left edge — neither sidebar gives up any
position. Its resize strip is the top edge; hidden by sliding down.
It only becomes VISIBLE once the center column is measured: before
that, `centerRect` is the {0,0} fallback and `right` computes to the
full viewport width — the panel (and its overflow content) would
flash full-width for a frame until the first measurement lands.
Rendering stays unconditional so the mount/render chain (auto-terminal
etc.) is never gated on geometry.
*/}
<div
ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
data-dsh-panel
data-dsh-bottom-panel
style={{
height: bottomPanelHeight,
left: centerRectRef.current.left,
// Keep the panel above the on-screen keyboard when the visual
// viewport shrinks (see the keyboardInset effect).
bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
// Direct from the center column's measured right edge: the bottom
// panel spans ONLY the center column, ending exactly at the
// details column's left edge.
right: window.innerWidth - centerRectRef.current.right,
// Unmeasured center column → keep the panel invisible (zero-size
// geometry would flash full-width overflow instead).
visibility: centerMeasured ? undefined : 'hidden',
}}
data-dragging={draggingBottom || undefined}
>
<div
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
dragCommitted.current = false
bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
setDraggingBottom(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startY, startHeight } = bottomDrag.current
scheduleDrag(clampHeight(startHeight + (startY - event.clientY)))
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
if (dragCommitted.current) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startY, startHeight } = bottomDrag.current
// Up position wins over the rAF pending value (see the abortDrag
// comment — issue #247).
commitDrag(clampHeight(startHeight + (startY - event.clientY)))
setDraggingBottom(false)
}}
onPointerCancel={(event) => { abortDrag(() => setDraggingBottom(false), event) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }}
/>
{/*
The bottom panel's own close control at its tab strip's right end
(the strip reserves the width via CSS so the + menu never hides
under it): one tap collapses the panel.
*/}
<Tooltip label={t('collapseBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.bottomClose}
aria-label={t('collapseBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconCloseFill14 />
</button>
</Tooltip>
<div className={css.panelBody}>
<Workbench
state={state}
tree={augmentedTree}
newTabOptions={newTabOptions}
actions={wrappedActions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/>
</div>
</div>
</div>
)
}
/** The header control that expands/collapses the bottom workbench (see
* sidebar/bottom-toggle.tsx — registered into DSH's session header). */
export function BottomDockToggle(props: { store: SidebarStore }) {
const { store } = props
const snapshot = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot(), [store]),
)
const open = snapshot.state?.bottomOpen === true
const label = open ? t('collapseBottomPanel') : t('expandBottomPanel')
return (
<Tooltip label={label} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
data-dsh-bottom-toggle
data-active={open ? 'true' : undefined}
aria-label={label}
aria-pressed={open}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)
}
-547
View File
@@ -1,547 +0,0 @@
/* Subagent page: main-agent topology tree. Row/card chrome mirrors the
official ui-subagent catalog row (min-height 50, 7/8/11 padding, radius 8,
flex-start alignment, tree connector lines via border-left hairlines). */
.subagent {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.subagentHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
}
.subagentTitle {
flex: 1;
min-width: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The descendant count badge ("3 · 1 运行中") beside the title. */
.subagentCount {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.subagentRefresh {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.subagentRefresh:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.subagentBody {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 2px 6px 8px;
}
/* One node card: state dot + label + secondary + (running) live lines. */
.subagentRow {
position: relative;
display: flex;
align-items: flex-start;
gap: 8px;
box-sizing: border-box;
width: 100%;
min-height: 50px;
padding: 7px 8px 7px 11px;
border: none;
border-radius: 8px;
background: transparent;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
outline: none;
}
.subagentRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Keyboard focus (mirror of the official catalog row): a visible fill only
when focused via keyboard; the selected state below still wins on top. */
.subagentRow:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
/* The currently-open session, highlighted in place (mirror of the workspace
`.sessionRow.selected` — declared after the hover/focus rules so it wins). */
.subagentRowActive,
.subagentRowActive:hover,
.subagentRowActive:focus-visible {
background: var(--dsw-alias-interactive-bg-active);
}
.subagentRowDisabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;
}
.subagentRowDisabled:hover {
background: transparent;
}
/* Loading placeholders are disabled but keep the default cursor (official
`.loadingRow` distinction from `.disabled`). */
.subagentRowLoading {
cursor: default;
}
/* The state dot aligns to the first text line (official catalog detail). */
.subagentDot {
margin-top: 4px;
}
.subagentContent {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
gap: 2px;
}
.subagentLabel,
.subagentSecondary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subagentLabel {
color: inherit;
font-weight: 400;
}
.subagentSecondary {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
/* ── Live lines (running cards only) ──────────────────────────────────── */
.subagentLive {
display: flex;
align-items: baseline;
gap: 4px;
min-width: 0;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
}
.subagentLiveTool {
flex: none;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-secondary);
}
.subagentLiveArgs {
min-width: 0;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: var(--dsw-font-xxxs-11-line-height);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subagentLiveText {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-secondary);
}
/* ── Tree connector lines (official catalog recipe) ────────────────────── */
.subagentNode {
position: relative;
min-width: 0;
}
.subagentChildren {
position: relative;
margin-left: 18px;
padding-left: 4px;
}
.subagentChildren::before {
content: '';
position: absolute;
left: 0;
top: -26px;
height: 26px;
border-left: 1px solid var(--dsw-alias-border-l2);
}
.subagentChildren[aria-busy='true']::before {
content: none;
}
.subagentChildren > .subagentNode::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -4px;
border-left: 1px solid var(--dsw-alias-border-l2);
}
.subagentChildren > .subagentNode:last-child::before {
bottom: auto;
height: 17px;
}
.subagentChildren > .subagentNode > .subagentRow::before {
content: '';
position: absolute;
top: 16px;
left: -4px;
width: 14px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.subagentEmpty {
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
.subagentEmptyHint {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-dimmed);
}
.subagentError {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
}
.subagentErrorRetry {
flex: none;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-strong-11);
cursor: pointer;
}
.subagentErrorRetry:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* ── Background-job section (below the topology tree) ────────────────── */
.jobs {
margin-top: 10px;
border-top: 1px solid var(--dsw-alias-border-l2);
padding-top: 8px;
}
.jobsHeader {
display: flex;
align-items: center;
gap: 8px;
height: 26px;
padding: 0 2px;
}
.jobsTitle {
flex: 1;
min-width: 0;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jobsCount {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.jobsList {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
/* One job row; the whole row lights up on hover, the expandable target
stays transparent (the kill button rides on the row's right edge). */
.jobsRow {
display: flex;
align-items: center;
gap: 4px;
border-radius: 8px;
}
.jobsRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.jobsRowSettled {
opacity: 0.8;
}
/* The row whose output is shown in the bottom dock. */
.jobsRowSelected,
.jobsRowSelected:hover {
background: var(--dsw-alias-interactive-bg-active);
}
.jobsRowMain {
flex: 1;
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 6px 8px 6px 11px;
border: none;
border-radius: 8px;
background: transparent;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
outline: none;
}
.jobsRowMain:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
.jobsDot {
margin-top: 5px;
}
.jobsContent {
display: flex;
flex-direction: column;
min-width: 0;
gap: 1px;
}
/* The command line: a kind badge followed by the command itself in the code
face, like the terminal's live lines. */
.jobsLabelLine {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.jobsKind {
flex: none;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
line-height: 14px;
color: var(--dsw-alias-label-tertiary);
}
.jobsLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: var(--dsw-font-xxxs-11-line-height);
color: var(--dsw-alias-label-primary);
}
.jobsSecondary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.jobsKill {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-right: 4px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.jobsKill:hover {
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
}
/* The armed (first-click) state: a filled confirm chip instead of the icon. */
.jobsKillArmed,
.jobsKillArmed:hover {
width: auto;
height: 20px;
padding: 0 8px;
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
font: var(--dsw-font-xxxs-strong-11);
white-space: nowrap;
}
.jobsKill:disabled {
opacity: 0.5;
cursor: default;
}
.jobsKillError {
flex: none;
margin-right: 4px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-error-primary);
}
/* ── The shared output dock (bottom of the page, sticky like a terminal) ── */
/* Direct child of the scrolling body, so it docks at the scrollport's bottom
edge while the job list scrolls above it; a solid background keeps the
tree from showing through. */
.jobsPane {
position: sticky;
bottom: 0;
z-index: 1;
margin-top: 4px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-base);
box-shadow: 0 -6px 12px -8px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.jobsPaneHeader {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 4px 0 10px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.jobsPaneDot {
flex: none;
}
.jobsPaneLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: var(--dsw-font-xxxs-11-line-height);
color: var(--dsw-alias-label-primary);
}
.jobsPaneStatus {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.jobsPaneClose {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.jobsPaneClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.jobsPanePre {
margin: 0;
max-height: 200px;
overflow: auto;
padding: 6px 10px;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: 1.5;
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
word-break: break-word;
}
.jobsPaneHint {
padding: 8px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.jobsPaneError {
color: var(--dsw-alias-state-error-primary);
}
-872
View File
@@ -1,872 +0,0 @@
/**
* Subagent page: the FULL agent topology of the current tree's main session.
*
* The root is resolved by walking the durable parent chain upward from the
* current session to the first non-subagent session — the MAIN session — and
* every subagent under it shares this one topology view, no matter how deep
* the current selection is (including a subagent transcript opened in the
* main view). The main agent renders as the root node card (click it to jump
* back to the main session), with its subagents hanging below it in clearly
* LAYERED levels: tree connector lines (first level included) and per-level
* indentation show the hierarchy, and the currently-open session is
* highlighted in place. Every branch is expanded automatically (lazy
* catalogs hydrate on demand and consume live membership while visible).
*
* Each node card carries live status (state dot, durable label, mode and
* activity); while a child RUNS, its card additionally shows the LAST text
* output and LAST tool call pulled from its history tail, auto-refreshing
* every few seconds while the page is visible. Clicking a card jumps
* straight into the child transcript (`openSubagent`); the page stays open
* and the topology remains rooted at the main session.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
IconRefreshOutline14, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
Context,
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarSubagentDiagnosticEntry,
SidebarJobView,
} from '../context-types.ts'
import {
collectBranchIds,
countSubagentDescendants,
isSideThreadSummary,
rootAncestor,
} from './subagent-detect.ts'
import { type LastActivity } from '../subagent-activity.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
import {
collectTreeJobs,
formatJobDuration,
isJobLive,
orderJobs,
jobDotState,
jobStatusLabel,
type TreeJob,
} from './subagent-jobs.ts'
import { api, type JobOutputResult } from './api.ts'
import { usePolling } from './use-polling.ts'
import { IconStopOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import css from './SubagentView.module.css'
/** Refresh cadence of the live "last text + tool call" lines while a child runs. */
const POLL_MS = 3000
/** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 60
/** Refresh cadence of an expanded job-output panel while its job runs. */
const JOB_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */
const JOB_KILL_ARM_MS = 3000
/** The direct subagent children of one parent (durable `origin` rows;
* Side Chat threads ride the same origin but are tab-strip conversations,
* never topology). */
function directChildren(
byId: Readonly<Record<string, SidebarSessionSummary>>,
parentSessionId: string,
): SidebarSessionSummary[] {
return Object.values(byId).filter(
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId
&& !isSideThreadSummary(summary),
)
}
/** Human label of one catalog child: durable label, then summary title, then id. */
function childLabel(
entry: SidebarSubagentChildEntry,
summary: SidebarSessionSummary | undefined,
): string {
return entry.label ?? summary?.displayTitle ?? entry.id
}
function diagnosticReason(entry: SidebarSubagentDiagnosticEntry): string {
switch (entry.reason) {
case 'corrupt': return t('subagentDiagCorrupt')
case 'unsupported': return t('subagentDiagUnsupported')
case 'unavailable': return t('subagentDiagUnavailable')
}
}
/** The secondary line of one card: title · mode · activity (skips empty parts). */
function cardSecondary(
summary: SidebarSessionSummary | undefined,
entry: SidebarSubagentChildEntry,
): string {
return [
summary?.displayTitle,
entry.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'),
entry.activity === 'running' ? t('subagentRunning') : t('subagentInactive'),
].filter(Boolean).join(' · ')
}
/** First `limit` characters with an ellipsis when truncated. */
function preview(text: string, limit: number): string {
return text.length > limit ? `${text.slice(0, limit)}…` : text
}
/** Collapse whitespace for the single-paragraph live-text preview. */
function flatten(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
/** Disabled "loading…" cards backed by the summary mirror while a catalog hydrates. */
function CatalogLoadingRows(props: {
parentSessionId: string
byId: Readonly<Record<string, SidebarSessionSummary>>
level: number
}) {
const { parentSessionId, byId, level } = props
const children = directChildren(byId, parentSessionId)
if (children.length === 0) {
return <div className={css.subagentEmpty}>{t('loading')}</div>
}
return (
<>
{children.map(summary => (
<div
key={summary.id}
role="treeitem"
aria-disabled="true"
aria-level={level}
aria-label={t('loading')}
className={`${css.subagentRow} ${css.subagentRowDisabled} ${css.subagentRowLoading}`}
>
<StateDot state={summary.running === true ? 'ongoing' : 'done'} className={css.subagentDot} />
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{t('loading')}</span>
</span>
</div>
))}
</>
)
}
/**
* The live lines of one RUNNING subagent card: a pure presentation of the
* batch `subagents.live` activity. The polling lives in one place (the
* SubagentView hook), not per card. A running child with neither output yet
* reads "thinking…".
*/
function SubagentLiveLines(props: { live: LastActivity | undefined }) {
const { live } = props
if (live?.text === undefined && live?.tool === undefined) {
return <span className={css.subagentLive}>{t('subagentThinking')}</span>
}
return (
<>
{live.tool !== undefined && (
<span className={css.subagentLive}>
<span className={css.subagentLiveTool}>{live.tool.name}</span>
{live.tool.args !== '' && (
<span className={css.subagentLiveArgs}>{preview(live.tool.args, ARGS_PREVIEW)}</span>
)}
</span>
)}
{live.text !== undefined && (
<span className={css.subagentLiveText}>{flatten(live.text)}</span>
)}
</>
)
}
/**
* One shared live-preview poller for the whole Subagent tree. Unlike the old
* per-card `subagents.history` timers, this sends at most ONE `subagents.live`
* request at a time (the shared poller's self-scheduling mode arms the next
* tick only after the previous request settles, so a slow host never sees
* abort/restart storms); a response settling after the poller stopped (page
* hidden, tree re-rooted) is dropped via the aborted signal.
*/
function useSubagentLive(
rootId: string | undefined,
active: boolean,
): Readonly<Record<string, LastActivity>> {
const [live, setLive] = useState<Record<string, LastActivity>>({})
// A new tree must never inherit another root's live previews.
useEffect(() => { setLive({}) }, [rootId])
const poll = useCallback(async (signal: AbortSignal): Promise<void> => {
if (rootId === undefined) return
const result = await api.subagentsLive(rootId, signal)
if (!signal.aborted) setLive(result.live)
}, [rootId])
usePolling(rootId !== undefined && active, poll, {
intervalMs: POLL_MS,
mode: 'self-scheduling',
immediate: true,
})
return live
}
interface RowsProps {
parentSessionId: string
catalog: SidebarSubagentCatalog | undefined
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>
byId: Readonly<Record<string, SidebarSessionSummary>>
level: number
/** The currently-open session id (highlighted in the topology). */
currentSessionId: string
/** The batch live-preview map (child id → latest activity). */
live: Readonly<Record<string, LastActivity>>
openChild: (address: SidebarSubagentAddress) => void
refresh: (parentSessionId: string) => void
}
/** Render one topology level; branches are always expanded (lazy catalogs). */
function CatalogRows({
parentSessionId, catalog, catalogs, byId, level, currentSessionId, live,
openChild, refresh,
}: RowsProps) {
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
// Side Chat threads are honest catalog citizens (durable descriptor, 'Side: '
// label) but they are NOT subagent topology — filter them out here (the tab
// strip owns them). Legacy threads created before the descriptor fix still
// arrive as corrupt diagnostics; they are recognized by summary title.
const visibleEntries = (catalog?.entries ?? []).filter((entry) => {
if (entry.kind === 'child') return !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false)
return !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false)
})
return (
<>
{emptyLoading && (
<CatalogLoadingRows parentSessionId={parentSessionId} byId={byId} level={level} />
)}
{catalog?.state === 'error' && (
<div className={css.subagentError}>
<span>{catalog.error?.message ?? t('error')}</span>
<button
type="button"
className={css.subagentErrorRetry}
onClick={() => { refresh(parentSessionId) }}
>
<IconRefreshOutline14 />
{t('retry')}
</button>
</div>
)}
{visibleEntries.map((entry) => {
if (entry.kind === 'diagnostic') {
return (
<div key={entry.id} className={css.subagentNode}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
className={`${css.subagentRow} ${css.subagentRowDisabled}`}
title={diagnosticReason(entry)}
>
<StateDot state="error" className={css.subagentDot} />
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{entry.id}</span>
<span className={css.subagentSecondary}>{diagnosticReason(entry)}</span>
</span>
</div>
</div>
)
}
const childCatalog = catalogs[entry.id]
const knownLeaf = !entry.hasChildren
const summary = byId[entry.id]
const label = childLabel(entry, summary)
const secondary = cardSecondary(summary, entry)
const childLoading = childCatalog === undefined
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
const address: SidebarSubagentAddress = {
parentSessionId,
childSessionId: entry.id,
mode: entry.mode,
}
const current = entry.id === currentSessionId
return (
<div key={entry.id} className={css.subagentNode}>
<div
role="treeitem"
tabIndex={0}
aria-level={level}
aria-label={`${label} ${secondary}`}
aria-current={current ? 'true' : undefined}
{...knownLeaf ? {} : { 'aria-expanded': true }}
className={clsx(css.subagentRow, current && css.subagentRowActive)}
onClick={() => { openChild(address) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openChild(address)
}
}}
>
<StateDot
state={entry.activity === 'running' ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{label}</span>
<span className={css.subagentSecondary}>{secondary}</span>
{entry.activity === 'running' && (
<SubagentLiveLines live={live[entry.id]} />
)}
</span>
</div>
{!knownLeaf && (
<div role="group" className={css.subagentChildren} aria-busy={childLoading || undefined}>
{childCatalog === undefined
? (
<CatalogLoadingRows
parentSessionId={entry.id}
byId={byId}
level={level + 1}
/>
)
: (
<CatalogRows
parentSessionId={entry.id}
catalog={childCatalog}
catalogs={catalogs}
byId={byId}
level={level + 1}
currentSessionId={currentSessionId}
live={live}
openChild={openChild}
refresh={refresh}
/>
)}
</div>
)}
</div>
)
})}
</>
)
}
/**
* The shared output dock of the jobs section: ONE pane at the bottom of the
* sidebar body (sticky, terminal-like) shows the SELECTED job's output as
* the MODEL has read it so far (replayed from the owner session's event
* log), refreshed every {@link JOB_POLL_MS} while the job runs and the
* page is visible. The model's `job_output` cursor is never touched — the
* pane can never steal the agent's bytes, and it stays empty until the
* agent reads the job. A single dock — not a panel per row — keeps the
* job list compact and stable when many jobs are running.
*/
function JobOutputPane(props: {
ownerSessionId: string
job: SidebarJobView
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
onClose: () => void
}) {
const { ownerSessionId, job, active, onClose } = props
const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading')
const controllerRef = useRef<AbortController | undefined>(undefined)
const preRef = useRef<HTMLPreElement>(null)
const load = useCallback(async (): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
try {
const result = await api.jobOutput({ sessionId: ownerSessionId }, job.id, controller.signal)
setState(result)
} catch {
// A newer pull aborted this one, or the wire failed: keep the last
// known output; only a dock that never loaded anything shows an error.
setState(current => (current === 'loading' ? 'error' : current))
}
}, [ownerSessionId, job.id])
useEffect(() => {
void load()
if (!active || !isJobLive(job)) return
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
return () => { window.clearInterval(timer) }
// isJobLive reads only job.status; whole-job identity churns on every
// catalog refresh and must not restart the poll interval.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [load, active, job.status])
useEffect(() => () => { controllerRef.current?.abort() }, [])
// Terminal-tail behavior: while the job runs, each refresh pins the view
// to the newest output; a settled dock leaves scrolling to the reader.
useEffect(() => {
if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
const pre = preRef.current
if (pre !== null) pre.scrollTop = pre.scrollHeight
// Same as the poll effect above: only the status transition matters.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, job.status])
return (
<div className={css.jobsPane} role="region" aria-label={`${job.label} ${t('jobs')}`}>
<div className={css.jobsPaneHeader}>
<StateDot state={jobDotState(job.status)} className={css.jobsPaneDot} />
<span className={css.jobsPaneLabel} title={job.label}>{job.label}</span>
<span className={css.jobsPaneStatus}>
{jobStatusLabel(job.status, t)}
{job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
</span>
<button
type="button"
className={css.jobsPaneClose}
aria-label={t('close')}
title={t('close')}
onClick={onClose}
>
<IconStopOutline16 size={10} />
</button>
</div>
{state === 'loading' && <div className={css.jobsPaneHint}>{t('loading')}</div>}
{state === 'error' && (
<div className={`${css.jobsPaneHint} ${css.jobsPaneError}`}>{t('jobOutputError')}</div>
)}
{typeof state === 'object' && (
<>
{state.text.length > 0
? <pre ref={preRef} className={css.jobsPanePre}>{state.text}</pre>
: state.read
? <div className={css.jobsPaneHint}>{t('jobNoOutput')}</div>
: <div className={css.jobsPaneHint}>{t('jobNotReadYet')}</div>}
{state.truncated && <div className={css.jobsPaneHint}>{t('jobOutputTruncated')}</div>}
</>
)}
</div>
)
}
/**
* The background-job section of the Subagent page: every job of the whole
* current tree (main agent + subagents, owner-labeled), fed by the harness
* `session/jobs` push mirror. Clicking a row feeds its model-read output to
* the shared bottom dock (event replay — never the model's cursor); live
* rows carry a two-click-confirm kill button. Renders nothing while the
* tree has no jobs.
*/
function JobsSection(props: {
byId: SidebarSessionList['byId']
jobsBySession: SidebarSessionList['jobsBySession']
rootId: string | undefined
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
}) {
const { byId, jobsBySession, rootId, active } = props
const rows = useMemo(
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
[byId, jobsBySession, rootId],
)
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
const [armedId, setArmedId] = useState<string | undefined>(undefined)
const [killingId, setKillingId] = useState<string | undefined>(undefined)
const [killErrorId, setKillErrorId] = useState<string | undefined>(undefined)
// The duration clock only runs while a live row is on screen.
const [now, setNow] = useState(() => Date.now())
const selectedRow = useMemo(
() => (selectedId === undefined ? undefined : rows.find(row => row.job.id === selectedId)),
[rows, selectedId],
)
const liveCount = useMemo(
() => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0),
[rows],
)
const multiOwner = useMemo(
() => new Set(rows.map(row => row.ownerSessionId)).size > 1,
[rows],
)
// The kill button stays armed only briefly; a stray click must never kill.
useEffect(() => {
if (armedId === undefined) return
const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS)
return () => { window.clearTimeout(timer) }
}, [armedId])
useEffect(() => {
if (liveCount === 0) return
setNow(Date.now())
const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000)
return () => { window.clearInterval(timer) }
}, [liveCount])
// The docked output pane follows its job: when the selected job leaves
// the mirror (settled and dropped, or the tree switched), close the dock.
useEffect(() => {
if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined)
}, [selectedId, selectedRow])
// NOTE: every hook must live ABOVE the empty-state return — a hook below it
// would flip this component's hook count when the mirror empties and crash
// React with "Rendered fewer hooks than expected" (the #300 regression).
const kill = useCallback(async (row: TreeJob): Promise<void> => {
setKillingId(row.job.id)
setKillErrorId(undefined)
try {
await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
} catch {
setKillErrorId(row.job.id)
} finally {
setKillingId(undefined)
setArmedId(undefined)
}
}, [])
if (rows.length === 0) return null
const countLabel = liveCount > 0
? t('jobsCountRunning', { count: rows.length, running: liveCount })
: t('jobsCount', { count: rows.length })
return (
<>
<section className={css.jobs} aria-label={t('jobs')}>
<div className={css.jobsHeader}>
<span className={css.jobsTitle}>{t('jobs')}</span>
<span className={css.jobsCount}>{countLabel}</span>
</div>
<ul className={css.jobsList} aria-label={t('jobs')}>
{rows.map((row) => {
const { job } = row
const live = isJobLive(job)
const selected = selectedId === job.id
const armed = armedId === job.id
const killing = killingId === job.id
const killFailed = killErrorId === job.id
const elapsed = live
? now - job.startedAt
: (job.finishedAt ?? job.startedAt) - job.startedAt
const secondary = [
...(multiOwner ? [row.ownerTitle] : []),
jobStatusLabel(job.status, t),
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
formatJobDuration(elapsed, t),
].filter(Boolean).join(' · ')
return (
<li
key={job.id}
className={clsx(
css.jobsRow,
!live && css.jobsRowSettled,
selected && css.jobsRowSelected,
)}
>
<button
type="button"
className={css.jobsRowMain}
aria-pressed={selected}
aria-label={`${job.label} ${secondary}`}
onClick={() => { setSelectedId(selected ? undefined : job.id) }}
>
<StateDot state={jobDotState(job.status)} className={css.jobsDot} />
<span className={css.jobsContent}>
<span className={css.jobsLabelLine}>
<span className={css.jobsKind}>{job.kind}</span>
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
</span>
<span className={css.jobsSecondary}>{secondary}</span>
</span>
</button>
{job.status === 'running' && (
<button
type="button"
className={armed ? `${css.jobsKill} ${css.jobsKillArmed}` : css.jobsKill}
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
title={armed ? t('jobKillConfirm') : t('jobKill')}
disabled={killing}
onClick={(event) => {
event.stopPropagation()
if (armed) void kill(row)
else setArmedId(job.id)
}}
>
{armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
</button>
)}
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
</li>
)
})}
</ul>
</section>
{selectedRow !== undefined && (
<JobOutputPane
ownerSessionId={selectedRow.ownerSessionId}
job={selectedRow.job}
active={active}
onClose={() => { setSelectedId(undefined) }}
/>
)}
</>
)
}
/**
* The sidebar's Subagent topology page.
* @param props - current session id, whether the page is actually visible
* (active tab + open panel), the client context, and an optional
* jump-notify hook fired right before `openSubagent` (lets the sidebar
* shell re-open the Subagent page after the conversation switch lands on
* the child session).
* @returns the main agent's topology tree, or the empty/error/loading states.
*/
export function SubagentView(props: {
sessionId: string
active: boolean
ctx: Context
onOpenChild?: (address: SidebarSubagentAddress) => void
}) {
const { sessionId, active, ctx, onOpenChild } = props
const sessions = ctx.sessions
// The same list feed the official catalog consumes (byId lineage + the
// lazy per-parent catalogs). Older DSH snapshots without the subagent seam
// simply leave these surfaces empty — the page degrades to the empty state.
const list = useSyncExternalStore(
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
useCallback(() => sessions.list.getSnapshot(), [sessions]),
)
const byId = list.byId
// Memoized so the empty-catalog fallback keeps a stable identity — a fresh
// `{}` per render would invalidate every catalog-dependent memo/effect.
const catalogs = useMemo(() => list.subagentsByParent ?? {}, [list.subagentsByParent])
// The topology root: the main agent of the current session's tree.
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
const rootCatalog = rootId === undefined ? undefined : catalogs[rootId]
const rootSummary = rootId === undefined ? undefined : byId[rootId]
const live = useSubagentLive(rootId, active)
/** Catalog owners currently consuming live membership updates. */
const observedRef = useRef(new Set<string>())
const observe = useCallback((parentSessionId: string, open: boolean): void => {
sessions.setSubagentCatalogOpen?.(parentSessionId, open)
if (open) observedRef.current.add(parentSessionId)
else observedRef.current.delete(parentSessionId)
}, [sessions])
// While the page is visible the topology root consumes live membership; a
// root change (switching to another main agent's tree) or the page hiding
// (tab switched away / panel collapsed) releases everything observed.
useEffect(() => {
if (rootId === undefined || !active) return
observe(rootId, true)
return () => {
// The cleanup must release everything observed AT cleanup time (the set
// mutates as branches open), so reading the ref here is the point.
// eslint-disable-next-line react-hooks/exhaustive-deps
for (const parentSessionId of observedRef.current) {
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
}
observedRef.current.clear()
}
}, [rootId, active, observe, sessions])
// Every branch of the always-expanded topology consumes live membership
// (add-only: a branch stays observed until the root changes or the page
// hides, which releases the whole set via the root effect's cleanup).
const branches = useMemo(() => collectBranchIds(catalogs, rootId), [catalogs, rootId])
useEffect(() => {
if (!active) return
for (const id of branches) {
if (!observedRef.current.has(id)) observe(id, true)
}
}, [branches, active, observe])
// Unobserve everything on unmount (the host stops refreshing unused catalogs).
useEffect(() => () => {
for (const parentSessionId of observedRef.current) {
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
}
observedRef.current.clear()
}, [sessions])
const openChild = useCallback((address: SidebarSubagentAddress): void => {
// Notify the shell first: the jump switches the sidebar to the child
// session's own layout, and the shell re-opens the Subagent page on top
// of it (the topology stays rooted at the main agent with the child
// highlighted) — the README "page stays open" contract.
onOpenChild?.(address)
try {
sessions.openSubagent?.(address)
} catch (error) {
console.error('[dsh-better-sidebar] openSubagent failed:', error)
}
}, [sessions, onOpenChild])
/** Jump back to the main agent (the topology root) from its node. */
const openMain = useCallback((): void => {
if (rootId === undefined) return
try {
sessions.open?.(rootId)
} catch (error) {
console.error('[dsh-better-sidebar] open session failed:', error)
}
}, [sessions, rootId])
const refresh = useCallback((parentSessionId: string): void => {
void sessions.refreshSubagents?.(parentSessionId)
}, [sessions])
const totals = useMemo(
() => rootId === undefined
? { count: 0, runningCount: 0 }
: countSubagentDescendants(byId, rootId),
[byId, rootId],
)
// Session summaries can announce membership before the descriptor-backed
// catalog catches up (or a catalog that just went ready is still empty).
const summaryBackedLoading = rootId !== undefined
&& (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0))
&& directChildren(byId, rootId).length > 0
const readyEmpty = rootCatalog?.state === 'ready'
&& rootCatalog.entries.length === 0
&& directChildren(byId, rootId ?? '').length === 0
const countLabel = totals.count === 0
? undefined
: totals.runningCount > 0
? t('subagentCountRunning', { count: totals.count, running: totals.runningCount })
: t('subagentCount', { count: totals.count })
/** Arrow-key tree navigation over the visible rows (official catalog recipe). */
const bodyRef = useRef<HTMLDivElement>(null)
const focusAt = useCallback((index: number): void => {
const items = bodyRef.current?.querySelectorAll<HTMLElement>(
'[role="treeitem"]:not([aria-disabled="true"])',
) ?? []
if (items.length === 0) return
items[(index + items.length) % items.length]?.focus()
}, [])
const onTreeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>): void => {
const items = bodyRef.current?.querySelectorAll<HTMLElement>(
'[role="treeitem"]:not([aria-disabled="true"])',
) ?? []
const index = Array.prototype.indexOf.call(items, document.activeElement)
if (event.key === 'ArrowDown') {
event.preventDefault()
focusAt(index + 1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
focusAt(index < 0 ? items.length - 1 : index - 1)
} else if (event.key === 'Home') {
event.preventDefault()
focusAt(0)
} else if (event.key === 'End') {
event.preventDefault()
focusAt(items.length - 1)
}
}, [focusAt])
return (
<div className={css.subagent}>
<div className={css.subagentHeader}>
<span className={css.subagentTitle}>
{t('subagent')}
{rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
? ` · ${rootSummary.displayTitle}`
: ''}
</span>
{countLabel !== undefined && <span className={css.subagentCount}>{countLabel}</span>}
<button
type="button"
className={css.subagentRefresh}
aria-label={t('refresh')}
title={t('refresh')}
disabled={rootId === undefined}
onClick={() => { if (rootId !== undefined) refresh(rootId) }}
>
<IconRefreshOutline14 />
</button>
</div>
<div
ref={bodyRef}
className={css.subagentBody}
onKeyDown={onTreeKeyDown}
>
<div
role="tree"
aria-label={t('subagent')}
aria-busy={summaryBackedLoading || undefined}
>
{rootId !== undefined && rootSummary !== undefined && (
<div
role="treeitem"
tabIndex={0}
aria-level={0}
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
aria-current={rootId === sessionId ? 'true' : undefined}
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
onClick={openMain}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openMain()
}
}}
>
<StateDot
state={rootSummary.running === true ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
</span>
<span className={css.subagentSecondary}>
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
</span>
</span>
</div>
)}
{rootId !== undefined && (
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
{summaryBackedLoading && (
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
)}
{!summaryBackedLoading && (
<CatalogRows
parentSessionId={rootId}
catalog={rootCatalog}
catalogs={catalogs}
byId={byId}
level={1}
currentSessionId={sessionId}
live={live}
openChild={openChild}
refresh={refresh}
/>
)}
</div>
)}
{readyEmpty && (
<div className={css.subagentEmpty}>
<div>{t('subagentEmpty')}</div>
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
</div>
)}
</div>
<JobsSection
byId={byId}
jobsBySession={list.jobsBySession}
rootId={rootId}
active={active}
/>
</div>
</div>
)
}
-363
View File
@@ -1,363 +0,0 @@
/**
* The tab strip of one pane: tabs capped at TAB_MAX_WIDTH (ellipsized),
* overflow scrolls horizontally, a close button per tab, a four-way split
* button cluster, and the + menu that opens new tabs (explorer / git /
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane. Right-clicking a
* tab opens the tab context menu (close / close others / close to the left /
* close to the right, the close ones scoped to this pane).
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCloseFill14, IconPlusOutline16, Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTab } from './state.ts'
import { isAgentTabId } from './state.ts'
import { isPinnedVirtualTab } from './pinned.ts'
import { useSubmenuFlip } from './menu-flip.ts'
import { IconPinOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** One + menu option. */
export interface NewTabOption {
id: string
label: string
disabled?: boolean
/** Leading icon (Menu row). */
icon?: ReactNode
}
/** Drag payload for tab moves (HTML5 DnD dataTransfer). */
export const TAB_DRAG_TYPE = 'application/x-dsh-tab'
export interface TabDragPayload {
tabId: string
paneId: string
}
export function serializeDrag(payload: TabDragPayload): string {
return JSON.stringify(payload)
}
export function parseDrag(raw: string): TabDragPayload | null {
try {
const parsed = JSON.parse(raw) as TabDragPayload
if (typeof parsed.tabId === 'string' && typeof parsed.paneId === 'string') return parsed
return null
} catch {
return null
}
}
/** Global tab-drag flag: PDF iframes become non-interactive synchronously. */
function setTabDragging(active: boolean): void {
if (active) document.body.setAttribute('data-dsh-tab-dragging', '')
else document.body.removeAttribute('data-dsh-tab-dragging')
}
export function TabBar(props: {
paneId: string
tabs: SidebarTab[]
active: string | null
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
onNewTab: (optionId: string) => void
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
/**
* Pin/unpin a terminal tab (v0.17.0+). Called with `'workspace'` or
* `'global'` to pin (the shell snapshots the home cwd), or `null` to
* unpin. Non-terminal tabs never trigger this callback. Optional: the
* menu hides the pin entry when unset (legacy callers).
*/
onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
getTabIcon?: (tab: SidebarTab) => ReactNode
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
* resolver returns the rendered pill or null). */
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onPinTab, getTabIcon, getTabBadge,
} = props
const [menuOpen, setMenuOpen] = useState(false)
// The tab right-click context menu: the target tab plus the cursor
// position (the portaled Menu anchors there, following the git lens/FileTree).
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
// The strip sits at the panel's top, so the pin submenu must grow downward;
// the flip hook derives that from the cursor y (upper half → "down").
useSubmenuFlip(tabMenu)
const [dragOver, setDragOver] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
// The context target's index in the render-time tab snapshot; -1 when the
// tab disappeared since the menu opened (the menu hides then).
const tabMenuIndex = tabMenu === null ? -1 : tabs.findIndex(tab => tab.id === tabMenu.tabId)
// Middle-click close: the press target is recorded on middle mousedown
// (preventDefaulted to disarm Chrome's middle-click autoscroll — its
// indicator is inert here because the strip hides its scrollbar and only
// the wheel handler scrolls) and the close settles on the first middle
// mouseup OVER that same tab. Release-position semantics match VS Code
// (microsoft/vscode#101028) and what users expect from Chrome tabs
// (crbug/40679924): pressing on a tab and releasing elsewhere cancels the
// close. The browser dispatches auxclick to the nearest common ancestor of
// the press/release targets when they differ, so any drift, autoscroll
// scroll, or tab-list reflow between press and release would otherwise
// swallow the close; settling on the recorded press target at mouseup
// keeps release semantics without depending on auxclick delivery.
const onCloseRef = useRef(onClose)
const middlePressed = useRef<{ id: string; node: HTMLElement } | null>(null)
useEffect(() => {
onCloseRef.current = onClose
})
useEffect(() => {
const onMouseUp = (event: MouseEvent): void => {
if (event.button !== 1) return
const pressed = middlePressed.current
middlePressed.current = null
// Close only when the release lands on the pressed tab; a drag-away
// release cancels the press (one-shot per press).
if (pressed !== null && pressed.node.isConnected && pressed.node.contains(event.target as Node)) {
onCloseRef.current(pressed.id)
}
}
window.addEventListener('mouseup', onMouseUp)
return () => { window.removeEventListener('mouseup', onMouseUp) }
}, [])
// Wheel over the strip scrolls the tab row horizontally (a plain mouse
// wheel emits deltaY, which overflow-x alone never consumes). Bound as a
// native NON-passive listener: React registers onWheel passively at the
// root, where preventDefault() is a no-op. Modifier keys keep their native
// meaning (shift = horizontal scroll, ctrl/cmd = zoom), and a strip that
// does not overflow leaves the event alone so the page scrolls normally.
useEffect(() => {
const el = listRef.current
if (el === null) return
const onWheel = (event: WheelEvent): void => {
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
if (el.scrollWidth <= el.clientWidth) return
event.preventDefault()
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? el.clientWidth : 1
el.scrollLeft += (event.deltaX + event.deltaY) * unit
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => { el.removeEventListener('wheel', onWheel) }
}, [])
useEffect(() => {
const clear = (): void => { setTabDragging(false); setDragOver(false) }
window.addEventListener('dragend', clear, true)
window.addEventListener('drop', clear, true)
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('dragend', clear, true)
window.removeEventListener('drop', clear, true)
window.removeEventListener('blur', clear)
}
}, [])
return (
<div
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
onDragOver={(event) => {
// The strip owns drops on itself (merge into this pane); stopping
// propagation keeps the pane root from also running its edge-zone
// handler on the same drop.
event.preventDefault()
event.stopPropagation()
setDragOver(true)
}}
onDragLeave={() => { setDragOver(false) }}
onDrop={(event) => {
event.preventDefault()
event.stopPropagation()
setDragOver(false)
setTabDragging(false)
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
const payload = parseDrag(raw)
if (payload !== null) onDropTab(payload, null)
}}
>
<div ref={listRef} className={css.tabList}>
{tabs.map(tab => {
const pinned = isPinnedVirtualTab(tab) || tab.pin !== undefined
return (
<div
key={tab.id}
className={clsx(css.tab, active === tab.id && css.tabActive, pinned && css.pinnedTab)}
title={tab.title}
draggable={!pinned}
onDragStart={pinned ? undefined : (event) => {
setTabDragging(true)
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
event.dataTransfer.effectAllowed = 'move'
}}
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
onDrop={(event) => {
if (pinned) { event.stopPropagation(); return }
event.preventDefault()
event.stopPropagation()
setTabDragging(false)
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
const payload = parseDrag(raw)
if (payload !== null) onDropTab(payload, tab.id)
}}
onClick={() => { onActivate(tab.id) }}
onMouseDown={(event) => {
// Middle-click close: record the press target and disarm
// Chrome's middle-click autoscroll (its indicator is inert
// here — the strip scrolls via the wheel handler only). The
// close itself settles on the first middle mouseup over this
// same tab (window-level), keeping release semantics.
if (event.button === 1) {
event.preventDefault()
middlePressed.current = { id: tab.id, node: event.currentTarget }
}
}}
onContextMenu={(event) => {
// Take over the browser menu: the tab context menu offers the
// close operations for this pane. Opening it also dismisses
// the + menu (only one menu at a time).
event.preventDefault()
setMenuOpen(false)
setTabMenu({ tabId: tab.id, x: event.clientX, y: event.clientY })
}}
>
{pinned && <IconPinOutline16 size={16} />}
{getTabIcon?.(tab) ?? null}
{getTabBadge?.(tab) ?? null}
<span className={css.tabTitle}>{tab.title}</span>
<button
type="button"
className={css.tabClose}
aria-label={t('close')}
onClick={(event) => {
event.stopPropagation()
onClose(tab.id)
}}
>
<IconCloseFill14 />
</button>
</div>
)
})}
{/*
The + sits immediately after the rightmost tab (sticky at the
right edge of the scrollport when the tabs overflow, so it stays
reachable no matter how many tabs are open).
*/}
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={newTabOptions.map(option => ({
id: option.id,
label: option.label,
...(option.disabled === true ? { disabled: true } : {}),
...(option.icon !== undefined ? { icon: option.icon } : {}),
}))}
onSelect={(id) => {
onNewTab(id)
setMenuOpen(false)
}}
portal
compact
align="end"
anchor={(
<button
type="button"
className={css.tabBarPlus}
aria-label={t('newTab')}
title={t('newTab')}
onClick={() => { setMenuOpen(v => !v); setTabMenu(null) }}
>
<IconPlusOutline16 />
</button>
)}
/>
{/*
The tab context menu, positioned at the right-click cursor (portal
so the panel's overflow clip cannot crop it). Close operations are
scoped to THIS pane: "close others/left/right" walk the render-time
tab snapshot and reuse the per-tab onClose path (which routes
through the service and releases terminals), so the target tab is
never closed and the pane never empties mid-loop.
*/}
<Menu
open={tabMenu !== null && tabMenuIndex >= 0}
onClose={() => { setTabMenu(null) }}
items={(() => {
// The target tab drives the pin entry's shape: terminal tabs
// get either a "Pin ▸" submenu (unpinned) or a single "Unpin"
// row (pinned). Non-terminal tabs and missing onPinTab get no
// pin entry at all. Pinned VIRTUAL tabs (injected from other
// sessions) get a stripped menu: only Unpin + Close (no
// close-others/left/right — those are pane-scoped operations
// that don't apply to cross-session virtual tabs).
const targetTab = tabMenuIndex >= 0 ? tabs[tabMenuIndex] : undefined
const isTerminal = targetTab?.type === 'terminal'
const isPinnedVirtual = targetTab !== undefined && isPinnedVirtualTab(targetTab)
const pinEntries = isTerminal && onPinTab !== undefined
? targetTab!.pin !== undefined
? [{ id: 'unpin', label: t('unpinTerminal') }]
: [{
id: 'pin',
label: isAgentTabId(targetTab!.id) ? t('pinAgentTerminal') : t('pinTerminal'),
submenu: [
{ id: 'pinWorkspace', label: t('pinToWorkspace') },
{ id: 'pinGlobal', label: t('pinToGlobal') },
],
}]
: []
if (isPinnedVirtual) {
return [
...pinEntries,
{ id: 'close', label: t('close') },
]
}
return [
...pinEntries,
{ id: 'close', label: t('close') },
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
{ id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
{ id: 'closeRight', label: t('closeRightTabs'), ...(tabMenuIndex >= tabs.length - 1 ? { disabled: true } : {}) },
]
})()}
onSelect={(id) => {
const target = tabMenu
if (target === null) return
setTabMenu(null)
const index = tabs.findIndex(tab => tab.id === target.tabId)
if (index < 0) return
if (id === 'pinWorkspace') {
onPinTab?.(target.tabId, 'workspace')
} else if (id === 'pinGlobal') {
onPinTab?.(target.tabId, 'global')
} else if (id === 'unpin') {
onPinTab?.(target.tabId, null)
} else if (id === 'close') {
onClose(target.tabId)
} else if (id === 'closeOthers') {
for (const tab of tabs) {
if (tab.id !== target.tabId) onClose(tab.id)
}
} else if (id === 'closeLeft') {
for (const tab of tabs.slice(0, index)) onClose(tab.id)
} else if (id === 'closeRight') {
for (const tab of tabs.slice(index + 1)) onClose(tab.id)
}
}}
portal
compact
align="start"
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
anchor={<span />}
/>
</div>
</div>
)
}
-449
View File
@@ -1,449 +0,0 @@
/**
* The interactive terminal: xterm.js over a WebSocket to the host pty.
* The host replays the session's transcript on connect, then streams live
* output; input frames are raw text, resize frames are JSON with
* type:"resize". Transient disconnects (page refresh, host restart) reconnect
* automatically; a server-side refusal (close code 1011 with a reason, e.g.
* a failed pty spawn) stops the loop and shows the reason with a manual
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*
* Three control frames shape the pty lifecycle on unmount:
* - `{type:'close'}` — the user closed the tab. The host kills the pty
* immediately (quota released).
* - `{type:'park'}` — the user switched to another conversation. The tab is
* still open in its session's persisted state but its view unmounted; the
* host keeps the pty alive indefinitely (no grace countdown), so switching
* back reattaches the same shell instead of respawning one.
* - bare socket drop (no frame) — page refresh, crash, plugin teardown, or a
* same-session re-render. The host's reconnect grace keeps the shell alive
* for a quick reconnect.
*
* Two attach modes share one upgrade endpoint:
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
* terminal closes when the user closes the tab); a bare socket drop
* leaves the pty alive (the agent owns the lifetime) — agent terminals
* never send park (their lifetime is already indefinite on bare drop).
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
* frame schedules a 0-ms close; a park frame marks the pty as parked; a
* bare socket drop gets the host's reconnect grace.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import '@xterm/xterm/css/xterm.css'
import { t } from './locales.ts'
import { ONE_DARK, ONE_LIGHT } from './one-dark-palette.ts'
import { openWhenSized } from './open-when-sized.ts'
import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
import { resolveTerminalFont } from './terminal-font.ts'
import {
buildTerminalLinks,
shouldActivateTerminalLink,
openTerminalUrl,
} from './terminal-links.ts'
import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
const FAILURE_LIMIT = 3
/**
* The WS close-code-1011 reason the host sends when node-pty is unavailable
* (mirror of the host's PTY_DEPS_MISSING; the value is a wire contract, so
* the two sides keep the literal in lockstep). The view then fetches the
* full repair details from /sidebar/api/terminal.deps.
*/
const PTY_DEPS_MISSING = 'pty-deps-missing'
/**
* 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:'
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
/**
* Curated ANSI palettes for the terminal. The surface colors (background,
* foreground, cursor, selection) ride the theme tokens so the terminal
* blends with the panel in both schemes; the 16 ANSI colors are the same
* designed syntax families the app's code surfaces use — one-dark for
* dark, one-light for light (one-dark-palette.ts, shared with the
* CodeMirror themes) — read live so a scheme flip re-themes in place.
*/
const ANSI_DARK: Record<string, string> = {
black: ONE_DARK.black, red: ONE_DARK.red, green: ONE_DARK.green, yellow: ONE_DARK.yellow,
blue: ONE_DARK.blue, magenta: ONE_DARK.magenta, cyan: ONE_DARK.cyan, white: ONE_DARK.gray,
brightBlack: ONE_DARK.faintGray, brightRed: ONE_DARK.red, brightGreen: ONE_DARK.green,
brightYellow: ONE_DARK.yellow, brightBlue: ONE_DARK.blue, brightMagenta: ONE_DARK.magenta,
brightCyan: ONE_DARK.cyan, brightWhite: ONE_DARK.white,
}
const ANSI_LIGHT: Record<string, string> = {
black: ONE_LIGHT.black, red: ONE_LIGHT.red, green: ONE_LIGHT.green, yellow: ONE_LIGHT.yellow,
blue: ONE_LIGHT.blue, magenta: ONE_LIGHT.magenta, cyan: ONE_LIGHT.cyan, white: ONE_LIGHT.gray,
brightBlack: ONE_LIGHT.faintGray, brightRed: ONE_LIGHT.red, brightGreen: ONE_LIGHT.green,
brightYellow: ONE_LIGHT.yellow, brightBlue: ONE_LIGHT.blue, brightMagenta: ONE_LIGHT.magenta,
brightCyan: ONE_LIGHT.cyan, brightWhite: ONE_LIGHT.offWhite,
}
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
function xtermTheme(): ITheme {
const dark = isDarkScheme()
// Skin systems set --dsw-alias-bg-base to `transparent` or translucent
// glass values (the dsh-web-ui skins use rgba 0.16–0.7); effectiveTokenValue
// treats those as unset below the opacity floor, so the opaque fallback
// engages and the terminal never renders see-through over the skin's
// backdrop (issue #90). Effectively opaque scoped surfaces (e.g. a skin's
// 0.96 porcelain) pass through — the skin still controls the terminal.
const background = effectiveTokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
const foreground = effectiveTokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
return {
background,
foreground,
cursor: foreground,
cursorAccent: background,
selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
...(dark ? ANSI_DARK : ANSI_LIGHT),
}
}
export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
const { scope, tabId, store } = props
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(null)
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
const [lastUrl, setLastUrl] = useState<string | null>(null)
const connectRef = useRef<(() => void) | null>(null)
useEffect(() => {
const host = hostRef.current
if (host === null) return
// The custom font prefs (side card settings, terminal card) resolve at
// mount; store changes re-apply them live below.
const font = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
const term = new Terminal({
cursorBlink: true,
fontSize: font.fontSize,
fontFamily: font.fontFamily,
allowTransparency: true,
convertEol: false,
scrollback: 4000,
theme: xtermTheme(),
})
const fit = new FitAddon()
term.loadAddon(fit)
// Ctrl+Click (Cmd+Click on mac) opens http(s) URLs printed in the
// pty stream — a plain click is left for xterm's text-selection
// gesture. Only http(s) is dispatched; file:// / mailto: / etc. are
// underlined for visibility but rejected at activation. See
// terminal-links.ts for the line scanner, modifier gate and scheme
// guard.
const linkProvider = term.registerLinkProvider({
provideLinks: (lineNumber, callback) => {
// xterm's `provideLinks` hands us a 1-based buffer line number
// (its own built-in ILinkProvider does `buffer.lines.get(e - 1)`,
// i.e. the public `bufferLineNumber` is 1-based while `getLine`
// takes a 0-based index). Passing `lineNumber` straight through
// would fetch the row *below* the one xterm asked us to scan, so
// the URL text would come from the wrong row while `range.y` still
// pointed at the requested row — links landed one line too high.
const line = term.buffer.active.getLine(lineNumber - 1)
if (line === undefined) {
callback(undefined)
return
}
const descriptors = buildTerminalLinks(line.translateToString(true), lineNumber)
if (descriptors.length === 0) {
callback(undefined)
return
}
callback(descriptors.map(descriptor => ({
range: descriptor.range,
text: descriptor.text,
activate: (event) => {
if (!shouldActivateTerminalLink(event)) return
openTerminalUrl(descriptor.text)
},
})))
},
})
// Re-theme in place when the app's scheme flips (tokens + palette).
const applyTheme = (): void => {
term.options.theme = xtermTheme()
term.refresh(0, term.rows - 1)
}
const schemeSub = subscribeColorScheme(applyTheme)
let socket: WebSocket | null = null
let closed = false
let retry: number | undefined
let failures = 0
const wsUrl = (): string => {
const url = new URL('/sidebar/ws/terminal', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
// Agent terminals attach by uuid (the host looks them up in the agent
// pty registry); UI-tab terminals attach by sessionId+tab (the host
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
if (isAgentTabId(tabId)) {
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
} else {
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
url.search = params.toString()
}
// Same construction the app's own downlink WebSockets use (new URL
// over location.origin + protocol swap): whatever the environment
// does to the app's websockets applies identically here.
return url.toString()
}
const sendResize = (): void => {
if (socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
}
const connect = (): void => {
if (closed) return
const url = wsUrl()
setLastUrl(url)
socket = new WebSocket(url)
socket.onopen = () => {
failures = 0
setConnected(true)
setFatal(null)
sendResize()
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') term.write(event.data)
}
socket.onclose = (event) => {
setConnected(false)
// node-pty dependency missing/broken (issue #140): the host closed
// with the short marker. Fetch the full repair details over HTTP —
// a WS close reason is capped at 123 bytes, too small for the
// pasteable command. A failed fetch falls back to the plain banner.
if (event.code === 1011 && event.reason === PTY_DEPS_MISSING) {
void api.terminalDeps().then((status) => {
if (status.ok) {
// The host recovered between the close and the fetch — the
// plain banner with a retry is the honest state.
setFatal(t('terminalDepsFailed'))
return
}
setFatal(null)
setDepsFatal(status)
}).catch(() => {
setFatal(t('terminalDepsFailed'))
})
return
}
// 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
}
// A server-side refusal carries a close code + reason; retrying it
// forever would only spin the banner, so surface it with a retry.
if (event.code === 1011 && event.reason !== '') {
setFatal(event.reason)
return
}
// Unreasoned drops (upgrade rejected, host down, mid-handshake
// refusal) normally recover on the next attempt; after a few
// consecutive failures stop spinning and show the close code.
failures += 1
if (failures >= FAILURE_LIMIT) {
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
setFatal(`${t('terminalConnectFailed')}${detail}`)
return
}
if (!closed) retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
socket?.close()
}
}
connectRef.current = connect
const inputSub = term.onData((data) => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
})
// Resize streams fire per layout frame during panel open/close
// animations; fit() measures glyphs, so coalesce to one fit per
// animation frame (same pattern as the core's frame-batcher, kept
// local — this view lives in the lazy terminal chunk and does not
// import core-bundle modules).
let resizeFrame: number | null = null
const observer = new ResizeObserver(() => {
if (resizeFrame !== null) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
})
})
observer.observe(host)
// Custom font prefs (the terminal card's secondary settings) apply LIVE:
// on any store change re-resolve and diff the two options, re-fitting
// when they moved (the grid dimensions may change with the font). The
// subscribe fires on every store change (tabs, panels…), so the diff is
// what keeps this cheap.
const fontSub = store.subscribe(() => {
const next = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
if (next.fontFamily !== term.options.fontFamily || next.fontSize !== term.options.fontSize) {
term.options.fontFamily = next.fontFamily
term.options.fontSize = next.fontSize
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
}
})
// The terminal must not be opened in a zero-size container: xterm's
// renderer creation fails there and the next Viewport refresh crashes
// reading `.dimensions` off the undefined renderer (blank terminal on
// WKWebView when the bottom panel's expand slide leaves the host at
// height 0; any display:none-hidden ancestor does the same). Defer
// open+fit until the host has a real size — writes arriving meanwhile
// are buffered by xterm's WriteBuffer and render once open, and
// FitAddon.fit() is a safe no-op before open. sendResize() here covers
// the deferred path where the socket may already be open with the
// default 80x24 dims.
const cancelOpen = openWhenSized(host, () => {
try {
term.open(host)
fit.fit()
sendResize()
} catch (error) {
console.error('[dsh-better-sidebar] xterm open failed:', error)
}
})
connect()
return () => {
closed = true
cancelOpen()
window.clearTimeout(retry)
observer.disconnect()
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame)
fontSub()
schemeSub()
inputSub.dispose()
// Three unmount cases, distinguished by the store's tab/open state and
// the active session id:
// 1. The tab was closed by the user (NOT in its session's state): send
// `{type:'close'}` — the host releases the pty immediately.
// 2. The user switched to another conversation (the tab IS still open
// in scope.sessionId's state, but the active session is now a
// different one): send `{type:'park'}` — the host keeps the pty
// alive indefinitely (no grace countdown), so switching back
// reattaches the SAME shell. Without this, the bare socket drop
// would start the 30s reconnect-grace countdown and kill the shell
// while the user is still actively working in the other session.
// 3. A same-session unmount (page refresh, crash, plugin teardown, a
// re-render that re-mounts the view): bare socket drop — the host's
// reconnect grace keeps the shell alive for a quick reconnect.
// Agent terminals follow the close-frame rule; their lifetime is owned
// by the agent, so a bare drop (case 3) already leaves them alive
// indefinitely — no park frame needed.
const tabStillOpen = store.tabOpen(scope.sessionId, tabId)
const sessionSwitched = store.getSnapshot().sessionId !== scope.sessionId
if (!tabStillOpen
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
} else if (tabStillOpen && sessionSwitched && !isAgentTabId(tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'park' }))
}
socket?.close()
linkProvider.dispose()
term.dispose()
connectRef.current = null
}
}, [scope.sessionId, scope.cwd, tabId, store])
return (
<div className={css.terminalWrap}>
{depsFatal !== null && (
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
)}
{fatal !== null && (
<div className={css.terminalBanner}>
{t('terminalError')}: {fatal}
{lastUrl !== null && <div className={css.terminalBannerUrl}>{lastUrl}</div>}
<button
type="button"
className={css.terminalRetry}
onClick={() => { setFatal(null); connectRef.current?.() }}
>
{t('terminalRetry')}
</button>
</div>
)}
{fatal === null && depsFatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
<div ref={hostRef} className={css.terminal} />
</div>
)
}
/**
* The node-pty dependency failure banner (issue #140): explains that the
* terminal's native dependency failed to load and shows the PASTEABLE repair
* command (bash / cmd / PowerShell) with a copy button — the user pastes it
* into a terminal where their DSH profile lives and runs it, then retries.
* Extracted as a standalone component for direct testing.
*/
export function TerminalDepsBanner(props: { deps: TerminalDepsInfo; onRetry: () => void }) {
const { deps, onRetry } = props
const [copied, setCopied] = useState(false)
const copy = async (): Promise<void> => {
const written = await writeClipboard(deps.command)
if (written) {
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className={css.terminalDepsBanner}>
<div className={css.terminalDepsTitle}>{t('terminalDepsFailed')}</div>
<div className={css.terminalDepsHint}>
{t('terminalDepsHint')}
{deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''}
</div>
<div className={css.terminalDepsCommandRow}>
<pre className={css.terminalRepairCommand}>{deps.command}</pre>
<button type="button" className={css.terminalRetry} onClick={() => { void copy() }} aria-label={t('copy')}>
{copied ? t('copied') : t('copy')}
</button>
</div>
{deps.note !== undefined && <div className={css.terminalDepsNote}>{deps.note}</div>}
<div className={css.terminalDepsActions}>
<button type="button" className={css.terminalRetry} onClick={onRetry}>
{t('terminalRetry')}
</button>
</div>
</div>
)
}
-579
View File
@@ -1,579 +0,0 @@
/**
* The code/markdown file viewer: a CodeMirror 6 editor with line wrapping,
* syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S
* save, and a preview/edit toggle for markdown files. Registered as the
* `code` (catch-all) and `markdown` built-in viewers; the editor tab host
* fetches the content through the fsRead strategy and passes it in props,
* so this component never fetches or dispatches — it only edits.
*
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
* row below the host's title bar, VSCode-style — unless the host passes
* `toolbar: 'host'` (the merged editor-explorer mode), in which case this
* component skips the row and reports state + registers commands through
* the FileViewerProps toolbar callbacks so the host's path-input header
* renders the controls instead.
*/
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { api, htmlUrl } from './api.ts'
import { markdownPreviewSource } from './markdown-frontmatter.ts'
import { rewriteLocalImageUrls } from './markdown-images.ts'
import { languageForPath } from './lang.ts'
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts'
import { useSelectionPopup } from './selection-popup.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { analyzeMarkdownHtml } from './markdown-html.ts'
import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from './MarkdownHtml.tsx'
import { MdToc } from './md-toc.tsx'
import { splitMermaidBlocks } from './mermaid-blocks.ts'
import { t } from './locales.ts'
import { HTML_IFRAME_SANDBOX } from './html-preview.ts'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
/** Per-file preview scroll memory. Module-level so it survives viewer
* remounts: the save-then-switch-to-preview reload (EditorHost #215 case B)
* rebuilds the whole TextEditor instance, and without this the preview
* would remount at the top. Keyed by session + path; a fresh entry reads 0
* (new file opens at the top), re-opens/toggles restore the last position. */
const previewScrollMemory = new Map<string, number>()
const previewScrollKey = (scope: { sessionId: string }, path: string): string => `${scope.sessionId}::${path}`
export function TextEditor(props: FileViewerProps) {
const { ctx, scope, path, viewerId, content, truncated } = props
const [mode, setMode] = useState<ViewMode>('preview')
/** The editor's current text (null while clean); preview renders this. */
const [draft, setDraft] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
const hostRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<CodeMirrorView | null>(null)
const savingRef = useRef(false)
/** The theme compartment of the current view (reconfigured on scheme flip). */
const themeCompRef = useRef<CmThemeCompartment | null>(null)
/** The app's resolved color scheme; the editor re-themes in place on flips. */
const [dark, setDark] = useState(() => isDarkScheme())
/** The markdown preview container (selection-containment + line lookup). */
const mdRef = useRef<HTMLDivElement>(null)
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
/** Preview scroll position across the preview<->edit toggle. The preview
* container re-mounts on every mode switch and its scrollTop lives on that
* element, so capture it on scroll and restore after each remount. Seeded
* from the module-level per-file memory so a full viewer rebuild
* (save-then-switch-to-preview reload) also keeps the position. */
const previewScrollRef = useRef(previewScrollMemory.get(previewScrollKey(scope, path)) ?? 0)
/** True while a programmatic restore is in flight; raw scroll events caused
* by the restore (or by the browser clamping a collapsed reload container
* to 0) must not overwrite the remembered position. */
const restoringRef = useRef(false)
/** Preview-side handoff data for the preview -> edit switch: the text at the
* top of the preview viewport (best-effort) plus the scroll ratio. Captured
* throttled on preview scroll; consumed when entering edit mode so the
* editor opens where the reader was instead of at the file top. */
const previewSyncRef = useRef<{ text: string | null; ratio: number }>({ text: null, ratio: 0 })
const anchorThrottleRef = useRef(false)
/**
* The floating "add to conversation" popup (viewport-anchored; null =
* hidden). The hook owns show/hide/commit plus the global dismissal
* listeners (outside mousedown, Escape, hidden tab/window, surface
* leaving the viewport) — see selection-popup.ts.
*/
const selectionPopup = useSelectionPopup({
onCommit: (insert) => { appendToDraft(ctx, scope.sessionId, insert) },
// The surface that must stay on screen: the markdown preview container
// in preview mode, the CodeMirror host otherwise.
getSurface: () => (markdown && mode === 'preview' ? mdRef.current : hostRef.current),
})
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
// A new file (tab switch) starts clean: fresh preview mode, no draft.
useEffect(() => {
setMode('preview')
setDraft(null)
setDirty(false)
setSaveState('idle')
selectionPopup.hide()
// hide() reads a live ref; the reset must fire only on a content (file)
// swap, and the hook object's identity churns on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content])
// A different file switches the remembered preview scroll position to that
// file's own entry (first open: none, so the preview starts at the top).
useEffect(() => {
previewScrollRef.current = previewScrollMemory.get(previewScrollKey(scope, path)) ?? 0
}, [scope, path])
// Create the CodeMirror editor once the content is loaded. The view owns
// the document; React only tracks dirty state through the update listener
// (the draft — the preview's text — is snapshotted from the live view on
// entering preview, not re-stringified per keystroke). For markdown the
// view stays mounted while previewing (hidden), so unsaved edits survive
// the preview/edit toggle. The theme + syntax colors live in a compartment
// so a scheme flip reconfigures only that part — the document, undo
// history and scroll position survive.
useEffect(() => {
if (content === undefined) return
const host = hostRef.current
if (host === null) return
const language = languageForPath(path)
const themeComp = new CmThemeCompartment()
themeCompRef.current = themeComp
const state = EditorState.create({
doc: content,
extensions: [
CodeMirrorView.lineWrapping,
lineNumbers(),
history(),
EditorState.tabSize.of(2),
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
cmSurfaceTheme,
themeComp.of(dark),
...(language !== null ? [language] : []),
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) {
setDirty(true)
}
}),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => { save(); return true },
},
...defaultKeymap,
...historyKeymap,
]),
// Selection popup (the code and markdown editors): a non-empty
// selection anchors the floating "add to conversation" button above
// its head. Scrolling (geometry/viewport change) or losing focus
// hides it; typing collapses the selection and hides it too.
...(viewerId === 'code' || viewerId === 'markdown' ? [
CodeMirrorView.updateListener.of((update) => {
if (update.geometryChanged || update.viewportChanged) {
selectionPopup.hide()
return
}
if (!update.view.hasFocus) {
selectionPopup.hide()
return
}
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
const sel = update.state.selection.main
if (sel.empty) {
selectionPopup.hide()
return
}
const text = update.state.sliceDoc(sel.from, sel.to)
if (text.trim() === '') {
selectionPopup.hide()
return
}
// Page coordinates (the document root may scroll); the popup is
// position:fixed, so convert to viewport coordinates.
const rect = update.view.coordsAtPos(sel.head)
if (rect === null) {
selectionPopup.hide()
return
}
const doc = update.state.doc
selectionPopup.show(
buildSelectionInsert(path, scope.cwd, {
start: doc.lineAt(sel.from).number,
end: doc.lineAt(sel.to).number,
}, text),
rect.left - window.scrollX + (rect.right - rect.left) / 2,
rect.top - window.scrollY,
)
}),
] : []),
],
})
const view = new CodeMirrorView({ state, parent: host })
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
themeCompRef.current = null
}
// The keymap's save() reads live refs; scope/path are stable for a
// tab's lifetime, and the dark flip is handled by the reconfigure
// effect below (recreating the view here would drop the draft).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content, path])
// Scheme flip: re-theme in place (the compartment holds only the
// scheme-dependent extensions; everything else is untouched).
useEffect(() => {
const view = viewRef.current
const themeComp = themeCompRef.current
if (view === null || themeComp === null) return
view.dispatch({ effects: themeComp.reconfigure(dark) })
}, [dark])
// The editor may have been display:none while previewing; re-measure when
// it becomes visible again (CodeMirror sizes itself on reveal). A mode
// flip also invalidates any anchored selection popup. When entering edit
// from a scrolled preview, the editor opens where the reader was: the line
// mapped from the text anchored at the preview viewport top (or, failing a
// unique text match, the proportional scroll position).
useEffect(() => {
selectionPopup.hide()
if (mode !== 'edit') return
const view = viewRef.current
if (view === null) return
const sync = previewSyncRef.current
if (!markdown) return
const doc = view.state.doc
let target: number | undefined
if (sync.text !== null) {
const lines = linesOfSelection(mdText, sync.text)
if (lines !== null) target = doc.line(Math.min(lines.start, doc.lines)).from
}
// ratio === 1 means the reader was at the very bottom (e.g. the last
// block's text is a repeated filler line that linesOfSelection rejects
// as ambiguous) — it must still land the editor at the bottom.
if (target === undefined && sync.ratio > 0 && sync.ratio <= 1) {
target = Math.max(1, Math.min(doc.length - 1, Math.round(doc.length * sync.ratio)))
}
if (target === undefined) return
// Position the editor by writing its OWN scroller directly (after a fresh
// measure) instead of CodeMirror's scrollIntoView: that path walks every
// scrollable ancestor — and even the window when the browser is zoomed
// (visualViewport < innerHeight) — to reveal the target, which dragged
// the whole sidebar up when the reader was at the very end of the
// document. A plain scrollTop write on the editor scroller can never
// touch anything outside the editor.
view.requestMeasure()
view.dispatch({ selection: { anchor: target } })
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const block = view.lineBlockAt(target)
view.scrollDOM.scrollTop = Math.max(0, block.top - 8)
// Force CodeMirror to re-measure and re-render its virtualized
// viewport at the NEW scroll position (its scroll-observer is async
// and can lag a direct write).
view.requestMeasure()
})
})
// The reveal reads the live document/view refs; only the flip into
// preview triggers it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode])
// Snapshot the live document into the draft whenever the preview needs
// it: entering preview (the markdown preview renders `draft ?? content`)
// and a content swap (the view was just re-created above, so the read is
// the new document — matching the reset-to-null of a clean tab). The
// updateListener used to re-stringify the WHOLE document on every
// keystroke (O(docLength) per key) for a draft only preview reads.
useEffect(() => {
const view = viewRef.current
setDraft(view === null ? null : view.state.doc.toString())
}, [mode, content])
const save = (): void => {
const view = viewRef.current
if (view === null || savingRef.current) return
savingRef.current = true
setSaveState('saving')
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
savingRef.current = false
setDraft(null)
setDirty(false)
setSaveState('saved')
}).catch(() => {
savingRef.current = false
setSaveState('failed')
})
}
/** The markdown source the preview renders (draft wins over saved content). */
const mdText = draft ?? content ?? ''
/** Preview-only source with a closed leading YAML frontmatter block hidden.
* The raw `mdText` stays untouched for editing, saving, and selection line
* lookup. All preview renderers share this source so plain Markdown,
* Mermaid, and documents containing raw HTML behave consistently. */
const previewMdText = markdown ? markdownPreviewSource(mdText) : mdText
// Re-apply the remembered preview scroll position whenever the preview
// container mounts or its content changes (mode flip back to preview, or a
// same-file reload — e.g. the save-then-switch-to-preview reload — which
// temporarily collapses the container and clamps scrollTop to 0). Restored
// in a before-paint layout effect so the user never sees the top flash.
useLayoutEffect(() => {
if (mode !== 'preview') return
const el = mdRef.current
if (el === null || previewScrollRef.current <= 0) return
if (el.scrollHeight <= el.clientHeight) return
if (el.scrollTop === previewScrollRef.current) return
restoringRef.current = true
el.scrollTop = previewScrollRef.current
requestAnimationFrame(() => { restoringRef.current = false })
}, [mode, previewMdText])
/** The preview source with local image destinations rewritten to absolute
* media URLs (see {@link rewriteLocalImageUrls}). */
const previewText = markdown
? rewriteLocalImageUrls(previewMdText, scope, path, window.location.origin)
: previewMdText
/** md/mermaid block split for the preview (mermaid fences lift out). Split
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
const mdBlocks = useMemo(
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
[markdown, mode, previewMdText],
)
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null for
* every markdown preview, so the render below always takes the split
* renderer — its markdown runs rewrite local image destinations internally
* (see MarkdownHtml.tsx). The legacy single-pass branches (fed the
* pre-rewritten `previewText`) are dead in the current wiring. */
const htmlInfo = useMemo(
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
[markdown, mode, previewMdText],
)
const hasMermaid = useMemo(
() => htmlInfo !== null
? htmlInfo.segments.some((segment) => segment.kind === 'markdown'
&& splitMermaidBlocks(segment.text).some((block) => block.kind === 'mermaid'))
: mdBlocks.some((block) => block.kind === 'mermaid'),
[htmlInfo, mdBlocks],
)
/** The media context for the split renderer (local-src rewriting inside
* sanitized HTML). Memoized on primitives: MarkdownDocument sanitizes per
* `media` identity, so a fresh object per render would re-sanitize every
* keystroke. */
const htmlMedia = useMemo<MarkdownHtmlMedia>(
() => ({ scope, path, origin: window.location.origin }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[scope.sessionId, scope.cwd, path],
)
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
/**
* Selection popup for the markdown preview: a mouse-up inside the preview
* container anchors the floating "add to conversation" button above the
* selection. Line numbers come from a best-effort reverse-search of the
* selected text in the source ({@link linesOfSelection} — an ambiguous or
* missing hit omits them). The button's own mousedown preventDefaults so
* the selection survives until the click commits.
*/
const handlePreviewMouseUp = (): void => {
const sel = window.getSelection()
if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
selectionPopup.hide()
return
}
const host = mdRef.current
if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
selectionPopup.hide()
return
}
const text = sel.toString()
if (text.trim() === '') {
selectionPopup.hide()
return
}
const rect = sel.getRangeAt(0).getBoundingClientRect()
const lines = linesOfSelection(mdText, text)
selectionPopup.show(
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
rect.left + rect.width / 2,
rect.top,
)
}
const editable = content !== undefined
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
// Per-feature sandbox escape hatch: the global side card setting (warned)
// plus a per-surface temporary unlock. The unlock state starts at the
// "default unsafe" pref so a preview can open straight into the red
// unsandboxed state (still restorable from the status row). With the
// sandbox OFF the preview iframe drops its sandbox attribute entirely —
// the previewed page then runs on the GUI's own origin with full session
// access.
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
// Host-toolbar mode (the merged editor header renders the controls): skip
// the own toolbar row, report the state after every relevant render (the
// JSON key guards redundant calls), and register the commands on mount.
const hostToolbar = props.toolbar === 'host'
const lastToolbarRef = useRef('')
useEffect(() => {
if (!hostToolbar) return
const state: EditorToolbarState = { modes: markdown || html, mode, dirty, editable, saveState }
const key = JSON.stringify(state)
if (lastToolbarRef.current === key) return
lastToolbarRef.current = key
props.onToolbarState?.(state)
})
useEffect(() => {
if (!hostToolbar) return
// `save` reads live refs only, and `setMode` is the stable state setter —
// registering this render's closures is safe for the mount's lifetime.
props.onToolbarControls?.({ setMode, save })
return () => { props.onToolbarControls?.(null) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostToolbar])
return (
<>
{!hostToolbar && (
<div className={css.editorHeader}>
{(markdown || html) && (
<div className={css.editorModeToggle}>
<button
type="button"
className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
onClick={() => { setMode('preview') }}
>
{t('preview')}
</button>
<button
type="button"
className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
onClick={() => { setMode('edit') }}
>
{t('edit')}
</button>
</div>
)}
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
{editable && (
<button
type="button"
className={css.iconButton}
aria-label={t('save')}
title={`${t('save')} (Ctrl/Cmd+S)`}
onClick={save}
>
<IconCheckOutline16 />
</button>
)}
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
</div>
)}
{editable && (
<>
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
<div
className={clsx(css.editorCm, (markdown || html) && mode === 'preview' && css.editorCmHidden)}
ref={hostRef}
/>
</>
)}
{markdown && mode === 'preview' && (
<div
className={css.editorMd}
ref={mdRef}
onMouseUp={handlePreviewMouseUp}
onScroll={(event) => {
const el = event.currentTarget
if (!restoringRef.current && el.scrollHeight > el.clientHeight) {
previewScrollRef.current = el.scrollTop
previewScrollMemory.set(previewScrollKey(scope, path), el.scrollTop)
}
if (!anchorThrottleRef.current) {
anchorThrottleRef.current = true
setTimeout(() => { anchorThrottleRef.current = false }, 120)
const ratio = el.scrollHeight > el.clientHeight
? el.scrollTop / (el.scrollHeight - el.clientHeight)
: 0
// The first block below the viewport's top edge, chosen with
// layout coordinates (caretRangeFromPoint needs an in-viewport
// point and returns null when the panel is partially off-screen).
// Its text is the anchor the editor syncs to on preview -> edit.
let text: string | null = null
const base = el.getBoundingClientRect()
const blocks = el.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li')
for (const block of blocks) {
if (block.getBoundingClientRect().top - base.top + el.scrollTop >= el.scrollTop - 2) {
const t = (block.textContent ?? '').replace(/[ \t\r\n]+/g, ' ').trim()
if (t.length >= 8) text = t
break
}
}
previewSyncRef.current = { text, ratio }
}
selectionPopup.hide()
}}
>
{/* The fence copy-button labels must come from this plugin's own
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
fall back to hardcoded Chinese otherwise (same pattern as the
chat's AssistantMarkdown). Render-time t() keeps them following
the active locale on live switches. Plain markdown (no HTML)
renders exactly as before — one MarkdownText pass for the whole
document, or the mermaid lazy chunk (single markdown parse;
cross-fence references/footnotes stay intact) when a mermaid
fence exists. Documents containing HTML (block runs or inline
tags) render through the split document renderer: markdown runs
keep the MarkdownText/mermaid path while raw-HTML runs render
as sanitized DOM (see markdown-html.tsx). */}
{/* The outline button rides on top of the preview scroll container
(sticky, zero-height — first child so it pins from the very
top) once the document has enough headings. */}
<MdToc />
{htmlInfo !== null
? <MarkdownDocument info={htmlInfo} media={htmlMedia} codeLabels={codeLabels} />
: hasMermaid
? <LazyMermaidMarkdown text={previewText} codeLabels={codeLabels} />
: <MarkdownText {...markdownTextProps(previewText, codeLabels)} />}
</div>
)}
{html && mode === 'preview' && (
<>
<SandboxStatusBar
sandboxed={!htmlNoSandbox}
local={localUnlock}
dangerCopy={t('htmlNoSandboxWarning')}
onUnlock={() => { setLocalUnlock(true) }}
onRestore={() => { setLocalUnlock(false) }}
/>
{/* Route-src (never srcdoc — a srcdoc frame inherits the parent
origin when unsandboxed; the route URL keeps the frame
cross-origin by construction). The preview shows the SAVED
file; the draft is only visible in edit mode. */}
<iframe
className={css.editorHtml}
src={htmlUrl(scope, path)}
sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
referrerPolicy="no-referrer"
allow=""
title={path}
/>
</>
)}
{selectionPopup.popup !== null && createPortal(
<button
type="button"
ref={selectionPopup.buttonRef}
className={css.selectionPopup}
style={{ left: selectionPopup.popup.left, top: selectionPopup.popup.top }}
// Keep the selection (and CodeMirror focus) alive until the click
// commits — without this the popup unmounts before click lands.
onMouseDown={(event) => { event.preventDefault() }}
onClick={selectionPopup.commit}
>
{t('addToConversation')}
</button>,
document.body,
)}
</>
)
}
-300
View File
@@ -1,300 +0,0 @@
/**
* The files window's tree surface: a global file-name search box on top
* (300ms debounce; an in-flight search is aborted by the next keystroke)
* over either the shared controlled FileTree (empty query) or the flat
* result list (relative paths; click opens through the caller's mode-aware
* open). Owns its refresh tick: the icon next to the search input clears
* the tree cache. EditorHost docks it as the tab's right panel (wrapped in
* a drag-resize handle) and provides the file context-menu open escapes.
*
* Uploads (header pickers, the tree's drag-drop and "upload here" menu)
* all funnel through here: one session at a time, shown in a full-window
* progress overlay with cancel, followed by a tree refresh and a one-line
* hint under the search row (success fades, failures and cancels stay).
* OS file drags are shielded at the panel host (see Sidebar.tsx), so a
* drop over the file window uploads here and never reaches DSH's chat
* intake.
*/
import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'
import clsx from 'clsx'
import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import type { SidebarStore } from './state.ts'
import { FileTree } from './FileTree.tsx'
import { IconUploadOutline16 } from './icons.tsx'
import type { OpenWithTarget } from './open-with.ts'
import { t } from './locales.ts'
import { resolveSidebarPath } from './produced-files.ts'
import { UploadOverlay } from './UploadOverlay.tsx'
import {
summarizeResults, uploadHintText, uploadItemsFromFiles, uploadToDir,
UPLOAD_HINT_MS, type UploadItem,
} from './upload.ts'
import css from './sidebar.module.css'
/** One in-flight upload session (the overlay's progress source). */
interface UploadSession {
dir: string
done: number
total: number
/** Relative path of the file being uploaded ('' when none is in flight). */
current: string
controller: AbortController
}
export function TreePanel(props: {
sessionId: string
cwd: string | undefined
/** The sidebar store (passed through to the tree's fence-refusal notice). */
store: SidebarStore
expanded: string[]
revealed: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
/** File context-menu "open in a new tab" (passed through to FileTree). */
onOpenFileNewTab?: (path: string) => void
/** File context-menu "open to the side" (passed through to FileTree). */
onOpenFileSide?: (path: string) => void
/** The "open with" menu surface (passed through to FileTree; absent →
* the whole section is hidden). */
openWithTargets?: OpenWithTarget[]
openWithPinned?: string[]
openWithSsh?: boolean
onOpenWith?: (targetId: string, path: string) => void
onToggleOpenWithPin?: (targetId: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
/** A tree rename landed (passed through to FileTree for tab retargeting). */
onPathRenamed?: (oldPath: string, newPath: string) => void
/** A tree delete landed (passed through to FileTree for tab closing). */
onPathDeleted?: (path: string, isDir: boolean) => void
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
}) {
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full } = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
// The tree caches loaded directories per refresh tick, so content changed
// outside DSH (another editor, a sync tool) stays stale until the manual
// refresh click. Re-focusing the window bumps the tick automatically, and
// integrations can force a refresh by dispatching a bubbling
// `dsh-sidebar:refresh-files` event on `window`.
useEffect(() => {
const bump = (): void => { setRefreshTick(tick => tick + 1) }
window.addEventListener('focus', bump)
window.addEventListener('dsh-sidebar:refresh-files', bump)
return () => {
window.removeEventListener('focus', bump)
window.removeEventListener('dsh-sidebar:refresh-files', bump)
}
}, [])
/** One-line upload status under the search row ('' hides the hint). */
const [uploadStatus, setUploadStatus] = useState('')
/** Whether the status line is a failure/cancel (error color, stays visible). */
const [uploadFailed, setUploadFailed] = useState(false)
/** The in-flight upload session (null → no overlay, buttons enabled). */
const [upload, setUpload] = useState<UploadSession | null>(null)
/** True between the cancel click and the session settling (button disabled). */
const [cancelling, setCancelling] = useState(false)
/** Set by cancelUpload; the settle path shows 'upload cancelled' instead of
* summarizing the partial results. */
const cancelledRef = useRef(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null)
/** Start one upload session into `dir` (absolute, inside the workspace). */
const startUpload = (dir: string, items: UploadItem[]): void => {
if (items.length === 0 || cwd === undefined || upload !== null) return
cancelledRef.current = false
const controller = new AbortController()
setUploadFailed(false)
setUploadStatus(uploadHintText(0, items.length, '', dir, t))
setUpload({ dir, done: 0, total: items.length, current: '', controller })
void uploadToDir({ sessionId, cwd }, dir, items, (done, total, current) => {
if (current !== '') setUploadStatus(uploadHintText(done, total, current, dir, t))
setUpload(session => session === null ? session : { ...session, done, total, current })
}, controller.signal).then((results) => {
setUpload(null)
setCancelling(false)
// Reload the tree whatever the outcome: files may have landed before a
// cancel, and failures leave whatever did succeed visible.
setRefreshTick(tick => tick + 1)
if (cancelledRef.current) {
setUploadStatus(t('uploadCancelled'))
setUploadFailed(true)
return
}
const status = summarizeResults(results, t)
setUploadStatus(status)
setUploadFailed(results.some(result => !result.ok))
// Success messages are transient; failures stay until the next action.
if (results.every(result => result.ok)) {
window.setTimeout(() => {
setUploadStatus(current => current === status ? '' : current)
}, UPLOAD_HINT_MS)
}
})
}
/** Cancel the in-flight upload (aborts the request; the host drops its temp). */
const cancelUpload = (): void => {
if (upload === null || cancelling) return
cancelledRef.current = true
setCancelling(true)
upload.controller.abort()
}
const folderInputProps = { webkitdirectory: '' } as InputHTMLAttributes<HTMLInputElement>
const needle = query.trim()
useEffect(() => {
if (needle === '') {
setResults(null)
setError(null)
return
}
const controller = new AbortController()
const timer = window.setTimeout(() => {
api.fsSearch({ sessionId, cwd }, needle, controller.signal).then((found) => {
setResults(found)
setError(null)
}).catch((failure: unknown) => {
if (controller.signal.aborted) return
setResults(null)
setError(failure instanceof Error ? failure.message : String(failure))
})
}, 300)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [sessionId, cwd, needle])
const busy = upload !== null
return (
<div className={clsx(css.editorTreePanel, full === true && css.editorTreePanelFull)}>
<div className={css.editorTreeSearch}>
<input
className={css.editorSearchInput}
value={query}
placeholder={t('editorSearchPlaceholder')}
spellCheck={false}
onChange={(event) => { setQuery(event.target.value) }}
/>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { setRefreshTick(tick => tick + 1) }}
>
<IconRefreshOutline16 size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('uploadFiles')}
title={t('uploadFiles')}
disabled={busy}
onClick={() => { fileInputRef.current?.click() }}
>
<IconUploadOutline16 size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('uploadFolder')}
title={t('uploadFolder')}
disabled={busy}
onClick={() => { folderInputRef.current?.click() }}
>
<IconFolderOpen16 size={14} />
</button>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(event) => {
if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
event.target.value = ''
}}
/>
<input
ref={folderInputRef}
type="file"
multiple
{...folderInputProps}
style={{ display: 'none' }}
onChange={(event) => {
if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
event.target.value = ''
}}
/>
</div>
{uploadStatus !== '' && (
<div className={clsx(css.editorSearchHint, uploadFailed && css.editorError)} title={uploadStatus}>{uploadStatus}</div>
)}
{needle === '' ? (
<FileTree
sessionId={sessionId}
cwd={cwd}
store={store}
expanded={expanded}
revealed={revealed}
onToggle={onToggle}
onOpenFile={onOpenFile}
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithPinned}
openWithSsh={openWithSsh}
onOpenWith={onOpenWith}
onToggleOpenWithPin={onToggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
refreshTick={refreshTick}
onUploadRequest={startUpload}
busy={busy}
/>
) : (
<div className={css.explorerBody}>
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
{error === null && results !== null && results.matches.length === 0 && (
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
)}
{error === null && results !== null && results.matches.map(rel => (
<button
key={rel}
type="button"
className={css.editorSearchResult}
title={rel}
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
>
{rel}
</button>
))}
{error === null && results?.truncated === true && (
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
)}
</div>
)}
{upload !== null && (
<UploadOverlay
dir={upload.dir}
done={upload.done}
total={upload.total}
current={upload.current}
onCancel={cancelUpload}
cancelling={cancelling}
/>
)}
</div>
)
}
-62
View File
@@ -1,62 +0,0 @@
/**
* Full-window upload progress over the files tree: a blurred scrim (same mask
* token as the repo's Modal primitive) with a card showing the target
* directory, file-level progress, and a cancel button. Esc cancels too —
* clicking the scrim does not, so a stray click can never abort an upload.
* Rendered inside TreePanel (absolute inset-0), so it covers only the file
* window and never the conversation column.
*/
import { useEffect, type ReactNode } from 'react'
import { IconUploadOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import { uploadHintText } from './upload.ts'
import css from './sidebar.module.css'
export function UploadOverlay(props: {
/** Absolute upload directory (the session workspace or a tree directory). */
dir: string
done: number
total: number
/** Relative path of the file being uploaded ('' when none is in flight). */
current: string
onCancel: () => void
/** True while cancellation is in flight (disables the cancel button). */
cancelling?: boolean
}): ReactNode {
const { dir, done, total, current, onCancel, cancelling } = props
// Esc cancels (MermaidModal-style native listener; the overlay is not a
// focus-trapped dialog, so a window listener is the honest scope).
useEffect(() => {
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') onCancel()
}
window.addEventListener('keydown', onKey)
return () => { window.removeEventListener('keydown', onKey) }
}, [onCancel])
const percent = total === 0 ? 0 : Math.min(100, Math.round((done / total) * 100))
return (
<div className={css.uploadOverlay} role="dialog" aria-modal="true" aria-label={t('uploadingTo', { dir })}>
<div className={css.uploadOverlayCard}>
<div className={css.uploadOverlayTitle} title={dir}>
<IconUploadOutline16 size={16} />
<span>{t('uploadingTo', { dir })}</span>
</div>
<div
className={css.uploadOverlayProgress}
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={done}
aria-valuetext={t('uploadProgress', { done, total, name: current })}
>
<div className={css.uploadOverlayProgressFill} style={{ width: `${percent}%` }} />
</div>
<div className={css.uploadOverlayStatus}>{uploadHintText(done, total, current, dir, t)}</div>
<button type="button" className={css.uploadOverlayCancel} disabled={cancelling} onClick={onCancel}>
{t('cancel')}
</button>
</div>
</div>
)
}
-205
View File
@@ -1,205 +0,0 @@
/**
* The "add plugin" modals (Side card settings → the dashed cards at the
* end of the 侧边栏内容 / 文件预览 grids): declare that the sidebar's
* extension points — tab pages and file previewers — are open to plugins
* (registered through `ctx.betterSidebar`), point at the GitHub topic page
* for discovery, and show the repo's recommended plugin catalog of the
* matching kind (name / url / description / install script).
*
* Per entry there are two actions:
* - 「跳转」opens the plugin's repo in a REAL new browser tab (window.open
* — a button, so the sidebar link takeover cannot reroute it);
* - 「安装」only COPIES the install script to the clipboard (writeClipboard)
* with a transient "已复制" feedback on the button — the user pastes and
* runs it wherever they manage their DSH profile. No terminal is opened,
* nothing is closed, nothing can fail outward.
*
* The body is extracted as {@link PluginListBody} so tests render it
* directly — the Modal primitive runs hooks unconditionally, so an open
* Modal must never be renderToString'd (same rule as the settingsFor popup
* in SideCardSection); the modal itself mounts only while open.
*/
import { useState, type ReactNode } from 'react'
import { Modal, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import type { BetterSidebarService } from './service.ts'
import { PLUGIN_TOPIC_URL, type PluginEntry } from './plugins-shared.ts'
import { builtinTabPlugins } from './plugins-tabs.ts'
import { builtinViewerPlugins } from './plugins-viewers.ts'
import { t } from './locales.ts'
import css from './SideCardSection.module.css'
/** Which extension point the modal is adding a plugin for. */
export type PluginKind = 'tab' | 'viewer'
/** The catalog of one kind (kept in two repo files: plugins-tabs.ts /
* plugins-viewers.ts). */
function catalogOf(kind: PluginKind): readonly PluginEntry[] {
return kind === 'tab' ? builtinTabPlugins : builtinViewerPlugins
}
/** How long the "已复制" feedback stays on the copy button. */
const COPIED_FEEDBACK_MS = 1500
/** The modal body: the GitHub topic button + the recommended plugin list
* with per-entry jump/copy buttons (extracted for direct testing). */
export function PluginListBody(props: { service: BetterSidebarService; kind: PluginKind }) {
const { kind } = props
// Which entry's copy button currently shows the "已复制" feedback.
const [copiedId, setCopiedId] = useState<string | null>(null)
// Live catalog filter (name / id / description). A free-text search keeps
// the list usable once the catalogs grow beyond a handful of entries:
// every keystroke narrows the rendered rows, so a long catalog never
// becomes a wall of scrolling.
const [query, setQuery] = useState('')
const catalog = catalogOf(kind)
const needle = query.trim().toLowerCase()
const matches = (entry: PluginEntry): boolean => {
if (needle === '') return true
const name = typeof entry.name === 'function' ? entry.name() : entry.name
const description = typeof entry.description === 'function' ? entry.description() : entry.description
return name.toLowerCase().includes(needle)
|| entry.id.toLowerCase().includes(needle)
|| description.toLowerCase().includes(needle)
}
const filtered = catalog.filter(matches)
// Group by the optional category (stable order: category order of first
// appearance, uncategorized entries last under the plain list).
const groups = new Map<string | undefined, PluginEntry[]>()
for (const entry of filtered) {
const key = entry.category === undefined ? undefined : (typeof entry.category === 'function' ? entry.category() : entry.category)
const list = groups.get(key)
if (list === undefined) groups.set(key, [entry])
else list.push(entry)
}
/** Copy the entry's install script to the clipboard and flash the button's
* "已复制" label for a moment. The feedback ONLY appears after a
* successful write — when the clipboard is unavailable or denied
* (writeClipboard resolves false) nothing is shown, so the user is never
* told to paste a command that was not placed on the clipboard. Never
* closes anything, never throws outward. */
const copy = async (entry: PluginEntry): Promise<void> => {
const written = await writeClipboard(entry.install)
if (!written) return
setCopiedId(entry.id)
window.setTimeout(() => {
setCopiedId(current => (current === entry.id ? null : current))
}, COPIED_FEEDBACK_MS)
}
/** Open the plugin's repo in a REAL new browser tab (window.open — a
* button, so the sidebar link takeover cannot reroute it). */
const jump = (entry: PluginEntry): void => {
window.open(entry.url, '_blank', 'noopener')
}
/** One catalog row (extracted so the group render stays flat). The name
* resolves like the description (string or () => string) so it follows
* the active locale; a plain-string entry keeps its raw name. */
const renderEntry = (entry: PluginEntry): ReactNode => {
const name = typeof entry.name === 'function' ? entry.name() : entry.name
return (
<div key={entry.id} className={css.pluginEntry}>
<div className={css.pluginEntryHead}>
{/* The name is a BUTTON on the same window.open path as the
jump button: as an anchor it would be caught by the
document-capture link takeover (which ignores
target=_blank) and land in the sidebar browser. */}
<button
type="button"
className={css.pluginName}
aria-label={`${t('openPlugin')}: ${name}`}
onClick={() => { jump(entry) }}
>
{name}
</button>
<span className={css.pluginEntryActions}>
<button
type="button"
className={css.pluginJumpBtn}
aria-label={`${t('openPlugin')}: ${name}`}
onClick={() => { jump(entry) }}
>
{t('openPlugin')}
</button>
<button
type="button"
className={css.pluginCopyBtn}
aria-label={`${t('copyInstall')}: ${name}`}
onClick={() => { copy(entry) }}
>
{copiedId === entry.id ? t('copied') : t('copy')}
</button>
</span>
</div>
<div className={css.pluginDesc}>
{typeof entry.description === 'function' ? entry.description() : entry.description}
</div>
<code className={css.pluginInstall}>{entry.install}</code>
</div>
)
}
return (
<div className={css.pluginList}>
<button
type="button"
className={css.pluginTopicBtn}
onClick={() => { window.open(PLUGIN_TOPIC_URL, '_blank', 'noopener') }}
>
{t('addPluginsBrowseMore')}
</button>
<input
type="search"
className={css.pluginSearch}
placeholder={t('addPluginsSearch')}
aria-label={t('addPluginsSearch')}
value={query}
onChange={event => { setQuery(event.currentTarget.value) }}
/>
<div className={css.groupHeading}>
<span>{t('addPluginsRecommended')}</span>
<span className={css.count}>{filtered.length}</span>
</div>
{catalog.length === 0 ? (
<div className={css.pluginEmpty}>{t('addPluginsEmpty')}</div>
) : filtered.length === 0 ? (
<div className={css.pluginEmpty}>{t('addPluginsNoMatch')}</div>
) : (
<div className={css.pluginEntries}>
{[...groups.entries()].map(([category, entries]) => (
<div key={category ?? '\u0000'} className={css.pluginGroup}>
{category !== undefined && (
<div className={css.pluginGroupHeading}>{category}</div>
)}
{entries.map(renderEntry)}
</div>
))}
</div>
)}
</div>
)
}
/** The modal itself (mounted only while open — see the module comment). */
export function AddPluginModal(props: { service: BetterSidebarService; onClose: () => void; kind: PluginKind }) {
const { service, onClose, kind } = props
return (
<Modal
open
onClose={onClose}
title={kind === 'tab' ? t('addPluginsTabCard') : t('addPluginsViewerCard')}
description={kind === 'tab' ? t('addPluginsTabDesc') : t('addPluginsViewerDesc')}
closeLabel={t('close')}
className={css.pluginModal}
footer={(
<button type="button" className={css.done} onClick={onClose}>
{t('settingsDone')}
</button>
)}
>
<PluginListBody service={service} kind={kind} />
</Modal>
)
}
-453
View File
@@ -1,453 +0,0 @@
/**
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
* `/sidebar/api/<method>` with the sessionId and — when known — the session's
* cwd from the client's own list summary. The host prefers its attached
* session header and uses the summary cwd only while the session is still
* hydrating at page load (a detached session would otherwise fail the
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidebarSessionEvent } from '../context-types.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message)
}
}
/**
* Whether a wire failure is the workspace fence refusing a path outside the
* session workspace (the host message reads `path "..." is outside
* workspace`). The request-trust fence answers code `forbidden` with the
* bare message 'forbidden', so the message fragment — not the code alone —
* identifies this case.
*/
export function isOutsideWorkspaceError(error: unknown): boolean {
return error instanceof SidebarApiError && isOutsideWorkspaceMessage(error.message)
}
/** Message-level variant for surfaces that stored the raw text (file-tree level errors). */
export function isOutsideWorkspaceMessage(message: string): boolean {
return message.includes('outside workspace')
}
/** Explorer row (host fs-tree shape). */
export interface FsEntry {
name: string
path: string
isDir: boolean
hidden: boolean
/** Whether the row is a symlink; `isDir` then describes the link's target. */
isSymlink: boolean
/** For symlinks: the target is missing or unreadable (stat failed). */
broken: boolean
}
/** Git status entry (host git shape). */
export interface GitStatusEntry {
path: string
xy: string
}
/** Git status snapshot. */
export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
/** True when the host capped `entries` (huge untracked set); the panel
* shows a truncation notice instead of freezing (#369). */
truncated?: boolean
root?: string
repositories?: string[]
}
/** One linked Git checkout. */
export interface GitWorktree {
path: string
branch: string
current: boolean
changes: number
}
/** One git log row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
hash: string
/** Full 40-char hash (advanced operations). */
hashFull: string
subject: string
author: string
/** ISO 8601 author date (`%ai`). */
date: string
/** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
refs: string
}
/** Text read result. */
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
/** Binary read result (no content; images load through the media route).
* `head` carries the first bytes (base64) for viewer detect sniffing. */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
/**
* One jobs.output response: the output the MODEL has read so far for the
* job (replayed from the owner session's event log — the model's
* job_output cursor is never touched, so the pane can never steal the
* agent's bytes). `read` is false until the model actually called
* job_output for the job.
*/
export interface JobOutputResult {
text: string
/** True when the host capped the text at its output limit. */
truncated: boolean
/** Whether the model has read the job at least once. */
read: boolean
}
/** The `subagents.live` response: running child id → latest activity. */
export type SubagentLiveResult = { live: Record<string, LastActivity> }
/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
export type TerminalDepsStatus =
| { ok: true }
| {
ok: false
/** The require-time error message (module missing, native binding broken…). */
cause: string
/** The pasteable repair command (terminal/cmd). */
command: string
/** The detected profile name (null when undetected → the command defaults to web). */
profile: string | null
/** Optional supplementary hint (fallback command only). */
note?: string
}
/**
* Parse one `/sidebar` JSON response envelope into its value. A non-ok
* status, an unparseable body, or any shape other than `{ok: true, value}`
* surfaces as {@link SidebarApiError} carrying the wire code (falling back
* to the HTTP status). Shared by the JSON api route and the raw upload
* route, whose envelopes are identical.
*/
async function readEnvelope<T>(response: Response): Promise<T> {
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
= await response.json().catch(() => null)
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
throw new SidebarApiError(
parsed?.error?.code ?? 'http',
parsed?.error?.message ?? `HTTP ${response.status}`,
)
}
return parsed.value as T
}
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
try {
response = await fetch(`/sidebar/api/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
} catch (error) {
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
}
return readEnvelope<T>(response)
}
/**
* Upload one file to the sidebar's raw upload route: the File goes straight
* into the POST body (no JSON/base64 re-encoding — the host streams it into
* the workspace). Failure surfaces as {@link SidebarApiError} with the wire
* code, exactly like every `/sidebar/api` call. An aborted `signal` rejects
* with the DOMException as-is (the caller decides whether that is an error).
*/
async function fetchUpload<T>(
scope: SessionScope,
dir: string,
relativePath: string,
body: Blob,
signal?: AbortSignal,
): Promise<T> {
const params = new URLSearchParams({ sessionId: scope.sessionId, dir, relativePath })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
let response: Response
try {
response = await fetch(`/sidebar/upload?${params.toString()}`, {
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
body,
signal,
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
}
return readEnvelope<T>(response)
}
/** One request's session scope: the conversation id plus its cwd when known. */
export interface SessionScope {
sessionId: string
/** The session's working directory from the client list summary (optional). */
cwd?: string
/** Selected Git repository when cwd is a workspace container. */
repoRoot?: string
}
/** Fold a scope into a JSON payload ({cwd} only when present). */
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
return {
sessionId: scope.sessionId,
...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}),
...(scope.repoRoot !== undefined && scope.repoRoot !== '' ? { repoRoot: scope.repoRoot } : {}),
...extra,
}
}
/** Add a linked-worktree selection to a scoped Git request. The host validates
* membership before using it as a command cwd. */
function gitPayload(scope: SessionScope, worktree: string | undefined, extra: Record<string, unknown>): Record<string, unknown> {
return scopePayload(scope, { ...(worktree !== undefined && worktree !== '' ? { worktree } : {}), ...extra })
}
/** One external-open request from the file tree. */
type OpenExternalPayload =
| { action: 'reveal'; path: string }
| { action: 'url'; url: string }
/** The host route's success shape. */
type OpenExternalResult = { started: boolean }
/**
* Remote VSCode-family URLs must be consumed on the browser/client machine:
* the DSH host can be a headless remote server with no editor or DISPLAY.
* Local editor URLs and reveal actions still belong to the host opener.
*/
function shouldOpenExternalOnClient(payload: OpenExternalPayload): payload is { action: 'url'; url: string } {
if (payload.action !== 'url') return false
let parsed: URL
try {
parsed = new URL(payload.url)
} catch {
return false
}
return parsed.protocol !== 'http:'
&& parsed.protocol !== 'https:'
&& parsed.hostname === 'vscode-remote'
&& parsed.pathname.startsWith('/ssh-remote+')
}
/**
* Dispatch an external-open request to the correct machine. SSH remote-editor
* URLs stay in the synchronous user-click chain and navigate the client so
* its registered vscode:// / cursor:// handler can launch. Everything else
* keeps using the DSH host route.
*/
function openExternal(payload: OpenExternalPayload): Promise<OpenExternalResult> {
if (!shouldOpenExternalOnClient(payload)) {
return call<OpenExternalResult>('open.external', payload)
}
try {
window.location.assign(payload.url)
return Promise.resolve({ started: true })
} catch (error) {
return Promise.reject(error)
}
}
/** The sidebar API surface (session scope threaded through every call). */
export const api = {
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
/** Global recursive file-name search rooted at the session cwd (the editor
* side panel's search box); matches are cwd-relative '/'-separated paths. */
fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal),
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
/** Rename one tree row within its directory (single-segment name; the
* server refuses existing destinations, the workspace root, and — while
* the fence is armed — anything resolving outside the workspace). */
fsRename: (scope: SessionScope, path: string, name: string) =>
call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })),
/** Permanently delete one tree row (recursive for directories; a symlink
* row unlinks the link only). The UI confirms before calling this. */
fsRemove: (scope: SessionScope, path: string) =>
call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
* `relativePath`); the host streams it under the session workspace. */
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
fetchUpload<{ path: string; size: number }>(scope, dir, relativePath, body, signal),
gitWorktrees: (scope: SessionScope, signal?: AbortSignal) =>
call<GitWorktree[]>('git.worktrees', scopePayload(scope, {}), signal),
gitStatus: (scope: SessionScope, worktree?: string, signal?: AbortSignal) =>
call<GitStatusResult>('git.status', gitPayload(scope, worktree, {}), signal),
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, worktree?: string, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}), staged }), signal),
gitStage: (scope: SessionScope, path?: string, worktree?: string) =>
call<{ ok: true }>('git.stage', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}) })),
gitUnstage: (scope: SessionScope, path?: string, worktree?: string) =>
call<{ ok: true }>('git.unstage', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}) })),
gitCommit: (scope: SessionScope, message: string, worktree?: string) =>
call<{ ok: true }>('git.commit', gitPayload(scope, worktree, { message })),
gitBranch: (scope: SessionScope, worktree?: string, signal?: AbortSignal) =>
call<{ current: string; names: string[] }>('git.branch', gitPayload(scope, worktree, {}), signal),
gitCheckout: (scope: SessionScope, branch: string, worktree?: string) =>
call<{ ok: true }>('git.checkout', gitPayload(scope, worktree, { branch })),
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
gitLog: (scope: SessionScope, count?: number, skip?: number, worktree?: string, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', gitPayload(scope, worktree, {
...(count !== undefined ? { count } : {}),
...(skip !== undefined ? { skip } : {}),
}), signal),
/** Full patch text of one commit (diff display for the history rows). */
gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) =>
call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal),
/** One file's content at a revision (`git show <rev>:<path>`); null when the
* revision has no such path. The diff views' on-demand hunk-fold expansion
* reads both sides' full contents through this. */
gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal),
/** The session's file-tool events for the changes tab's session lens: the
* `tool/call` + `tool/result` rows past `afterSeq` (0 = whole window),
* capped to the recent window host-side. The client runtime exposes no
* event-log face, so the lens polls this delta route. */
changesOps: (scope: SessionScope, afterSeq?: number, signal?: AbortSignal) =>
call<{ events: SidebarSessionEvent[]; lastSeq: number }>('changes.ops', scopePayload(scope, {
...(afterSeq !== undefined && afterSeq > 0 ? { afterSeq } : {}),
}), signal),
/** Discard the worktree changes of one file (the index is untouched). */
gitDiscard: (scope: SessionScope, path: string, worktree?: string) =>
call<{ ok: true }>('git.discard', gitPayload(scope, worktree, { path })),
/** Revert one commit onto the current branch. */
gitRevert: (scope: SessionScope, hash: string, worktree?: string) =>
call<{ ok: true }>('git.revert', gitPayload(scope, worktree, { hash })),
/** Cherry-pick one commit onto the current branch. */
gitCherryPick: (scope: SessionScope, hash: string, worktree?: string) =>
call<{ ok: true }>('git.cherry-pick', gitPayload(scope, worktree, { hash })),
/** Release a terminal's process immediately (tab closed; the WS close frame
* may be unreachable while the socket is down, so the host also accepts
* this explicit route). */
ptyClose: (scope: SessionScope, tab: string) =>
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/** Terminal dependency status (issue #140): after a WS close 1011 with
* reason `pty-deps-missing` the view fetches the full repair details here
* (the close reason itself is capped at 123 bytes). */
terminalDeps: () =>
call<TerminalDepsStatus>('terminal.deps', {}),
/**
* The output the model has read so far for one background job (replayed
* from the owner session's event log — never the model's job_output
* cursor). The scope MUST be the job's OWNER session.
*/
jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
/** Request cancellation of one background job (live jobs flip to stopping). */
jobKill: (scope: SessionScope, id: string, reason?: string) =>
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
id,
...(reason !== undefined ? { reason } : {}),
})),
/**
* One batch live-preview fetch for the whole Subagent tree. The payload is
* the already-resolved topology ROOT (not a session scope); the host
* enumerates descendants once and folds running children's activity.
*/
subagentsLive: (rootSessionId: string, signal?: AbortSignal) =>
call<SubagentLiveResult>('subagents.live', { rootSessionId }, signal),
/** Create a Side Chat thread: a child session seeded with the parent's
* full log up to now. Empty question = immediate create (Codex-style):
* the thread opens empty, the first prompt carries the boundary. */
sidechatStart: (sessionId: string, question?: string) =>
call<{ childId: string }>('sidechat.start', { sessionId, question: question ?? '' }),
/** Deliver one follow-up message to a Side Chat thread. */
sidechatPrompt: (childId: string, text: string) =>
call<{ accepted: true }>('sidechat.prompt', { childId, text }),
/** Abort a Side Chat thread's running turn (queued work is preserved). */
sidechatCancel: (childId: string) =>
call<{ accepted: true }>('sidechat.cancel', { childId }),
/** Release a Side Chat thread's live agent (history stays persisted). */
sidechatDispose: (childId: string) =>
call<{ accepted: true }>('sidechat.dispose', { childId }),
/** Live state + agent identity (provider/model/preset) of a thread. */
sidechatInfo: (childId: string) =>
call<SidechatThreadInfo>('sidechat.info', { childId }),
/** One transcript pull of a Side Chat thread: the thread's OWN events
* (the inherited seed is cut host-side and never crosses the wire).
* `afterSeq` narrows the response to the delta beyond it (poll tail).
* `live` is the thread's in-flight model deltas (DSH 0.1.5 publishes them
* outside the session log) — the CURRENT attempt on every pull, never a
* delta, so the caller replaces its live set instead of appending. */
sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) =>
call<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }>('sidechat.events', {
childId,
...(afterSeq !== undefined ? { afterSeq } : {}),
}, signal),
/** The effective terminal shell and its display name (plugin-global). */
shellGet: () =>
call<{ shell: string; name: string }>('shell.get', {}),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}),
/** Merge a patch into the side card preferences (revision-guarded). */
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
call<{ value?: unknown; revision?: number }>('settings.update', {
patch,
...(expectedRevision !== undefined ? { expectedRevision } : {}),
}),
/** Probe a URL's response headers (the sidebar browser's embeddability
* check; see the host's browser.probe route). */
browserProbe: (url: string, signal?: AbortSignal) =>
call<BrowserProbeResult>('browser.probe', { url }, signal),
/** External open for the file tree's "open with" menu. Remote SSH editor
* URLs are launched on the browser/client machine; reveal and local URLs
* keep using the host's platform opener. */
openExternal,
}
/** Absolute URL of the media route for one path (images only). */
export function mediaUrl(scope: SessionScope, path: string): string {
return fileUrl(scope, path, false)
}
/** Absolute URL of the download route: serves raw bytes (binary-safe) with
* `Content-Disposition: attachment`, so the browser saves the file. */
export function downloadUrl(scope: SessionScope, path: string): string {
return fileUrl(scope, path, true)
}
/** Shared URL builder for the /sidebar/file route (media vs download). */
function fileUrl(scope: SessionScope, path: string, download: boolean): string {
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
if (download) params.set('download', '1')
return `/sidebar/file?${params.toString()}`
}
/**
* Absolute URL of the HTML preview route (see html-route.ts): the path is
* fully encoded so the previewed page's relative assets resolve back into
* the same route with the session scope intact. The UNC marker is
* platform-neutral — the host's requireAbsolute resolves the decoded
* forward-slash `//server/share/...` form on both win32 and POSIX — so no
* client-side platform signal is needed.
*/
export function htmlUrl(scope: SessionScope, path: string): string {
return encodeHtmlUrl(scope.sessionId, path)
}

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