Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
import { createContext, memo, useContext } from 'react'
import { DecodeText } from '@/components/ui/decode-text'
import { StatusbarControls } from '../shell/statusbar-controls'
import type { WiringApi } from './types'
/** The controller publishes its wired surfaces here; every registered pane
* / chrome slot reads one back through `WiredPane`. */
export const ContribWiringContext = createContext<WiringApi | null>(null)
/** Render a wired surface inside a registered pane / chrome slot.
*
* Memoized on `part` (its only prop): a zone re-rendering for reasons that
* don't touch the wiring — a drag hint sweeping the tree, a sash resize, an
* edit-mode toggle — re-renders the group chrome but NOT this component, so
* the (expensive) pane body it reads from context is untouched. When the
* wiring's `api` genuinely changes, the context read re-renders it as normal. */
export const WiredPane = memo(function WiredPane({ part }: { part: keyof WiringApi }) {
const api = useContext(ContribWiringContext)
if (!api) {
if (part === 'statusbar') {
return <StatusbarControls items={[]} leftItems={[]} />
}
return (
<div className="grid h-full place-items-center">
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
</div>
)
}
return <>{api[part]}</>
})
+936
View File
@@ -0,0 +1,936 @@
import { useStore } from '@nanostores/react'
import { atom, computed } from 'nanostores'
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
import { SessionDraftTitle } from '@/app/chat/session-draft-title'
import { SessionStatusDot } from '@/app/chat/session-status-dot'
import { PALETTE_AREA, type PaletteContribution, paletteToggle } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { InlinePreviewDirective } from '@/components/assistant-ui/inline-preview-directive'
import { IdleMount } from '@/components/idle-mount'
import { $layoutEditMode, toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { allPaneIds, group, groupLeafIds, split } from '@/components/pane-shell/tree/model'
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
import {
$layoutTree,
bindPaneVisibility,
bindToolPaneCollapse,
bindTreeSideVisibility,
declareDefaultTree,
dismissTreePane,
isPaneVisible,
markCollapsePane,
mirrorLayoutTree,
paneRootSide,
registerLayoutResetHandler,
registerPaneCloser,
registerPaneOpener,
removeTreePane,
resetLayoutTree,
revealTreePane,
setStripTabHidden,
targetZoneTabStripVisible,
togglePaneVisible,
toggleTargetZoneTabStrip,
watchContributedPanes
} from '@/components/pane-shell/tree/store'
import { $workspaceOwnerLabels, workspaceOwnerTitle } from '@/components/pane-shell/workspace-scope'
import { SidebarProvider } from '@/components/ui/sidebar'
import { discoverBundledPlugins } from '@/contrib/plugins'
import { Slot } from '@/contrib/react/slot'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { translateNow } from '@/i18n'
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { Download, FileText, LayoutDashboard, PanelBottom, PanelTop, Terminal, Upload, Zap } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
import { setYoloEnabled } from '@/lib/yolo-session'
import { pruneComposerPopoutZones } from '@/store/composer-popout'
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 { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share'
import {
$reviewOpen,
$reviewScopeCwd,
$reviewScopeTarget,
closeReview,
openReview,
REVIEW_PANE_ID
} from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
import { $botChatScopes } from '@/store/session-states'
import { watchUnreadWriteGuard } from '@/store/session-unread-remote'
import { $statusbarVisible } from '@/store/statusbar-prefs'
import { isBrowserWindow, isHudWindow } from '@/store/windows'
import { BrowserPopoutShell } from '../chat/browser-popout-shell'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchPreviewTiles } from '../chat/preview-tile'
import { watchRouteTiles } from '../chat/route-tile'
import { startSessionDrag } from '../chat/session-drag'
import {
SessionTileCloseConfirm,
stackSessionTilesIntoMain,
startUnrestoredTileTitleBackfill,
watchSessionTiles,
WorkspaceTabMenu
} from '../chat/session-tile'
import { AppContextMenu } from '../context-menu/app-context-menu'
import { HudShell } from '../hud/hud-shell'
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
import { $workspaceIsPage } from '../routes'
import { FilesPane, LogsPane, 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" />
// Boot-hidden panes mount behind display:none (instant-toggle contract) — defer
// them to idle so they're off the first-paint path, warm before reveal.
const idle = (node: ReactElement) => <IdleMount>{node}</IdleMount>
// 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>
/** The `@session` payload for the workspace tab — the loaded primary session,
* or null on a fresh draft / full-page view (nothing to link). */
const workspaceDragPayload = (): SessionDragPayload | null => {
const selected = $selectedStoredSessionId.get()
if (!selected || $workspaceIsPage.get()) {
return null
}
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, selected))
return { id: selected, profile: stored?.profile ?? '', title: stored ? storedSessionTitle(stored) : '' }
}
// The main tab drags like a session tile — drop it on a composer to link the
// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane
// move when there's no loaded session to carry.
const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void) => {
const payload = workspaceDragPayload()
if (!payload) {
return false
}
startSessionDrag(payload, event, { onTap })
return true
}
registry.registerMany([
{
id: 'sessions',
area: 'panes',
title: translateNow('sidebar.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',
tabTitle: () => translateNow('sidebar.sessions'),
collapsible: true,
dock: { pane: 'workspace', pos: 'left' },
revealAliases: ['chat-sidebar'],
// Standing chrome: no close gestures at all — the tab is shown/hidden
// (zone menu Show/Hide rows + the auto-registered ⌘K toggle below).
hideOnly: true,
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_TITLE,
data: {
placement: 'main',
minWidth: '22vw',
tabDrag: workspaceTabDrag,
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.
//
// NO minHeight: a tool panel drags all the way down to its collapsed
// header (the sash floors it at COLLAPSED_ZONE_PX and folds the zone to
// its rail there). A real floor left a sliver of unusable terminal.
data: {
placement: 'bottom',
height: '20vh',
maxHeight: '80vh',
revealOnPreset: true,
lifecycleKeepAlive: 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: () => idle(<FilesPane />)
},
{
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: () => idle(<ReviewPaneContent />)
}
])
// ---------------------------------------------------------------------------
// 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
},
paletteToggle({
id: 'layout.editMode',
label: 'Toggle layout edit mode',
action: 'layout.editMode',
icon: LayoutDashboard,
keywords: ['layout', 'zones', 'panes', 'edit', 'rearrange'],
get: () => $layoutEditMode.get(),
set: enabled => $layoutEditMode.set(enabled)
}),
// 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
},
// The core `::preview{file="…"}` transcript directive — the model (or a
// skill) renders a workspace HTML file LIVE inside its own message
// (sandboxed srcdoc iframe; falls back to the classic preview card for
// non-HTML targets and remote gateways). Also the reference consumer for
// the `transcript.directives` area plugins register into.
{
id: 'transcript.preview',
area: TRANSCRIPT_DIRECTIVE_AREA,
data: {
name: 'preview',
render: ({ attrs, streaming }) => <InlinePreviewDirective attrs={attrs} streaming={streaming} />
} satisfies TranscriptDirectiveContribution
},
{
id: 'layout.reset',
area: PALETTE_AREA,
data: {
id: 'layout.reset',
label: 'Reset layout',
icon: LayoutDashboard,
keywords: ['layout', 'reset', 'default', 'panes'],
run: resetLayoutTree
} satisfies PaletteContribution
},
// Hiding the bar removes the surface that would otherwise offer it back, so
// ⌘K is the guaranteed door in (alongside the rebindable ⌘⇧S).
paletteToggle({
id: 'view.toggleStatusbar',
label: 'Toggle status bar',
action: 'view.toggleStatusbar',
icon: PanelBottom,
keywords: ['status bar', 'statusbar', 'bottom bar', 'hide', 'show', 'chrome'],
get: () => $statusbarVisible.get(),
set: enabled => $statusbarVisible.set(enabled)
}),
paletteToggle({
id: 'view.toggleTabStrip',
label: 'Toggle tabs',
action: 'view.toggleTabStrip',
icon: PanelTop,
keywords: ['tab strip', 'tab bar', 'tabs', 'header', 'zone', 'hide', 'show', 'chrome'],
// On-screen truth for the zone the verbs target, not a stored flag: a zone
// on auto has no stored value, and the row must read as "what pressing
// this does to what I can see".
get: () => Boolean(targetZoneTabStripVisible()),
set: () => void toggleTargetZoneTabStrip()
}),
// 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: () => window.dispatchEvent(new CustomEvent('hermes:open-keybinds'))
} satisfies PaletteContribution
},
// Profile sharing: bundle the active profile (config, skills, theme, layout)
// into a portable archive, or adopt someone else's. Both open native dialogs,
// so the palette closing on select is correct.
{
id: 'profile.export',
area: PALETTE_AREA,
data: {
id: 'profile.export',
label: 'Export profile…',
icon: Upload,
keywords: ['profile', 'export', 'share', 'bundle', 'theme', 'settings', 'backup'],
run: () => void runExportProfileFlow()
} satisfies PaletteContribution
},
{
id: 'profile.import',
area: PALETTE_AREA,
data: {
id: 'profile.import',
label: 'Import profile…',
icon: Download,
keywords: ['profile', 'import', 'share', 'bundle', 'archive', 'restore'],
run: () => void runImportProfileFlow()
} satisfies PaletteContribution
}
])
// ---------------------------------------------------------------------------
// Layout presets — CHAT (main) always dominates.
// ---------------------------------------------------------------------------
// The REAL default: sessions left, chat main, and the right sidebars in column
// order main | … | review | file-browser (files outermost). Each is its OWN
// zone. Review collapses to nothing while its pane is hidden (⌘G off).
//
// Preview tiles are DYNAMIC panes (like session tiles), so no preset names one:
// they're registered by watchPreviewTiles as tabs open, and dockPaneBeside lands
// each one directly beside the file tree wherever that currently lives — so a
// file double-click still slides a preview open as its own pane next to the
// tree, never as a tab stacked into the files sidebar.
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(['files'], { id: 'grp-files' })],
[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', 'review', 'terminal'])], [1, 4.6])
const TERMINAL_TREE = split(
'column',
[
split('row', [group(['sessions']), group(['workspace']), group(['files', '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(['review'])], [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. A popped-out Browser and the HUD have no layout tree — registering
// tiles there would still run, and preview-tile watching would try to dock
// into a tree this window never renders (and, in the HUD, paint a webview
// into the transparent overlay).
if (!isBrowserWindow() && !isHudWindow()) {
watchSessionTiles()
startUnrestoredTileTitleBackfill()
watchRouteTiles()
watchPreviewTiles()
}
// Composer pop-out state is keyed by layout zone, so drop entries for zones the
// user has since closed or merged away — otherwise a long-lived install keeps a
// row for every split it has ever had.
$layoutTree.subscribe(tree => {
if (tree) {
pruneComposerPopoutZones(groupLeafIds(tree))
}
})
// Mirror sidebar pins into the backend keep-flag so the auto-archive sweep
// never hides a pinned chat (and pre-existing pins migrate transparently).
watchSessionPins()
// Release unread-write guards once a list page confirms the value we wrote.
watchUnreadWriteGuard()
// 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',
// The placeholder, not the draft's live name — `tabTitle` below renders
// that. Keeping it here would re-register the pane on every keystroke.
// A bot chat reads as its BOT: every canonical Bot Chat is stored under
// the same name, which told two open bots apart by nothing (#99152).
title: workspaceOwnerTitle(
stored ? storedSessionTitle(stored) : NEW_SESSION_TITLE,
selected ? $botChatScopes.get()[selected] : undefined
),
data: {
// The tab's status dot — the SAME primitive the sidebar row and session
// tiles render, so the main tab never disagrees with its sidebar row. A
// fresh draft has no session to key by, which IS its status: the dot
// resolves to `draft` and marks the tab rather than leaving a hole.
tabLead: () => <SessionStatusDot session={stored} storedSessionId={selected} />,
// A draft's name lives in its composer, not in any session row, so the
// label subscribes to it directly — typing renames the tab without
// re-registering the pane.
tabTitle: stored ? undefined : () => <SessionDraftTitle scope={selected} />,
// Pages aren't tab-able: the main zone's bar stands down while one shows.
headerVeto: $workspaceIsPage.get(),
placement: 'main',
minWidth: '22vw',
tabDrag: workspaceTabDrag,
tabWrap: wrapWorkspaceTab,
uncloseable: true
},
render: renderWorkspacePane
})
}
$selectedStoredSessionId.listen(syncWorkspaceTitle)
$sessions.listen(syncWorkspaceTitle)
$botChatScopes.listen(syncWorkspaceTitle)
$workspaceOwnerLabels.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.
// ---------------------------------------------------------------------------
// HIDE-STYLE PANES (files, review, preview): the binding lives in the tree
// store — bindPaneVisibility — alongside bindToolPaneCollapse, so both are
// testable against the real function instead of a copy.
// TOOL PANELS (terminal, logs): the binding lives in the tree store —
// bindToolPaneCollapse — so the boot rule it encodes is testable against the
// real function instead of a copy. See its docblock for the semantics.
// 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()))
// The tree pane's own presence tracks ⌘J directly, not just the column's
// collapse — otherwise a pane revealed into that shared column would drag the
// tree along with it.
//
// Both get a CLOSER and an OPENER. The closer keeps ⌘J/⌘G truthful when the
// pane is closed from the tab menu; the opener is its mirror, so bringing the
// pane back through the tree (the toggle's reveal path, the rail, a preset)
// writes the store too. Without the opener the boolean went stale the moment
// anything but the toggle showed the pane — the divergence this whole change
// is about.
bindPaneVisibility(
'files',
computed([$hasWorkspace, $fileBrowserOpen], (workspace, open) => workspace && open),
() => setFileBrowserOpen(false),
() => setFileBrowserOpen(true)
)
// ⌘G — the review sidebar appears/disappears (and comes to the front).
bindPaneVisibility(
'review',
computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace),
closeReview,
() => openReview($reviewScopeCwd.get(), $reviewScopeTarget.get())
)
// ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not
// hides; PTYs stay alive while collapsed (see PersistentTerminal).
bindToolPaneCollapse(
'terminal',
$terminalTakeover,
() => setTerminalTakeover(false),
() => setTerminalTakeover(true)
)
// ⌘K door onto the same pane the keybind and statusbar pill flip — was a
// one-way "open" row under Go to, so it never showed on/off and couldn't hide.
// Reads the TREE like every other pane toggle: `$terminalTakeover` stays true
// behind a stacked sibling tab or a minimized zone, which would light the row
// "on" for a terminal that isn't on screen.
registry.register(
paletteToggle({
id: 'view.showTerminal',
label: 'Toggle terminal',
action: 'view.showTerminal',
icon: Terminal,
keywords: ['terminal', 'shell', 'console', 'pty'],
get: () => isPaneVisible('terminal'),
set: () => togglePaneVisible('terminal')
})
)
// Logs are ⌘K-ONLY chrome: the pane contribution EXISTS only while $logsOpen
// is on. Off (the default) keeps logs out of the registry and the tree
// entirely — no secondary tab riding the terminal strip, no preset or
// adoption path that resurrects it. Session-only on purpose (not persisted):
// a fresh boot never re-opens logs automatically. The palette toggle is the
// single door in; tab ✕ / ⌘W / the toggle itself remove it again.
const $logsOpen = atom(false)
let unregisterLogsPane: (() => void) | null = null
const syncLogsPane = (open: boolean) => {
if (open) {
unregisterLogsPane ??= registry.register({
id: 'logs',
area: 'panes',
title: 'logs',
// Same tool-panel sizing rule as the terminal above — no minHeight, so
// the sash floors it at COLLAPSED_ZONE_PX and folds the zone to its rail
// rather than leaving a sliver. dock: its OWN zone beside the terminal —
// never a tab in the terminal's strip.
data: {
placement: 'bottom',
dock: { pane: 'terminal', pos: 'right' },
height: '20vh',
maxHeight: '80vh'
},
render: () => idle(<LogsPane />)
})
// Summoning logs is explicit intent — front it (un-dismisses if a ✕ close
// left a dismissal record behind).
revealTreePane('logs')
} else {
unregisterLogsPane?.()
unregisterLogsPane = null
// No dismissal record — the next toggle-on must re-adopt cleanly. Also
// sweeps 'logs' out of persisted trees from before it was summon-only.
// Guarded: removePane rebuilds the tree even for an absent pane, and a
// no-op boot sweep would commit (and persist) a fresh identical tree.
const tree = $layoutTree.get()
if (tree && allPaneIds(tree).includes('logs')) {
removeTreePane('logs')
}
}
}
// Tool-panel tab semantics (✕ / ⌘W route through the store) so the palette
// toggle stays truthful either way.
markCollapsePane('logs')
registerPaneCloser('logs', () => $logsOpen.set(false))
registerPaneOpener('logs', () => $logsOpen.set(true))
syncLogsPane($logsOpen.get())
$logsOpen.listen(syncLogsPane)
registry.register(
paletteToggle({
id: 'logs.toggle',
label: 'Toggle logs',
icon: FileText,
keywords: ['logs', 'agent log', 'tail', 'debug'],
// On-screen, not the store's boolean. Summon-only keeps the two in step
// while logs sits in its own zone, but the user can still drag it into the
// terminal's strip or minimize its zone — and then `$logsOpen` reads true
// with nothing visible, so the row would show "on" and its press would
// spend itself re-asserting a value it already held.
get: () => isPaneVisible('logs'),
set: () => togglePaneVisible('logs')
})
)
// Hide-only chrome tabs (sessions / Bots) get a ⌘K toggle each — the palette
// door onto the same show/hide the zone menu offers. Auto-registered from the
// panes area so a plugin's hideOnly pane (Bots registers at plugin load, after
// this module runs) gets its row for free; disposers keep it in step when a
// plugin unloads. Registry writes during a subscriber callback are safe (the
// registry snapshots per-area and re-notifies), and re-registering the same
// palette id replaces the row instead of stacking duplicates.
{
const stripTabToggles = new Map<string, () => void>()
const syncStripTabToggles = () => {
const hideOnlyPanes = registry
.getArea('panes')
.filter(c => (c.data as { hideOnly?: boolean } | undefined)?.hideOnly)
const wanted = new Set(hideOnlyPanes.map(c => c.id))
for (const [paneId, dispose] of stripTabToggles) {
if (!wanted.has(paneId)) {
dispose()
stripTabToggles.delete(paneId)
}
}
for (const pane of hideOnlyPanes) {
if (stripTabToggles.has(pane.id)) {
continue
}
const title = String(pane.title ?? pane.id)
stripTabToggles.set(
pane.id,
registry.register(
paletteToggle({
id: `strip-tab.${pane.id}`,
label: translateNow('zones.toggleStripTab', title),
icon: LayoutDashboard,
keywords: [title.toLowerCase(), 'tab', 'pane', 'sidebar', 'show', 'hide'],
// On-screen truth, same contract as the logs toggle above.
get: () => isPaneVisible(pane.id),
set: visible => {
if (visible) {
revealTreePane(pane.id)
} else {
setStripTabHidden(pane.id, true)
}
}
})
)
)
}
}
syncStripTabToggles()
registry.subscribeArea('panes', syncStripTabToggles)
}
// YOLO (dangerous-command approval bypass) is a status-bar zap and a /yolo
// command; ⌘K is the third door onto the SAME store function, so a user who
// lives in the palette never has to hunt for the pill.
registry.register(
paletteToggle({
id: 'session.yolo',
label: 'Toggle yolo',
icon: Zap,
keywords: ['yolo', 'approvals', 'auto-approve', 'bypass', 'dangerous', 'commands'],
get: () => $yoloActive.get(),
set: enabled => void setYoloEnabled(enabled).catch(() => undefined)
})
)
// 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')
)
// ---------------------------------------------------------------------------
interface TitlebarSlotProps {
area: 'titleBar.center' | 'titleBar.left' | 'titleBar.right'
className: string
style?: CSSProperties
}
function TitlebarSlot({ area, className, style }: TitlebarSlotProps) {
const items = useContributions(area)
if (items.length === 0) {
return null
}
return (
<div className={className} style={style}>
<Slot area={area} />
</div>
)
}
export function ContribController() {
const sidebarOpen = useStore($sidebarOpen)
const statusbarVisible = useStore($statusbarVisible)
// HUD mode is the SAME app with its frame removed: the wiring (gateway,
// sessions, streams, submit) mounts identically, and only the shell around
// the chat surface differs. Branching here rather than at the window entry
// is what keeps the HUD's composer the real composer.
if (isHudWindow()) {
return (
<ContribWiring>
<AppContextMenu />
<HudShell />
</ContribWiring>
)
}
if (isBrowserWindow()) {
return (
<ContribWiring>
<BrowserPopoutShell />
</ContribWiring>
)
}
return (
<SidebarProvider
className="h-screen min-h-0 flex-col bg-background"
onOpenChange={setSidebarOpen}
open={sidebarOpen}
style={{ '--sidebar-width': '100%' } as CSSProperties}
>
<ContribWiring>
<AppContextMenu />
<div
className="flex h-screen min-h-0 w-screen flex-col bg-(--ui-bg-chrome) text-(--ui-text-primary)"
// Window-glass hook: this div and the sidebar-wrapper above it are
// the app shell's two full-window opaque painters; the
// [data-hermes-glass] rules in styles.css clear them so the tint
// painted by <body> is the only thing between the page and the
// vibrancy material.
data-contrib-shell=""
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 bg-(--ui-sidebar-surface-background) 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,24px)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
/>
<TitlebarSlot
area="titleBar.left"
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, 24px) + 1rem))'
}}
/>
<TitlebarSlot
area="titleBar.center"
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]"
/>
<TitlebarSlot
area="titleBar.right"
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
right:
// Five static cluster buttons: four systemTools plus the
// always-present right-sidebar toggle (titlebar-controls.tsx).
// Keep in sync with wiring.tsx's SYSTEM_TOOL_COUNT.
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 5 * var(--titlebar-control-size, 24px) + 0.5rem))'
}}
/>
</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. Unmounted — not
just hidden — while toggled off, so its 15s status poll and the
per-turn readouts stop with it. */}
{statusbarVisible && <WiredPane part="statusbar" />}
</div>
</ContribWiring>
</SidebarProvider>
)
}
// Referenced type kept for plugin authors' reference (payload shape of
// statusBar.* contributions).
export type { StatusbarItem }
@@ -0,0 +1,128 @@
// Dev-only: drive the credit-notice UX without a backend that can hit real
// usage bands. Each trigger emits ONE synthetic `notification.show` /
// `notification.clear` gateway event through the real fan-out
// (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in
// `use-message-stream/gateway-event/status.ts` — toast render, key-replacement
// escalation, TTL self-dismiss, native OS notification, and billing re-poll.
//
// Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none
// of this ships in a production build.
import type { GatewayEvent } from '@hermes/shared'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { registry } from '@/contrib/registry'
import { CreditCard } from '@/lib/icons'
import { emitLocalGatewayEvent } from '@/store/gateway'
import { $activeSessionId } from '@/store/session'
interface NoticeStep {
key: string
level: string
kind: 'sticky' | 'ttl'
text: string
ttl_ms?: number
}
// Walks the same lifecycle the Nous credits tracker drives: usage escalates in
// place (50→75→90, one key), then grant-spent, then the depleted/restored pair.
// Wraps around. These are all separate SHOW steps; the stepper auto-clears the
// previous notice when the key changes, so the demo shows one toast at a time
// (real usage CAN stack these, but that's noise when you're eyeballing a single
// transition). The same-key usage steps still demonstrate in-place escalation.
const STEPS: readonly NoticeStep[] = [
{ key: 'credits.usage', kind: 'sticky', level: 'info', text: "• You've used $110.00 of your $220.00 cap" },
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $165.00 of your $220.00 cap" },
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $198.00 of your $220.00 cap" },
{ key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $12.00 top-up left' },
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }
]
let cursor = 0
let lastShownKey: null | string = null
function clearNotice(key: string): void {
emitLocalGatewayEvent({
payload: { key },
session_id: $activeSessionId.get() ?? '',
type: 'notification.clear'
} as GatewayEvent)
}
function showNotice(step: NoticeStep): void {
emitLocalGatewayEvent({
payload: {
id: `${step.key}:${Date.now()}`,
key: step.key,
kind: step.kind,
level: step.level,
text: step.text,
ttl_ms: step.ttl_ms ?? null
},
session_id: $activeSessionId.get() ?? '',
type: 'notification.show'
} as GatewayEvent)
}
/** Fire the next notice in the scripted sequence, wrapping at the end. */
export function stepCreditsNoticeDemo(): void {
const step = STEPS[cursor % STEPS.length]
// One toast at a time: retire the previous notice when we move to a new key.
// (Same-key steps skip this, so the usage line still escalates in place.)
if (lastShownKey && lastShownKey !== step.key) {
clearNotice(lastShownKey)
}
showNotice(step)
lastShownKey = step.key
cursor += 1
}
// The hotkey: Ctrl+Shift+C on every platform (Ctrl, not Cmd, so it can't clash
// with a system Cmd chord and works the same on Windows/Linux). Matched on
// `code` so it's keyboard-layout independent, and captured before the composer
// so a focused input can't swallow it.
function isTriggerChord(e: KeyboardEvent): boolean {
return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey && e.code === 'KeyC'
}
/**
* Install the dev trigger: a capture-phase hotkey (Ctrl+Shift+C), a ⌘K palette
* entry, and a `window.__creditsDemo()` console hook. Returns a disposer.
*/
export function installCreditsNoticeDemo(): () => void {
const onKeyDown = (e: KeyboardEvent) => {
if (!isTriggerChord(e)) {
return
}
e.preventDefault()
e.stopPropagation()
stepCreditsNoticeDemo()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
;(window as unknown as { __creditsDemo?: () => void }).__creditsDemo = stepCreditsNoticeDemo
const disposePalette = registry.register({
id: 'dev.creditsNotice',
area: PALETTE_AREA,
data: {
id: 'dev.creditsNotice',
icon: CreditCard,
keywords: ['credits', 'notice', 'toast', 'billing', 'dev', 'demo'],
label: 'Dev: cycle credit notices',
run: stepCreditsNoticeDemo
} satisfies PaletteContribution
})
console.info('[dev] credit-notice demo ready — press Ctrl+Shift+C, or run window.__creditsDemo()')
return () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
disposePalette()
delete (window as unknown as { __creditsDemo?: () => void }).__creditsDemo
}
}
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds } from '@/store/session'
import {
$attentionSessionIds,
$sessionStates,
$workingSessionIds,
clearAllSessionStates,
publishSessionState
} from '@/store/session-states'
import { rehydrateLiveSessionStatuses } from './use-background-sync'
/**
* `session.active_list` is the authoritative snapshot of what is RUNNING in the
* polled gateway process. A session that finished while Desktop was looking
* elsewhere — or whose runtime id was recycled by a backend respawn — simply
* stops appearing in the response. Absence is therefore a completion signal,
* not "no news": if nothing reaps it, the row spins forever and the
* busy→idle edge that paints the green "your turn" dot never fires.
*/
describe('rehydrateLiveSessionStatuses — reaping vanished runtimes', () => {
beforeEach(() => {
vi.useFakeTimers()
$selectedStoredSessionId.set(null)
$unreadFinishedSessionIds.set([])
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$activeSessionId.set(null)
})
it('clears a working session that disappears from the live snapshot', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-a', session_key: 'stored-a', status: 'working' }]
})
expect($workingSessionIds.get()).toEqual(['stored-a'])
// The turn finished and the gateway reaped the session between polls.
rehydrateLiveSessionStatuses({ sessions: [] })
expect($workingSessionIds.get()).toEqual([])
})
it('fires the unread "your turn" marker for a vanished background session', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-b', session_key: 'stored-b', status: 'working' }]
})
rehydrateLiveSessionStatuses({ sessions: [] })
expect($unreadFinishedSessionIds.get()).toEqual(['stored-b'])
})
it('clears a blocked session that disappears from the live snapshot', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-c', session_key: 'stored-c', status: 'waiting' }]
})
expect($attentionSessionIds.get()).toEqual(['stored-c'])
rehydrateLiveSessionStatuses({ sessions: [] })
expect($attentionSessionIds.get()).toEqual([])
})
it('leaves runtimes this poll never seeded alone', () => {
// A background PROFILE's sessions are served by a different gateway and
// never appear in this profile's active_list. Reaping them would dark out
// every other profile's running rows.
rehydrateLiveSessionStatuses(
{ sessions: [{ id: 'runtime-other', session_key: 'stored-other', status: 'working' }] },
Date.now(),
'other'
)
rehydrateLiveSessionStatuses({ sessions: [] }, Date.now(), 'default')
expect($workingSessionIds.get()).toEqual(['stored-other'])
})
it('seals open tool parts and clears awaitingResponse when a session vanishes', () => {
const openTool = {
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'patch',
args: {},
argsText: '{}'
} as never
publishSessionState('runtime-tools', {
...createClientSessionState('stored-tools'),
busy: true,
awaitingResponse: true,
messages: [{ id: 'a1', role: 'assistant', parts: [openTool], pending: false } as never]
})
// Keep the runtime referenced so the settled state stays in the store
// instead of being evicted as no-longer-needed.
$activeSessionId.set('runtime-tools')
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-tools', session_key: 'stored-tools', status: 'working' }]
})
rehydrateLiveSessionStatuses({ sessions: [] })
const state = $sessionStates.get()['runtime-tools']
expect(state.busy).toBe(false)
expect(state.awaitingResponse).toBe(false)
expect((state.messages[0].parts[0] as { result?: unknown }).result).toBeDefined()
})
it('clears a session stuck awaiting a response without the busy flag', () => {
publishSessionState('runtime-await', {
...createClientSessionState('stored-await'),
awaitingResponse: true,
busy: false
})
$activeSessionId.set('runtime-await')
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-await', session_key: 'stored-await', status: 'working' }]
})
rehydrateLiveSessionStatuses({ sessions: [] })
expect($sessionStates.get()['runtime-await'].awaitingResponse).toBe(false)
})
})
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $selectedStoredSessionId, $unreadFinishedSessionIds } from '@/store/session'
import { $workingSessionIds, clearAllSessionStates } from '@/store/session-states'
import { rehydrateLiveSessionStatuses, resetLiveRuntimeTracking } from './use-background-sync'
/**
* (C) The sidebar spinner is driven by `$workingSessionIds`, which is keyed by
* STORED session id. A turn that STARTS while Desktop isn't receiving stream
* events — a background profile, a degraded remote socket, a session opened on
* another surface — is only ever learned about through the `session.active_list`
* poll. If that poll can't seed a row the renderer has never seen, the thread
* name never gets its arc even though the backend is plainly working.
*/
describe('rehydrateLiveSessionStatuses — seeding a turn the renderer never saw start', () => {
beforeEach(() => {
vi.useFakeTimers()
$selectedStoredSessionId.set(null)
$unreadFinishedSessionIds.set([])
resetLiveRuntimeTracking()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
clearAllSessionStates()
resetLiveRuntimeTracking()
$unreadFinishedSessionIds.set([])
})
it('shows the spinner for a turn that started with no stream events', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
})
expect($workingSessionIds.get()).toContain('stored-cold')
})
it('keeps the spinner across polls while the turn is still running', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
})
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
})
expect($workingSessionIds.get()).toContain('stored-cold')
})
it('shows the spinner when a runtime id is recycled onto a new stored session', () => {
// A respawned backend can mint the same runtime id for a different stored
// session. The row for the NEW stored id must light up, not the stale one.
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-1', session_key: 'stored-old', status: 'working' }]
})
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-1', session_key: 'stored-new', status: 'working' }]
})
expect($workingSessionIds.get()).toContain('stored-new')
expect($workingSessionIds.get()).not.toContain('stored-old')
})
it('leaves a starting session idle — the agent build is not proof of a turn', () => {
// `starting` = `agent_build_started` without `agent_ready`. _start_agent_build
// runs on the first prompt OR any incidental RPC that needs the agent, so it
// is not proof of a turn — lighting the spinner here would fire on merely
// opening a session. A real turn arrives as `working`.
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-boot', session_key: 'stored-boot', status: 'starting' }]
})
expect($workingSessionIds.get()).not.toContain('stored-boot')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
import { $activeSessionId } from '@/store/session'
import { useBackgroundSync } from './use-background-sync'
const noop = () => undefined
const requestGateway = async () => ({ sessions: [] })
function render(activeGatewayProfile: string, activeConnectionId: string, refreshSessions: () => Promise<void>) {
return renderHook(
({ connectionId, profile }: { connectionId: string; profile: string }) => {
useBackgroundSync({
activeConnectionId: connectionId,
activeGatewayProfile: profile,
activeIsMessaging: false,
activeSessionId: null,
activeStoredSessionId: null,
freshDraftReady: false,
gatewayState: 'open',
refreshActiveTranscript: noop,
refreshCronJobs: noop,
refreshCurrentModel: noop,
refreshHermesConfig: noop,
refreshMessagingSessions: noop,
refreshSessions,
requestGateway
})
},
{ initialProps: { connectionId: activeConnectionId, profile: activeGatewayProfile } }
)
}
describe('useBackgroundSync profile-scoped session refresh', () => {
beforeEach(() => {
vi.useFakeTimers()
$activeSessionId.set(null)
$changeEventsAvailable.set(false)
$cronChangeTick.set(0)
$sessionsChangeTick.set(0)
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('refreshes the session list after the active gateway profile changes', async () => {
const refreshSessions = vi.fn(async () => undefined)
const hook = render('default', 'local', refreshSessions)
await act(async () => undefined)
expect(refreshSessions).toHaveBeenCalledTimes(1)
refreshSessions.mockClear()
hook.rerender({ connectionId: 'local', profile: 'nova' })
await act(async () => undefined)
expect(refreshSessions).toHaveBeenCalledTimes(1)
})
it('refreshes the session list when the backend changes but the profile name does not', async () => {
const refreshSessions = vi.fn(async () => undefined)
const hook = render('default', 'work', refreshSessions)
await act(async () => undefined)
refreshSessions.mockClear()
hook.rerender({ connectionId: 'homelab', profile: 'default' })
await act(async () => undefined)
expect(refreshSessions).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,912 @@
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill'
import { getLatestSessionMessages, type ProfileScope } from '@/hermes'
import { preserveLocalAssistantErrors, sealOpenToolParts, toChatMessages } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { sessionMessagesSignature } from '@/lib/session-signatures'
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
import { $onBattery, batteryPollInterval } from '@/store/power'
import { refreshActiveProfile } from '@/store/profile'
import { refreshProjectTree } from '@/store/projects'
import {
$activeSessionId,
$busy,
$currentCwd,
$selectedStoredSessionId,
getSessionOwnerHint,
ownerLookupSessionRows,
sessionMatchesStoredId,
setCurrentCwd
} from '@/store/session'
import type { SessionProfileRoute } from '@/store/session-request-router'
import {
$sessionStates,
$sessionTiles,
publishSessionState,
SESSION_WATCHDOG_TIMEOUT_MS,
setSessionStalled
} from '@/store/session-states'
import type { ClientSessionState } from '../../types'
import type { GatewayRequester } from '../types'
interface ActiveTranscriptSession {
ownerRoute?: SessionProfileRoute
profile?: string | null
}
/** Resolve an active transcript from visible rows or its unique hidden owner. */
export function resolveActiveTranscriptSession(storedSessionId: string): ActiveTranscriptSession | undefined {
const visible = ownerLookupSessionRows().find(session => sessionMatchesStoredId(session, storedSessionId))
if (visible) {
return { profile: visible.profile }
}
const ownerRoute = getSessionOwnerHint(storedSessionId)
return ownerRoute ? { ownerRoute, profile: ownerRoute.profile } : undefined
}
export interface ActiveTranscriptRefreshDeps {
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
requestSequenceRef: MutableRefObject<number>
selectedStoredSessionIdRef: MutableRefObject<string | null>
resolveSession: (storedSessionId: string) => ActiveTranscriptSession | null | undefined
signatureRef: MutableRefObject<Map<string, string>>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}
function tileRuntimeOwnsLiveState(runtimeId: string): boolean {
const state = $sessionStates.get()[runtimeId]
return Boolean(state && (state.busy || state.awaitingResponse || state.needsInput || state.turnLive))
}
type TileTranscriptTarget = { ownerRoute?: SessionProfileRoute; storedSessionId: string; runtimeId?: string }
/** Signature key per tile — carries the owner route so two connections/profiles
* sharing a stored id (or a tile re-homed to another owner) never alias. */
function tileTranscriptSignatureKey(tile: TileTranscriptTarget): string {
const route = tile.ownerRoute
return `tile:${route ? `${route.connectionId}:${route.targetProfile ?? route.profile}:` : ''}${tile.storedSessionId}`
}
/**
* Reconcile the persisted transcripts of every open WORKSPACE TILE (#93942
* slice 1). Bot canonical chats live here — never in $sessions /
* $messagingSessions (they carry the core `hidden` flag), so the main-pane
* reconcile path's resolveSession() bails on them and a background delivery
* never reaches an open bot chat. Each tile carries its own stored↔runtime id
* pair, so no resolution step is needed; refreshes are signature-gated per
* tile so a no-change event costs nothing, and a busy tile is skipped (its own
* stream owns the view while streaming).
*
* Sequencing note (#94255 review): all tiles SHARE one request sequence, so a
* second tick arriving mid-read invalidates every in-flight read from the
* first (latest-wins — same discipline as the main pane path). Under rapid
* tick bursts only the final tick lands updates; that is intended, since each
* tick re-reads from storage anyway.
*/
export async function reconcileTileTranscripts({
requestSequenceRef,
signatureRef,
updateSessionState,
tiles: tilesOverride
}: {
requestSequenceRef: MutableRefObject<number>
signatureRef: MutableRefObject<Map<string, string>>
tiles?: TileTranscriptTarget[]
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}): Promise<void> {
const tiles = tilesOverride ?? $sessionTiles.get()
const openSignatureKeys = new Set(tiles.map(tileTranscriptSignatureKey))
for (const signatureKey of signatureRef.current.keys()) {
if (!openSignatureKeys.has(signatureKey)) {
signatureRef.current.delete(signatureKey)
}
}
for (const tile of tiles) {
const storedSessionId = tile.storedSessionId
const runtimeSessionId = tile.runtimeId
if (!runtimeSessionId) {
// Resume not yet bound — the tile's own stream owns the view.
continue
}
if (!storedSessionId || !runtimeSessionId || tileRuntimeOwnsLiveState(runtimeSessionId)) {
continue
}
if ($activeSessionId.get() === runtimeSessionId) {
// The main pane reconcile already owns this surface.
continue
}
const requestId = ++requestSequenceRef.current
// With a tiles override (test path), the live $sessionTiles check can't
// see the synthetic tile — treat override tiles as present.
const tileStillPresent = () =>
tilesOverride
? tilesOverride.some(t => t.storedSessionId === storedSessionId && t.runtimeId === runtimeSessionId)
: $sessionTiles.get().some(t => t.storedSessionId === storedSessionId && t.runtimeId === runtimeSessionId)
// Bot tiles are pinned to an exact owner (connection + target profile);
// read from that backend, not whichever profile is foreground. Tiles
// without a route keep the legacy local read.
const profileScope: ProfileScope = tile.ownerRoute
? {
connectionId: tile.ownerRoute.connectionId,
profile: tile.ownerRoute.targetProfile ?? tile.ownerRoute.profile
}
: undefined
const signatureKey = tileTranscriptSignatureKey(tile)
try {
const latest = await getLatestSessionMessages(storedSessionId, profileScope)
if (
requestId !== requestSequenceRef.current ||
tileRuntimeOwnsLiveState(runtimeSessionId) ||
!tileStillPresent()
) {
// Tile closed or superseded mid-read — discard AND prune its
// signature so the map doesn't grow one entry per ever-opened tile
// for the app's lifetime (#94255 review point 3).
signatureRef.current.delete(signatureKey)
continue
}
const signature = sessionMessagesSignature(latest.messages)
if (signatureRef.current.get(signatureKey) === signature) {
continue
}
signatureRef.current.set(signatureKey, signature)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({
...state,
messages: preserveLocalAssistantErrors(
graftRefreshedTailOntoBackfill(messages, state.messages),
state.messages
)
}),
storedSessionId
)
} catch {
// Non-fatal: the next change event retries.
}
}
}
/** Reconcile one persisted transcript snapshot into the currently viewed session. */
export async function reconcileActiveTranscript({
activeSessionIdRef,
busyRef,
requestSequenceRef,
resolveSession,
selectedStoredSessionIdRef,
signatureRef,
updateSessionState
}: ActiveTranscriptRefreshDeps): Promise<void> {
const storedSessionId = selectedStoredSessionIdRef.current
const runtimeSessionId = activeSessionIdRef.current
if (!storedSessionId || !runtimeSessionId || busyRef.current) {
return
}
const stored = resolveSession(storedSessionId)
if (!stored) {
return
}
const requestId = requestSequenceRef.current + 1
requestSequenceRef.current = requestId
try {
const profileScope: ProfileScope = stored.ownerRoute
? {
connectionId: stored.ownerRoute.connectionId,
profile: stored.ownerRoute.targetProfile ?? stored.ownerRoute.profile
}
: stored.profile
const latest = await getLatestSessionMessages(storedSessionId, profileScope)
if (
requestId !== requestSequenceRef.current ||
busyRef.current ||
selectedStoredSessionIdRef.current !== storedSessionId ||
activeSessionIdRef.current !== runtimeSessionId
) {
return
}
const signatureKey = stored.ownerRoute
? JSON.stringify([
stored.ownerRoute.connectionId,
stored.ownerRoute.profile,
stored.ownerRoute.targetProfile ?? '',
stored.ownerRoute.mode ?? '',
storedSessionId
])
: `${stored.profile ?? 'default'}:${storedSessionId}`
const signature = sessionMessagesSignature(latest.messages)
if (signatureRef.current.get(signatureKey) === signature) {
return
}
signatureRef.current.set(signatureKey, signature)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({
...state,
// The refresh re-reads only the newest tail page; graft it onto any
// older pages "Show earlier" already backfilled instead of clobbering
// them (see transcript-backfill).
messages: preserveLocalAssistantErrors(graftRefreshedTailOntoBackfill(messages, state.messages), state.messages)
}),
storedSessionId
)
} catch {
// Non-fatal: the next change event or manual resume can hydrate the view.
}
}
// Cron sessions are written by a background scheduler tick, messaging turns by
// the background gateway (Telegram, WeChat, Discord, …) — neither signals the
// desktop websocket directly. Backends with the change watcher broadcast
// `cron.changed` / `sessions.changed` when those on-disk writes land, so the
// timers below become slow safety-net backstops; against an older backend
// (no `change_events` on gateway.ready) they stay at the legacy cadence.
const CRON_POLL_INTERVAL_MS = 30_000
const CRON_BACKSTOP_INTERVAL_MS = 5 * 60_000
const MESSAGING_POLL_INTERVAL_MS = 10_000
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
const ACTIVE_MESSAGING_SESSION_BACKSTOP_INTERVAL_MS = 30_000
// Match the TUI's live-session refresh cadence. Auto-compression can rotate a
// stored session id while its turn keeps running; until the next snapshot the
// sidebar row points at the new id while the renderer still knows the old one.
// A 15s cadence made that healthy transition look finished long enough to be
// alarming (and clicking the row appeared to "fix" it by touching the live
// session). This snapshot is small and already polled at 1.5s by the TUI.
const LIVE_SESSION_STATUS_POLL_INTERVAL_MS = 1_500
// With change events the snapshot re-pulls on every sessions.changed tick, so
// the interval only covers the degraded-socket edge the stream can't replay
// (see rehydrateLiveSessionStatuses) — 30s is plenty for that.
const LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS = 30_000
// Coalesce tick-driven sidebar list refreshes: sessions.changed fires (floored
// to 2s server-side) on every state.db write during a streaming turn, and the
// full list refresh is heavier than the active_list snapshot. Trailing-edge
// scheduled, so the burst's last write always lands.
const SESSIONS_LIST_TICK_GAP_MS = 10_000
// A typing burst keeps the composer's contentEditable input handling on the
// same renderer main thread as the list refresh above (#95033): with a large
// session store, one refresh pass can block keystroke echo long enough that
// input visibly stalls. While the keyboard is warm — any keydown in this
// renderer window, not just the composer — hold that pass and land it once
// shortly after the last keypress. Sidebar staleness during a burst is
// accepted; the lighter polls (active_list snapshot, cron, transcript
// backstops) keep their cadence because they carry liveness, not the heavy
// list reconciliation.
const TYPING_BURST_QUIET_MS = 1_500
interface LiveSessionStatusItem {
id?: string
last_active?: number
session_key?: string
status?: 'idle' | 'starting' | 'waiting' | 'working'
}
interface LiveSessionStatusResponse {
sessions?: LiveSessionStatusItem[]
}
// Runtime ids this poll has seen live, per gateway profile. A profile only
// ever reaps what its OWN snapshot previously reported: background profiles are
// served by different gateways and never appear in this profile's active_list,
// so an unscoped reap would dark out every other profile's running rows.
const liveRuntimeIdsByProfile = new Map<string, Set<string>>()
// Renderer-wide keyboard warmth, tracked at module scope like the live-runtime
// bookkeeping above: any keydown anywhere in the window marks activity, and a
// burst stays warm for TYPING_BURST_QUIET_MS after the last key. IME
// composition still emits keydown (keyCode 229), so one listener covers both.
let lastRendererInputAt = 0
/** Record renderer-wide keyboard activity (wired to a capture-phase window
* keydown listener by useBackgroundSync). */
export function noteRendererKeyboardActivity(nowMs = Date.now()): void {
lastRendererInputAt = nowMs
}
/** True while a typing burst is still warm enough to hold the heavy list
* refresh (see TYPING_BURST_QUIET_MS). */
export function isTypingBurstActive(nowMs = Date.now()): boolean {
return nowMs - lastRendererInputAt < TYPING_BURST_QUIET_MS
}
function remainingTypingQuietMs(nowMs: number): number {
return Math.max(0, TYPING_BURST_QUIET_MS - (nowMs - lastRendererInputAt))
}
/** Forget keyboard history — test isolation only (mirrors
* resetLiveRuntimeTracking). */
export function resetTypingActivityTracking(): void {
lastRendererInputAt = 0
}
/** Restore sidebar liveness after a renderer/backend reconnect. Stream events
* normally own these states, but events emitted while Desktop was disconnected
* cannot be replayed. `session.active_list` is the authoritative in-memory
* snapshot and does not resume, focus, or otherwise mutate a chat.
*
* The snapshot is authoritative about ABSENCE too. A turn that ends while the
* websocket is degraded — a remote gateway over a flaky link, a reconnect, a
* profile swap — drops out of `_sessions` without Desktop ever seeing the
* `running: false` edge, so the row keeps spinning and the busy→idle transition
* that paints the green "your turn" dot never fires. Reaping runtimes that
* vanish between polls restores both. */
export function rehydrateLiveSessionStatuses(
response: LiveSessionStatusResponse,
nowMs = Date.now(),
profileKey = 'default'
): void {
const seen = new Set<string>()
for (const session of response.sessions ?? []) {
const runtimeSessionId = session.id?.trim()
const storedSessionId = session.session_key?.trim()
const needsInput = session.status === 'waiting'
const working = session.status === 'working' || needsInput
if (!runtimeSessionId || !storedSessionId) {
continue
}
seen.add(runtimeSessionId)
const existing = $sessionStates.get()[runtimeSessionId]
// A turn we just submitted is not yet running as far as the backend is
// concerned, so the snapshot honestly reports it idle — but the local
// stream is already waiting on its first token, and it is the newer
// information. The stream path refuses to clear busy in exactly this window
// (`awaitingResponse && !sawAssistantPayload`); without the same refusal
// here a poll lands between submit and first token and darkens the row.
const busy = working || Boolean(existing?.awaitingResponse && !existing.sawAssistantPayload)
// Avoid re-arming the watchdog on every poll. Publish only when the
// authoritative live snapshot differs from the renderer mirror; normal
// gateway events continue to own subsequent transitions.
if (
!existing ||
existing.storedSessionId !== storedSessionId ||
existing.busy !== busy ||
existing.needsInput !== needsInput
) {
publishSessionState(runtimeSessionId, {
...(existing ?? createClientSessionState(storedSessionId)),
busy,
needsInput,
storedSessionId
})
}
if (!working) {
setSessionStalled(storedSessionId, false)
continue
}
const lastActiveMs = Number(session.last_active) * 1000
const isQuiet =
session.status === 'working' &&
Number.isFinite(lastActiveMs) &&
lastActiveMs > 0 &&
nowMs - lastActiveMs >= SESSION_WATCHDOG_TIMEOUT_MS
setSessionStalled(storedSessionId, isQuiet)
}
// A runtime this profile's snapshot reported live LAST poll but not this one
// has ended: the gateway reaps a session out of `_sessions` when its turn
// completes and its transport goes away. Settle it through the normal publish
// path so the busy→idle transition fires — that edge is what clears the
// spinner AND marks the row unread ("your turn"). Only ids this profile
// previously saw are eligible, so another profile's live rows are untouched.
const previouslyLive = liveRuntimeIdsByProfile.get(profileKey)
if (previouslyLive) {
for (const runtimeSessionId of previouslyLive) {
if (seen.has(runtimeSessionId)) {
continue
}
const existing = $sessionStates.get()[runtimeSessionId]
if (existing?.busy || existing?.needsInput || existing?.awaitingResponse) {
publishSessionState(runtimeSessionId, {
...existing,
awaitingResponse: false,
busy: false,
needsInput: false,
streamId: null,
turnStartedAt: null,
turnLive: false,
// The turn ended without its completion events reaching us — a lost
// `tool.complete` would otherwise leave a spinning tool row in an
// idle session. Seal open tool parts the same way the settle path
// does, so the transcript matches the state.
messages: sealOpenToolParts(existing.messages)
})
}
}
}
liveRuntimeIdsByProfile.set(profileKey, seen)
}
/** Forget every profile's live-runtime bookkeeping. A gateway wipe already
* drops the session states these ids point at, so a carried-over set would
* only reap runtimes that no longer exist. */
export function resetLiveRuntimeTracking(): void {
liveRuntimeIdsByProfile.clear()
}
interface BackgroundSyncParams {
activeConnectionId: null | string
activeGatewayProfile: string
activeIsMessaging: boolean
activeSessionId: null | string
activeStoredSessionId: null | string
freshDraftReady: boolean
gatewayState: string
refreshActiveTranscript: () => 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
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}
/** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab
* re-focus. On battery the cadence stretches (see store/power) — these are
* safety-net refreshes, not the live path, so they're the right thing to slow
* when the machine is spending its charge. Returns nothing — meant to live
* inside an effect. */
export function windowIsActivelyViewed({
focused,
visibilityState
}: {
focused: boolean
visibilityState: DocumentVisibilityState
}): boolean {
return visibilityState === 'visible' && focused
}
function visiblePoll(intervalMs: number, tick: () => void): () => void {
const run = () => {
// On macOS an unfocused or app-hidden BrowserWindow commonly remains
// `visibilityState === "visible"`. Visibility alone therefore kept every
// safety-net gateway poll alive while the user was in another app. These
// are stale-data backstops, not the live event path, so pause them until
// the window is actually being viewed and catch up immediately on focus.
if (windowIsActivelyViewed({ focused: document.hasFocus(), visibilityState: document.visibilityState })) {
tick()
}
}
let intervalId = window.setInterval(run, batteryPollInterval(intervalMs, $onBattery.get()))
const unsubscribeBattery = $onBattery.listen(onBattery => {
window.clearInterval(intervalId)
intervalId = window.setInterval(run, batteryPollInterval(intervalMs, onBattery))
})
document.addEventListener('visibilitychange', run)
window.addEventListener('focus', run)
return () => {
unsubscribeBattery()
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', run)
window.removeEventListener('focus', 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({
activeConnectionId,
activeGatewayProfile,
activeIsMessaging,
activeSessionId,
activeStoredSessionId,
freshDraftReady,
gatewayState,
refreshActiveTranscript,
refreshCronJobs,
refreshCurrentModel,
refreshHermesConfig,
refreshMessagingSessions,
refreshSessions,
requestGateway,
updateSessionState
}: BackgroundSyncParams): void {
const changeEventsAvailable = useStore($changeEventsAvailable)
const cronChangeTick = useStore($cronChangeTick)
const sessionsChangeTick = useStore($sessionsChangeTick)
const activeTranscriptBusy = useStore($busy)
const activeTranscriptRefreshPendingRef = useRef<string | null>(null)
// Tile reconcile state (#93942 slice 1): shared sequence guard + per-tile
// transcript signatures, so no-change ticks and closed tiles cost nothing.
const tileRequestSequenceRef = useRef(0)
const tileSignatureRef = useRef(new Map<string, string>())
// Tile reconciliation reads each runtime's live state directly from
// $sessionStates; the primary chat's $busy atom has no authority over tiles.
const requestActiveTranscriptRefresh = useCallback(
(preservePending: boolean) => {
if (!activeStoredSessionId || !activeSessionId) {
return
}
const storedSessionId = activeStoredSessionId
const runtimeSessionId = activeSessionId
const sessionKey = `${storedSessionId}:${runtimeSessionId}`
if (preservePending) {
activeTranscriptRefreshPendingRef.current = sessionKey
}
if ($busy.get()) {
return
}
if (preservePending && activeTranscriptRefreshPendingRef.current === sessionKey) {
activeTranscriptRefreshPendingRef.current = null
}
let sawBusyDuringRead = false
const unsubscribeBusy = $busy.listen(busy => {
sawBusyDuringRead ||= busy
})
void Promise.resolve(refreshActiveTranscript()).finally(() => {
unsubscribeBusy()
// If streaming began while the read was in flight, reconciliation was
// discarded and the external event still needs one idle retry.
if (
preservePending &&
(sawBusyDuringRead || $busy.get()) &&
$activeSessionId.get() === runtimeSessionId &&
$selectedStoredSessionId.get() === storedSessionId
) {
activeTranscriptRefreshPendingRef.current = sessionKey
}
})
},
[activeSessionId, activeStoredSessionId, refreshActiveTranscript]
)
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)
}
}, [activeConnectionId, activeGatewayProfile, gatewayState, refreshCurrentModel, refreshSessions, requestGateway])
// Reconnect backstop (#94779): turns that finished while the socket was
// down never replay their sessions.changed tick, so the open transcript
// stayed stale until the user reopened it. Pull one signature-gated tail on
// every (re)connect — a no-change read costs nothing. Keyed on the
// connection, not the session, so a plain session switch adds no read;
// messaging transcripts already refresh on open in their own effect below.
useEffect(() => {
if (gatewayState === 'open' && !activeIsMessaging && activeSessionId && activeStoredSessionId) {
requestActiveTranscriptRefresh(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- connect-scoped: session deps would fire on every switch
}, [activeConnectionId, activeGatewayProfile, gatewayState])
// A reconnect loses renderer-only working/attention atoms while the backend
// keeps the actual turns alive. Re-seed from the gateway's in-memory session
// registry immediately, then re-pull on every sessions.changed broadcast; a
// slow visible poll remains as the backstop for the degraded-socket edge the
// stream cannot replay (legacy cadence against older backends).
useEffect(() => {
if (gatewayState !== 'open') {
return
}
let cancelled = false
let inFlight = false
const refreshLiveStatuses = async () => {
if (inFlight) {
return
}
inFlight = true
try {
const response = await requestGateway<LiveSessionStatusResponse>('session.active_list', {})
if (!cancelled) {
rehydrateLiveSessionStatuses(response, Date.now(), activeGatewayProfile)
}
} catch {
// Older gateways may not expose session.active_list. Live stream events
// still work as before; leave the current sidebar state untouched.
} finally {
inFlight = false
}
}
const dispose = visiblePoll(
changeEventsAvailable ? LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS : LIVE_SESSION_STATUS_POLL_INTERVAL_MS,
() => void refreshLiveStatuses()
)
void refreshLiveStatuses()
return () => {
cancelled = true
dispose()
}
// sessionsChangeTick: each sessions.changed broadcast re-seeds immediately
// via the effect re-run (already coalesced to 2s server-side).
}, [activeGatewayProfile, changeEventsAvailable, gatewayState, requestGateway, sessionsChangeTick])
// sessions.changed also means the *stored* list may have new rows (a cron
// run's session, an inbound messaging turn creating a thread). The full list
// refresh is heavier than the active_list snapshot, so trail it on a gap
// instead of firing per tick. Direct atom subscription: the throttle state
// lives in the effect closure, not in refs synced from renders.
useEffect(() => {
if (gatewayState !== 'open' || !changeEventsAvailable) {
return
}
let lastRunAt = 0
let timer: null | number = null
let typingDeferTimer: null | number = null
const run = () => {
lastRunAt = Date.now()
void refreshSessions()
void refreshMessagingSessions()
// The project tree is a grouping of the same stored rows, so a session
// created/deleted/renamed/re-homed outside this window goes stale in the
// Projects sidebar without this (#100354). refreshProjectTree() keeps the
// cached tree on failure, so a not-yet-ready backend costs nothing.
void refreshProjectTree()
requestActiveTranscriptRefresh(true)
// Bot canonical chats live in workspace tiles, never in the main-pane
// selection — without this they never see background deliveries
// (#93942 scenario A). Signature-gated per tile, so no-change ticks
// cost nothing.
void reconcileTileTranscripts({
requestSequenceRef: tileRequestSequenceRef,
signatureRef: tileSignatureRef,
updateSessionState
})
}
// Hold the coalesced pass while a typing burst is warm (#95033) so the
// heavy list work never lands under keystrokes. One timer services every
// caller: ticks that arrive mid-deferral find it already armed and return.
// Fire time is the remaining quiet window, not a poll — a later key
// extends lastRendererInputAt, and the firing callback re-arms if still
// warm. There is no starvation cap: a continuous burst keeps holding.
const runWhenKeyboardQuiet = () => {
const now = Date.now()
if (!isTypingBurstActive(now)) {
if (typingDeferTimer !== null) {
window.clearTimeout(typingDeferTimer)
typingDeferTimer = null
}
run()
return
}
if (typingDeferTimer === null) {
typingDeferTimer = window.setTimeout(() => {
typingDeferTimer = null
runWhenKeyboardQuiet()
}, remainingTypingQuietMs(now))
}
}
const unsubscribe = $sessionsChangeTick.listen(() => {
const since = Date.now() - lastRunAt
if (since >= SESSIONS_LIST_TICK_GAP_MS) {
runWhenKeyboardQuiet()
} else if (typingDeferTimer === null && timer === null) {
// Within the gap a pass is already scheduled — trailing timer or a
// typing deferral. Arming another one here would stack extra passes.
timer = window.setTimeout(() => {
timer = null
runWhenKeyboardQuiet()
}, SESSIONS_LIST_TICK_GAP_MS - since)
}
})
return () => {
unsubscribe()
if (timer !== null) {
window.clearTimeout(timer)
}
if (typingDeferTimer !== null) {
window.clearTimeout(typingDeferTimer)
}
}
}, [
changeEventsAvailable,
gatewayState,
refreshMessagingSessions,
refreshSessions,
requestActiveTranscriptRefresh,
updateSessionState
])
// Keyboard warmth for the deferral above: capture phase on window. Any
// keydown in this renderer (composer, modal, settings) counts — conservative
// on purpose. Pure timestamp write, no React state.
useEffect(() => {
const markInput = (): void => noteRendererKeyboardActivity()
window.addEventListener('keydown', markInput, true)
return () => {
window.removeEventListener('keydown', markInput, true)
}
}, [])
// Keep the cron-jobs section live without a user action (scheduler ticks in
// the background). cron.changed (jobs.json moved: CRUD or a scheduler tick's
// bookkeeping) drives the refresh; the visible poll is the backstop.
useEffect(() => {
if (gatewayState !== 'open') {
return
}
if (cronChangeTick > 0) {
void refreshCronJobs()
}
return visiblePoll(
changeEventsAvailable ? CRON_BACKSTOP_INTERVAL_MS : CRON_POLL_INTERVAL_MS,
() => void refreshCronJobs()
)
}, [changeEventsAvailable, cronChangeTick, gatewayState, refreshCronJobs])
// A busy transition only consumes a pending sessions.changed refresh. It
// never creates one, so an ordinary local turn going busy -> idle does not
// add a REST read. The event itself is coalesced by the list throttle above.
useEffect(() => {
if (
gatewayState !== 'open' ||
activeTranscriptBusy ||
!activeSessionId ||
!activeStoredSessionId ||
activeTranscriptRefreshPendingRef.current !== `${activeStoredSessionId}:${activeSessionId}`
) {
return
}
requestActiveTranscriptRefresh(true)
}, [activeSessionId, activeStoredSessionId, activeTranscriptBusy, gatewayState, requestActiveTranscriptRefresh])
// Preserve the pre-existing messaging behavior: refresh once when a
// messaging transcript opens, then keep its visibility backstop. Desktop
// sessions never enter this effect and therefore gain no periodic timer.
useEffect(() => {
if (gatewayState !== 'open' || !activeIsMessaging || !activeSessionId || !activeStoredSessionId) {
return
}
const runScheduledRefresh = () => requestActiveTranscriptRefresh(false)
runScheduledRefresh()
return visiblePoll(
changeEventsAvailable ? ACTIVE_MESSAGING_SESSION_BACKSTOP_INTERVAL_MS : ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS,
runScheduledRefresh
)
}, [
activeIsMessaging,
activeSessionId,
activeStoredSessionId,
changeEventsAvailable,
gatewayState,
requestActiveTranscriptRefresh
])
// Messaging session lists against an older backend: no sessions.changed, so
// keep the legacy visible poll. (Event-capable backends fold this into the
// trailing sessions.changed refresh above.)
useEffect(() => {
if (gatewayState !== 'open' || changeEventsAvailable) {
return
}
return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions())
}, [changeEventsAvailable, gatewayState, refreshMessagingSessions])
// 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])
}
@@ -0,0 +1,570 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
import { _resetLegacyDiscardForTests } from '@/store/session'
import type * as WindowsStore from '@/store/windows'
import type { SessionInfo } from '@/types/hermes'
import { makeSessionInfo } from '../../../test/session-info'
import { useDesktopIntegrations } from './use-desktop-integrations'
// Mutable HUD-window flag so the restore tests can flip the window kind the
// hook believes it runs in. Default false keeps the pre-existing restore
// coverage exercising the real main-window path.
const { hudWindowMock } = vi.hoisted(() => ({ hudWindowMock: vi.fn(() => false) }))
vi.mock('@/store/mcp-deeplink-install', () => ({
requestMcpInstallFromDeepLink: vi.fn()
}))
vi.mock('@/store/windows', async importOriginal => {
const actual = await importOriginal<typeof WindowsStore>()
return {
...actual,
isHudWindow: () => hudWindowMock()
}
})
// Pure-jsdom localStorage (no nanostores persistence module needed — the
// production functions write directly to window.localStorage through the
// persistString/storedString helpers in @/lib/storage, which in jsdom resolves
// to the real localStorage global).
// We import the hook and drive it with explicit rx-stores/props to exercise the
// profile-ready gate, ownership validation, and legacy-key discard.
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const session = (over: Partial<SessionInfo> = {}): SessionInfo => makeSessionInfo({ id: 'live', ...over })
describe('useDesktopIntegrations', () => {
let navigate: ReturnType<typeof vi.fn<(...args: unknown[]) => void>>
beforeEach(() => {
window.localStorage.clear()
_resetLegacyDiscardForTests()
vi.mocked(requestMcpInstallFromDeepLink).mockClear()
navigate = vi.fn()
// Every test starts as a main window; only the HUD describe flips this.
hudWindowMock.mockReturnValue(false)
// Stub the desktop bridge so the hook's useEffect callbacks don't try to
// reach real Electron IPC. The established desktop-test pattern assigns a
// plain object to window.hermesDesktop rather than using vi.spyOn.
desktopWindow.hermesDesktop = {
setPreviewShortcutActive: vi.fn(),
onOpenUpdatesRequested: vi.fn(),
onFocusSession: vi.fn(),
onNotificationAction: vi.fn(),
onNotificationActivate: vi.fn(),
onDeepLink: vi.fn(),
signalDeepLinkReady: vi.fn(),
onClosePreviewRequested: vi.fn(),
onOpenFolderRequested: vi.fn()
} as unknown as Window['hermesDesktop']
})
afterEach(() => {
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
}
vi.restoreAllMocks()
})
function render({
activeProfile = 'default',
locationPathname = '/',
profileReady = false,
resumeExhaustedSessionId = null as string | null,
// null = config record still loading (the hook takes undefined; null dodges the destructuring default).
resumeLastSession = true as boolean | null,
routedSessionId = null as string | null,
sessions = [] as readonly SessionInfo[]
} = {}) {
return renderHook(
({
activeProfile,
locationPathname,
profileReady,
resumeExhaustedSessionId,
resumeLastSession,
routedSessionId,
sessions
}: {
activeProfile: string
locationPathname: string
profileReady: boolean
resumeExhaustedSessionId: string | null
resumeLastSession: boolean | null
routedSessionId: string | null
sessions: readonly SessionInfo[]
}) =>
useDesktopIntegrations({
activeProfile,
chatOpen: false,
hasPreview: false,
locationPathname,
navigate,
profileReady,
refreshSessions: vi.fn(),
resumeExhaustedSessionId,
resumeLastSession: resumeLastSession ?? undefined,
routedSessionId,
runtimeIdByStoredSessionId: { current: new Map() },
sessions
}),
{
initialProps: {
activeProfile,
locationPathname,
profileReady,
resumeExhaustedSessionId,
resumeLastSession,
routedSessionId,
sessions
}
}
)
}
describe('profile-ready gate', () => {
it('does NOT restore before profileReady is true', () => {
// Set remembered state, but profileReady=false.
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/remembered-session')
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
render({ profileReady: false })
// no navigation should have occurred
expect(navigate).not.toHaveBeenCalled()
})
it('restores on profileReady when remembered route exists and owns the session', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/remembered-session')
const sessions = [session({ id: 'remembered-session', profile: 'default' })]
render({ profileReady: true, sessions })
expect(navigate).toHaveBeenCalledWith('/remembered-session', { replace: true })
})
it('restores remembered session id when no remembered route exists', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
const sessions = [session({ id: 'remembered-session', profile: 'default' })]
render({ profileReady: true, sessions })
// sessionRoute('remembered-session') = '/remembered-session'
expect(navigate).toHaveBeenCalledWith('/remembered-session', { replace: true })
})
it('waits for sessions before validating a remembered session route', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/remembered-session')
const result = render({ profileReady: true, sessions: [] })
expect(navigate).not.toHaveBeenCalled()
expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBe('/remembered-session')
result.rerender({
activeProfile: 'default',
locationPathname: '/',
profileReady: true,
resumeExhaustedSessionId: null,
resumeLastSession: true,
routedSessionId: null,
sessions: [session({ id: 'remembered-session', profile: 'default' })]
})
expect(navigate).toHaveBeenCalledWith('/remembered-session', { replace: true })
})
})
describe('display.resume_last_session', () => {
it('stays on the fresh chat when the setting is off, and keeps remembering the open chat', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/remembered-session')
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
const sessions = [session({ id: 'remembered-session', profile: 'default' })]
const result = render({ profileReady: true, resumeLastSession: false, sessions })
expect(navigate).not.toHaveBeenCalled()
// The user opens another chat: it is still remembered for the next launch
// (and for notifications), so flipping the switch back on resumes it.
result.rerender({
activeProfile: 'default',
locationPathname: '/other-session',
profileReady: true,
resumeExhaustedSessionId: null,
resumeLastSession: false,
routedSessionId: 'other-session',
sessions: [...sessions, session({ id: 'other-session', profile: 'default' })]
})
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('other-session')
})
it('holds the restore until the config record answers, then restores when on', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
const sessions = [session({ id: 'remembered-session', profile: 'default' })]
const result = render({ profileReady: true, resumeLastSession: null, sessions })
expect(navigate).not.toHaveBeenCalled()
result.rerender({
activeProfile: 'default',
locationPathname: '/',
profileReady: true,
resumeExhaustedSessionId: null,
resumeLastSession: true,
routedSessionId: null,
sessions
})
expect(navigate).toHaveBeenCalledWith('/remembered-session', { replace: true })
})
})
describe('ownership validation', () => {
it('refuses to restore a session route owned by another profile', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/ai-session')
const sessions = [session({ id: 'ai-session', profile: 'ai-engineer' })]
// The route belongs to ai-engineer; active profile is default.
// No navigation should happen — wrong owner.
render({ activeProfile: 'default', profileReady: true, sessions })
expect(navigate).not.toHaveBeenCalled()
})
it('refuses to restore a session id owned by another profile', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'ai-session')
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/ai-session')
const sessions = [session({ id: 'ai-session', profile: 'ai-engineer' })]
render({ activeProfile: 'default', profileReady: true, sessions })
// Both route and fallback session id are owned by another profile.
expect(navigate).not.toHaveBeenCalled()
})
it('clears stale remembered route owned by wrong profile', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.ai-engineer', '/ai-session')
const sessions = [session({ id: 'ai-session', profile: 'ai-engineer' })]
render({ activeProfile: 'ai-engineer', profileReady: true, sessions })
// The route and session match the active profile — should restore.
expect(navigate).toHaveBeenCalledWith('/ai-session', { replace: true })
})
})
describe('two profiles with distinct sessions', () => {
it('restores profile A session when profile A is active', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.coder', '/coder-session')
const sessions = [
session({ id: 'coder-session', profile: 'coder' }),
session({ id: 'ops-session', profile: 'ops' })
]
render({ activeProfile: 'coder', profileReady: true, sessions })
expect(navigate).toHaveBeenCalledWith('/coder-session', { replace: true })
})
it('does NOT bleed profile A session into profile B', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.coder', '/coder-session')
const sessions = [session({ id: 'coder-session', profile: 'coder' })]
// ops profile is active but has no own remembered route
render({
activeProfile: 'ops',
profileReady: true,
sessions
})
// No navigation — coder's remembered route doesn't belong to ops.
expect(navigate).not.toHaveBeenCalled()
})
})
describe('HUD window (win=hud)', () => {
beforeEach(() => {
hudWindowMock.mockReturnValue(true)
})
it('does NOT restore remembered navigation on a blank new-chat route', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/remembered-session')
render({ profileReady: true, sessions: [session({ id: 'remembered-session', profile: 'default' })] })
// The HUD is a fresh full renderer booting at the default route, but its
// destination was chosen explicitly by hudTargetSessionId() at open time
// — remembered-navigation restore must not hijack it to the last session.
expect(navigate).not.toHaveBeenCalled()
})
it('does NOT write remembered navigation while showing a session', () => {
render({
profileReady: true,
routedSessionId: 'live',
sessions: [session({ id: 'live', profile: 'default' })]
})
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBeNull()
expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBeNull()
})
it('does not restore the remembered session id either', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
render({ profileReady: true, sessions: [session({ id: 'remembered-session', profile: 'default' })] })
expect(navigate).not.toHaveBeenCalled()
})
})
describe('legacy key behavior', () => {
it('discards legacy global keys on read and does NOT restore from them', () => {
// Simulate a pre-per-profile install.
window.localStorage.setItem('hermes.desktop.lastSessionId', 'legacy-session')
window.localStorage.setItem('hermes.desktop.lastRoute', '/session/legacy-session')
// Profile contexts without matching sessions.
const sessions = [session({ id: 'legacy-session', profile: 'default' })]
render({ profileReady: true, sessions })
// Legacy keys must be discarded.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId')).toBeNull()
expect(window.localStorage.getItem('hermes.desktop.lastRoute')).toBeNull()
// And no navigation should happen (the per-profile keys were empty).
expect(navigate).not.toHaveBeenCalled()
})
})
describe('stale-result suppression during profile switch', () => {
it('remembers route for the new profile after switch, not the old one', () => {
const sessions = [
session({ id: 'coder-session', profile: 'coder' }),
session({ id: 'ops-session', profile: 'ops' })
]
// Render with coder active and navigate to a session.
const { rerender } = render({
activeProfile: 'coder',
locationPathname: '/coder-session',
profileReady: true,
routedSessionId: 'coder-session',
sessions
})
// The coder session should be persisted under coder's key.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.coder')).toBe('coder-session')
// Now switch to ops.
rerender({
activeProfile: 'ops',
locationPathname: '/ops-session',
profileReady: true,
resumeExhaustedSessionId: null,
resumeLastSession: true,
routedSessionId: 'ops-session',
sessions
})
// The ops session should now be persisted under ops's key.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.ops')).toBe('ops-session')
// Coder's remembered session should still be there.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.coder')).toBe('coder-session')
})
it('does NOT overwrite remembered state when session ownership fails validation', () => {
// Simulate an async restore result arriving for a route that doesn't
// own the active profile.
const sessions = [session({ id: 'coder-session', profile: 'coder' })]
// Active profile is ops, but the routed session belongs to coder.
render({
activeProfile: 'ops',
locationPathname: '/',
profileReady: true,
routedSessionId: 'coder-session', // wrong profile!
sessions
})
// No session should be remembered for the active profile.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.ops')).toBeNull()
})
})
describe('route-scoped restoration', () => {
it('restores a non-session route like /skills', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/skills')
const sessions = [session({ id: 'some-session', profile: 'default' })]
render({ profileReady: true, sessions })
// /skills is not a session route — no ownership validation needed.
expect(navigate).toHaveBeenCalledWith('/skills', { replace: true })
})
it('does NOT restore overlay routes (settings/command-center)', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/settings')
render({ profileReady: true, sessions: [] })
// Overlay routes should not be restored.
expect(navigate).not.toHaveBeenCalled()
})
it('does NOT persist overlay routes for next boot', () => {
const { rerender } = render({
activeProfile: 'default',
locationPathname: '/settings',
profileReady: true,
routedSessionId: null,
sessions: []
})
// Remembering effect fires on route change.
rerender({
activeProfile: 'default',
locationPathname: '/settings',
profileReady: true,
resumeExhaustedSessionId: null,
resumeLastSession: true,
routedSessionId: null,
sessions: []
})
// Overlay routes must NOT be persisted.
expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBeNull()
})
})
describe('exhausted session cleanup', () => {
it('clears remembered session id when the exhausted session matches', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'exhausted')
const sessions = [session({ id: 'exhausted', profile: 'default' })]
render({
profileReady: true,
resumeExhaustedSessionId: 'exhausted',
sessions
})
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBeNull()
})
it('clears remembered route when it carries the exhausted session', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/exhausted')
const sessions = [session({ id: 'exhausted', profile: 'default' })]
render({
profileReady: true,
resumeExhaustedSessionId: 'exhausted',
sessions
})
expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBeNull()
})
it('does NOT clear exhausted when profileReady is false', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'exhausted')
render({
profileReady: false,
resumeExhaustedSessionId: 'exhausted',
sessions: []
})
// profileReady=false gates the cleanup effect.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('exhausted')
})
it('does NOT clear remembered state when exhausted id does not match', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'other-session')
render({
profileReady: true,
resumeExhaustedSessionId: 'exhausted',
sessions: [session({ id: 'other-session', profile: 'default' })]
})
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('other-session')
})
})
describe('notification activate + plugin deep links', () => {
it('navigates when a plugin notification activate payload arrives', () => {
let activate: ((payload: { activate?: string }) => void) | undefined
desktopWindow.hermesDesktop = {
...desktopWindow.hermesDesktop,
onNotificationActivate: (cb: (payload: { activate?: string }) => void) => {
activate = cb
return () => undefined
}
} as unknown as Window['hermesDesktop']
render({ profileReady: true, sessions: [] })
activate?.({ activate: '/index-network/intent/1' })
expect(navigate).toHaveBeenCalledWith('/index-network/intent/1')
})
it('navigates hermes://index-network/intent/1 deep links through the same path vocabulary', () => {
let deepLink: ((payload: { kind: string; name: string; params: Record<string, string> }) => void) | undefined
desktopWindow.hermesDesktop = {
...desktopWindow.hermesDesktop,
onDeepLink: (cb: (payload: { kind: string; name: string; params: Record<string, string> }) => void) => {
deepLink = cb
return () => undefined
},
signalDeepLinkReady: vi.fn()
} as unknown as Window['hermesDesktop']
render({ profileReady: true, sessions: [] })
deepLink?.({ kind: 'index-network', name: 'intent/1', params: {} })
expect(navigate).toHaveBeenCalledWith('/index-network/intent/1')
})
it('routes hermes://mcp/install to the pending-install dialog, not navigation', () => {
let deepLink: ((payload: { kind: string; name: string; params: Record<string, string> }) => void) | undefined
desktopWindow.hermesDesktop = {
...desktopWindow.hermesDesktop,
onDeepLink: (cb: (payload: { kind: string; name: string; params: Record<string, string> }) => void) => {
deepLink = cb
return () => undefined
},
signalDeepLinkReady: vi.fn()
} as unknown as Window['hermesDesktop']
render({ profileReady: true, sessions: [] })
deepLink?.({ kind: 'mcp', name: 'install', params: { name: 'context7' } })
expect(requestMcpInstallFromDeepLink).toHaveBeenCalledWith({ name: 'context7' })
expect(navigate).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,363 @@
import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { commandFocusedPreview } from '@/app/chat/right-rail/preview-nav'
import { openSession } from '@/app/open-session'
import { resolveDeepLinkAction } from '@/lib/deeplink-routes'
import { pathFromHermesDeepLink, resolveHermesOpenPath } from '@/lib/hermes-open-target'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
import { startMcpHealthChecker, stopMcpHealthChecker } from '@/store/mcp-health'
import {
clearPluginNotifyHandlers,
invokePluginNotifyAction,
invokePluginNotifyActivate,
respondToApprovalAction
} from '@/store/native-notifications'
import { openPluginInstallRequest } from '@/store/plugin-install-request'
import { openFolderAsProject } from '@/store/projects'
import {
getRememberedRoute,
getRememberedSessionId,
sessionBelongsToProfile,
setRememberedRoute,
setRememberedSessionId
} from '@/store/session'
import { onSessionsChanged } from '@/store/session-sync'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
import { isBrowserWindow, isHudWindow, isSecondaryWindow } from '@/store/windows'
import type { SessionInfo } from '@/types/hermes'
import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus'
import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from '../../routes'
type RememberedSession = Pick<SessionInfo, '_lineage_root_id' | 'id' | 'profile'>
interface DesktopIntegrationsParams {
activeProfile: string
chatOpen: boolean
hasPreview: boolean
locationPathname: string
navigate: (to: string, options?: { replace?: boolean }) => void
profileReady: boolean
refreshSessions: () => Promise<unknown> | unknown
/** `display.resume_last_session`; `undefined` while the config record is still loading. */
resumeLastSession: boolean | undefined
resumeExhaustedSessionId: null | string
routedSessionId: null | string
runtimeIdByStoredSessionId: { readonly current: Map<string, string> }
sessions: readonly RememberedSession[]
}
/**
* 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({
activeProfile,
locationPathname,
navigate,
profileReady,
refreshSessions,
resumeLastSession,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionId,
sessions
}: 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()
// Background MCP health: HTTP/SSE servers only (never spawns stdio),
// notifies on transitions into needs-auth/error with a Sign in action.
startMcpHealthChecker()
// The native "Check for Updates…" menu item lives in the app menu next to
// "About Hermes" — it is the OS-standard affordance for updating THIS app,
// so it always opens the client overlay. Inheriting the connection-mode
// default pointed a Mac at its remote Linux backend and left the app itself
// silently stale (#70266).
const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow('client'))
return () => {
unsubscribe?.()
stopUpdatePoller()
stopMcpHealthChecker()
}
}, [])
// 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)
}, [])
const restoredRef = useRef(false)
// Wait until boot has adopted the primary profile, then restore that profile's
// navigation exactly once. The same effect owns subsequent writes so the
// initial `/` cannot overwrite remembered history before it is read.
// This ref is a one-time lifecycle latch, not a mirror of reactive atom state.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!profileReady || isHudWindow() || isBrowserWindow()) {
return
}
if (!restoredRef.current) {
// Only cold-start navigation at the default route is replaceable; a deep
// link or hidden-then-shown window keeps its explicit destination.
if (locationPathname === NEW_CHAT_ROUTE) {
// display.resume_last_session (#60812): hold the latch until the config
// record answers, then either restore below or stay on the fresh chat.
// Remembered ids keep being written either way, so flipping the switch
// back on resumes from the very next launch.
if (resumeLastSession === undefined) {
return
}
if (!resumeLastSession) {
restoredRef.current = true
return
}
const route = getRememberedRoute(activeProfile)
const routeSession = route ? routeSessionId(route) : null
const last = getRememberedSessionId(activeProfile)
const restorableNonSessionRoute =
!!route && route !== NEW_CHAT_ROUTE && !routeSession && !isOverlayView(appViewForPath(route))
// Boot adoption can publish renderer.ready before its async session
// refresh completes. Keep the restore latch open until ownership can be
// decided; treating an unloaded list as authoritative would erase valid
// remembered navigation permanently.
if (sessions.length === 0 && !restorableNonSessionRoute && (routeSession || last)) {
return
}
restoredRef.current = true
if (
route &&
route !== NEW_CHAT_ROUTE &&
!isOverlayView(appViewForPath(route)) &&
(!routeSession || sessionBelongsToProfile(sessions, routeSession, activeProfile))
) {
navigate(route, { replace: true })
return
}
// A remembered route carried a session id we can no longer validate —
// clear the stale entry so the next cold start won't re-try it.
if (routeSession) {
setRememberedRoute(null, activeProfile)
}
if (last && sessionBelongsToProfile(sessions, last, activeProfile)) {
navigate(sessionRoute(last), { replace: true })
return
}
if (last) {
setRememberedSessionId(null, activeProfile)
}
} else {
restoredRef.current = true
}
}
// Remember the open chat (session id for notifications/resume) AND the last
// non-overlay route (a page like /skills, or a session route) per profile.
// Session-shaped routes require an explicit matching owner; unresolved and
// wrong-profile rows must not replace known-safe navigation.
if (routedSessionId && sessionBelongsToProfile(sessions, routedSessionId, activeProfile)) {
setRememberedSessionId(routedSessionId, activeProfile)
setRememberedRoute(locationPathname, activeProfile)
} else if (!routedSessionId && !isOverlayView(appViewForPath(locationPathname))) {
setRememberedRoute(locationPathname, activeProfile)
}
}, [activeProfile, locationPathname, navigate, profileReady, resumeLastSession, routedSessionId, sessions])
useEffect(() => {
if (!profileReady || !resumeExhaustedSessionId) {
return
}
if (getRememberedSessionId(activeProfile) === resumeExhaustedSessionId) {
setRememberedSessionId(null, activeProfile)
}
if (routeSessionId(getRememberedRoute(activeProfile) ?? '') === resumeExhaustedSessionId) {
setRememberedRoute(null, activeProfile)
}
}, [activeProfile, profileReady, resumeExhaustedSessionId])
// Native-notification click -> jump to the session WHERE IT ALREADY IS (open
// tile / main), else beside what's loaded rather than over it — the click
// came from outside the app and shouldn't cost the user the chat they left
// on screen. Runtime id is 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) {
openSession(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current), navigate, 'stack')
}
})
return () => unsubscribe?.()
}, [navigate, runtimeIdByStoredSessionId])
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
void respondToApprovalAction(sessionId ?? null, actionId)
})
return () => unsubscribe?.()
}, [])
// Plugin OS notification body/action → optional callback + navigate. Activation
// is user-driven (click), so this is offer-not-hijack. Paths share the
// hermes://index-network/intent/1 vocabulary with deep links.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onNotificationActivate?.(payload => {
if (!payload) {
return
}
if (payload.actionId) {
invokePluginNotifyAction(payload.notifyId, payload.actionId)
} else {
invokePluginNotifyActivate(payload.notifyId)
}
if (payload.activate) {
// Defense-in-depth: re-resolve at the IPC boundary rather than trusting
// the pre-IPC validation — any future hermesDesktop.notify caller gets
// funneled through the same resolver.
const path = resolveHermesOpenPath(payload.activate)
if (path) {
navigate(path)
}
}
clearPluginNotifyHandlers(payload.notifyId)
})
return () => unsubscribe?.()
}, [navigate])
// hermes:// deep links:
// - mcp/install?… → pending MCP install (explicit confirm, never auto-install)
// - plugin/install?… (and legacy plugin-agent/plugin-desktop) → plugin install
// modal awaiting explicit confirmation. Never auto-installs.
// - blueprint/<name>?… → reviewable /blueprint command in the composer
// - <plugin>/<path>?… → in-app navigate (e.g. index-network/intent/1)
// - open/<path>?… → in-app navigate (generic)
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
if (!payload?.kind) {
return
}
if (payload.kind === 'mcp' && payload.name === 'install') {
requestMcpInstallFromDeepLink(payload.params || {})
return
}
const action = resolveDeepLinkAction(payload)
if (action.type === 'composer-blueprint') {
const slots = Object.entries(action.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
return `${k}=${sval}`
})
.join(' ')
const command = `/blueprint ${action.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
return
}
if (action.type === 'plugin-install') {
openPluginInstallRequest({
repo: action.repo,
enable: action.enable,
force: action.force,
legacyHint: action.legacyHint
})
return
}
// Not a core action — treat as a plugin-scoped or open/ navigation deep
// link (hermes://index-network/intent/1, hermes://open/…). The resolver
// rejects reserved kinds and unsafe paths.
const path = pathFromHermesDeepLink(payload.kind, payload.name || '', payload.params || {})
if (path) {
navigate(path)
}
})
void window.hermesDesktop?.signalDeepLinkReady?.()
return () => unsubscribe?.()
}, [navigate])
// ⌘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(id => navigate(sessionRoute(id)))
)
return () => unsubscribe?.()
}, [navigate])
// Native browser gestures (⌘R, a mouse's back/forward buttons, a trackpad
// swipe) that landed on the app's own chrome rather than inside a page — main
// answers those against the focused guest and never asks. Only ⌘R has an
// app-level meaning to fall back to; an unfocused swipe is a no-op.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onPreviewNav?.(command => {
if (!commandFocusedPreview(command) && command === 'reload') {
window.location.reload()
}
})
return () => unsubscribe?.()
}, [])
// File > Open Folder… — same open-folder-as-project upsert as the ⌘O keybind.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onOpenFolderRequested?.(() => void openFolderAsProject())
return () => unsubscribe?.()
}, [])
// Another window mutated the shared session list -> re-pull the sidebar.
useEffect(() => {
if (isSecondaryWindow() || isBrowserWindow()) {
return
}
return onSessionsChanged(() => void refreshSessions())
}, [refreshSessions])
}
@@ -0,0 +1,68 @@
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 { $sessions } from '@/store/session'
import { $attentionSessionIds } from '@/store/session-states'
import { isAuxiliaryWindow } 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 (isAuxiliaryWindow()) {
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)
}, [])
}
@@ -0,0 +1,128 @@
import { useEffect, useRef } from 'react'
import {
initQuickEntryBridge,
QUICK_TARGET_CURRENT,
QUICK_TARGET_NEW,
type QuickEntrySessionOption,
setQuickEntrySubmitHandler
} from '@/store/quick-entry'
import { $gatewayState, $sessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import { isAuxiliaryWindow } from '@/store/windows'
interface QuickEntryBridgeParams {
startFreshSessionDraft: () => void
submitText: (text: string) => Promise<unknown> | unknown
}
// The picker is a capture aid, not a session browser — a handful of recent
// rows is the whole point.
const QUICK_ENTRY_SESSION_OPTIONS = 5
function sessionOptions(): QuickEntrySessionOption[] {
return $sessions
.get()
.filter(session => !session.archived)
.slice(0, QUICK_ENTRY_SESSION_OPTIONS)
.map(session => ({
id: session.id,
title: session.title?.trim() || session.preview?.trim() || session.id
}))
}
/**
* Wires the global-hotkey Quick Entry window back into the app, both ways:
*
* - **Inbound:** text captured there is routed by target and submitted through
* THIS window's normal prompt machinery — current chat rides `submitText`, a
* picked stored session rides the session-tile delegate (resume + submit,
* background, without touching the primary view — the same path tiled
* sessions use), and "new session" is a fresh draft + submit, exactly what
* clicking New Chat and typing does. One submit pipeline, no bespoke RPC.
* - **Outbound:** gateway connection state + the recent-session list are pushed
* to the quick window (via main, which caches the latest push), so its input
* disables with a reconnect hint whenever the backend is unreachable.
*
* Handlers register ONCE through refs tracking the latest callbacks —
* re-registering on identity churn leaves a nulled-handler window that can drop
* a submit (the same bug shape use-pet-bridge guards). Primary window only: a
* secondary session window must not also claim the global capture channel, or
* one keystroke would send N prompts.
*/
export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: QuickEntryBridgeParams): void {
const submitTextRef = useRef(submitText)
submitTextRef.current = submitText
const startFreshRef = useRef(startFreshSessionDraft)
startFreshRef.current = startFreshSessionDraft
useEffect(() => {
if (isAuxiliaryWindow()) {
return
}
setQuickEntrySubmitHandler(({ target, text }) => {
if (target === QUICK_TARGET_NEW) {
// Same as the user clicking New Chat and typing: fresh draft, then the
// normal submit creates the backend session.
startFreshRef.current()
void submitTextRef.current(text)
return
}
if (target !== QUICK_TARGET_CURRENT) {
// A picked stored session: resume + submit in the background through
// the session-tile delegate so the primary view stays where it is.
const delegate = sessionTileDelegate()
if (delegate) {
void delegate
.resumeTile(target)
.then(runtimeId => delegate.submitToSession(runtimeId, text))
// A dead/undeliverable target must not swallow the prompt.
.catch(() => void submitTextRef.current(text))
return
}
}
void submitTextRef.current(text)
})
const dispose = initQuickEntryBridge()
return () => {
setQuickEntrySubmitHandler(null)
dispose()
}
}, [])
// Push gateway truth into the quick window whenever it changes: connection
// state gates its input; the recent-session list feeds its target picker.
useEffect(() => {
if (isAuxiliaryWindow()) {
return
}
const api = window.hermesDesktop?.quickEntry
if (!api?.pushState) {
return
}
const push = () => {
api.pushState({ connected: $gatewayState.get() === 'open', sessions: sessionOptions() })
}
push()
const offGateway = $gatewayState.listen(push)
const offSessions = $sessions.listen(push)
return () => {
offGateway()
offSessions()
}
}, [])
}
@@ -0,0 +1,378 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesModule from '@/hermes'
import { setSessionOwnerHint, setSessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import { useSessionTileDelegate } from './use-session-tile-delegate'
vi.mock('@/hermes', async importActual => ({
...(await importActual<typeof HermesModule>()),
getLatestSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' }))
}))
vi.mock('@/store/gateway', async importActual => ({
...(await importActual<Record<string, unknown>>()),
requestGatewayForAgent: vi.fn(),
requestGatewayForProfile: vi.fn()
}))
const { getLatestSessionMessages } = await import('@/hermes')
const { requestGatewayForAgent, requestGatewayForProfile } = await import('@/store/gateway')
const row = (over: Partial<SessionInfo>): SessionInfo =>
({
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 1,
model: null,
output_tokens: 0,
preview: null,
profile: 'default',
source: null,
started_at: 0,
title: null,
...over
}) as SessionInfo
function renderTile(
requestGateway: ReturnType<typeof vi.fn>,
refs?: {
runtimeIdByStoredSessionIdRef?: { current: Map<string, string> }
sessionStateByRuntimeIdRef?: { current: Map<string, unknown> }
updateSessionState?: ReturnType<typeof vi.fn>
}
) {
renderHook(() =>
useSessionTileDelegate({
archiveSession: vi.fn(async () => undefined),
branchStoredSession: vi.fn(async () => undefined),
executeSlashCommand: vi.fn(async () => undefined) as never,
removeSession: vi.fn(async () => undefined),
requestGateway: requestGateway as never,
runtimeIdByStoredSessionIdRef: (refs?.runtimeIdByStoredSessionIdRef ?? { current: new Map() }) as never,
sessionStateByRuntimeIdRef: (refs?.sessionStateByRuntimeIdRef ?? { current: new Map() }) as never,
updateSessionState: (refs?.updateSessionState ?? vi.fn()) as never
})
)
}
describe('useSessionTileDelegate resumeTile', () => {
beforeEach(() => {
setSessions([])
vi.mocked(getLatestSessionMessages).mockClear()
})
afterEach(() => {
setSessions([])
})
it('carries the owning profile into a cold tile resume so it cannot fork profiles', async () => {
// A tile opens a session owned by another profile. Resuming without the
// profile lets the gateway fall back to the launch-profile DB and clone the
// conversation into the wrong profile (#67603). The owning profile must ride
// both the transcript prefetch and the resume RPC.
setSessions([row({ id: 'stored-x', profile: 'ai-engineer' })])
const requestGateway = vi.fn(async (method: string) =>
method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never)
)
vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-1' } as never)
renderTile(requestGateway)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x')
expect(runtimeId).toBe('runtime-1')
expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer')
expect(requestGatewayForProfile).toHaveBeenCalledWith(
'ai-engineer',
'session.resume',
{
session_id: 'stored-x',
cols: 96,
profile: 'ai-engineer',
omit_messages: true
},
undefined,
undefined
)
expect(requestGateway).not.toHaveBeenCalled()
})
it('resolves and carries a default-profile session explicitly', async () => {
setSessions([row({ id: 'stored-y', profile: 'default' })])
const requestGateway = vi.fn(async () => ({}) as never)
// #92961: a known owner is ALWAYS routed through the profile router —
// even 'default' — never dispatched on the ambient socket.
vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-2' } as never)
renderTile(requestGateway)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-y')
expect(runtimeId).toBe('runtime-2')
expect(requestGatewayForProfile).toHaveBeenCalledWith(
'default',
'session.resume',
{
session_id: 'stored-y',
cols: 96,
profile: 'default',
omit_messages: true
},
undefined,
undefined
)
expect(requestGateway).not.toHaveBeenCalled()
})
it('carries a session row connection owner into a same-named tile resume', async () => {
setSessions([row({ connection_id: 'source-b', id: 'stored-shared', profile: 'default' })])
const ambientRequest = vi.fn(async () => ({}) as never)
vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ session_id: 'runtime-shared' } as never)
renderTile(ambientRequest)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-shared')
expect(runtimeId).toBe('runtime-shared')
expect(requestGatewayForAgent).toHaveBeenCalledWith('source-b', 'default', 'session.resume', {
session_id: 'stored-shared',
cols: 96,
omit_messages: true,
profile: 'default'
})
expect(ambientRequest).not.toHaveBeenCalled()
})
it('routes a Bot tile prefetch and resume through its exact connection owner', async () => {
const route = {
connectionId: 'barry',
mode: 'remote' as const,
profile: 'oxcoder',
targetProfile: 'backend-oxcoder'
}
setSessionOwnerHint('stored-remote', route)
vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ session_id: 'runtime-remote' } as never)
const ambientRequest = vi.fn(async () => ({}) as never)
renderTile(ambientRequest)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-remote')
expect(runtimeId).toBe('runtime-remote')
expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-remote', {
connectionId: 'barry',
profile: 'backend-oxcoder'
})
expect(requestGatewayForAgent).toHaveBeenCalledWith('barry', 'oxcoder', 'session.resume', {
session_id: 'stored-remote',
cols: 96,
omit_messages: true,
profile: 'backend-oxcoder'
})
expect(ambientRequest).not.toHaveBeenCalled()
})
it('reuses a warm binding that still carries a transcript', async () => {
const stateA = { busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-a' }
const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-a', 'runtime-a']]) }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-a', stateA]]) }
const requestGateway = vi.fn(async () => ({}) as never)
renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef })
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-a')
expect(runtimeId).toBe('runtime-a')
expect(requestGateway).not.toHaveBeenCalled()
expect(getLatestSessionMessages).not.toHaveBeenCalled()
})
it('merges persisted messages into a warm tile on explicit reopen (#96183)', async () => {
const stateA = {
busy: false,
messages: [{ id: 'm1', parts: [{ type: 'text', text: 'old' }], role: 'user' }],
storedSessionId: 'stored-a'
}
const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-a', 'runtime-a']]) }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-a', stateA]]) }
const updateSessionState = vi.fn((_id, updater) => updater(stateA))
const requestGateway = vi.fn(async () => ({}) as never)
vi.mocked(getLatestSessionMessages).mockResolvedValueOnce({
messages: [
{ id: 'm1', content: 'old', role: 'user' },
{ id: 'm2', content: 'cron delivery', role: 'user' }
],
session_id: 'stored-a'
} as never)
renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef, updateSessionState })
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-a', { refreshTranscript: true })
expect(runtimeId).toBe('runtime-a')
expect(requestGateway).not.toHaveBeenCalled()
expect(getLatestSessionMessages).toHaveBeenCalled()
expect(updateSessionState).toHaveBeenCalled()
const updater = updateSessionState.mock.calls[0][1] as (state: typeof stateA) => {
messages: Array<{ parts?: Array<{ text?: string }> }>
}
const next = updater(stateA)
const texts = next.messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
expect(texts.some(text => text.includes('cron delivery'))).toBe(true)
})
it('falls through to a real resume when the warm binding has no transcript (post-wake empty tile)', async () => {
// Sleep/wake regression: a released/stale cached state (messages: []) must
// NOT satisfy the warm path — reusing it re-bound the tile to a dead
// runtime id and painted the pane permanently empty.
setSessions([row({ id: 'stored-b', profile: 'default' })])
const staleState = { busy: false, messages: [], storedSessionId: 'stored-b' }
const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-b', 'runtime-dead']]) }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-dead', staleState]]) }
const requestGateway = vi.fn(async () => ({}) as never)
vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-fresh' } as never)
renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef })
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-b')
expect(runtimeId).toBe('runtime-fresh')
expect(requestGatewayForProfile).toHaveBeenCalledWith(
'default',
'session.resume',
{
session_id: 'stored-b',
cols: 96,
profile: 'default',
omit_messages: true
},
undefined,
undefined
)
})
it('hydrates the tile model and provider from resume info', async () => {
setSessions([row({ id: 'stored-model', profile: 'default' })])
const updateSessionState = vi.fn()
vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({
info: { fast: true, model: 'gpt-5', provider: 'openai', reasoning_effort: 'high', running: false },
session_id: 'runtime-model'
} as never)
renderTile(vi.fn(), { updateSessionState })
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-model')
expect(runtimeId).toBe('runtime-model')
expect(updateSessionState).toHaveBeenCalled()
const updater = updateSessionState.mock.calls[0][1] as (state: { messages: unknown[] }) => Record<string, unknown>
const next = updater({ messages: [] })
expect(next.model).toBe('gpt-5')
expect(next.provider).toBe('openai')
expect(next.reasoningEffort).toBe('high')
expect(next.fast).toBe(true)
})
it('invalidateRuntimeBindings clears the stored→runtime map so tiles re-resume after reconnect', async () => {
setSessions([row({ id: 'stored-c', profile: 'default' })])
const liveState = { busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-c' }
const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-c', 'runtime-dead']]) }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-dead', liveState]]) }
const requestGateway = vi.fn(async () => ({}) as never)
vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-fresh' } as never)
renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef })
// Gateway reconnect (what resetTileRuntimeBindings calls on wake):
sessionTileDelegate()!.invalidateRuntimeBindings!()
expect(runtimeIdByStoredSessionIdRef.current.size).toBe(0)
// The next resume goes cold instead of reusing the dead binding.
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-c')
expect(runtimeId).toBe('runtime-fresh')
})
})
describe('useSessionTileDelegate retireBusyClaim', () => {
it('retires a stale busy claim through the session-state write path (#93059)', () => {
const busyState = { awaitingResponse: true, busy: true, messages: [{ id: 'm1' }], storedSessionId: 'stored-d' }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-dead', busyState]]) }
const updateSessionState = vi.fn()
renderTile(
vi.fn(async () => ({}) as never),
{ sessionStateByRuntimeIdRef, updateSessionState }
)
expect(sessionTileDelegate()!.retireBusyClaim!('runtime-dead')).toBe(true)
expect(updateSessionState).toHaveBeenCalledWith('runtime-dead', expect.any(Function))
// The updater is the downgrade: busy/awaiting off, everything else intact.
const updater = updateSessionState.mock.calls[0][1] as (state: typeof busyState) => typeof busyState
expect(updater(busyState)).toEqual({ ...busyState, awaitingResponse: false, busy: false })
})
it('reports a miss instead of minting a cache entry for a runtime it never held', () => {
// No phantoms: updateSessionState mints a state for any id it is handed,
// and prune never collects a transcript-less entry — so a miss must not
// reach the write path; the store retires its own mirror instead.
const idle = { awaitingResponse: false, busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-e' }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-idle', idle]]) }
const updateSessionState = vi.fn()
renderTile(
vi.fn(async () => ({}) as never),
{ sessionStateByRuntimeIdRef, updateSessionState }
)
expect(sessionTileDelegate()!.retireBusyClaim!('runtime-unknown')).toBe(false)
expect(sessionTileDelegate()!.retireBusyClaim!('runtime-idle')).toBe(false)
expect(updateSessionState).not.toHaveBeenCalled()
})
})
describe('useSessionTileDelegate interruptSession', () => {
beforeEach(() => {
setSessions([])
})
afterEach(async () => {
setSessions([])
const { clearSessionRecentlyInterrupted } = await import('../../session/hooks/use-prompt-actions/utils')
clearSessionRecentlyInterrupted()
})
it('marks the session recently interrupted so a quick tile edit/resend still interrupt-firsts (#83855)', async () => {
const { isSessionRecentlyInterrupted } = await import('../../session/hooks/use-prompt-actions/utils')
const requestGateway = vi.fn(async () => ({}) as never)
renderTile(requestGateway)
await sessionTileDelegate()!.interruptSession('runtime-tile-1')
expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: 'runtime-tile-1' })
// Same 3s cooldown the primary chat's Stop sets: busy reads false while the
// gateway winds down, so the rewind path must still interrupt-first.
expect(isSessionRecentlyInterrupted('runtime-tile-1')).toBe(true)
})
})
@@ -0,0 +1,379 @@
import { useEffect } from 'react'
import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill'
import {
fetchStoredTranscriptAcrossBackends,
getLatestSessionMessages,
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
} from '@/hermes'
import { translateNow } from '@/i18n/runtime'
import { type ChatMessage, toChatMessages } from '@/lib/chat-messages'
import { notify } from '@/store/notifications'
import {
isReadOnlyRuntimeId,
readOnlyRuntimeIdFor,
resumeWithStoredTranscriptFallback
} from '@/store/read-only-transcript'
import { knownSessionOwner, ownerLookupSessionRows } from '@/store/session'
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
import { requestForSessionProfile, type SessionOwnerScope } from '@/store/session-request-router'
import { publishSessionState, sessionTileOwnerRoute, setSessionTileDelegate } from '@/store/session-states'
import type { SessionResumeResponse } from '@/types/hermes'
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
import { singleFlightSessionResume } from '../../session/hooks/use-prompt-actions/single-flight-resume'
import { markSessionRecentlyInterrupted, withSessionNotFoundResume } from '../../session/hooks/use-prompt-actions/utils'
import {
chatMessageArraysEquivalent,
reconcileResumeMessages,
resolveSessionOwner
} from '../../session/hooks/use-session-actions/utils'
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
import type { GatewayRequester } from '../types'
type SessionStateCache = ReturnType<typeof useSessionStateCache>
function mergeTileTranscript(
previous: ChatMessage[],
prefetchMessages: SessionResumeResponse['messages'] | undefined
): ChatMessage[] {
const prefetched = toChatMessages(prefetchMessages ?? [])
if (!prefetched.length) {
return previous
}
const persisted = graftRefreshedTailOntoBackfill(prefetched, previous)
return reconcileResumeMessages(persisted, previous)
}
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(() => {
// A tile's runtime binding can die the same way the foreground's does
// (sleep/wake, backend restart). The cache maps stored -> runtime, so walk
// it backwards to find the durable id this runtime belongs to.
const storedSessionIdForRuntime = (runtimeId: string): null | string => {
const cached = sessionStateByRuntimeIdRef.current.get(runtimeId)?.storedSessionId
if (cached) {
return cached
}
for (const [storedId, mapped] of runtimeIdByStoredSessionIdRef.current) {
if (mapped === runtimeId) {
return storedId
}
}
return null
}
// Repoint the stored -> runtime mapping at the recovered id so subsequent
// tile actions use the live binding instead of re-recovering every call.
const rebindTileRuntime = (deadRuntimeId: string) => (recoveredId: string) => {
const storedId = storedSessionIdForRuntime(deadRuntimeId)
if (storedId) {
runtimeIdByStoredSessionIdRef.current.set(storedId, recoveredId)
}
}
// Same ladder as the window's session-RPC dispatcher: tile route → the
// row's owner (exact when connection-tagged, else the hint / profile) →
// the async cross-profile probe (exact when the resolved row is tagged).
const ownerForStoredSession = async (storedSessionId: string): Promise<SessionOwnerScope> => {
const owner =
sessionTileOwnerRoute(storedSessionId) ??
knownSessionOwner(ownerLookupSessionRows(), storedSessionId) ??
(await resolveSessionOwner(storedSessionId))
return owner
}
const requestForStoredSession = async <T>(
storedSessionId: string,
method: string,
params: Record<string, unknown>,
timeoutMs?: number
): Promise<T> => {
const owner = await ownerForStoredSession(storedSessionId)
return requestForSessionProfile<T>(owner, requestGateway, method, params, timeoutMs)
}
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 })
},
// Gateway reconnect (sleep/wake, backend respawn): every stored→runtime
// binding recorded pre-reconnect points at a runtime id the respawned
// backend no longer knows. Drop the map so resumeTile's warm path can't
// re-bind a tile to a dead runtime; live bindings re-record from
// post-reconnect events and fresh resumes.
invalidateRuntimeBindings: preserveStoredSessionIds => {
for (const storedSessionId of runtimeIdByStoredSessionIdRef.current.keys()) {
if (!preserveStoredSessionIds?.has(storedSessionId)) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
}
}
},
// Reconnect reconcile (#93059): retire an orphaned runtime's busy claim
// through updateSessionState so the cache, focused view, busyRef and
// tile mirrors settle together. A runtime this cache never held reports
// false instead of minting an entry; the store downgrades its mirror.
retireBusyClaim: runtimeId => {
const cached = sessionStateByRuntimeIdRef.current.get(runtimeId)
if (!cached || (!cached.busy && !cached.awaitingResponse)) {
return false
}
updateSessionState(runtimeId, state => ({ ...state, awaitingResponse: false, busy: false }))
return true
},
interruptSession: async runtimeId => {
// Read-only stored-transcript tiles have no live turn to interrupt.
if (isReadOnlyRuntimeId(runtimeId)) {
return
}
// Same cooldown as the primary chat's Stop (#83855): the gateway may
// still be winding down after this interrupt, so a quick edit/resend
// on the tile must go interrupt-first even though busy already reads
// false. Mark the runtime id (and any recovered id) before the RPC so
// the window covers the whole wind-down.
markSessionRecentlyInterrupted(runtimeId)
const storedSessionId = storedSessionIdForRuntime(runtimeId)
const routedRequest = storedSessionId
? <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) =>
requestForStoredSession<T>(storedSessionId, method, params ?? {}, timeoutMs)
: requestGateway
await withSessionNotFoundResume(
runtimeId,
storedSessionId,
liveId => routedRequest('session.interrupt', { session_id: liveId }),
{
requestGateway: routedRequest,
onRecovered: recoveredId => {
markSessionRecentlyInterrupted(recoveredId)
rebindTileRuntime(runtimeId)(recoveredId)
}
}
)
},
resumeTile: async (storedSessionId, options) => {
const existing = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cached = existing ? sessionStateByRuntimeIdRef.current.get(existing) : undefined
const refreshTranscript = options?.refreshTranscript === true
// Warm path: reuse a live binding — but only when it still carries a
// transcript (or is mid-turn, where messages legitimately stream in).
// A binding whose cached state has no messages is either a released
// transcript or a stale pre-reconnect survivor; reusing it painted the
// post-sleep/wake tile permanently empty. Fall through to a real
// resume instead — it's idempotent for a genuinely live session.
//
// Explicit reopen (`refreshTranscript`) must still REST-merge: the
// warm snapshot is whatever the tile last painted, and cron bot-chat
// deliveries that landed while the panel's WS was down never arrive
// as realtime events (#96183).
if (
existing &&
cached?.storedSessionId === storedSessionId &&
(cached.busy || cached.messages.length > 0) &&
!refreshTranscript
) {
publishSessionState(existing, cached)
return existing
}
// Resolve the owning profile before binding a runtime. A tile can open a
// session from any profile, not just the active one; resuming (or
// reading messages) without a profile lets the gateway fall back to the
// launch-profile DB and fork the conversation into the wrong profile —
// the same cross-profile bleed the recovery resumes had (#67603).
const owner = await ownerForStoredSession(storedSessionId)
const restScope =
owner && typeof owner === 'object'
? { connectionId: owner.connectionId, profile: owner.targetProfile || owner.profile }
: owner
const prefetchPromise = getLatestSessionMessages(storedSessionId, restScope).catch(() => null)
if (existing && cached?.storedSessionId === storedSessionId && (cached.busy || cached.messages.length > 0)) {
const prefetch = await prefetchPromise
const merged = mergeTileTranscript(cached.messages, prefetch?.messages)
if (!chatMessageArraysEquivalent(cached.messages, merged)) {
updateSessionState(existing, state => ({ ...state, messages: merged }), storedSessionId)
} else {
publishSessionState(existing, cached)
}
return existing
}
// #94724 no-owner recovery: dispatching the resume through the same
// fail-closed gate as the window's RPC dispatcher keeps an unknown
// owner off the ambient socket, and the wrapper opens the stored
// transcript read-only instead of dead-ending the tile — the id-only
// REST read routes no live session at all.
const outcome = await resumeWithStoredTranscriptFallback(
storedSessionId,
() => {
assertSessionOwnerResolved(owner, { method: 'session.resume', sessionId: storedSessionId })
return singleFlightSessionResume(storedSessionId, () =>
requestForSessionProfile<SessionResumeResponse>(owner, requestGateway, 'session.resume', {
session_id: storedSessionId,
cols: 96,
omit_messages: true,
...(owner ? { profile: typeof owner === 'string' ? owner : owner.profile } : {})
})
)
},
async () => {
const stored = (await prefetchPromise) ?? (await fetchStoredTranscriptAcrossBackends(storedSessionId))
if (!stored) {
throw new Error('stored transcript unavailable on every reachable backend')
}
return stored
}
)
const prefetch = await prefetchPromise
if (outcome.mode === 'read-only') {
const readOnlyId = readOnlyRuntimeIdFor(storedSessionId)
updateSessionState(
readOnlyId,
state => ({
...state,
busy: false,
awaitingResponse: false,
messages: state.messages.length > 0 ? state.messages : toChatMessages(outcome.transcript?.messages ?? [])
}),
storedSessionId
)
notify({
kind: 'info',
title: translateNow('desktop.readOnlyTranscriptTitle'),
message: translateNow('desktop.readOnlyTranscriptBody')
})
return readOnlyId
}
const resumed = outcome.resumed
const runtimeId = resumed?.session_id
if (!runtimeId) {
throw new Error('resume returned no session id')
}
const info = resumed?.info
updateSessionState(
runtimeId,
state => ({
...state,
busy: Boolean(info?.running),
// Persist the session's own model/provider from resume so the tile
// pill does not wait on a chrome-scoped catalog read (#93892).
...(typeof info?.model === 'string' ? { model: info.model } : {}),
...(typeof info?.provider === 'string' ? { provider: info.provider } : {}),
...(typeof info?.reasoning_effort === 'string' ? { reasoningEffort: info.reasoning_effort } : {}),
...(typeof info?.fast === 'boolean' ? { fast: info.fast } : {}),
messages:
state.messages.length > 0 ? state.messages : toChatMessages(prefetch?.messages ?? resumed?.messages ?? [])
}),
storedSessionId
)
return runtimeId
},
submitToSession: async (runtimeId, text) => {
// A read-only stored-transcript tile has no live runtime to submit
// into (#94724). Refuse with the explanation instead of minting a
// misrouted prompt on a backend that never owned the session.
if (isReadOnlyRuntimeId(runtimeId)) {
notify({ kind: 'info', message: translateNow('desktop.readOnlyTranscriptSendBlocked') })
return
}
const storedSessionId = storedSessionIdForRuntime(runtimeId)
const routedRequest = storedSessionId
? <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) =>
requestForStoredSession<T>(storedSessionId, method, params ?? {}, timeoutMs)
: requestGateway
await withSessionNotFoundResume(
runtimeId,
storedSessionId,
liveId => routedRequest('prompt.submit', { session_id: liveId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS),
{ requestGateway: routedRequest, onRecovered: rebindTileRuntime(runtimeId) }
)
},
updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater)
})
}, [
archiveSession,
branchStoredSession,
executeSlashCommand,
removeSession,
requestGateway,
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef,
updateSessionState
])
}
+7
View File
@@ -0,0 +1,7 @@
// The contribution-driven shell package. `controller` registers panes /
// layouts / chrome and mounts the app root; `wiring` is the data controller +
// memoized pane surfaces; `panes` holds the real-data pane bodies + statusbar
// group setters. Only the controller is a public entry (the app root renders
// it); the rest are internal to this directory.
export { ContribController } from './controller'
export { ContribWiring, WiredPane } from './wiring'
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import type { SidebarNavItem } from '../types'
import { latestChatActions, latestSidebarActions } from './latest-actions'
import type { ChatActions, SidebarActions } from './types'
function makeChatActions(): ChatActions {
return {
onAddContextRef: vi.fn(),
onAddUrl: vi.fn(),
onAttachDroppedItems: vi.fn(),
onAttachImageBlob: vi.fn(),
onBranchInNewChat: vi.fn(),
onCancel: vi.fn(),
onDeleteSelectedSession: vi.fn(),
onDismissError: vi.fn(),
onEdit: vi.fn(),
onPasteClipboardImage: vi.fn(),
onPickFiles: vi.fn(),
onPickFolders: vi.fn(),
onPickImages: vi.fn(),
onReload: vi.fn(),
onRemoveAttachment: vi.fn(),
onRestoreToMessage: vi.fn(),
onRetryResume: vi.fn(),
onSteer: vi.fn(),
onSubmit: vi.fn(),
onThreadMessagesChange: vi.fn(),
onToggleSelectedPin: vi.fn(),
onTranscribeAudio: vi.fn()
}
}
function makeSidebarActions(): SidebarActions {
return {
onArchiveSession: vi.fn(),
onBranchSession: vi.fn(),
onDeleteSession: vi.fn(),
onLoadMoreMessaging: vi.fn(),
onLoadMoreSessions: vi.fn(),
onManageCronJob: vi.fn(),
onNavigate: vi.fn(),
onNewSessionInWorkspace: vi.fn(),
onNewSessionSplit: vi.fn(),
onResumeSession: vi.fn(),
onTriggerCronJob: vi.fn()
}
}
describe('latestActions adapters', () => {
it('dereferences the latest steer handler from a stable actions object', async () => {
const staleSteer = vi.fn(async () => false)
const latestSteer = vi.fn(async () => true)
const actions = makeChatActions()
actions.onSteer = staleSteer
const adapted = latestChatActions(actions)
actions.onSteer = latestSteer
await expect(adapted.onSteer('continue in selected session')).resolves.toBe(true)
expect(staleSteer).not.toHaveBeenCalled()
expect(latestSteer).toHaveBeenCalledWith('continue in selected session')
})
it('dereferences the latest sidebar handler from a stable actions object', () => {
const staleNavigate = vi.fn()
const latestNavigate = vi.fn()
const item = { id: 'settings', icon: vi.fn(), label: 'Settings', route: '/settings' } satisfies SidebarNavItem
const actions = makeSidebarActions()
actions.onNavigate = staleNavigate
const adapted = latestSidebarActions(actions)
actions.onNavigate = latestNavigate
adapted.onNavigate(item)
expect(staleNavigate).not.toHaveBeenCalled()
expect(latestNavigate).toHaveBeenCalledWith(item)
})
// An absent optional handler must stay absent through the adapter. Children
// gate on PRESENCE, not just invocation: onDismissError renders the dismiss
// button only when defined, onRestoreToMessage gates the restore-confirm
// flow, and onTranscribeAudio gates voice recording. Wrapping an undefined
// field in an arrow function makes it unconditionally truthy, which would
// paint a dead dismiss button and let voice recording run with no
// transcription backend.
it('leaves absent optional handlers undefined instead of always-truthy wrappers', () => {
const chat = makeChatActions()
chat.onDismissError = undefined
chat.onRestoreToMessage = undefined
chat.onTranscribeAudio = undefined
const adaptedChat = latestChatActions(chat)
expect(adaptedChat.onDismissError).toBeUndefined()
expect(adaptedChat.onRestoreToMessage).toBeUndefined()
expect(adaptedChat.onTranscribeAudio).toBeUndefined()
const sidebar = makeSidebarActions()
sidebar.onLoadMoreMessaging = undefined
const adaptedSidebar = latestSidebarActions(sidebar)
expect(adaptedSidebar.onLoadMoreMessaging).toBeUndefined()
})
it('still late-binds a PRESENT optional handler to the latest closure', async () => {
const staleTranscribe = vi.fn(async () => 'stale')
const latestTranscribe = vi.fn(async () => 'latest')
const actions = makeChatActions()
actions.onTranscribeAudio = staleTranscribe
const adapted = latestChatActions(actions)
actions.onTranscribeAudio = latestTranscribe
expect(adapted.onTranscribeAudio).toBeTypeOf('function')
await expect(adapted.onTranscribeAudio!(new Blob())).resolves.toBe('latest')
expect(staleTranscribe).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,72 @@
import type { ChatActions, SidebarActions } from './types'
/**
* Surfaces receive one stable `actions` object whose fields are mutated by the
* wiring controller each render. If a memoized surface passes `actions.foo`
* directly, the child keeps the function from the surface's last render and can
* submit/click against a stale session closure. These adapters keep a stable
* wrapper but dereference the latest field at call time.
*
* OPTIONAL handlers must stay optional. Several children gate on a handler's
* *presence*, not just call it — `onDismissError` renders the dismiss button
* only when it exists (assistant-message.tsx), `onRestoreToMessage` gates the
* restore-confirm flow (thread/index.tsx), and `onTranscribeAudio` gates voice
* recording/conversation (use-voice-recorder, use-voice-conversation). An
* unconditional arrow wrapper is always truthy, which would render a dead
* dismiss button and let voice recording proceed with no transcription backend.
* So wrap an optional field only when it is currently present, and re-read the
* latest value inside the wrapper for the stale-closure fix.
*/
function latestOptional<A extends unknown[], R>(
read: () => ((...args: A) => R) | undefined
): ((...args: A) => R) | undefined {
// Presence is sampled from the object identity the surface currently holds.
// The controller mutates fields in place rather than swapping a handler
// between defined and undefined, so presence is stable for a given actions
// object while the *closure* is what churns — which is exactly what the
// indirection below re-reads.
return read() ? (...args: A) => read()!(...args) : undefined
}
export function latestChatActions(actions: ChatActions): ChatActions {
return {
onAddContextRef: (...args) => actions.onAddContextRef(...args),
onAddUrl: (...args) => actions.onAddUrl(...args),
onAttachDroppedItems: (...args) => actions.onAttachDroppedItems(...args),
onAttachImageBlob: (...args) => actions.onAttachImageBlob(...args),
onBranchInNewChat: latestOptional(() => actions.onBranchInNewChat),
onCancel: (...args) => actions.onCancel(...args),
onDeleteSelectedSession: (...args) => actions.onDeleteSelectedSession(...args),
onDismissError: latestOptional(() => actions.onDismissError),
onEdit: (...args) => actions.onEdit(...args),
onPasteClipboardImage: (...args) => actions.onPasteClipboardImage(...args),
onPickFiles: (...args) => actions.onPickFiles(...args),
onPickFolders: (...args) => actions.onPickFolders(...args),
onPickImages: (...args) => actions.onPickImages(...args),
onReload: (...args) => actions.onReload(...args),
onRemoveAttachment: (...args) => actions.onRemoveAttachment(...args),
onRestoreToMessage: latestOptional(() => actions.onRestoreToMessage),
onRetryResume: (...args) => actions.onRetryResume(...args),
onSteer: (...args) => actions.onSteer(...args),
onSubmit: (...args) => actions.onSubmit(...args),
onThreadMessagesChange: (...args) => actions.onThreadMessagesChange(...args),
onToggleSelectedPin: (...args) => actions.onToggleSelectedPin(...args),
onTranscribeAudio: latestOptional(() => actions.onTranscribeAudio)
}
}
export function latestSidebarActions(actions: SidebarActions): SidebarActions {
return {
onArchiveSession: (...args) => actions.onArchiveSession(...args),
onBranchSession: (...args) => actions.onBranchSession(...args),
onDeleteSession: (...args) => actions.onDeleteSession(...args),
onLoadMoreMessaging: latestOptional(() => actions.onLoadMoreMessaging),
onLoadMoreSessions: (...args) => actions.onLoadMoreSessions(...args),
onManageCronJob: (...args) => actions.onManageCronJob(...args),
onNavigate: (...args) => actions.onNavigate(...args),
onNewSessionInWorkspace: (...args) => actions.onNewSessionInWorkspace(...args),
onNewSessionSplit: (...args) => actions.onNewSessionSplit(...args),
onResumeSession: (...args) => actions.onResumeSession(...args),
onTriggerCronJob: (...args) => actions.onTriggerCronJob(...args)
}
}
@@ -0,0 +1,184 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { getHermesConfigRecord, saveMcpServers } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { MCP_DEEPLINK_NAME_RE } from '@/lib/mcp-deeplink'
import { getServers } from '@/lib/mcp-servers'
import { $mcpInstallRequest } from '@/store/mcp-deeplink-install'
import { notify, readableError } from '@/store/notifications'
import { setHermesConfigCache } from '../hooks/use-config-record'
/**
* Explicit-confirm gate for `hermes://mcp/install` deep links. The payload is
* arbitrary attacker-controllable input (any web page can open the link), so
* this dialog shows the server name and the FULL pretty-printed config —
* exactly what would be written — and nothing touches config until the user
* confirms. stdio (`command`) entries carry an extra caution banner because
* confirming lets Hermes spawn that local process. An existing server name is
* never silently overwritten: confirm stays blocked until the user picks a
* fresh name or cancels.
*/
export function McpInstallDeepLinkDialog() {
const { t } = useI18n()
const m = t.settings.mcp
const navigate = useNavigate()
const request = useStore($mcpInstallRequest)
const [name, setName] = useState('')
const [existingNames, setExistingNames] = useState<null | string[]>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// (Re)arm per request: seed the editable name and fetch the current server
// map so a same-name conflict is visible before the user confirms.
useEffect(() => {
if (!request) {
return
}
setName(request.name)
setExistingNames(null)
setSaving(false)
setError(null)
let cancelled = false
getHermesConfigRecord()
.then(config => {
if (!cancelled) {
setExistingNames(Object.keys(getServers(config)))
}
})
.catch(() => {
// Conflict preflight failed (offline backend?) — confirm still re-fetches
// and merges, so leave the dialog usable rather than wedging it.
if (!cancelled) {
setExistingNames([])
}
})
return () => {
cancelled = true
}
}, [request])
if (!request) {
return null
}
const trimmedName = name.trim()
const nameValid = MCP_DEEPLINK_NAME_RE.test(trimmedName)
const nameConflict = existingNames?.includes(trimmedName) ?? false
const checkingConflicts = existingNames === null
const close = () => {
if (!saving) {
$mcpInstallRequest.set(null)
}
}
const confirm = async () => {
if (saving || !nameValid || nameConflict || checkingConflicts) {
return
}
setSaving(true)
setError(null)
try {
// Merge over the FRESHEST server map — saveMcpServers replaces the whole
// `mcp_servers` document, so saving over a stale snapshot would drop
// servers added elsewhere since the dialog opened.
const current = getServers(await getHermesConfigRecord())
if (Object.prototype.hasOwnProperty.call(current, trimmedName)) {
setExistingNames(Object.keys(current))
setError(m.deepLinkNameConflict(trimmedName))
return
}
const nextServers = { ...current, [trimmedName]: request.config }
await saveMcpServers(nextServers)
setHermesConfigCache(previous => (previous ? { ...previous, mcp_servers: nextServers } : previous))
notify({ kind: 'success', title: m.savedTitle, message: m.savedMessage(trimmedName) })
$mcpInstallRequest.set(null)
navigate(`/skills?tab=mcp&server=${encodeURIComponent(trimmedName)}`)
} catch (err) {
setError(readableError(err, m.saveFailed).message)
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && close()} open>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{m.deepLinkTitle}</DialogTitle>
<DialogDescription>{m.deepLinkDescription}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
{request.transport === 'stdio' && (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{m.deepLinkStdioWarning}</span>
</div>
)}
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
{m.name}
<Input onChange={event => setName(event.target.value)} value={name} />
</label>
{!nameValid && <p className="text-xs text-destructive">{m.deepLinkNameInvalid}</p>}
{nameValid && nameConflict && (
<p className="text-xs text-destructive">{m.deepLinkNameConflict(trimmedName)}</p>
)}
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
{m.serverJson}
<pre className="max-h-64 overflow-auto rounded-md border border-border bg-muted/40 p-2 font-mono text-xs whitespace-pre-wrap break-all text-foreground">
{JSON.stringify(request.config, null, 2)}
</pre>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<DialogFooter>
<Button disabled={saving} onClick={close} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={saving || !nameValid || nameConflict || checkingConflicts}
onClick={() => void confirm()}
variant={request.transport === 'stdio' ? 'destructive' : 'default'}
>
{saving ? t.common.saving : m.deepLinkConfirm}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Real-data panes + composable bar items for the contrib root:
*
* - `PreviewRailPane` — the REAL ChatPreviewRail; files-pane clicks feed it.
* - `FilesPane` — real file browser; activating a file opens it in preview.
* - Core statusbar items with LIVE store-backed labels, registered as DATA
* contributions (`area: 'statusBar.left' / 'statusBar.right'`, payload =
* StatusbarItem) — plugins add theirs through the identical call.
*/
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { atom } from 'nanostores'
import { RightSidebarPane } from '@/app/right-sidebar'
import { ReviewPane } from '@/app/right-sidebar/review'
import type { GroupSetter } from '@/app/shell/group-setter'
import type { StatusbarItem } from '@/app/shell/statusbar-controls'
import type { TitlebarTool } from '@/app/shell/titlebar-controls'
import { DecodeText } from '@/components/ui/decode-text'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { getLogs } from '@/hermes'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { openPreview } from '@/store/preview'
import { $currentCwd } from '@/store/session'
// ---------------------------------------------------------------------------
// Logs — live agent-log tail. ⌘K-only chrome: the pane contribution exists
// only while the "Toggle logs" palette command has it summoned ($logsOpen in
// the controller) — never in a default layout, never a standing tab.
// ---------------------------------------------------------------------------
export function LogsPane() {
const { 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)
/** Open a file from the tree in the real preview pipeline. */
function previewFile(path: string) {
void normalizeOrLocalPreviewTarget(path, $currentCwd.get() || undefined)
.then(target => {
if (target) {
openPreview(target, 'file-browser')
}
})
.catch(() => undefined)
}
// Layout fit for wrapped asides. Edge chrome (borders/shadows) is neutralized
// GLOBALLY by the tree's seam invariant (see LayoutTreeRoot) — only sizing
// and titlebar clearance are per-wrapper concerns.
const ZONE_CONTENT = 'h-full [&>aside]:h-full [&>aside]:w-full [&>aside]:pt-0'
export function FilesPane() {
return (
<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">
<ContribRender render={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')
@@ -0,0 +1,281 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Fail-closed owner resolution for the window's ONE session-scoped RPC
// dispatcher. A request that names a session whose owner NO rung can name
// (tile route → exact hint → connection-tagged / profiled row → REST probe)
// must not ride the ambient presentation socket: "active" has no routing
// authority, and the fallback turned missing ownership metadata into a
// misleading backend "session not found". The single exception is the legacy
// single-backend Desktop (no registry source, ≤1 profile), where the ambient
// gateway IS the owner by construction.
const gatewayMocks = vi.hoisted(() => ({
activeConnectionId: null as null | string,
requestGatewayForAgent: vi.fn(async () => ({ routed: true })),
requestGatewayForProfile: vi.fn(async () => ({ profiled: true }))
}))
vi.mock('@/store/gateway', async importActual => ({
...(await importActual<Record<string, unknown>>()),
activeGatewayConnectionId: () => gatewayMocks.activeConnectionId,
requestGatewayForAgent: gatewayMocks.requestGatewayForAgent,
requestGatewayForProfile: gatewayMocks.requestGatewayForProfile
}))
const probe = vi.hoisted(() => ({ resolveSessionOwner: vi.fn(async () => undefined as unknown) }))
const sessionMocks = vi.hoisted(() => ({ requestSessionResume: vi.fn() }))
vi.mock('@/app/session/hooks/use-session-actions/utils', async importActual => ({
...(await importActual<Record<string, unknown>>()),
resolveSessionOwner: probe.resolveSessionOwner
}))
vi.mock('@/store/session', async importActual => ({
...(await importActual<Record<string, unknown>>()),
requestSessionResume: sessionMocks.requestSessionResume
}))
const { createSessionRpcDispatcher } = await import('./session-rpc-dispatcher')
const { $connectionsRegistry } = await import('@/store/connection-registry-state')
const { $profiles } = await import('@/store/profile')
const { $removedSessionIds, $sessionMutationsInFlight } = await import('@/store/session-removal')
const { _resetSessionOwnerHintsForTests, setCronSessions, setMessagingSessions, setSessionOwnerHint, setSessions } =
await import('@/store/session')
const { isSessionOwnerResolutionError } = await import('@/store/session-owner-resolution')
const { $sessionTiles } = await import('@/store/session-states')
const { makeSessionInfo } = await import('@/test/session-info')
function dispatcher(
ambientRequest = vi.fn(async () => ({ ambient: true })),
selectedStoredSessionId: null | string = null
) {
return {
ambientRequest,
request: createSessionRpcDispatcher({
ambientRequest: ambientRequest as never,
runtimeIdByStoredSessionIdRef: { current: new Map([['stored-omar', 'rt-omar']]) },
selectedStoredSessionIdRef: { current: selectedStoredSessionId },
sessionStateByRuntimeIdRef: { current: new Map() }
})
}
}
beforeEach(() => {
gatewayMocks.activeConnectionId = 'local'
$connectionsRegistry.set({ connections: [{ id: 'local' }] } as never)
$profiles.set([{ name: 'default' }, { name: 'omar' }] as never)
probe.resolveSessionOwner.mockResolvedValue(undefined)
})
afterEach(() => {
$connectionsRegistry.set(null)
setSessions([])
setCronSessions([])
setMessagingSessions([])
$sessionTiles.set([])
$profiles.set([])
$removedSessionIds.set(new Set())
$sessionMutationsInFlight.set(new Set())
_resetSessionOwnerHintsForTests({ storage: true })
sessionMocks.requestSessionResume.mockReset()
vi.clearAllMocks()
})
describe('createSessionRpcDispatcher: fail closed', () => {
it('rejects with an explicit owner-resolution error instead of riding the ambient socket', async () => {
const { ambientRequest, request } = dispatcher()
await expect(request('prompt.submit', { session_id: 'rt-orphan', text: 'hi' })).rejects.toSatisfy(
isSessionOwnerResolutionError
)
await expect(request('prompt.submit', { session_id: 'rt-orphan', text: 'hi' })).rejects.toThrow(
/owner could not be resolved for "rt-orphan" \(prompt.submit\)/
)
expect(probe.resolveSessionOwner).toHaveBeenCalledWith('rt-orphan')
expect(ambientRequest).not.toHaveBeenCalled()
expect(gatewayMocks.requestGatewayForAgent).not.toHaveBeenCalled()
expect(gatewayMocks.requestGatewayForProfile).not.toHaveBeenCalled()
})
it('still lets a request with NO session (ambient chrome) reach the ambient socket', async () => {
const { ambientRequest, request } = dispatcher()
await expect(request('config.get', {})).resolves.toEqual({ ambient: true })
expect(ambientRequest).toHaveBeenCalledWith('config.get', {})
})
it('keeps the legacy single-backend Desktop on the ambient socket: no registry source, one profile', async () => {
gatewayMocks.activeConnectionId = null
$connectionsRegistry.set(null)
$profiles.set([{ name: 'default' }] as never)
const { ambientRequest, request } = dispatcher()
await expect(request('session.resume', { session_id: 'stored-legacy' })).resolves.toEqual({ ambient: true })
expect(ambientRequest).toHaveBeenCalledWith('session.resume', { session_id: 'stored-legacy' })
})
it('fails closed as soon as there is somewhere to misroute to: a second profile, or a live registry source', async () => {
gatewayMocks.activeConnectionId = null
$connectionsRegistry.set(null)
$profiles.set([{ name: 'default' }, { name: 'omar' }] as never)
await expect(dispatcher().request('session.resume', { session_id: 'stored-x' })).rejects.toSatisfy(
isSessionOwnerResolutionError
)
gatewayMocks.activeConnectionId = 'local'
$connectionsRegistry.set({ connections: [{ id: 'local' }] } as never)
$profiles.set([{ name: 'default' }] as never)
await expect(dispatcher().request('session.resume', { session_id: 'stored-x' })).rejects.toSatisfy(
isSessionOwnerResolutionError
)
})
})
describe('createSessionRpcDispatcher: exact owner rungs', () => {
it('routes by the connection-tagged row when the hint is gone (runtime id translated to the stored id)', async () => {
setSessions([makeSessionInfo({ connection_id: 'local', id: 'stored-omar', profile: 'omar' })])
const { ambientRequest, request } = dispatcher()
await expect(request('prompt.submit', { session_id: 'rt-omar', text: 'again' })).resolves.toEqual({ routed: true })
expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledWith('local', 'omar', 'prompt.submit', {
session_id: 'rt-omar',
text: 'again'
})
expect(ambientRequest).not.toHaveBeenCalled()
expect(probe.resolveSessionOwner).not.toHaveBeenCalled()
})
it('prefers the exact hint over an untagged row profile, and the probe result over nothing', async () => {
setSessions([makeSessionInfo({ id: 'stored-omar', profile: 'default' })])
setSessionOwnerHint('stored-omar', { connectionId: 'local', profile: 'omar' })
await expect(dispatcher().request('session.interrupt', { session_id: 'rt-omar' })).resolves.toEqual({
routed: true
})
expect(gatewayMocks.requestGatewayForAgent).toHaveBeenLastCalledWith('local', 'omar', 'session.interrupt', {
session_id: 'rt-omar'
})
_resetSessionOwnerHintsForTests()
setSessions([])
probe.resolveSessionOwner.mockResolvedValue({ connectionId: 'homelab', profile: 'worker' })
await expect(dispatcher().request('session.activate', { session_id: 'stored-hidden' })).resolves.toEqual({
routed: true
})
expect(gatewayMocks.requestGatewayForAgent).toHaveBeenLastCalledWith('homelab', 'worker', 'session.activate', {
session_id: 'stored-hidden'
})
})
it('resolves owners from the cron and messaging sidebar slices, not just recents (cron approval.respond)', async () => {
// A scheduler-minted cron session has no tile, no hint, and no row in
// $sessions — its row lives in the sidebar's cron slice. The row rung must
// see that slice, or the approval raised inside a cron chat fails closed
// with SessionOwnerResolutionError and can never be answered.
setCronSessions([makeSessionInfo({ id: 'stored-cron', profile: 'omar', source: 'cron' })])
const { ambientRequest, request } = dispatcher()
await expect(
request('approval.respond', { choice: 'once', request_id: 'req-1', session_id: 'stored-cron' })
).resolves.toEqual({ profiled: true })
expect(gatewayMocks.requestGatewayForProfile).toHaveBeenLastCalledWith(
'omar',
'approval.respond',
{
choice: 'once',
request_id: 'req-1',
session_id: 'stored-cron'
},
undefined,
undefined
)
expect(ambientRequest).not.toHaveBeenCalled()
expect(probe.resolveSessionOwner).not.toHaveBeenCalled()
// Messaging slice, connection-tagged row → exact route.
setMessagingSessions([makeSessionInfo({ connection_id: 'homelab', id: 'stored-tg', profile: 'bots' })])
await expect(request('prompt.submit', { session_id: 'stored-tg', text: 'hi' })).resolves.toEqual({ routed: true })
expect(gatewayMocks.requestGatewayForAgent).toHaveBeenLastCalledWith('homelab', 'bots', 'prompt.submit', {
session_id: 'stored-tg',
text: 'hi'
})
})
})
describe('createSessionRpcDispatcher: stale runtime recovery', () => {
it('requests a durable rebind for the visible session after a structured 4001', async () => {
setSessions([makeSessionInfo({ connection_id: 'local', id: 'stored-omar', profile: 'omar' })])
gatewayMocks.requestGatewayForAgent.mockRejectedValueOnce(
Object.assign(new Error('runtime was reaped'), { code: 4001 })
)
const { request } = dispatcher(undefined, 'stored-omar')
await expect(request('process.list', { session_id: 'rt-omar' })).rejects.toThrow('runtime was reaped')
expect(sessionMocks.requestSessionResume).toHaveBeenCalledWith('stored-omar', {
connectionId: 'local',
profile: 'omar'
})
})
it('does not let a background 4001 pull a different session into the foreground', async () => {
setSessions([makeSessionInfo({ connection_id: 'local', id: 'stored-omar', profile: 'omar' })])
gatewayMocks.requestGatewayForAgent.mockRejectedValueOnce(
Object.assign(new Error('session not found'), { code: 4001 })
)
const { request } = dispatcher(undefined, 'stored-other')
await expect(request('process.list', { session_id: 'rt-omar' })).rejects.toThrow('session not found')
expect(sessionMocks.requestSessionResume).not.toHaveBeenCalled()
})
it.each([
['tombstoned', $removedSessionIds],
['being deleted', $sessionMutationsInFlight]
])('still reports the 4001 for a selected session that is %s', async (_state, sessions) => {
// The rebind decision moved to requestSessionResume (store/session-removal),
// which drops resume requests for a removal-pending id — this seam only has
// to keep surfacing the error to its caller.
setSessions([makeSessionInfo({ connection_id: 'local', id: 'stored-omar', profile: 'omar' })])
sessions.set(new Set(['stored-omar']))
gatewayMocks.requestGatewayForAgent.mockRejectedValueOnce(
Object.assign(new Error('session not found'), { code: 4001 })
)
const { request } = dispatcher(undefined, 'stored-omar')
await expect(request('process.list', { session_id: 'rt-omar' })).rejects.toThrow('session not found')
})
it('does not interpret an unrelated coded RPC failure as a stale runtime', async () => {
setSessions([makeSessionInfo({ connection_id: 'local', id: 'stored-omar', profile: 'omar' })])
gatewayMocks.requestGatewayForAgent.mockRejectedValueOnce(
Object.assign(new Error('tool output says session not found'), { code: 5007 })
)
const { request } = dispatcher(undefined, 'stored-omar')
await expect(request('process.list', { session_id: 'rt-omar' })).rejects.toThrow(
'tool output says session not found'
)
expect(sessionMocks.requestSessionResume).not.toHaveBeenCalled()
})
it('leaves the warm resume lifecycle to recover its own session.activate failure', async () => {
setSessions([makeSessionInfo({ connection_id: 'local', id: 'stored-omar', profile: 'omar' })])
gatewayMocks.requestGatewayForAgent.mockRejectedValueOnce(
Object.assign(new Error('session not found'), { code: 4001 })
)
const { request } = dispatcher(undefined, 'stored-omar')
await expect(request('session.activate', { session_id: 'rt-omar' })).rejects.toThrow('session not found')
expect(sessionMocks.requestSessionResume).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,125 @@
/**
* The window's ONE session-scoped RPC dispatcher, factored out of the contrib
* wiring controller so the exact production routing (not a re-implementation)
* can be driven by integration tests alongside the real session/prompt hooks.
*
* Route each RPC by the session IT targets, not by whatever tile is focused.
* `requestGateway` is one shared closure used for every session RPC in the
* window; keying the owner off $focusedStoredSessionId sent a NON-focused
* tile's RPC (any bot chat while another pane is active) to the focused tile's
* backend. That is the Bot Mode bug: a bot's prompt.submit carried its own
* session_id but ran on the default backend (served via ?profile= from the
* default's state.db), or 4001'd when the default backend didn't hold the
* runtime session.
*
* params.session_id is a RUNTIME id, while tiles and session rows key on the
* STORED id, so translate first (state cache, then a reverse scan of the
* stored->runtime map, then the persisted tile map — the same ladder
* use-session-tile-delegate uses, plus the tile rung that survives a reload
* when the state cache is cold). A miss on ALL rungs means the id is already a
* stored id (several RPCs pass stored ids directly), so use it as-is. Only an
* RPC with no session_id at all (ambient/config calls) keeps the focused-tile
* route.
*
* Session-scoped RPCs route to the backend that OWNS the session — never to
* whatever is "active" (active is presentation only). The owner ladder is
* resolveSessionRpcOwner (tile route → exact unique owner hint → the row's
* owner: exact when connection-tagged, else its profile), then a
* cross-profile REST probe for a hidden/unlisted session. A request with a
* session whose owner STILL cannot be named fails closed with an explicit
* SessionOwnerResolutionError rather than riding the ambient socket (the one
* exception: the legacy single-backend Desktop, where ambient IS the owner).
* Only a request with NO session at all falls to the ambient socket.
*/
import type { MutableRefObject } from 'react'
import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils'
import type { ClientSessionState } from '@/app/types'
import { isSessionGoneForBackgroundPolling } from '@/store/runtime-gone'
import { getSessionOwnerHint, knownSessionOwner, ownerLookupSessionRows, requestSessionResume } from '@/store/session'
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
import { requestForSessionProfile, type SessionOwnerScope } from '@/store/session-request-router'
import { $focusedStoredSessionId, sessionTileOwnerRoute, storedSessionIdForRuntimeId } from '@/store/session-states'
import { findStoredIdForRuntimeId, resolveRoutingSessionId, resolveSessionRpcOwner } from './wiring-routing'
export type AmbientGatewayRequest = <T>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
signal?: AbortSignal
) => Promise<T>
export interface SessionRpcDispatcherDeps {
ambientRequest: AmbientGatewayRequest
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionIdRef: MutableRefObject<null | string>
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
}
export function createSessionRpcDispatcher(deps: SessionRpcDispatcherDeps): AmbientGatewayRequest {
const { ambientRequest, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef } = deps
return async <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number, signal?: AbortSignal) => {
const paramSessionId = typeof params?.session_id === 'string' && params.session_id ? params.session_id : undefined
const routingSessionId = resolveRoutingSessionId({
focusedStoredSessionId: $focusedStoredSessionId.get(),
paramSessionId,
selectedStoredSessionId: selectedStoredSessionIdRef.current,
storedIdForRuntime: runtimeId =>
sessionStateByRuntimeIdRef.current.get(runtimeId)?.storedSessionId ??
findStoredIdForRuntimeId(runtimeIdByStoredSessionIdRef.current, runtimeId) ??
storedSessionIdForRuntimeId(runtimeId) ??
undefined
})
let owner: SessionOwnerScope = resolveSessionRpcOwner({
routingSessionId,
sessionOwnerHint: storedSessionId => getSessionOwnerHint(storedSessionId),
sessionRowOwner: storedSessionId => knownSessionOwner(ownerLookupSessionRows(), storedSessionId),
tileOwnerRoute: sessionTileOwnerRoute
})
if (!owner && routingSessionId) {
// Unknown owner for a REAL session: probe across profiles (REST, not the
// gateway socket, so no recursion) rather than defaulting to active. A
// hit stamps ownership on the row (exact when the row came back
// connection-tagged); a miss leaves owner undefined.
const probed = await resolveSessionOwner(routingSessionId)
if (probed) {
owner = probed
}
}
// A request that names a session but whose owner nobody can name must not
// ride the ambient socket: that turns missing metadata into a misleading
// backend "session not found" on a backend that never held the runtime.
assertSessionOwnerResolved(owner, { method, sessionId: paramSessionId ? routingSessionId : null })
try {
return await requestForSessionProfile<T>(owner, ambientRequest, method, params ?? {}, timeoutMs, signal)
} catch (error) {
// A missed session.reclaimed leaves later RPCs answering 4001 against a
// still-resumable stored row. Prompt actions already retry their own
// calls; this seam covers the other session-scoped callers and wakes
// route-resume for the visible main session only. Do not retry the
// failing RPC — it may be destructive, and a fresh binding is async.
// A session the user just deleted is filtered by requestSessionResume,
// which drops resume requests for a removal-pending id.
if (
method !== 'session.resume' &&
method !== 'session.activate' &&
paramSessionId &&
routingSessionId &&
routingSessionId === selectedStoredSessionIdRef.current &&
isSessionGoneForBackgroundPolling(error)
) {
requestSessionResume(routingSessionId, typeof owner === 'object' && owner ? owner : undefined)
}
throw error
}
}
}
@@ -0,0 +1,70 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { ChatRoutesSurface } from './surfaces'
import type { WiringActions } from './types'
vi.mock('@/contrib/react/use-contributions', () => ({ useContributions: vi.fn() }))
vi.mock('@/store/connections', () => ({ $activeConnectionId: atom('local') }))
vi.mock('@/store/gateway', () => ({ $gateway: atom<unknown>(null) }))
vi.mock('@/store/profile', () => ({ $activeGatewayProfile: atom('default') }))
vi.mock('@/store/session', () => ({
$freshDraftReady: atom(false),
$gatewayState: atom('open')
}))
vi.mock('../chat', () => ({
ChatView: ({ gateway }: { gateway: { id?: string } | null }) => <div data-testid="gateway">{gateway?.id}</div>
}))
vi.mock('../chat/sidebar', () => ({ ChatSidebar: () => null }))
vi.mock('../right-sidebar/terminal/chrome', () => ({ TerminalPaneChrome: () => null }))
vi.mock('../shell/hooks/use-status-snapshot', () => ({ useStatusSnapshot: () => ({}) }))
vi.mock('../shell/hooks/use-statusbar-items', () => ({
useStatusbarItems: () => ({ leftStatusbarItems: [], statusbarItems: [] })
}))
vi.mock('../shell/statusbar-controls', () => ({ StatusbarControls: () => null }))
vi.mock('../routes', () => ({
contributedRoutes: () => [],
NEW_CHAT_ROUTE: '/new',
ROUTES_AREA: 'routes',
sessionRoute: (id: string) => `/${id}`
}))
vi.mock('./latest-actions', () => ({ latestChatActions: () => ({}), latestSidebarActions: () => ({}) }))
vi.mock('./panes', () => ({ setStatusbarItemGroup: vi.fn(), useStatusbarContributions: () => [] }))
vi.mock('../shell/model-menu-panel', () => ({ ModelMenuPanel: () => null }))
afterEach(() => {
cleanup()
$gateway.set(null)
$activeGatewayProfile.set('default')
})
describe('ChatRoutesSurface', () => {
it('passes the live gateway after an open-to-open profile switch', () => {
const gatewayA = { id: 'a' } as unknown as HermesGateway
const gatewayB = { id: 'b' } as unknown as HermesGateway
$gateway.set(gatewayA)
const actions = { getGateway: () => $gateway.get() } as unknown as WiringActions
render(
<MemoryRouter>
<ChatRoutesSurface actions={actions} />
</MemoryRouter>
)
expect(screen.getByTestId('gateway').textContent).toBe('a')
act(() => {
$gateway.set(gatewayB)
$activeGatewayProfile.set('other')
})
expect(screen.getByTestId('gateway').textContent).toBe('b')
})
})
+199
View File
@@ -0,0 +1,199 @@
/**
* 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'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { $activeConnectionId } from '@/store/connections'
import { $gateway } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
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 { latestChatActions, latestSidebarActions } from './latest-actions'
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']
}) {
const latestActions = useMemo(() => latestSidebarActions(actions), [actions])
return <ChatSidebar currentView={currentView} {...latestActions} />
})
export const TerminalSurface = memo(function TerminalSurface() {
return (
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-(--ui-terminal-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 activeConnectionId = useStore($activeConnectionId)
const activeGatewayProfile = useStore($activeGatewayProfile)
const gatewayState = useStore($gatewayState)
const freshDraftReady = useStore($freshDraftReady)
const gatewayScope = `${activeConnectionId ?? ''}\0${activeGatewayProfile}`
const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway, gatewayScope)
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 the gateway instance/state and ROUTES_AREA itself;
* the voice cap arrives as a prop. 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 activeConnectionId = useStore($activeConnectionId)
const activeGatewayProfile = useStore($activeGatewayProfile)
const gateway = useStore($gateway)
const gatewayState = useStore($gatewayState)
useContributions(ROUTES_AREA)
const routeContributions = contributedRoutes()
const modelMenuContent = useMemo(
() =>
gatewayState === 'open' ? (
<ModelMenuPanel
gateway={gateway || undefined}
onSelectModel={actions.selectModel}
ownerConnectionId={activeConnectionId || undefined}
profile={activeGatewayProfile}
requestGateway={actions.requestGateway}
/>
) : null,
[actions, activeConnectionId, activeGatewayProfile, gateway, gatewayState]
)
const chatActions = useMemo(() => latestChatActions(actions), [actions])
const chatView = (
<ChatView
gateway={gateway}
maxVoiceRecordingSeconds={maxVoiceRecordingSeconds}
modelMenuContent={modelMenuContent}
modelOptionsOwnerConnectionId={activeConnectionId || undefined}
modelOptionsProfile={activeGatewayProfile}
requestModelOptionsForOwner={actions.requestGateway}
{...chatActions}
/>
)
// FULL-PAGE views (not chat): a page is not a tab-able surface, so the zone's
// tab strip stands down while one is showing. That is `paneChrome.headerVeto`
// on the contribution, not a DOM marker — the `data-zone-no-header` attribute
// that used to ride this wrapper gated a body double-click toggle that no
// longer exists, and nothing has read it since.
const page = (view: ReactNode) => (
<div className="contents">
<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" />
<Route element={null} path="webhooks" />
{/* 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}>
<ContribRender render={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
View File
@@ -0,0 +1,79 @@
import type { ComponentProps, ReactNode } from 'react'
import type { ChatView } from '../chat'
import type { ChatSidebar } from '../chat/sidebar'
import type { CommandCenterSection } from '../command-center'
import type { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
import type { ModelMenuPanel } from '../shell/model-menu-panel'
export type GatewayRequester = ReturnType<typeof useGatewayRequest>['requestGateway']
/** The ChatSidebar handlers the controller owns — forwarded verbatim. */
export type SidebarActions = Pick<
ComponentProps<typeof ChatSidebar>,
| 'onArchiveSession'
| 'onBranchSession'
| 'onDeleteSession'
| 'onLoadMoreMessaging'
| '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'
| 'onAttachPrCommentUrl'
| '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 {
/** Imperative access to the live gateway for controller-owned callbacks.
* Rendered surfaces subscribe to the active `$gateway` atom directly. */
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
}
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import { findStoredIdForRuntimeId, resolveRoutingSessionId, resolveSessionRpcOwner } from './wiring-routing'
describe('findStoredIdForRuntimeId', () => {
it('reverse-resolves a runtime id to its stored id', () => {
const bindings = new Map([
['stored-a', 'runtime-a'],
['stored-b', 'runtime-b']
])
expect(findStoredIdForRuntimeId(bindings, 'runtime-b')).toBe('stored-b')
})
it('returns undefined for an unknown runtime id', () => {
expect(findStoredIdForRuntimeId(new Map([['stored-a', 'runtime-a']]), 'runtime-x')).toBeUndefined()
expect(findStoredIdForRuntimeId(new Map(), 'anything')).toBeUndefined()
})
})
describe('resolveRoutingSessionId', () => {
const never = (): string | undefined => undefined
it('routes by the RPC target session, not the focused tile (the Bot Mode misroute)', () => {
// A bot chat is a background tile: focused/selected point at the DEFAULT
// chat, but the RPC targets the bot. Routing must follow the RPC's target.
const routing = resolveRoutingSessionId({
focusedStoredSessionId: 'default-chat',
paramSessionId: 'runtime-bot',
selectedStoredSessionId: 'default-chat',
storedIdForRuntime: runtimeId => (runtimeId === 'runtime-bot' ? 'stored-bot' : undefined)
})
expect(routing).toBe('stored-bot')
})
it('treats an unresolved session_id as already a stored id', () => {
// Several RPCs pass stored ids directly; a runtime miss must not drop back
// to the focused tile (that reintroduces the misroute).
const routing = resolveRoutingSessionId({
focusedStoredSessionId: 'default-chat',
paramSessionId: 'stored-bot-direct',
selectedStoredSessionId: 'default-chat',
storedIdForRuntime: never
})
expect(routing).toBe('stored-bot-direct')
})
it('falls back to focused then selected when the RPC carries no session_id', () => {
expect(
resolveRoutingSessionId({
focusedStoredSessionId: 'focused',
paramSessionId: undefined,
selectedStoredSessionId: 'selected',
storedIdForRuntime: never
})
).toBe('focused')
expect(
resolveRoutingSessionId({
focusedStoredSessionId: null,
paramSessionId: undefined,
selectedStoredSessionId: 'selected',
storedIdForRuntime: never
})
).toBe('selected')
expect(
resolveRoutingSessionId({
focusedStoredSessionId: null,
paramSessionId: undefined,
selectedStoredSessionId: null,
storedIdForRuntime: never
})
).toBeNull()
})
})
describe('resolveSessionRpcOwner', () => {
const none = () => undefined
const omar = { connectionId: 'local', mode: 'local' as const, profile: 'omar' }
const homelab = { connectionId: 'homelab', mode: 'remote' as const, profile: 'worker', targetProfile: 'w' }
it('returns undefined for an RPC with no session (ambient chrome)', () => {
expect(
resolveSessionRpcOwner({
routingSessionId: null,
sessionOwnerHint: none,
sessionRowOwner: none,
tileOwnerRoute: none
})
).toBeUndefined()
})
it('prefers the persisted tile owner route over the hint and the row', () => {
const owner = resolveSessionRpcOwner({
routingSessionId: 'stored-bot',
sessionOwnerHint: () => omar,
sessionRowOwner: () => 'default',
tileOwnerRoute: () => homelab
})
expect(owner).toEqual(homelab)
})
it('prefers the exact unique owner hint over the session row profile', () => {
// The row is presentation state: an optimistic row minted while the
// ambient profile stayed `default` reads `default` even though the create
// ran on local::omar. The hint recorded at create time is exact.
const owner = resolveSessionRpcOwner({
routingSessionId: 'stored-omar',
sessionOwnerHint: id => (id === 'stored-omar' ? omar : undefined),
sessionRowOwner: () => 'default',
tileOwnerRoute: none
})
expect(owner).toEqual(omar)
})
it('reconstructs the EXACT owner from a connection-tagged row when the hint is gone (evicted / relaunch)', () => {
// The bounded hint map is transient. A row tagged with its owning
// connection (optimistic create row, unified-list splice, or a tag
// mergeSessionPage carried across a refresh) names the same registry
// entry, so the second turn still dials the socket that holds the runtime.
expect(
resolveSessionRpcOwner({
routingSessionId: 'stored-omar',
sessionOwnerHint: none,
sessionRowOwner: () => ({ connectionId: 'local', profile: 'omar' }),
tileOwnerRoute: none
})
).toEqual({ connectionId: 'local', profile: 'omar' })
// The hint still outranks the row when both exist.
expect(
resolveSessionRpcOwner({
routingSessionId: 'stored-omar',
sessionOwnerHint: () => omar,
sessionRowOwner: () => ({ connectionId: 'homelab', profile: 'omar' }),
tileOwnerRoute: none
})
).toEqual(omar)
})
it('falls back to the session row profile, then to undefined for the probe', () => {
expect(
resolveSessionRpcOwner({
routingSessionId: 'stored-1',
sessionOwnerHint: none,
sessionRowOwner: () => 'coder',
tileOwnerRoute: none
})
).toBe('coder')
expect(
resolveSessionRpcOwner({
routingSessionId: 'stored-1',
sessionOwnerHint: none,
sessionRowOwner: () => ' ',
tileOwnerRoute: none
})
).toBeUndefined()
expect(
resolveSessionRpcOwner({
routingSessionId: 'stored-1',
sessionOwnerHint: none,
sessionRowOwner: () => null,
tileOwnerRoute: none
})
).toBeUndefined()
})
})
@@ -0,0 +1,102 @@
/**
* Pure routing helpers for the contrib wiring controller.
*
* Kept out of wiring.tsx so they can be unit-tested without importing the whole
* React/Electron controller module.
*/
import type { SessionOwnerRoute } from '@/store/session-request-router'
/**
* Resolve a runtime session id back to its stored id by reverse-scanning the
* stored->runtime binding map — the same ladder use-session-tile-delegate's
* `storedSessionIdForRuntime` uses. Returns undefined when the id isn't a known
* runtime id, so the caller can treat it as already a stored id.
*/
export function findStoredIdForRuntimeId(bindings: Map<string, string>, runtimeId: string): string | undefined {
for (const [storedId, mapped] of bindings) {
if (mapped === runtimeId) {
return storedId
}
}
return undefined
}
/**
* The stored session id a session-scoped RPC should route by.
*
* Route by the session the RPC TARGETS (its `session_id` param), not by the
* window's focused tile: `requestGateway` is one shared closure for every
* session RPC, so keying off the focused tile sent a non-focused tile's RPC
* (a bot chat while another pane is active) to the focused tile's backend — the
* Bot Mode misroute. `session_id` is a RUNTIME id while tiles/rows key on the
* STORED id, so translate via the state cache, then the reverse binding scan;
* an unknown id is already a stored id (several RPCs pass stored ids directly).
* With no `session_id` at all (ambient/config calls) fall back to the focused
* then selected tile.
*/
export function resolveRoutingSessionId(args: {
paramSessionId: string | undefined
storedIdForRuntime: (runtimeId: string) => string | undefined
focusedStoredSessionId: null | string
selectedStoredSessionId: null | string
}): null | string {
const { focusedStoredSessionId, paramSessionId, selectedStoredSessionId, storedIdForRuntime } = args
if (paramSessionId) {
return storedIdForRuntime(paramSessionId) ?? paramSessionId
}
return focusedStoredSessionId ?? selectedStoredSessionId
}
/** The owner shapes the ladder below can return: an exact route (connection +
* profile), a bare profile name, or undefined (unknown — probe, never
* "active"). The type-only import keeps this module runtime-import-free. */
export type SessionRpcOwnerRoute = SessionOwnerRoute
/**
* The SYNC owner a session-scoped RPC routes to, resolved in this order:
*
* 1. the persisted tile owner route (a bot chat / split tile records the
* exact connectionId + profile it was opened with, survives relaunch);
* 2. the exact, UNIQUE session owner hint (recorded the moment a routed
* session.create returns, or at plugin open time; persisted, bounded);
* 3. the session row's owner — an EXACT route when the row is
* connection-tagged (optimistic row from a routed create, the unified
* list splice, or a tag carried across a refresh), else its bare
* profile (the cross-profile aggregator tags rows, but a bare profile
* loses the connection and can lag the create);
* 4. undefined → the caller runs the cross-profile probe, and fails closed
* if that misses too.
*
* The hint outranks the row because the row is presentation state that can
* be stamped from the AMBIENT profile (an optimistic row minted while
* All-profiles / Bot routing left `default` active), and because it carries
* no connection: a fresh chat created on `local::omar` whose row read
* `default` ran its first turn on omar and then 4001'd "session not found"
* on the second, when the row's `default` owner won the route. The
* connection-tagged row rung is what keeps two-turn continuity from resting
* on the transient hint alone (bounded, evictable, gone after a relaunch).
*/
export function resolveSessionRpcOwner(args: {
routingSessionId: null | string
tileOwnerRoute: (storedSessionId: string) => SessionRpcOwnerRoute | undefined
sessionOwnerHint: (storedSessionId: string) => SessionRpcOwnerRoute | undefined
sessionRowOwner: (storedSessionId: string) => null | SessionRpcOwnerRoute | string | undefined
}): SessionRpcOwnerRoute | string | undefined {
const { routingSessionId, sessionOwnerHint, sessionRowOwner, tileOwnerRoute } = args
if (!routingSessionId) {
return undefined
}
const fromRow = sessionRowOwner(routingSessionId)
return (
tileOwnerRoute(routingSessionId) ??
sessionOwnerHint(routingSessionId) ??
(typeof fromRow === 'string' ? fromRow.trim() || undefined : (fromRow ?? undefined))
)
}
File diff suppressed because it is too large Load Diff