Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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]}</>
|
||||
})
|
||||
@@ -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
|
||||
])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user