feat(desktop): contribution controller, surfaces, and wiring

This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:28:32 -04:00
parent 0f398f8e9c
commit 0f922002eb
11 changed files with 2575 additions and 0 deletions

View file

@ -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<WiringApi | null>(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 <StatusbarControls items={[]} leftItems={[]} />
}
return (
<div className="grid h-full place-items-center">
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
</div>
)
}
return <>{api[part]}</>
})

View file

@ -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 = () => <WiredPane part="chatRoutes" />
// 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) => <WorkspaceTabMenu>{tab}</WorkspaceTabMenu>
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: () => <WiredPane part="sidebar" />
},
{
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: () => <WiredPane part="terminal" />
},
{
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: () => <FilesPane />
},
{
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: () => <PreviewRailPane />
},
{
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: () => <ReviewPaneContent />
},
{
// 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: () => <LogsPane />
}
])
// ---------------------------------------------------------------------------
// 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 <hermes home>/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 (
<SidebarProvider
className="h-screen min-h-0 flex-col bg-background"
onOpenChange={setSidebarOpen}
open={sidebarOpen}
style={{ '--sidebar-width': '100%' } as CSSProperties}
>
<ContribWiring>
<div
className="flex h-screen min-h-0 w-screen flex-col bg-(--ui-bg-chrome) text-(--ui-text-primary)"
style={{ '--titlebar-height': '0px' } as CSSProperties}
>
{/* 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. */}
<div className="relative flex h-[34px] shrink-0 items-center border-b border-(--ui-stroke-tertiary) text-xs">
{/* 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). */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,1.25rem)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
/>
<div
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 1.25rem) + 1rem))'
}}
>
<Slot area="titleBar.left" />
</div>
<div className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]">
<Slot area="titleBar.center" />
</div>
<div
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
right:
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * (var(--titlebar-control-size, 1.25rem) + 0.25rem) + 0.5rem))'
}}
>
<Slot area="titleBar.right" />
</div>
</div>
<LayoutTreeRoot />
{/* "Close running tab?" — the busy/input-blocked tile close gate. */}
<SessionTileCloseConfirm />
{/* The REAL statusbar (model pill, command center, agents, ) with
statusBar.left/right contributions merged in. */}
<WiredPane part="statusbar" />
</div>
</ContribWiring>
</SidebarProvider>
)
}
// Referenced type kept for plugin authors' reference (payload shape of
// statusBar.* contributions).
export type { StatusbarItem }

View file

@ -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> | unknown
refreshCronJobs: () => Promise<unknown> | unknown
refreshCurrentModel: (force?: boolean) => Promise<unknown> | unknown
refreshHermesConfig: () => Promise<unknown> | unknown
refreshMessagingSessions: () => Promise<unknown> | unknown
refreshSessions: () => Promise<unknown> | 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])
}

View file

@ -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> | unknown
resumeExhaustedSessionId: null | string
routedSessionId: null | string
runtimeIdByStoredSessionId: { readonly current: Map<string, string> }
}
/**
* 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])
}

View file

@ -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> | unknown
submitText: (text: string) => Promise<unknown> | 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)
}, [])
}

View file

@ -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<typeof useSessionStateCache>
interface SessionTileDelegateParams {
archiveSession: (storedSessionId: string) => Promise<unknown>
branchStoredSession: (storedSessionId: string) => Promise<unknown>
executeSlashCommand: ReturnType<typeof usePromptActions>['executeSlashCommand']
removeSession: (storedSessionId: string) => Promise<unknown>
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<SessionResumeResponse>('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
])
}

View file

@ -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'

View file

@ -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 <div className="p-3 text-xs text-(--ui-text-quaternary)">log unavailable: {String(error)}</div>
}
if (!data) {
return (
<div className="grid h-full place-items-center">
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="LOGS" />
</div>
)
}
// No chrome of its own — the zone header (when the user summons it) is the
// pane's only label. Just the tail.
return (
<pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap break-words p-2.5 font-mono text-[0.66rem] leading-relaxed text-(--ui-text-secondary)">
{data.lines.join('\n')}
</pre>
)
}
// ---------------------------------------------------------------------------
// 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<string>) | null>(null)
export function PreviewRailPane() {
const previewTarget = useStore($previewTarget)
const fileTarget = useStore($filePreviewTarget)
const restartPreviewServer = useStore($restartPreviewServer)
if (!previewTarget && !fileTarget) {
return (
<div className="grid h-full place-items-center px-4 text-center">
<div className="flex flex-col items-center gap-1.5">
<DecodeText className="text-(--ui-text-quaternary)" prefix={1} text="PREVIEW" />
<span className="text-[0.68rem] text-(--ui-text-quaternary)">click a file in the files pane</span>
</div>
</div>
)
}
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.
<div
className={cn(ZONE_CONTENT, 'min-h-0 w-full overflow-hidden [&>aside]:pt-0')}
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
>
<ChatPreviewRail onRestartServer={restartPreviewServer ?? undefined} setTitlebarToolGroup={setTitlebarToolGroup} />
</div>
)
}
/** 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 (
<div className={ZONE_CONTENT}>
<RightSidebarPane onActivateFile={previewFile} onActivateFolder={previewFile} />
</div>
)
}
// ---------------------------------------------------------------------------
// 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 (
<div className={cn(ZONE_CONTENT, 'flex min-h-0 flex-col [&>aside]:min-h-0 [&>aside]:flex-1')}>
<ReviewPane key={cwd || 'no-cwd'} />
</div>
)
}
// ---------------------------------------------------------------------------
// 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: () => <ContribBoundary id={c.id} variant="chip">{c.render!()}</ContribBoundary>
} 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 `<prefix>.<side>`, so page-owned items flow through
* the same pipe plugins use. Setting an empty list clears the group.
*/
export function registryGroupSetter<T>(prefix: string): GroupSetter<T> {
const disposers = new Map<string, () => 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<StatusbarItem>('statusBar')
export const setTitlebarToolGroup = registryGroupSetter<TitlebarTool>('titleBar.tools')

View file

@ -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 <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
}
export const SidebarSurface = memo(function SidebarSurface({
actions,
currentView
}: {
actions: SidebarActions
currentView: ComponentProps<typeof ChatSidebar>['currentView']
}) {
return <ChatSidebar currentView={currentView} {...actions} />
})
export const TerminalSurface = memo(function TerminalSurface() {
return (
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-(--ui-editor-surface-background)">
<TerminalPaneChrome />
</div>
)
})
/** 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 <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
})
/** 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' ? (
<ModelMenuPanel
gateway={gateway || undefined}
onSelectModel={actions.selectModel}
requestGateway={actions.requestGateway}
/>
) : null,
[actions, gateway, gatewayState]
)
const chatView = (
<ChatView
gateway={gateway}
maxVoiceRecordingSeconds={maxVoiceRecordingSeconds}
modelMenuContent={modelMenuContent}
onAddContextRef={actions.onAddContextRef}
onAddUrl={actions.onAddUrl}
onAttachDroppedItems={actions.onAttachDroppedItems}
onAttachImageBlob={actions.onAttachImageBlob}
onBranchInNewChat={actions.onBranchInNewChat}
onCancel={actions.onCancel}
onDeleteSelectedSession={actions.onDeleteSelectedSession}
onDismissError={actions.onDismissError}
onEdit={actions.onEdit}
onPasteClipboardImage={actions.onPasteClipboardImage}
onPickFiles={actions.onPickFiles}
onPickFolders={actions.onPickFolders}
onPickImages={actions.onPickImages}
onReload={actions.onReload}
onRemoveAttachment={actions.onRemoveAttachment}
onRestoreToMessage={actions.onRestoreToMessage}
onRetryResume={actions.onRetryResume}
onSteer={actions.onSteer}
onSubmit={actions.onSubmit}
onThreadMessagesChange={actions.onThreadMessagesChange}
onToggleSelectedPin={actions.onToggleSelectedPin}
onTranscribeAudio={actions.onTranscribeAudio}
/>
)
// 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) => (
<div className="contents" data-zone-no-header>
<Suspense fallback={null}>{view}</Suspense>
</div>
)
return (
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route element={page(<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="skills" />
<Route element={page(<MessagingView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="messaging" />
<Route element={page(<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="artifacts" />
<Route element={null} path="agents" />
<Route element={null} path="command-center" />
<Route element={null} path="cron" />
<Route element={null} path="profiles" />
<Route element={null} path="settings" />
<Route element={null} path="starmap" />
{/* 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
element={page(<ContribBoundary id={route.key}>{route.render()}</ContribBoundary>)}
key={route.key}
path={route.path.slice(1)}
/>
))}
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
</Routes>
)
})

View file

@ -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<typeof useGatewayRequest>['requestGateway']
/** The ChatSidebar handlers the controller owns — forwarded verbatim. */
export type SidebarActions = Pick<
ComponentProps<typeof ChatSidebar>,
| '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<typeof ChatView>,
| '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<typeof ChatView>['gateway']
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
requestGateway: GatewayRequester
selectModel: ComponentProps<typeof ModelMenuPanel>['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
}

View file

@ -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 `<WiredPane part="…"/>` 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<string, string>())
// 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<WiringActions | null>(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<typeof handleDesktopGatewayEvent>[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(
() => <SidebarSurface actions={actions} currentView={currentView} />,
[actions, currentView]
)
const terminalNode = useMemo(() => <TerminalSurface />, [])
const statusbarNode = useMemo(
() => (
<StatusbarSurface
actions={actions}
agentsOpen={agentsOpen}
chatOpen={chatOpen}
commandCenterOpen={commandCenterOpen}
/>
),
[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(
() => <ChatRoutesSurface actions={actions} maxVoiceRecordingSeconds={voiceMaxRecordingSeconds} />,
[actions, voiceMaxRecordingSeconds]
)
const api = useMemo<WiringApi>(
() => ({
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 (
<ContribWiringContext.Provider value={api}>
<div
className="contents"
style={
{
'--titlebar-controls-left': `${controlsPos.left}px`,
'--titlebar-controls-top': `${controlsPos.top - controlsTranslateY}px`,
'--titlebar-tools-right': titlebarToolsRight,
'--titlebar-tools-width': titlebarToolsWidth,
'--shell-preview-toolbar-gap': systemToolsWidth
} as CSSProperties
}
>
<TitlebarControls
leftTools={leftTitlebarTools}
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
tools={rightTitlebarTools}
/>
{children}
</div>
{/* The full real overlay set (mirrors DesktopController's `overlays`). */}
<RemoteDisplayBanner />
{!isSecondaryWindow() && <DesktopInstallOverlay />}
{!isSecondaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
requestGateway={requestGateway}
/>
)}
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<SessionPickerOverlay onResume={resumeSession} />
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<PetGenerateOverlay />
<SessionSwitcher />
<FileActionDialogs />
<RemoteFolderPicker />
{settingsOpen && (
<Suspense fallback={null}>
<SettingsView
gateway={gatewayRef.current}
onClose={closeOverlayToPreviousRoute}
onConfigSaved={() => {
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'] })
}}
/>
</Suspense>
)}
{commandCenterOpen && (
<Suspense fallback={null}>
<CommandCenterView
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
)}
{agentsOpen && (
<Suspense fallback={null}>
<AgentsView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{cronOpen && (
<Suspense fallback={null}>
<CronView
onClose={closeOverlayToPreviousRoute}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
)}
{profilesOpen && (
<Suspense fallback={null}>
<ProfilesView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{starmapOpen && (
<Suspense fallback={null}>
<StarmapView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{/* The full hotkey map (⌘/ and the titlebar keyboard button). */}
<KeybindPanel />
{/* Toasts above everything. */}
<NotificationStack />
{/* Petdex floating mascot — renders nothing unless installed + enabled. */}
<FloatingPet />
{/* Single persistent xterm host chasing the terminal pane's slot rect. */}
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
</ContribWiringContext.Provider>
)
}