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 ? ( }>
) } +function ExitHudButton() { + const { t } = useI18n() + + return ( + + + + ) +} + function ConversationPill({ disabled, level, @@ -269,11 +307,7 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa + + + + { + 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