|
|
|
@@ -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() }
|
|
|
|
|
}
|