feat: 适配 DSH 0.1.7(0.1.6-alpha.2 → 0.1.7-rc.1) - #748
Conversation
DSH 0.1.6-alpha.2 made two plugin-facing contracts stricter, and both broke
this plugin silently:
- `SidebarRightGuideEntry.id` is now required and unique per provider. The
native tab registrations sent guide entries without one, so the second kind
collided on `undefined` and `SidebarRightTabRegistry.register` threw
'duplicate guide entry id'. The throw happens inside `ctx.inject`'s callback
body, which cordis swallows, so the whole native surface stayed empty with no
symptom beyond a missing guide row.
- `conversation.chat.turnTail` changed from a `chain` to an additive `list`.
The chain's `select` made one entry render and let this plugin REPLACE the
host's produced-files row; a list cannot express that, and the registration
now throws 'list slot ... requires options.id'. Upstream chose the list
deliberately ('An additive list lets both plugins contribute without either
knowing the other's data or rendering'), so the plugin's row can only ever
duplicate the host's deliverables row -- which itself opens files through the
host's openFile, i.e. into this plugin's editor. The contribution is removed
and the row is the host's alone.
The file-opening half survives as `sidebar-file.ts` (the changes tab and the
editor host still use it), and `resolveSidebarPath` moves to the `paths.ts`
path-helper module beside its siblings. `registerNativeSurface` grows an
optional `reportFailure` so a future contract break reaches the visible
diagnostic strip instead of a silently empty guide, and one bad descriptor can
no longer take the remaining registrations down with it.
The peer floor had to move: semver's prerelease rule needs a comparator on the same [major,minor,patch] tuple, so `^0.1.5-rc.1` can never match any 0.1.6 prerelease. The floor becomes `^0.1.6-alpha.2` (which does admit 0.1.6 alpha/rc prereleases and excludes 0.1.5), and `engines.dsh` follows. This is a deliberate support-line break, matching how v0.19.0 dropped 0.1.2: 0.1.5-rc hosts stay on 0.19.1. Three upstream changes actually reach this plugin and are adapted here: - `IconSendOutline16` no longer exists in ui-primitives (only `IconSendOutline14`). - `TerminalBlockLabels` gained a required `noExitCode` pill text. - `ConnectionIndicator` dropped `reconnectLabel`: the outage label is now the one static label that already names the retry action, so the label copy moves there, matching the host's own '连接异常,点击立即重连'. ui-primitives still declares no `dependencies` while its bundle bare-imports more than it used to: `diff` and `simple-icons` were previously satisfied only transitively and had to be hoisted into devDependencies, exactly like the anser/shiki/katex group before them. `@deepseek-ai/dsh-code-runtime` is gone upstream (the package no longer exists) and leaves the dev tree. The e2e fallback npx spec is pinned rather than version-less: an unpinned `npx @deepseek-ai/dsh` resolves npm's `latest`, which is still the 0.1.5 line this plugin no longer supports.
DSH 0.1.6-alpha.2 ships a right-Sidebar terminal and a right-Sidebar browser of its own. Both were things this plugin had built from scratch, so keeping them meant two implementations of one surface plus a plugin that shadows a host type the product is actively developing. Terminal: the plugin's whole PTY stack goes — the UI-tab manager, the agent-owned registry, the lazy node-pty loader, the eight model-facing terminal_* tools, the xterm view and its font/link/scrollback helpers, and the cross-session 'pinned terminal' rail that existed only to keep a terminal alive across a session switch. The host's terminal kind becomes the only provider, which also retires the node-pty dependency, its pnpm allowBuilds entry, and the installer's -Repair mode (issue #140's whole class of 'node-pty failed to load' support). Model-facing capability note: the plugin used to inject terminal_* tools (off by default). Those are the model's only cross-call persistent terminal, and DSH's own equivalent (@deepseek-ai/dsh-tool-terminal) is not mounted by any shipped bundle, so removing them leaves the model with the one-shot bash/pwsh tools only. That is the accepted cost of not maintaining a second PTY surface; the README documents how to enable the upstream toolset. Browser: the plugin's browser tab type is dropped, so the host's own browser kind owns embedded pages. The DOM-level link takeover STAYS (it is the only thing that can route an https link to the system browser by protocol, which the host does not do) but now delegates to the host's browser tab via ctx.sidebarRight.openTab('browser', …) with a window.open fallback, and the two settings that only ever configured the plugin's own iframe (sandbox escape hatch, loopback allowlist) go with it — along with the host-side browser.probe route they fed. Cards: the empty-pane welcome cards adopt the host's guide-capsule recipe (0.5px l4 hairline, 24px radius, layer-1 fill, 56px floor, 14px/20px padding) so the plugin's 'pick what opens here' surface reads as the same design as DSH's own. A theme.spec guard pins the geometry and the token-only paint — including that the upstream-only --dsw-alias-bg-l1/-l2 spellings (defined nowhere, so their declarations are silently dropped upstream) never appear here. Also drops the dead state the removed features left behind (SidebarTab.pin, nextTerminal, agentWaits, bottomOpenedOnce, the agent-tab helpers, the pinned-tab memo key), re-points every test fixture that used them at surviving equivalents, and prunes the specs of the deleted features.
Adds the per-version adaptation record (docs/plans/2026-09-21-…-adaptation.md), rewrites AGENTS.md §3 for the new baseline (the two silent contract breaks, the terminal/browser handoff, the new official plugin-manager surface, the three ui-primitives breaks, the widened primitives bare-import set), and updates both READMEs and the integration guide for the v0.20.0 feature set. Also retires the support text the terminal deletion made false: the install flow is one step again (no build-script approval), the FAQ's two node-pty rows collapse into one pointing at DSH's own terminal, the platform note no longer promises prebuilt native binaries (there are no native deps left), and the installers drop the -Repair mode and their allowBuilds writes along with pnpm-workspace.yaml's now-empty allowBuilds block. Removes the dead browserNoSandboxWarning locale key (its setting went with the plugin's browser) and tightens theme.spec's capsule guard so a literal colour fails it. The e2e resolver now refuses a PATH that is not on the baseline line: this machine's PATH is 0.1.6-alpha.1, and running the mount lane against it fails at 'the bundle was not registered' with a zero exit from the CLI — a misleading dead end worth one explicit error instead.
DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).
Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
addressed by the plugin row's Loader entry id and read from the
module's exported Config. The preferences are merged into Config and
marked volatile — which requires DSH's own schemastery, because only
that build wraps a volatile field in the cosmokit reference the
loader's volatile commit walks. With the public package a settings
write reports success and revision 1 while the effective value never
changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
mode is render-time, not load-time. Five call sites that relied on the
old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
boundary injection now writes plugin:dsh-better-sidebar, the same kind
DSH's own v3->v4 migration produces, so historical threads survive.
Tool results moved to role 'tool' with top-level content/isError; all
four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
and the on-demand subagent-catalog observation API. The Tasks page
reads projectionsBySession and a new jobs.list route.
Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
spreadsheet worker, zoom, per-directory refresh); editor.canOpen
refuses those 26 extensions. Markdown, HTML (with its sandbox
switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
host, fixing a regression from the 0.1.6 round: the fallback pointed
at a 'browser' type the plugin no longer registers, so http links in
its own markdown did nothing.
Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
refreshSubagents do not exist on the current baseline, so the
topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
primitives call at module-evaluation time; and the e2e token exchange
needs Playwright's request stack, not Node's fetch.
The one-time import of a pre-0.1.7 `settings.yaml` section never ran. It
started from the `ctx.inject(['settings'], …)` callback, which fires the
moment the settings SERVICE appears — while the loader is still mounting
rows, so `describe()` does not list this plugin's own entry yet, the lookup
returns undefined, and the import bailed on "no form" without a trace.
Upstream's own migration waits (`ctx.root.loader.await()`); this does the
same.
That made every early return of the import invisible, which is how it went
unnoticed: the callback simply produced nothing. It now returns a named
outcome ('imported' / 'no-profile-home' / 'no-form' / 'already-configured' /
'no-legacy-section' / 'rejected') and the caller logs it — anomalies at warn,
the rest at info. The `loader` mirror's `await` is optional so a composition
without the cordis loader still reaches the import instead of aborting the
whole settings face inside the inject callback; the smoke fakes model it.
Verified on the live 3080 instance: after the upgrade the settings form
reports the user's real values back (autoOpenJobs false, workspaceFence
false, the nine-entry tabsEnabled map), where before it reported pure
defaults. The mount lane stays 7/7.
…ll short-circuit, dead disabled-plugins.json)
…s list 0.1.7 reads `package.json#icon` plus `locale/<lang>.json` to illustrate and label a plugin in the Plugins page and the `plugin_manager` list. The plugin had neither, so it rendered as a bare row (and every other third-party entry in this profile still does — ours is now the only one with a real mark). The icon follows the two icons DSH ships for this feature (packages/experimental/*/icon.svg): a 36x36, fill-only geometric mark with a blue gradient and no stroked outlines or text. It reads as a panel with a docked right rail — the sidebar this plugin is. The first cut used two blocks separated by a gap, but the rail shared the panel's light tone at the top, so the split leaned on a 3px gap that shrinks away at list size; a darker rail flush inside one panel survives any downscale. `locale/en.json` is the discovery baseline English needs to be present for the whole directory to be read — and `locale/zh.json` carries the Chinese title and blurb. The manifest's own `description` still advertised the terminal and the browser, which this plugin handed to DSH in 0.20.0; it is now user-visible as the fallback label, so it was corrected too. Every failure in this feature is silent: bad metadata only ever degrades to a plain text row. `tests/plugin-meta.spec.ts` therefore pins both ends — the manifest wiring and the packed tarball — plus the house style of the artwork, and was red/green verified (removing `icon.svg` from `files` fails it twice, once against the real tarball). Verified on the live 3080 instance: `readPluginMeta` resolves the icon to a `data:image/svg+xml` URI and the page renders it (`ourIconExact: 1`, no page errors).
一条交叉信息,可能影响本分支的客户端适配清单我在
为什么这比
|
| 包 | 硬依赖位置 |
|---|---|
@linxin666/dsh-remote-web-ui |
lib/types/client/index.js:30 export const inject = ['slots','locale','connection','settingsScope','remote'] |
dsh-context |
lib/client.js:11842 ctx.inject(["settingsScope"], …) |
dshmarket |
client/client.js:12179 ctx.inject(["settingsScope"], …) |
想确认的一点
本 issue 的清单里已经列了 settings.plugin.item 退役与 Icon* 改名,但没提 settingsScope。想请作者确认:本分支的 client 半区当前是仍以 settingsScope 作为 inject 依赖,还是已经改成运行期 ctx.get(...)?
如果是前者,建议和 ui-primitives 的改名一起并进本次适配,否则 0.1.7 用户会遇到和上面一样的 boot 失败——而且因为它是解析期失败,症状是"插件页整片报错",不是某个功能静默失效,比 748 里第 3 节那类静默问题更容易被误判成 DSH 本身的问题。
参考修法(ctx.get 是运行期解析,缺席时降级而不是挂起):把 settingsScope 从 inject 摘掉,改为
const binder = ctx.get('webUiSettings') ?? ctx.get('settingsScope'),没有则跳过设置卡片。
本评论由 DSH 助手代 @The-five-stooges 补充(用户明确要求代为提报)。
Two things the Plugins list made obvious once the icon rendered beside the
built-ins.
The artwork was flat: one blue panel, one darker blue rail. The built-ins
each carry their own two-hue palette — the file header says so outright
("these glyphs carry their own brand colors and gradients instead of riding
currentColor") — and at the 30px a row renders at, a single-hue mark reads as
a plain block. Three candidates were rendered next to the real built-in
artwork at the real sizes (30 / 36 / 72, light and dark) before picking one: a
violet rail sat too close to the panel's deep blue to read as a second colour,
and a pale teal panel washed out. The shipped pair is blue for the panel
(gradient) and teal-green for the rail (gradient), which stays legible at row
size and matches the Subagent artwork's proven blue/green combination without
sharing its silhouette.
The description sold the plugin as someone else's sidebar. It now leads with
what it is — better — in one sentence rather than a colon and a feature list,
in both locales, and both `package.json` (the npm blurb and the list's
fallback label) and `dsh.plugin.json` say the same thing. The README never
framed it that way, so nothing there changed.
The artwork guard gained the two properties this decision rests on: the mark
must be colourised (two gradients, no currentColor) and must not use a
CSS-only paint, since the icon ships as an <img> and the search artwork's
foreignObject technique would stay empty there. Red/green verified — a flat
single-hue redraw fails it.
Verified on the live 3080 instance: the row renders the new artwork and
"更好用的右侧边栏——文件树、编辑器、文件变动、任务和侧边对话都收在这一栏里,每个会话互不干扰。"
with no VSCode framing anywhere in the copy.
Move the support line from 0.1.7-alpha.1 to 0.1.7-rc.1 (peer floor `^0.1.7-rc.1`). 0.21.0-alpha.1 never reached npm, and once the code-card labels below are fixed the same build crashes on alpha.1 (its DiffBlock still reads the `labels.files` upstream deleted), so declaring only the host we verify against is the honest floor — and from rc.1 on the host enforces peers at boot anyway: a row whose `@deepseek-ai/dsh-*` peers do not satisfy the runtime is silently disabled. Dependencies: 28 devDependency pins and 14 peers move to 0.1.7-rc.1. rc.1 also raised the vendored runtime (cordis 4.0.4, schemastery 3.18.4, cordis-plugin-group 1.0.4, cordis-plugin-include 1.0.9) and publishes DSH cross-references as exact versions, so bumping schemastery/cordis alone left four unmet transitive peers — the three vendor packages are hoisted into devDependencies, which is what makes `pnpm peers check` clean again. One compile break: rc.1 added `CodeToolbarLabels` (codeLabel / wrapLabel / unwrapLabel, all required), made DiffBlockLabels / ReadBlockLabels extend it and deleted `DiffBlockLabels.files`. Fixing SideChatView then leaving markdown on the legacy banner would render two different code-card styles inside one side-chat view, so the plugin adopts the card everywhere: `markdownTextProps()` now passes `labels.code.toolbarLabels` with the host's own wording (codeBlock.title|wrap|unwrap, added to all 20 dictionaries; `sideChatBlockFiles` retires with the deleted footer). Adopting the card surfaced a silent regression the suite caught: the new toolbar shows `codeLabel` for languages the highlighter does not know, and mermaid has no shiki grammar, so the info string no longer reaches the DOM and the banner-text probe in mermaid.tsx matched nothing — mermaid fences stopped being swapped for diagrams. Detection now matches the FENCE BODY from the source that produced the render. `tests/market-manifest.spec.ts` pins every DSH peer as a floating caret range: an exact pin (the devDependency style) or a comparator without a prerelease would fail rc.1's preflight and silently stop the plugin from loading, and `pnpm peers check` cannot see that — it validates the resolved dev tree, not the range shape the host reads.
The alpha.1 handover refused 27 extensions so DSH's own document preview could own them, but nine of those have NO host renderer at all: `avif` and `ods` sit in `document/unviewable.ts`'s "known binary, no renderer" list and get the unsupported empty state directly, while `xlsb` / `xlt` / `xltx` / `xltm` / `ots` / `dot` / `dotx` fall through to the plain-text body, fail the binary check and land on the same dead end. Before that handover these files reached the plugin's `code` catch-all and rendered the binary-download pane, so refusing them was a user-visible regression we introduced ourselves — and rc.1 did not restore it. They are claimed again (`fods` stays handed over: the host shows that flat XML as plain text, which beats a download pane). `canOpen` had zero test coverage, which is exactly why the regression went unnoticed, so `tests/native-surface.spec.ts` now pins both directions: every format the host renders must be refused, every format it cannot render must be claimed. Reverting to the old list makes it fail with `macro.xlsb: expected false to be true`.
f618573 to
e314b43
Compare
New design record `docs/plans/2026-09-23-dsh-0.1.7-rc.1-adaptation.md` (method, the version-bump-only delta, the code-card break and the mermaid regression it exposed, the over-yield recovery, the live 3080 upgrade with its boot-log findings, and the evidence — including the note that the mount lane first ran against a stale tarball because `e2e_resolve_tarball` picks the newest existing one instead of packing). AGENTS.md §3 gains the rc.1 rules: the boot-time peer preflight (a new §3.4 item 12) with its four gotchas, the vendor hoist lesson, and the code-card/toolbarLabels contract. Two claims are corrected while we are here: the guide's four-entry description cap was attributed to rc.1 but `GuideBody.tsx` and the host's terminal guide entry are byte-identical across alpha.1 / alpha.2 / rc.1, and the icon surface is 25 named imports plus a type-only `IconProps`, not 26. README / README_EN / guide: version, badges, peer floor, the pinned-DSH advice (`alpha` is now 0.1.7-alpha.2 while rc.1 rides `next`), the reduced refusal list, and the correction that npm `latest` is 0.1.5-rc.3.
`tests/primitives-exports.spec.ts` collected its file list with
`execFileSync('rg', …)`. ripgrep is installed on a developer machine but not
on the CI runners, so the whole spec failed to COLLECT there
(`spawnSync rg ENOENT`, which is why `ci` and `ci-windows` were red on this
branch while `pnpm test` was green locally). Replaced with a plain readdir
walk over `src/` and `tests/` — no external binary, and the scan is a few
hundred small files.
Verified by running the spec with a failing `rg` shim first on PATH: it
passes, which it cannot do while still calling ripgrep.
75a008d to
819e0f2
Compare
|
Btw(仅供参考,不阻塞本 PR):第三方描述符的坏图标会把面板整体打成空白 顺带在 排查时撞见一幕:第三方描述符的 不知道值不值得顺手加一层守卫:取值处 try/catch + 校验,非法值退回无图标 / 默认标题(或给每个 chip、card 套个微型 error boundary),让坏描述符只废掉自己那一格;消费点在 |
Adds an explicit DSH-to-plugin table to the Installation section of both READMEs, because "which version do I install" now has more than one right answer: 0.21.0-rc.1 targets DSH 0.1.7-rc.1 and later, 0.19.1 is the cut for 0.1.6-alpha.2 and earlier, and the two 0.1.7 alphas have NO published plugin at all (0.21.0-alpha.1 never reached npm), so those hosts must move DSH to rc.1 first. The peer column is included on purpose: it is a range, not an exact pin — `^0.1.7-rc.1` already admits a later rc.2 or the stable 0.1.7, so upstream shipping one does not require a new plugin version. Pinning the peer to an exact 0.1.7-rc.1 would instead be actively harmful: DSH 0.1.7-rc.1's own boot preflight silently disables any plugin row whose `@deepseek-ai/dsh*` peers do not satisfy the running host.
|
供参考(可选,不阻塞本 PR):给描述符图标加一层共享守卫,坏 glyph 不再把面板打成空白 顺着上一条 Btw 试了一版,贴上来仅供参考。做法是把四处重复的取值逻辑收敛成一个共享守卫:工厂抛错就记一条日志并返回
一个 React 细节值得记一句:错误边界捕不到自己 render 输出里的非法元素类型(那是在边界自身的 reconcile 阶段抛的),所以守卫里多了一层 要不要按这个方向做由你定,需要的话我可以开 PR。 patch(基于 819e0f2)diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md
index 62125f0..ed75c91 100644
--- a/docs/external-plugin-guide.md
+++ b/docs/external-plugin-guide.md
@@ -30,7 +30,7 @@
| path 种子的去向(v0.19.2+) | `path` seed 的含义**跟随类型**:只有 `editor`(唯一认领 `dsh-resource://file/**` 的类型)把 path 转成资源地址打开(文件落在编辑器);**其余类型保留页面型打开**,path 随导航 params 落到合成记录的 `tab.path` 供组件消费——组件型 tab 的 path seed 不会被改道到文件编辑器(v0.19.0/0.19.1 上一切 path seed 都被改道,组件从未挂载,#632) |
| 终端(已交还宿主) | 插件**不再提供任何终端**:宿主 0.1.6 起自带 `ui-sidebar-terminal`(kind `terminal`),插件侧 PTY 栈与 `terminal_*` 工具整体删除。这里不再有「插件终端数量上限」这类语义 |
| 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `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` 接管页承载同一视图)。**本插件默认贡献 4 个 guide 条目**(文件 / 文件变动 / 任务管理 / 侧边对话,恰好在上限内),**但宿主的终端条目也占一行**——装了宿主终端即是 5 条,说明整列不渲染;要让说明回来,需在插件设置页关掉足够多的 tab 类型把总数压到 ≤ 4 条 |
+| 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title` + 图标取 `icon`(缺图标时宿主补一个方块占位;图标本身经共享守卫渲染,见 §4 的 `TabDescriptor.icon`——坏 glyph 只丢图标、不打崩面板),说明取可选的 `description`——**宿主只在 guide 列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明;未声明 `description` 的条目渲染成单行「图标 + 标题」(rc.1 起 `description` 回到宿主契约,但**宿主与插件都没有兜底句**,所以插件恢复字段而不恢复旧的通用句);`hidden: true` 的类型不占行。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图)。**本插件默认贡献 4 个 guide 条目**(文件 / 文件变动 / 任务管理 / 侧边对话,恰好在上限内),**但宿主的终端条目也占一行**——装了宿主终端即是 5 条,说明整列不渲染;要让说明回来,需在插件设置页关掉足够多的 tab 类型把总数压到 ≤ 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` 三个设置项同步删除(旧文档里的键会被忽略);**插件自带的终端与浏览器 tab 类型**(宿主 0.1.6/0.1.7 自带两者)与**只读文件预览**(image / pdf / Office / 表格,宿主 0.1.7 的 `ui-sidebar-documentpreview` 接手)同样不再内置,详见 §4.4 与 §5.4 |
@@ -192,7 +192,13 @@ interface TabDescriptor {
* 长得一样,纯噪音),条目就渲染成单行「图标 + 标题」。函数形式在渲染时求值,跟随语言。
*/
description?: string | (() => string)
- /** 图标:ReactNode 或 (size: number) => ReactNode(不声明时宿主补一个方块占位) */
+ /**
+ * 图标:ReactNode 或 (size: number) => ReactNode(不声明时宿主补一个方块占位)。
+ * 每个消费面(+ 菜单的空面板卡片、工作台 tab 条、原生芯片与 guide 胶囊、
+ * 设置页清单)都经共享守卫渲染(`src/client/descriptor-icon.tsx`):工厂抛错、
+ * 或返回一个 `type` 为 `undefined` 的元素(典型成因是读了一个被 DSH 升级改名的
+ * 宿主导出 → React error #130),都只丢掉这个 glyph,行与整个面板照常渲染。
+ */
icon?: ReactNode | ((size: number) => ReactNode)
/** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, sidechat=35 */
order?: number
diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx
index 321ab99..279868a 100644
--- a/src/client/SideCardSection.tsx
+++ b/src/client/SideCardSection.tsx
@@ -61,6 +61,7 @@ import {
import { api } from './api.ts'
import { parsePrefs } from './prefs.ts'
import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx'
+import { descriptorIcon } from './descriptor-icon.tsx'
import { t } from './locales.ts'
import { parseDesktopEnv } from './desktop-env.ts'
import { getShellPreset, getShellPresets } from './shell-presets.ts'
@@ -97,12 +98,6 @@ function textOf(value: string | (() => string) | undefined): string {
return typeof value === 'function' ? value() : value
}
-/** Resolve a descriptor icon (ReactNode or size function). */
-function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
- if (icon === undefined) return null
- return typeof icon === 'function' ? icon(size) : icon
-}
-
/** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
@@ -415,7 +410,7 @@ function SelectMenu(props: {
onClick={() => { setOpen(now => !now) }}
>
{!multi && hasIcons && selected[0] !== undefined && (
- <span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
+ <span className={css.selectAnchorIcon}>{descriptorIcon(selected[0].icon, 16)}</span>
)}
<span className={css.selectAnchorText}>
{selected.length === 0 ? (placeholder ?? '—') : selected.map(option => textOf(option.title)).join(', ')}
@@ -433,7 +428,7 @@ function SelectMenu(props: {
label: hasIcons
? (
<span className={css.selectOption}>
- <span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
+ <span className={css.selectOptionIcon}>{descriptorIcon(option.icon, 24)}</span>
<span className={css.selectOptionText}>
<span className={css.title}>{textOf(option.title)}</span>
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
@@ -914,7 +909,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
{renderCard({
title: textOf(tab.title),
desc: tab.id,
- icon: iconOf(tab.icon, 16),
+ icon: descriptorIcon(tab.icon, 16),
enabled: prefs.tabsEnabled[tab.id] !== false,
onToggle: (next) => { onToggleTab(tab.id, next) },
// The settings gear only while the feature is enabled: its
@@ -956,7 +951,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
{renderCard({
title: textOf(viewer.title) || viewer.id,
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
- icon: iconOf(viewer.icon, 16),
+ icon: descriptorIcon(viewer.icon, 16),
enabled: prefs.viewersEnabled[viewer.id] !== false,
onToggle: (next) => { onToggleViewer(viewer.id, next) },
onOpenSettings: prefs.viewersEnabled[viewer.id] !== false && hasSettings(viewer)
diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx
index dd6a954..9cd2059 100644
--- a/src/client/Sidebar.tsx
+++ b/src/client/Sidebar.tsx
@@ -49,6 +49,7 @@ import { useCenterColumn } from './sidebar/use-center-column.ts'
import { useHostFeeds } from './sidebar/use-host-feeds.ts'
import { mountedSessions } from './native/surface.ts'
import type { TabDragPayload } from './TabBar.tsx'
+import { descriptorIcon } from './descriptor-icon.tsx'
import { t } from './locales.ts'
import { api } from './api.ts'
import css from './sidebar.module.css'
@@ -593,7 +594,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor === undefined) return null
- return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
+ return descriptorIcon(descriptor.icon, 14)
}
/**
diff --git a/src/client/descriptor-icon.tsx b/src/client/descriptor-icon.tsx
new file mode 100644
index 0000000..972012e
--- /dev/null
+++ b/src/client/descriptor-icon.tsx
@@ -0,0 +1,81 @@
+/**
+ * Guarded rendering of a descriptor's declared glyph.
+ *
+ * `ctx.betterSidebar` is a public service, so a tab/viewer descriptor's `icon`
+ * is foreign code. A third-party plugin that reads a host export a DSH upgrade
+ * renamed gets `undefined` back and hands us an element whose `type` is
+ * `undefined` — React error #130 at render time (issue: `@modusensus/dsh-mneme`
+ * still reads `primitives.IconArchiveOutline20`, which DSH 0.1.7 renamed). The
+ * consumption sites — the `+` menu's empty-pane cards, the workbench tab strip,
+ * the native chip and guide capsule, the settings inventory — all sit OUTSIDE
+ * the per-tab `RenderBoundary`, so a single bad glyph used to blank the whole
+ * panel ("the sidebar opens but nothing works").
+ *
+ * Contain it where it enters the tree: a throwing factory and a crashing node
+ * both degrade to no glyph, and the row survives without it.
+ *
+ * This mirrors the treatment file-icon factories already get (the `safeIcon`
+ * helper in service.ts) and the issue-#31 rule that a foreign registration
+ * must never take a surface down.
+ */
+import { Component, type ErrorInfo, type ReactNode } from 'react'
+
+/** The icon shape a descriptor declares: a node, or a size factory. */
+export type DescriptorIcon = ReactNode | ((size: number) => ReactNode) | undefined
+
+/**
+ * Catches a crashing glyph and renders nothing in its place.
+ *
+ * Deliberately permanent for the mounted position (same shape as
+ * `RenderBoundary`, minus the retry affordance): a broken glyph stays hidden
+ * until the row remounts, instead of re-crashing on every parent render.
+ */
+class IconGuard extends Component<{ children?: ReactNode }, { failed: boolean }> {
+ state = { failed: false }
+
+ static getDerivedStateFromError(): { failed: boolean } {
+ return { failed: true }
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo): void {
+ console.error('[dsh-better-sidebar] descriptor icon render error:', error, info.componentStack)
+ }
+
+ render(): ReactNode {
+ return this.state.failed ? null : this.props.children
+ }
+}
+
+/**
+ * One level of indirection between the guard and the foreign node — and it has
+ * to be exactly that. An error boundary does NOT catch an invalid element type
+ * in its own render output: React throws while reconciling the boundary's own
+ * children, i.e. during the boundary's render, and only an ANCESTOR boundary
+ * would see it. Rendering the glyph through a component that sits BELOW the
+ * guard moves the crash into a descendant's render, which the guard does catch.
+ */
+function Glyph({ children }: { children?: ReactNode }): ReactNode {
+ return children
+}
+
+/**
+ * Resolve one descriptor glyph at `size`, guarded end to end.
+ * @param icon - the descriptor's declared icon (node or size factory).
+ * @param size - pixel size handed to a factory.
+ * @returns the glyph inside the guard, or null when the descriptor declares none.
+ */
+export function descriptorIcon(icon: DescriptorIcon, size: number): ReactNode {
+ if (icon === undefined || icon === null) return null
+ let glyph: ReactNode
+ try {
+ glyph = typeof icon === 'function' ? (icon as (size: number) => ReactNode)(size) : icon
+ } catch (error) {
+ console.error('[dsh-better-sidebar] descriptor icon factory error:', error)
+ return null
+ }
+ return (
+ <IconGuard>
+ <Glyph>{glyph}</Glyph>
+ </IconGuard>
+ )
+}
diff --git a/src/client/native/index.ts b/src/client/native/index.ts
index c126034..c850696 100644
--- a/src/client/native/index.ts
+++ b/src/client/native/index.ts
@@ -23,6 +23,7 @@
* absent from the native guide and `openTab` refuses it.
*/
import type { Context } from '../../context-types.ts'
+import { descriptorIcon } from '../descriptor-icon.tsx'
import { t } from '../locales.ts'
import { parseFileAddress } from '../resource-address.ts'
import type { BetterSidebarService, TabDescriptor } from '../service.ts'
@@ -129,12 +130,17 @@ function guideDescriptionOf(descriptor: TabDescriptor | undefined): { descriptio
/**
* The guide row's glyph for a descriptor icon (nothing when it has none).
* The native guide renders `entry.icon`, so a takeover registered without one
- * is the only row in the list with a blank leading slot.
+ * is the only row in the list with a blank leading slot. The node rides the
+ * shared guard: the host paints it inside OUR element tree, so a third-party
+ * glyph that would throw at render leaves the capsule without an icon instead
+ * of taking the guide down (descriptor-icon.tsx).
* @param icon - the descriptor's icon value.
* @returns the guide-entry icon fields, or an empty object.
*/
function guideIconOf(icon: TabDescriptor['icon']): { icon?: (props: { size?: number }) => unknown } {
- return typeof icon === 'function' ? { icon: (props: { size?: number }) => icon(props.size ?? 16) } : {}
+ return typeof icon === 'function'
+ ? { icon: (props: { size?: number }) => descriptorIcon(icon, props.size ?? 16) }
+ : {}
}
/**
diff --git a/src/client/native/tab-adapter.tsx b/src/client/native/tab-adapter.tsx
index 89bfa21..89cc62c 100644
--- a/src/client/native/tab-adapter.tsx
+++ b/src/client/native/tab-adapter.tsx
@@ -26,6 +26,7 @@ import type { Context } from '../../context-types.ts'
import type { SessionScope } from '../api.ts'
import { RenderBoundary } from '../RenderBoundary.tsx'
import { OrphanedTab } from '../OrphanedTab.tsx'
+import { descriptorIcon } from '../descriptor-icon.tsx'
import { referenceInChat } from '../reference-in-chat.ts'
import type { BetterSidebarService } from '../service.ts'
import type { SidebarStore, SidebarTab, TabType } from '../state.ts'
@@ -372,9 +373,7 @@ export function NativeTabTitle(props: NativeTitleInjected & NativeBodyFrameworkP
const icon = path !== undefined && descriptorId === EDITOR_KIND
? service.fileIcon(path, CHIP_ICON_SIZE)
: undefined
- const glyph = icon ?? (typeof descriptor?.icon === 'function'
- ? descriptor.icon(CHIP_ICON_SIZE)
- : descriptor?.icon)
+ const glyph = icon ?? descriptorIcon(descriptor?.icon, CHIP_ICON_SIZE)
if (glyph === undefined || glyph === null) return title
return (
<>
diff --git a/src/client/service.ts b/src/client/service.ts
index 59f1e79..e85b73f 100644
--- a/src/client/service.ts
+++ b/src/client/service.ts
@@ -174,6 +174,13 @@ export interface TabDescriptor {
* the active locale.
*/
description?: string | (() => string)
+ /**
+ * Glyph shown for this type (`+` menu, tab strip, native chip and guide
+ * capsule, settings inventory). Every consumer renders it through the shared
+ * guard (descriptor-icon.tsx): a factory that throws, or a node whose `type`
+ * is `undefined` (the classic cause being a host export that a DSH upgrade
+ * renamed — React error #130), costs only this glyph instead of the panel.
+ */
icon?: ReactNode | ((size: number) => ReactNode)
/** + menu sort order (ascending); default 100. */
order?: number
diff --git a/src/client/sidebar/TabContent.tsx b/src/client/sidebar/TabContent.tsx
index 9a729f3..56b8c51 100644
--- a/src/client/sidebar/TabContent.tsx
+++ b/src/client/sidebar/TabContent.tsx
@@ -10,6 +10,7 @@ import type { SidebarState, SidebarStore, SidebarTab } from '../state.ts'
import type { SessionScope } from '../api.ts'
import { OrphanedTab } from '../OrphanedTab.tsx'
import { RenderBoundary } from '../RenderBoundary.tsx'
+import { descriptorIcon } from '../descriptor-icon.tsx'
import { tabContentCompare, type TabContentMemoKey } from '../tab-content-memo.ts'
import type { NewTabOption } from '../TabBar.tsx'
import css from '../sidebar.module.css'
@@ -65,6 +66,6 @@ export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: Ses
label: typeof d.title === 'function' ? d.title() : d.title,
disabled: !(d.available?.(ctx, scope, state) ?? true),
// 14 matches the compact + menu's icon slot (compactList .itemIcon).
- icon: typeof d.icon === 'function' ? d.icon(14) : d.icon,
+ icon: descriptorIcon(d.icon, 14),
}))
}
diff --git a/tests/descriptor-icon.spec.tsx b/tests/descriptor-icon.spec.tsx
new file mode 100644
index 0000000..e2c5155
--- /dev/null
+++ b/tests/descriptor-icon.spec.tsx
@@ -0,0 +1,125 @@
+/**
+ * Descriptor-icon guard spec.
+ *
+ * `ctx.betterSidebar` is a public service, so a descriptor's `icon` is foreign
+ * code. A third-party plugin that reads a host export a DSH upgrade renamed
+ * gets `undefined` back and hands over an element with `type: undefined`
+ * (React error #130) — the real case: `@modusensus/dsh-mneme@0.8.6` still
+ * reads `primitives.IconArchiveOutline20`, which DSH 0.1.7 renamed to
+ * `IconArchiveOutlineRegular`/`…Medium` with no alias.
+ *
+ * Before this guard that crash landed in `PaneEmptyCards`, the tab strip, the
+ * native chip and the settings inventory — all OUTSIDE the per-tab
+ * RenderBoundary — so one bad glyph blanked the whole panel. These cases pin
+ * the containment: the row survives without its icon, the factory throw is
+ * logged, and nothing escapes to an ancestor boundary.
+ */
+// @vitest-environment jsdom
+import { describe, expect, it, vi } from 'vitest'
+import { createElement, type ReactNode } from 'react'
+import { descriptorIcon } from '../src/client/descriptor-icon.tsx'
+import { createBetterSidebarService } from '../src/client/service.ts'
+import { buildNewTabOptions } from '../src/client/sidebar/TabContent.tsx'
+import { createSidebarStore, makeDefaultState } from '../src/client/state.ts'
+import type { Context } from '../src/context-types.ts'
+import { renderRoot, setupReactAct } from './test-utils.ts'
+
+setupReactAct()
+
+/** A minimal fake of the empty-pane card that used to blow up: label + glyph. */
+function Card(props: { icon: ReactNode }): ReactNode {
+ return createElement('button', { type: 'button' }, props.icon, createElement('span', null, 'label'))
+}
+
+describe('descriptorIcon', () => {
+ it('returns null for a descriptor that declares no icon', () => {
+ expect(descriptorIcon(undefined, 14)).toBeNull()
+ expect(descriptorIcon(null, 14)).toBeNull()
+ })
+
+ it('resolves a size factory and a plain node alike', () => {
+ const factory = vi.fn((size: number) => createElement('i', { 'data-size': size }))
+ const rendered = renderRoot(createElement(Card, { icon: descriptorIcon(factory, 14) }))
+ expect(factory).toHaveBeenCalledWith(14)
+ expect(rendered.container.querySelector('i')?.getAttribute('data-size')).toBe('14')
+
+ rendered.rerender(createElement(Card, { icon: descriptorIcon(createElement('i', null), 14) }))
+ expect(rendered.container.querySelector('i')).not.toBeNull()
+ rendered.unmount()
+ })
+
+ it('drops the glyph when the factory throws (and says why)', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const boom = (): ReactNode => { throw new Error('third-party icon exploded') }
+ const rendered = renderRoot(createElement(Card, { icon: descriptorIcon(boom, 14) }))
+ // The row survives: only the glyph is missing.
+ expect(rendered.container.textContent).toBe('label')
+ expect(error).toHaveBeenCalledWith(
+ '[dsh-better-sidebar] descriptor icon factory error:',
+ expect.any(Error),
+ )
+ rendered.unmount()
+ error.mockRestore()
+ })
+
+ it('contains an element whose type is undefined instead of crashing the panel', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ // The exact third-party shape: a renamed host export reads as undefined.
+ const missingHostExport = undefined as unknown as (props: Record<string, unknown>) => ReactNode
+ const rendered = renderRoot(createElement(Card, {
+ icon: descriptorIcon(() => createElement(missingHostExport), 14),
+ }))
+ // The whole card is still there — the crash was contained to the glyph.
+ expect(rendered.container.textContent).toBe('label')
+ expect(error).toHaveBeenCalledWith(
+ '[dsh-better-sidebar] descriptor icon render error:',
+ expect.any(Error),
+ expect.anything(),
+ )
+ rendered.unmount()
+ error.mockRestore()
+ })
+
+ it('contains an invalid element nested inside a wrapper node', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const missingHostExport = undefined as unknown as (props: Record<string, unknown>) => ReactNode
+ const rendered = renderRoot(createElement(Card, {
+ icon: descriptorIcon(createElement('span', null, createElement(missingHostExport)), 14),
+ }))
+ expect(rendered.container.textContent).toBe('label')
+ rendered.unmount()
+ error.mockRestore()
+ })
+})
+
+/**
+ * The real funnel, not just the helper: a third-party tab registered through
+ * the public service reaches the `+` menu through `buildNewTabOptions`. Before
+ * the guard this option carried the raw invalid element, so the empty-pane card
+ * that renders it blew up the whole panel; now the option is pre-guarded and
+ * the row renders without its glyph.
+ */
+describe('buildNewTabOptions with a third-party descriptor', () => {
+ it('gives the menu a guarded glyph instead of an invalid element', () => {
+ const store = createSidebarStore()
+ const service = createBetterSidebarService(store)
+ const missingHostExport = undefined as unknown as (props: Record<string, unknown>) => ReactNode
+ service.registerTab({
+ id: 'third-party:broken',
+ title: 'Broken',
+ icon: () => createElement(missingHostExport, { size: 14 }),
+ component: () => null,
+ })
+ const ctx = { get: (name: string) => (name === 'betterSidebar' ? service : undefined) } as unknown as Context
+ const options = buildNewTabOptions(makeDefaultState(), ctx, { sessionId: 's1' })
+ const broken = options.find(option => option.id === 'third-party:broken')
+ expect(broken?.icon).not.toBeNull()
+
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const rendered = renderRoot(createElement(Card, { icon: broken?.icon ?? null }))
+ // The card survives with its label; only the glyph is gone.
+ expect(rendered.container.textContent).toBe('label')
+ rendered.unmount()
+ error.mockRestore()
+ })
+}) |
概述
把插件从「仅支持 DSH
^0.1.6-alpha.2」迁到「仅支持 DSH^0.1.7-alpha.1」,并按用户拍板的取舍落地。本分支同时承载 0.1.6-alpha.2 适配(此前只提交在本分支、未开 PR、未发版)与本次 0.1.7-alpha.1 → 0.1.7-rc.1 适配。0.20.0→0.21.0-rc.1(含-→ release.yml 走alphadist-tag)^0.1.6-alpha.2→^0.1.7-rc.1;@deepseek-ai/cordis^4.0.2→^4.0.4latest现为 0.19.1)标签区间
dsh-v0.1.6-alpha.2→dsh-v0.1.7-alpha.1是 1299 个 commit / 4754 个文件;dsh-v0.1.7-alpha.1→dsh-v0.1.7-rc.1是 318 个 commit / 1361 个文件(中间跨过一个未适配的0.1.7-alpha.2)。追加:0.1.7-alpha.1 → 0.1.7-rc.1
承接上面的 alpha.1 适配,把支持线推到 0.1.7-rc.1。这一版是发行候选收敛版:绝大部分「变更文件」只是
package.json的版本号与workspace:^→workspace:*\|~改写,ui-sidebar-right/ui-slots/ui-settings/dsh-settings与其 loader、webserver+client/connection、packages/session全线(会话格式仍是 v4)、llm的MessageSourceMap、subagent、客户端 externals 白名单、util/workspace-path、plugin-artwork与package-meta的src/逐字未变。CodeToolbarLabels(codeLabel/wrapLabel/unwrapLabel三个必填),让DiffBlockLabels/ReadBlockLabels继承它,并删除了DiffBlockLabels.files。修完SideChatView后如果让 markdown 继续用旧 banner,同一个侧边对话视图里会出现两种代码卡风格,所以全面采用:markdownTextProps()也传labels.code.toolbarLabels,措辞照抄宿主(codeBlock.title\|wrap\|unwrap,进 20 份词典;sideChatBlockFiles随被删的页脚一起退役)。codeLabel,而 mermaid 正是没有 shiki grammar 的语言——info string 不再进 DOM,于是mermaid.tsx那条「读 banner 文本是不是mermaid」的探测恒假,mermaid 围栏不再被换成图。改为按围栏正文匹配。app-boot/src/plugin-compatibility.ts):profile 行里凡是@deepseek-ai/dsh*的 peer 不满足semver.satisfies(宿主版本, range, { includePrerelease: true }),整行静默 disabled(只在 stderr 留一行),peerDependenciesMeta.optional不豁免,豁免只能写compatibility.json。因此 peer 一律写浮动^范围、绝不钉精确版本,新增tests/market-manifest.spec.ts守护——pnpm peers check看不到这个失败模式。~(cordis 4.0.4 / schemastery 3.18.4 / cordis-plugin-group 1.0.4 / cordis-plugin-include 1.0.9)。只抬 schemastery 与 cordis 不够——后两个是dsh-app-boot/dsh-config-editor的传递 peer,pnpm 会沿用 lockfile 旧副本,pnpm peers check一次报四类;按既有做法把三个 vendor 包显式钉进 devDependencies 才收敛。avif/ods落在unviewable.ts的「已知二进制、无渲染器」表,xlsb/xlt/xltx/xltm/ots/dot/dotx落回 text 兜底后按二进制判定失败),点开只有一句「暂不支持预览」;而它们在让出之前是走插件下载面板的。现已收回(fods继续让出:宿主会用纯文本显示那段扁平 XML)。canOpen此前零测试覆盖,正是回归没被发现的原因,补了双向守护。证据:
pnpm peers check/typecheck/lint/build/check:consumer-types全 0 退出码;pnpm test= 1132 passed | 9 skipped;pnpm test:mount在真实@deepseek-ai/dsh@0.1.7-rc.1上 7/7;真机 3080 已升到 rc.1 + 插件 0.21.0-rc.1,插件挂载、零 pageerror、浏览器实际加载的 bundle 含新标签且不含已删的旧键,设置表单 15 个偏好键(含用户自己的pinned: ["vscode"])完整保留,volatile 提交在 schemastery 3.18.4 上翻值 → revision 递增 → 读回新值仍然成立。硬破坏(不修就崩或静默失效)
1.
dsh-settings整体重写 —— 本版最大的一处SettingsProvider.register(ns, schema)被删除,只剩SettingsForms的describe/update/replace/mutate/configure;表单按插件 Loader 行的entry.options.id寻址,schema 取自entry.fiber.runtime.Config。原来那条
register调用抛出的TypeError被 cordis 吞掉,表现为:设置页只剩默认值、agentOpenTools永远打不开、aionui-panel互斥失效——没有任何报错。适配:
Config,每个字段标为 volatile;ownEntryId(ctx)运行时自发现行 id(聚合包会用别的 id 挂同一个包,不能硬编码)。configure({ auto: false }, ctx.fiber)关掉原生自动表单,避免同一批字段渲染两遍。settings.yaml改名成.imported后按「同名 section → 同名 entry id」导入,而插件的 section 键是包名、行 id 是better-sidebar,导入必然 warn 失败、用户设置全丢。插件现在自己读那个段(按当前 schema 过滤掉已删字段)回填,且只在该行还没有用户值时执行。最关键的一处:schemastery 必须换成
@deepseek-ai/schemastery。meta.volatile只是标记;DSH 的构建额外在解析期把 volatile 字段包成 cosmokitVolatile引用,而 Loader 的_commitVolatile提交的正是那些引用。用公开的schemastery包时,settings.update返回 200、revision也递增,但有效值永远不变——每一次改设置都被静默丢弃(真机症状:--dsh-e2e-marker为空、title-bar 方案不生效)。附带第二个坑:.volatile()经extra()返回副本而非原地修改,写成for (const f of ...) f.volatile()会丢掉标记,宿主随即报Plugin entry "better-sidebar" has no volatile fields。2.
ui-primitives图标整族改名Icon<Name><14|16>→Icon<Name>Regular/…Medium(具名导出 81 → 184,旧尺寸后缀名一个不剩)。插件 26 个具名导入消失、17 个文件受影响。undefined,React 抛Element type is invalid;tsc会直接报no exported member。src/client/icons.tsx里 13 个插件自绘的同风格 glyph 被正确排除(全局替换会误伤)。IconXxx14默认 14 →XxxRegular默认 16,5 处原先依赖默认值的调用点已显式传size={14}。tests/primitives-exports.spec.ts守护(此前测试对图标名零断言),并做过红/绿验证。3. 会话格式 v3 → v4
source.kind === 'plugin'被硬拒。改用plugin:dsh-better-sidebar——与 DSH 自己的 v3→v4 迁移对历史行产出的 kind 逐字一致,所以历史 sidechat 会话不会丢。role: 'tool'+ 顶层content/isError/toolCallId。插件 4 个解析器原先靠type === 'tool-result'命中,在新形状下恒返回空串,工具输出 / jobs 回放 / 转录 / 文件变动四处同时静默空白。现在四处都同时接受新旧形状。4. 客户端会话快照换字段
subagentsByParent/jobsBySession→projectionsBySession,且按需观察 APIsetSubagentCatalogOpen被整体删除(0.1.7 改为全量加载投影,那套机制应当删除而非改名)。后台任务列表没有替代字段,新增插件路由jobs.list。同一版ctx.jobs.*的 caller 从Agent变成SessionId。让出与收敛(用户拍板)
ui-sidebar-documentpreview长齐了 code / excel / office(宿主侧转 PDF) / pdf / image / html / markdown / text,带缩放与按目录自动刷新。插件删掉自己的image/pdf/binary-download,并在editor.canOpen里拒绝认领 26 个扩展名。保留 markdown、html(含宿主没有的两个沙箱开关)、以及codecatch-all 的可编辑 + 保存能力。urlTarget」,其余放行给宿主。这修掉了上一轮自己引入的回归:兜底仍指向插件已删除的'browser'类型,而service.openTab对未注册类型静默return,于是插件自绘 markdown 里的 http 链接点了没反应。fileskind,宿主自己的 watch 覆盖不到,所以自带一套(150ms 去抖、64 句柄上限、沿用同一道 workspace fence)。顺带修掉的既有缺陷
ctx.sessions.list的current字段在两个 tag 里都不存在(插件自造了它,tsc 一直放行)onScreen恒 false、store.setSession恒收到undefined(按会话持久化实际没绑上)、窄视口 park 门控恒假sessions.openSubagent/open/refreshSubagents在当前基线不存在package.json.files漏lib/client-locale.jswindowshim 缺addEventListenerservice.ts的 spec 一个用例都跑不起来fetchSet-Cookie,会话播种失败;改用 Playwrightrequest.newContext()验证
门禁全绿:
pnpm peers checkpnpm typecheckpnpm lintpnpm check:consumer-typespnpm testpnpm buildclient-editor.js/client-mermaid.js/client-locale.jspnpm test:mount(真实@deepseek-ai/dsh@0.1.7-rc.1scratch profile)pnpm test挂载 lane 覆盖:插件在真实宿主挂载且无 pageerror、原生 guide 逐条打开(插件 4 条 + 宿主 terminal 恰好 1 条、browser 断言缺席)、完整 tab 扫荡、两条设置写入往返(
--dsh-e2e-marker与 title-bar 方案)、以及 perf lane。运行轨迹里可见新增的/sidebar/api/jobs.list被正常轮询。三处反证(比正向通过更有说服力):
builtins.spec.ts用 26 个扩展名逐条断言editor不认领,同时断言 md/html/代码仍然认领。urlTarget时defaultPrevented === false且不调用任何打开函数」与「命中但被禁用时走window.open兜底」。文档
AGENTS.md§2/§3/§6 重写为 0.1.7 基线(§3 分为硬破坏 / 让出收敛 / 既有缺陷 / 宿主契约四节)。README.md/README_EN.md/docs/external-plugin-guide.md同步;并修正了「v0.20.0 已是 npm latest」这一与事实不符的表述(实测latest是 0.19.1)。需要 reviewer 注意
browser的 e2e 断言方向变了:从「必须恰好 1 条」改成「必须 0 条」,因为宿主自己在 Web profile 不再挂载该包。这是钉住新真值,不是放宽。dependencies新增@deepseek-ai/schemastery(替换公开schemastery):这是正确性要求而非风格选择,理由见上;市场受管约束(禁cordis、禁生命周期脚本)未受影响。bash/pwsh;上游@deepseek-ai/dsh-tool-terminal仍未被任何 shipped bundle 默认挂载,需要时自行在 profile 启用。mode: 'unknown'不打印模式词(词典无对应 key)。