spike(A): publish apes:profile as an official right-sidebar tab type
Phase A spike of the C8 root fix: instead of only rendering in the fork's
own shell, apes:profile now also registers with DSH's OFFICIAL right
sidebar through its documented two-stage extension protocol:
1. ctx.sidebarRightTabs.register({ id, kind, priority:'extension',
title, guide }) — the type declaration
2. ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
{ name:'sidebar.right.pane.tab', key: id }, Body)) — the keyed body
The registry is reached through ctx.inject(['sidebarRightTabs'], ...) (the
same pattern intercept.tsx uses for the async remote.session namespace), so
activation order cannot strand the bridge and a deployment without the
official sidebar stays a silent no-op — the fork's own shell is untouched.
The body reuses the existing ApesProfileView (content is shared, never
duplicated); only the shell-scope binding differs. Guide entry order 70
keeps the apes trio's position in the official entry list.
Verified: typecheck clean; build clean; 1255 passed / 9 skipped (120 files,
6 new bridge specs pinning the two-stage contract, both no-op paths, and the
HMR-safe unwind).
Local commit only — not pushed until the in-app spike is confirmed.
This commit is contained in:
@@ -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<keyof typeof apesZh, string> = {
|
||||
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',
|
||||
}
|
||||
|
||||
@@ -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: <id> }, 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<string, unknown>
|
||||
}, component: (props: Record<string, unknown>) => 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 <ApesProfileView {...viewProps} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>) => 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() }
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user