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:
figmar
2026-10-03 20:42:13 +08:00
parent 9ea9e9b9fa
commit 96cc334143
5 changed files with 358 additions and 1 deletions
+3
View File
@@ -33,6 +33,8 @@ export const apesZh = {
apesProfileNoCwd: '未知', apesProfileNoCwd: '未知',
apesProfileRunning: '运行中', apesProfileRunning: '运行中',
apesProfileIdle: '空闲', apesProfileIdle: '空闲',
/** Guide-page capsule description (official right sidebar's entry list). */
apesProfileGuideDesc: '当前会话的身份与工作目录',
// Apps view // Apps view
apesAppsNoFeed: '当前 DSH 版本未提供工作区应用数据源', apesAppsNoFeed: '当前 DSH 版本未提供工作区应用数据源',
apesAppsWorkspace: '工作区', apesAppsWorkspace: '工作区',
@@ -54,6 +56,7 @@ export const apesEn: Record<keyof typeof apesZh, string> = {
apesProfileNoCwd: 'unknown', apesProfileNoCwd: 'unknown',
apesProfileRunning: 'running', apesProfileRunning: 'running',
apesProfileIdle: 'idle', apesProfileIdle: 'idle',
apesProfileGuideDesc: "This session's identity and working directory",
apesAppsNoFeed: 'This DSH version exposes no workspace-app feed', apesAppsNoFeed: 'This DSH version exposes no workspace-app feed',
apesAppsWorkspace: 'Workspace', apesAppsWorkspace: 'Workspace',
} }
+187
View File
@@ -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() }
}
+1 -1
View File
@@ -164,7 +164,7 @@ function ApesAgentsView(props: TabComponentProps) {
} }
/** apes:profile — the current session's identity (title, cwd, running state). */ /** 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 list = useSessionList(props.ctx)
const current = list.current const current = list.current
const summary = current === undefined ? undefined : list.byId[current] const summary = current === undefined ? undefined : list.byId[current]
+11
View File
@@ -16,6 +16,7 @@ import { createBetterSidebarService, matchUrlTarget } from './service.ts'
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts' import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts' import { registerBuiltins } from './builtins/index.ts'
import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './apes/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 { Sidebar } from './Sidebar.tsx'
import { RenderBoundary } from './RenderBoundary.tsx' import { RenderBoundary } from './RenderBoundary.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx' import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
@@ -180,6 +181,16 @@ export function apply(ctx: Context): void {
() => registerApesTabs(ctx, service), () => registerApesTabs(ctx, service),
'dsh-apes-sidebar: register apes tabs', '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 // 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 // silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom. // to the page so a blank panel is never the only symptom.
+156
View File
@@ -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()
})
})