From 0f922002eb778e3b723dbecb2e9b10693fc10d16 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:32 -0400 Subject: [PATCH] feat(desktop): contribution controller, surfaces, and wiring --- apps/desktop/src/app/contrib/context.tsx | 36 + apps/desktop/src/app/contrib/controller.tsx | 615 ++++++++++++ .../app/contrib/hooks/use-background-sync.ts | 133 +++ .../contrib/hooks/use-desktop-integrations.ts | 176 ++++ .../src/app/contrib/hooks/use-pet-bridge.ts | 71 ++ .../hooks/use-session-tile-delegate.ts | 108 ++ apps/desktop/src/app/contrib/index.ts | 7 + apps/desktop/src/app/contrib/panes.tsx | 207 ++++ apps/desktop/src/app/contrib/surfaces.tsx | 204 ++++ apps/desktop/src/app/contrib/types.ts | 79 ++ apps/desktop/src/app/contrib/wiring.tsx | 939 ++++++++++++++++++ 11 files changed, 2575 insertions(+) create mode 100644 apps/desktop/src/app/contrib/context.tsx create mode 100644 apps/desktop/src/app/contrib/controller.tsx create mode 100644 apps/desktop/src/app/contrib/hooks/use-background-sync.ts create mode 100644 apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts create mode 100644 apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts create mode 100644 apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts create mode 100644 apps/desktop/src/app/contrib/index.ts create mode 100644 apps/desktop/src/app/contrib/panes.tsx create mode 100644 apps/desktop/src/app/contrib/surfaces.tsx create mode 100644 apps/desktop/src/app/contrib/types.ts create mode 100644 apps/desktop/src/app/contrib/wiring.tsx diff --git a/apps/desktop/src/app/contrib/context.tsx b/apps/desktop/src/app/contrib/context.tsx new file mode 100644 index 000000000000..8c90662bc4f8 --- /dev/null +++ b/apps/desktop/src/app/contrib/context.tsx @@ -0,0 +1,36 @@ +import { createContext, memo, useContext } from 'react' + +import { DecodeText } from '@/components/ui/decode-text' + +import { StatusbarControls } from '../shell/statusbar-controls' + +import type { WiringApi } from './types' + +/** The controller publishes its wired surfaces here; every registered pane + * / chrome slot reads one back through `WiredPane`. */ +export const ContribWiringContext = createContext(null) + +/** Render a wired surface inside a registered pane / chrome slot. + * + * Memoized on `part` (its only prop): a zone re-rendering for reasons that + * don't touch the wiring — a drag hint sweeping the tree, a sash resize, an + * edit-mode toggle — re-renders the group chrome but NOT this component, so + * the (expensive) pane body it reads from context is untouched. When the + * wiring's `api` genuinely changes, the context read re-renders it as normal. */ +export const WiredPane = memo(function WiredPane({ part }: { part: keyof WiringApi }) { + const api = useContext(ContribWiringContext) + + if (!api) { + if (part === 'statusbar') { + return + } + + return ( +
+ +
+ ) + } + + return <>{api[part]} +}) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx new file mode 100644 index 000000000000..ff9452388b8b --- /dev/null +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -0,0 +1,615 @@ +import { useStore } from '@nanostores/react' +import { computed } from 'nanostores' +import type { CSSProperties, ReactElement } from 'react' + +import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail' +import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib' +import { type StatusbarItem } from '@/app/shell/statusbar-controls' +import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode' +import { allPaneIds, group, split } from '@/components/pane-shell/tree/model' +import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer' +import { + $layoutTree, + bindTreeSideVisibility, + declareDefaultTree, + dismissTreePane, + dockPaneBeside, + mirrorLayoutTree, + paneRootSide, + registerLayoutResetHandler, + registerPaneCloser, + registerPaneOpener, + resetLayoutTree, + revealTreePane, + setTreePaneHidden, + watchContributedPanes +} from '@/components/pane-shell/tree/store' +import { SidebarProvider } from '@/components/ui/sidebar' +import { discoverBundledPlugins } from '@/contrib/plugins' +import { Slot } from '@/contrib/react/slot' +import { registry } from '@/contrib/registry' +import { discoverRuntimePlugins } from '@/contrib/runtime-loader' +import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' +import { LayoutDashboard } from '@/lib/icons' +import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' +import { Codecs, persistentAtom } from '@/lib/persisted' +import { toggleKeybindPanel } from '@/store/keybinds' +import { + $fileBrowserOpen, + $panesFlipped, + $sidebarOpen, + FILE_BROWSER_DEFAULT_WIDTH, + FILE_BROWSER_MAX_WIDTH, + FILE_BROWSER_MIN_WIDTH, + setFileBrowserOpen, + setSidebarOpen, + SIDEBAR_DEFAULT_WIDTH, + SIDEBAR_MAX_WIDTH +} from '@/store/layout' +import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview' +import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review' +import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session' + +import { watchRouteTiles } from '../chat/route-tile' +import { + SessionTileCloseConfirm, + stackSessionTilesIntoMain, + watchSessionTiles, + WorkspaceTabMenu +} from '../chat/session-tile' +import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store' +import { $workspaceIsPage } from '../routes' + +import { FilesPane, LogsPane, PreviewRailPane, ReviewPaneContent } from './panes' +import { ContribWiring, WiredPane } from './wiring' + +/** + * Stripped-down app root (bb/contrib-areas) on the layout TREE model, mounting + * the REAL app surfaces. The title bar and status bar sit OUTSIDE the grid + * (fixed chrome) but are fully composable: title bar renders `titleBar.left/ + * right` slots; the status bar consumes `statusBar.left/right` DATA + * contributions (payload = StatusbarItem). Core registers its items through + * the same calls a plugin would use. + */ + +// --------------------------------------------------------------------------- +// Pane contributions. `data.placement` = semantic role for grid presets; +// `data.minWidth/maxWidth/minHeight/maxHeight` = the SAME clamps the app's +// `Pane` props declare — the layout tree sizes zones by weight (percentage) +// but a zone never shrinks/grows past its active pane's clamp. +// Headers are contextual (tree-side): a pane alone in a zone shows no +// header/tab by default; stacked panes show chips. Double-click a zone +// toggles its header either way. +// --------------------------------------------------------------------------- + +// ONE render identity for the workspace pane — syncWorkspaceTitle re-registers +// the contribution (new title) and a fresh closure would remount the chat. +const renderWorkspacePane = () => +// The main tab carries the same session context menu as tile tabs (targets +// the loaded primary session; no menu on a fresh draft). +const wrapWorkspaceTab = (tab: ReactElement) => {tab} + +registry.registerMany([ + { + id: 'sessions', + area: 'panes', + title: 'sessions', + // Collapsible: leaves the grid on narrow viewports (edge overlay instead). + // dock: where a RE-ADOPTED pane lands (healed from a stale dismissal) — + // its default-ish spot beside main, not a random same-placement stack. + data: { + placement: 'left', + collapsible: true, + dock: { pane: 'workspace', pos: 'left' }, + revealAliases: ['chat-sidebar'], + width: `${SIDEBAR_DEFAULT_WIDTH}px`, + minWidth: `${SIDEBAR_DEFAULT_WIDTH}px`, + maxWidth: `${SIDEBAR_MAX_WIDTH}px` + }, + render: () => + }, + { + id: 'workspace', + area: 'panes', + // Live-retitled to the loaded session by syncWorkspaceTitle below. + title: 'New session', + data: { placement: 'main', minWidth: '22vw', tabWrap: wrapWorkspaceTab, uncloseable: true }, + render: renderWorkspacePane + }, + { + id: 'terminal', + area: 'panes', + title: 'terminal', + // revealOnPreset: choosing a layout that places the terminal (e.g. + // "Terminal deck") turns takeover on so the zone actually shows, instead of + // staying collapsed behind the ⌃` toggle. height sizes the fixed track (a + // single-pane zone declaring a height is a fixed track — the preset weight + // is moot): a short deck, not a third of the window. + data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true }, + render: () => + }, + { + id: 'files', + area: 'panes', + title: 'files', + // dock: re-adoption target after a stale dismissal (see sessions). + data: { + placement: 'right', + collapsible: true, + dock: { pane: 'workspace', pos: 'right' }, + revealAliases: ['file-browser'], + width: FILE_BROWSER_DEFAULT_WIDTH, + minWidth: FILE_BROWSER_MIN_WIDTH, + maxWidth: FILE_BROWSER_MAX_WIDTH + }, + render: () => + }, + { + id: 'preview', + area: 'panes', + title: 'preview', + // The rail brings its OWN tab strip (per-target tabs with close buttons). + // Exists only while something is previewed — visibility is bound to the + // preview targets below, like every other self-managed surface. dock: + // adoption seed only — dockPaneBeside re-docks it next to files on every + // reveal anyway (position-aware). + data: { + placement: 'right', + dock: { pane: 'files', pos: 'left' }, + width: 'clamp(18rem, 36vw, 32rem)', + minWidth: PREVIEW_RAIL_MIN_WIDTH, + maxWidth: PREVIEW_RAIL_MAX_WIDTH + }, + render: () => + }, + { + id: 'review', + area: 'panes', + title: 'review', + // The second right sidebar: hidden until ⌘G ($reviewOpen) — bound below + // like the other chrome toggles; its zone collapses while hidden. + data: { + placement: 'right', + collapsible: true, + revealAliases: [REVIEW_PANE_ID], + width: FILE_BROWSER_DEFAULT_WIDTH, + minWidth: FILE_BROWSER_MIN_WIDTH, + maxWidth: FILE_BROWSER_MAX_WIDTH + }, + render: () => + }, + { + // Optional chrome — in NO default layout. Adoption stacks it with the + // terminal; $logsOpen (default off, ⌘K "Toggle logs") reveals it. + id: 'logs', + area: 'panes', + title: 'logs', + // revealOnPreset: the Quad layout places logs, so applying it turns the + // logs pane on (like a ⌘K "Toggle logs") instead of leaving it collapsed. + data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true }, + render: () => + } +]) + +// --------------------------------------------------------------------------- +// Chrome contributions. The title bar and status bar are fixed chrome outside +// the grid, composable through these areas. Everything real lives in the real +// components (TitlebarControls / useStatusbarItems). Sample PLUGIN +// contributions don't live here — they're their own files under `src/plugins/`, +// auto-discovered by discoverBundledPlugins() below. +// --------------------------------------------------------------------------- + +registry.registerMany([ + // Titlebar center stays empty on purpose: session title lives in tabs + + // sidebar; place/cwd lives in the sidebar project tree. Center is drag + // chrome (plugins can still contribute to titleBar.center if needed). + // Layout edit mode registers through the SAME declarative surfaces plugins + // use: a rebindable keybind (collision-checked in the panel) + a ⌘K row + // whose hotkey hint tracks the live binding. + { + id: 'layout.editMode', + area: KEYBINDS_AREA, + data: { + id: 'layout.editMode', + label: 'Toggle layout edit mode', + defaults: ['mod+shift+\\'], + run: toggleLayoutEditMode + } satisfies KeybindContribution + }, + { + id: 'layout.editMode', + area: PALETTE_AREA, + data: { + id: 'layout.editMode', + label: 'Toggle layout edit mode', + action: 'layout.editMode', + icon: LayoutDashboard, + keywords: ['layout', 'zones', 'panes', 'edit', 'rearrange'], + run: toggleLayoutEditMode + } satisfies PaletteContribution + }, + // The agent's write -> see loop: rescan /desktop-plugins + // without relaunching (same-id reloads dispose the previous incarnation). + { + id: 'plugins.reload', + area: PALETTE_AREA, + data: { + id: 'plugins.reload', + label: 'Reload desktop plugins', + keywords: ['plugins', 'reload', 'refresh', 'desktop'], + run: () => void discoverRuntimePlugins() + } satisfies PaletteContribution + }, + { + id: 'layout.reset', + area: PALETTE_AREA, + data: { + id: 'layout.reset', + label: 'Reset layout', + icon: LayoutDashboard, + keywords: ['layout', 'reset', 'default', 'panes'], + run: resetLayoutTree + } satisfies PaletteContribution + }, + // The keybind panel's non-titlebar door (the keyboard icon is gone). + { + id: 'keybinds.panel', + area: PALETTE_AREA, + data: { + id: 'keybinds.panel', + label: 'Keyboard shortcuts', + keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'], + run: toggleKeybindPanel + } satisfies PaletteContribution + } +]) + +// --------------------------------------------------------------------------- +// Layout presets — CHAT (main) always dominates. +// --------------------------------------------------------------------------- + +// The REAL default: sessions left, chat main, and the right sidebars in +// column order main | … | review | preview | file-browser (files outermost, +// preview DIRECTLY left of the file tree). Each is its OWN zone — main +// parity: a file double-click slides the preview open as its own pane beside +// the tree, never as a tab stacked into the files sidebar. Preview/review +// zones collapse to nothing while their pane is hidden (no target / ⌘G off). +// This static spot is just the seed — dockPaneBeside keeps preview adjacent +// to files WHEREVER files moves (see the target listeners below). +const DEFAULT_TREE = split( + 'row', + [ + group(['sessions'], { id: 'grp-sessions' }), + group(['workspace'], { id: 'grp-main' }), + split( + 'column', + [ + split( + 'row', + [ + group(['review'], { id: 'grp-review' }), + group(['preview'], { id: 'grp-preview' }), + group(['files'], { id: 'grp-files' }) + ], + [1, 1, 1.2], + 'spl-rail' + ), + group(['terminal'], { id: 'grp-terminal' }) + ], + [1.6, 1], + 'spl-right' + ) + ], + [1, 3.4, 1.25], + 'spl-root' +) + +const FOCUS_TREE = split( + 'row', + [group(['sessions']), group(['workspace', 'files', 'preview', 'review', 'terminal'])], + [1, 4.6] +) + +const TERMINAL_TREE = split( + 'column', + [ + split('row', [group(['sessions']), group(['workspace']), group(['files', 'preview', 'review'])], [1, 3.2, 1.2]), + group(['terminal']) + ], + [3, 1] +) + +const QUAD_TREE = split( + 'column', + [ + split('row', [group(['sessions', 'files']), group(['workspace'])], [1, 3]), + split('row', [group(['terminal']), group(['preview', 'review', 'logs'])], [1.4, 1]) + ], + [3, 1] +) + +registry.registerMany([ + { id: 'default', area: 'layouts', title: 'Default', order: 0, data: DEFAULT_TREE }, + { id: 'focus', area: 'layouts', title: 'Focus', order: 10, data: FOCUS_TREE }, + { id: 'terminal-deck', area: 'layouts', title: 'Terminal deck', order: 20, data: TERMINAL_TREE }, + { id: 'quad', area: 'layouts', title: 'Quad', order: 30, data: QUAD_TREE } +]) + +declareDefaultTree(DEFAULT_TREE) + +// Bundled plugins load AFTER core, so a same-id contribution from a plugin +// deliberately overrides the core default (last writer wins). Third-party +// runtime plugins will flow through the same discovery seam. +discoverBundledPlugins() + +// Plugin panes join the tree by their `placement` hint the moment they +// register — incl. runtime plugins arriving seconds after boot. +watchContributedPanes() + +// Session + route (page) tiles: persisted splits register panes docked beside +// main. +watchSessionTiles() +watchRouteTiles() + +// The main tab reads as its SESSION (the loaded title, "New session" on a +// fresh draft) — a stack of main + tiles is then just a row of session names. +// register() replaces same-id in place; the render fn is the shared constant +// above, so the pane content never remounts. +const syncWorkspaceTitle = () => { + const selected = $selectedStoredSessionId.get() + const stored = selected ? $sessions.get().find(s => sessionMatchesStoredId(s, selected)) : null + + registry.register({ + id: 'workspace', + area: 'panes', + title: stored ? storedSessionTitle(stored) : 'New session', + data: { + // Pages aren't tab-able: the main zone's bar stands down while one shows. + headerVeto: $workspaceIsPage.get(), + placement: 'main', + minWidth: '22vw', + tabWrap: wrapWorkspaceTab, + uncloseable: true + }, + render: renderWorkspacePane + }) +} + +$selectedStoredSessionId.listen(syncWorkspaceTitle) +$sessions.listen(syncWorkspaceTitle) +$workspaceIsPage.listen(syncWorkspaceTitle) + +// Layout reset collapses every session tile into main as a tab (after the +// workspace) instead of re-scattering them — pre-placed before adoption. +registerLayoutResetHandler(stackSessionTilesIntoMain) + +// --------------------------------------------------------------------------- +// Titlebar chrome toggles -> tree. The TitlebarControls buttons keep their +// store semantics ($sidebarOpen / $fileBrowserOpen / $panesFlipped); the tree +// reacts — a hidden pane's zone collapses (content stays mounted), the flip +// toggle mirrors the root row. +// --------------------------------------------------------------------------- + +function bindPaneVisibility( + paneId: string, + $open: { get(): boolean; listen(fn: (open: boolean) => void): void }, + close?: () => void, + open?: () => void +) { + setTreePaneHidden(paneId, !$open.get()) + $open.listen(isOpen => setTreePaneHidden(paneId, !isOpen)) + + // The tab menu's Close routes through the owning store (never dismissal), + // so the pane's toggle buttons stay truthful. + if (close) { + registerPaneCloser(paneId, close) + } + + // The opener is the mirror: preset application (revealOnPreset) shows the + // pane through the same store, so the toggle stays truthful. + if (open) { + registerPaneOpener(paneId, open) + } +} + +// SIDES have one source of truth: the TREE. The legacy $panesFlipped flag is +// DERIVED from where the sessions zone actually sits (TitlebarControls maps +// its left/right buttons through it), so dragging sessions across — or +// applying a mirrored preset — remaps the buttons automatically. The flip +// action (⌘\ / titlebar) mirrors the tree only when they disagree. +const sessionsOnRight = () => { + const tree = $layoutTree.get() + + if (!tree) { + return null + } + + const order = allPaneIds(tree) + const sessions = order.indexOf('sessions') + const main = order.indexOf('workspace') + + return sessions >= 0 && main >= 0 ? sessions > main : null +} + +$layoutTree.subscribe(() => { + const flipped = sessionsOnRight() + + if (flipped !== null && flipped !== $panesFlipped.get()) { + $panesFlipped.set(flipped) + } +}) + +$panesFlipped.listen(flipped => { + const current = sessionsOnRight() + + if (current !== null && current !== flipped) { + mirrorLayoutTree() + } +}) + +// POSITIONAL side toggles (titlebar buttons, ⌘B / ⌘J): $sidebarOpen ≙ the +// LEFT side of the main zone, $fileBrowserOpen ≙ the RIGHT — everything on +// that side hides together, whatever panes have been rearranged there. +bindTreeSideVisibility('left', $sidebarOpen, setSidebarOpen) +bindTreeSideVisibility('right', $fileBrowserOpen, setFileBrowserOpen) + +// Workspace-scoped surfaces: the file tree and git diff only mean something +// inside a project. A detached chat (no cwd) hides them — their zones +// collapse and the chat absorbs the width; picking a project brings them +// back. The terminal is NOT workspace-gated: unlike the old shell (where it +// rode the rail's row and vanished with it), its zone stands on its own. +const $hasWorkspace = computed($currentCwd, cwd => Boolean(cwd.trim())) + +bindPaneVisibility('files', $hasWorkspace) +// ⌘G — the review sidebar appears/disappears (and comes to the front). +bindPaneVisibility( + 'review', + computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace), + closeReview +) +// ⌃` / statusbar toggle — the terminal zone follows takeover instead of +// being forced on (PTYs stay alive while hidden; see PersistentTerminal). +bindPaneVisibility( + 'terminal', + $terminalTakeover, + () => setTerminalTakeover(false), + () => setTerminalTakeover(true) +) + +// Preview EXISTS only while something is previewed (old-shell semantics: +// closing the last preview tab closes the pane; a new target opens + fronts +// it). Same visibility binding as every other self-managed surface, driven +// by the live targets instead of a toggle. +const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) => + Boolean(target || fileTarget) +) + +bindPaneVisibility('preview', $previewVisible, closeRightRail) + +// Logs are optional chrome: off by default, toggled from ⌘K, persisted. +const $logsOpen = persistentAtom('hermes.desktop.logsOpen', false, Codecs.bool) + +bindPaneVisibility( + 'logs', + $logsOpen, + () => $logsOpen.set(false), + () => $logsOpen.set(true) +) +registry.register({ + id: 'logs.toggle', + area: PALETTE_AREA, + data: { + id: 'logs.toggle', + label: 'Toggle logs', + keywords: ['logs', 'agent log', 'tail', 'debug'], + run: () => $logsOpen.set(!$logsOpen.get()) + } satisfies PaletteContribution +}) + +// Sessions/files Close = collapse their SIDE (⌘B/⌘J truthful, titlebar button +// flips back) — but only while the pane actually lives in that root side +// column. Dragged next to main, a side collapse can't hide it (the collapse +// skips main-bearing children), so Close falls back to dismissal there — +// otherwise ⌘W/Close silently no-op. +registerPaneCloser('sessions', () => + paneRootSide('sessions') === 'left' ? setSidebarOpen(false) : dismissTreePane('sessions') +) +registerPaneCloser('files', () => + paneRootSide('files') === 'right' ? setFileBrowserOpen(false) : dismissTreePane('files') +) + +// A preview target lands NEXT TO the file tree — position-aware: wherever +// files currently lives (default rail, ⌘\-flipped, dragged into a stack), the +// preview zone docks directly beside it. A user who drags the preview pane +// somewhere pins it there instead (until a preset/reset). Then reveal: open +// the side, unhide, front — a NEW target while already visible still fronts. +const revealPreview = () => { + dockPaneBeside('preview', 'files') + revealTreePane('preview') +} + +$previewTarget.listen(target => target && revealPreview()) +$filePreviewTarget.listen(target => target && revealPreview()) + +// --------------------------------------------------------------------------- + +export function ContribController() { + const sidebarOpen = useStore($sidebarOpen) + + return ( + + +
+ {/* Title bar: fixed chrome outside the grid, composable via slots. + Layout contract (no contribution can break it): + - a full-bar DRAG BASE underneath (pointer-events-none, like + AppShell's drag strips) — everywhere without content drags + the window; + - each slot region is width-fit, no-drag, pointer-events-auto, + so every contribution is clickable by construction; + - LEFT/RIGHT slots align to the MAIN PANE's geometry via the + tree-published --workspace-left/right vars (pure CSS, no rect + threading), clamped to clear the REAL TitlebarControls + clusters (fixed, z-70); center is truly window-centered. */} +
+ {/* Drag strips, AppShell-style: cut to AVOID the fixed control + clusters instead of overlapping them — Electron's no-drag + carve-out of fixed/transformed elements is unreliable, so a + full-bar drag base kills their clicks. In-flow slot content + still carves via its own no-drag wrapper (the same pattern as + the app's session-title button). */} + + + + ) +} + +// Referenced type kept for plugin authors' reference (payload shape of +// statusBar.* contributions). +export type { StatusbarItem } diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts new file mode 100644 index 000000000000..bef0647a17d5 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -0,0 +1,133 @@ +import { useEffect } from 'react' + +import { refreshActiveProfile } from '@/store/profile' +import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session' + +import type { GatewayRequester } from '../types' + +// Cron sessions are written by a background scheduler tick, messaging turns by +// the background gateway (Telegram, WeChat, Discord, …) — neither signals the +// desktop websocket, so poll the bounded lists while the app is visible. +const CRON_POLL_INTERVAL_MS = 30_000 +const MESSAGING_POLL_INTERVAL_MS = 10_000 +const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000 + +interface BackgroundSyncParams { + activeIsMessaging: boolean + activeSessionId: null | string + freshDraftReady: boolean + gatewayState: string + refreshActiveMessagingTranscript: () => Promise | unknown + refreshCronJobs: () => Promise | unknown + refreshCurrentModel: (force?: boolean) => Promise | unknown + refreshHermesConfig: () => Promise | unknown + refreshMessagingSessions: () => Promise | unknown + refreshSessions: () => Promise | unknown + requestGateway: GatewayRequester +} + +/** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab + * re-focus. Returns nothing — meant to live inside an effect. */ +function visiblePoll(intervalMs: number, tick: () => void): () => void { + const run = () => { + if (document.visibilityState === 'visible') { + tick() + } + } + + const intervalId = window.setInterval(run, intervalMs) + document.addEventListener('visibilitychange', run) + + return () => { + window.clearInterval(intervalId) + document.removeEventListener('visibilitychange', run) + } +} + +/** + * Keeps app data live while the gateway is open: an on-connect reseed (model / + * profile / sessions + relative-cwd resolution), the cron / messaging / + * open-transcript visibility polls, and the fresh-draft model/config reseed. + * All the "the desktop websocket won't tell us, so poll" logic in one place. + */ +export function useBackgroundSync({ + activeIsMessaging, + activeSessionId, + freshDraftReady, + gatewayState, + refreshActiveMessagingTranscript, + refreshCronJobs, + refreshCurrentModel, + refreshHermesConfig, + refreshMessagingSessions, + refreshSessions, + requestGateway +}: BackgroundSyncParams): void { + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + void refreshCurrentModel() + void refreshActiveProfile() + void refreshSessions() + + // A RELATIVE workspace cwd (config `terminal.cwd: .`) renders as "." in the + // file tree header — resolve it to the backend's absolute path once. + // Session runtime info still overrides later, and never while a session is + // active. + const cwd = $currentCwd.get().trim() + + if (!$activeSessionId.get() && cwd && !/^(\/|[A-Za-z]:[\\/])/.test(cwd)) { + void requestGateway<{ cwd?: string }>('config.get', { key: 'project', cwd }) + .then(info => { + if (info.cwd && !$activeSessionId.get()) { + setCurrentCwd(info.cwd) + } + }) + .catch(() => undefined) + } + }, [gatewayState, refreshCurrentModel, refreshSessions, requestGateway]) + + // Keep the cron-jobs section live without a user action (scheduler ticks in + // the background); re-check on tab re-focus too. + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + return visiblePoll(CRON_POLL_INTERVAL_MS, () => void refreshCronJobs()) + }, [gatewayState, refreshCronJobs]) + + // Keep the messaging-platform session lists live (inbound turns are written + // by the gateway, not the desktop websocket). + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions()) + }, [gatewayState, refreshMessagingSessions]) + + // Only the open messaging transcript needs its own poll — local chats are + // live over the websocket already. + useEffect(() => { + if (gatewayState !== 'open' || !activeIsMessaging) { + return + } + + const dispose = visiblePoll(ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS, () => void refreshActiveMessagingTranscript()) + void refreshActiveMessagingTranscript() + + return dispose + }, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript]) + + // A fresh new-session draft (gateway open, no active session) re-pulls the + // model + config so the composer pill reflects the profile default. + useEffect(() => { + if (gatewayState === 'open' && !activeSessionId && freshDraftReady) { + void refreshCurrentModel() + void refreshHermesConfig() + } + }, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig]) +} diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts new file mode 100644 index 000000000000..6dd78dfb7f8d --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -0,0 +1,176 @@ +import { useEffect, useRef } from 'react' + +import { closeActiveTab } from '@/app/chat/close-tab' +import { storedSessionIdForNotification } from '@/lib/session-ids' +import { respondToApprovalAction } from '@/store/native-notifications' +import { + getRememberedRoute, + getRememberedSessionId, + setRememberedRoute, + setRememberedSessionId +} from '@/store/session' +import { onSessionsChanged } from '@/store/session-sync' +import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates' +import { isSecondaryWindow } from '@/store/windows' + +import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus' +import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, sessionRoute } from '../../routes' + +interface DesktopIntegrationsParams { + chatOpen: boolean + hasPreview: boolean + locationPathname: string + navigate: (to: string, options?: { replace?: boolean }) => void + refreshSessions: () => Promise | unknown + resumeExhaustedSessionId: null | string + routedSessionId: null | string + runtimeIdByStoredSessionId: { readonly current: Map } +} + +/** + * All the Electron-main / OS / cross-window integrations the shell listens for: + * update polling, the ⌘W close shortcut, deep links, native-notification + * navigation, preview-shortcut enablement, remembered-session restore, and + * cross-window session-list sync. Kept out of the wiring controller so the + * "talks to the desktop shell" surface reads as one unit. + */ +export function useDesktopIntegrations({ + locationPathname, + navigate, + refreshSessions, + resumeExhaustedSessionId, + routedSessionId, + runtimeIdByStoredSessionId +}: DesktopIntegrationsParams): void { + // Update polling — populates $desktopVersion/$updateStatus, which feed the + // statusbar version pill and the update toasts. Also honors the main + // process's "open updates" menu request. + useEffect(() => { + startUpdatePoller() + const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow()) + + return () => { + unsubscribe?.() + stopUpdatePoller() + } + }, []) + + // The renderer OWNS ⌘W: on macOS the native menu accelerator would else + // close the window, so claim it unconditionally — the menu then routes ⌘W + // to us (close-preview-requested IPC) and we decide tab-vs-window. + useEffect(() => { + window.hermesDesktop?.setPreviewShortcutActive?.(true) + }, []) + + // Remember the open chat (session id for notifications/resume) AND the last + // non-overlay route (a page like /skills, or a session route) so a relaunch + // lands where you were. Overlays (settings/command-center/…) aren't stored — + // you don't want to boot into a modal. + useEffect(() => { + if (routedSessionId) { + setRememberedSessionId(routedSessionId) + } + + if (!isOverlayView(appViewForPath(locationPathname))) { + setRememberedRoute(locationPathname) + } + }, [locationPathname, routedSessionId]) + + const restoredRef = useRef(false) + + // Restore once on cold start — only when the renderer booted at the default + // route (a hidden-then-shown window keeps its own route). Prefer the full + // remembered route (covers pages); fall back to the last session id. + useEffect(() => { + if (restoredRef.current || locationPathname !== NEW_CHAT_ROUTE) { + restoredRef.current = true + + return + } + + restoredRef.current = true + const route = getRememberedRoute() + + if (route && route !== NEW_CHAT_ROUTE && !isOverlayView(appViewForPath(route))) { + navigate(route, { replace: true }) + + return + } + + const last = getRememberedSessionId() + + if (last) { + navigate(sessionRoute(last), { replace: true }) + } + }, [locationPathname, navigate]) + + useEffect(() => { + if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) { + setRememberedSessionId(null) + } + }, [resumeExhaustedSessionId]) + + // Native-notification click -> jump to the session (runtime id translated to + // the stored id the chat route is keyed by); action buttons resolve in place. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => { + if (sessionId) { + navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current))) + } + }) + + return () => unsubscribe?.() + }, [navigate, runtimeIdByStoredSessionId]) + + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => { + void respondToApprovalAction(sessionId ?? null, actionId) + }) + + return () => unsubscribe?.() + }, []) + + // hermes:// deep links -> a reviewable /blueprint command in the composer. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => { + if (!payload || payload.kind !== 'blueprint' || !payload.name) { + return + } + + const slots = Object.entries(payload.params || {}) + .map(([k, v]) => { + const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v + + return `${k}=${sval}` + }) + .join(' ') + + const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}` + requestComposerInsert(command, { mode: 'block', target: 'main' }) + requestComposerFocus('main') + }) + + void window.hermesDesktop?.signalDeepLinkReady?.() + + return () => unsubscribe?.() + }, []) + + // ⌘W via the macOS menu accelerator → close the focused tab; if nothing is + // closeable, fall back to closing the window (so ⌘W still works as the + // OS-standard window close, esp. secondary windows). The Win/Linux keyboard + // path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() => void closeActiveTab()) + + return () => unsubscribe?.() + }, []) + + // Another window mutated the shared session list -> re-pull the sidebar. + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + return onSessionsChanged(() => void refreshSessions()) + }, [refreshSessions]) +} diff --git a/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts b/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts new file mode 100644 index 000000000000..b4e0afc0d5e3 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react' + +import { setPetActivity } from '@/store/pet' +import { setPetScale } from '@/store/pet-gallery' +import { + setPetOverlayOpenAppHandler, + setPetOverlayScaleHandler, + setPetOverlaySubmitHandler +} from '@/store/pet-overlay' +import { $attentionSessionIds, $sessions } from '@/store/session' +import { isSecondaryWindow } from '@/store/windows' + +import type { GatewayRequester } from '../types' + +interface PetBridgeParams { + requestGateway: GatewayRequester + resumeSession: (sessionId: string) => Promise | unknown + submitText: (text: string) => Promise | unknown +} + +/** + * Wires the popped-out pet overlay back into the app: submit a prompt, resize, + * and open the most-recent thread, plus mirroring "a session is awaiting the + * user" into the pet's pose. Handlers register ONCE through refs tracking the + * latest callbacks — re-registering on identity churn leaves a nulled-handler + * window that can drop a submit. Primary window only. + */ +export function usePetBridge({ requestGateway, resumeSession, submitText }: PetBridgeParams): void { + const submitTextRef = useRef(submitText) + submitTextRef.current = submitText + const resumeSessionRef = useRef(resumeSession) + resumeSessionRef.current = resumeSession + const requestGatewayRef = useRef(requestGateway) + requestGatewayRef.current = requestGateway + + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + setPetOverlaySubmitHandler(text => void submitTextRef.current(text)) + // Alt+wheel resize from the popped-out pet — persist through this window's + // gateway (the overlay has none) so it survives restart. + setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) + // Mail icon: $sessions is most-recent-first; the pet is global, so "most + // recent" is the right target. + setPetOverlayOpenAppHandler(() => { + const recent = $sessions.get()[0] + + if (recent?.id) { + void resumeSessionRef.current(recent.id) + } + }) + + return () => { + setPetOverlaySubmitHandler(null) + setPetOverlayOpenAppHandler(null) + setPetOverlayScaleHandler(null) + } + }, []) + + // Mirror "a session is blocked on the user" (clarify/approval) into the pet's + // awaitingInput flag so it shows the `waiting` pose. + useEffect(() => { + const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 }) + + sync() + + return $attentionSessionIds.listen(sync) + }, []) +} diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts new file mode 100644 index 000000000000..d7a7e0467272 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -0,0 +1,108 @@ +import { useEffect } from 'react' + +import { getSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' +import { toChatMessages } from '@/lib/chat-messages' +import { publishSessionState, setSessionTileDelegate } from '@/store/session-states' +import type { SessionResumeResponse } from '@/types/hermes' + +import type { usePromptActions } from '../../session/hooks/use-prompt-actions' +import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache' +import type { GatewayRequester } from '../types' + +type SessionStateCache = ReturnType + +interface SessionTileDelegateParams { + archiveSession: (storedSessionId: string) => Promise + branchStoredSession: (storedSessionId: string) => Promise + executeSlashCommand: ReturnType['executeSlashCommand'] + removeSession: (storedSessionId: string) => Promise + requestGateway: GatewayRequester + runtimeIdByStoredSessionIdRef: SessionStateCache['runtimeIdByStoredSessionIdRef'] + sessionStateByRuntimeIdRef: SessionStateCache['sessionStateByRuntimeIdRef'] + updateSessionState: SessionStateCache['updateSessionState'] +} + +/** + * Publishes the session-tile delegate: resume / submit / interrupt / slash for + * tiled sessions WITHOUT touching the primary view ($activeSessionId / + * $messages stay the main thread's). Resume reuses a live runtime binding when + * one exists (incl. the main thread's own session); a cold tile binds + + * hydrates the cache, which publishSessionState mirrors to the tile. + */ +export function useSessionTileDelegate({ + archiveSession, + branchStoredSession, + executeSlashCommand, + removeSession, + requestGateway, + runtimeIdByStoredSessionIdRef, + sessionStateByRuntimeIdRef, + updateSessionState +}: SessionTileDelegateParams): void { + useEffect(() => { + setSessionTileDelegate({ + archiveSession: async storedSessionId => { + await archiveSession(storedSessionId) + }, + branchSession: async storedSessionId => { + await branchStoredSession(storedSessionId) + }, + deleteSession: async storedSessionId => { + await removeSession(storedSessionId) + }, + executeSlash: async (rawCommand, sessionId) => { + await executeSlashCommand(rawCommand, { sessionId }) + }, + interruptSession: async runtimeId => { + await requestGateway('session.interrupt', { session_id: runtimeId }) + }, + resumeTile: async storedSessionId => { + const existing = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) + const cached = existing ? sessionStateByRuntimeIdRef.current.get(existing) : undefined + + if (existing && cached?.storedSessionId === storedSessionId) { + publishSessionState(existing, cached) + + return existing + } + + const [prefetch, resumed] = await Promise.all([ + getSessionMessages(storedSessionId).catch(() => null), + requestGateway('session.resume', { session_id: storedSessionId, cols: 96 }) + ]) + + const runtimeId = resumed?.session_id + + if (!runtimeId) { + throw new Error('resume returned no session id') + } + + updateSessionState( + runtimeId, + state => ({ + ...state, + busy: Boolean(resumed?.info?.running), + messages: + state.messages.length > 0 ? state.messages : toChatMessages(prefetch?.messages ?? resumed?.messages ?? []) + }), + storedSessionId + ) + + return runtimeId + }, + submitToSession: async (runtimeId, text) => { + await requestGateway('prompt.submit', { session_id: runtimeId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + }, + updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater) + }) + }, [ + archiveSession, + branchStoredSession, + executeSlashCommand, + removeSession, + requestGateway, + runtimeIdByStoredSessionIdRef, + sessionStateByRuntimeIdRef, + updateSessionState + ]) +} diff --git a/apps/desktop/src/app/contrib/index.ts b/apps/desktop/src/app/contrib/index.ts new file mode 100644 index 000000000000..c39e6f3148e4 --- /dev/null +++ b/apps/desktop/src/app/contrib/index.ts @@ -0,0 +1,7 @@ +// The contribution-driven shell package. `controller` registers panes / +// layouts / chrome and mounts the app root; `wiring` is the data controller + +// memoized pane surfaces; `panes` holds the real-data pane bodies + statusbar +// group setters. Only the controller is a public entry (the app root renders +// it); the rest are internal to this directory. +export { ContribController } from './controller' +export { ContribWiring, WiredPane } from './wiring' diff --git a/apps/desktop/src/app/contrib/panes.tsx b/apps/desktop/src/app/contrib/panes.tsx new file mode 100644 index 000000000000..37aaba24c9ad --- /dev/null +++ b/apps/desktop/src/app/contrib/panes.tsx @@ -0,0 +1,207 @@ +/** + * 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 type { CSSProperties } from 'react' + +import { ChatPreviewRail } from '@/app/chat/right-rail/preview' +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 { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' +import type { TitlebarTool } from '@/app/shell/titlebar-controls' +import { DecodeText } from '@/components/ui/decode-text' +import { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import { registry } from '@/contrib/registry' +import { getLogs } from '@/hermes' +import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' +import { cn } from '@/lib/utils' +import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview' +import { $currentCwd } from '@/store/session' + +// --------------------------------------------------------------------------- +// Logs — live agent-log tail. OPTIONAL chrome: not in any default layout, +// hidden until the ⌘K "Toggle logs" command opens it ($logsOpen). +// --------------------------------------------------------------------------- + +export function LogsPane() { + const { data, error } = useQuery({ + queryKey: ['contrib-logs-tail'], + queryFn: () => getLogs({ lines: 300 }), + refetchInterval: 5000 + }) + + if (error) { + return
log unavailable: {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) + +export function PreviewRailPane() { + const previewTarget = useStore($previewTarget) + const fileTarget = useStore($filePreviewTarget) + const restartPreviewServer = useStore($restartPreviewServer) + + if (!previewTarget && !fileTarget) { + return ( +
+
+ + click a file in the files pane +
+
+ ) + } + + return ( + // The contrib layout zeroes --titlebar-height (content sits BELOW the + // titlebar, so the real components' clearance padding must collapse) — + // but the rail SIZES its per-file tab strip with that var. Restore the + // real value for this subtree so the tabs always render at full height. +
aside]:pt-0')} + style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties} + > + +
+ ) +} + +/** 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) { + setCurrentSessionPreviewTarget(target, 'file-browser', path) + } + }) + .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: () => {c.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') diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx new file mode 100644 index 000000000000..1d3d475fc166 --- /dev/null +++ b/apps/desktop/src/app/contrib/surfaces.tsx @@ -0,0 +1,204 @@ +/** + * Wiring surfaces — each pane is its own memoized component. Every surface + * reads the reactive state it renders from at the leaf (its own atom + * subscriptions) and reaches the controller's callbacks through the stable + * `actions` bag, so a state change scoped to one surface (or a bare + * wiring-controller tick) never re-renders another. This is what keeps the + * layout tree's zones independently rendered — the whole point of the shell. + */ + +import { useStore } from '@nanostores/react' +import { type ComponentProps, lazy, memo, type ReactNode, Suspense, useMemo } from 'react' +import { Navigate, Route, Routes, useParams } from 'react-router-dom' + +import { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import { $freshDraftReady, $gatewayState } from '@/store/session' + +import { ChatView } from '../chat' +import { ChatSidebar } from '../chat/sidebar' +import { TerminalPaneChrome } from '../right-sidebar/terminal/chrome' +import { contributedRoutes, NEW_CHAT_ROUTE, ROUTES_AREA, sessionRoute } from '../routes' +import { useStatusSnapshot } from '../shell/hooks/use-status-snapshot' +import { useStatusbarItems } from '../shell/hooks/use-statusbar-items' +import { ModelMenuPanel } from '../shell/model-menu-panel' +import { StatusbarControls } from '../shell/statusbar-controls' + +import { setStatusbarItemGroup, useStatusbarContributions } from './panes' +import type { SidebarActions, WiringActions } from './types' + +// Same lazy-view split as DesktopController — pages load on demand. The +// full-page views the workspace route table mounts live here; overlay views +// (agents/settings/…) are the controller's and stay in wiring.tsx. +const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView })) +const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView })) +const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView })) + +export function LegacySessionRedirect() { + const { sessionId } = useParams() + + return +} + +export const SidebarSurface = memo(function SidebarSurface({ + actions, + currentView +}: { + actions: SidebarActions + currentView: ComponentProps['currentView'] +}) { + return +}) + +export const TerminalSurface = memo(function TerminalSurface() { + return ( +
+ +
+ ) +}) + +/** Owns the statusbar's own data hooks (status snapshot poll, contributed + * items) so its 15s refresh — and any statusbar-only churn — re-renders the + * bar alone, never the chat/sidebar/terminal. */ +export const StatusbarSurface = memo(function StatusbarSurface({ + actions, + agentsOpen, + chatOpen, + commandCenterOpen +}: { + actions: WiringActions + agentsOpen: boolean + chatOpen: boolean + commandCenterOpen: boolean +}) { + const gatewayState = useStore($gatewayState) + const freshDraftReady = useStore($freshDraftReady) + const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway) + const extraLeftItems = useStatusbarContributions('left') + const extraRightItems = useStatusbarContributions('right') + + const { leftStatusbarItems, statusbarItems } = useStatusbarItems({ + agentsOpen, + chatOpen, + commandCenterOpen, + extraLeftItems, + extraRightItems, + freshDraftReady, + gatewayState, + inferenceStatus, + openAgents: actions.openAgents, + openCommandCenterSection: actions.openCommandCenterSection, + requestGateway: actions.requestGateway, + statusSnapshot, + toggleCommandCenter: actions.toggleCommandCenter + }) + + return +}) + +/** The workspace pane: the real route table (chat + full-page views + plugin + * routes). Subscribes to `$gatewayState` and ROUTES_AREA itself; the gateway + * instance + voice cap arrive as props so a reconnect/config load re-renders + * only this surface. ChatView subscribes to its own session atoms, so + * streaming never round-trips through the controller. */ +export const ChatRoutesSurface = memo(function ChatRoutesSurface({ + actions, + maxVoiceRecordingSeconds +}: { + actions: WiringActions + maxVoiceRecordingSeconds?: number +}) { + const gatewayState = useStore($gatewayState) + useContributions(ROUTES_AREA) + const routeContributions = contributedRoutes() + + // Recapture the live gateway instance whenever the connection state flips. + // getGateway reads a controller ref, so gatewayState is the intentional + // re-eval trigger (not a value the computation itself reads). + const gateway = useMemo( + () => actions.getGateway(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [actions, gatewayState] + ) + + const modelMenuContent = useMemo( + () => + gatewayState === 'open' ? ( + + ) : null, + [actions, gateway, gatewayState] + ) + + const chatView = ( + + ) + + // FULL-PAGE views (not chat) mark the zone body `data-zone-no-header`: a + // page is not a tab-able surface, so the zone's double-click header toggle + // stands down while one is showing (see onZoneDoubleClick). + const page = (view: ReactNode) => ( +
+ {view} +
+ ) + + return ( + + + + )} path="skills" /> + )} path="messaging" /> + )} path="artifacts" /> + + + + + + + {/* Registry-contributed pages (core features + plugins) render in the + workspace pane like any built-in view — behind the same blast wall + as every other contribution mount. */} + {routeContributions.map(route => ( + {route.render()})} + key={route.key} + path={route.path.slice(1)} + /> + ))} + } path="new" /> + } path="sessions/:sessionId" /> + } path="*" /> + + ) +}) diff --git a/apps/desktop/src/app/contrib/types.ts b/apps/desktop/src/app/contrib/types.ts new file mode 100644 index 000000000000..1e2c60ac776a --- /dev/null +++ b/apps/desktop/src/app/contrib/types.ts @@ -0,0 +1,79 @@ +import type { ComponentProps, ReactNode } from 'react' + +import type { ChatView } from '../chat' +import type { ChatSidebar } from '../chat/sidebar' +import type { CommandCenterSection } from '../command-center' +import type { useGatewayRequest } from '../gateway/hooks/use-gateway-request' +import type { ModelMenuPanel } from '../shell/model-menu-panel' + +export type GatewayRequester = ReturnType['requestGateway'] + +/** The ChatSidebar handlers the controller owns — forwarded verbatim. */ +export type SidebarActions = Pick< + ComponentProps, + | 'onArchiveSession' + | 'onBranchSession' + | 'onDeleteSession' + | 'onLoadMoreMessaging' + | 'onLoadMoreProfileSessions' + | 'onLoadMoreSessions' + | 'onManageCronJob' + | 'onNavigate' + | 'onNewSessionInWorkspace' + | 'onNewSessionSplit' + | 'onResumeSession' + | 'onTriggerCronJob' +> + +/** The ChatView handlers the controller owns — forwarded verbatim. */ +export type ChatActions = Pick< + ComponentProps, + | 'onAddContextRef' + | 'onAddUrl' + | 'onAttachDroppedItems' + | 'onAttachImageBlob' + | 'onBranchInNewChat' + | 'onCancel' + | 'onDeleteSelectedSession' + | 'onDismissError' + | 'onEdit' + | 'onPasteClipboardImage' + | 'onPickFiles' + | 'onPickFolders' + | 'onPickImages' + | 'onReload' + | 'onRemoveAttachment' + | 'onRestoreToMessage' + | 'onRetryResume' + | 'onSteer' + | 'onSubmit' + | 'onThreadMessagesChange' + | 'onToggleSelectedPin' + | 'onTranscribeAudio' +> + +/** + * The complete controller-owned callback surface. One object, one stable + * identity for the app's life — its fields are mutated in place each render, + * so surfaces bound to it never re-render on identity churn but always invoke + * the latest closure. + */ +export interface WiringActions extends SidebarActions, ChatActions { + /** The live gateway instance (held in a controller ref). Surfaces recapture + * it by subscribing to `$gatewayState`, so no gateway prop needs threading. */ + getGateway: () => ComponentProps['gateway'] + openAgents: () => void + openCommandCenterSection: (section: CommandCenterSection) => void + requestGateway: GatewayRequester + selectModel: ComponentProps['onSelectModel'] + toggleCommandCenter: () => void +} + +/** The four wired surfaces the controller publishes; `WiredPane` renders one by + * key inside a registered pane / chrome slot. */ +export interface WiringApi { + sidebar: ReactNode + chatRoutes: ReactNode + terminal: ReactNode + statusbar: ReactNode +} diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx new file mode 100644 index 000000000000..60b70641d93c --- /dev/null +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -0,0 +1,939 @@ +/** + * Real-featureset wiring for the contrib (layout tree) root — the minimal + * subset of DesktopController's hook chain that makes the REAL surfaces work: + * gateway boot -> sessions list -> click-to-resume -> live transcript -> + * composer send, plus the real terminal. + * + * The wired nodes (sidebar / chat routes / terminal) are exposed through + * context; registered panes render `` to consume them. + */ + +import { useStore } from '@nanostores/react' +import { useQueryClient } from '@tanstack/react-query' +import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEffect, useMemo, useRef } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { formatRefValue } from '@/components/assistant-ui/directive-text' +import { BootFailureOverlay } from '@/components/boot-failure-overlay' +import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' +import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' +import { NotificationStack } from '@/components/notifications' +import { DesktopOnboardingOverlay } from '@/components/onboarding' +import { FloatingPet } from '@/components/pet/floating-pet' +import { RemoteDisplayBanner } from '@/components/remote-display-banner' +import { emitGatewayEvent } from '@/contrib/events' +import { getSessionMessages, triggerCronJob } from '@/hermes' +import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' +import { sessionMessagesSignature } from '@/lib/session-signatures' +import { isMessagingSource } from '@/lib/session-source' +import { latestSessionTodos } from '@/lib/todos' +import { setCronFocusJobId } from '@/store/cron' +import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' +import { $filePreviewTarget, $previewTarget } from '@/store/preview' +import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile' +import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects' +import { + $activeSessionId, + $connection, + $currentCwd, + $freshDraftReady, + $gatewayState, + $messages, + $messagingSessions, + $resumeExhaustedSessionId, + $resumeFailedSessionId, + $selectedStoredSessionId, + $sessions, + sessionMatchesStoredId, + sessionPinId, + setAwaitingResponse, + setBusy, + setCurrentBranch, + setCurrentCwd, + setCurrentModel, + setCurrentProvider, + setMessages +} from '@/store/session' +import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos' +import { isSecondaryWindow } from '@/store/windows' +import { useSkinCommand } from '@/themes/use-skin-command' + +import { requestComposerInsert } from '../chat/composer/focus' +import { useComposerActions } from '../chat/hooks/use-composer-actions' +import { CommandPalette } from '../command-palette' +import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot' +import { useGatewayRequest } from '../gateway/hooks/use-gateway-request' +import { useKeybinds } from '../hooks/use-keybinds' +import { ModelPickerOverlay } from '../model-picker-overlay' +import { ModelVisibilityOverlay } from '../model-visibility-overlay' +import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay' +import { FileActionDialogs } from '../right-sidebar/file-actions' +import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker' +import { PersistentTerminal } from '../right-sidebar/terminal/persistent' +import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes' +import { SessionPickerOverlay } from '../session-picker-overlay' +import { SessionSwitcher } from '../session-switcher' +import { useContextSuggestions } from '../session/hooks/use-context-suggestions' +import { useCwdActions } from '../session/hooks/use-cwd-actions' +import { useHermesConfig } from '../session/hooks/use-hermes-config' +import { useMessageStream } from '../session/hooks/use-message-stream' +import { useModelControls } from '../session/hooks/use-model-controls' +import { usePreviewRouting } from '../session/hooks/use-preview-routing' +import { usePromptActions } from '../session/hooks/use-prompt-actions' +import { useRouteResume } from '../session/hooks/use-route-resume' +import { useSessionActions } from '../session/hooks/use-session-actions' +import { useSessionListActions } from '../session/hooks/use-session-list-actions' +import { useSessionStateCache } from '../session/hooks/use-session-state-cache' +import { useOverlayRouting } from '../shell/hooks/use-overlay-routing' +import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width' +import { KeybindPanel } from '../shell/keybind-panel' +import { titlebarControlsPosition } from '../shell/titlebar' +import { TitlebarControls } from '../shell/titlebar-controls' +import { UpdatesOverlay } from '../updates-overlay' + +import { ContribWiringContext } from './context' +import { useBackgroundSync } from './hooks/use-background-sync' +import { useDesktopIntegrations } from './hooks/use-desktop-integrations' +import { usePetBridge } from './hooks/use-pet-bridge' +import { useSessionTileDelegate } from './hooks/use-session-tile-delegate' +import { $restartPreviewServer, useTitlebarToolContributions } from './panes' +import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces' +import type { WiringActions, WiringApi } from './types' + +// Overlay views the controller mounts over the shell — lazy, load on demand. +// The workspace-route full-page views (skills/messaging/artifacts) are the +// ChatRoutesSurface's and live in ./surfaces. +const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView })) +const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView })) +const CronView = lazy(async () => ({ default: (await import('../cron')).CronView })) +const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView })) +const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView })) +const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView })) + +// Surfaces (the four wired panes), the render context + WiredPane, and the +// WiringActions/WiringApi contracts all live in sibling modules — this file is +// the controller that assembles them. +export { WiredPane } from './context' + +export function ContribWiring({ children }: { children: ReactNode }) { + const queryClient = useQueryClient() + const location = useLocation() + const navigate = useNavigate() + + const busyRef = useRef(false) + const creatingSessionRef = useRef(false) + const messagingTranscriptSignatureRef = useRef(new Map()) + // Stable identity for the whole callback surface (see WiringActions). Mutated + // in place each render so memoized surfaces never re-render on churn. + const actionsRef = useRef(null) + + const gatewayState = useStore($gatewayState) + const activeSessionId = useStore($activeSessionId) + const currentCwd = useStore($currentCwd) + const freshDraftReady = useStore($freshDraftReady) + const resumeFailedSessionId = useStore($resumeFailedSessionId) + const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) + const selectedStoredSessionId = useStore($selectedStoredSessionId) + const messagingSessions = useStore($messagingSessions) + const profileScope = useStore($profileScope) + + const routedSessionId = routeSessionId(location.pathname) + const routeToken = `${location.pathname}:${location.search}:${location.hash}` + const routeTokenRef = useRef(routeToken) + routeTokenRef.current = routeToken + const getRouteToken = useCallback(() => routeTokenRef.current, []) + + // Mirror "the workspace is showing a full page" into its atom — the + // workspace pane contribution re-registers headerVeto from it, so the main + // zone's tab bar stands down on pages (and returns with the chat). + useEffect(() => { + syncWorkspaceIsPage(location.pathname) + }, [location.pathname]) + + const { + agentsOpen, + chatOpen, + closeOverlayToPreviousRoute, + commandCenterInitialSection, + commandCenterOpen, + cronOpen, + currentView, + openAgents, + openCommandCenterSection, + openStarmap, + profilesOpen, + settingsOpen, + starmapOpen, + toggleCommandCenter + } = useOverlayRouting() + + const { + activeSessionIdRef, + ensureSessionState, + resetViewSync, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + } = useSessionStateCache({ + activeSessionId, + busyRef, + selectedStoredSessionId, + setAwaitingResponse, + setBusy, + setMessages + }) + + const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest() + + const { + loadMoreMessagingForPlatform, + loadMoreSessions, + loadMoreSessionsForProfile, + refreshCronJobs, + refreshMessagingSessions, + refreshSessions + } = useSessionListActions({ profileScope }) + + const updateActiveSessionRuntimeInfo = useCallback( + (info: { branch?: string; cwd?: string }) => { + const sessionId = activeSessionIdRef.current + + if (!sessionId) { + return + } + + updateSessionState(sessionId, state => ({ + ...state, + branch: info.branch ?? state.branch, + cwd: info.cwd ?? state.cwd + })) + }, + [activeSessionIdRef, updateSessionState] + ) + + const { refreshProjectBranch } = useCwdActions({ + activeSessionId, + activeSessionIdRef, + onSessionRuntimeInfo: updateActiveSessionRuntimeInfo, + requestGateway + }) + + const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ + activeSessionIdRef, + refreshProjectBranch + }) + + const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ + activeSessionId, + queryClient, + requestGateway + }) + + const openProviderSettings = useCallback(() => navigate(`${SETTINGS_ROUTE}?tab=providers`), [navigate]) + + // Post-turn rehydrate from stored history (same behavior as DesktopController, + // including finished-todos restoration). + const hydrateFromStoredSession = useCallback( + async ( + attempts = 1, + storedSessionId = selectedStoredSessionIdRef.current, + runtimeSessionId = activeSessionIdRef.current + ) => { + if (!storedSessionId || !runtimeSessionId) { + return + } + + const storedProfile = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))?.profile + + for (let index = 0; index < Math.max(1, attempts); index += 1) { + try { + const latest = await getSessionMessages(storedSessionId, storedProfile) + const messages = toChatMessages(latest.messages) + updateSessionState( + runtimeSessionId, + state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), + storedSessionId + ) + + const restored = todosForHydration(latestSessionTodos(messages)) + + if (restored) { + setSessionTodos(runtimeSessionId, restored) + } else { + clearSessionTodos(runtimeSessionId) + } + + return + } catch { + // Best-effort fallback when live stream payloads are empty. + } + + if (index < attempts - 1) { + await new Promise(resolve => window.setTimeout(resolve, 250)) + } + } + }, + [activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState] + ) + + // Refresh the open messaging transcript (inbound platform turns arrive via + // the background gateway, not the desktop websocket). Signature-gated so a + // no-change poll doesn't churn the thread. + const refreshActiveMessagingTranscript = useCallback(async () => { + const storedSessionId = selectedStoredSessionIdRef.current + const runtimeSessionId = activeSessionIdRef.current + + if (!storedSessionId || !runtimeSessionId || busyRef.current) { + return + } + + const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + + if (!stored || !isMessagingSource(stored.source)) { + return + } + + try { + const latest = await getSessionMessages(storedSessionId, stored.profile) + const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}` + const sig = sessionMessagesSignature(latest.messages) + + if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) { + return + } + + messagingTranscriptSignatureRef.current.set(signatureKey, sig) + const messages = toChatMessages(latest.messages) + + updateSessionState( + runtimeSessionId, + state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), + storedSessionId + ) + } catch { + // Non-fatal: next poll or manual refresh can hydrate. + } + }, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState]) + + const { handleGatewayEvent } = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession, + queryClient, + refreshHermesConfig, + refreshSessions, + sessionStateByRuntimeIdRef, + updateSessionState + }) + + // Agent-driven preview routing (agent opens a URL/file -> the preview rail + // follows) + the preview server restart handler, layered over the base + // gateway event stream exactly like DesktopController. + const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({ + activeSessionIdRef, + baseHandleGatewayEvent: handleGatewayEvent, + currentCwd, + currentView, + requestGateway, + routedSessionId, + selectedStoredSessionId + }) + + // Composer @-mention context suggestions (files/dirs under the cwd). + useContextSuggestions({ + activeSessionId, + activeSessionIdRef, + currentCwd, + gatewayState, + requestGateway + }) + + // Expose the restart handler to the preview pane contribution (module + // boundary crossed via atom — contrib-panes can't import this file). + useEffect(() => { + $restartPreviewServer.set(restartPreviewServer) + + return () => $restartPreviewServer.set(null) + }, [restartPreviewServer]) + + const { + archiveSession, + branchCurrentSession, + branchStoredSession, + createBackendSessionForSend, + openNewSessionTile, + removeSession, + resumeSession, + selectSidebarItem, + startFreshSessionDraft + } = useSessionActions({ + activeSessionId, + activeSessionIdRef, + busyRef, + creatingSessionRef, + ensureSessionState, + getRouteToken, + navigate, + requestGateway, + resetViewSync, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + }) + + // A profile switch/create drops to a fresh new-session draft so the + // previously open session doesn't bleed across contexts. Skip initial value. + const freshSessionRequest = useStore($freshSessionRequest) + const lastFreshRef = useRef(freshSessionRequest) + + useEffect(() => { + if (freshSessionRequest === lastFreshRef.current) { + return + } + + lastFreshRef.current = freshSessionRequest + startFreshSessionDraft() + }, [freshSessionRequest, startFreshSessionDraft]) + + // Swapping the live gateway to another profile must re-pull that profile's + // global model + active-profile pill (both are nanostores — the blanket + // invalidateQueries on swap doesn't touch them). + const activeGatewayProfile = useStore($activeGatewayProfile) + const lastGatewayProfileRef = useRef(activeGatewayProfile) + + useEffect(() => { + if (activeGatewayProfile === lastGatewayProfileRef.current) { + return + } + + lastGatewayProfileRef.current = activeGatewayProfile + // Force: the new profile has its own default, so reseed even if the + // composer already shows the previous profile's model. + void refreshCurrentModel(true) + void refreshActiveProfile() + }, [activeGatewayProfile, refreshCurrentModel]) + + // New session anchored to a workspace (sidebar "+" on a project/worktree). + // Seeds cwd + branch from the clicked workspace; an explicit worktree path + // also drills the sidebar into that project so the new lane is visible. + const startSessionInWorkspace = useCallback( + (path: null | string) => { + startFreshSessionDraft() + + // A worktree lane carries its own path; the trunk "+" can be path-less + // (the main checkout is implicit), so fall back to the active project's + // root instead of no-op'ing on null. + const target = path?.trim() || resolveNewSessionCwd() + + if (!target) { + return + } + + setCurrentCwd(target) + void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) + .then(info => { + const resolved = info.cwd || target + + setCurrentCwd(resolved) + setCurrentBranch(info.branch || '') + + if (path?.trim()) { + restoreWorktree(resolved) + void followActiveSessionCwd(resolved) + } + }) + .catch(() => undefined) + }, + [requestGateway, startFreshSessionDraft] + ) + + // Composer "branch off into a new worktree": open a fresh session anchored + // to the just-created tree, then prefill the task that kicked it off. + const startWorkSessionRequest = useStore($startWorkSessionRequest) + const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0) + + useEffect(() => { + if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) { + return + } + + lastStartWorkTokenRef.current = startWorkSessionRequest.token + startSessionInWorkspace(startWorkSessionRequest.path) + + if (startWorkSessionRequest.draft) { + requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' }) + } + }, [startSessionInWorkspace, startWorkSessionRequest]) + + const composer = useComposerActions({ activeSessionId, currentCwd, requestGateway }) + + const branchInNewChat = useCallback( + async (messageId?: string) => { + const branched = await branchCurrentSession(messageId) + + if (branched) { + await refreshSessions().catch(() => undefined) + } + + return branched + }, + [branchCurrentSession, refreshSessions] + ) + + const handleSkinCommand = useSkinCommand() + + const { + cancelRun, + editMessage, + executeSlashCommand, + handleThreadMessagesChange, + reloadFromMessage, + restoreToMessage, + steerPrompt, + submitText, + transcribeVoiceAudio + } = usePromptActions({ + activeSessionId, + activeSessionIdRef, + branchCurrentSession: branchInNewChat, + busyRef, + createBackendSessionForSend, + getRouteToken, + handleSkinCommand, + openMemoryGraph: openStarmap, + refreshSessions, + requestGateway, + resumeStoredSession: resumeSession, + selectedStoredSessionIdRef, + startFreshSessionDraft, + sttEnabled, + updateSessionState + }) + + // Session-tile delegate (resume/submit/interrupt/slash + the session verbs + // the tile TAB menu needs, without touching the primary view). + useSessionTileDelegate({ + archiveSession, + branchStoredSession, + executeSlashCommand, + removeSession, + requestGateway, + runtimeIdByStoredSessionIdRef, + sessionStateByRuntimeIdRef, + updateSessionState + }) + + // The popped-out pet overlay's bridge back into the app. + usePetBridge({ requestGateway, resumeSession, submitText }) + + // Clear a failed turn's red error banner. Errors are renderer-local (never + // persisted): a bare error placeholder is dropped entirely; a partial-output + // failure keeps its content and sheds the error. Both the runtime cache AND + // the live $messages view must be updated — preserveLocalAssistantErrors + // re-grafts any still-errored view message on the next session.info flush. + const dismissError = useCallback( + (messageId: string) => { + const runtimeSessionId = activeSessionIdRef.current + + if (!runtimeSessionId) { + return + } + + const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] => + messages.flatMap(message => { + if (message.id !== messageId || !message.error) { + return [message] + } + + if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) { + return [] + } + + return [{ ...message, error: undefined, pending: false }] + }) + + // View first: the cache update below triggers a re-sync that reads + // $messages as the error-preservation baseline. + setMessages(clearErrorIn($messages.get())) + + updateSessionState(runtimeSessionId, state => ({ + ...state, + messages: clearErrorIn(state.messages) + })) + }, + [activeSessionIdRef, updateSessionState] + ) + + useRouteResume({ + activeSessionId, + activeSessionIdRef, + creatingSessionRef, + currentView, + freshDraftReady, + gatewayState, + locationPathname: location.pathname, + resumeSession, + resumeFailedSessionId, + resumeExhaustedSessionId, + routedSessionId, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + startFreshSessionDraft + }) + + // Plugins hear the stream FIRST (isolated fan-out in contrib/events), then + // the app dispatches as before — a plugin listener can't affect app flow. + const handleGatewayEventWithPlugins = useCallback( + (event: Parameters[0]) => { + emitGatewayEvent(event) + handleDesktopGatewayEvent(event) + }, + [handleDesktopGatewayEvent] + ) + + useGatewayBoot({ + handleGatewayEvent: handleGatewayEventWithPlugins, + onConnectionReady: c => { + connectionRef.current = c + }, + onGatewayReady: g => { + gatewayRef.current = g + }, + refreshHermesConfig, + refreshSessions + }) + + // Only the open messaging transcript needs its own poll — local chats are + // live over the websocket already. + const activeIsMessaging = + !!selectedStoredSessionId && + isMessagingSource(messagingSessions.find(s => sessionMatchesStoredId(s, selectedStoredSessionId))?.source) + + // Keep app data live while the gateway is open (on-connect reseed + the + // cron / messaging / transcript visibility polls + fresh-draft reseed). + useBackgroundSync({ + activeIsMessaging, + activeSessionId, + freshDraftReady, + gatewayState, + refreshActiveMessagingTranscript, + refreshCronJobs, + refreshCurrentModel, + refreshHermesConfig, + refreshMessagingSessions, + refreshSessions, + requestGateway + }) + + // Electron-main / OS / cross-window integrations: update polling, ⌘W close, + // deep links, native-notification nav, preview-shortcut enablement, + // remembered-session restore, and cross-window session-list sync. + const previewTarget = useStore($previewTarget) + const filePreviewTarget = useStore($filePreviewTarget) + + useDesktopIntegrations({ + chatOpen, + hasPreview: Boolean(filePreviewTarget || previewTarget), + locationPathname: location.pathname, + navigate, + refreshSessions, + resumeExhaustedSessionId, + routedSessionId, + runtimeIdByStoredSessionId: runtimeIdByStoredSessionIdRef + }) + + // Pin/unpin the selected session (statusbar keybind + chat header) — pinned + // on the durable lineage-root id so it survives auto-compression. + const toggleSelectedPin = useCallback(() => { + const sessionId = $selectedStoredSessionId.get() + + if (!sessionId) { + return + } + + const session = $sessions.get().find(s => sessionMatchesStoredId(s, sessionId)) + const pinId = session ? sessionPinId(session) : sessionId + + if ($pinnedSessionIds.get().includes(pinId)) { + unpinSession(pinId) + } else { + pinSession(pinId) + } + }, []) + + // Single global listener for every rebindable hotkey plus the on-screen + // keybind editor's capture mode (same as DesktopController). + useKeybinds({ + openNewSessionTab: () => void openNewSessionTile('center'), + startFreshSession: startFreshSessionDraft, + toggleCommandCenter, + toggleSelectedPin + }) + + // The controller's entire callback surface, gathered into the stable + // `actions` bag. `nextActions` is TS-checked against WiringActions each + // render; its fields are copied into the ref object so `actions` keeps one + // identity for the app's life (memoized surfaces don't re-render on churn) + // while every handler still closes over the latest values. + const nextActions: WiringActions = { + onAddContextRef: composer.addContextRefAttachment, + onAddUrl: url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url), + onArchiveSession: sessionId => void archiveSession(sessionId), + onAttachDroppedItems: composer.attachDroppedItems, + onAttachImageBlob: composer.attachImageBlob, + onBranchInNewChat: messageId => void branchInNewChat(messageId), + onBranchSession: sessionId => void branchStoredSession(sessionId), + onCancel: cancelRun, + onDeleteSelectedSession: () => { + const id = $selectedStoredSessionId.get() + + if (id) { + void removeSession(id) + } + }, + onDeleteSession: sessionId => void removeSession(sessionId), + onDismissError: dismissError, + onEdit: editMessage, + onLoadMoreMessaging: loadMoreMessagingForPlatform, + onLoadMoreProfileSessions: loadMoreSessionsForProfile, + onLoadMoreSessions: loadMoreSessions, + onManageCronJob: jobId => { + setCronFocusJobId(jobId) + navigate(CRON_ROUTE) + }, + onNavigate: selectSidebarItem, + onNewSessionInWorkspace: startSessionInWorkspace, + onNewSessionSplit: dir => void openNewSessionTile(dir), + onPasteClipboardImage: opts => composer.pasteClipboardImage(opts), + onPickFiles: () => void composer.pickContextPaths('file'), + onPickFolders: () => void composer.pickContextPaths('folder'), + onPickImages: () => void composer.pickImages(), + onReload: reloadFromMessage, + onRemoveAttachment: id => void composer.removeAttachment(id), + onRestoreToMessage: restoreToMessage, + onResumeSession: sessionId => navigate(sessionRoute(sessionId)), + onRetryResume: sessionId => void resumeSession(sessionId, true), + onSteer: steerPrompt, + onSubmit: submitText, + onThreadMessagesChange: handleThreadMessagesChange, + onToggleSelectedPin: toggleSelectedPin, + onTranscribeAudio: transcribeVoiceAudio, + onTriggerCronJob: jobId => { + void triggerCronJob(jobId) + .then(() => refreshCronJobs()) + .catch(() => undefined) + }, + getGateway: () => gatewayRef.current, + openAgents, + openCommandCenterSection, + requestGateway, + selectModel, + toggleCommandCenter + } + + if (actionsRef.current) { + Object.assign(actionsRef.current, nextActions) + } else { + actionsRef.current = nextActions + } + + const actions = actionsRef.current + + // Each pane node is memoized on ONLY the reactive inputs it truly consumes; + // everything else reaches its surface through `actions` (stable) or the + // surface's own atom subscriptions. A wiring tick that doesn't touch a + // node's keys leaves its element reference intact, so `WiredPane` (memoized) + // bails on that pane subtree — panes render independently of one another. + const sidebarNode = useMemo( + () => , + [actions, currentView] + ) + + const terminalNode = useMemo(() => , []) + + const statusbarNode = useMemo( + () => ( + + ), + [actions, agentsOpen, chatOpen, commandCenterOpen] + ) + + // The voice cap changes only on config load; the gateway instance + all + // chat reactivity are subscribed inside ChatRoutesSurface / ChatView. + const chatRoutesNode = useMemo( + () => , + [actions, voiceMaxRecordingSeconds] + ) + + const api = useMemo( + () => ({ + chatRoutes: chatRoutesNode, + sidebar: sidebarNode, + statusbar: statusbarNode, + terminal: terminalNode + }), + [chatRoutesNode, sidebarNode, statusbarNode, terminalNode] + ) + + // The REAL titlebar tool clusters (sidebar/flip toggles, haptics, keybinds, + // settings gear) — fixed chrome positioned via the same CSS vars AppShell + // sets, computed here from the live connection. Page-registered tools + // (preview's monitor/devtools cluster, …) arrive as registry contributions. + const leftTitlebarTools = useTitlebarToolContributions('left') + const rightTitlebarTools = useTitlebarToolContributions('right') + const connection = useStore($connection) + const controlsPos = titlebarControlsPosition(connection?.windowButtonPosition, Boolean(connection?.isFullscreen)) + // Exact vertical centering: titlebarControlsPosition() returns + // (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2, but TitlebarControls + // also applies a hard translate-y-0.5 (+2px) to its clusters. Cancel that + // constant so cluster center == bar center — measured, not eyeballed. + const controlsTranslateY = 2 + // Windows/WSLg reserve native min/max/close on the right (AppShell parity: + // prefer the live WCO measurement, fall back to the static reservation). + const measuredOverlayWidth = useWindowControlsOverlayWidth() + const nativeOverlayWidth = measuredOverlayWidth ?? connection?.nativeOverlayWidth ?? 0 + const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem' + // Pane-registered tools (preview's monitor/devtools cluster) anchor flush + // against the static system cluster — in the tree layout the titlebar band + // sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply. + const SYSTEM_TOOL_COUNT = 4 + const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length + const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))` + + const titlebarToolsWidth = + paneToolCount > 0 + ? `calc(${systemToolsWidth} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))` + : systemToolsWidth + + return ( + +
+ navigate(SETTINGS_ROUTE)} + tools={rightTitlebarTools} + /> + {children} +
+ + {/* The full real overlay set (mirrors DesktopController's `overlays`). */} + + {!isSecondaryWindow() && } + {!isSecondaryWindow() && ( + { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + requestGateway={requestGateway} + /> + )} + + + + + + + + + + + + + {settingsOpen && ( + + { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + onMainModelChanged={(provider, model) => { + setCurrentProvider(provider) + setCurrentModel(model) + updateModelOptionsCache(provider, model, true) + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + /> + + )} + + {commandCenterOpen && ( + + navigate(path)} + onOpenSession={sessionId => navigate(sessionRoute(sessionId))} + /> + + )} + + {agentsOpen && ( + + + + )} + + {cronOpen && ( + + navigate(sessionRoute(sessionId))} + /> + + )} + + {profilesOpen && ( + + + + )} + + {starmapOpen && ( + + + + )} + + {/* The full hotkey map (⌘/ and the titlebar keyboard button). */} + + + {/* Toasts above everything. */} + + + {/* Petdex floating mascot — renders nothing unless installed + enabled. */} + + + {/* Single persistent xterm host chasing the terminal pane's slot rect. */} + +
+ ) +}