/** * One slot consumer component per slot id. * * Each consumer: * 1. Reads the slot registry via PluginContextProvider. * 2. Filters claims for its slot id (and any additional prop-based filter). * 3. Renders each contribution wrapped in a per-claim SlotErrorBoundary * and a CurrentPluginLayer (so plugin hooks work correctly). * 4. Renders nothing when zero claims match. */ import type { IntentNode } from "@blackbelt-technology/pi-dashboard-shared/dashboard-plugin/intent-types.js"; import type { SlotId } from "@blackbelt-technology/pi-dashboard-shared/dashboard-plugin/slot-types.js"; import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js"; import React, { useCallback, useState } from "react"; import { useLocation, useRoute } from "wouter"; import { IntentRenderer } from "./intent-renderer.js"; import { useSlotIntents } from "./intent-store.js"; import { sendPluginAction } from "./plugin-action-bridge.js"; import { CurrentPluginLayer, useSlotRegistryOrNull } from "./plugin-context.js"; import { useShellSessionOrNull } from "./shell-sessions-context.js"; import { SlotErrorBoundary } from "./slot-error-boundary.js"; import type { FolderDescriptor } from "./slot-registry.js"; import { forActionId, forFolder, forSession, forSessionRendered, forToolName, type SlotRegistry } from "./slot-registry.js"; /** * Returns true when at least one plugin claim exists for `slotId` AND matches * the given `session` per the slot's session targeting rules AND would * actually render visible output (i.e. its `shouldRender(session)` returns * `true`, or no `shouldRender` is declared). Lets call sites conditionally * render parent containers (e.g. titled subcards) without triggering the * slot's own render path twice. * * Note: this consults `shouldRender` (introduced by change * `auto-hide-empty-session-subcards`). Claims whose component conditionally * returns `null` should declare `shouldRender` so this hook reports `false` * and parent wrappers hide cleanly. */ export function useSlotHasClaimsForSession(slotId: SlotId, session: DashboardSession): boolean { const registry = useSlotRegistryOrNull(); if (!registry) return false; return forSessionRendered(registry.getClaims(slotId), session).length > 0; } // ── Helpers ─────────────────────────────────────────────────────────────────── function renderClaim( claim: { pluginId: string; Component?: React.ComponentType> }, slotId: string, props: Record, ) { if (!claim.Component) return null; const Comp = claim.Component; return ( ); } /** * Render an entry from the IntentStore. Wraps in a SlotErrorBoundary + * CurrentPluginLayer so plugin-hook semantics match legacy refs claims. * * See change: adopt-server-driven-intent-rendering. */ function renderIntent( pluginId: string, slotId: SlotId, intent: IntentNode, sessionId: string | null, ) { return ( sendPluginAction(pluginId, sessionId, action, payload)} /> ); } // ── Slot consumers ──────────────────────────────────────────────────────────── export function SidebarFolderSectionSlot({ folder }: { folder: FolderDescriptor }) { const registry = useSlotRegistryOrNull(); if (!registry) return null; const claims = forFolder(registry.getClaims("sidebar-folder-section"), folder); if (!claims.length) return null; return ( <> {claims.map(c => renderClaim(c as Parameters[0], "sidebar-folder-section", { folder }), )} ); } /** * `worktree-card-section` — folder-scoped block rendered INSIDE a worktree * session card, scoped to the worktree's own `cwd` (not the parent repo it * collapses under). The KB plugin claims this slot with `FolderKbSection` so a * worktree — which groups under its `gitWorktree.mainPath` and therefore never * gets its own sidebar folder card — still surfaces its own KB row. * See change: kb-row-on-worktree-session-card. */ export function WorktreeCardSectionSlot({ folder }: { folder: FolderDescriptor }) { const registry = useSlotRegistryOrNull(); if (!registry) return null; const claims = forFolder(registry.getClaims("worktree-card-section"), folder); if (!claims.length) return null; return ( <> {claims.map(c => renderClaim(c as Parameters[0], "worktree-card-section", { folder, placement: "card" }), )} ); } export function SessionCardBadgeSlot({ session }: { session: DashboardSession }) { const registry = useSlotRegistryOrNull(); const intents = useSlotIntents("session-card-badge", session.id); const legacyClaims = registry ? forSessionRendered(registry.getClaims("session-card-badge"), session) : []; if (!legacyClaims.length && intents.size === 0) return null; return ( <> {legacyClaims.map((c) => renderClaim(c as Parameters[0], "session-card-badge", { session }), )} {Array.from(intents.entries()).map(([pluginId, intent]) => renderIntent(pluginId, "session-card-badge", intent, session.id), )} ); } export function SessionCardActionBarSlot({ session }: { session: DashboardSession }) { const registry = useSlotRegistryOrNull(); const intents = useSlotIntents("session-card-action-bar", session.id); const legacyClaims = registry ? forSessionRendered(registry.getClaims("session-card-action-bar"), session) : []; if (!legacyClaims.length && intents.size === 0) return null; return ( <> {legacyClaims.map((c) => renderClaim(c as Parameters[0], "session-card-action-bar", { session }), )} {Array.from(intents.entries()).map(([pluginId, intent]) => renderIntent(pluginId, "session-card-action-bar", intent, session.id), )} ); } export function SessionCardMemorySlot({ session }: { session: DashboardSession }) { const registry = useSlotRegistryOrNull(); const intents = useSlotIntents("session-card-memory", session.id); const legacyClaims = registry ? forSessionRendered(registry.getClaims("session-card-memory"), session) : []; if (!legacyClaims.length && intents.size === 0) return null; return ( <> {legacyClaims.map((c) => renderClaim(c as Parameters[0], "session-card-memory", { session }), )} {Array.from(intents.entries()).map(([pluginId, intent]) => renderIntent(pluginId, "session-card-memory", intent, session.id), )} ); } export function SessionCardFlowsSlot({ session }: { session: DashboardSession }) { const registry = useSlotRegistryOrNull(); const intents = useSlotIntents("session-card-flows", session.id); const legacyClaims = registry ? forSessionRendered(registry.getClaims("session-card-flows"), session) : []; if (!legacyClaims.length && intents.size === 0) return null; return ( <> {legacyClaims.map((c) => renderClaim(c as Parameters[0], "session-card-flows", { session }), )} {Array.from(intents.entries()).map(([pluginId, intent]) => renderIntent(pluginId, "session-card-flows", intent, session.id), )} ); } export function WorkspaceActionBarSlot({ session }: { session: DashboardSession }) { const registry = useSlotRegistryOrNull(); const intents = useSlotIntents("workspace-action-bar", session.id); const legacyClaims = registry ? forSessionRendered(registry.getClaims("workspace-action-bar"), session) : []; if (!legacyClaims.length && intents.size === 0) return null; return ( <> {legacyClaims.map((c) => renderClaim(c as Parameters[0], "workspace-action-bar", { session }), )} {Array.from(intents.entries()).map(([pluginId, intent]) => renderIntent(pluginId, "workspace-action-bar", intent, session.id), )} ); } /** * `composer-panel` — rendered below the chat composer input. Passes slot * components a READ-ONLY composer context `{ draft, language? }` (the current * input value). The slot component owns its own debounce/side-effects (e.g. a * grammar check); core does not debounce or interpret the draft. Renders * nothing when no plugin claims the slot, so the composer is unchanged from * before the slot existed. See change: make-grammar-fully-plugin-contained. */ export function ComposerPanelSlot({ draft, language, sessionId, sessionStatus, onApplyText, }: { draft: string; language?: string; sessionId?: string; sessionStatus?: string; onApplyText: (text: string) => void; }) { const registry = useSlotRegistryOrNull(); if (!registry) return null; const claims = registry.getClaims("composer-panel"); if (!claims.length) return null; return ( <> {claims.map((c) => renderClaim(c as Parameters[0], "composer-panel", { draft, language, sessionId, sessionStatus, onApplyText, }), )} ); } export function ContentViewSlot({ session, routeParams, onClose, }: { session: DashboardSession; routeParams: Record; onClose: () => void; }) { const registry = useSlotRegistryOrNull(); if (!registry) return null; // Multiple plugins may claim `content-view` (multiplicity: // "one-active"). Each claim's optional `predicate` decides whether // it wants to render right now; predicates close over the plugin's // own UI-state store. The first claim (priority order) whose // predicate returns true wins. If no predicate is true, this slot // renders null so the shell's `?? sessionDetail` fallback shows the // default chat view. See change: pluginize-flows-via-registry // (design.md Decision 3 RECONSIDERED). const intents = useSlotIntents("content-view", session.id); const legacyClaims = registry ? forSession(registry.getClaims("content-view"), session) : []; // one-active: intents take precedence over legacy when both present. if (intents.size > 0) { const [pluginId, intent] = Array.from(intents.entries())[0]; return renderIntent(pluginId, "content-view", intent, session.id) as React.ReactElement; } if (!legacyClaims.length) return null; const claim = legacyClaims[0]; return renderClaim(claim as Parameters[0], "content-view", { session, routeParams, onClose, }); } export function ContentHeaderStickySlot({ session }: { session: DashboardSession }) { const registry = useSlotRegistryOrNull(); const intents = useSlotIntents("content-header-sticky", session.id); const legacyClaims = registry ? forSessionRendered(registry.getClaims("content-header-sticky"), session) : []; if (!legacyClaims.length && intents.size === 0) return null; return ( <> {legacyClaims.map((c) => renderClaim(c as Parameters[0], "content-header-sticky", { session }), )} {Array.from(intents.entries()).map(([pluginId, intent]) => renderIntent(pluginId, "content-header-sticky", intent, session.id), )} ); } export function ContentInlineFooterSlot({ session }: { session: DashboardSession }) { const registry = useSlotRegistryOrNull(); const intents = useSlotIntents("content-inline-footer", session.id); const legacyClaims = registry ? forSessionRendered(registry.getClaims("content-inline-footer"), session) : []; if (!legacyClaims.length && intents.size === 0) return null; return ( <> {legacyClaims.map((c) => renderClaim(c as Parameters[0], "content-inline-footer", { session }), )} {Array.from(intents.entries()).map(([pluginId, intent]) => renderIntent(pluginId, "content-inline-footer", intent, session.id), )} ); } export function AnchoredPopoverSlot({ anchorEl, onDismiss, }: { anchorEl: HTMLElement; onDismiss: () => void; }) { const registry = useSlotRegistryOrNull(); if (!registry) return null; const claims = registry.getClaims("anchored-popover"); if (!claims.length) return null; // one-at-a-time: render the first claim only const claim = claims[0]; return renderClaim(claim as Parameters[0], "anchored-popover", { anchorEl, onDismiss, }); } export function CommandRouteSlot({ command, session, routeParams, onClose, }: { command: string; session: DashboardSession; routeParams: Record; onClose: () => void; }) { const registry = useSlotRegistryOrNull(); if (!registry) return null; const allClaims = registry.getClaims("command-route"); const claims = allClaims.filter(c => c.command === command); if (!claims.length) return null; const claim = claims[0]; return renderClaim(claim as Parameters[0], "command-route", { session, routeParams, onClose, }); } /** * Inert since `plugin-settings-pages`. * * `settings-section` contributions render exclusively on their owning plugin's * page (`/settings/plugins/`) via `SettingsSectionByPluginSlot`. Two live * render paths was the bug this change removes, so this consumer emits nothing * for any `tab`. Kept as an exported no-op only so an out-of-tree import does * not hard-fail at module load. See change: plugin-settings-pages (design D3). */ export function SettingsSectionSlot(_props: { tab?: string }): null { return null; } /** * Render every `settings-section` contribution that belongs to a single plugin * id, irrespective of the claim's `tab` field. This is the ONLY render path for * the slot: the host mounts it inside `PluginSettingsPage`'s chrome. * * Consumes BOTH forms, per the canonical dual-source contract: * - refs claims, ordered by the registry comparator (ascending `priority`, * tie-broken by `pluginId.localeCompare`); * - intent broadcasts (which carry no priority), rendered after every claim in * store order. * * Intents are filtered against the enabled set here because the registry's own * filter covers claims only — without this, a plugin disabled while its page is * open would keep an intent-rendered body mounted (design D6, D7, D8). * * See change: add-plugin-activation-ui, plugin-settings-pages. */ export function SettingsSectionByPluginSlot({ pluginId }: { pluginId: string }) { const registry = useSlotRegistryOrNull(); const allIntents = useSlotIntents("settings-section", null); const claims = registry ? registry.getClaims("settings-section").filter((c) => c.pluginId === pluginId) : []; const enabled = registry ? registry.isPluginEnabled(pluginId) : false; const intent = enabled ? allIntents.get(pluginId) : undefined; if (!claims.length && !intent) return null; return ( <> {claims.map((c) => renderClaim(c as Parameters[0], "settings-section", {}), )} {intent ? renderIntent(pluginId, "settings-section", intent, null) : null} ); } /** * Optional payload fields a `tool-renderer` plugin may consume, mirroring the * built-in `ToolRendererProps`. Required core (`toolName`/`toolInput`/ * `sessionId`) stays separate so existing claims keep compiling. * See change: wire-tool-renderer-slot. */ interface ToolRendererExtraProps { status?: "running" | "complete" | "error"; result?: string; toolDetails?: Record; images?: unknown[]; context?: unknown; } export function ToolRendererSlot({ toolName, toolInput, sessionId, FallbackComponent, ...extra }: { toolName: string; toolInput: Record; sessionId: string; FallbackComponent?: React.ComponentType<{ toolName: string; toolInput: Record; sessionId: string; } & ToolRendererExtraProps>; } & ToolRendererExtraProps) { const registry = useSlotRegistryOrNull(); if (!registry) return null; const claims = forToolName(registry.getClaims("tool-renderer"), toolName); if (!claims.length) { return FallbackComponent ? ( ) : null; } const claim = claims[0]; return renderClaim(claim as Parameters[0], "tool-renderer", { toolName, toolInput, sessionId, ...extra, }); } /** * Render the first `automation-action-editor` claim whose `config.actionId` * matches `actionId`. Used by the create-automation dialog to host a plugin- * contributed payload editor (e.g. flows-plugin's input-wiring form) for a * specific action id, additively below the generic `ActionPayloadForm`. * * Renders nothing when no claim targets the action id (the dialog then shows * only the generic form). The contributed component receives the current * `payload`, an `onChange(payload)` callback, and the run `cwd`. * * See change: wire-flow-inputs-in-automation. */ export function AutomationActionEditorSlot({ actionId, payload, onChange, cwd, }: { actionId: string; payload: Record; onChange: (payload: Record) => void; cwd?: string; }) { const registry = useSlotRegistryOrNull(); if (!registry) return null; const claims = forActionId(registry.getClaims("automation-action-editor"), actionId); if (!claims.length) return null; const claim = claims[0]; return renderClaim(claim as Parameters[0], "automation-action-editor", { payload, onChange, cwd, }); } /** * Returns `true` when at least one `automation-action-editor` claim targets * `actionId`. Lets the dialog decide render/submit paths without mounting the * editor. See change: wire-flow-inputs-in-automation. */ export function useHasAutomationActionEditor(actionId: string): boolean { const registry = useSlotRegistryOrNull(); if (!registry) return false; return forActionId(registry.getClaims("automation-action-editor"), actionId).length > 0; } // ── shell-overlay-route ─────────────────────────────────────────────────────── // // Plugin-owned full-screen URL routes mounted at the top of the shell’s // dispatch chain (desktop + mobile). Each claim ships a wouter path via // `config.path` and a React component. The first matching claim wins. // // See change: add-flow-agent-popout. interface ShellOverlayRouteClaim { pluginId: string; /** First-class path field (preferred). See change: fix-flows-plugin-polish. */ path?: string; /** First-class session-param field (preferred). Defaults to "sid". */ sessionParam?: string; /** Back-action depth (1 = detail, 2 = overlay). See change: fix-plugin-and-scoped-back-navigation. */ depth?: 1 | 2; /** Back-action parent path pattern for depth-2 routes. See change: fix-plugin-and-scoped-back-navigation. */ parentPath?: string; /** Container selection: route-backed dialog (default) or full-viewport page. * First-class only — unlike `path`/`sessionParam` there are no legacy * manifests carrying it under `config`. See change: add-route-backed-overlay-dialogs. */ presentation?: "page" | "dialog"; /** Legacy fallback: some older manifests put `path` / `sessionParam` under `config`. */ config?: Record; Component?: React.ComponentType>; } function overlayPath(c: ShellOverlayRouteClaim): string | null { if (typeof c.path === "string") return c.path; const p = c.config?.path; return typeof p === "string" ? p : null; } function overlaySessionParam(c: ShellOverlayRouteClaim): string { if (typeof c.sessionParam === "string" && c.sessionParam.length > 0) return c.sessionParam; const sp = c.config?.sessionParam; return typeof sp === "string" && sp.length > 0 ? sp : "sid"; } /** * Render the first `shell-overlay-route` claim whose path matches the * current URL. Returns `null` when no claim matches. * * Mounted by App.tsx at the top of the desktop overlay switch and inside * `MobileShell.detailPanel`. The shell falls through to its own rendering * when this returns null. */ export function ShellOverlayRouteSlot({ onBack, registry: registryProp, }: { onBack: () => void; /** Optional registry override. Same fallback rules as `useShellOverlayRouteMatched`: when omitted, falls back to `useSlotRegistryOrNull()`. */ registry?: SlotRegistry | null; /** * Container for claims whose effective `presentation` is `"dialog"`. * When omitted, every claim renders in the page container — so a host that * has not opted in behaves exactly as it did before this change. */ }) { const ctxRegistry = useSlotRegistryOrNull(); const effective = registryProp ?? ctxRegistry; const claims = (effective?.getClaims("shell-overlay-route") ?? []) as ShellOverlayRouteClaim[]; // Each ShellOverlayRouteProbe is a separate component — one useRoute call // per claim. The first probe whose route matches reports up via // `onMatched`. We render at most one match (first-wins). return ( ); } /** * Companion hook: returns `true` when any registered `shell-overlay-route` * claim’s path matches the current URL. Replaces hand-wired `||`-chains * of `useRoute` flags in the shell. */ /** * Synchronous match against `shell-overlay-route` claims. * * **Important**: this hook is callable from inside `App.tsx` BEFORE the * `` is mounted (App is the parent of the provider). * It therefore accepts the registry as an optional argument; when not * provided it falls back to `useSlotRegistryOrNull()` (works only when * called from inside the provider). * * The shell typically passes `_pluginRegistry` (the module-level * SlotRegistry created in `App.tsx`) so the hook resolves even when * called outside the provider tree. * * See change: fix-flows-plugin-polish (hook-outside-provider fix). */ export function useShellOverlayRouteMatched(registry?: SlotRegistry | null): boolean { const ctxRegistry = useSlotRegistryOrNull(); const effective = registry ?? ctxRegistry; const claims = (effective?.getClaims("shell-overlay-route") ?? []) as ShellOverlayRouteClaim[]; const [location] = useLocation(); let matched = false; for (const c of claims) { const path = overlayPath(c); if (!path) continue; if (matchWouterPattern(path, location)) { matched = true; break; } } return matched; } /** * Effective `presentation` of the matched `shell-overlay-route` claim, or * `null` when none matches. * * The shell needs this in `App.tsx`'s body — BEFORE it renders — to pick the * mobile layout: a `"page"` claim renders full-viewport OUTSIDE the * `MobileShell` detail panel, while a `"dialog"` claim renders inside it at its * declared depth (D3a). Same registry-argument rules as * `useShellOverlayRouteMatched`. * * **Known caveat (D2a):** this shares `matchWouterPattern` with * `useShellOverlayRouteMatched`, which supports `:param` but not wouter's regex * segments, whereas the in-slot probes use the real `useRoute`. For a claim * using a regex segment the two could disagree, giving page layout with a * dialog container or vice versa. No bundled claim uses one today; fixing the * divergence means routing both through the router's own parser and is * deliberately out of this change's scope. * * See change: add-route-backed-overlay-dialogs. */ export function useShellOverlayRoutePresentation( registry?: SlotRegistry | null, ): "page" | "dialog" | null { const ctxRegistry = useSlotRegistryOrNull(); const effective = registry ?? ctxRegistry; const claims = (effective?.getClaims("shell-overlay-route") ?? []) as ShellOverlayRouteClaim[]; const [location] = useLocation(); for (const c of claims) { const path = overlayPath(c); if (!path) continue; if (matchWouterPattern(path, location)) return c.presentation ?? "dialog"; } return null; } // ── Internal helpers (one useRoute call per claim via per-claim component) ─── /** * Mini wouter-pattern matcher for `useShellOverlayRouteMatched`. Supports * the same `:param` syntax wouter uses (no regex parts). Returns true on * exact match. */ function matchWouterPattern(pattern: string, location: string): boolean { return matchWouterPatternWithParams(pattern, location) !== null; } /** * Same as `matchWouterPattern` but returns the captured `:param` values * as `{param: decoded-value}` on match, `null` on miss. Used for the * synchronous first-render path so the slot consumer doesn't have to * wait for ``'s useEffect to fire. */ function matchWouterPatternWithParams( pattern: string, location: string, ): Record | null { const patternParts = pattern.split("/").filter(Boolean); const locParts = location.split("/").filter(Boolean); if (patternParts.length !== locParts.length) return null; const params: Record = {}; for (let i = 0; i < patternParts.length; i++) { const p = patternParts[i]!; const l = locParts[i]!; if (p.startsWith(":")) { try { params[p.slice(1)] = decodeURIComponent(l); } catch { params[p.slice(1)] = l; } continue; } if (p !== l) return null; } return params; } /** * Owns the "which claim is matched" state. Renders one probe per claim; * each probe reports its own match state up. The first reported match * wins; later matches are ignored. */ function ShellOverlayRouteSwitch({ claims, onBack, }: { claims: ShellOverlayRouteClaim[]; onBack: () => void; }) { // Compute the first-match synchronously so the first render already has // the right claim mounted (no empty flicker). Probes still update the // state on subsequent renders when the URL changes. const [location] = useLocation(); const initialMatch = (() => { for (let i = 0; i < claims.length; i++) { const path = overlayPath(claims[i]!); if (!path) continue; const params = matchWouterPatternWithParams(path, location); if (params) return { index: i, params }; } return null; })(); const [matchedClaimIndex, setMatchedClaimIndex] = useState( initialMatch?.index ?? null, ); const [matchedParams, setMatchedParams] = useState>( initialMatch?.params ?? {}, ); if (typeof import.meta !== "undefined" && (import.meta as { env?: { DEV?: boolean } }).env?.DEV) { // eslint-disable-next-line no-console console.debug( "[shell-overlay-route] switch render", { location, claimCount: claims.length, claimPaths: claims.map((c) => overlayPath(c)), initialMatchIndex: initialMatch?.index ?? null, currentMatchedIndex: matchedClaimIndex, }, ); } const reportMatch = useCallback( (index: number, params: Record) => { setMatchedClaimIndex((prev) => { // First-wins, but smallest index wins on simultaneous reports. if (prev === null || index < prev) { setMatchedParams(params); return index; } return prev; }); }, [], ); const reportUnmatch = useCallback((index: number) => { setMatchedClaimIndex((prev) => (prev === index ? null : prev)); }, []); const probes = claims.map((c, i) => ( )); if (matchedClaimIndex === null) return <>{probes}; const claim = claims[matchedClaimIndex]!; // The height-propagation wrapper is part of the claim's contract, so it is // identical under BOTH containers — a claim that sizes itself against a flex // parent renders the same either way (task 4.2). const body = (
); // The slot renders the claim body only. `presentation` is consumed by the // HOST via `useShellOverlayRoutePresentation`, which lifts a dialog claim out // of the content region entirely — the underlay has to cover the viewport, so // it cannot be wrapped from in here. An earlier draft injected a container // through this component; that seam could not position the underlay and was // removed. See design D2a. return ( <> {probes} {body} ); } function ShellOverlayRouteProbe({ claimIndex, path, onMatch, onUnmatch, }: { claimIndex: number; path: string | null; onMatch: (index: number, params: Record) => void; onUnmatch: (index: number) => void; }) { // useRoute is called exactly once per probe component instance — hook // order is stable per probe across renders. const [matched, params] = useRoute(path ?? "/__shell_overlay_no_match__"); React.useEffect(() => { if (matched) onMatch(claimIndex, (params as Record) ?? {}); else onUnmatch(claimIndex); }, [matched, params, claimIndex, onMatch, onUnmatch]); return null; } function ShellOverlayRouteRender({ claim, params, onBack, }: { claim: ShellOverlayRouteClaim; params: Record; onBack: () => void; }) { const sessionParam = overlaySessionParam(claim); const sessionId = params[sessionParam]; const session = useShellSessionOrNull(sessionId ?? ""); return (
{renderClaim(claim as Parameters[0], "shell-overlay-route", { params, session, onBack, })}
); }