Merge remote-tracking branch 'origin/main' into feat/keyless-web-search-fallback

This commit is contained in:
Teknium
2026-08-19 19:32:38 -07:00
34 changed files with 1420 additions and 335 deletions
+77 -13
View File
@@ -4,15 +4,84 @@
// latch when handing the session back to the app window.
import { type BrowserWindow, ipcMain } from 'electron'
import { hudFrostFor, type TranslucencyState } from './translucency'
export interface HudIpcDeps {
isMac: boolean
isWindows: boolean
glassSupported: boolean
/** Main's authoritative translucency state (Settings → Appearance). */
getTranslucencyState: () => TranslucencyState
getHudWindow: () => BrowserWindow | null
openHudWindow: (sessionId: null | string, profile: null | string) => void
closeHudWindow: () => void
setHudSessionId: (sessionId: null | string) => void
}
export function registerHudIpc({ isMac, getHudWindow, openHudWindow, closeHudWindow, setHudSessionId }: HudIpcDeps) {
export function registerHudIpc({
isMac,
isWindows,
glassSupported,
getTranslucencyState,
getHudWindow,
openHudWindow,
closeHudWindow,
setHudSessionId
}: HudIpcDeps) {
// Whether the band currently covers the window below the bar. The renderer
// is the only party that can know this (it measures the transcript), and it
// is half of the frost decision — the other half is the user's setting,
// which main owns. Latched so a Settings change can re-decide without
// waiting for the HUD to report again.
let bandShowing = false
let applied: null | string = null
let appliedTo: BrowserWindow | null = null
// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do,
// because Chromium composites a transparent window's page against nothing and
// the desktop is not in its backdrop root. The material IS the window's content
// view, so it frosts the whole rectangle; the HUD's layout leaves no dead
// margins for that reason, and it only turns on while the band is showing
// (idle HUD mode must be the bar and nothing else).
//
// Diffed before issuing: `setVibrancy` carries a 150ms animation that restarts
// if re-issued, so a repeated call would keep the material from ever settling
// (the same churn the chat windows' native-diff contract exists to prevent).
//
// The diff is keyed to the WINDOW as well as the value. A HUD respawn (the
// profile switch in openHudWindow destroys and rebuilds it) hands back a
// fresh window carrying no material, and a latch that only remembered the
// value would recognise its own last answer and skip — leaving the new HUD
// unfrosted until something else happened to change the signature.
const applyHudFrost = () => {
const hudWindow = getHudWindow()
if (!hudWindow || hudWindow.isDestroyed()) {
applied = null
appliedTo = null
return
}
const frost = hudFrostFor(getTranslucencyState(), bandShowing)
const signature = `${frost.vibrancy ?? 'off'}:${frost.backgroundMaterial}`
if (applied === signature && appliedTo === hudWindow) {
return
}
applied = signature
appliedTo = hudWindow
if (isMac && typeof hudWindow.setVibrancy === 'function') {
hudWindow.setVibrancy(frost.vibrancy)
}
if (isWindows && glassSupported && typeof hudWindow.setBackgroundMaterial === 'function') {
hudWindow.setBackgroundMaterial(frost.backgroundMaterial)
}
}
ipcMain.handle('hermes:hud:open', async (_event, request) => {
openHudWindow(
typeof request?.sessionId === 'string' ? request.sessionId : null,
@@ -22,18 +91,9 @@ export function registerHudIpc({ isMac, getHudWindow, openHudWindow, closeHudWin
return { ok: true }
})
// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do,
// because Chromium composites a transparent window's page against nothing and
// the desktop is not in its backdrop root. Vibrancy IS the window's content
// view, so it frosts the whole rectangle; the HUD's layout leaves no dead
// margins for that reason, and the renderer only turns it on while the band is
// showing (idle HUD mode must be the bar and nothing else).
ipcMain.handle('hermes:hud:vibrancy', (_event, on) => {
const hudWindow = getHudWindow()
if (hudWindow && !hudWindow.isDestroyed() && isMac) {
hudWindow.setVibrancy(on ? 'hud' : null)
}
ipcMain.handle('hermes:hud:frost', (_event, showing) => {
bandShowing = Boolean(showing)
applyHudFrost()
return { ok: true }
})
@@ -125,4 +185,8 @@ export function registerHudIpc({ isMac, getHudWindow, openHudWindow, closeHudWin
return { ok: true }
})
// Main re-applies the frost when the translucency SETTING changes, since the
// band's own report only fires when the band itself moves.
return { applyHudFrost }
}
+11 -1
View File
@@ -12136,8 +12136,11 @@ registerPetOverlayIpc({
})
// --- HUD mode (chrome-free floating chat) — see hud-ipc.ts. ---------------
registerHudIpc({
const hudIpc = registerHudIpc({
isMac: IS_MAC,
isWindows: IS_WINDOWS,
glassSupported: GLASS_SUPPORTED,
getTranslucencyState: () => translucencyState,
getHudWindow: () => hudWindow,
openHudWindow,
closeHudWindow,
@@ -12145,6 +12148,7 @@ registerHudIpc({
hudSessionId = value
}
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
@@ -13897,6 +13901,12 @@ ipcMain.on('hermes:translucency', (_event, payload) => {
scheduleTranslucencyWrite()
// The HUD's frost reads the same setting but answers on its own terms (see
// hudFrostFor) — and it is a transparent window, so it is deliberately not
// in the chat fan-out below. It self-diffs, so an unrelated change costs
// nothing native.
hudIpc.applyHudFrost()
if (changed.backing || changed.material || changed.opacity) {
for (const win of BrowserWindow.getAllWindows()) {
applyWindowTranslucency(win, changed)
+4 -1
View File
@@ -75,7 +75,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore),
moveBy: delta => ipcRenderer.send('hermes:hud:move-by', delta),
setBounds: bounds => ipcRenderer.send('hermes:hud:set-bounds', bounds),
setVibrancy: on => ipcRenderer.invoke('hermes:hud:vibrancy', on),
// Whether the band covers the window below the bar. Main pairs it with the
// user's translucency setting to decide the native frost (macOS vibrancy /
// Windows 11 DWM backdrop) — see hudFrostFor.
setFrost: showing => ipcRenderer.invoke('hermes:hud:frost', showing),
// The HUD tells main which session it is on; main hands that back to the
// app window when the HUD closes, so the app can re-home onto it.
setSession: sessionId => ipcRenderer.send('hermes:hud:session', sessionId),
@@ -23,6 +23,7 @@ import {
glassMaterialsFor,
glassSupportedOn,
glassSurfaceKeep,
hudFrostFor,
normalizeMaterial,
normalizeMode,
normalizeScope,
@@ -245,6 +246,53 @@ describe('vibrancyFor', () => {
})
})
// The HUD is a transparent window, so its frost has no opaque page to hide
// behind: every state that isn't "frost wanted" has to resolve to no material
// at all, or the band leaves a grey slab hanging over another app.
describe('hudFrostFor', () => {
it('wears the chosen frost on both platforms while the band is showing', () => {
expect(hudFrostFor(glass(60, 'header'), true)).toEqual({ vibrancy: 'header', backgroundMaterial: 'mica' })
expect(hudFrostFor(glass(60, 'under-window'), true)).toEqual({
vibrancy: 'under-window',
backgroundMaterial: 'acrylic'
})
})
// The material is the whole window rectangle and nothing on the page can
// clip it, so a hidden band must mean no frost — this is the veto that keeps
// idle HUD mode the bar and nothing else.
it('is off whenever the band is not covering the window', () => {
expect(hudFrostFor(glass(60, 'header'), false)).toEqual({ vibrancy: null, backgroundMaterial: 'none' })
})
// ...and the setting is the other veto: Glass off, or the tint at zero,
// means the HUD never frosts however engaged the band is.
it('is off whenever glass itself is off', () => {
expect(hudFrostFor(clear(60), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' })
expect(hudFrostFor(glass(0, 'header'), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' })
})
// Unlike a chat window, which keeps 'sidebar' under its titlebar band in
// every non-glass state. Pinning this is what stops someone "fixing" the
// null into a resting material and painting the slab back.
it('resolves off to no material at all, not to a resting one', () => {
expect(hudFrostFor(clear(60), true).vibrancy).toBeNull()
expect(vibrancyFor(clear(60))).toBe('sidebar')
})
// The tint is painted by the renderer, exactly as it is for a chat window —
// dragging it must not re-issue setVibrancy, whose 150ms animation restarts
// on every call and never lets the material settle.
it('does not move any native property as the tint slider is dragged', () => {
for (let intensity = 1; intensity <= 100; intensity += 1) {
expect(hudFrostFor(glass(intensity, 'popover'), true)).toEqual({
vibrancy: 'popover',
backgroundMaterial: 'tabbed'
})
}
})
})
describe('glassSupportedOn', () => {
it('is on for macOS regardless of kernel version', () => {
expect(glassSupportedOn('darwin')).toBe(true)
+1
View File
@@ -26,6 +26,7 @@ export {
glassMaterialsFor,
glassSupportedOn,
glassSurfaceKeep,
hudFrostFor,
normalizeMaterial,
normalizeMode,
normalizeScope,
@@ -0,0 +1,25 @@
import { cn } from '@/lib/utils'
// Shared class names for the composer's control row, in a module of their own
// so both the row (`controls.tsx`) and the menus it renders can wear them
// without importing each other in a cycle.
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
export const GHOST_ICON_BTN = cn(
ICON_BTN,
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
// Send/voice-conversation primary: solid foreground-on-background circle
// (reads as black-on-white in light mode, white-on-black in dark mode) to
// match the reference composer's high-contrast CTA. Keeps the pill itself
// neutral and lets the action visually dominate the row.
export const PRIMARY_ICON_BTN = cn(
'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0',
'bg-foreground text-background hover:bg-foreground/90',
'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100'
)
/** A toggle that is currently ON — dictation, spoken replies, the wake word. */
export const ACTIVE_ICON_BTN = 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { I18nProvider } from '@/i18n'
import { $hudMode } from '@/store/hud'
import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word'
import { ComposerControls } from './controls'
@@ -57,6 +58,44 @@ async function expectShortcutTooltip(label: string, shortcut: string) {
afterEach(() => {
cleanup()
$hudMode.set(false)
})
// The HUD is a Spotlight bar a few hundred pixels wide: the four voice
// controls fold into one menu there, and the way out of HUD mode joins the
// row instead of floating above the bar in a reserved strip. The docked
// composer keeps every control inline and shows no exit.
describe('HUD mode', () => {
it('keeps the voice controls inline and offers no exit in the docked composer', () => {
renderControls()
expect(screen.getByLabelText('Voice dictation')).toBeTruthy()
expect(screen.getByLabelText('Read replies aloud')).toBeTruthy()
expect(screen.queryByLabelText('Exit HUD mode')).toBeNull()
expect(screen.queryByLabelText('Voice')).toBeNull()
})
it('folds them into one menu and offers the way out in the HUD', () => {
$hudMode.set(true)
renderControls()
expect(screen.getByLabelText('Voice')).toBeTruthy()
expect(screen.getByLabelText('Exit HUD mode')).toBeTruthy()
// Folded away, not duplicated — the whole point is the row's width back.
expect(screen.queryByLabelText('Voice dictation')).toBeNull()
expect(screen.queryByLabelText('Read replies aloud')).toBeNull()
})
// A collapsed menu that looked idle while the mic was open would be a worse
// trade than the space it saves, so the trigger reports the live state.
it('reports a live voice state on the collapsed trigger', () => {
$hudMode.set(true)
renderControls({ voiceStatus: 'recording' })
expect(screen.getByLabelText('Stop dictation')).toBeTruthy()
expect(screen.queryByLabelText('Voice')).toBeNull()
})
})
describe('ComposerControls shortcut tooltips', () => {
+58 -28
View File
@@ -7,26 +7,18 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hudMode, closeHud } from '@/store/hud'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import type { ChatBarState, VoiceStatus } from './types'
import { VoiceMenu } from './voice-menu'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
export const GHOST_ICON_BTN = cn(
ICON_BTN,
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
// Send/voice-conversation primary: solid foreground-on-background circle
// (reads as black-on-white in light mode, white-on-black in dark mode) to
// match the reference composer's high-contrast CTA. Keeps the pill itself
// neutral and lets the action visually dominate the row.
export const PRIMARY_ICON_BTN = cn(
'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0',
'bg-foreground text-background hover:bg-foreground/90',
'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100'
)
// Re-exported: `context-menu.tsx` and other row neighbours have always reached
// for these here, and the row is where they read as belonging.
export { ACTIVE_ICON_BTN, GHOST_ICON_BTN, ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
interface ConversationProps {
active: boolean
@@ -70,6 +62,7 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
const hudMode = useStore($hudMode)
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
@@ -84,9 +77,27 @@ export function ComposerControls({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
<WakeWordButton disabled={disabled} />
{/* The HUD is a Spotlight bar a few hundred pixels wide, so the four
separate voice toggles fold into one menu there and leave the row to
the input. The docked composer has the width and keeps them inline —
same controls, same state, different budget. */}
{hudMode ? (
<VoiceMenu
autoSpeak={autoSpeak}
disabled={disabled}
onDictate={onDictate}
onStartConversation={conversation.onStart}
onToggleAutoSpeak={onToggleAutoSpeak}
state={state}
voiceStatus={voiceStatus}
/>
) : (
<>
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
<WakeWordButton disabled={disabled} />
</>
)}
{showQueueButton ? (
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
<Button
@@ -142,10 +153,37 @@ export function ComposerControls({
</Button>
</Tip>
)}
{/* The way out of HUD mode, riding the controls row rather than floating
above the bar. The old chip lived in a 26px transparent strip reserved
over the composer (--hud-chip-strip), which under glass is bare
untinted material with a hidden button in it — a band of chrome above
the surface, paid for in every state, for a control that is invisible
until hovered. Here it costs no reserved space and sits with the other
things you can press. */}
{hudMode ? <ExitHudButton /> : null}
</div>
)
}
function ExitHudButton() {
const { t } = useI18n()
return (
<Tip label={t.titlebar.exitHud}>
<Button
aria-label={t.titlebar.exitHud}
className={cn(GHOST_ICON_BTN, 'p-0')}
onClick={closeHud}
size="icon"
type="button"
variant="ghost"
>
<Codicon name="screen-normal" size="0.875rem" />
</Button>
</Tip>
)
}
function ConversationPill({
disabled,
level,
@@ -269,11 +307,7 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa
<Button
aria-label={label}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
active && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
)}
className={cn(GHOST_ICON_BTN, 'p-0', active && ACTIVE_ICON_BTN)}
disabled={disabled}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
@@ -318,11 +352,7 @@ function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolea
<Button
aria-label={label}
aria-pressed={wake.listening && !pausedForVoice}
className={cn(
GHOST_ICON_BTN,
'p-0',
wake.listening && !pausedForVoice && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
)}
className={cn(GHOST_ICON_BTN, 'p-0', wake.listening && !pausedForVoice && ACTIVE_ICON_BTN)}
disabled={disabled || pausedForVoice || wake.pending}
onClick={() => {
triggerHaptic(wake.listening ? 'close' : 'open')
@@ -365,7 +395,7 @@ function DictationButton({
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
status === 'recording' && ACTIVE_ICON_BTN,
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
@@ -0,0 +1,162 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
dropdownMenuRow,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Ear, EarOff, iconSize, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN } from './control-classes'
import type { ChatBarState, VoiceStatus } from './types'
export interface VoiceMenuProps {
autoSpeak: boolean
disabled: boolean
state: ChatBarState
voiceStatus: VoiceStatus
onDictate: () => void
onStartConversation: () => void
onToggleAutoSpeak: () => void
}
/**
* Every voice control behind one trigger: dictation, spoken replies, the
* "hey hermes" wake word, and starting a full conversation.
*
* The bar carried four separate icon buttons — mic, speaker, ear, and the
* voice-conversation primary — which is most of a Spotlight-width composer
* spent on toggles that are set once and rarely touched. Collapsing them costs
* one click on the rare change and gives the row back to the input.
*
* The trigger is not a static glyph: it REPORTS the loudest live voice state
* (recording, transcribing, listening for the wake word, speaking replies), so
* folding the controls away never hides the fact that something is listening.
* A collapsed menu that looked idle while the mic was hot would be a worse
* trade than the space it saves.
*/
export function VoiceMenu({
autoSpeak,
disabled,
state,
voiceStatus,
onDictate,
onStartConversation,
onToggleAutoSpeak
}: VoiceMenuProps) {
const { t } = useI18n()
const c = t.composer
const wake = useStore($wakeWord)
const phrase = wake.phrase || 'hey hermes'
const dictating = state.voice.active || voiceStatus !== 'idle'
const wakeListening = wake.listening
// Anything live keeps the trigger lit, so a folded menu can never look idle
// while the mic is open.
const active = dictating || wakeListening || autoSpeak
const dictationLabel =
voiceStatus === 'recording'
? c.stopDictation
: voiceStatus === 'transcribing'
? c.transcribingDictation
: c.voiceDictation
const wakeLabel = wakeListening ? c.wakeWordListening(phrase) : c.wakeWordOff(phrase)
const triggerLabel = dictating ? dictationLabel : wakeListening ? wakeLabel : c.voiceControls
return (
<DropdownMenu>
<Tip label={wake.notice && !dictating ? `${triggerLabel} — ${wake.notice}` : triggerLabel}>
<DropdownMenuTrigger asChild>
<Button
aria-label={triggerLabel}
className={cn(GHOST_ICON_BTN, 'p-0', active && ACTIVE_ICON_BTN)}
disabled={disabled}
size="icon"
type="button"
variant="ghost"
>
{voiceStatus === 'recording' ? (
<Square className={cn('fill-current', iconSize.xs)} />
) : voiceStatus === 'transcribing' ? (
<Loader2 className={cn('animate-spin', iconSize.sm)} />
) : wakeListening ? (
<Ear className={iconSize.sm} />
) : (
<Codicon name="mic" size="0.875rem" />
)}
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="min-w-52">
<DropdownMenuItem
className={dropdownMenuRow}
disabled={disabled}
onSelect={() => {
triggerHaptic('open')
onStartConversation()
}}
>
<AudioLines className={iconSize.sm} />
{c.startVoice}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Checkbox items, because all three are toggles the user is reading
the CURRENT state of — the reason they were pressed-state buttons
before. A plain row would fold that state away with the menu. */}
<DropdownMenuCheckboxItem
checked={dictating}
className={dropdownMenuRow}
disabled={disabled || !state.voice.enabled || voiceStatus === 'transcribing'}
onSelect={event => {
// Keep the menu open: dictation is a mode you watch, and closing
// on select hides the recording state the trigger just entered.
event.preventDefault()
triggerHaptic(dictating ? 'close' : 'open')
onDictate()
}}
>
{dictationLabel}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={autoSpeak}
className={dropdownMenuRow}
disabled={disabled}
onSelect={event => {
event.preventDefault()
triggerHaptic(autoSpeak ? 'close' : 'open')
onToggleAutoSpeak()
}}
>
{autoSpeak ? <Volume2 className={iconSize.sm} /> : <VolumeX className={iconSize.sm} />}
{autoSpeak ? c.stopSpeakingReplies : c.speakReplies}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={wakeListening}
className={dropdownMenuRow}
disabled={disabled || wake.pending}
onSelect={event => {
event.preventDefault()
triggerHaptic(wakeListening ? 'close' : 'open')
void toggleWakeWord()
}}
>
{wakeListening ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />}
{wakeLabel}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+18 -1
View File
@@ -62,12 +62,14 @@ import {
$selectedStoredSessionId,
$sessionResumeRequest,
$sessions,
rememberedSessionProfile,
sessionMatchesStoredId,
sessionPinId,
setAwaitingResponse,
setBusy,
setMessages
} from '@/store/session'
import { requestForSessionProfile } from '@/store/session-request-router'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { armWakeWord, stopClientCapture } from '@/store/wake-word'
import { isAuxiliaryWindow, isHudWindow } from '@/store/windows'
@@ -279,7 +281,22 @@ export function ContribWiring({ children }: { children: ReactNode }) {
setMessages
})
const { connectionRef, gateway, gatewayRef, requestGateway } = useGatewayRequest()
const { connectionRef, gateway, gatewayRef, requestGateway: ambientRequestGateway } = useGatewayRequest()
// When chrome stays on the launch backend (Bot Mode / all-profiles
// navigation), session-owned RPCs still have to hit the session's backend.
const requestGateway = useCallback(
<T,>(method: string, params?: Record<string, unknown>, timeoutMs?: number, signal?: AbortSignal) => {
const owner = rememberedSessionProfile(
$sessions.get(),
selectedStoredSessionIdRef.current,
$activeGatewayProfile.get()
)
return requestForSessionProfile<T>(owner, ambientRequestGateway, method, params ?? {}, timeoutMs, signal)
},
[ambientRequestGateway]
)
const { loadMoreMessagingForPlatform, loadMoreSessions, refreshCronJobs, refreshMessagingSessions, refreshSessions } =
useSessionListActions({ profileScope })
+60 -15
View File
@@ -3,14 +3,18 @@ import { type RefObject, useEffect } from 'react'
/** The caret is in the composer — see the `:has()` rules in styles.css. */
const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus'
/** An open completion list owns the surface; the band falls back behind it. */
const DRAWER_SELECTOR = '[data-slot="composer-completion-drawer"]'
/**
* Native frost behind the band.
*
* macOS vibrancy, not CSS — `backdrop-filter` reaches nothing here, because a
* transparent window's backdrop root is the document and the desktop was never
* in it. Vibrancy is composited by WindowServer BELOW the web contents, which
* is what lets it see the desktop and also what makes it untouchable from the
* page: no mask, clip or stacking order can shape it.
* A platform material, not CSS — `backdrop-filter` reaches nothing here,
* because a transparent window's backdrop root is the document and the desktop
* was never in it. The material is composited BELOW the web contents (macOS
* vibrancy via WindowServer, Windows 11 via the DWM backdrop), which is what
* lets it see the desktop and also what makes it untouchable from the page: no
* mask, clip or stacking order can shape it.
*
* That is survivable because the band is a flat panel. It was NOT survivable
* while the band carried a vertical gradient — the frost stayed a slab under a
@@ -31,38 +35,79 @@ const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus'
* document.activeElement, which stays put when the window is blurred and would
* latch the frost on forever once the user had ever typed here.
*
* `backing` is the veto over both of those. Because the frost is the window and
* not the sheet, it is only ever right when the sheet covers the window; short
* of that the excess is frost over empty space. Gating the caller's `engaged`
* alone would not do it — focus turns the frost on by itself, which is how a
* brand new thread still frosted its whole empty window.
* Two vetoes sit over that:
*
* - `backing` — because the frost is the window and not the sheet, it is only
* ever right when the sheet covers the window; short of that the excess is
* frost over empty space. Gating the caller's `engaged` alone would not do
* it — focus turns the frost on by itself, which is how a brand new thread
* still frosted its whole empty window.
* - An open completion drawer, which drops the band to 25% and blurs it
* (see the `composer-completion-drawer` rule in styles.css). Full-strength
* frost behind a band that has deliberately stepped back is the same bare
* slab in a different disguise. Observed rather than passed in: the drawer
* mounts inside the composer subtree from three different call sites, so a
* prop would need every one of them to remember.
*
* Whether the frost is wanted AT ALL is the user's translucency setting, and
* that answer lives in main (`hudFrostFor`) next to the state it reads. This
* hook reports what the band is doing; it does not decide the material.
*/
export function useHudGlass(rootRef: RefObject<HTMLElement | null>, engaged: boolean, backing: boolean): void {
useEffect(() => {
const root = rootRef.current
const setVibrancy = window.hermesDesktop?.hud?.setVibrancy
const setFrost = window.hermesDesktop?.hud?.setFrost
if (!root || !setVibrancy) {
if (!root || !setFrost) {
return
}
let on: boolean | null = null
const apply = () => {
const next = backing && (engaged || root.querySelector(TYPING_SELECTOR) !== null)
const next =
backing &&
root.querySelector(DRAWER_SELECTOR) === null &&
(engaged || root.querySelector(TYPING_SELECTOR) !== null)
if (on !== next) {
on = next
void setVibrancy(next)
void setFrost(next)
}
}
// The drawer mounts and unmounts without any focus change, so neither
// focusin/focusout nor a re-render is guaranteed to follow it. Coalesced
// to a frame: this observes the whole shell, and a streaming reply mutates
// the transcript tens of times a second — the drawer's state cannot change
// more than once per paint, so re-deciding per mutation is pure churn.
let frame: null | number = null
const schedule = () => {
if (frame === null) {
frame = requestAnimationFrame(() => {
frame = null
apply()
})
}
}
const observer = new MutationObserver(schedule)
observer.observe(root, { childList: true, subtree: true })
apply()
root.addEventListener('focusin', apply)
root.addEventListener('focusout', apply)
return () => {
void setVibrancy(false)
void setFrost(false)
observer.disconnect()
if (frame !== null) {
cancelAnimationFrame(frame)
}
root.removeEventListener('focusin', apply)
root.removeEventListener('focusout', apply)
}
-23
View File
@@ -2,18 +2,12 @@ import { useStore } from '@nanostores/react'
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { TitlebarIcon } from '@/app/shell/titlebar-icon'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { closeHud } from '@/store/hud'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { $busy, $messages } from '@/store/session'
import { RICH_INPUT_SLOT } from '../chat/composer/rich-editor'
import { WiredPane } from '../contrib/wiring'
import { titlebarButtonClass } from '../shell/titlebar'
import { useHudClickThrough } from './click-through'
import { useHudGlass } from './glass'
@@ -170,7 +164,6 @@ function useHudHeld(): boolean {
* `useRecentActivity`).
*/
export function HudShell() {
const { t } = useI18n()
const [recent, holdBand] = useRecentActivity()
const held = useHudHeld()
@@ -388,22 +381,6 @@ export function HudShell() {
<WiredPane part="chatRoutes" />
{/* The way back — without it the only exits are ⌘⇧H and ⌘W, both
invisible. Placed and revealed entirely from styles.css. */}
<Tip label={t.titlebar.exitHud}>
<Button
aria-label={t.titlebar.exitHud}
className={`${titlebarButtonClass} absolute z-20`}
data-hud-exit=""
onClick={closeHud}
size="icon-titlebar"
type="button"
variant="ghost"
>
<TitlebarIcon name="screen-normal" />
</Button>
</Tip>
{/* The resize handle: bottom-right corner, the one sanctioned way to
change the HUD's size. Invisible chrome — a hot corner, not a
button — so it never reads as part of the surface. `data-hud-grabbing`
@@ -13,6 +13,7 @@ import { setSessionYolo } from '@/lib/yolo-session'
import { normalizeChoices, setClarifyRequest } from '@/store/clarify'
import { migrateSessionDraft } from '@/store/composer'
import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue'
import { openGatewayForAgent, openGatewayForProfile } from '@/store/gateway'
import { $gatewaySwitching } from '@/store/gateway-switch'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
@@ -20,6 +21,7 @@ import {
$activeGatewayProfile,
$gatewaySwapTarget,
$newChatProfile,
$showAllProfiles,
ensureGatewayAgent,
ensureGatewayProfile,
normalizeProfileKey
@@ -740,7 +742,15 @@ export function useSessionActions({
// A row spliced from a CONNECTED registry gateway (#88880) carries its
// owning connection — activate THAT gateway, not a same-named local
// profile. Rows without the tag keep the legacy profile path.
if (storedForProfile?.connection_id) {
// All-profiles / plugin navigation must not steal chrome API-home:
// dial the owning backend without moving $activeGatewayProfile.
if ($showAllProfiles.get()) {
if (storedForProfile?.connection_id) {
await openGatewayForAgent(storedForProfile.connection_id, sessionProfile || 'default')
} else if (sessionProfile) {
await openGatewayForProfile(normalizeProfileKey(sessionProfile))
}
} else if (storedForProfile?.connection_id) {
await ensureGatewayAgent(storedForProfile.connection_id, sessionProfile || 'default')
} else {
await ensureGatewayProfile(sessionProfile)
+1 -1
View File
@@ -96,7 +96,7 @@ declare global {
setIgnoreMouse: (ignore: boolean) => void
moveBy: (delta: { x: number; y: number; width: number; height: number }) => void
setBounds: (bounds: { x: number; y: number; width: number; height: number }) => void
setVibrancy: (on: boolean) => Promise<{ ok: boolean }>
setFrost: (showing: boolean) => Promise<{ ok: boolean }>
setSession: (sessionId: null | string) => void
onGoto: (callback: (sessionId: string) => void) => () => void
onChanged: (callback: (state: { open: boolean; sessionId: null | string }) => void) => () => void
+1
View File
@@ -1765,6 +1765,7 @@ export const ar = defineLocale({
endShort: 'إنهاء',
stopDictation: 'إيقاف الإملاء',
transcribingDictation: 'جار تفريغ الإملاء',
voiceControls: 'صوت',
voiceDictation: 'إملاء صوتي',
lookupLoading: 'جار البحث...',
lookupNoMatches: 'لا توجد نتائج',
+1
View File
@@ -2253,6 +2253,7 @@ export const en: Translations = {
endShort: 'End',
stopDictation: 'Stop dictation',
transcribingDictation: 'Transcribing dictation',
voiceControls: 'Voice',
voiceDictation: 'Voice dictation',
speakReplies: 'Read replies aloud',
stopSpeakingReplies: 'Stop reading replies aloud',
+1
View File
@@ -1954,6 +1954,7 @@ export const ja = defineLocale({
endShort: '終了',
stopDictation: '口述を停止',
transcribingDictation: '口述を文字起こし中',
voiceControls: '音声',
voiceDictation: '音声口述',
speakReplies: '返信を読み上げる',
stopSpeakingReplies: '返信の読み上げを停止',
+1
View File
@@ -1913,6 +1913,7 @@ export interface Translations {
endShort: string
stopDictation: string
transcribingDictation: string
voiceControls: string
voiceDictation: string
speakReplies: string
stopSpeakingReplies: string
+1
View File
@@ -1892,6 +1892,7 @@ export const zhHant = defineLocale({
endShort: '結束',
stopDictation: '停止聽寫',
transcribingDictation: '正在轉寫聽寫',
voiceControls: '語音',
voiceDictation: '語音聽寫',
speakReplies: '朗讀回覆',
stopSpeakingReplies: '停止朗讀回覆',
+1
View File
@@ -2439,6 +2439,7 @@ export const zh: Translations = {
endShort: '结束',
stopDictation: '停止听写',
transcribingDictation: '正在转写听写',
voiceControls: '语音',
voiceDictation: '语音听写',
speakReplies: '朗读回复',
stopSpeakingReplies: '停止朗读回复',
+11 -4
View File
@@ -3276,12 +3276,19 @@ async function openStoredBotChat(name, storedId, summary) {
typeof summary?.message_count === 'number' && Number.isFinite(summary.message_count)
const expectHistory = hasAuthoritativeCount ? summary.message_count > 0 : true
// A profile backend that just woke up can lose the hydration-timeout race
// even though the session is fine (hermes-agent#89617) — clicking Retry
// succeeds because the backend is warm by then. retryHydrationTimeoutOnce
// asks the SDK layer to retry that same wait internally, BEFORE it arms the
// core stranded-session overlay: a plugin-side retry can't do this because
// only host.openSession sees the resume-exhausted latch that overlay reads.
await host.openSession(storedId, {
profile: name,
intent: 'main',
awaitHydration: true,
expectHistory,
keepAllProfilesScope: false
keepAllProfilesScope: true,
retryHydrationTimeoutOnce: true
})
return storedId
@@ -3321,7 +3328,7 @@ function createCanonicalChat(name) {
if (sid && typeof host.openSession === 'function') {
try {
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false })
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true })
opened = true
} catch {
// The stored row may not exist until the kickoff persists it. Retry
@@ -3336,7 +3343,7 @@ function createCanonicalChat(name) {
await host.request('prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' })
if (!opened && sid && typeof host.openSession === 'function') {
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false })
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true })
}
} catch {
// The chat already exists. Keep the pin so the next click
@@ -7845,7 +7852,7 @@ async function openProfileSession(botName, session, gatewayGeneration) {
typeof session?.message_count === 'number' && Number.isFinite(session.message_count)
const expectHistory = hasAuthoritativeCount ? session.message_count > 0 : Boolean(session?.preview)
await host.openSession(id, { profile, awaitHydration: true, expectHistory, keepAllProfilesScope: false })
await host.openSession(id, { profile, awaitHydration: true, expectHistory, keepAllProfilesScope: true })
if (gatewayGeneration !== $sessionsGatewayGeneration.get()) return
$botSelectedSessions.set({ ...$botSelectedSessions.get(), [profile]: id })
}
@@ -112,7 +112,8 @@ test('pin: preferred_session present opens the resolved session and keeps the pi
intent: 'main',
awaitHydration: true,
expectHistory: true,
keepAllProfilesScope: false
keepAllProfilesScope: true,
retryHydrationTimeoutOnce: true
}
}])
assert.equal(runtime.saved.length, 0, 'a live pin must not be rewritten')
@@ -214,6 +215,63 @@ test('pin: precise hit but failed hydration keeps the pin and surfaces the failu
'must not fork the forever-chat on a hiccup')
})
test('pin: a waking-backend hydration timeout asks the SDK to retry internally', async () => {
// The internal retry-and-succeed behavior lives in host.openSession itself
// (apps/desktop/src/sdk/index.ts) now, because only that layer sees the
// $resumeExhaustedSessionId latch that the core stranded-session overlay
// reads — a plugin-side retry can silently resolve while that overlay stays
// latched (hermes-agent#89617). This harness stubs host.openSession with a
// bare mock, so it can only prove the plugin ASKS for the retry, not that
// the overlay never appears; see profile-routing.test.ts for that.
const opts = []
const runtime = loadOpenPath({
openSession: async (id, options) => { opts.push(options) },
request: async method => {
if (method === 'profiles.list') {
return {
profiles: [{
name: 'ops',
preferred_session: {
id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat',
preview: 'latest', started_at: 1, last_active: 2, message_count: 3
}
}]
}
}
return {}
}
})
const result = await runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY)
assert.equal(result, 'pin-1')
assert.equal(opts.length, 1)
assert.equal(opts[0].retryHydrationTimeoutOnce, true, 'the SDK must own the hydration-timeout retry')
})
test('pin: a persistent hydration timeout still surfaces the failure', async () => {
const runtime = loadOpenPath({
openSession: async () => { throw new Error("Timed out loading ops's session history.") },
request: async method => {
if (method === 'profiles.list') {
return {
profiles: [{
name: 'ops',
preferred_session: {
id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat',
preview: 'latest', started_at: 1, last_active: 2, message_count: 3
}
}]
}
}
return {}
}
})
await assert.rejects(runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY), /Timed out loading/)
assert.equal(runtime.saved.length, 0, 'a confirmed-live pin must survive a persistent hydration timeout')
})
// ── transient failures must never destroy the pin ──────────────────────────
test('transient: profiles.list failure keeps the pin when the direct open works', async () => {
@@ -90,7 +90,7 @@ test('sessions workspace: opening a stored row uses profile-aware navigation and
const runtime = load({ profile: 'default' })
await runtime.__sessions.openProfileSession('ops', { id: 'stored-123', message_count: 4 }, 0)
assert.deepEqual(plain(runtime.calls), [
['openSession', 'stored-123', { profile: 'ops', awaitHydration: true, expectHistory: true, keepAllProfilesScope: false }]
['openSession', 'stored-123', { profile: 'ops', awaitHydration: true, expectHistory: true, keepAllProfilesScope: true }]
])
assert.equal(runtime.__sessions.$botSelectedSessions.get().ops, 'stored-123')
})
@@ -117,7 +117,7 @@ test('sessions workspace: an empty session with no preview does not demand histo
const runtime = load()
await runtime.__sessions.openProfileSession('ops', { id: 'stored-empty', message_count: 0 }, 0)
assert.deepEqual(plain(runtime.calls), [
['openSession', 'stored-empty', { profile: 'ops', awaitHydration: true, expectHistory: false, keepAllProfilesScope: false }]
['openSession', 'stored-empty', { profile: 'ops', awaitHydration: true, expectHistory: false, keepAllProfilesScope: true }]
])
})
+200 -65
View File
@@ -79,6 +79,8 @@ import {
import { runGatewayRestart } from '@/store/system-actions'
import type { UsageStats } from '@/types/hermes'
import { planPluginOpenSession } from './plugin-open-session-plan'
// -- state: readonly views over the app's live atoms -------------------------
const readonlyAtom = <T>(atomLike: ReadableAtom<T>): ReadableAtom<T> => atomLike
@@ -226,18 +228,26 @@ interface PluginOpenSessionOptions {
intent?: OpenSessionIntent
keepAllProfilesScope?: boolean
profile?: null | string
/** A cold profile backend can lose the hydration-timeout race once and still
* be fine on a second try. When set, a hydration timeout is retried
* internally before it reaches the caller or arms the core stranded-session
* overlay ($resumeExhaustedSessionId) — a caller-side retry can't do this
* itself because only this SDK layer sees $resumeExhaustedSessionId. */
retryHydrationTimeoutOnce?: boolean
}
function waitForFocusedSessionHydration({
expectHistory,
generation,
profile,
requireActiveProfile,
storedSessionId,
timeoutMs
}: {
expectHistory: boolean
generation: number
profile: string
requireActiveProfile: boolean
storedSessionId: string
timeoutMs: number
}): Promise<void> {
@@ -275,7 +285,7 @@ function waitForFocusedSessionHydration({
return
}
const profileMatches = normalizeProfileKey($activeGatewayProfile.get()) === profile
const profileMatches = !requireActiveProfile || normalizeProfileKey($activeGatewayProfile.get()) === profile
const sessionMatches = $selectedStoredSessionId.get() === storedSessionId
const runtimeReady = Boolean($activeSessionId.get())
const historyPainted = Boolean($messages.get().length)
@@ -312,6 +322,64 @@ function waitForFocusedSessionHydration({
})
}
// Wait for a profile switch, but never longer than the wake budget.
//
// ensureGatewayProfile awaits the store's dial, and HermesGateway.connect() has
// no dial timeout of its own: a backend that accepts the socket and then never
// completes the handshake leaves this promise pending for the life of the
// window. That is not merely a slow open. waitForFocusedSessionHydration arms
// the only timer on this path, and it is armed AFTER this await returns - so an
// unbounded activation means the wake never settles at all, and the pane wedges
// with no error, no Retry and no timeout (#89556: `ws accepted` in the gateway
// log with no matching `ws closed`).
//
// The activation gets its OWN budget rather than sharing the hydration one. A
// cold profile backend can legitimately spend most of the hydration budget
// painting a large transcript - that race is already tight enough to lose
// (#89617) - so charging activation to the same clock would turn a wedge into a
// regression. The trade is that a wake that is slow in BOTH phases can now take
// up to twice the budget before it surfaces; that is a maintainer call and is
// called out in the PR rather than buried here.
// The caller supplies the dial itself, because WHICH backend to open is a
// routing decision (a workspace switch moves chrome; a plain bot navigation
// only opens the gateway) while the deadline enforced here is the same either
// way.
async function awaitProfileActivation(
dial: () => Promise<void>,
targetProfile: string,
timeoutMs: number
): Promise<void> {
const activation = dial()
let timer: number | undefined
try {
await Promise.race([
activation,
new Promise<never>((_resolve, reject) => {
// Same message shape as the hydration timeout on purpose: openSession's
// catch keys the core stranded-session surface off this prefix, and a
// wedged dial wants exactly that surface. The phase is distinguished in
// the [bot-wake] support log, not in the user-facing string.
timer = window.setTimeout(
() => reject(new Error(`Timed out loading ${targetProfile}'s session history.`)),
timeoutMs
)
})
])
} finally {
if (timer !== undefined) {
window.clearTimeout(timer)
}
}
// No extra catch on the abandoned dial: an in-flight activation has no
// cancellation handle and keeps running after the budget expires, but
// Promise.race subscribes to every input, so a rejection that lands after the
// race has settled is already handled and cannot escape as an unhandled
// rejection. An explicit `activation.catch()` here was dead code - verified
// by mutation: removing it changed no test outcome.
}
export const host = {
state: {
/** Runtime id of the active chat session (null on a fresh draft). */
@@ -375,14 +443,6 @@ export const host = {
window.location.hash = path.startsWith('#') ? path : `#${path}`
},
/** Open a stored session the way core surfaces do (focus an existing
* tile/main, else load into main). When `profile` names a non-active
* profile, its backend is activated first so the resume routes to the
* right state.db — the same soft profile swap the unified sidebar does.
* `keepAllProfilesScope` (default true) keeps the Sessions sidebar in the
* unified all-profiles view instead of narrowing it to the target
* profile's sessions — a cross-profile open from a plugin surface is a
* navigation, not a scope choice; pass false to also scope the sidebar. */
/** Pre-dial a profile's gateway socket in the background — pool-only, no
* activation, no navigation, no scope change (openGatewayForProfile; it
* already no-ops for shared-remote routes and the primary). Roster UIs
@@ -499,80 +559,151 @@ export const host = {
ensureAgent: async (connectionId: null | string, profile: string): Promise<void> =>
ensureGatewayAgent(connectionId, (profile ?? '').trim() || 'default'),
/** Open a stored session — optionally pre-activating its profile first. */
/** Open a stored session the way core surfaces do. A plugin/Bot Mode open
* is navigation, not a workspace or chrome API-home switch —
* keepAllProfilesScope defaults true so `$activeGatewayProfile` /
* Sessions REST stay on the previous (usually launch) backend while the
* bot backend is dialed in the background. The bot forever-chat is hidden
* and would otherwise look like every session disappeared. Pass false to
* also scope chrome onto that profile and collapse the sidebar. */
openSession: async (storedSessionId: string, options: PluginOpenSessionOptions = {}): Promise<void> => {
const generation = ++openSessionGeneration
const profile = (options.profile ?? '').trim()
const targetProfile = normalizeProfileKey(profile || $activeGatewayProfile.get())
const expectHistory = options.expectHistory ?? false
const plan = planPluginOpenSession({
activeProfile: $activeGatewayProfile.get(),
keepAllProfilesScope: options.keepAllProfilesScope,
profile
})
// Wake-path phase timings. Logged ONLY on a hydration timeout (bridged
// into desktop.log via the renderer-console tap), so a support bundle
// pinpoints WHERE the budget went — profile activation vs hydration —
// instead of leaving us to infer it from process spawn timestamps.
const wakeStartedAt = Date.now()
let profileActiveAt = wakeStartedAt
const hydrationTimeoutMs = Math.max(1, options.hydrationTimeoutMs ?? DEFAULT_SESSION_HYDRATION_TIMEOUT_MS)
// Which half of the wake a timeout landed in. Only meaningful on the
// failure path, where the two phases have different remedies: a stuck dial
// is a gateway problem, a slow transcript is a backend-warmup one.
let wakePhase: 'activation' | 'hydration' = 'activation'
if (profile && profile !== $activeGatewayProfile.get()) {
await ensureGatewayProfile(profile)
profileActiveAt = Date.now()
if (options.keepAllProfilesScope !== false) {
setShowAllProfiles(true)
}
}
if (generation !== openSessionGeneration) {
throw new Error('Session open was superseded by a newer selection.')
}
if (options.awaitHydration) {
// Keep the target-specific overlay visible through transcript hydration,
// not merely through the gateway/profile activation that precedes it.
$gatewaySwapTarget.set(targetProfile)
}
// Bounded to 2 attempts (never more): a cold profile backend can lose the
// hydration-timeout race once and still be fine moments later, but this is
// a caller-opt-in retry of the SAME wait, not a backoff loop.
const maxAttempts = options.awaitHydration && options.retryHydrationTimeoutOnce ? 2 : 1
try {
openSession(
storedSessionId,
(to: string, opts?: { replace?: boolean }) => {
const target = to.startsWith('#') ? to : `#${to}`
// WHICH backend to dial is the plan's call; HOW LONG to wait is the wake
// budget's. A workspace switch moves $activeGatewayProfile / chrome REST;
// a plain navigation only opens the bot's gateway so session.resume can
// hydrate, leaving chrome on the launch backend.
const dial = plan.switchWorkspace
? () => ensureGatewayProfile(plan.switchWorkspace as string)
: plan.dialWithoutSwitching
? () => openGatewayForProfile(plan.dialWithoutSwitching as string)
: null
if (opts?.replace) {
window.location.replace(target)
} else {
window.location.hash = target
}
},
options.intent ?? 'in-place'
)
if (dial) {
// Bounded only on the hydration contract, which is where a budget and a
// Retry surface both already exist. A plain open never asked for a
// deadline and has nowhere to render one, so it keeps today's
// behaviour rather than gaining a rejection its callers cannot handle.
await (options.awaitHydration ? awaitProfileActivation(dial, targetProfile, hydrationTimeoutMs) : dial())
profileActiveAt = Date.now()
}
// Judge the main surface AFTER the open: on a cold start the persisted
// route can already point at this session while selection has not
// settled, so a pre-open "already selected" precondition skips the
// resume exactly when it is needed (#89206 — blank Bot Chat with the
// roster preview intact). The surface is healthy only when this stored
// session is selected, a runtime is bound, and the expected transcript
// is present; anything less gets an explicit sequenced resume request.
// The route-resume effect only honors the request while the route
// points at this session, and consumes it alongside any resume the
// navigation itself triggers, so a redundant request is a no-op.
const surfaceHealthy =
$selectedStoredSessionId.get() === storedSessionId &&
Boolean($activeSessionId.get()) &&
(!expectHistory || $messages.get().length > 0)
if (plan.showAllProfiles !== null) {
setShowAllProfiles(plan.showAllProfiles)
}
if (options.awaitHydration && !surfaceHealthy) {
requestSessionResume(storedSessionId)
wakePhase = 'hydration'
if (generation !== openSessionGeneration) {
throw new Error('Session open was superseded by a newer selection.')
}
if (options.awaitHydration) {
await waitForFocusedSessionHydration({
expectHistory,
generation,
profile: targetProfile,
storedSessionId,
timeoutMs: Math.max(1, options.hydrationTimeoutMs ?? DEFAULT_SESSION_HYDRATION_TIMEOUT_MS)
})
// Keep the target-specific overlay visible through transcript hydration,
// not merely through the gateway/profile activation that precedes it.
$gatewaySwapTarget.set(targetProfile)
}
// Only the HYDRATION half retries. Activation already failed its own
// bounded wait above, and a wedged dial does not get better by dialling
// again inside the same wake — that is the Retry surface's job.
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
openSession(
storedSessionId,
(to: string, opts?: { replace?: boolean }) => {
const target = to.startsWith('#') ? to : `#${to}`
if (opts?.replace) {
window.location.replace(target)
} else {
window.location.hash = target
}
},
options.intent ?? 'in-place'
)
// Judge the main surface AFTER the open: on a cold start the persisted
// route can already point at this session while selection has not
// settled, so a pre-open "already selected" precondition skips the
// resume exactly when it is needed (#89206 — blank Bot Chat with the
// roster preview intact). The surface is healthy only when this stored
// session is selected, a runtime is bound, and the expected transcript
// is present; anything less gets an explicit sequenced resume request.
// The route-resume effect only honors the request while the route
// points at this session, and consumes it alongside any resume the
// navigation itself triggers, so a redundant request is a no-op.
const surfaceHealthy =
$selectedStoredSessionId.get() === storedSessionId &&
Boolean($activeSessionId.get()) &&
(!expectHistory || $messages.get().length > 0)
if (options.awaitHydration && !surfaceHealthy) {
requestSessionResume(storedSessionId)
}
if (options.awaitHydration) {
await waitForFocusedSessionHydration({
expectHistory,
generation,
profile: targetProfile,
// A background dial never moves $activeGatewayProfile, so gating
// hydration on it would wait for something that is not coming.
requireActiveProfile: plan.requireActiveProfileForHydration,
storedSessionId,
timeoutMs: hydrationTimeoutMs
})
}
break
} catch (error) {
const retryable =
options.awaitHydration &&
generation === openSessionGeneration &&
attempt < maxAttempts &&
error instanceof Error &&
error.message.startsWith('Timed out loading ')
if (!retryable) {
throw error
}
// Logged per attempt so a support bundle shows the retry happened at
// all; the terminal failure is reported once by the catch below.
console.warn('[bot-wake] hydration timed out, retrying', {
attempt,
hydrationWaitMs: Date.now() - profileActiveAt,
profile: targetProfile,
storedSessionId
})
}
}
} catch (error) {
if (
@@ -581,10 +712,14 @@ export const host = {
error instanceof Error &&
error.message.startsWith('Timed out loading ')
) {
const timedOutAt = Date.now()
console.warn('[bot-wake] hydration timed out', {
hydrationWaitMs: Date.now() - profileActiveAt,
attempts: wakePhase === 'hydration' ? maxAttempts : 1,
hydrationWaitMs: wakePhase === 'hydration' ? timedOutAt - profileActiveAt : 0,
phase: wakePhase,
profile: targetProfile,
profileActivationMs: profileActiveAt - wakeStartedAt,
profileActivationMs: (wakePhase === 'activation' ? timedOutAt : profileActiveAt) - wakeStartedAt,
runtimeBound: Boolean($activeSessionId.get()),
selectionSettled: $selectedStoredSessionId.get() === storedSessionId,
storedSessionId,
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { planPluginOpenSession } from './plugin-open-session-plan'
describe('planPluginOpenSession', () => {
it('defaults to navigation: dial the worker, do not steal chrome', () => {
const plan = planPluginOpenSession({
activeProfile: 'default',
profile: 'worker'
})
expect(plan.switchWorkspace).toBeNull()
expect(plan.dialWithoutSwitching).toBe('worker')
expect(plan.showAllProfiles).toBe(true)
expect(plan.requireActiveProfileForHydration).toBe(false)
})
it('does not steal chrome even when keepAllProfilesScope is explicitly true', () => {
const plan = planPluginOpenSession({
activeProfile: 'default',
keepAllProfilesScope: true,
profile: 'worker'
})
expect(plan.switchWorkspace).toBeNull()
expect(plan.dialWithoutSwitching).toBe('worker')
expect(plan.showAllProfiles).toBe(true)
expect(plan.requireActiveProfileForHydration).toBe(false)
})
it('does not re-dial when chrome is already on that profile', () => {
const plan = planPluginOpenSession({
activeProfile: 'worker',
keepAllProfilesScope: true,
profile: 'worker'
})
expect(plan.switchWorkspace).toBeNull()
expect(plan.dialWithoutSwitching).toBeNull()
// Still restores the unified list: re-opening an already-live bot must not
// leave Sessions collapsed onto a profile whose forever-chat is hidden.
expect(plan.showAllProfiles).toBe(true)
})
it('treats keepAllProfilesScope:false as an explicit workspace switch', () => {
const plan = planPluginOpenSession({
activeProfile: 'default',
keepAllProfilesScope: false,
profile: 'worker'
})
expect(plan.switchWorkspace).toBe('worker')
expect(plan.dialWithoutSwitching).toBeNull()
expect(plan.showAllProfiles).toBe(false)
expect(plan.requireActiveProfileForHydration).toBe(true)
})
it('treats an empty profile as neither a dial nor a workspace switch', () => {
const plan = planPluginOpenSession({
activeProfile: 'default',
keepAllProfilesScope: true,
profile: ' '
})
expect(plan.switchWorkspace).toBeNull()
expect(plan.dialWithoutSwitching).toBeNull()
expect(plan.showAllProfiles).toBeNull()
})
})
@@ -0,0 +1,50 @@
/** How `host.openSession` should treat a named profile.
A plugin/Bot Mode open is navigation, not a workspace or chrome API-home
switch. `keepAllProfilesScope` defaults true (undefined counts as true).
Pass false for an explicit profile workspace switch. */
export interface PluginOpenSessionPlan {
/** Dial this profile's backend without making it chrome-home. */
dialWithoutSwitching: null | string
/** Hydration may wait until `$activeGatewayProfile` matches the target. */
requireActiveProfileForHydration: boolean
/** Unified Sessions list vs collapse onto the switched profile. Null = leave. */
showAllProfiles: boolean | null
/** Activate this profile as the workspace/API home. Null = do not steal chrome. */
switchWorkspace: null | string
}
export function planPluginOpenSession(input: {
activeProfile: string
keepAllProfilesScope?: boolean
profile: string
}): PluginOpenSessionPlan {
const profile = (input.profile ?? '').trim()
const activeProfile = (input.activeProfile ?? '').trim()
const keepWorkspace = input.keepAllProfilesScope !== false
if (!profile) {
return {
dialWithoutSwitching: null,
requireActiveProfileForHydration: false,
showAllProfiles: null,
switchWorkspace: null
}
}
if (keepWorkspace) {
return {
dialWithoutSwitching: profile !== activeProfile ? profile : null,
requireActiveProfileForHydration: false,
showAllProfiles: true,
switchWorkspace: null
}
}
return {
dialWithoutSwitching: null,
requireActiveProfileForHydration: true,
showAllProfiles: false,
switchWorkspace: profile
}
}
+307 -17
View File
@@ -102,10 +102,18 @@ vi.mock('@/store/gateway', async () => {
const { host } = await import('./index')
const { openSession: openSessionCore } = await import('@/app/open-session')
const { deleteProfile } = await import('@/hermes')
const { requestGatewayForAgent, requestGatewayForProfile, retireLocalProfileGateways } = await import('@/store/gateway')
const { $activeGatewayProfile, $gatewaySwapTarget, $profiles, ensureGatewayProfile, refreshProfiles } =
await import('@/store/profile')
const { openGatewayForProfile, requestGatewayForAgent, requestGatewayForProfile, retireLocalProfileGateways } =
await import('@/store/gateway')
const {
$activeGatewayProfile,
$gatewaySwapTarget,
$profiles,
ensureGatewayProfile,
refreshProfiles,
setShowAllProfiles
} = await import('@/store/profile')
const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } = await import('@/store/session-states')
@@ -290,9 +298,7 @@ describe('connection-aware plugin host APIs', () => {
describe('profile-aware plugin session opens', () => {
it('waits until the target Bot Chat runtime and history are on main before resolving', async () => {
vi.mocked(ensureGatewayProfile).mockImplementationOnce(async (target: null | string | undefined) => {
$activeGatewayProfile.set(target || 'default')
})
vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined)
let resolved = false
@@ -307,8 +313,12 @@ describe('profile-aware plugin session opens', () => {
resolved = true
})
await Promise.resolve()
await Promise.resolve()
// Flush a macrotask rather than counting microtask ticks: the profile
// activation is now a bounded race, so the number of awaits between the
// call and the core open is an implementation detail, and `resolved`
// staying false through a full turn of the event loop is the stronger
// assertion anyway.
await new Promise(resolve => setTimeout(resolve, 0))
expect(openSessionCore).toHaveBeenCalledWith('bot-chat', expect.any(Function), 'in-place')
expect(resolved).toBe(false)
@@ -339,9 +349,7 @@ describe('profile-aware plugin session opens', () => {
})
it('requests an explicit resume on a cold open where selection has not settled (#89206)', async () => {
vi.mocked(ensureGatewayProfile).mockImplementationOnce(async (target: null | string | undefined) => {
$activeGatewayProfile.set(target || 'default')
})
vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined)
// Cold-start shape from the field: the persisted route already points at
// the bot's stored session, but no selection, no runtime, no transcript.
@@ -356,8 +364,7 @@ describe('profile-aware plugin session opens', () => {
hydrationTimeoutMs: 1_000
})
await Promise.resolve()
await Promise.resolve()
await new Promise(resolve => setTimeout(resolve, 0))
// The old pre-open "already selected" precondition skipped this request,
// stranding the pane blank until timeout. It must fire now.
@@ -395,9 +402,7 @@ describe('profile-aware plugin session opens', () => {
})
it('resolves a history-bearing wake on transcript paint without waiting for the runtime (paint-first)', async () => {
vi.mocked(ensureGatewayProfile).mockImplementationOnce(async (target: null | string | undefined) => {
$activeGatewayProfile.set(target || 'default')
})
vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined)
setMockAtom($selectedStoredSessionId, null)
setMockAtom($activeSessionId, null)
@@ -468,6 +473,59 @@ describe('profile-aware plugin session opens', () => {
expect($gatewaySwapTarget.get()).toBeNull()
})
it('retries a Bot Chat hydration timeout once without ever arming the Retry surface (#89617)', async () => {
$activeGatewayProfile.set('hyoseob')
// First attempt: leave the surface unhealthy so the hydration wait below
// times out, exactly like a profile backend still waking up. Second
// attempt: the backend is warm now — hydrate immediately. Queued as two
// one-shots (not a standing mockImplementation) so this doesn't leak into
// later tests via the shared afterEach's vi.clearAllMocks(), which clears
// call history but not a standing implementation.
vi.mocked(openSessionCore)
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => {
setMockAtom($selectedStoredSessionId, 'waking-bot-chat')
setMockAtom($activeSessionId, 'runtime-waking')
setMockAtom($messages, [{ id: 'history-waking', parts: [], role: 'assistant' }] as never)
})
await host.openSession('waking-bot-chat', {
profile: 'hyoseob',
awaitHydration: true,
expectHistory: true,
hydrationTimeoutMs: 1,
retryHydrationTimeoutOnce: true
})
expect(openSessionCore).toHaveBeenCalledTimes(2)
// The overlay in apps/desktop/src/app/chat/index.tsx is gated purely on
// this atom equalling the routed session id — if it were ever set here,
// the user would land on "Couldn't load this session" even though the
// retry above succeeded underneath, since only a manual resumeSession()
// call clears it for the currently-routed session.
expect(setResumeExhaustedSessionId).not.toHaveBeenCalled()
expect($gatewaySwapTarget.get()).toBeNull()
})
it('still arms the Retry surface when a retried Bot Chat hydration times out twice', async () => {
$activeGatewayProfile.set('hyoseob')
await expect(
host.openSession('stranded-bot-chat', {
profile: 'hyoseob',
awaitHydration: true,
expectHistory: true,
hydrationTimeoutMs: 1,
retryHydrationTimeoutOnce: true
})
).rejects.toThrow(/timed out loading/i)
expect(openSessionCore).toHaveBeenCalledTimes(2)
expect(setResumeExhaustedSessionId).toHaveBeenCalledWith('stranded-bot-chat')
expect($gatewaySwapTarget.get()).toBeNull()
})
it('lets the latest rapid bot selection win and cancels the older hydration wait', async () => {
vi.mocked(ensureGatewayProfile).mockImplementation(async (target: null | string | undefined) => {
$activeGatewayProfile.set(target || 'default')
@@ -500,8 +558,240 @@ describe('profile-aware plugin session opens', () => {
await second
expect(await firstOutcome).toMatch(/superseded/i)
expect($activeGatewayProfile.get()).toBe('hyoseob')
expect($activeGatewayProfile.get()).toBe('remote-worker')
expect($selectedStoredSessionId.get()).toBe('chat-b')
expect($gatewaySwapTarget.get()).toBeNull()
})
it('keeps chrome API home on the previous profile when opening a Bot Chat', async () => {
$activeGatewayProfile.set('default')
await host.openSession('bot-chat', {
profile: 'worker',
keepAllProfilesScope: true
})
expect(ensureGatewayProfile).not.toHaveBeenCalled()
expect(openGatewayForProfile).toHaveBeenCalledWith('worker')
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
expect($activeGatewayProfile.get()).toBe('default')
})
it('defaults keepAllProfilesScope to navigation instead of a workspace switch', async () => {
$activeGatewayProfile.set('default')
await host.openSession('bot-chat', { profile: 'worker' })
expect(ensureGatewayProfile).not.toHaveBeenCalled()
expect(openGatewayForProfile).toHaveBeenCalledWith('worker')
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
expect($activeGatewayProfile.get()).toBe('default')
})
it('still switches workspace when keepAllProfilesScope is false', async () => {
$activeGatewayProfile.set('default')
vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined)
await host.openSession('stored-worker', {
profile: 'worker',
keepAllProfilesScope: false
})
expect(ensureGatewayProfile).toHaveBeenCalledWith('worker')
expect(openGatewayForProfile).not.toHaveBeenCalled()
expect(setShowAllProfiles).toHaveBeenCalledWith(false)
expect($activeGatewayProfile.get()).toBe('worker')
})
it('keeps the Sessions sidebar in all-profiles even when the bot is already live', async () => {
$activeGatewayProfile.set('hyoseob')
setMockAtom($selectedStoredSessionId, 'bot-chat')
setMockAtom($activeSessionId, 'runtime-live')
setMockAtom($messages, [{ id: 'history', parts: [], role: 'assistant' }] as never)
await host.openSession('bot-chat', {
profile: 'hyoseob',
awaitHydration: true,
expectHistory: true,
hydrationTimeoutMs: 1_000
})
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
})
it('surfaces a wedged profile activation instead of waiting on it forever (#89556)', async () => {
// The dial the store is waiting on never settles - a backend that accepts
// the socket and then never completes the handshake. Before this was
// bounded, openSession's await sat here for the life of the window and the
// hydration timer, which is armed downstream, never got a chance to fire:
// the pane wedged with no error and no Retry rather than timing out.
vi.mocked(ensureGatewayProfile).mockImplementationOnce(() => new Promise<void>(() => undefined))
setMockAtom($selectedStoredSessionId, 'wedged-chat')
setMockAtom($activeSessionId, 'runtime-wedged')
setMockAtom($messages, [{ id: 'history-1', parts: [], role: 'user' }] as never)
await expect(
host.openSession('wedged-chat', {
profile: 'medicina',
intent: 'main',
awaitHydration: true,
expectHistory: true,
keepAllProfilesScope: false,
hydrationTimeoutMs: 60
})
).rejects.toThrow("Timed out loading medicina's session history.")
// The stranded-session surface is the point: a bounded failure the user can
// retry, not a frozen pane.
expect(setResumeExhaustedSessionId).toHaveBeenCalledWith('wedged-chat')
expect($gatewaySwapTarget.get()).toBeNull()
})
it('names the phase in the wake log so a stuck dial is not read as a slow transcript', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
vi.mocked(ensureGatewayProfile).mockImplementationOnce(() => new Promise<void>(() => undefined))
await expect(
host.openSession('wedged-chat', {
profile: 'medicina',
intent: 'main',
awaitHydration: true,
expectHistory: true,
keepAllProfilesScope: false,
hydrationTimeoutMs: 60
})
).rejects.toThrow('Timed out loading ')
expect(warn).toHaveBeenCalledWith(
'[bot-wake] hydration timed out',
expect.objectContaining({ phase: 'activation' })
)
const payload = warn.mock.calls.at(-1)?.[1] as { hydrationWaitMs: number; profileActivationMs: number }
// The whole budget went to activation. Reporting it as hydration wait time
// would send a support bundle reader looking at transcript size.
expect(payload.profileActivationMs).toBeGreaterThan(0)
expect(payload.hydrationWaitMs).toBe(0)
warn.mockRestore()
})
it('gives hydration its own full budget after a slow but successful activation', async () => {
// Guards the choice of a SEPARATE budget per phase over one shared clock.
// A cold profile backend can legitimately spend most of the budget getting
// its socket up; if hydration then inherited what was left, this wake would
// fail even though the transcript painted well inside the documented
// 20s-equivalent window.
vi.mocked(ensureGatewayProfile).mockImplementationOnce(
async (target: null | string | undefined) =>
new Promise<void>(resolve => {
setTimeout(() => {
$activeGatewayProfile.set(target || 'default')
resolve()
}, 150)
})
)
const opening = host.openSession('slow-dial-chat', {
profile: 'medicina',
intent: 'main',
awaitHydration: true,
expectHistory: true,
keepAllProfilesScope: false,
hydrationTimeoutMs: 200
})
// 300ms total is past a single shared 200ms budget and inside hydration's
// own one, which only starts once the profile is actually active.
await new Promise(resolve => setTimeout(resolve, 300))
setMockAtom($selectedStoredSessionId, 'slow-dial-chat')
setMockAtom($activeSessionId, 'runtime-medicina')
setMockAtom($messages, [{ id: 'history-1', parts: [], role: 'user' }] as never)
await expect(opening).resolves.toBeUndefined()
expect(setResumeExhaustedSessionId).not.toHaveBeenCalled()
})
it('absorbs an activation that rejects after its budget has already expired', async () => {
// The abandoned race loser keeps running - there is no cancellation handle
// for an in-flight dial - so a late rejection must not escape as an
// unhandled promise rejection long after the caller gave up.
// Node's process-level hook, not window's: under jsdom a rejection that
// escapes lands on the runner's process, which is where vitest turns it
// into an "Unhandled Rejection" run failure.
const unhandled: unknown[] = []
const onUnhandled = (reason: unknown) => {
unhandled.push(reason)
}
const existing = process.listeners('unhandledRejection')
for (const listener of existing) {
process.off('unhandledRejection', listener)
}
process.on('unhandledRejection', onUnhandled)
vi.mocked(ensureGatewayProfile).mockImplementationOnce(
() =>
new Promise<void>((_resolve, reject) => {
setTimeout(() => reject(new Error('dial failed after the caller gave up')), 120)
})
)
await expect(
host.openSession('late-failure-chat', {
profile: 'medicina',
intent: 'main',
awaitHydration: true,
expectHistory: true,
keepAllProfilesScope: false,
hydrationTimeoutMs: 40
})
).rejects.toThrow('Timed out loading ')
await new Promise(resolve => setTimeout(resolve, 200))
process.off('unhandledRejection', onUnhandled)
for (const listener of existing) {
process.on('unhandledRejection', listener)
}
expect(unhandled).toEqual([])
})
it('leaves a plain open unbounded, because it has no budget and no Retry surface', async () => {
// Deliberate scope line, not an oversight: the activation deadline rides on
// the same contract as the hydration one. A caller that never passed
// awaitHydration gets exactly the behaviour it had before, since a
// rejection here would surface to code with nowhere to render it.
vi.mocked(ensureGatewayProfile).mockImplementationOnce(() => new Promise<void>(() => undefined))
let settled = 'pending'
void host
.openSession('plain-chat', {
profile: 'medicina',
intent: 'main',
keepAllProfilesScope: false,
hydrationTimeoutMs: 40
})
.then(
() => {
settled = 'resolved'
},
() => {
settled = 'rejected'
}
)
await new Promise(resolve => setTimeout(resolve, 200))
expect(settled).toBe('pending')
expect(setResumeExhaustedSessionId).not.toHaveBeenCalled()
})
})
+9 -2
View File
@@ -573,7 +573,9 @@ async function gatewayForProfile(
export async function requestGatewayForProfile<T>(
profile: string,
method: string,
params: Record<string, unknown> = {}
params: Record<string, unknown> = {},
timeoutMs?: number,
signal?: AbortSignal
): Promise<T> {
const route = await gatewayForProfile(profile, true)
@@ -584,7 +586,12 @@ export async function requestGatewayForProfile<T>(
const routedParams = route.scopeProfile ? { ...params, profile: route.key } : params
return await route.gateway.request<T>(method, routedParams)
// Same arity contract as the ambient path in session-request-router: only
// pass the deadline args through when the caller set them, so a plain
// profile-routed RPC keeps its two-argument call shape.
return await (timeoutMs === undefined && signal === undefined
? route.gateway.request<T>(method, routedParams)
: route.gateway.request<T>(method, routedParams, timeoutMs, signal))
} finally {
route.release()
}
@@ -167,6 +167,38 @@ describe('requestForSessionProfile', () => {
expect(secondaryGateways[0].request).toHaveBeenCalledWith('session.resume', { session_id: 'stored-loki-chat' })
})
it('forwards timeout and abort signal onto the owning profile socket', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop()
await ensureGatewayForProfile('default')
const ambient = vi.fn(async (method: string, params?: Record<string, unknown>) => ({
ambient: true,
method,
params
}))
const controller = new AbortController()
await requestForSessionProfile(
'loki',
ambient as never,
'prompt.submit',
{ session_id: 'stored-loki-chat', text: 'hi' },
1_800_000,
controller.signal
)
expect(ambient).not.toHaveBeenCalled()
expect(secondaryGateways[0].request).toHaveBeenCalledWith(
'prompt.submit',
{ session_id: 'stored-loki-chat', text: 'hi' },
1_800_000,
controller.signal
)
})
it('keeps the ambient dispatcher when the active route already serves the owner', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
@@ -40,13 +40,28 @@ export function sessionRpcNeedsProfileRoute(
*/
export function requestForSessionProfile<T>(
ownerProfile: null | string | undefined,
ambientRequest: <R>(method: string, params?: Record<string, unknown>) => Promise<R>,
ambientRequest: <R>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
signal?: AbortSignal
) => Promise<R>,
method: string,
params: Record<string, unknown> = {}
params: Record<string, unknown> = {},
timeoutMs?: number,
signal?: AbortSignal
): Promise<T> {
if (!sessionRpcNeedsProfileRoute(ownerProfile)) {
return ambientRequest<T>(method, params)
// Forward the extra args only when the caller actually supplied them. The
// ambient dispatcher is a plain gateway request whose arity callers assert
// on; handing it a trailing `undefined, undefined` on every session RPC
// changes the observed call shape for the many callers that never asked
// for a deadline (the plugin host bridge in contrib/wiring is the only one
// that does).
return timeoutMs === undefined && signal === undefined
? ambientRequest<T>(method, params)
: ambientRequest<T>(method, params, timeoutMs, signal)
}
return requestGatewayForProfile<T>(normKey(ownerProfile), method, params)
return requestGatewayForProfile<T>(normKey(ownerProfile), method, params, timeoutMs, signal)
}
@@ -400,6 +400,28 @@ describe('glass is confined to chat windows', () => {
// The mode is still the user's choice — only the page rewrite is withheld.
expect($translucency.get().mode).toBe('glass')
expect(document.documentElement.hasAttribute('data-hermes-glass')).toBe(false)
})
// The HUD paints its band from the app's field mix, so it needs the setting
// and the tint number even though its surfaces must not be rewritten. The
// two flags are what keep those separable: keying the band off
// `data-hermes-glass` would silently never match.
it('still publishes the live setting and the tint to a special-purpose window', () => {
setSearch('?win=hud')
setTranslucency(60)
setTranslucencyMode('glass')
expect(document.documentElement.hasAttribute('data-hermes-glass-on')).toBe(true)
expect(document.documentElement.style.getPropertyValue('--translucency-glass-keep')).toBe('40%')
})
it('withdraws both flags when glass is switched off', () => {
setSearch('?win=hud')
setTranslucency(60)
setTranslucencyMode('glass')
setTranslucencyMode('clear')
expect(document.documentElement.hasAttribute('data-hermes-glass-on')).toBe(false)
expect(document.documentElement.style.getPropertyValue('--translucency-glass-keep')).toBe('')
})
+18 -4
View File
@@ -262,24 +262,38 @@ const applyGlassSurfaces = ({ intensity, mode, scope }: TranslucencyState): void
}
const root = document.documentElement
const glassOn = mode === 'glass' && intensity > 0 && GLASS_SUPPORTED && isChatWindow()
// Is the user's Glass setting live at all — the same answer in every window.
const glassLive = mode === 'glass' && intensity > 0 && GLASS_SUPPORTED
// ...and may THIS window's field surfaces be rewritten for it. Only real
// chat windows: the HUD, pet overlay, quick entry and wake indicator are
// transparent windows that own their backgrounds, and the surface rewrite
// would fight them. The HUD still wants the first answer, because its band
// paints the app's field mix from `--translucency-glass-keep` and its native
// frost is gated on the setting being on (see the `[data-hud-glass]` rules
// and hudFrostFor) — which is why these are two flags and not one.
const glassOn = glassLive && isChatWindow()
// Clear mode fades the whole window uniformly, so overlay text and the
// covered transcript blend; styles.css strengthens the overlay scrim while
// this attribute is present. Native opacity applies in every window kind, so
// no chat-window gate.
const clearOn = mode === 'clear' && intensity > 0
root.toggleAttribute('data-hermes-glass-on', glassLive)
root.toggleAttribute('data-hermes-glass', glassOn)
root.toggleAttribute('data-hermes-clear', clearOn)
if (glassOn) {
root.setAttribute('data-hermes-glass-scope', scope)
if (glassLive) {
root.style.setProperty('--translucency-glass-keep', `${glassSurfaceKeep(intensity)}%`)
} else {
root.removeAttribute('data-hermes-glass-scope')
root.style.removeProperty('--translucency-glass-keep')
}
if (glassOn) {
root.setAttribute('data-hermes-glass-scope', scope)
} else {
root.removeAttribute('data-hermes-glass-scope')
}
if (glassOn && scope === 'sidebar') {
startRailTracking()
} else {
+74 -152
View File
@@ -2503,12 +2503,6 @@ button[data-slot='aui_msg-reactions'] svg {
/* The band is narrower than the bar, centred under it, so the bar's corner
controls sit clear of the sheet's edge instead of on top of it. */
--hud-band-inset: 0.5rem;
/* Clear space above the composer for the exit chip. */
--hud-chip-strip: 1.625rem;
/* How long the exit chip stays after you stop pointing at the HUD. Long
enough to cross the gap between the bar and the chip without it
evaporating, short enough that it isn't furniture. */
--hud-exit-hold: 600ms;
}
/* Chat surface carries nothing in HUD mode — the visual is the BAND below. */
@@ -2579,19 +2573,32 @@ button[data-slot='aui_msg-reactions'] svg {
scroll or a stray click while you're aiming at the app behind it. Focus the
composer and it becomes a real scrollable surface. This also means hover
alone can't reveal the band anymore — hover is not engagement. */
/* The band's sheet, behind the transcript: a tint wearing the same gradient
mask as the text, so the whole surface ramps out together and the band has no
top edge at all.
No desktop blur under it, and that is a limit rather than an omission. CSS
backdrop-filter reaches nothing here — a transparent window's backdrop root
is the document, and the desktop was never in it (verified on the real
window, not assumed). macOS vibrancy does see the desktop, but WindowServer
composites it below the web contents after this process has finished drawing,
so no mask, clip or stacking order can shape it; left on under a fading tint
it stays a flat slab and the top of the band goes pale exactly where it
should be disappearing. The way through is NSVisualEffectView.maskImage
behind a small native addon, which is its own change. */
/* The band's sheet, behind the transcript: a tint the whole surface ramps out
with, so the band has no top edge at all.
CSS backdrop-filter reaches nothing here — a transparent window's backdrop
root is the document, and the desktop was never in it (verified on the real
window, not assumed). The blur is a native platform material instead, and
because it is composited below the web contents after this process has
finished drawing, no mask, clip or stacking order can shape it: it is the
whole window rectangle or nothing. That is why it is switched on only while
the band covers the window (useHudGlass) and why the sheet must be a flat
panel — under a fading GRADIENT the frost stayed a slab and the top of the
band went pale exactly where it should have been disappearing. Shaping it
would need NSVisualEffectView.maskImage behind a native addon.
Under Glass the sheet wears the SAME paint the docked thread does:
`--ui-bg-chrome` kept at `--translucency-glass-keep`, the one number
store/translucency publishes from the Tint slider (see the
`[data-hermes-glass]` block). One painter, one token, one lever — the band
reads as the app's thread surface rather than as a HUD-only lookalike that
drifts the first time either side is touched.
With Glass off there is no material behind the window, so the sheet keeps
its own card tint. Heavier than the text in front of it, deliberately: with
no blur to separate the band from what it lies over, the sheet is the only
thing keeping half-opacity text off someone else's UI. Fade the words, keep
the paper. */
[data-hud-shell] [data-hud-glass] {
position: absolute;
right: var(--hud-band-inset);
@@ -2600,10 +2607,6 @@ button[data-slot='aui_msg-reactions'] svg {
z-index: 0;
pointer-events: none;
border-radius: 0.75rem 0.75rem 0 0;
/* Heavier than the text in front of it, deliberately: with no blur to
separate the band from what it lies over, the sheet is the only thing
keeping half-opacity text off someone else's UI. Fade the words, keep the
paper. */
background: color-mix(in srgb, var(--dt-card) 80%, transparent);
/* Slides down behind the bar as it fades. A TRANSFORM, not height: it is
composited, so it stays smooth where animating height re-lays-out the panel
@@ -2637,6 +2640,43 @@ button[data-slot='aui_msg-reactions'] svg {
transition-duration: var(--hud-reveal);
}
/* ── Under Glass ─────────────────────────────────────────────────────────────
The band becomes the app's thread surface: the docked window's field mix,
over the platform material.
Keyed to `data-hermes-glass-on` — "the user's Glass setting is live" — and
NOT to `data-hermes-glass`, which additionally means "this window's field
surfaces may be rewritten" and is false here by design: the HUD is a
transparent window that owns its own backgrounds (isChatWindow). Two flags,
because the HUD wants the setting without the rewrite. */
:root[data-hermes-glass-on] [data-hud-shell] [data-hud-glass] {
background: color-mix(in srgb, var(--ui-bg-chrome) var(--translucency-glass-keep, 100%), transparent);
}
/* The band spans the window edge to edge under glass. The 8px side inset keeps
the sheet clear of the bar's corner controls when it is an opaque panel —
but the frost is the WINDOW, so an inset sheet leaves a hairline of bare
untinted material down both sides. Nothing to hold off the material's edge:
the window's own rounded corners are where the band ends. */
:root[data-hermes-glass-on] [data-hud-shell] {
--hud-band-inset: 0px;
}
:root[data-hermes-glass-on] [data-hud-shell] [data-hud-glass] {
border-radius: 0.75rem 0.75rem 0 0;
}
/* Engaged, the field steps up the same way the docked thread's does — but
never below the resting tint, so a high lever cannot make focusing the
composer paint MORE transparent than glancing at it. */
:root[data-hermes-glass-on] [data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] {
background: color-mix(
in srgb,
var(--ui-bg-chrome) min(100%, calc(var(--translucency-glass-keep, 100%) + 12%)),
transparent
);
}
/* Engaged means the caret is in the composer, not merely that the window holds
focus somewhere. Activating a window restores focus to whatever had it last,
so `:focus-within` counted grabbing the bar to DRAG the HUD as sitting down
@@ -2803,17 +2843,10 @@ button[data-slot='aui_msg-reactions'] svg {
Parked in the top half of the screen (data-hud-edge='top', broadcast by
main on move/resize), the whole HUD mirrors vertically: composer hugs the
window's top edge, the band hangs BELOW it, and text melts at the bottom.
Same surfaces, same variables — only the anchors swap.
The bar would sit flush against the window's top edge, leaving no "above the
composer" to put anything in — which is where the exit chip belongs. Reserve
the strip as dock padding rather than moving the bar: --hud-bar-height is
measured from the dock's box, so the band's offset picks the gap up on its
own instead of needing a second variable kept in sync. */
Same surfaces, same variables — only the anchors swap. */
[data-hud-shell][data-hud-edge='top'] [data-slot='composer-dock'] {
top: 0 !important;
bottom: auto !important;
padding-top: var(--hud-chip-strip) !important;
}
/* Flipped, the band hangs from the bar instead of standing on it. */
@@ -3028,128 +3061,17 @@ button[data-slot='aui_msg-reactions'] svg {
}
/* The exit button. HUD mode has no titlebar, so this is the only visible way
back; it rides the bar's outer edge at the right.
back — it rides the composer's controls row (see ExitHudButton in
composer/controls.tsx) and therefore needs no placement, no reveal, and no
substrate of its own here: it is one of the bar's buttons and wears what
they wear.
It wears the BAR'S material, not the desktop's. Every shipped control that
floats over content the app does not own resolves this the same way — give
the control its own substrate and let the glyph read against that, never
against the backdrop:
- Apple HIG, Materials: "Materials help visually separate foreground
elements, such as text and controls, from background elements." Controls
sit ON a material, never directly on content; even `clear` Liquid Glass
is specced with a 35%-opacity dimming layer behind it over bright
content.
https://developer.apple.com/design/human-interface-guidelines/materials
- Firefox picture-in-picture, the closest analogue we have (a small
always-on-top window over arbitrary content): the close/unpip buttons
are `background-color: rgba(255,255,255,.8)` with a `#000` glyph — an
opaque chip, not a bare icon.
toolkit/themes/shared/pictureinpicture/player.css
- Discord's overlay redesign: "This layer provided contrast against the
game making the UI easier to see."
https://discord.com/blog/redesigning-the-discord-overlay
Anything that instead tries to derive contrast from the backdrop is a dead
end here. mix-blend-difference composites against the PAGE behind the
element, and behind a transparent Electron window that is nothing — the
desktop is composited by WindowServer after Chromium has finished drawing
the frame (the same reason backdrop-filter can't frost the HUD). A glyph
halo has the same flaw from the other side: it guesses one backdrop
luminance and is grime over everything else.
So: the composer bar's exact tokens — same fill, same hairline, same bottom
shadow. The bar is the HUD's solved case for "our surface, over an unknown
desktop, in either appearance", and theme plus OS light/dark (mode 'system')
come free with it, because --dt-card and --ui-text-primary are the pair the
whole app is built on. The chip inverts with the appearance instead of
betting on one.
Placed from --hud-bar-height rather than CSS anchor(). Lightning CSS drops a
whole rule containing an `anchor()` on the vertical axis, so the flipped-edge
override never reached the browser and the chip rendered off screen. */
[data-hud-shell] [data-hud-exit] {
left: auto;
top: auto;
right: 0.375rem;
bottom: calc(var(--hud-bar-height, var(--composer-fallback-height)) + 0.375rem);
/* Square. `size="icon-titlebar"` sizes width and height from two DIFFERENT
vars (--titlebar-control-size / --titlebar-control-height, 20x22 today),
which is right in a titlebar row and reads as a dent on a lone floating
chip — so height comes off the width instead. */
aspect-ratio: 1 / 1;
height: auto;
background: var(--dt-card) !important;
border: 1px solid var(--ui-stroke-secondary) !important;
border-radius: 0.5rem;
box-shadow: 0 2px 2px -1px rgb(0 0 0 / 0.12);
color: var(--ui-text-primary) !important;
/* Gone until you reach for the HUD. A permanent chip is a fragment of Hermes
parked on top of whatever you are really working in; reaching for the bar
is the motion that means "I want the app", so that is what brings the way
out with it. Hover, not focus — the gate #81893 warned about made the
escape hatch depend on the caret landing in the composer, broken exactly
when you most want out, whereas pointing at the bar needs nothing to be
working.
`visibility`, not opacity alone: an always-on-top window eats clicks
wherever the page hands the hit test something real, so a 0-opacity chip
with `pointer-events: auto` would be an invisible button in the corner
that drops you out of HUD mode when you click the app behind it.
`visibility: hidden` takes it out of `elementFromPoint` as well as off the
screen, and it transitions discretely, so it flips in step with the fade
rather than needing a second mechanism. */
opacity: 0;
visibility: hidden;
/* Leaving holds first. The chip sits 0.375rem clear of the bar, so the
cursor crosses shell dead space on its way up from the bar to the chip and
both hover triggers are false for that moment — without the hold it would
fade out from under a hand that is on its way to press it. */
transition:
opacity var(--hud-fade) var(--hud-ease-exit) var(--hud-exit-hold),
visibility 0s linear calc(var(--hud-exit-hold) + var(--hud-fade)),
background-color var(--hud-reveal) var(--hud-ease-enter);
pointer-events: auto;
}
/* Flipped, "above the composer" is the reserved strip at the top of the
window rather than a gap the band has to leave. */
[data-hud-shell][data-hud-edge='top'] [data-hud-exit] {
bottom: auto;
top: 0;
}
/* What counts as reaching for it: the bar, the transcript band, or the chip
itself. The band only answers `:hover` while it is on screen (faded out it
is `pointer-events: none`), so this can't reveal the chip over a HUD that
isn't there. The chip's own hover is in the set so arriving re-asserts the
reveal that the hold above was covering for.
Hovering the shell at large is deliberately NOT a trigger — most of the
HUD's rectangle is empty window over someone else's app, and it is
`pointer-events: none` precisely so the cursor passing through means
nothing. */
[data-hud-shell]:has([data-slot='composer-dock']:hover) [data-hud-exit],
[data-hud-shell]:has([data-slot='composer-bounds']:hover) [data-hud-exit],
[data-hud-shell] [data-hud-exit]:hover,
[data-hud-shell] [data-hud-exit]:focus-visible {
opacity: 1;
visibility: visible;
transition-duration: var(--hud-reveal), 0s, var(--hud-reveal);
transition-delay: 0s;
}
/* Hover and focus-visible: the app's own control-hover tint, so pointing at it
confirms it is a button with the same feedback every other icon button in
Hermes gives. Layered OVER the card rather than replacing it —
--ui-control-hover-background is a translucent color-mix meant to sit on an
opaque titlebar, and here the button IS the opaque surface, so assigning it
directly would make the chip see-through on hover. */
[data-hud-shell] [data-hud-exit]:hover,
[data-hud-shell] [data-hud-exit]:focus-visible {
background:
linear-gradient(var(--ui-control-hover-background), var(--ui-control-hover-background)), var(--dt-card) !important;
}
It used to float above the bar in a reserved 26px strip, hidden until you
hovered. That strip was transparent window, which the glass band then
rendered as a slab of bare untinted material across the top of the HUD, and
the chip needed its own opaque card to be legible over an unknown desktop.
Both problems were the placement, not the button: on the bar it is already
on our surface. */
/* The corner resize handle — a hot corner, not a button. The window is created
non-resizable (the transparent-frameless Windows drag-growth bug), so this
+27
View File
@@ -274,6 +274,33 @@ export function glassMaterialsFor(isWindows: boolean): readonly GlassMaterial[]
return isWindows ? WINDOWS_GLASS_MATERIALS : GLASS_MATERIALS
}
/**
* The native frost a HUD-style transparent window should carry.
*
* Two gates, because the HUD's frost answers to more than the setting. The
* material is the WINDOW's — nothing on the page can clip it — so it is only
* ever right while the band actually covers the window below the bar;
* `showing` is the renderer's answer to that (see `useHudGlass`). The setting
* is the other half: Glass off, or the tint at zero, means no frost at all.
*
* The off answer is `null` rather than a resting material, which is the one
* way this differs from `vibrancyFor`. A chat window is opaque and keeps
* 'sidebar' under its titlebar band whatever the setting says; a transparent
* window has no opaque page to hide an unwanted material behind, so off has
* to mean off or the frost is a grey slab hanging over someone else's app.
*/
export function hudFrostFor(
state: TranslucencyState,
showing: boolean
): { backgroundMaterial: WindowsBackgroundMaterial; vibrancy: GlassMaterial | null } {
const active = showing && glassActive(state)
return {
vibrancy: active ? state.material : null,
backgroundMaterial: active ? backgroundMaterialFor(state) : 'none'
}
}
/**
* The rung the picker highlights. A frost with no rung of its own here — a
* Mac's 'header' read on Windows — folds onto the rung that renders the same