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: '未知',
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',
}
+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). */
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]
+11
View File
@@ -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.