Skip to content

feat: 适配 DSH 0.1.7(0.1.6-alpha.2 → 0.1.7-rc.1) - #748

Merged
Menghuan1918 merged 14 commits into
mainfrom
feat/dsh-0.1.7-alpha.1
Sep 23, 2026
Merged

Menghuan1918 merged 14 commits into
mainfrom
feat/dsh-0.1.7-alpha.1

Conversation

@Menghuan1918

@Menghuan1918 Menghuan1918 commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

概述

把插件从「仅支持 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 走 alpha dist-tag)
  • peer 下限:^0.1.6-alpha.2 → ^0.1.7-rc.1;@deepseek-ai/cordis ^4.0.2 → ^4.0.4
  • 支持线:0.1.6-alpha.2 及更早停留在 v0.19.1(0.20.0 从未发布到 npm;npm latest 现为 0.19.1)

下限必须动:semver 的预发布规则要求比较子与候选版本同 [major,minor,patch] 元组,^0.1.6-alpha.2 在数学上永远容纳不下任何 0.1.7-* 预发布版(实测对 ^0.1.6-alpha.2 / >=0.1.6-alpha.2 全为 false)。

标签区间 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/ 逐字未变。

  1. 唯一的编译破坏 —— 宿主的代码卡:rc.1 新增 CodeToolbarLabels(codeLabel / wrapLabel / unwrapLabel 三个必填),让 DiffBlockLabels / ReadBlockLabels 继承它,并删除了 DiffBlockLabels.files。修完 SideChatView 后如果让 markdown 继续用旧 banner,同一个侧边对话视图里会出现两种代码卡风格,所以全面采用:markdownTextProps() 也传 labels.code.toolbarLabels,措辞照抄宿主(codeBlock.title\|wrap\|unwrap,进 20 份词典;sideChatBlockFiles 随被删的页脚一起退役)。
  2. 采用新卡带出一个静默回归(测试当场抓到):新工具条对没有语法高亮的语言显示 codeLabel,而 mermaid 正是没有 shiki grammar 的语言——info string 不再进 DOM,于是 mermaid.tsx 那条「读 banner 文本是不是 mermaid」的探测恒假,mermaid 围栏不再被换成图。改为按围栏正文匹配。
  3. rc.1 新增启动期 peer 预检(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 看不到这个失败模式。
  4. 依赖面成套搬:rc.1 把包内 DSH 引用发布成精确版本、vendor 用 ~(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 才收敛。
  5. 顺手修掉一处我们上一版自己引入的回归:alpha.1 让出的 27 个扩展名里,有 9 个宿主根本没有渲染器(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) 关掉原生自动表单,避免同一批字段渲染两遍。
  • 一次性 legacy 迁移:0.1.7 把 settings.yaml 改名成 .imported 后按「同名 section → 同名 entry id」导入,而插件的 section 键是包名、行 id 是 better-sidebar,导入必然 warn 失败、用户设置全丢。插件现在自己读那个段(按当前 schema 过滤掉已删字段)回填,且只在该行还没有用户值时执行。

最关键的一处:schemastery 必须换成 @deepseek-ai/schemastery。meta.volatile 只是标记;DSH 的构建额外在解析期把 volatile 字段包成 cosmokit Volatile 引用,而 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 个文件受影响。

  • 失败模式是 render 期而非 load 期:client bundle 是 CJS + external,缺导出读成 undefined,React 抛 Element type is invalid;tsc 会直接报 no exported member。
  • 改名集合是从实际 import 语句算出来的,因此 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 会话不会丢。
  • tool 结果消息改为 role: 'tool' + 顶层 content/isError/toolCallId。插件 4 个解析器原先靠 type === 'tool-result' 命中,在新形状下恒返回空串,工具输出 / jobs 回放 / 转录 / 文件变动四处同时静默空白。现在四处都同时接受新旧形状。

4. 客户端会话快照换字段

subagentsByParent / jobsBySession → projectionsBySession,且按需观察 API setSubagentCatalogOpen 被整体删除(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(含宿主没有的两个沙箱开关)、以及 code catch-all 的可编辑 + 保存能力。
  • 宿主 browser kind 只在 desktop profile 挂载,Web lane 现在显式断言它缺席。
  • 外链接管收敛为「只认领 urlTarget」,其余放行给宿主。这修掉了上一轮自己引入的回归:兜底仍指向插件已删除的 'browser' 类型,而 service.openTab 对未注册类型静默 return,于是插件自绘 markdown 里的 http 链接点了没反应。
  • 文件树新增实时刷新:插件接管了 files kind,宿主自己的 watch 覆盖不到,所以自带一套(150ms 去抖、64 句柄上限、沿用同一道 workspace fence)。

顺带修掉的既有缺陷

缺陷 症状
ctx.sessions.list 的 current 字段在两个 tag 里都不存在(插件自造了它,tsc 一直放行) onScreen 恒 false、store.setSession 恒收到 undefined(按会话持久化实际没绑上)、窄视口 park 门控恒假
sessions.openSubagent / open / refreshSubagents 在当前基线不存在 拓扑卡片点击、跳回主会话、Retry 三个按钮全是 no-op
package.json.files 漏 lib/client-locale.js locale chunk 从来没进过 npm tarball
测试 harness 的 window shim 缺 addEventListener 0.1.7 的 primitives 在模块求值期就调用它,任何 import service.ts 的 spec 一个用例都跑不起来
e2e token 交换走 Node 全局 fetch 宿主静态 fallback 回空体 405 + 无 Set-Cookie,会话播种失败;改用 Playwright request.newContext()

验证

门禁全绿:

门禁 结果
pnpm peers check No peer dependency issues found
pnpm typecheck 通过
pnpm lint 通过
pnpm check:consumer-types 通过
pnpm test 112 files / 1124 passed / 9 skipped
pnpm build 产出恰为 client-editor.js / client-mermaid.js / client-locale.js
pnpm test:mount(真实 @deepseek-ai/dsh@0.1.7-rc.1 scratch profile) 7 passed
pnpm test 1132 passed | 9 skipped

挂载 lane 覆盖:插件在真实宿主挂载且无 pageerror、原生 guide 逐条打开(插件 4 条 + 宿主 terminal 恰好 1 条、browser 断言缺席)、完整 tab 扫荡、两条设置写入往返(--dsh-e2e-marker 与 title-bar 方案)、以及 perf lane。运行轨迹里可见新增的 /sidebar/api/jobs.list 被正常轮询。

三处反证(比正向通过更有说服力):

  1. tool 结果解析:把新形状分支改回旧行为 → 16 failed / 88 passed,4 条真实生产者 round-trip 全挂;恢复后全绿。
  2. 让出的预览:builtins.spec.ts 用 26 个扩展名逐条断言 editor 不认领,同时断言 md/html/代码仍然认领。
  3. 链接接管:断言「未命中 urlTarget 时 defaultPrevented === false 且不调用任何打开函数」与「命中但被禁用时走 window.open 兜底」。

文档

  • 新增 docs/plans/2026-09-22-dsh-0.1.7-alpha.1-adaptation.md:完整适配记录(含 8 条落地偏差与逐条取舍)。
  • 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 启用。
  • 子代理发现的信息损失(已在计划文档记录):0.1.7 的投影只带健康子会话,unreadable child 的诊断行没有来源了,任务页不再显示该行;mode: 'unknown' 不打印模式词(词典无对应 key)。

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).
@The-five-stooges

Copy link
Copy Markdown

一条交叉信息,可能影响本分支的客户端适配清单

我在 0.1.7-alpha.1 上实测到另一个独立于 dsh-settings 重写的客户端侧硬破坏,它和 settings.plugin.item 的退役是两回事,建议核对本分支是否已处理:

settingsScope 这个客户端服务在 0.1.7 里被改名为 settingsSchema。

  • 0.1.5-rc.x:@deepseek-ai/dsh-client-ui-settings/lib/client.js → super(ctx, "settingsScope")(SettingsScopeBinder extends Service)
  • 0.1.7-alpha.1:同文件 → super(ctx, "settingsSchema")(SettingsSchemaService extends Service);在 0.1.7-alpha.1 的整棵 @deepseek-ai 依赖树里 settingsScope 出现 0 次

为什么这比 register 缺失更严重

cordis 的 inject 是解析期依赖。名字解析不到时 fiber 停在 pending,整条 client entry 不 activate:

Failed to load plugins
web boot: 1 entry did not activate
@linxin666/dsh-remote-web-ui: pending (waiting for service: settingsScope)

try/catch 或 typeof … === 'function' 这一类运行期守卫救不了它——代码根本不会跑到。

已确认同样症状的插件(都在 0.1.7-alpha.1 上):

包 硬依赖位置
@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`.
@Menghuan1918
Menghuan1918 deleted the feat/dsh-0.1.7-alpha.1 branch September 23, 2026 14:25
@Menghuan1918
Menghuan1918 restored the feat/dsh-0.1.7-alpha.1 branch September 23, 2026 14:25
@Menghuan1918 Menghuan1918 reopened this Sep 23, 2026
@Menghuan1918 Menghuan1918 changed the title feat: 适配 DSH 0.1.7-alpha.1(并承载 0.1.6-alpha.2 适配) feat: 适配 DSH 0.1.7(0.1.6-alpha.2 → 0.1.7-rc.1) Sep 23, 2026
@Menghuan1918
Menghuan1918 force-pushed the feat/dsh-0.1.7-alpha.1 branch 2 times, most recently from f618573 to e314b43 Compare September 23, 2026 14:29
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.
@Menghuan1918
Menghuan1918 force-pushed the feat/dsh-0.1.7-alpha.1 branch 2 times, most recently from 75a008d to 819e0f2 Compare September 23, 2026 14:36
@fudaming

Copy link
Copy Markdown

Btw(仅供参考,不阻塞本 PR):第三方描述符的坏图标会把面板整体打成空白

顺带在 0.1.7-alpha.2 + 干净 scratch profile 跑了 plugin-mount,7/7 通过,alpha.2 上不用额外适配。

排查时撞见一幕:第三方描述符的 icon() 返回值似乎没有守卫。@modusensus/dsh-mneme@0.8.6 的 const IconArchiveOutline20 = primitives.IconArchiveOutline20(这个导出自 0.1.7-alpha.1 起只剩 Regular / Medium)拿到 undefined,icon(14) 于是产出 type: undefined 的元素,本插件 PaneEmptyCards 一渲染就 React error #130、侧栏空白([dsh-better-sidebar] render error: …#130)。

不知道值不值得顺手加一层守卫:取值处 try/catch + 校验,非法值退回无图标 / 默认标题(或给每个 chip、card 套个微型 error boundary),让坏描述符只废掉自己那一格;消费点在 TabContent.tsx、Sidebar.tsx、native/tab-adapter.tsx。不处理也完全没问题,记录一下而已。

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.
@Menghuan1918
Menghuan1918 merged commit fad1b73 into main Sep 23, 2026
3 checks passed
@fudaming

Copy link
Copy Markdown

供参考(可选,不阻塞本 PR):给描述符图标加一层共享守卫,坏 glyph 不再把面板打成空白

顺着上一条 Btw 试了一版,贴上来仅供参考。做法是把四处重复的取值逻辑收敛成一个共享守卫:工厂抛错就记一条日志并返回 null;产出的节点套 IconGuard 错误边界,崩了只丢这个 glyph,行与面板照常渲染。顺带删掉 SettingsCardSection 里那份重复的私有 iconOf。

  • 基于本分支 head 819e0f2,9 files / +240 / −19(含 spec 与文档)。
  • 本地 typecheck / lint / test 通过(114 files / 1138 passed);新增 spec 6 例,其中一例穿过真实 buildNewTabOptions 漏斗;把四处消费点回退掉后该例立即变红。
  • 真机(0.1.7-rc.1 + @modusensus/dsh-mneme@0.8.6):面板可用、各插件入口可用;mneme 那个 tab 只剩一行缺图标的条目——它自己的 sidebar.footer.action 条目是注册进宿主槽位的组件(不在本插件渲染树里),那条仍会崩,这里只保证它带不走本插件的面板。

一个 React 细节值得记一句:错误边界捕不到自己 render 输出里的非法元素类型(那是在边界自身的 reconcile 阶段抛的),所以守卫里多了一层 Glyph passthrough,让崩溃发生在守卫下方的组件 render 里;第一版没这层,spec 直接红。

要不要按这个方向做由你定,需要的话我可以开 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()
+  })
+})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants