feat(native): render the plugin's tabs in DSH's native right Sidebar

Phase 2 of the 0.1.5 adaptation: the plugin stops drawing its own right
panel and contributes tab TYPES to DSH's native right Sidebar, which owns
the right column. The bottom workbench (terminal dock) stays plugin-owned.

Registrations (src/client/native/):
- every TabDescriptor becomes a native tab type (kind = descriptor.id, the
  default `extension` band) plus a keyed body in `sidebar.right.pane.tab`
  and a live chip title in `sidebar.right.pane.tab.title`
- the `editor` type also claims `dsh-resource://file/**`, so it outranks
  the built-in `text` preview (fallback) and every file the product opens
  lands in the plugin's editor
- the built-in `files` page kind is TAKEN OVER by an extension
  registration of the same kind: `openTab('files')` draws the plugin's
  explorer, and the built-in resumes when the plugin unregisters
- registrations follow the plugin registry + the side-card settings: a
  type registered later (external plugin) gets its native type too, a
  disabled type is unregistered (absent from the guide, refuses opens)

Adapter (src/client/native/tab-adapter.tsx):
- one synthetic plugin `SidebarTab` per native tab id, minted from the
  native record + navigation params (or the descriptor's own createTab,
  which a native guide open bypasses), kept live across navigations and
  updateTab writes
- the explorer's per-tab expansion/reveal sets and the per-kind instance
  counter, which the plugin's own layout used to hold

Service routing (src/client/service.ts):
- `setSurface` installs the native write face; openTab/openFile land
  natively (a path becomes a resource address, a path-less editor open
  becomes the `files` page, the rest carry the seed as navigation params),
  closeTab/updateTab/activateTab recognise native tab ids
- new optional `OpenTabSeed.target` ('right' default, 'bottom' keeps the
  bottom workbench); the bottom panel's own + menu and auto-terminal pass
  'bottom'
- src/client/native/surface.ts probes the controller's per-session writes
  (`openTabIn`/`openResourceIn`/`closeIn`, not in `ISidebarRight`) and
  queues opens for a session whose surface is not mounted yet

Right panel retired behaviourally (Phase 3 deletes the code): Sidebar
renders its bottom workbench only, keeps the panel collapsed, and hides
the right-panel toggle. The panel-specific e2e specs are removed; the
mount lane now drives the native guide (tab sweep), the plugin explorer
inside the native pane (editor/mermaid/README chunks) and the sidechat
host routes.

Verified: pnpm typecheck / lint / build / test (126 files, 1349 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (7/7).
This commit is contained in:
Menghuan1918
2026-09-09 11:47:31 +08:00
parent ba0114749f
commit 51546f7433
40 changed files with 1560 additions and 1568 deletions
+229
View File
@@ -0,0 +1,229 @@
/**
* The plugin's content, registered into DSH's native right Sidebar.
*
* Every tab the plugin can draw becomes a native tab TYPE plus a BODY:
*
* - the `editor` type is both a page (the files window) and a resource
* viewer — it claims `dsh-resource://file/**` at the default `extension`
* band, which outranks the built-in `text` preview (`fallback`) and the
* built-in `files` tree (`builtin`), so every file the product opens lands
* in the plugin's editor;
* - the built-in `files` page kind is TAKEN OVER by an `extension`
* registration of the same kind, so `openTab('files')` draws the plugin's
* explorer instead of the built-in tree; the built-in resumes when this
* plugin unregisters;
* - every other descriptor (changes / subagent / side chat / terminal /
* browser / diff) becomes a page type of its own.
*
* The registrations follow the plugin's registry lifecycle: a descriptor
* registered later (an external plugin) gets its native type too, and a type
* the user disabled in the side-card settings is unregistered — so it is
* absent from the native guide and `openTab` refuses it.
*/
import type { Context } from '../../context-types.ts'
import { t } from '../locales.ts'
import { parseFileAddress } from '../resource-address.ts'
import type { BetterSidebarService, TabDescriptor } from '../service.ts'
import type { SidebarStore } from '../state.ts'
import {
NativeTabBody,
NativeTabTitle,
type NativeBodyInjected,
type NativeTabInfo,
type NativeTabParams,
type NativeTabRecords,
} from './tab-adapter.tsx'
/** The native tab-type registry face (`ctx.sidebarRightTabs`). */
interface NativeTabRegistry {
register(definition: {
id: string
kind: string
patterns?: readonly string[]
priority?: 'extension' | 'builtin' | 'fallback'
canOpen?: (address: string) => boolean
title: (address: string) => string
guide?: readonly { order: number; title: () => string; description: () => string; icon?: unknown }[]
}): () => void
}
/** The kind the plugin's files window owns (page + file viewer). */
const EDITOR_KIND = 'editor'
/** The built-in page kind this plugin takes over. */
const FILES_KIND = 'files'
/** The plugin's implementation id for a descriptor (unique across kinds). */
function nativeId(descriptorId: string): string {
return `dsh-better-sidebar:${descriptorId}`
}
/** The descriptor's title text, evaluated fresh for the current locale. */
function titleOf(descriptor: TabDescriptor): string {
return typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
}
/** One descriptor's live native registrations. */
interface Registration {
readonly dispose: () => void
}
/** Everything the registrations need. */
export interface NativeSurfaceDeps {
readonly ctx: Context
readonly store: SidebarStore
readonly service: BetterSidebarService
/** The shared native tab record registry (the surface writes it too). */
readonly records: NativeTabRecords
}
/**
* Register the plugin's tabs into the native right Sidebar and keep them in
* step with the plugin's own registry and settings.
* @param deps - client context, the plugin store/service, and the records.
* @returns a disposer unregistering everything.
*/
export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
const { ctx, store, service, records } = deps
const disposers: Array<() => void> = []
// The native seat declares `sidebar.right.pane.tab` when its client half
// activates, which may be after this plugin; the slot registry's `inject`
// re-runs on every declaration lifetime, and the tab-type registry
// (`ctx.sidebarRightTabs`) is provided by that same package, so one wait
// covers both.
const disposeSeat = ctx.slots.inject('sidebar.right.pane.tab', () => {
const tabs = ctx.get('sidebarRightTabs') as unknown as NativeTabRegistry | undefined
if (tabs === undefined) return () => {}
const live = new Map<string, Registration>()
const fileParamsOf = (info: NativeTabInfo): NativeTabParams | undefined => {
const address = parseFileAddress(info.tab.contentId)
return address === undefined ? undefined : { path: address.path }
}
const fileSessionIdOf = (info: NativeTabInfo): string | undefined => {
const address = parseFileAddress(info.tab.contentId)
return address !== undefined && address.scope === 'session' ? address.sessionId : undefined
}
/** Register the body + chip-title slots for one native implementation id. */
const registerSlots = (
id: string,
injected: Omit<NativeBodyInjected, 'sessionId'>,
params: Pick<NativeBodyInjected, 'paramsOf' | 'sessionIdOf'>,
): Array<() => void> => [
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
name: 'sidebar.right.pane.tab',
key: id,
inject: (sessionId: string) => ({ ...injected, ...params, sessionId }),
}, NativeTabBody)),
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
name: 'sidebar.right.pane.tab.title',
key: id,
inject: () => ({ records }),
}, NativeTabTitle)),
]
/** One descriptor's native type + body + title, as one disposable. */
const registerDescriptor = (descriptor: TabDescriptor): (() => void) => {
const id = nativeId(descriptor.id)
const isEditor = descriptor.id === EDITOR_KIND
const icon = descriptor.icon
const disposeType = tabs.register({
id,
kind: descriptor.id,
...(isEditor
? {
patterns: ['dsh-resource://file/**'],
canOpen: (address: string) => parseFileAddress(address) !== undefined,
}
: {}),
// An external implementation outranks the product's own viewers, which
// is what lets the plugin's editor take over file addresses.
priority: 'extension',
title: () => titleOf(descriptor),
...(descriptor.hidden === true
? {}
: {
guide: [{
order: descriptor.order ?? 100,
title: () => titleOf(descriptor),
description: () => t('nativeGuideDesc'),
...(typeof icon === 'function'
? { icon: (props: { size?: number }) => icon(props.size ?? 16) }
: {}),
}],
}),
})
const slots = registerSlots(
id,
{ ctx, store, service, records, descriptorId: descriptor.id },
isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {},
)
return () => {
for (const dispose of slots.reverse()) dispose()
disposeType()
}
}
/** One `files`-kind takeover: the plugin's explorer under the built-in kind. */
const registerFilesKind = (): (() => void) => {
const id = 'dsh-better-sidebar:files'
const disposeType = tabs.register({
id,
kind: FILES_KIND,
priority: 'extension',
title: () => t('files'),
guide: [{
order: 10,
title: () => t('files'),
description: () => t('nativeGuideDesc'),
}],
})
const slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {})
return () => {
for (const dispose of slots.reverse()) dispose()
disposeType()
}
}
/** Bring the live registrations in line with the registry + the settings. */
const sync = (): void => {
const wanted = new Map<string, () => () => void>()
for (const descriptor of service.getTabs()) {
if (!service.isTabEnabled(descriptor.id)) continue
wanted.set(descriptor.id, () => registerDescriptor(descriptor))
}
for (const [descriptorId, registration] of live) {
if (wanted.has(descriptorId)) continue
registration.dispose()
live.delete(descriptorId)
}
for (const [descriptorId, create] of wanted) {
if (live.has(descriptorId)) continue
live.set(descriptorId, { dispose: create() })
}
// The built-in files kind follows the editor type's switch: with the
// editor disabled the plugin has no explorer to put there.
const wantsFiles = service.isTabEnabled(EDITOR_KIND)
const hasFiles = live.has(FILES_KIND)
if (wantsFiles && !hasFiles) live.set(FILES_KIND, { dispose: registerFilesKind() })
if (!wantsFiles && hasFiles) {
live.get(FILES_KIND)?.dispose()
live.delete(FILES_KIND)
}
}
disposers.push(service.subscribe(sync), store.subscribe(sync))
sync()
return () => {
for (const registration of live.values()) registration.dispose()
live.clear()
}
})
return () => {
disposeSeat()
for (const dispose of disposers.reverse()) dispose()
}
}
export type { NativeTabRecords }
+149
View File
@@ -0,0 +1,149 @@
/**
* The plugin's write face over DSH's native right Sidebar (`ctx.sidebarRight`).
*
* The service speaks in the plugin's own vocabulary (tab type, seed, session
* scope); this module turns those into the native surface's vocabulary
* (kind + navigation params, or a `dsh-resource://` address) and forwards
* tab-record operations to the plugin's native record registry.
*
* Two native limits shape the implementation:
*
* - the surface exists only while a session's panel is mounted, and the
* service's public face (`ISidebarRight`) writes only into THAT session.
* The controller also carries `openTabIn` / `openResourceIn` /
* `closeIn`, which act on any session whose store the runtime has minted;
* both are probed at call time, and an open for a session that has no
* store yet is QUEUED and replayed when that session comes on screen;
* - layout state is memory-only, so a queued open is not durable either.
*/
import type { Context } from '../../context-types.ts'
import { fileAddressFor } from '../resource-address.ts'
import type { NativeTabParams, SidebarSurface } from '../service.ts'
import type { NativeTabRecords } from './tab-adapter.tsx'
/** One open the surface could not place yet. */
type Pending =
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean }
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean }
/** The controller face this module uses (a structural slice of `ISidebarRight`). */
interface NativeController {
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
close(tabId: string): void
/** Not part of `ISidebarRight`: the concrete controller's per-session writes. */
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
closeIn?(sessionId: string, tabId: string): void
}
/** The plugin's write face over the native surface. */
export interface NativeSurface extends SidebarSurface {
/** Replay opens that were queued for a session that had no mounted surface. */
flushPending(): void
/** Stop observing the session list. */
dispose(): void
}
/** The active session id, as the client list reports it. */
function activeSessionId(ctx: Context): string | undefined {
try {
return ctx.sessions.list.getSnapshot().current
} catch {
return undefined
}
}
/**
* Bind the plugin's write face to the native controller.
* @param ctx - the client context (session list + `ctx.sidebarRight`).
* @param records - the plugin's native tab record registry.
* @returns the surface, plus a disposer unbinding its session subscription.
*/
export function createNativeSurface(ctx: Context, records: NativeTabRecords): NativeSurface {
const pending: Pending[] = []
const controller = (): NativeController | undefined =>
ctx.get('sidebarRight') as unknown as NativeController | undefined
const place = (entry: Pending): boolean => {
const api = controller()
if (api === undefined) return false
const active = activeSessionId(ctx)
const onScreen = active !== undefined && active === entry.sessionId
if (entry.kind === 'tab') {
const options = { params: entry.params, revealIfOpened: entry.revealIfOpened }
if (onScreen) {
api.openTab(entry.tabKind, options)
return true
}
if (api.openTabIn !== undefined) {
api.openTabIn(entry.sessionId, entry.tabKind, options)
return true
}
return false
}
const options = {
...(entry.line === undefined ? {} : { params: { line: entry.line } }),
revealIfOpened: entry.revealIfOpened,
}
if (onScreen) {
api.openResource(entry.address, options)
return true
}
if (api.openResourceIn !== undefined) {
api.openResourceIn(entry.sessionId, entry.address, options)
return true
}
return false
}
const flushPending = (): void => {
if (pending.length === 0) return
for (let index = pending.length - 1; index >= 0; index--) {
const entry = pending[index]
if (entry !== undefined && place(entry)) pending.splice(index, 1)
}
}
const enqueue = (entry: Pending): void => {
if (!place(entry)) pending.push(entry)
}
const unsubscribe = ctx.sessions.list.subscribe(flushPending)
return {
openTab({ sessionId, kind, params, revealIfOpened }) {
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened })
},
openResource({ sessionId, address, line, revealIfOpened }) {
enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened })
},
fileAddress(sessionId, cwd, path) {
return fileAddressFor(sessionId, cwd, path)
},
close(sessionId, tabId) {
const record = records.get(tabId)
if (record === undefined) return undefined
records.drop(tabId)
const api = controller()
if (api !== undefined) {
if (sessionId === activeSessionId(ctx)) api.close(tabId)
else if (api.closeIn !== undefined) api.closeIn(sessionId, tabId)
}
return { type: record.tab.type, title: record.tab.title }
},
update(tabId, patch) {
if (!records.has(tabId)) return false
records.update(tabId, patch)
return true
},
activate(tabId) {
// The native surface has no cross-pane activation face the plugin needs:
// a tab is focused by opening its (kind, address) again, which the
// native open already de-duplicates.
return records.has(tabId)
},
has: tabId => records.has(tabId),
flushPending,
dispose: () => { unsubscribe() },
}
}
+338
View File
@@ -0,0 +1,338 @@
/**
* The plugin's side of a native right-Sidebar tab.
*
* A plugin tab descriptor is one registration in the plugin's own registry
* (`ctx.betterSidebar`); this module adapts the native tab record to that
* descriptor's component contract. The native surface carries the tab's
* record (id, kind, title, content identity, navigation params, lifetime
* signal); the plugin's components expect a `SidebarTab` plus the explorer
* state the plugin's own layout used to hold — both live here.
*
* The adapter owns three things the native layout does not carry:
*
* - a synthetic plugin `SidebarTab` per native tab id, minted from the native
* record + `navigation.params` and kept live across navigations, so a
* component that retitles itself (`updateTab`) or rewrites its path (the
* editor's in-place file switch) keeps working;
* - the explorer's per-tab expansion/reveal sets;
* - the per-kind instance counter behind titles like "Terminal 2".
*
* Nothing here is a singleton: the registry is created once per client
* activation and handed to every registration.
*/
import { createElement, useEffect, useMemo, useSyncExternalStore } from 'react'
import type { ComponentType, ReactNode } from 'react'
import type { Context } from '../../context-types.ts'
import type { SessionScope } from '../api.ts'
import { RenderBoundary } from '../RenderBoundary.tsx'
import { OrphanedTab } from '../OrphanedTab.tsx'
import { referenceInChat } from '../reference-in-chat.ts'
import type { BetterSidebarService } from '../service.ts'
import type { SidebarStore, SidebarTab, TabType } from '../state.ts'
import css from '../sidebar.module.css'
/**
* The plugin-side seed a native open carries in `navigation.params`.
* JSON-shaped by convention (the native surface does not validate it).
*/
export interface NativeTabParams {
/** Overrides the descriptor's title for this instance. */
readonly title?: string
/** A file path (the editor window's content seed). */
readonly path?: string
/** A URL the tab navigates to on mount (the browser tab's seed). */
readonly url?: string
/** A diff reference (the diff tab's content seed). */
readonly diff?: SidebarTab['diff']
/** JSON-serializable custom state carried on the synthetic record. */
readonly meta?: unknown
/** A line to land on (file addresses carry it as a navigation parameter). */
readonly line?: number
}
/** The native tab information this adapter reads (structural mirror of `useTabInfo`). */
export interface NativeTabInfo {
readonly tab: {
readonly id: string
readonly kind: string
readonly title: string
readonly contentId: string
readonly visible: boolean
readonly navigation: {
readonly address: string
readonly params: NativeTabParams | undefined
readonly revision: number
}
readonly signal: AbortSignal
}
}
/** One native tab's plugin-side view state. */
interface View {
tab: SidebarTab
scope: SessionScope
expanded: string[]
revealed: string[]
/** Bumped on every mutation; the components subscribe to it. */
version: number
}
/** The plugin-side record registry for native tabs. */
export interface NativeTabRecords {
/**
* The synthetic record for a native tab, minted on first sight and kept
* across navigations (a navigation refreshes the seed fields, never the
* identity or a plugin-side title/meta mutation).
* @param input - the native record and the session it lives in.
* @returns the current view state.
*/
ensure(input: {
id: string
kind: string
title: string
params: NativeTabParams | undefined
scope: SessionScope
/**
* The descriptor's own factory, called ONCE for a record that arrives
* without seed fields (a native guide open, which knows nothing about the
* plugin's per-instance minting): it supplies the title and the meta a
* view needs — the side chat's thread bootstrap, the terminal's name.
*/
mint?: () => { title?: string; meta?: unknown } | undefined
}): View
/** One record by native tab id. */
get(id: string): View | undefined
/** Whether this id belongs to a native tab (vs the plugin's own layout). */
has(id: string): boolean
/** Merge a patch into the synthetic record (the `updateTab` path). */
update(id: string, patch: { title?: string; path?: string; meta?: unknown }): void
/** Forget a record (the native tab closed). */
drop(id: string): void
/** Toggle one directory in a record's expansion set. */
toggleExpanded(id: string, path: string): void
/** Mint the next instance number of a kind (titles like "Terminal 2"). */
nextInstance(kind: string): number
/** A per-record version for `useSyncExternalStore`. */
versionOf(id: string): number
/** Subscribe to record changes (title/path/meta/expanded). */
subscribe(listener: () => void): () => void
}
/** Create the record registry for one client activation. */
export function createNativeTabRecords(): NativeTabRecords {
const views = new Map<string, View>()
const instances = new Map<string, number>()
const listeners = new Set<() => void>()
const notify = (): void => { for (const listener of listeners) listener() }
const put = (id: string, view: View): void => {
views.set(id, { ...view, version: view.version + 1 })
notify()
}
return {
ensure({ id, kind, title, params, scope, mint }) {
const existing = views.get(id)
if (existing === undefined) {
const seeded = params?.title === undefined && params?.meta === undefined ? mint?.() : undefined
const meta = params?.meta ?? seeded?.meta
const minted: View = {
tab: {
id,
type: kind as TabType,
title: params?.title ?? seeded?.title ?? title,
...(params?.path === undefined ? {} : { path: params.path }),
...(params?.diff === undefined ? {} : { diff: params.diff }),
...(meta === undefined ? {} : { meta }),
},
scope,
expanded: [],
revealed: [],
version: 0,
}
views.set(id, minted)
return minted
}
// A navigation may carry new seed fields (the editor's in-place switch,
// a browser tab pointed at another URL); the record's identity and any
// plugin-side mutation (title/meta from updateTab) stay.
const patch: Partial<SidebarTab> = {}
if (params?.path !== undefined && params.path !== existing.tab.path) patch.path = params.path
if (params?.diff !== undefined) patch.diff = params.diff
if (params?.url !== undefined) {
const meta = typeof existing.tab.meta === 'object' && existing.tab.meta !== null
? existing.tab.meta as Record<string, unknown>
: {}
patch.meta = { ...meta, url: params.url }
}
if (existing.scope.cwd !== scope.cwd) {
views.set(id, { ...existing, scope, tab: { ...existing.tab, ...patch } })
return views.get(id)!
}
if (Object.keys(patch).length === 0) return existing
const next: View = { ...existing, tab: { ...existing.tab, ...patch } }
views.set(id, next)
return next
},
get: id => views.get(id),
has: id => views.has(id),
update(id, patch) {
const entry = views.get(id)
if (entry === undefined) return
put(id, { ...entry, tab: { ...entry.tab, ...patch } })
},
drop(id) {
if (views.delete(id)) notify()
},
toggleExpanded(id, path) {
const entry = views.get(id)
if (entry === undefined) return
const expanded = entry.expanded.includes(path)
? entry.expanded.filter(candidate => candidate !== path)
: [...entry.expanded, path]
put(id, { ...entry, expanded })
},
nextInstance(kind) {
const next = (instances.get(kind) ?? 0) + 1
instances.set(kind, next)
return next
},
versionOf: id => views.get(id)?.version ?? 0,
subscribe(listener) {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
}
}
/** What a body registration injects (the plugin's business face). */
export interface NativeBodyInjected {
readonly sessionId: string
readonly ctx: Context
readonly store: SidebarStore
readonly service: BetterSidebarService
readonly records: NativeTabRecords
/** The descriptor id this body draws (one registration per descriptor). */
readonly descriptorId: string
/**
* Extra seed fields derived from the native record — a file address carries
* its path there, not in `navigation.params`.
*/
readonly paramsOf?: (info: NativeTabInfo) => NativeTabParams | undefined
/**
* The session the body acts in, when the record names one (a
* `session`-scoped file address names its own session); absent falls back to
* the session the slot is scoped to.
*/
readonly sessionIdOf?: (info: NativeTabInfo) => string | undefined
}
/** The props the slot framework adds to every tab body and title. */
export interface NativeBodyFrameworkProps {
readonly useTabInfo: () => NativeTabInfo
}
/** Subscribe a component to its own record's mutations. */
function useRecordVersion(records: NativeTabRecords, id: string): number {
return useSyncExternalStore(
listener => records.subscribe(listener),
() => records.versionOf(id),
)
}
/** The current session's workspace root, live from the client session list. */
function useSessionCwd(ctx: Context, sessionId: string): string | undefined {
return useSyncExternalStore(
useMemo(() => (listener: () => void) => ctx.sessions.list.subscribe(listener), [ctx]),
() => ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd,
)
}
/**
* One plugin tab rendered inside the native right Sidebar: the descriptor's
* own component with the plugin's props, over a synthetic record minted from
* the native tab and dropped when the record ends.
*/
export function NativeTabBody(props: NativeBodyInjected & NativeBodyFrameworkProps): ReactNode {
const { ctx, store, service, records, descriptorId, useTabInfo } = props
const info = useTabInfo()
const nativeTab = info.tab
const version = useRecordVersion(records, nativeTab.id)
const sessionId = props.sessionIdOf?.(info) ?? props.sessionId
const cwd = useSessionCwd(ctx, sessionId)
const scope = useMemo((): SessionScope => ({ sessionId, cwd }), [sessionId, cwd])
// `version` is not read: it only forces this render when the record changed.
void version
const derived = props.paramsOf?.(info)
const params = derived === undefined && nativeTab.navigation.params === undefined
? undefined
: { ...derived, ...nativeTab.navigation.params }
const descriptor = service.getTab(descriptorId)
const view = records.ensure({
id: nativeTab.id,
kind: nativeTab.kind,
title: nativeTab.title,
params,
scope,
mint: () => {
const state = store.getSnapshot().state
if (descriptor?.createTab === undefined || state === undefined) return undefined
const minted = descriptor.createTab(state)
return minted === null ? undefined : { title: minted.tab.title, meta: minted.tab.meta }
},
})
useEffect(() => () => { records.drop(nativeTab.id) }, [records, nativeTab.id])
if (descriptor === undefined) {
return createElement(OrphanedTab, { ctx, store, scope, tab: view.tab, visible: nativeTab.visible })
}
return createElement(
RenderBoundary,
{ className: css.tabBoundaryError },
createElement(descriptor.component, {
ctx,
store,
scope,
tab: view.tab,
visible: nativeTab.visible,
expanded: view.expanded,
revealed: view.revealed,
onToggleDir: (path: string) => { records.toggleExpanded(nativeTab.id, path) },
onReferenceFile: (path: string, isDir: boolean) => { referenceInChat(ctx, sessionId, cwd, path, isDir) },
onOpenDiff: (tab: SidebarTab) => {
service.openTab({
type: 'diff',
title: tab.title,
id: tab.id,
...(tab.diff === undefined ? {} : { diff: tab.diff }),
}, scope)
},
onSubagentJump: (childSessionId: string) => {
service.openTab({ type: 'subagent', meta: { childSessionId } }, scope)
},
}),
)
}
/** What a title registration injects. */
export interface NativeTitleInjected {
readonly records: NativeTabRecords
}
/**
* A live tab chip: the synthetic record's title (the editor rewrites it on an
* in-place file switch, the side chat on the thread's first prompt). Without
* this registration the chip would keep the title captured when the tab
* opened.
*/
export function NativeTabTitle(props: NativeTitleInjected & NativeBodyFrameworkProps): ReactNode {
const { records, useTabInfo } = props
const nativeTab = useTabInfo().tab
return useSyncExternalStore(
listener => records.subscribe(listener),
() => records.get(nativeTab.id)?.tab.title ?? nativeTab.title,
)
}
/** The component pair one descriptor contributes to the native surface. */
export interface NativeTabComponents {
readonly body: ComponentType<NativeBodyInjected & NativeBodyFrameworkProps>
readonly title: ComponentType<NativeTitleInjected & NativeBodyFrameworkProps>
}