mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(desktop): contribution controller, surfaces, and wiring
This commit is contained in:
parent
0f398f8e9c
commit
0f922002eb
11 changed files with 2575 additions and 0 deletions
36
apps/desktop/src/app/contrib/context.tsx
Normal file
36
apps/desktop/src/app/contrib/context.tsx
Normal 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]}</>
|
||||
})
|
||||
615
apps/desktop/src/app/contrib/controller.tsx
Normal file
615
apps/desktop/src/app/contrib/controller.tsx
Normal 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 }
|
||||
133
apps/desktop/src/app/contrib/hooks/use-background-sync.ts
Normal file
133
apps/desktop/src/app/contrib/hooks/use-background-sync.ts
Normal 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])
|
||||
}
|
||||
176
apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
Normal file
176
apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
Normal 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])
|
||||
}
|
||||
71
apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts
Normal file
71
apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts
Normal 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)
|
||||
}, [])
|
||||
}
|
||||
108
apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts
Normal file
108
apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts
Normal 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
|
||||
])
|
||||
}
|
||||
7
apps/desktop/src/app/contrib/index.ts
Normal file
7
apps/desktop/src/app/contrib/index.ts
Normal 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'
|
||||
207
apps/desktop/src/app/contrib/panes.tsx
Normal file
207
apps/desktop/src/app/contrib/panes.tsx
Normal 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')
|
||||
204
apps/desktop/src/app/contrib/surfaces.tsx
Normal file
204
apps/desktop/src/app/contrib/surfaces.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
79
apps/desktop/src/app/contrib/types.ts
Normal file
79
apps/desktop/src/app/contrib/types.ts
Normal 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
|
||||
}
|
||||
939
apps/desktop/src/app/contrib/wiring.tsx
Normal file
939
apps/desktop/src/app/contrib/wiring.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue