/** * 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 { Component, createElement, type ErrorInfo, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { Context } from '../context-types.ts' import { createSidebarStore } from './state.ts' import { Sidebar } from './Sidebar.tsx' import { registerTurnTailInterception } from './intercept.tsx' import { loadPrefs } from './prefs.ts' import { SideCardSection } from './SideCardSection.tsx' import { api } from './api.ts' import { t } from './locales.ts' import css from './sidebar.module.css' import './layout.css' /** Services required before mounting (provided by the client runtime). */ export const inject = ['slots', 'sessions', 'connection'] /** * Error boundary over the sidebar tree: a render error must never blank the * whole panel silently — it shows a dismissible error strip and logs the * stack for diagnosis. */ class SidebarBoundary extends Component<{ children: ReactNode }, { error: string | null }> { state = { error: null as string | null } static getDerivedStateFromError(error: unknown): { error: string } { return { error: error instanceof Error ? error.message : String(error) } } componentDidCatch(error: Error, info: ErrorInfo): void { console.error('[dsh-better-sidebar] render error:', error, info.componentStack) } render(): ReactNode { if (this.state.error !== null) { return (
dsh-better-sidebar: {this.state.error}
) } return this.props.children } } /** * Client plugin body. * @param ctx - the client cordis context (slots, sessions). */ export function apply(ctx: Context): void { // 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() // 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 { ctx.effect(() => { let disposed = false let root: Root | undefined let host: HTMLDivElement | undefined void (async () => { // 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(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }), ]) if (prefs !== null) sidebarStore.setPrefs(prefs) if (disposed) return try { host = document.createElement('div') host.setAttribute('data-dsh-better-sidebar', '') document.body.appendChild(host) root = createRoot(host) root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore }))) } catch (error) { fail('mount', error) } })() return () => { disposed = true root?.unmount() host?.remove() } }, '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', ) // 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 and keeps the shared store in sync. ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'better-sidebar', order: 100, label: () => t('settingsNav'), inject: () => ({ store: sidebarStore }), }, SideCardSection)) } catch (error) { fail('load', error) } }