From fac59ae2f3f766532d23a7ac6ba1ecb9b7461ad0 Mon Sep 17 00:00:00 2001 From: jorge guerrero Date: Sat, 26 Sep 2026 21:45:48 -0400 Subject: [PATCH 1/9] fix(desktop): stop panel host from blocking macOS window drag The shell's base stylesheet makes every direct body child no-drag (`html[data-platform=darwin] body > :not(#root)`). The viewport-sized `[data-dsh-panel-host]` layer inherited it, and since app-region ignores pointer-events it cancelled every window-drag strip beneath it once the shell recomputed regions. Take the host and layer out of the app-region computation (`!important`, the shell selector carries an id's specificity) and keep the panels themselves no-drag so their controls still receive clicks. Fixes #772 Co-Authored-By: Claude Opus 5.5 --- src/client/sidebar.module.css | 16 ++++++++++++++++ tests/e2e/mount.e2e.ts | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index d47c746..7f21af4 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -63,6 +63,22 @@ overflow: clip; } +/* Desktop drag regions ignore pointer-events: the shell's base stylesheet + makes every direct body child `no-drag`, and this viewport-sized layer + inherited it, cancelling every window-drag strip beneath it once the + shell recomputed regions (window drags worked once, then stopped). + Take the host and layer out of the app-region computation; `!important` + because the shell's `body > :not(#root)` selector outranks ours. The + panels themselves stay no-drag so their controls keep receiving clicks. */ +:global(html[data-platform] body > [data-dsh-better-sidebar]), +:global([data-dsh-panel-host]) { + -webkit-app-region: initial !important; +} + +:global([data-dsh-panel-host]) > * { + -webkit-app-region: no-drag; +} + :global([data-dsh-panel-host][data-dsh-panel-host-degraded]) { position: absolute; top: 0; diff --git a/tests/e2e/mount.e2e.ts b/tests/e2e/mount.e2e.ts index 7e6d8fd..69aa766 100644 --- a/tests/e2e/mount.e2e.ts +++ b/tests/e2e/mount.e2e.ts @@ -615,6 +615,16 @@ test('conservative auto: URL stamps alone never modify the layout; plugin chrome return false }) expect(hasNoDragRule, 'the bundle must ship the drag-region opt-out rule').toBe(true) + // The viewport-sized panel host must NOT inherit the shell's blanket + // `body > :not(#root) { no-drag }` — it would cancel every window-drag + // strip beneath it on macOS (issue #772). + const hasHostReset = await page.evaluate(() => { + for (const tag of document.querySelectorAll('style')) { + if (tag.textContent !== null && /\[data-dsh-panel-host\]\{-webkit-app-region:\s*initial\s*!important/.test(tag.textContent)) return true + } + return false + }) + expect(hasHostReset, 'the panel host must opt out of app-region computation').toBe(true) }) test('standard WCO geometry drives the strip reactively (issue #257)', async ({ page }) => { From 3728e9036bde2fa2ee6600c8d2e3ca3b6f2b4471 Mon Sep 17 00:00:00 2001 From: ymac <707337118@qq.com> Date: Sun, 13 Sep 2026 18:24:03 +0800 Subject: [PATCH 2/9] fix(native): release a tab type whose slot registration failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin reload (or a disable/enable) tears the client fiber down while this module's own service/store subscriptions are still live: unregistering each built-in descriptor notifies the plugin's service, and every notification ran `sync()` again. The `files` takeover is never a descriptor — its key is `FILES_KIND` — so the drop loop released it every single time, and the block below re-created it. Re-registering on the now-inactive context throws inside `registerSlots` (`ctx.slots.inject` cannot create an effect on an inactive context) *after* `tabs.register` already put the type into the host registry, so that registration's disposer was lost with the exception. The id then stayed taken for the rest of the page's life: every later attempt was refused with sidebarRight: tab type id "dsh-better-sidebar:files" is already registered and the kind rendered the host's "nothing here can view this kind of content yet" face until a page refresh. - `sync()` skips the `files` takeover in its drop loop: the takeover is owned by the editor-type switch, so an unrelated notification no longer puts its host-side type through a tear-down/re-register window; - a registration that fails after its type was registered releases the type again (and whatever slot disposers it collected), so the same id stays registrable and a retry can succeed; - one descriptor that cannot register no longer aborts the whole sync, so the remaining types are still brought up; - teardown releases every registration even when one release throws. Verified: tests/native-registration.spec.ts (new, 4 cases) is red 4/4 against the unfixed code and green with the fix; tsc/eslint clean and the full unit suite passes. --- src/client/native/index.ts | 123 ++++++++++++++----- tests/native-registration.spec.ts | 197 ++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 29 deletions(-) create mode 100644 tests/native-registration.spec.ts diff --git a/src/client/native/index.ts b/src/client/native/index.ts index c126034..aa70b08 100644 --- a/src/client/native/index.ts +++ b/src/client/native/index.ts @@ -156,6 +156,21 @@ interface Registration { readonly dispose: () => void } +/** + * Run one registration disposer, reporting a failure instead of propagating + * it: releasing a registration must never mask the error being handled, and + * one broken entry must not leave the others registered. + * @param dispose - the disposer to run. + * @param what - the registration's name, for the log line. + */ +function disposeSafely(dispose: () => void, what: string): void { + try { + dispose() + } catch (error) { + console.error(`[dsh-better-sidebar] ${what} release failed:`, error) + } +} + /** Everything the registrations need. */ export interface NativeSurfaceDeps { readonly ctx: Context @@ -208,23 +223,44 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void { return address !== undefined && address.scope === 'session' ? address.sessionId : undefined } - /** Register the body + chip-title slots for one native implementation id. */ + /** + * Register the body + chip-title slots for one native implementation id. + * + * A slot registration can fail long after the TYPE registration succeeded + * (`ctx.effect` on a context that is already inactive, i.e. during a + * plugin reload/disposal), so the disposers are collected as they are + * handed out and the partial set is released on the way out: the caller + * must be able to roll its type registration back WITHOUT losing track of + * a slot that did register. + */ const registerSlots = ( id: string, injected: Omit, params: Pick, - ): Array<() => void> => [ - ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ - name: 'sidebar.right.pane.tab', - key: id, - inject: (sessionId: string) => ({ ...injected, ...params, sessionId }), - }, NativeTabBody)), - ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ - name: 'sidebar.right.pane.tab.title', - key: id, - inject: () => ({ records, service, descriptorId: injected.descriptorId }), - }, NativeTabTitle)), - ] + ): Array<() => void> => { + // Collected AS THEY ARE HANDED OUT: a slot registration can fail long + // after the TYPE registration succeeded (a plugin reload makes + // `ctx.slots.inject` throw on the now-inactive context), and the caller + // must then be able to release the type again WITHOUT losing track of a + // slot that did register. + const disposers: Array<() => void> = [] + try { + disposers.push(ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', + key: id, + inject: (sessionId: string) => ({ ...injected, ...params, sessionId }), + }, NativeTabBody))) + disposers.push(ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab.title', + key: id, + inject: () => ({ records, service, descriptorId: injected.descriptorId }), + }, NativeTabTitle))) + } catch (error) { + for (const dispose of disposers) disposeSafely(dispose, `native tab slot "${id}"`) + throw error + } + return disposers + } /** One descriptor's native type + body + title, as one disposable. */ const registerDescriptor = (descriptor: TabDescriptor): (() => void) => { @@ -271,14 +307,25 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void { }], }), }) - const slots = registerSlots( - id, - { ctx, store, service, records, descriptorId: descriptor.id }, - isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {}, - ) + // The type is in the host's registry the moment `tabs.register` + // returns; a slot failure must release it here, or the id stays taken + // for the rest of the page's life (the host refuses a second + // registration of the same id) and the tab kind renders the host's + // "nothing can view this" face forever. + let slots: Array<() => void> + try { + slots = registerSlots( + id, + { ctx, store, service, records, descriptorId: descriptor.id }, + isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {}, + ) + } catch (error) { + disposeSafely(disposeType, `native tab type "${id}"`) + throw error + } return () => { - for (const dispose of slots.reverse()) dispose() - disposeType() + for (const dispose of slots.reverse()) disposeSafely(dispose, `native tab slot "${id}"`) + disposeSafely(disposeType, `native tab type "${id}"`) } } @@ -304,10 +351,16 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void { ...guideIconOf(editor?.icon), }], }) - const slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {}) + let slots: Array<() => void> + try { + slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {}) + } catch (error) { + disposeSafely(disposeType, `native tab type "${id}"`) + throw error + } return () => { - for (const dispose of slots.reverse()) dispose() - disposeType() + for (const dispose of slots.reverse()) disposeSafely(dispose, `native tab slot "${id}"`) + disposeSafely(disposeType, `native tab type "${id}"`) } } @@ -319,14 +372,23 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void { wanted.set(descriptor.id, () => registerDescriptor(descriptor)) } for (const [descriptorId, registration] of live) { - if (wanted.has(descriptorId)) continue - registration.dispose() + // `files` is this module's OWN takeover, never a descriptor: it is + // owned by the editor-type switch below. Re-creating it here on every + // notification used to put its host-side type through a + // tear-down/re-register window on each store commit — and a + // re-registration attempted on an already-inactive context (a plugin + // reload) left the type registered with nobody holding its disposer, + // so the same id could never be registered again for the rest of the + // page's life. + if (descriptorId === FILES_KIND || wanted.has(descriptorId)) continue + disposeSafely(() => registration.dispose(), `native tab type "${descriptorId}"`) live.delete(descriptorId) } for (const [descriptorId, create] of wanted) { if (live.has(descriptorId)) continue // One descriptor must not take the rest of the surface down with it: - // report and continue so the remaining types still register. + // report and continue so the remaining types still register. `create` + // has already released whatever it managed to install. try { live.set(descriptorId, { dispose: create() }) } catch (error) { @@ -345,17 +407,20 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void { } } if (!wantsFiles && hasFiles) { - live.get(FILES_KIND)?.dispose() + const takeover = live.get(FILES_KIND) live.delete(FILES_KIND) + if (takeover !== undefined) disposeSafely(() => takeover.dispose(), 'the native "files" takeover') } } const disposeSubscriptions = [service.subscribe(sync), store.subscribe(sync)] sync() return () => { - for (const registration of live.values()) registration.dispose() + for (const registration of live.values()) { + disposeSafely(() => registration.dispose(), 'a native tab registration') + } live.clear() - for (const dispose of disposeSubscriptions.reverse()) dispose() + for (const dispose of disposeSubscriptions.reverse()) disposeSafely(dispose, 'a native tab subscription') } }) return () => { diff --git a/tests/native-registration.spec.ts b/tests/native-registration.spec.ts new file mode 100644 index 0000000..33d2b5c --- /dev/null +++ b/tests/native-registration.spec.ts @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +/** + * The native tab registration LIFECYCLE (`src/client/native/index.ts`): what a + * store/service notification may do to the host's tab-type registry, and what + * a failed registration is allowed to leave behind. + * + * The failure this locks is a plugin RELOAD: cordis tears the fiber down, each + * built-in descriptor is unregistered one by one, every unregistration notifies + * the plugin's service, and `sync()` therefore runs again on a context that is + * already inactive. A registration attempted there throws + * (`ctx.effect` on an inactive context) — and if the host-side type was already + * registered at that point, its disposer is lost, the id stays taken for the + * rest of the page's life, and every later attempt is refused with + * `tab type id "…" is already registered`. The kind then renders the host's + * "nothing here can view this kind of content" face until a page refresh. + * + * Two properties keep that impossible, and both are asserted here: + * 1. an unrelated notification never re-creates the `files` takeover (the + * tear-down/re-register window the reload used to walk into); + * 2. a registration that fails after its type was registered releases the + * type again, so the id stays registrable and one broken descriptor cannot + * take the others down with it. + */ +import { describe, expect, it, vi } from 'vitest' +import { registerNativeSurface } from '../src/client/native/index.ts' +import { createNativeTabRecords } from '../src/client/native/tab-adapter.tsx' +import { createBetterSidebarService } from '../src/client/service.ts' +import { createSidebarStore } from '../src/client/state.ts' + +/** + * The fake `sidebarRightTabs`, mirroring the host's two hard rules: one + * registration per id, and the returned disposer is the only way to free it. + */ +function createRegistry() { + const registered = new Map void>() + const events: string[] = [] + let failReleaseOf: string | undefined + return { + registered, + events, + failRelease(id: string): void { failReleaseOf = id }, + register(definition: { id: string }): () => void { + if (registered.has(definition.id)) { + throw new Error(`sidebarRight: tab type id "${definition.id}" is already registered`) + } + events.push(`register ${definition.id}`) + const dispose = (): void => { + if (failReleaseOf === definition.id) throw new Error(`cannot release ${definition.id}`) + registered.delete(definition.id) + events.push(`release ${definition.id}`) + } + registered.set(definition.id, dispose) + return dispose + }, + } +} + +/** + * A fake client context: `slots.inject` runs its callback synchronously (the + * real one resolves the declaration and calls `ctx.effect`, which throws while + * `inactive`), `slots.register` throws for a key listed in `failKeys`, and + * `inject` hands back the registry while honouring the callback's own teardown. + */ +function createHost() { + const registry = createRegistry() + // One mutable state object the ctx closures read: the test flips it after + // the host was built. + const state = { + /** True while the plugin's context is being torn down (the reload case). */ + inactive: false, + /** Native ids whose slot registration fails. */ + failKeys: new Set(), + } + const ctx = { + inject: ( + _deps: readonly string[], + callback: (injected: { get: (name: string) => unknown }) => (() => void) | void, + ) => { + const teardown = callback({ get: () => registry }) + return { dispose: () => { if (typeof teardown === 'function') teardown() } } + }, + get: () => registry, + slots: { + inject: (_name: string, callback: () => Array<() => void> | (() => void)) => { + if (state.inactive) throw new Error('cannot create effect on inactive context') + return typeof callback === 'function' ? callback() : () => {} + }, + register: (options: { key?: string }) => { + if (options.key !== undefined && state.failKeys.has(options.key)) { + throw new Error(`slot registration for "${options.key}" failed`) + } + return () => {} + }, + }, + } + return { registry, state, ctx } +} + +/** A store + service with two enabled descriptors, plus the record registry. */ +function createPlugins() { + const store = createSidebarStore() + store.setSession('s1') + const service = createBetterSidebarService(store) + service.registerTab({ id: 'editor', title: 'Files', component: () => null, icon: () => null }) + service.registerTab({ id: 'git', title: 'Changes', component: () => null }) + return { store, service, records: createNativeTabRecords() } +} + +/** Silence (and count) the failure logs the fixed code reports on purpose. */ +function silenceFailures(): { restore: () => void; messages: string[] } { + const messages: string[] = [] + const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + messages.push(args.map(String).join(' ')) + }) + return { restore: () => { spy.mockRestore() }, messages } +} + +describe('native tab registration lifecycle', () => { + it('releases a type whose slot registration failed, and can register it again later', () => { + const host = createHost() + const { store, service, records } = createPlugins() + const log = silenceFailures() + try { + // The reload: the context goes inactive while notifications are still + // arriving, so every slot registration fails after its type registered. + host.state.inactive = true + expect(() => registerNativeSurface({ ctx: host.ctx as never, store, service, records })).not.toThrow() + expect([...host.registry.registered.keys()], 'a failed registration leaves no type behind').toEqual([]) + + // The context comes back (the reload's next activation): the same ids + // must be registrable — no "already registered" from the orphan. + host.state.inactive = false + host.state.failKeys.clear() + store.setPrefs(store.getPrefs()) + expect([...host.registry.registered.keys()].sort()).toEqual([ + 'dsh-better-sidebar:editor', + 'dsh-better-sidebar:files', + 'dsh-better-sidebar:git', + ]) + } finally { + log.restore() + } + }) + + it('never re-creates the "files" takeover on an unrelated notification', () => { + const host = createHost() + const { store, service, records } = createPlugins() + const dispose = registerNativeSurface({ ctx: host.ctx as never, store, service, records }) + // The takeover is this module's own entry (never a descriptor), so no + // store/service notification has any business touching it. Before the fix + // every notification disposed and re-created it. + host.registry.events.length = 0 + for (let round = 0; round < 3; round++) store.setPrefs(store.getPrefs()) + expect(host.registry.events, 'no notification may tear the "files" takeover down').toEqual([]) + expect(host.registry.registered.has('dsh-better-sidebar:files')).toBe(true) + dispose() + }) + + it('keeps the other tab types when one descriptor cannot register', () => { + const host = createHost() + const { store, service, records } = createPlugins() + host.state.failKeys.add('dsh-better-sidebar:git') + const log = silenceFailures() + try { + expect(() => registerNativeSurface({ ctx: host.ctx as never, store, service, records })).not.toThrow() + expect([...host.registry.registered.keys()].sort()).toEqual([ + 'dsh-better-sidebar:editor', + 'dsh-better-sidebar:files', + ]) + // The broken descriptor is retried on the next sync instead of being + // written off, and the retry succeeds once it can register. + host.state.failKeys.clear() + store.setPrefs(store.getPrefs()) + expect([...host.registry.registered.keys()].sort()).toEqual([ + 'dsh-better-sidebar:editor', + 'dsh-better-sidebar:files', + 'dsh-better-sidebar:git', + ]) + } finally { + log.restore() + } + }) + + it('releases every registration on teardown even when one release throws', () => { + const host = createHost() + const { store, service, records } = createPlugins() + const log = silenceFailures() + try { + const dispose = registerNativeSurface({ ctx: host.ctx as never, store, service, records }) + host.registry.failRelease('dsh-better-sidebar:editor') + expect(() => dispose()).not.toThrow() + expect([...host.registry.registered.keys()]).toEqual(['dsh-better-sidebar:editor']) + } finally { + log.restore() + } + }) +}) From 0b40f74ce8ebc5bacd9c2c8d9476b9a3948bf1e6 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 28 Sep 2026 10:57:10 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(native):=20=E9=92=89=E7=89=A2=20files?= =?UTF-8?q?=20=E6=8E=A5=E7=AE=A1=E6=B3=84=E6=BC=8F=E7=9A=84=E5=AE=88?= =?UTF-8?q?=E6=8A=A4=E6=96=AD=E8=A8=80=20+=20=E8=AE=B0=E5=BD=95=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E4=B8=8D=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #777 已修掉根因(清理循环跳过 FILES_KIND、注册失败回滚已占用的 id),但它的两处 断言写在「活着的 id 集合」上——泄漏的 id 恰好构成同一个集合,未修复代码上同样 通过;且槽位失败判据按 key 命中,而一个描述符的两个槽共用一个 key,所以 registerSlots 的「部分回滚」分支从未被执行。 - tests/native-registration.spec.ts:断言改写到注册表的事件日志(孤儿 id 会让键 集合看起来完全正常);槽位失败判据改为 name::key,从而可以只让某个描述符的 第二个槽失败、真正走到部分回滚;补 reportFailure 的 phase 断言。实测:在 v0.22.0 源码上 4/4 红,在修复后的源码上 4/4 绿。 - src/client/native/index.ts:给 disposeSafely 补注释,说明释放失败只写 console.error、不弹诊断条的理由(两个调用点都在 teardown 路径上;真被占住的 id 会在下一次注册尝试里经 reportFailure 显式报出)。 - AGENTS.md:§3.4 第 9 条补上这两条不变量(含真机日志实证的链接),§6 把 native-registration.spec.ts 纳入关键测试守护。 - docs/plans/2026-09-28-native-files-takeover-reload-leak.md:事故/证据/否决方案 记录(含「吸收」方案为何被否)。 验证:pnpm typecheck / pnpm lint 通过;pnpm test 122 files / 1292 passed / 9 skipped。 --- AGENTS.md | 4 +- ...09-28-native-files-takeover-reload-leak.md | 50 ++++++++ tests/native-registration.spec.ts | 118 ++++++++++++------ 3 files changed, 130 insertions(+), 42 deletions(-) create mode 100644 docs/plans/2026-09-28-native-files-takeover-reload-leak.md diff --git a/AGENTS.md b/AGENTS.md index b9dce87..47cf6d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ 6. **`@deepseek-ai/dsh-client-runtime` 包已消亡**(继任 seed 是裸名 `dsh-client-store`,无 `/client` 子路径):peerDependencies、devDependencies、`dsh.client.inject`、chunk externals 白名单(`src/client/chunk-loader.ts` / `tsdown.config.ts` / `tests/chunk-loader.spec.ts` / `tests/manifest-consistency.spec.ts` 四处同步)均已无该条目。 7. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。 8. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / scope / session-projection / system-prompt / user-approval / util-time 等,与直接依赖同款精确钉版);**`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`,0.1.7-alpha.1 要求 `^4.0.3`,rc.1 起要求 `~4.0.4`**(rc.1 顺手把 vendor 一起抬了:cordis 4.0.4 / schemastery 3.18.4 / cordis-plugin-group 1.0.4 / cordis-plugin-include 1.0.9;loader 保持 1.0.5)。**rc.1 实测的教训**:只改 `dependencies` 里的 schemastery 和 peer 里的 cordis **不够**——`cordis-plugin-group` / `-include` 是 `dsh-app-boot` / `dsh-config-editor` 的**传递 peer**,pnpm 会继续沿用 lockfile 里的旧副本,`pnpm peers check` 一次报出四类 unmet peer;按本条把四个 vendor 包**显式钉进 devDependencies** 才收敛(这也是 rc.1 那条「DSH 内部引用一律精确版本」的 workspace-release-ranges 政策的直接后果)。适配新版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。**0.1.7 新增的同类问题**见 §3.3 第 12 条(`ui-primitives` 的第二层裸 import)。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**的历史已消除,rc.1 起上游恢复发版,0.1.7 起与 DSH 同 tuple。 -9. **右侧栏是 DSH 原生栏,插件只提供 tab 类型**:聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`。插件的每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`,`extension` 带)+ 原生 tab 体(`sidebar.right.pane.tab` keyed 槽,key = `dsh-better-sidebar:`);`editor` 类型同时认领 `dsh-resource://file/**`(**但按 §3.2 第 5 条的清单让出一部分**),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件文件树,注销即复位)。`ctx.sidebarRight.openTab/openResource/close` 只对**在屏会话**写入;跨会话用具体类上的 `openTabIn/openResourceIn/closeIn`。原生 tab 的插件侧状态在 `src/client/native/tab-adapter.tsx`。**注册必须等服务、不能等槽声明**:原生栏先声明 `sidebar.right.pane.tab` 再 `provide('sidebarRightTabs')`,真机 profile 上槽声明回调里 `ctx.get('sidebarRightTabs')` 仍是 undefined(实测 3 秒后才出现),按槽触发注册会静默什么都不注册且永不重试——用 `ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(`tests/native-surface.spec.ts` 以「槽先触发、服务后到达」的顺序守护)。**0.1.6-alpha.2 起**:`SidebarRightGuideEntry.id` 必填且同 provider 内唯一(缺它会静默清空整个承载面);同 provider 的 guide 条目是**追加**关系,`chain` 那种「替换宿主条目」的语义只存在于 `conversation.chat.turnTail` 且已被上游改成 `list`。**0.1.7 起**:`sidebar-right` 本体的契约**零破坏**——槽名无改名、`SidebarRightTabDefinition` 没有新的必填字段、只有新增的可选 `keepMounted` 与 `ISidebarRight.mounted`,`openTabIn/openResourceIn/closeIn` 在具体 class 上字节不变;`tab.visible` 语义收紧为 `active && (...)`(全局面板/后台会话时 body 不挂载);持久化校验收紧(非根 / 列轴 / 嵌套 / 3+ docked pane 的 split 一律判非法**整份丢弃**,0.1.6 的 sidebar 自己产不出这种布局);`RightbarRoot` 现在为每个保留会话渲染一个隐藏 seat(`SidebarSessionViews`,未导出、插件拿不到)。原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**(非 flex 容器),native 适配层因此给每个 tab 体包一层 `height:100%` 的列 flex 宿主(`sidebar.module.css` 的 `.nativeTabHost`),tab 组件根继续用 `flex:1`/`height:100%`。全局面板(根级 `main` keyed 槽 + `sidebar.panellist` + `ctx.layout.selectPanel/beginNavigation`、`rightbar` 改根级并新增 `rightbar.session`)插件**不接入**,只做兼容。底部工作台的中心列锚点认 `[data-slot="main.conversation"]`(并跳过 `display: contents` 祖先,同时保留 alpha.1 的 `[data-slot="conversation"]`);文件地址语法跟随 alpha.2(`fileAddressFor` 一律 session 作用域、绝对路径保留前导 `/`、`parseFileAddress` 前缀解析并去 `?`/`#`)。**原生指南说明的 4 条上限(归属更正:这是 0.1.7-alpha.1 起就存在的既成事实,不是 rc.1 的变更)**:`ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx` 的 `MAX_DESCRIBED_ENTRIES = 4` 及其判定行在 alpha.1 / alpha.2 / rc.1 三个 tag 上**逐字相同**,宿主终端的 guide 条目(`ui-sidebar-terminal/src/client/TerminalGuide.tsx`)也是 alpha.1 就有了,`ui-sidebar-terminal/src` 在 rc.1 **零改动**。含义不变:条目 ≤ 4 条才渲染说明,更长的列表是整列丢弃而不是截断——插件默认贡献 4 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话),与宿主的终端条目合计后可能超过 4 条,此时说明不渲染;插件**不恢复**旧的 `nativeGuideDesc` 通用兜底句。 +9. **右侧栏是 DSH 原生栏,插件只提供 tab 类型**:聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`。插件的每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`,`extension` 带)+ 原生 tab 体(`sidebar.right.pane.tab` keyed 槽,key = `dsh-better-sidebar:`);`editor` 类型同时认领 `dsh-resource://file/**`(**但按 §3.2 第 5 条的清单让出一部分**),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件文件树,注销即复位)。`ctx.sidebarRight.openTab/openResource/close` 只对**在屏会话**写入;跨会话用具体类上的 `openTabIn/openResourceIn/closeIn`。原生 tab 的插件侧状态在 `src/client/native/tab-adapter.tsx`。**注册必须等服务、不能等槽声明**:原生栏先声明 `sidebar.right.pane.tab` 再 `provide('sidebarRightTabs')`,真机 profile 上槽声明回调里 `ctx.get('sidebarRightTabs')` 仍是 undefined(实测 3 秒后才出现),按槽触发注册会静默什么都不注册且永不重试——用 `ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(`tests/native-surface.spec.ts` 以「槽先触发、服务后到达」的顺序守护)。**0.1.6-alpha.2 起**:`SidebarRightGuideEntry.id` 必填且同 provider 内唯一(缺它会静默清空整个承载面);同 provider 的 guide 条目是**追加**关系,`chain` 那种「替换宿主条目」的语义只存在于 `conversation.chat.turnTail` 且已被上游改成 `list`。**0.1.7 起**:`sidebar-right` 本体的契约**零破坏**——槽名无改名、`SidebarRightTabDefinition` 没有新的必填字段、只有新增的可选 `keepMounted` 与 `ISidebarRight.mounted`,`openTabIn/openResourceIn/closeIn` 在具体 class 上字节不变;`tab.visible` 语义收紧为 `active && (...)`(全局面板/后台会话时 body 不挂载);持久化校验收紧(非根 / 列轴 / 嵌套 / 3+ docked pane 的 split 一律判非法**整份丢弃**,0.1.6 的 sidebar 自己产不出这种布局);`RightbarRoot` 现在为每个保留会话渲染一个隐藏 seat(`SidebarSessionViews`,未导出、插件拿不到)。原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**(非 flex 容器),native 适配层因此给每个 tab 体包一层 `height:100%` 的列 flex 宿主(`sidebar.module.css` 的 `.nativeTabHost`),tab 组件根继续用 `flex:1`/`height:100%`。全局面板(根级 `main` keyed 槽 + `sidebar.panellist` + `ctx.layout.selectPanel/beginNavigation`、`rightbar` 改根级并新增 `rightbar.session`)插件**不接入**,只做兼容。底部工作台的中心列锚点认 `[data-slot="main.conversation"]`(并跳过 `display: contents` 祖先,同时保留 alpha.1 的 `[data-slot="conversation"]`);文件地址语法跟随 alpha.2(`fileAddressFor` 一律 session 作用域、绝对路径保留前导 `/`、`parseFileAddress` 前缀解析并去 `?`/`#`)。**原生指南说明的 4 条上限(归属更正:这是 0.1.7-alpha.1 起就存在的既成事实,不是 rc.1 的变更)**:`ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx` 的 `MAX_DESCRIBED_ENTRIES = 4` 及其判定行在 alpha.1 / alpha.2 / rc.1 三个 tag 上**逐字相同**,宿主终端的 guide 条目(`ui-sidebar-terminal/src/client/TerminalGuide.tsx`)也是 alpha.1 就有了,`ui-sidebar-terminal/src` 在 rc.1 **零改动**。含义不变:条目 ≤ 4 条才渲染说明,更长的列表是整列丢弃而不是截断——插件默认贡献 4 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话),与宿主的终端条目合计后可能超过 4 条,此时说明不渲染;插件**不恢复**旧的 `nativeGuideDesc` 通用兜底句。**原生注册账本的两条不变量(v0.22.1 起,由 `tests/native-registration.spec.ts` 守护)**:① `live` 里同时装着描述符类型(键 = descriptor id)与 `files` 接管(键 = `FILES_KIND`,**永远不在 `wanted` 里**),清理循环必须**跳过 `FILES_KIND`**——它只由编辑器类型的 `wantsFiles/hasFiles` 开关分支与 seat disposer 管理;否则每次通知都会把它拆掉再重建,而 teardown 期间的那次重建会把 id 孤儿化:`tabs.register` 建在**宿主** ctx 上(宿主 ctx 此时仍活着)所以照样成功,紧随的 `ctx.slots.inject` 建在**插件** ctx 上却抛 `cannot create effect on inactive context`,`live.set` 永不执行 → 该 id 在整页生命周期内不可再注册(真实表现:`native register files error: … already registered` 刷屏 + 文件树落到宿主空态,只有刷新页面才恢复;官方桌面壳日志实证见 [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](docs/plans/2026-09-28-native-files-takeover-reload-leak.md))。② 因此「注册成功一半」是**可达状态**:任何在宿主取走 id **之后**失败的注册(槽位注册抛错等)必须**回滚释放该 type**、并释放已经建好的槽位——插件侧只留一个下次通知可以重试的失败,绝不留下无处释放的 id。相关断言必须写在注册表的**事件日志**上而不是「活着的 id 集合」上:孤儿 id 会让键集合看起来完全正常,键集合断言在未修复代码上同样通过。 10. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折)守护。**0.1.7 复查:`Session.create` 的五个参数与函数体逐字相同**;上游新增了 `buildForkSeed(events, boundary)` 导出,与本插件手工的 `buildSidechatInheritance` 职责重叠但**收益不明确**(插件还有 dangling-tool-call 退回结构化快照的自有需求),暂不替换。 11. **`dsh.plugin.json` 从来不是 DSH 契约**:0.1.7 全树 grep 仍 **0 命中**。DSH 真正读的是 `package.json` 的 `dsh.bundle.patch` + `dsh.client`(`platform`/`inject`/`immediately`/`external`)+ `exports["./client"]`。`dsh.plugin.json` 只服务第三方 registry(社区市场)通道,`lib/client-registry.js` 同理;`tests/manifest-consistency.spec.ts` 守护的是这条通道的形状,不是 DSH 契约。**`dsh.manifestVersion` 也仍是「只声明、零读」**(0.1.7 的 `package-manifest` README 自己写明「Current installers and loaders do not enforce `dsh.manifestVersion` or `engines.dsh`」)。0.1.7 的官方管控面新增:`plugin-manager` 的**多 registry 回退**(`registryPlan()`:配置源 → `fallbackRegistries`(默认含 `registry.npmmirror.com`),`network`/`timeout`/`not-found`/`no-matching-version` 会自动问下一个源;`registry` 入参只存在于 Web 对话框与 `plugin_manager` 工具,**CLI 没有 registry flag**)、`waitForInstall`、以及**插件展示元数据**:`package.json#icon`(36×36、纯填充无描边、**双色渐变**——与 DSH 自带的 `PluginArtwork*` 同风格:它们「carry their own brand colors instead of riding currentColor」,且每个都是多色,单色版在 30px 行内尺寸下会读成一块纯色板)+ `locale/.json` 的 `meta.title`/`description`。本插件**已采纳**(v0.21.1):`icon.svg` + `locale/en.json` + `locale/zh.json`,并由 `tests/plugin-meta.spec.ts` 守护「声明了就必须发布出去」。**rc.1 复查:`packages/boot/app-boot/src/package-meta.ts` 与 `scripts/verify-package-meta.ts` 在两个 tag 上逐字相同**(`ui-plugin-manager/src/client/presentation.ts` 只多了一段 `incompatible-version` 文案),所以上面三条硬规则与「**没有任何尺寸/渐变校验**」都仍然成立。三条硬规则:icon 必须是**包内相对路径**且写进 `files`;`locale/en.json` 是发现基线(缺它整个目录都不读);`exports` 必须暴露 `./locale/*.json` 与 `./package.json`。**所有失败都是静默的**——元数据有问题只会退化成一行纯文字,没有任何报错。`dsh.bundle.patch` 现在多了形状校验(非法值会 `throw`,引导期跳过该 bundle),本插件是合法的单字符串。 12. **peer 声明是启动期硬约束(rc.1 起;本版最容易静默失效的一处)**:`packages/boot/app-boot/src/plugin-compatibility.ts`(rc.1 **新文件**)只做一件事——遍历 profile 行的 `peerDependencies`,对**名字为 `@deepseek-ai/dsh` 或以 `@deepseek-ai/dsh-` 开头**的条目跑 `semver.satisfies(宿主版本, range, { includePrerelease: true })`,不满足就把整行 `row.disabled = true`(group 行连 `group` 一起关掉),只在 stderr 留一行 `disabling profile plugin