diff --git a/src/client/apes/locales.ts b/src/client/apes/locales.ts index ba2f46d..6616ff0 100644 --- a/src/client/apes/locales.ts +++ b/src/client/apes/locales.ts @@ -33,6 +33,8 @@ export const apesZh = { apesProfileNoCwd: '未知', apesProfileRunning: '运行中', apesProfileIdle: '空闲', + /** Guide-page capsule description (official right sidebar's entry list). */ + apesProfileGuideDesc: '当前会话的身份与工作目录', // Apps view apesAppsNoFeed: '当前 DSH 版本未提供工作区应用数据源', apesAppsWorkspace: '工作区', @@ -54,6 +56,7 @@ export const apesEn: Record = { apesProfileNoCwd: 'unknown', apesProfileRunning: 'running', apesProfileIdle: 'idle', + apesProfileGuideDesc: "This session's identity and working directory", apesAppsNoFeed: 'This DSH version exposes no workspace-app feed', apesAppsWorkspace: 'Workspace', } diff --git a/src/client/apes/native-tabs.tsx b/src/client/apes/native-tabs.tsx new file mode 100644 index 0000000..df2992b --- /dev/null +++ b/src/client/apes/native-tabs.tsx @@ -0,0 +1,187 @@ +/** + * Native right-sidebar bridge (Phase A spike) — registers apes:* tabs as + * OFFICIAL `@deepseek-ai/dsh-client-ui-sidebar-right` tab types through the + * public two-stage protocol its README documents: + * + * 1. TYPE — `ctx.sidebarRightTabs.register({ id, kind, priority, title, guide? })` + * a static declaration with no runtime hooks; the `id` is the type's + * identity in the tab system AND the key its body registers under. + * 2. BODY — `ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( + * { name: 'sidebar.right.pane.tab', key: }, Body))` + * the keyed seat dispatches a tab's body to the type registered for its + * `kind`; the body reads `useTabInfo()` (injected by the seat) for + * `{ sidebar, panel, tab }`. + * + * ACTIVATION ORDER — `sidebarRightTabs` is installed by that package's own + * `apply()` through `ctx.reflect.provide(...)`, so it may not exist yet when + * our plugin activates. We therefore reach it through `ctx.inject(['sidebarRightTabs'], …)` + * (the same pattern intercept.tsx uses for the async `remote.session` + * namespace): the callback runs when the service exists and re-runs after a + * remount, so the bridge is order-independent and HMR-safe. Deployments + * without the official right sidebar simply never see the callback — the + * fork's own shell stays the only surface, which is also what the render-time + * `guide`/type registration tolerates. + * + * SCOPE — the spike registers exactly ONE tab (`apes:profile` / 画像) to prove + * the path end-to-end before deciding on the full nine-tab migration. The + * existing `service.registerTab` route in `tabs.tsx` is deliberately left + * untouched, so both surfaces coexist during the spike and the whole spike + * reverts by deleting this file plus its one call site. + * + * Side effect worth knowing (inherent to the official default-page rule): the + * dock's default page depends on how many guide entries are registered — + * exactly one auto-opens it, zero or many open the guide page. Our guide entry + * therefore turns a fresh-empty dock from "Files" into the guide page listing + * every entry capsule. + */ +import { useMemo, type ReactNode } from 'react' +import { IconUserOutline16 } from './icons.tsx' +import { t } from './locales.ts' +import { ApesProfileView } from './tabs.tsx' +import type { TabComponentProps } from '../service.ts' + +/** The apes type's identity in the official tab system. Package-scoped so it + * can never collide with a native or third-party registration. */ +const PROFILE_ID = 'dsh-apes-sidebar/profile' +/** The kind the panel opens the tab with (`openTab(kind)`); `apes:` prefixed so + * it reads as ours in the official registry. */ +const PROFILE_KIND = 'apes:profile' + +/** The official registry's definition shape (the subset we use). */ +interface NativeTabDefinition { + id: string + kind: string + priority?: 'builtin' | 'extension' | 'fallback' + title: () => string + guide?: Array<{ + order: number + title: () => string + description?: () => string + icon?: (props: { size?: number; className?: string }) => ReactNode + }> +} + +/** The registry service face the official package provides. */ +interface NativeTabRegistry { + register(definition: NativeTabDefinition): () => void +} + +/** The slots service face (structural subset of `@deepseek-ai/dsh-client-ui-slots`). */ +interface NativeSlots { + inject(key: string, callback: () => () => void): () => void + register(options: { + name: string + key?: string + locale?: string + inject?: (...args: unknown[]) => Record + }, component: (props: Record) => ReactNode): () => void +} + +/** What the seat hands a tab body in addition to our own injected props. */ +interface NativeTabBodyProps { + useTabInfo?: () => unknown + sessionId?: string + ctx: TabComponentProps['ctx'] +} + +/** The tab info the seat's `useTabInfo()` hook returns (the subset we read). */ +interface NativeTabInfo { + sidebar?: { expanded?: boolean; fullscreen?: boolean } + panel?: { id?: string } + tab?: { id?: string; title?: string; visible?: boolean } +} + +/** Fallback reader for an absent seat hook (never in production — the seat + * always injects `tabInfo`; this keeps the body hook-safe either way, since + * the fallback calls no hooks of its own). */ +function fallbackTabInfo(): NativeTabInfo { + return { sidebar: { expanded: true }, panel: { id: '' }, tab: { visible: true } } +} + +/** + * The official surface's body for 画像: the SAME `ApesProfileView` the fork + * already ships, re-bound to the official tab scope. The view reads only + * `props.ctx` (the sessions feed) and `props.scope` (sessionId / cwd), so the + * shell-only props are filled with placeholders — which is the spike's whole + * point: the panel content is shared, never duplicated. + */ +function ProfileNativeBody(props: NativeTabBodyProps): ReactNode { + const useInfo = props.useTabInfo ?? fallbackTabInfo + const info = useInfo() as NativeTabInfo | undefined + const sessionId = props.sessionId + ?? props.ctx.sessions.list.getSnapshot().current + ?? '' + const scope = useMemo(() => ({ sessionId }), [sessionId]) + const viewProps = { + ctx: props.ctx, + scope, + visible: info?.tab?.visible ?? true, + store: undefined, + tab: { + id: info?.tab?.id ?? PROFILE_ID, + type: PROFILE_KIND, + title: info?.tab?.title ?? t('apesProfileTitle'), + }, + } as unknown as TabComponentProps + return +} + +/** + * Register the apes:profile tab as an official right-sidebar tab type. + * @param ctx — the client context whose `inject` waits for the registry service. + * @returns a disposer that disposes the inject fiber (which unwinds both stages). + */ +export function registerApesNativeTabs(ctx: unknown): () => void { + const anyCtx = ctx as { + inject?: (deps: string[], callback: (fctx: unknown) => void) => { dispose(): void } + } + if (typeof anyCtx.inject !== 'function') return () => {} + const fiber = anyCtx.inject(['sidebarRightTabs'], (fctx: unknown) => { + const inner = fctx as { + get?: (name: string) => unknown + slots?: NativeSlots + effect?: (callback: () => () => void, label?: string) => unknown + } + const registry = inner.get?.('sidebarRightTabs') as NativeTabRegistry | undefined + const slots = inner.slots + if (registry === undefined || slots === undefined || typeof inner.effect !== 'function') return + inner.effect(() => { + const disposers: Array<() => void> = [] + try { + // Stage 1 — the type. A page type: it recognizes no resource address + // and is opened by kind; `extension` is the band for types contributed + // from outside the product (highest rank, so it can also take over a + // builtin of the same kind). + disposers.push(registry.register({ + id: PROFILE_ID, + kind: PROFILE_KIND, + priority: 'extension', + title: () => t('apesProfileTitle'), + guide: [{ + order: 70, + title: () => t('apesProfileTitle'), + description: () => t('apesProfileGuideDesc'), + icon: IconUserOutline16, + }], + })) + // Stage 2 — the body, dispatched through the keyed seat. `inject` + // hands the body the client context the view reads its feed from. + disposers.push(slots.inject('sidebar.right.pane.tab', () => slots.register({ + name: 'sidebar.right.pane.tab', + key: PROFILE_ID, + inject: () => ({ ctx }), + }, ProfileNativeBody as unknown as (props: Record) => ReactNode))) + } catch (error) { + // A half-registered bridge must never take the fork's own sidebar + // down: unwind what landed and let the own-shell surface carry on. + console.error('[dsh-apes-sidebar] native right-sidebar registration failed:', error) + } + return () => { + for (const dispose of disposers.reverse()) { + try { dispose() } catch { /* already disposed */ } + } + } + }, 'dsh-apes-sidebar: apes native tab (profile)') + }) + return () => { void fiber.dispose() } +} diff --git a/src/client/apes/tabs.tsx b/src/client/apes/tabs.tsx index 2eb292b..4c10c80 100644 --- a/src/client/apes/tabs.tsx +++ b/src/client/apes/tabs.tsx @@ -164,7 +164,7 @@ function ApesAgentsView(props: TabComponentProps) { } /** apes:profile — the current session's identity (title, cwd, running state). */ -function ApesProfileView(props: TabComponentProps) { +export function ApesProfileView(props: TabComponentProps) { const list = useSessionList(props.ctx) const current = list.current const summary = current === undefined ? undefined : list.byId[current] diff --git a/src/client/index.tsx b/src/client/index.tsx index 1f19a1e..6b16ca4 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -16,6 +16,7 @@ import { createBetterSidebarService, matchUrlTarget } from './service.ts' import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts' import { registerBuiltins } from './builtins/index.ts' import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './apes/index.ts' +import { registerApesNativeTabs } from './apes/native-tabs.tsx' import { Sidebar } from './Sidebar.tsx' import { RenderBoundary } from './RenderBoundary.tsx' import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx' @@ -180,6 +181,16 @@ export function apply(ctx: Context): void { () => registerApesTabs(ctx, service), 'dsh-apes-sidebar: register apes tabs', ) + // Phase A spike: also publish apes:profile as an OFFICIAL right-sidebar tab + // type (ctx.sidebarRightTabs + the keyed `sidebar.right.pane.tab` seat), so + // the tab renders inside DSH's own panel instead of only in the fork's + // shell. Both surfaces coexist during the spike; the disposer reverses the + // two-stage registration on fiber disposal (HMR-safe). A throw is caught + // inside the bridge and never reaches this effect. + ctx.effect( + () => registerApesNativeTabs(ctx), + 'dsh-apes-sidebar: apes native right-sidebar tabs', + ) // A failure anywhere in the client lifecycle must never take the app down // silently: log with the plugin prefix and pin a visible diagnostic strip // to the page so a blank panel is never the only symptom. diff --git a/tests/native-tabs.spec.ts b/tests/native-tabs.spec.ts new file mode 100644 index 0000000..07309b9 --- /dev/null +++ b/tests/native-tabs.spec.ts @@ -0,0 +1,156 @@ +/** + * Native right-sidebar bridge tests (Phase A spike). + * + * `registerApesNativeTabs` publishes apes:profile as an OFFICIAL + * `@deepseek-ai/dsh-client-ui-sidebar-right` tab type over the public + * two-stage protocol: the type declaration on `ctx.sidebarRightTabs`, then the + * keyed body on the `sidebar.right.pane.tab` seat. The bridge reaches the + * registry through `ctx.inject(['sidebarRightTabs'], …)` so activation order + * cannot strand it, and a deployment without the official sidebar must stay a + * silent no-op instead of breaking the fork's own shell. + * + * The spec drives that contract with a fake cordis context (a recording + * `inject` + `effect`), so it pins: the dependency name, the definition's + * identity/band/guide entry, the body's seat key, the disposer's unwind, and + * both no-op paths. No DOM and no real DSH host — same node environment as + * apes-tabs.spec.ts. + */ +import { describe, expect, it, vi } from 'vitest' +// Browser globals before the client graph loads (mirror of apes-tabs.spec.ts). +import './browser-globals.ts' + +import { registerApesNativeTabs } from '../src/client/apes/native-tabs.tsx' + +/** One recorded type declaration. */ +interface RecordedDefinition { + id: string + kind: string + priority?: string + title: () => string + guide?: Array<{ order: number; title: () => string; description?: () => string; icon?: unknown }> +} + +/** One recorded body registration. */ +interface RecordedBody { + options: { name: string; key?: string; inject?: (...args: unknown[]) => Record } + component: unknown +} + +/** Build a fake client context plus the registries it hands the bridge. */ +function harness(options: { service?: boolean } = {}) { + const definitions: RecordedDefinition[] = [] + const bodies: RecordedBody[] = [] + let typeUnwound = false + let bodyUnwound = false + + const registry = { + register(definition: RecordedDefinition) { + definitions.push(definition) + return () => { if (!typeUnwound) typeUnwound = true } + }, + } + const slots = { + inject(key: string, callback: () => () => void) { + // The seat exists: run the declaration callback like the real registry. + expect(key).toBe('sidebar.right.pane.tab') + return callback() + }, + register(registration: RecordedBody['options'], component: unknown) { + bodies.push({ options: registration, component }) + return () => { if (!bodyUnwound) bodyUnwound = true } + }, + } + const effects: Array<() => void> = [] + const fctx = { + get: (name: string) => (options.service === false ? undefined : name === 'sidebarRightTabs' ? registry : undefined), + slots, + effect: (callback: () => () => void) => { effects.push(callback()) }, + } + const inject = vi.fn((deps: string[], callback: (inner: unknown) => void) => { + expect(deps).toEqual(['sidebarRightTabs']) + callback(fctx) + return { dispose: () => { for (const dispose of effects) dispose() } } + }) + return { + ctx: { inject }, + inject, + definitions, + bodies, + get typeUnwound() { return typeUnwound }, + get bodyUnwound() { return bodyUnwound }, + } +} + +describe('apes native right-sidebar bridge (spike)', () => { + it('waits on ctx.inject for the official registry service', () => { + const h = harness() + const dispose = registerApesNativeTabs(h.ctx) + expect(h.inject).toHaveBeenCalledTimes(1) + expect(h.inject.mock.calls[0]![0]).toEqual(['sidebarRightTabs']) + dispose() + }) + + it('registers the profile type in the extension band with a guide entry', () => { + const h = harness() + const dispose = registerApesNativeTabs(h.ctx) + try { + expect(h.definitions).toHaveLength(1) + const def = h.definitions[0]! + expect(def.id).toBe('dsh-apes-sidebar/profile') + expect(def.kind).toBe('apes:profile') + // `extension` is the band for types contributed from outside the product. + expect(def.priority).toBe('extension') + // The title is read fresh (locale switches must follow). + expect(def.title()).toBe('画像') + expect(def.guide).toHaveLength(1) + const entry = def.guide![0]! + expect(entry.order).toBe(70) + expect(entry.title()).toBe('画像') + expect(typeof entry.description?.()).toBe('string') + expect(entry.icon).toBeDefined() + } finally { + dispose() + } + }) + + it('registers the body under the type id on the keyed seat', () => { + const h = harness() + const dispose = registerApesNativeTabs(h.ctx) + try { + expect(h.bodies).toHaveLength(1) + const body = h.bodies[0]! + expect(body.options.name).toBe('sidebar.right.pane.tab') + // The seat key MUST equal the registered id — that is how the panel + // dispatches a tab body to our type. + expect(body.options.key).toBe(h.definitions[0]!.id) + expect(body.component).toBeDefined() + // The body's injected props carry the client context the view reads. + expect(body.options.inject?.()).toEqual({ ctx: h.ctx }) + } finally { + dispose() + } + }) + + it('the disposer unwinds both stages (HMR-safe)', () => { + const h = harness() + const dispose = registerApesNativeTabs(h.ctx) + expect(h.definitions).toHaveLength(1) + expect(h.bodies).toHaveLength(1) + dispose() + expect(h.typeUnwound).toBe(true) + expect(h.bodyUnwound).toBe(true) + }) + + it('is a silent no-op when the official registry service never arrives', () => { + const h = harness({ service: false }) + const dispose = registerApesNativeTabs(h.ctx) + expect(h.inject).toHaveBeenCalledTimes(1) + expect(h.definitions).toHaveLength(0) + expect(h.bodies).toHaveLength(0) + expect(() => dispose()).not.toThrow() + }) + + it('is a no-op for a context without inject (non-cordis harness)', () => { + expect(() => registerApesNativeTabs({} as never)()).not.toThrow() + }) +})