Compare commits

..
Author SHA1 Message Date
figmar 0aafa94c4d chore(gen1): 第一代版本封板 — DSH 2.0.17 兼容 + settings API 迁移 + React #130 修复
- peerDeps 升级到 0.2.0-rc.2
- settings API 迁移: register() → describe() + settings/document-updated
- turnTail slots.register 补 id 字段
- EditorHost viewer.component undefined guard
- 56/56 测试全绿, typecheck/build 通过

tag: v1.2.0-gen1
2026-10-11 16:52:48 +08:00
figmar df6f99c4a3 chore(peer-deps): bump to 0.2.0-rc.2 for DSH 2.0.17 compatibility
PeerDependencies changed from ^0.1.5-rc.1 to ^0.2.0-rc.2 to match
DSH Desktop 2.0.17's built-in dsh packages (Harness v0.2.0-rc.2).

Rollback anchor: git revert this commit to restore 0.1.5-rc.1 peerDeps.

Context: DSH 2.0.17 release notes state '新版内核会检查插件声明的
兼容版本,不兼容的插件可能被跳过'. This is Plan D (force-install
via peerDeps bump) — if runtime API breakage occurs, fall back to
Plan A (rebase onto upstream dsh-better-sidebar 0.25.0).
2026-10-11 14:44:51 +08:00
figmar 40a05af9ba docs(wiki): 01 需求分析改口径——对比官方原生(不再把上游操作面单列一层)
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
用户纠正:editor/git/subagent/sidechat/terminal/browser 也是 apes-sidebar
打包进来的,对外是一个完整需求整体,对比的另一端只能是 DSH 官方原生。

- 用官方包自带 README 取证:原生右列 = 容器(sidebar-right) + 唯一页「文件」
  (sidebar-files, 只读) + 唯一查看器(documentpreview, kind=text, 纯文本兜底)
- 新增官方原生做不到的九件事表,作为 user 处境的 before
- 洞察 0 改为「官方假设你只是看,而真实使用是在这儿干活」(只读→可写)
- 新增洞察 6:官方只有一个入口→罗盘不出现;入口一多罗盘才登场(单一工具→工作台)
- 附录补官方证据引文;上游/fork 的区别降为对内维护说明
2026-10-04 02:02:55 +08:00
figmar 2b88c85ea7 docs(wiki): 01-需求分析 改为「原生 vs 上游 vs 本 fork」对比式需求分析
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
把用户贴的 10 行 tab 表按供给来源拆成三层,逐行追问没有它时他会怎么办:
- DSH 原生 = 右列容器 + 「文件」页 + documentpreview 文本兜底(证据 external-plugin-guide.md)
- 上游 = 六个操作面(文件/变动/子代理/侧边对话/终端/浏览器)
- 本 fork = 三个态势面(agents/apps/profile)

新增 7 条需求变化洞察(操作面→态势面、零切换棘轮、提问副作用归零、
看得见→敢放手、心智模型外化、价值随并行度放大、增量成本≈0),
U1-U6 收敛为由分析推出的需求表;实现/根因只留附录索引。
2026-10-04 02:00:25 +08:00
figmar 2224991047 docs(wiki): 需求分析改为使用者视角重写
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
原稿按「现象/根因(含证据)/需求/验收标准」组织,实质是在讲我们怎么修的;
现在从「用户为什么会需要」写起:谁在用 → 他会怎么说 → 为什么难受 → 他想要什么
→ 怎样算满意(全部是用户能亲手点到、亲眼看到的行为)。

- 五条实现导向的需求 → U1~U6 用户问题(U5 拆成两套侧边栏 / 弹窗被切)
- 新增:不做什么(省得期待错位)、已知的暂时满足不了(应用层 feed、完整子代理树、
  沙箱态相对资源),如实交代边界
- 新增:验收总表(动作 → 应看到)与优先级/状态表
- 实现机制与提交号全部移入附录 A「根因档案」索引,指向 02/03/05/06
2026-10-04 01:55:35 +08:00
figmar 63bcb47ed0 docs(wiki): 建立项目百科(需求分析 → 架构 → 功能 → 部署 → 运维 → 排障 → 开发与测试)
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
Gitea 的 wiki 此前是空的。本次把百科的**源文件版本化进仓库**(docs/wiki/),
再用脚本发布到 wiki——文档因此与代码同源、可 review、可回溯,而不是只存在于网页上。

八页(Home 作为导航与项目速览):

  Home          项目速览 / 两条版本线的宿主兼容性差异 / 文档地图 / 谱系说明
  01-需求分析    五条核心需求(现象·根因·验收标准·状态)+ 非目标与约束 + 需求→实现→锚点
  02-系统架构    host/client 分层与产物、三层接口面、面板宿主层叠契约与 isolate 陷阱、关键不变量
  03-功能说明    面板/预览器总表、信息层三 tab 数据来源、HTML 预览交付方式、i18n 与令牌、配置面
  04-部署指南    宿主版本矩阵与选线决策、profile 接线(file: + bundles)、构建、验收清单、卸载与切换
  05-运维手册    分支/tag 策略与回滚剧本、发布纪律(版本三元组/非确定性产物)、巡检、备份铁律、诊断
  06-故障排查    8 类症状 → 根因 → 处置(含本会话实机定位过的全部案例)+ 定位手法
  07-开发与测试  工程约定、目录、门禁基线、测试分层与契约 spec、扩展点、测量式 re-base 方法

脚本 scripts/push-wiki.sh:幂等发布(POST 新建 / PATCH 覆盖),凭据走 git credential helper
(临时 netrc,600,退出即删),不落盘、不进 argv。已实测:8 页全部发布成功,复推为更新。

内容纪律:只写**本次会话实证过**的事实(提交号、行数、层级取值、产物大小、宿主版本矩阵、
静默禁用机制、re-base 步骤),未验证的部分明确标注为待验证或已知限制;并如实说明本仓库
历代承载过 v1 / v2 / 当前 fork 三代实现,本百科描述的是 main 上的 fork 线。
2026-10-04 01:19:31 +08:00
figmar 4422001c5e fix(panels): mount the panel host inside the shell's framed content viewport
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
On DSH Desktop the plugin's panel layer covered host modals — the community
market dialog (dsh-community-market, which renders into the shell's own
`shell.overlay` slot) had its lower-left corner clipped by our workbench.

The fence is not a z-index mistake on either side. The shell's framed modes do:

  const contentViewport = document.getElementById('root')
  document.body.dataset.dshDesktopMode = environment.mode
  /* …and tags #root with data-dsh-desktop-content-viewport */

with

  body:is([data-dsh-desktop-mode="compatibility"], […"extended"]) [data-dsh-desktop-content-viewport] {
    isolation: isolate;
  }

`isolation: isolate` makes #root a stacking context (z-index: auto) wrapping the
whole app INCLUDING the shell's overlay layer (`.dshDesktopOverlay`,
z-index: 1000) that hosts plugin overlays. Our host was appended to
document.body — OUTSIDE that context — and because it is a positioned element
with z-index 25 it painted above the entire context, so the market's 1000 never
competed. The shell's overlay layer is the boundary plugins are meant to sit
under, so the fix is to join the layer, not to renumber ours: panelHostParent()
mounts the host into #root when the shell framed one (body otherwise, i.e. the
plain web deployment keeps today's behaviour), where the documented 25 keeps
its meaning — above the app frame (z-index auto), below the overlay layer.

The re-anchor guard now re-appends into the resolved parent instead of always
`document.body`, and also watches `data-dsh-desktop-mode` on <body> (the same
shell effect that stamps the viewport) so a shell that frames #root after our
mount still gets the host moved.

Verified: typecheck clean; build clean; 1367 passed / 9 skipped (131 files,
+3 parenting specs). Revert is one line per call site if the shell's overlay
layer turns out to sit below our panels for a reason not visible in the CSS.
2026-10-04 00:08:13 +08:00
figmar 4c242acf7b fix(html-preview): deliver the document through srcdoc so a sandboxed frame can load it
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
The preview showed nothing but the bare word `forbidden` in DSH Desktop. The
cause is not this plugin's routes but the host, one layer above them:
`DesktopWebServer` wraps EVERY route with `permits()`, and in the default
isolated/扩展 mode `ordinaryBrowserEnabled` is false, so a request is served
only when it carries the Electron renderer capability
(`x-dsh-desktop-renderer`). That header is attached solely to MAIN-frame and
SAME-ORIGIN-subframe traffic (`requestComesFromRendererOrigin`), and a frame
sandboxed WITHOUT allow-same-origin is an opaque origin at request time — so
the preview document request could never be served, while every other sidebar
route (all main-frame) worked fine.

The fix keeps the sandbox boundary exactly where it was and moves the delivery:
the document is fetched by the MAIN frame — which the host does trust — and
handed to the frame through `srcdoc`. New shared component HtmlPreviewFrame
owns that path plus the sandbox tokens for BOTH surfaces (the editor viewer and
the changes tab's op render preview, which had no escape hatch at all), so the
two can no longer drift; a failed fetch falls back to handing the frame the
route URL, leaving the route's own answer visible instead of a blank pane.

`withPreviewBase` injects `<base href="<the file's route directory>">`: a
srcdoc document has no URL of its own, so without it `./style.css` would
resolve against the GUI page. A document that declares its own base is left
alone.

Two consequences, both deliberate:
- sandboxed: the frame stays opaque, so a self-contained document previews
  completely while a multi-file one shows its markup with the relative assets
  missing (those requests are opaque-origin too — inlining them is a separate
  job);
- unlocked (the warned per-surface unlock, or the setting): the srcdoc frame
  inherits the GUI origin — which is what that escape hatch already meant — and
  there relative references resolve and load like any other same-origin
  request, so multi-file previews work in that mode.

Verified: typecheck clean; build clean; 1364 passed / 9 skipped (130 files,
+13: the pure base-rewrite spec, the delivery spec, and the updated
sandbox-contract assertions, which now pin srcdoc + no route src instead of
the old route-src shape).
2026-10-03 23:15:44 +08:00
figmar e5d34ac89f merge: sync fork onto upstream v0.19.1 — one sidebar instead of two
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
Brings 5a7e213 (the v0.19.1 re-base) onto main. The merge result is deliberately
the sync branch's tree verbatim: every file both sides touched is upstream
v0.19.1 + our rename, and the v0.18.0-era content main still carries for those
files (including the two package.json.bak.* scratch backups, FreeWindow, the
hand-written openpath interception and the v0.18.0 service surface) is
superseded rather than textually merged — a hunk-level merge of a 135-commit
upstream jump would be a guess, not an integration.

Net effect on main: the fork now runs on the base that registers its content
into DSH's OWN right sidebar, so the duplicate second sidebar and its duplicate
toggle cluster are gone. The fork's own contribution is unchanged and intact:
the apes:* tabs (agents / apps / profile) in src/client/apes/, the switch.sh
two-line switcher, and the fork identity (1.2.0).

Tree verified identical to sync/v0.19.1 (git diff --quiet) before this commit.
2026-10-03 22:51:27 +08:00
figmar 5a7e2139a0 chore(sync): re-base the fork onto upstream dsh-better-sidebar v0.19.1
The fork was pinned at upstream v0.18.0. That is the entire reason the window
ran TWO sidebars and TWO near-identical panel toggles: DSH 2.0.10 ships its own
right sidebar (@deepseek-ai/dsh-client-ui-sidebar-right), and upstream only
adopted it in v0.19.0-alpha.1 — which arrived after our fork point. v0.19.1 is
also the one upstream line whose support range actually matches our host
(engines.dsh >=0.1.5-rc.1, 14 DSH peers ^0.1.5-rc.1, host = 0.1.5-rc.2), so it
is the only tag the host's startup precheck accepts.

What upstream v0.18.0 → v0.19.1 brings
- src/client/native/{index,surface,tab-adapter}.tsx: every tab the plugin can
  draw becomes an OFFICIAL native tab type + body, and an external plugin's
  descriptor registered later gets its native type too. This is what retires
  the fork's own right panel — and with it the duplicate toggle cluster
  (`toggleCluster` is gone from v0.19.1).
- native tab-chip icons via the `sidebar.right.pane.tab.title` seat, coloured
  built-in tab icons, file/folder glyphs from DSH's own `FileTypeIcon`.
- guide descriptions (DSH renders them while the guide lists <= 4 entries),
  tab bodies that fill the panel, and the 0.1.5-rc.2 adaptation work we were
  carrying by hand (peer/devDep pinning, primitives' transitive devDeps).

How the sync was done
Rebuilt the tree from the v0.19.1 tag rather than resolving 53 conflicted
files: our v0.18.0 delta was measured first and is only (a) one mechanical
rename `dsh-better-sidebar` -> `dsh-apes-sidebar` and (b) ten real edits. So:
tag tree + rename (scoped: code/config/scripts/tests/README, while the 46
historical docs/plans records and switch.sh's upstream package name stay
untouched) + re-applied deltas + our apes module.

Fork deltas re-applied on the new base
- src/client/apes/ (icons / locales / tabs): the three apes:* information-layer
  tabs (agents / apps / profile), registered through the same BetterSidebarService
  as the builtins — which is exactly why they now ride upstream's native adapter
  into the official sidebar for free.
- identity: package.json (name/version 1.2.0/description/forkedFrom), dsh.plugin.json
  (id/version), SIDEBAR_SERVICE_VERSION 1.2.0, README fork notice,
  docs/external-plugin-guide.md.
- scripts/switch.sh + the pnpm switch* scripts (apes <-> upstream two-line switch).
- tests/apes-tabs.spec.ts (unescaped: it starts from the v0.19.1 base).

Dropped
- the Phase A native-registration spike (`apes/native-tabs.tsx` + its spec): it
  re-derived by hand exactly what upstream's native layer already does — the
  hand-written bridge is dead weight now, and v0.19.1's own e2e/jsdom lanes
  cover the same seat contract far better.
- the unused apesProfileGuideDesc key and the two package.json.bak.* backups.

Verified on the new base: typecheck clean; build clean; 1351 passed / 9 skipped
(128 files), up from 1255 on the old base.
2026-10-03 22:51:09 +08:00
Atlas Zeng 8753096a58 Merge pull request #628 from omdsh-dev/fix/waitfor-skip-echo-hazard
修复 main 变红的真实断言:skip 类用例的命令里含等待 needle,被交互式 shell 回显后进了 transcript,waitFor 走快路径导致 skipWait 无等待可跳。改用 bare shell + waitForShellReady。三个 check 全绿。以管理员合并:main 保护要求 1 个批准 review,仓库只有 owner 一人。
2026-09-11 16:42:20 +08:00
Menghuan1918 b2e497b9c9 fix(test): keep the wait needles out of the shells the skip cases spawn
main went red on the merge commit (run 34579312780) with a real assertion:
`tests/agent-pty.spec.ts:413` expected `skipWait` to report 1 registered wait
and got 0. The cause is the test's own preparation, not the registry: an
interactive shell ECHOES what is written to it, so a terminal opened with a
command that contains the wait needle already has that needle in its
transcript by the time `waitFor` runs — `waitFor`'s documented fast path then
resolves the wait immediately and `skipWait` finds nothing to skip.

POSIX /bin/sh does not echo a command written to a non-tty, so this had been
latent for as long as the suite existed; PowerShell does echo it, which is why
it only ever failed on Windows, and why it took a Windows run to expose it
rather than a code change.

The three affected cases (skip / waiting-snapshot / wait-notification) now open
a BARE shell and wait for the shell to be up rather than for a printed marker,
via a new `waitForShellReady` helper that watches for output of any kind. That
removes both halves of the hazard: no needle in the command, and no dependency
on which words a given shell prints at startup. Nothing about what the cases
assert changed — they are about the registry's wait bookkeeping, which needs a
live terminal and no particular output.

Verified: tests/agent-pty.spec.ts 33 passed (and the three cases now finish in
milliseconds instead of waiting out a transcript poll), full suite 127 files /
1343 passed / 9 skipped.
2026-09-11 16:37:03 +08:00
Atlas Zeng 817ee8fc6d Merge pull request #626 from omdsh-dev/feat/dsh-0.1.5-rc.2
适配 DSH 0.1.5-rc.2(v0.19.1)+ CI 三类失败修复 + 文件图标与内置 Tab 图标彩色(含 #531 / #594 的成果)。三个 check 全绿;ci-windows 在单 fork 下连续五次绿(127 files / 0 errors / 0 worker 死亡)。以仓库管理员权限合并:main 的分支保护要求 1 个批准 review,仓库只有 owner 一人,无第三方可批准。
2026-09-11 16:27:56 +08:00
Menghuan1918 d725e54608 docs(plans): record the outline revert (only the terminal size was asked for) 2026-09-11 16:20:58 +08:00
Menghuan1918 da5540c9ac revert(icons): drop the glyph outlines, keep only the terminal's smaller size
The hairline outline I added to the colored tab glyphs reads as a thick stroke
in the live instance, so it goes: the glyphs stay exactly as the icon set draws
them, tinted only through the wrapper's token. What the feedback actually asked
for was the terminal SIZE, and that is all that remains — it renders a step
down (0.85x, floor 10px) because the VSCodicon terminal is a wide filled
rectangle that read heavier than its neighbours.

tests/builtins.spec.ts keeps pinning both readings (tasks = layers, terminal =
the shrunk VscTerminal with no style override), so the assertion now also
guards against an outline creeping back in.
2026-09-11 16:20:07 +08:00
Menghuan1918 0c18e492cf docs(plans): record the icon rework feedback and the green single-fork Windows runs 2026-09-11 15:59:13 +08:00
Menghuan1918 1ef2192a44 docs(plans): record the two green single-fork Windows runs 2026-09-11 15:53:06 +08:00
Menghuan1918 4fe67e3965 fix(ci): run the Windows suite in a single fork (the bounded pool was not enough)
The `--maxWorkers=2` fix from the previous commit was falsified by real CI:
run 34574847774 still ended with `[vitest-pool]: Worker forks emitted error /
Worker exited unexpectedly` while reporting 126 of 127 files passed and zero
FAIL lines. The worker that died was the one running
tests/native-surface.spec.ts — it produced NO results at all, which is the
same shape as the two earlier crashes (125/126 and 123/124: N-1 files
reported plus a silent worker exit at teardown).

So the consistent signature across all four crashes is a worker-recycling
race, not a test failure. `test:windows` now runs the whole suite in ONE fork
(`--maxWorkers=1`), which removes recycling entirely; the cost is wall-clock
(14 s -> 41 s locally, and the Windows step stays well inside its 30-minute
budget) and the signal is unchanged. Retries are still deliberately not used —
they would hide a real regression instead of removing the race.

The CI comment now records both Windows failure classes with their evidence,
so the next person can tell what this flag is for.
2026-09-11 15:43:22 +08:00
Menghuan1918 fe2017af49 fix(icons): say "background work", not "to-do list", and lighten the terminal glyph
Two glyphs read wrong in the live instance:

- Tasks shows subagent sessions AND the host's background jobs, so a checklist
  glyph described a page that does not exist. `VscLayers` (sheets stacked, i.e.
  work running behind the conversation) says what the page is; the amber token
  stays, so it still reads as live activity.
- The terminal was the widest glyph in the set and the only bare fill in a
  strip of the plugin's own 1.5px-stroked icons, which made it look like a
  heavy foreign block. It now renders a step down (0.85x the strip size, floor
  10px) and every colored tab glyph gets a hairline outline of its own ink
  (`stroke: currentColor` + `non-scaling-stroke`, so the line stays one device
  pixel across the sets' mixed 16- and 24-unit viewBoxes; `paint-order: stroke`
  centres it on the fill edge instead of fattening the shape).

tests/builtins.spec.ts pins both readings by glyph identity (tasks = layers,
terminal = the shrunk, outlined VscTerminal), so a future swap has to argue
with a failing assertion rather than with a comment.

Gates: typecheck / lint green, pnpm test 127 files / 1343 passed / 9 skipped.
2026-09-11 15:40:32 +08:00
Menghuan1918 6317dde7c0 docs(icons): record the host-artwork decision and update the rules that cite it
- docs/plans/2026-09-11-file-icons-and-tab-glyphs.md: the design record —
  what the two stacked PRs did, the measured upstream facts that made their
  colored implementation redundant (rc.2's `FileTypeIcon`/`CodeFileIcon` with
  the host's own palette, the classifier never returning a folder, the native
  tab definition having no icon field while its title slot IS the chip), the
  three merge conflicts and how each was resolved, what was deleted, the
  catch-all semantics change, and the verification evidence.
- docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md: cross-link it from the
  change table.
- docs/external-plugin-guide.md: the file-icon fallback chain now ends at the
  host's artwork, with the catch-all caveat spelled out; §12 loses its "one
  exemption" paragraph entirely — the plugin draws no color of its own any
  more (file icons are host pixels, tab glyphs ride tokens).
- AGENTS.md §5: replaces the icon-theme exemption with the two new rules
  (file-icon chain + catch-all hazard, and where the colored tab glyphs live
  plus the fact that the native chip glyph is drawn by the plugin inside the
  host's title slot).
- README.md / README_EN.md: the v0.19.1 entry gains the icon section
  (#611 / #531 / #594) with the dataset deletion and the new public API.
2026-09-11 15:32:07 +08:00
Menghuan1918 22e772ce45 feat(icons): file icons and tab glyphs in color, from the host's own artwork
Issue #611 asked for file-type icons like the official explorer's, and #429
built a colored theme for them. DSH 0.1.5-rc.2 makes most of that work
unnecessary: `@deepseek-ai/dsh-client-ui-primitives` now exports
`FileTypeIcon` / `CodeFileIcon` with the host's own 48-type full-color code
artwork plus category-colored sheet glyphs (and its classifier behind them).
So the plugin renders the host's icons instead of shipping a color table, and
color becomes the only behaviour rather than an opt-in theme.

File icons (src/client/file-icons.tsx)
- `builtinFileIcon(path, size)` → `<FileTypeIcon path size />`; folders use the
  host's folder category. The plugin's own per-extension VSCodicon map is gone.
- The public `registerFileIcon` API from #531 is unchanged and still outranks
  the built-ins, so an external plugin can take over any row. One semantic
  consequence, now documented and tested: because the host classifier covers
  every path, a registered catch-all (`exts: []`) claims rows it used to lose
  to the built-in map. An unregistered catch-all (the old icon theme's shape)
  is unaffected.
- Nothing is lazy any more: the artwork lives in a platform module the client
  already holds, so `lib/client-file-icons.js` and its 563-entry dataset (255
  kB) are deleted outright — the core bundle grew by 10 kB (0.85 → 0.86 MB).
- `src/client/file-icon-theme.ts`, the `fileIconTheme` pref (prefs-shared /
  config / client prefs), the settings-page select row and the six
  `settingsFileIconTheme*` keys in all 20 dictionaries are removed with it.

Tab glyphs (src/client/builtins/tab-icons.tsx)
- Every built-in tab type now declares a colored glyph: files (the host's
  folder artwork), changes/diff (commit), tasks (checklist), side chat
  (discussion), terminal (shell), browser (globe).
- The color comes from a `--dsw-alias-*` token through a wrapper class, never
  from a literal — the skin contract (§12) holds and `tests/theme.spec.ts`
  enforces both halves (no literals in the module, every color declaration in
  the stylesheet resolves to a token).

Native right Sidebar chips (src/client/native/tab-adapter.tsx)
- The host's tab definition has no icon field, but the
  `sidebar.right.pane.tab.title` slot IS the chip's content, so the plugin
  draws the glyph itself: an editor tab with a path shows that file's icon,
  every other tab shows its descriptor's icon — the same `descriptor.icon` the
  workbench strip and the guide capsules already render, so the three cannot
  drift. The glyph is `aria-hidden`, which keeps the chip's accessible name
  exactly the title the mount lane matches on.

Gates: typecheck / lint / check:consumer-types green; pnpm test 127 files /
1342 passed / 9 skipped (3 new chip cases, 1 rewritten built-in case).
2026-09-11 15:28:49 +08:00
Menghuan1918 a4e94c976e Merge the file-icon theme shell from #594 (feat/file-icon-theme)
Brings in the theme plumbing around #531's API — the `fileIconTheme` pref
thread, the settings row, the 20 dictionaries' keys and the theme loader's
test — so the working tree is a complete, green starting point before the
colored implementation is replaced.

Conflicts (three; resolved by taking this line's side where it had moved on):

- src/client/service.ts: the import block keeps `baseName, extOf` (the
  `isNarrowWidth` import the theme branch needed belongs to its own
  fresh-session seeding, which main has since rewritten) and the feature-list
  doc comment keeps main's "v0.19.0 REMOVED 'floatWindows'" note while
  adopting the sharper `fileIcons` wording (matched by exts / names /
  folderNames).
- tests/plugin-shape.spec.ts and tests/prefs.spec.ts: main DELETED the store
  seeding tests these files used to hold and rewrote the schema assertions
  (the `SIDEBAR_PREFS_DEFAULTS` destructuring), so this line's side wins —
  the theme branch's re-added blocks describe behaviour main no longer has.

Next commit removes `fileIconTheme` again along with the 563-entry dataset and
its lazy chunk: DSH 0.1.5-rc.2 exports the official `FileTypeIcon` artwork, so
the plugin renders the host's icons instead of shipping its own color table.
2026-09-11 15:14:47 +08:00
Menghuan1918 1d05e39be2 Merge the file-icon API from #531 (feat/file-icons-api) into the rc.2 line
Brings in the plugin-side file-icon feature (issue #611): the public
`registerFileIcon` API plus the resolver chain the tree, the editor tab and
the external-plugin surface all read.

Conflicts (three, all comment/list unions — resolved by union, verified green):

- src/client/service.ts: main's "v0.19.0 REMOVED 'floatWindows'" note stays
  (the capability is gone and must not come back); the feature list gains
  'fileIcons'.
- src/client/Sidebar.tsx: one comment above `tabIconOf`; both sides' wording
  is kept (the editor-with-path special case and the descriptor fallback).
- docs/external-plugin-guide.md: the `features` list comment keeps main's
  "additive only, the one exception being floatWindows" wording plus the new
  'fileIcons' entry.

Notes on what this merge does NOT bring in: #594 (feat/file-icon-theme) is
stacked on this branch and lands next, but its colored-theme implementation
(563-entry react-icons dataset in a 255 kB lazy chunk plus a `fileIconTheme`
switch) is being replaced — DSH 0.1.5-rc.2 exports the official
`FileTypeIcon`/`CodeFileIcon` artwork from ui-primitives, so the plugin does
not need to ship its own color dataset.

Gates on the merge result: pnpm typecheck clean, pnpm test 127 files /
1331 passed / 9 skipped.
2026-09-11 15:09:32 +08:00
Menghuan1918 d5bbb52c89 docs(plans): record the two consecutive green Windows runs (rc.2 baseline)
ci-windows is green twice on 5451f5c — the PR-triggered run 34561189320 and a
follow-up workflow_dispatch run 34561457067, both 126 files / 1318 passed /
3 skipped with zero `Unhandled Errors` and zero `Worker exited unexpectedly`
(the window that motivated this work saw two of the latter). plugin-mount is
green twice as well: `added 521 packages in 24s` with no ERESOLVE lines
against 3062 in the historical OOM run, mount smoke 7 passed, aggregate lane
passed.

Also records the falsification this PR went through — the first cut still
failed on real CI in two places (the third 5000 ms default victim,
tests/git.spec.ts:85 at 9607 ms, and `--legacy-peer-deps` breaking the CLI
install with ERR_MODULE_NOT_FOUND) — and states plainly that two green runs
do not prove the worker-death root cause, only that it stopped reproducing
once concurrency was bounded and the timeout budgets were corrected.
2026-09-11 12:18:34 +08:00
Menghuan1918 5451f5c699 fix(ci): raise the global testTimeout and drop --legacy-peer-deps (real-CI feedback)
The first run of this PR against real Windows and real npm found two things
the local macOS pass could not:

1. ci-windows lost a THIRD file to vitest's 5000 ms default —
   `tests/git.spec.ts:85` ("caps status entries at the truncation limit")
   took 9607 ms building a pathological untracked set with real git calls.
   Three different files (git / powershell / agent-pty), one root cause, so
   per-file timeouts were whack-a-mole: vitest.config.ts now sets a global
   `testTimeout: 15_000`. The two heaviest suites keep their own 30 s budgets
   and a genuinely hung case still fails.

2. `--legacy-peer-deps` made the mount lane's CLI install broken instead of
   robust: it skips exactly the peers a global install has to supply, and
   `@deepseek-ai/cordis-plugin-group` — required at boot by
   `@deepseek-ai/dsh-app-boot` — is a peer, not a dependency, so the CLI died
   with ERR_MODULE_NOT_FOUND (`Cannot find package
   '@deepseek-ai/cordis-plugin-group'`) before the smoke could start. The
   flag is removed; the pin to a fully-published rc.2 (no ETARGET) plus the
   4 GB heap (no resolver OOM) is the whole fix.

Verified locally: a plain `npm install -g @deepseek-ai/dsh@0.1.5-rc.2` into a
scratch prefix (with NODE_OPTIONS=--max-old-space-size=4096) resolves the peer
(`dsh/node_modules/@deepseek-ai/cordis-plugin-group` present), that CLI reports
0.1.5-rc.2, and `DSH_CMD` pointed at it runs the mount lane green — 7 passed.
`pnpm test` stays green with the new timeout: 126 files / 1312 passed / 9 skipped.

Docs (plan §C, AGENTS.md §2, both READMEs' v0.19.1 entries) record the rejected
middle step and why, so nobody re-adds it.
2026-09-11 12:10:16 +08:00
Menghuan1918 62c83ff611 docs(plans): record the 3080 deployment (plugin 0.19.1 on DSH rc.2) with route evidence 2026-09-11 12:03:53 +08:00
Menghuan1918 112f151c04 docs(plans): link PR #626 and record the local rc.2 mount-smoke evidence 2026-09-11 12:03:07 +08:00
Menghuan1918 787cff4319 docs(plans): add the DSH 0.1.5-rc.2 adaptation and CI-failure plan 2026-09-11 12:02:45 +08:00
Menghuan1918 e567bf6924 docs: record the rc.2 baseline (README badges, guide, AGENTS, plan)
- docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md: the adaptation plan —
  the upstream rc.1 -> rc.2 delta per affected area, why there is no
  plugin-side code change, and the CI failure taxonomy (A real Windows
  timeouts, B the two silent worker deaths, C the mount lane's npm
  ETARGET/OOM) with the evidence and the fix for each.
- AGENTS.md §2/§3: CI pins rc.2, the mount lane's npm flags and their reason
  (floating transitives + staged prerelease publishing), the Windows lane's
  bounded pool and why, and the primitives-dependencies note extended to rc.2.
- README.md / README_EN.md: support badge (v0.19.1, verified on rc.2), the
  install section's stable-release note (rc.1 hosts need no upgrade — the peer
  floor is unchanged) and a v0.19.1 changelog entry covering the two CI fixes.
- docs/external-plugin-guide.md: baseline line -> v0.19.1 / DSH 0.1.5-rc.1+,
  verified on rc.2.
- Comment-only: `DSH 0.1.5-rc.1` -> `0.1.5-rc.1+` where the note describes a
  behaviour that arrived in rc.1 and is unchanged in rc.2 (native guide
  description rendering, in src/client/native/index.ts, src/client/service.ts
  and the three specs that assert it).
2026-09-11 12:02:44 +08:00
Menghuan1918 9ffaeb2f78 fix(ci): stop the Windows lane's real timeouts and the mount lane's npm failures
Two independent, evidence-backed defects in the 2026-09-09/10 CI window
(60 completed runs: 26 success / 17 failure; ci-windows|Test red 8 times,
plugin-mount|Install dsh CLI red 4 times).

1. Windows: six of the eight red ci-windows runs were genuine 5000 ms
   vitest timeouts in specs that really start a process —
   `tests/agent-pty.spec.ts` (`:71` / `:106`; a PowerShell + ConPTY pair per
   terminal) and `tests/install-powershell.spec.ts` (`:105`; a cold
   `powershell.exe` start measured 12.1 s). vitest.config.ts sets no
   testTimeout, so both suites ran on the 5000 ms default, and
   `waitForTranscript`'s own poll budget was *also* 5000 ms — the inner wait
   consumed the whole outer budget, which is a structurally guaranteed
   timeout, not a flake. Both suites now declare a 30 s budget (describe-level
   options, verified working on vitest 4) and the inner poll drops to 15 s so
   it stays strictly below the outer bound.

2. Windows: `ci-windows`'s Test step runs `pnpm test:windows`
   (`vitest run --maxWorkers=2`, the script added with the baseline commit)
   instead of `pnpm test`. Five specs spawn real processes, node-pty's ConPTY
   helper and a cold powershell.exe are both console-bound, and an unbounded
   fork pool on a 2-core runner is the amplifier behind both the timeouts
   above and the two silent worker deaths (`Worker exited unexpectedly` with
   0 FAIL and every file reported passing — one of them on a commit whose
   sibling run was green). Local `pnpm test` semantics are unchanged; only the
   Windows lane is bounded.

   The worker-death mode itself is NOT proven fixed here: it does not
   reproduce on macOS and the two occurrences have no same-commit rerun. This
   is the evidence-backed part; the real Windows result decides whether more
   is needed, and retries are deliberately not used to paper over it.

3. plugin-mount: `npm install -g @deepseek-ai/dsh@<ver>` never really pinned
   anything — the pin's transitives are floating `^` ranges, so while upstream
   publishes a prerelease in stages (rc.2 went out package by package between
   14:43 and 14:57 UTC on 09-10) npm resolved a mixed rc.1/rc.2 peer graph:
   3062 ERESOLVE lines then `Ineffective mark-compacts near heap limit`
   (exit 134), or a hard ETARGET on a sibling that appeared 5.5 minutes (resp.
   25 seconds) later. The same mechanism hit the alpha line on 09-09. The lane
   now pins an already-complete rc.2 and passes `--legacy-peer-deps` plus
   `NODE_OPTIONS=--max-old-space-size=4096` to decouple the install from the
   upstream publish window; a mismatch would not be silent because the mount
   smoke in the same lane boots the real shell.

   The stale comment claiming rc.1 is both `latest` and `next` is corrected —
   it documented a reality that had already changed.
2026-09-11 12:02:41 +08:00
Menghuan1918 52a7e6a553 feat(dsh): adapt to DSH 0.1.5-rc.2 (baseline pin advance, v0.19.1)
The pinned baseline moves to DSH 0.1.5-rc.2 (npm `next`; `latest` is still
rc.1) while the plugin's peer floor stays ^0.1.5-rc.1, so rc.1 hosts need no
DSH upgrade to run this version. Nothing in the rc.1 -> rc.2 delta touches
this plugin: of 300 changed upstream files, the overwhelming majority are
single-line `version` bumps in per-package package.json files, and the only
real code edits are the message-feedback dialog, deliverables card CSS and
the CodeFileIcon SVG artwork split (`code-file-icon-artwork.ts`). There are
zero changes under packages/api|host|session|agent and zero type-surface
changes beyond one line in message-feedback/types.ts, so the adaptation is
the pin itself — devDependencies, the CI mount lane and
SIDEBAR_SERVICE_VERSION — with no plugin-side code change and no runtime
compatibility layer.

- package.json: devDependency pins 0.1.5-rc.1 -> 0.1.5-rc.2 (including
  dsh-client-locale, which is still published on the DSH tuple); peers keep
  the ^0.1.5-rc.1 floor; version -> 0.19.1
- dsh.plugin.json: version -> 0.19.1; engines.dsh keeps >=0.1.5-rc.1 (same
  supported range as the peer floor — the two must not fork)
- pnpm-lock.yaml: lockfile realigned to rc.2
- src/client/service.ts: SIDEBAR_SERVICE_VERSION -> 0.19.1 (tests/service.spec.ts
  asserts it equals package.json's version)

Verified locally on macOS arm64 against the rc.2 tree: pnpm install clean,
`pnpm peers check` reports no peer dependency issues, typecheck / lint /
check:consumer-types green, `pnpm test` 126 files / 1312 passed / 9 skipped.
The real-mount smoke (scripts/e2e-mount.sh with DSH_CMD pinned to rc.2) and
the aggregate double-mount lane both pass — evidence recorded in
docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md.
2026-09-11 12:02:31 +08:00
Atlas Zeng 0a2f1ac018 Merge pull request #606 from omdsh-dev/feat/wait-banner
feat(terminal): 等待 banner + 跳过等待(terminal_wait_for 增 skipped)
2026-09-11 11:28:21 +08:00
Menghuan1918 7f2674ab70 docs+test: 对齐等待 banner 的注释/设计口径并钉住 skip-wait 的 404 契约
v0.19.0 发版前 review 发现两处「文档与实现互相矛盾」(均不改运行时行为):

- use-host-feeds.ts 的 effect 注释仍写「terminal tab 类型禁用时 pushes are
  ignored」,而 mirrorAgentWaits 恰恰改成了不忽略(只跳过 tab 增删 reconcile)。
  注释改为如实描述;
- 设计文档 §1 写「轮询每圈优先检查 skipped」,实现是 aborted → exited →
  skipped。维持实现现状(pty 已死是客观事实,两种顺序下用户可见结果——banner
  消失 / skipWait 归零 / 等待结束——完全相同,只有 kind 文案不同),设计原文
  同步为「退出优先」,并在实施偏差记录写清未采纳「用户意图优先」的理由与日后
  改动点;
- src/index.ts 的 agent-pty.skip-wait 注释把「无活动等待 → 0」泛化成了
  「幂等」,收窄为「无活动等待返回 {skipped:0};未知 uuid 走 expect → 404,
  客户端 .catch 容忍并等下一次推送收敛」。

另把 skipWait 未知 uuid 的既有断言从「只校验 /not found/ 文案」升级为
「SidebarError + status 404 + code not-found」——HTTP 层 writeError 直接把
status 映射成响应码,此前这条契约没有测试守护(把 404 改成 410 时新断言即
失败,已验证)。

验证:pnpm test 126 files / 1312 passed / 0 failed,tsc --noEmit 与 eslint
均 exit 0;改动面仅 5 个文件,src/client/service.ts、builtins/ 与
external-plugin-guide.md 继续零改动。
2026-09-10 22:26:02 +08:00
HuanLinOTO 22a0452a59 Merge origin/main into feat/wait-banner: resolve use-host-feeds import overlap
Conflict: src/client/sidebar/use-host-feeds.ts
- ours: import { mirrorAgentWaits, ... } from '../state.ts'
- theirs: same line + new import { isNarrowWidth } from '../breakpoints.ts'
Resolution keeps both imports; the two sides' semantic changes are in
disjoint regions (ours: tab-disabled wait mirroring; theirs:
activateTasksPage native-landing) and both survive intact.
2026-09-10 22:04:13 +08:00
Atlas Zeng e786d8f112 Merge pull request #619 from omdsh-dev/fix/auto-activation-native-landing
fix(shell): land auto-activation in DSH's native right Sidebar
2026-09-10 21:42:04 +08:00
HuanLinOTO 70a150794b fix: address Copilot review round 3 (code-point truncation, a11y live status, disabled-tab wait mirroring, test unmount, concurrent-wait test) 2026-09-10 21:28:22 +08:00
HuanLinOTO ccb89fd91b Merge origin/main into feat/wait-banner: resolve floats retirement + openTabInBottomPane 2026-09-10 18:21:09 +08:00
Menghuan1918 17db3cf014 docs(plans): record the auto-activation landing regression
The v0.19.0 regression, its root cause in 223b254, the four decisions
(native landing, narrow-viewport park, jump-back always expands, bottom
workbench keeps its own flows), the change list, and the evidence — including
the environment trap that the PATH `dsh` on this machine is 0.1.2-rc.1 and
cannot register the `file:` tarball into a scratch profile.
2026-09-10 17:13:32 +08:00
Menghuan1918 6c1efc1b00 fix(shell): land auto-activation in DSH's native right Sidebar
The two auto-open switches (a new subagent, a new background job) and the
topology jump-back opened the Tasks page with `target: 'bottom'` — the
plugin's own bottom workbench, i.e. the bottom bar. The right column IS DSH's
native Sidebar since v0.19.0 and every other open in this plugin already lands
there, so `service.ts`'s native branch was bypassed and the switches did NOT
do what their copy promises (README feature table + `settingsSubagentDesc` /
`settingsJobsDesc` in all 20 dictionaries: "wide viewports also expand the
sidebar, while narrow full-screen drawers are not forced open").

- use-host-feeds: one `activateTasksPage(ctx, sessionId, { background })`
  carries all three triggers and opens the Tasks page at the default (native)
  landing, so the host expands the column in the same step.
- Narrow viewports keep that promise: below 768px the host draws the column
  FULLSCREEN (`autoFullscreen = viewportWidth < 768`, `position: fixed;
  inset: 0`) and expands it on every open with no way to suppress it, so a
  BACKGROUND activation parks the tab — reads `ctx.sidebarRight.isExpanded()`
  first and puts the column back to collapsed in the same React batch (only
  for the mounted session, which is what that face acts on, and only when the
  user had it collapsed). The explicit jump-back is a user gesture and always
  takes the host's expansion.
- The bottom workbench keeps its own flows on `target: 'bottom'` (its `+`
  menu, the first-expansion auto-terminal); `OpenTabSeed.target` is unchanged.
- tests/sidebar-auto-activation.spec.tsx: asserts the native surface receives
  `openTab { kind: 'subagent', revealIfOpened: true }`, that the workbench
  stays closed and gains no tab, the park counts per width, the resize-before
  -fire read, and the jump-back's no-park. All 10 cases fail against the
  unfixed module.

Root cause: 223b254 (retire the plugin's own right panel, v0.19.0-alpha.0)
rewrote these three landings from the plugin's right pane to the bottom one.
2026-09-10 17:13:21 +08:00
Menghuan1918 bbf953b8a8 docs(readme): declare the native sidebar API adaptation at the top
A GFM IMPORTANT alert under the title states that the right column is
DSH's own sidebar since v0.19.0 (tab types/bodies via ctx.sidebarRightTabs
/ ctx.sidebarRight, file opens via ctx.sidebarRight.openResource, no
self-drawn right panel), and that the bottom workbench plus the
ctx.betterSidebar service are unchanged.
2026-09-10 15:13:36 +08:00
Menghuan1918 a0bbfa0089 docs(plans): record the rc.1 merge and v0.19.0 release 2026-09-10 14:54:19 +08:00
Atlas Zeng 754974afea Merge pull request #613 from omdsh-dev/feat/dsh-0.1.5-rc.1
feat(dsh): adapt to DSH 0.1.5-rc.1 and restore the guide description
2026-09-10 14:46:18 +08:00
Menghuan1918 191b764154 docs(readme): cite the rc.1 adaptation PR in the changelog 2026-09-10 14:41:40 +08:00
Menghuan1918 725b2f7cec feat(dsh): adapt to DSH 0.1.5-rc.1 and restore the guide description
Baseline moves to DSH 0.1.5-rc.1 — the 0.1.5 line's first release
candidate, and the npm `latest` dist-tag now: peer floor ^0.1.5-rc.1,
devDependency pins, the CI mount-lane pin and dsh.plugin.json engines
all follow. 0.1.5-alpha.2 users stay on v0.19.0-alpha.1.

The upstream delta from alpha.2 is small: 373 changed files, mostly
version bumps, sidebar preview polish and test snapshots. Exactly two
changes touch this plugin, and only one needs work:

- `SidebarRightGuideEntry.description` is back (optional). The native
  guide renders it only while it lists at most 4 entries; a longer list
  drops every description. The plugin restores `TabDescriptor.description`
  and the six built-in `guideDesc*` lines (20 dictionaries) verbatim from
  0b6ac5d^, but deliberately does NOT restore the old generic
  `nativeGuideDesc` fallback: the host has no fallback of its own, so a
  descriptor that declares none now ships no `description` field at all
  and renders as a clean title-only capsule.

  Consequence worth knowing: the plugin contributes six guide entries, so
  the default composition is over the 4-entry limit and shows titles only.
  The mount lane proves the restored field end to end by shrinking the
  enabled set to three types through the plugin's own settings route
  (scratch profile only) and requiring the Files capsule to grow its line.

- The built-in `files` type declares a guide description again and uses a
  coloured folder glyph; the plugin owns that kind, so nothing to do.

Verified unchanged from alpha.2 (so nobody re-checks them): the global
main-panel model, the file address grammar (util/workspace-path changed
only its package.json), 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.

Restore caveat fixed in the lane: the settings route merges patches
key-wise, so undoing a temporary `tabsEnabled` change by posting the
original (empty) map leaves the disabled keys behind — the e2e restores
every key it touched explicitly.
2026-09-10 14:41:16 +08:00
Menghuan1918 036a747c80 docs(plans): record the alpha.2 merge and v0.19.0-alpha.1 release 2026-09-10 00:14:45 +08:00
Atlas Zeng 6729239575 Merge pull request #609 from omdsh-dev/feat/dsh-0.1.5-alpha.2
feat(dsh): adapt to DSH 0.1.5-alpha.2
2026-09-09 23:52:57 +08:00
Atlas Zeng 7545de3765 Merge pull request #605 from omdsh-dev/refactor/retire-own-right-panel
refactor(shell): 阶段三 —— 退役自绘右侧面板与自由窗口
2026-09-09 23:52:19 +08:00
Atlas Zeng 14ee16c108 Merge pull request #604 from omdsh-dev/feat/native-sidebar-surface
feat(native): 阶段二 —— 右侧栏改由 DSH 原生 Sidebar 承载
2026-09-09 23:51:54 +08:00
Atlas Zeng 35c5c05d6e Merge pull request #603 from omdsh-dev/feat/dsh-0.1.5-alpha-compat
feat(dsh): 适配 DSH 0.1.5-alpha.1(阶段一:基线 + 契约修复)
2026-09-09 23:51:27 +08:00
Menghuan1918 bec90212f5 docs(plans): record the alpha.2 verification evidence 2026-09-09 23:17:13 +08:00
Menghuan1918 0e9b9169eb docs(readme): cite the alpha.2 adaptation PR in the changelog 2026-09-09 23:10:08 +08:00
Menghuan1918 0b6ac5d267 feat(dsh): adapt to DSH 0.1.5-alpha.2
Baseline moves to DSH 0.1.5-alpha.2 only (peer floor ^0.1.5-alpha.2,
CI pin, manifest engines); alpha.1 users stay on v0.19.0-alpha.0.

Host-contract changes handled:
- the native guide entry lost its `description` field (entries are
  icon+title capsules now): `TabDescriptor.description` and the six
  `guideDesc*` keys plus the `nativeGuideDesc` fallback are removed.
- the file address grammar changed: `fileAddressFor` always returns a
  session-scoped address (absolute paths keep their leading `/`),
  `parseFileAddress` is prefix-based and strips `?`/`#`. The plugin's
  purity-gated mirror follows.
- global main panels replaced the `conversation` root slot with the
  keyed `main` slot (`main.conversation`): the bottom workbench's
  center-column locator now accepts both keys and skips
  `display: contents` slot hosts.

Fixes a pre-existing fill bug the alpha.2 probe made obvious: the native
tab body host is a block scroller with a definite height, so tab roots
that only declared `flex: 1` collapsed to content height and the side
chat composer drifted off the pane bottom. The native adapter now wraps
every tab body in a `height: 100%` column flex host.

Also promotes the dependencies `dsh-client-ui-primitives@0.1.5-alpha.2`
dropped from its manifest (anser / shiki / mdast-util-* / micromark-* /
katex / @shikijs/langs) and the newly unmet `dsh-session-persistence`
peer into devDependencies.
2026-09-09 23:09:48 +08:00
Menghuan1918 d4765819c0 feat(guide): let a tab type declare its own line in the new-tab list
The native new-tab list rendered one generic line ("opens in the DSH
sidebar") under every plugin entry, so six plugin rows read identically.
`TabDescriptor.description` now carries the real purpose (string or
locale-following thunk; absent falls back to the generic line), and the
six visible builtins declare theirs across all 20 dictionaries.

- service.ts: `TabDescriptor.description` + docs (guide §4.1, §0 row).
- native/: the guide entry's description follows the descriptor; the
  `files` takeover reuses the editor's line.
- builtins: files / changes / tasks / side chat / terminal / browser.
- tests: builtins assert every visible type declares a distinct line;
  the lifecycle test asserts descriptor lines (and the takeover reuse).
2026-09-09 22:25:59 +08:00
Menghuan1918 9ebaa09f80 docs(agents): record the native tab-type registration ordering trap 2026-09-09 15:18:06 +08:00
Menghuan1918 2d9f90b551 docs: link the phase-3 PR in the v0.19.0-alpha.0 changelog 2026-09-09 15:18:06 +08:00
Menghuan1918 223b2545d9 refactor(shell): retire the plugin's own right panel and free windows
DSH 0.1.5 owns the right column (its native Sidebar hosts every tab type
this plugin registers), so the plugin's own right panel, its width drag /
new-session seed prefs, and the free-window feature are gone. The bottom
workbench is the only plugin-owned surface left, and its expand/collapse
control is registered into DSH's session-header utilities list slot (the
header's corner seat belongs to the native sidebar's own control).

- state: one `bottomSplits` tree; `makeDefaultState()` takes no arguments;
  `toggleBottomPanel` / `openTabInBottomPane` are the only landing
  reducers; `setBottomHeight` and `sanitizeState` share the
  `CONVERSATION_MIN` cap (a persisted tall height no longer snaps on the
  first drag).
- service: every landing in the workbench (creation or focus) expands it;
  `SIDEBAR_FEATURES` drops `'floatWindows'`; the open/close/activate tab
  pools cover the single tree.
- shell: `Sidebar.tsx` renders only the bottom workbench; the toggle lives
  in `sidebar/bottom-toggle.tsx`; layout-push pushes height only (no width,
  no `--dsh-sidebar-width`).
- prefs: `openByDefault` / `defaultWidthPercent` / `changesDiffFloat`
  removed from the schema, defaults, client parser, settings page, and all
  20 dictionaries; the retired keys in stored documents are ignored.
- tests: free-window specs deleted, panel/float assertions dropped,
  remaining suites adapted (124 files / 1287 tests); the mount lane asserts
  the header toggle and sweeps the native sidebar's guide page.
- docs: README / README_EN / guide §0,§11 / AGENTS test map.
2026-09-09 15:18:06 +08:00
Menghuan1918 91de0e2932 fix(native): give the "Files" guide row its glyph
The guide renders `entry.icon`, and the `files` kind takeover registered
without one — so after the editor type stopped contributing its own
duplicate row (b56ceec), the single "Files" row was the only entry in the
new-tab list with a blank icon slot. The takeover now carries the editor
descriptor's icon (the takeover IS that descriptor's page).

Extracted `guideIconOf` so both registration sites build the icon field
the same way; the lifecycle test asserts the takeover's guide entry has
an icon.
2026-09-09 15:18:04 +08:00
Menghuan1918 b56ceec41d fix(native): offer one "Files" row in the new-tab list
The editor type's page identity IS the `files` kind takeover (same
explorer, same title), so the guide/new-tab list showed two identical
"Files" entries — observed on a real profile after the takeover landed.
The editor type now contributes no guide entry of its own; it stays
registered as the file RESOURCE viewer. Covered by the registration
lifecycle test (editor: no guide, files takeover: guide).
2026-09-09 15:06:31 +08:00
Menghuan1918 4da2b43610 fix(native): title a file tab by its own name, not the descriptor's
The editor type serves both the files PAGE and every file RESOURCE; its
native `title` callback ignored the address, so every opened file showed
up in the strip as "Files" — indistinguishable tabs. Verified on a real
profile: after opening `AGENTS.md` from the tree and a chat file link,
the strip read ["文件", "文件", "文件"].

`title(address)` now returns the file name for a file address and keeps
the descriptor title for a page address (`sidebar://editor`). Covered by
the registration-lifecycle test in tests/native-surface.spec.ts.
2026-09-09 14:34:08 +08:00
HuanLinOTO 9a2c56eb42 test: 按 Copilot review 修测试健壮性(localStorage try/catch、动态 uuid);记录 pinned 跨会话限制 2026-09-09 14:29:44 +08:00
HuanLinOTO 9ce3c2706a fix(sidebar): 等待 banner 字体继承 UI 令牌,去掉整句 monospace 覆盖 2026-09-09 14:08:29 +08:00
HuanLinOTO 8c452104e5 docs(plans): 终端等待 banner 实施偏差记录 2026-09-09 13:47:05 +08:00
HuanLinOTO b3ebe52c3e feat(sidebar): agent 终端 tab 标题在等待时显示 ⏳ 徽章 2026-09-09 13:43:29 +08:00
HuanLinOTO c4c856a450 feat(sidebar): agent 终端等待 banner(needle + 跳过等待按钮) 2026-09-09 13:37:08 +08:00
HuanLinOTO 1fa777293f feat(sidebar): 镜像 agent 终端等待状态到 agentWaits(瞬态) 2026-09-09 13:29:27 +08:00
HuanLinOTO 993f7aaacd feat(terminal): terminal_wait_for 增 skipped 结果与 agent-pty.skip-wait 路由 2026-09-09 13:26:36 +08:00
HuanLinOTO fb2afabc72 feat(terminal): registry 支持等待登记、skipWait 与 skipped 结果 2026-09-09 13:20:08 +08:00
HuanLinOTO 2448068369 docs(plans): 修正计划 Task 5 测试(stub 描述符、UI tab 断言、文本计数) 2026-09-09 13:15:49 +08:00
HuanLinOTO d660f33966 docs(plans): 终端等待 banner 实施计划 2026-09-09 13:12:19 +08:00
Menghuan1918 aa1b9945d9 fix(native): register the plugin's tab types when the registry service arrives
The native seat declares `sidebar.right.pane.tab` BEFORE it provides
`sidebarRightTabs`, so driving the registration off the slot declaration
read the service as missing and registered nothing — permanently, since a
declaration never collapses. Observed on a real DSH profile (0.1.5-alpha.1,
`dsh web` on the web profile): the plugin host and its other slot
registrations mounted, the native guide page stayed empty, and a probe
printed `sidebarRightTabs= undefined` at declaration time with the registry
present three seconds later. The scratch mount lane never saw it because
there the plugin activates after the native package finished providing.

`registerNativeSurface` now waits on the SERVICE (`ctx.inject`), which
re-runs whenever the registry appears/reappears, and keeps the per-body
slot registrations on `slots.inject` (they only need the declaration).

Regression test: `tests/native-surface.spec.ts` models the real order
(slot callback fires first, registry arrives later) and fails on the old
implementation. Verified on the real web profile: all guide entries
(files / editor / git / subagent / sidechat / terminal / browser) appear
and the terminal tab renders.
2026-09-09 13:07:53 +08:00
HuanLinOTO 4a6a77a27e docs(plans): 终端等待 banner + 跳过等待设计 2026-09-09 13:03:34 +08:00
Menghuan1918 d7e3e2b986 fix(test): give the native-surface terminal descriptor a component
TabDescriptor requires `component`; the fixture omitted it, so CI's
typecheck step failed on the phase-2 branch (vitest never compiled the
file's props strictly, so the local run stayed green).
2026-09-09 12:33:49 +08:00
Menghuan1918 51546f7433 feat(native): render the plugin's tabs in DSH's native right Sidebar
Phase 2 of the 0.1.5 adaptation: the plugin stops drawing its own right
panel and contributes tab TYPES to DSH's native right Sidebar, which owns
the right column. The bottom workbench (terminal dock) stays plugin-owned.

Registrations (src/client/native/):
- every TabDescriptor becomes a native tab type (kind = descriptor.id, the
  default `extension` band) plus a keyed body in `sidebar.right.pane.tab`
  and a live chip title in `sidebar.right.pane.tab.title`
- the `editor` type also claims `dsh-resource://file/**`, so it outranks
  the built-in `text` preview (fallback) and every file the product opens
  lands in the plugin's editor
- the built-in `files` page kind is TAKEN OVER by an extension
  registration of the same kind: `openTab('files')` draws the plugin's
  explorer, and the built-in resumes when the plugin unregisters
- registrations follow the plugin registry + the side-card settings: a
  type registered later (external plugin) gets its native type too, a
  disabled type is unregistered (absent from the guide, refuses opens)

Adapter (src/client/native/tab-adapter.tsx):
- one synthetic plugin `SidebarTab` per native tab id, minted from the
  native record + navigation params (or the descriptor's own createTab,
  which a native guide open bypasses), kept live across navigations and
  updateTab writes
- the explorer's per-tab expansion/reveal sets and the per-kind instance
  counter, which the plugin's own layout used to hold

Service routing (src/client/service.ts):
- `setSurface` installs the native write face; openTab/openFile land
  natively (a path becomes a resource address, a path-less editor open
  becomes the `files` page, the rest carry the seed as navigation params),
  closeTab/updateTab/activateTab recognise native tab ids
- new optional `OpenTabSeed.target` ('right' default, 'bottom' keeps the
  bottom workbench); the bottom panel's own + menu and auto-terminal pass
  'bottom'
- src/client/native/surface.ts probes the controller's per-session writes
  (`openTabIn`/`openResourceIn`/`closeIn`, not in `ISidebarRight`) and
  queues opens for a session whose surface is not mounted yet

Right panel retired behaviourally (Phase 3 deletes the code): Sidebar
renders its bottom workbench only, keeps the panel collapsed, and hides
the right-panel toggle. The panel-specific e2e specs are removed; the
mount lane now drives the native guide (tab sweep), the plugin explorer
inside the native pane (editor/mermaid/README chunks) and the sidechat
host routes.

Verified: pnpm typecheck / lint / build / test (126 files, 1349 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (7/7).
2026-09-09 11:47:31 +08:00
Menghuan1918 ba0114749f feat(dsh): adapt to DSH 0.1.5-alpha.1
Move the plugin onto the DSH 0.1.5-alpha.1 baseline (peer floor
^0.1.5-alpha.1, CI pinned to the alpha dist-tag) and fix the two host
contract changes that alpha line introduced.

Baseline:
- all @deepseek-ai/dsh-* peer/dev deps -> 0.1.5-alpha.1; optional peer
  @deepseek-ai/dsh-client-ui-sidebar-right (declared, unused until the
  native-surface phase); devDep @deepseek-ai/dsh-agent-loop (inbox fold)
- dsh.plugin.json engines >=0.1.5-alpha.1; version 0.19.0-alpha.0;
  SIDEBAR_SERVICE_VERSION follows

Live assistant stream (assistant/chunk is gone):
- 0.1.5 publishes in-flight model deltas as process-local
  agent/assistant-stream frames instead of durable assistant/chunk events
  and embeds the settled stream in assistant/message / assistant/attempt.
  src/assistant-live.ts folds the frames into a bounded per-session buffer;
  sidechat.events serves the current attempt as `live` rows (replaced every
  poll, never a delta) and the transcript renders them; the inherited
  in-progress snapshot reads message content / attempt stream / live frames.

Cold session reads (persistence.inspect is gone):
- 0.1.5 reads a stored session through an explicit read handle
  (open(id,'read') -> handle.read() -> close()). src/session-store.ts owns
  that protocol; every cold read (sidechat transcript, cold-resume preset,
  session.cwd, changes replay) goes through it.

Dead code and docs:
- drop the remote.session.openWorkspacePath interception: 0.1.5's chat opens
  files through ctx.sidebarRight.openResource instead, so the wrapper had no
  caller and would hijack the host's "open in app" gesture; the
  interceptOpenPath pref, its settings row and its locale keys go with it
- AGENTS.md §3 + external-plugin-guide.md describe the new baseline
- vitest setup bridges Node's localStorage accessor to jsdom's store (the
  pre-existing 26 failures on Node >= 22)
- desktop-layout e2e seeds through host RPC and types into the Lexical
  composer (0.1.5 replaced the textarea) instead of driving the directory
  picker

Verified: pnpm typecheck / lint / build / test (124 files, 1327 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (18/18).
2026-09-09 11:15:30 +08:00
Atlas Zeng d88dcfc3a5 Merge pull request #595 from omdsh-dev/feat/wait-for-regex-needle
feat(terminal): wait_for needle 支持 regex,found 返回实际匹配文本
2026-09-08 23:27:45 +08:00
HuanLinOTO 3e408dbe48 feat(terminal): wait_for needle 支持 regex,found 返回实际匹配文本
needle 以 JavaScript 正则优先(编译失败回退字面匹配,旧字面 needle 完全兼容);found 结果新增 match 字段回报实际匹配文本——单个 needle 可覆盖多结局场景(如 (BUILD_OK|BUILD_FAIL) 同步构建成功/失败,match 区分命中分支);工具 description / needle 参数 / output schema / render 同步更新并写明多重匹配场景;新增 3 个 pty 级测试(alternation 命中分支、非法正则回退、元字符匹配);全量测试失败集合与干净 main 基线完全一致(27 个失败均为预存在 win32 非回归)
2026-09-08 23:07:07 +08:00
Atlas Zeng eecf9dc536 Merge pull request #593 from omdsh-dev/feat/terminal-shell-quoted-args-probe
feat(terminal): 终端 shell 配置加固——引号解析、POSIX 预检、本地化报错
2026-09-08 22:28:22 +08:00
Menghuan1918 ad22abcfe6 docs(plans): 修正实施偏差记录的 commit 引用(cbbed88 → 8704994) 2026-09-08 22:20:15 +08:00
Menghuan1918 81919516d8 fix(terminal): WS close reason 按 UTF-8 字节截断,避免超 123 字节抛错
ws 用 Buffer.byteLength 校验 close reason 的 123 字节上限(ws/lib/sender.js),
原 slice(0, 100) 按 UTF-16 code unit 截断,非 ASCII shell 名可达 316 字节
(实测 200 个 CJK 字符),ws.close() 会抛 RangeError 顶掉原始错误。
改为按字节截断且不切碎 code point,并补回归测试。
2026-09-08 22:20:15 +08:00
Menghuan1918 162c16ec32 feat(file-icon-theme): 可选彩色图标主题接线(懒加载 chunk + 设置开关 + i18n)
- 新 chunk `file-icons`(`lib/client-file-icons.js`,255 kB / gzip 90 kB):把数据表
  注册成 `registerFileIcon` 描述符——按 glyph+color 归并,563 条规则 → 约 250 条注册;
  目录规则只走 `folderNames`(只认领列出的目录,未列出的仍用内置 glyph)。
- `src/client/file-icon-theme.ts`:pref → loadChunk → 注册/注销,generation 守卫
  丢弃「切换中途落地」的加载;chunk 拉取失败只 log,保持内置 glyph。
- pref `fileIconTheme`(默认 `'builtin'`,未知值按 builtin 处理)+ 设置页
  「文件 → 文件图标」下拉(内置单色 / 彩色品牌图标)。
- chunk 名同步四处(chunk-loader / bundle-route / tsdown / manifest-consistency)+
  `bundle-route.spec`;tsdown 为该 chunk 加 react-icons ESM alias——否则整包打进
  6.4 MB,alias 后 255 kB。
- i18n:6 个新 key 镜像到全部 19 份词典(zh/en 之外),locale 键集守护通过。
- 皮肤契约:指南 §12 增豁免条款(品牌色是内容非 chrome,仅限该懒加载 chunk),
  AGENTS §5 同步;`tests/theme.spec.ts` 新增边界用例(核心图标模块零颜色字面量、
  核心不静态 import 该 chunk)。
- 测试:`tests/file-icon-theme.spec.tsx` 7 例(loader 六态 + 真实数据三表注册);
  prefs 新增默认值/解析用例;builtins / prefs / plugin-shape / smoke 夹具补字段。
- 全量 `pnpm test` 失败集与 main 基线逐条一致(5 文件 / 26 例预存环境失败),
  新增用例全绿;typecheck / lint / build / check:consumer-types 全通过。
2026-09-08 21:43:28 +08:00
fenter 8909455df2 feat(file-icon-theme): 彩色文件图标数据表(源自 #429 by @fenter)
本仓库可选图标主题的数据层:扩展名 / 精确文件名 / 目录名 → 品牌或通用
glyph + 展示色,共 563 条规则(218 扩展名 + 197 文件名 + 148 目录名)。
数据来自 PR #429(fenters/DSH-better-sidebar,feat/file-type-icons),
此处作为本插件内置可选主题复用,作者身份保留。

- 目录表的 Android/android、IOS/ios 两组大小写变体折叠为一条规则(解析器
  按大小写不敏感匹配,重复键会静默互相覆盖)。
- 剔除 44 个未被任何规则引用的 react-icons 导入(#429 写作时本仓库尚未
  接入 ESLint,未触发 unused 检查)。
- 本提交只放数据 + IconEntry/colored;注册与接线在后续提交完成。
2026-09-08 21:25:41 +08:00
Menghuan1918 a9a635138a feat(file-icons): FileIconDescriptor 支持 names / folderNames 与 open 参数
图标主题(VSCode 的 fileNames / folderNames 半边)需要按**文件名**和**目录名**
着色,扩展名规则表达不了 `package.json` / `Dockerfile` / `node_modules`。

- `names?: readonly string[]`:精确 basename(大小写不敏感),命中优先于扩展名。
- `folderNames?: readonly string[]`:精确目录 basename,命中优先于保留扩展名;
  只认领列出的目录,未列出的目录仍走 `'folder'`/`'folder-open'` 或内置 glyph。
- `exts` 变为可选:省略 = 没有扩展名规则(`names`-only 注册不再是 catch-all);
  `exts: []` 仍是全局默认 catch-all,语义不变。
- `icon(path, size, open?)`:目录行的展开态传入工厂,一条注册即可渲染开/合两态。
- `matchFileIcon` 先 names 后 exts;`matchFolderIcon(open, name?)` 先 folderNames
  后保留扩展名(新参数可选,旧调用不变)。
- 指南 §7 同步:字段、匹配顺序、四例注册(names / folderNames / 全目录 / catch-all)。

测试:`tests/file-icons.spec.tsx` 19 → 23(names 大小写、name 优先于 ext、
folderNames 只认领列出目录、open 传入工厂);`tests/consumer-types.ts` 补新字段。
2026-09-08 21:24:03 +08:00
HuanLinOTO e847771954 docs(plans): 回填终端 shell 配置加固实施偏差 2026-09-08 21:21:31 +08:00
HuanLinOTO e8761ee851 feat(terminal): 配置 shell 未找到时显示本地化可操作横幅(全语言) 2026-09-08 21:21:30 +08:00
HuanLinOTO 8aa9441627 feat(terminal): shell 覆盖去引号进解析、标题跟随覆盖、close reason 机器标记 2026-09-08 21:21:30 +08:00
HuanLinOTO 870499430b docs(terminal): resolveShellExecutable JSDoc 跟随 shell-not-found 新错误码 2026-09-08 21:21:30 +08:00
HuanLinOTO 26706bc3e9 feat(terminal): POSIX 也预检 shell 可执行文件,统一 shell-not-found 错误码 2026-09-08 21:21:30 +08:00
HuanLinOTO 829ad49ddf feat(terminal): 引号感知的 shell 参数解析与路径去引号纯函数 2026-09-08 21:21:30 +08:00
HuanLinOTO de0eac54b7 docs(plans): 终端 shell 配置加固设计与实施计划 2026-09-08 21:21:29 +08:00
Menghuan1918 8722bfb29c fix(file-icons): 适配 v0.18.1 基线——重命名行接入解析器 + 测试与门禁补齐
rebase 到 main(v0.18.1)后的适配层:

- 冲突(EditorHost ×2 / TreePanel / FileTree)按并集解:main 侧
  onPathRenamed/onPathDeleted 与 PR 侧 service prop 并存。
- main 新增的行内重命名行仍用 <VscFile>/<VscFolder>,而本次改动已删除
  VscFile import(合并后 TS2304)——改走 dirRowIcon/fileRowIcon,与树行
  同一条回退链;VscFolder 随之无引用,从 import 移除。
- tests/file-icons.spec.tsx:markerB 换成独立元素类型(<b>),使
  「priority 降序 / 同级按注册序」断言真正可区分(此前两个 marker 都是
  <span>,断言恒真);新增「工厂返回 undefined 则拒绝该级、链继续」与
  「注册/注销触发订阅通知」两例(17 → 19)。
- tests/consumer-types.ts:补齐 FileIconDescriptor / registerFileIcon /
  getFileIcons / matchFileIcon / matchFolderIcon / fileIcon / folderIcon /
  FOLDER_EXT / FOLDER_OPEN_EXT 的编译期门禁。
- README:v0.19.0(未发布)段移到「最近更新」最前(此前被自动合并夹在
  v0.18.1 与 v0.18.0 之间)。
- file-icons.tsx:注释修正——展平后的 Map 是后写覆盖,不是「first match
  wins」;当前无重复扩展名键,仅潜在语义。
2026-09-08 21:14:15 +08:00
Menghuan1918 8025b83a45 feat(file-icons): 目录保留扩展名注册 + 全局默认兜底 + 编辑器文件 tab 对应
- registerFileIcon 保留 'folder'/'folder-open' 特殊扩展名:目录行(含根行)
  图标可注册,匹配保留值不认领真实 .folder 文件
- 新增权威解析器 fileIcon(path,size)(具体注册 → 内置 glyph → catch-all
  全局默认 → VscFile,逐工厂崩溃隔离)与 folderIcon(path,open,size);
  matchFileIcon 收窄为仅具体扩展名匹配
- 编辑器文件 tab(非 meta.dir)与文件树行显示同一 fileIcon 图标
- 同步 external-plugin-guide §7(保留值/回退链/三例注册)与 README
  (功能一览、v0.19.0-alpha.0 发布注记、服务化扩展章节)
- tests/file-icons.spec.tsx 重写为 17 用例(链序/保留值/目录/崩溃回退)
2026-09-08 21:11:32 +08:00
Menghuan1918 05773e0f2a feat(file-icons): 文件树按扩展名区分图标 + 对外 registerFileIcon API
- 新增内置扩展名→glyph 映射(md/媒体/pdf/json/代码/配置/数据库/压缩包,
  单色 currentColor 遵循皮肤契约,未知扩展回退 VscFile)
- ctx.betterSidebar 新增 registerFileIcon/getFileIcons/matchFileIcon
  (priority 降序,同优先级 catch-all 垫底),features 增加 'fileIcons'
- FileTree 文件行消费注册表:注册图标优先(可为彩色 ReactNode,
  工厂抛错回退内置),注册/注销即时生效
- 同步 docs/external-plugin-guide.md §7 与 tests/file-icons.spec.ts
2026-09-08 21:11:32 +08:00
Atlas Zeng 146840bb4f Merge pull request #586 from omdsh-dev/chore/release-0.18.1
chore(release): 0.18.1
2026-09-08 16:20:03 +08:00
Menghuan1918 364a48df98 chore(release): 0.18.1
- package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION → 0.18.1(三者锁步由
  tests/service.spec.ts 与 tests/manifest-consistency.spec.ts 断言)
- README / README_EN:新增 v0.18.1 条目并同步三处徽章版本号——自 v0.18.0 起的
  11 个 PR:#499 变更面板预览增强(md 阅读模式 / html·pdf 渲染 / 密钥脱敏)、
  #550 文件树重命名与删除、#535 目录名词典化、#562 侧边对话种子补 fork 标记、
  #569 markdown 本地图片改写、#576 git 折叠上下文按需展开(含 git.show 路由
  rev:path 修正),以及 #524/#526/#527/#534/#536/#540/#541/#542 的内部收敛
- docs/external-plugin-guide.md:当前版本行 → v0.18.1
- DSH 基线不变(peer ^0.1.2-rc.1,CI 钉 0.1.2-rc.1),无运行时行为改动

验证:make check(typecheck → lint → build → test → check:consumer-types)全绿,
124 test files / 1319 passed, 9 skipped
2026-09-08 16:16:16 +08:00
Atlas Zeng df1f3b9666 Merge pull request #576 from LaoYueHanNi/fix/diff-fold-expand
fix(diff): git 折叠上下文按需展开,修复「点击展开」无反应
2026-09-08 16:14:53 +08:00
haruhi 5bf7dfe3c4 fix(git): git.show 路由改用仓库相对路径,修复折叠展开空白
- src/index.ts: git.show 的 path 不再经 resolveGitPath 转绝对路径——rev:path 语法只认仓库相对路径(diff 输出剥 a// b/ 前缀的形态),绝对路径恒 fatal 返 null,折叠展开渲染空行;透传后暴露面与 git.diff/git.log 一致(仅本仓库版本树)
- src/client/changes/DiffPane.tsx / src/client/DiffTab.tsx: 两侧内容皆空时抛错走「上下文未加载」降级,不再静默空白展开

背景:真机 link 测试发现展开后 marker 消失但零行渲染——git.show 路由此前无真实调用方,resolveGitPath 的工作区语义与 rev:path 树寻址不匹配的缝隙首次暴露。
设计 docs/plans/2026-09-07-git-fold-expand-design.md 实施偏差第 8 条同提交。
2026-09-08 00:04:25 +08:00
haruhi 900654b8fd fix(diff): git lens 折叠行接入按需展开,修复点击无反应
- src/client/changes/DiffPane.tsx / src/client/DiffTab.tsx: 按 SidebarDiffRef 构造 fold loader——commit 侧 <hash>^ 与 <hash>;staged 侧 HEAD 与 :0;unstaged 侧 :0 与工作区 fsRead(失败/二进制降级空串);per 文件内容 Promise 缓存,目标或刷新 tick 变化时重置
- fallback 换侧时记录 effectiveStaged,loader 依实际渲染侧取数,修正切片行号错位
- src/client/locales.ts 及 19 个 locales-*.ts: 新增 changesFoldLoading / changesFoldUnavailable 全量补译(共 20 文件 21 处)
- tests/diff-fold-expand.spec.tsx: 展开/折叠/缓存去重、失败降级、无 loader 保持 inert、session 路径回归

背景:git 提交/工作区 diff 折叠行承诺「点击展开」却永远不可点,与 session lens 行为不一致;接入状态机后承诺为真,session 路径与现有文案零变化。
设计 docs/plans/2026-09-07-git-fold-expand-design.md §4.5-4.6。
2026-09-07 23:51:10 +08:00
haruhi d990c10e59 feat(diff): 折叠行异步展开状态机与内容切片
- src/client/api.ts: 仿 gitCommitDiff 新增 gitShow 封装,走既有 git.show 服务路由(payload {rev, path},返回 content 或 null),服务端零改动
- src/client/diff/rows.ts: 新增 foldRowsFromContents 纯函数——old 侧按 fold 行号切片为 context 行(new 侧经区间偏移映射,落界才带行号),new 侧补纯增行;越界截断、\r 保留
- src/client/diff/DiffRows.tsx: 新增 resolveFold prop;foldData/foldLoading/foldFailed 三态,加载中不可点,失败降级静态标记;foldEpoch 使 segments 重置后 in-flight 回调失效
- src/client/diff/DiffFiles.tsx: resolveFold 下传各文件 DiffRows,fold 级 Promise 缓存去重并发点击与重挂载
- tests/diff-rows.spec.ts: foldRowsFromContents 六用例(对称/纯删/纯增/\r\n/空内容/越界)

背景:git -U3 不输出 hunk 间未改动行,gap fold 只有行号范围没有 rows,渲染复用「点击展开」文案却无交互;展开能力建为独立状态机(含 api 封装),git lens 只需注入取数 loader。
设计 docs/plans/2026-09-07-git-fold-expand-design.md §4.1-4.4。
2026-09-07 23:51:06 +08:00
haruhi 6b02285742 docs(diff): git 折叠行按需真实展开设计文档
- 新增 docs/plans/2026-09-07-git-fold-expand-design.md:bug 证据链、切片算法、两 lens 取数映射与边缘 case 清单
- §8 仓库规范补「风格对齐」条款;文末补实施偏差记录 7 条

背景:changes 的 git lens 折叠行显示「点击展开」却无交互(-U3 裁剪使 gap fold 无行文本),文档定稿按需拉取两侧内容真实展开的修复方案。
2026-09-07 23:51:05 +08:00
Atlas Zeng 6fbfeedc01 Merge pull request #499 from lhh010/feat/changes-md-reading
feat(changes): markdown reading mode + expanded syntax highlighting
2026-09-07 16:06:27 +08:00
Menghuan1918 9302d02cf5 refactor(changes): bring the PDF render toggle in line with the pane conventions
Escape the .pdf extension in the op-target regex (the unescaped /.pdf$/
matched any path ending in pdf) and fold the copy-pasted toggle button
into PaneToggle; record the sixth deliverable in the design doc.
2026-09-06 23:48:25 +08:00
Menghuan1918 0c3d1d6e32 docs(changes): design doc for the op-preview enhancements
Records the five features shipped on this PR (md reading mode incl.
mermaid, highlight expansion, secret redaction, html render mode), their
deliberate boundaries (redaction heuristics, saved-file render
semantics), and the 2026-09-06 standards pass.
2026-09-06 23:46:46 +08:00
Menghuan1918 157e02e768 refactor(changes): dedup the op-preview path and align conventions
- redaction useMemo: one redactText pass per field (the outcome carries
  both text and hit; was a hit-scan plus a mask pass), and the banner
  condition drops the redundant redactionOn check (hit implies on)
- three copy-pasted header toggles -> PaneToggle; the md reading body
  -> MdReadingView (absorbing the mermaid branch + codeLabels)
- redaction preference persisted under the repo's dsh-sidebar:v1: prefix
- render toggle limited to .html/.htm (the editor html viewer's ext set;
  .xhtml went beyond any claim)
- tsx/jsx share TS_WORDS/JS_WORDS; CSS word list loses its duplicate
  entries and its nonexistent true/false constants
- parseReadContent/parseReadLines share the contentLines() envelope core
  (content extraction + Showing-lines filter)
2026-09-06 23:46:46 +08:00
Menghuan1918 04693192df fix(redact): keep the credential heuristics off ordinary files
Path layer: patterns now match with a right boundary (must not continue
into [a-z0-9]) and list only store-plural forms (credentials/secrets/
passwords, not tokens), so tokenizer.ts / dev.environment.ts /
style.keys.ts / api-tokens.md are no longer masked whole while
.env.local / credentials.json / mytoken.yaml still are.

Content layer: drop the bare key/auth/token/pass/bearer field names —
they label far more ordinary content (keyboard keys, CSS custom
properties, lexical tokens) than secrets; qualified names stay and
Bearer schemes remain covered by BEARER_RE.

Adds the over-masking regression cases; fixes the spec's source import
extension to .ts (repo convention).
2026-09-06 23:46:46 +08:00
Menghuan1918 03e19c4cd4 fix(changes): use real skin tokens in the preview-pane additions
.htmlFrame hardcoded #fff -> var(--dsw-alias-bg-base) (mirrors the editor's
.editorHtml); .redactBanner referenced --dsw-alias-state-warning-primary,
which does not exist in the DSW vocabulary (it is state-warn-*), so the
var() always fell through to a hardcoded #b8860b — use the real token with
no literal fallback.
2026-09-06 23:46:46 +08:00
lhh010 dc40f82180 chore: re-run CI (agent-pty timing flake on ci-windows) 2026-09-06 23:18:53 +08:00
lhh010 6df06717ad feat(changes): PDF render preview for .pdf op targets 2026-09-06 23:06:18 +08:00
lhh010 8456d77a92 Merge remote-tracking branch 'origin/main' into feat/changes-md-reading
# Conflicts:
#	src/client/MarkdownHtml.tsx
2026-09-06 22:59:56 +08:00
Atlas Zeng 2dc2dcf418 Merge pull request #569 from omdsh-dev/fix/markdown-preview-local-images
fix(editor): rewrite local images in markdown runs of the split renderer
2026-09-06 22:52:37 +08:00
Menghuan1918 2a99968fa3 fix(editor): rewrite local images in markdown runs of the split renderer
Every .md preview renders through MarkdownDocument (htmlInfo is ungated),
but its markdown segments passed the raw source into MarkdownText, whose
chat-security stance drops non-http(s) image destinations — so local
markdown-syntax images (![alt](./icon.svg), reference-style definitions)
always fell back to alt text. Only HTML <img src> tags were rewritten
(postProcessSanitized).

Apply rewriteLocalImageUrls to each markdown run (segment + appended
reference definitions) inside MarkdownDocument's prepared memo, through
the same media trust fence the sanitized HTML leaves already use.
Idempotent for already-absolute media URLs; remote destinations
untouched. Also correct the stale "non-null only for HTML documents"
comment on TextEditor's htmlInfo.
2026-09-06 21:53:49 +08:00
Atlas Zeng bb21657eae Merge pull request #562 from omdsh-dev/fix/sidechat-seed-inherited-inbox
fix(sidechat): 种子补 fork 标记,子会话不再继承父会话未领取的 inbox 消息
2026-09-06 21:43:13 +08:00
lhh010 e356791661 feat(changes): HTML render preview for .html op targets
.html/.htm/.xhtml op previews (read / write / edit, non-error) gain a
Render/Raw toggle in the preview pane head: the render view loads the
SAVED file through the same /sidebar/html route the editor's html viewer
uses, so relative assets (./style.css, img/x.png) resolve inside the
route and a segmented read still renders the whole document (the route
serves the file, not the op snapshot — the file-trace-style srcDoc
approach would render a cut-off document whose scripts never run).

The frame is ALWAYS sandboxed (HTML_IFRAME_SANDBOX, moved to the shared
html-preview.ts module — opaque origin, no allow-same-origin, no
top-navigation) plus the route's CSP sandbox header as
defense-in-depth; unlike the editor viewer this surface has no
no-sandbox escape hatch. The contract is pinned by new sandbox-spec
tests on the extracted HtmlRenderPreview component and the pane toggle
(off by default; absent for non-html and error targets).

Locales: zh/en + all 19 shipped third-language overrides (key-set-equal
gate green). Typecheck + eslint clean; sandbox/redact/diff-rows/frontmatter
suites 36/36 green. The full-suite host-side failures (fs-operations /
pty smoke / git / install-powershell) are pre-existing on the base
branch (verified via stash) and unrelated.
2026-09-06 10:37:53 +08:00
Menghuan1918 93ad87dfe7 fix(sidechat): stamp the seed with fork markers so the child inherits no pending inbox
sidechat.start passed `seed` without `meta.isSeeded: true` +
`inheritedEventCount`, so dsh-session treated the whole seed as the
child's OWN events and the child's Inbox constructor replayed the
parent's `agent/inbox/spliced` history — inheriting whatever input sat
UNCLAIMED in the parent at the click moment:

- next-turn: a queued follow-up behind a still-running turn;
- next-step (the reported long-context case, parent queue empty): a
  tool-result context / steering spliced between step boundaries.

The first side prompt then claimed [phantom, boundary, question] — the
stale parent message was sent to the model BEFORE the boundary and
logged as a user/message. The fix passes the exact marker pair the
host's own session.fork uses (api-session-controller); ownEvents() now
stops at the end-seed boundary and the inherited inbox replays empty.

Regression tests run the REAL Session.create + Inbox in both
directions (old shape inherits the phantom, marked shape stays empty);
routes tests pin the two create options. Verified: pnpm typecheck /
test (5 pre-existing unrelated UI-spec failures on main unchanged) /
build.
2026-09-06 00:59:03 +08:00
Atlas Zeng a5c52b3f1b Merge pull request #550 from omdsh-dev/feat/file-tree-rename-delete
feat(files): 文件树重命名/删除 + 右键菜单减重与子菜单钳制
2026-09-04 23:04:37 +08:00
Menghuan1918 e39ea92ade feat(files): tree rename (inline) + delete (confirmed) + submenu visual tether
资源管理器补全:文件树右键菜单新增「重命名」(行内编辑:预填+全选、
Enter/失焦提交、Esc 取消、IME 组合键守卫)与「删除」(Modal 确认,文件/
目录文案区分,永久删除——宿主无回收站)。根行(工作区根)不提供两项,
服务端 fs.rename/fs.remove 双保险拒绝;目标已存在 409;link-aware——
symlink 行只改/删链接本身(lstat 判定),fence 校验对解析后目标进行。

落定收尾:pruneTree 清旧前缀缓存 + 收起 stale expanded + 单层重载父目录;
tab 调解走 service(rename→updateTab 改路径改标题、delete→closeTab 关
受影响 tab,覆盖两侧分栏/底栏/浮动窗口,经 tree-mutations.ts)。

二级菜单视觉关系(layout.css,仍限 data-dsh-sidebar-submenu 作用域):
父行在子菜单打开期间保持高亮(宿主已渲染 aria-expanded="true"),
卡片间距 10px→4px(悬停走廊同步收窄),翻转规则一并 4px。

i18n 新增 7 key × 20 语言文件(locales.spec 的 key-set-parity 门禁)。
测试:fs-operations 追加 12 例(rename/remove 全失败面);新增
file-tree-rename-delete.spec.tsx 8 例(菜单可见性/重命名流/删除流/错误条);
menu-flip.spec.tsx 契约扩展覆盖 tether 规则。设计文档
docs/plans/2026-09-04-file-tree-rename-delete-design.md。
2026-09-04 22:08:02 +08:00
Menghuan1918 76b57508f4 fix(ui): compact context menus + viewport-clamped submenu flip
减重:全部右键菜单(FileTree 行菜单 / TabBar tab 菜单 / GitLens 文件与
历史菜单 / FreeWindow 标题菜单)与「+」菜单切到宿主 Menu 原语的 compact
密度(26px 行高 / 12px 字 / 164px 卡片),图标随槽位 16→14,「打开方式」
子菜单图钉 20→16,还原工具型右键菜单的趁手感。

越界:宿主子菜单纯 CSS 定位(从父行向上、向右生长)不受视口钳制——树顶
部右键时「打开方式 ▸」子菜单伸出视口上沿,窄面板(280–400px)时伸出右
沿。新增 menu-flip.ts:菜单打开期间在 <body> 发布 data-dsh-sidebar-submenu
="down|left" 方向 token(y < vh/2 → down;x + 400 > vw → left),layout.css
据此翻转子菜单生长方向与 ::before 悬停桥;属性存续期即作用域,宿主自有
菜单不受影响。刻意不做 max-height 滚动钳制——overflow 裁剪会切断悬停桥。

测试:menu-flip.spec.tsx(token 纯函数 + hook 生命周期 + CSS 契约跨文件
一致性);tab-bar / file-tree 两个 spec 追加翻转向量断言;mount.e2e 追加
compact 行高(≤30px)布局级证据。设计偏差记录同步至
docs/plans/2026-08-22-open-with-menu-design.md。
2026-09-04 20:28:35 +08:00
Atlas Zeng e1b9b539e4 Revise version details and update QR codes
Updated version information and fixed QR code image links in README.
2026-09-04 17:33:42 +08:00
lhh010 7f9a040065 fix(changes): resolve merge lint errors from origin/main sync
- DiffPane.tsx: gate the redacted view on the computed `hit` (the unused-var
  lint was the memo returning redactionHit: true unconditionally; now an
  unredacted payload short-circuits like the toggle-off path, and only an
  actual secret hit builds the masked copy)
- MarkdownHtml.tsx: drop the three dead imports (ComponentType,
  lazyChunkComponent, MermaidMarkdownProps) left by the mermaid-lazy merge

Typecheck clean; eslint clean on both files; 39 targeted tests pass.
2026-09-04 14:25:33 +08:00
lhh010 16d8df2e93 Merge remote-tracking branch 'origin/main' into feat/changes-md-reading
# Conflicts:
#	src/client/changes/DiffPane.tsx
#	src/client/locales-ar.ts
#	src/client/locales-de.ts
#	src/client/locales-fr.ts
#	src/client/locales-hi.ts
#	src/client/locales-id.ts
#	src/client/locales-it.ts
#	src/client/locales-ja.ts
#	src/client/locales-ko.ts
#	src/client/locales-nl.ts
#	src/client/locales-pl.ts
#	src/client/locales-pt.ts
#	src/client/locales-ru.ts
#	src/client/locales-sv.ts
#	src/client/locales-th.ts
#	src/client/locales-tr.ts
#	src/client/locales-vi.ts
#	src/client/locales-zh-HK.ts
#	src/client/locales-zh-MO.ts
#	src/client/locales-zh-TW.ts
2026-09-04 14:13:34 +08:00
lhh010 a2d3faf7b5 feat(changes): secret redaction layer for op previews
The changes-tab op previews replay read/write/edit payloads into the DOM
(diff rows, read rows, markdown reading mode, error text) — when the model
reads a credentials file, that plaintext must not land on screen or in
inspectable DOM. New display-only redaction layer, on by default with a
one-click pane toggle (persisted in localStorage):

- path layer: credential-shaped filenames (.env / *secret* / *credential* /
  *token* / *api-key* / private keys...) mask whole files line-wise
- content layer: ordinary files mask only secret-shaped spans — secret
  field assignments keep the field name (api_key: [REDACTED]), plus bare
  token prefixes (sk- / AKIA / ghp_ / xox / ya29.), Bearer headers, and
  PEM private-key block headers
- redacted payloads flow through the same viewOp shape into diff rows,
  read rows, markdown reading mode, and error text

Known edge (documented in README): an unquoted mermaid node label
containing a redacted secret breaks the diagram and falls back to source;
workaround is quoting the label. Session data is untouched — this is
display-only and not a boundary against same-context malicious plugins.

Locales: zh/en dictionaries + all 19 shipped third-language overrides
(key-set-equal gate green). 9 new unit tests; typecheck clean; targeted
suites 40/40 green. The fs-operations failure is pre-existing on the base
branch (environment-dependent, verified via stash) and unrelated.
2026-09-04 13:10:21 +08:00
Menghuan1918 f59ffd0741 docs(plans): 全方位代码防腐审查报告与 backlog(2026-09-03) 2026-09-03 21:21:22 +08:00
Atlas Zeng f7332f332b Merge pull request #542 from omdsh-dev/feat/split-sidebar
feat(client): Sidebar.tsx 按关注点增量拆分
2026-09-03 21:20:15 +08:00
Menghuan1918 e105b01f2b refactor(client): 抽取自由窗口拖出检测与浮窗层到 sidebar/free-windows.tsx
Sidebar.tsx 拆分第五块:useFloatDragout(document 捕获态 dragover/
drop/dragend/blur 监听、会话列矩形命中测试、hint 状态去抖)与
FreeWindowLayer(floats.map 渲染 + 拖出 hint 覆盖层,fragment 渲染
保证 DOM 顺序与原先逐字一致)原样迁出,注释逐字保留。

行为不变:effect 依赖与回调闭包相同;层组件接收的 renderTab/
getTabIcon/store/ctx/sessionId/cwd 即原先 JSX 内联闭包捕获的同一批
每 render 新建的值,FreeWindow 收到的 props 与回调语义不变。
2026-09-03 21:09:20 +08:00
Menghuan1918 b4ab3f154f refactor(client): 抽取 pinned 虚拟 tab 注入与动作包装到 sidebar/use-pinned-tabs.ts
Sidebar.tsx 拆分第四块:activePinnedTabId/pinnedRevision 状态、
pinnedEntries/pinnedVirtualTabs/augmentedTree 三个 memo(含
exhaustive-deps 豁免注释随迁)与 wrappedActions(虚拟 id 路由回
HOME 会话的 reduceFor + ptyClose 语义)原样迁出,注释逐字保留。
调用点移到 actions memo 之后(hook 无 effect,仅 useState/useMemo,
位置调整不改变行为)。

行为不变:memo 依赖数组逐字相同;wrappedActions 的 actions 入参即
原先闭包捕获的同一个 memo 产物。
2026-09-03 21:08:03 +08:00
Menghuan1918 d5b1201ad8 refactor(client): 抽取宿主推送与自动激活触发器到 sidebar/use-host-feeds.ts
Sidebar.tsx 拆分第三块:agent-terminals / agent-opens 两条 WS 推送
(含重连退避与失败上限)、子代理自动激活(去抖重估)、后台任务自动
激活、拓扑跳回(subagentJumpRef)连同 FAILURE_LIMIT /
AUTO_OPEN_DEBOUNCE_MS 常量原样迁出,注释逐字保留。Sidebar 侧仅
消费返回的 subagentJumpRef(renderTab 的 onSubagentJump 装填)。

行为不变:五个 effect 的依赖数组与闭包值逐字相同,hook 每 render
无条件调用,effect 挂载顺序保持(feeds → center → float →
bottomAuto → drag)。
2026-09-03 21:05:41 +08:00
Menghuan1918 6bccd21a49 refactor(client): 抽取中心列定位/测量链到 sidebar/use-center-column.ts
Sidebar.tsx 拆分第二块:centerRectRef/centerMeasured/centerColRef/
draggingRef、measureCenter(ref + 直写 DOM,避免动画帧级 re-render)
与整条 locate/ResizeObserver/MutationObserver/1.5s 重试链原样迁出到
src/client/sidebar/use-center-column.ts,注释逐字保留。bottomRef 与
state?.bottomOpen 由调用方传入(bottomRef 在主文件先建再共享,行为与
原先 measureCenter 闭包捕获一致)。

行为不变:hook 每次 render 全量重跑,effect 依赖与值完全相同;唯一
文字差异是三处依赖数组补列了跨 hook 边界后失去 useRef 溯源的稳定
ref 对象(centerColRef/draggingRef,标识恒定,不改变触发时机),
为 react-hooks/exhaustive-deps 零告警所需。
2026-09-03 21:03:14 +08:00
Menghuan1918 dbc73f0bf3 refactor(client): 抽取 TabContent 单元格与 + 菜单构建到 sidebar/TabContent.tsx
Sidebar.tsx 拆分第一块:memo 化的 TabContent(含 TabContentProps 与
tabContentCompare 比较器)和 buildNewTabOptions 原样迁出到
src/client/sidebar/TabContent.tsx,注释逐字保留。

行为不变:组件体零改动,仅模块路径与 import 调整(相对路径加一层
../);Sidebar.tsx 改为 import 该模块,对外导出面不变(仍只有
Sidebar 具名导出)。
2026-09-03 21:00:20 +08:00
Atlas Zeng 68839aa5fb Merge pull request #541 from omdsh-dev/feat/unify-polling
feat(client): 收敛四处轮询习语到共享 use-polling 原语
2026-09-03 20:45:46 +08:00
Menghuan1918 1ec1565b13 refactor(client): 四处轮询站点迁移到 use-polling,语义逐一保持
- SubagentView useSubagentLive:self-scheduling(上一请求 settle 后才排
  下一次,不重叠)+ 每代 AbortSignal + signal.aborted 拦迟到写入,行为
  与原 disposed 标志等价(teardown 同步置 disposed 并 abort)。
- SideChatView:立即拉取 effect 与 2s 定时拆分——输入变化(含 running
  翻转)仍先拉一次转录,运行中间隔 tick 拉转录+徽章;fetchThread 自带
  的逐次 abort 与卸载 abort 原样保留。
- GitLens:可见时 2s 固定静默 tick,无初始突发(mount/scope 变更的完整
  刷新在别处);refresh 身份变化照旧重启间隔相位。
- ChangesTab:输入变化即拉一次(含隐藏态翻转),可见时 2.5s 固定间隔;
  pull 内部 pollGen 代际守卫不动。

守护 spec 全绿:subagent-live-polling + subagent-jobs-view 13/13、
sidechat-view + sidechat-transcript 29/29、git-view-worktree +
changes-tab + changes-ops 13/13;断言零改动。
2026-09-03 20:35:59 +08:00
Menghuan1918 6ef11ae010 feat(client): 引入共享 use-polling 轮询原语
四处站点(SubagentView 活动预览 / SideChatView 转录 / GitLens 状态 /
ChangesTab 会话事件)各自手写取消安全与代际失效,机制各异。先落地共享
原语:间隔与调度模式(fixed-interval / self-scheduling)参数化、每代一个
AbortSignal + 卸载/依赖变更即失效(迟到响应靠 signal.aborted 拦截,不信任
abort 必然触发 reject)、任务异常静默保留旧值由下一 tick 重试。
2026-09-03 20:35:59 +08:00
Atlas Zeng aa01140252 Merge pull request #540 from omdsh-dev/feat/drop-rc7-fallback
feat(client): 删除 rc.7 宿主的 __DSH_MODULES__ 回退路径
2026-09-03 20:35:46 +08:00
Menghuan1918 dc33087ce1 feat(client): 删除 rc.7 宿主的 __DSH_MODULES__ 回退路径
peer 基线已是 ^0.1.2-rc.1(全部 dsh-* peer),最后提供
window.__DSH_MODULES__ 页面全局的宿主是 0.1.0-rc.7,远低于下限,
装不上本插件——回退在受支持宿主上不可达。受支持宿主上 apply() 在
任何懒加载发生前调 setChunkModuleSystem(ctx.modules),moduleSystem()
前两跳(注入变量 + 插件全局 __dshSidebarModuleSystem__)必中。

回退的唯一活消费者是 vitest fixture:installModuleSystem() 原先经
rc.7 全局投递 fake 模块系统,现改为与生产同路径的 setChunkModuleSystem
注入;无任何专测回退分支的用例,全部断言保持不变。

- chunk-loader.ts: 删 moduleSystem() 第三跳,同步头注 /
  ChunkModuleSystem 接口注中的回退描述
- index.tsx: 删「falls back to the rc.7 global」注释
- README v0.14.0 changelog 与 docs/plans 设计文档中的提及属历史
  存档(描述 PR #232 当时的迁移),保留不改

验证:lint / typecheck / build / vitest 119 文件 1243 用例全绿;
真机挂载 lane(钉版 @deepseek-ai/dsh@0.1.2-rc.1 CLI)mount.e2e
全内置 tab sweep(含终端 / editor 懒加载 chunk)通过;lib/*.js
产物中无 __DSH_MODULES__ 残留。
2026-09-03 20:29:59 +08:00
Menghuan1918 8820efd6bf fix(tests): 补回 add-plugin-modal.spec 缺失的 builtinTabPlugins import
#535(i18n catalog names)给 tests/add-plugin-modal.spec.tsx 新增
sentinelEntry 行时漏了 import;#535 自身 CI run 被 cancel,#536 带病
合入,main HEAD 7b5c7dd 的 typecheck/test 双红(本地 stash 复现一致:
TS2304 / ReferenceError: builtinTabPlugins is not defined,CI run
33754439466 failure)。补上与 plugin-list.spec.ts 同源的 import。

与本 PR 主题无关,仅为本流验证门(typecheck/test 全绿)所需的最小
main 修复,独立成 commit 便于 review 剔除。
2026-09-03 20:29:50 +08:00
Atlas Zeng 7b5c7ddcc4 Merge pull request #536 from omdsh-dev/feat/eslint
feat(lint): 引入 ESLint flat config 并接入 CI 与 Makefile
2026-09-03 20:18:10 +08:00
Atlas Zeng f8bb3013ff Merge pull request #535 from omdsh-dev/feat/i18n-catalog-names
feat(i18n): 目录项与 shell 预设文案词典化,兜底错误条改皮肤令牌链
2026-09-03 20:18:05 +08:00
Atlas Zeng 463e96b6b4 Merge pull request #534 from omdsh-dev/feat/palette-single-source
refactor(theme): One Dark/Light 语法色板单源化
2026-09-03 20:18:01 +08:00
Menghuan1918 98e9a4ff0f docs(readme): 开发与构建命令清单补 pnpm lint(双语镜像) 2026-09-03 20:00:08 +08:00
Menghuan1918 5a1e75e46b fix(lint): 清零存量 lint 告警并接入 CI / Makefile 门禁
存量清理(首跑 141 errors / 21 warnings → 0 / 0):
- 删除孤儿 disable:SideChatView / Sidebar / selection-popup 三处 exhaustive-deps 豁免与 chunk-artifact 的 no-new-func 豁免(多行理由注释把 disable-next-line 顶离了实报行;selection-popup 改为提取 popupOpen 标志真修复)
- 死代码:未用导入(Sidebar×7、TextEditor、SideCardSection、config、fs-operations 及 6 个 spec)、sidechat-core 死累加器 total、chunk-loader.spec 死助手 registry、sidechat-seed-validation 死助手 toolResultMessage
- 真实小修:agent-opens 三处 throw 补 cause;lazy-chunk any → ComponentType<P>(P extends object);service.ts claimed 初值改定值赋值;chunk-loader task / spec 内 etag / pendingLoad / snapshot 改 const;terminal-links 去掉字符类内多余转义;SubagentView catalogs 空兜底 useMemo 稳定标识;editor-host.spec 组件提出大写 FakeViewer(rules-of-hooks);pty-helpers.spec 内联 require 提升为静态导入;panel-host-css 注释里的零宽空格改为可见写法
- 依赖数组豁免(均带理由):granular scope 字段(EditorHost / PdfView / ChangesTab / GitLens×2)、job.status 粒度(SubagentView×2)、清理期读 ref、mount-only 注册等;ctx(挂载期恒定)直接入 deps(Sidebar×3)、TextEditor previewScroll 补 scope(幂等)
- 刻意控制字符正则(conversation-draft / markdown-images)与宿主槽位 any 签名(context-types)以带理由豁免保留

接入:
- Makefile:lint 目标(typecheck 旁),check 聚合补 lint 保持「对齐 CI」语义
- ci.yml ci / ci-windows 两 job 与 release.yml 在 Typecheck 后加 Lint 步骤(release 注释同步顺序)
2026-09-03 20:00:08 +08:00
Menghuan1918 f418692ad0 build(lint): 引入 ESLint 工具链与 flat config 最小规则集
- devDependencies: eslint 10 / typescript-eslint 8 / eslint-plugin-react-hooks 7 / globals / @eslint/js(ESLint 10 起 @eslint/js 不再随 eslint 自带,需显式安装)
- eslint.config.js:@eslint/js recommended + typescript-eslint recommended(非 type-checked)+ react-hooks recommended(v7 的 React Compiler 语义规则档整体关闭——57 处存量误报,保留经典 rules-of-hooks / exhaustive-deps 双规则)
- globals 分域:src/client → browser;src 根 / scripts / 根级 config → node;tests → node+browser
- reportUnusedDisableDirectives: error 揪孤儿豁免;no-unused-vars 对齐仓库既有 _ 前缀占位约定
- package.json 增加 lint script(eslint .)
2026-09-03 20:00:07 +08:00
Menghuan1918 e8834bd349 feat(theme): drive the last-resort error bar through skin tokens
The host-anchor fallback diagnostic strip inlined hardcoded colors
(#f2a1a1 on #1b1b22). Route them through var() chains like the existing
SideChatView.module.css fallback pattern: text/border read
--dsw-alias-state-error-primary (the codebase's canonical error color,
23 existing usages) and the background reads
--dsw-alias-bg-layer-3,var(--dsw-alias-bg-base,...) (elevated surface,
then page base). The old hexes ride as the chain tails, so with no skin
tokens on the page the bar renders byte-identical to before, while any
--dsw-alias-* skin re-themes it (guide §12: no hardcoded colors). The
guide does not cover this strip and tests/theme.spec.ts asserts
effectiveTokenValue semantics only, so neither needs a follow-up.
2026-09-03 19:59:00 +08:00
Menghuan1918 040b182b1c refactor(theme): One Dark/Light 语法色板单源化
新增 src/client/one-dark-palette.ts,作为 one-dark / one-light 两套语法
色系的唯一来源:TerminalView 的 ANSI 16 与 cm-themes 的 CodeMirror 高亮
规则改为从色板常量组装各自的映射(ANSI 槽位 / lezer tag 到色相的映射
仍留在各消费方——两边的角色映射本就不同)。

行为不变的纯重构:每个 hex/rgba 迁移前后逐字节一致(旧新片段在桩
tags 下求值深度比对相等;产物 hex 清单无删除、非色板 hex 计数零变化)。
TerminalView 的 token 兜底色与 cm-themes 的 selection/activeLine rgba
与语法色系无关,保留原处未合并。

chunk 边界:色板为纯常量,被 terminal / editor 两个懒 chunk 各自内联
(每 chunk 约 200B 字面量),不引入跨 chunk import;核心 bundle
(client.js / client-registry.js)对该模块零引用(grep 产物验证)。
tests/one-dark-palette.spec.ts 钉住色板字面量防意外漂移。
2026-09-03 19:59:00 +08:00
Menghuan1918 fa301da29e feat(i18n): localize plugin catalog names and shell preset desc
Catalog entry names (15 tab + 4 viewer) and the dsh-desktop shell preset
desc were raw hardcoded zh strings rendered straight into the add-plugin
modals and the title-bar scheme dropdown. Mirror the existing desc
pattern exactly: PluginEntry.name / ShellPreset.desc widen to the
i18n-friendly string-or-() =>-string union, the catalogs carry lazy
t('pluginXxxName') / t('presetDshDesktopDesc') lookups, and the
add-plugin modal (render, search filter, aria-labels) plus the scheme
SelectMenu resolve them like every other copy. The plain-string arm of
the union stays as the raw-name fallback, and t() itself never yields
empty text (missing keys render the key), so a dict gap can never blank
a name.

20 new dictionary keys added to zh/en (locales.ts), ja (locales-ja.ts),
and all 18 third-language dictionaries — tests/locales.spec.ts enforces
key-set equality against zh for every shipped dict (precedent: 9cc642b),
so the 'leave other locales-* untouched' shortcut would fail the hard
parity gate; real translations are provided instead.

Spec follow-ups (behavior change: names/descs now render through t()):
- plugin-list.spec.ts: name non-empty assertion resolves the union.
- add-plugin-modal.spec.tsx: sentinel/office name assertions resolve
  from the catalog (en locale in tests) instead of pinning zh literals.
- shell-presets.spec.ts: desc non-empty assertion resolves the union.
2026-09-03 19:59:00 +08:00
lhh010 2eb9f66ce8 feat(changes): render mermaid fences in the reading mode via the shared lazy chunk
Documents containing mermaid fences now route through the editor preview chunk-resident renderer (splitMermaidBlocks detection + LazyMermaidMarkdown), so diagrams draw with zoom/pan instead of code blocks, while plain documents keep the byte-for-byte single MarkdownText pass. The lazy stub moves to a light mermaid-lazy.tsx module (re-exported from MarkdownHtml.tsx) so the core client bundle imports it without statically pulling DOMPurify/HTML analysis along. Typecheck, 4 affected suites (22 tests), and the full build pass.
2026-09-02 09:14:59 +08:00
lhh010 0142ac550b feat(diff): expand syntax highlighting — mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5
Ports dsh-file-trace v0.2.1 highlight coverage into the shared diff engine: JS/TS module suffixes share the js/ts keyword lists; CSS gains its own config (slash-star block comments threaded across lines, hyphenated property words via a wider word charset, 120+ properties/selectors/at-rules); markup files gain markup comments plus common tags/attributes; GraphQL and JSONC/JSON5 map in. Previously css/scss/less had a wrong hash-comment config and html was plain CONFIG_LANG, so both effectively rendered uncolored.
2026-09-01 23:05:26 +08:00
lhh010 b7138cf4fd feat(changes): markdown reading mode for .md op previews
The changes tab preview pane gains a Reading/Raw toggle for .md/.markdown/.mdx session ops (read, write, and edit alike). Reading mode renders the resulting document through the shared MarkdownText pass (the same stack as the editor preview): GFM tables with alignment, task lists, strikethrough, footnotes, math, and code fences, with local image destinations rewritten through the session-scoped /sidebar/file media route (rewriteLocalImageUrls). Content sources: read -> parseReadContent (envelope stripped, blank lines preserved — markdown block structure depends on them, which parseReadLines drops); write -> the written content; edit -> the reconstructed resulting document from the in-window prior content when known, else the new snippet. Ported from dsh-file-trace v0.2.0 (PR #471 lineage), re-based onto the unified changes tab (#475) and the repo shared-renderer conventions (markdownTextProps labels contract, skin tokens, 21-dictionary locale parity).
2026-09-01 18:23:28 +08:00
226 changed files with 17657 additions and 9054 deletions
+49 -4
View File
@@ -41,6 +41,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
- name: Build
@@ -88,8 +90,30 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
# `test:windows` runs the whole suite in ONE fork (`--maxWorkers=1`),
# not `pnpm test`'s unbounded pool. Two distinct Windows-only failures
# motivated it, both recorded in docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md:
#
# 1. Six red runs were real 5000 ms vitest timeouts in specs that
# really start a process (tests/agent-pty:71/:106,
# tests/install-powershell:105 — a cold powershell.exe start
# measured 12.1 s, tests/git.spec.ts:85 at 9.6 s). The suite now
# declares its own budgets (global testTimeout 15 s, 30 s in the
# two heaviest describes), and serial execution stops the
# process-spawning specs starving each other on a 2-core runner.
# 2. Four runs died at teardown with `[vitest-pool]: Worker forks
# emitted error / Worker exited unexpectedly` while reporting
# N-1 of N files passed and zero FAIL lines — a worker recycling
# race, not a test failure. It survived --maxWorkers=2, so the
# lane now recycles nothing at all.
#
# No retries: they would hide a real regression instead of removing the
# race. The cost is wall-clock (the Windows step runs several minutes
# against a 30-minute job budget), not signal.
- name: Test
run: pnpm test
run: pnpm test:windows
- name: Build
run: pnpm build
- name: Check consumer type surface
@@ -127,10 +151,31 @@ jobs:
# 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.2-rc.1 is published under the `next` dist-tag; the lanes
# speak the slash-only RPC dialect (tests/e2e/host-protocol.ts).
# DSH 0.1.5-rc.2 is the `next` dist-tag on npm (`latest` still points
# at rc.1); the lanes speak the slash-only RPC dialect
# (tests/e2e/host-protocol.ts).
#
# The larger heap is the fix for one of the two ways this step used to
# die (runs 34490912649 and 34363627184 on the alpha line): the pinned
# version's TRANSITIVES are declared as floating `^` ranges, so while
# upstream publishes a prerelease in stages (0.1.5-rc.2 went out
# 14:43-14:57 on 2026-09-10) npm resolved a mixed rc.1/rc.2 peer graph —
# 3062 ERESOLVE lines, then "Ineffective mark-compacts near heap limit"
# (exit 134). The other failure mode was a hard ETARGET on a sibling
# package that appeared minutes later; pinning a version that is fully
# published (not mid-flight) is the fix for that one.
#
# Peer resolution stays ON: `--legacy-peer-deps` was tried in the first
# cut of this fix and made the install broken rather than robust — it
# skips exactly the peers a global install has to supply, and
# @deepseek-ai/dsh-app-boot requires `@deepseek-ai/cordis-plugin-group`
# (a peer, not a dependency) at boot, so the CLI died with
# ERR_MODULE_NOT_FOUND before the smoke could even start. Verified
# locally that the plain install resolves it.
- name: Install dsh CLI (pinned)
run: npm install -g @deepseek-ai/dsh@0.1.2-rc.1
env:
NODE_OPTIONS: --max-old-space-size=4096
run: npm install -g @deepseek-ai/dsh@0.1.5-rc.2
# lib/ is gitignored; the tarball is produced from this build.
- name: Build and pack the npm tarball
+6 -2
View File
@@ -88,6 +88,9 @@ jobs:
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
@@ -95,8 +98,9 @@ jobs:
run: pnpm build
# The consumer-facing declaration surface guard — mirrors the ci.yml
# verification order (typecheck → test → build → check:consumer-types)
# so a release is gated on the same checks a PR runs.
# 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
+16 -13
View File
@@ -1,7 +1,7 @@
# dsh-better-sidebar 仓库规则(AGENTS)
# dsh-apes-sidebar 仓库规则(AGENTS)
> 本文只含**项目全局开发规则**(面向贡献者与 agent)。
> 消费插件接入 API 全参考(`ctx.betterSidebar` 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、自由窗口、皮肤契约等)→ [docs/external-plugin-guide.md](docs/external-plugin-guide.md);逐特性设计史(含实施偏差记录)→ [docs/plans/](docs/plans/)。
> 消费插件接入 API 全参考(`ctx.betterSidebar` 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、原生右侧栏承载面、皮肤契约等)→ [docs/external-plugin-guide.md](docs/external-plugin-guide.md);逐特性设计史(含实施偏差记录)→ [docs/plans/](docs/plans/)。
---
@@ -17,28 +17,29 @@
## 2. CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`)
「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add file:<tarball>`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,经「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk),再经文件树打开 seed 文件强制加载 editor chunk(`client-editor.js`)。
「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-apes-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开插件 tab 类型(含终端懒加载 chunk),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.2-rc.1`(`next` dist-tag;peer 下限 `^0.1.2-rc.1`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.5-rc.2`(npm `next`;`latest` 仍是 rc.1;peer 下限 `^0.1.5-rc.1`)。该步骤用 `NODE_OPTIONS=--max-old-space-size=4096`:钉版自身的传递依赖是浮动 `^` 范围,上游分阶段发布预发布版时(rc.2 于 2026-09-10 的 14:43–14:57 逐包上线)npm 会组出混合 peer 图,3062 条 ERESOLVE 后 OOM(exit 134);另一个失败模式是对未发布兄弟包 ETARGET,靠「钉一个已完整发布的版本」修掉。**不要加 `--legacy-peer-deps`**:它跳过的正是全局安装必须提供的 peer,`@deepseek-ai/dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` 是 peer 而非 dependency,加了它 CLI 直接 `ERR_MODULE_NOT_FOUND`(实测过一次,见 rc.2 计划 C 节)。`ci-windows` 的 `Test` 跑 `pnpm test:windows`(`--maxWorkers=2`)而非 `pnpm test`——多个 spec 真起进程(`agent-pty` / `pty-deps` / `pty-helpers` / `install-powershell` / `smoke`),2 核 runner 上并行起 ConPTY / 冷启 `powershell.exe` 是超时与 worker 静默死亡的放大器;`vitest.config.ts` 的全局 `testTimeout: 15_000`(默认 5000 在 Windows 上对真起进程的用例太低,三个不同文件先后翻车)是配套的一半。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
---
## 3. DSH 0.1.2 适配要点(0.1.2-rc.1+ 基线)
## 3. DSH 0.1.5 适配要点(0.1.5-rc.1+ 基线)
**v0.18.0 起插件毕业正式版(npm `latest`),仅支持 DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.2-rc.1`,DSH rc.x 发在 `next` dist-tag)。v0.18.0-alpha.0 进入 alpha 通道时已删除对 0.1.0-rc.8 ~ 0.1.1-rc.2 的兼容层——双方言 RPC(点分回退)、MarkdownText 双形状 labels、`dsh-client-runtime` externals/inject 残留行;stable DSH 用户请固定装 v0.17.1(npm 上的旧 latest,0.1.2-alpha.x 宿主继续用 npm `alpha` 的 v0.18.0-alpha.0)。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag。
**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.2`)。**v0.19.1 把钉版推进到 rc.2 而 peer 下限不动**:rc.1 → rc.2 的 delta 里没有任何触及本插件的面(零 `packages/api|host|session|agent` 变更,真正的代码改动只有消息反馈弹窗、产物卡片 CSS、`CodeFileIcon` 的 SVG 数据拆分),`^0.1.5-rc.1` 天然容纳 rc.2,rc.1 用户无需升级即可用新版插件。0.1.5-alpha.2 用户停留在 **v0.19.0-alpha.1**(npm `alpha`)——rc 线的 delta 都很小,插件不再对 alpha 线做运行时兼容;0.1.2-rc.1 稳定线用户继续用 **v0.18.1**——0.1.5 的会话事件模型与文件打开漏斗都变了,插件不再对 0.1.2 做运行时兼容。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag(0.19.1 无后缀 → `latest`;npm `alpha` 标签仍指向 `0.19.0-alpha.1`)。
宿主契约(均经真机挂载冒烟 14/14 验证):
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()`(0.1.2-alpha.4 起的按需读 API,前身 `Session.events` 属性已删)、冷读 `sessionPersistence.inspect`,服务端 `session/end-seed` 切割 + `afterSeq` 增量);`ctx.connection` 镜像与 inject 已删。设计见 [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)。
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-apes-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 起上游恢复发版,peer/devDep 已与基线一并钉 `0.1.2-rc.1`,该例外消除。
10. **聊天文件打开漏斗是 `remote.session.openWorkspacePath`**(`ctx.workspaces.openPath` 已随 alpha 删除,`IWorkspaces` 无此方法):「聊天区文件在侧边栏打开」拦截在 `ctx.inject(['remote.session'], …)` 内以 **defineProperty 数据属性遮蔽**该命名空间方法(gateway client 的方法是 accessor 属性、异步挂载、contribution 重挂载时服务重建——inject 回调重跑即自愈);实现见 `src/client/openpath-intercept.ts`,设计见 [docs/plans/2026-08-31-openpath-intercept-alpha-design.md](docs/plans/2026-08-31-openpath-intercept-alpha-design.md)。
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 同一组包**(rc.2 同:只把 `CodeFileIcon` 的 SVG 数据拆进了新文件,无 `dependencies`)——这组提升不得回退,`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-apes-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`。
---
@@ -58,14 +59,16 @@
- **构建纯度门**: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`。
- **皮肤契约**:视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌,无硬编码颜色,**插件没有任何豁免面**——文件/文件夹图标是 DSH 官方 `FileTypeIcon` 的图形(宿主自己的调色板),插件画的每个 glyph(含内置 tab 彩色图标)颜色都来自令牌;`tests/theme.spec.ts` 同时守护「图标模块零颜色字面量」「样式表每条 `color` 解析到令牌」「没有图标数据被做成 chunk」。契约全文与 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)(唯一权威接入文档,不再双份维护)。
- **文件/文件夹图标**:回退链是「具体 `names`/`exts` 注册 → catch-all(`exts: []`)→ 宿主 `FileTypeIcon`」。插件**不持有**扩展名表、图标数据或图标 chunk(#429 的 563 条数据与 `fileIconTheme` 开关在 rc.2 适配时删除,因为宿主已导出官方图形)。**语义雷区**:宿主分类器覆盖任意路径,所以注册 `exts: []` 会接管所有未具体命中的行——只想补几个类型的插件必须用 `exts`/`names`。
- **内置 tab 图标**:六个内置类型与 diff 视图的 glyph 集中在 `src/client/builtins/tab-icons.tsx`(颜色在 `tab-icons.module.css`,全部 `--dsw-alias-*`),三处消费面自动同步:底部工作台 tab 条、原生指南胶囊、原生 tab 芯片。**原生芯片的图标是插件自己画的**——宿主的 `SidebarRightTabDefinition` 没有 icon 字段,但 `sidebar.right.pane.tab.title` 槽就是芯片内容(`NativeTabTitle` 渲染 `[glyph][title]`,glyph `aria-hidden` 以免改可访问名)。
---
## 6. 文档与测试地图
- **接入 API 全参考**:[docs/external-plugin-guide.md](docs/external-plugin-guide.md)(消费插件开发者向;§4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 自由窗口 / §12 皮肤契约 / §15 真实案例)。
- **接入 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 双协议)/ `free-window.spec.tsx`(自由窗口)/ `theme.spec.ts`(皮肤契约)/ `plugin-list.spec.ts`(推荐插件目录)/ `fs-search.spec.ts`(host 文件名搜索)。
- **关键测试守护**:`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 文件名搜索)。
+7 -5
View File
@@ -1,7 +1,6 @@
# dsh-better-sidebar 命令面薄封装:目标仅转发 package.json scripts(唯一事实源),
# dsh-apes-sidebar 命令面薄封装:目标仅转发 package.json scripts(唯一事实源),
# 新增/改名 script 不需要动这里;本文件只补目标发现(help)与 CI 门禁聚合
# (check / mount / mount-aggregate / registry)。
# 注:lint 目标留给引入 ESLint 的后续 PR。
.DEFAULT_GOAL := help
@@ -17,11 +16,14 @@ build: ## 构建(清 lib/ → tsc → tsdown)
typecheck: ## 类型检查(tsc --noEmit)
pnpm typecheck
lint: ## 代码检查(eslint .,flat config 最小规则集)
pnpm lint
test: ## 单元测试(vitest run)
pnpm test
check: ## 聚合校验门禁:typecheck → build → test → check:consumer-types(对齐 CI)
pnpm typecheck && pnpm build && pnpm test && pnpm check:consumer-types
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
@@ -39,4 +41,4 @@ mount-aggregate: ## 聚合双挂载回归:build + pack → pnpm test:mount:agg
registry: ## 组装 plugin-registry 暂存(registry/,不入库):build → node scripts/package-registry.mjs
pnpm build && node scripts/package-registry.mjs
.PHONY: help install build typecheck test check clean pack mount mount-aggregate registry
.PHONY: help install build typecheck lint test check clean pack mount mount-aggregate registry
+145 -82
View File
@@ -1,18 +1,34 @@
# dsh-apes-sidebar
> **Fork notice** — This project is a fork of [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) v0.18.0 (commit `9e1a034`), distributed under the MIT License. Original copyright (c) 2026 dsh-external. This fork adds the `apes:*` information-layer tabs (tasks / agents / apps / messages / profile) on top of the upstream sidebar. See `package.json → forkedFrom` for the exact upstream pin.
> [!NOTE]
> **Fork notice** — 本项目是 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) 的 fork,
> 基线 **upstream v0.19.1**(commit `8753096`,MIT)。
>
> - **上游 v0.18.0 → v0.19.1 的关键变化**:v0.19.0-alpha.1 起,插件的内容**注册进 DSH 原生右侧栏**
> (`src/client/native/`)——不再自绘第二套右侧面板与开关按钮。这正是本 fork 此前"窗口里有两套侧边栏
> 与两套开关"的根因;升到 v0.19.1 后**只剩一套**。
> - **本 fork 的增量**:`src/client/apes/` 下的 3 个信息层 tab(`apes:agents` 智能体 / `apes:apps` 应用 /
> `apes:profile` 画像),按 DSH 标准 `TabDescriptor` 接口注册——因此它们会自动获得上游原生适配层
> 的全部待遇(进原生 guide、tab 芯片图标、开合与分栏)。
> - 版本线独立于上游:`1.2.0`(fork)。上游历史版本见其 CHANGELOG。
> - **📚 项目百科**(需求分析 → 系统架构 → 功能说明 → 部署指南 → 运维手册 → 故障排查 → 开发与测试):
> <https://gitea.apescale.com/figmar/apes-sidebar/wiki> 源文件在仓库 `docs/wiki/`,
> 改完用 `bash scripts/push-wiki.sh` 一条命令发布。
> [!IMPORTANT]
> **已适配 DSH 原生侧边栏 API**(v0.19.0 起,DSH `0.1.5-rc.1+`):右列就是 DSH 自己的右侧栏——插件的每个 tab 类型与 tab 体通过 `ctx.sidebarRightTabs` / `ctx.sidebarRight` 注册与打开,聊天里的文件打开统一走 `ctx.sidebarRight.openResource('dsh-resource://file/…')`,插件**不再自绘右侧面板**(旧的浮窗能力同步移除)。自绘的底部工作台与开放给其他插件的 `ctx.betterSidebar` 服务保持不变,接入方式见[插件接入指南](docs/external-plugin-guide.md)。
<!-- 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://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-apes-sidebar" /></a>
<a href="https://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-apes-sidebar" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml/badge.svg" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.0 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--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 />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.19.1 正式版):0.1.5-rc.1+(已在 0.1.5-rc.2 上验证)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-apes-sidebar"><img alt="插件生态:GitHub topic dsh-apes-sidebar" src="https://img.shields.io/badge/%E6%8F%92%E4%BB%B6%E7%94%9F%E6%80%81-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="内嵌浏览器" src="https://img.shields.io/badge/-内嵌浏览器-4d6bfe" /> <img alt="真实终端" src="https://img.shields.io/badge/-真实终端-4d6bfe" /> <img alt="文件变动" src="https://img.shields.io/badge/-文件变动-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="侧边对话" src="https://img.shields.io/badge/-侧边对话-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
通过 <code>registerTab</code> / <code>registerFileViewer</code> 注册新的侧边栏页面与文件预览器。
@@ -23,7 +39,7 @@
</div>
<div align="center">
<img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/991c4b70-d45a-461f-a8c8-0a28b4218e60" />
<img alt="dsh-apes-sidebar 工作台截图" src="https://github.com/user-attachments/assets/991c4b70-d45a-461f-a8c8-0a28b4218e60" />
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
</div>
@@ -42,15 +58,14 @@
## ✨ 功能一览
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红;文件树与文件 tab 按扩展名显示图标——markdown / 图片 / PDF / 代码 / 配置 / 压缩包等各有 glyph,插件可经 `registerFileIcon` 注册自定义图标与目录图标)+ 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 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split)
- **🌿 文件变动**: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 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
- **🪟 自由窗口**:把标签栏的任一 tab 拖到主会话区域——成为可移动 / 缩放 / 置顶的悬浮窗口(默认 390×780),拖回侧边栏 pane 即停靠,随会话持久化;`features` 含 `'floatWindows'`,插件 tab 无差别支持
- **🖥️ 原生右侧栏 + 底部工作台**:右列交给 DSH 0.1.5 的原生右侧栏——插件把每个 tab 类型注册成原生 tab(文件打开走 `dsh-resource://file/**`,并接管内置「文件」页 / 文件树),插件自己只保留底部工作台(分栏 / 终端 / 随会话持久化),开合按钮挂在会话头右侧
- **📌 固定终端**:右键终端 Tab 可「固定到工作区 / 固定到全局」——固定后切换会话不消失,在 TabBar 内联呈现(跨会话虚拟 Tab,点击就地激活,PTY 按 home 会话 id+tab 直连宿主 PTY,无需切回宿主会话);Agent 终端被 reconcile 移除时豁免保留
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
@@ -64,14 +79,14 @@
**前置**:已装好 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.18.0 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.19.1 正式版):0.1.5-rc.1+(已在 0.1.5-rc.2 上验证)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a>
> 📌 **正式版**:`v0.18.0` 起适配 DSH **0.1.2-rc.1+**(npm dist-tag `latest`),不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable(≤ 0.1.1-rc.2)用户请固定安装 `dsh-better-sidebar@0.17.1`(`@latest` 已由 v0.18.0 接管);停留在 0.1.2-alpha.x 的宿主请先升级 DSH,或继续用 `dsh-better-sidebar@alpha`(v0.18.0-alpha.0)。
> 📌 **正式版**:`v0.19.0` 起适配 DSH **0.1.5-rc.1+**(npm dist-tag `latest`;`v0.19.1` 已在 **0.1.5-rc.2** 上完成真机挂载验证,rc.1 用户无需升级即可用本版——peer 下限仍是 `^0.1.5-rc.1`)。仍停在 DSH 0.1.5-alpha.2 的用户请固定安装 `dsh-apes-sidebar@0.19.0-alpha.1`;0.1.2-rc.1 稳定线用户继续用 `dsh-apes-sidebar@0.18.x`;DSH ≤ 0.1.1-rc.2 请用 `dsh-apes-sidebar@0.17.1`。
```sh
dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入)
dsh plugin --profile web add dsh-apes-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入)
cd ~/.dsh/profiles/web && pnpm approve-builds --all # 放行构建脚本(自动重跑安装)
dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
dsh plugin --profile web add dsh-apes-sidebar@latest # 重跑即成功
```
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
@@ -79,10 +94,10 @@ dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
**方式二:让 DSH 自己装**——把下面这段提示词发给任意一个 DSH 会话:
```text
帮我安装 dsh-better-sidebar 插件(DSH 侧边栏工作台),步骤:
1. 执行 dsh plugin --profile web add dsh-better-sidebar@latest(首次会被 pnpm 11 拦截 node-pty 构建脚本而失败,属正常)
帮我安装 dsh-apes-sidebar 插件(DSH 侧边栏工作台),步骤:
1. 执行 dsh plugin --profile web add dsh-apes-sidebar@latest(首次会被 pnpm 11 拦截 node-pty 构建脚本而失败,属正常)
2. 在 ~/.dsh/profiles/web 下执行 pnpm approve-builds --all(放行构建脚本,会自动重跑安装)
3. 再次执行 dsh plugin --profile web add dsh-better-sidebar@latest
3. 再次执行 dsh plugin --profile web add dsh-apes-sidebar@latest
4. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R)
遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。
```
@@ -93,7 +108,7 @@ dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
<summary><b>更新</b></summary>
```sh
dsh plugin --profile web add dsh-better-sidebar@latest
dsh plugin --profile web add dsh-apes-sidebar@latest
```
也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
@@ -108,10 +123,10 @@ dsh plugin --profile web add dsh-better-sidebar@latest
| 报 `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` 之前。 |
| 页面出现**两个侧边栏** | 双挂载。旧的手动挂载行:`~/.dsh/profiles/web/cordis.patch.yml` 还留着 `- insert: ... better-sidebar ...`,删掉那段(同 id 重复挂载 loader 会直接报 `duplicate loader entry id`)。聚合包(如 `@linxin666/dsh-web-ui-all`)以**不同 id** 挂载本包时,0.13.x 起插件自身 bundle patch 会自动退让(检测到已有启用中的同包名挂载就不挂自己),无需手动处理;若仍双挂载,先确认聚合包的 bundle 顺序在 `dsh-apes-sidebar` 之前。 |
| 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`。 |
| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-apes-sidebar@latest`。 |
</details>
@@ -123,11 +138,11 @@ dsh plugin --profile web add dsh-better-sidebar@latest
```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:<克隆目录绝对路径>"
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-apes-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'
name: 'dsh-apes-sidebar'
config:
shell: /bin/zsh
shellArgs:
@@ -137,7 +152,7 @@ dsh plugin --profile web add dsh-better-sidebar@latest
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`。
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-apes-sidebar": "^0.16.1"` 再 `pnpm install`。
</details>
@@ -151,7 +166,7 @@ git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-s
pnpm install && pnpm build
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-better-sidebar
dsh registry enable dsh-external/dsh-apes-sidebar
```
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
@@ -168,7 +183,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
| **🖥️ 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> | **🪟 双工作台:右侧栏 + 底部面板 + 分栏**<br/><sub>右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉;把 tab 拖到主会话区域可变为**自由窗口**(悬浮 / 缩放 / 置顶,拖回 pane 停靠)。</sub><br/><div align="center"><img width="420" alt="双工作台(右侧栏 + 底部面板)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></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> |
## 🌐 插件生态
@@ -176,7 +191,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 8 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。
```ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
import type {} from 'dsh-apes-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
@@ -190,7 +205,7 @@ export function apply(ctx: Context) {
}
```
GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) 下已有 **28+ 生态插件**(持续增长中):
GitHub topic [`dsh-apes-sidebar`](https://github.com/topics/dsh-apes-sidebar) 下已有 **28+ 生态插件**(持续增长中):
<div align="center">
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="66%" alt="设置页「添加插件」弹窗:推荐插件目录 + 一键复制安装命令" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a><br />
@@ -213,7 +228,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
| [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 面板对称 |
| [ztyhehe/dsh-apes-sidebar-svn](https://github.com/ztyhehe/dsh-apes-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-apes-sidebar-svn?style=flat&color=4d6bfe" /> | SVN 源码管理页:status / diff / log / commit / update / revert / 冲突解决,与内置 Git 面板对称 |
| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | <img alt="stars" src="https://img.shields.io/github/stars/Melody-max114/dsh-excel-panel?style=flat&color=4d6bfe" /> | Excel 编辑页:xlsx 预览 / 编辑、公式实时计算、合并单元格、保存回原文件 |
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch 搜索结果引用卡片:搜索词、来源摘要、关键词高亮 |
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | 全局文档面板:随身 Markdown 笔记,任何工作区随时可读 |
@@ -226,7 +241,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
| [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 彩色)、多实例并行、进程树跨平台杀死 |
| [dong-victor/dsh-apes-sidebar-starter](https://github.com/dong-victor/dsh-apes-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-starter?style=flat&color=4d6bfe" /> | 运行配置页:IDEA 式 Run/Debug 配置(npm / springboot / python / custom)——一键启动、历史保存、WebSocket 实时日志(ANSI 彩色)、多实例并行、进程树跨平台杀死 |
| [baosfeng/my-dsh-plugins](https://github.com/baosfeng/my-dsh-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/baosfeng/my-dsh-plugins?style=flat&color=4d6bfe" /> | 个人多插件合集(`dsh-file-activity`):侧边栏文件活动页——记录文件读取 / 新增 / 修改历史与统计,按文件夹平铺,点击用原生预览打开 |
| [Hoemr/dsh-better-overleaf](https://github.com/Hoemr/dsh-better-overleaf) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-better-overleaf?style=flat&color=4d6bfe" /> | Overleaf 标签页:直连 CDP 浏览器登录(支持第三方 Chromium)、项目切换、工作区下 overleaf/ 目录本地 git 镜像与双向同步 |
@@ -241,7 +256,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|---|---|---|
| [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、流式输出、保存回写 |
| [dong-victor/dsh-apes-sidebar-jupyter](https://github.com/dong-victor/dsh-apes-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-jupyter?style=flat&color=4d6bfe" /> | `.ipynb` 可运行 Notebook 视图:懒启动 Python kernel、流式输出、保存回写 |
</details>
@@ -252,69 +267,116 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
| 插件 | ⭐ | 简介 |
|---|---|---|
| [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 |
| [eg-bole/dsh-apes-sidebar-icons](https://github.com/eg-bole/dsh-apes-sidebar-icons) | <img alt="stars" src="https://img.shields.io/github/stars/eg-bole/dsh-apes-sidebar-icons?style=flat&color=4d6bfe" /> | VSCode 风格文件 / 文件夹图标主题:文件树与编辑器 Tab 换上熟悉的开发环境图标(vscode-icons 移植,纯 DOM 覆盖零侵入,安装 / 卸载零残留) |
| [dong-victor/dsh-apes-sidebar-terminal-plus](https://github.com/dong-victor/dsh-apes-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | 终端增强:内嵌 Nerd Font 图标字体、修复 xterm 图标渲染、稳定终端 cwd |
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | 预览划选增强:侧边栏预览里划选文本 → 浮动「发送到会话」 |
| [Hoemr/dsh-quicklook](https://github.com/Hoemr/dsh-quicklook) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-quicklook?style=flat&color=4d6bfe" /> | QuickLook 式空格预览:活动文件标签页按 Space 全尺寸查看图片 / PDF / 文本,Space 或 Esc 关闭 |
</details>
> 📣 **上架你的插件**:给仓库打上 `dsh-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-apes-sidebar` topic 即出现在 [topic 页](https://github.com/topics/dsh-apes-sidebar);再向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提一条 `PluginEntry` PR,即可进入设置页内置推荐目录(数据完整性由 `tests/plugin-list.spec.ts` 守护)。
## 🆕 最近更新
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.0 正式版):0.1.5-rc.2+" src="https://img.shields.io/badge/DSH-0.1.5--rc.2%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.19.1 正式版):0.1.5-rc.1+(已在 0.1.5-rc.2 上验证)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### v1.1.0(fork · DSH 2.0.10 / 0.1.5-rc.2 适配 + 精简 + 风格对齐)
### v0.19.1
> 📌 **DSH 2.0.10 适配**:跟随 DSH 2.0.10 内发的 `@deepseek-ai/dsh-*@0.1.5-rc.2`,peer + devDeps 全量对齐。
> 📌 **正式版**(npm `latest`,无 prerelease 后缀):钉版推进到 **DSH 0.1.5-rc.2**(npm `next`),**peer 下限仍是 `^0.1.5-rc.1`**——rc.2 的上游 delta 里没有任何触及本插件的面(零 `packages/api|host|session|agent` 变更,真实代码改动只有消息反馈弹窗、产物卡片 CSS 与 `CodeFileIcon` 的 SVG 数据拆分),因此 rc.1 用户无需升级 DSH 即可用本版。DSH 0.1.5-alpha.2 用户继续用 **v0.19.0-alpha.1**;0.1.2-rc.1 稳定线继续用 **v0.18.1**。
**🗑 精简 tab(去 `apes:tasks`)**
- 🎯 **适配 DSH 0.1.5-rc.2**:devDependency 钉版、CI 挂载车道与 `SIDEBAR_SERVICE_VERSION` 同步到 rc.2;插件侧**零代码改动**(上游 delta 未触及本插件,逐文件核对见 [docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md](docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md)——300 个变更文件里绝大多数只是各包 `package.json` 的版本号单行)。
- 🎨 **文件图标(#611)与内置 Tab 图标改为彩色**(#531 + #594 合并,实现按 rc.2 重做):
- **文件/文件夹图标走 DSH 官方图形**:回退链末端是宿主 `ui-primitives` 的 `FileTypeIcon`(48 类官方全彩代码/配置图形 + markdown/图片/PDF/Office/视频/文件夹的类目色板),插件**不再自带扩展名表,也不再需要图标懒加载 chunk**——`#429` 那份 563 条彩色数据与 `lib/client-file-icons.js`(255 kB)连同 `fileIconTheme` 设置开关一并删除,彩色就是唯一形态(核心 bundle 因此只增 10 kB)。
- **新增对外 `registerFileIcon` API**(能力 `'fileIcons'`):按扩展名(`exts`)、精确文件名(`names`)、目录名(`folderNames`)注册自己的图标,优先级降序、同级按注册序;**注意**:宿主分类器覆盖任意路径,所以注册 `exts: []` 的 catch-all 会接管所有未具体命中的行。
- **内置 tab 图标彩色**:文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器 六个类型与 diff 视图的 glyph 换成彩色版本(颜色全部来自 `--dsw-alias-*` 令牌,皮肤照旧全覆盖)。
- **原生右侧栏的 tab 芯片也带图标**:宿主的 tab 定义没有 icon 字段,但 `sidebar.right.pane.tab.title` 槽就是芯片内容——插件在该槽渲染「图标 + 标题」(编辑器 tab 带路径时显示该文件的图标),glyph 为 `aria-hidden`,芯片可访问名不变。
- 🛠 **CI 修复一:Windows lane 的真实超时**。`ci-windows` 在 2026-09-09/10 窗口内红了 8 次,其中 6 次是真起进程的用例撞上 vitest 默认 5000ms:`tests/agent-pty.spec.ts`(真起 PowerShell + ConPTY)与 `tests/install-powershell.spec.ts`(冷启 `powershell.exe` 实测 12.1s)现在各自声明 30s 预算,且 `vitest.config.ts` 的全局 `testTimeout` 从默认 5000ms 提到 **15s**(第一次真实 Windows 跑又在**第三个**文件上翻车:`tests/git.spec.ts:85` 耗时 9607ms——三个文件同一个根因,逐文件加超时是打地鼠);`waitForTranscript` 的内层轮询预算从 5000ms 降到 15s,**内层预算必须小于外层**(原先是同一个 5000ms,结构性必然超时)。`ci-windows` 的 `Test` 改跑 `pnpm test:windows`(`--maxWorkers=2`),在 2 核 runner 上不再让真起进程的 spec 互相抢占。
- 🛠 **CI 修复二:挂载车道的 npm 安装**。`plugin-mount` 的 `npm install -g @deepseek-ai/dsh@<ver>` 曾 4 次失败(2 次 `ETARGET`、2 次 `JavaScript heap out of memory` exit 134):钉版自身的传递依赖是浮动 `^` 范围,上游**分阶段发布**预发布版时(rc.2 于 09-10 的 14:43–14:57 逐包上线)npm 会组出 rc.1/rc.2 混合 peer 图(3062 条 ERESOLVE)。现在钉一个**已完整发布**的 rc.2(修 ETARGET)并加 `NODE_OPTIONS=--max-old-space-size=4096`(修 OOM)。中途试过 `--legacy-peer-deps`,被真实 CI 否掉:它跳过的正是全局安装必须提供的 peer,`dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` 是 peer 而非 dependency,加了它 CLI 直接 `ERR_MODULE_NOT_FOUND`。
- 移除 `apes:tasks` —— 与 DSH 原生侧栏的官方会话树功能重叠,保留 **3 个** `apes:*` 信息层 tab:`apes:agents` / `apes:apps` / `apes:profile`
- 同步清理死代码:`ApesTasksView` + `collectTreeJobs`/`orderJobs`/`isJobLive`/`jobDotState` import + apes i18n 死 key(tasks/status/duration)
### v0.19.0
**🎨 风格对齐 DSW 设计令牌**
> 📌 **正式版**(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**。
- apes 视图内联样式的颜色全部改用 DSW alias 令牌(`--dsw-alias-bg-*` / `--dsw-alias-border-*` / `--dsw-alias-label-*` / `--dsw-alias-state-success-*` / `--dsw-alias-interactive-*`),与 DSH 原生侧栏视觉统一;`var(...)` 带字面 fallback 保证非 DSH 环境(单测 harness)仍可渲染
- 字号 / 间距 / 圆角保持内联值(DSH 未对插件面板公开 `--ds-*` 几何令牌,其自身 vendor bundle 亦内联)
**✨ 新功能**
**🔧 0.1.5-rc.2 适配修复**
- 📝 **新建标签页列表恢复可选说明**(#613):DSH 0.1.5-rc.1 让 `SidebarRightGuideEntry.description` 回归(可选),插件随之恢复 `TabDescriptor.description`,六个内置类型各写回一条说明(原生指南的 文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器 六行都带上它),`guideDesc*` 词条回到 20 份词典。**宿主的原生指南只在列出的条目 ≤ 4 条时渲染说明**(更长的列表是整列丢弃,不是截断),而插件默认贡献 6 个 guide 条目——因此默认组合下说明不渲染,只有在插件设置页关掉足够多的 tab 类型、把 guide 压到 ≤ 4 条时才会出现。未声明说明的条目仍是「图标 + 标题」单行(插件不补通用兜底句)。
- primitives npm 发布缺陷(15 个运行时依赖被误列为 devDeps)→ 补进本地 `devDependencies` 供 vitest 内联解析
- sidechat seed 校验契约升级:`dsh-session@0.1.5` 的 `assertAssistantSettlementShape` 要求 assistant 消息的 `data` 顶层带 `stream: Array` → 测试 seed helper 对齐
- 完整 gate:typecheck ✓ / build ✓ / test 119 files · 1249 passed ✓
**🐛 修复**
---
- 无。rc.1 相对 alpha.2 的 delta 很小(373 个变更文件,绝大多数是上游各包版本号、原生右侧栏预览 UI 打磨与测试快照),除上述说明字段回归(并附带上游 `files` 类型改用自己的彩色文件夹图标作指南字形)外,没有需要插件适配的变更。
### v1.0.0(fork · apes 信息层)
**🧰 CI 与内部**
> 📌 **fork 独立版本线**:自 upstream `dsh-better-sidebar@v0.18.0`(commit `9e1a034`)fork,新增 `apes:*` 信息层 tab。upstream 历史版本见下方 v0.18.0 段。
- 基线推进到 **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,与本次改动无关)。
**🪧 新增 4 个 `apes:*` 信息层 tab**(独立模块 `src/client/apes/`,与 upstream builtin tab 解耦)
### v0.19.0-alpha.1
- **`apes:tasks` 任务**:接 `ctx.sessions.list` 的 `jobsBySession` + running sessions —— live 任务列表(label / 运行中·完成·失败 / kind / 耗时 + owner 归属)
- **`apes:agents` 智能体**:接 `subagentsByParent` 目录(降级 `byId`)—— subagent 拓扑(main → 直接 children 的 activity:running / inactive)
- **`apes:apps` 应用**:展示当前工作区 cwd(`scope.cwd` + `byId[current].cwd`;无 DSH host feed,保留说明占位)
- **`apes:profile` 画像**:接 `byId[current]` —— 当前会话画像(title / cwd / running / agent 状态)
> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-apes-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`)。
**🔌 数据接入**
**✨ 新功能**
- 数据源 `ctx.sessions.list.getSnapshot()` 经 `useSyncExternalStore` 响应式订阅(snapshot 含 `current` / `byId` / `subagentsByParent` / `jobsBySession`)
- 复用 upstream pure helper(无 UI / Node 依赖):`subagent-lineage.ts`(`rootAncestor` / `isSideThreadSummary`)+ `subagent-jobs.ts`(`collectTreeJobs` / `orderJobs` / `isJobLive` / `jobDotState`)
- UI 独立内联样式,不耦合 builtin CSS module / `StateDot`(保持 apes 模块隔离,cherry-pick upstream 时只碰 `index.tsx` 一处)
- 🪟 **每个 tab 体都填满面板**(#609):原生右侧栏的 tab 体宿主是「有确定高度的块级滚动容器」而非 flex 容器,此前插件各 tab 的根只写 `flex: 1`,在块容器里塌成内容高度——侧边对话的输入框因此贴不到面板底(转录一长就被推出可视区)。现在 native 适配层统一给每个 tab 体包一层 `height: 100%` 的列 flex 宿主,插件全部 tab(含第三方 `registerTab` 注册的 descriptor)恢复与底部工作台一致的填满语义。
**🌏 i18n**:4 个视图全部走 `t(key)`,`zh` 默认 + `en` fallback,状态 / 耗时 / 空态均有双语词条
**🐛 修复**
- 🧭 **跟随 DSH 0.1.5-alpha.2 的文件地址语法**:`fileAddressFor` 一律产出 session 作用域地址、绝对路径保留前导 `/`;`parseFileAddress` 改为前缀解析并忽略 `?`/`#` 后缀。
- 🪟 **底部工作台的中心列定位跟随 alpha.2 全局面板改动**:`conversation` 槽改为根级 `main` keyed 槽下的 `main.conversation`,定位器改认新 key 并跳过 `display: contents` 槽宿主(alpha.1 的旧 key 仍兼容)。
**🧰 CI 与内部**
- 基线推进到 DSH 0.1.5-alpha.2(#609):peer 下限、22 个 devDependency 钉版、CI 挂载车道与 `dsh.plugin.json` engines 同步;`pnpm peers check` 干净(按 §3-9 补提 `dsh-session-persistence` 传递 peer)。
- **移除 `TabDescriptor.description`**:alpha.2 的原生指南条目不再渲染第二行(改为「图标+标题」胶囊),该字段与 6 个 `guideDesc*` 词条(20 份词典)一并下线;新建标签页的默认页改由注册表选(恰好 1 个指南条目则直接打开它)。
### v0.19.0-alpha.0
> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-apes-sidebar@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.19.0(未发布)
- 🎨 **可选彩色图标主题**:设置页「文件 → 文件图标」可在**内置单色**与**彩色品牌图标**之间切换——彩色主题含 563 条规则(218 扩展名 + 197 精确文件名 + 148 目录名,如 `.tsx → React`、`package.json → npm`、`node_modules` 着色),数据在**懒加载 chunk**(`lib/client-file-icons.js`)里,只有选中时才下载,默认关闭时启动零开销。数据源自 [#429](https://github.com/omdsh-dev/DSH-better-sidebar/pull/429)(@fenter)。
- 🎨 **文件图标多样化 + 对外图标注册 API([file-icons.tsx](./src/client/file-icons.tsx))**:文件树与编辑器文件 tab 不再是单一 `VscFile`——markdown / 图片媒体 / PDF / JSON / 40+ 代码扩展 / 配置 / 数据库 / lock / 压缩包各有专属 glyph(VSCodicons 单色 `currentColor`,遵循皮肤契约),未知扩展回退通用图标。`ctx.betterSidebar` 新增 `registerFileIcon`(`features` 含 `'fileIcons'`):按扩展名 / 精确文件名(`names`)/ 目录名(`folderNames`)注册自定义图标(彩色 ReactNode 亦可,颜色责任在注册方),`exts: []` 为全局默认(只兜内置 glyph 没认领的扩展,不吞掉内置多样性),保留扩展名 `'folder'` / `'folder-open'` 可换目录行图标;`fileIcon` / `folderIcon` 为权威解析器(完整回退链 + 逐工厂崩溃隔离),注册/注销即时生效。接入示例见[外部插件指南 §7](./docs/external-plugin-guide.md)。
### v0.18.1
> 📌 **正式版**(npm `latest`):DSH 基线不变(**0.1.2-rc.1+**,peer 下限 `^0.1.2-rc.1`)——本版是 v0.18.0 之后的增量发布:变更面板预览能力增强、文件树可写,以及五项修复。
**✨ 新功能**
- 📄 **变更面板操作预览增强**(#499):`.md` 阅读模式(含 mermaid 渲染)、`.html` 与 `.pdf` 内嵌渲染预览;diff 语法高亮扩展到 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5;新增**密钥脱敏**层(预览默认开启,面板头部可切换)
- 🗂️ **文件树重命名 / 删除**(#550):行内重命名 + 确认式删除,右键菜单减重与子菜单视口钳制
- 🧩 **插件目录名与 shell 预设文案词典化**(#535):跟随宿主语言
**🐛 修复**
- 🔀 **git diff 折叠上下文真实展开**(#576,修复 #577):折叠行此前显示「n 行…点击展开」却点不动(`-U3` 裁剪使 gap 段没有行文本);现按需经 `git.show` 拉取两侧完整内容切片填充,带加载 / 失败降级三态与请求去重;顺带修复该路由的 `rev:path` 寻址(此前恒返空)
- 💬 **侧边对话种子不再继承父会话未领取的 inbox 消息**(#562):补 fork 标记对,消除「上下文很长时侧边对话先把之前的 User 消息发出去」的幽灵消息
- 🖼️ **Markdown 分栏渲染器内的本地图片**(#569):改写为可访问 URL,不再 404
**🧰 CI 与内部**
- ESLint flat config 接入 CI 与 Makefile(#536)、Makefile 命令面规范化(#526)、e2e 脚本加固(#527)、共享组件测试工具收敛样板(#524)
- 重构:Sidebar.tsx 按关注点拆分(#542)、四处轮询习语收敛到 `use-polling`(#541)、删除 rc.7 宿主的 `__DSH_MODULES__` 回退路径(#540)、One Dark/Light 语法色板单源化(#534)、重复实现收敛与死代码清理(#525)
### v0.18.0
> 📌 **正式版**(npm `latest`):本版仅支持 **DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请固定安装 `dsh-better-sidebar@0.17.1`,停留在 0.1.2-alpha.x 的宿主继续用 `dsh-better-sidebar@alpha`(v0.18.0-alpha.0)。
自 v0.17.1 以来的全部更改(v0.18.1-alpha.0 / v0.19.0-alpha.0 两个中间版本号未发布,内容一并并入本版):
**🔗 宿主线毕业正式版**
- **进入 DSH 0.1.2 线并毕业**:v0.18.0-alpha.0 删除 0.1.1-rc.x 兼容层、修复 alpha.1+ 侧边对话转录空白(#472);基线经 alpha.3(#497)、alpha.5(#516,`Session.events` → `snapshotEvents()`)逐步上行;聊天文件打开漏斗迁移 `remote.session.openWorkspacePath`(#494)、「Show in folder」reveal 滚动限界(#453)。本版钉版基线 **DSH 0.1.2-rc.1**(相对 alpha.5 零源码变化、纯版本升级;`dsh-client-locale` 恢复发版,全线对齐 rc.1;CI 真机挂载冒烟 14/14)
> 📌 **正式版**(npm `latest`):本版仅支持 **DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请固定安装 `dsh-apes-sidebar@0.17.1`,停留在 0.1.2-alpha.x 的宿主继续用 `dsh-apes-sidebar@alpha`(v0.18.0-alpha.0)。
**✨ 新功能**
@@ -339,23 +401,26 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
**🌐 生态收录**
- 新收录 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 补齐)
- 新收录 10+ 插件:dsh-apes-sidebar-icons(#441)、dsh-sidenote(#451,原 dsh-sidechat #470)、dsh-github-workbench(#410)、dsh-bilingual-reader(#379)、dsh-server-deck(#413)、dsh-md-export(#405)、dsh-code-nav(#404)、dsh-suhuang-scroll(#392)、dsh-better-overleaf(#370)等(均含 18+ 语言 i18n 补齐)
### v0.19.0-alpha.0
<details>
<summary><b>历史版本(v0.12.0 – v0.15.2)</b></summary>
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。
### 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** 正式版。
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.3`,npm dist-tag `alpha`,安装 `dsh-apes-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`)。
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.2`,npm dist-tag `alpha`,安装 `dsh-apes-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))。
@@ -393,7 +458,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
**🐛 修复**
- 🛒 **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'` 的类型合并方式不变);未使用该导入的插件无影响
- 🔤 **类型基底迁移到 `@deepseek-ai/cordis`**([#338](https://github.com/omdsh-dev/DSH-better-sidebar/pull/338)):`Context` = 真实 vendored cordis Context 与结构化服务面的**交集**,`ctx.betterSidebar` 类型合并改挂 `@deepseek-ai/cordis`,公开版 cordis 不再被依赖。**消费者迁移**:`import type { Context } from 'cordis'` 改为 `import type { Context } from '@deepseek-ai/cordis'`(`import type {} from 'dsh-apes-sidebar'` 的类型合并方式不变);未使用该导入的插件无影响
- 🧩 **插件树内 `ctx.betterSidebar` 读取全面修复**([#357](https://github.com/omdsh-dev/DSH-better-sidebar/pull/357),修复 [#356](https://github.com/omdsh-dev/DSH-better-sidebar/issues/356)):npm 安装的 DSH 0.1.1-rc.x(web bundle)下侧边栏页面每次加载即崩(`cannot get property "betterSidebar" without inject`)——26 处内部直读 `ctx.betterSidebar` 改走 `ctx.get('betterSidebar')`(root reflect store 解析,不受 fiber 链影响);外部消费者 `inject: ['betterSidebar'] + ctx.betterSidebar` 契约不变
- 🔐 **文件 API 会话工作区边界**([#345](https://github.com/omdsh-dev/DSH-better-sidebar/pull/345),修复 [#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)):`fs.tree / fs.read / fs.write` 的 workspace 越界访问修复;媒体、HTML 预览与上传统一 real-path 符号链接校验;新增绝对路径 / 符号链接 / 上传 / 嵌套 Git 会话回归测试
- 🪟 **面板宿主层级与视口裁剪**([#330](https://github.com/omdsh-dev/DSH-better-sidebar/pull/330) [#278](https://github.com/omdsh-dev/DSH-better-sidebar/pull/278),修复 [#277](https://github.com/omdsh-dev/DSH-better-sidebar/issues/277)):面板宿主层 z-index 40→25——低于 DSH cordis 动态插件面板 30,工作台不再遮挡 cordis 清单 / 审批面(AppFrame 20 之上、100+ 浮层之下);宿主 `overflow: hidden` 裁剪视口边缘,收起的面板不再把文档撑出双向滚动(实测 `scrollWidth` 2289→1672 / `scrollHeight` 1280→1032,任意皮肤)
@@ -413,9 +478,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
- 🔧 **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` 属非法形状)
<details>
<summary><b>历史版本(v0.12.0 – v0.15.2)</b></summary>
### v0.15.2
自 v0.15.1 以来的全部更改:
@@ -534,7 +596,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
推荐添加QQ群(577011007)
<div align="center">
<img width="220" alt="微信群二维码" src="https://github.com/user-attachments/assets/39caafc7-9629-4b13-bb2b-eac17eab5b6a" />
<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>
@@ -552,21 +614,22 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
## 🔌 服务化扩展
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。v0.19.0 起新增文件图标注册:`registerFileIcon` 按扩展名(或保留的 `'folder'` / `'folder-open'` 目录扩展名、`exts: []` 全局默认)替换文件树与文件 tab 的图标,彩色 ReactNode 亦可——内置消费、注册即生效,无需自己接线。
完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、自由窗口与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。
完整接入文档(全字段、匹配算法、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))、推荐插件目录(名字 / 仓库 / 简介 / 安装脚本),每个条目「**跳转**」直达仓库、「**复制**」把安装命令写入剪贴板。
设置页「侧边卡片」两个网格末尾的**虚线卡片**分别打开 Tab / 预览插件弹窗:声明扩展点、「**在 GitHub 上浏览更多插件**」按钮([GitHub topic `dsh-apes-sidebar`](https://github.com/topics/dsh-apes-sidebar))、推荐插件目录(名字 / 仓库 / 简介 / 安装脚本),每个条目「**跳转**」直达仓库、「**复制**」把安装命令写入剪贴板。
**收录新插件**:向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts)(Tab 注册)或 [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts)(文件预览注册)追加一条 `PluginEntry`,并把仓库打上 `dsh-better-sidebar` topic;数据完整性由 `tests/plugin-list.spec.ts` 守护。
**收录新插件**:向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts)(Tab 注册)或 [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts)(文件预览注册)追加一条 `PluginEntry`,并把仓库打上 `dsh-apes-sidebar` topic;数据完整性由 `tests/plugin-list.spec.ts` 守护。
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.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
@@ -606,7 +669,7 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元
## 🤝 参与贡献
- **代码改动走 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
- **收录生态插件**:给仓库打 `dsh-apes-sidebar` topic + 向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提 PR
- **提交前自检**:`pnpm typecheck && pnpm build && pnpm test`(或 `make check` 一键聚合;CI 另有 npm 打包 → 真实挂载 → 无头渲染门禁 `pnpm test:mount`,及聚合双挂载回归 `pnpm test:mount:aggregate`)
- 仓库工作规范见 [`AGENTS.md`](./AGENTS.md)(含仓库硬约束与 CI 说明)
@@ -637,5 +700,5 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元
---
<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>
<sub>MIT License · Built for the <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> ecosystem · 在 <a href="https://github.com/topics/dsh-apes-sidebar">topic dsh-apes-sidebar</a> 发现更多生态插件</sub>
</div>
+136 -44
View File
@@ -1,16 +1,19 @@
# dsh-better-sidebar
# dsh-apes-sidebar
> [!IMPORTANT]
> **Built on DSH's native sidebar API** (since v0.19.0, DSH `0.1.5-rc.1+`): the right column *is* DSH's own sidebar — every plugin tab type and tab body registers and opens through `ctx.sidebarRightTabs` / `ctx.sidebarRight`, every file open from the chat goes through `ctx.sidebarRight.openResource('dsh-resource://file/…')`, and the plugin **no longer draws a right panel of its own** (the old free-window capability is gone with it). The self-drawn bottom workbench and the `ctx.betterSidebar` service other plugins register against are unchanged — see the [plugin integration guide](docs/external-plugin-guide.md).
<!-- 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://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-apes-sidebar" /></a>
<a href="https://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-apes-sidebar" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml/badge.svg" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.0): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--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 />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.19.1): 0.1.5-rc.1+ (verified on rc.2)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-apes-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-apes-sidebar" src="https://img.shields.io/badge/plugin%20ecosystem-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit &amp; preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="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>.
@@ -22,7 +25,7 @@
<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" />
<img alt="dsh-apes-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
</div>
## 📑 Contents
@@ -44,11 +47,10 @@
- **🌐 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 + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab
- **🌿 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
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
- **🪟 Free Windows**: drag any tab onto the main conversation area to turn it into a movable / resizable / raiseable floating window (default 390×780); drag it back onto a pane to dock; persisted per session. `features` includes `'floatWindows'` and plugin tabs are supported identically
- **🖥️ 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
@@ -62,14 +64,14 @@
**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.18.0): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.19.1): 0.1.5-rc.1+ (verified on rc.2)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a>
> 📌 **Stable release**: starting with `v0.18.0` the plugin targets DSH **0.1.2-rc.1+** (npm dist-tag `latest`) and drops 0.1.0-rc.8 ~ 0.1.1-rc.2 — stable-DSH (≤ 0.1.1-rc.2) users should stay pinned to `dsh-better-sidebar@0.17.1` (`@latest` now points at v0.18.0); hosts still on 0.1.2-alpha.x should upgrade DSH first, or keep `dsh-better-sidebar@alpha` (v0.18.0-alpha.0).
> 📌 **Stable release**: starting with `v0.19.0` the plugin targets DSH **0.1.5-rc.1+** (npm dist-tag `latest`; `v0.19.1` is verified end-to-end against **0.1.5-rc.2**, so rc.1 hosts need no DSH upgrade — the peer floor stays `^0.1.5-rc.1`). Hosts still on DSH 0.1.5-alpha.2 should stay pinned to `dsh-apes-sidebar@0.19.0-alpha.1`; the 0.1.2-rc.1 stable line keeps using `dsh-apes-sidebar@0.18.x`; DSH ≤ 0.1.1-rc.2 should use `dsh-apes-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)
dsh plugin --profile web add dsh-apes-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
dsh plugin --profile web add dsh-apes-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).
@@ -77,10 +79,10 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot
**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)
Install the dsh-apes-sidebar plugin (a sidebar workbench for DSH):
1. Run: dsh plugin --profile web add dsh-apes-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
3. Run the add command again: dsh plugin --profile web add dsh-apes-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
```
@@ -91,7 +93,7 @@ If anything fails, check the troubleshooting table in the README at https://gith
<summary><b>Updating</b></summary>
```sh
dsh plugin --profile web add dsh-better-sidebar@latest
dsh plugin --profile web add dsh-apes-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.
@@ -106,10 +108,10 @@ or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.16.1"`) and
| `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`. |
| Two sidebars on the page | Double-mount. Old hand-written line: `~/.dsh/profiles/web/cordis.patch.yml` still has `- insert: ... better-sidebar ...` — delete it (a same-id duplicate mount makes the loader fail loudly with `duplicate loader entry id`). When an aggregate bundle (e.g. `@linxin666/dsh-web-ui-all`) mounts this package under a **different** id, the plugin's own bundle patch backs off automatically since 0.13.x (it detects an already-enabled mount of the same package name and does not mount itself) — no manual fix needed; if it still double-mounts, make sure the aggregate bundle precedes `dsh-apes-sidebar` in `dsh.profile.bundles`. |
| 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`. |
| `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-apes-sidebar@latest`. |
</details>
@@ -121,11 +123,11 @@ To debug local changes or track the dev branch, point the dependency at a local
```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>"
2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-apes-sidebar": "link:<absolute path of the clone>"
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml (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'
name: 'dsh-apes-sidebar'
config:
shell: /bin/zsh
shellArgs:
@@ -135,7 +137,7 @@ To debug local changes or track the dev branch, point the dependency at a local
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`.
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-apes-sidebar": "^0.16.1"` and re-run `pnpm install`.
</details>
@@ -149,7 +151,7 @@ git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-s
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
dsh registry enable dsh-external/dsh-apes-sidebar
```
Update: `git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`. Remove the other channel's mount before switching.
@@ -166,7 +168,7 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
| **🖥️ 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> | **🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes**<br/><sub>The right sidebar and the bottom panel can stay open together; drag a tab to a pane edge to **split**, to the middle to **merge** (works across panels); panel width/height drag from the left/top edge; on mobile everything merges into a full-width drawer; drag a tab onto the main conversation area to turn it into a **free window** (float / resize / raise, drag back onto a pane to dock).</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> |
| **💬 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
@@ -174,7 +176,7 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
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
import type {} from 'dsh-apes-sidebar' // triggers the ctx.betterSidebar type merge
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
@@ -188,7 +190,7 @@ export function apply(ctx: Context) {
}
```
The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) already hosts **28+ ecosystem plugins** (and growing):
The GitHub topic [`dsh-apes-sidebar`](https://github.com/topics/dsh-apes-sidebar) already hosts **28+ ecosystem plugins** (and growing):
<div align="center">
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="66%" alt="The built-in Add Plugins modal: recommended catalog + one-click install command" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a><br />
@@ -211,7 +213,7 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
| [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 |
| [ztyhehe/dsh-apes-sidebar-svn](https://github.com/ztyhehe/dsh-apes-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-apes-sidebar-svn?style=flat&color=4d6bfe" /> | SVN source-control tab: status / diff / log / commit / update / revert / conflict resolution — symmetric to the built-in Git panel |
| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | <img alt="stars" src="https://img.shields.io/github/stars/Melody-max114/dsh-excel-panel?style=flat&color=4d6bfe" /> | Excel editing: xlsx preview/edit, live formula evaluation, merged cells, save back to the original file |
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch results as sidebar cards: query, source snippets, highlighted keywords |
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | Global docs panel: portable Markdown notes, readable from any workspace |
@@ -224,7 +226,7 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
| [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 |
| [dong-victor/dsh-apes-sidebar-starter](https://github.com/dong-victor/dsh-apes-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-starter?style=flat&color=4d6bfe" /> | Run-configurations tab: IDEA-style Run/Debug configs (npm / springboot / python / custom) — one-click launch, history, WebSocket live logs (ANSI colors), parallel instances, cross-platform process-tree kill |
| [baosfeng/my-dsh-plugins](https://github.com/baosfeng/my-dsh-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/baosfeng/my-dsh-plugins?style=flat&color=4d6bfe" /> | Personal multi-plugin collection (`dsh-file-activity`): a sidebar file-activity tab recording read / added / modified history and stats, flat-browsed by folder, opened with the native preview |
| [Hoemr/dsh-better-overleaf](https://github.com/Hoemr/dsh-better-overleaf) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-better-overleaf?style=flat&color=4d6bfe" /> | Overleaf tab: direct-CDP browser login (third-party Chromium supported), project switching, local git mirrors under the workspace with two-way sync |
@@ -239,7 +241,7 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
|---|---|---|
| [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 |
| [dong-victor/dsh-apes-sidebar-jupyter](https://github.com/dong-victor/dsh-apes-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-jupyter?style=flat&color=4d6bfe" /> | Runnable `.ipynb` notebook view: lazy-start Python kernel, streaming outputs, save-back |
</details>
@@ -250,13 +252,13 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
| 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 |
| [dong-victor/dsh-apes-sidebar-terminal-plus](https://github.com/dong-victor/dsh-apes-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | Terminal enhancement: bundled Nerd Font icons, xterm glyph fixes, stable terminal cwd |
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | Preview selection boost: select text in any sidebar preview → floating "send to session" |
| [Hoemr/dsh-quicklook](https://github.com/Hoemr/dsh-quicklook) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-quicklook?style=flat&color=4d6bfe" /> | QuickLook-style Space preview: press Space on the active file tab for a full-size image / PDF / text overlay; Space or Esc closes |
</details>
> 📣 **List your plugin**: tag your repo with the `dsh-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`).
> 📣 **List your plugin**: tag your repo with the `dsh-apes-sidebar` topic to appear on the [topic page](https://github.com/topics/dsh-apes-sidebar); then PR one `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) to join the built-in recommended catalog (data integrity is guarded by `tests/plugin-list.spec.ts`).
## 🆕 Recent Updates
@@ -265,11 +267,100 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
<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.18.0): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.19.1): 0.1.5-rc.1+ (verified on rc.2)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
### v0.19.1
> 📌 **Stable release** (npm `latest`, no prerelease suffix): the pinned baseline moves to **DSH 0.1.5-rc.2** (npm `next`) while the **peer floor stays `^0.1.5-rc.1`** — nothing in the rc.2 upstream delta touches this plugin (zero changes under `packages/api|host|session|agent`; the only real code edits are the message-feedback dialog, deliverables card CSS and the `CodeFileIcon` SVG data split), so rc.1 hosts need no DSH upgrade to run this version. Hosts on DSH 0.1.5-alpha.2 stay on **v0.19.0-alpha.1**; the 0.1.2-rc.1 stable line keeps using **v0.18.1**.
- 🎯 **Adapted to DSH 0.1.5-rc.2**: devDependency pins, the CI mount lane and `SIDEBAR_SERVICE_VERSION` all move to rc.2; there is **no plugin-side code change** (the upstream delta does not touch this plugin — file-by-file audit in [docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md](docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md): the vast majority of the 300 changed files are single-line `version` bumps).
- 🎨 **File icons (#611) and the built-in tab glyphs are now colored** (#531 + #594 merged, implementation redone for rc.2):
- **Files and folders use DSH's own artwork**: the fallback link is the host's `FileTypeIcon` from `ui-primitives` (48 full-color official code/config glyphs plus the category-colored sheets for markdown, images, PDF, Office, video and folders), so the plugin **ships no extension table and needs no icon chunk** — #429's 563-entry color dataset and `lib/client-file-icons.js` (255 kB) are gone, along with the `fileIconTheme` switch. Color is simply the behaviour now, and the core bundle grew by 10 kB.
- **New public `registerFileIcon` API** (capability `'fileIcons'`): register your own glyphs by extension (`exts`), exact file name (`names`) or directory name (`folderNames`), ranked by priority then registration order. **Caveat**: the host classifier covers every path, so a catch-all (`exts: []`) claims every row the specific rules miss.
- **Colored built-in tab glyphs**: Files / Changes / Tasks / Side chat / Terminal / Browser and the diff view all get a colored glyph, painted from `--dsw-alias-*` tokens so every skin still applies.
- **The native right Sidebar's tab chips carry a glyph too**: the host's tab definition has no icon field, but the `sidebar.right.pane.tab.title` slot *is* the chip's content — the plugin renders `[glyph][title]` there (an editor tab with a path shows that file's icon). The glyph is `aria-hidden`, so the chip's accessible name is unchanged.
- 🛠 **CI fix 1: real Windows-lane timeouts**. `ci-windows` went red eight times in the 2026-09-09/10 window; six of those were process-spawning cases hitting vitest's 5000 ms default: `tests/agent-pty.spec.ts` (real PowerShell + ConPTY per terminal) and `tests/install-powershell.spec.ts` (a cold `powershell.exe` start measured 12.1 s) now declare a 30 s budget each, vitest.config.ts raises the global `testTimeout` from its 5000 ms default to **15 s** (the first real Windows run then lost a *third* file to the same default — `tests/git.spec.ts:85` at 9607 ms; three files, one cause, so per-file patches were whack-a-mole), and `waitForTranscript`'s inner poll budget dropped from 5000 ms to 15 s — **the inner budget must sit below the outer one** (they used to be the same 5000 ms, a structurally guaranteed timeout). `ci-windows`'s `Test` step now runs `pnpm test:windows` (`--maxWorkers=2`) so the process-spawning specs stop starving each other on a 2-core runner.
- 🛠 **CI fix 2: the mount lane's npm install**. `plugin-mount`'s `npm install -g @deepseek-ai/dsh@<ver>` failed four times (two `ETARGET`, two `JavaScript heap out of memory` / exit 134): the pinned version declares its transitives as floating `^` ranges, so while upstream publishes a prerelease in stages (rc.2 appeared package by package between 14:43 and 14:57 UTC on 09-10) npm resolved a mixed rc.1/rc.2 peer graph (3062 ERESOLVE lines). The lane now pins an **already-complete** rc.2 (fixes ETARGET) and passes `NODE_OPTIONS=--max-old-space-size=4096` (fixes the OOM). `--legacy-peer-deps` was tried on the way and rejected by real CI: it skips exactly the peers a global install must supply, and `@deepseek-ai/cordis-plugin-group` — required at boot by `dsh-app-boot` — is a peer rather than a dependency, so the CLI died with `ERR_MODULE_NOT_FOUND`.
### v0.19.0
> 📌 **Stable release** (npm `latest`, no prerelease suffix): supports **DSH 0.1.5-rc.1+** only (peer floor `^0.1.5-rc.1`, CI pins `@deepseek-ai/dsh@0.1.5-rc.1`). Hosts on DSH 0.1.5-alpha.2 should stay on **v0.19.0-alpha.1** (the npm `alpha` tag still points there); the 0.1.2-rc.1 stable line keeps using **v0.18.1**.
**✨ New**
- 📝 **The new-tab list gets an optional description back**: DSH 0.1.5-rc.1 restored `SidebarRightGuideEntry.description` (optional), so the plugin restores `TabDescriptor.description` — each of the six built-in types declares a line again (all six guide rows — Files / Changes / Tasks / Side chat / Terminal / Browser — carry one), and the `guideDesc*` keys return to all 20 dictionaries. **The host's native guide renders descriptions only while the listed entries are ≤ 4** (a longer list drops every description rather than truncating), and the plugin contributes six guide entries by default — so in the default composition no description is rendered; they appear once the reader disables enough tab types (per-type switches live on the plugin's settings page) to bring the guide down to four entries or fewer. An entry that declares none stays a single icon + title line (the plugin ships no generic fallback sentence). (#613)
**🐛 Fixes**
- None. The rc.1 delta over alpha.2 is small (373 changed files, overwhelmingly upstream package version bumps, native-sidebar preview UI polish, and test snapshots); apart from the restored description field (plus upstream's `files` type now using its own coloured folder glyph as the guide glyph) nothing needed adapting.
**🧰 CI & internals**
- Baseline moved to **DSH 0.1.5-rc.1+** (#613): peer floor, devDependency pins, the CI mount lane, and `dsh.plugin.json`'s `engines.dsh` all follow (rc.1 is both the `latest` and `next` dist-tag on npm).
- Explicitly unchanged, no need to re-check: the global main-panel model (the `main` slot / `sidebar.panellist` / `ctx.layout` / root-scoped `rightbar` + `rightbar.session`, still not adopted), the file address grammar (`packages/util/workspace-path` only had its version bumped), the native tab-body host contract (`.paneBody` is still a block scroller with a definite height), the core / agent / session / subagent host APIs, and the `ui-primitives` export surface (only CodeBlock rendering changed).
- `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` still declares no `dependencies` while its bundle still bare-imports `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex` — the third-party devDependencies hoisted in the previous release therefore stay, and must not be reverted.
- Real-host verification (DSH 0.1.5-rc.1 + plugin 0.19.0): `typecheck` / `lint` / `check:consumer-types` green; unit suite **124 files · 1296 passed · 9 skipped**; `pnpm peers check` clean; the mount smoke lane against real rc.1 **7 passed** (including the tab-body fill assertion and the new "descriptions render only at ≤4 guide entries" assertion); on the local 3080 instance the guide still shows six icon+title capsules (6 > 4, so upstream renders no descriptions — upstream also raised the capsule `min-height` from 48px to 56px), clicking `AGENTS.md` in the file tree lands in the plugin editor (CodeMirror mounted), the side-chat composer sits on the pane floor (host box 962px == pane body 962px, composer 8px off the bottom), the bottom workbench's edges coincide with the centre column, and `pageerror` is 0 (the only console noise is the third-party `dsh-tauri-worktree` `/api/dsh-worktree/attach` 500, unrelated to this change).
### v0.19.0-alpha.1
> 🧪 **alpha channel** (npm dist-tag `alpha`, install `dsh-apes-sidebar@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-apes-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).
> 📌 **Stable release** (npm `latest`): this release targets **DSH 0.1.2-rc.1+ only** (peer floor `^0.1.2-rc.1`); 0.1.0-rc.8 ~ 0.1.1-rc.2 are not supported — stable-DSH users should stay pinned to `dsh-apes-sidebar@0.17.1`, hosts on 0.1.2-alpha.x keep `dsh-apes-sidebar@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):
@@ -300,23 +391,23 @@ All changes since v0.17.1 (the two intermediate version numbers v0.18.1-alpha.0
**🌐 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)
- 10+ new curated plugins: dsh-apes-sidebar-icons (#441), dsh-sidenote (#451, formerly dsh-sidechat #470), dsh-github-workbench (#410), dsh-bilingual-reader (#379), dsh-server-deck (#413), dsh-md-export (#405), dsh-code-nav (#404), dsh-suhuang-scroll (#392), dsh-better-overleaf (#370), and more (each with 18+ language i18n coverage)
### v0.19.0-alpha.0
### v0.19.0-alpha.0 (0.1.2-alpha.5 adaptation, unpublished, folded into v0.18.0)
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.5`, 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.
> 🧪 **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.
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.3`, npm dist-tag `alpha`, install `dsh-apes-sidebar@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`).
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.2`, npm dist-tag `alpha`, install `dsh-apes-sidebar@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)).
@@ -354,7 +445,7 @@ All changes since v0.15.2:
**🐛 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
- 🔤 **Type base migrated to `@deepseek-ai/cordis`** ([#338](https://github.com/omdsh-dev/DSH-better-sidebar/pull/338)): the declaration surface (`src/context-types.ts`) no longer depends on or restates the public `cordis` — `Context` is now an **intersection** of the real vendored cordis Context with the structural service faces, and the `ctx.betterSidebar` type merge lives on `@deepseek-ai/cordis`. **Consumer migration**: change `import type { Context } from 'cordis'` to `import type { Context } from '@deepseek-ai/cordis'` (the `import type {} from 'dsh-apes-sidebar'` merge path is unchanged); plugins that never used that import are unaffected
- 🧩 **All internal `ctx.betterSidebar` reads fixed** ([#357](https://github.com/omdsh-dev/DSH-better-sidebar/pull/357), fixes [#356](https://github.com/omdsh-dev/DSH-better-sidebar/issues/356)): on npm-installed DSH 0.1.1-rc.x (web bundle) the sidebar died on every page load (`cannot get property "betterSidebar" without inject`) — the 26 internal direct reads now go through `ctx.get('betterSidebar')` (root reflect-store resolution, immune to the fiber chain); external consumers keep `inject: ['betterSidebar'] + ctx.betterSidebar`
- 🔐 **Session-workspace boundary for file APIs** ([#345](https://github.com/omdsh-dev/DSH-better-sidebar/pull/345), fixes [#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)): fixed `fs.tree / fs.read / fs.write` workspace escapes; media, HTML preview and uploads share real-path symlink validation; regression tests for absolute paths, symlinks, uploads and nested Git sessions
- 🪟 **Panel-host layering and viewport clipping** ([#330](https://github.com/omdsh-dev/DSH-better-sidebar/pull/330) [#278](https://github.com/omdsh-dev/DSH-better-sidebar/pull/278), fixes [#277](https://github.com/omdsh-dev/DSH-better-sidebar/issues/277)): panel host z-index 40→25 — below the DSH cordis dynamic-plugin layer (30), so the workbench no longer covers cordis inventory/approval surfaces (still above AppFrame's 20 and below the 100+ float stack); `overflow: hidden` on the host clips at the viewport edge, so collapsed panels no longer stretch the document into bidirectional scrolling (measured `scrollWidth` 2289→1672 / `scrollHeight` 1280→1032, any skin)
@@ -506,19 +597,20 @@ All changes since v0.14.0:
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, float windows 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).
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.
The dashed cards at the end of the "Sidebar content" / "File viewers" grids in the "Side Cards" settings section open the **Add tab plugins** / **Add preview plugins** modals: each declares its open extension point, offers a "**Browse more plugins on GitHub**" button (the [GitHub topic `dsh-apes-sidebar`](https://github.com/topics/dsh-apes-sidebar)), and lists the recommended catalog (name / repo / description / install script) — "**Open**" jumps to the repo, "**Copy**" writes the install command to the clipboard.
**Curating a new plugin**: append a `PluginEntry` to [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) (tab registrations) or [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) (file-previewer registrations) and tag your repo with the `dsh-better-sidebar` topic; data integrity is guarded by `tests/plugin-list.spec.ts`.
**Curating a new plugin**: append a `PluginEntry` to [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) (tab registrations) or [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) (file-previewer registrations) and tag your repo with the `dsh-apes-sidebar` topic; data integrity is guarded by `tests/plugin-list.spec.ts`.
## 🛠️ Development & Build
```sh
pnpm install # @deepseek-ai/* devDependencies resolve (baseline 0.1.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
@@ -571,7 +663,7 @@ WeChat / QQ group QR codes will live here. After uploading the QR images (drag t
## 🤝 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)
- **Curate an ecosystem plugin**: tag your repo with `dsh-apes-sidebar` + PR a `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts)
- **Before submitting**: `pnpm typecheck && pnpm build && pnpm test` (or `make check` for the one-shot aggregate; CI additionally gates on npm-pack → real-mount → headless-render via `pnpm test:mount`, plus the aggregate double-mount regression `pnpm test:mount:aggregate`)
- See [`AGENTS.md`](./AGENTS.md) for the repository rules (hard constraints, CI lanes, release flow)
@@ -602,5 +694,5 @@ Thanks to everyone who contributed:
---
<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>
<sub>MIT License · Built for the <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> ecosystem · discover more on the <a href="https://github.com/topics/dsh-apes-sidebar">dsh-apes-sidebar topic</a></sub>
</div>
+190 -37
View File
@@ -1,13 +1,39 @@
# 外部插件接入指南:基于 dsh-better-sidebar 实现新页面
# 外部插件接入指南:基于 dsh-apes-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+**;自由窗口(`floatWindows`)**v0.16.0+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.18.0**(正式版,仅支持 DSH 0.1.2-rc.1+;旧宿主 stable 线为 v0.17.1)。
> 适用版本:**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.1**(正式版,npm `latest`,仅支持 DSH **0.1.5-rc.1+**,已在 **0.1.5-rc.2** 上完成真机挂载验证;**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 起把自己改造成一个**注册表服务**:
@@ -30,7 +56,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
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' 类型合并
import type {} from 'dsh-apes-sidebar' // 触发 declare module '@deepseek-ai/cordis' 类型合并
```
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §10)。
@@ -42,15 +68,15 @@ import type {} from 'dsh-better-sidebar' // 触发 declare module '@deepseek-ai
"name": "my-plugin",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"dsh-better-sidebar": "workspace:*"
"dsh-apes-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
"dsh-apes-sidebar": { "optional": true }
}
}
```
- `dsh-better-sidebar` 必须是 **peerDependency**(不是 dependency),避免两份实例;
- `dsh-apes-sidebar` 必须是 **peerDependency**(不是 dependency),避免两份实例;
- `optional: true`:better-sidebar 未安装时你的插件照常加载,注册代码因为 `ctx.betterSidebar` 为 undefined 而安全跳过。
> ⚠️ **计划上架 DSH 插件市场**的插件还须遵守市场 manifest 约束(`dependencies`/`peerDependencies`/`optionalDependencies` 一律不得出现 `cordis`、`scripts` 不得含 install 类钩子),详见 [AGENTS.md](../AGENTS.md) 硬约束一节。
@@ -66,10 +92,10 @@ import type {
FileViewerDescriptor,
FileViewerProps,
FileFetchStrategy,
} from 'dsh-better-sidebar'
} from 'dsh-apes-sidebar'
// 方式二:子路径(与主入口等价)
import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api
import type { TabDescriptor } from 'dsh-apes-sidebar/client/service' // 别名 ./client/api
```
v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
@@ -78,10 +104,10 @@ v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者
import type {
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
} from 'dsh-better-sidebar/client/service'
} from 'dsh-apes-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` 也能编译。
> 💡 **类型合并触发路径**:`import type {} from 'dsh-apes-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module '@deepseek-ai/cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-apes-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。
---
@@ -89,7 +115,7 @@ import type {
```ts
// my-plugin/src/client/index.ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
import type {} from 'dsh-apes-sidebar' // 触发 ctx.betterSidebar 类型合并
import type { Context } from '@deepseek-ai/cordis'
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需)
@@ -155,7 +181,15 @@ interface TabDescriptor {
id: string
/** 标题(i18n 友好:传字符串或返回字符串的函数) */
title: string | (() => string)
/** 图标:ReactNode 或 (size: number) => ReactNode */
/**
* 一行说明,渲染在标题下方(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
@@ -362,7 +396,7 @@ ctx.effect(() => {
| 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——落点由二级设置 `changesDiffFloat` 决定(默认**自由浮窗**居中弹出,可选面板下半 split),设置项见 tab descriptor 的 `settings.toggles` select |
| `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`) |
@@ -549,10 +583,31 @@ interface BetterSidebarService {
registerTab(descriptor: TabDescriptor): () => void
/** 注册文件预览器;返回 disposer */
registerFileViewer(descriptor: FileViewerDescriptor): () => void
/** 注册自定义文件树/文件 tab 图标(v0.19.0+,features 含 'fileIcons');返回 disposer */
registerFileIcon(descriptor: FileIconDescriptor): () => void
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
getTabs(): readonly TabDescriptor[]
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
getFileViewers(): readonly FileViewerDescriptor[]
/** 当前已注册的文件图标描述符快照(v0.19.0+) */
getFileIcons(): readonly FileIconDescriptor[]
/** 按 path 匹配**具体**注册(priority 降序、注册序):先 names 文件名,再
* 具体扩展名;不查 catch-all 与 folder 保留值。消费方一般直接用
* fileIcon/folderIcon 全链解析器。 */
matchFileIcon(path: string): FileIconDescriptor | undefined
/** 匹配目录行注册:先按 folderNames 匹配目录名(name 传 basename,可省),
* 再按 'folder'/'folder-open' 保留扩展名;priority 降序、注册序;
* 返回 undefined = 回退内置 VscFolder/VscFolderOpened */
matchFolderIcon(open: boolean, name?: string): FileIconDescriptor | undefined
/** 文件图标权威解析器(v0.19.0+),完整回退链:
* ① 具体 names/扩展名注册 → ② 内置 glyph(md/媒体/pdf/json/代码/配置/数据库/lock/压缩包)
* → ③ 最优 catch-all 注册(exts: [],即全局默认)→ ④ 通用 VscFile。
* 任一注册工厂抛错都会被吞(console.error 后跳下一级),永远返回有效 ReactNode。 */
fileIcon(path: string, size: number): ReactNode
/** 目录图标解析器:注册的 folderNames/'folder'(闭合)/'folder-open'(展开)图标 →
* 内置 VscFolder/VscFolderOpened;path 为目录自身路径(主题可按目录变化),
* open 会传给工厂,一条注册即可渲染开/合两态。 */
folderIcon(path: string, open: boolean, size: number): ReactNode
/** 按 id 查 tab 描述符 */
getTab(id: string): TabDescriptor | undefined
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
@@ -584,9 +639,10 @@ interface BetterSidebarService {
// ── v0.12.0+ ──────────────────────────────────────────────────────────
/** 插件版本(如 '0.17.1';与 package.json 同步,测试守护) */
readonly version: string
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
* 'pluginSettings' | 'urlTarget' | 'settingSelect' | 'floatWindows'
/** 能力清单(只增不删,唯一例外:v0.19.0 删除了 'floatWindows'):
* 'badge' | 'tabLifecycle' | 'updateTab' | 'openFile' | 'targetedOpen' |
* 'stateSubscription' | 'tabMeta' | 'pluginSettings' | 'urlTarget' |
* 'settingSelect' | 'fileIcons'
* ——用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
@@ -618,8 +674,99 @@ interface OpenTabSeed {
* undefined = 不改,null = 显式清除 */
meta?: unknown
}
/** 文件图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */
interface FileIconDescriptor {
/** 唯一 id(如 'my-plugin:icons') */
id: string
/** 小写扩展名、不带前导点(如 ['csv','tsv'])。两个**保留值**认领目录行
* 而非文件扩展名:'folder'(闭合目录)、'folder-open'(展开目录)——
* 它们不会匹配真实文件(名为 x.folder 的文件不受影响)。
* [] = catch-all 全局默认:只兜内置 glyph 没认领的扩展名(注册的具体
* names/扩展名与内置 glyph 永远优先于它)。
* **省略** = 完全没有扩展名规则(只有 names 的注册不是 catch-all)。 */
exts?: readonly string[]
/** 精确**文件名**(basename,大小写不敏感,如 ['package.json','Dockerfile'])——
* 图标主题的 fileNames 半边;命中优先于扩展名。省略/[] = 无文件名规则。 */
names?: readonly string[]
/** 精确**目录名**(basename,大小写不敏感,如 ['node_modules','src'])——
* 图标主题的 folderNames 半边;命中优先于保留扩展名,且**只认领列出的
* 目录**(要接管所有目录请用 'folder'/'folder-open')。省略/[] = 无规则。 */
folderNames?: readonly string[]
/** priority 高者胜,缺省 0(同级按注册先后) */
priority?: number
/** 尺寸感知的图标工厂(文件树/文件 tab 当前以 size=14 渲染)。
* 与内置图标(currentColor 单色,遵循皮肤契约)不同,注册图标可以是
* 任意 ReactNode——包括彩色图标;颜色在皮肤间的表现由注册方自行负责。
* open:目录行的展开态(文件行为 undefined),一条注册即可渲染开/合两态。 */
icon: (path: string, size: number, open?: boolean) => ReactNode
}
```
**图标注册示例**(v0.19.0+;一次注册可同时覆盖具体扩展名、目录与全局默认):
```ts
if (ctx.betterSidebar.features.includes('fileIcons')) {
ctx.effect(() =>
ctx.betterSidebar.registerFileIcon({
id: 'my-plugin:icons',
exts: ['csv', 'tsv'],
icon: (path, size) => <MyCsvIcon size={size} />, // 彩色也可以
})
)
ctx.effect(() =>
ctx.betterSidebar.registerFileIcon({
id: 'my-plugin:names', // 精确文件名:package.json 与别的 .json 区分开
names: ['package.json', 'Dockerfile'],
icon: (path, size) => <MyBrandIcon size={size} />,
})
)
ctx.effect(() =>
ctx.betterSidebar.registerFileIcon({
id: 'my-plugin:folders', // 只认领列出的目录名
folderNames: ['node_modules', 'src'],
icon: (path, size, open) => open === true ? <MyOpenFolderIcon size={size} /> : <MyFolderIcon size={size} />,
})
)
ctx.effect(() =>
ctx.betterSidebar.registerFileIcon({
id: 'my-plugin:all-folders', // 接管所有目录行(保留扩展名)
exts: ['folder', 'folder-open'],
icon: (path, size, open) => open === true ? <MyOpenFolderIcon size={size} /> : <MyFolderIcon size={size} />,
})
)
ctx.effect(() =>
ctx.betterSidebar.registerFileIcon({
id: 'my-plugin:default', // 全局默认:只兜内置 glyph 没认领的文件
exts: [],
icon: (path, size) => <MyGenericFileIcon size={size} />,
})
)
}
```
**消费表面与回退链**(由本插件内置消费,插件无需自己接线):
- 文件树文件行 / 编辑器文件 tab(每个文件独立窗口):`fileIcon(path, size)`
——具体 `names`/扩展名注册 → catch-all 全局默认(`exts: []`)→ **DSH 官方图标**。
- 文件树目录行(含根行):`folderIcon(path, open, size)`——`folderNames` 命中
→ `'folder'`/`'folder-open'` 保留扩展名 → DSH 官方的文件夹图形。
注册/注销即时生效(文件树与 tab 栏订阅注册表变化自动重渲染);图标工厂抛错会被吞掉
(console.error 后跳到回退链下一级),不会空白行。
**内置图标 = DSH 官方图形**(v0.19.0+,插件不含任何图标数据):
- 回退链末端是 `FileTypeIcon` / `CodeFileIcon`(`@deepseek-ai/dsh-client-ui-primitives`,
DSH 0.1.5-rc.2+):48 个代码/配置类目的官方全彩图形 + markdown / 图片 / PDF / Word /
Excel / PPT / 视频 / 文件夹 / 通用文档的类目色板图形,分类器是宿主的
`classifyFileType`(精确文件名 → 前缀/后缀 → 项目上下文 → 扩展名)。本插件因此**没有**
自己的扩展名表、**没有**图标 chunk、**没有**图标主题开关——彩色是唯一形态。
- **语义后果(重要)**:宿主分类器覆盖任意路径,所以「插件自己已经能画这个扩展名」不再是
拦住 catch-all 的理由——**注册了 `exts: []` 的插件会接管全部未具体命中的行**(优先级降序、
同优先级按注册序)。只想补几个扩展名就照常用 `exts`/`names`,别用 catch-all 兜底。
- 插件自己注册的图标颜色由注册方负责(见 §12):品牌色是内容标识而非 chrome。
**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:
```ts
@@ -719,44 +866,49 @@ ctx.effect(() =>
| 陷阱 | 说明 |
|---|---|
| **构建纯度门** | 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` 不再被依赖 |
| **统一 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-apes-sidebar'` 即拿到 `ctx.betterSidebar`,或直接 `import type { Context } from 'dsh-apes-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 环境) |
| **不要 value-import `dsh-apes-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import |
| **client 声明图零 Node 依赖(v0.12.0+)** | `dsh-apes-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__`。对消费插件透明 |
| **聊天文件打开漏斗(alpha 宿主)** | 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)汇入 `ctx.remote.session.openWorkspacePath`(Typert remote 命名空间,cordis 服务 key `remote.session`,方法为 **accessor 属性**、异步挂载)。better-sidebar 的「聊天区文件在侧边栏打开」即在 `ctx.inject(['remote.session'], …)` 内以 defineProperty 遮蔽该方法(`src/client/openpath-intercept.ts`)。你的插件若要观测/旁路聊天文件打开,走同一服务;不要假设 pre-alpha 的 `ctx.workspaces.openPath` 存在(alpha 的 `IWorkspaces` 已无此方法) |
| **聊天文件打开漏斗(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,`features` 含 `'floatWindows'`)
## 11. 自由窗口(v0.16.0 – v0.18.x,**v0.19.0 已移除**)
任意 tab(含你注册的)可拖出侧边栏成为悬浮**自由窗口**——对你的组件基本透明,只需知道以下语义:
自由窗口(tab 拖出侧边栏成为可移动/缩放的悬浮窗,`SidebarState.floats`、`floatTab`/`dockFloat`/`raiseFloat`、`features` 里的 `'floatWindows'`、右键「移动到自由窗口」、`src/client/FreeWindow.tsx`)在 **v0.19.0-alpha.0 整体删除**:右列交给 DSH 原生右侧栏后,插件只剩底部工作台,浮窗既没有语义归属也没有可用手势(拖出目标区域现在是原生栏的属地)。
- **拖出**:tab 拖到主会话区域(conversation 列)松开即浮动(`floatTab`:移入 `SidebarState.floats`,默认 390×780 按视口钳制居中,清空 pane 折叠)。检测在 `Sidebar.tsx`(`body[data-dsh-tab-dragging]` 门控);窄视口禁用。右键「移动到自由窗口」始终可用。
- **窗口操作**(`src/client/FreeWindow.tsx`,`[data-dsh-panel-host]` 内、z-42):头部拖动移动;拖到 pane(`[data-dsh-pane]`)松开**停靠**(`dockFloat`);右下角缩放(最小 320×200);点击置顶(层叠 = `floats` 数组序);X = `closeTab`(触发 `onClose`、释放终端)。
- **持久化**:`floats` 随会话进 localStorage(`dsh-sidebar:v1:<sessionId>`);`sanitizeState` 宽容校验(非法条目单独丢弃、几何钳入视口、diff/ephemeral 不持久化)。
- **服务语义**:`openTab` 聚焦命中浮动 tab = 置顶窗口(不重复开);`closeTab`/`activateTab` 关窗/置顶并触发回调;agent 终端 reconcile 移除已消失的浮动窗口。**浮窗内你的组件 `visible` 恒 true**。
- **稳定寻址面**:`[data-dsh-float-window]` / `[data-dsh-float-id]` / `[data-dsh-pane]` / `[data-dsh-float-dock-over]` / `[class*='floatDropHint']`;全令牌驱动,头部 `-webkit-app-region: no-drag`。
- **⚠️ portal 事件劫持陷阱**:拖拽表面子树含 portal 覆盖层时(如你的组件在 tab 头部区域渲染弹层),portal 后代合成事件**沿 React 树冒泡**回 `onPointerDown`,会被误判为拖拽开始(吞点击 + 抢 pointer,菜单/X 失灵;jsdom 不走此路径,只有 e2e 能抓)。拖拽起点必须带**同源守卫**:`event.currentTarget.contains(event.target)` 为假或目标在 `button` 内时直接返回(回归:`tests/free-window.spec.tsx` portaled-menu 用例)。
- 升级到 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 组件遵循同样的令牌规则即可自动兼容全部皮肤。
>
> **没有任何豁免面**:文件与文件夹图标是 **DSH 官方的 `FileTypeIcon` 图形**(宿主自己
> 的调色板,插件不画像素也不存数据),插件画的每个 glyph(含内置 tab 的彩色图标)颜色
> 都来自 `--dsw-alias-*`——`tests/theme.spec.ts` 同时守护「图标模块零颜色字面量」与
> 「样式表里每条 `color` 都解析到令牌」。插件自己注册的图标(`registerFileIcon`)颜色由
> 注册方负责,不受本节令牌约束,但**不要**把彩色图标数据塞进核心 bundle 的常驻渲染路径
> (要按需加载就照 `src/client/chunk-loader.ts` 的懒加载 chunk 走)。
### 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` 断言恰一节点且为 `[data-slot="conversation"]` 的父级;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。
- **根锚点**:宿主 div 带 `data-dsh-apes-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-apes-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`。
@@ -768,7 +920,7 @@ ctx.effect(() =>
### 12.2 注意事项
- 类名是 CSS Modules 哈希,**不是契约**;精确命中用 `[data-dsh-better-sidebar]` + 子串类名(`[class*='panel']`)或 DOM 结构。
- 类名是 CSS Modules 哈希,**不是契约**;精确命中用 `[data-dsh-apes-sidebar]` + 子串类名(`[class*='panel']`)或 DOM 结构。
- 改动本契约必须同步本文档、设计文档与 `tests/theme.spec.ts`。
---
@@ -790,11 +942,11 @@ ctx.effect(() =>
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"dsh-better-sidebar": "workspace:*",
"dsh-apes-sidebar": "workspace:*",
"react": "^18.2.0"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
"dsh-apes-sidebar": { "optional": true }
}
}
```
@@ -803,7 +955,7 @@ ctx.effect(() =>
```tsx
import { createElement } from 'react'
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
import type {} from 'dsh-apes-sidebar' // 触发 ctx.betterSidebar 类型合并
import type { Context } from '@deepseek-ai/cordis'
export const inject = ['betterSidebar']
@@ -861,8 +1013,9 @@ 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/FreeWindow.tsx`**:自由窗口(移动/停靠/缩放/置顶,§11)
- **`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` 守护)
@@ -880,7 +1033,7 @@ better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"),
- **接入方式**:可选软依赖——client half 本地重述最小服务契约(`registerTab`),未安装 better-sidebar 时注册静默跳过,插件原有表面不受影响;
- **注册内容**:`dsh-sentinel:watches` tab(order 60,单实例):全服务器监控表 + 最近触发历史;
- **类型处理**:未 value-import `dsh-better-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
- **类型处理**:未 value-import `dsh-apes-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
- **实测**:v0.3.0 起,真实 web profile 验证通过。
通过 `ctx.betterSidebar` 的三方插件 [dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) —— 基于 better-sidebar 的划选提问:对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题);快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档。
@@ -96,3 +96,5 @@
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 标记即为规范用法。
@@ -89,6 +89,8 @@ Cursor (SSH)
**实施偏差(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(不处理,注释说明)。
@@ -96,13 +98,18 @@ Cursor (SSH)
- 自定义编辑器仅支持 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 标签后缀;未接线隐藏。
- `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`。
@@ -0,0 +1,76 @@
# 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 通过。
@@ -0,0 +1,62 @@
# 文件树重命名 / 删除设计(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 刷新机制管)。
@@ -0,0 +1,61 @@
# 变更页操作预览增强设计(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 出现条件)。
@@ -0,0 +1,217 @@
# 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 加「两侧皆空 → 降级『上下文未加载』」防御,取数异常不再静默空白展开。
@@ -0,0 +1,79 @@
# 终端 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;不直推。
@@ -0,0 +1,645 @@
# 终端 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 链接、改动文件清单、行为对照(改前/改后)。
@@ -0,0 +1,90 @@
# DSH 0.1.5-alpha.2 适配(v0.19.0-alpha.1 基线)
日期:2026-09-09 分支:`feat/dsh-0.1.5-alpha.2`
## 背景
DSH `dsh-v0.1.5-alpha.1..dsh-v0.1.5-alpha.2` 的宿主契约里有一批会实际波及本插件的变更。本计划记录哪些值得处理、哪些只做兼容、以及每一处的子系统级落点。插件对外的全部行为差异以 [docs/external-plugin-guide.md](../external-plugin-guide.md) 为准;仓库级适配要点(基线、CI 钉版、宿主契约)同步到 [AGENTS.md](../AGENTS.md) §3。
## 上游 delta(alpha.1 → alpha.2,影响本插件的六项)
| # | 上游变更 | 对插件的影响 |
|---|---|---|
| 1 | `SidebarRightGuideEntry` 去掉 `description` 字段;指南条目变为「图标 + 标题」胶囊 | 删除 `TabDescriptor.description` 与 6 个 `guideDesc*` 词典键(连同 `nativeGuideDesc` 兜底句——不再有第二行说明) |
| 2 | 新增**全局面板**:根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`;`rightbar` 改根级并新增会话级 `rightbar.session` 子槽 | 插件**不接入**(兼容 only) |
| 3 | 原生 tab 体宿主 `.paneBody`(`dsh-client-ui-dockkit`)是**有确定高度的块级滚动容器**,不是 flex 容器;DSH 自己的 tab 体(`ui-sidebar-documentpreview` 的文档预览、`ui-sidebar-files` 的 FilesBody)在根上声明 `height: 100%` | 原生适配层给每个 tab 体包一层 `height: 100%` 的列 flex 宿主(`sidebar.module.css` `.nativeTabHost`,带 `data-dsh-native-tab-host`),修复 sidechat 输入框贴不到面板底的根塌陷 |
| 4 | 文件地址语法:`fileAddressFor` 一律返回 session 作用域地址(绝对路径保留前导 `/`);`parseFileAddress` 前缀解析并去 `?`/`#`;`absoluteFileAddress` 仍在 | 镜像 alpha.2 语法(插件自实现于 `src/client/resource-address.ts`——客户端 bundle 纯度门禁禁 value-import 未列名的 `@deepseek-ai/*`,不能直接引 DSH 工具包) |
| 5 | `ui-sidebar-textpreview` 被 `ui-sidebar-documentpreview` 取代(同 `text` kind、`fallback` 带、`dsh-resource://file/**` 模式) | 插件 `editor` 仍以 `extension` 优先级认领 `dsh-resource://file/**`,压过其 `text` 的 `fallback` 带 |
| 6 | 基线升到 0.1.5-alpha.2(peer 下限 / manifest / CI 钉版) | 插件基线 = alpha.2 only;alpha.1 用户留在 v0.19.0-alpha.0 |
> 后续(2026-09-10):`SidebarRightGuideEntry.description` 在 DSH 0.1.5-rc.1 回归(可选,且宿主只在 guide 列出的条目 ≤ 4 条时渲染),插件已在 **v0.19.0** 恢复该字段——见 [2026-09-10-dsh-0.1.5-rc.1-adaptation.md](./2026-09-10-dsh-0.1.5-rc.1-adaptation.md)。
>
> 后续(2026-09-10):退役自绘右侧面板(#605)时,`use-host-feeds.ts` 的三处自动激活被误改成 `target: 'bottom'`,任务页因此落到插件自己的底部工作台而非 DSH 原生右侧栏。修复见 [2026-09-10-auto-activation-native-landing-fix.md](./2026-09-10-auto-activation-native-landing-fix.md)。
## 决策
- **基线 = alpha.2 only**:peer 下限 `^0.1.5-alpha.2`、manifest `>=0.1.5-alpha.2`、CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`;不再对 alpha.1 做运行时兼容。
- **全局面板:兼容 only,不接入**——不迁移到 `main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`,仅保证在 alpha.2 宿主上不破坏。
- **删除 `TabDescriptor.description`**:宿主不再渲染第二行,字段留着只会在类型上撒谎;同步删 6 个 `guideDesc*` 与 `nativeGuideDesc` 兜底句。
- **修复 tab 体填充**:native 适配层为每个 tab 体(含 orphaned 回退)包 `height:100%` 列 flex 宿主 `.nativeTabHost`;tab 组件根遵循 `flex:1` / `height:100%` + `min-height:0` 契约。
- **镜像 alpha.2 地址语法**:`fileAddressFor` 不再产生 `absolute` 作用域(但仍解析旧地址与第三方地址),`parseFileAddress` 前缀解析并去 `?`/`#`。
- **中心列锚点跟随 alpha.2**:`center-column.ts` 的 `CENTER_COLUMN_SELECTOR` 改为 `#root [data-slot="main.conversation"], #root [data-slot="conversation"]`,`resolveCenterColumn` 从对话槽宿主向上跳过 `display: contents` 祖先取真实列;alpha.1 的 `[data-slot="conversation"]` 依旧认可。
## 改动清单(子系统级)
| 子系统 | 文件 | 变更 |
|---|---|---|
| 基线 | `package.json` | 版本 → `0.19.0-alpha.1`;dsh-* peer 全部 `^0.1.5-alpha.2`;devDeps 同步钉 `0.1.5-alpha.2` |
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 alpha.2 |
| 基线 | `dsh.plugin.json` | `version` → `0.19.0-alpha.1`;`engines.dsh` → `>=0.1.5-alpha.2` |
| 基线 | `.github/workflows/ci.yml` | mount lane 钉 `@deepseek-ai/dsh@0.1.5-alpha.2` |
| 基线 | `package.json` | 按 §3-9 补提 `dsh-session-persistence`(`pnpm peers check` 唯一新失配项) |
| 基线 | `package.json` | **上游打包缺陷**:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 的 manifest 不再声明任何 `dependencies`(alpha.1 声明了 19 个),但其 `lib/*.js` 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`。宿主自身由预构建前端 bundle 满足,独立安装的消费方(本仓库的 dev/test 树)必须自备,故把 alpha.1 声明的同一组版本提升进 devDependencies(`@shikijs/langs`、`@types/mdast`、`anser`、`katex`、`mdast-util-from-markdown/gfm/math`、`micromark-core-commonmark/extension-gfm/extension-math/factory-space/util-character/util-classify-character/util-sanitize-uri/util-symbol/util-types`、`shiki`;`clsx` 已在 dependencies) |
| 基线/文档 | `AGENTS.md` | §2/§3 基线戳、§10 补 alpha.2 行为差异(本 workstream 回填) |
| Tab 契约 | `src/client/service.ts` | `TabDescriptor` 删除 `description` 字段;`SIDEBAR_SERVICE_VERSION` → `0.19.0-alpha.1` |
| Tab 契约 | `src/client/builtins/tabs.tsx` | 6 个内置 tab 删除 `description` 行 |
| Tab 契约 | `src/client/native/index.ts` | native 注册的 guide 条目去掉 `description`(删 `descriptionOf` 与通用兜底);`guide` 条目只剩 `order`/`title`/`icon` |
| 词典 | `src/client/locales.ts` + 各 `locales-*.ts` | 删除 `nativeGuideDesc` + 6 个 `guideDesc*` 词条(zh/en + 全部第三方语言) |
| Tab 高度 | `src/client/native/tab-adapter.tsx` | `NativeTabBody` 把 live 组件与 orphaned 回退都包进 `css.nativeTabHost`(带 `data-dsh-native-tab-host`) |
| Tab 高度 | `src/client/sidebar.module.css` | 新增 `.nativeTabHost`(`display:flex; flex-direction:column; height:100%; min-height:0`) |
| 地址语法 | `src/client/resource-address.ts` | 重写为镜像 alpha.2 文件地址语法(`fileAddressFor` 不再产 `absolute` 作用域、`parseFileAddress` 前缀去 `?`/`#`、保留 `absolute` 解析) |
| 中心列锚点 | `src/client/center-column.ts` | `CENTER_COLUMN_SELECTOR` 加 `main.conversation`;新增 `columnOfSlotHost` 跳过 `display: contents` 祖先 |
| 中心列锚点 | `src/client/sidebar/use-center-column.ts` | 宿主查找逻辑对齐 alpha.2 |
| 中心列锚点 | `src/client/layout.css` | 选择器/注释同步 `main.conversation` 与 `display: contents` 跳过 |
| 测试 | `tests/builtins.spec.ts` | 指南断言从 `description` 改成 `title`(图标+标题胶囊) |
| 测试 | `tests/native-surface.spec.ts` | 注册/guide 断言去 `description`;断言 guide 条目不再携带 `description` |
| 测试 | `tests/center-column-locator.spec.ts` | 覆盖 `main.conversation` 解析与 `display: contents` 跳过 |
| 测试 | `tests/resource-address.spec.ts` | 覆盖 alpha.2 地址语法 |
| 测试 | `tests/e2e/mount.e2e.ts` | guide 页断言跟随胶囊形态;新增 tab 体填充断言(可见 tab 的 `[data-dsh-native-tab-host]` 盒高 == 其带盒祖先高 ±2px,且 sidechat composer 底边距面板底 ≤12px) |
| 文档 | `docs/external-plugin-guide.md` | §0 行为差异表、§4.1 删 `description`、§10 加 tab 体高度契约、§12 中心列锚点、`ui-sidebar-documentpreview` |
| 文档 | `docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md` | 本计划 |
## 已知差距
- **side-chat 线程仍只携带 `subagent/descriptor`,不携带 alpha.2 的 `subagent/catalog`**:DSH 自己的直接子代理发现现在优先读 `catalog`,而我们沿用的 `subagents.listDescendants`(`src/subagent-live-route.ts`)与转录种子的 `type: 'subagent/descriptor'`(`src/sidechat-routes.ts`)路径不变。catalog 只作为新增读面出现,不构成对现有路径的破坏,本期不跟进。
## 验证
- **门禁**(本分支 HEAD):`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm vitest run --exclude '**/*.e2e.ts'` = 124 files / 1295 passed / 9 skipped;`pnpm peers check` = No peer dependency issues found。
- **真机挂载冒烟**(`DSH_CMD` 指向 npm 安装的 DSH 0.1.5-alpha.2,`scripts/e2e-mount.sh`):7 passed。新增的 tab 体填充断言(可见 tab 的 `[data-dsh-native-tab-host]` 盒高 == 其带盒祖先 ±2px,sidechat composer 底边距面板底 ≤12px)是这次修复的回归守护——它在修复前失败(`gap -862px`,因为断言最初漏跳过 `display: contents` 槽宿主)。
- **真机 3080(web profile 升到 alpha.2 + 插件 0.19.0-alpha.1)**:
- 原生 guide 六行均为 48px「图标+标题」胶囊,无第二行:文件 / 文件变动 / 任务管理 / 侧边对话(beta) / 终端 / 浏览器。
- 文件树点击 `AGENTS.md` → tab 标题 `AGENTS.md` + CodeMirror 挂载(`editor` 接管成立)。
- **sidechat 输入框贴底**:原生右侧栏 `hostHeight 962 == paneBodyHeight 962`(fillGap 0),composer 底边距面板底 8px(即它自己的 margin);底部工作台同样 8px。
- 底部工作台中心列锚点:`[data-dsh-bottom-panel]` 与 `[data-dsh-center-col]` 左右边完全重合(gap 0)——证明 `main.conversation` + 跳过 `display: contents` 的解析在真机成立;真机 `[data-slot]` 清单同时确认 `main` / `main.conversation` / `rightbar.session` 已存在、旧 `conversation` 槽已消失。
- `pageerror` 0 条;控制台仅 `POST /api/dsh-worktree/attach` 500(第三方 dsh-tauri-worktree 插件,与本次改动无关)。
- 未覆盖:**聊天里的文件链接点击**——当前可见会话没有可解析的文件提及(内联 `code` 是纯文本,不是链接),该漏斗(`ctx.sidebarRight.openResource(fileAddressFor(...))`)由挂载车道与 `tests/resource-address.spec.ts` 覆盖,本实例未实测。
- 仓库没有守护指南文档章节的测试;`tests/builtins.spec.ts` / `tests/native-surface.spec.ts` 守护的是 guide 行形态而非文档文本。
- 早期本分支相关测试曾整体收集失败:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 在模块加载时裸 `import 'anser'`,而该包 manifest 已不再声明依赖。补齐 devDependencies 后恢复(见改动清单)。
## 不做
- 不接入 alpha.2 全局面板(`main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`)。
- 不引入 `subagent/catalog` 读取面。
- 不改 DSH 源码(仓库硬约束 §1)。
## 落地
- #603 → #604 → #605 → #609 依序合入 `main`(各自 retarget 到 main 后以 merge commit 合并);main 的 `package.json` 为 `0.19.0-alpha.1`、`engines.dsh` 为 `>=0.1.5-alpha.2`。main 的 CI 首轮 `ci-windows` 因既有的 pty/git 5s 超时抖动失败,重跑后三 job 全绿。
- 发布 [v0.19.0-alpha.1](https://github.com/omdsh-dev/DSH-better-sidebar/releases/tag/v0.19.0-alpha.1):`release.yml` 通过全门禁后经 npm Trusted Publishing 发布,npm `alpha` dist-tag → `0.19.0-alpha.1`(带 provenance)。发布产物核对:`lib/index.js` 与本地已验证构建逐字节一致;`lib/client.js` 仅差打包非确定性(已知);`package.json` 仅少 dev-only 字段(`packageManager` / `prepare` / `prepublishOnly`,npm 发布时剥离)。
- 注意:新版本在 registry 上可见有约 5 分钟传播延迟(版本元数据先到、tarball 后到),期间 `npm view`/tarball 可能 404——不是发布失败。
@@ -0,0 +1,192 @@
# 终端等待 banner(wait banner + 跳过)设计
> 2026-09-09 · 目标 v0.18.2
## 背景
`terminal_wait_for`(v0.18.1,PR #595 起 needle 支持 regex)会阻塞 agent 的工具调用直到
needle 出现 / 超时 / 终端退出。等待期间用户在侧边栏看不到任何"agent 正在等什么"的信号,
也无法干预——只能等 agent 自己超时(默认 10s,长等待可设到分钟级)。
需求:
1. agent 开始 `wait for needle` 时,在对应终端的顶部显示 banner:
**「Agent 正在等待 {needle}」**,右侧「跳过等待」按钮;
2. 点击跳过后,`terminal_wait_for` 立即返回,agent 明确知道是用户跳过(而非超时);
3. 终端 tab 未激活时也能感知等待 → tab 标题加 ⏳ 徽章。
## 方案对比
| 方案 | 思路 | 取舍 |
|---|---|---|
| **A. 搭现有推送车(采纳)** | 等待状态并入 `AgentTerminalSnapshot`,经现有 `/sidebar/ws/agent-terminals` 推送;跳过走新 `/sidebar/api` 方法 | 改动集中;零新 WS 生命周期;推送时机由 registry `notify()` 天然覆盖 |
| B. 独立 waits 推送 WS | 新开 `/sidebar/ws/agent-waits` | 重连/订阅/teardown 全套翻倍,YAGNI |
| C. 客户端轮询 | 定时拉 terminal 列表 | 延迟与开销差,违背现有推送架构 |
已确认的两个决策:
- **skip 语义 = 新增第四种返回 `kind:'skipped'`**(不复用 timeout、不走 abort——abort
语义属于工具调用取消,用户语义只是"不等了");
- **banner 主体在终端卡片顶部 + tab 标题 ⏳ 徽章**(复用 v0.12+ 的 `TabDescriptor.badge`
通道,git tab 有先例)。
## 设计
### 1. Host:`AgentPtyRegistry`(src/agent-pty.ts)
- `AgentTerminalHandle` 增活动等待记录数组 `waits: Array<{ needle, since, skipped }>`;
并发 `wait_for` 同 uuid 时各登记一条。
- `waitFor()`:快速路径(已 exited / needle 已在 transcript)不登记、不推 banner;
进入轮询前登记记录并 `notify()`(banner 出现),`finally` 移除记录并 `notify()`(banner
消失——found/timeout/exited/skipped/工具 abort 五条出路统一走 finally)。
- 轮询每圈检查本记录的 `skipped` → 返回 `{ kind:'skipped', needle }`;50ms 轮询
天然承载跳过延迟,无需事件唤醒。与终端退出在同一 tick 竞争时**退出优先**(见实施
偏差记录)。
- 新方法 `skipWait(uuid): number`:标记该 uuid 全部活动记录并返回条数;uuid 未知 →
`not-found` 404(与 `expect` 一致)。
- `AgentTerminalSnapshot` 增 `waiting?: { needle: string; since: number }`(取最新登记
的一条;并发等待显示最新 needle,跳过跳过全部)。
- `AgentTerminalWaitResult` 增 `skipped` 变体。
### 2. 工具层:`terminal_wait_for`(src/tools.ts)
- output schema `oneOf` 增 `{ kind: 'skipped', needle }` 分支;
- render:`Skipped by user while waiting for "<needle>" — the wait ended early; call terminal_read to inspect the transcript if needed.`;
- 工具描述补一句:用户可从侧栏 banner 跳过等待(返回 `skipped`)。
### 3. API:`buildApi`(src/index.ts)
- 新方法 `'agent-pty.skip-wait'`(命名对齐现有 `'agent-pty.close'`):
`requireString uuid` → `registry.skipWait(uuid)`;degraded 模式(registry 为 null)
幂等返回 `{ ok: true, skipped: 0 }`。
### 4. 推送零改动
`attachAgentList` 发的就是 `registry.list(sessionId)`,snapshot 新增的 `waiting` 字段
自动随车;等待开始/结束由 `notify()` 触发推送。兼容性:
- 旧客户端 + 新 host:多余字段被忽略;
- 新客户端 + 旧 host:永远收不到 `waiting` → 无 banner,安全降级。
### 5. 客户端状态(src/client/state.ts)
- `SidebarState` 增 `agentWaits: Record<uuid, { needle: string; since: number }>`(默认
`{}`;等待结束即删除键);
- `reconcileAgentTerminals` 从同一推送同步该映射——需解除"无 tab 增删即早退"的短路
(仅 waiting 变化也要产出新状态);
- **不持久化**:`sanitizeState` 逐字段重建、不恢复该字段 → 刷新后自然清空;WS attach
即发首条推送,立刻纠正。
### 6. Banner(src/client/TerminalView.tsx)
- 仅 `agent:` tab 渲染;位置在终端区顶部(现有 fatal/deps banner 之上);
- 内容:`t('terminalWaitBanner', { needle })`(zh「Agent 正在等待 {needle}」)+ 右侧
`t('terminalSkipWait')`(zh「跳过等待」)按钮 → `api.agentSkipWait(uuid)`;
- needle 超 ~80 字符截断显示,`title` 悬浮显全文;
- banner 消失完全由推送驱动(skipped/exited/abort 均经 `notify`),点击不做乐观更新。
### 7. Tab ⏳ 徽章(src/client/builtins/tabs.tsx)
- terminal 描述符增 `badge: (_ctx, scope, state) => ...`:`agent:` tab 且
`state.agentWaits[uuid]` 存在时返回 `'⏳'`,否则 null——复用 v0.12+ 徽章通道
(git changes tab 先例),不碰 `TabBar`。
### 8. i18n / 样式
- 词典键:`terminalWaitBanner`、`terminalSkipWait`;zh/en 同步,**ja 翻译必须同步
`src/client/locales-ja.ts`**(仓库规则);
- `.terminalWaitBanner` 全部消费 `--dsw-alias-*` / `--ds-*` 令牌,无硬编码颜色
(皮肤契约,`tests/theme.spec.ts` 守护)。
### 9. 测试
- `tests/agent-pty.spec.ts`:跳过返回 `skipped`;活动等待期间 snapshot 含 `waiting`、
结束后消失;`skipWait` 未知 uuid → 404、无活动等待 → 0;等待开始/结束触发 `notify`;
- `tests/tools.spec.ts`:FakeRegistry 增 skipped 形状;schema/render 断言;
- `tests/agent-terminal-reconcile.spec.ts`:`agentWaits` 设置/清除、仅 waiting 变化也
触发状态更新;
- badge / banner 组件测试视现有 jsdom 基建可行性补齐。
## 边缘情况
- **并发多个 `wait_for` 同一 uuid**:banner/⏳ 显示最新登记的 needle;跳过一次跳过全部;
- **工具调用取消(abort)**:`finally` 移除记录 → banner 消失并推送;
- **终端退出 / 用户关 tab(close frame 杀 pty)**:轮询下一圈返回 `exited`,记录移除;
- **UI-tab 终端**:无等待概念,`agentWaits` 永不含有其条目。
## 不做的事
- 不新增独立 waits WebSocket(方案 B 否决);
- 不改 `ctx.betterSidebar` 服务面(`external-plugin-guide.md` 无需同步);
- 不做乐观 UI(banner 消失以服务端推送为准)。
## 实施偏差记录
- **⏳ tab 徽章不走 `TabDescriptor.badge`**:实现时核实该 API 是 type-keyed
(`badge(ctx, scope, state)`,无 tab 参数、external 插件共享),无法定位单个
tab。改为 Sidebar 壳层 `tabBadgeOf` 的 sidebar-internal 特例(agent: 前缀判断
+ `state.agentWaits` 查表),`service.ts` 与 external-plugin-guide.md 零改动。
- **banner 抽为独立组件 `TerminalWaitBanner.tsx`**:设计原文写在 TerminalView
内;抽出后无 xterm 依赖,jsdom 单测可直接渲染(TerminalView 仍负责订阅 store
并把 onSkip 接到 `api.agentSkipWait`)。
- **词典同步扩到全部 21 个语言文件**:设计只写了 zh/en/ja,但
`tests/locales.spec.ts` 断言每个第三方词典与 zh 键集相等——只同步三语会让该
守护测试挂掉。按仓库惯例(全语言同步,如 e8761ee)补齐其余 18 语言同两键。
- **banner 配色**:仓库 CSS 无 state-info 令牌,沿用 `.terminalBanner` 的 warn
对(`--dsw-alias-state-warn-label/-tertiary`),符合皮肤契约。
- **设计文档与计划随 feat 分支进 PR**:main 受分支保护,纯文档直推被 hook 拒绝
(`4a6a77a` / `2448068` 随本分支合并)。
- **测试断言以文本计数取代 CSS 类名**:CSS module 类名在测试变换下不稳定,
⏳ 数量断言改从 `textContent` 计数;长 needle 截断断言改为计量 needle 自身
贡献字符数(本地化句子包裹 needle,整体长度断言与语言相关,原计划内部矛盾)。
- **新测试的 localStorage 兜底**:Node 26 + jsdom 29 组合下测试环境可能无全局
`localStorage`(上游 `bottom-auto-terminal.spec.tsx` 在同环境即失败,属已知
win32/环境非回归类);新 spec 的 afterEach 加守卫 + try/catch(jsdom opaque
origin 下 localStorage 可以是 throwing accessor,裸 typeof 也会炸),
上游测试一律未动。
- **已知限制:跨会话 pinned 虚拟 tab 收不到实时 banner/⏳**(Copilot review
指出):等待状态 feed 按 viewer 会话订阅(`/sidebar/ws/agent-terminals` 按
当前 sessionId 连接),钉到其他会话的 pinned 虚拟 agent tab
(`pinned:<homeSessionId>:agent:<uuid>`)在其 home 会话非当前会话时收不到
实时等待状态。修复需跨会话 wait feed 多路复用(连 home 会话推送或聚合
推送),超出本特性范围,留待后续设计;跳过 API 按 uuid 全局寻址,跨会话
跳过本身可达。
- **Copilot review 第三轮修复(合并 rc.1 后扫描)**:
1. `truncateNeedle` 改按 Unicode 码点截断(`Array.from`),原 UTF-16 码元
截断会在截断点劈开代理对(emoji 渲染为替换字符);
2. banner 消息加 `role="status"` + `aria-live="polite"`,完整未截断的
本地化消息经 `aria-label` 提供给读屏器(不新增词典键);
3. `terminal` tab 类型禁用时推送处理从整体跳过改为 `mirrorAgentWaits`
——仍镜像权威 `waiting` map(等在禁用窗口内 resolve 也清 banner 状态),
只跳过 tab 增删 reconcile,否则重启用后已开终端残留 stale banner/⏳
直到无关推送到达;
4. badge 测试跟踪挂载的 root 并在 `afterEach` 里先 unmount 再清 DOM
(裸 `innerHTML = ''` 不触发 React effect 清理,store 订阅/host-feed
effect 会泄漏进后续测试);
5. 补并发双 `waitFor` 同 uuid 测试:快照显示最新 needle(`waits.at(-1)`)、
`skipWait` 返回 2、两个 promise 都以各自 needle `skipped` 收敛、快照
清空且 skipWait 幂等归零。
- **轮询优先级:终端退出优先于用户跳过**(v0.19.0 发版前 review 发现):设计原文
写「每圈优先检查 `skipped`」,实现是 `aborted → handle.exited → record.skipped`,
即 pty 退出与用户点跳过落进同一个 50ms tick 时返回 `exited`。**维持实现现状**,
设计原文已同步为「退出优先」:两种顺序下用户可见结果完全相同(banner 消失、
`skipWait` 返回 0、等待结束),只有工具结果的 `kind` 文案不同;而进程已死是
客观事实,报 `exited` 不撒谎,工具结果本身也引导 agent 用 `terminal_read` 复核。
若日后要让「用户意图优先」,改动点是循环内两行先后 + 一条同 tick 竞态测试。
- **两个已知限制的补充记录**(同一次 review):
1. **pinned tab 的 banner 与 ⏳ 不对称**:pinned 虚拟 id 是
`pinned:<homeSessionId>:agent:<uuid>`,`isAgentTabId` 只认 `agent:` 前缀,因此
钉住的 agent tab **不会误挂 ⏳**(比"收不到"更强的保证);但 Workbench 渲染
pinned tab 时给 TerminalView 传的是**原 tab id + home 作用域**,所以 home 会话
正在等待时该视图**会**显示 banner,而它的 strip 徽章仍不出现。两个信号数据源
不同(banner 读 home store,徽章读 viewer 的 `agentWaits`)。
2. **⏳ 到不了 DSH 原生右侧栏**:徽章经 `getTabBadge` 渲染,唯一消费点是插件
自绘的 `TabBar`;原生栏的 `NativeTabTitle` 契约只带 title,不带 badge。agent
终端标签页走 `openTabInBottomPane`(底部工作台),故 agent 流程可见;这是原生
承载面契约决定的上限,非本特性缺陷。
- **注释随实现同步**(v0.19.0 发版前 review):`use-host-feeds.ts` effect 头部原写
「terminal tab 类型禁用时 pushes are ignored」,与上面第 3 项的 `mirrorAgentWaits`
改法矛盾,已改为「不增删 tab,但仍镜像权威 wait map」;`src/index.ts` 的
`agent-pty.skip-wait` 注释原文把「无活动等待 → 0」泛化成了「幂等」,已收窄为
「无活动等待返回 `{skipped:0}`;未知 uuid 走 `expect` → 404(已退出的终端即属此
列),客户端 `.catch` 容忍并等下一次推送收敛」。
@@ -0,0 +1,980 @@
# 终端等待 banner + 跳过等待 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** agent 阻塞在 `terminal_wait_for` 时,对应终端卡片顶部显示「Agent 正在等待 {needle}」banner(右侧「跳过等待」按钮),tab 标题加 ⏳ 徽章;跳过后工具返回新增的 `{kind:'skipped'}`。
**Architecture:** 等待状态登记在 `AgentPtyRegistry` 的 handle 上,随现有 `/sidebar/ws/agent-terminals` 推送快照(`waiting` 字段)自然下发;跳过走新增 `/sidebar/api/agent-pty.skip-wait` 路由。客户端把推送镜像进 `state.agentWaits`,banner 组件与 tab 徽章从 store 读。
**Tech Stack:** TypeScript / React 18 / vitest(node 环境 pty 测试 + jsdom Sidebar 壳测试)/ ws 推送 / CSS Modules(皮肤令牌)。
**Spec:** `docs/plans/2026-09-09-terminal-wait-banner-design.md`(与本计划同分支,执行者两份都读)。
## Global Constraints
- 分支:`feat/wait-banner`(已含设计文档提交 `4a6a77a`);所有代码改动走该分支 → PR,禁止直推 main(分支保护)。
- 皮肤契约:新增 CSS 只允许 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌;本项目复用现成 warn 对 `--dsw-alias-state-warn-label` / `--dsw-alias-state-warn-tertiary`(仓库 CSS 无 state-info 令牌,已核实)。
- i18n:新增 zh key 必须同步 en 词典与 `src/client/locales-ja.ts` 的 ja 翻译。
- client bundle 纯度门:新文件只准 import 本插件模块(locales / css / api),禁止 value-import `@dsh-external/*` 或非白名单 `@deepseek-ai/*`。
- 不改 `src/client/service.ts` / `docs/external-plugin-guide.md`(⏳ 徽章走 shell 层特例,不动 TabDescriptor API)。
- 上游测试(含 win32 已知 27 个非回归失败)一律不改。
- 每个任务用 `pnpm exec vitest run <file>` 验证;最终任务必须 `pnpm lint` + `pnpm typecheck` + `pnpm test` + `pnpm build` 全绿才 push(CI 曾因漏 lint 挂过)。
- 提交信息风格:`feat(terminal): ...` / `test(...)`,仓库惯例小写 scope。
---
### Task 1: Registry 等待登记 + `skipped` 结果 + `waiting` 快照
**Files:**
- Modify: `src/agent-pty.ts`
- Test: `tests/agent-pty.spec.ts`
**Interfaces:**
- Consumes: 现有 `AgentPtyRegistry.waitFor` / `notify` / `subscribe` / `snapshotOf`。
- Produces:
- `type AgentTerminalActiveWait = { needle: string; since: number; skipped: boolean }`
- `AgentTerminalHandle.waits: AgentTerminalActiveWait[]`(create 时初始化为 `[]`)
- `AgentTerminalSnapshot.waiting?: { needle: string; since: number }`(最新一条活动等待)
- `AgentTerminalWaitResult` 新增 `{ kind: 'skipped'; needle: string }` 变体
- `registry.skipWait(uuid: string): number`(标记全部活动等待,返回条数;未知 uuid 抛 not-found)
- [ ] **Step 1: 写失败测试(4 个新用例,加在 `describe('AgentPtyRegistry')` 内、现有 `waitFor` 用例之后)**
在 `tests/agent-pty.spec.ts` 的 import 块不需要新增(`AgentPtyRegistry` / `snapshotOf` 已导入)。追加用例:
```ts
it('waitFor returns skipped when the user skips from the sidebar', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'skip-test', 'echo skip-ready', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'skip-ready')
// waitFor registers its record synchronously (before the first poll
// await), so the skip can fire immediately after the call.
const waitPromise = registry.waitFor(uuid, 'NEVER_APPEARS_XYZ', 30_000)
expect(await registry.skipWait(uuid)).toBe(1)
const result = await waitPromise
expect(result.kind).toBe('skipped')
if (result.kind === 'skipped') expect(result.needle).toBe('NEVER_APPEARS_XYZ')
// Idempotent: nothing left to skip once the wait resolved.
expect(registry.skipWait(uuid)).toBe(0)
} finally {
registry.disposeAll()
}
})
it('snapshot exposes waiting while a wait is active and clears after it ends', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'wait-snap', 'echo snap-ready', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'snap-ready')
const waitPromise = registry.waitFor(uuid, 'LATER_MARK_9', 30_000)
// Registration happens synchronously before waitFor's first await.
expect(registry.list('s1')[0]?.waiting?.needle).toBe('LATER_MARK_9')
expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number')
expect(registry.skipWait(uuid)).toBe(1)
const result = await waitPromise
expect(result.kind).toBe('skipped')
expect(registry.list('s1')[0]?.waiting).toBeUndefined()
} finally {
registry.disposeAll()
}
})
it('fires change listeners when a wait starts and ends', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'watched-wait', 'echo notify-ready', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'notify-ready')
let changes = 0
const unsubscribe = registry.subscribe(() => { changes += 1 })
const waitPromise = registry.waitFor(uuid, 'NEVER_NOTIFY_1', 30_000)
const afterStart = changes
expect(afterStart).toBeGreaterThanOrEqual(1)
expect(await registry.skipWait(uuid)).toBe(1)
expect(await waitPromise).toEqual({ kind: 'skipped', needle: 'NEVER_NOTIFY_1' })
expect(changes).toBeGreaterThan(afterStart)
unsubscribe()
} finally {
registry.disposeAll()
}
})
it('skipWait rejects an unknown uuid with not-found', () => {
const registry = new AgentPtyRegistry(testShell())
try {
expect(() => registry.skipWait('missing-uuid')).toThrow(/not found/)
} finally {
registry.disposeAll()
}
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/agent-pty.spec.ts`
Expected: FAIL — `waiting` 为 undefined、`skipWait` 不存在、skipped 分支不存在(TS 编译错误或断言失败)。
- [ ] **Step 3: 实现 `src/agent-pty.ts`**
(a) 在 `AgentTerminalReadResult` 之后、`AgentTerminalWaitResult` 之前新增:
```ts
/** One active wait_for registration on a handle (banner + skip bookkeeping). */
export interface AgentTerminalActiveWait {
/** The needle being awaited (shown on the sidebar wait banner). */
needle: string
/** Epoch ms when the wait registered (age display / debugging). */
since: number
/** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */
skipped: boolean
}
```
(b) `AgentTerminalWaitResult` union 末尾追加变体:
```ts
| {
/** The user skipped the wait from the sidebar banner. */
kind: 'skipped'
/** The needle that was awaited. */
needle: string
}
```
(c) `AgentTerminalSnapshot` 追加字段(`exitSignal` 之后):
```ts
/**
* The model's active `terminal_wait_for` on this terminal (the sidebar
* renders the wait banner from it). Present only while a wait is
* registered; carries the LATEST wait when several overlap.
*/
waiting?: { needle: string; since: number }
```
(d) `AgentTerminalHandle` 追加字段(`exitSignal` 之后):
```ts
/** Active wait_for registrations (skip bookkeeping; empty while idle). */
waits: AgentTerminalActiveWait[]
```
(e) `create()` 的 handle 字面量中 `exited: false,` 之后加 `waits: [],`。
(f) `snapshotOf` 在 `return out` 前追加:
```ts
const active = handle.waits.at(-1)
if (active !== undefined) out.waiting = { needle: active.needle, since: active.since }
```
(g) 新方法(放在 `waitFor` 之后):
```ts
/**
* Mark every active wait on one terminal as skipped (the sidebar banner's
* skip button). Each waiting poll loop observes its record's flag within
* one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
* is waiting (a stale banner racing a wait that already resolved).
* @returns the number of waits that transitioned to skipped.
*/
skipWait(uuid: string): number {
const handle = this.expect(uuid)
let count = 0
for (const record of handle.waits) {
if (!record.skipped) {
record.skipped = true
count += 1
}
}
return count
}
```
(h) 重写 `waitFor` 中段(`const firstHit = ...` 快速路径之后到方法结尾),登记记录 + 轮询检查 + `finally` 清理:
```ts
const firstHit = locateNeedle(handle.transcript, needle, re)
if (firstHit !== undefined) {
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start }
}
// Register the active wait so the sidebar can show the wait banner (the
// snapshot's `waiting` field rides the agent-terminals push). Registered
// only after the fast paths: a wait that resolves instantly never
// flashes the banner. The notify() makes every push subscriber (the
// sidebar view) converge on the new waiting state immediately.
const record: AgentTerminalActiveWait = { needle, since: start, skipped: false }
handle.waits.push(record)
this.notify()
try {
while (true) {
if (signal?.aborted) signal.throwIfAborted()
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
if (record.skipped) {
return { kind: 'skipped', needle }
}
const hit = locateNeedle(handle.transcript, needle, re)
if (hit !== undefined) {
return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start }
}
if (Date.now() >= deadline) {
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
}
await new Promise(resolve => {
const t = setTimeout(resolve, 50)
// Allow the Node process to exit even if the timer is pending.
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
})
}
} finally {
const index = handle.waits.indexOf(record)
if (index !== -1) handle.waits.splice(index, 1)
this.notify()
}
```
(i) `waitFor` 的 JSDoc:`@returns one of `found` / `timeout` / `exited`.` 改为 `@returns one of `found` / `timeout` / `exited` / `skipped`.`;并在 `@param signal` 行后补一行 `* A wait can also be skipped by the user from the sidebar banner (`skipWait`), which resolves it with `{kind:'skipped'}`.`
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/agent-pty.spec.ts`
Expected: PASS(全部用例,含既有 425 行文件内的用例)。
- [ ] **Step 5: Commit**
```bash
git add src/agent-pty.ts tests/agent-pty.spec.ts
git commit -m "feat(terminal): registry 支持等待登记、skipWait 与 skipped 结果"
```
---
### Task 2: 工具层 `skipped` 分支 + 宿主 `agent-pty.skip-wait` 路由
**Files:**
- Modify: `src/tools.ts`(terminal_wait_for 定义,约 287-377 行)
- Modify: `src/index.ts`(buildApi 表,`'agent-pty.close'` 之后,约 545 行)
- Test: `tests/tools.spec.ts`
**Interfaces:**
- Consumes: Task 1 的 `AgentTerminalWaitResult` skipped 变体、`registry.skipWait(uuid): number`。
- Produces: `/sidebar/api/agent-pty.skip-wait` 路由(payload `{uuid}` → `{ok:true, skipped:number}`);工具 output schema 第四分支;Task 4 的客户端 `api.agentSkipWait` 调它。
- [ ] **Step 1: 写失败测试**
`tests/tools.spec.ts`:(a) `FakeRegistry` 增加注入口 —— 在 `readonly terminals = ...` 行后加属性,并把 `waitFor` 改为:
```ts
/** When set, waitFor resolves with the skipped shape instead of the found default. */
nextWaitResult: { kind: 'skipped' } | undefined
waitFor(_uuid: string, needle: string): Promise<
{ kind: 'found'; needle: string; line: number; column: number; match: string; elapsedMs: number }
| { kind: 'skipped'; needle: string }
> {
// Mirrors the registry's empty-needle rejection (the tool layer no
// longer duplicates this check).
if (needle === '') return Promise.reject(new Error('needle must be a non-empty string'))
if (this.nextWaitResult !== undefined) return Promise.resolve({ kind: 'skipped', needle })
return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, match: needle, elapsedMs: 1 })
}
```
(b) 在 `describe('agent terminal tools')` 内、现有 `terminal_wait_for returns the registry result...` 用例后追加:
```ts
it('terminal_wait_for returns skipped (schema-valid) when the registry reports a user skip', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_wait_for')
registry.nextWaitResult = { kind: 'skipped' }
const value = await tool.execute({ uuid: 'uuid-1', needle: 'BUILD_OK', timeout_ms: 1000 }, exec('s1'))
expect(value).toEqual({ kind: 'skipped', needle: 'BUILD_OK' })
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_wait_for render describes the user skip and the needle', () => {
const { captured } = mount()
const tool = toolOf(captured, 'terminal_wait_for')
const blocks = tool.output.render({}, { kind: 'skipped', needle: 'BUILD_OK' }) as Array<{ type: string; text: string }>
expect(blocks[0]!.type).toBe('text')
expect(blocks[0]!.text).toContain('Skipped by user')
expect(blocks[0]!.text).toContain('BUILD_OK')
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/tools.spec.ts`
Expected: FAIL(schema 拒绝 skipped / render 走到 exited 分支输出不符)。
- [ ] **Step 3: 实现**
(a) `src/tools.ts` terminal_wait_for:
描述末尾(`'The wait is cooperative: ...'` 句之后)追加一句:
```ts
+ 'The user can skip the wait from the sidebar ( a banner on the terminal\'s tab shows the needle and a skip button ) — the tool then returns `skipped`.',
```
output.schema 的 `oneOf` 数组末尾(exited 分支对象之后)追加:
```ts
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'skipped' },
needle: { type: 'string', required: true },
},
},
```
render 的 value 类型与分支 —— `const v = value as { kind: 'found' | 'timeout' | 'exited' | 'skipped'; ... }`(原行加 `'skipped'`),并在 timeout 分支后插入:
```ts
if (v.kind === 'skipped') {
return [{ type: 'text', text: `Skipped by user while waiting for "${v.needle}" — the wait ended early. Call terminal_read to inspect the transcript and decide how to proceed.` }]
}
```
(b) `src/index.ts` buildApi 表,`'agent-pty.close'` 条目(约 541-545 行)之后追加:
```ts
// The sidebar wait banner's skip button: abort every active
// terminal_wait_for on one agent terminal. Idempotent — 0 when nothing
// is waiting (a stale banner racing a wait that already resolved).
// Degraded mode (node-pty unavailable) has no registry and no waits: an
// honest ok.
'agent-pty.skip-wait': (payload) => {
const uuid = requireString(payload, 'uuid')
return { ok: true, skipped: agentPtyRegistry?.skipWait(uuid) ?? 0 }
},
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/tools.spec.ts`
Expected: PASS。
- [ ] **Step 5: Commit**
```bash
git add src/tools.ts src/index.ts tests/tools.spec.ts
git commit -m "feat(terminal): terminal_wait_for 增 skipped 结果与 agent-pty.skip-wait 路由"
```
---
### Task 3: 客户端状态 `agentWaits` 镜像 + 推送类型
**Files:**
- Modify: `src/client/state.ts`(SidebarState 接口 ~121 行、makeDefaultState ~221 行、sanitizeState ~1283 行、reconcileAgentTerminals ~1017 行)
- Modify: `src/client/sidebar/use-host-feeds.ts`(~66 行解析类型)
- Test: `tests/agent-terminal-reconcile.spec.ts`
**Interfaces:**
- Consumes: 宿主推送数组(Task 1 的 snapshot 含 `waiting`)。
- Produces:
- `SidebarState.agentWaits: Record<string, { needle: string; since: number }>`(uuid → 活动等待;瞬态,不持久化)
- `reconcileAgentTerminals(state, list)` 的 list 元素增 `waiting?: { needle: string; since: number } | null`,并把等待状态镜像进 `agentWaits`(仅等待变化也产出新状态)。
- [ ] **Step 1: 写失败测试**
`tests/agent-terminal-reconcile.spec.ts` 追加两个用例(沿用文件现有 import,无需新增):
```ts
it('mirrors the pushed waiting state into agentWaits and clears it when it ends', () => {
let s = makeDefaultState()
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'busy', waiting: { needle: 'READY_1', since: 42 } }])
expect(s.agentWaits['aaa-111']).toEqual({ needle: 'READY_1', since: 42 })
// The next push without waiting drops the entry (the push is authoritative).
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
expect(s.agentWaits['aaa-111']).toBeUndefined()
})
it('produces a new state when ONLY the wait state changes (no tab add/remove)', () => {
let s = makeDefaultState()
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
const before = s
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable', waiting: { needle: 'N', since: 1 } }])
expect(next).not.toBe(before)
expect(next.agentWaits['aaa-111']).toBeDefined()
// Idempotent: the same push again returns the same reference.
expect(reconcileAgentTerminals(next, [{ uuid: 'aaa-111', title: 'stable', waiting: { needle: 'N', since: 1 } }])).toBe(next)
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/agent-terminal-reconcile.spec.ts`
Expected: FAIL(`agentWaits` 不存在 → TS 错误 / 断言失败)。
- [ ] **Step 3: 实现**
(a) `SidebarState` 接口 `floats: FloatWindow[]` 之后追加:
```ts
/**
* Live agent-terminal wait state (uuid → the wait the model currently
* blocks on in `terminal_wait_for`), mirrored from the host's
* agent-terminals push. Transient by design: sanitizeState never restores
* it, so a reload starts clean and the next push (sent immediately on WS
* attach) repopulates it.
*/
agentWaits: Record<string, { needle: string; since: number }>
```
(b) `makeDefaultState` 的返回字面量 `floats: [],` 之后加 `agentWaits: {},`。
(c) `sanitizeState` 的返回字面量 `floats,` 之后加(带注释):
```ts
// The agent wait state is TRANSIENT (like revealed): never restored from
// storage — the host's first push after attach repopulates it.
agentWaits: {},
```
(d) `reconcileAgentTerminals`:参数类型改为
```ts
agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
```
在 `const toRemove = ...` 之后、`if (toAdd.length === 0 && toRemove.length === 0) return state` 处,替换为:
```ts
// Mirror the live wait state from the push (authoritative: a vanished
// waiting field simply drops the entry). A waits-only change must still
// produce a new state — the tab add/remove no-change check alone would
// swallow banner updates.
const serverWaits: Record<string, { needle: string; since: number }> = {}
for (const terminal of agentTerminals) {
if (terminal.waiting !== undefined && terminal.waiting !== null) {
serverWaits[terminal.uuid] = { needle: terminal.waiting.needle, since: terminal.waiting.since }
}
}
if (toAdd.length === 0 && toRemove.length === 0 && sameAgentWaits(state.agentWaits, serverWaits)) return state
```
文件末尾的 `return next` 改为 `return { ...next, agentWaits: serverWaits }`。
在 `reconcileAgentTerminals` 前方新增辅助函数:
```ts
/** Shallow equality of two agent-wait maps (same keys, same needle+since). */
function sameAgentWaits(
a: SidebarState['agentWaits'] | undefined,
b: Record<string, { needle: string; since: number }>,
): boolean {
if (a === undefined) return Object.keys(b).length === 0
const aKeys = Object.keys(a)
if (aKeys.length !== Object.keys(b).length) return false
for (const key of aKeys) {
const av = a[key]
const bv = b[key]
if (av === undefined || bv === undefined) return false
if (av.needle !== bv.needle || av.since !== bv.since) return false
}
return true
}
```
(e) `src/client/sidebar/use-host-feeds.ts` 66 行的解析类型改为:
```ts
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean; waiting?: { needle: string; since: number } | null }>
```
- [ ] **Step 4: 跑测试确认通过 + 全仓 typecheck(状态字段新增会暴露遗漏的构造点)**
Run: `pnpm exec vitest run tests/agent-terminal-reconcile.spec.ts && pnpm typecheck`
Expected: PASS。若 typecheck 报其他 `SidebarState` 字面量缺 `agentWaits`(tests 里的手搓状态),补 `agentWaits: {}` —— **只许改测试里测试自己构造的字面量,不许改上游断言逻辑**。
- [ ] **Step 5: Commit**
```bash
git add src/client/state.ts src/client/sidebar/use-host-feeds.ts tests/agent-terminal-reconcile.spec.ts
git commit -m "feat(sidebar): 镜像 agent 终端等待状态到 agentWaits(瞬态)"
```
---
### Task 4: 等待 banner 组件 + TerminalView 接线 + api 方法 + i18n + CSS
**Files:**
- Create: `src/client/TerminalWaitBanner.tsx`
- Modify: `src/client/TerminalView.tsx`(render 区 ~387-408 行 + 组件顶部订阅)
- Modify: `src/client/api.ts`(`agentPtyClose` 之后 ~348 行)
- Modify: `src/client/locales.ts`(zh ~117 / en ~567,紧随 `terminalRetry`)
- Modify: `src/client/locales-ja.ts`(~105,紧随 `terminalRetry`)
- Modify: `src/client/sidebar.module.css`(`.terminalBanner` 块之后 ~2202 行)
- Test: `tests/terminal-wait-banner.spec.tsx`(新建)
**Interfaces:**
- Consumes: Task 2 的 `agent-pty.skip-wait` 路由;Task 3 的 `state.agentWaits`;`api` 对象、`t()`、`css` module。
- Produces: `TerminalWaitBanner({ needle: string; onSkip: () => void })` + `truncateNeedle(needle: string): string`(80 字符截断);词典键 `terminalWaitBanner` / `terminalSkipWait`。
- [ ] **Step 1: 写失败测试(新文件 `tests/terminal-wait-banner.spec.tsx`)**
```tsx
/**
* TerminalWaitBanner unit tests: localized text with the needle, the skip
* button wiring, and the long-needle display cap (full text stays on the
* title attribute). The component imports no xterm — renderable in plain
* jsdom (the repo's jsdom pattern, see bottom-auto-terminal.spec.tsx head).
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
import { TerminalWaitBanner } from '../src/client/TerminalWaitBanner.tsx'
describe('TerminalWaitBanner', () => {
const mounted: Array<{ root: Root; container: HTMLDivElement }> = []
function mount(needle: string, onSkip: () => void): HTMLDivElement {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => { root.render(createElement(TerminalWaitBanner, { needle, onSkip })) })
mounted.push({ root, container })
return container
}
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => { root.unmount() })
container.remove()
}
})
it('shows the localized waiting text with the needle and a working skip button', () => {
let skipped = 0
const container = mount('BUILD_OK', () => { skipped += 1 })
// jsdom navigator.language is en-US → the en dictionary applies.
expect(container.textContent).toContain('Agent is waiting for')
expect(container.textContent).toContain('BUILD_OK')
expect(container.textContent).toContain('Skip wait')
const button = container.querySelector('button')
expect(button).not.toBeNull()
act(() => { button!.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(skipped).toBe(1)
})
it('truncates a long needle for display but keeps the full text on the title', () => {
const long = 'X'.repeat(200)
const container = mount(long, () => {})
const span = container.querySelector('span[title]')
expect(span).not.toBeNull()
expect(span!.getAttribute('title')).toBe(long)
expect((span!.textContent ?? '').length).toBeLessThanOrEqual(80)
})
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/terminal-wait-banner.spec.tsx`
Expected: FAIL(模块不存在)。
- [ ] **Step 3: 实现**
(a) 新建 `src/client/TerminalWaitBanner.tsx`:
```tsx
/**
* The "Agent 正在等待 {needle}" wait banner: rendered at the top of an
* agent-owned terminal's view while the model blocks in terminal_wait_for.
* The skip button asks the host to abort every active wait on the terminal
* (`agent-pty.skip-wait`); the banner disappears when the host's next
* agent-terminals push drops the waiting field — no optimistic UI. Kept in
* its own module (no xterm imports) so jsdom tests can render it directly.
*/
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** Cap the needle shown inline; the full text rides the title tooltip. */
const NEEDLE_DISPLAY_CAP = 80
/** Truncate one needle for inline display (title attr carries the full text). */
export function truncateNeedle(needle: string): string {
return needle.length > NEEDLE_DISPLAY_CAP ? `${needle.slice(0, NEEDLE_DISPLAY_CAP - 1)}…` : needle
}
export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) {
const { needle, onSkip } = props
return (
<div className={css.terminalWaitBanner}>
<span className={css.terminalWaitNeedle} title={needle}>
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
</span>
<button type="button" className={css.terminalRetry} onClick={onSkip}>
{t('terminalSkipWait')}
</button>
</div>
)
}
```
(b) `src/client/api.ts`:`agentPtyClose` 条目之后追加:
```ts
/** Skip every active terminal_wait_for on one agent terminal (the wait
* banner's skip button). Idempotent: {skipped:0} when none is active. */
agentSkipWait: (uuid: string) =>
call<{ ok: true; skipped: number }>('agent-pty.skip-wait', { uuid }),
```
(c) `src/client/locales.ts`:zh 词典 `terminalRetry: '重试',` 行后加:
```ts
terminalWaitBanner: 'Agent 正在等待 {needle}',
terminalSkipWait: '跳过等待',
```
en 词典 `terminalRetry: 'Retry',` 行后加:
```ts
terminalWaitBanner: 'Agent is waiting for {needle}',
terminalSkipWait: 'Skip wait',
```
(d) `src/client/locales-ja.ts`:`terminalRetry: '再試行',` 行后加(仓库规则:新 zh 键必须同步 ja):
```ts
terminalWaitBanner: 'エージェントが {needle} を待機中',
terminalSkipWait: '待機をスキップ',
```
(e) `src/client/sidebar.module.css`:`.terminalBannerUrl` 块之后加:
```css
/* The "Agent 正在等待 {needle}" wait banner: a warn-toned strip above the
terminal surface while the model blocks in terminal_wait_for; the needle
ellipsizes inline and the full text rides the title tooltip. */
.terminalWaitBanner {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 3px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.terminalWaitNeedle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
```
(f) `src/client/TerminalView.tsx`:
imports 区追加:
```ts
import { TerminalWaitBanner } from './TerminalWaitBanner.tsx'
```
组件体内(`const [lastUrl, setLastUrl] = ...` 之后)加等待订阅:
```ts
// Agent terminals only: the model's active terminal_wait_for (mirrored
// from the host's agent-terminals push into the store) drives the wait
// banner. Read + subscribe like the font prefs above; the banner vanishes
// when the host's push drops the waiting field (skip / exit / abort all
// converge through the same push). getSnapshot() is {sessionId, state?,
// prefs} — the state may be briefly undefined around session switches.
const agentUuid = isAgentTabId(tabId) ? agentUuidOf(tabId) : null
const [waiting, setWaiting] = useState<{ needle: string; since: number } | undefined>(undefined)
useEffect(() => {
if (agentUuid === null) return
const read = (): void => {
const next = store.getSnapshot().state?.agentWaits?.[agentUuid]
setWaiting(prev => {
const nextValue = next === undefined ? undefined : { needle: next.needle, since: next.since }
if (prev?.needle === nextValue?.needle && prev?.since === nextValue?.since) return prev
return nextValue
})
}
read()
return store.subscribe(read)
}, [agentUuid, store])
```
return JSX 中 `<div className={css.terminalWrap}>` 的第一个子元素位置(`depsFatal` 条目之前)加:
```tsx
{agentUuid !== null && waiting !== undefined && (
<TerminalWaitBanner
needle={waiting.needle}
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
/>
)}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/terminal-wait-banner.spec.tsx`
Expected: PASS。
- [ ] **Step 5: Commit**
```bash
git add src/client/TerminalWaitBanner.tsx src/client/TerminalView.tsx src/client/api.ts src/client/locales.ts src/client/locales-ja.ts src/client/sidebar.module.css tests/terminal-wait-banner.spec.tsx
git commit -m "feat(sidebar): agent 终端等待 banner(needle + 跳过等待按钮)"
```
---
### Task 5: tab 标题 ⏳ 徽章(shell 层特例)+ jsdom 链路测试
**Files:**
- Modify: `src/client/Sidebar.tsx`(`tabBadgeOf` ~828-841 行)
- Test: `tests/agent-wait-badge.spec.tsx`(新文件,harness 照抄 `tests/bottom-auto-terminal.spec.tsx` 1-97 行的模式)
**Interfaces:**
- Consumes: Task 3 的 `state.agentWaits`;`isAgentTabId` / `agentUuidOf`(Sidebar.tsx 已 import,第 38-39 行)。
- Produces: agent tab 的 ⏳ 徽章 pill(复用 `css.tabBadge`);推送 → 状态 → 徽章全链路。
- [ ] **Step 1: 写失败测试(新文件 `tests/agent-wait-badge.spec.tsx`)**
完整文件(FakeWebSocket 记录 url 与实例,驱动 `/sidebar/ws/agent-terminals` 推送;`registerStubTerminal` 防止懒加载 chunk 在 jsdom 里拉真实 bundle):
```tsx
/**
* The agent-terminal wait badge chain, end to end in jsdom: the host's
* agent-terminals push (mirroring the real /sidebar/ws/agent-terminals
* frames) reconciles tabs AND the wait map; the shell's tabBadgeOf renders
* the hourglass pill on the agent tab while a wait is live and drops it
* when the push clears. Harness mirrors tests/bottom-auto-terminal.spec.tsx
* (real Sidebar shell + fake context + stubbed WebSocket).
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
import { Sidebar } from '../src/client/Sidebar.tsx'
import { createSidebarStore, openTabInActivePane } from '../src/client/state.ts'
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
class FakeWebSocket {
static instances: FakeWebSocket[] = []
onmessage: ((event: { data: unknown }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
close = (): void => {}
constructor(readonly url: string) {
FakeWebSocket.instances.push(this)
}
}
let sessionSeq = 0
function mountSidebar(): { container: HTMLDivElement; store: ReturnType<typeof createSidebarStore>; service: BetterSidebarService; unmount: () => void } {
vi.stubGlobal('WebSocket', FakeWebSocket)
FakeWebSocket.instances = []
const container = document.createElement('div')
document.body.append(container)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// Replace the terminal descriptor with a stub: the real one lazy-loads the
// xterm chunk, which jsdom cannot fetch — the badge under test renders in
// the TAB STRIP, independent of the view component.
service.registerTab({
id: 'terminal',
title: () => 'Terminal',
component: () => null,
})
const sessionId = `s1-${++sessionSeq}`
store.setSession(sessionId)
// Stub the terminal descriptor: the real one lazy-loads the xterm chunk,
// which jsdom cannot fetch (the bottom-auto-terminal harness does the same).
service.registerTab({
id: 'terminal',
title: () => 'Terminal',
component: () => null,
})
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = {
current: sessionId,
byId: { [sessionId]: { cwd: '/tmp' } },
}
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
betterSidebar: service,
get: (name: string) => name === 'betterSidebar' ? service : undefined,
}
const root: Root = createRoot(container)
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
return {
container,
store,
service,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
let sessionSeq = 0
afterEach(() => {
document.body.innerHTML = ''
localStorage.clear()
vi.unstubAllGlobals()
})
/** The agent-terminals push socket the shell opened for the current session. */
function feedsSocket(): FakeWebSocket {
const socket = FakeWebSocket.instances.find(candidate => candidate.url.includes('/sidebar/ws/agent-terminals'))
if (socket === undefined) throw new Error('agent-terminals socket not connected')
return socket
}
describe('agent terminal wait badge (push → state → tab pill)', () => {
it('shows ⏳ on the agent tab while a wait is active and drops it when it ends', () => {
const { container } = mountSidebar()
const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false }
// First push: the agent tab lands in the active pane.
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
expect(container.textContent).toContain('dev server')
expect(container.textContent).not.toContain('⏳')
// Second push carries a live wait → the hourglass pill appears.
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) })
expect(container.textContent).toContain('⏳')
// The wait resolved (skip/exit/abort all converge to a waiting-less push).
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
expect(container.textContent).not.toContain('⏳')
})
it('shows ⏳ only on agent tabs, never on UI-owned terminal tabs', () => {
const { container, store } = mountSidebar()
// A UI-owned terminal tab (id NOT agent:) lives in the strip too; the
// badge lookup keys on the agent uuid, so it never badges.
act(() => { store.reduce(s => openTabInActivePane(s, { id: 'terminal:manual-1', type: 'terminal', title: 'UI terminal' })) })
const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false }
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) })
// Exactly ONE hourglass: the agent tab. (Counted from textContent — CSS
// module class names are not stable under the test transform.)
expect((container.textContent.match(/⏳/g) ?? []).length).toBe(1)
// The wait resolved → the pill disappears entirely.
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
expect(container.textContent).not.toContain('⏳')
})
})
```
> 实现注意:`sessionSeq` 声明放 `mountSidebar` 之前(与 bottom-auto-terminal.spec.tsx 相同的模块级写法);`createSidebarStore` 返回类型直接用 `ReturnType<typeof createSidebarStore>`,勿手写接口。
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/agent-wait-badge.spec.tsx`
Expected: FAIL(⏳ 从不出现)。
- [ ] **Step 3: 实现 Sidebar.tsx 的 `tabBadgeOf` 特例**
`src/client/Sidebar.tsx` 828 行 `const tabBadgeOf = (tab: SidebarTab): ReactNode => {` 函数体开头(descriptor 查找之前)插入:
```ts
// Agent-terminal wait indicator (sidebar-internal, deliberately NOT a
// TabDescriptor.badge — that API is type-keyed and shared with external
// plugins, and cannot address one tab): the agent-terminals push mirrors
// the model's live terminal_wait_for into state.agentWaits; an agent tab
// whose uuid is waiting shows the hourglass pill.
if (isAgentTabId(tab.id)) {
const wait = state.agentWaits?.[agentUuidOf(tab.id)]
if (wait !== undefined) return <span className={css.tabBadge}>{'⏳'}</span>
}
```
(`isAgentTabId` / `agentUuidOf` 已在 38-39 行 import;`css` / `state` 均在作用域内。)
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/agent-wait-badge.spec.tsx`
Expected: PASS(2 个用例)。
- [ ] **Step 5: Commit**
```bash
git add src/client/Sidebar.tsx tests/agent-wait-badge.spec.tsx
git commit -m "feat(sidebar): agent 终端 tab 标题在等待时显示 ⏳ 徽章"
```
---
### Task 6: 设计文档偏差记录 + 全量验证 + 推送 + PR
**Files:**
- Modify: `docs/plans/2026-09-09-terminal-wait-banner-design.md`(实施偏差记录节)
- 无代码改动。
- [ ] **Step 1: 更新设计文档**
在 `docs/plans/2026-09-09-terminal-wait-banner-design.md` 末尾追加:
```markdown
## 实施偏差记录
- **⏳ tab 徽章不走 `TabDescriptor.badge`**:实现时核实该 API 是 type-keyed
(`badge(ctx, scope, state)`,无 tab 参数,external 插件共享),无法定位单个
tab。改为 Sidebar 壳层 `tabBadgeOf` 的 sidebar-internal 特例(agent: tab 前缀
判断 + `state.agentWaits` 查表),`service.ts` 与 external-plugin-guide.md
零改动。
- **banner 抽为独立组件 `TerminalWaitBanner.tsx`**:设计原文写在
TerminalView.tsx 内;抽出后无 xterm 依赖,可直接被 jsdom 单测渲染
(TerminalView 仍负责订阅 store 并接线 onSkip → api.agentSkipWait)。
- **设计文档随 feat 分支进 PR**:main 受分支保护,纯文档直推被 hook 拒绝
(`4a6a77a` 随本分支合并)。
- **banner 配色**:仓库 CSS 无 state-info 令牌,沿用 `.terminalBanner` 的
warn 对(`--dsw-alias-state-warn-label/-tertiary`),符合皮肤契约。
```
- [ ] **Step 2: 全量验证**
Run(依次,全绿才继续):
`pnpm lint` → `pnpm typecheck` → `pnpm test` → `pnpm build`
Expected:
- lint: 0 error(历史上 CI 因 no-useless-assignment 挂过——新代码避免「先声明后必然覆盖」的写法);
- test: 除已知的 win32 非回归失败集(bottom-auto-terminal / free-window / sidebar-auto-activation / sidebar-crash 等既有失败,对照 main 基线)外全绿,新增 spec 全部 PASS;
- build: lib/ 产物含新 chunk `client.js` 变化(TerminalWaitBanner 打进 client bundle)。
- [ ] **Step 3: 提交并推送**
```bash
git add docs/plans/2026-09-09-terminal-wait-banner-design.md
git commit -m "docs(plans): 终端等待 banner 实施偏差记录"
git push -u origin feat/wait-banner
```
- [ ] **Step 4: 建 PR**
```bash
gh pr create --repo omdsh-dev/DSH-better-sidebar --base main --head feat/wait-banner \
--title "feat(terminal): 等待 banner + 跳过等待(terminal_wait_for skipped)" \
--body "设计:docs/plans/2026-09-09-terminal-wait-banner-design.md(随本 PR)。agent 阻塞在 terminal_wait_for 时:对应终端卡片顶部显示「Agent 正在等待 {needle}」banner,右侧跳过按钮走 agent-pty.skip-wait;tab 标题 ⏳ 徽章;工具新增 {kind:'skipped'} 返回。"
```
- [ ] **Step 5: 等待 CI(用 terminal_wait_for 盯 `(ci|ci-windows|plugin-mount).*\((pass|fail)\)` 之类的多结局 needle,勿 sleep)**
Expected: 三个 job 全 pass。
@@ -0,0 +1,60 @@
# 自动激活落点修复:任务页回到 DSH 原生右侧栏
日期:2026-09-10 分支:`fix/auto-activation-native-landing`
## 背景
用户报告:**自动打开的侧边栏是底栏,而不是右侧边栏**。两个自动激活开关(`autoOpenSubagent` / `autoOpenJobs`)与子代理拓扑跳回在 v0.19.0 上把任务页(`subagent` tab)开进了插件自绘的**底部工作台**,底栏随之展开;而产品文案描述的是侧边栏落点——README 特性表「新子代理 / 新任务可自动激活任务页,**宽屏同时展开侧边栏**,窄屏不强制展开全屏抽屉」,`settingsSubagentDesc` / `settingsJobsDesc` 及全部 20 份词典同样措辞,README 的 #314 条目也写着「宽屏展开侧边栏,窄屏只准备 Tab」。
## 根因
`223b254`(v0.19.0-alpha.0「退役插件自绘右侧面板与自由窗口」)改写了 `src/client/sidebar/use-host-feeds.ts` 的三处触发:
| | 改动前(v0.18.x) | 改动后(v0.19.0) |
|---|---|---|
| 展开 | `store.reduce(s => s.panelOpen ? s : togglePanel(s))`(宽屏才展开) | 无(由落点决定) |
| 落点 | `activePane = firstLeaf(s.splits).id` + `openTab({ type:'subagent', title })` —— **无 `target`** | `openTab({ type:'subagent', title, target:'bottom' })` |
`service.ts:683` 的判据是「装了原生面 + 非 `bottom` → 落 DSH 原生右侧栏」,`target: 'bottom'` 直接绕过它走 `land = openTabInBottomPane`(`service.ts:744` → `state.ts:453`,置 `bottomOpen: true`)。也就是说:退役右侧面板时,`splits`(插件自己的右面板)消失,落点被改成了插件自己的**底**面板,而正解是当时同文件其它打开早已默认落上的新宿主——DSH 原生右侧栏。
## 决策
| # | 决策 | 理由 |
|---|---|---|
| 1 | 三处触发改回默认落点(`'right'`) | 宿主 `openContent` 内部 `planSetExpanded(state, true)`(`dsh-client-ui-sidebar-right`),打开即在同一步展开原生栏;这才是文案里的「侧边栏」 |
| 2 | 后台活动(子代理 / 新任务)在窄屏**停放**,不展开 | 宿主 `RightbarSeat` 有 `autoFullscreen = viewportWidth < 768`,该宽度下原生栏以 `position: fixed; inset: 0` 全屏绘制——展开即整屏抢占,正是 #373 修掉的行为。宿主没有「只放 tab、不展开」的公开选项,故开前读 `ctx.sidebarRight.isExpanded()`、开后 `toggleExpanded()` 收回;两次提交落在同一 React 批内,中间态不会渲染 |
| 3 | 停放仅在目标会话在屏时执行 | `isExpanded` / `toggleExpanded` 作用于**已挂载会话**(`mounted()` = `binding.surfaces[binding.sessionId]`);目标不在屏时执行会误动用户当前那一栏 |
| 4 | 拓扑跳回(用户点击)一律展开 | 它是显式手势,不是后台活动;对齐 v0.18.x 跳回无窄屏门禁的语义 |
| 5 | 底部工作台自有流程保持 `target: 'bottom'` | 底栏 `+` 菜单(`Sidebar.tsx:628`)与首次展开自动终端(`:336`)本就该落底栏;`OpenTabSeed.target` API 不变 |
阈值一致性:插件既有 `NARROW_MAX_WIDTH = 768`(`width < 768`,`src/client/breakpoints.ts`)与宿主 `viewportWidth < 768` 的全屏规则逐字对齐,不引入第二个断点。
窄屏语义与 v0.18.x 等价:**tab 已就位、抽屉不展开**(用户点开会话头右侧的展开控件即可看到任务页)。
## 改动清单(子系统级)
| 子系统 | 文件 | 变更 |
|---|---|---|
| 触发 | `src/client/sidebar/use-host-feeds.ts` | 新增模块内 `activateTasksPage(ctx, sessionId, { background })`(落点 + 停放判定集中在此);三处调用改为它(子代理 / 后台任务 `background: true`,跳回 `false`);重新引入 `isNarrowWidth`;三段 docblock 与文件头同步 |
| 测试 | `tests/sidebar-auto-activation.spec.tsx` | 断言原生面收到 `openTab { kind:'subagent', revealIfOpened:true }`、底部工作台保持原状(未展开、未新增 tab)、窄屏停放次数(1 / 0)、宽屏与已展开栏不停放、去抖触发时读视口、跳回不停放 |
无 API / 持久化 / i18n / README 行为文案改动:文案本就在描述修复后的行为。
## 验证
- **回归守护(确定性)**:新 spec 10 passed;把 `src/client/sidebar/use-host-feeds.ts` 回退到 `HEAD` 后同 10 条**全红**(10 failed),证明它测的正是这次修复。
- **门槛**:`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 干净;`pnpm test` = **124 files / 1299 passed / 9 skipped**(较基线 +3 条,即本 spec 的净增)。
- **打包挂载**:`pnpm build && pnpm pack && pnpm test:mount` = **7 passed**(真实 DSH 0.1.5-rc.1,scratch profile,不触碰用户 `~/.dsh`)。
- **环境事实(踩过一次)**:本机 PATH 上的 `dsh` 是 **0.1.2-rc.1**,用它跑挂载车道会在 `dsh plugin add` 后报 `dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册`(该版本不会把 `file:` tarball 注册进 profile bundles,且插件 peer 要求 `^0.1.5-rc.1`)。CI 装的是钉版 0.1.5-rc.1;本地需用同版本的 shim / `DSH_CMD` 才能复现车道结果。
- **真机**:见 PR 描述(3080 实例,web profile 换装新 tarball 后人工触发子代理 / 后台任务确认)。
## 未覆盖(诚实记录)
- 挂载车道是 keyless 的(无模型),无法真派生子代理或后台任务,因此**该行为只有单测是确定性证据**;宿主 `session/create` 的请求体不接受 `origin` / `parentId`,也无法用 RPC 造出 `origin: 'subagent'` 的子会话,所以真机断言只能依赖人工触发或非确定性的 prompt。
- 宿主若在窄屏自行折叠(`shown && !fullscreen && !canShow` 的 room rule),停放不会与之冲突(两者都指向「不展开」)。
## 不做
- 不新增设置项、不改变 `OpenTabSeed.target` 的语义、不把任务页改到别处承载。
- 不动底栏 `+` 菜单 / 首次展开自动终端(它们是底部工作台自己的流程)。
- 不改 DSH 源码(仓库硬约束 §1)。
@@ -0,0 +1,86 @@
# DSH 0.1.5-rc.1 适配(v0.19.0 正式版基线)
日期:2026-09-10 分支:`feat/dsh-0.1.5-rc.1`
## 背景
DSH `dsh-v0.1.5-alpha.2..dsh-v0.1.5-rc.1`(上游 commit `183f08e9c`,npm 发布 2026-09-10T03:12Z)的 delta 很小:373 个变更文件,绝大多数是各包 `package.json` 版本号、原生右侧栏预览 UI 打磨与测试快照。触及本插件的只有一处契约回归——`SidebarRightGuideEntry.description` 回来了。本计划记录该 delta、插件对它的取舍、以及 v0.19.0 正式版的基线决策。上一版(alpha.2)的适配记录见 [2026-09-09-dsh-0.1.5-alpha.2-adaptation.md](./2026-09-09-dsh-0.1.5-alpha.2-adaptation.md);插件对外的全部行为差异以 [docs/external-plugin-guide.md](../external-plugin-guide.md) 为准;仓库级适配要点(基线、CI 钉版、宿主契约)同步到 [AGENTS.md](../AGENTS.md) §2/§3。
## 上游 delta(alpha.2 → rc.1,影响本插件的三项)
| # | 上游变更 | 对插件的影响 |
|---|---|---|
| 1 | `SidebarRightGuideEntry.description?: () => string` 回归(可选):原生指南**只在列出条目 ≤ 4 条时**渲染它(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢弃说明;没有 `icon` 的条目改由宿主补方块占位 | 恢复 `TabDescriptor.description`(六个内置类型声明它,`guideDesc*` 词条一并回来);**不恢复**旧的通用兜底句 `nativeGuideDesc`——宿主自己没有兜底,通用句在所有页面上长得一样、纯噪音;未声明该字段的 descriptor 就不发 `description` 字段 |
| 2 | 内置 `files` 类型重新声明指南说明,且改用彩色的 `FileTypeIcon kind="folder"` 作指南字形 | 仅背景信息:`files` 页面 kind 由插件接管,该行现在随插件渲染(`editor` descriptor 的图标与说明) |
| 3 | `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` **仍不声明 `dependencies`**,而其 bundle 继续裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex` | 上一版提升进 devDependencies 的那组第三方包**不得回退**(回退即 vitest 一加载 primitives 就 `Cannot find package 'anser'`);`ui-primitives` 的导出面未变,只有 CodeBlock 渲染变化 |
**明确未变**(不必再核):
- 全局主面板模型——根级 keyed `main` 槽、`sidebar.panellist`、`ctx.layout.selectPanel` / `beginNavigation`、根级 `rightbar` + `rightbar.session`;插件仍不接入。
- 文件地址语法——`packages/util/workspace-path` 只动了 `package.json` 的版本号。
- 原生 tab 体宿主契约——`dsh-client-ui-dockkit` 的 `.paneBody` 仍是「有确定高度的块级滚动容器」,只加了两行 z-index。
- core / agent / session / subagent 宿主 API——仅版本号变化。
## 决策
- **插件版本 0.19.0(无 prerelease 后缀)**:`release.yml` 按「版本号是否含 `-`」自动选 dist-tag,因此本版发 npm **`latest`**;npm 的 `alpha` 标签保持指向 `0.19.0-alpha.1`(上游 rc.1 自己的 GitHub Release 仍标 pre-release,与本插件的通道无关)。
- **基线 = DSH 0.1.5-rc.1+ only**:peer 下限 `^0.1.5-rc.1`、manifest `engines.dsh >=0.1.5-rc.1`、CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`。DSH **0.1.5-alpha.2 用户留在 v0.19.0-alpha.1**,DSH **0.1.2-rc.1 用户留在 v0.18.1**。
- **恢复 `description`,不恢复通用兜底句**:宿主渲染第二行时读 descriptor 自己的说明;`nativeGuideDesc` 兜底句不再存在——没写说明的条目就是单行「图标 + 标题」。
- **接受 ≤4 渲染规则,不做额外工程**:插件默认贡献 6 个 guide 条目 > 4,说明在默认组合下不渲染(见下节)。不为此裁剪内置类型、不改条目数、不把关键信息只放进说明里。
- **升级本地 3080 实例**到 DSH 0.1.5-rc.1 + 插件 0.19.0,作为真机观察点。
## ≤4 渲染规则的后果(重要)
上游只在 guide 列出的条目 **≤ 4 条**时渲染 `description`(`MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明——注意是「整列丢弃」,不是截断到前四条。插件默认贡献 6 条 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),**因此在默认组合下,插件与内置类型的说明都不会出现**,六条仍是「图标 + 标题」。
说明只在读者于插件设置页关掉足够多的 tab 类型、把 guide 压到 ≤ 4 条时才出现(逐类型开关;关掉 `editor` 会同时移除接管出去的「文件」行)。这是上游行为而非插件缺陷:插件恢复字段是为了让「关到 ≤ 4 条」的读者拿到真实说明,而不是为了在默认组合里显示什么。指南 §0 与 §4.1 都写明了这条规则,消费插件不应把关键信息只放在 `description` 里。
## 改动清单(子系统级)
| 子系统 | 文件 | 变更 |
|---|---|---|
| 基线 | `package.json` | 版本 → `0.19.0`(无后缀);dsh-* peer 全部 `^0.1.5-rc.1`;devDeps 同步钉 `0.1.5-rc.1`;`dsh-client-locale` 跟随基线到 `0.1.5-rc.1`(该包已回到与 DSH 同 tuple 发版,§3-9 的落后例外保持消除) |
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 rc.1 |
| 基线 | `dsh.plugin.json` | `version` → `0.19.0`;`engines.dsh` → `>=0.1.5-rc.1` |
| 基线 | `.github/workflows/ci.yml` | mount lane 钉 `@deepseek-ai/dsh@0.1.5-rc.1`(rc.1 在 npm 上同时是 `latest` 与 `next`) |
| 基线 | `package.json` | 上一版提升的第三方 devDependencies(anser / shiki / `@shikijs/langs` / `mdast-util-*` / `micromark-*` / katex)保留不回退——`ui-primitives@0.1.5-rc.1` 仍无 `dependencies` |
| Tab 契约 | `src/client/service.ts` | `TabDescriptor` 恢复可选 `description`;`SIDEBAR_SERVICE_VERSION` 跟随插件版本(`0.19.0`) |
| Tab 契约 | `src/client/builtins/tabs.tsx` | 六个内置类型(editor / git / subagent / sidechat / terminal / browser)重新声明 `description` |
| Tab 契约 | `src/client/native/index.ts` | guide 条目在 descriptor 声明了说明时带上 `description`;未声明就不发该字段(不补通用句) |
| 词典 | `src/client/locales.ts` + 19 份 `locales-*.ts` | 恢复 6 个 `guideDesc*` 词条(zh / en / ja 与其余第三方语言) |
| 测试 | `tests/builtins.spec.ts` | 指南断言从「仅 `title`」改回「`title` + `description`」 |
| 测试 | `tests/native-surface.spec.ts` | 断言 guide 条目携带 `description`;`editor` 类型仍不单独占行、接管行承载其图标与说明 |
| 文档 | `docs/external-plugin-guide.md` | 顶部基线行(v0.19.0 / DSH 0.1.5-rc.1+ / 两个钉版旧版本)、§0 「新建标签页列表」行、§4.1 恢复 `description` 字段与 ≤4 规则 |
| 文档 | `docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md` | 加一行前向链接(该版删掉的 `description` 在 rc.1 回归) |
| 文档 | `docs/plans/2026-09-10-dsh-0.1.5-rc.1-adaptation.md` | 本计划 |
| 文档 | `AGENTS.md` | §2 CI 基线戳、§3 标题与基线 prose、§3 第 9/10 条(devDeps 不回退 / `description` 回归) |
| 文档 | `README.md` / `README_EN.md` | 顶部与安装节的「支持的 DSH 版本」徽章、安装节正式版说明、新增 `### v0.19.0` 更新段落 |
## 验证
- **本工作流(文档)未执行任何门禁或真机验证**,只做了与工作树现状的交叉核对:`package.json` 为 `0.19.0`(dsh-* peer 与 devDep 均 `0.1.5-rc.1`)、`dsh.plugin.json` 的 `engines.dsh` 为 `>=0.1.5-rc.1`、`.github/workflows/ci.yml` 钉 `@deepseek-ai/dsh@0.1.5-rc.1`、`SIDEBAR_SERVICE_VERSION` 为 `0.19.0`、`src/client/builtins/tabs.tsx` 六个类型与 `src/client/native/index.ts` 的 guide 条目(含 `files` 接管行)重新带上 `description`、`src/client/locales*.ts` 恢复 6 个 `guideDesc*` 键。
- 仓库**没有守护指南文档章节的测试**——`tests/builtins.spec.ts` / `tests/native-surface.spec.ts` 守护的是 guide 行的形态,不是文档文本,因此文档改动没有对应的测试命令可跑。
- **待补(由主代理在实施完成后回填,本文件不预填未经执行的结论)**:门禁数字(`pnpm typecheck` / `pnpm lint` / `pnpm vitest run --exclude '**/*.e2e.ts'` / `pnpm peers check`)、`scripts/e2e-mount.sh` 真机挂载车道的通过数,以及本地 3080 实例(DSH 0.1.5-rc.1 + 插件 0.19.0)上的实测结果——默认组合下 6 条 guide 行仍是「图标 + 标题」、关掉 tab 类型至 ≤ 4 条后说明出现、以及 `pageerror` / 控制台状况。
### 回填(主代理实测)
- **门禁**:`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm vitest run --exclude '**/*.e2e.ts'` = **124 files / 1296 passed / 9 skipped**;`pnpm peers check` = No peer dependency issues found(rc.1 未新增失配的传递 peer,因此没有再提升任何 devDep)。
- **挂载冒烟**(`DSH_CMD` 指向 npm 安装的 0.1.5-rc.1,`scripts/e2e-mount.sh`):**7 passed**,含 tab 体填充断言与新增的 ≤4 条描述断言。
- **≤4 断言的真实形态**:默认 6 条下永远拿不到说明文本,所以该用例先用插件自己的 `settings.get` / `settings.update` 把启用集合压到 3 个类型(files / sidechat / browser),断言 `files` 胶囊文本含英文说明片段 `workspace tree`,并断言被禁用的三个条目离开 guide。
- **踩坑(已修)**:恢复设置时最初只把原始 `tabsEnabled`(scratch profile 里是 `{}`)原样发回——而该路由的 patch 是**逐键合并**,空对象清不掉刚写下的三个 `false`,于是同车道的 `perf.e2e.ts` 扫 guide 时找不到 `git` 条目而失败。改为对本次动过的每个键显式恢复(原值缺省即 `true`)后全绿;这条经验对任何"临时改设置再恢复"的 e2e 都适用。
- **真机 3080**(DSH 0.1.5-rc.1 + 插件 0.19.0):guide 六行胶囊均为「图标 + 标题」、**无说明**(6 > 4,符合上游规则);胶囊高度 56px(上游把 `min-height` 从 alpha.2 的 48px 调到 56px);文件树点击 `AGENTS.md` → tab 标题 `AGENTS.md` + CodeMirror 挂载;sidechat 宿主盒 962px == `.paneBody` 962px(fillGap 0),composer 底边距 8px;底部工作台与 `[data-dsh-center-col]` 左右边完全重合(gap 0);`pageerror` 0;控制台仅第三方 `dsh-tauri-worktree` 的 `/api/dsh-worktree/attach` 500(alpha.2 期同样存在,与本次无关)。rc.1 真机的 `[data-slot]` 清单与 alpha.2 完全一致(`main` / `main.conversation` / `rightbar` / `rightbar.session`),印证"全局面板模型未变"。
## 不做
- 不为 ≤4 渲染规则裁剪内置 tab 类型、调整条目数或改写图标。
- 不恢复 `nativeGuideDesc` 通用兜底句。
- 不接入 alpha.2 的全局面板(`main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`)——沿用上一版结论,rc.1 上这些面同样只是兼容保留。
- 不回退提升进 devDependencies 的第三方包。
- 不改 DSH 源码(仓库硬约束 §1)。
## 落地
- [#613](https://github.com/omdsh-dev/DSH-better-sidebar/pull/613) 合入 `main`(merge commit `754974a`);main 的 `package.json` 为 `0.19.0`、`engines.dsh` 为 `>=0.1.5-rc.1`。PR 的 `ci` / `ci-windows` / `plugin-mount` 三个 check 全绿。
- 发布 [v0.19.0](https://github.com/omdsh-dev/DSH-better-sidebar/releases/tag/v0.19.0):`release.yml` 通过全门禁后经 npm Trusted Publishing 发布到 **`latest`**(带 provenance);实测 dist-tags:`latest = 0.19.0`、`alpha = 0.19.0-alpha.1`(不变)、`beta` 不变。
- 产物核对:`lib/index.js` 与本地已验证构建**逐字节一致**,且与 `v0.19.0-alpha.1` 的同名文件同哈希(宿主侧代码在这两版之间确实没动);`lib/client.js` 仅差已知的打包非确定性;发布包的 client bundle 内含恢复后的说明文案。
- **npm 传播延迟仍是 ~4–5 分钟**:06:49:10 工作流报 `Published package dsh-better-sidebar@0.19.0`,06:53:53 dist-tags 才可见 `latest = 0.19.0`(版本元数据与 tarball 依次到达,期间 `npm view` / tarball 会 404)。这不是发布失败,别重发。
@@ -0,0 +1,104 @@
# DSH 0.1.5-rc.2 适配(v0.19.1 基线)+ CI 三类失败修复
日期:2026-09-10(复核 2026-09-11) 分支:`feat/dsh-0.1.5-rc.2`
## 背景
DSH `dsh-v0.1.5-rc.1..dsh-v0.1.5-rc.2`(上游 4 个 commit / 300 个变更文件,npm 发布 2026-09-10T14:57Z,dist-tag **`next`**;`latest` 仍是 rc.1)是 0.1.5 线的第二个候选版。本计划记录该 delta、插件对它的结论(**无需任何运行时/类型层适配**,只推进钉版),以及同一时间窗内 `ci-windows` / `plugin-mount` 两个车道真实存在的三类 CI 失败与修法。上一版(rc.1)记录见 [2026-09-10-dsh-0.1.5-rc.1-adaptation.md](./2026-09-10-dsh-0.1.5-rc.1-adaptation.md);仓库级适配要点同步到 [AGENTS.md](../../AGENTS.md) §2/§3。
## 上游 delta(rc.1 → rc.2,影响本插件的部分:无)
300 个文件里绝大多数是各包 `package.json` 的 `version` 单行改动(`1+1-`)。逐文件过完后的真实代码改动只有四处:
| # | 上游变更 | 文件 | 对插件的影响 |
|---|---|---|---|
| 1 | 消息反馈改为对称提交(弹窗、动作按钮、控制器、槽位、i18n) | `packages/client/ui-message-feedback/src/client/*` | 无——插件不注册反馈槽、不读该控制器 |
| 2 | 产物卡片内边距与呈现微调 | `packages/client/ui-deliverables/src/client/*`、`ui-chat/src/client/chat/TurnTailNodeView.module.css` | 无——插件不消费这两个包的组件,也不做会话尾迹布局 |
| 3 | `CodeFileIcon` 的 477 行内联 SVG 拆进新增的 `code-file-icon-artwork.ts`(另有 `.manifest.json` 记录设计导出摘要) | `packages/client/ui-primitives/src/CodeFileIcon.tsx` | 无——`CodeFileIcon` 的导出面与 props 未变,且插件**本来就不用它**(插件文件树用 `IconCodeOutline16`;`#611` 的文件图标主题还在 PR #594,未进 main) |
| 4 | 其余全部为版本号 | 各 `package.json` | 无 |
**明确未变(不必再核)**:
- `packages/api/*`(session-controller / workspace-controller / gateway / remotes)、`packages/host/*`、`packages/session/*`、`packages/agent/*`——**零**变更;除 `packages/feedback/message-feedback/src/types.ts`(1 行)外**零**类型文件变更。
- 斜杠 RPC 方言、一次性 token 鉴权、会话事件模型、`sidebarRight` / `sidebarRightTabs` 承载面、`SidebarRightGuideEntry.description` 的 ≤4 渲染规则:全部沿用 rc.1 结论。
- `@deepseek-ai/dsh-client-ui-primitives` 仍不声明 `dependencies`(bundle 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`)——提升进 devDependencies 的那组包继续不得回退。
## 决策
- **peer 下限保持 `^0.1.5-rc.1` 不动**:rc.2 无契约变化,`^0.1.5-rc.1` 天然容纳它,rc.1 用户无需升级即可用新版插件。因此本版是**纯 patch 的 0.19.1**,只推进 devDependency 钉版、CI 挂载钉版与 `SIDEBAR_SERVICE_VERSION`。
- **`engines.dsh` 保持 `>=0.1.5-rc.1`**:与 peer 下限同源(同一个支持范围,两处不能分叉)。
- **CI 挂载车道钉 rc.2**:真机证据要落在最新版上;`latest` 仍是 rc.1 只是上游的发布节奏,与本插件支持范围无关。
- **不为此版写运行时兼容层**:上游 delta 里没有本插件触达的面,写兼容层就是无据的复杂度。
## CI 三类失败(2026-09-09/10 窗口,60 次已完成 run 的取证)
窗口内 `ci-windows|Test` 失败 8 次、`plugin-mount|Install dsh CLI (pinned)` 失败 4 次。逐条读日志后归为三类,其中两类是**真实缺陷**、一类是**运维噪音**:
### A. Windows 真实超时(6/8 次 `ci-windows` 失败,主因)
- 证据:run `34494370187`、`34490215638`、`34459499945`(**3 次 attempt 全红**)、`34372999975`、`34322271074`、`34319666611` 均为 `Error: Test timed out in 5000ms`,位置 `tests/agent-pty.spec.ts:71:3` / `:106:3`;`tests/install-powershell.spec.ts:105:41` 的 `powershell.exe` 单测自身耗时 **12151ms** 后才超时。
- 根因:`vitest.config.ts` 没有任何 `testTimeout` 配置(默认 5000ms),而这些 spec 每个用例都要真起一个 `PowerShell + ConPTY` 对;`tests/agent-pty.spec.ts` 的 `waitForTranscript` 默认预算**也**是 5000ms——内层轮询吃掉整个对外预算,结构上必然超时。
- 修法:**全局** `testTimeout: 15_000`(`vitest.config.ts`)+ 两个最重的 describe 显式 `{ timeout: 30_000 }`(vitest 4 的 describe 级选项,已实测生效)+ `waitForTranscript` 默认预算降到 15s。**为什么是全局而不是逐文件**:本 PR 第一次跑真实 Windows 时,同一个 5000ms 默认又在**第三个文件**上翻车——`tests/git.spec.ts:85`「caps status entries at the truncation limit」耗时 **9607ms**(构造病态 untracked 集合 + 真实 git 调用)。三个不同文件、同一个根因,逐文件加超时是打地鼠;15s 是默认值的 3 倍、也是 Windows 上实测最大单测耗时的 ~1.5 倍,而 pty / powershell 两类最重的用例各有自己的 30s 预算。
### B. Windows worker 静默死亡(2 次,非确定性)
- 证据:run `34500290681`(295615abb)、`34448911025`(bbf953b8a) 报 `[vitest-pool]: Worker forks emitted error / Caused by: Error: Worker exited unexpectedly`(`Errors 1 error`),同一份日志里 `0 FAIL`、125 个文件全过;后者静默吞掉一个文件的 10 条结果。同一 commit `8ae527170` 的另一次 run 里 `ci-windows` 是**绿的** → 该步骤可证伪地非确定。
- 归因(**推断,非确证**):真起进程的 spec 有 5 个(`agent-pty` / `pty-deps` / `pty-helpers` / `install-powershell` / `smoke`),node-pty 的 ConPTY 辅助进程与冷启 `powershell.exe` 都绑定控制台,在默认**无界 fork 池**下并行抢占;本地 macOS 无法复现该模式,因此不做无证据的猜测性改动(不加 `retry`,不改默认 pool)。
- 修法(两次迭代,第一次被真实结果证否):先上 `test:windows`(`vitest run --maxWorkers=2`)——**真实 Windows 上仍复现**(run `34574847774`:126/127 文件全过、`0 FAIL`、`Unhandled Error: Worker exited unexpectedly`,丢结果的那个 worker 正是跑 `tests/native-surface.spec.ts` 的)。该次日志里 `native-surface.spec.ts` **零结果**,与历史两次崩溃同形(`34500290681` 125/126、`34448911025` 123/124——都是「N-1 个文件有结果 + teardown 期 worker 静默退出」)。
- 迭代后的修法:`--maxWorkers=1`(整个套件跑在一个 fork 里,**零 worker 回收**)。理由:跨 4 次崩溃的一致形态是「worker 回收/退出竞态」,而不是某个测试失败;单 worker 从根上取消这个竞态,代价只是墙钟时间(本地 14s → 41s,Windows 步骤仍在 30 分钟 job 预算内)。**仍不加 retry**:retry 会把真实回归一起吞掉。
### C. `plugin-mount` 的 npm 安装失败(4 次:2× ETARGET + 2× OOM)
- 证据:run `34492160123` / `34491256626` 报 `npm error notarget No matching version found for @deepseek-ai/dsh-tool-bash-persistent@^0.1.5-rc.2` / `@deepseek-ai/dsh-sandbox-windows-acl@^0.1.5-rc.2`;run `34490912649` 先刷 **3062 条 ERESOLVE**、9 分钟后 `FATAL ERROR: Ineffective mark-compacts near heap limit` exit 134。同一机制在 2026-09-09 的 alpha 线也复现过一次(run `34363627184`,1808 条 ERESOLVE → OOM);同期成功的安装是 `added 521 packages in 19s`、**0 条 ERESOLVE**。
- 根因:**钉版并没有真的钉住**。`@deepseek-ai/dsh@0.1.5-rc.1` 的传递依赖声明为浮动 `^0.1.5-rc.1`,会被上游**分阶段发布**中的 rc.2 满足(rc.2 的 14:43–14:57 逐个包上线),于是 npm 组出 rc.1/rc.2 混合 peer 图 → 解析器爆炸(OOM),或对尚未发布的兄弟包硬报 ETARGET(两个 ETARGET 分别在该包上线前 5.5 分钟与 25 秒)。
- 修法(`.github/workflows/ci.yml`):钉一个**已完整发布**的 rc.2(修 ETARGET),并加 `NODE_OPTIONS=--max-old-space-size=4096`(修 OOM)。
- **一次被真实结果否掉的中间方案(值得记)**:第一版修复还加了 `--legacy-peer-deps`(想法是「跳过 peer 协商即与发布窗口解耦」)。真实 Windows 结果表明它只是把安装**弄坏**而不是弄稳:它跳过的正是全局安装必须提供的 peer,而 `@deepseek-ai/dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` **是 peer 而不是 dependency**,于是 CLI 在冒烟开始前就以 `ERR_MODULE_NOT_FOUND` 死掉(run 34560785101 的 `plugin-mount`)。本地复现并验证:同一命令去掉该 flag 后安装树完整(`dsh/node_modules/@deepseek-ai/cordis-plugin-group` 存在),且 `DSH_CMD` 指向这个 classic 安装的 `test:mount` **7 passed**——宿主自带依赖,插件侧看不到差别。
- 顺带修掉一处过时注释(原文断言「rc.1 同时是 `latest` 与 `next`」——现在 `latest=rc.1`、`next=rc.2`);这条注释正是浮动范围隐患长期不可见的来源。
## 改动清单(子系统级)
| 子系统 | 文件 | 变更 |
|---|---|---|
| 基线 | `package.json` | `version` → `0.19.1`;dsh-* **devDependencies** 全部钉 `0.1.5-rc.2`(含 `dsh-client-locale`,上游仍与 DSH 同 tuple 发版);**peerDependencies 一律保持 `^0.1.5-rc.1`** |
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 rc.2 |
| 基线 | `dsh.plugin.json` | `version` → `0.19.1`;`engines.dsh` 保持 `>=0.1.5-rc.1`(与 peer 下限同源) |
| 基线 | `src/client/service.ts` | `SIDEBAR_SERVICE_VERSION` → `0.19.1`(`tests/service.spec.ts` 断言它 === `package.json` 的 version) |
| CI | `.github/workflows/ci.yml` | mount 车道钉 `@deepseek-ai/dsh@0.1.5-rc.2` + `NODE_OPTIONS=--max-old-space-size=4096`(C 类;**不用** `--legacy-peer-deps`,理由见 C 节);`ci-windows` 的 `Test` 改跑 `pnpm test:windows`(B 类);注释同步事实 |
| CI | `package.json` | 新增 `scripts.test:windows`(`vitest run --maxWorkers=2`) |
| CI | `vitest.config.ts` | 全局 `testTimeout: 15_000`(A 类;覆盖 git / powershell / pty 三类真起进程的 spec,逐文件打地鼠的做法在真实 Windows 上被证否) |
| CI | `tests/agent-pty.spec.ts` | `AgentPtyRegistry` describe 显式 `{ timeout: 30_000 }`;`waitForTranscript` 默认预算 5000 → 15_000(A 类) |
| CI | `tests/install-powershell.spec.ts` | `PowerShell installer entry points` describe 显式 `{ timeout: 30_000 }`(A 类) |
| 文档 | `docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md` | 本计划 |
| 图标 | `src/client/file-icons.tsx` / `src/client/builtins/tab-icons.*` / `src/client/native/tab-adapter.tsx` | 文件图标改用宿主官方 `FileTypeIcon`;内置 tab 与原生芯片图标彩色——独立记录见 [2026-09-11-file-icons-and-tab-glyphs.md](./2026-09-11-file-icons-and-tab-glyphs.md) |
| 文档 | `AGENTS.md` | §2 CI 基线戳与 mount 车道说明、§3 标题/基线段(rc.2 仍无契约变化,只推进钉版) |
| 文档 | `README.md` / `README_EN.md` | 支持版本徽章与说明、新增 `### v0.19.1` 段 |
| 文档 | `docs/external-plugin-guide.md` | 顶部基线行 → v0.19.1 / DSH 0.1.5-rc.1+(已在 rc.2 上验证) |
| 文档 | `tests/native-surface.spec.ts`、`tests/builtins.spec.ts`、`tests/e2e/mount.e2e.ts`、`src/client/service.ts`、`src/client/native/index.ts` | 注释里的 `DSH 0.1.5-rc.1` → `0.1.5-rc.1+`(同一行为在 rc.2 未变) |
## 验证
- **未执行**的门禁与真机步骤都留空,由主代理实施后回填(本文件不预填未经执行的结论)。
### 回填(主代理实测)
- **门禁(rc.2 钉版,macOS arm64)**:`pnpm install` 成功且锁文件对齐 rc.2;`pnpm peers check` = `No peer dependency issues found`;`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm test` = **126 files / 1312 passed / 9 skipped**(与 rc.1 基线同数,无新增 skip)。
- **真机挂载冒烟(rc.2)**:`DSH_CMD` 指向 `npx -y --package @deepseek-ai/dsh@0.1.5-rc.2 dsh`;见"落地"节的实测计数。
- **真实 Windows 收敛**:`ci-windows` **连续两次全绿**(同一 commit `5451f5c`)——`pull_request` 自动触发的 run [34561189320](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34561189320) 与随后 `workflow_dispatch` 的 run [34561457067](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34561457067),两次都是 **126 files / 1318 passed / 3 skipped / 0 errors**(`Unhandled Errors` 与 `Worker exited unexpectedly` 出现次数均为 **0**;同环境的 2026-09-09/10 窗口里这两行出现过 2 次)。同一分支的 `plugin-mount` 两次都绿:`added 521 packages in 24s`(**0 条 ERESOLVE**,与历史 OOM run 的 3062 条对比)、`test:mount` **7 passed**、聚合双挂载通过。
- **首次真实结果的证伪(已修正)**:本 PR 的第一版修复在同一 commit 上仍红两处,`.github/workflows/ci.yml` 的改动因此被真实证据改过一轮——见 C 节的「一次被真实结果否掉的中间方案」与 A 节第三个文件(`tests/git.spec.ts:85`,9607ms)。
- **B 类的实测结论(已按"升级而非掩盖"走完两轮)**:`--maxWorkers=2` 下仍崩溃(run `34574847774`),故升级为 `--maxWorkers=1`(单 fork、零回收)。这**不等于证明了根因**(4 次崩溃的一致形态是「N-1 文件有结果 + teardown 期静默退出」,只能说明是 worker 回收竞态而非测试失败);收敛判据是单 worker 下连续两次 `ci-windows` 全绿——**已达成**:run [34575721734](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34575721734)(PR 触发)与 run [34576146970](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34576146970)(同 commit `4fe67e3` 的 `workflow_dispatch`)都是 **127 files / 0 errors**,`Unhandled Errors` 与 `Worker exited unexpectedly` 出现 0 次。
- **本地 3080 真机**(DSH 0.1.5-rc.2 + 插件 0.19.1):部署与路由探针已完成(见"落地"节),GUI 人工验证由用户执行。
## 不做
- 不为 rc.2 写任何运行时/类型层兼容层(上游 delta 未触及本插件的面)。
- 不把 peer 下限抬到 `^0.1.5-rc.2`(会无谓地把 rc.1 用户挡在上一版)。
- 不为 B 类做无证据的猜测性改动:不加 vitest `retry`、不改默认 pool、不关掉真实 pty 测试。
- 不改 DSH 源码(仓库硬约束 §1);不改 `release.yml`(0.19.1 无 prerelease 后缀 → 自动发 `latest`,符合 patch 语义)。
## 落地
- PR:[#626](https://github.com/omdsh-dev/DSH-better-sidebar/pull/626)(`feat/dsh-0.1.5-rc.2`)。
- 本地真机挂载冒烟(rc.2,macOS arm64):`pnpm test:mount` **7 passed**(含 tab 体填充断言、指南 ≤4 条说明渲染断言、mermaid / README 预览与 sidechat 路由烟测);`pnpm test:mount:aggregate` 通过(聚合双挂载下插件自动退让,`/sidebar/api` 未知方法 404 与 `terminal.deps` 契约均符合)。
- 本地 3080 实例部署(`~/.dsh/profiles/web` + `~/.dsh/dsh-web.sh`):profile 依赖从 `dsh-better-sidebar-0.19.0.tgz` 改指 0.19.1 tarball 并 `pnpm install`(`node_modules/dsh-better-sidebar/package.json` = **0.19.1**);`dsh-web.sh` 的 npx 钉版改 `@deepseek-ai/dsh@0.1.5-rc.2`,旧进程(PID 99521/99509/99507)定向 kill 后由 LaunchAgent `com.pm2.dshweb.resurrect` 自动拉起(新 PID 36025,npx 缓存 hash 因版本变更从 `1da1392061ab1944` 变为 `c40503fdf38a82ea`)。**已核**:新缓存里的 `@deepseek-ai/dsh` = **0.1.5-rc.2**,`/sidebar/api/shell.get` → `{"ok":true,"value":{"shell":"zsh","name":"zsh"}}`、`settings.get` 回默认设置、`terminal.deps` → `{"ok":true}`(三条插件路由在生产实例上工作);GUI 人工验证由用户执行,结论回填到本节的下一步。
- PR:[#626](https://github.com/omdsh-dev/DSH-better-sidebar/pull/626)(`feat/dsh-0.1.5-rc.2`)。PR 三个 check(`ci` / `ci-windows` / `plugin-mount`)在 run 34561189320 与 run 34561457067 上均绿。
- 待回填:3080 人工验证结果(用户执行)、发版情况(v0.19.1 的 tag / npm 发布由用户决定)。
@@ -0,0 +1,92 @@
# 文件图标与彩色 Tab 图标(v0.19.1,#531 + #594 合并后的重做)
日期:2026-09-11 分支:`feat/dsh-0.1.5-rc.2`(并入 PR [#626](https://github.com/omdsh-dev/DSH-better-sidebar/pull/626))
## 背景与决策
[#611](https://github.com/omdsh-dev/DSH-better-sidebar/issues/611) 希望文件树按类型显示图标(「仿照官方做法」),[#429](https://github.com/omdsh-dev/DSH-better-sidebar/pull/429)(@fenter)为此做了 563 条彩色图标数据,落成两个叠着的 PR:
- [#531](https://github.com/omdsh-dev/DSH-better-sidebar/pull/531)(`feat/file-icons-api`,base=main):文件树/文件 tab 按扩展名分图标 + 对外 `registerFileIcon` API;
- [#594](https://github.com/omdsh-dev/DSH-better-sidebar/pull/594)(`feat/file-icon-theme`,base=#531 分支):在其上叠「内置单色 / 彩色品牌图标」可选主题,彩色数据做成懒加载 chunk(255 kB)+ 设置页开关。
两者在 2026-09-08 都绿,但都基于 v0.18.1、落后 main 55 个 commit,且与 DSH 0.1.5-rc.2 的一个新事实冲突:**rc.2 的 `ui-primitives` 已经导出官方文件类型图形** `FileTypeIcon` / `CodeFileIcon`(48 类全彩代码/配置图形 + 12 类类目色板图形 + `classifyFileType` 分类器)。也就是 #611 想要的东西上游自己做了。
用户决策(2026-09-11):**两个 PR 都合、并进 v0.19.1 一个 PR;文件图标改用宿主官方 `FileTypeIcon`;内置 tab 图标彩色 + 原生右侧栏芯片也加图标;删掉主题开关,彩色即唯一行为。**
## 上游事实(实测,非推断)
| 事实 | 证据 |
|---|---|
| 宿主导出官方图形 | `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.2` 的 `lib/types/index.d.ts` 导出 `FileTypeIcon`、`classifyFileType`、`fileExtension`、`CodeFileIcon`、`isCodeFileType` 与 `CodeFileType` 类型;`FileTypeIcon` 声明 `size` 默认 28 并透传给内部 `FileGlyph`/`CodeFileIcon`(14px 树行可用) |
| 全彩图形保留自己的调色板 | `CodeFileIcon.d.ts` 的文档原话「its identifying palette intact」;`kind:'folder'` 落宿主的 `IconFolderClose16`(单色,随 `currentColor`) |
| 分类器不返回文件夹 | `classifyFileType` 只走 `classifyCodeFileType ?? NAME_TYPES ?? EXTENSION_TYPES ?? 'other'`,`'folder'` 只能由调用方以 `kind` 显式指定 |
| 官方图形在 rc.1 就有 | `npm pack @deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` 的 tarball 内含 `lib/FileTypeIcon.module.css` 与 `lib/types/{FileTypeIcon,CodeFileIcon,code-file-types}.d.ts`(rc.2 只是把 SVG 数据拆进 `code-file-icon-artwork.ts`) |
| 原生 tab 定义没有 icon 字段 | `dsh-client-ui-sidebar-right` 的 `SidebarRightTabDefinition` 只有 `id/kind/patterns/priority/canOpen/title/guide`;`icon` 只在 `SidebarRightGuideEntry` 上 |
| 但 title 槽就是芯片内容 | 宿主 `contract/slots.d.ts`:`'sidebar.right.pane.tab.title'` —— 「A tab's title as its chip (and a floating panel's header) shows it」 |
## 合并(3 处冲突,均为注释/列表并集)
| 文件 | 冲突 | 解法 |
|---|---|---|
| `src/client/service.ts` | ① import 区(main 保留 `extOf`,PR 侧已改成 `baseName, extOf` + 自己的 `isNarrowWidth`)② `SIDEBAR_FEATURES` 注释与列表(main 写「v0.19.0 删除了 floatWindows」,PR 侧在旧清单上加 `'fileIcons'`) | ① 取 `baseName, extOf`:`isNarrowWidth` 属于 PR 自己的「新建会话按视口折叠」逻辑,main 已把那套 seeding 重写掉,合并后该 import 是死代码(实测 grep 确认后删除)。② 并集:保留「已移除 floatWindows」措辞 + 追加 `'fileIcons'` |
| `src/client/Sidebar.tsx` | `tabIconOf` 上方注释(两侧各改一句) | 并集:保留 main 的「The tab icon from the tab-type registry.」并补 PR 侧「带 path 的编辑器 tab 显示文件图标」的说明 |
| `docs/external-plugin-guide.md` | `features` 清单注释 | 并集:main 的新措辞(只增不删,唯一例外 v0.19.0 删除 floatWindows)+ `'fileIcons'` |
#594 的其余冲突(`tests/plugin-shape.spec.ts`、`tests/prefs.spec.ts`)**取本线一侧**:main 已删除这两个文件里的 store-seeding 用例并重写了 schema 断言(`SIDEBAR_PREFS_DEFAULTS` 解构),PR 侧重新加回的块描述的是 main 不再存在的行为——把旧块带回来只会测到死代码。
## 重做(把 #594 的彩色实现换成宿主官方图形)
### 删除
- `src/client/chunks/file-icons.tsx`(563 条数据)、`src/client/file-icon-theme.ts`(chunk loader)、`tests/file-icon-theme.spec.tsx`;
- `fileIconTheme` pref 三处(`prefs-shared.ts` / `src/config.ts` / `src/client/prefs.ts`)、设置页「文件图标」select 行、20 份词典里的 6 个 `settingsFileIconTheme*` key(共 126 行);
- chunk 注册三处(`src/bundle-route.ts` 的 `CHUNK_NAMES`、`src/client/chunk-loader.ts` 的 `ChunkName`、`tsdown.config.ts` 的 `CHUNKS` 与 react-icons alias)与对应测试期望(`bundle-route.spec.ts` / `manifest-consistency.spec.ts`);
- `src/client/index.tsx` 里驱动主题 loader 的 effect。
### 新增/改写
| 位置 | 内容 |
|---|---|
| `src/client/file-icons.tsx` | `builtinFileIcon(path, size)` → `<FileTypeIcon path size />`;`builtinFolderIcon` → `<FileTypeIcon kind="folder" size />`(宿主只有一个文件夹图形,开/合两态不另画);`fallbackFileIcon(size)` → `<FileTypeIcon kind="other" size />` |
| `src/client/service.ts` 的 `fileIcon` 链 | 具体注册 → **catch-all 全局默认 → 宿主图形**(原先 built-in map 排在 catch-all 之前) |
| `src/client/builtins/tab-icons.tsx` + `.module.css` | 六个内置类型 + diff 视图的彩色 glyph;颜色全部 `var(--dsw-alias-*)`(files=brand、changes=success、tasks=warn、sidechat/browser=business、terminal=label-primary,各带 fallback 链) |
| `src/client/native/tab-adapter.tsx` 的 `NativeTabTitle` | 渲染 `[glyph][title]`:编辑器 tab 带 path 时用 `service.fileIcon(path, 14)`,其余用 `descriptor.icon(14)`;glyph `aria-hidden`;`NativeTitleInjected` 增加 `service` 与 `descriptorId`,`registerSlots` 的 title 注入同步 |
| `src/client/sidebar.module.css` | 新增 `.chipIcon`(芯片 glyph 的排版,颜色继承) |
| `tests/theme.spec.ts` | 豁免段整体删除,改为三条守护:图标模块零颜色字面量、`tab-icons.module.css` 里每条 `color` 都解析到 `var(--dsw-`、`src/client/chunks/*` 不含 6 位颜色字面量(图标数据不得再做回 chunk) |
| `tests/file-icons.spec.tsx` | 内置断言从 codicon 身份改为宿主 `FileTypeIcon`;新增「path/size 透传」「文件夹用 `kind:'folder'`」;catch-all 用例改写为「注册了 catch-all 就接管全部未具体命中的行」 |
| `tests/native-surface.spec.ts` | 新增 3 例芯片 glyph:descriptor glyph 在标题前、编辑器 tab 用文件图标而非类型图标、类型注销后只剩标题 |
## 语义后果(必须写进文档)
**宿主分类器覆盖任意路径**,因此「插件自己已经能画这个扩展名」不再是拦住 catch-all 的理由:注册 `exts: []` 的插件现在会接管所有未具体命中的文件行(原先会被内置 map 拦下)。旧版彩色主题那种「整表注册、靠内置 map 兜底」的形态不再成立——只想补几个类型的插件用 `exts`/`names`。已写进 [指南 §7](../external-plugin-guide.md) 与 [AGENTS.md](../../AGENTS.md) §5。
## 验证
**已执行(macOS arm64,DSH 0.1.5-rc.2 钉版)**
- `pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;
- `pnpm test` = **127 files / 1343 passed / 9 skipped**(v0.19.1 基线 126/1312 + 合并与新增的 31 例,失败数 0;返工后 +1 例 glyph 身份断言);
- `pnpm build`:`lib/client.js` **0.85 → 0.86 MB**(+10 kB),**没有** `lib/client-file-icons.js`;核心 bundle 内不含图标数据(只有 6 个被引用的 glyph 组件);
- 真机挂载冒烟(`DSH_CMD` 指向 `@deepseek-ai/dsh@0.1.5-rc.2`):`pnpm test:mount` **7 passed**(含 guide 深扫、tab 体填充断言、mermaid/README 预览、sidechat 路由),`pnpm test:mount:aggregate` 通过。
### 真机反馈的两处返工(用户人工验证后)
| 反馈 | 结论与改动 |
|---|---|
| 「任务管理」图标不对——该页是 subagent + 后台 jobs,不是 todo | `VscTasklist`(清单)→ **`VscLayers`**(多层堆叠 = 后台在跑的工作)。`tests/builtins.spec.ts` 用 glyph 身份把这条读法钉住 |
| 终端图标「非常怪」 | 根因是**观感权重**:`VscTerminal` 是整套里最宽的实心矩形,在 14px 下比邻座重。**只缩小一档**(`0.85 × size`,下限 10px)——形状与绘制方式保持图标集原样 |
| 追加反馈:上一版给全部 tab glyph 加的 `currentColor` 发丝描边(`strokeWidth: 1` + `non-scaling-stroke`)「太粗」 | **整体回退**:不描边,glyph 只用包裹类的令牌着色。“描边”是我对「怪」的过度解读,反馈的本意只是终端尺寸。`tests/builtins.spec.ts` 现在同时钉住「终端尺寸更小」与「glyph 不带 style 覆盖」,防止描边再溜回来 |
### 真实 CI(已收敛)
`ci` / `plugin-mount` 每轮都绿;`ci-windows` 的 worker 回收竞态在 `--maxWorkers=1`(单 fork、零回收)下**连续三次全绿**:run [34575721734](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34575721734)、[34576146970](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34576146970)、[34576493202](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34576493202),均 **127 files / 0 errors**。两轮迭代(`--maxWorkers=2` 被真实结果证否)的取证见 [rc.2 计划 §B](./2026-09-10-dsh-0.1.5-rc.2-adaptation.md)。
**待验证**
- 3080 人工验证(返工后第二轮):树行与编辑器 tab 的彩色图标、指南六行彩色、原生右侧栏芯片图标、14px 下官方 48 类图形的可读性、以及这次返工后的任务/终端两个图标(用户此前明确选择用官方图形;若细节折损不可接受,回退方案是「树行用官方图形、编辑器 tab 保留插件 glyph 着色」,改动只在 `file-icons.tsx` 一处)。
## 不做
- 不改 DSH 源码;不把 #429 的 563 条数据搬回仓库(要走按需加载也应按懒加载 chunk 走,而不是常驻数据);
- 不为彩色图标新增皮肤契约豁免(插件侧零硬编码颜色);
- 不恢复 `floatWindows`;不动已合入的 rc.2 基线结论与 CI 修复。
+185
View File
@@ -0,0 +1,185 @@
# 01 · 需求分析:官方原生右列 vs 装了 apes-sidebar
> 本篇只回答一个问题:**一个在 DSH 里干活的人,从"官方原生"换到"装了这个包",他的需求和处境发生了什么变化?**
> 对比的两端只有两个:**官方原生的右侧栏**,和**装了 apes-sidebar 之后的右侧栏**(那个包是一个整体,九个 tab 一起算)。
> 机制与根因只在[附录 A](#附录-a--证据与实现索引)留索引,细节见 [02-系统架构](02-系统架构)、[06-故障排查](06-故障排查)。
## 一、先钉住"谁在用、他在哪儿干活"
在 DSH 里,**右侧栏是使用者一天里盯得最久的一块地方**。它不是"偶尔打开的设置页",而是"工作台 + 仪表盘"。
一个典型使用者(在 DSH 里做项目的人):
- 中间是对话主线——他和模型一起干活;
- 右边是这块地方——看文件、看改动、看子代理、顺手追问、跑命令、看预览;
- 他往往同时开着好几个会话、好几个项目。
这决定了本页所有需求的共同特征:**这些信息"看一眼就够,但要频繁看"。**
频繁看的东西必须常驻在视野里——不能要求他"跳出去看一眼,再跳回来"。
## 二、官方原生的右列:一个「只读的文件浏览器 + 预览器」
本机宿主(`dsh@0.1.5-rc.2`)里,官方真正放进右列的东西只有三件:
| 官方包 | 它给用户什么 | 官方原话 |
| --- | --- | --- |
| `dsh-client-ui-sidebar-right` | **容器**:每会话一块可停靠面板、两种形态(贴靠 / 全屏)、展开按钮在会话 header 角落、最多左右两格、拖拽与浮窗、tab 菜单、内置撤销历史 | "为每个会话持有一个停靠面……拥有导航控制器(`ctx.sidebarRight`)、tab 类型注册表" |
| `dsh-client-ui-sidebar-files` | **唯一的页**:「文件」——把会话工作区根目录画成一棵**只读**的树 | "把会话的工作区根目录画成一棵树,逐层经线上列出,并把文件打开到 Sidebar 里" |
| `dsh-client-ui-sidebar-documentpreview` | **唯一的查看器**(kind = `text`):预览 Markdown / 代码 / 图片 / PDF / HTML,认不出的扩展名走**纯文本兜底** | "在右侧 Sidebar 预览**可读文件**,无需另开 tab 即可切换已注册的渲染器" |
一个容易被忽略、但对"用户体验"影响很大的细节:
> 引导页(罗盘)**只在注册了两个以上入口时才出现**——官方随包只有一个入口(Files),
> 所以官方用户的右列**默认直接就是那棵文件树,罗盘从不露面**。
> (原文:"恰好一个入口时直接打开对应页面(随包组合中为 Files)"。)
**一句话概括官方原生的取向:它假设你只是想「看」。**
它给你树和预览,都是只读的;**它不假设你要在这块地方「干活」**。
于是,官方原生用户在右列里**做不到**这些事(每一条都是他能直接感知的缺失):
| 他想做的事 | 官方原生右列 | 他只能怎么办 |
| --- | --- | --- |
| 改一个文件 | ❌ 只有预览,没有可写的编辑器 | 跳出去,用外部编辑器打开 |
| 知道刚才改了哪些东西 | ❌ 没有变更清单、没有 diff | 切终端敲 `git status` / `git diff`,或另开 diff 工具 |
| 看某个子代理干了什么 | ❌ 右列里没有这个入口 | 翻会话树、翻聊天记录去认人 |
| 追问一句又不想弄脏主线 | ❌ 没有旁路对话 | 硬着头皮在主对话里问,或另开一个窗口 |
| 跑一条命令 | ❌ 没有终端 | 切到系统终端,还得先 `cd` 到对的目录 |
| 看页面 / HTML 出来什么样 | ❌ 没有内嵌浏览器 | 切浏览器标签,或手动打开产物文件 |
| 知道谁在替我干活、跑到哪了 | ❌ 完全没有 | 只能往上翻记录,翻到一半又忘了上下文 |
| 知道这摊活是在哪儿干的 | ❌ 界面不写 | 靠脑子记,或敲 `pwd` 确认 |
| 知道这个会话现在什么状态 | ❌ 界面不写 | 靠界面上的小细节猜 |
## 三、装了 apes-sidebar 之后:同一块地方,从「看」变成「干 + 心里有数」
apes-sidebar 把这九件事一次性放进右列。它们**是一个整体需求**:把右列从"文件浏览器"变成"工作台 + 仪表盘"。
| tab | 他真正想要的是什么 | 官方原生时他被迫怎么做 | 装了之后 | 需求被改变成 |
| --- | --- | --- | --- | --- |
| 文件(编辑器) | 看**和改**代码,而不丢掉当前上下文 | ⌘Tab 跳到外部编辑器;回来要重新找位置 | 右列里直接看、直接改 | 「看文件不该让我离开现场」 |
| 文件变动(git) | 刚才到底改了哪些东西 | 切终端敲命令,或另开 diff 工具 | 变更清单 + 单文件 diff,就在旁边 | 「改了什么必须当场看得见」——**这是信任的前提** |
| 子代理 | 进某个子代理,看它到底干了什么 | 翻会话树、翻记录去认人 | 右列里直接进入该子代理 | 「子代理是可进入的,不是只能干等的」 |
| 侧边对话 | 追问一句,但不想污染主线 | 硬着头皮在主对话里问,或另开窗口 | 旁路开一个小对话,两边互不干扰 | 「提问不该有副作用」——**性质变了** |
| 终端 | 跑一条命令 | 切到系统终端,常常还得先 `cd` | 右列里跑,工作目录天然就是对的 | 「命令的上下文应该跟着会话走」 |
| 浏览器 / 预览 | 看这个页面出来是什么样 | 切浏览器标签,或手动打开产物 | 右列内嵌,当场看 | 「产出物要能当场验收」 |
| **智能体** | 谁在替我干活、跑到哪了、哪个停了 | **基本没办法**——只能翻记录猜 | 主代理在最上,直系子代理一人一行,带运行/结束状态 | 「派了活之后,我要的是**扫一眼**,不是盯着守」(见 §四·洞察 4) |
| **应用** | 这摊活到底是在哪儿干的 | **基本没办法**——靠脑子记或敲 `pwd` | 打开就看到当前会话锚定的工作区 | 「界面应该告诉我我在哪,而不是要我自己记住」 |
| **画像** | 这个会话现在是什么状态 | 靠界面小细节猜(在跑?停了?哪一次?) | 标题 / 运行或空闲 / 工作区,写清楚 | 「离席回来,不用重建心智模型」 |
连**顺序**都在讲同一件事:`10…50` 是操作面(看/改/查/问/跑/验),`60…70` 是态势面(谁在干、在哪儿干、什么状态)。
先让"活能在这儿干完",再让"我心里有数"——这是这个包对用户的两级承诺。
## 四、深度分析:需求到底发生了什么变化
### 洞察 0 · 官方原生假设"你只是看",而真实使用是"在这儿干活"
这是整个需求空间的起点。官方原生不是做得不好,而是**取向不同**:只读预览是"查看器"的思路,
而使用者把它当"工位"用——他会在这里改一个文件、跑一条命令、瞄一眼改动。
**当一块地方被当成工位,只读就会立刻变成绕路。** 这就是这个包存在的全部理由。
### 洞察 1 · 从「只读」到「可写」:补的不是功能,是主权的归还
官方只给预览,意味着**每一个"改一下"都要离开这块地方**。装上之后,改、查改动、跑、验四件事都在原地完成。
用户省下的不是几次点击,而是**每次离开都要付出的"上下文重建"成本**——回来要重新找到刚才的位置、重新想起来自己在干嘛。
需求因此从「我要能看」变成「我要在这儿把活干完」。
### 洞察 2 · 「留在原地」会变成默认预期(棘轮效应)
一旦右列里能改文件、能跑命令、能看预览,他对"跳出去一下"的容忍度会**永久下降**。
他会开始说「为什么这个还要我切窗口?」——而这句话在他用官方原生时是说不出口的(因为原生本来就没有可切的东西)。
**这类需求只前进、不后退**,所以值得一次做对:它一旦被填好,就变成基础设施。
### 洞察 3 · 消除副作用之后,他会开始"问"(侧边对话)
侧边对话的价值不在"多一个对话框",而在**把提问的代价从"可能污染主线"降到"零"**。
需求的性质变了:以前他的问题是「要不要问?」(一个决策),现在变成「顺手问一下」(一个动作)。
**当一件事的代价趋近于零,用户的行为频率会跳一个数量级**——这是需求分析里最值得警惕、也最值得利用的一条规律。
### 洞察 4 · 看得见 → 敢放手(智能体 tab 的核心价值)
这是整个包里最值钱的一条。官方原生在这一点上是**彻底空白**:
- 官方没有子代理视图,也没有任何"队伍"概念 → 他派了活之后**只能等**,或者不停往上翻记录确认;
- 这个包的「子代理」让他能**进入**某一个;「智能体」让他**不必进去就看得到全局**——主代理 + 直系子代理,谁在跑、谁停了。
用户真正的痛点不是"我进不去子代理",而是**"我不敢派太多活"**:
因为看不见,他只能用"盯着"来对冲风险,而盯着就失去了并行的意义。
> **看得见 → 敢放手 → 真的并行 → 才配得上"多智能体"。**
> 需求从「我要能看见」升级为「我要能放心」。
### 洞察 5 · 心智模型从脑子里搬到界面上(应用 / 画像)
多项目、多会话的人切换时要重建一次"我这是在哪儿"——标题差不多的会话最容易看错,
而看错的代价是**写错文件**。官方原生在右列里一个字都不写这件事。
「应用」「画像」做的就是**把这份"我记得"外化成"界面写着"**。
需求的性质:从「我要记性好」变成「界面不该让我记」。
### 洞察 6 · "引导页"第一次有了意义:从单一工具到工作台
官方原生只有一个入口(Files),所以它**默认直接开文件树,罗盘界面根本不会出现**。
入口一多,罗盘才登场——用户第一次能"一屏看见这块地方能干什么"。
这是**用户层面的分水岭**:官方是"一个工具",装了之后是"一个工作台的目录页"。
(对照 §二那个细节:入口数决定了这块地方的形态。)
### 洞察 7 · 价值随"并行度"放大;而成本几乎为零
- 单会话、单项目的用户:「应用」「画像」几乎用不上,「智能体」也只有一个主代理;
- 一旦同时开几个会话、派几个子代理、换几个项目:这三个 tab 的边际价值**陡增**。
所以这套东西**不是人人需要,但接到的人离不开**。
同时它**不新增数据源、不新增进程、不引入新的失败面**——只是把宿主已经投影出来的信息换个视角呈现
(逐条列在 [03-功能说明](03-功能说明))。对使用者意味着:**低风险地换到确定性**,这是它最该守住的边界。
## 五、由以上分析推出的需求(U1–U6)
| # | 需求(用户口吻) | 怎么算他满意了 | 现状 |
| --- | --- | --- | --- |
| U1 | 「我要在这儿把活干完,别老让我跳出去」 | 改文件、查改动、跑命令、看预览、顺手追问,都在右列完成 | ✅ 操作面六个 tab |
| U2 | 「我要看得见谁在替我干活」 | 右列点一下就有队伍表;人数/状态一眼可辨;没有子代理时明确说"没有",不是空白 | ✅ 智能体 tab |
| U3 | 「我要知道我是在哪儿干的活、这会话什么状态」 | 打开就看到工作区与运行/空闲,不用敲 `pwd`;拿不到应用信息时如实说明 | ◑ 工作区/状态 ✅ · 应用层等宿主 |
| U4 | 「我点开 HTML 就该看得见」 | 自包含 HTML 完整显示,而不是一行 `forbidden`;**但不许为此拆掉沙箱** | ✅(多文件需我本人点「临时解锁」) |
| U5 | 「我的界面不许自己乱套」 | 右列只有一套侧边栏、一个开合入口;弹窗完整盖在面板之上、按钮点得到 | ✅ |
| U6 | 「我敢升级,也退得回去」 | 有"宿主版本 ↔ 插件版本"对照表;每步升级都有分支/tag 可回滚 | ✅ 两条版本线;新宿主线待实机验证 |
## 六、我们不做的事(省得期待错位)
- **不重造官方已有的东西。** 官方给了容器与文件树,我们在它里面长东西,不另起一套侧边栏。
- **不为"看得见"放宽安全边界。** 需要放宽必须由用户**本人显式点**「临时解锁」。
- **不做团队/房间/多智能体协作编排。** 那是宿主的另一类职责。
- **不承诺"装上立刻见效"。** 单会话用户可能几乎感觉不到差异(见洞察 7)。
## 七、已知的「暂时满足不了」(诚实交代)
| 他想要的 | 现状 | 为什么(一句话) | 何时会有 |
| --- | --- | --- | --- |
| 知道这摊活属于哪个**应用**、有哪些入口 | 「应用」只显示工作区 | 宿主目前没有向插件提供应用清单的通道 | 宿主提供 feed 后即可接上 |
| 一次摊开**完整**子代理树(子代理的子代理) | 只显示**直系** | 宿主投影只给一层 | 同上 |
| 沙箱状态下多文件 HTML 的相对资源 | 需点「临时解锁」 | 不透明源沙箱下子请求拿不到宿主信任头(安全边界的必然结果) | 不打算改——这是刻意保留的边界 |
> 这三条说明同一件事:**需求跑在宿主能力前面**。写出来不是免责,是让读的人知道
> "不能用"和"还没做"是两回事。
## 附录 A · 证据与实现索引
开发与排障看这里;其余读者可跳过。
**"官方原生到底有什么"的证据**(本机宿主 `dsh@0.1.5-rc.2`,官方包自带文档):
- 容器:`@deepseek-ai/dsh-client-ui-sidebar-right/README.zh.md` —— 停靠面、两种形态、扩展席位、引导页。
- 唯一的页:`@deepseek-ai/dsh-client-ui-sidebar-files/README.zh.md` —— "文件树 tab 类型……把文件打开到 Sidebar 里"。
- 唯一的查看器:`@deepseek-ai/dsh-client-ui-sidebar-documentpreview/README.zh.md` —— "Sidebar tab 的 kind 为 `text`",未知扩展名纯文本兜底。
- "官方只有一个入口 ⇒ 罗盘不出现":同上 sidebar-right 文档「引导页」一节。
**本包一侧的对应实现**(`editor`/`git`/`subagent`/`sidechat`/`terminal`/`browser` 为随包操作面;
`agents`/`apps`/`profile` 是本 fork 自增的态势面——对内维护用,对外它是一个包):
| 需求 | 机制(去哪看) | 关键证据 |
| --- | --- | --- |
| U5-a 右列只有一套侧边栏 | 每个 tab 注册成原生 tab 类型(`kind = descriptor.id`)渲染进官方右列;自绘面板已移除 | [02-系统架构](02-系统架构)、[05-运维手册](05-运维手册) |
| U5-b 弹窗不被面板切 | 宿主容器上的层叠上下文(`isolation`)导致;挂载点改挂 `#root` 而不再挂 `document.body` | [06-故障排查](06-故障排查) |
| U4 HTML 预览 | 主框架 `fetch` → `srcDoc`,**`sandbox` 属性未动**;失败回退路由 URL | [06-故障排查](06-故障排查) |
| 与官方 `text` 预览的关系 | 本包的编辑器类型以更高优先级接管 `dsh-resource://file/**`,卸载即恢复官方预览 | `docs/external-plugin-guide.md` §「内置类型接管」 |
| U6 版本线 | 宿主版本不匹配时宿主**静默禁用**整行(不报错、无日志);`^` 跨 minor 判 false | [04-部署指南](04-部署指南)、[06-故障排查](06-故障排查) |
<!-- 本页只讲"为什么需要"。实现细节、提交号、测试名一律留在 02–07 与附录索引里。 -->
+154
View File
@@ -0,0 +1,154 @@
# 02 · 系统架构
> 本篇说明插件的**分层、接口面、层叠契约与关键不变量**。
> 与源码一一对应,可直接当作改动前的「雷区地图」使用。
## 一、总体分层
```
┌──────────────────────────────────────────────────────────────────────────┐
│ DSH 宿主(Desktop Electron 壳 / Web profile) │
│ ├─ 原生右侧栏 ctx.sidebarRight / sidebarRightTabs(tab 类型注册表) │
│ ├─ 会话与任务 ctx.sessions.list(会话摘要、子代理、投影)、ctx.tools │
│ ├─ webServer 路由挂载点(插件用 /sidebar/* 前缀注册) │
│ └─ 桌面壳层 webserver 外层的请求围栏 + #root 的 content-viewport 标记 │
└───────────────┬──────────────────────────────────────────────────────────┘
│ ① 宿主 → 插件:cordis 服务注入(inject)
│ ② 插件 → 宿主:原生 tab / 资源打开 / 路由
┌───────────────┴──────────────────────────────────────────────────────────┐
│ 插件 HOST 半(Node,lib/index.js) │
│ · src/index.ts 路由注册(/sidebar/*)、信任围栏、会话/文件数据、HTML 交付 │
│ · src/trust-fence.ts 请求信任围栏(Host 头 / 令牌) │
│ · src/invariant.ts 依赖零的断言与 semver 工具(host/client 共用) │
└───────────────┬──────────────────────────────────────────────────────────┘
│ 浏览器侧模块(__ModuleLoader__ bundle,非 CJS)
┌───────────────┴──────────────────────────────────────────────────────────┐
│ 插件 CLIENT 半(浏览器,lib/client.js + 懒加载 chunk) │
│ · src/client/index.tsx 应用入口:装配 store/service/原生适配/信息层 │
│ · src/client/native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题) │
│ · src/client/builtins/ 内置面板:文件 / 文件变动 / 子代理 / 侧边对话 / │
│ 终端 / 浏览器 + 文件预览器 │
│ · src/client/sidebar/ 自绘底部工作台与开合控件(原生侧边栏之外的部分) │
│ · src/client/apes/ 信息层三 tab(本 fork 的增量) │
│ · src/client/service.ts ctx.betterSidebar 服务(供其他插件注册) │
└──────────────────────────────────────────────────────────────────────────┘
```
## 二、构建产物
| 产物 | 角色 | 备注 |
| --- | --- | --- |
| `lib/index.js` | host 半(ESM,Node) | 路由、围栏、数据面 |
| `lib/invariant.js` | 不变量面(零依赖) | host/client 都要 import,**不得**引入 Node 内置或宿主包 |
| `lib/client.js` | client 主 bundle | 浏览器 `window.__ModuleLoader__.load({...})` 形态,**不是** CJS/ESM,Node 里 `require` 会因 `window is not defined` 抛错 |
| `lib/client-registry.js` | 懒加载 chunk | 高开销能力(图标/预览库)按需加载 |
| `lib/client-editor.js` | 懒加载 chunk | 编辑器(CodeMirror 系) |
| `lib/client-locale.js` | 懒加载 chunk | 语言包 |
| `lib/client-mermaid.js` | 懒加载 chunk | Mermaid 渲染 |
| `lib/client-terminal.js` | 懒加载 chunk | 终端 |
| `lib/types/**/*.d.ts` | 类型声明 | 供消费方类型检查 |
> 懒加载动机:主 bundle 不应静态 import 重型预览库;chunk 通过宿主 `ctx.modules` 解析外部依赖
> (入口在 `src/client/index.tsx` 里先 `setChunkModuleSystem(ctx.modules)`)。
## 三、三层接口面
### 3.1 宿主 → 插件(cordis 注入)
```ts
// src/index.ts
export const name = 'dsh-apes-sidebar'
export const inject = ['webServer', 'sessions', 'webRuntime', 'tools']
export function apply(ctx: Context, config?: SidebarConfig): void
```
客户端侧读取的宿主面(结构化镜像,运行时由宿主提供):
`ctx.sessions.list`(`getSnapshot/subscribe`)、`ctx.sidebarRight`(原生右列与 `openResource`)、
`ctx.slots`、`ctx.settings`(挂载行 `config`)、`ctx.modules`(懒加载 chunk 的模块系统)。
### 3.2 插件 → 宿主
| 方向 | 手段 |
| --- | --- |
| 注册 tab 类型与 tab 体 | `src/client/native/registerNativeSurface()` → 原生右侧栏注册表 |
| 打开会话内文件 | `ctx.sidebarRight.openResource('dsh-resource://file/…')` |
| 暴露 HTTP 能力 | `/sidebar/` 前缀路由:`api` · `file` · `html` · `upload` · `ws/terminal` · `ws/agent-opens` · `ws/agent-terminals` |
### 3.3 插件 → 其他插件(对外服务)
`ctx.betterSidebar`(`src/client/service.ts`)对外开放 `registerTab` / `registerFileViewer` 等,
并按「能力清单」做前向兼容门控(能力只增不减)。对外契约见仓库内
[docs/external-plugin-guide.md](../blob/main/docs/external-plugin-guide.md)。
服务版本常量 `SIDEBAR_SERVICE_VERSION` 必须与 `package.json` / `dsh.plugin.json` 的版本三方一致
(有单元测试断言这一对)。
## 四、面板宿主与层叠契约(重点雷区)
插件的所有面板渲染在一个 **body 级(或壳层 viewport 级)的宿主层**里:
```css
[data-dsh-panel-host] { position: fixed; inset: 0; z-index: 25; pointer-events: none }
```
`z-index: 25` 不是随手取的,它要同时满足:**在 app 内容与壳层 overlay 之上、在宿主交给插件用的
overlay 槽之下**。完整取义:
| 层级 | 对象 |
| --- | --- |
| `z-index: auto` | app 的流内内容、`.dshDesktopFrame` |
| **25** | **本插件的面板宿主** |
| 30 | 宿主的动态插件面板(ui-cordis) |
| 100+ | app 的 overlay 栈(Toast / onBoarding 等) |
| 1000 | `.dshDesktopOverlay[data-shell-overlay]`(宿主给 `shell.overlay` 槽的容器,第三方弹窗在此) |
**陷阱(已修,`4422001`)**:DSH 桌面壳的有框模式会给上游 `#root` 打标
`data-dsh-desktop-content-viewport` 并施加 `isolation: isolate`,使 `#root` 成为一个**层叠上下文**。
宿主自己的 overlay 层就在这个上下文内;若插件宿主挂在 `document.body`(上下文之外),
它是「带正 z-index 的定位元素」,会画在整个上下文**之上**——把宿主 overlay 里的弹窗(如社区插件市场)盖住。
```ts
// src/client/desktop-env.ts —— 修法:挂进壳层的 content viewport
export function panelHostParent(doc: Document = document): HTMLElement {
return doc.querySelector<HTMLElement>('#root[data-dsh-desktop-content-viewport]') ?? doc.body
}
```
进入该上下文后,25 恢复原意(在 app frame 之上、在 overlay 层 1000 之下);无该标记的部署
(纯网页 / 未打标的壳)仍挂 `document.body`,行为不变。重新锚定守卫同时监听
`document.body` 的 `data-dsh-desktop-mode`(壳层打标与它是同一个 effect),因此壳层在插件挂载**之后**
才框住 `#root` 的情况也能被搬到正确位置。
## 五、数据流示例
**会话 → 信息层**
```
ctx.sessions.list.getSnapshot() → { byId, projectionsBySession? }
useSyncExternalStore(subscribe) (uSES 安全订阅)
├─ apes:agents → rootAncestor(byId, scope.sessionId) 找主代理
│ → projectionsBySession[rootId].values.subagentCatalog 取直系子代理
│ (无投影时回退 byId 里的 durable 子会话)
└─ apes:profile / apes:apps → byId[scope.sessionId](标题 / cwd / running)
```
**文件 → 预览**(HTML 为例)
```
编辑器/diff 面板 → /sidebar/html/<session>/<path> (路由负责路径围栏与体积上限)
主框架 fetch 取回文档文本 → 注入 <base href="<该文件的路由目录>">
→ iframe srcDoc + sandbox(不透明源)
取失败时回退为 iframe src=路由 URL,把路由的答复原样透出
```
## 六、关键不变量(改动前必读)
1. **host / client 拆分是结构性的**:client bundle 在浏览器运行,不得 import Node 内置;
`src/invariant.ts` 两侧共用,必须保持零依赖。
2. **`dsh-better-sidebar` 是 peer,永不打包**:一旦被内联,client 会带上第二份侧边栏注册表,
tab 注册进死实例,界面上永不出现。
3. **tab id 前缀归本 fork**:信息层用 `apes:*`;不得占用上游/宿主内部 id。
4. **HTML 预览的沙箱令牌不得放宽**(不加 `allow-same-origin` 等);交付方式只能是「主框架取文档 + `srcDoc`」。
5. **颜色只用宿主设计令牌**(`--dsw-alias-*`),不写硬编码色值;几何量(字号/间距/圆角)无公开令牌,内联即可。
6. **所有面向用户的字符串走 i18n**,`zh` / `en` 必须同时存在。
7. **变更后必须重新同步到 profile**:构建产物非字节确定性,且 profile 的 `file:` 依赖是复制语义,
不能假定旧副本可用(见 [04-部署指南](04-部署指南))。
+109
View File
@@ -0,0 +1,109 @@
# 03 · 功能说明
> 本篇是**功能清单与行为契约**:有哪些面板、数据从哪来、文案与配色怎么走、安全边界在哪、能配什么。
## 一、面板与标签页总表
「来源」列:**上游** = 来自 `dsh-better-sidebar`;**宿主** = DSH 自身提供并占用该槽位;**本 fork** = 本项目的增量。
| id | 名称 | order | 来源 | 数据 / 行为 |
| --- | --- | --- | --- | --- |
| `editor` | 文件 | 10 | 上游 | 文件树 + 编辑器(CodeMirror 系,懒加载 `client-editor`) |
| `git` | 文件变动 | 20 | 上游 | 工作区变更、单个 diff 视图 |
| `subagent` | 子代理 | 30 | 上游 | 会话的子代理视图 |
| `sidechat` | 侧边对话 | 35 | 上游 | 会话内旁路对话 |
| `terminal` | 终端 | 40 | 上游 | PTY 终端(懒加载 `client-terminal`;v0.24.1 线起由宿主内置终端承担) |
| `browser` | 浏览器 | 50 | 上游 | 内嵌浏览器/HTML 预览宿主 |
| `diff` | (内部) | -1 | 上游 | 非用户可见,供内部打开 diff |
| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑 |
| **`apes:apps`** | **应用** | **65** | **本 fork** | 当前会话所属工作区(宿主暂无「应用」feed) |
| **`apes:profile`** | **画像** | **70** | **本 fork** | 当前会话摘要(标题 / cwd / 运行态) |
> order 10–50 是上游/宿主既定带宽,apos 信息层取 60/65/70 留出余量,避免与原生面板抢占位置。
## 二、信息层三 tab(本 fork 的增量)
三个 tab 全部是**单实例**(`single: true`),且都按 DSH 标准 `TabDescriptor` 注册,
因此自动获得原生侧边栏的全部待遇:进入原生 guide、tab 芯片带图标、可开合与分栏。
| tab | 展示内容 | 数据来源 | 空态 |
| --- | --- | --- | --- |
| `apes:agents` | 根(主代理)卡片 + 其直系子代理卡片;每行显示标签、`one-shot`/`continuable`、运行态圆点 | ① `ctx.sessions.list` 的 `byId`(durable 会话摘要);② 宿主的会话投影 `projectionsBySession[sessionId].values.subagentCatalog`(直系子代理清单) | 「当前会话没有子代理」提示 |
| `apes:apps` | 当前会话的工作区(cwd) | `byId[scope.sessionId].cwd`,回退 `scope.cwd` | 明确写出「宿主今日无应用 feed」 |
| `apes:profile` | 会话标题、运行/空闲、cwd | `byId[scope.sessionId]` | 「无 cwd」提示 |
**实现要点**:两个来源被归一化成同一种行形状 `{ id, label, mode }`,**活动态统一从 `byId` 读**,
于是「投影已就绪」与「仅有 durable 摘要」两种情况下渲染一致;这也是上游 0.1.6→0.1.7 会话 feed
迁移(`subagentsByParent` → `projectionsBySession`、`list.current` 删除)后的落地方式。
## 三、文件预览器
| id | 适用 | 说明 |
| --- | --- | --- |
| `image` | 图片 | 走媒体路由,受 `mediaLimit` 限制 |
| `pdf` | PDF | 内嵌查看 |
| `markdown` | Markdown | 渲染(Mermaid 走懒加载 chunk,可手动刷新) |
| `html` | HTML | **不透明源沙箱 iframe**,见下节 |
| `code` | 代码 | 高亮查看(编辑器懒加载) |
| `binary-download` | 二进制 | 只提供下载 |
### 3.1 HTML 预览的交付方式(重要)
```
路由 /sidebar/html/<session>/<path> ← 路径围栏 + 体积上限(readLimit)
│
├─ 主框架 fetch(唯一同时被宿主围栏信任、又能带上 Electron 头的角色)
│
├─ 注入 <base href="<该文件的路由目录>"> ← 让 ./style.css 等相对引用解析回本文件所在目录
│
└─ iframe srcDoc=<文档> + sandbox=HTML_IFRAME_SANDBOX
```
| 状态 | 自包含 HTML | 多文件 HTML(外链 CSS/JS/图片) |
| --- | --- | --- |
| 沙箱态(默认) | ✅ 完整可用 | 正文可用,**相对资源不可用**(iframe 源为 `null`,子请求同样被宿主围栏拒绝) |
| 临时解锁(无 sandbox 属性) | ✅ | ✅ **相对资源可用**(iframe 继承 GUI 源,成为普通同源请求) |
设计取舍:**沙箱令牌一个字符都不放宽**。多文件 HTML 想要完整效果需用户显式点「临时解锁」——
这是把「放宽边界」的决定权留给用户的显式动作,而不是插件默认降级。
## 四、安全边界(三层)
| 层 | 机制 | 说明 |
| --- | --- | --- |
| 预览内容 | `sandbox="allow-scripts allow-popups allow-downloads allow-modals"` | **不含 `allow-same-origin`**:预览文档运行在不透明源里。宿主路由另有 CSP `sandbox` 兜底 |
| 插件路由 | 请求信任围栏(`src/trust-fence.ts`) | 校验 Host 头/令牌,拒绝跨站或非授权来源对 `/sidebar/*` 的访问 |
| 桌面壳 | Electron 注入的 `x-dsh-desktop-renderer` 头 | 扩展模式下所有路由的前置围栏;只发给主框架与同源子框架。这正是「沙箱 iframe 发不出请求」的根源 |
## 五、i18n 与主题
- **语言包**:`src/client/locales.ts` + 19 个 `src/client/locales-*.ts` 覆盖文件;默认 `zh`,缺省回退 `en`。
- **本 fork 文案**:集中在 `src/client/apes/locales.ts`,每个 key 同时提供 `zh` / `en`。
- **硬性约定**:界面里**不得出现裸字符串**,一律 `t('key')`。
- **配色**:只用宿主设计令牌 `--dsw-alias-*`(bg / border / label / state / interactive 系列),
写法为 `var(--dsw-alias-xxx, <字面回退>)`,保证在非 DSH 环境(单元测试)也能渲染。
**无公开的 `--ds-*` 几何令牌**,字号/间距/圆角内联即可。
## 六、配置面(挂载行 `config`)
配置写在插件挂载行上(profile 的 `cordis.patch.yml` 或 bundle patch 的 insert 行),设置页写的也是它:
| 字段 | 默认 | 含义 |
| --- | --- | --- |
| `readLimit` | `512 KiB` | 单文件读取上限(超出截断并标记 `truncated`) |
| `mediaLimit` | `20 MiB` | 媒体(图片等)读取上限 |
| `uploadLimit` | `128 MiB` | 上传上限 |
| `listLimit` | `1000` | 目录列举条目上限 |
| `terminalsPerSession` | `3` | 每会话终端数上限 |
| `shell` / `shellArgs` | 自动解析 | 终端 shell 与其启动参数(`shellArgs` 非空时替换默认 `-l`) |
例:在挂载行里指定 shell
```yaml
- insert:
- id: better-sidebar
name: 'dsh-apes-sidebar'
config:
shell: /bin/zsh
shellArgs: ['--noprofile', '--no-rc']
```
+163
View File
@@ -0,0 +1,163 @@
# 04 · 部署指南
> 目标:在一台机器上把本插件挂进 DSH,并确认它**真的在跑**。
> 关键前提:**先确认宿主版本,再选版本线**——选错线的唯一症状是「静默失效」。
## 一、前置条件
| 项 | 要求 |
| --- | --- |
| Node | ≥ 20(`engines.node`);本机验证用 22.x |
| pnpm | 11.x(`pnpm install` 会自动触发 `prepare: tsdown` 构建) |
| DSH | 见下面的版本矩阵 |
| 终端可用性 | 仅 v0.19.1 线需要额外放行 `node-pty` 原生构建(见 §五) |
### 1.1 宿主版本矩阵(先读这张表)
| 版本线 | 分支 | 插件版本 | peer 声明 | 适用宿主 |
| --- | --- | --- | --- | --- |
| 旧宿主线 | `main` | 1.2.0 | `^0.1.5-rc.1` | DSH `0.1.5-rc.1+`(含 `0.1.5-rc.2`,即 **DSH Desktop 2.0.10** 内置版本) |
| 新宿主线 | `sync/v0.24.1` | 1.3.0 | `^0.2.0-rc.1` | DSH `0.2.0-rc.1+` |
**怎么查宿主版本**(扩展模式桌面版):
```bash
# 内置的 dsh 版本
grep -m1 '"version"' "/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh/package.json"
# 或看 profile 里装的宿主包
grep -m1 -A2 '"@deepseek-ai/dsh"' ~/.dsh/profiles/desktop/package.json
```
**选线决策**
```
宿主 ≥ 0.2.0-rc.1 ? ── 是 ──▶ sync/v0.24.1(1.3.0)
└─ 否 ──▶ main(1.2.0)
```
> ⚠️ **peer 不匹配不会报错。** 宿主以 `semver.satisfies(host, peerRange, { includePrerelease: true })` 预检,
> caret 跨 minor 判 false 后**整行静默禁用**:不加载、无日志、界面上「像没装」。
> 因此「装完没反应」的第一件事永远是核对宿主版本 ↔ peer 范围。
## 二、安装方式(本 fork 未发布 npm 包)
`npm view dsh-apes-sidebar` 返回 404 —— **本 fork 不在 npm 上**(README 里的 npm 徽章/命令是沿用上游文档)。
实际部署使用「本地源码 + `file:` 链接依赖」:
### 步骤 1:构建
```bash
cd <插件仓库绝对路径>
git checkout main # 或 sync/v0.24.1(按宿主版本选)
pnpm install # 会顺带跑 prepare: tsdown
pnpm typecheck && pnpm build && pnpm test # 门禁三连
```
产物(`lib/`):`index.js` / `invariant.js`(host)+ `client.js` + 懒加载 chunk
(`client-registry` / `client-editor` / `client-locale` / `client-mermaid` / `client-terminal`)+ `types/`。
### 步骤 2:接进 profile
编辑 `~/.dsh/profiles/<profile>/package.json`(桌面版为 `desktop`,网页版通常为 `web`):
```jsonc
{
"dependencies": {
"dsh-apes-sidebar": "file:/绝对路径/dsh-apes-sidebar" // ← 必须是绝对路径
},
"dsh": {
"profile": {
"bundles": [
"…",
"dsh-apes-sidebar" // ← 加入 bundle 栈
]
}
}
}
```
> 也可以在 profile 自己的 `cordis.patch.yml` 里手写挂载行(老方式)。**两种方式不要同时用**:
> 会双挂载成两个 Node 半、两个侧边栏。包内自带的 `cordis.patch.yml` 是 `dsh.bundle.patch` 层,
> 由官方 CLI 安装时自动追加 bundle,并带「聚合包双挂载退让」守卫。
### 步骤 3:让 profile 重新安装依赖
```bash
cd ~/.dsh/profiles/<profile> && pnpm install
```
**为什么必须重装**:pnpm 对 `file:` 依赖是**复制**语义(不是软链)。源码 `pnpm build` 之后,
profile 的 `node_modules/dsh-apes-sidebar/lib` **仍是旧副本**——这就是「改了代码但界面没变」的头号原因。
DSH 启动时也会 reconcile profile 的 pnpm workspace,但不要假定它一定帮你刷新。
### 步骤 4:完整重启
**⌘Q 完全退出 DSH,再重新启动**(不是关窗口)。client 半有热加载,但 host 半与依赖变更必须走完整重启。
## 三、验收清单
重启后逐条核对(每条都对应一个可观察事实):
- [ ] **右列只有一套侧边栏**、一个开合控件(不再出现自绘的第二套开关)
- [ ] 原生 guide 里能看到本插件的 tab 芯片,且**带图标**
- [ ] 三个信息层 tab 存在:**智能体 / 应用 / 画像**(order 在原生面板之后)
- [ ] `apes:agents` 打开后:有主代理卡片;有子代理时列出直系子代理(标签 + 模式 + 运行态)
- [ ] 打开一个 HTML 文件:**沙箱态下自包含 HTML 能正常渲染**(不再是 `forbidden`)
- [ ] 打开「社区插件市场」:弹窗**完整盖在**本插件面板之上(面板不切弹窗角)
- [ ] 页面左下角**没有**红色诊断条(有的话说明 client 生命周期某处抛错)
- [ ] 开发者控制台**没有** `[dsh-apes-sidebar] … error` 前缀的错误
## 四、网页 profile 与桌面 profile 的差异
| 项 | 桌面版(DSH Desktop) | 网页版 |
| --- | --- | --- |
| profile 目录 | `~/.dsh/profiles/desktop` | 通常 `~/.dsh/profiles/web` |
| 外壳 | Electron 壳,有「扩展模式/兼容模式」与请求围栏 | 浏览器直连宿主端口 |
| 预览 iframe | 受渲染器围栏约束(因此必须走 `srcDoc` 交付) | 无该围栏,但仍受路由 CSP/沙箱约束 |
| 面板宿主层级 | 壳层会给 `#root` 打 content-viewport 标记 → 宿主挂进 `#root` | 无标记 → 宿主挂 `document.body` |
| 刷新方式 | ⌘Q 完整退出再启动 | 硬刷新(Cmd/Ctrl+Shift+R) |
## 五、v0.19.1 线特有:放行 node-pty 原生构建
`main`(上游 v0.19.1 基线)在 `package.json` 中仍有 `node-pty` 依赖;pnpm 11 默认**拦截依赖的构建脚本**,
于是首次安装会失败或终端不可用:
```bash
cd ~/.dsh/profiles/<profile>
pnpm approve-builds # 选中 node-pty,回车确认
pnpm rebuild node-pty # 必要时
```
`sync/v0.24.1`(1.3.0)**不含** `node-pty`(终端交由宿主内置承担),无需此步。
(`main` 与 `sync/v0.24.1` 的 `node-pty` 引用数:1 / 0。)
## 六、切换与卸载
同一条线只能装一个(两条线都注册 `ctx.betterSidebar`,entry id 冲突,第二个被丢弃)。
仓库自带切换脚本:
```bash
bash scripts/switch.sh --status # 只看当前是哪条线
bash scripts/switch.sh aps # 切到本 fork 线(本地 file: 安装)
bash scripts/switch.sh upstream # 切回上游 dsh-better-sidebar
bash scripts/switch.sh upstream 0.18.0 # 指定上游版本
bash scripts/switch.sh aps --profile web # 指定 profile
```
**完全卸载**:从 profile 的 `dependencies` 与 `dsh.profile.bundles` 中移除 `dsh-apes-sidebar`,
`pnpm install`,⌘Q 完整重启。若曾手写过 `cordis.patch.yml` 挂载行,一并删除(否则会留下悬空 entry)。
> 若同时装了聚合包(如 `@linxin666/dsh-web-ui-all`)且它内部也挂载了本包,
> 包内 bundle patch 的守卫会让**本行退让**;此时请确认聚合包在 bundle 栈中位于本行之前。
## 七、变更后的重新部署
```bash
# 插件仓库
pnpm typecheck && pnpm build && pnpm test
# profile
cd ~/.dsh/profiles/<profile> && pnpm install
# ⌘Q 退出 DSH 再启动
```
改完代码**必须**走完这三步:产物构建非字节确定性,profile 的 `file:` 副本也不会自动跟随。
+133
View File
@@ -0,0 +1,133 @@
# 05 · 运维手册
> 本篇是**日常运维、升级与回滚**的操作手册。所有命令都以「先留锚点、再动手、改完回读验证」为原则。
## 一、版本线与分支策略(当前远端实况)
| ref | 指向 | 含义 / 用途 |
| --- | --- | --- |
| `main` | `4422001` | **旧宿主线**:插件 1.2.0,上游基线 v0.19.1,peer `^0.1.5-rc.1`。本机 DSH Desktop 2.0.10 正在跑这条 |
| `sync/v0.24.1` | `e5bbef1` | **新宿主线**:插件 1.3.0,上游基线 v0.24.1,peer `^0.2.0-rc.1`。待 ≥0.2.0 宿主实机验证 |
| `backup/pre-sync-v0.19.1` | `96cc334` | v0.19.1 re-base **之前**的状态(旧 fork 三角色 tab 版) |
| `backup/pre-sync-v0.24.1` | `4422001` | v0.24.1 re-base 之前的 `main`(等价于当前 main,作为下次升级的锚点) |
| `tags/apes-v1.1.0-pre-sync` | `96cc334` | 与上面第一支 backup 同一提交,tag 形式的锚点 |
| `tags/apes-v1.2.0` | `4422001` | 当前已验收的 1.2.0 线 |
**纪律**:任何 re-base / 大改之前,先建 `backup/pre-sync-<新基线>` 分支 + `apes-v<版本>` tag,
并**推送**到远端(本地锚点在多人/多机场景等于不存在)。
## 二、回滚剧本
### 场景 A:某次提交引入回归(单点回退)
```bash
cd <插件仓库>
git log --oneline -5 # 定位可疑提交
git revert <sha> # 保留历史的回退(推荐)
# 或整体丢弃:git reset --hard <上一提交>
pnpm typecheck && pnpm build && pnpm test
cd ~/.dsh/profiles/<profile> && pnpm install # file: 是复制语义,必须重装
# ⌘Q 完全退出 DSH → 重启
```
### 场景 B:整条线回退(例如新线在真机上不可用)
```bash
git checkout main # 或 backup/pre-sync-v0.24.1
pnpm install && pnpm build
cd ~/.dsh/profiles/<profile> && pnpm install
# ⌘Q 重启
```
### 场景 C:profile 依赖/config 被改坏
```bash
cd ~/.dsh/profiles/<profile>
cp package.json package.json.bak.$(date +%s) # 先备份
bash <插件仓库>/scripts/switch.sh aps # 或 upstream,脚本会重写依赖与 bundle 行
pnpm install
# ⌘Q 重启
```
**回滚验收**:重启后按 [04-部署指南](04-部署指南) §三 的验收清单逐条核对(至少「右列一套侧边栏」
与「三个信息层 tab 存在」两条)。
## 三、发布 / 变更纪律
1. **版本三元组必须一致**:
- `package.json` 的 `version`
- `dsh.plugin.json` 的 `version`
- `src/client/service.ts` 的 `SIDEBAR_SERVICE_VERSION`
三者不一致会被单元测试(`tests/service.spec.ts`)直接拦下——**不要改其中一个而忘了另一个**。
2. **门禁三连必过**:`pnpm typecheck && pnpm build && pnpm test`。当前基线:`main` 131 个测试文件 /
1367 通过;`sync/v0.24.1` 141 个文件 / 1515 通过(另有 9 个 skip)。
3. **产物非字节确定性**:两次构建的字节数可能相同但 **sha 不同**。因此「改完代码 → 让 profile 用旧副本」
永远不成立;必须重新 `pnpm build` + 让 profile 重新安装。
4. **发布方式**:本 fork 未发布 npm 包,发布 = 推分支/tag + 目标机器 `git fetch && checkout && pnpm build`
+ profile 重装。发布前把 `README.md` 与 `docs/wiki/` 一并更新(wiki 见 §六)。
5. **不要顺手改无关文件**:re-base 时保持「只带自己的增量」,避免下次升级冲突面扩大。
## 四、日常巡检清单
| 频率 | 检查项 | 命令 / 判据 |
| --- | --- | --- |
| 每次部署后 | 版本三元组一致 | 见 §三.1 |
| 每次部署后 | 产物标记齐全 | `grep -c 'apes:agents' lib/client.js`(>0)、`grep -c 'dsh-desktop-content-viewport' lib/client.js`(>0) |
| 每次部署后 | profile 副本已刷新 | 比对 `lib/client.js` 与 `~/.dsh/profiles/<p>/node_modules/dsh-apes-sidebar/lib/client.js` 的 sha256 |
| 每次升级宿主/插件 | 宿主版本 ↔ peer 范围 | 见 [04-部署指南](04-部署指南) §1.1(不匹配 = 静默禁用) |
| 每月/大改前 | 锚点是否已推远端 | `git ls-remote <remote> \| grep -E 'heads/(main\|sync)\|tags/apes'` |
| 异常时 | 诊断条与控制台 | 页面左下角红条;控制台 `[dsh-apes-sidebar]` 前缀 |
## 五、备份与配置铁律
- **改任何 profile 配置前先备份**,改完**回读验证**(不要假定写入即生效):
```bash
cp ~/.dsh/profiles/<profile>/package.json{,.bak.$(date +%s)}
# 改完:
python3 -c "import json;json.load(open('$HOME/.dsh/profiles/<profile>/package.json'));print('JSON OK')"
```
- **DSH 应用配置的类型陷阱**:部分字段(如材质/透明度类的 `macosMaterial`)必须是**字符串**
(`"off"` / `"transparent"`)。若被写成 YAML 布尔(`false`),DSH 会启动进**恢复模式**。
改完务必回读校验类型:
```bash
python3 -c "import yaml;d=yaml.safe_load(open('$HOME/.dsh/settings.yaml'));v=d.get('macosMaterial');print(v, type(v).__name__)"
```
- **不要回滚/手工编辑** profile 的 `pnpm-lock.yaml`:由 pnpm 重建;手工改会导致解析错乱。
- **凭据不进仓库**:远端推送用系统凭据助手(`git credential fill`),仓库内不写 token。
## 六、百科(wiki)本身的维护
百科源文件版本化在仓库 `docs/wiki/`,通过脚本推送到 Gitea wiki:
```bash
bash scripts/push-wiki.sh # 推送/更新全部页面(幂等:有则 PATCH,无则 POST)
bash scripts/push-wiki.sh 05-运维手册 # 只推一页
```
凭据来自 `git credential fill`(与推送代码同一套,不落盘)。修改文档的推荐流程:
**改 `docs/wiki/*.md` → 提交到仓库 → 跑脚本推送**,这样 wiki 与代码同源可回溯。
## 七、诊断与日志
| 信号 | 含义 | 位置 |
| --- | --- | --- |
| 左下角红色诊断条 `[dsh-apes-sidebar] <phase> error: …` | client 生命周期某阶段抛错(mount / native / 懒加载等) | 页面左下角(`z-index: 2147483000`),同时 `console.error` 打前缀 |
| 控制台 `[dsh-apes-sidebar] …` | 同上,且带上具体阶段名 | 开发者工具 Console |
| 「插件像没装」且**无任何日志** | 宿主兼容性预检失败(peer 不匹配)→ **静默禁用** | 只能靠版本矩阵排查,见 [06-故障排查](06-故障排查) §1 |
| 界面正常但面板层级错乱 | 面板宿主挂载点/层叠上下文问题 | 见 [02-系统架构](02-系统架构) §四 |
## 八、性能与容量
| 项 | 观察值 | 说明 |
| --- | --- | --- |
| `lib/client.js` | ≈ 0.9 MB | 主 bundle,始终加载 |
| `lib/client-mermaid.js` | ≈ 7 MB(+12 MB map) | 懒加载,仅在需要渲染 Mermaid 时拉取 |
| `lib/client-editor.js` | ≈ 2 MB | 懒加载,仅打开编辑器时 |
| `lib/client-terminal.js` | ≈ 0.5 MB | 懒加载,仅打开终端时 |
| 全量测试 | 约 3–4 分钟 | 变更后按需跑全量,日常可只跑相关 spec |
主 bundle 不应静态引入上述重型库;新增重型依赖一律走 `src/client/chunks/`(懒加载 chunk)。
+208
View File
@@ -0,0 +1,208 @@
# 06 · 故障排查
> 每条都给出**症状 → 根因(机制)→ 处置 → 验证**。标注 ✅ 的条目是在真机上定位并验证过的案例。
## 速查表
| 症状 | 最可能根因 | 跳到 |
| --- | --- | --- |
| 装完「像没装」,界面无变化且**无任何日志** | peer 范围与宿主版本不匹配 → 静默禁用 | §1 |
| 页面出现**两个侧边栏** / 两套开合按钮 | 双挂载(两条线同装,或残留挂载行) | §2 |
| HTML 预览只显示 `forbidden` | 桌面壳渲染器围栏 + 不透明源 iframe | §3 |
| 插件面板盖住宿主/第三方弹窗(如插件市场) | 面板宿主挂在层叠上下文之外 | §4 |
| 终端打不开 / `node-pty` 加载失败 | pnpm 拦截原生构建(仅 v0.19.1 线) | §5 |
| 改了代码,界面没变 | 产物未重建 / profile 副本未刷新 / 浏览器缓存 | §6 |
| DSH 拒绝加载插件、entry pending | 插件声明(`dsh.plugin.json` / bundle patch)不合法 | §7 |
| DSH 启动进**恢复模式** | profile 配置被写成错误类型(如 YAML 布尔) | §8 |
---
## §1 装完「像没装」:静默禁用 ✅
**症状**:profile 里依赖装了、文件也在,但界面没有任何变化;日志里也没有插件相关报错。
**根因**:宿主启动时做兼容性预检,判定 `peerDependencies` 与自身版本是否满足:
```
semver.satisfies(hostVersion, peerRange, { includePrerelease: true })
```
caret 范围**跨 minor 必然为 false**(例如 `^0.1.5-rc.1` 对 `0.2.0`)。判定失败后宿主的处理是
**跳过这一行**——不抛错、不打日志、不加载。
**处置**:核对宿主版本,改用匹配的版本线(见 [04-部署指南](04-部署指南) §1.1)。
```bash
grep -m1 '"version"' "/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh/package.json"
grep -n 'dsh-client-ui-sidebar-right' <插件仓库>/package.json # 看 peer 范围
```
**验证**:重启后右列出现插件 tab;`lib/client.js` 里能 grep 到 `apes:agents`。
**教训**:这一条没有日志可查,因此**部署前先看版本矩阵**是唯一可靠的预防手段。
## §2 两个侧边栏 / 两套开合按钮 ✅
**症状**:窗口里出现两套右侧面板与两组开合控件。
**根因**:两个方向都可能:
1. **旧版遗留**:v0.18.0 基线时插件自绘右侧面板与开关(`toggleCluster`)。升级后自绘部分应已移除。
2. **双挂载**:同一条线被挂两次(profile 自己的 `cordis.patch.yml` 里还留着旧挂载行,
与 bundle patch 同时生效),或者本 fork 线与上游线同时安装(两者都注册 `ctx.betterSidebar`,
entry id 冲突,第二个被丢弃)。
**处置**:
```bash
# 确认产物里已经没有自绘开关
grep -c toggleCluster <插件仓库>/lib/client.js # 期望 0
# 检查是否双挂载:profile 里是否同时存在 bundle 行 与 手写挂载行
grep -n "dsh-apes-sidebar\|dsh-better-sidebar" ~/.dsh/profiles/<profile>/cordis.patch.yml ~/.dsh/profiles/<profile>/package.json
```
- 有残留手写挂载行 → 删除它,只保留一条通道。
- 两条线同装 → `bash scripts/switch.sh aps`(或 `upstream`)二选一。
- 装了聚合包(如 `@linxin666/dsh-web-ui-all`)→ 确认聚合包在本包之前,让包内守卫生效。
**验证**:右列只剩一套面板与一个开合控件。
## §3 HTML 预览显示 `forbidden` ✅
**症状**:预览 HTML 文件时 iframe 里只有一行裸文本 `forbidden`;而「临时解锁」后又能看。
**根因(三层)**:
1. DSH Desktop 在**扩展模式**下给所有路由套了一层请求围栏:`ordinaryBrowserEnabled` 恒为 false,
不带 Electron 注入的 `x-dsh-desktop-renderer` 头的请求一律 `403 + "forbidden"`;
2. 该头只注入给 **mainFrame** 与**同源**子框架;
3. 预览 iframe 的 sandbox 令牌不含 `allow-same-origin`,请求时其源为 `null` → 拿不到头 → 403。
与「是否升级了插件基线」无关(v0.18.0 起行为一致)。
**处置**:插件已在 1.2.0+ 用 **`srcDoc` 交付**修复(主框架取文档 → 注入 `<base>` → 注入 iframe),
`main` 上对应提交 `4c242ac`。**不要**通过给 iframe 加 `allow-same-origin` 来"修"——那是安全降级。
**验证**:
- 沙箱态下自包含 HTML(含内联脚本)能正常渲染;
- 有外链资源的 HTML 在沙箱态下缺资源属**已知限制**,点「临时解锁」后应完整显示。
**排查命令**(确认宿主围栏在起作用):
```bash
lsof -nP -iTCP -sTCP:LISTEN | grep -i dsh # 找宿主端口(每次启动可能不同)
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:<端口>/" # 无渲染器头 → 403
```
## §4 插件面板盖住宿主/第三方弹窗 ✅
**症状**:打开「社区插件市场」(宿主自带 `dsh-community-market`)时,弹窗左下角被本插件的
文件面板整齐切掉一块。
**根因**:桌面壳的有框模式会给上游 `#root` 打标 `data-dsh-desktop-content-viewport` 并施加
`isolation: isolate`,使 `#root` 成为**层叠上下文**;宿主的 overlay 层(`.dshDesktopOverlay`,
`z-index: 1000`)位于其中。插件面板宿主若挂在 `document.body`(该上下文之外)且是定位元素,
就会画在整个上下文之上,把 overlay 里的弹窗盖住。
**处置**:已修(`4422001`)——面板宿主改为挂进 `#root[data-dsh-desktop-content-viewport]`,
无标记时回退 `document.body`。**不要**通过调大 `z-index` 解决(25 的取义见
[02-系统架构](02-系统架构) §四;乱调会破坏与宿主其他层级的关系)。
**验证**:插件市场弹窗完整盖住本插件面板,同时本插件面板仍在对话内容之上。
## §5 终端不可用 / `node-pty` 加载失败(仅 v0.19.1 线)✅
**症状**:终端面板报 shell 启动失败或提示 `node-pty` 加载失败;`pnpm install` 阶段报
"ignored build scripts"。
**根因**:pnpm 11 默认**拦截依赖的构建脚本**,`node-pty` 的原生二进制没被编译。
(`main` 依赖 `node-pty`;`sync/v0.24.1` 已移除该依赖,终端由宿主内置承担。)
**处置**:
```bash
cd ~/.dsh/profiles/<profile>
pnpm approve-builds # 选中 node-pty
pnpm rebuild node-pty # 必要时
# ⌘Q 完整退出 DSH 再启动
```
**验证**:终端能开出 shell;`node_modules/node-pty/build/Release/*.node` 存在。
## §6 改了代码,界面没变 ✅
**症状**:源码已改、`pnpm build` 也跑了,但界面行为仍是旧的。
**根因(三个叠加因素)**:
1. 产物**非字节确定性**,且 profile 的 `file:` 依赖是**复制**语义 → profile 里仍是旧副本;
2. `lib/client.js` 是浏览器的 `window.__ModuleLoader__.load({...})` bundle,**不是 CJS/ESM**,
在 Node 里 `require` 会因 `window is not defined` 报错——因此不能用「在 Node 里试跑」来验证前端改动;
3. host 半的改动必须重启进程(client 半有热加载,host 半没有)。
**处置**:
```bash
cd <插件仓库> && pnpm build
cd ~/.dsh/profiles/<profile> && pnpm install # 刷新 file: 副本
# ⌘Q 完整退出 DSH → 重启
```
**验证**:比对两边产物 sha:
```bash
shasum -a 256 <插件仓库>/lib/client.js \
~/.dsh/profiles/<profile>/node_modules/dsh-apes-sidebar/lib/client.js
```
## §7 DSH 拒绝加载插件 / entry pending
**症状**:插件目录存在,但宿主不加载,或 loader 报 entry pending / 恢复模式。
**根因**:插件的声明面不合法或不被接受,常见于:
- `dsh.plugin.json` 的 `engines.dsh` 与宿主不符;
- bundle patch / 挂载行的 `insert` 结构不合法(例如把声明写成宿主无法解析的形态);
- 同 id 重复挂载(`duplicate loader entry id` / `duplicate prefix route`)。
**处置**:
```bash
cat <插件仓库>/dsh.plugin.json # 检查 id / engines / main
cat <插件仓库>/cordis.patch.yml # 检查 insert 结构与守卫表达式
grep -n "cordis.patch\|dsh-apes-sidebar" ~/.dsh/profiles/<profile>/package.json
```
确保只有一条挂载通道、`name` 与实际包名一致(本 fork 为 `dsh-apes-sidebar`),
`engines.dsh` 与宿主版本相容。改完 `pnpm install` + **完整重启**。
**验证**:启动日志无 loader 报错;右列出现插件 tab。
## §8 DSH 启动进「恢复模式」
**症状**:DSH 启动后进入恢复/安全模式,插件与侧边栏都不正常。
**根因**:profile 或应用配置被写成**错误类型**。典型是材质/透明度字段要求**字符串**
(`"off"` / `"transparent"`),但被 YAML 序列化成了布尔 `false` —— 宿主按类型校验失败即进恢复模式。
**处置与验证**:
```bash
python3 -c "import yaml;d=yaml.safe_load(open('$HOME/.dsh/settings.yaml'));v=d.get('macosMaterial');print(repr(v), type(v).__name__)"
# 期望:'off' str(或 'transparent' str);若为 False bool → 改回字符串
```
改配置前**先备份**,改完**回读校验类型**(见 [05-运维手册](05-运维手册) §五)。
---
## 附:定位手法(本次会话验证有效)
1. **先按用户可见文案 grep 找归属**:`grep -rl "<界面上的中文短语>" <app>/lib <profile>/node_modules`。
若两边都搜不到,说明它来自**运行时下载/缓存**(例如市场插件),再按 `NSUserData` 缓存或
`desktop-market/state.json` 之类线索去找提供方。
2. **再读提供方自己的 CSS/DOM 结构**:拿到它声明的 z-index、定位方式与所在槽位
(如 `renderSlot("shell.overlay")`),据此判断是「它太高」还是「我们太高」。
3. **层叠问题先找 `isolation` / `transform` / `filter` / `contain`**:这些才是层叠上下文与包含块的真正来源,
比 z-index 数值更关键。
4. **像素证据要用原图量**:交给视觉模型的截图会被缩放(1920→1280),裁剪坐标 ≠ 屏幕坐标;
用 PIL 在原图上量边界、比对 RGB 剖面,再下结论。
+164
View File
@@ -0,0 +1,164 @@
# 07 · 开发与测试
> 面向改代码的人(与 agent)。**先读 [02-系统架构](02-系统架构) §六「关键不变量」**,
> 再动手改,能省掉大部分回退。
## 一、工程约定
| 项 | 约定 |
| --- | --- |
| 语言 | TypeScript **strict**(含 `noUncheckedIndexedAccess`)、ESM、导入写 `.ts` 扩展名 |
| 构建 | `tsdown`(rolldown),host/client 双产物;懒加载 chunk 走 `src/client/chunks/` |
| 包管理 | `pnpm`(11.x);`pnpm install` 会触发 `prepare` 构建 |
| 测试 | `vitest`;默认 node 环境,需要 DOM 的 spec 用 `// @vitest-environment jsdom` |
| 宿主 peer | `dsh-better-sidebar`(本仓库已改名)**永不打包**,必须留在 peer/neverBundle 一侧 |
| 命名 | 信息层 tab id 前缀 `apes:`;插件名 `dsh-apes-sidebar` |
| 颜色 | 只用 `--dsw-alias-*` 设计令牌(`var(--token, <回退>)`),不写死色值 |
## 二、目录结构(`main` 实况)
```
src/
index.ts HOST 入口:name / inject / apply(ctx, config),路由与围栏装配
invariant.ts 零依赖不变量面(host/client 共用)
context-types.ts cordis 服务面契约(ctx.sessions / sidebarRight / …)
trust-fence.ts 请求信任围栏(Host 头 / 令牌)
html-route.ts /sidebar/html 路由(路径围栏 + 体积上限)
config.ts 挂载行配置 schema 与默认值(readLimit / mediaLimit / …)
fs-*.ts git.ts … 文件系统、Git、会话、任务、终端等 host 能力
client/
index.tsx CLIENT 入口(apply):装配 store/service/native/apes
service.ts ctx.betterSidebar 服务实现(对外 API + 能力清单)
state.ts 侧边栏状态存储(store)
native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题)
builtins/ 内置面板:editor / git / subagent / sidechat / terminal / browser + 预览器
sidebar/ 自绘底部工作台与开合控件
apes/ 信息层三 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts)
changes/ diff/ 文件变动与 diff 视图
chunks/ 懒加载 chunk:editor / locale / mermaid / terminal
locales*.ts i18n(`locales.ts` + 19 个语言覆盖文件)
*.module.css 组件样式(含 sidebar.module.css:面板宿主层契约)
tests/ vitest specs(131 个文件,含 e2e/ 的挂载脚本型用例)
scripts/ install / switch / e2e 挂载 / 发布辅助
docs/ external-plugin-guide.md(对外契约)+ plans/(上游历史设计)+ wiki/(本百科源文件)
```
## 三、门禁(提交前必跑)
```bash
pnpm typecheck # tsc --noEmit(strict)
pnpm build # rm -rf lib && tsc(d.ts) && tsdown
pnpm test # vitest run
```
当前基线(本次会话实测):`main` = 131 文件 / **1367 通过** + 9 skip;
`sync/v0.24.1` = 141 文件 / **1515 通过** + 9 skip。全量 test 约 3–4 分钟。
**任何一条不过就不要提交**;三条全绿才算完成。
## 四、测试体系(按层次)
| 层次 | 环境 | 手法 | 代表 spec |
| --- | --- | --- | --- |
| 宿主插件形状 / 对外契约 | node | 断言导出面与语义(含与 `package.json` 的版本一致性) | `smoke.spec.ts` · `plugin-shape.spec.ts` · `service.spec.ts` · `consumer-types.ts` |
| 原生侧边栏适配 | node | 用「类型为真、运行时为假」的 mock 注册表 | `native-surface.spec.ts` |
| 面板宿主与层叠契约 | node + jsdom | CSS 断言 + DOM 挂载点解析 | `panel-host-css.spec.ts` · `panel-host-parent.spec.ts` |
| 预览交付 | node + jsdom | 纯逻辑(`<base>` 注入)+ 客户端挂载(stub `fetch`,`createRoot` + `act`) | `html-preview.spec.ts` · `html-preview-frame.spec.tsx` |
| 沙箱契约 | node | 对渲染结果做**字符串断言**(服务端渲染把 `srcDoc` 输出为 camelCase `srcDoc=""`,与客户端设置的真实属性 `srcdoc` 不同 —— 断言方式随之不同) | `sandbox-views.spec.tsx` |
| 信息层 tab | node | mock `ctx.sessions.list` 与投影 | `apes-tabs.spec.ts` |
| 桌面环境探测 | node | stub 全局(URL stamp / preload 标记) | `desktop-env.spec.ts` |
约定:
- **不引入 jsdom 到默认环境**:需要 DOM 的 spec 在文件头写 `// @vitest-environment jsdom`。
- **不依赖真实 DSH 宿主**:需要真宿主的用例归到 `tests/e2e/`(脚本型挂载),不进单元门禁。
- 新增「契约」必须**同时新增 pin 它的 spec**(例如本文档提到的 25 层级、沙箱令牌、`srcDoc` 交付方式,
都有对应断言)。改契约 = 改代码 + 改 spec,两者必须一起提交。
## 五、扩展点(对外服务)
其他插件通过 `ctx.betterSidebar` 接入:
```ts
// 注册一个 tab 类型
await ctx.betterSidebar.registerTab(descriptor)
// 注册一个文件预览器
await ctx.betterSidebar.registerFileViewer(viewer)
```
- 服务按**能力清单**做前向兼容门控(能力只增不减),消费方应据此判断可用性而非版本号比较。
- 完整契约、字段表与示例见仓库内 [docs/external-plugin-guide.md](../blob/main/docs/external-plugin-guide.md)。
- 改服务面时:**只能新增**;删除或改语义需同步更新能力清单、指南与本百科。
## 六、上游同步:测量式 re-base(本项目的方法论)
**不 merge 上游**。上游每个版本差距可达数百文件,直接合并会引入无关变更并放大冲突面;
本项目改用「先测量、再重建、后补增量」:
### 步骤
**① 测出自己的增量(把改名归一化掉)**
```python
# 对每个变更文件:取出两边 blob,把改名归一化后比较
old = git('show', f'{base}:{path}'); new = git('show', f'HEAD:{path}')
rename_only = old.replace('dsh-better-sidebar', 'dsh-apes-sidebar') == new
```
→ 得到三张清单:**仅改名**(自动处理)/ **真改动** / **新增**。数量级参考:v0.19.1→v0.24.1 为
78 / 11 / 10。
**② 确定改名的精确范围(不要重新推断)**
```bash
# 仍保留上游名字的文件 = 有意保留(本仓库为 README.md、package.json、docs/plans/*)
comm -12 <(git grep -l 'dsh-better-sidebar' <旧基线> | cut -d: -f2 | sort) \
<(git grep -l 'dsh-better-sidebar' HEAD | cut -d: -f2 | sort)
```
其余文件只做子串替换 `dsh-better-sidebar` → `dsh-apes-sidebar`;**camelCase 服务名 `betterSidebar` 必须保留**。
**③ 造一个"改名归一化基座",让增量与改名解耦**
```bash
git checkout -B tmp/norm-<旧基线> <旧基线 tag>
# → 套同一口径改名 → commit
git diff tmp/norm-<旧基线> HEAD -- . ':(exclude)pnpm-lock.yaml' > /tmp/apes-delta.patch
# 校验:这条 diff 的文件清单必须恰好等于 ① 的「真改动 + 新增」
```
**④ 在新基线上重建 + 三点合并**
```bash
git checkout -B sync/<新基线 tag> <新基线 tag>
# → 套同一口径改名 → commit("rename only")
git apply -3 /tmp/apes-delta.patch # 冲突会以 <<<<<<< ours / >>>>>>> theirs 留存(命令本身不失败)
```
**⑤ 逐条判读冲突(只有五种形态)**
| 形态 | 判定 |
| --- | --- |
| 版本/身份 JSON | 取**新基线**侧,再改成本 fork 的版本;两侧各自新增的元数据取**并集**(依赖、files 条目、`forkedFrom` 块) |
| 上游搬走了某个 import | 取上游的新路径,保留本 fork 的新 import |
| 上游新环境里的注册块 | 保留本 fork 的块,放进上游的新上下文 |
| 同一函数两边都定义(上游上移/下移了它) | 删除本 fork 那份;用 `grep -c 'const <name>'` 断言只剩 1 处 |
| 长文档(README) | 整体取上游原文,再机械套上本 fork 的两处增量:顶部 fork notice + 包名替换(**跳过** `github.com/<上游 org>` 与徽章域名行) |
**⑥ 迁移检查(API 漂移)+ 门禁**
- 先**解完所有冲突标记再** `pnpm install`(`prepare: tsdown` 会因为语法错误直接失败),
并**删掉 lockfile 重新生成**(不要把旧 lockfile 带过来)。
- 逐条跑 `pnpm typecheck` 修类型漂移。经验:漂移通常集中在**本 fork 自己新增的模块**里。
上游会在 `context-types.ts` 的注释里写清迁移路径(例:0.1.6 的 `subagentsByParent`
→ 0.1.7 的 `projectionsBySession[id].values.subagentCatalog`,`list.current` 被删除),照注释做即可。
- 最后跑门禁三连,并且**用 `grep` 核对关键标记**(tab id、`srcDoc`、`dsh-desktop-content-viewport` 等)。
**⑦ 收尾(纪律)**
- 安全锚点:`backup/pre-sync-<新基线>` 分支 + `apes-v<版本>` tag,**推送远端**。
- **不要把新线同步进正在使用的 profile**:peer 范围可能已跨 minor,同步过去只会「静默失效」,
同时把本机环境弄脏。保持 profile 在已验证的线上。
## 七、提交与分支纪律
- 提交信息用 `type(scope): 摘要`(`feat` / `fix` / `chore` / `docs` / `refactor`),正文说明**为什么**与验证方式。
- 一个提交一件事;re-base 类改动拆成「rename only」+「增量移植」两个提交,便于回溯与回滚。
- 分支命名:`sync/<上游 tag>`(跟随上游)、`backup/pre-sync-<基线>`(锚点)、其余按需。
- 提交前:门禁三连 + 关键标记 grep + (若涉及部署)提醒 profile 重装与完整重启。
+82
View File
@@ -0,0 +1,82 @@
# apes-sidebar 项目百科
> DeepSeek Harness(DSH)的**服务化右侧边栏工作台** + **apes 信息层**。
> 本项目是 [`dsh-better-sidebar`](https://github.com/omdsh-dev/DSH-better-sidebar)(MIT)的 fork,
> 在其「文件 / 文件变动 / 任务 / 侧边对话 / 终端 / 浏览器」工作台之上,追加三个信息层标签页:
> **智能体(agents)/ 应用(apps)/ 画像(profile)**。
## 一、项目速览
| 项 | 值 |
| --- | --- |
| 仓库 | `figmar/apes-sidebar`(公开) |
| 默认分支 | `main` |
| 许可证 | MIT(fork 自 `omdsh-dev/DSH-better-sidebar`) |
| 语言 / 构建 | TypeScript(strict)· `tsdown`(rolldown)· `pnpm` |
| 宿主 | DSH Desktop(Electron 壳)或 DSH Web profile |
| 上游跟随方式 | 不合并上游提交,而是**测量式 re-base**(见 [07-开发与测试](07-开发与测试)) |
## 二、两条版本线(重要)
本仓库同时维护两条互相独立、**不能同时挂载**的版本线(两条线都注册 `ctx.betterSidebar`,
cordis entry id 冲突,第二个会被丢弃):
| 分支 | 版本 | 上游基线 | peer / 宿主要求 | 状态 |
| --- | --- | --- | --- | --- |
| **`main`** | 1.2.0 | `dsh-better-sidebar` **v0.19.1**(`8753096`) | `^0.1.5-rc.1`(DSH **0.1.5-rc.1+**) | 已在本机 DSH Desktop 2.0.10(`dsh 0.1.5-rc.2`)实机运行 |
| **`sync/v0.24.1`** | 1.3.0 | `dsh-better-sidebar` **v0.24.1**(`a2751cf`) | `^0.2.0-rc.1`(DSH **0.2.0-rc.1+**) | 构建与测试全绿,**待具备 0.2.0 宿主实机验证** |
> ⚠️ **宿主的兼容性预检是「静默禁用」,不报错、不加载、无日志。**
> 宿主用 `semver.satisfies(host, peerRange, { includePrerelease: true })` 判定;caret 范围跨 minor
> 不成立(`^0.1.5-rc.1` 在 `0.2.0` 宿主上为 false),于是整行被跳过。
> **现象 = 插件"好像没装"**。选线请先看宿主版本,见 [04-部署指南](04-部署指南)。
## 三、文档地图
| 页面 | 内容 |
| --- | --- |
| [01-需求分析](01-需求分析) | **官方原生 vs 装了本包**:官方右列只有「文件树 + 只读文本预览」(附官方包证据),九个 tab 是一个整体需求;7 条需求变化洞察、U1–U6、已知限制 |
| [02-系统架构](02-系统架构) | host/client 拆分、三层接口面、面板宿主层级契约、关键不变量 |
| [03-功能说明](03-功能说明) | 全部标签页与预览器、信息层数据来源、i18n、主题令牌、安全边界 |
| [04-部署指南](04-部署指南) | 环境矩阵、选线、profile 接线、构建、验收清单、卸载 |
| [05-运维手册](05-运维手册) | 版本线与分支策略、回滚剧本、发布纪律、巡检、备份铁律 |
| [06-故障排查](06-故障排查) | 症状 → 根因 → 处置(含全部实机验证过的案例) |
| [07-开发与测试](07-开发与测试) | 工程约定、目录、门禁、测试体系、扩展点、上游 re-base 方法 |
## 四、快速开始(TL;DR)
```bash
# 1) 构建(Node ≥ 20,pnpm)
pnpm install && pnpm typecheck && pnpm build && pnpm test
# 2) 接到 DSH profile(以桌面 profile 为例,使用 file: 链接依赖)
# 在 ~/.dsh/profiles/desktop/package.json 中:
# "dsh-apes-sidebar": "file:/绝对路径/dsh-apes-sidebar"
# 并在 dsh.profile.bundles 列表里加入 "dsh-apes-sidebar"
# 3) 让 profile 重新安装依赖,然后 ⌘Q 完整退出 DSH 再启动
```
完整步骤与坑见 [04-部署指南](04-部署指南)。
## 五、仓库内的事实源
| 位置 | 说明 |
| --- | --- |
| `README.md` | 面向用户的功能与安装说明(主体沿用上游文档,顶部有 fork notice) |
| `AGENTS.md` | 面向 agent/协作者的工程约定(构建、布局、硬性规则、测试约定)⚠️ 注意:本文件当前描述的是**另一套 from-scratch 实现**,与 `main` 的 fork 树不一致,以源码为准 |
| `docs/external-plugin-guide.md` | 插件接入指南:`ctx.betterSidebar.registerTab / registerFileViewer` 的对外契约 |
| `docs/plans/` | 上游历史设计文档(保持上游原样,未随 fork 改名) |
| `docs/wiki/` | **本百科的源文件**(Gitea wiki 页面的可版本化副本,由 `scripts/push-wiki.sh` 推送) |
| `scripts/switch.sh` | 两线切换脚本(`aps` ↔ `upstream`),含 `--status` |
## 六、谱系说明(如实交代)
`figmar/apes-sidebar` 这个仓库先后承载过同一产品族的三代实现:
1. **v1**:早期 JS bundle 版侧边栏插件(仓库描述里提到的「可配置显示名 / 预设 feyOS」即出自那一代);
2. **v2**:from-scratch 重写(`src/index.ts` + `src/client/index.tsx` 结构,五个 `apes:*` tab),
即 `AGENTS.md` 所描述的那一套,其产物不在当前 `main` 上;
3. **当前线(v0.19.1 / v0.24.1 fork)**:即 `main` 与 `sync/v0.24.1` 上的代码——本百科描述的对象。
若需要把本百科改写成描述 v2 重写版,请告知,源文件在 `docs/wiki/` 可直接替换。
+2 -2
View File
@@ -1,10 +1,10 @@
{
"id": "dsh-external/dsh-apes-sidebar",
"version": "1.1.0",
"version": "1.2.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
"dsh": ">=0.1.2-rc.1"
"dsh": ">=0.1.5-rc.1"
},
"contributes": {
"tools": [],
+88
View File
@@ -0,0 +1,88 @@
// 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 } },
},
)
+53 -36
View File
@@ -1,18 +1,12 @@
{
"name": "dsh-apes-sidebar",
"version": "1.1.0",
"description": "APE fork of dsh-better-sidebar v0.18.0: VSCode-like right sidebar (explorer / editor / terminal / git / browser) + apes:* information-layer tabs (agents / apps / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
"version": "1.2.0",
"description": "APE fork of dsh-better-sidebar (base v0.19.1): VSCode-like right sidebar (explorer / editor / terminal / git / browser) isolated per session, plus apes:* information-layer tabs (agents / apps / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"forkedFrom": {
"name": "dsh-better-sidebar",
"version": "0.18.0",
"commit": "9e1a034",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"publishConfig": {
"access": "public"
},
@@ -55,6 +49,7 @@
"@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"
@@ -72,11 +67,11 @@
"src",
"scripts/install.sh",
"scripts/install.ps1",
"scripts/switch.sh",
"cordis.patch.yml",
"README.md",
"README_EN.md",
"LICENSE"
"LICENSE",
"scripts/switch.sh"
],
"scripts": {
"build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
@@ -86,10 +81,12 @@
"watch": "tsdown --watch",
"prepare": "tsdown",
"test": "vitest run",
"test:windows": "vitest run --maxWorkers=1",
"test:mount": "bash scripts/e2e-mount.sh",
"test:mount:aggregate": "bash scripts/e2e-aggregate-mount.sh",
"clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"",
"check:consumer-types": "bash scripts/check-consumer-types.sh",
"lint": "eslint .",
"switch": "bash scripts/switch.sh",
"switch:status": "bash scripts/switch.sh --status",
"switch:aps": "bash scripts/switch.sh aps",
@@ -97,20 +94,21 @@
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.2",
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
"@deepseek-ai/dsh-session": "^0.1.5-rc.2",
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
"@deepseek-ai/dsh-subagent": "^0.1.5-rc.2",
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
"@deepseek-ai/cordis": "^4.0.4",
"@deepseek-ai/dsh-agent": "^0.2.0-rc.2",
"@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "^0.2.0-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2",
"@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.2.0-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
"@deepseek-ai/dsh-invariants": "^0.2.0-rc.2",
"@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
"@deepseek-ai/dsh-session": "^0.2.0-rc.2",
"@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
"@deepseek-ai/dsh-subagent": "^0.2.0-rc.2",
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -118,6 +116,9 @@
"peerDependenciesMeta": {
"@huanlin/dsh-plugin-better-locale": {
"optional": true
},
"@deepseek-ai/dsh-client-ui-sidebar-right": {
"optional": true
}
},
"dependencies": {
@@ -155,11 +156,13 @@
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "0.1.5-rc.2",
"@deepseek-ai/dsh-agent-loop": "0.1.5-rc.2",
"@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
"@deepseek-ai/dsh-code-runtime": "0.1.5-rc.2",
"@deepseek-ai/dsh-host-webserver": "0.1.5-rc.2",
@@ -167,6 +170,7 @@
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
"@deepseek-ai/dsh-scope": "0.1.5-rc.2",
"@deepseek-ai/dsh-session": "0.1.5-rc.2",
"@deepseek-ai/dsh-session-persistence": "0.1.5-rc.2",
"@deepseek-ai/dsh-session-projection": "0.1.5-rc.2",
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
"@deepseek-ai/dsh-subagent": "0.1.5-rc.2",
@@ -174,27 +178,25 @@
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
"@deepseek-ai/dsh-user-approval": "0.1.5-rc.2",
"@deepseek-ai/dsh-util-time": "0.1.5-rc.2",
"@eslint/js": "^10.0.1",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
"@shikijs/langs": "^4.3.1",
"@types/mdast": "^4.0.4",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"cordis": "^4.0.0-rc.8",
"jsdom": "^29.1.1",
"lightningcss": "^1.32.0",
"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",
"anser": "^2.3.5",
"shiki": "^4.3.1",
"@shikijs/langs": "^4.3.1",
"cordis": "^4.0.0-rc.8",
"eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"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",
@@ -205,6 +207,21 @@
"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-symbol": "^2.0.1",
"micromark-util-types": "^2.0.2",
"react": "^18.2.0",
"react-dom": "18.2.0",
"shiki": "^4.3.1",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"typescript-eslint": "^8.69.0",
"unrun": "^0.2.39",
"vitest": "^4.1.8"
},
"forkedFrom": {
"name": "dsh-better-sidebar",
"version": "0.19.1",
"commit": "8753096",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
}
}
-195
View File
@@ -1,195 +0,0 @@
{
"name": "dsh-apes-sidebar",
"version": "1.0.0",
"description": "APE fork of dsh-better-sidebar v0.18.0: VSCode-like right sidebar (explorer / editor / terminal / git / browser) + apes:* information-layer tabs (tasks / agents / apps / messages / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"forkedFrom": {
"name": "dsh-better-sidebar",
"version": "0.18.0",
"commit": "9e1a034",
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"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-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",
"scripts/switch.sh",
"cordis.patch.yml",
"README.md",
"README_EN.md",
"LICENSE"
],
"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",
"switch": "bash scripts/switch.sh",
"switch:status": "bash scripts/switch.sh --status",
"switch:aps": "bash scripts/switch.sh aps",
"switch:upstream": "bash scripts/switch.sh upstream"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@deepseek-ai/dsh-agent": "^0.1.2-rc.1",
"@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
"@deepseek-ai/dsh-invariants": "^0.1.2-rc.1",
"@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
"@deepseek-ai/dsh-session": "^0.1.2-rc.1",
"@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
"@deepseek-ai/dsh-subagent": "^0.1.2-rc.1",
"@deepseek-ai/dsh-tools": "^0.1.2-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
}
},
"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.2-rc.1",
"@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-locale": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
"@deepseek-ai/dsh-code-runtime": "0.1.2-rc.1",
"@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1",
"@deepseek-ai/dsh-invariants": "0.1.2-rc.1",
"@deepseek-ai/dsh-llm": "0.1.2-rc.1",
"@deepseek-ai/dsh-scope": "0.1.2-rc.1",
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
"@deepseek-ai/dsh-session-projection": "0.1.2-rc.1",
"@deepseek-ai/dsh-settings": "0.1.2-rc.1",
"@deepseek-ai/dsh-subagent": "0.1.2-rc.1",
"@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1",
"@deepseek-ai/dsh-tools": "0.1.2-rc.1",
"@deepseek-ai/dsh-user-approval": "0.1.2-rc.1",
"@deepseek-ai/dsh-util-time": "0.1.2-rc.1",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
"@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",
"cordis": "^4.0.0-rc.8",
"jsdom": "^29.1.1",
"lightningcss": "^1.32.0",
"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"
}
}
-195
View File
@@ -1,195 +0,0 @@
{
"name": "dsh-apes-sidebar",
"version": "1.0.0",
"description": "APE fork of dsh-better-sidebar v0.18.0: VSCode-like right sidebar (explorer / editor / terminal / git / browser) + apes:* information-layer tabs (tasks / agents / apps / messages / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"forkedFrom": {
"name": "dsh-better-sidebar",
"version": "0.18.0",
"commit": "9e1a034",
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"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-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",
"scripts/switch.sh",
"cordis.patch.yml",
"README.md",
"README_EN.md",
"LICENSE"
],
"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",
"switch": "bash scripts/switch.sh",
"switch:status": "bash scripts/switch.sh --status",
"switch:aps": "bash scripts/switch.sh aps",
"switch:upstream": "bash scripts/switch.sh upstream"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.2",
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
"@deepseek-ai/dsh-session": "^0.1.5-rc.2",
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
"@deepseek-ai/dsh-subagent": "^0.1.5-rc.2",
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
"@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
}
},
"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.2",
"@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
"@deepseek-ai/dsh-code-runtime": "0.1.5-rc.2",
"@deepseek-ai/dsh-host-webserver": "0.1.5-rc.2",
"@deepseek-ai/dsh-invariants": "0.1.5-rc.2",
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
"@deepseek-ai/dsh-scope": "0.1.5-rc.2",
"@deepseek-ai/dsh-session": "0.1.5-rc.2",
"@deepseek-ai/dsh-session-projection": "0.1.5-rc.2",
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
"@deepseek-ai/dsh-subagent": "0.1.5-rc.2",
"@deepseek-ai/dsh-system-prompt": "0.1.5-rc.2",
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
"@deepseek-ai/dsh-user-approval": "0.1.5-rc.2",
"@deepseek-ai/dsh-util-time": "0.1.5-rc.2",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
"@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",
"cordis": "^4.0.0-rc.8",
"jsdom": "^29.1.1",
"lightningcss": "^1.32.0",
"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"
}
}
+1557 -168
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,8 +1,8 @@
## =============================================================================
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
# dsh-apes-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-better-sidebar@<version>
# dsh plugin --profile web add dsh-apes-sidebar@<version>
#
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
@@ -52,7 +52,7 @@ param(
[string]$Profile = 'web'
)
$PKG = 'dsh-better-sidebar'
$PKG = 'dsh-apes-sidebar'
$REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
# DSH_HOME:DSH_HOME 环境变量 > %USERPROFILE% > $HOME
@@ -116,11 +116,11 @@ if (!/^\s*allowBuilds:\s*$/m.test(t)) {
}
}
}
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (!/^\s*-\s+dsh-apes-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-apes-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
t += "\nminimumReleaseAgeExclude:\n - dsh-apes-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
@@ -233,7 +233,7 @@ if ($addCode -ne 0) {
$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 'dsh-apes-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。'
Warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
exit 1
}
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# =============================================================================
# push-wiki.sh — 把 docs/wiki/*.md 推送/更新到本仓库的 Gitea wiki
#
# 为什么需要它:本项目的百科源文件**版本化在仓库里**(docs/wiki/),
# Gitea wiki 只是它的发布面。改文档 = 改仓库文件 + 跑本脚本,日志与回溯都在 git 里。
#
# 行为:
# · 幂等:页面不存在则 POST 新建,已存在则 PATCH 覆盖(标题 = 文件名去掉 .md)
# · 凭据来自 `git credential fill`(与推代码同一套),脚本自身不存任何 token
# · 凭据经临时 netrc 文件传给 curl(600 权限,退出即删),不进入 argv
#
# 用法:
# bash scripts/push-wiki.sh # 推送全部页面
# bash scripts/push-wiki.sh 05-运维手册 # 只推指定页面(按文件名/片段匹配)
# WIKI_REMOTE=origin bash scripts/push-wiki.sh # 指定 remote(默认 gitea,缺失则 origin)
# =============================================================================
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
WIKI_DIR="$ROOT/docs/wiki"
FILTER="${1:-}"
# ---- 解析 remote / owner / repo -------------------------------------------
pick_remote() {
for r in "${WIKI_REMOTE:-}" gitea origin; do
[ -z "$r" ] && continue
if git remote get-url "$r" >/dev/null 2>&1; then echo "$r"; return 0; fi
done
echo "无法找到可用的 git remote(试过: ${WIKI_REMOTE:-<空>} gitea origin)" >&2
return 1
}
REMOTE="$(pick_remote)"
URL="$(git remote get-url "$REMOTE")"
case "$URL" in
https://*) ;;
*) echo "本脚本只支持 https remote(当前: $URL)" >&2; exit 1 ;;
esac
HOST="$(printf '%s' "$URL" | sed -E 's#^https://([^/]+)/.*#\1#')"
SLUG="$(printf '%s' "$URL" | sed -E 's#^https://[^/]+/##; s#\.git$##')"
OWNER="${SLUG%%/*}"
REPO="${SLUG#*/}"
API="https://$HOST/api/v1/repos/$OWNER/$REPO/wiki"
echo "wiki 目标 : $OWNER/$REPO @ $HOST (remote: $REMOTE)"
echo "源目录 : docs/wiki"
echo
# ---- 凭据 -----------------------------------------------------------------
CRED="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill 2>/dev/null || true)"
USER="$(printf '%s\n' "$CRED" | sed -n 's/^username=//p')"
PASS="$(printf '%s\n' "$CRED" | sed -n 's/^password=//p')"
if [ -z "$PASS" ]; then
echo "未从 git credential helper 取到 $HOST 的凭据(先确保 git push 能成功)" >&2
exit 1
fi
NETRC="$(mktemp)"
chmod 600 "$NETRC"
trap 'rm -f "$NETRC"' EXIT
printf 'machine %s\nlogin %s\npassword %s\n' "$HOST" "$USER" "$PASS" > "$NETRC"
json_body() { # $1 = file
python3 - "$1" <<'PY'
import base64, json, sys, pathlib
p = pathlib.Path(sys.argv[1])
print(json.dumps({
"title": p.stem,
"content_base64": base64.b64encode(p.read_text(encoding="utf-8").encode("utf-8")).decode("ascii"),
}, ensure_ascii=False))
PY
}
api() { # $1 = method, $2 = path, $3 = body(optional) -> 输出 HTTP 码
local method="$1" path="$2" body="${3:-}"
if [ -n "$body" ]; then
curl -s --netrc-file "$NETRC" -o /tmp/push-wiki-resp.json -w '%{http_code}' \
-X "$method" -H 'Content-Type: application/json' --data-binary "$body" "$API$path"
else
curl -s --netrc-file "$NETRC" -o /tmp/push-wiki-resp.json -w '%{http_code}' -X "$method" "$API$path"
fi
}
# ---- 逐页推送 -------------------------------------------------------------
shopt -s nullglob
files=("$WIKI_DIR"/*.md)
[ ${#files[@]} -gt 0 ] || { echo "docs/wiki 下没有 .md 文件" >&2; exit 1; }
ok=0; fail=0
for f in "${files[@]}"; do
title="$(basename "$f" .md)"
if [ -n "$FILTER" ] && [[ "$title" != *"$FILTER"* ]]; then continue; fi
body="$(json_body "$f")"
code="$(api POST /new "$body")"
if [ "$code" = "201" ]; then
echo " + $title (新建)"; ok=$((ok+1)); continue
fi
enc="$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1], safe=""))' "$title")"
code="$(api PATCH "/page/$enc" "$body")"
if [ "$code" = "200" ]; then
echo " ~ $title (更新)"; ok=$((ok+1))
else
echo " ! $title 失败(HTTP $code): $(head -c 200 /tmp/push-wiki-resp.json 2>/dev/null)" >&2
fail=$((fail+1))
fi
done
echo
echo "完成:成功 $ok 页,失败 $fail 页"
[ "$fail" -eq 0 ] || exit 1
echo "查看: https://$HOST/$OWNER/$REPO/wiki"
+3 -3
View File
@@ -158,9 +158,9 @@ async function classifyTarget(raw: string, cwd: string): Promise<{ kind: AgentOp
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}")`)
if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`)
throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`)
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 }
+146 -30
View File
@@ -134,6 +134,13 @@ export interface AgentTerminalSnapshot {
exitCode?: number | null
/** Exit signal name if the process was killed by a signal; null otherwise. */
exitSignal?: string | null
/**
* The model's active `terminal_wait_for` on this terminal (the sidebar
* renders the wait banner from it). Present only while a wait is
* registered; carries the LATEST wait when several overlap.
*/
waiting?: { needle: string; since: number }
}
/** Map a POSIX signal number to its conventional name (best-effort). */
@@ -149,22 +156,56 @@ function signalNameOf(signal: number | null | undefined): string | null {
return SIGNAL_NAMES[signal] ?? `signal ${signal}`
}
/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
/**
* 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
const idx = transcript.indexOf(needle)
if (idx === -1) 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 < idx; i += 1) {
for (let i = 0; i < hit.index; i += 1) {
if (transcript.charCodeAt(i) === 0x0a /* \n */) {
line += 1
lineStart = i + 1
}
}
return { line, column: idx - lineStart }
return { line, column: hit.index - lineStart, match: hit.text }
}
/** One live agent terminal. */
@@ -189,6 +230,8 @@ export interface AgentTerminalHandle {
exitCode?: number | null
/** Exit signal number once known (POSIX only; undefined on Windows). */
exitSignal?: number | null
/** Active wait_for registrations (skip bookkeeping; empty while idle). */
waits: AgentTerminalActiveWait[]
}
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
@@ -203,17 +246,32 @@ export interface AgentTerminalReadResult {
lineEnd: number
}
/** One active wait_for registration on a handle (banner + skip bookkeeping). */
export interface AgentTerminalActiveWait {
/** The needle being awaited (shown on the sidebar wait banner). */
needle: string
/** Epoch ms when the wait registered (age display / debugging). */
since: number
/** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */
skipped: boolean
}
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
export type AgentTerminalWaitResult =
| {
/** The needle was found in the transcript. */
kind: 'found'
/** The matched substring. */
/** 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
}
@@ -237,6 +295,12 @@ export type AgentTerminalWaitResult =
/** The exit signal name, if the process was killed by a signal. */
exitSignal?: string | null
}
| {
/** The user skipped the wait from the sidebar banner. */
kind: 'skipped'
/** The needle that was awaited. */
needle: string
}
/** Snapshot projection of a handle (drops the pty reference and transcript). */
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
@@ -250,6 +314,8 @@ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
out.exitCode = handle.exitCode ?? null
out.exitSignal = signalNameOf(handle.exitSignal)
}
const active = handle.waits.at(-1)
if (active !== undefined) out.waiting = { needle: active.needle, since: active.since }
return out
}
@@ -310,6 +376,7 @@ export class AgentPtyRegistry {
pty,
transcript: '',
exited: false,
waits: [],
}
pty.onData((data) => {
handle.transcript += data
@@ -456,10 +523,16 @@ export class AgentPtyRegistry {
* 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 - substring to search for (case-sensitive, verbatim).
* @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`.
* A wait can also be skipped by the user from the sidebar banner (`skipWait`), which resolves it with `{kind:'skipped'}`.
* @returns one of `found` / `timeout` / `exited` / `skipped`.
*/
async waitFor(
uuid: string,
@@ -474,39 +547,82 @@ export class AgentPtyRegistry {
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)
const firstHit = locateNeedle(handle.transcript, needle, re)
if (firstHit !== undefined) {
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
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) }
// Register the active wait so the sidebar can show the wait banner (the
// snapshot's `waiting` field rides the agent-terminals push). Registered
// only after the fast paths: a wait that resolves instantly never
// flashes the banner. The notify() makes every push subscriber (the
// sidebar view) converge on the new waiting state immediately.
const record: AgentTerminalActiveWait = { needle, since: start, skipped: false }
handle.waits.push(record)
this.notify()
try {
// Exit outranks a skip in the same tick: `skipWait` flips `record.skipped`
// and the next poll observes it, but a pty that died first is the
// objective fact (a skip is moot once the process is gone). The user
// sees the same three things either way — the banner clears, `skipWait`
// reports 0 for the resolved wait, and the tool result says the wait
// ended — only the reported `kind` differs (see the design doc's
// implementation-deviation record).
while (true) {
if (signal?.aborted) signal.throwIfAborted()
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
if (record.skipped) {
return { kind: 'skipped', needle }
}
const hit = locateNeedle(handle.transcript, needle, re)
if (hit !== undefined) {
return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start }
}
if (Date.now() >= deadline) {
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
}
await new Promise(resolve => {
const t = setTimeout(resolve, 50)
// Allow the Node process to exit even if the timer is pending.
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
})
}
const hit = locateNeedle(handle.transcript, needle)
if (hit !== undefined) {
return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
}
if (Date.now() >= deadline) {
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
}
await new Promise(resolve => {
const t = setTimeout(resolve, 50)
// Allow the Node process to exit even if the timer is pending.
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
})
} finally {
const index = handle.waits.indexOf(record)
if (index !== -1) handle.waits.splice(index, 1)
this.notify()
}
}
/**
* Mark every active wait on one terminal as skipped (the sidebar banner's
* skip button). Each waiting poll loop observes its record's flag within
* one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
* is waiting (a stale banner racing a wait that already resolved).
* @returns the number of waits that transitioned to skipped.
*/
skipWait(uuid: string): number {
const handle = this.expect(uuid)
let count = 0
for (const record of handle.waits) {
if (!record.skipped) {
record.skipped = true
count += 1
}
}
return count
}
/**
* Send a POSIX signal to a terminal's foreground process.
*
+140
View File
@@ -0,0 +1,140 @@
/**
* 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() },
}
}
+78 -3
View File
@@ -8,12 +8,13 @@
* 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, useState } from 'react'
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'
@@ -30,6 +31,11 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
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) }, [])
@@ -39,6 +45,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
setLoading(true)
setError(null)
setData(null)
setEffectiveStaged(null)
const load = async (): Promise<void> => {
try {
if (diff.kind === 'commit') {
@@ -53,7 +60,10 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
// 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 (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!diff.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setData({ diff: result.diff })
@@ -82,6 +92,71 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
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}>
@@ -104,7 +179,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
<>
{data.untracked !== undefined
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffFiles diff={data.diff} />}
: <DiffFiles diff={data.diff} resolveFold={foldLoader} />}
{data.diff === '' && data.untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
+25 -6
View File
@@ -40,8 +40,9 @@ 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, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, type SidebarStore, type SidebarTab } from './state.ts'
import css from './sidebar.module.css'
type EditorLoad =
@@ -177,8 +178,7 @@ export function EditorHost(props: {
*/
const openFileSide = (absolute: string): void => {
store.reduce((state) => {
const key = treeOf(state, tab.id)
const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
const pane = leafWithTab(state.bottomSplits, tab.id) ?? firstLeaf(state.bottomSplits)
const fresh: SidebarTab = {
id: mintTabId(),
type: 'editor',
@@ -186,8 +186,8 @@ export function EditorHost(props: {
path: absolute,
meta: { treeOpen: false },
}
const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
return { ...state, [key]: node, activePane: leafId }
const { node, leafId } = insertLeafAt(state.bottomSplits, pane.id, 'row', fresh, false)
return { ...state, bottomSplits: node, activePane: leafId }
})
}
@@ -225,6 +225,16 @@ export function EditorHost(props: {
})
}
// 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).
@@ -338,6 +348,9 @@ export function EditorHost(props: {
}
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
@@ -386,6 +399,9 @@ export function EditorHost(props: {
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
service={ctx.get('betterSidebar')}
/>
</div>
)
@@ -467,7 +483,7 @@ export function EditorHost(props: {
? <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, {
{!showEmpty && load.status === 'ready' && load.viewer.component !== undefined && createElement(load.viewer.component, {
ctx, store, scope, path, title,
viewerId: load.viewer.id,
content: load.content,
@@ -508,6 +524,9 @@ export function EditorHost(props: {
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
service={ctx.get('betterSidebar')}
/>
</div>
)}
+248 -21
View File
@@ -24,17 +24,21 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import {
IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
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 { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { builtinFileIcon, builtinFolderIcon } from './file-icons.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 { BetterSidebarService } from './service.ts'
import type { SidebarStore } from './state.ts'
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
import css from './sidebar.module.css'
@@ -133,20 +137,65 @@ export function FileTree(props: {
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
/**
* The sidebar registry service: when present, externally registered file
* icons (`registerFileIcon`) outrank the host's file-type artwork on file rows.
* Absent → the built-ins alone (the host always passes it today).
*/
service?: BetterSidebarService
}) {
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, service } = props
const [data, setData] = useState<Record<string, LevelData>>({})
/**
* Registry revision for the file-icon feature: bumps on ANY registry
* change (register/dispose of tabs, viewers, or icons — one listener
* set) so rows re-resolve their icons. The value itself is unread; the
* state bump IS the re-render trigger.
*/
const [, setIconsVersion] = useState(0)
useEffect(
() => service?.subscribe(() => { setIconsVersion(version => version + 1) }),
[service],
)
/**
* One file row's leading glyph. The service resolver owns the whole
* chain (registered specific ext → built-in glyph map → registered
* global default → stock VscFile, with per-factory crash isolation);
* without a service the built-in map alone applies.
*/
const fileRowIcon = (path: string): ReactNode =>
service !== undefined ? service.fileIcon(path, 14) : builtinFileIcon(path, 14)
/**
* One directory row's leading glyph: the registered `'folder'` /
* `'folder-open'` icon when present, else the built-in VSCodicons glyphs.
*/
const dirRowIcon = (path: string, open: boolean): ReactNode =>
service !== undefined ? service.folderIcon(path, open, 14) : builtinFolderIcon(open, 14)
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). */
@@ -268,6 +317,63 @@ export function FileTree(props: {
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)
@@ -373,13 +479,14 @@ export function FileTree(props: {
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. */
* 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={16} />
if (target.id === 'vscode') return <IconVscode16 size={16} />
if (target.id === 'cursor') return <SiCursor size={16} />
if (target.id === 'zed') return <SiZedindustries size={16} />
return <IconCodeOutline16 size={16} />
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))
@@ -412,7 +519,7 @@ export function FileTree(props: {
onToggleOpenWithPin?.(target.id)
}}
>
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
{pinnedNow ? <VscPinned size={12} /> : <VscPin size={12} />}
</span>
</span>
),
@@ -433,7 +540,7 @@ export function FileTree(props: {
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
</span>
),
icon: <VscLinkExternal size={16} />,
icon: <VscLinkExternal size={14} />,
submenu,
},
]
@@ -447,6 +554,58 @@ export function FileTree(props: {
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
? dirRowIcon(entry.path, expandedSet.has(entry.path))
: fileRowIcon(entry.path)}
<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) {
@@ -471,6 +630,10 @@ export function FileTree(props: {
}
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 (
@@ -496,7 +659,7 @@ export function FileTree(props: {
onDrop={(event) => { handleDirDrop(event, entry.path) }}
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
>
{isOpen ? <VscFolderOpened size={14} /> : <VscFolder size={14} />}
{dirRowIcon(entry.path, isOpen)}
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
@@ -529,7 +692,7 @@ export function FileTree(props: {
onDrop={(event) => { handleFileDrop(event, entry.path) }}
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
>
<VscFile size={14} />
{fileRowIcon(entry.path)}
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
@@ -551,6 +714,22 @@ export function FileTree(props: {
<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 }}
@@ -558,7 +737,7 @@ export function FileTree(props: {
onDrop={(event) => { handleDirDrop(event, root) }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<VscFolderOpened size={14} />
{dirRowIcon(root, true)}
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
@@ -650,22 +829,31 @@ export function FileTree(props: {
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={16} /> }]
? [{ 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={16} /> }]
? [{ 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={16} /> }]
? [{ 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={16} /> }]
? [{ 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 },
]
: []),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
]}
onSelect={(id) => {
const target = rowMenu
@@ -692,16 +880,55 @@ export function FileTree(props: {
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>
)
}
-337
View File
@@ -1,337 +0,0 @@
/**
* One free window: a tab dragged out of the workbench floating over the
* conversation area at viewport coordinates (rendered inside the panel host,
* so desktop-shell transforms can never hijack its fixed containing block).
*
* The header drags the window with the panel-resize pattern — pointer
* capture + per-frame direct DOM writes + a store commit on release — and
* doubling as the DOCK-BACK gesture: while the pointer is over a workbench
* pane ([data-dsh-pane], either panel), that pane highlights live and
* releasing docks the tab into it (center merge); releasing anywhere else
* just moves the window. The SE corner resizes, any press raises (the
* floats array's order is the stacking order), the header right-click menu
* and the X button dock / close. The tab content reuses the regular tab
* renderer, so every tab type (terminal, editor, plugin tabs) floats
* unchanged.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { FloatWindow, SidebarTab } from './state.ts'
import { FLOAT_MIN_H, FLOAT_MIN_W } from './state.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** A drag's mutable bookkeeping (start geometry + the last applied values). */
interface DragState {
/** Which handle owns the drag (the abort path reads it from this ref —
* the React `dragging` state lags a render behind pointerdown). */
mode: 'move' | 'resize'
pointerX: number
pointerY: number
startX: number
startY: number
startW: number
startH: number
/** Last geometry actually written to the DOM (the cancel-path fallback). */
applied: { x: number; y: number; w: number; h: number }
/** Settled after the final pointer event (the up position wins over the
* rAF-pending value — a fast flick coalesces its tail into the up event). */
committed: boolean
}
/** The pane under a viewport point, if any (rect hit-test; the dragged
* window itself is not a pane, so it cannot shadow the targets). */
function paneAt(x: number, y: number): HTMLElement | null {
for (const pane of document.querySelectorAll<HTMLElement>('[data-dsh-pane]')) {
const rect = pane.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) continue
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return pane
}
return null
}
/** Pointer-capture helpers tolerant of environments without the API (jsdom
* lacks setPointerCapture — component tests dispatch plain MouseEvents, so
* the optional calls keep them driving the drag; real browsers always have
* it and a missing pointerId can never occur there). */
const capturePointer = (element: HTMLElement, pointerId: number): void => {
element.setPointerCapture?.(pointerId)
}
const releasePointer = (element: HTMLElement, pointerId: number): void => {
element.releasePointerCapture?.(pointerId)
}
/** Whether the element holds the pointer (assumed true without the API). */
const holdsPointer = (element: HTMLElement, pointerId: number): boolean => {
return element.hasPointerCapture?.(pointerId) !== false
}
export function FreeWindow(props: {
float: FloatWindow
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
onRaise: () => void
onMove: (x: number, y: number) => void
onResize: (w: number, h: number) => void
/** Dock the tab back; the pane id, or null for the active pane. */
onDock: (paneId: string | null) => void
onClose: () => void
}) {
const { float, renderTab, getTabIcon, onRaise, onMove, onResize, onDock, onClose } = props
const rootRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<DragState | null>(null)
// The pane currently highlighted as the dock target (a direct DOM write:
// per-frame React state would re-render every mounted tab at drag cadence).
const dockTargetRef = useRef<HTMLElement | null>(null)
// rAF coalescing: pointermove outruns the display, one write per frame.
const frameRef = useRef<number | null>(null)
const pendingRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null)
const [dragging, setDragging] = useState<'move' | 'resize' | null>(null)
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
useEffect(() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
}, [])
/** Apply the pending geometry to the DOM (one rAF per frame at most). */
const scheduleApply = (geo: { x: number; y: number; w: number; h: number }): void => {
pendingRef.current = geo
if (frameRef.current !== null) return
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null
const pending = pendingRef.current
const drag = dragRef.current
const root = rootRef.current
if (pending === null || drag === null || root === null) return
drag.applied = pending
root.style.left = `${pending.x}px`
root.style.top = `${pending.y}px`
root.style.width = `${pending.w}px`
root.style.height = `${pending.h}px`
})
}
/** Flush the pending frame synchronously (the release path's last write). */
const flushNow = (): void => {
const pending = pendingRef.current
const drag = dragRef.current
const root = rootRef.current
if (pending === null || drag === null || root === null) return
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
pendingRef.current = null
drag.applied = pending
root.style.left = `${pending.x}px`
root.style.top = `${pending.y}px`
root.style.width = `${pending.w}px`
root.style.height = `${pending.h}px`
}
const clearDockHighlight = (): void => {
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
dockTargetRef.current = null
}
/** Release a drag: `pane` (when set) docks instead of moving. */
const finishDrag = (mode: 'move' | 'resize', geo: { x: number; y: number; w: number; h: number }, pane: HTMLElement | null): void => {
const drag = dragRef.current
if (drag === null || drag.committed) return
drag.committed = true
dragRef.current = null
setDragging(null)
const target = pane ?? dockTargetRef.current
clearDockHighlight()
if (mode === 'move' && target !== null) {
onDock(target.getAttribute('data-dsh-pane'))
} else if (mode === 'move') {
onMove(geo.x, geo.y)
} else {
onResize(geo.w, geo.h)
}
}
/** Cancel-path settle (pointercancel / lostpointercapture): the last
* APPLIED geometry is the user-visible truth — commit it, never roll back
* (the panel drags' issue-#247 semantics). The mode comes from the ref —
* the React `dragging` state can still be null when the cancel lands
* before the pointerdown re-render commits. */
const abortDrag = (): void => {
const drag = dragRef.current
if (drag === null || drag.committed) return
flushNow()
finishDrag(drag.mode, drag.applied, null)
}
const clampMove = (x: number, y: number): { x: number; y: number; w: number; h: number } => {
const vw = window.innerWidth
const vh = window.innerHeight
return {
x: Math.min(Math.max(x, 0), Math.max(0, vw - float.w)),
y: Math.min(Math.max(y, 0), Math.max(0, vh - float.h)),
w: float.w,
h: float.h,
}
}
const clampResize = (w: number, h: number): { x: number; y: number; w: number; h: number } => {
const vw = window.innerWidth
const vh = window.innerHeight
return {
x: float.x,
y: float.y,
w: Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x))),
h: Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y))),
}
}
return (
<div
ref={rootRef}
className={clsx(css.floatWindow, dragging !== null && css.floatWindowDragging)}
data-dsh-float-window
data-dsh-float-id={float.id}
style={{ left: float.x, top: float.y, width: float.w, height: float.h }}
onPointerDown={() => { onRaise() }}
>
<div
className={css.floatHeader}
onPointerDown={(event) => {
if (event.button !== 0) return
// Portaled overlays (the header Menu) live at document.body in the
// DOM, yet their events still bubble through the React tree into
// this handler — without the containment guard a menu-row click
// would start a drag, preventDefault (killing the row's compat
// mousedown) and capture the pointer (retargeting the release at
// the header, so the row's click never fires). Buttons inside the
// header (the close X) need the same exclusion: a capture here
// swallows their click.
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
if (event.target instanceof Element && event.target.closest('button') !== null) return
event.preventDefault()
capturePointer(event.currentTarget, event.pointerId)
dragRef.current = {
mode: 'move',
pointerX: event.clientX,
pointerY: event.clientY,
startX: float.x,
startY: float.y,
startW: float.w,
startH: float.h,
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
committed: false,
}
setDragging('move')
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
const geo = clampMove(drag.startX + (event.clientX - drag.pointerX), drag.startY + (event.clientY - drag.pointerY))
scheduleApply(geo)
const target = paneAt(event.clientX, event.clientY)
if (target !== dockTargetRef.current) {
clearDockHighlight()
if (target !== null) target.setAttribute('data-dsh-float-dock-over', '')
dockTargetRef.current = target
}
}}
onPointerUp={(event) => {
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
releasePointer(event.currentTarget, event.pointerId)
flushNow()
const geo = clampMove(
dragRef.current.startX + (event.clientX - dragRef.current.pointerX),
dragRef.current.startY + (event.clientY - dragRef.current.pointerY),
)
finishDrag('move', geo, paneAt(event.clientX, event.clientY))
}}
onPointerCancel={() => { abortDrag() }}
onLostPointerCapture={() => { abortDrag() }}
onContextMenu={(event) => {
event.preventDefault()
setMenu({ x: event.clientX, y: event.clientY })
}}
>
{getTabIcon?.(float.tab) ?? null}
<span className={css.floatTitle} title={float.tab.title}>{float.tab.title}</span>
<button
type="button"
className={css.floatClose}
aria-label={t('close')}
onClick={(event) => { event.stopPropagation(); onClose() }}
>
<IconCloseFill14 />
</button>
<Menu
open={menu !== null}
onClose={() => { setMenu(null) }}
items={[
{ id: 'dock', label: t('dockToSidebar') },
{ id: 'close', label: t('close') },
]}
onSelect={(id) => {
setMenu(null)
if (id === 'dock') onDock(null)
else if (id === 'close') onClose()
}}
portal
align="start"
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
anchor={<span />}
/>
</div>
{/* The tab cell reuses .paneTab (the pane's per-tab wrapper): the
column-flex chain (window → .floatContent → .paneTab) is what
gives the component a definite width via align-stretch — the exact
sizing contract a tab gets inside a pane. */}
<div className={css.floatContent}>
<div className={css.paneTab}>{renderTab(float.tab, true, float.id)}</div>
</div>
<div
className={css.floatResize}
onPointerDown={(event) => {
if (event.button !== 0) return
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
event.preventDefault()
capturePointer(event.currentTarget, event.pointerId)
dragRef.current = {
mode: 'resize',
pointerX: event.clientX,
pointerY: event.clientY,
startX: float.x,
startY: float.y,
startW: float.w,
startH: float.h,
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
committed: false,
}
setDragging('resize')
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
scheduleApply(clampResize(
drag.startW + (event.clientX - drag.pointerX),
drag.startH + (event.clientY - drag.pointerY),
))
}}
onPointerUp={(event) => {
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
releasePointer(event.currentTarget, event.pointerId)
flushNow()
const geo = clampResize(
dragRef.current.startW + (event.clientX - dragRef.current.pointerX),
dragRef.current.startH + (event.clientY - dragRef.current.pointerY),
)
finishDrag('resize', geo, null)
}}
onPointerCancel={() => { abortDrag() }}
onLostPointerCapture={() => { abortDrag() }}
/>
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
/**
* The one HTML-preview frame both preview surfaces render.
*
* Why this is not a plain `src` to the `/sidebar/html` route: on DSH Desktop
* the host answers 403 `forbidden` to every request that does not carry the
* Electron renderer capability (`x-dsh-desktop-renderer`), and the shell
* attaches that header only to MAIN-frame and SAME-ORIGIN-subframe traffic
* (`requestComesFromRendererOrigin`). A frame sandboxed WITHOUT
* allow-same-origin is an opaque origin at request time, so its document
* request never carries the capability and the preview shows nothing but the
* bare refusal.
*
* So the document is fetched by the MAIN frame — which does carry it — and
* handed to the frame through `srcdoc`. The `sandbox` attribute stays exactly
* as before: the preview still runs in an opaque origin, and the route's own
* workspace fence still bounds what can be fetched.
*
* Two consequences worth knowing:
* - sandboxed: an opaque origin cannot make network requests either, so a
* self-contained document previews completely while a multi-file one shows
* its markup with the relative assets missing (inlining them is a separate
* job, not this component's);
* - unsandboxed (the warned per-surface unlock, or the setting): the srcdoc
* frame inherits the GUI's origin — which is precisely what that escape
* hatch already meant — and there its relative references resolve and load
* like any other same-origin request.
*
* A fetch that fails hands the frame the route URL instead, so the route's
* own answer (its JSON error, or the refusal) stays visible rather than being
* replaced by a blank pane.
*/
import { useEffect, useState, type ReactNode } from 'react'
import { HTML_IFRAME_SANDBOX, withPreviewBase } from './html-preview.ts'
/** One preview frame. */
export function HtmlPreviewFrame(props: {
/** The `/sidebar/html/<sessionId>/<path…>` route URL of the SAVED file. */
url: string
/** Whether the opaque-origin sandbox applies (the default for both surfaces). */
sandboxed: boolean
className?: string
title: string
}): ReactNode {
const { url, sandboxed, className, title } = props
const [doc, setDoc] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
const controller = new AbortController()
setDoc(null)
setFailed(false)
void fetch(url, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(String(response.status))
return response.text()
})
.then((text) => { setDoc(text) })
.catch((error: unknown) => {
// An abort is this effect's own cleanup, not a failure.
if ((error as { name?: string }).name !== 'AbortError') setFailed(true)
})
return () => { controller.abort() }
}, [url])
// The delivery path depends only on what we have: the fetched markup, the
// route's own answer after a failed fetch, or an empty frame while loading.
const delivery = doc !== null
? { srcDoc: withPreviewBase(doc, url) }
: failed ? { src: url } : { srcDoc: '' }
return (
<iframe
{...delivery}
className={className}
sandbox={sandboxed ? HTML_IFRAME_SANDBOX : undefined}
referrerPolicy="no-referrer"
allow=""
title={title}
/>
)
}
+17 -13
View File
@@ -10,32 +10,31 @@
* 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 `src` attributes are
* rewritten through the session-scoped `/sidebar/file` media route — the same
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
* 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 type { ComponentType } from 'react'
import { markdownTextProps } from './markdown-labels.tsx'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { resolveLocalMediaDest } from './markdown-images.ts'
import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts'
import {
analyzeHtmlSegment,
type AnalyzedMarkdownHtml,
} from './markdown-html.ts'
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.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. */
export const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
'mermaid',
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
)
* 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 {
@@ -235,7 +234,12 @@ export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentPr
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
if (segment.kind === 'markdown') {
const defs = info.referenceDefinitions
const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
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,
+3
View File
@@ -40,6 +40,9 @@ export function PdfView(props: { scope: SessionScope; path: string; title: strin
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(() => {
+1 -1
View File
@@ -7,7 +7,7 @@
* 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 toggle cluster, the
* 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).
*
+6 -76
View File
@@ -54,13 +54,9 @@ import clsx from 'clsx'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import {
clampWidthPercent,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
type SidebarPrefs,
type TitleBarScheme,
} from '../prefs-shared.ts'
import { api } from './api.ts'
import { parsePrefs } from './prefs.ts'
@@ -573,7 +569,6 @@ export function SettingsBody(props: {
*/
export function SideCardSection({ store, service }: SideCardSectionProps) {
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
const [error, setError] = useState<string | null>(null)
// Which feature's secondary settings popup is open (null = closed).
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
@@ -625,9 +620,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
if (cancelled) return
revisionRef.current = view.revision
if (dirtyRef.current) return
const next = parsePrefs(view.value)
setPrefs(next)
setWidthDraft(String(next.defaultWidthPercent))
setPrefs(parsePrefs(view.value))
}).catch(() => { /* the store's defaults stay authoritative */ })
return () => { cancelled = true }
}, [])
@@ -658,9 +651,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
/** Settle one commit: success adopts the server values, failure reverts. */
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
const settled = outcome.ok ? outcome.prefs : previous
setPrefs(settled)
setWidthDraft(String(settled.defaultWidthPercent))
setPrefs(outcome.ok ? outcome.prefs : previous)
}
/** Optimistically apply one pref patch, then commit (revert on failure). */
@@ -673,10 +664,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
void commit(patch).then(outcome => applyOutcome(previous, outcome))
}
const onToggle = (next: boolean): void => {
applyPref({ openByDefault: next })
}
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
const onToggleTab = (id: string, next: boolean): void => {
applyPref({ tabsEnabled: { ...optimisticRef.current.tabsEnabled, [id]: next } })
@@ -779,20 +766,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
return raw
}
const commitWidth = (): void => {
const parsed = Number(widthDraft)
if (!Number.isFinite(parsed)) {
setWidthDraft(String(prefs.defaultWidthPercent))
return
}
const clamped = clampWidthPercent(parsed)
const previous = prefs
setPrefs({ ...previous, defaultWidthPercent: clamped })
setWidthDraft(String(clamped))
setError(null)
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
}
/**
* One SMALL toggle card for the responsive inventory grid: the card's main
* area is the switch (click to flips, visual state IS the state), the icon
@@ -868,51 +841,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
<div className={css.group}>
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenTitle')}</span>
<span className={css.desc}>{t('settingsOpenDesc')}</span>
</span>
<Switch
label={t('settingsOpenTitle')}
checked={prefs.openByDefault}
onChange={onToggle}
/>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsWidthTitle')}</span>
<span className={css.desc}>{t('settingsWidthDesc')}</span>
</span>
<span className={css.control}>
<Input
type="number"
className={css.percentInput}
value={widthDraft}
min={WIDTH_PERCENT_MIN}
max={WIDTH_PERCENT_MAX}
step={1}
aria-label={t('settingsWidthTitle')}
onChange={event => { setWidthDraft(event.currentTarget.value) }}
onBlur={commitWidth}
onKeyDown={event => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
</span>
<Switch
label={t('settingsOpenPathTitle')}
checked={prefs.interceptOpenPath}
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
/>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenToolsTitle')}</span>
@@ -947,9 +875,11 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
...getShellPresets().map(preset => ({
value: `preset:${preset.id}`,
title: preset.title,
// The preset desc is i18n-friendly (string or () => string)
// — resolve it like every other settings text here.
desc: preset.detect?.(detectedEnv) === true
? `${preset.desc}(${t('settingsSchemeDetectedSuffix')})`
: preset.desc,
? `${textOf(preset.desc)}(${t('settingsSchemeDetectedSuffix')})`
: textOf(preset.desc),
})),
{ value: 'custom', title: t('settingsSchemeCustomTitle'), desc: t('settingsSchemeCustomDesc') },
]}
+46 -15
View File
@@ -58,6 +58,7 @@ import {
sideThreadRows,
threadHasCompletedTurn,
threadTrailingPending,
type SidechatLiveEvent,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import {
@@ -69,6 +70,7 @@ import {
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'
@@ -111,6 +113,8 @@ const inFlightStarts = new Set<string>()
* 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). */
@@ -382,7 +386,7 @@ export function SideChatView(props: {
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ entries: [] })
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)
@@ -456,14 +460,18 @@ export function SideChatView(props: {
try {
const cache = cacheRef.current
const afterSeq = cache.entries.at(-1)?.event.seq
const { events } = await api.sidechatEvents(childId, afterSeq, controller.signal)
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)
}
setRevision(value => value + 1)
} catch {
// Aborted by a newer pull or a wire failure: keep the last rows.
}
@@ -481,7 +489,7 @@ export function SideChatView(props: {
// 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: [] }
cacheRef.current = { entries: [], live: [] }
prevRowsRef.current = []
controllerRef.current?.abort()
setError(null)
@@ -493,17 +501,25 @@ export function SideChatView(props: {
}
}, [threadId, fetchInfo])
// Poll while the tab is visible and the thread runs.
// 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)
if (!running) return
const timer = window.setInterval(() => {
void fetchThread(threadId)
void fetchInfo(threadId)
}, POLL_MS)
return () => { window.clearInterval(timer) }
}, [visible, threadId, running, fetchThread, fetchInfo])
// `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() }, [])
@@ -523,8 +539,24 @@ export function SideChatView(props: {
// 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 next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current)
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
},
@@ -574,7 +606,6 @@ export function SideChatView(props: {
}
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threads])
const growComposer = (): void => {
@@ -688,7 +719,7 @@ export function SideChatView(props: {
)}
items={menuItems}
selectedId={threadId}
onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }}
onSelect={(id) => { if (id === '$new') openNewThread(); else openExistingThread(id) }}
onClose={() => { setMenuOpen(false) }}
align="end"
portal
+228 -1226
View File
File diff suppressed because it is too large Load Diff
+26 -37
View File
@@ -53,6 +53,7 @@ import {
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'
@@ -180,52 +181,30 @@ function SubagentLiveLines(props: { live: LastActivity | undefined }) {
/**
* 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: a recursive timeout starts only after the previous
* request settles, so a slow host never sees abort/restart storms.
* 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>>({})
const controllerRef = useRef<AbortController | undefined>(undefined)
// A new tree must never inherit another root's live previews.
useEffect(() => { setLive({}) }, [rootId])
useEffect(() => {
if (rootId === undefined || !active) return
const targetRootId = rootId
let disposed = false
let timer: number | undefined
const schedule = (): void => {
if (disposed) return
timer = window.setTimeout(() => { void load() }, POLL_MS)
}
async function load(): Promise<void> {
if (disposed) return
const controller = new AbortController()
controllerRef.current = controller
try {
const result = await api.subagentsLive(targetRootId, controller.signal)
if (!disposed) setLive(result.live)
} catch {
// Keep the last known live map; the next scheduled poll retries.
} finally {
if (controllerRef.current === controller) controllerRef.current = undefined
if (!disposed) schedule()
}
}
void load()
return () => {
disposed = true
if (timer !== undefined) window.clearTimeout(timer)
controllerRef.current?.abort()
controllerRef.current = undefined
}
}, [rootId, active])
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
}
@@ -415,6 +394,9 @@ function JobOutputPane(props: {
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() }, [])
@@ -425,6 +407,8 @@ function JobOutputPane(props: {
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 (
@@ -656,7 +640,9 @@ export function SubagentView(props: {
useCallback(() => sessions.list.getSnapshot(), [sessions]),
)
const byId = list.byId
const catalogs = list.subagentsByParent ?? {}
// 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])
@@ -680,6 +666,9 @@ export function SubagentView(props: {
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)
}
+12 -14
View File
@@ -4,9 +4,8 @@
* 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 (float as a free window / close / close
* others / close to the left / close to the right, the close ones scoped to
* this pane).
* 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'
@@ -16,6 +15,7 @@ import {
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'
@@ -67,9 +67,6 @@ export function TabBar(props: {
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
/** Float a tab out as a free window (the tab context menu's entry; the
* drag-to-conversation gesture is handled at the Sidebar shell level). */
onFloatTab: (tabId: string) => 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
@@ -84,12 +81,15 @@ export function TabBar(props: {
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onFloatTab, onPinTab, getTabIcon, getTabBadge,
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
@@ -265,6 +265,7 @@ export function TabBar(props: {
setMenuOpen(false)
}}
portal
compact
align="end"
anchor={(
<button
@@ -293,9 +294,8 @@ export function TabBar(props: {
// 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 — the menu stays exactly the legacy 5-item
// shape. Pinned VIRTUAL tabs (injected from other sessions)
// get a stripped menu: only Unpin + Close (no float, 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
@@ -320,7 +320,6 @@ export function TabBar(props: {
]
}
return [
{ id: 'float', label: t('moveToFreeWindow') },
...pinEntries,
{ id: 'close', label: t('close') },
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
@@ -334,9 +333,7 @@ export function TabBar(props: {
setTabMenu(null)
const index = tabs.findIndex(tab => tab.id === target.tabId)
if (index < 0) return
if (id === 'float') {
onFloatTab(target.tabId)
} else if (id === 'pinWorkspace') {
if (id === 'pinWorkspace') {
onPinTab?.(target.tabId, 'workspace')
} else if (id === 'pinGlobal') {
onPinTab?.(target.tabId, 'global')
@@ -355,6 +352,7 @@ export function TabBar(props: {
}
}}
portal
compact
align="start"
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
anchor={<span />}
+55 -13
View File
@@ -37,6 +37,7 @@ 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'
@@ -47,6 +48,7 @@ import {
shouldActivateTerminalLink,
openTerminalUrl,
} from './terminal-links.ts'
import { TerminalWaitBanner } from './TerminalWaitBanner.tsx'
import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
@@ -60,6 +62,13 @@ const FAILURE_LIMIT = 3
*/
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 }>
@@ -67,24 +76,24 @@ 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 palettes the app's code surfaces use (one-dark family for dark,
* one-light family for light), read live so a scheme flip re-themes in
* place.
* 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: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
brightCyan: '#56b6c2', brightWhite: '#ffffff',
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: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
brightCyan: '#0997b3', brightWhite: '#fafafa',
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). */
@@ -115,6 +124,27 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
const [fatal, setFatal] = useState<string | null>(null)
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
const [lastUrl, setLastUrl] = useState<string | null>(null)
// Agent terminals only: the model's active terminal_wait_for (mirrored
// from the host's agent-terminals push into the store) drives the wait
// banner. Read + subscribe like the font prefs above; the banner vanishes
// when the host's push drops the waiting field (skip / exit / abort all
// converge through the same push). getSnapshot() is {sessionId, state?,
// prefs} — the state may be briefly undefined around session switches.
const agentUuid = isAgentTabId(tabId) ? agentUuidOf(tabId) : null
const [waiting, setWaiting] = useState<{ needle: string; since: number } | undefined>(undefined)
useEffect(() => {
if (agentUuid === null) return
const read = (): void => {
const next = store.getSnapshot().state?.agentWaits?.[agentUuid]
setWaiting(prev => {
const nextValue = next === undefined ? undefined : { needle: next.needle, since: next.since }
if (prev?.needle === nextValue?.needle && prev?.since === nextValue?.since) return prev
return nextValue
})
}
read()
return store.subscribe(read)
}, [agentUuid, store])
const connectRef = useRef<(() => void) | null>(null)
useEffect(() => {
@@ -241,6 +271,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
})
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 !== '') {
@@ -372,6 +408,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
return (
<div className={css.terminalWrap}>
{agentUuid !== null && waiting !== undefined && (
<TerminalWaitBanner
needle={waiting.needle}
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
/>
)}
{depsFatal !== null && (
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
)}
+43
View File
@@ -0,0 +1,43 @@
/**
* The "Agent 正在等待 {needle}" wait banner: rendered at the top of an
* agent-owned terminal's view while the model blocks in terminal_wait_for.
* The skip button asks the host to abort every active wait on the terminal
* (`agent-pty.skip-wait`); the banner disappears when the host's next
* agent-terminals push drops the waiting field — no optimistic UI. Kept in
* its own module (no xterm imports) so jsdom tests can render it directly.
*/
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** Cap the needle shown inline; the full text rides the title tooltip. */
const NEEDLE_DISPLAY_CAP = 80
/** Truncate one needle for inline display (title attr carries the full text).
* Cut by Unicode code points, not UTF-16 code units — a naive slice can split
* a surrogate pair at the cutoff and render a dangling replacement char. */
export function truncateNeedle(needle: string): string {
const points = Array.from(needle)
return points.length > NEEDLE_DISPLAY_CAP
? `${points.slice(0, NEEDLE_DISPLAY_CAP - 1).join('')}…`
: needle
}
export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) {
const { needle, onSkip } = props
return (
<div className={css.terminalWaitBanner}>
<span
className={css.terminalWaitNeedle}
title={needle}
role="status"
aria-live="polite"
aria-label={t('terminalWaitBanner', { needle })}
>
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
</span>
<button type="button" className={css.terminalRetry} onClick={onSkip}>
{t('terminalSkipWait')}
</button>
</div>
)
}
+21 -23
View File
@@ -31,27 +31,18 @@ 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 { lazyChunkComponent } from './lazy-chunk.tsx'
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 { HtmlPreviewFrame } from './HtmlPreviewFrame.tsx'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
/**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
* could read session data) and NO allow-top-navigation (a previewed page
* must not hijack the GUI). The user can disable the sandbox per-feature
* in the side card settings (warned); the toggle below reflects it.
*/
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
/** 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
@@ -117,13 +108,16 @@ export function TextEditor(props: FileViewerProps) {
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
}, [path])
}, [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
@@ -220,6 +214,7 @@ export function TextEditor(props: FileViewerProps) {
// 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
@@ -276,6 +271,9 @@ export function TextEditor(props: FileViewerProps) {
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
@@ -340,9 +338,11 @@ export function TextEditor(props: FileViewerProps) {
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
[markdown, mode, previewMdText],
)
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
* for documents that actually contain HTML — plain markdown keeps the
* legacy single-pass render path below, byte-for-byte. */
/** 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],
@@ -545,16 +545,14 @@ export function TextEditor(props: FileViewerProps) {
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
{/* The preview shows the SAVED file; the draft is only visible in
edit mode. HtmlPreviewFrame owns both the delivery path
(main-frame fetch -> srcdoc, which a sandboxed frame could not
reach on its own) and the sandbox tokens. */}
<HtmlPreviewFrame
className={css.editorHtml}
src={htmlUrl(scope, path)}
sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
referrerPolicy="no-referrer"
allow=""
url={htmlUrl(scope, path)}
sandboxed={!htmlNoSandbox}
title={path}
/>
</>
+11 -1
View File
@@ -19,6 +19,7 @@ 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 { BetterSidebarService } from './service.ts'
import type { SidebarStore } from './state.ts'
import { FileTree } from './FileTree.tsx'
import { IconUploadOutline16 } from './icons.tsx'
@@ -63,11 +64,17 @@ export function TreePanel(props: {
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
/** The sidebar registry service (file-icon registrations; passed through to FileTree). */
service?: BetterSidebarService
}) {
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full, service } = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -252,9 +259,12 @@ export function TreePanel(props: {
onOpenWith={onOpenWith}
onToggleOpenWithPin={onToggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
refreshTick={refreshTick}
onUploadRequest={startUpload}
busy={busy}
service={service}
/>
) : (
<div className={css.explorerBody}>
+15 -9
View File
@@ -43,7 +43,7 @@ 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 { service, kind } = props
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
@@ -56,8 +56,9 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
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 entry.name.toLowerCase().includes(needle)
return name.toLowerCase().includes(needle)
|| entry.id.toLowerCase().includes(needle)
|| description.toLowerCase().includes(needle)
}
@@ -93,8 +94,12 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
window.open(entry.url, '_blank', 'noopener')
}
/** One catalog row (extracted so the group render stays flat). */
const renderEntry = (entry: PluginEntry): ReactNode => (
/** 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
@@ -104,16 +109,16 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
<button
type="button"
className={css.pluginName}
aria-label={`${t('openPlugin')}: ${entry.name}`}
aria-label={`${t('openPlugin')}: ${name}`}
onClick={() => { jump(entry) }}
>
{entry.name}
{name}
</button>
<span className={css.pluginEntryActions}>
<button
type="button"
className={css.pluginJumpBtn}
aria-label={`${t('openPlugin')}: ${entry.name}`}
aria-label={`${t('openPlugin')}: ${name}`}
onClick={() => { jump(entry) }}
>
{t('openPlugin')}
@@ -121,7 +126,7 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
<button
type="button"
className={css.pluginCopyBtn}
aria-label={`${t('copyInstall')}: ${entry.name}`}
aria-label={`${t('copyInstall')}: ${name}`}
onClick={() => { copy(entry) }}
>
{copiedId === entry.id ? t('copied') : t('copy')}
@@ -133,7 +138,8 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
</div>
<code className={css.pluginInstall}>{entry.install}</code>
</div>
)
)
}
return (
<div className={css.pluginList}>
-3
View File
@@ -33,8 +33,6 @@ export const apesZh = {
apesProfileNoCwd: '未知',
apesProfileRunning: '运行中',
apesProfileIdle: '空闲',
/** Guide-page capsule description (official right sidebar's entry list). */
apesProfileGuideDesc: '当前会话的身份与工作目录',
// Apps view
apesAppsNoFeed: '当前 DSH 版本未提供工作区应用数据源',
apesAppsWorkspace: '工作区',
@@ -56,7 +54,6 @@ export const apesEn: Record<keyof typeof apesZh, string> = {
apesProfileNoCwd: 'unknown',
apesProfileRunning: 'running',
apesProfileIdle: 'idle',
apesProfileGuideDesc: "This session's identity and working directory",
apesAppsNoFeed: 'This DSH version exposes no workspace-app feed',
apesAppsWorkspace: 'Workspace',
}
-187
View File
@@ -1,187 +0,0 @@
/**
* Native right-sidebar bridge (Phase A spike) — registers apes:* tabs as
* OFFICIAL `@deepseek-ai/dsh-client-ui-sidebar-right` tab types through the
* public two-stage protocol its README documents:
*
* 1. TYPE — `ctx.sidebarRightTabs.register({ id, kind, priority, title, guide? })`
* a static declaration with no runtime hooks; the `id` is the type's
* identity in the tab system AND the key its body registers under.
* 2. BODY — `ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
* { name: 'sidebar.right.pane.tab', key: <id> }, Body))`
* the keyed seat dispatches a tab's body to the type registered for its
* `kind`; the body reads `useTabInfo()` (injected by the seat) for
* `{ sidebar, panel, tab }`.
*
* ACTIVATION ORDER — `sidebarRightTabs` is installed by that package's own
* `apply()` through `ctx.reflect.provide(...)`, so it may not exist yet when
* our plugin activates. We therefore reach it through `ctx.inject(['sidebarRightTabs'], …)`
* (the same pattern intercept.tsx uses for the async `remote.session`
* namespace): the callback runs when the service exists and re-runs after a
* remount, so the bridge is order-independent and HMR-safe. Deployments
* without the official right sidebar simply never see the callback — the
* fork's own shell stays the only surface, which is also what the render-time
* `guide`/type registration tolerates.
*
* SCOPE — the spike registers exactly ONE tab (`apes:profile` / 画像) to prove
* the path end-to-end before deciding on the full nine-tab migration. The
* existing `service.registerTab` route in `tabs.tsx` is deliberately left
* untouched, so both surfaces coexist during the spike and the whole spike
* reverts by deleting this file plus its one call site.
*
* Side effect worth knowing (inherent to the official default-page rule): the
* dock's default page depends on how many guide entries are registered —
* exactly one auto-opens it, zero or many open the guide page. Our guide entry
* therefore turns a fresh-empty dock from "Files" into the guide page listing
* every entry capsule.
*/
import { useMemo, type ReactNode } from 'react'
import { IconUserOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import { ApesProfileView } from './tabs.tsx'
import type { TabComponentProps } from '../service.ts'
/** The apes type's identity in the official tab system. Package-scoped so it
* can never collide with a native or third-party registration. */
const PROFILE_ID = 'dsh-apes-sidebar/profile'
/** The kind the panel opens the tab with (`openTab(kind)`); `apes:` prefixed so
* it reads as ours in the official registry. */
const PROFILE_KIND = 'apes:profile'
/** The official registry's definition shape (the subset we use). */
interface NativeTabDefinition {
id: string
kind: string
priority?: 'builtin' | 'extension' | 'fallback'
title: () => string
guide?: Array<{
order: number
title: () => string
description?: () => string
icon?: (props: { size?: number; className?: string }) => ReactNode
}>
}
/** The registry service face the official package provides. */
interface NativeTabRegistry {
register(definition: NativeTabDefinition): () => void
}
/** The slots service face (structural subset of `@deepseek-ai/dsh-client-ui-slots`). */
interface NativeSlots {
inject(key: string, callback: () => () => void): () => void
register(options: {
name: string
key?: string
locale?: string
inject?: (...args: unknown[]) => Record<string, unknown>
}, component: (props: Record<string, unknown>) => ReactNode): () => void
}
/** What the seat hands a tab body in addition to our own injected props. */
interface NativeTabBodyProps {
useTabInfo?: () => unknown
sessionId?: string
ctx: TabComponentProps['ctx']
}
/** The tab info the seat's `useTabInfo()` hook returns (the subset we read). */
interface NativeTabInfo {
sidebar?: { expanded?: boolean; fullscreen?: boolean }
panel?: { id?: string }
tab?: { id?: string; title?: string; visible?: boolean }
}
/** Fallback reader for an absent seat hook (never in production — the seat
* always injects `tabInfo`; this keeps the body hook-safe either way, since
* the fallback calls no hooks of its own). */
function fallbackTabInfo(): NativeTabInfo {
return { sidebar: { expanded: true }, panel: { id: '' }, tab: { visible: true } }
}
/**
* The official surface's body for 画像: the SAME `ApesProfileView` the fork
* already ships, re-bound to the official tab scope. The view reads only
* `props.ctx` (the sessions feed) and `props.scope` (sessionId / cwd), so the
* shell-only props are filled with placeholders — which is the spike's whole
* point: the panel content is shared, never duplicated.
*/
function ProfileNativeBody(props: NativeTabBodyProps): ReactNode {
const useInfo = props.useTabInfo ?? fallbackTabInfo
const info = useInfo() as NativeTabInfo | undefined
const sessionId = props.sessionId
?? props.ctx.sessions.list.getSnapshot().current
?? ''
const scope = useMemo(() => ({ sessionId }), [sessionId])
const viewProps = {
ctx: props.ctx,
scope,
visible: info?.tab?.visible ?? true,
store: undefined,
tab: {
id: info?.tab?.id ?? PROFILE_ID,
type: PROFILE_KIND,
title: info?.tab?.title ?? t('apesProfileTitle'),
},
} as unknown as TabComponentProps
return <ApesProfileView {...viewProps} />
}
/**
* Register the apes:profile tab as an official right-sidebar tab type.
* @param ctx — the client context whose `inject` waits for the registry service.
* @returns a disposer that disposes the inject fiber (which unwinds both stages).
*/
export function registerApesNativeTabs(ctx: unknown): () => void {
const anyCtx = ctx as {
inject?: (deps: string[], callback: (fctx: unknown) => void) => { dispose(): void }
}
if (typeof anyCtx.inject !== 'function') return () => {}
const fiber = anyCtx.inject(['sidebarRightTabs'], (fctx: unknown) => {
const inner = fctx as {
get?: (name: string) => unknown
slots?: NativeSlots
effect?: (callback: () => () => void, label?: string) => unknown
}
const registry = inner.get?.('sidebarRightTabs') as NativeTabRegistry | undefined
const slots = inner.slots
if (registry === undefined || slots === undefined || typeof inner.effect !== 'function') return
inner.effect(() => {
const disposers: Array<() => void> = []
try {
// Stage 1 — the type. A page type: it recognizes no resource address
// and is opened by kind; `extension` is the band for types contributed
// from outside the product (highest rank, so it can also take over a
// builtin of the same kind).
disposers.push(registry.register({
id: PROFILE_ID,
kind: PROFILE_KIND,
priority: 'extension',
title: () => t('apesProfileTitle'),
guide: [{
order: 70,
title: () => t('apesProfileTitle'),
description: () => t('apesProfileGuideDesc'),
icon: IconUserOutline16,
}],
}))
// Stage 2 — the body, dispatched through the keyed seat. `inject`
// hands the body the client context the view reads its feed from.
disposers.push(slots.inject('sidebar.right.pane.tab', () => slots.register({
name: 'sidebar.right.pane.tab',
key: PROFILE_ID,
inject: () => ({ ctx }),
}, ProfileNativeBody as unknown as (props: Record<string, unknown>) => ReactNode)))
} catch (error) {
// A half-registered bridge must never take the fork's own sidebar
// down: unwind what landed and let the own-shell surface carry on.
console.error('[dsh-apes-sidebar] native right-sidebar registration failed:', error)
}
return () => {
for (const dispose of disposers.reverse()) {
try { dispose() } catch { /* already disposed */ }
}
}
}, 'dsh-apes-sidebar: apes native tab (profile)')
})
return () => { void fiber.dispose() }
}
+24 -3
View File
@@ -8,7 +8,7 @@
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidebarSessionEvent } from '../context-types.ts'
import type { BrowserProbeResult } from './browser.ts'
@@ -278,6 +278,15 @@ export const api = {
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) =>
@@ -307,6 +316,11 @@ export const api = {
/** 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
@@ -332,6 +346,10 @@ export const api = {
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/** Skip every active terminal_wait_for on one agent terminal (the wait
* banner's skip button). Idempotent: {skipped:0} when none is active. */
agentSkipWait: (uuid: string) =>
call<{ ok: true; skipped: number }>('agent-pty.skip-wait', { uuid }),
/** 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). */
@@ -376,9 +394,12 @@ export const api = {
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). */
* `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[] }>('sidechat.events', {
call<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }>('sidechat.events', {
childId,
...(afterSeq !== undefined ? { afterSeq } : {}),
}, signal),
+39
View File
@@ -0,0 +1,39 @@
/*
* Colors for the built-in tab glyphs (src/client/builtins/tab-icons.tsx).
*
* Every declaration resolves to a `--dsw-alias-*` token, so all skins stay in
* charge — including the token's own fallback chain, which keeps the glyph
* legible on a host that does not define the brand/state families. The plugin
* never writes a color literal here; `tests/theme.spec.ts` enforces both
* halves of that rule.
*
* The chips sit on the panel surface, so a token that is itself translucent
* would wash out at 14px; each rule therefore prefers an opaque state/brand
* token and falls back to the primary ink.
*/
.files {
color: var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary));
}
.changes {
color: var(--dsw-alias-state-success-primary, var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary)));
}
.tasks {
color: var(--dsw-alias-state-warn-primary, var(--dsw-alias-label-primary));
}
.sidechat {
color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary)));
}
.terminal {
/* The shell chip is ink, not accent. Its glyph is the widest in the set and
renders a step smaller than its siblings (see tab-icons.tsx). */
color: var(--dsw-alias-label-primary);
}
.browser {
color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary)));
}
+71
View File
@@ -0,0 +1,71 @@
/**
* The built-in tab glyphs, in color.
*
* Every built-in tab type and the incoming 8th (changes) declare their icon
* here, so the three surfaces that draw a tab's glyph — the bottom workbench's
* tab strip, the native right Sidebar's guide capsules and its tab chips — all
* read the same colorful glyph from `descriptor.icon`.
*
* The color always arrives from a theme token, never from the plugin: the
* glyph is a VSCodicon drawn in `currentColor`, and the wrapper class supplies
* that color. A skin therefore keeps control of everything (the guide's §12);
* this module is what `tests/theme.spec.ts` checks for color literals.
*
* `files` is the one exception in kind rather than in color: it renders DSH's
* own folder artwork (`FileTypeIcon`), matching the file rows this tab shows.
*/
import type { ReactNode } from 'react'
import { FileTypeIcon } from '@deepseek-ai/dsh-client-ui-primitives'
import styles from './tab-icons.module.css'
import {
VscCommentDiscussion,
VscGitCommit,
VscGlobe,
VscLayers,
VscTerminal,
} from 'react-icons/vsc'
/** The styled wrapper classes; typed so a renamed rule fails the build. */
const css = styles as Record<'files' | 'changes' | 'tasks' | 'sidechat' | 'terminal' | 'browser', string>
/** One tab type's glyph, sized by the caller's surface (14px in a strip). */
export type TabIcon = (size: number) => ReactNode
/** Surround a glyph with the class that hands it its token-driven color. */
function themed(className: string, glyph: ReactNode): ReactNode {
return <span className={className}>{glyph}</span>
}
/** The Files tab: the host's folder artwork, like the rows it opens. */
export const filesTabIcon: TabIcon = (size) => (
<span className={css.files}>
<FileTypeIcon kind="folder" size={size} />
</span>
)
/** Changes / diff: the commit glyph, green like the diff affordances. */
export const changesTabIcon: TabIcon = (size) =>
themed(css.changes, <VscGitCommit size={size} />)
/**
* Tasks (subagents and background jobs) — the live-activity amber. The glyph
* is layered sheets, not a checklist: this page lists RUNNING work (subagent
* sessions plus the host's background jobs), not a to-do list.
*/
export const tasksTabIcon: TabIcon = (size) =>
themed(css.tasks, <VscLayers size={size} />)
/** Side chat — the conversational/secondary accent. */
export const sidechatTabIcon: TabIcon = (size) =>
themed(css.sidechat, <VscCommentDiscussion size={size} />)
/**
* Terminal — primary ink, the shell is text. Rendered one step down from the
* strip's 14px: the VSCodicon terminal is a wide filled rectangle and read
* heavier than its neighbours at full size.
*/
export const terminalTabIcon: TabIcon = (size) =>
themed(css.terminal, <VscTerminal size={Math.max(10, Math.round(size * 0.85))} />)
/** Browser — the same secondary accent as the side chat's sibling surfaces. */
export const browserTabIcon: TabIcon = (size) =>
themed(css.browser, <VscGlobe size={size} />)
+19 -36
View File
@@ -8,8 +8,11 @@
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
import { IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCodeOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import {
browserTabIcon, changesTabIcon, filesTabIcon, sidechatTabIcon, tasksTabIcon, terminalTabIcon,
} from './tab-icons.tsx'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
@@ -22,7 +25,6 @@ import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16, IconFloatWindowOutline16, IconPanelBottomOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
@@ -72,7 +74,7 @@ function terminalUuid(): string {
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
return allLeaves(state.splits)
return allLeaves(state.bottomSplits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
@@ -82,11 +84,12 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
return [
{
id: 'editor',
description: () => t('guideDescFiles'),
// The single files window: an editor tab with no path IS the file
// explorer (empty hint + docked tree); with a path it previews/edits
// the file. Visible in the + menu in the explorer's old slot.
title: () => t('files'),
icon: (size: number) => <IconFolderOpen16 size={size} />,
icon: filesTabIcon,
order: 10,
hidden: false,
dedupeKey: (tab) => tab.path,
@@ -148,39 +151,15 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
// log, and the git status needs a fetch — both stay out of the badge).
id: 'git',
title: () => t('changes'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
description: () => t('guideDescGit'),
icon: changesTabIcon,
order: 20,
single: true,
badge: (_ctx, scope) => {
const count = opCountOf(scope.sessionId)
return count === undefined || count === 0 ? null : count
},
// Declarative settings: the diff-open picker (free window vs docked
// pane) renders as an iconed select row under the changes card's gear
// in the Side card settings page.
settings: {
toggles: [{
key: 'changesDiffFloat',
type: 'select',
title: () => t('changesDiffOpenTitle'),
desc: () => t('changesDiffOpenDesc'),
options: [
{
value: true,
icon: (size: number) => <IconFloatWindowOutline16 size={size} />,
title: () => t('changesDiffOpenFloat'),
desc: () => t('changesDiffOpenFloatDesc'),
},
{
value: false,
icon: (size: number) => <IconPanelBottomOutline16 size={size} />,
title: () => t('changesDiffOpenPane'),
desc: () => t('changesDiffOpenPaneDesc'),
},
],
}],
},
component: ({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }) => (
component: ({ ctx, store, scope, tab, visible, onOpenDiff }) => (
<ChangesTab
ctx={ctx}
store={store}
@@ -195,7 +174,8 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'subagent',
title: () => t('subagent'),
icon: (size: number) => <IconThinkOutline16 size={size} />,
description: () => t('guideDescSubagent'),
icon: tasksTabIcon,
order: 30,
single: true,
// Declarative settings: the auto-open switches render under this row in
@@ -223,7 +203,8 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'sidechat',
title: () => t('sideChat'),
icon: (size: number) => <IconNewChatOutline16 size={size} />,
description: () => t('guideDescSidechat'),
icon: sidechatTabIcon,
order: 35,
// Codex-style: EVERY side conversation is its own tab. A plain open
// mints a fresh tab flagged `autoCreate` (the view creates the EMPTY
@@ -268,7 +249,8 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'terminal',
title: () => t('terminal'),
icon: (size: number) => <IconTerminalOutline16 size={size} />,
description: () => t('guideDescTerminal'),
icon: terminalTabIcon,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
// Declarative settings: the model-facing terminal tools switch, the
@@ -332,7 +314,8 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'browser',
title: () => t('browser'),
icon: (size: number) => <IconGlobeOutline16 size={size} />,
description: () => t('guideDescBrowser'),
icon: browserTabIcon,
order: 50,
// Declarative settings: the sandbox escape hatch, the link-takeover
// MASTER switch, and the per-protocol takeover switches (http on /
@@ -376,7 +359,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'diff',
title: () => t('changes'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
icon: changesTabIcon,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.id,
+30 -3
View File
@@ -1,5 +1,15 @@
/** Stable selector for the DSH AppFrame conversation slot. */
const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]'
/**
* Anchor selectors for the DSH AppFrame conversation slot host, newest first:
*
* - DSH 0.1.5-alpha.2 replaced the root `conversation` slot with a root-scoped
* keyed `main` slot, so the conversation renders under `main.conversation`
* (ConversationPanel -> `renderSlot('main.conversation')`);
* - alpha.1 and earlier spell the same host `conversation`.
*
* Both keys are accepted deliberately so one build spans the alpha.1 → alpha.2
* boundary; only one exists in any given host.
*/
const CENTER_COLUMN_SELECTOR = '#root [data-slot="main.conversation"], #root [data-slot="conversation"]'
/** Match Sidebar's existing last-resort retry cadence (issue #248). */
export const CENTER_COLUMN_REVALIDATE_MS = 1500
@@ -27,6 +37,22 @@ const validatedAt = new WeakMap<HTMLElement, number>()
*/
const documentStyleState = new WeakMap<Document, string | null>()
/**
* From the matched conversation slot host, walk up through transparent slot
* hosts to the real column: DSH slot hosts render with `display: contents`,
* so on alpha.2 the `main.conversation` host (and any further keyed slot
* wrappers above it) contributes no box, and the center column is the first
* ancestor whose computed display is NOT `contents`.
*/
function columnOfSlotHost(host: Element): HTMLElement | null {
const view = host.ownerDocument.defaultView
let node = host.parentElement
while (node !== null && (view?.getComputedStyle(node).display ?? '') === 'contents') {
node = node.parentElement
}
return node
}
/**
* Resolve the AppFrame center column while keeping streaming mutations cheap.
*
@@ -74,7 +100,8 @@ export function resolveCenterColumn(
}
const query = options.query ?? (() => doc.querySelector(CENTER_COLUMN_SELECTOR))
const col = query()?.parentElement as HTMLElement | null | undefined
const host = query()
const col = host === null || host === undefined ? null : columnOfSlotHost(host)
if (col === null || col === undefined || !col.isConnected) return undefined
validatedAt.set(col, now)
+16 -20
View File
@@ -3,9 +3,9 @@
* (repository truth: staged/unstaged files, commit box, history) and the
* session round (agent truth: every file the model read, wrote, or edited).
* Both lenses preview their selections in a shared resizable bottom pane
* ({@link DiffPane}); git targets expand into the dedicated diff tab —
* docked in a pane, or floated as a free window per the tab's setting.
* The active lens and the pane height persist in the tab's meta, so the tab
* ({@link DiffPane}); git targets expand into the dedicated diff tab docked
* in the workbench's diff pane. The active lens and the pane height persist
* in the tab's meta, so the tab
* reopens exactly where it was left.
*
* The session events ride the host's `changes.ops` route (the client
@@ -18,7 +18,8 @@ import type { SidebarSessionEvent } from '../../context-types.ts'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import { api } from '../api.ts'
import { floatTab, type SidebarDiffRef } from '../state.ts'
import { usePolling } from '../use-polling.ts'
import type { SidebarDiffRef } from '../state.ts'
import { GitLens } from './GitLens.tsx'
import { SessionLens } from './SessionLens.tsx'
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
@@ -93,6 +94,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
// only while nothing has ever loaded.
if (generation === pollGen.current) setOpsError(true)
}
// Granular scope fields: the scope object's identity churns, only its
// sessionId / cwd fields gate the poll target.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scope.sessionId, scope.cwd])
useEffect(() => {
pollGen.current += 1
@@ -101,12 +105,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
seqRef.current = 0
setOpsError(false)
}, [scope.sessionId])
useEffect(() => {
void pull()
if (!visible) return
const timer = window.setInterval(() => { void pull() }, 2_500)
return () => { window.clearInterval(timer) }
}, [visible, pull])
// One pull on every input change — mount, scope change, and visibility
// flip all re-pull (a hidden tab still catches up on the flip); only the
// poll CADENCE below is gated by visibility.
useEffect(() => { void pull() }, [visible, pull])
usePolling(visible, pull, { intervalMs: 2_500 })
// tick only forces the re-render; the fold reads the ref directly.
void tick
const ops = opsRef.current
@@ -134,18 +137,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) })
}
/** Expand the current git preview into the dedicated diff tab: docked
* into the shell's diff pane, or floated as a free window centered on
* the viewport when the tab's diff-open setting asks for it (default). */
/** Expand the current git preview into the dedicated diff tab, docked
* into the workbench's diff pane. */
const expandPreview = (): void => {
if (preview?.kind !== 'git') return
const diffTab = diffTabOf(preview.ref)
onOpenDiff?.(diffTab)
if (store.getPrefs().changesDiffFloat !== false) {
const x = Math.round(window.innerWidth / 2)
const y = Math.round(window.innerHeight / 2)
store.reduce(state => floatTab(state, diffTab.id, x, y))
}
onOpenDiff?.(diffTabOf(preview.ref))
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
+292 -9
View File
@@ -9,26 +9,37 @@
* diff tab via the shell.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { api } from '../api.ts'
import { api, htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import { HtmlPreviewFrame } from '../HtmlPreviewFrame.tsx'
import type { SidebarDiffRef, SidebarTab } from '../state.ts'
import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
import { PdfView } from '../PdfView.tsx'
import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { parseReadLines, type FileOp } from './ops.ts'
import { buildDiffSegments, diffLines, diffStats, displayPath, foldRowsFromContents, parseUnifiedDiff, unifiedSegments, type DiffFile, type DiffRow, type FoldSegment } from '../diff/rows.ts'
import { parseReadContent, parseReadLines, type FileOp } from './ops.ts'
import { redactText } from '../redact.ts'
import { rewriteLocalImageUrls } from '../markdown-images.ts'
import { markdownTextProps } from '../markdown-labels.tsx'
import { splitMermaidBlocks } from '../mermaid-blocks.ts'
import { LazyMermaidMarkdown } from '../mermaid-lazy.tsx'
import { createFrameBatcher } from '../frame-batcher.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
/** The drag handle height clamp (px) and keyboard-resize step. */
/** Drag handle height clamp (px) and keyboard-resize step. */
const HEIGHT_MIN = 140
const HEIGHT_STEP = 24
/** The redaction preference, persisted under the repo's sidebar storage
* prefix (see state.ts's `dsh-sidebar:v1`). */
const REDACTION_KEY = 'dsh-sidebar:v1:redaction'
/** What the pane is showing right now. */
export type ChangesPreview =
| { kind: 'git'; ref: SidebarDiffRef }
@@ -55,6 +66,60 @@ function diffOf(op: FileOp, prior: string | undefined): readonly DiffRow[] {
return []
}
/** The render view of one html op target. Extracted (and exported) so the
* always-sandboxed contract is pinned directly by the sandbox spec — this
* surface has NO no-sandbox escape hatch, so it always takes the srcdoc
* delivery path. */
export function HtmlRenderPreview(props: { src: string; title: string }) {
return (
<div className={css.htmlPane}>
<HtmlPreviewFrame
className={css.htmlFrame}
url={props.src}
sandboxed
title={props.title}
/>
</div>
)
}
/** One header pill toggle — the redaction / reading / render toggles share
* the shape (on-state styling + aria-pressed). */
function PaneToggle(props: { on: boolean; label: string; title?: string; onClick: () => void }) {
return (
<button
type="button"
className={css.mdToggle}
data-on={props.on ? 'true' : undefined}
aria-pressed={props.on}
title={props.title}
onClick={props.onClick}
>
{props.label}
</button>
)
}
/** The reading-mode body of one markdown op target: the shared MarkdownText
* pass (local image destinations already rewritten to the media route by
* the caller). Mermaid fences render through the same chunk-resident
* renderer the editor preview uses (one MarkdownText pass with the fences
* lifted out); the plain path stays byte-for-byte for documents without
* any. */
function MdReadingView(props: { text: string }) {
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
const hasMermaid = splitMermaidBlocks(props.text).some((block) => block.kind === 'mermaid')
return (
<div className={css.paneBody}>
<div className={css.mdBody}>
{hasMermaid
? <LazyMermaidMarkdown text={props.text} codeLabels={codeLabels} />
: <MarkdownText {...markdownTextProps(props.text, codeLabels)} />}
</div>
</div>
)
}
/** The diff tab a git preview expands into (the shell owns placement). */
export function diffTabOf(ref: SidebarDiffRef): SidebarTab {
if (ref.kind === 'worktree') {
@@ -92,7 +157,19 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
const [error, setError] = useState<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(undefined)
// 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 gitRef = target.kind === 'git' ? target.ref : null
// The scope every git call of this target shares (repoRoot folded in when
// the ref carries one, exactly like the load effect's paneScope).
const gitScope = useMemo<SessionScope>(() => ({
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef?.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}), [scope.sessionId, scope.cwd, gitRef?.repoRoot])
useEffect(() => {
if (gitRef === null) return
@@ -106,6 +183,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
setError(null)
setDiffText(null)
setUntracked(undefined)
setEffectiveStaged(null)
const load = async (): Promise<void> => {
try {
if (gitRef.kind === 'commit') {
@@ -118,7 +196,10 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
// The requested side is empty — try the OTHER side once (the change
// may have moved sides after the preview target was minted).
const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree)
if (other.diff !== '') result = other
if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!gitRef.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setDiffText(result.diff)
@@ -145,10 +226,119 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
return () => { cancelled = true }
}, [gitRef, scope.sessionId, scope.cwd, 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 target or a refresh tick. ───────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [gitRef, tick])
const foldLoader = useMemo(() => {
if (gitRef === null) return undefined
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 }> => {
if (gitRef.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(gitScope, `${gitRef.hashFull}^`, displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, gitRef.hashFull, displayPath(file.newPath), gitRef.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 ?? gitRef.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.newPath), gitRef.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.oldPath), gitRef.worktree),
api.fsRead(gitScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.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))
}, [gitRef, gitScope, effectiveStaged, scope])
// ── Op target material (pure snapshots; the prior content came with the
// target so a running op shows what is already known). ────────────────
const op = target.kind === 'op' ? target.op : null
const prior = target.kind === 'op' ? target.prior : undefined
const opRaw = target.kind === 'op' ? target.op : null
const priorRaw = target.kind === 'op' ? target.prior : undefined
// Secret redaction: on by default, toggle persists per browser (localStorage).
// Every op payload consumer below (diff rows, read rows, markdown source,
// error text) renders from the REDACTED shape, so masked payloads are the
// only thing that can reach the DOM while the toggle is on. Display-only:
// session events and the fs layer keep their original bytes.
const [redactionOn, setRedactionOn] = useState((): boolean => {
try { return localStorage.getItem(REDACTION_KEY) !== '0' } catch { return true }
})
const toggleRedaction = (): void => {
setRedactionOn((prev) => {
const next = !prev
try { localStorage.setItem(REDACTION_KEY, next ? '1' : '0') } catch { /* storage unavailable */ }
return next
})
}
const { op, prior, redactionHit } = useMemo(() => {
if (opRaw === null || !redactionOn) return { op: opRaw, prior: priorRaw, redactionHit: false }
const path = target.kind === 'op' ? target.path : ''
// One redactText pass per field: the outcome carries both the masked
// text and whether anything was hit.
const mask = (text: string | undefined): { masked: string | undefined; hit: boolean } => {
if (text === undefined) return { masked: undefined, hit: false }
const outcome = redactText(path, text)
return { masked: outcome.text, hit: outcome.hit }
}
const read = mask(opRaw.read)
const content = mask(opRaw.content)
const editOld = mask(opRaw.edit?.oldString)
const editNew = mask(opRaw.edit?.newString)
const errorText = mask(opRaw.errorText)
const priorMasked = mask(priorRaw)
const hit = read.hit || content.hit || editOld.hit || editNew.hit || errorText.hit || priorMasked.hit
if (!hit) return { op: opRaw, prior: priorRaw, redactionHit: false }
const redacted: FileOp = {
...opRaw,
...(read.masked !== undefined ? { read: read.masked } : {}),
...(content.masked !== undefined ? { content: content.masked } : {}),
...(opRaw.edit !== undefined
? { edit: { oldString: editOld.masked ?? opRaw.edit.oldString, newString: editNew.masked ?? opRaw.edit.newString } }
: {}),
...(errorText.masked !== undefined ? { errorText: errorText.masked } : {}),
}
return { op: redacted, prior: priorMasked.masked, redactionHit: true }
}, [opRaw, priorRaw, target, redactionOn])
const opLang = useMemo(() => (target.kind === 'op' ? langOfPath(target.path) : undefined), [target])
const opRows = useMemo(() => (op === null ? [] : diffOf(op, prior)), [op, prior])
const opSegments = useMemo(() => buildDiffSegments(opRows), [opRows])
@@ -158,6 +348,56 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
[op],
)
// ── Markdown reading mode: .md op targets (read/write/edit, non-error)
// toggle between the raw/diff view and the rendered document — the same
// shared MarkdownText pass the editor preview uses, with local image
// destinations rewritten through the /sidebar/file media route. ──────
const mdOp = target.kind === 'op' && !target.op.isError && /\.(md|markdown|mdx)$/i.test(target.path)
const [reading, setReading] = useState(false)
const readingSrc = useMemo(() => {
if (!mdOp || op === null) return ''
if (op.kind === 'read') return parseReadContent(op.read ?? '')
if (op.kind === 'write') return op.content ?? ''
if (op.kind === 'edit' && op.edit !== undefined) {
if (prior !== undefined && prior.includes(op.edit.oldString)) {
return prior.replace(op.edit.oldString, op.edit.newString)
}
return op.edit.newString
}
return ''
}, [mdOp, op, prior])
const readingText = useMemo(
() => (mdOp && reading && readingSrc !== '' && target.kind === 'op'
? rewriteLocalImageUrls(readingSrc, scope, target.path, window.location.origin)
: ''),
[mdOp, reading, readingSrc, scope, target],
)
// ── HTML render mode: .html/.htm op targets (the editor html viewer's
// ext set) load the SAVED file through the same /sidebar/html route the
// editor's html viewer uses — relative assets (./style.css, img/x.png)
// resolve inside the route, and a segmented read still renders the
// whole document (the route serves the file, not the op snapshot). The
// frame is always sandboxed (the attribute plus the route's CSP sandbox
// header); the editor tab owns the warned no-sandbox escape hatch. ──
const htmlOp = target.kind === 'op' && !target.op.isError && /\.(html?)$/i.test(target.path)
const [rendering, setRendering] = useState(false)
const htmlRenderSrc = useMemo(() => {
if (!htmlOp || target.kind !== 'op') return ''
return htmlUrl(scope, resolveSidebarPath(scope.cwd, target.path))
}, [htmlOp, scope, target])
// ── PDF render mode: .pdf op targets (read / write / edit, non-error) reuse
// the editor's PdfView verbatim — media-route bytes wrapped into an
// explicitly-typed Blob so the browser's native PDF viewer opens (a
// direct iframe src can fall back to a download). ──────────────────────
const pdfOp = target.kind === 'op' && !target.op.isError && /\.pdf$/i.test(target.path)
const [renderingPdf, setRenderingPdf] = useState(false)
const pdfRenderPath = useMemo(() => {
if (!pdfOp || target.kind !== 'op') return ''
return resolveSidebarPath(scope.cwd, target.path)
}, [pdfOp, scope, target])
// Header stats for git targets come off the parsed patch text.
const gitStats = useMemo(() => {
if (target.kind !== 'git' || diffText === null || diffText === '') return null
@@ -264,6 +504,38 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
</button>
</>
)}
{redactionHit && (
<span className={css.redactBanner} role="status">{t('changesRedactBanner')}</span>
)}
{target.kind === 'op' && (
<PaneToggle
on={redactionOn}
label={redactionOn ? t('changesRedactOnLabel') : t('changesRedactOffLabel')}
title={redactionOn ? t('changesRedactOff') : t('changesRedactOn')}
onClick={toggleRedaction}
/>
)}
{mdOp && (
<PaneToggle
on={reading}
label={t(reading ? 'changesMdRaw' : 'changesMdReading')}
onClick={() => { setReading(value => !value) }}
/>
)}
{htmlOp && (
<PaneToggle
on={rendering}
label={t(rendering ? 'changesHtmlRaw' : 'changesHtmlRender')}
onClick={() => { setRendering(value => !value) }}
/>
)}
{pdfOp && (
<PaneToggle
on={renderingPdf}
label={t(renderingPdf ? 'changesPdfRaw' : 'changesPdfRender')}
onClick={() => { setRenderingPdf(value => !value) }}
/>
)}
<button
type="button"
className={css.iconButton}
@@ -274,7 +546,17 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
<IconCloseOutline16 size={14} />
</button>
</div>
{target.kind === 'op' && op !== null && op.isError
{target.kind === 'op' && htmlOp && rendering && htmlRenderSrc !== ''
? <HtmlRenderPreview src={htmlRenderSrc} title={target.path} />
: target.kind === 'op' && pdfOp && renderingPdf && pdfRenderPath !== ''
? (
<div className={css.htmlPane}>
<PdfView scope={scope} path={pdfRenderPath} title={target.path} />
</div>
)
: target.kind === 'op' && mdOp && reading && readingText !== ''
? <MdReadingView text={readingText} />
: target.kind === 'op' && op !== null && op.isError
? (
<div className={css.paneBody}>
<div className={css.readError} role="alert">
@@ -306,6 +588,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
{diffText !== null && diffText !== '' && (
<DiffFiles
diff={diffText}
resolveFold={foldLoader}
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
untrackedContent={untracked}
/>
+14 -5
View File
@@ -17,6 +17,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
import { api } from '../api.ts'
import { usePolling } from '../use-polling.ts'
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import { relativeTime, t } from '../locales.ts'
@@ -168,6 +169,9 @@ export function GitLens(props: GitLensProps) {
} finally {
if (options.loading && options.generation === refreshGeneration.current) setLoading(false)
}
// Granular scope fields: the scope object's identity churns, only its
// sessionId / cwd fields gate the git target.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scope.sessionId, scope.cwd, repoRoot])
const refresh = useCallback(async (silent = false): Promise<void> => {
@@ -231,6 +235,9 @@ export function GitLens(props: GitLensProps) {
} finally {
refreshInFlight.current = false
}
// Granular scope fields: the scope object's identity churns, only its
// sessionId / cwd fields gate the refresh target.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scope.sessionId, scope.cwd, refreshTarget])
useEffect(() => {
@@ -274,11 +281,11 @@ export function GitLens(props: GitLensProps) {
const generation = refreshGeneration.current += 1
void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation })
}
useEffect(() => {
if (!visible) return
const timer = window.setInterval(() => { void refresh(true) }, 2_000)
return () => { window.clearInterval(timer) }
}, [visible, refresh])
/** The silent poll tick (the status-only fast path between worktree
* re-lists, see refresh) — fixed 2s cadence while visible, no initial
* burst (mount and scope changes already refresh above). */
const pollTick = useCallback((): Promise<void> => refresh(true), [refresh])
usePolling(visible, pollTick, { intervalMs: 2_000 })
/** Append the next history page (lazy: only when the user asks for more). */
const loadMoreLog = async (): Promise<void> => {
@@ -664,6 +671,7 @@ export function GitLens(props: GitLensProps) {
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
}}
portal
compact
align="start"
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
anchor={<span />}
@@ -721,6 +729,7 @@ export function GitLens(props: GitLensProps) {
}
}}
portal
compact
align="start"
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
anchor={<span />}
+8
View File
@@ -342,6 +342,14 @@
/* ── Shared preview pane ───────────────────────────────────────────────── */
.redactBanner { flex: none; font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; border-radius: 7px; color: var(--dsw-alias-state-warn-primary); background: var(--dsw-alias-interactive-bg-hover); white-space: nowrap; }
.mdToggle { flex: none; border: 1px solid var(--dsw-alias-border-l1); border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; cursor: pointer; }
.mdToggle:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.mdToggle[data-on='true'] { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary); }
.mdBody { padding: 12px 16px 20px; font: var(--dsw-font-xs-regular-13); line-height: 1.6; min-height: 0; overflow-y: auto; }
.htmlPane { flex: 1; min-height: 0; display: flex; }
.htmlFrame { flex: 1; width: 100%; border: none; background: var(--dsw-alias-bg-base); }
.diffPane { border-top: 1px solid var(--dsw-alias-border-l1); display: flex; flex-direction: column; flex: none; min-height: 0; }
.dragHandle { flex: none; height: 8px; cursor: ns-resize; position: relative; background: var(--dsw-alias-bg-base); }
.dragHandle::after { content: ''; position: absolute; left: calc(50% - 18px); top: 3px; width: 36px; height: 2px; border-radius: 1px; background: var(--dsw-alias-border-l2); }
+28 -4
View File
@@ -201,6 +201,33 @@ export function knownContentBefore(ops: readonly FileOp[], path: string, before:
return undefined
}
/**
* The read-tool response's `<content>` body split into lines with the
* "(Showing lines …)" note dropped — the shared envelope core both read
* parsers consume: {@link parseReadLines} recovers line numbers from the
* "<n>: " prefixes, {@link parseReadContent} keeps the raw text.
*/
function contentLines(raw: string): string[] {
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
const body = contentMatch ? contentMatch[1]! : raw
return body.split('\n').filter((line) => !/^\s*\(Showing lines .*\)\s*$/.test(line))
}
/**
* Strip the DSH read-tool response envelope down to the file's raw content,
* PRESERVING blank lines (unlike {@link parseReadLines}, which drops them):
* drops the <content> wrapper, the "(Showing lines ...)" note, and the
* per-line "<n>: " prefixes. The markdown reading mode needs the blank lines
* because markdown block structure depends on them.
*/
export function parseReadContent(raw: string): string {
return contentLines(raw)
.map((line) => line.replace(/^\s*\d+:\s/, ''))
.join('\n')
.replace(/^[\n]+/, '')
.replace(/\n+$/, '')
}
/** One parsed read line: the file's own line number and its content text. */
export interface ReadLine { readonly line: number; readonly text: string }
@@ -210,12 +237,9 @@ export interface ReadLine { readonly line: number; readonly text: string }
* "<n>: " prefix as the line number, falling back to sequential counting.
*/
export function parseReadLines(raw: string): ReadLine[] {
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
const body = contentMatch ? contentMatch[1]! : raw
const result: ReadLine[] = []
let fallback = 1
for (const line of body.split('\n')) {
if (/^\s*\(Showing lines .*\)\s*$/.test(line)) continue
for (const line of contentLines(raw)) {
if (line.length === 0) continue
const match = line.match(/^\s*(\d+):\s?(.*)$/)
if (match !== null) {
+6 -8
View File
@@ -23,8 +23,9 @@
* arbitrary file names, so the plugin's own host route serves the chunks),
* 2. read the factory from the global registry,
* 3. call it with a require that resolves the platform externals through
* `__DSH_MODULES__.import(spec)` — the seed-word branch, the one part of
* the module system that is stable across versions.
* the injected module system's `import(spec)` (the `ctx.modules` service)
* — the seed-word branch, the one part of the module system that is
* stable across versions.
*
* Caching contract (three layers, each with a failure path):
* - In-memory: one in-flight promise per chunk, memoized until
@@ -90,8 +91,7 @@ const CHUNK_REVALIDATE_TIMEOUT_MS = 5_000
* The client module system surface this loader needs to resolve externals.
* DSH 0.1.0-rc.8 provides it as the `ctx.modules` service (no page global
* anymore); the plugin injects it at activation via
* {@link setChunkModuleSystem}. The rc.7-era `window.__DSH_MODULES__` global
* remains as a fallback so older hosts and the test harness keep working.
* {@link setChunkModuleSystem}.
*/
export interface ChunkModuleSystem {
import(specifier: string): Promise<unknown>
@@ -123,12 +123,11 @@ export function setChunkModuleSystem(system: ChunkModuleSystem | undefined): voi
}
/** Resolve the shell-installed module system (injected, then the plugin
* global shared with chunk-bundle copies, then the rc.7 page global). */
* global shared with chunk-bundle copies). */
function moduleSystem(): ChunkModuleSystem | undefined {
const g = globalThis as Record<string, unknown>
return injectedModuleSystem
?? g[MODULE_SYSTEM_GLOBAL] as ChunkModuleSystem | undefined
?? (g as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
}
/** The plugin-owned chunk factory registry the chunk scripts populate. */
@@ -244,8 +243,7 @@ export async function loadChunk(name: ChunkName): Promise<ChunkExports> {
if (revalidation !== null) await revalidation
const cached = cache.get(name)
if (cached !== undefined) return cached
let task: Promise<ChunkExports>
task = (async (): Promise<ChunkExports> => {
const task: Promise<ChunkExports> = (async (): Promise<ChunkExports> => {
const test = testLoaders.get(name)
if (test !== undefined) return test()
const modules = moduleSystem()
+44 -42
View File
@@ -2,15 +2,17 @@
* CodeMirror 6 theme pieces for the sidebar editor. The editor surface
* (background, caret, gutter) rides the DSH theme tokens so it blends with
* the panel in both schemes; only the syntax token colors need concrete
* values, and those come from the same designed palettes the app's code
* surfaces use — the one-dark family for dark, the one-light family for
* light. The scheme flip reconfigures these via a compartment (see
* TextEditor), so the document, undo history and scroll survive re-theming.
* values, and those come from 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 terminal's ANSI palette). The
* scheme flip reconfigures these via a compartment (see TextEditor), so
* the document, undo history and scroll survive re-theming.
*/
import { Compartment } from '@codemirror/state'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags, type Tag } from '@lezer/highlight'
import { EditorView } from '@codemirror/view'
import { ONE_DARK, ONE_LIGHT } from './one-dark-palette.ts'
/** Token-driven surface shared by both schemes (pure CSS values). */
export const cmSurfaceTheme = EditorView.theme({
@@ -53,52 +55,52 @@ interface HighlightRule {
fontStyle?: string
}
/** one-dark syntax palette (mirrors @codemirror/theme-one-dark). */
/** one-dark syntax rules (mirrors @codemirror/theme-one-dark; hues from one-dark-palette.ts). */
const HIGHLIGHTS_DARK: HighlightRule[] = [
{ tag: tags.comment, color: '#5c6370', fontStyle: 'italic' },
{ tag: tags.keyword, color: '#c678dd' },
{ tag: tags.string, color: '#98c379' },
{ tag: tags.number, color: '#d19a66' },
{ tag: tags.bool, color: '#d19a66' },
{ tag: tags.atom, color: '#d19a66' },
{ tag: tags.typeName, color: '#e5c07b' },
{ tag: tags.className, color: '#e5c07b' },
{ tag: tags.propertyName, color: '#e06c75' },
{ tag: tags.function(tags.variableName), color: '#61afef' },
{ tag: tags.variableName, color: '#e06c75' },
{ tag: tags.operator, color: '#56b6c2' },
{ tag: tags.tagName, color: '#e06c75' },
{ tag: tags.attributeName, color: '#d19a66' },
{ tag: tags.heading, color: '#e06c75', fontStyle: 'bold' },
{ tag: tags.comment, color: ONE_DARK.faintGray, fontStyle: 'italic' },
{ tag: tags.keyword, color: ONE_DARK.magenta },
{ tag: tags.string, color: ONE_DARK.green },
{ tag: tags.number, color: ONE_DARK.orange },
{ tag: tags.bool, color: ONE_DARK.orange },
{ tag: tags.atom, color: ONE_DARK.orange },
{ tag: tags.typeName, color: ONE_DARK.yellow },
{ tag: tags.className, color: ONE_DARK.yellow },
{ tag: tags.propertyName, color: ONE_DARK.red },
{ tag: tags.function(tags.variableName), color: ONE_DARK.blue },
{ tag: tags.variableName, color: ONE_DARK.red },
{ tag: tags.operator, color: ONE_DARK.cyan },
{ tag: tags.tagName, color: ONE_DARK.red },
{ tag: tags.attributeName, color: ONE_DARK.orange },
{ tag: tags.heading, color: ONE_DARK.red, fontStyle: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontStyle: 'bold' },
{ tag: tags.link, color: '#61afef', fontStyle: 'underline' },
{ tag: tags.meta, color: '#e5c07b' },
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
{ tag: tags.link, color: ONE_DARK.blue, fontStyle: 'underline' },
{ tag: tags.meta, color: ONE_DARK.yellow },
{ tag: tags.invalid, color: ONE_DARK.white, fontStyle: 'bold' },
]
/** one-light syntax palette (the light counterpart of one-dark). */
/** one-light syntax rules (the light counterpart; hues from one-dark-palette.ts). */
const HIGHLIGHTS_LIGHT: HighlightRule[] = [
{ tag: tags.comment, color: '#a0a1a7', fontStyle: 'italic' },
{ tag: tags.keyword, color: '#a626a4' },
{ tag: tags.string, color: '#50a14f' },
{ tag: tags.number, color: '#986801' },
{ tag: tags.bool, color: '#0184bc' },
{ tag: tags.atom, color: '#0184bc' },
{ tag: tags.typeName, color: '#c18401' },
{ tag: tags.className, color: '#c18401' },
{ tag: tags.propertyName, color: '#e45649' },
{ tag: tags.function(tags.variableName), color: '#c18401' },
{ tag: tags.variableName, color: '#e45649' },
{ tag: tags.operator, color: '#383a42' },
{ tag: tags.tagName, color: '#e45649' },
{ tag: tags.attributeName, color: '#986801' },
{ tag: tags.heading, color: '#e45649', fontStyle: 'bold' },
{ tag: tags.comment, color: ONE_LIGHT.gray, fontStyle: 'italic' },
{ tag: tags.keyword, color: ONE_LIGHT.magenta },
{ tag: tags.string, color: ONE_LIGHT.green },
{ tag: tags.number, color: ONE_LIGHT.orange },
{ tag: tags.bool, color: ONE_LIGHT.blue },
{ tag: tags.atom, color: ONE_LIGHT.blue },
{ tag: tags.typeName, color: ONE_LIGHT.yellow },
{ tag: tags.className, color: ONE_LIGHT.yellow },
{ tag: tags.propertyName, color: ONE_LIGHT.red },
{ tag: tags.function(tags.variableName), color: ONE_LIGHT.yellow },
{ tag: tags.variableName, color: ONE_LIGHT.red },
{ tag: tags.operator, color: ONE_LIGHT.black },
{ tag: tags.tagName, color: ONE_LIGHT.red },
{ tag: tags.attributeName, color: ONE_LIGHT.orange },
{ tag: tags.heading, color: ONE_LIGHT.red, fontStyle: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontStyle: 'bold' },
{ tag: tags.link, color: '#4078f2', fontStyle: 'underline' },
{ tag: tags.meta, color: '#c18401' },
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
{ tag: tags.link, color: ONE_LIGHT.link, fontStyle: 'underline' },
{ tag: tags.meta, color: ONE_LIGHT.yellow },
{ tag: tags.invalid, color: ONE_LIGHT.white, fontStyle: 'bold' },
]
/** The scheme-dependent extension pair (surface tint + syntax highlight). */
+1
View File
@@ -203,6 +203,7 @@ export function appendToDraft(ctx: Context, sessionId: string, text: string): bo
*/
export function fileMention(relativePath: string): { mention: string; label: string } | undefined {
const path = relativePath.replace(/[\\/]+$/, '')
// eslint-disable-next-line no-control-regex -- rejecting control characters is the point of this guard
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
const mention = /\s/u.test(path) ? `@"${path}"` : `@${path}`
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
+21
View File
@@ -70,3 +70,24 @@ function parseTitlebarInset(raw: string | null): number {
export function resetDesktopEnvForTests(): void {
cached = undefined
}
/**
* The element the sidebar's panel host belongs in.
*
* The desktop shell stamps the upstream `#root` with
* `data-dsh-desktop-content-viewport` and gives it `isolation: isolate` — a
* stacking context around the whole app INCLUDING the shell's own overlay
* layer (`.dshDesktopOverlay`, z-index 1000), which is where a plugin's modal
* renders (the community market's, for one). A host parented to
* `document.body` sits OUTSIDE that context, so the panel layer's documented
* 25 outranks everything inside it and covered host modals; inside the
* viewport the same 25 keeps its meaning — above the app frame (z-index auto)
* and below the overlay layer (1000).
*
* Every other deployment (plain web, an unframed shell) keeps the body mount.
* @param doc - the document to resolve against.
* @returns the content viewport when the shell framed one, else the body.
*/
export function panelHostParent(doc: Document = document): HTMLElement {
return doc.querySelector<HTMLElement>('#root[data-dsh-desktop-content-viewport]') ?? doc.body
}
+29 -4
View File
@@ -7,10 +7,10 @@
* Untracked files produce no `git diff` output; the caller passes their
* content to render as a full-file addition instead.
*/
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { t } from '../locales.ts'
import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile } from './rows.ts'
import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile, type DiffRow, type FoldSegment } from './rows.ts'
import { langOfPath } from './highlight.ts'
import { DiffRows } from './DiffRows.tsx'
import css from './diff.module.css'
@@ -51,9 +51,14 @@ export interface DiffFilesProps {
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
untrackedPath?: string
untrackedContent?: string
/** Fetch a git gap fold's hidden rows on demand (both sides' contents by
* the fold's line ranges); forwarded to every file's DiffRows. Absent
* folds without `rows` stay non-expandable (session-op diffs and
* untracked additions always carry theirs). */
resolveFold?: (file: DiffFile, segment: FoldSegment) => Promise<readonly DiffRow[]>
}
export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesProps) {
export function DiffFiles({ diff, untrackedPath, untrackedContent, resolveFold }: DiffFilesProps) {
const parsed = useMemo(() => {
if (untrackedPath !== undefined) {
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
@@ -63,6 +68,26 @@ export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesPr
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
// One in-flight/resolved promise per file+fold key, so a fold re-clicked
// after a remount (file header collapsed and re-expanded) resolves without
// a second fetch and concurrent clicks join the same request. The cache
// dies with `parsed` (a new diff text); rejected promises are dropped so a
// later retry can go through.
const foldCache = useRef(new Map<string, Promise<readonly DiffRow[]>>())
useEffect(() => { foldCache.current = new Map() }, [parsed])
const loadFold = (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> => {
if (resolveFold === undefined) return Promise.resolve([])
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
const key = `${path}|${String(segment.oldStart)}|${String(segment.newStart)}`
let promise = foldCache.current.get(key)
if (promise === undefined) {
promise = resolveFold(file, segment)
foldCache.current.set(key, promise)
promise.catch(() => { foldCache.current.delete(key) })
}
return promise
}
// Segments and header stats computed once per file.
const files = useMemo(
() => parsed.files.map((file) => {
@@ -107,7 +132,7 @@ export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesPr
)}
</button>
{expandable && fileExpanded && (
<DiffRows segments={segments} lang={langOfPath(to)} />
<DiffRows segments={segments} lang={langOfPath(to)} resolveFold={resolveFold !== undefined ? (segment) => loadFold(file, segment) : undefined} />
)}
</div>
)
+68 -16
View File
@@ -4,11 +4,14 @@
* line gutters, rewrite (mod) tinting with intra-line character highlights,
* lightweight syntax coloring, and long-line folding. Presentational — the
* segments arrive precomputed (session ops via buildDiffSegments, git diffs
* via unifiedSegments) so both producers render identically.
* via unifiedSegments) so both producers render identically; the one
* exception is git gap folds, whose rows git never emitted and a caller
* supplies on demand through resolveFold (first click fetches, then they
* toggle like any fold).
*/
import { useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
import { t } from '../locales.ts'
import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type InlineDiff } from './rows.ts'
import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type FoldSegment, type InlineDiff } from './rows.ts'
import { hasBlockComment, isColored, scanLine, type CodeToken, type TokenType } from './highlight.ts'
import css from './diff.module.css'
@@ -105,21 +108,36 @@ export interface DiffRowsProps {
segments: readonly DiffSegment[]
/** Syntax language id (langOfPath); undefined renders plain text. */
lang?: string
/** Fetch a git gap fold's hidden rows on demand (both sides' contents
* sliced by the fold's line ranges). Absent folds without `rows` — git's
* unemitted gaps without a resolver — stay non-expandable markers. */
resolveFold?: (segment: FoldSegment) => Promise<readonly DiffRow[]>
}
/** One file's diff rows: fold chips between hunks, highlighted code rows. */
export function DiffRows({ segments, lang }: DiffRowsProps) {
export function DiffRows({ segments, lang, resolveFold }: DiffRowsProps) {
// Long diff lines fold to one ellipsized row; the set holds expanded row keys.
const [expandedLines, setExpandedLines] = useState<ReadonlySet<string>>(new Set())
// Hunk-fold segments expanded by index; default collapsed.
const [expandedFolds, setExpandedFolds] = useState<ReadonlySet<number>>(new Set())
// Row-count cap expanded: a huge file renders head rows plus this button.
const [expandedAll, setExpandedAll] = useState(false)
// On-demand fold expansion (git gaps): resolved rows per segment index,
// the loading/failed markers, and an epoch that invalidates in-flight
// resolves when the segments identity changes.
const [foldData, setFoldData] = useState<ReadonlyMap<number, readonly DiffRow[]>>(new Map())
const [foldLoading, setFoldLoading] = useState<ReadonlySet<number>>(new Set())
const [foldFailed, setFoldFailed] = useState<ReadonlySet<number>>(new Set())
const foldEpoch = useRef(0)
// Reset all folding when the segments identity changes (new target).
useEffect(() => {
setExpandedLines(new Set())
setExpandedFolds(new Set())
setExpandedAll(false)
setFoldData(new Map())
setFoldLoading(new Set())
setFoldFailed(new Set())
foldEpoch.current += 1
}, [segments])
const inlineMap = useMemo(() => buildInlineMap(segments), [segments])
@@ -190,7 +208,19 @@ export function DiffRows({ segments, lang }: DiffRowsProps) {
</div>
)
}
const expandable = segment.rows !== undefined && segment.count >= MIN_FOLD
if (foldFailed.has(segIndex)) {
// The on-demand fetch failed: a quiet, non-clickable marker — the
// expansion promise is gone, so no interaction is promised either.
return (
<div key={`fold-${String(segIndex)}`} className={css.foldRow}>
<span className={css.foldMarker} title={t('changesContext')}>
{t('changesFoldUnavailable')}
</span>
</div>
)
}
const resolvedRows = foldData.get(segIndex)
const expandable = (segment.rows !== undefined || resolvedRows !== undefined || resolveFold !== undefined) && segment.count >= MIN_FOLD
if (!expandable) {
// A tiny fold (or a git gap with no rows to reveal): a quiet marker.
return (
@@ -201,27 +231,49 @@ export function DiffRows({ segments, lang }: DiffRowsProps) {
</div>
)
}
const loading = foldLoading.has(segIndex)
const isExpanded = expandedFolds.has(segIndex)
const revealed = segment.rows ?? resolvedRows
return (
<div
key={`fold-${String(segIndex)}`}
className={css.foldRow}
data-expandable="true"
data-expandable={loading ? undefined : 'true'}
data-expanded={isExpanded ? 'true' : undefined}
onClick={() => {
setExpandedFolds(prev => {
const next = new Set(prev)
if (next.has(segIndex)) next.delete(segIndex)
else next.add(segIndex)
return next
})
onClick={loading ? undefined : () => {
if (revealed !== undefined) {
setExpandedFolds(prev => {
const next = new Set(prev)
if (next.has(segIndex)) next.delete(segIndex)
else next.add(segIndex)
return next
})
return
}
// A git gap fold's first click: fetch its rows, then expand.
if (resolveFold === undefined) return
const epoch = foldEpoch.current
setFoldLoading(prev => new Set(prev).add(segIndex))
resolveFold(segment).then(
(rows) => {
if (foldEpoch.current !== epoch) return
setFoldData(prev => new Map(prev).set(segIndex, rows))
setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next })
setExpandedFolds(prev => new Set(prev).add(segIndex))
},
() => {
if (foldEpoch.current !== epoch) return
setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next })
setFoldFailed(prev => new Set(prev).add(segIndex))
},
)
}}
>
{isExpanded
? segment.rows!.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
{isExpanded && revealed !== undefined
? revealed.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
: (
<span className={css.foldMarker} title={t('changesContext')}>
{t('changesFold', { count: segment.count })}
{loading ? t('changesFoldLoading') : t('changesFold', { count: segment.count })}
</span>
)}
</div>
+85 -25
View File
@@ -132,28 +132,77 @@ const MD_LANG: LangConfig = {
wordBody: WORD,
}
/** JS keyword list, shared by js/jsx/mjs/cjs. */
const JS_WORDS = `async await break case catch class const continue debugger default delete do else
export extends false finally for from function get if implements import in instanceof interface
let new null of return set static super switch this throw true try typeof undefined var void
while with yield`
/** TS keyword list, shared by ts/tsx/mts/cts. */
const TS_WORDS = `abstract any as asserts async await boolean break case catch class const constructor
continue debugger declare default delete do else enum export extends false finally for from
function get if implements import in infer instanceof interface is keyof let module namespace
never new null number object of override private protected public readonly return satisfies set
static string super switch symbol this throw true try type typeof undefined union unknown var
void while with yield`
/** CSS family: block comments delimited by slash-star, hyphenated property words. */
const CSS_LANG: LangConfig = {
lineComments: [],
blockComment: ['/*', '*/'],
strings: ['"', "'"],
keywords: kw(`media import charset keyframes font-face supports page namespace layer scope container
property value at-rule when and not only from to important
background background-color background-image background-position background-size
border border-radius border-color border-width border-style bottom box-shadow box-sizing
color content cursor clip clip-path clear display direction
flex flex-direction flex-wrap flex-flow flex-grow flex-shrink flex-basis
font font-family font-size font-style font-weight float fill
grid grid-area grid-template grid-template-columns grid-template-rows grid-gap gap
height left letter-spacing line-height list-style margin margin-top margin-right
margin-bottom margin-left max-height max-width min-height min-width opacity order
outline overflow padding padding-top padding-right padding-bottom padding-left
position pointer-events right rotate scale translate transform transform-origin
text-align text-decoration text-transform top transition transition-property
user-select vertical-align visibility white-space width word-break word-spacing z-index
align-items align-content align-self justify-content justify-items justify-self
aspect-ratio inset object-fit object-position resize scroll-behavior filter backdrop-filter
animation animation-name animation-duration animation-timing-function animation-delay
will-change
html body p a div span li ul ol table tr td th form input button label select textarea img
section header footer main nav article aside h1 h2 h3 h4 h5 h6 i b em strong small pre code
inherit initial unset auto none fixed absolute relative sticky static flex block inline
inline-block inline-flex grid hidden visible bold normal root var calc env`),
constants: new Set(),
macro: false,
wordStart: /[A-Za-z-]/u,
wordBody: /[A-Za-z0-9-]/u,
}
/** Markup family (HTML/XML/SVG/Vue): markup comments plus common tags. */
const MARKUP_LANG: LangConfig = {
lineComments: [],
blockComment: ['<!--', '-->'],
strings: ['"', "'"],
keywords: kw(`html head body title meta link script style template slot
div span p a img ul ol li table thead tbody tr th td form input button label select
option textarea header footer main nav section article aside figure figcaption
h1 h2 h3 h4 h5 h6 strong em b i u s small br hr pre code blockquote
svg path circle rect line polyline polygon g defs use text symbol
xml doctype class id href src type value name content charset async defer
vue component props setup script-style export import v-if v-for v-bind v-on`),
constants: new Set(),
macro: false,
wordStart: LETTER,
wordBody: WORD,
}
/** Extension → language id, mirroring the read tool's hint table. */
const LANGS: Readonly<Record<string, LangConfig>> = {
ts: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor
continue debugger declare default delete do else enum export extends false finally for from
function get if implements import in infer instanceof interface is keyof let module namespace
never new null number object of override private protected public readonly return satisfies set
static string super switch symbol this throw true try type typeof undefined union unknown var
void while with yield`),
tsx: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor
continue declare default delete do else enum export extends false finally for from function get
if implements import in infer instanceof interface is keyof let module namespace never new null
number object of override private protected public readonly return satisfies set static string
super switch symbol this throw true try type typeof undefined union unknown var void while with
yield`),
js: C_FAMILY(`async await break case catch class const continue debugger default delete do else
export extends false finally for from function get if implements import in instanceof interface
let new null of return set static super switch this throw true try typeof undefined var void
while with yield`),
jsx: C_FAMILY(`async await break case catch class const continue debugger default delete do else
export extends false finally for from function get if implements import in instanceof interface
let new null of return set static super switch this throw true try typeof undefined var void
while with yield`),
ts: C_FAMILY(TS_WORDS),
tsx: C_FAMILY(TS_WORDS),
js: C_FAMILY(JS_WORDS),
jsx: C_FAMILY(JS_WORDS),
json: CONFIG_LANG,
py: HASH_FAMILY(`and as assert async await break class continue def del elif else except finally
for from global if import in is lambda nonlocal not or pass raise return try while with yield
@@ -225,11 +274,22 @@ const LANGS: Readonly<Record<string, LangConfig>> = {
md: MD_LANG,
markdown: MD_LANG,
mdx: MD_LANG,
html: CONFIG_LANG,
htm: CONFIG_LANG,
css: HASH_FAMILY(''),
scss: HASH_FAMILY(''),
less: HASH_FAMILY(''),
mjs: C_FAMILY(JS_WORDS),
cjs: C_FAMILY(JS_WORDS),
mts: C_FAMILY(TS_WORDS),
cts: C_FAMILY(TS_WORDS),
jsonc: CONFIG_LANG,
json5: CONFIG_LANG,
html: MARKUP_LANG,
htm: MARKUP_LANG,
xml: MARKUP_LANG,
svg: MARKUP_LANG,
vue: MARKUP_LANG,
css: CSS_LANG,
scss: CSS_LANG,
less: CSS_LANG,
graphql: HASH_FAMILY('query mutation fragment on directive enum input interface scalar schema type implements'),
gql: HASH_FAMILY('query mutation fragment on directive enum input interface scalar schema type implements'),
lua: HASH_FAMILY(`and break do else elseif end false for function goto if in local nil not or
repeat return then true until while`),
}
+35
View File
@@ -451,6 +451,41 @@ export function untrackedFile(path: string, content: string): DiffFile {
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
}
/**
* Materialize a git gap fold's hidden rows from the two sides' full file
* contents, by the fold's known line ranges: the old side drives context
* rows (each mapped onto the new side through the fold's offset — a gap is
* an unchanged run, so the sides align), and new-side lines the old range
* never reaches become pure additions. Line numbers clip to the actual
* content (a no-newline file's ranges can overrun by one); `\r` endings
* survive verbatim, like git's own context lines.
*/
export function foldRowsFromContents(fold: FoldSegment, oldContent: string, newContent: string): DiffRow[] {
const oldLines = oldContent.length === 0 ? [] : oldContent.split('\n')
const newLines = newContent.length === 0 ? [] : newContent.split('\n')
const offset = fold.newStart - fold.oldStart
const rows: DiffRow[] = []
const oldFrom = Math.max(fold.oldStart, 1)
const oldTo = Math.min(fold.oldEnd, oldLines.length)
for (let oldLine = oldFrom; oldLine <= oldTo; oldLine += 1) {
const text = oldLines[oldLine - 1]!
const newLine = oldLine + offset
rows.push(
newLine >= fold.newStart && newLine <= fold.newEnd && newLine <= newLines.length
? { kind: 'context', oldLine, newLine, text }
: { kind: 'context', oldLine, text },
)
}
// New-side lines beyond what the old range reached (a pure-addition gap):
// rows carrying only the new-side number, exactly like added lines.
const newFrom = Math.max(fold.newStart, 1)
const newTo = Math.min(fold.newEnd, newLines.length)
for (let newLine = Math.max(newFrom, oldTo + offset + 1); newLine <= newTo; newLine += 1) {
rows.push({ kind: 'add', newLine, text: newLines[newLine - 1]! })
}
return rows
}
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
export function displayPath(path: string): string {
if (path === '/dev/null') return path
+62
View File
@@ -0,0 +1,62 @@
/**
* Built-in file and folder glyphs for the file tree (feature: file icons).
*
* The built-in set is DSH's OWN file-type artwork: `FileTypeIcon` from
* `@deepseek-ai/dsh-client-ui-primitives` classifies a path and renders the
* same glyphs the host's own explorer shows — the 48 full-color code and
* configuration categories, plus the category-colored sheet glyphs for
* markdown, images, PDFs, office documents, video, folders and the generic
* fallback. Colored artwork is content, not chrome, and the host draws it
* with its own palette, so this module carries no color literals and stays
* inside the skin contract (the guide's §12).
*
* Nothing here needs a lazy chunk: the artwork belongs to a platform module
* the client already holds in its frozen module table, so the core bundle
* gains a function call rather than 500-odd icon rules.
*
* External plugins that want their own glyphs (per extension, per exact file
* name, per directory name) register `FileIconDescriptor`s through
* `ctx.betterSidebar.registerFileIcon`; their registrations win over these.
*/
import type { ReactNode } from 'react'
import { FileTypeIcon } from '@deepseek-ai/dsh-client-ui-primitives'
/**
* A file row: the host's own classifier and artwork for any path. Unknown
* extensions land on the generic document glyph, exactly like the host's
* explorer.
* @param path - the row's path (any separator; the classifier reads the basename).
* @param size - the square edge in px.
* @returns the host's file-type glyph.
*/
export function builtinFileIcon(path: string, size: number): ReactNode {
return <FileTypeIcon path={path} size={size} />
}
/**
* A directory row: the host's folder glyph.
*
* The host ships ONE folder drawing (`kind: 'folder'` resolves to its
* monochrome `IconFolderClose16`, which rides `currentColor` and therefore
* still follows the skin), and the classifier never returns a folder kind of
* its own. The expansion state is already legible from the tree's own chevron
* and row affordances, so this deliberately does not invent a second folder
* drawing.
* @param _open - whether the row is expanded (accepted for API compatibility).
* @param size - the square edge in px.
* @returns the host's folder glyph.
*/
export function builtinFolderIcon(_open: boolean, size: number): ReactNode {
return <FileTypeIcon kind="folder" size={size} />
}
/**
* The last-resort glyph: the host's generic document, for the resolver's leaf
* when no registration and no built-in match applies (and for surfaces that
* render a file row without a service).
* @param size - the square edge in px.
* @returns the host's generic file glyph.
*/
export function fallbackFileIcon(size: number): ReactNode {
return <FileTypeIcon kind="other" size={size} />
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Shared vocabulary of the sidebar's HTML preview surfaces (the editor's
* html viewer and the changes tab's op render preview): the sandbox tokens
* both preview iframes load with, plus the document-base rewrite the srcdoc
* delivery path needs.
*/
/**
* The sandbox tokens of an HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
* could read session data) and NO allow-top-navigation (a previewed page
* must not hijack the GUI). The editor viewer can disable the sandbox
* per-feature in the side card settings (warned); the changes tab's op
* render preview always stays sandboxed.
*/
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
/** Whether a document already declares its own base URL. */
const HAS_BASE = /<base[\s/>]/i
/** Index just past the element a `<base>` must precede to land in <head>. */
function insertionPoint(html: string): number {
const head = /<head\b[^>]*>/i.exec(html)
if (head !== null) return head.index + head[0].length
const htmlTag = /<html\b[^>]*>/i.exec(html)
if (htmlTag !== null) return htmlTag.index + htmlTag[0].length
return 0
}
/**
* The directory part of a preview route URL, with its trailing slash.
* @param url - the `/sidebar/html/<sessionId>/<path…>` route URL.
* @returns the URL up to and including the last `/`.
*/
export function previewBaseOf(url: string): string {
const cut = url.lastIndexOf('/')
return cut < 0 ? url : url.slice(0, cut + 1)
}
/**
* Point a preview document's relative references back at its own route.
*
* A `srcdoc` document has no URL of its own — the browser resolves its
* relative references against the EMBEDDING page's URL, so `./style.css` in a
* previewed file would resolve to the GUI's origin and hit whatever route
* happens to match. Injecting `<base href="<the file's route directory>">`
* keeps relative resolution inside `/sidebar/html/…`, where the file's own
* siblings live. A document that declares its own `<base>` is left alone: the
* author said what the base is.
*
* @param html - the previewed document's markup.
* @param url - the route URL the document was fetched from.
* @returns the markup, with a base element injected when it had none.
*/
export function withPreviewBase(html: string, url: string): string {
if (HAS_BASE.test(html)) return html
const href = previewBaseOf(url).replace(/"/g, '&quot;')
const at = insertionPoint(html)
return `${html.slice(0, at)}<base href="${href}">${html.slice(at)}`
}
+2 -26
View File
@@ -1,24 +1,12 @@
/**
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
* icon library has none), a diff glyph, and the two panel-toggle glyphs for
* the top-right cluster. Per-tab icons live on the tab descriptors
* icon library has none), a diff glyph, and the bottom-workbench toggle.
* Per-tab icons live on the tab descriptors
* (`descriptor.icon`), not in a type-keyed switch — the icon mapping was
* registry-ized with the tab types.
*/
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
/**
* Right-panel toggle glyph (the "侧拉" button): a frame with a filled strip
* along its RIGHT edge, in the app's outline style (1.5px stroke,
* currentColor).
*/
export const IconPanelRightOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2" width="13" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="10.5" y="3.25" width="2.75" height="9.5" rx="1" fill="currentColor" stroke="none" />
</svg>
)
/**
* Bottom-panel toggle glyph (the "底栏" button): a frame with a filled strip
* along its BOTTOM edge, in the app's outline style.
@@ -161,15 +149,3 @@ export const IconVscode16 = ({ size = 16, className }: IconProps) => (
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
</svg>
)
/**
* Free-window glyph in the app's outline style (1.5px stroke, currentColor):
* a background frame with a detached rounded mini-window floating over its
* top-right — the changes tab's "diff opens as a free window" setting.
*/
export const IconFloatWindowOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="4.5" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.5" />
<rect x="9.5" y="1.5" width="5" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
)
+6 -3
View File
@@ -25,9 +25,12 @@
* legacy signal engines emit without isComposing.
*/
/** The pure decision: is this keyboard event part of an IME composition? */
export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
return event.isComposing || event.keyCode === 229
/** The pure decision: is this keyboard event part of an IME composition?
* `isComposing` is optional on the input: React's synthetic KeyboardEvent
* type does not declare it (the DOM event always carries it), and the
* keyCode 229 fallback covers exactly those callers. */
export function isImeComposition(event: { isComposing?: boolean; keyCode: number }): boolean {
return event.isComposing === true || event.keyCode === 229
}
/**
+70 -49
View File
@@ -16,14 +16,18 @@ import { createBetterSidebarService, matchUrlTarget } from './service.ts'
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts'
import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './apes/index.ts'
import { registerApesNativeTabs } from './apes/native-tabs.tsx'
import { Sidebar } from './Sidebar.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
import { createNativeTabRecords } from './native/tab-adapter.tsx'
import { registerNativeSurface } from './native/index.ts'
import { registerBottomToggle } from './sidebar/bottom-toggle.tsx'
import { createNativeSurface } from './native/surface.ts'
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
import { loadBootDecision } from './prefs.ts'
import { panelHostParent } from './desktop-env.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en } from './locales.ts'
@@ -60,8 +64,8 @@ export function apply(ctx: Context): void {
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
attachLocale(ctx.locale)
// The apes module has its own locale namespace (isolated from the upstream
// betterSidebar dicts per D1=B); attach it so apes t() tracks the DSH
// active-locale preference, and register its zh/en dicts under `apes`.
// betterSidebar dicts): attach it so apes t() tracks the DSH active-locale
// preference, and register its zh/en dicts under `apes`.
attachApesLocale(ctx.locale)
ctx.effect(() => {
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
@@ -145,6 +149,27 @@ export function apply(ctx: Context): void {
// are ready by the time the sidebar renders.
const service = createBetterSidebarService(sidebarStore)
ctx.provide('betterSidebar', service)
// The native right-Sidebar surface: the plugin's content is registered as
// DSH tab types (one per descriptor) and every open routes there, so the
// right column belongs to the host and only the bottom workbench stays
// plugin-owned. Both halves live for this fiber's lifetime.
const nativeRecords = createNativeTabRecords()
const nativeSurface = createNativeSurface(ctx, nativeRecords)
service.setSurface(nativeSurface)
ctx.effect(
() => registerNativeSurface({ ctx, store: sidebarStore, service, records: nativeRecords }),
'dsh-apes-sidebar: native right-Sidebar registrations',
)
// The bottom workbench's expand/collapse button in DSH's session header
// (the header's corner seat belongs to the native sidebar's own control).
ctx.effect(
() => registerBottomToggle(ctx, sidebarStore),
'dsh-apes-sidebar: bottom-workbench toggle',
)
ctx.effect(
() => () => { nativeSurface.dispose(); service.setSurface(undefined) },
'dsh-apes-sidebar: native right-Sidebar surface',
)
// Terminal tab titles use the host's effective shell name (e.g. bash/zsh)
// instead of "Terminal 1". Start with a safe fallback and replace it as
// soon as the host shell info resolves. Tabs created before the response
@@ -156,8 +181,7 @@ export function apply(ctx: Context): void {
terminalTitle = name
const snapshot = service.getSnapshot()
if (snapshot.state === undefined) return
const tabs = allLeaves(snapshot.state.splits)
.concat(allLeaves(snapshot.state.bottomSplits))
const tabs = allLeaves(snapshot.state.bottomSplits)
.flatMap(leaf => leaf.tabs)
for (const tab of tabs) {
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
@@ -173,34 +197,32 @@ export function apply(ctx: Context): void {
'dsh-apes-sidebar: register built-in tabs and viewers',
)
// The apes:* information-layer tabs (the fork's own contribution, isolated
// under src/client/apes per D1=B). Registered through the same service as
// the builtins so they get the full sidebar lifecycle; the disposer runs on
// fiber disposal (HMR-safe). A throw here is caught by the outer try and
// routed to fail() — a bad apes registration must never blank the sidebar.
// under src/client/apes). Registered through the SAME service as the
// builtins, so the fork's native adapter (`native/index.ts`) turns each one
// into an official right-sidebar tab type + body automatically — that is the
// entire reason this fork now runs ONE sidebar instead of two. The disposer
// runs on fiber disposal (HMR-safe).
ctx.effect(
() => registerApesTabs(ctx, service),
'dsh-apes-sidebar: register apes tabs',
)
// Phase A spike: also publish apes:profile as an OFFICIAL right-sidebar tab
// type (ctx.sidebarRightTabs + the keyed `sidebar.right.pane.tab` seat), so
// the tab renders inside DSH's own panel instead of only in the fork's
// shell. Both surfaces coexist during the spike; the disposer reverses the
// two-stage registration on fiber disposal (HMR-safe). A throw is caught
// inside the bridge and never reaches this effect.
ctx.effect(
() => registerApesNativeTabs(ctx),
'dsh-apes-sidebar: apes native right-sidebar tabs',
)
// A failure anywhere in the client lifecycle must never take the app down
// silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom.
// to the page so a blank panel is never the only symptom. This strip is
// the last-resort reporter (no CSS module is reachable from here), so its
// colors go through skin token chains with the previous hexes as the
// chain tails — worst case (no skin tokens on the page) it renders
// byte-identical to the old hardcoded bar, and any `--dsw-alias-*` skin
// re-themes it (guide §12: no hardcoded colors).
const fail = (phase: string, error: unknown): void => {
console.error(`[dsh-apes-sidebar] ${phase} error:`, error)
try {
const bar = document.createElement('div')
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;'
+ 'color:var(--dsw-alias-state-error-primary,#f2a1a1);'
+ 'background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-base,#1b1b22));'
+ 'border:1px solid var(--dsw-alias-state-error-primary,#f2a1a1);border-radius:8px;white-space:pre-wrap'
bar.textContent = `[dsh-apes-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
document.body.appendChild(bar)
} catch {
@@ -208,10 +230,9 @@ export function apply(ctx: Context): void {
}
}
try {
// rc.8+ exposes the client module system as the `ctx.modules` service
// (no window.__DSH_MODULES__ page global anymore); the chunk loader needs
// it to resolve its externals, so inject it before anything can load a
// lazy chunk. The loader falls back to the rc.7 global when absent.
// rc.8+ exposes the client module system as the `ctx.modules` service;
// the chunk loader needs it to resolve its externals, so inject it
// before anything can load a lazy chunk.
setChunkModuleSystem(ctx.modules)
// Fresh chunk state for this activation: drop per-test fixtures and
// revalidate loaded chunk scripts against the bundle route's ETags —
@@ -239,17 +260,26 @@ export function apply(ctx: Context): void {
host?.remove()
host = undefined
}
/** Re-attach the host if the page (a desktop shell wrapper, SPA
* navigation, …) ever removes it from <body>. Cheap: childList only,
* no subtree, no attribute filtering. */
/** Put the host in the layer it belongs to — re-attaching it if the
* page (a desktop shell wrapper, SPA navigation, …) removed it, and
* moving it if the shell framed the content viewport after our mount
* (see panelHostParent). */
const anchorHost = (): void => {
if (host === undefined) return
const parent = panelHostParent()
if (host.parentElement !== parent) parent.appendChild(host)
}
/** Watch for the host being removed, and for the shell stamping its
* framed mode onto <body> (the same effect tags #root as the content
* viewport, which is where the host must live). */
const guardAnchor = (): void => {
if (bodyObserver !== undefined) return
bodyObserver = new MutationObserver(() => {
if (host !== undefined && !document.body.contains(host)) {
document.body.appendChild(host)
}
bodyObserver = new MutationObserver(anchorHost)
bodyObserver.observe(document.body, {
childList: true,
attributes: true,
attributeFilter: ['data-dsh-desktop-mode'],
})
bodyObserver.observe(document.body, { childList: true })
}
/** One-shot geometry self-check: if the host page transforms
* <html>/<body> itself (exotic shells), a fixed panel host would
@@ -303,7 +333,10 @@ export function apply(ctx: Context): void {
try {
host = document.createElement('div')
host.setAttribute('data-dsh-apes-sidebar', '')
document.body.appendChild(host)
// The shell's framed content viewport when there is one (see
// panelHostParent): it is a stacking context, so a body-level host
// would paint above every overlay the shell hosts inside it.
panelHostParent().appendChild(host)
root = createRoot(host)
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
mounted = true
@@ -324,7 +357,7 @@ export function apply(ctx: Context): void {
// rides the same fetch, so one round trip covers both decisions).
const decision = await Promise.race([
loadBootDecision(api),
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
new Promise<null>(resolve => { window.setTimeout(() => resolve(null), 2000) }),
])
if (disposed) return
if (decision !== null) {
@@ -363,18 +396,6 @@ export function apply(ctx: Context): void {
'dsh-apes-sidebar: turn-tail interception',
)
ctx.effect(
() => {
try {
return registerOpenPathInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return () => {}
}
},
'dsh-apes-sidebar: open-path interception',
)
ctx.effect(
() => {
try {
+5 -59
View File
@@ -8,10 +8,9 @@
*/
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts'
import { revealPaths, type SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import { wrapOpenWorkspacePath, type OpenWorkspacePathService } from './openpath-intercept.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
@@ -25,18 +24,10 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st
ctx.get('betterSidebar')?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
}
/**
* The produced files the turn-tail selector last matched for the visible
* session. The "Show in folder" gesture carries no file path of its own
* (`'.'`), so the reveal highlights exactly these rows when available.
*/
let lastProduced: readonly string[] = []
/**
* Reveal the produced files in the sidebar explorer: expand their parent
* directories, highlight the rows, and focus the explorer tab (expanding the
* hosting panel when it is collapsed). Unknown files fall back to revealing
* the workspace root itself.
* directories, highlight the rows, and focus the explorer tab. Unknown
* files fall back to revealing the workspace root itself.
*/
export function revealInExplorer(
ctx: Context,
@@ -53,15 +44,6 @@ export function revealInExplorer(
? files.map(path => resolveSidebarPath(cwd, path))
: cwd === undefined ? [] : [cwd]
store.reduce(state => revealPaths(state, cwd, targets))
// A type-only open never auto-expands the panel (only content opens do,
// see service.openTab) — so a reveal opens the panel itself when it is
// collapsed, exactly like the subagent auto-open flows, or the highlight
// would be set on an invisible panel.
store.reduce(s => (s.panelOpen ? s : togglePanel(s)))
// Pin the landing to the right panel: the files window must appear where
// the panel just expanded, not in a bottom-panel pane the user last
// touched.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
// Focus the single-instance editor home tab (the files window) where the
// reveal highlight renders. Read via ctx.get like every other internal
// consumer (#357): the provider is not on this fiber chain, so a direct
@@ -130,6 +112,7 @@ export function SidebarProducedFiles(props: {
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
name: 'conversation.chat.turnTail',
id: 'dsh-apes-sidebar:turn-tail',
// Decline the takeover while the editor tab type is disabled in the side
// card settings: the produced-files row falls back to the default
// deliverables behavior instead of offering chips that cannot open. Also
@@ -137,9 +120,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
select: (owner) => {
if (store.getSuspended()) return null
if (store.getPrefs().tabsEnabled['editor'] === false) return null
const matched = selectProducedFiles(owner)
if (matched !== null) lastProduced = matched
return matched
return selectProducedFiles(owner)
},
priority: -1,
registrant: 'dsh-apes-sidebar',
@@ -149,38 +130,3 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
}),
}, SidebarProducedFiles))
}
/**
* Register the chat file-open interception: shadows
* `remote.session.openWorkspacePath` — the single funnel every chat-side
* file open goes through on alpha hosts (tool-row path links, the
* produced-files row, prose mentions, inline-code paths) — so opens land in
* the sidebar editor instead of the Host OS. The folder-reveal gesture
* ("Show in folder" passes `'.'`) is the one exception: it is routed to the
* explorer. Gated by BOTH the `interceptOpenPath` pref and the editor tab's
* enable switch; declined opens fall through to the original remote call.
*
* The `remote.session` namespace service mounts asynchronously (the gateway
* client creates it when the session-controller contribution arrives) and
* is recreated on contribution remounts, so the wrapper installs through
* `ctx.inject`: the callback runs once the service exists and re-runs after
* every remount, re-applying the shadow on the fresh instance. Returns the
* disposer (disposes the inject fiber, which restores the original method
* descriptor — HMR-safe).
*/
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
const fiber = ctx.inject(['remote.session'], (fctx) => {
fctx.effect(() => {
const service = fctx.get('remote.session') as OpenWorkspacePathService
return wrapOpenWorkspacePath(service, {
takeoverEnabled: () => !store.getSuspended()
&& store.getPrefs().interceptOpenPath !== false
&& store.getPrefs().tabsEnabled['editor'] !== false,
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
})
}, 'dsh-apes-sidebar: open-path interception wrap')
})
return () => { void fiber.dispose() }
}
+16 -25
View File
@@ -1,37 +1,28 @@
/**
* Size written to `--dsh-sidebar-width` / `--dsh-sidebar-height`.
* The conversation column (output + composer) must keep at least
* {@link PANEL_MIN} of the viewport after the bottom panel claims height.
* Size written to `--dsh-sidebar-height`. The conversation column (output +
* composer) must keep at least {@link CONVERSATION_MIN} of the viewport after
* the bottom workbench claims height; a closed workbench pushes 0. The right
* column belongs to DSH's native Sidebar, so this shell pushes no width.
*/
import { PANEL_MIN } from './state.ts'
import { CONVERSATION_MIN } from './state.ts'
export interface LayoutPushInput {
narrow: boolean
panelOpen: boolean
bottomOpen: boolean
width: number
bottomHeight: number
viewportWidth: number
viewportHeight: number
}
export interface LayoutPushSize {
width: number
export interface BottomPushInput {
/** Whether the bottom workbench is expanded. */
open: boolean
/** Committed (or mid-drag) bottom height in px. */
height: number
/** Viewport height the cap is measured against (visual viewport when known). */
viewportHeight: number
}
function finiteNonNegative(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0
}
/** Compute the live layout-push size. Narrow drawers float and push 0. */
export function layoutPushSize(input: LayoutPushInput): LayoutPushSize {
if (input.narrow) return { width: 0, height: 0 }
const viewportWidth = finiteNonNegative(input.viewportWidth)
/** Compute the live layout-push height for the bottom workbench. */
export function bottomPushHeight(input: BottomPushInput): number {
if (!input.open) return 0
const viewportHeight = finiteNonNegative(input.viewportHeight)
const maxHeight = Math.max(0, viewportHeight - Math.min(PANEL_MIN, viewportHeight))
return {
width: input.panelOpen ? Math.min(finiteNonNegative(input.width), viewportWidth) : 0,
height: input.bottomOpen ? Math.min(finiteNonNegative(input.bottomHeight), maxHeight) : 0,
}
const maxHeight = Math.max(0, viewportHeight - Math.min(CONVERSATION_MIN, viewportHeight))
return Math.min(finiteNonNegative(input.height), maxHeight)
}
+75 -47
View File
@@ -1,47 +1,25 @@
/**
* Layout push: an open panel occupies layout instead of floating over it.
* The right panel reserves padding inside the AppFrame three-column grid,
* so the app sidebar, conversation, and details column all remain to its
* left while the frame's border-box stays viewport-wide. Harness measures
* that border-box to choose desktop versus narrow presentation; preserving
* it prevents a desktop window from entering narrow mode merely because the
* plugin panel opened.
* Layout push: the bottom workbench occupies layout instead of floating over
* the conversation. The right column belongs to DSH's native Sidebar, so this
* stylesheet pushes no width — only the workbench's height.
*
* AppFrame positions its details drag handle from the measured border-box.
* Move that handle left by the same reserved width so it stays on the details
* column edge. The ordinary sidebar handle remains at its native coordinate.
*
* The bottom panel squeezes only the conversation column. The anchor is the
* The workbench squeezes only the conversation column. The anchor is the
* [data-dsh-center-col] tag the Sidebar shell's locator writes on the
* frame's measured center grid item (Sidebar.tsx locate — not a positional
* path: the frame also contains an overlay layer and drag handles). A
* stretched grid item shrinks by its bottom margin, so the conversation
* output and composer lift without touching either sidebar.
*
* Sizes ride CSS variables updated by the Sidebar shell (0 while collapsed).
* Expand/collapse uses the host theme duration; drags disable transitions so
* every reserved edge tracks the pointer.
* The size rides a CSS variable updated by the Sidebar shell (0 while
* collapsed). Expand/collapse uses the host theme duration; drags disable
* transitions so the reserved edge tracks the pointer.
*/
/* Current shells expose [data-dsh-frame]; rc.8-era shells expose the same
AppFrame only as the root slot's direct child. */
#root [data-dsh-frame],
#root > [data-slot="root"] > div {
box-sizing: border-box;
padding-right: var(--dsh-sidebar-width, 0px);
transition: padding-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
#root [data-dsh-frame] > [data-side="details"],
#root > [data-slot="root"] > div > [data-side="details"] {
transform: translateX(calc(0px - var(--dsh-sidebar-width, 0px)));
transition:
left var(--ds-transition-duration-slow) var(--ds-ease-in-out),
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
locate/measureCenter) tags the measured column node with
[data-dsh-center-col] — one attribute write per node lifetime. The rule
locate/measureCenter, resolving via center-column.ts) tags the measured
column node with [data-dsh-center-col] — one attribute write per node
lifetime. The locator anchors on the conversation slot host
(`main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on alpha.1)
and skips the transparent display:contents slot hosts above it. The rule
used to anchor on a structural has()-selector over the conversation slot
wrapper, whose match cache invalidates on every #root subtree mutation
(the streaming chat mutates it constantly), re-evaluating the selector
@@ -73,13 +51,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
padding-right: 78px;
}
body[data-dsh-sidebar-dragging] #root [data-dsh-frame],
body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div,
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-side="details"],
body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div > [data-side="details"],
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
#root [data-dsh-frame][data-dragging] > [data-side="details"],
#root > [data-slot="root"] > div[data-dragging] > [data-side="details"] {
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
transition: none;
}
@@ -88,11 +60,11 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
only this plugin's localized row; render the requested Lucide
gallery-horizontal-end SVG as a currentColor mask so it follows the native
nav hover/active colors without changing the shell's 16px icon rhythm. */
[data-dsh-better-sidebar-settings-nav] > svg:first-child {
[data-dsh-apes-sidebar-settings-nav] > svg:first-child {
display: none;
}
[data-dsh-better-sidebar-settings-nav]::before {
[data-dsh-apes-sidebar-settings-nav]::before {
content: '';
flex: none;
width: 16px;
@@ -103,11 +75,67 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
}
@media (prefers-reduced-motion: reduce) {
#root [data-dsh-frame],
#root > [data-slot="root"] > div,
#root [data-dsh-frame] > [data-side="details"],
#root > [data-slot="root"] > div > [data-side="details"],
#root [data-dsh-center-col] {
transition: none;
}
}
/* Context-menu submenu flip (viewport clamping for the portaled menus).
*
* The ui-primitives Menu clamps its main portal list into the viewport but
* positions nested submenus with static CSS only — anchored at the parent
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
* Both defaults are the worst case for our menus: a tall "open with" submenu
* escapes past the viewport top when the row menu opens near the top, and
* past the right edge in a narrow workbench pane (a pane can be far narrower
* than the card 218 + gap + submenu 165 a submenu needs).
*
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on <body> — with the
* words "down" and/or "left" — for exactly the lifetime of one of OUR
* submenu-bearing menus (FileTree row menu, TabBar tab menu). The tokens
* flip the growth direction; the attribute's absence restores the
* primitive's own geometry untouched, and host-owned menus are never
* affected because they never open while the attribute exists (each side
* closes on the other's outside pointerdown).
*
* No max-height/overflow scroll cap here on purpose: overflow clipping
* would cut the submenu's ::before hover bridge (the corridor that keeps
* itemWrap's mouseleave from closing the card mid-crossing). */
/* Visual tether, part 1 — the parent row stays lit while its submenu is
* open. The primitive only styles :hover, so once the pointer crossed into
* the side card the parent row's fill cleared and the floating card read as
* unrelated. The Menu marks the open parent row with aria-expanded="true"
* (host-rendered, stable), so the same hover token re-applies for the
* submenu's whole lifetime. */
body[data-dsh-sidebar-submenu] div[role="menu"] button[aria-expanded="true"] {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Visual tether, part 2 — the side card hugs its parent (10px → 4px gap),
* with the ::before hover corridor narrowed to match. Ordered BEFORE the
* flip overrides below so their left/right flips still win. */
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"] {
left: calc(100% + 4px);
}
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"]::before {
left: -4px;
width: 4px;
}
body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] {
top: -4px;
bottom: auto;
}
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"] {
left: auto;
right: calc(100% + 4px);
}
/* The hover bridge follows the card to the left side of the parent row. */
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"]::before {
left: auto;
right: -4px;
}
+2 -2
View File
@@ -22,12 +22,12 @@ interface LazyChunkViewProps<P> {
props: P
}
function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
function LazyChunkView<P extends object>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
const [attempt, setAttempt] = useState(0)
const [state, setState] = useState<
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; Comp: ComponentType<any> }
| { status: 'ready'; Comp: ComponentType<P> }
>({ status: 'loading' })
useEffect(() => {
+49 -16
View File
@@ -18,13 +18,27 @@
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ar: Record<string, string> = {
files: 'الملفات',
guideDescFiles: 'استعرض شجرة ملفات مساحة العمل؛ انقر للمعاينة أو التحرير',
guideDescGit: 'تغييرات Git وتعديلات ملفات هذه الجلسة في تبويب واحد',
guideDescSubagent: 'بنية الوكلاء الفرعيين والمهام الخلفية مباشرةً',
guideDescSidechat: 'خيط متابعة مستقل يرث سياق هذه الجلسة',
guideDescTerminal: 'صدفة حقيقية (xterm + pty) مع إعادة الاتصال',
guideDescBrowser: 'متصفح مدمج يمكنه فتح روابط المحادثة',
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
changesRead: 'قراءة',
changesMdReading: 'قراءة',
changesHtmlRender: "عرض",
changesHtmlRaw: "المصدر",
changesPdfRender: "عرض",
changesPdfRaw: "المصدر",
changesMdRaw: 'الخام',
changesWrite: 'كتابة',
changesEdit: 'تحرير',
changesRunning: 'قيد التنفيذ',
changesError: 'خطأ',
changesFold: '{count} أسطر… انقر للتوسيع',
changesFoldLoading: 'جارٍ التوسيع…',
changesFoldUnavailable: 'السياق غير متوفر',
changesContext: 'سياق',
changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
explorer: 'المستكشف',
@@ -74,7 +88,6 @@ export const ar: Record<string, string> = {
closeOtherTabs: 'إغلاق التبويبات الأخرى',
closeLeftTabs: 'إغلاق التبويبات على اليسار',
closeRightTabs: 'إغلاق التبويبات على اليمين',
moveToFreeWindow: 'النقل إلى نافذة حرة',
floatDropHint: 'حرّك للفتح في نافذة حرة',
dockToSidebar: 'العودة إلى الشريط الجانبي',
pinTerminal: 'تثبيت الطرفية',
@@ -89,16 +102,17 @@ export const ar: Record<string, string> = {
pinnedTerminalScopeGlobal: 'مثبتة عام',
pinnedRailLabel: 'الطرفيات المثبتة',
closePinnedTerminal: 'إغلاق الطرفية',
collapse: 'طي الشريط الجانبي',
expand: 'توسيع الشريط الجانبي',
collapseBottomPanel: 'طي اللوحة السفلية',
expandBottomPanel: 'توسيع اللوحة السفلية',
terminalError: 'فشل اتصال الطرفية',
terminalConnectFailed: 'فشل اتصال الطرفية عدة مرات',
terminalRetry: 'إعادة المحاولة',
terminalWaitBanner: 'الوكيل ينتظر {needle}',
terminalSkipWait: 'تخطي الانتظار',
terminalDepsFailed: 'فشل تحميل تبعية الطرفية node-pty',
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
terminalShellNotFound: 'لم يتم العثور على الصدفة المُعدَّة: {name} — تحقق من مسار الصدفة في الإعدادات → Side card → Terminal',
preview: 'معاينة',
toc: 'الفهرس',
edit: 'تحرير',
@@ -145,12 +159,6 @@ export const ar: Record<string, string> = {
changesOpenDiffTab: 'فتح في تبويب diff منفصل',
changesClosePreview: 'إغلاق المعاينة',
changesResizePreview: 'تغيير حجم المعاينة',
changesDiffOpenTitle: 'يفتح الفرق كـ',
changesDiffOpenDesc: 'أين يضع إجراء «التوسيع إلى تبويب diff» الفرق',
changesDiffOpenFloat: 'نافذة عائمة',
changesDiffOpenFloatDesc: 'نافذة عائمة في وسط الشاشة — سحب وتحجيم وتثبيت فوق',
changesDiffOpenPane: 'لوحة',
changesDiffOpenPaneDesc: 'مثبتة أسفل اللوحة المصدر (تقسيم diff بنمط VSCode)',
changesLoadError: 'سجلات ملفات الجلسة غير متاحة الآن',
staged: 'مُدرج',
unstaged: 'غير مُدرج',
@@ -199,6 +207,13 @@ export const ar: Record<string, string> = {
addToConversation: 'إضافة إلى المحادثة',
copyRelative: 'نسخ المسار النسبي',
copyAbsolute: 'نسخ المسار المطلق',
rename: 'إعادة تسمية',
renameInvalid: 'لا يمكن أن يكون الاسم فارغًا أو يحتوي على فواصل مسار.',
delete: 'حذف',
deleteTitle: 'حذف «{name}»؟',
deleteDescFile: 'سيتم حذف هذا الملف نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
deleteDescDir: 'سيتم حذف هذا الدليل وكل محتوياته نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
dismiss: 'إغلاق',
download: 'تنزيل',
uploadFiles: 'رفع ملفات',
uploadFolder: 'رفع مجلد',
@@ -217,13 +232,6 @@ export const ar: Record<string, string> = {
settingsIntro: 'إدارة ما تعرضه البطاقة الجانبية وكيف تتصرف',
settingsPopupDesc: 'تكوين خيارات ذات صلة بـ {feature}',
settingsDone: 'تم',
settingsOpenTitle: 'فتح افتراضياً للمحادثات الجديدة',
settingsOpenDesc: 'توسيع البطاقة الجانبية تلقائياً للمحادثات الجديدة تماماً؛ المحادثات الموجودة تحتفظ بتخطيطاتها',
settingsWidthTitle: 'الحصة الافتراضية للعرض',
settingsWidthDesc: 'الحصة الافتراضية للبطاقة الجانبية من عرض النافذة للمحادثات الجديدة (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي',
settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام',
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
settingsTitleBarTitle: 'وضع توافق الموضع',
@@ -422,4 +430,29 @@ export const ar: Record<string, string> = {
pluginDocsPanelDesc: '«وثائق عامة» في شريط DSH الجانبي: ملاحظات Markdown عامة، قابلة للقراءة من أي مساحة عمل — قائمة ملفات، مخطط تفصيلي، فتح في Chrome / VS Code، وأزرار نسخ؛ دليل الوثائق قابل للتكوين (الافتراضي ~/.dsh/docs)',
pluginEgoBrowserDesc: 'متصفح الوكيل لـ DeepSeek Harness: 32 أداة ego_* تقود متصفح Chromium حقيقي، مع تبويب «متصفح ego» أصلي في الشريط الجانبي يعرض مباشرة كل صفحة يزورها الوكيل — يمكنك النقر والسحب والكتابة لتولي التحكم. يسجّل التبويب تلقائيًا عند وجود better-sidebar، وإلا يظهر كفقاعة عائمة',
pluginBilingualReaderDesc: 'اقرأ ملفات PDF في الشريط الجانبي لـ DSH: عرض PDF أصلي، حدد نصًا لترجمته بواسطة النموذج اللغوي مع السياق، معزولًا تمامًا عن المحادثة الرئيسية',
changesRedactOn: "تنقيع مُفعّل: تظهر الأسرار كـ [REDACTED]؛ انقر للتعطيل (على مسؤوليتك)",
changesRedactOff: "تنقيع مُعطّل: سيُعرض المحتوى كما هو؛ انقر للتفعيل",
changesRedactOnLabel: "تنقيع",
changesRedactOffLabel: "بدون تنقيع",
changesRedactBanner: "مُنقّى",
pluginSentinelName: 'dsh-sentinel نظام الاستيقاظ',
pluginEgoBrowserName: 'ego-browser متصفح الوكيل',
pluginBetterOverleafName: 'dsh-better-overleaf تبويب Overleaf',
pluginDocsPanelName: 'dsh-docs-panel مستندات عامة',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge بيانات اعتماد Git',
pluginGitRemotesName: 'dsh-git-remotes أجهزة Git البعيدة',
pluginGithubWorkbenchName: 'dsh-github-workbench منصة GitHub',
pluginSidebarQaName: 'dsh-sidebar-qa تحديد وسؤال',
pluginSidenoteName: 'dsh-sidenote محادثة جانبية',
pluginServerDeckName: 'dsh-server-deck لوحة الخوادم',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel نفق SSH',
pluginTurnReviewName: 'dsh-turn-review مراجعة الجولة',
pluginBilingualReaderName: 'dsh-bilingual-reader قارئ ثنائي اللغة',
pluginOfficeName: 'معاينة Office',
pluginMdExportName: 'تصدير Markdown',
pluginCodeNavName: 'مستكشف معاينة الكود',
pluginVideoPreviewName: 'معاينة الفيديو',
presetDshDesktopDesc: 'وضع Electron المتقدم (بلا إطار): يحجز macOS شريطًا علويًا بمقدار 20px، ويحجز Windows بمقدار 32px لشريط العنوان عند غياب WCO',
}
+49 -16
View File
@@ -3,13 +3,27 @@
*/
export const de: Record<string, string> = {
files: 'Dateien',
guideDescFiles: 'Dateibaum des Arbeitsbereichs durchsuchen; Klick öffnet Vorschau oder Editor',
guideDescGit: 'Git-Änderungen und Dateiänderungen dieser Sitzung in einem Tab',
guideDescSubagent: 'Subagent-Topologie und Hintergrundaufgaben, live',
guideDescSidechat: 'Ein unabhängiger Folge-Thread mit dem Kontext dieser Sitzung',
guideDescTerminal: 'Eine echte Shell (xterm + pty) mit Wiederverbindung',
guideDescBrowser: 'Eingebetteter Browser, der Chat-Links übernehmen kann',
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
changesRead: 'Lesen',
changesMdReading: 'Lesen',
changesHtmlRender: "Rendern",
changesHtmlRaw: "Quelltext",
changesPdfRender: "Rendern",
changesPdfRaw: "Quelltext",
changesMdRaw: 'Rohfassung',
changesWrite: 'Schreiben',
changesEdit: 'Bearbeiten',
changesRunning: 'läuft',
changesError: 'Fehler',
changesFold: '{count} Zeilen… zum Aufklappen klicken',
changesFoldLoading: 'Wird aufgeklappt…',
changesFoldUnavailable: 'Kontext nicht verfügbar',
changesContext: 'Kontext',
changesPriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt',
explorer: 'Explorer',
@@ -59,7 +73,6 @@ export const de: Record<string, string> = {
closeOtherTabs: 'Andere Tabs schließen',
closeLeftTabs: 'Tabs links schließen',
closeRightTabs: 'Tabs rechts schließen',
moveToFreeWindow: 'In freies Fenster verschieben',
floatDropHint: 'Loslassen, um in einem freien Fenster zu öffnen',
dockToSidebar: 'Zurück zur Seitenleiste',
pinTerminal: 'Terminal anheften',
@@ -74,16 +87,17 @@ export const de: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Global angeheftet',
pinnedRailLabel: 'Angeheftete Terminals',
closePinnedTerminal: 'Terminal schließen',
collapse: 'Seitenleiste einklappen',
expand: 'Seitenleiste ausklappen',
collapseBottomPanel: 'Unteres Panel einklappen',
expandBottomPanel: 'Unteres Panel ausklappen',
terminalError: 'Terminalverbindung fehlgeschlagen',
terminalConnectFailed: 'Terminal konnte wiederholt keine Verbindung herstellen',
terminalRetry: 'Erneut versuchen',
terminalWaitBanner: 'Agent wartet auf {needle}',
terminalSkipWait: 'Warten überspringen',
terminalDepsFailed: 'Die Terminal-Abhängigkeit node-pty konnte nicht geladen werden',
terminalDepsHint: 'Führen Sie den folgenden Befehl in einem Terminal oder in cmd auf dem DSH-System aus, um dies zu beheben, und klicken Sie dann auf „Erneut versuchen“ (node-pty bleibt mit der DSH-Core-Version synchron):',
terminalDepsProfile: ' (erkanntes Profil: {profile})',
terminalShellNotFound: 'Konfigurierte Shell nicht gefunden: {name} — Shell-Pfad unter Einstellungen → Side card → Terminal prüfen',
preview: 'Vorschau',
toc: 'Inhaltsverzeichnis',
edit: 'Bearbeiten',
@@ -130,12 +144,6 @@ export const de: Record<string, string> = {
changesOpenDiffTab: 'In eigenem Tab öffnen',
changesClosePreview: 'Vorschau schließen',
changesResizePreview: 'Vorschauhöhe anpassen',
changesDiffOpenTitle: 'Diff öffnet als',
changesDiffOpenDesc: 'Wo die Aktion „In eigenem Tab öffnen“ den Diff platziert',
changesDiffOpenFloat: 'Freies Fenster',
changesDiffOpenFloatDesc: 'Ein schwebendes Fenster mittig im Viewport — verschieben, skalieren, oben halten',
changesDiffOpenPane: 'Bereich',
changesDiffOpenPaneDesc: 'Unter dem Quellbereich angedockt (VSCode-artiger Diff-Split)',
changesLoadError: 'Sitzungsdatei-Aufzeichnungen sind derzeit nicht verfügbar',
staged: 'Gestaged',
unstaged: 'Nicht gestaged',
@@ -184,6 +192,13 @@ export const de: Record<string, string> = {
addToConversation: 'Zur Unterhaltung hinzufügen',
copyRelative: 'Relativen Pfad kopieren',
copyAbsolute: 'Absoluten Pfad kopieren',
rename: 'Umbenennen',
renameInvalid: 'Der Name darf nicht leer sein und keine Pfadtrenner enthalten.',
delete: 'Löschen',
deleteTitle: '„{name}“ löschen?',
deleteDescFile: 'Dadurch wird die Datei dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
deleteDescDir: 'Dadurch werden das Verzeichnis und dessen gesamter Inhalt dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
dismiss: 'Schließen',
download: 'Herunterladen',
uploadFiles: 'Dateien hochladen',
uploadFolder: 'Ordner hochladen',
@@ -202,13 +217,6 @@ export const de: Record<string, string> = {
settingsIntro: 'Verwalten Sie, was die Seitenkarte anzeigt und wie sie sich verhält',
settingsPopupDesc: 'Optionen für „{feature}“ konfigurieren',
settingsDone: 'Fertig',
settingsOpenTitle: 'Für neue Unterhaltungen standardmäßig geöffnet',
settingsOpenDesc: 'Die Seitenkarte wird für brandneue Unterhaltungen automatisch ausgeklappt; bestehende Unterhaltungen behalten ihr eigenes Layout',
settingsWidthTitle: 'Standardbreite',
settingsWidthDesc: 'Standardanteil der Seitenkarte an der Fensterbreite für neue Unterhaltungen (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Chat-Dateien in der Seitenleiste öffnen',
settingsOpenPathDesc: 'Dateilinks im Chat (Werkzeugzeilen, erstellte Dateien, Erwähnungen) werden im Seitenleisten-Editor geöffnet statt in der System-Standardanwendung',
settingsOpenToolsTitle: 'Seitenleisten-Öffnungswerkzeug für das Modell bereitstellen',
settingsOpenToolsDesc: 'Wenn aktiviert, kann das Modell über das sidebar_open-Werkzeug Dateien, Ordner und HTTP(S)-Seiten in der Seitenleiste öffnen (standardmäßig deaktiviert)',
settingsTitleBarTitle: 'Kompatibilitätsmodus der Position',
@@ -407,4 +415,29 @@ export const de: Record<string, string> = {
pluginDocsPanelDesc: '„Globale Dokumente“ in der DSH-Seitenleiste: eigene Markdown-Notizen aus jedem Arbeitsbereich lesen – Dateiliste, Gliederung, Öffnen in Chrome / VS Code und Kopieren-Buttons; das Dokumentenverzeichnis ist konfigurierbar (Standard ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Der Agent-Browser für DeepSeek Harness: 32 ego_*-Tools steuern ein echtes Chromium; ein nativer „ego-Browser“-Tab in der Seitenleiste zeigt live jede Seite, die der Agent besucht – Klicken, Ziehen und Tippen zum Übernehmen. Registriert den Tab automatisch, wenn better-sidebar vorhanden ist, sonst eine schwebende Blase',
pluginBilingualReaderDesc: 'PDFs im DSH-Seitenbereich lesen: native PDF-Anzeige, Text auswählen und mit dem LLM übersetzen — mit Kontext, vollständig vom Hauptgespräch isoliert',
changesRedactOn: "Schwärzung aktiv: Geheimnisse als [REDACTED]; Klicken zum Deaktivieren (auf eigenes Risiko)",
changesRedactOff: "Schwärzung aus: Inhalte werden unverändert angezeigt; Klicken zum Aktivieren",
changesRedactOnLabel: "Schwärzung",
changesRedactOffLabel: "Ohne Schwärzung",
changesRedactBanner: "Geschwärzt",
pluginSentinelName: 'dsh-sentinel Wecksystem',
pluginEgoBrowserName: 'ego-browser Agent-Browser',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf-Tab',
pluginDocsPanelName: 'dsh-docs-panel Globale Dokumente',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Git-Zugangsdaten',
pluginGitRemotesName: 'dsh-git-remotes Git-Remotes',
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub-Workbench',
pluginSidebarQaName: 'dsh-sidebar-qa Auswählen & Fragen',
pluginSidenoteName: 'dsh-sidenote Seiten-Chat',
pluginServerDeckName: 'dsh-server-deck Server-Deck',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel SSH-Tunnel',
pluginTurnReviewName: 'dsh-turn-review Runden-Review',
pluginBilingualReaderName: 'dsh-bilingual-reader Zweisprachiger Reader',
pluginOfficeName: 'Office-Vorschau',
pluginMdExportName: 'Markdown-Export',
pluginCodeNavName: 'Code-Vorschau-Navigation',
pluginVideoPreviewName: 'Video-Vorschau',
presetDshDesktopDesc: 'Elektronischer Erweiterte-Modus (rahmenlos): macOS reserviert oben 20px; Windows reserviert ohne WCO 32px für die Titelleiste',
}
+49 -16
View File
@@ -10,13 +10,27 @@
/** fr dictionary for the `betterSidebar` namespace. */
export const fr: Record<string, string> = {
files: 'Fichiers',
guideDescFiles: 'Parcourir l\'arborescence du workspace ; un clic ouvre l\'aperçu ou l\'édition',
guideDescGit: 'Modifications Git et éditions de cette session dans un seul onglet',
guideDescSubagent: 'Topologie des sous-agents et tâches d\'arrière-plan, en direct',
guideDescSidechat: 'Un fil de suivi indépendant héritant du contexte de cette session',
guideDescTerminal: 'Un vrai shell (xterm + pty) avec reconnexion',
guideDescBrowser: 'Navigateur intégré, qui peut reprendre les liens du chat',
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
changesRead: 'Lecture',
changesMdReading: 'Lecture',
changesHtmlRender: "Rendu",
changesHtmlRaw: "Source",
changesPdfRender: "Rendu",
changesPdfRaw: "Source",
changesMdRaw: 'Brut',
changesWrite: 'Écriture',
changesEdit: 'Modification',
changesRunning: 'en cours',
changesError: 'erreur',
changesFold: '{count} lignes… cliquer pour déplier',
changesFoldLoading: 'Dépliage…',
changesFoldUnavailable: 'Contexte indisponible',
changesContext: 'contexte',
changesPriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté',
explorer: 'Explorateur',
@@ -66,7 +80,6 @@ export const fr: Record<string, string> = {
closeOtherTabs: 'Fermer les autres onglets',
closeLeftTabs: 'Fermer les onglets à gauche',
closeRightTabs: 'Fermer les onglets à droite',
moveToFreeWindow: 'Déplacer vers la fenêtre flottante',
floatDropHint: 'Relâchez pour ouvrir dans une fenêtre flottante',
dockToSidebar: 'Revenir à la barre latérale',
pinTerminal: 'Épingler le terminal',
@@ -81,16 +94,17 @@ export const fr: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Épinglé globalement',
pinnedRailLabel: 'Terminaux épinglés',
closePinnedTerminal: 'Fermer le terminal',
collapse: 'Réduire la barre latérale',
expand: 'Déployer la barre latérale',
collapseBottomPanel: 'Réduire le panneau inférieur',
expandBottomPanel: 'Déployer le panneau inférieur',
terminalError: 'Échec de la connexion au terminal',
terminalConnectFailed: 'Le terminal a échoué à se connecter à plusieurs reprises',
terminalRetry: 'Réessayer',
terminalWaitBanner: 'L’agent attend {needle}',
terminalSkipWait: 'Ignorer l’attente',
terminalDepsFailed: 'Échec du chargement de la dépendance terminal node-pty',
terminalDepsHint: 'Exécutez la commande suivante dans un terminal ou cmd de l’environnement DSH pour réparer, puis cliquez sur Réessayer (node-pty reste à la même version que le cœur DSH) :',
terminalDepsProfile: ' (profil détecté : {profile})',
terminalShellNotFound: 'Shell configuré introuvable : {name} — vérifiez le chemin du shell dans Réglages → Side card → Terminal',
preview: 'Aperçu',
toc: 'Sommaire',
edit: 'Modifier',
@@ -137,12 +151,6 @@ export const fr: Record<string, string> = {
changesOpenDiffTab: 'Ouvrir dans un onglet diff',
changesClosePreview: 'Fermer l\'aperçu',
changesResizePreview: 'Redimensionner l\'aperçu',
changesDiffOpenTitle: 'Le diff s\'ouvre en',
changesDiffOpenDesc: 'Où l\'action « Ouvrir dans un onglet diff » place le diff',
changesDiffOpenFloat: 'Fenêtre flottante',
changesDiffOpenFloatDesc: 'Une fenêtre flottante centrée sur la fenêtre — déplacer, redimensionner, garder au premier plan',
changesDiffOpenPane: 'Panneau',
changesDiffOpenPaneDesc: 'Ancré sous le panneau source (division de diff style VSCode)',
changesLoadError: 'Les enregistrements de fichiers de la session sont indisponibles',
staged: 'En scène',
unstaged: 'Hors scène',
@@ -191,6 +199,13 @@ export const fr: Record<string, string> = {
addToConversation: 'Ajouter à la discussion',
copyRelative: 'Copier le chemin relatif',
copyAbsolute: 'Copier le chemin absolu',
rename: 'Renommer',
renameInvalid: 'Le nom ne peut pas être vide ni contenir de séparateurs de chemin.',
delete: 'Supprimer',
deleteTitle: 'Supprimer « {name} » ?',
deleteDescFile: 'Ce fichier sera définitivement supprimé. Cette action est irréversible.',
deleteDescDir: 'Ce répertoire et tout son contenu seront définitivement supprimés. Cette action est irréversible.',
dismiss: 'Fermer',
download: 'Télécharger',
uploadFiles: 'Importer des fichiers',
uploadFolder: 'Importer un dossier',
@@ -209,13 +224,6 @@ export const fr: Record<string, string> = {
settingsIntro: 'Gérer le contenu affiché par la carte latérale et son comportement par défaut',
settingsPopupDesc: 'Configurer les options liées à « {feature} »',
settingsDone: 'Terminé',
settingsOpenTitle: 'Ouvert par défaut pour les nouvelles sessions',
settingsOpenDesc: 'Déployer automatiquement la carte latérale lors de la création d’une session ; les sessions existantes conservent chacune leur disposition',
settingsWidthTitle: 'Part de largeur par défaut',
settingsWidthDesc: 'Pourcentage de la largeur de la fenêtre occupé par la carte latérale pour les nouvelles sessions (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Ouvrir les fichiers de discussion dans la barre latérale',
settingsOpenPathDesc: 'À la place de l’application par défaut du système, ouvrir dans l’éditeur de la barre latérale les liens vers les fichiers du chat (lignes d’outils, listes de produits, mentions de fichiers)',
settingsOpenToolsTitle: 'Injecter l\'outil d\'ouverture latérale pour le modèle',
settingsOpenToolsDesc: 'Une fois activé, le modèle peut ouvrir des fichiers, dossiers et pages HTTP(S) dans la barre latérale via l\'outil sidebar_open (désactivé par défaut)',
settingsTitleBarTitle: 'Mode de compatibilité de position',
@@ -414,4 +422,29 @@ export const fr: Record<string, string> = {
pluginDocsPanelDesc: '« Documentation globale » dans la barre latérale DSH : notes Markdown globales, lisibles depuis n’importe quel espace de travail — sélection dans la liste, saut via le plan flottant, ouverture externe Chrome / VS Code, copie de code, répertoire configurable (défaut ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Le navigateur d’agent pour DeepSeek Harness : 32 outils ego_* pilotent un vrai Chromium ; un onglet natif « ego browser » dans la barre latérale montre en direct chaque page visitée par l’agent — cliquez, glissez et tapez pour reprendre la main. Enregistre l’onglet automatiquement si better-sidebar est présent, sinon une bulle flottante',
pluginBilingualReaderDesc: 'Lire des PDF dans la barre latérale DSH : affichage PDF natif, sélectionnez du texte pour le traduire avec le LLM, avec contexte et totalement isolé de la conversation principale',
changesRedactOn: "Masquage actif : les secrets s'affichent en [REDACTED] ; cliquez pour désactiver (à vos risques)",
changesRedactOff: "Masquage désactivé : le contenu s'affiche tel quel ; cliquez pour activer",
changesRedactOnLabel: "Masquage",
changesRedactOffLabel: "Sans masquage",
changesRedactBanner: "Masqué",
pluginSentinelName: 'dsh-sentinel Système de réveil',
pluginEgoBrowserName: 'ego-browser Navigateur d’agents',
pluginBetterOverleafName: 'dsh-better-overleaf Onglet Overleaf',
pluginDocsPanelName: 'dsh-docs-panel Docs globales',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Identifiants Git',
pluginGitRemotesName: 'dsh-git-remotes Dépôts distants Git',
pluginGithubWorkbenchName: 'dsh-github-workbench Atelier GitHub',
pluginSidebarQaName: 'dsh-sidebar-qa Sélectionner & demander',
pluginSidenoteName: 'dsh-sidenote Chat latéral',
pluginServerDeckName: 'dsh-server-deck Tableau de bord serveurs',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel Tunnel SSH',
pluginTurnReviewName: 'dsh-turn-review Revue du tour',
pluginBilingualReaderName: 'dsh-bilingual-reader Lecteur bilingue',
pluginOfficeName: 'Aperçu Office',
pluginMdExportName: 'Exportation Markdown',
pluginCodeNavName: 'Navigateur d’aperçu de code',
pluginVideoPreviewName: 'Aperçu vidéo',
presetDshDesktopDesc: 'Mode avancé Electron (sans bordure) : macOS réserve 20px en haut ; Windows réserve 32px pour la barre de titre sans WCO',
}
+49 -16
View File
@@ -17,13 +17,27 @@
/** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const hi: Record<string, string> = {
files: 'फ़ाइलें',
guideDescFiles: 'वर्कस्पेस फ़ाइल ट्री देखें; क्लिक करने पर प्रीव्यू या संपादन खुलता है',
guideDescGit: 'Git बदलाव और इस सत्र के फ़ाइल बदलाव एक ही टैब में',
guideDescSubagent: 'सबएजेंट टोपोलॉजी और बैकग्राउंड कार्य, लाइव',
guideDescSidechat: 'इस सत्र का संदर्भ विरासत में लेने वाला स्वतंत्र फ़ॉलो-अप थ्रेड',
guideDescTerminal: 'असली शेल (xterm + pty), रीकनेक्ट सहित',
guideDescBrowser: 'अंतर्निहित ब्राउज़र, चैट के लिंक भी खोल सकता है',
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
changesRead: 'पढ़ना',
changesMdReading: 'पठन',
changesHtmlRender: "रेंडर",
changesHtmlRaw: "मूल",
changesPdfRender: "रेंडर",
changesPdfRaw: "मूल",
changesMdRaw: 'मूल पाठ',
changesWrite: 'लिखना',
changesEdit: 'संपादन',
changesRunning: 'चल रहा है',
changesError: 'त्रुटि',
changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
changesFoldLoading: 'विस्तार हो रहा है…',
changesFoldUnavailable: 'संदर्भ अनुपलब्ध',
changesContext: 'संदर्भ',
changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
explorer: 'एक्सप्लोरर',
@@ -73,7 +87,6 @@ export const hi: Record<string, string> = {
closeOtherTabs: 'अन्य टैब बंद करें',
closeLeftTabs: 'बाएँ टैब बंद करें',
closeRightTabs: 'दाएँ टैब बंद करें',
moveToFreeWindow: 'फ़्री विंडो में ले जाएँ',
floatDropHint: 'फ़्री विंडो में खोलने के लिए छोड़ें',
dockToSidebar: 'साइडबार पर वापस जाएँ',
pinTerminal: 'टर्मिनल पिन करें',
@@ -88,16 +101,17 @@ export const hi: Record<string, string> = {
pinnedTerminalScopeGlobal: 'वैश्विक रूप से पिन किया गया',
pinnedRailLabel: 'पिन किए गए टर्मिनल',
closePinnedTerminal: 'टर्मिनल बंद करें',
collapse: 'साइडबार संक्षिप्त करें',
expand: 'साइडबार विस्तृत करें',
collapseBottomPanel: 'निचला पैनल संक्षिप्त करें',
expandBottomPanel: 'निचला पैनल विस्तृत करें',
terminalError: 'टर्मिनल कनेक्शन विफल',
terminalConnectFailed: 'टर्मिनल बार-बार कनेक्ट विफल',
terminalRetry: 'पुनः प्रयास',
terminalWaitBanner: 'एजेंट {needle} की प्रतीक्षा कर रहा है',
terminalSkipWait: 'प्रतीक्षा छोड़ें',
terminalDepsFailed: 'टर्मिनल निर्भरता node-pty लोड विफल',
terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):',
terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})',
terminalShellNotFound: 'कॉन्फ़िगर किया गया shell नहीं मिला: {name} — Settings → Side card → Terminal में shell पथ जाँचें',
preview: 'पूर्वावलोकन',
toc: 'विषय-सूची',
edit: 'संपादित करें',
@@ -144,12 +158,6 @@ export const hi: Record<string, string> = {
changesOpenDiffTab: 'अलग diff टैब में खोलें',
changesClosePreview: 'पूर्वावलोकन बंद करें',
changesResizePreview: 'पूर्वावलोकन का आकार बदलें',
changesDiffOpenTitle: 'diff खुलता है',
changesDiffOpenDesc: '«अलग diff टैब में खोलें» क्रिया diff को कहाँ रखती है',
changesDiffOpenFloat: 'मुक्त विंडो',
changesDiffOpenFloatDesc: 'व्यूपोर्ट के केंद्र में एक तैरती विंडो — खींचें, आकार बदलें, ऊपर रखें',
changesDiffOpenPane: 'पैनल',
changesDiffOpenPaneDesc: 'सोर्स पैनल के नीचे डॉक किया (VSCode-शैली diff स्प्लिट)',
changesLoadError: 'सेशन फ़ाइल रिकॉर्ड अभी अनुपलब्ध हैं',
staged: 'स्टेज किया',
unstaged: 'अनस्टेज',
@@ -198,6 +206,13 @@ export const hi: Record<string, string> = {
addToConversation: 'वार्तालाप में जोड़ें',
copyRelative: 'सापेक्ष पथ कॉपी',
copyAbsolute: 'निरपेक्ष पथ कॉपी',
rename: 'नाम बदलें',
renameInvalid: 'नाम खाली नहीं हो सकता और उसमें पथ विभाजक नहीं हो सकते।',
delete: 'हटाएँ',
deleteTitle: '“{name}” हटाएँ?',
deleteDescFile: 'यह फ़ाइल स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
deleteDescDir: 'यह निर्देशिका और उसकी सारी सामग्री स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
dismiss: 'बंद करें',
download: 'डाउनलोड',
uploadFiles: 'फ़ाइलें अपलोड करें',
uploadFolder: 'फ़ोल्डर अपलोड करें',
@@ -216,13 +231,6 @@ export const hi: Record<string, string> = {
settingsIntro: 'साइड कार्ड क्या दिखाता है और कैसे व्यवहार करता है प्रबंधित करें',
settingsPopupDesc: '{feature} के लिए संबंधित विकल्प कॉन्फ़िगर करें',
settingsDone: 'पूर्ण',
settingsOpenTitle: 'नए वार्तालाप के लिए डिफ़ॉल्ट रूप से खोलें',
settingsOpenDesc: 'बिल्कुल नए वार्तालाप के लिए साइड कार्ड स्वतः विस्तृत करें; मौजूदा वार्तालाप अपना लेआउट रखते हैं',
settingsWidthTitle: 'डिफ़ॉल्ट चौड़ाई हिस्सा',
settingsWidthDesc: 'नए वार्तालाप के लिए साइड कार्ड की विंडो चौड़ाई का डिफ़ॉल्ट हिस्सा (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें',
settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें',
settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें',
settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)',
settingsTitleBarTitle: 'स्थिति संगतता मोड',
@@ -421,4 +429,29 @@ export const hi: Record<string, string> = {
pluginDocsPanelDesc: 'DSH साइडबार में ग्लोबल डॉक्स: किसी भी वर्कस्पेस से अपने Markdown नोट्स पढ़ें — फ़ाइल सूची, रूपरेखा, Chrome / VS Code में खोलें, और कॉपी बटन; docs डायरेक्टरी कॉन्फ़िगर करने योग्य (डिफ़ॉल्ट ~/.dsh/docs)',
pluginEgoBrowserDesc: 'DeepSeek Harness के लिए एजेंट ब्राउज़र: 32 ego_* टूल असली Chromium चलाते हैं; साइडबार में नेटिव «ego browser» टैब एजेंट के हर विज़िट किए गए पेज को लाइव दिखाता है — आप क्लिक, ड्रैग और टाइप करके नियंत्रण ले सकते हैं। better-sidebar मौजूद होने पर टैब स्वतः रजिस्टर होता है, अन्यथा फ्लोटिंग बबल के रूप में दिखता है',
pluginBilingualReaderDesc: 'DSH साइडबार में PDF पढ़ें: मूल PDF प्रदर्शन, टेक्स्ट चुनकर LLM से अनुवाद करें, संदर्भ के साथ और मुख्य संवाद से पूरी तरह अलग',
changesRedactOn: "रिडैक्शन चालू: संवेदनशील मान [REDACTED] दिखेंगे; बंद करने के लिए क्लिक करें (अपने जोखिम पर)",
changesRedactOff: "रिडैक्शन बंद: सामग्री जैसी है वैसी दिखेगी; चालू करने के लिए क्लिक करें",
changesRedactOnLabel: "रिडैक्शन",
changesRedactOffLabel: "बिना रिडैक्शन",
changesRedactBanner: "रिडैक्टेड",
pluginSentinelName: 'dsh-sentinel वेक-अप सिस्टम',
pluginEgoBrowserName: 'ego-browser एजेंट ब्राउज़र',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf टैब',
pluginDocsPanelName: 'dsh-docs-panel ग्लोबल डॉक्स',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Git क्रेडेंशियल',
pluginGitRemotesName: 'dsh-git-remotes Git रिमोट',
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub वर्कबेंच',
pluginSidebarQaName: 'dsh-sidebar-qa चुनें और पूछें',
pluginSidenoteName: 'dsh-sidenote साइड चैट',
pluginServerDeckName: 'dsh-server-deck सर्वर डेक',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel SSH टनल',
pluginTurnReviewName: 'dsh-turn-review टर्न समीक्षा',
pluginBilingualReaderName: 'dsh-bilingual-reader द्विभाषी रीडर',
pluginOfficeName: 'Office प्रीव्यू',
pluginMdExportName: 'Markdown एक्सपोर्ट',
pluginCodeNavName: 'कोड प्रीव्यू नेविगेटर',
pluginVideoPreviewName: 'वीडियो प्रीव्यू',
presetDshDesktopDesc: 'Electron एडवांस्ड (फ्रेमलेस) मोड: macOS ऊपर 20px सुरक्षित रखता है; Windows WCO अनुपलब्ध होने पर टाइटल बार के लिए 32px रखता है',
}
+49 -16
View File
@@ -15,13 +15,27 @@
export const id: Record<string, string> = {
files: 'Berkas',
guideDescFiles: 'Telusuri pohon berkas workspace; klik untuk pratinjau atau edit',
guideDescGit: 'Perubahan Git dan perubahan berkas sesi ini dalam satu tab',
guideDescSubagent: 'Topologi subagent dan tugas latar belakang, langsung',
guideDescSidechat: 'Utas lanjutan independen yang mewarisi konteks sesi ini',
guideDescTerminal: 'Shell sungguhan (xterm + pty), bisa menyambung ulang',
guideDescBrowser: 'Peramban tertanam, bisa mengambil alih tautan chat',
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
changesRead: 'Baca',
changesMdReading: 'Baca',
changesHtmlRender: "Render",
changesHtmlRaw: "Sumber",
changesPdfRender: "Render",
changesPdfRaw: "Sumber",
changesMdRaw: 'Mentah',
changesWrite: 'Tulis',
changesEdit: 'Edit',
changesRunning: 'berjalan',
changesError: 'error',
changesFold: '{count} baris… klik untuk meluaskan',
changesFoldLoading: 'Meluaskan…',
changesFoldUnavailable: 'Konteks tidak tersedia',
changesContext: 'konteks',
changesPriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru',
explorer: 'Explorer',
@@ -71,7 +85,6 @@ export const id: Record<string, string> = {
closeOtherTabs: 'Tutup Tab Lain',
closeLeftTabs: 'Tutup Tab di Kiri',
closeRightTabs: 'Tutup Tab di Kanan',
moveToFreeWindow: 'Pindahkan ke jendela bebas',
floatDropHint: 'Lepaskan untuk membuka di jendela bebas',
dockToSidebar: 'Kembali ke bilah samping',
pinTerminal: 'Sematkan Terminal',
@@ -86,16 +99,17 @@ export const id: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Disematkan global',
pinnedRailLabel: 'Terminal Tersemat',
closePinnedTerminal: 'Tutup Terminal',
collapse: 'Lipat sidebar',
expand: 'Bentangkan sidebar',
collapseBottomPanel: 'Lipat panel bawah',
expandBottomPanel: 'Bentangkan panel bawah',
terminalError: 'Koneksi terminal gagal',
terminalConnectFailed: 'Terminal gagal terhubung berulang kali',
terminalRetry: 'Coba lagi',
terminalWaitBanner: 'Agen menunggu {needle}',
terminalSkipWait: 'Lewati penungguan',
terminalDepsFailed: 'Dependensi terminal node-pty gagal dimuat',
terminalDepsHint: 'Jalankan perintah di bawah ini di terminal atau cmd pada mesin DSH untuk memperbaikinya, lalu coba lagi (node-pty tetap sinkron dengan versi inti DSH):',
terminalDepsProfile: ' (profil terdeteksi: {profile})',
terminalShellNotFound: 'Shell yang dikonfigurasi tidak ditemukan: {name} — periksa path shell di Pengaturan → Side card → Terminal',
preview: 'Pratinjau',
toc: 'Daftar Isi',
edit: 'Edit',
@@ -142,12 +156,6 @@ export const id: Record<string, string> = {
changesOpenDiffTab: 'Buka di tab diff terpisah',
changesClosePreview: 'Tutup pratinjau',
changesResizePreview: 'Ubah ukuran pratinjau',
changesDiffOpenTitle: 'Diff terbuka sebagai',
changesDiffOpenDesc: 'Di mana aksi "buka di tab diff terpisah" menempatkan diff',
changesDiffOpenFloat: 'Jendela bebas',
changesDiffOpenFloatDesc: 'Jendela mengambang di tengah viewport — geser, ubah ukuran, selalu di atas',
changesDiffOpenPane: 'Panel',
changesDiffOpenPaneDesc: 'Ditambat di bawah panel sumber (split diff gaya VSCode)',
changesLoadError: 'Catatan berkas sesi sedang tidak tersedia',
staged: 'Staged',
unstaged: 'Unstaged',
@@ -196,6 +204,13 @@ export const id: Record<string, string> = {
addToConversation: 'Tambahkan ke obrolan',
copyRelative: 'Salin path relatif',
copyAbsolute: 'Salin path absolut',
rename: 'Ganti nama',
renameInvalid: 'Nama tidak boleh kosong atau mengandung pemisah jalur.',
delete: 'Hapus',
deleteTitle: 'Hapus "{name}"?',
deleteDescFile: 'Ini menghapus file secara permanen. Tidak dapat dibatalkan.',
deleteDescDir: 'Ini menghapus direktori dan seluruh isinya secara permanen. Tidak dapat dibatalkan.',
dismiss: 'Tutup',
download: 'Unduh',
uploadFiles: 'Unggah berkas',
uploadFolder: 'Unggah folder',
@@ -214,13 +229,6 @@ export const id: Record<string, string> = {
settingsIntro: 'Kelola apa yang ditampilkan kartu samping dan perilakunya',
settingsPopupDesc: 'Konfigurasikan opsi terkait untuk {feature}',
settingsDone: 'Selesai',
settingsOpenTitle: 'Buka secara default untuk obrolan baru',
settingsOpenDesc: 'Bentangkan kartu samping secara otomatis untuk obrolan baru; obrolan yang sudah ada mempertahankan tata letaknya',
settingsWidthTitle: 'Porsi lebar default',
settingsWidthDesc: 'Porsi lebar jendela default kartu samping untuk obrolan baru (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Buka berkas obrolan di sidebar',
settingsOpenPathDesc: 'Buka tautan berkas di obrolan (baris alat, berkas yang dihasilkan, mention) di editor sidebar alih-alih aplikasi default sistem',
settingsOpenToolsTitle: 'Suntik alat buka sidebar untuk model',
settingsOpenToolsDesc: 'Saat diaktifkan, model dapat membuka file, folder, dan halaman HTTP(S) di sidebar melalui alat sidebar_open (nonaktif secara default)',
settingsTitleBarTitle: 'Mode kompatibilitas posisi',
@@ -419,4 +427,29 @@ export const id: Record<string, string> = {
pluginDocsPanelDesc: 'Dokumen global di sidebar DSH: baca catatan Markdown Anda sendiri dari ruang kerja apa pun — daftar berkas, kerangka, buka di Chrome / VS Code, dan tombol salin; direktori docs dapat dikonfigurasi (default ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Browser agen untuk DeepSeek Harness: 32 alat ego_* menggerakkan Chromium sungguhan; tab «ego browser» native di bilah samping menampilkan langsung setiap halaman yang dikunjungi agen — Anda bisa mengklik, menyeret, dan mengetik untuk mengambil alih. Tab terdaftar otomatis jika better-sidebar terpasang; jika tidak, jatuh ke gelembung mengambang',
pluginBilingualReaderDesc: 'Baca PDF di sidebar DSH: tampilan PDF asli, pilih teks untuk diterjemahkan dengan LLM, dengan konteks dan sepenuhnya terisolasi dari percakapan utama',
changesRedactOn: "Redaksi aktif: rahasia tampil sebagai [REDACTED]; klik untuk mematikan (risiko Anda sendiri)",
changesRedactOff: "Redaksi mati: konten tampil apa adanya; klik untuk mengaktifkan",
changesRedactOnLabel: "Redaksi",
changesRedactOffLabel: "Tanpa redaksi",
changesRedactBanner: "Teredaksi",
pluginSentinelName: 'dsh-sentinel Sistem bangun',
pluginEgoBrowserName: 'ego-browser Peramban agen',
pluginBetterOverleafName: 'dsh-better-overleaf Tab Overleaf',
pluginDocsPanelName: 'dsh-docs-panel Dokumen global',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Kredensial Git',
pluginGitRemotesName: 'dsh-git-remotes Remote Git',
pluginGithubWorkbenchName: 'dsh-github-workbench Meja kerja GitHub',
pluginSidebarQaName: 'dsh-sidebar-qa Pilih & tanya',
pluginSidenoteName: 'dsh-sidenote Obrolan samping',
pluginServerDeckName: 'dsh-server-deck Dek server',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel Terowongan SSH',
pluginTurnReviewName: 'dsh-turn-review Tinjauan giliran',
pluginBilingualReaderName: 'dsh-bilingual-reader Pembaca dwibahasa',
pluginOfficeName: 'Pratinjau Office',
pluginMdExportName: 'Ekspor Markdown',
pluginCodeNavName: 'Navigator pratinjau kode',
pluginVideoPreviewName: 'Pratinjau video',
presetDshDesktopDesc: 'Mode Electron lanjutan (tanpa bingkai): macOS mencadangkan 20px di atas; Windows mencadangkan 32px untuk bilah judul saat WCO tidak tersedia',
}
+49 -16
View File
@@ -8,13 +8,27 @@
export const it: Record<string, string> = {
files: 'File',
guideDescFiles: 'Sfoglia l\'albero del workspace; un clic apre anteprima o editor',
guideDescGit: 'Modifiche Git e modifiche ai file di questa sessione in un solo tab',
guideDescSubagent: 'Topologia dei subagent e attività in background, in tempo reale',
guideDescSidechat: 'Un thread di approfondimento indipendente che eredita il contesto della sessione',
guideDescTerminal: 'Una vera shell (xterm + pty) con riconnessione',
guideDescBrowser: 'Browser integrato, può aprire i link della chat',
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
changesRead: 'Lettura',
changesMdReading: 'Lettura',
changesHtmlRender: "Render",
changesHtmlRaw: "Sorgente",
changesPdfRender: "Render",
changesPdfRaw: "Sorgente",
changesMdRaw: 'Grezzo',
changesWrite: 'Scrittura',
changesEdit: 'Modifica',
changesRunning: 'in esecuzione',
changesError: 'errore',
changesFold: '{count} righe… clicca per espandere',
changesFoldLoading: 'Espansione…',
changesFoldUnavailable: 'Contesto non disponibile',
changesContext: 'contesto',
changesPriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto',
explorer: 'Esplora risorse',
@@ -64,7 +78,6 @@ export const it: Record<string, string> = {
closeOtherTabs: 'Chiudi le altre schede',
closeLeftTabs: 'Chiudi le schede a sinistra',
closeRightTabs: 'Chiudi le schede a destra',
moveToFreeWindow: 'Sposta in finestra libera',
floatDropHint: 'Rilascia per aprire in una finestra libera',
dockToSidebar: 'Torna alla barra laterale',
pinTerminal: 'Blocca Terminale',
@@ -79,16 +92,17 @@ export const it: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Bloccato globalmente',
pinnedRailLabel: 'Terminali Bloccati',
closePinnedTerminal: 'Chiudi Terminale',
collapse: 'Comprimi barra laterale',
expand: 'Espandi barra laterale',
collapseBottomPanel: 'Comprimi pannello inferiore',
expandBottomPanel: 'Espandi pannello inferiore',
terminalError: 'Connessione del terminale non riuscita',
terminalConnectFailed: 'Il terminale non è riuscito a connettersi più volte',
terminalRetry: 'Riprova',
terminalWaitBanner: 'L’agente è in attesa di {needle}',
terminalSkipWait: 'Salta attesa',
terminalDepsFailed: 'Caricamento della dipendenza del terminale node-pty non riuscito',
terminalDepsHint: 'Esegua il comando seguente in un terminale o cmd sul computer DSH per ripristinarlo, poi riprovi (node-pty resta sincronizzato con la versione del core DSH):',
terminalDepsProfile: ' (rilevato profilo: {profile})',
terminalShellNotFound: 'Shell configurata non trovata: {name} — verifica il percorso della shell in Impostazioni → Side card → Terminal',
preview: 'Anteprima',
toc: 'Indice',
edit: 'Modifica',
@@ -135,12 +149,6 @@ export const it: Record<string, string> = {
changesOpenDiffTab: 'Apri in una scheda diff',
changesClosePreview: 'Ridimensiona l\'anteprima',
changesResizePreview: 'Ridimensiona anteprima',
changesDiffOpenTitle: 'Il diff si apre come',
changesDiffOpenDesc: 'Dove l\'azione "apri in una scheda diff" colloca il diff',
changesDiffOpenFloat: 'Finestra libera',
changesDiffOpenFloatDesc: 'Una finestra flottante centrata sul viewport — trascina, ridimensiona, sempre in primo piano',
changesDiffOpenPane: 'Pannello',
changesDiffOpenPaneDesc: 'Ancorato sotto il pannello sorgente (divisione diff in stile VSCode)',
changesLoadError: 'I record dei file della sessione non sono disponibili',
staged: 'In stage',
unstaged: 'Non in stage',
@@ -189,6 +197,13 @@ export const it: Record<string, string> = {
addToConversation: 'Aggiungi alla conversazione',
copyRelative: 'Copia percorso relativo',
copyAbsolute: 'Copia percorso assoluto',
rename: 'Rinomina',
renameInvalid: 'Il nome non può essere vuoto o contenere separatori di percorso.',
delete: 'Elimina',
deleteTitle: 'Eliminare "{name}"?',
deleteDescFile: 'Il file verrà eliminato definitivamente. Questa azione non può essere annullata.',
deleteDescDir: 'La directory e tutto il suo contenuto verranno eliminati definitivamente. Questa azione non può essere annullata.',
dismiss: 'Chiudi',
download: 'Scarica',
uploadFiles: 'Carica file',
uploadFolder: 'Carica cartella',
@@ -207,13 +222,6 @@ export const it: Record<string, string> = {
settingsIntro: 'Gestisca cosa mostra la scheda laterale e come si comporta',
settingsPopupDesc: 'Configuri le opzioni relative a {feature}',
settingsDone: 'Fatto',
settingsOpenTitle: 'Apri per impostazione predefinita per le nuove conversazioni',
settingsOpenDesc: 'Espande automaticamente la scheda laterale per le conversazioni nuove; le conversazioni esistenti mantengono il proprio layout',
settingsWidthTitle: 'Quota di larghezza predefinita',
settingsWidthDesc: 'La quota predefinita della scheda laterale sulla larghezza della finestra per le nuove conversazioni (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Apri i file della chat nella barra laterale',
settingsOpenPathDesc: 'Apre i collegamenti ai file nella chat (righe di strumenti, file prodotti, menzioni) nell’editor della barra laterale invece dell’app predefinita di sistema',
settingsOpenToolsTitle: 'Iniettare lo strumento di apertura della barra laterale per il modello',
settingsOpenToolsDesc: 'Se attivato, il modello può aprire file, cartelle e pagine HTTP(S) nella barra laterale tramite lo strumento sidebar_open (disattivato per impostazione predefinita)',
settingsTitleBarTitle: 'Modalità di compatibilità della posizione',
@@ -412,4 +420,29 @@ export const it: Record<string, string> = {
pluginDocsPanelDesc: 'Documenti globali nella barra laterale di DSH: legga le sue note Markdown da qualsiasi spazio di lavoro — una lista di file, una struttura, apertura in Chrome / VS Code e pulsanti di copia; la directory dei documenti è configurabile (predefinita ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Il browser agente per DeepSeek Harness: 32 strumenti ego_* pilotano un vero Chromium; una scheda nativa «ego browser» nella barra laterale mostra in tempo reale ogni pagina visitata dall’agente — puoi cliccare, trascinare e digitare per prendere il controllo. Registra la scheda automaticamente se better-sidebar è presente, altrimenti una bolla flottante',
pluginBilingualReaderDesc: 'Leggi PDF nella barra laterale DSH: visualizzazione PDF nativa, seleziona il testo per tradurlo con l’LLM, con contesto e completamente isolato dalla conversazione principale',
changesRedactOn: "Mascheramento attivo: i segreti appaiono come [REDACTED]; clicca per disattivare (a tuo rischio)",
changesRedactOff: "Mascheramento disattivo: il contenuto appare invariato; clicca per attivare",
changesRedactOnLabel: "Mascheramento",
changesRedactOffLabel: "Senza mascheramento",
changesRedactBanner: "Mascherato",
pluginSentinelName: 'dsh-sentinel Sistema di riattivazione',
pluginEgoBrowserName: 'ego-browser Browser dell’agente',
pluginBetterOverleafName: 'dsh-better-overleaf Scheda Overleaf',
pluginDocsPanelName: 'dsh-docs-panel Documenti globali',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Credenziali Git',
pluginGitRemotesName: 'dsh-git-remotes Remote Git',
pluginGithubWorkbenchName: 'dsh-github-workbench Banco di lavoro GitHub',
pluginSidebarQaName: 'dsh-sidebar-qa Seleziona e chiedi',
pluginSidenoteName: 'dsh-sidenote Chat laterale',
pluginServerDeckName: 'dsh-server-deck Ponte server',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel Tunnel SSH',
pluginTurnReviewName: 'dsh-turn-review Revisione del turno',
pluginBilingualReaderName: 'dsh-bilingual-reader Lettore bilingue',
pluginOfficeName: 'Anteprima Office',
pluginMdExportName: 'Esportazione Markdown',
pluginCodeNavName: 'Navigatore anteprima codice',
pluginVideoPreviewName: 'Anteprima video',
presetDshDesktopDesc: 'Modalità avanzata Electron (senza bordi): macOS riserva 20px in alto; Windows riserva 32px per la barra del titolo quando WCO non è disponibile',
}
+49 -16
View File
@@ -17,13 +17,27 @@
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ja: Record<string, string> = {
files: 'ファイル',
guideDescFiles: 'ワークスペースのファイルツリーを閲覧し、クリックでプレビュー/編集',
guideDescGit: 'Git の変更と今回のセッションのファイル変更を 1 つのタブに',
guideDescSubagent: 'サブエージェントの構成とバックグラウンドタスクをリアルタイム表示',
guideDescSidechat: '現在のセッションの文脈を引き継ぐ独立した追加スレッド',
guideDescTerminal: '本物のシェル(xterm + pty)、再接続対応',
guideDescBrowser: '埋め込みブラウザ。チャット内のリンクも引き受け可能',
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
changesRead: '読み取り',
changesMdReading: '閲覧',
changesHtmlRender: "レンダリング",
changesHtmlRaw: "原文",
changesPdfRender: "レンダリング",
changesPdfRaw: "原文",
changesMdRaw: '原文',
changesWrite: '書き込み',
changesEdit: '編集',
changesRunning: '実行中',
changesError: 'エラー',
changesFold: '{count} 行…クリックで展開',
changesFoldLoading: '展開中…',
changesFoldUnavailable: 'コンテキスト未読み込み',
changesContext: 'コンテキスト',
changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
explorer: 'エクスプローラー',
@@ -73,7 +87,6 @@ export const ja: Record<string, string> = {
closeOtherTabs: '他のタブを閉じる',
closeLeftTabs: '左のタブを閉じる',
closeRightTabs: '右のタブを閉じる',
moveToFreeWindow: 'フリーウィンドウへ移動',
floatDropHint: '離すとフリーウィンドウで開きます',
dockToSidebar: 'サイドバーへ戻す',
pinTerminal: 'ターミナルを固定',
@@ -88,16 +101,17 @@ export const ja: Record<string, string> = {
pinnedTerminalScopeGlobal: 'グローバルに固定',
pinnedRailLabel: '固定されたターミナル',
closePinnedTerminal: 'ターミナルを閉じる',
collapse: 'サイドバーを折りたたむ',
expand: 'サイドバーを展開',
collapseBottomPanel: '下パネルを折りたたむ',
expandBottomPanel: '下パネルを展開',
terminalError: 'ターミナル接続に失敗',
terminalConnectFailed: 'ターミナル接続が繰り返し失敗しました',
terminalRetry: '再試行',
terminalWaitBanner: 'エージェントが {needle} を待機中',
terminalSkipWait: '待機をスキップ',
terminalDepsFailed: 'ターミナル依存関係 node-pty の読み込みに失敗',
terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):',
terminalDepsProfile: '(検出された profile:{profile})',
terminalShellNotFound: '設定されたシェルが見つかりません:{name}(設定 → サイドカード → ターミナル のシェルパスを確認してください)',
preview: 'プレビュー',
toc: '目次',
edit: '編集',
@@ -144,12 +158,6 @@ export const ja: Record<string, string> = {
changesOpenDiffTab: '独立タブで開く',
changesClosePreview: 'プレビューを閉じる',
changesResizePreview: 'プレビューの高さを変更',
changesDiffOpenTitle: 'diff の展開先',
changesDiffOpenDesc: '「独立タブで開く」操作が diff をどこに配置するか',
changesDiffOpenFloat: 'フリーウィンドウ',
changesDiffOpenFloatDesc: 'ビューポート中央に浮かぶウィンドウ — ドラッグ・リサイズ・最前面固定',
changesDiffOpenPane: 'ペイン',
changesDiffOpenPaneDesc: 'ソースペインの下にドッキング(VSCode 式 diff 分割)',
changesLoadError: 'セッションのファイル記録は現在利用できません',
staged: 'ステージ済み',
unstaged: '未ステージ',
@@ -198,6 +206,13 @@ export const ja: Record<string, string> = {
addToConversation: '会話に追加',
copyRelative: '相対パスをコピー',
copyAbsolute: '絶対パスをコピー',
rename: '名前の変更',
renameInvalid: '名前は空にできず、パス区切り文字を含められません。',
delete: '削除',
deleteTitle: '「{name}」を削除しますか?',
deleteDescFile: 'このファイルを完全に削除します。元に戻せません。',
deleteDescDir: 'このディレクトリと内容物をすべて完全に削除します。元に戻せません。',
dismiss: '閉じる',
download: 'ダウンロード',
uploadFiles: 'ファイルをアップロード',
uploadFolder: 'フォルダーをアップロード',
@@ -216,13 +231,6 @@ export const ja: Record<string, string> = {
settingsIntro: 'サイドカードの表示内容とデフォルト挙動を管理',
settingsPopupDesc: '「{feature}」の関連オプションを設定',
settingsDone: '完了',
settingsOpenTitle: '新規会話でデフォルト展開',
settingsOpenDesc: '新規会話時にサイドカードを自動展開。既存の会話はそれぞれのレイアウトを維持',
settingsWidthTitle: 'デフォルト幅の比率',
settingsWidthDesc: '新規会話時のサイドカードがウィンドウ幅に占める割合 (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く',
settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く',
settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入',
settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)',
settingsTitleBarTitle: '位置互換モード',
@@ -421,4 +429,29 @@ export const ja: Record<string, string> = {
pluginDocsPanelDesc: 'DSH サイドバー内の「グローバルドキュメント」:任意のワークスペースから読めるグローバル Markdown ノート。リストで選んで閲覧、ホバーでアウトラインジャンプ、Chrome / VS Code で外部オープン、コードコピー。ディレクトリは設定可(デフォルト ~/.dsh/docs)',
pluginEgoBrowserDesc: 'DeepSeek Harness 用エージェントブラウザ:32 個の ego_* ツールが本物の Chromium を操作し、サイドバーのネイティブ「ego ブラウザ」Tab がエージェントの訪れる全ページをライブ表示。クリック・ドラッグ・入力で操作を引き継げます。better-sidebar があれば Tab を自動登録、なければフローティングバブルにフォールバック',
pluginBilingualReaderDesc: 'DSH サイドバーで論文 PDF を読む:ネイティブ PDF 表示、テキストを選択して LLM で翻訳。コンテキストを使い、メイン会話から完全に分離',
changesRedactOn: "マスキング有効:機密値は [REDACTED] で表示。クリックで無効化(自己責任)",
changesRedactOff: "マスキング無効:内容がそのまま表示されます。クリックで有効化",
changesRedactOnLabel: "マスキング中",
changesRedactOffLabel: "マスキング解除",
changesRedactBanner: "マスク済み",
pluginSentinelName: 'dsh-sentinel ウェイクアップシステム',
pluginEgoBrowserName: 'ego-browser エージェントブラウザー',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf タブ',
pluginDocsPanelName: 'dsh-docs-panel グローバルドキュメント',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Git 認証情報',
pluginGitRemotesName: 'dsh-git-remotes Git リモート',
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub ワークベンチ',
pluginSidebarQaName: 'dsh-sidebar-qa 選択して質問',
pluginSidenoteName: 'dsh-sidenote サイドチャット',
pluginServerDeckName: 'dsh-server-deck サーバーデッキ',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel SSH トンネル',
pluginTurnReviewName: 'dsh-turn-review ターンレビュー',
pluginBilingualReaderName: 'dsh-bilingual-reader バイリンガルリーダー',
pluginOfficeName: 'Office プレビュー',
pluginMdExportName: 'Markdown エクスポート',
pluginCodeNavName: 'コードプレビューナビゲーター',
pluginVideoPreviewName: '動画プレビュー',
presetDshDesktopDesc: 'Electron 高度モード(枠なし):macOS は上部 20px、Windows は WCO 未提供時に 32px のタイトルバー分を確保',
}
+49 -16
View File
@@ -9,13 +9,27 @@
export const ko: Record<string, string> = {
files: '파일',
guideDescFiles: '워크스페이스 파일 트리를 보고 클릭하면 미리보기/편집',
guideDescGit: 'Git 변경 사항과 이번 세션의 파일 변경을 한 탭에서',
guideDescSubagent: '하위 에이전트 구조와 백그라운드 작업을 실시간으로',
guideDescSidechat: '현재 세션의 컨텍스트를 이어받는 독립 후속 스레드',
guideDescTerminal: '실제 셸(xterm + pty), 재연결 지원',
guideDescBrowser: '내장 브라우저, 채팅 링크도 대신 열 수 있음',
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
changesRead: '읽기',
changesMdReading: '읽기',
changesHtmlRender: "렌더링",
changesHtmlRaw: "원문",
changesPdfRender: "렌더링",
changesPdfRaw: "원문",
changesMdRaw: '원문',
changesWrite: '쓰기',
changesEdit: '편집',
changesRunning: '실행 중',
changesError: '오류',
changesFold: '{count}줄… 클릭하여 펼치기',
changesFoldLoading: '펼치는 중…',
changesFoldUnavailable: '컨텍스트를 불러올 수 없음',
changesContext: '컨텍스트',
changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
explorer: '탐색기',
@@ -65,7 +79,6 @@ export const ko: Record<string, string> = {
closeOtherTabs: '다른 탭 닫기',
closeLeftTabs: '왼쪽 탭 닫기',
closeRightTabs: '오른쪽 탭 닫기',
moveToFreeWindow: '자유 창으로 이동',
floatDropHint: '놓으면 자유 창에서 열립니다',
dockToSidebar: '사이드바로 돌아가기',
pinTerminal: '터미널 고정',
@@ -80,16 +93,17 @@ export const ko: Record<string, string> = {
pinnedTerminalScopeGlobal: '전역에 고정됨',
pinnedRailLabel: '고정된 터미널',
closePinnedTerminal: '터미널 닫기',
collapse: '사이드바 접기',
expand: '사이드바 펼치기',
collapseBottomPanel: '하단 패널 접기',
expandBottomPanel: '하단 패널 펼치기',
terminalError: '터미널 연결 실패',
terminalConnectFailed: '터미널 연결이 여러 번 실패했습니다',
terminalRetry: '다시 시도',
terminalWaitBanner: '에이전트가 {needle} 대기 중',
terminalSkipWait: '대기 건너뛰기',
terminalDepsFailed: '터미널 의존성 node-pty 로드 실패',
terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):',
terminalDepsProfile: ' (profile 감지됨: {profile})',
terminalShellNotFound: '설정된 셸을 찾을 수 없습니다: {name} — 설정 → 사이드 카드 → 터미널에서 셸 경로를 확인하세요',
preview: '미리보기',
toc: '목차',
edit: '편집',
@@ -136,12 +150,6 @@ export const ko: Record<string, string> = {
changesOpenDiffTab: '별도 탭에서 열기',
changesClosePreview: '미리보기 닫기',
changesResizePreview: '미리보기 높이 조정',
changesDiffOpenTitle: 'diff 열기 위치',
changesDiffOpenDesc: '「별도 탭에서 열기」 작업이 diff를 배치하는 곳',
changesDiffOpenFloat: '자유 창',
changesDiffOpenFloatDesc: '뷰포트 중앙의 떠 있는 창 — 드래그, 크기 조절, 항상 위',
changesDiffOpenPane: '패널',
changesDiffOpenPaneDesc: '소스 패널 아래에 도킹(VSCode 스타일 diff 분할)',
changesLoadError: '세션 파일 기록을 지금 사용할 수 없습니다',
staged: '스테이징됨',
unstaged: '스테이징 안 됨',
@@ -190,6 +198,13 @@ export const ko: Record<string, string> = {
addToConversation: '대화에 추가',
copyRelative: '상대 경로 복사',
copyAbsolute: '절대 경로 복사',
rename: '이름 바꾸기',
renameInvalid: '이름은 비워 둘 수 없으며 경로 구분 기호를 포함할 수 없습니다.',
delete: '삭제',
deleteTitle: '"{name}"을(를) 삭제할까요?',
deleteDescFile: '이 파일이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
deleteDescDir: '이 디렉터리와 모든 내용이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
dismiss: '닫기',
download: '다운로드',
uploadFiles: '파일 업로드',
uploadFolder: '폴더 업로드',
@@ -208,13 +223,6 @@ export const ko: Record<string, string> = {
settingsIntro: '사이드 카드의 표시 내용과 기본 동작을 관리합니다',
settingsPopupDesc: '"{feature}" 관련 옵션을 구성합니다',
settingsDone: '완료',
settingsOpenTitle: '새 대화 기본 열림',
settingsOpenDesc: '새 대화를 만들 때 사이드 카드를 자동으로 펼칩니다. 기존 대화는 각자의 레이아웃을 유지합니다',
settingsWidthTitle: '기본 너비 비율',
settingsWidthDesc: '새 대화에서 사이드 카드가 창 너비에서 차지하는 비율(%) (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기',
settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다',
settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입',
settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)',
settingsTitleBarTitle: '위치 호환 모드',
@@ -413,4 +421,29 @@ export const ko: Record<string, string> = {
pluginDocsPanelDesc: 'DSH 사이드바의 "전역 문서": 전역 Markdown 메모로, 어떤 작업 공간에서든 언제든 읽을 수 있습니다. 목록을 클릭해 읽기, 마우스를 올리면 개요로 이동, Chrome / VS Code에서 외부 열기, 코드 복사. 디렉터리를 설정할 수 있습니다(기본 ~/.dsh/docs)',
pluginEgoBrowserDesc: 'DeepSeek Harness용 에이전트 브라우저: 32개의 ego_* 도구가 실제 Chromium을 구동하며, 사이드바의 네이티브 «ego 브라우저» 탭에서 에이전트가 방문하는 모든 페이지를 실시간으로 볼 수 있습니다 — 클릭, 드래그, 타이핑으로 직접 조작할 수 있습니다. better-sidebar가 있으면 탭을 자동 등록하고, 없으면 플로팅 버블로 대체합니다',
pluginBilingualReaderDesc: 'DSH 사이드바에서 PDF 읽기: 네이티브 PDF 표시, 텍스트를 선택해 LLM으로 번역, 컨텍스트 사용 및 메인 대화와 완전 격리',
changesRedactOn: "마스킹 켜짐: 민감 값이 [REDACTED]로 표시됩니다. 클릭하면 꺼집니다(책임 본인)",
changesRedactOff: "마스킹 꺼짐: 내용이 그대로 표시됩니다. 클릭하면 켜집니다",
changesRedactOnLabel: "마스킹 중",
changesRedactOffLabel: "마스킹 해제",
changesRedactBanner: "마스킹됨",
pluginSentinelName: 'dsh-sentinel 웨이크업 시스템',
pluginEgoBrowserName: 'ego-browser 에이전트 브라우저',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf 탭',
pluginDocsPanelName: 'dsh-docs-panel 전역 문서',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Git 자격 증명',
pluginGitRemotesName: 'dsh-git-remotes Git 리모트',
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub 워크벤치',
pluginSidebarQaName: 'dsh-sidebar-qa 선택 후 질문',
pluginSidenoteName: 'dsh-sidenote 사이드 채팅',
pluginServerDeckName: 'dsh-server-deck 서버 데크',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel SSH 터널',
pluginTurnReviewName: 'dsh-turn-review 턴 리뷰',
pluginBilingualReaderName: 'dsh-bilingual-reader 이중언어 리더',
pluginOfficeName: 'Office 미리보기',
pluginMdExportName: 'Markdown 내보내기',
pluginCodeNavName: '코드 미리보기 내비게이터',
pluginVideoPreviewName: '동영상 미리보기',
presetDshDesktopDesc: 'Electron 고급 모드(테두리 없음): macOS는 상단에 20px, Windows는 WCO가 없을 때 타이틀 바에 32px를 확보',
}
+49 -16
View File
@@ -15,13 +15,27 @@
*/
export const nl: Record<string, string> = {
files: 'Bestanden',
guideDescFiles: 'Blader door de bestandsboom van de workspace; klik opent voorbeeld of editor',
guideDescGit: 'Git-wijzigingen en bestandswijzigingen van deze sessie in één tabblad',
guideDescSubagent: 'Subagent-topologie en achtergrondtaken, live',
guideDescSidechat: 'Een onafhankelijke vervolgthread met de context van deze sessie',
guideDescTerminal: 'Een echte shell (xterm + pty) met herverbinding',
guideDescBrowser: 'Ingebouwde browser die chatlinks kan overnemen',
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
changesRead: 'Lezen',
changesMdReading: 'Lezen',
changesHtmlRender: "Renderen",
changesHtmlRaw: "Bron",
changesPdfRender: "Renderen",
changesPdfRaw: "Bron",
changesMdRaw: 'Brontekst',
changesWrite: 'Schrijven',
changesEdit: 'Bewerken',
changesRunning: 'actief',
changesError: 'fout',
changesFold: '{count} regels… klik om uit te klappen',
changesFoldLoading: 'Uitklappen…',
changesFoldUnavailable: 'Context niet beschikbaar',
changesContext: 'context',
changesPriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw',
explorer: 'Verkenner',
@@ -71,7 +85,6 @@ export const nl: Record<string, string> = {
closeOtherTabs: 'Andere tabbladen sluiten',
closeLeftTabs: 'Tabbladen links sluiten',
closeRightTabs: 'Tabbladen rechts sluiten',
moveToFreeWindow: 'Naar vrij venster verplaatsen',
floatDropHint: 'Loslaten om in een vrij venster te openen',
dockToSidebar: 'Terug naar de zijbalk',
pinTerminal: 'Terminal vastmaken',
@@ -86,16 +99,17 @@ export const nl: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Wereldwijd vastgemaakt',
pinnedRailLabel: 'Vastgemaakte terminals',
closePinnedTerminal: 'Terminal sluiten',
collapse: 'Zijbalk inklappen',
expand: 'Zijbalk uitklappen',
collapseBottomPanel: 'Onderpaneel inklappen',
expandBottomPanel: 'Onderpaneel uitklappen',
terminalError: 'Terminalverbinding mislukt',
terminalConnectFailed: 'Terminalverbinding herhaaldelijk mislukt',
terminalRetry: 'Opnieuw proberen',
terminalWaitBanner: 'Agent wacht op {needle}',
terminalSkipWait: 'Wachten overslaan',
terminalDepsFailed: 'Terminalafhankelijkheid node-pty kon niet worden geladen',
terminalDepsHint: 'Voer het onderstaande commando uit in een terminal of cmd op de DSH-machine om het te herstellen en probeer het opnieuw (node-pty blijft in sync met de DSH-coreversie):',
terminalDepsProfile: ' (gedetecteerde profile: {profile})',
terminalShellNotFound: 'Geconfigureerde shell niet gevonden: {name} — controleer het shell-pad onder Instellingen → Side card → Terminal',
preview: 'Voorbeeld',
toc: 'Inhoudsopgave',
edit: 'Bewerken',
@@ -142,12 +156,6 @@ export const nl: Record<string, string> = {
changesOpenDiffTab: 'In eigen diff-tabblad openen',
changesClosePreview: 'Voorbeeld sluiten',
changesResizePreview: 'Voorbeeld vergroten/verkleinen',
changesDiffOpenTitle: 'Diff opent als',
changesDiffOpenDesc: 'Waar de actie „in eigen diff-tabblad openen” de diff plaatst',
changesDiffOpenFloat: 'Vrij venster',
changesDiffOpenFloatDesc: 'Een zwevend venster in het midden — slepen, schalen, bovenop houden',
changesDiffOpenPane: 'Paneel',
changesDiffOpenPaneDesc: 'Vastgemaakt onder het bronpaneel (VSCode-achtige diff-split)',
changesLoadError: 'Sessiebestandsregistraties zijn nu niet beschikbaar',
staged: 'Gestaged',
unstaged: 'Niet gestaged',
@@ -196,6 +204,13 @@ export const nl: Record<string, string> = {
addToConversation: 'Aan conversatie toevoegen',
copyRelative: 'Relatief pad kopiëren',
copyAbsolute: 'Absoluut pad kopiëren',
rename: 'Naam wijzigen',
renameInvalid: 'De naam mag niet leeg zijn of padscheidingstekens bevatten.',
delete: 'Verwijderen',
deleteTitle: '"{name}" verwijderen?',
deleteDescFile: 'Dit verwijdert het bestand permanent. Dit kan niet ongedaan worden gemaakt.',
deleteDescDir: 'Dit verwijdert de map en alles erin permanent. Dit kan niet ongedaan worden gemaakt.',
dismiss: 'Sluiten',
download: 'Downloaden',
uploadFiles: 'Bestanden uploaden',
uploadFolder: 'Map uploaden',
@@ -214,13 +229,6 @@ export const nl: Record<string, string> = {
settingsIntro: 'Beheer wat de zijkaart toont en hoe deze zich gedraagt',
settingsPopupDesc: 'Gerelateerde opties voor {feature} configureren',
settingsDone: 'Klaar',
settingsOpenTitle: 'Standaard openen voor nieuwe conversaties',
settingsOpenDesc: 'De zijkaart automatisch uitklappen voor gloednieuwe conversaties; bestaande conversaties behouden hun eigen layout',
settingsWidthTitle: 'Standaard breedte-aandeel',
settingsWidthDesc: 'Het standaard breedte-aandeel van de zijkaart voor nieuwe conversaties (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Chatbestanden in de zijbalk openen',
settingsOpenPathDesc: 'Bestandslinks in de chat (toolrijen, geproduceerde bestanden, vermeldingen) openen in de zijbalk-editor in plaats van de standaard systeemapp',
settingsOpenToolsTitle: 'Zijbalk-openen-tool voor het model injecteren',
settingsOpenToolsDesc: 'Indien ingeschakeld kan het model bestanden, mappen en HTTP(S)-pagina\'s in de zijbalk openen via de sidebar_open-tool (standaard uit)',
settingsTitleBarTitle: 'Positiecompatibiliteitsmodus',
@@ -419,4 +427,29 @@ export const nl: Record<string, string> = {
pluginDocsPanelDesc: 'Globale docs in de DSH-zijbalk: lees uw eigen Markdown-notities vanuit elke werkruimte — een bestandslijst, een outline, openen in Chrome / VS Code, en kopieerknoppen; de docs-map is configureerbaar (standaard ~/.dsh/docs)',
pluginEgoBrowserDesc: 'De agentbrowser voor DeepSeek Harness: 32 ego_*-tools besturen een echt Chromium; een native «ego-browser»-tab in de zijbalk toont live elke pagina die de agent bezoekt — u kunt klikken, slepen en typen om over te nemen. Registreert de tab automatisch als better-sidebar aanwezig is, anders een zwevende bel',
pluginBilingualReaderDesc: 'Lees PDF’s in de DSH-zijbalk: native PDF-weergave, selecteer tekst om te vertalen met de LLM, met context en volledig geïsoleerd van het hoofdgesprek',
changesRedactOn: "Redactie aan: geheimen worden als [REDACTED] getoond; klik om uit te schakelen (op eigen risico)",
changesRedactOff: "Redactie uit: inhoud wordt ongewijzigd getoond; klik om in te schakelen",
changesRedactOnLabel: "Redactie",
changesRedactOffLabel: "Zonder redactie",
changesRedactBanner: "Geredigeerd",
pluginSentinelName: 'dsh-sentinel Weksysteem',
pluginEgoBrowserName: 'ego-browser Agentbrowser',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf-tab',
pluginDocsPanelName: 'dsh-docs-panel Globale documenten',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Git-inloggegevens',
pluginGitRemotesName: 'dsh-git-remotes Git-remotes',
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub-workbench',
pluginSidebarQaName: 'dsh-sidebar-qa Selecteren & vragen',
pluginSidenoteName: 'dsh-sidenote Zijchat',
pluginServerDeckName: 'dsh-server-deck Serverdeck',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel SSH-tunnel',
pluginTurnReviewName: 'dsh-turn-review Beurtreview',
pluginBilingualReaderName: 'dsh-bilingual-reader Tweetalige lezer',
pluginOfficeName: 'Office-voorvertoning',
pluginMdExportName: 'Markdown-export',
pluginCodeNavName: 'Codevoorvertoning-navigator',
pluginVideoPreviewName: 'Videovoorvertoning',
presetDshDesktopDesc: 'Geavanceerde Electronmodus (zonder rand): macOS reserveert 20px bovenaan; Windows reserveert 32px voor de titelbalk wanneer WCO niet beschikbaar is',
}
+49 -16
View File
@@ -19,13 +19,27 @@
/** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const pl: Record<string, string> = {
files: 'Pliki',
guideDescFiles: 'Przeglądaj drzewo plików obszaru roboczego; klik otwiera podgląd lub edycję',
guideDescGit: 'Zmiany Git i zmiany plików z tej sesji w jednej karcie',
guideDescSubagent: 'Topologia subagentów i zadania w tle na żywo',
guideDescSidechat: 'Niezależny wątek uzupełniający dziedziczący kontekst tej sesji',
guideDescTerminal: 'Prawdziwy shell (xterm + pty) z ponownym łączeniem',
guideDescBrowser: 'Wbudowana przeglądarka, może przejmować linki z czatu',
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
changesRead: 'Odczyt',
changesMdReading: 'Czytanie',
changesHtmlRender: "Renderuj",
changesHtmlRaw: "Źródło",
changesPdfRender: "Renderuj",
changesPdfRaw: "Źródło",
changesMdRaw: 'Źródło',
changesWrite: 'Zapis',
changesEdit: 'Edycja',
changesRunning: 'w toku',
changesError: 'błąd',
changesFold: '{count} wierszy… kliknij, aby rozwinąć',
changesFoldLoading: 'Rozwijanie…',
changesFoldUnavailable: 'Kontekst niedostępny',
changesContext: 'kontekst',
changesPriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa',
explorer: 'Eksplorator',
@@ -75,7 +89,6 @@ export const pl: Record<string, string> = {
closeOtherTabs: 'Zamknij inne karty',
closeLeftTabs: 'Zamknij karty po lewej',
closeRightTabs: 'Zamknij karty po prawej',
moveToFreeWindow: 'Przenieś do wolnego okna',
floatDropHint: 'Puść, aby otworzyć w wolnym oknie',
dockToSidebar: 'Wróć do panelu bocznego',
pinTerminal: 'Przypnij Terminal',
@@ -90,16 +103,17 @@ export const pl: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Przypięty globalnie',
pinnedRailLabel: 'Przypięte terminale',
closePinnedTerminal: 'Zamknij terminal',
collapse: 'Zwiń panel boczny',
expand: 'Rozwiń panel boczny',
collapseBottomPanel: 'Zwiń panel dolny',
expandBottomPanel: 'Rozwiń panel dolny',
terminalError: 'Połączenie z terminalem nie powiodło się',
terminalConnectFailed: 'Terminal nie mógł połączyć się wielokrotnie',
terminalRetry: 'Ponów',
terminalWaitBanner: 'Agent czeka na {needle}',
terminalSkipWait: 'Pomiń oczekiwanie',
terminalDepsFailed: 'Zależność terminala node-pty nie mogła się załadować',
terminalDepsHint: 'Uruchom poniższe polecenie w terminalu lub cmd na maszynie DSH, aby to naprawić, a następnie ponów (node-pty pozostaje w synchronizacji z wersją rdzenia DSH):',
terminalDepsProfile: ' (wykryty profil: {profile})',
terminalShellNotFound: 'Nie znaleziono skonfigurowanej powłoki: {name} — sprawdź ścieżkę powłoki w Ustawienia → Side card → Terminal',
preview: 'Podgląd',
toc: 'Spis treści',
edit: 'Edytuj',
@@ -146,12 +160,6 @@ export const pl: Record<string, string> = {
changesOpenDiffTab: 'Otwórz w osobnej karcie',
changesClosePreview: 'Zamknij podgląd',
changesResizePreview: 'Zmień wysokość podglądu',
changesDiffOpenTitle: 'Diff otwiera się jako',
changesDiffOpenDesc: 'Gdzie akcja „otwórz w osobnej karcie” umieszcza diff',
changesDiffOpenFloat: 'Odrębne okno',
changesDiffOpenFloatDesc: 'Pływające okno wyśrodkowane na ekranie — przeciągaj, skaluj, trzymaj na wierzchu',
changesDiffOpenPane: 'Panel',
changesDiffOpenPaneDesc: 'Zadokowane pod panelem źródłowym (podział diff w stylu VSCode)',
changesLoadError: 'Zapisy plików sesji są teraz niedostępne',
staged: 'W indeksie',
unstaged: 'Poza indeksem',
@@ -200,6 +208,13 @@ export const pl: Record<string, string> = {
addToConversation: 'Dodaj do rozmowy',
copyRelative: 'Kopiuj ścieżkę względną',
copyAbsolute: 'Kopiuj ścieżkę bezwzględną',
rename: 'Zmień nazwę',
renameInvalid: 'Nazwa nie może być pusta ani zawierać separatorów ścieżki.',
delete: 'Usuń',
deleteTitle: 'Usunąć „{name}”?',
deleteDescFile: 'Spowoduje to trwałe usunięcie pliku. Tej operacji nie można cofnąć.',
deleteDescDir: 'Spowoduje to trwałe usunięcie katalogu i całej jego zawartości. Tej operacji nie można cofnąć.',
dismiss: 'Zamknij',
download: 'Pobierz',
uploadFiles: 'Wgraj pliki',
uploadFolder: 'Wgraj folder',
@@ -218,13 +233,6 @@ export const pl: Record<string, string> = {
settingsIntro: 'Zarządzaj tym, co pokazuje karta boczna i jak się zachowuje',
settingsPopupDesc: 'Skonfiguruj opcje powiązane z: {feature}',
settingsDone: 'Gotowe',
settingsOpenTitle: 'Otwórz domyślnie dla nowych rozmów',
settingsOpenDesc: 'Rozwiń kartę boczną automatycznie dla zupełnie nowych rozmów; istniejące rozmowy zachowują własne układy',
settingsWidthTitle: 'Domyślny udział szerokości',
settingsWidthDesc: 'Domyślny udział szerokości okna dla karty bocznej w nowych rozmowach (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Otwieraj pliki czatu w panelu bocznym',
settingsOpenPathDesc: 'Otwieraj linki plików na czacie (wiersze narzędzi, wyprodukowane pliki, wzmianki) w edytorze panelu bocznego zamiast w domyślnej aplikacji systemu',
settingsOpenToolsTitle: 'Wstrzyknij narzędzie otwierania panelu bocznego dla modelu',
settingsOpenToolsDesc: 'Po włączeniu model może otwierać pliki, foldery i strony HTTP(S) w panelu bocznym za pomocą narzędzia sidebar_open (domyślnie wyłączone)',
settingsTitleBarTitle: 'Tryb zgodności pozycji',
@@ -423,4 +431,29 @@ export const pl: Record<string, string> = {
pluginDocsPanelDesc: 'Globalne dokumenty w panelu bocznym DSH: globalne notatki Markdown, czytelne z dowolnego obszaru roboczego — lista z kliknięciem, kontur z przejściami, otwieranie w Chrome / VS Code i przyciski kopiowania kodu; katalog konfigurowalny (domyślnie ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Przeglądarka agenta dla DeepSeek Harness: 32 narzędzia ego_* sterują prawdziwym Chromium; natywna karta «ego browser» na pasku bocznym pokazuje na żywo każdą stronę odwiedzaną przez agenta — możesz klikać, przeciągać i pisać, aby przejąć kontrolę. Karta rejestruje się automatycznie, gdy better-sidebar jest zainstalowany; w przeciwnym razie pływający dymek',
pluginBilingualReaderDesc: 'Czytaj PDF-y w panelu bocznym DSH: natywny podgląd PDF, zaznacz tekst, aby przetłumaczyć go LLM-em, z kontekstem i całkowicie izolowany od głównej rozmowy',
changesRedactOn: "Redakcja włączona: tajne dane pokazywane jako [REDACTED]; kliknij, aby wyłączyć (na własne ryzyko)",
changesRedactOff: "Redakcja wyłączona: treść pokazywana bez zmian; kliknij, aby włączyć",
changesRedactOnLabel: "Redakcja",
changesRedactOffLabel: "Bez redakcji",
changesRedactBanner: "Zredagowano",
pluginSentinelName: 'dsh-sentinel System wybudzania',
pluginEgoBrowserName: 'ego-browser Przeglądarka agenta',
pluginBetterOverleafName: 'dsh-better-overleaf Karta Overleaf',
pluginDocsPanelName: 'dsh-docs-panel Dokumenty globalne',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Dane uwierzytelniające Git',
pluginGitRemotesName: 'dsh-git-remotes Zdalne repozytoria Git',
pluginGithubWorkbenchName: 'dsh-github-workbench Warsztat GitHub',
pluginSidebarQaName: 'dsh-sidebar-qa Zaznacz i zapytaj',
pluginSidenoteName: 'dsh-sidenote Czat boczny',
pluginServerDeckName: 'dsh-server-deck Panel serwerów',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel Tunel SSH',
pluginTurnReviewName: 'dsh-turn-review Rewizja tury',
pluginBilingualReaderName: 'dsh-bilingual-reader Czytnik dwujęzyczny',
pluginOfficeName: 'Podgląd Office',
pluginMdExportName: 'Eksport Markdown',
pluginCodeNavName: 'Nawigator podglądu kodu',
pluginVideoPreviewName: 'Podgląd wideo',
presetDshDesktopDesc: 'Tryb zaawansowany Electron (bez ramki): macOS rezerwuje 20px u góry; Windows bez WCO rezerwuje 32px na pasek tytułu',
}
+49 -16
View File
@@ -1,12 +1,26 @@
export const pt: Record<string, string> = {
files: 'Arquivos',
guideDescFiles: 'Navegue pela árvore do workspace; um clique abre a pré-visualização ou a edição',
guideDescGit: 'Alterações do Git e edições desta sessão em um único separador',
guideDescSubagent: 'Topologia de subagentes e tarefas em segundo plano, ao vivo',
guideDescSidechat: 'Uma conversa paralela independente que herda o contexto desta sessão',
guideDescTerminal: 'Um shell real (xterm + pty) com reconexão',
guideDescBrowser: 'Navegador incorporado, que pode abrir links do chat',
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
changesRead: 'Leitura',
changesMdReading: 'Leitura',
changesHtmlRender: "Renderizar",
changesHtmlRaw: "Fonte",
changesPdfRender: "Renderizar",
changesPdfRaw: "Fonte",
changesMdRaw: 'Bruto',
changesWrite: 'Escrita',
changesEdit: 'Edição',
changesRunning: 'em execução',
changesError: 'erro',
changesFold: '{count} linhas… clique para expandir',
changesFoldLoading: 'Expandindo…',
changesFoldUnavailable: 'Contexto indisponível',
changesContext: 'contexto',
changesPriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado',
explorer: 'Explorador',
@@ -56,7 +70,6 @@ export const pt: Record<string, string> = {
closeOtherTabs: 'Fechar outras abas',
closeLeftTabs: 'Fechar abas à esquerda',
closeRightTabs: 'Fechar abas à direita',
moveToFreeWindow: 'Mover para janela flutuante',
floatDropHint: 'Solte para abrir em uma janela flutuante',
dockToSidebar: 'Voltar para a barra lateral',
pinTerminal: 'Fixar Terminal',
@@ -71,16 +84,17 @@ export const pt: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Fixado globalmente',
pinnedRailLabel: 'Terminais Fixados',
closePinnedTerminal: 'Fechar Terminal',
collapse: 'Recolher barra lateral',
expand: 'Expandir barra lateral',
collapseBottomPanel: 'Recolher painel inferior',
expandBottomPanel: 'Expandir painel inferior',
terminalError: 'Falha na conexão do terminal',
terminalConnectFailed: 'O terminal falhou ao conectar repetidamente',
terminalRetry: 'Tentar novamente',
terminalWaitBanner: 'O agente está aguardando {needle}',
terminalSkipWait: 'Pular espera',
terminalDepsFailed: 'A dependência do terminal node-pty falhou ao carregar',
terminalDepsHint: 'Execute o comando abaixo em um terminal ou cmd na máquina do DSH para reparar e tente novamente (node-pty permanece em sincronia com a versão do núcleo do DSH):',
terminalDepsProfile: ' (perfil detectado: {profile})',
terminalShellNotFound: 'Shell configurado não encontrado: {name} — verifique o caminho do shell em Configurações → Side card → Terminal',
preview: 'Pré-visualizar',
toc: 'Sumário',
edit: 'Editar',
@@ -127,12 +141,6 @@ export const pt: Record<string, string> = {
changesOpenDiffTab: 'Abrir em uma aba de diff',
changesClosePreview: 'Fechar prévia',
changesResizePreview: 'Ajustar altura da prévia',
changesDiffOpenTitle: 'O diff abre como',
changesDiffOpenDesc: 'Onde a ação "abrir em uma aba de diff" coloca o diff',
changesDiffOpenFloat: 'Janela livre',
changesDiffOpenFloatDesc: 'Uma janela flutuante centralizada — arraste, redimensione, mantenha no topo',
changesDiffOpenPane: 'Painel',
changesDiffOpenPaneDesc: 'Ancorado abaixo do painel de origem (divisão de diff estilo VSCode)',
changesLoadError: 'Os registros de arquivos da sessão estão indisponíveis no momento',
staged: 'Preparadas',
unstaged: 'Não preparadas',
@@ -181,6 +189,13 @@ export const pt: Record<string, string> = {
addToConversation: 'Adicionar à conversa',
copyRelative: 'Copiar caminho relativo',
copyAbsolute: 'Copiar caminho absoluto',
rename: 'Renomear',
renameInvalid: 'O nome não pode ser vazio nem conter separadores de caminho.',
delete: 'Excluir',
deleteTitle: 'Excluir "{name}"?',
deleteDescFile: 'Isso exclui o arquivo permanentemente. Esta ação não pode ser desfeita.',
deleteDescDir: 'Isso exclui o diretório e todo o seu conteúdo permanentemente. Esta ação não pode ser desfeita.',
dismiss: 'Fechar',
download: 'Baixar',
uploadFiles: 'Enviar arquivos',
uploadFolder: 'Enviar pasta',
@@ -199,13 +214,6 @@ export const pt: Record<string, string> = {
settingsIntro: 'Gerencie o que o cartão lateral mostra e como ele se comporta',
settingsPopupDesc: 'Configurar opções relacionadas para {feature}',
settingsDone: 'Concluído',
settingsOpenTitle: 'Abrir por padrão em novas conversas',
settingsOpenDesc: 'Expandir o cartão lateral automaticamente em conversas totalmente novas; conversas existentes mantêm seus próprios layouts',
settingsWidthTitle: 'Participação padrão na largura',
settingsWidthDesc: 'Participação padrão do cartão lateral na largura da janela em novas conversas (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Abrir arquivos do chat na barra lateral',
settingsOpenPathDesc: 'Abrir links de arquivos no chat (linhas de ferramentas, arquivos produzidos, menções) no editor da barra lateral em vez do aplicativo padrão do sistema',
settingsOpenToolsTitle: 'Injetar a ferramenta de abertura na barra lateral para o modelo',
settingsOpenToolsDesc: 'Quando ativado, o modelo pode abrir arquivos, pastas e páginas HTTP(S) na barra lateral pela ferramenta sidebar_open (desativado por padrão)',
settingsTitleBarTitle: 'Modo de compatibilidade de posição',
@@ -404,4 +412,29 @@ export const pt: Record<string, string> = {
pluginDocsPanelDesc: 'Documentos globais na barra lateral do DSH: leia suas próprias notas Markdown de qualquer workspace — uma lista de arquivos, um sumário, abrir no Chrome / VS Code e botões de copiar; o diretório de documentos é configurável (padrão ~/.dsh/docs)',
pluginEgoBrowserDesc: 'O navegador de agente para o DeepSeek Harness: 32 ferramentas ego_* controlam um Chromium real; uma aba nativa «ego browser» na barra lateral mostra ao vivo cada página visitada pelo agente — você pode clicar, arrastar e digitar para assumir. Registra a aba automaticamente quando o better-sidebar está presente; caso contrário, uma bolha flutuante',
pluginBilingualReaderDesc: 'Leia PDFs na barra lateral do DSH: visualização nativa de PDF, selecione o texto para traduzir com o LLM, com contexto e totalmente isolado da conversa principal',
changesRedactOn: "Máscara ativa: segredos exibidos como [REDACTED]; clique para desativar (por sua conta)",
changesRedactOff: "Máscara desativada: conteúdo exibido como está; clique para ativar",
changesRedactOnLabel: "Máscara",
changesRedactOffLabel: "Sem máscara",
changesRedactBanner: "Mascarado",
pluginSentinelName: 'dsh-sentinel Sistema de despertar',
pluginEgoBrowserName: 'ego-browser Navegador de agentes',
pluginBetterOverleafName: 'dsh-better-overleaf Aba Overleaf',
pluginDocsPanelName: 'dsh-docs-panel Documentos globais',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Credenciais Git',
pluginGitRemotesName: 'dsh-git-remotes Remotos Git',
pluginGithubWorkbenchName: 'dsh-github-workbench Bancada GitHub',
pluginSidebarQaName: 'dsh-sidebar-qa Selecionar e perguntar',
pluginSidenoteName: 'dsh-sidenote Chat lateral',
pluginServerDeckName: 'dsh-server-deck Deck de servidores',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel Túnel SSH',
pluginTurnReviewName: 'dsh-turn-review Revisão do turno',
pluginBilingualReaderName: 'dsh-bilingual-reader Leitor bilíngue',
pluginOfficeName: 'Pré-visualização Office',
pluginMdExportName: 'Exportação Markdown',
pluginCodeNavName: 'Navegador de pré-visualização de código',
pluginVideoPreviewName: 'Pré-visualização de vídeo',
presetDshDesktopDesc: 'Modo avançado Electron (sem moldura): o macOS reserva 20px no topo; o Windows reserva 32px para a barra de título sem WCO',
}
+49 -16
View File
@@ -13,13 +13,27 @@
export const ru: Record<string, string> = {
files: 'Файлы',
guideDescFiles: 'Дерево файлов рабочей области: клик открывает просмотр или редактор',
guideDescGit: 'Изменения Git и правки файлов этой сессии в одной вкладке',
guideDescSubagent: 'Топология субагентов и фоновые задачи в реальном времени',
guideDescSidechat: 'Независимый дополнительный тред с контекстом этой сессии',
guideDescTerminal: 'Настоящий shell (xterm + pty) с переподключением',
guideDescBrowser: 'Встроенный браузер, перехватывает ссылки из чата',
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
changesRead: 'Чтение',
changesMdReading: 'Чтение',
changesHtmlRender: "Рендер",
changesHtmlRaw: "Исходник",
changesPdfRender: "Рендер",
changesPdfRaw: "Исходник",
changesMdRaw: 'Исходный текст',
changesWrite: 'Запись',
changesEdit: 'Изменение',
changesRunning: 'выполняется',
changesError: 'ошибка',
changesFold: '{count} строк… нажмите, чтобы развернуть',
changesFoldLoading: 'Разворачивание…',
changesFoldUnavailable: 'Контекст недоступен',
changesContext: 'контекст',
changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
explorer: 'Проводник',
@@ -69,7 +83,6 @@ export const ru: Record<string, string> = {
closeOtherTabs: 'Закрыть остальные вкладки',
closeLeftTabs: 'Закрыть вкладки слева',
closeRightTabs: 'Закрыть вкладки справа',
moveToFreeWindow: 'Переместить в свободное окно',
floatDropHint: 'Отпустите, чтобы открыть в свободном окне',
dockToSidebar: 'Вернуть на боковую панель',
pinTerminal: 'Закрепить терминал',
@@ -84,16 +97,17 @@ export const ru: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Закреплён глобально',
pinnedRailLabel: 'Закреплённые терминалы',
closePinnedTerminal: 'Закрыть терминал',
collapse: 'Свернуть боковую панель',
expand: 'Развернуть боковую панель',
collapseBottomPanel: 'Свернуть нижнюю панель',
expandBottomPanel: 'Развернуть нижнюю панель',
terminalError: 'Сбой подключения терминала',
terminalConnectFailed: 'Терминал не смог подключиться после нескольких попыток',
terminalRetry: 'Повторить',
terminalWaitBanner: 'Агент ожидает {needle}',
terminalSkipWait: 'Пропустить ожидание',
terminalDepsFailed: 'Не удалось загрузить зависимость терминала node-pty',
terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):',
terminalDepsProfile: ' (обнаружен profile: {profile})',
terminalShellNotFound: 'Настроенная оболочка не найдена: {name} — проверьте путь к оболочке в Настройки → Side card → Terminal',
preview: 'Предпросмотр',
toc: 'Оглавление',
edit: 'Изменить',
@@ -140,12 +154,6 @@ export const ru: Record<string, string> = {
changesOpenDiffTab: 'Открыть в отдельной вкладке',
changesClosePreview: 'Закрыть просмотр',
changesResizePreview: 'Изменить высоту просмотра',
changesDiffOpenTitle: 'Diff открывается как',
changesDiffOpenDesc: 'Куда действие «открыть в отдельной вкладке» помещает diff',
changesDiffOpenFloat: 'Свободное окно',
changesDiffOpenFloatDesc: 'Плавающее окно по центру экрана — перетаскивание, масштаб, поверх всех',
changesDiffOpenPane: 'Панель',
changesDiffOpenPaneDesc: 'Прикреплено под исходной панелью (разделение diff в стиле VSCode)',
changesLoadError: 'Записи файлов сессии сейчас недоступны',
staged: 'В индексе',
unstaged: 'Не в индексе',
@@ -194,6 +202,13 @@ export const ru: Record<string, string> = {
addToConversation: 'Добавить в диалог',
copyRelative: 'Копировать относительный путь',
copyAbsolute: 'Копировать абсолютный путь',
rename: 'Переименовать',
renameInvalid: 'Имя не может быть пустым или содержать разделители пути.',
delete: 'Удалить',
deleteTitle: 'Удалить «{name}»?',
deleteDescFile: 'Файл будет удалён безвозвратно. Это действие нельзя отменить.',
deleteDescDir: 'Каталог и всё его содержимое будут удалены безвозвратно. Это действие нельзя отменить.',
dismiss: 'Закрыть',
download: 'Скачать',
uploadFiles: 'Загрузить файлы',
uploadFolder: 'Загрузить папку',
@@ -213,13 +228,6 @@ export const ru: Record<string, string> = {
settingsIntro: 'Управление содержимым и поведением боковой карточки по умолчанию',
settingsPopupDesc: 'Настройка параметров «{feature}»',
settingsDone: 'Готово',
settingsOpenTitle: 'Открывать по умолчанию для новых сессий',
settingsOpenDesc: 'Автоматически разворачивать боковую карточку для новых сессий; у существующих сессий сохраняется их собственная раскладка',
settingsWidthTitle: 'Доля ширины по умолчанию',
settingsWidthDesc: 'Доля боковой карточки в ширине окна для новых сессий, в процентах (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Открывать файлы чата в боковой панели',
settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию',
settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели',
settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)',
settingsTitleBarTitle: 'Режим совместимости заголовка',
@@ -419,4 +427,29 @@ export const ru: Record<string, string> = {
pluginDocsPanelDesc: '«Глобальные документы» в боковой панели DSH: глобальные Markdown-заметки, доступные из любой рабочей области — список для чтения, навигация по плавающему оглавлению, открытие в Chrome / VS Code, копирование кода; каталог настраивается (по умолчанию ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Браузер агента для DeepSeek Harness: 32 инструмента ego_* управляют настоящим Chromium; встроенная вкладка «ego browser» в боковой панели показывает в реальном времени каждую страницу, которую посещает агент — кликайте, перетаскивайте и вводите текст, чтобы перехватить управление. Вкладка регистрируется автоматически при наличии better-sidebar, иначе используется плавающий пузырёк',
pluginBilingualReaderDesc: 'Читайте PDF в боковой панели DSH: нативный просмотр PDF, выделите текст для перевода с помощью LLM, используя контекст и полностью изолированно от основного разговора',
changesRedactOn: "Маскировка включена: секреты показываются как [REDACTED]; нажмите, чтобы отключить (на свой риск)",
changesRedactOff: "Маскировка выключена: содержимое показывается как есть; нажмите, чтобы включить",
changesRedactOnLabel: "Маскировка",
changesRedactOffLabel: "Без маскировки",
changesRedactBanner: "Замаскировано",
pluginSentinelName: 'dsh-sentinel Система пробуждения',
pluginEgoBrowserName: 'ego-browser Браузер агента',
pluginBetterOverleafName: 'dsh-better-overleaf Вкладка Overleaf',
pluginDocsPanelName: 'dsh-docs-panel Глобальные документы',
pluginFlowglassName: 'dsh-flowglass Flowglass',
pluginGitForgeName: 'dsh-git-forge Учётные данные Git',
pluginGitRemotesName: 'dsh-git-remotes Удалённые репозитории Git',
pluginGithubWorkbenchName: 'dsh-github-workbench Рабочая среда GitHub',
pluginSidebarQaName: 'dsh-sidebar-qa Выделить и спросить',
pluginSidenoteName: 'dsh-sidenote Боковой чат',
pluginServerDeckName: 'dsh-server-deck Панель серверов',
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
pluginSshTunnelName: 'dsh-ssh-tunnel SSH-туннель',
pluginTurnReviewName: 'dsh-turn-review Ревью раунда',
pluginBilingualReaderName: 'dsh-bilingual-reader Двуязычный ридер',
pluginOfficeName: 'Предпросмотр Office',
pluginMdExportName: 'Экспорт Markdown',
pluginCodeNavName: 'Навигатор предпросмотра кода',
pluginVideoPreviewName: 'Предпросмотр видео',
presetDshDesktopDesc: 'Расширенный режим Electron (без рамки): macOS резервирует 20px сверху; Windows без WCO резервирует 32px под строку заголовка',
}

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