From aa1b9945d9d8f22526ed4dd7fc65a66a2703d3c0 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Wed, 9 Sep 2026 13:06:43 +0800 Subject: [PATCH] fix(native): register the plugin's tab types when the registry service arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/client/native/index.ts | 27 ++++++++------- tests/native-surface.spec.ts | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/src/client/native/index.ts b/src/client/native/index.ts index fecd898..a38a4b5 100644 --- a/src/client/native/index.ts +++ b/src/client/native/index.ts @@ -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() 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() } } diff --git a/tests/native-surface.spec.ts b/tests/native-surface.spec.ts index 04cd5f4..4307246 100644 --- a/tests/native-surface.spec.ts +++ b/tests/native-surface.spec.ts @@ -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() + }) +})