/** * Real-data panes + composable bar items for the contrib root: * * - `PreviewRailPane` — the REAL ChatPreviewRail; files-pane clicks feed it. * - `FilesPane` — real file browser; activating a file opens it in preview. * - Core statusbar items with LIVE store-backed labels, registered as DATA * contributions (`area: 'statusBar.left' / 'statusBar.right'`, payload = * StatusbarItem) — plugins add theirs through the identical call. */ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { atom } from 'nanostores' import { RightSidebarPane } from '@/app/right-sidebar' import { ReviewPane } from '@/app/right-sidebar/review' import type { GroupSetter } from '@/app/shell/group-setter' import type { StatusbarItem } from '@/app/shell/statusbar-controls' import type { TitlebarTool } from '@/app/shell/titlebar-controls' import { DecodeText } from '@/components/ui/decode-text' import { ContribBoundary, ContribRender } from '@/contrib/react/boundary' import { useContributions } from '@/contrib/react/use-contributions' import { registry } from '@/contrib/registry' import { getLogs } from '@/hermes' import { useI18n } from '@/i18n' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' import { openPreview } from '@/store/preview' import { $currentCwd } from '@/store/session' // --------------------------------------------------------------------------- // Logs — live agent-log tail. ⌘K-only chrome: the pane contribution exists // only while the "Toggle logs" palette command has it summoned ($logsOpen in // the controller) — never in a default layout, never a standing tab. // --------------------------------------------------------------------------- export function LogsPane() { const { t } = useI18n() const { data, error } = useQuery({ queryKey: ['contrib-logs-tail'], queryFn: () => getLogs({ lines: 300 }), refetchInterval: 5000 }) if (error) { return
{t.surfaces.logUnavailable(String(error))}
} if (!data) { return (
) } // No chrome of its own — the zone header (when the user summons it) is the // pane's only label. Just the tail. return (
      {data.lines.join('\n')}
    
) } // --------------------------------------------------------------------------- // Preview — the real rail, fed by the files pane // --------------------------------------------------------------------------- /** Preview-server restart handler, provided by the wiring (usePreviewRouting). * Atom-bridged: this module can't import contrib-wiring (it imports us). */ export const $restartPreviewServer = atom<((url: string, context?: string) => Promise) | null>(null) /** Open a file from the tree in the real preview pipeline. */ function previewFile(path: string) { void normalizeOrLocalPreviewTarget(path, $currentCwd.get() || undefined) .then(target => { if (target) { openPreview(target, 'file-browser') } }) .catch(() => undefined) } // Layout fit for wrapped asides. Edge chrome (borders/shadows) is neutralized // GLOBALLY by the tree's seam invariant (see LayoutTreeRoot) — only sizing // and titlebar clearance are per-wrapper concerns. const ZONE_CONTENT = 'h-full [&>aside]:h-full [&>aside]:w-full [&>aside]:pt-0' export function FilesPane() { return (
) } // --------------------------------------------------------------------------- // Review — the real git diff pane (⌘G / $reviewOpen) // --------------------------------------------------------------------------- export function ReviewPaneContent() { const cwd = useStore($currentCwd) // Keyed by cwd like DesktopController so switching projects rebuilds the // diff state instead of showing the previous repo's files. return (
aside]:min-h-0 [&>aside]:flex-1')}>
) } // --------------------------------------------------------------------------- // Statusbar composability: plugins contribute DATA items into // `statusBar.left` / `statusBar.right`; the wiring feeds them into the REAL // useStatusbarItems as extraLeftItems/extraRightItems. No core filler here — // the real statusbar owns the core items (model pill, terminal toggle, …). // --------------------------------------------------------------------------- /** Collect statusbar contributions for one side. A `render()` contribution * becomes a render-item (arbitrary stateful node); otherwise the declarative * `data` payload is the StatusbarItem. */ export function useStatusbarContributions(side: 'left' | 'right'): StatusbarItem[] { const items = useContributions(`statusBar.${side}`) return items .map(c => c.render ? ({ id: c.id, render: () => ( ) } satisfies StatusbarItem) : (c.data as StatusbarItem) ) .filter(Boolean) } /** Collect TitlebarTool data contributions for one side of the titlebar. */ export function useTitlebarToolContributions(side: 'left' | 'right'): TitlebarTool[] { const items = useContributions(`titleBar.tools.${side}`) return items.map(c => c.data as TitlebarTool).filter(Boolean) } /** * Bridge a page's `GroupSetter` extension point (SkillsView, MessagingView, * ChatPreviewRail, …) into the registry: each call replaces the group's items * as DATA contributions in `.`, so page-owned items flow through * the same pipe plugins use. Setting an empty list clears the group. */ export function registryGroupSetter(prefix: string): GroupSetter { const disposers = new Map void>() return (id, items, side = 'right') => { const key = `${side}:${id}` disposers.get(key)?.() disposers.set( key, registry.registerMany( items.map((item, i) => ({ id: `${id}-${i}`, area: `${prefix}.${side}`, source: 'core', order: 100 + i, data: item as object })) ) ) } } /** The app's page-facing setters — the same `GroupSetter` shape pages already * take as props, backed by the registry instead of component state. */ export const setStatusbarItemGroup = registryGroupSetter('statusBar') export const setTitlebarToolGroup = registryGroupSetter('titleBar.tools')