Files
apes-sidebar/src/client/index.tsx
T
LuckVd 197b10a3fe fix(sidebar): read betterSidebar via ctx.get() in all internal consumers
Under the DSH 0.1.1-rc.x web shell, the Cordis plugin loader registers
the provided betterSidebar impl on a neighboring fiber that is not on
the rendering fiber parent chain, so every direct ctx.betterSidebar
read inside the plugin own tree throws

  cannot get property "betterSidebar" without inject

and the panel dies inside RenderBoundary on page load (observed with
0.14.0 and 0.15.2 on npm-installed dsh 0.1.1-rc.2). This is the same
failure class #135 fixed for the dsh-web-mobile path; declaring
betterSidebar in the plugin own inject would deadlock the fiber (it
waits for the service before running apply, but the service is only
provided inside apply).

ctx.get() resolves through the root reflect store and is unaffected by
the fiber chain, so all 26 internal reads switch to it. External
consumers keep using ctx.betterSidebar after injecting it, unchanged.
Test mocks gain a matching get() shim.
2026-08-24 00:45:50 +08:00

424 lines
20 KiB
TypeScript

/**
* Client half of dsh-better-sidebar: resolves the user's "Side card"
* preferences through the plugin's own fenced settings route, mounts the
* right sidebar portal (inside an error boundary so a rendering failure
* shows an error strip instead of a blank panel), registers the turn-tail
* interception, and contributes the Side card settings section to the DSH
* Settings shell. Requires the runtime's slots and sessions services; the
* bundle itself is a module-table consumer only (react + ui-primitives +
* xterm, all provided or inlined).
*/
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
import { loadExternalDisable, loadPrefs } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en,
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
zhHK, zhTW, zhMO,
} from './locales.ts'
import css from './sidebar.module.css'
import './layout.css'
/** Services required before mounting (provided by the client runtime; the
* locale service backs the sidebar's copy — see locales.ts). `modules`
* (rc.8+) is the client module system the chunk loader resolves its
* externals through — Cordis guards service access without inject. */
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale', 'modules']
/**
* Error boundary over the sidebar tree (root scope): a render error in the
* sidebar SHELL itself must never blank the page silently — the shared
* RenderBoundary shows a dismissible error strip and logs the stack. The
* per-tab scope (Sidebar.tsx) catches viewer/editor crashes first; this root
* boundary stays as the last resort for Workbench/shell errors.
*/
/**
* Client plugin body.
* @param ctx - the client cordis context (slots, sessions).
*/
export function apply(ctx: Context): void {
// The sidebar follows the DSH i18n system: attach the locale service so
// the module-level t()/isZh() resolve the Host-backed language preference
// (and switch live — the Sidebar root subscribes to it), and register the
// plugin's dictionaries into the shared locale registry. The disposers
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
attachLocale(ctx.locale)
ctx.effect(() => {
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
return () => { offZh(); offEn() }
}, 'dsh-better-sidebar: dictionaries')
// Opt-in third-language support through @huanlin/dsh-plugin-better-locale.
// When that plugin is installed, it publishes `ctx.betterLocale` (the
// override store) and patches LocaleRuntime.prototype.lookup to consult
// it. We mirror the same override awareness into the sidebar's own `t()`:
// attachBetterLocale() makes t() consult the store's getOverride first,
// so the sidebar's chrome (which bypasses ctx.locale and calls t()
// directly) also switches to the override language. We also register
// the ja dict with the better-locale store so external callers of
// ctx.locale.lookup('betterSidebar', key) get the override text too.
//
// Activation-order-safe: ctx.get('betterLocale') is a non-reactive read
// (cordis only re-evaluates declared `inject` deps). If better-locale
// activates after better-sidebar, the initial read returns undefined.
// We subscribe to the locale revision — better-locale bumps it on
// activation (when a persisted override exists) and on every override
// switch — and re-check ctx.get on each bump, attaching + registering
// the ja dict once the store becomes available.
ctx.effect(() => {
let dispose: (() => void) | undefined
const sync = (): void => {
dispose?.()
dispose = undefined
const store = ctx.get('betterLocale') as
| {
readonly active: string | undefined
getOverride(dshActive: string, ns: string, key: string): string | undefined
isOverrideActive(dshActive: string): boolean
register(ns: string, dicts: Record<string, Record<string, string>>): () => void
subscribe(listener: () => void): () => void
}
| undefined
attachBetterLocale(store)
if (store !== undefined) {
dispose = store.register(LOCALE_NS, {
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
'zh-HK': zhHK, 'zh-TW': zhTW, 'zh-MO': zhMO,
})
}
}
// Initial check (picks up the store if better-locale activated first).
sync()
// Re-check on every locale revision bump (better-locale bumps when it
// activates with a persisted override, and when the user switches).
const unsubscribe = ctx.locale.subscribe(sync)
return () => {
unsubscribe()
dispose?.()
attachBetterLocale(undefined)
}
}, 'dsh-better-sidebar: better-locale lazy integration')
// One store instance per activation: production code creates it only here,
// then hands it to the mounted panel and closes over it in the slot
// registrations (the official createXXXStore() factory rule — no
// module-level singleton).
const sidebarStore = createSidebarStore()
// The sidebar registry service: external plugins register tab types and
// file previewers through `ctx.betterSidebar.registerTab/registerFileViewer`.
// Published before the panel mounts so consumers injecting 'betterSidebar'
// are ready by the time the sidebar renders.
const service = createBetterSidebarService(sidebarStore)
ctx.provide('betterSidebar', service)
// Terminal tab titles use the host's effective shell name (e.g. bash/zsh)
// instead of "Terminal 1". Start with a safe fallback and replace it as
// soon as the host shell info resolves. Tabs created before the response
// arrives keep the fallback title, so also retitle any already-open UI
// terminal tabs that still carry it.
const fallbackTitle = t('terminal')
let terminalTitle = fallbackTitle
void api.shellGet().then(({ name }) => {
terminalTitle = name
const snapshot = service.getSnapshot()
if (snapshot.state === undefined) return
const tabs = allLeaves(snapshot.state.splits)
.concat(allLeaves(snapshot.state.bottomSplits))
.flatMap(leaf => leaf.tabs)
for (const tab of tabs) {
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
service.updateTab(tab.id, { title: name })
}
}
}).catch(() => { /* keep fallback */ })
// Register the plugin's own built-in tabs and viewers through the same
// service (eating our own dogfood). The disposer unregisters them on
// fiber disposal (HMR-safe).
ctx.effect(
() => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }),
'dsh-better-sidebar: register built-in tabs and viewers',
)
// 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.
const fail = (phase: string, error: unknown): void => {
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
try {
const bar = document.createElement('div')
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
document.body.appendChild(bar)
} catch {
// Nothing left to report with.
}
}
try {
// rc.8+ exposes the client module system as the `ctx.modules` service
// (no window.__DSH_MODULES__ page global anymore); the chunk loader needs
// it to resolve its externals, so inject it before anything can load a
// lazy chunk. The loader falls back to the rc.7 global when absent.
setChunkModuleSystem(ctx.modules)
// Fresh chunk state for this activation: drop per-test fixtures and
// revalidate loaded chunk scripts against the bundle route's ETags —
// unchanged chunks keep their resolved exports (no re-inject /
// re-execute on HMR), changed ones are dropped for a clean re-fetch.
void revalidateChunksOnReactivate()
ctx.effect(() => {
let disposed = false
let root: Root | undefined
let host: HTMLDivElement | undefined
let mounted = false
let bodyObserver: MutationObserver | undefined
let hostCheckFrame: number | null = null
const unmount = (): void => {
if (!mounted) return
mounted = false
bodyObserver?.disconnect()
bodyObserver = undefined
if (hostCheckFrame !== null) {
cancelAnimationFrame(hostCheckFrame)
hostCheckFrame = null
}
root?.unmount()
root = undefined
host?.remove()
host = undefined
}
/** Re-attach the host if the page (a desktop shell wrapper, SPA
* navigation, …) ever removes it from <body>. Cheap: childList only,
* no subtree, no attribute filtering. */
const guardAnchor = (): void => {
if (bodyObserver !== undefined) return
bodyObserver = new MutationObserver(() => {
if (host !== undefined && !document.body.contains(host)) {
document.body.appendChild(host)
}
})
bodyObserver.observe(document.body, { childList: true })
}
/** One-shot geometry self-check: if the host page transforms
* <html>/<body> itself (exotic shells), a fixed panel host would
* track the transformed box instead of the viewport. Flip the
* degraded mode and pin the host to the viewport every frame until
* the ancestor transform is actually gone. The normal path (no
* page-level transform) never runs the sync loop. */
const scheduleHostCheck = (): void => {
hostCheckFrame ??= requestAnimationFrame(() => {
hostCheckFrame = null
const layer = host?.querySelector<HTMLElement>('[data-dsh-panel-host]')
if (layer === null || layer === undefined) return
const rect = layer.getBoundingClientRect()
const mismatched = Math.abs(rect.left) > 8 || Math.abs(rect.top) > 8
|| Math.abs(rect.width - window.innerWidth) > 8 || Math.abs(rect.height - window.innerHeight) > 8
if (!mismatched) {
layer.removeAttribute('data-dsh-panel-host-degraded')
layer.style.transform = ''
return
}
layer.setAttribute('data-dsh-panel-host-degraded', '')
console.warn('[dsh-better-sidebar] panel host geometry mismatch — a page-level transform was detected; using degraded viewport sync')
// Track our own compensating translation so the loop judges the
// UNCORRECTED geometry: clearing degraded mode must wait for the
// ancestor transform to actually disappear — the frame right after
// our correction applies would otherwise look "fixed" and the
// offset would return immediately (CR #232 P1).
let applied = { x: 0, y: 0 }
const sync = (): void => {
const r = layer.getBoundingClientRect()
const rawLeft = r.left - applied.x
const rawTop = r.top - applied.y
if (Math.abs(rawLeft) <= 1 && Math.abs(rawTop) <= 1
&& Math.abs(r.width - window.innerWidth) <= 1 && Math.abs(r.height - window.innerHeight) <= 1) {
layer.removeAttribute('data-dsh-panel-host-degraded')
layer.style.transform = ''
return
}
const next = { x: -rawLeft, y: -rawTop }
if (next.x !== applied.x || next.y !== applied.y) {
applied = next
layer.style.transform = `translate(${applied.x}px, ${applied.y}px)`
}
hostCheckFrame = requestAnimationFrame(sync)
}
hostCheckFrame = requestAnimationFrame(sync)
})
}
const mount = (): void => {
if (mounted || disposed) return
try {
host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
mounted = true
guardAnchor()
scheduleHostCheck()
} catch (error) {
fail('mount', error)
}
}
const sync = async (): Promise<void> => {
if (disposed) return
// Resolve the user's side card prefs BEFORE the first session seeds,
// so a brand-new conversation opens (or stays closed) at the chosen
// width from first paint. A settings route failure falls back to the
// schema defaults; the sidebar still mounts (a stalled wire gives up
// after the timeout and mounts on the defaults).
const prefs = await Promise.race([
loadPrefs(api),
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
])
if (prefs !== null) sidebarStore.setPrefs(prefs)
if (disposed) return
// Mutual exclusion with the dsh-web-ui family right panel: while the
// aionui-panel provider is selected, the sidebar must not mount at
// all. Re-evaluated on every settings-document update (live switch).
const suspended = await loadExternalDisable(api)
if (disposed) return
sidebarStore.setSuspended(suspended)
if (suspended) unmount()
else mount()
}
void sync()
// Live re-evaluation: the runtime broadcasts settings-document updates
// (the aionui card saves through the same document). Best effort —
// deployments without the 'remote' service fall back to boot-time
// evaluation only.
const remote = ctx.get('remote') as { $on?: (event: string, listener: () => void) => () => void } | undefined
const offRemote = remote?.$on?.('settings/document-updated', () => { void sync() })
return () => {
disposed = true
offRemote?.()
unmount()
}
}, 'dsh-better-sidebar: sidebar mount')
ctx.effect(
() => {
try {
return registerTurnTailInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: turn-tail interception',
)
ctx.effect(
() => {
try {
return registerOpenPathInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: open-path interception',
)
ctx.effect(
() => {
try {
// External http(s) links in the chat/GUI open the sidebar instead
// of a new window. Gated on the browserInterceptLinks MASTER pref,
// the URL's protocol flag (browserInterceptHttp / Https — https
// defaults OFF: most https sites refuse iframe embedding), and the
// target tab's enable switch; Ctrl/Cmd+click always bypasses. The
// target is the first registered tab whose `urlTarget` claims the
// URL (enabled tabs only), else the built-in browser tab.
const urlTargetOf = (url: URL): string | undefined => {
const prefs = sidebarStore.getPrefs()
const enabled = service.getTabs().filter(tab => prefs.tabsEnabled[tab.id] !== false)
return matchUrlTarget(enabled, url)?.id
}
return registerLinkInterception({
takeoverEnabled: (url) => {
if (sidebarStore.getSuspended()) return false
const prefs = sidebarStore.getPrefs()
if (prefs.browserInterceptLinks === false) return false
const protocolOn = url.protocol === 'https:'
? prefs.browserInterceptHttps !== false
: prefs.browserInterceptHttp !== false
if (!protocolOn) return false
// A plugin claim is the target (already enabled-filtered);
// otherwise the built-in browser must be enabled.
return urlTargetOf(url) !== undefined || prefs.tabsEnabled['browser'] !== false
},
openInSidebar: (url) => {
let title: string | undefined
try { title = new URL(url).hostname } catch { /* keep the default title */ }
const type = urlTargetOf(new URL(url)) ?? 'browser'
ctx.get('betterSidebar')?.openTab({ type, url, title })
},
selfOrigin: window.location.origin,
})
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: link interception',
)
// The IME guard: composition keys (candidate arrows, confirm, cancel)
// belong to the input method, never to page JS. Inlined third-party UI
// (formerly Univer's office controls, #562 regression) has shipped
// unguarded keydown handlers that hijack ArrowUp/ArrowDown and break
// Chinese input; the document-capture guard neutralizes the whole class
// before React or any native listener sees the event. Registered as
// early as possible so no other capture-phase listener can win the
// ordering race.
ctx.effect(
() => {
try {
return registerImeGuard()
} catch (error) {
fail('ime guard', error)
return undefined
}
},
'dsh-better-sidebar: IME composition guard',
)
// DSH 0.1.x does not yet carry an icon through the settings.section
// registration contract: its shell renders a generic gear for every
// external section. Mark only this plugin's localized nav row so
// layout.css can paint the requested Side card SVG; the disposer clears
// the marker for HMR / plugin disable.
ctx.effect(
() => registerSettingsNavIcon(() => t('settingsNav')),
'dsh-better-sidebar: settings navigation icon',
)
// The "Side card" settings section: appears in the DSH Settings shell
// once the shell's declaration is on the ledger (slots.inject waits for
// it); the section reads/writes the prefs through the plugin's own
// fenced settings route, keeps the shared store in sync, and renders the
// declarative enable/disable inventory from the tab/viewer registry.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'better-sidebar',
order: 100,
label: () => t('settingsNav'),
inject: () => ({ store: sidebarStore, service }),
}, SideCardSection))
} catch (error) {
fail('load', error)
}
}