fix(native): register the plugin's tab types when the registry service arrives

The native seat declares `sidebar.right.pane.tab` BEFORE it provides
`sidebarRightTabs`, so driving the registration off the slot declaration
read the service as missing and registered nothing — permanently, since a
declaration never collapses. Observed on a real DSH profile (0.1.5-alpha.1,
`dsh web` on the web profile): the plugin host and its other slot
registrations mounted, the native guide page stayed empty, and a probe
printed `sidebarRightTabs= undefined` at declaration time with the registry
present three seconds later. The scratch mount lane never saw it because
there the plugin activates after the native package finished providing.

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

Regression test: `tests/native-surface.spec.ts` models the real order
(slot callback fires first, registry arrives later) and fails on the old
implementation. Verified on the real web profile: all guide entries
(files / editor / git / subagent / sidechat / terminal / browser) appear
and the terminal tab renders.
This commit is contained in:
Menghuan1918
2026-09-09 13:07:53 +08:00
parent d7e3e2b986
commit aa1b9945d9
2 changed files with 79 additions and 12 deletions
+15 -12
View File
@@ -85,15 +85,18 @@ export interface NativeSurfaceDeps {
*/
export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
const { ctx, store, service, records } = deps
const disposers: Array<() => void> = []
// The native seat declares `sidebar.right.pane.tab` when its client half
// activates, which may be after this plugin; the slot registry's `inject`
// re-runs on every declaration lifetime, and the tab-type registry
// (`ctx.sidebarRightTabs`) is provided by that same package, so one wait
// covers both.
const disposeSeat = ctx.slots.inject('sidebar.right.pane.tab', () => {
const tabs = ctx.get('sidebarRightTabs') as unknown as NativeTabRegistry | undefined
if (tabs === undefined) return () => {}
// Wait for the tab-type REGISTRY (a service), not for the slot declaration:
// the native seat declares `sidebar.right.pane.tab` BEFORE it provides
// `sidebarRightTabs`, so a declaration-triggered registration reads the
// service as missing and — because the declaration never collapses —
// registers nothing, permanently. Observed on a real profile: the
// declaration callback fired with both `sidebarRight` and
// `sidebarRightTabs` undefined, and the registry appeared a moment later.
// `ctx.inject` re-runs this body whenever the service appears/reappears,
// which is the lifecycle the registrations need.
const seat = ctx.inject(['sidebarRightTabs'], (injected) => {
const tabs = injected.get('sidebarRightTabs') as unknown as NativeTabRegistry | undefined
if (tabs === undefined) return
const live = new Map<string, Registration>()
const fileParamsOf = (info: NativeTabInfo): NativeTabParams | undefined => {
@@ -213,16 +216,16 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
}
}
disposers.push(service.subscribe(sync), store.subscribe(sync))
const disposeSubscriptions = [service.subscribe(sync), store.subscribe(sync)]
sync()
return () => {
for (const registration of live.values()) registration.dispose()
live.clear()
for (const dispose of disposeSubscriptions.reverse()) dispose()
}
})
return () => {
disposeSeat()
for (const dispose of disposers.reverse()) dispose()
void seat.dispose()
}
}
+64
View File
@@ -6,6 +6,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import { createNativeTabRecords } from '../src/client/native/tab-adapter.tsx'
import { registerNativeSurface } from '../src/client/native/index.ts'
import { createBetterSidebarService, type SidebarSurface } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
@@ -149,3 +150,66 @@ describe('service routing into the native surface', () => {
expect(calls).toEqual([])
})
})
describe('registerNativeSurface lifecycle (service-driven registration)', () => {
it('registers the native tab types when the tab-type registry ARRIVES after the slot declaration', () => {
// Regression: the native seat declares `sidebar.right.pane.tab` before it
// provides `sidebarRightTabs`, so a registration driven by the slot
// declaration reads the service as missing and registers nothing —
// observed on a real DSH profile (the guide page stayed empty while the
// same build worked in the scratch mount lane, where activation order
// happened to differ). The registration must follow the SERVICE.
const store = createSidebarStore()
store.setSession('s1')
const service = createBetterSidebarService(store)
service.registerTab({ id: 'terminal', title: 'Terminal', component: () => null })
service.registerTab({ id: 'editor', title: 'Files', component: () => null })
const records = createNativeTabRecords()
const registered: Array<{ id: string; kind: string }> = []
const slotKeys: string[] = []
// The registry is ABSENT while the slot callback fires and appears later
// (that ordering is the regression): a holder keeps the timing honest
// without a reassigned binding.
const registry: { current: { register: (definition: { id: string; kind: string }) => () => void } | undefined } = { current: undefined }
let runInjected: (() => void) | undefined
const ctx = {
inject: (deps: readonly string[], callback: (injected: { get: (name: string) => unknown }) => void) => {
expect(deps).toEqual(['sidebarRightTabs'])
runInjected = () => { callback({ get: () => registry.current }) }
return { dispose: () => { runInjected = undefined } }
},
get: () => registry.current,
slots: {
// The slot is already declared when this plugin activates: the
// callback runs immediately, with no service in sight.
inject: (_key: string, callback: () => () => void) => callback(),
register: (options: { name: string; key?: string }) => {
slotKeys.push(options.key ?? options.name)
return () => {}
},
},
}
const dispose = registerNativeSurface({ ctx: ctx as never, store, service, records })
// Nothing may register while the registry is absent…
expect(registered).toHaveLength(0)
expect(slotKeys).toHaveLength(0)
// …and everything registers once it appears.
registry.current = {
register: (definition) => {
registered.push({ id: definition.id, kind: definition.kind })
return () => {}
},
}
runInjected?.()
expect(registered.map(entry => entry.kind).sort()).toEqual(['editor', 'files', 'terminal'])
expect(registered.map(entry => entry.id)).toContain('dsh-better-sidebar:files')
expect(slotKeys).toContain('dsh-better-sidebar:terminal')
expect(slotKeys).toContain('dsh-better-sidebar:files')
dispose()
})
})