diff --git a/apps/desktop/electron/hud-ipc.ts b/apps/desktop/electron/hud-ipc.ts
index 8a8b7cf000..3eff9c1488 100644
--- a/apps/desktop/electron/hud-ipc.ts
+++ b/apps/desktop/electron/hud-ipc.ts
@@ -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 }
}
diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts
index 09170cc630..af28a90bc8 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -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)
diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts
index 2d1ba597ce..4b745b1353 100644
--- a/apps/desktop/electron/preload.ts
+++ b/apps/desktop/electron/preload.ts
@@ -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),
diff --git a/apps/desktop/electron/translucency.test.ts b/apps/desktop/electron/translucency.test.ts
index 6795fdd5d7..351fc72576 100644
--- a/apps/desktop/electron/translucency.test.ts
+++ b/apps/desktop/electron/translucency.test.ts
@@ -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)
diff --git a/apps/desktop/electron/translucency.ts b/apps/desktop/electron/translucency.ts
index 6e524a8d8f..72f936e9cc 100644
--- a/apps/desktop/electron/translucency.ts
+++ b/apps/desktop/electron/translucency.ts
@@ -26,6 +26,7 @@ export {
glassMaterialsFor,
glassSupportedOn,
glassSurfaceKeep,
+ hudFrostFor,
normalizeMaterial,
normalizeMode,
normalizeScope,
diff --git a/apps/desktop/src/app/chat/composer/control-classes.ts b/apps/desktop/src/app/chat/composer/control-classes.ts
new file mode 100644
index 0000000000..82285928c7
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/control-classes.ts
@@ -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'
diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx
index cceb970b9f..7e90654477 100644
--- a/apps/desktop/src/app/chat/composer/controls.test.tsx
+++ b/apps/desktop/src/app/chat/composer/controls.test.tsx
@@ -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', () => {
diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx
index 286493d006..1682f8d9c2 100644
--- a/apps/desktop/src/app/chat/composer/controls.tsx
+++ b/apps/desktop/src/app/chat/composer/controls.tsx
@@ -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
@@ -84,9 +77,27 @@ export function ComposerControls({
return (
-
-
-
+ {/* 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 ? (
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
{showQueueButton ? (
}>
)}
+ {/* 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 ? : null}
)
}
+function ExitHudButton() {
+ const { t } = useI18n()
+
+ return (
+
+
+
+
+
+ )
+}
+
function ConversationPill({
disabled,
level,
@@ -269,11 +307,7 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa
{
triggerHaptic(active ? 'close' : 'open')
@@ -318,11 +352,7 @@ function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolea
{
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}
diff --git a/apps/desktop/src/app/chat/composer/voice-menu.tsx b/apps/desktop/src/app/chat/composer/voice-menu.tsx
new file mode 100644
index 0000000000..b5e371bef5
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/voice-menu.tsx
@@ -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 (
+
+
+
+
+ {voiceStatus === 'recording' ? (
+
+ ) : voiceStatus === 'transcribing' ? (
+
+ ) : wakeListening ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {
+ triggerHaptic('open')
+ onStartConversation()
+ }}
+ >
+
+ {c.startVoice}
+
+
+ {/* 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. */}
+ {
+ // 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}
+
+ {
+ event.preventDefault()
+ triggerHaptic(autoSpeak ? 'close' : 'open')
+ onToggleAutoSpeak()
+ }}
+ >
+ {autoSpeak ? : }
+ {autoSpeak ? c.stopSpeakingReplies : c.speakReplies}
+
+ {
+ event.preventDefault()
+ triggerHaptic(wakeListening ? 'close' : 'open')
+ void toggleWakeWord()
+ }}
+ >
+ {wakeListening ? : }
+ {wakeLabel}
+
+
+
+ )
+}
diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx
index 3aa1d34f45..f996529eeb 100644
--- a/apps/desktop/src/app/contrib/wiring.tsx
+++ b/apps/desktop/src/app/contrib/wiring.tsx
@@ -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(
+ (method: string, params?: Record, timeoutMs?: number, signal?: AbortSignal) => {
+ const owner = rememberedSessionProfile(
+ $sessions.get(),
+ selectedStoredSessionIdRef.current,
+ $activeGatewayProfile.get()
+ )
+
+ return requestForSessionProfile(owner, ambientRequestGateway, method, params ?? {}, timeoutMs, signal)
+ },
+ [ambientRequestGateway]
+ )
const { loadMoreMessagingForPlatform, loadMoreSessions, refreshCronJobs, refreshMessagingSessions, refreshSessions } =
useSessionListActions({ profileScope })
diff --git a/apps/desktop/src/app/hud/glass.ts b/apps/desktop/src/app/hud/glass.ts
index 572f68b58f..2a917e4433 100644
--- a/apps/desktop/src/app/hud/glass.ts
+++ b/apps/desktop/src/app/hud/glass.ts
@@ -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, 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)
}
diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx
index 9b5d175503..0053d2cb9b 100644
--- a/apps/desktop/src/app/hud/hud-shell.tsx
+++ b/apps/desktop/src/app/hud/hud-shell.tsx
@@ -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() {
- {/* The way back — without it the only exits are ⌘⇧H and ⌘W, both
- invisible. Placed and revealed entirely from styles.css. */}
-
-
-
-
-
-
{/* 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`
diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts
index 572c7e1f62..96355eee62 100644
--- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts
@@ -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)
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index 1bd046c80d..4dcefedaf7 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -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
diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts
index d848009254..5ed5db5686 100644
--- a/apps/desktop/src/i18n/ar.ts
+++ b/apps/desktop/src/i18n/ar.ts
@@ -1765,6 +1765,7 @@ export const ar = defineLocale({
endShort: 'إنهاء',
stopDictation: 'إيقاف الإملاء',
transcribingDictation: 'جار تفريغ الإملاء',
+ voiceControls: 'صوت',
voiceDictation: 'إملاء صوتي',
lookupLoading: 'جار البحث...',
lookupNoMatches: 'لا توجد نتائج',
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index f4217322e0..5e94e4c4f0 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -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',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 550f16062d..7864578edc 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1954,6 +1954,7 @@ export const ja = defineLocale({
endShort: '終了',
stopDictation: '口述を停止',
transcribingDictation: '口述を文字起こし中',
+ voiceControls: '音声',
voiceDictation: '音声口述',
speakReplies: '返信を読み上げる',
stopSpeakingReplies: '返信の読み上げを停止',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 47210ce65b..afd3fda150 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1913,6 +1913,7 @@ export interface Translations {
endShort: string
stopDictation: string
transcribingDictation: string
+ voiceControls: string
voiceDictation: string
speakReplies: string
stopSpeakingReplies: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 51ac095878..23a3232625 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -1892,6 +1892,7 @@ export const zhHant = defineLocale({
endShort: '結束',
stopDictation: '停止聽寫',
transcribingDictation: '正在轉寫聽寫',
+ voiceControls: '語音',
voiceDictation: '語音聽寫',
speakReplies: '朗讀回覆',
stopSpeakingReplies: '停止朗讀回覆',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 46708723c5..613679cd72 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -2439,6 +2439,7 @@ export const zh: Translations = {
endShort: '结束',
stopDictation: '停止听写',
transcribingDictation: '正在转写听写',
+ voiceControls: '语音',
voiceDictation: '语音听写',
speakReplies: '朗读回复',
stopSpeakingReplies: '停止朗读回复',
diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js
index df073db64b..9585d291a5 100644
--- a/apps/desktop/src/plugins/hermes-bots/plugin.js
+++ b/apps/desktop/src/plugins/hermes-bots/plugin.js
@@ -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 })
}
diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs
index 7fd8e34bb6..2c060568fd 100644
--- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs
+++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs
@@ -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 () => {
diff --git a/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs
index e209eebc15..238d863ed1 100644
--- a/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs
+++ b/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs
@@ -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 }]
])
})
diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts
index 88019d6ef7..3bf5ab0079 100644
--- a/apps/desktop/src/sdk/index.ts
+++ b/apps/desktop/src/sdk/index.ts
@@ -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 = (atomLike: ReadableAtom): ReadableAtom => 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 {
@@ -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,
+ targetProfile: string,
+ timeoutMs: number
+): Promise {
+ const activation = dial()
+ let timer: number | undefined
+
+ try {
+ await Promise.race([
+ activation,
+ new Promise((_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 =>
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 => {
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,
diff --git a/apps/desktop/src/sdk/plugin-open-session-plan.test.ts b/apps/desktop/src/sdk/plugin-open-session-plan.test.ts
new file mode 100644
index 0000000000..068c8fe42c
--- /dev/null
+++ b/apps/desktop/src/sdk/plugin-open-session-plan.test.ts
@@ -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()
+ })
+})
diff --git a/apps/desktop/src/sdk/plugin-open-session-plan.ts b/apps/desktop/src/sdk/plugin-open-session-plan.ts
new file mode 100644
index 0000000000..bad5f490ec
--- /dev/null
+++ b/apps/desktop/src/sdk/plugin-open-session-plan.ts
@@ -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
+ }
+}
diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts
index da517a3730..56f28f6cc1 100644
--- a/apps/desktop/src/sdk/profile-routing.test.ts
+++ b/apps/desktop/src/sdk/profile-routing.test.ts
@@ -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(() => 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(() => 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(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((_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(() => 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()
+ })
})
diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts
index 5b723194d9..05a7920bf5 100644
--- a/apps/desktop/src/store/gateway.ts
+++ b/apps/desktop/src/store/gateway.ts
@@ -573,7 +573,9 @@ async function gatewayForProfile(
export async function requestGatewayForProfile(
profile: string,
method: string,
- params: Record = {}
+ params: Record = {},
+ timeoutMs?: number,
+ signal?: AbortSignal
): Promise {
const route = await gatewayForProfile(profile, true)
@@ -584,7 +586,12 @@ export async function requestGatewayForProfile(
const routedParams = route.scopeProfile ? { ...params, profile: route.key } : params
- return await route.gateway.request(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(method, routedParams)
+ : route.gateway.request(method, routedParams, timeoutMs, signal))
} finally {
route.release()
}
diff --git a/apps/desktop/src/store/session-request-router.test.ts b/apps/desktop/src/store/session-request-router.test.ts
index f6f3ef89a8..7aa4aa3f6a 100644
--- a/apps/desktop/src/store/session-request-router.test.ts
+++ b/apps/desktop/src/store/session-request-router.test.ts
@@ -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) => ({
+ 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')
diff --git a/apps/desktop/src/store/session-request-router.ts b/apps/desktop/src/store/session-request-router.ts
index 8c67a52916..dd55aaf52c 100644
--- a/apps/desktop/src/store/session-request-router.ts
+++ b/apps/desktop/src/store/session-request-router.ts
@@ -40,13 +40,28 @@ export function sessionRpcNeedsProfileRoute(
*/
export function requestForSessionProfile(
ownerProfile: null | string | undefined,
- ambientRequest: (method: string, params?: Record) => Promise,
+ ambientRequest: (
+ method: string,
+ params?: Record,
+ timeoutMs?: number,
+ signal?: AbortSignal
+ ) => Promise,
method: string,
- params: Record = {}
+ params: Record = {},
+ timeoutMs?: number,
+ signal?: AbortSignal
): Promise {
if (!sessionRpcNeedsProfileRoute(ownerProfile)) {
- return ambientRequest(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(method, params)
+ : ambientRequest(method, params, timeoutMs, signal)
}
- return requestGatewayForProfile(normKey(ownerProfile), method, params)
+ return requestGatewayForProfile(normKey(ownerProfile), method, params, timeoutMs, signal)
}
diff --git a/apps/desktop/src/store/translucency.test.ts b/apps/desktop/src/store/translucency.test.ts
index a2861a952f..1e9e0cadc9 100644
--- a/apps/desktop/src/store/translucency.test.ts
+++ b/apps/desktop/src/store/translucency.test.ts
@@ -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('')
})
diff --git a/apps/desktop/src/store/translucency.ts b/apps/desktop/src/store/translucency.ts
index b37bc25989..4bb0c89944 100644
--- a/apps/desktop/src/store/translucency.ts
+++ b/apps/desktop/src/store/translucency.ts
@@ -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 {
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 3816915bf8..fdedeb3cea 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -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
diff --git a/apps/shared/src/translucency.ts b/apps/shared/src/translucency.ts
index da32fd102f..fe8ea43b5f 100644
--- a/apps/shared/src/translucency.ts
+++ b/apps/shared/src/translucency.ts
@@ -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