Merge remote-tracking branch 'origin/main' into feat/keyless-web-search-fallback
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -75,7 +75,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore),
|
||||
moveBy: delta => ipcRenderer.send('hermes:hud:move-by', delta),
|
||||
setBounds: bounds => ipcRenderer.send('hermes:hud:set-bounds', bounds),
|
||||
setVibrancy: on => ipcRenderer.invoke('hermes:hud:vibrancy', on),
|
||||
// Whether the band covers the window below the bar. Main pairs it with the
|
||||
// user's translucency setting to decide the native frost (macOS vibrancy /
|
||||
// Windows 11 DWM backdrop) — see hudFrostFor.
|
||||
setFrost: showing => ipcRenderer.invoke('hermes:hud:frost', showing),
|
||||
// The HUD tells main which session it is on; main hands that back to the
|
||||
// app window when the HUD closes, so the app can re-home onto it.
|
||||
setSession: sessionId => ipcRenderer.send('hermes:hud:session', sessionId),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
glassMaterialsFor,
|
||||
glassSupportedOn,
|
||||
glassSurfaceKeep,
|
||||
hudFrostFor,
|
||||
normalizeMaterial,
|
||||
normalizeMode,
|
||||
normalizeScope,
|
||||
@@ -245,6 +246,53 @@ describe('vibrancyFor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The HUD is a transparent window, so its frost has no opaque page to hide
|
||||
// behind: every state that isn't "frost wanted" has to resolve to no material
|
||||
// at all, or the band leaves a grey slab hanging over another app.
|
||||
describe('hudFrostFor', () => {
|
||||
it('wears the chosen frost on both platforms while the band is showing', () => {
|
||||
expect(hudFrostFor(glass(60, 'header'), true)).toEqual({ vibrancy: 'header', backgroundMaterial: 'mica' })
|
||||
expect(hudFrostFor(glass(60, 'under-window'), true)).toEqual({
|
||||
vibrancy: 'under-window',
|
||||
backgroundMaterial: 'acrylic'
|
||||
})
|
||||
})
|
||||
|
||||
// The material is the whole window rectangle and nothing on the page can
|
||||
// clip it, so a hidden band must mean no frost — this is the veto that keeps
|
||||
// idle HUD mode the bar and nothing else.
|
||||
it('is off whenever the band is not covering the window', () => {
|
||||
expect(hudFrostFor(glass(60, 'header'), false)).toEqual({ vibrancy: null, backgroundMaterial: 'none' })
|
||||
})
|
||||
|
||||
// ...and the setting is the other veto: Glass off, or the tint at zero,
|
||||
// means the HUD never frosts however engaged the band is.
|
||||
it('is off whenever glass itself is off', () => {
|
||||
expect(hudFrostFor(clear(60), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' })
|
||||
expect(hudFrostFor(glass(0, 'header'), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' })
|
||||
})
|
||||
|
||||
// Unlike a chat window, which keeps 'sidebar' under its titlebar band in
|
||||
// every non-glass state. Pinning this is what stops someone "fixing" the
|
||||
// null into a resting material and painting the slab back.
|
||||
it('resolves off to no material at all, not to a resting one', () => {
|
||||
expect(hudFrostFor(clear(60), true).vibrancy).toBeNull()
|
||||
expect(vibrancyFor(clear(60))).toBe('sidebar')
|
||||
})
|
||||
|
||||
// The tint is painted by the renderer, exactly as it is for a chat window —
|
||||
// dragging it must not re-issue setVibrancy, whose 150ms animation restarts
|
||||
// on every call and never lets the material settle.
|
||||
it('does not move any native property as the tint slider is dragged', () => {
|
||||
for (let intensity = 1; intensity <= 100; intensity += 1) {
|
||||
expect(hudFrostFor(glass(intensity, 'popover'), true)).toEqual({
|
||||
vibrancy: 'popover',
|
||||
backgroundMaterial: 'tabbed'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('glassSupportedOn', () => {
|
||||
it('is on for macOS regardless of kernel version', () => {
|
||||
expect(glassSupportedOn('darwin')).toBe(true)
|
||||
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
glassMaterialsFor,
|
||||
glassSupportedOn,
|
||||
glassSurfaceKeep,
|
||||
hudFrostFor,
|
||||
normalizeMaterial,
|
||||
normalizeMode,
|
||||
normalizeScope,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared class names for the composer's control row, in a module of their own
|
||||
// so both the row (`controls.tsx`) and the menus it renders can wear them
|
||||
// without importing each other in a cycle.
|
||||
|
||||
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
|
||||
|
||||
export const GHOST_ICON_BTN = cn(
|
||||
ICON_BTN,
|
||||
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)
|
||||
|
||||
// Send/voice-conversation primary: solid foreground-on-background circle
|
||||
// (reads as black-on-white in light mode, white-on-black in dark mode) to
|
||||
// match the reference composer's high-contrast CTA. Keeps the pill itself
|
||||
// neutral and lets the action visually dominate the row.
|
||||
export const PRIMARY_ICON_BTN = cn(
|
||||
'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0',
|
||||
'bg-foreground text-background hover:bg-foreground/90',
|
||||
'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100'
|
||||
)
|
||||
|
||||
/** A toggle that is currently ON — dictation, spoken replies, the wake word. */
|
||||
export const ACTIVE_ICON_BTN = 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ChatBarState } from '@/app/chat/composer/types'
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { $hudMode } from '@/store/hud'
|
||||
import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word'
|
||||
|
||||
import { ComposerControls } from './controls'
|
||||
@@ -57,6 +58,44 @@ async function expectShortcutTooltip(label: string, shortcut: string) {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$hudMode.set(false)
|
||||
})
|
||||
|
||||
// The HUD is a Spotlight bar a few hundred pixels wide: the four voice
|
||||
// controls fold into one menu there, and the way out of HUD mode joins the
|
||||
// row instead of floating above the bar in a reserved strip. The docked
|
||||
// composer keeps every control inline and shows no exit.
|
||||
describe('HUD mode', () => {
|
||||
it('keeps the voice controls inline and offers no exit in the docked composer', () => {
|
||||
renderControls()
|
||||
|
||||
expect(screen.getByLabelText('Voice dictation')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Read replies aloud')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('Exit HUD mode')).toBeNull()
|
||||
expect(screen.queryByLabelText('Voice')).toBeNull()
|
||||
})
|
||||
|
||||
it('folds them into one menu and offers the way out in the HUD', () => {
|
||||
$hudMode.set(true)
|
||||
renderControls()
|
||||
|
||||
expect(screen.getByLabelText('Voice')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Exit HUD mode')).toBeTruthy()
|
||||
|
||||
// Folded away, not duplicated — the whole point is the row's width back.
|
||||
expect(screen.queryByLabelText('Voice dictation')).toBeNull()
|
||||
expect(screen.queryByLabelText('Read replies aloud')).toBeNull()
|
||||
})
|
||||
|
||||
// A collapsed menu that looked idle while the mic was open would be a worse
|
||||
// trade than the space it saves, so the trigger reports the live state.
|
||||
it('reports a live voice state on the collapsed trigger', () => {
|
||||
$hudMode.set(true)
|
||||
renderControls({ voiceStatus: 'recording' })
|
||||
|
||||
expect(screen.getByLabelText('Stop dictation')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('Voice')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComposerControls shortcut tooltips', () => {
|
||||
|
||||
@@ -7,26 +7,18 @@ import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hudMode, closeHud } from '@/store/hud'
|
||||
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
|
||||
|
||||
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
import { ModelPill } from './model-pill'
|
||||
import type { ChatBarState, VoiceStatus } from './types'
|
||||
import { VoiceMenu } from './voice-menu'
|
||||
|
||||
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
|
||||
export const GHOST_ICON_BTN = cn(
|
||||
ICON_BTN,
|
||||
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)
|
||||
// Send/voice-conversation primary: solid foreground-on-background circle
|
||||
// (reads as black-on-white in light mode, white-on-black in dark mode) to
|
||||
// match the reference composer's high-contrast CTA. Keeps the pill itself
|
||||
// neutral and lets the action visually dominate the row.
|
||||
export const PRIMARY_ICON_BTN = cn(
|
||||
'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0',
|
||||
'bg-foreground text-background hover:bg-foreground/90',
|
||||
'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100'
|
||||
)
|
||||
// Re-exported: `context-menu.tsx` and other row neighbours have always reached
|
||||
// for these here, and the row is where they read as belonging.
|
||||
export { ACTIVE_ICON_BTN, GHOST_ICON_BTN, ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
|
||||
|
||||
interface ConversationProps {
|
||||
active: boolean
|
||||
@@ -70,6 +62,7 @@ export function ComposerControls({
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const hudMode = useStore($hudMode)
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
@@ -84,9 +77,27 @@ export function ComposerControls({
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
<WakeWordButton disabled={disabled} />
|
||||
{/* The HUD is a Spotlight bar a few hundred pixels wide, so the four
|
||||
separate voice toggles fold into one menu there and leave the row to
|
||||
the input. The docked composer has the width and keeps them inline —
|
||||
same controls, same state, different budget. */}
|
||||
{hudMode ? (
|
||||
<VoiceMenu
|
||||
autoSpeak={autoSpeak}
|
||||
disabled={disabled}
|
||||
onDictate={onDictate}
|
||||
onStartConversation={conversation.onStart}
|
||||
onToggleAutoSpeak={onToggleAutoSpeak}
|
||||
state={state}
|
||||
voiceStatus={voiceStatus}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
<WakeWordButton disabled={disabled} />
|
||||
</>
|
||||
)}
|
||||
{showQueueButton ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
|
||||
<Button
|
||||
@@ -142,10 +153,37 @@ export function ComposerControls({
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
{/* The way out of HUD mode, riding the controls row rather than floating
|
||||
above the bar. The old chip lived in a 26px transparent strip reserved
|
||||
over the composer (--hud-chip-strip), which under glass is bare
|
||||
untinted material with a hidden button in it — a band of chrome above
|
||||
the surface, paid for in every state, for a control that is invisible
|
||||
until hovered. Here it costs no reserved space and sits with the other
|
||||
things you can press. */}
|
||||
{hudMode ? <ExitHudButton /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExitHudButton() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<Tip label={t.titlebar.exitHud}>
|
||||
<Button
|
||||
aria-label={t.titlebar.exitHud}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0')}
|
||||
onClick={closeHud}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="screen-normal" size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationPill({
|
||||
disabled,
|
||||
level,
|
||||
@@ -269,11 +307,7 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
active && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
|
||||
)}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', active && ACTIVE_ICON_BTN)}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
@@ -318,11 +352,7 @@ function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolea
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={wake.listening && !pausedForVoice}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
wake.listening && !pausedForVoice && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
|
||||
)}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', wake.listening && !pausedForVoice && ACTIVE_ICON_BTN)}
|
||||
disabled={disabled || pausedForVoice || wake.pending}
|
||||
onClick={() => {
|
||||
triggerHaptic(wake.listening ? 'close' : 'open')
|
||||
@@ -365,7 +395,7 @@ function DictationButton({
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
|
||||
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
|
||||
status === 'recording' && ACTIVE_ICON_BTN,
|
||||
status === 'transcribing' && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
data-active={active}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
dropdownMenuRow,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Ear, EarOff, iconSize, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
|
||||
|
||||
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN } from './control-classes'
|
||||
import type { ChatBarState, VoiceStatus } from './types'
|
||||
|
||||
export interface VoiceMenuProps {
|
||||
autoSpeak: boolean
|
||||
disabled: boolean
|
||||
state: ChatBarState
|
||||
voiceStatus: VoiceStatus
|
||||
onDictate: () => void
|
||||
onStartConversation: () => void
|
||||
onToggleAutoSpeak: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Every voice control behind one trigger: dictation, spoken replies, the
|
||||
* "hey hermes" wake word, and starting a full conversation.
|
||||
*
|
||||
* The bar carried four separate icon buttons — mic, speaker, ear, and the
|
||||
* voice-conversation primary — which is most of a Spotlight-width composer
|
||||
* spent on toggles that are set once and rarely touched. Collapsing them costs
|
||||
* one click on the rare change and gives the row back to the input.
|
||||
*
|
||||
* The trigger is not a static glyph: it REPORTS the loudest live voice state
|
||||
* (recording, transcribing, listening for the wake word, speaking replies), so
|
||||
* folding the controls away never hides the fact that something is listening.
|
||||
* A collapsed menu that looked idle while the mic was hot would be a worse
|
||||
* trade than the space it saves.
|
||||
*/
|
||||
export function VoiceMenu({
|
||||
autoSpeak,
|
||||
disabled,
|
||||
state,
|
||||
voiceStatus,
|
||||
onDictate,
|
||||
onStartConversation,
|
||||
onToggleAutoSpeak
|
||||
}: VoiceMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const wake = useStore($wakeWord)
|
||||
|
||||
const phrase = wake.phrase || 'hey hermes'
|
||||
const dictating = state.voice.active || voiceStatus !== 'idle'
|
||||
const wakeListening = wake.listening
|
||||
// Anything live keeps the trigger lit, so a folded menu can never look idle
|
||||
// while the mic is open.
|
||||
const active = dictating || wakeListening || autoSpeak
|
||||
|
||||
const dictationLabel =
|
||||
voiceStatus === 'recording'
|
||||
? c.stopDictation
|
||||
: voiceStatus === 'transcribing'
|
||||
? c.transcribingDictation
|
||||
: c.voiceDictation
|
||||
|
||||
const wakeLabel = wakeListening ? c.wakeWordListening(phrase) : c.wakeWordOff(phrase)
|
||||
const triggerLabel = dictating ? dictationLabel : wakeListening ? wakeLabel : c.voiceControls
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tip label={wake.notice && !dictating ? `${triggerLabel} — ${wake.notice}` : triggerLabel}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={triggerLabel}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', active && ACTIVE_ICON_BTN)}
|
||||
disabled={disabled}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{voiceStatus === 'recording' ? (
|
||||
<Square className={cn('fill-current', iconSize.xs)} />
|
||||
) : voiceStatus === 'transcribing' ? (
|
||||
<Loader2 className={cn('animate-spin', iconSize.sm)} />
|
||||
) : wakeListening ? (
|
||||
<Ear className={iconSize.sm} />
|
||||
) : (
|
||||
<Codicon name="mic" size="0.875rem" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="min-w-52">
|
||||
<DropdownMenuItem
|
||||
className={dropdownMenuRow}
|
||||
disabled={disabled}
|
||||
onSelect={() => {
|
||||
triggerHaptic('open')
|
||||
onStartConversation()
|
||||
}}
|
||||
>
|
||||
<AudioLines className={iconSize.sm} />
|
||||
{c.startVoice}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Checkbox items, because all three are toggles the user is reading
|
||||
the CURRENT state of — the reason they were pressed-state buttons
|
||||
before. A plain row would fold that state away with the menu. */}
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={dictating}
|
||||
className={dropdownMenuRow}
|
||||
disabled={disabled || !state.voice.enabled || voiceStatus === 'transcribing'}
|
||||
onSelect={event => {
|
||||
// Keep the menu open: dictation is a mode you watch, and closing
|
||||
// on select hides the recording state the trigger just entered.
|
||||
event.preventDefault()
|
||||
triggerHaptic(dictating ? 'close' : 'open')
|
||||
onDictate()
|
||||
}}
|
||||
>
|
||||
{dictationLabel}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={autoSpeak}
|
||||
className={dropdownMenuRow}
|
||||
disabled={disabled}
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic(autoSpeak ? 'close' : 'open')
|
||||
onToggleAutoSpeak()
|
||||
}}
|
||||
>
|
||||
{autoSpeak ? <Volume2 className={iconSize.sm} /> : <VolumeX className={iconSize.sm} />}
|
||||
{autoSpeak ? c.stopSpeakingReplies : c.speakReplies}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={wakeListening}
|
||||
className={dropdownMenuRow}
|
||||
disabled={disabled || wake.pending}
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic(wakeListening ? 'close' : 'open')
|
||||
void toggleWakeWord()
|
||||
}}
|
||||
>
|
||||
{wakeListening ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />}
|
||||
{wakeLabel}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -62,12 +62,14 @@ import {
|
||||
$selectedStoredSessionId,
|
||||
$sessionResumeRequest,
|
||||
$sessions,
|
||||
rememberedSessionProfile,
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages
|
||||
} from '@/store/session'
|
||||
import { requestForSessionProfile } from '@/store/session-request-router'
|
||||
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
|
||||
import { armWakeWord, stopClientCapture } from '@/store/wake-word'
|
||||
import { isAuxiliaryWindow, isHudWindow } from '@/store/windows'
|
||||
@@ -279,7 +281,22 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
setMessages
|
||||
})
|
||||
|
||||
const { connectionRef, gateway, gatewayRef, requestGateway } = useGatewayRequest()
|
||||
const { connectionRef, gateway, gatewayRef, requestGateway: ambientRequestGateway } = useGatewayRequest()
|
||||
|
||||
// When chrome stays on the launch backend (Bot Mode / all-profiles
|
||||
// navigation), session-owned RPCs still have to hit the session's backend.
|
||||
const requestGateway = useCallback(
|
||||
<T,>(method: string, params?: Record<string, unknown>, timeoutMs?: number, signal?: AbortSignal) => {
|
||||
const owner = rememberedSessionProfile(
|
||||
$sessions.get(),
|
||||
selectedStoredSessionIdRef.current,
|
||||
$activeGatewayProfile.get()
|
||||
)
|
||||
|
||||
return requestForSessionProfile<T>(owner, ambientRequestGateway, method, params ?? {}, timeoutMs, signal)
|
||||
},
|
||||
[ambientRequestGateway]
|
||||
)
|
||||
|
||||
const { loadMoreMessagingForPlatform, loadMoreSessions, refreshCronJobs, refreshMessagingSessions, refreshSessions } =
|
||||
useSessionListActions({ profileScope })
|
||||
|
||||
@@ -3,14 +3,18 @@ import { type RefObject, useEffect } from 'react'
|
||||
/** The caret is in the composer — see the `:has()` rules in styles.css. */
|
||||
const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus'
|
||||
|
||||
/** An open completion list owns the surface; the band falls back behind it. */
|
||||
const DRAWER_SELECTOR = '[data-slot="composer-completion-drawer"]'
|
||||
|
||||
/**
|
||||
* Native frost behind the band.
|
||||
*
|
||||
* macOS vibrancy, not CSS — `backdrop-filter` reaches nothing here, because a
|
||||
* transparent window's backdrop root is the document and the desktop was never
|
||||
* in it. Vibrancy is composited by WindowServer BELOW the web contents, which
|
||||
* is what lets it see the desktop and also what makes it untouchable from the
|
||||
* page: no mask, clip or stacking order can shape it.
|
||||
* A platform material, not CSS — `backdrop-filter` reaches nothing here,
|
||||
* because a transparent window's backdrop root is the document and the desktop
|
||||
* was never in it. The material is composited BELOW the web contents (macOS
|
||||
* vibrancy via WindowServer, Windows 11 via the DWM backdrop), which is what
|
||||
* lets it see the desktop and also what makes it untouchable from the page: no
|
||||
* mask, clip or stacking order can shape it.
|
||||
*
|
||||
* That is survivable because the band is a flat panel. It was NOT survivable
|
||||
* while the band carried a vertical gradient — the frost stayed a slab under a
|
||||
@@ -31,38 +35,79 @@ const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus'
|
||||
* document.activeElement, which stays put when the window is blurred and would
|
||||
* latch the frost on forever once the user had ever typed here.
|
||||
*
|
||||
* `backing` is the veto over both of those. Because the frost is the window and
|
||||
* not the sheet, it is only ever right when the sheet covers the window; short
|
||||
* of that the excess is frost over empty space. Gating the caller's `engaged`
|
||||
* alone would not do it — focus turns the frost on by itself, which is how a
|
||||
* brand new thread still frosted its whole empty window.
|
||||
* Two vetoes sit over that:
|
||||
*
|
||||
* - `backing` — because the frost is the window and not the sheet, it is only
|
||||
* ever right when the sheet covers the window; short of that the excess is
|
||||
* frost over empty space. Gating the caller's `engaged` alone would not do
|
||||
* it — focus turns the frost on by itself, which is how a brand new thread
|
||||
* still frosted its whole empty window.
|
||||
* - An open completion drawer, which drops the band to 25% and blurs it
|
||||
* (see the `composer-completion-drawer` rule in styles.css). Full-strength
|
||||
* frost behind a band that has deliberately stepped back is the same bare
|
||||
* slab in a different disguise. Observed rather than passed in: the drawer
|
||||
* mounts inside the composer subtree from three different call sites, so a
|
||||
* prop would need every one of them to remember.
|
||||
*
|
||||
* Whether the frost is wanted AT ALL is the user's translucency setting, and
|
||||
* that answer lives in main (`hudFrostFor`) next to the state it reads. This
|
||||
* hook reports what the band is doing; it does not decide the material.
|
||||
*/
|
||||
export function useHudGlass(rootRef: RefObject<HTMLElement | null>, engaged: boolean, backing: boolean): void {
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
const setVibrancy = window.hermesDesktop?.hud?.setVibrancy
|
||||
const setFrost = window.hermesDesktop?.hud?.setFrost
|
||||
|
||||
if (!root || !setVibrancy) {
|
||||
if (!root || !setFrost) {
|
||||
return
|
||||
}
|
||||
|
||||
let on: boolean | null = null
|
||||
|
||||
const apply = () => {
|
||||
const next = backing && (engaged || root.querySelector(TYPING_SELECTOR) !== null)
|
||||
const next =
|
||||
backing &&
|
||||
root.querySelector(DRAWER_SELECTOR) === null &&
|
||||
(engaged || root.querySelector(TYPING_SELECTOR) !== null)
|
||||
|
||||
if (on !== next) {
|
||||
on = next
|
||||
void setVibrancy(next)
|
||||
void setFrost(next)
|
||||
}
|
||||
}
|
||||
|
||||
// The drawer mounts and unmounts without any focus change, so neither
|
||||
// focusin/focusout nor a re-render is guaranteed to follow it. Coalesced
|
||||
// to a frame: this observes the whole shell, and a streaming reply mutates
|
||||
// the transcript tens of times a second — the drawer's state cannot change
|
||||
// more than once per paint, so re-deciding per mutation is pure churn.
|
||||
let frame: null | number = null
|
||||
|
||||
const schedule = () => {
|
||||
if (frame === null) {
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null
|
||||
apply()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(schedule)
|
||||
|
||||
observer.observe(root, { childList: true, subtree: true })
|
||||
|
||||
apply()
|
||||
root.addEventListener('focusin', apply)
|
||||
root.addEventListener('focusout', apply)
|
||||
|
||||
return () => {
|
||||
void setVibrancy(false)
|
||||
void setFrost(false)
|
||||
observer.disconnect()
|
||||
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
|
||||
root.removeEventListener('focusin', apply)
|
||||
root.removeEventListener('focusout', apply)
|
||||
}
|
||||
|
||||
@@ -2,18 +2,12 @@ import { useStore } from '@nanostores/react'
|
||||
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { TitlebarIcon } from '@/app/shell/titlebar-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { closeHud } from '@/store/hud'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { $busy, $messages } from '@/store/session'
|
||||
|
||||
import { RICH_INPUT_SLOT } from '../chat/composer/rich-editor'
|
||||
import { WiredPane } from '../contrib/wiring'
|
||||
import { titlebarButtonClass } from '../shell/titlebar'
|
||||
|
||||
import { useHudClickThrough } from './click-through'
|
||||
import { useHudGlass } from './glass'
|
||||
@@ -170,7 +164,6 @@ function useHudHeld(): boolean {
|
||||
* `useRecentActivity`).
|
||||
*/
|
||||
export function HudShell() {
|
||||
const { t } = useI18n()
|
||||
const [recent, holdBand] = useRecentActivity()
|
||||
const held = useHudHeld()
|
||||
|
||||
@@ -388,22 +381,6 @@ export function HudShell() {
|
||||
|
||||
<WiredPane part="chatRoutes" />
|
||||
|
||||
{/* The way back — without it the only exits are ⌘⇧H and ⌘W, both
|
||||
invisible. Placed and revealed entirely from styles.css. */}
|
||||
<Tip label={t.titlebar.exitHud}>
|
||||
<Button
|
||||
aria-label={t.titlebar.exitHud}
|
||||
className={`${titlebarButtonClass} absolute z-20`}
|
||||
data-hud-exit=""
|
||||
onClick={closeHud}
|
||||
size="icon-titlebar"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<TitlebarIcon name="screen-normal" />
|
||||
</Button>
|
||||
</Tip>
|
||||
|
||||
{/* The resize handle: bottom-right corner, the one sanctioned way to
|
||||
change the HUD's size. Invisible chrome — a hot corner, not a
|
||||
button — so it never reads as part of the surface. `data-hud-grabbing`
|
||||
|
||||
@@ -13,6 +13,7 @@ import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { normalizeChoices, setClarifyRequest } from '@/store/clarify'
|
||||
import { migrateSessionDraft } from '@/store/composer'
|
||||
import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue'
|
||||
import { openGatewayForAgent, openGatewayForProfile } from '@/store/gateway'
|
||||
import { $gatewaySwitching } from '@/store/gateway-switch'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
$activeGatewayProfile,
|
||||
$gatewaySwapTarget,
|
||||
$newChatProfile,
|
||||
$showAllProfiles,
|
||||
ensureGatewayAgent,
|
||||
ensureGatewayProfile,
|
||||
normalizeProfileKey
|
||||
@@ -740,7 +742,15 @@ export function useSessionActions({
|
||||
// A row spliced from a CONNECTED registry gateway (#88880) carries its
|
||||
// owning connection — activate THAT gateway, not a same-named local
|
||||
// profile. Rows without the tag keep the legacy profile path.
|
||||
if (storedForProfile?.connection_id) {
|
||||
// All-profiles / plugin navigation must not steal chrome API-home:
|
||||
// dial the owning backend without moving $activeGatewayProfile.
|
||||
if ($showAllProfiles.get()) {
|
||||
if (storedForProfile?.connection_id) {
|
||||
await openGatewayForAgent(storedForProfile.connection_id, sessionProfile || 'default')
|
||||
} else if (sessionProfile) {
|
||||
await openGatewayForProfile(normalizeProfileKey(sessionProfile))
|
||||
}
|
||||
} else if (storedForProfile?.connection_id) {
|
||||
await ensureGatewayAgent(storedForProfile.connection_id, sessionProfile || 'default')
|
||||
} else {
|
||||
await ensureGatewayProfile(sessionProfile)
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
@@ -1765,6 +1765,7 @@ export const ar = defineLocale({
|
||||
endShort: 'إنهاء',
|
||||
stopDictation: 'إيقاف الإملاء',
|
||||
transcribingDictation: 'جار تفريغ الإملاء',
|
||||
voiceControls: 'صوت',
|
||||
voiceDictation: 'إملاء صوتي',
|
||||
lookupLoading: 'جار البحث...',
|
||||
lookupNoMatches: 'لا توجد نتائج',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1954,6 +1954,7 @@ export const ja = defineLocale({
|
||||
endShort: '終了',
|
||||
stopDictation: '口述を停止',
|
||||
transcribingDictation: '口述を文字起こし中',
|
||||
voiceControls: '音声',
|
||||
voiceDictation: '音声口述',
|
||||
speakReplies: '返信を読み上げる',
|
||||
stopSpeakingReplies: '返信の読み上げを停止',
|
||||
|
||||
@@ -1913,6 +1913,7 @@ export interface Translations {
|
||||
endShort: string
|
||||
stopDictation: string
|
||||
transcribingDictation: string
|
||||
voiceControls: string
|
||||
voiceDictation: string
|
||||
speakReplies: string
|
||||
stopSpeakingReplies: string
|
||||
|
||||
@@ -1892,6 +1892,7 @@ export const zhHant = defineLocale({
|
||||
endShort: '結束',
|
||||
stopDictation: '停止聽寫',
|
||||
transcribingDictation: '正在轉寫聽寫',
|
||||
voiceControls: '語音',
|
||||
voiceDictation: '語音聽寫',
|
||||
speakReplies: '朗讀回覆',
|
||||
stopSpeakingReplies: '停止朗讀回覆',
|
||||
|
||||
@@ -2439,6 +2439,7 @@ export const zh: Translations = {
|
||||
endShort: '结束',
|
||||
stopDictation: '停止听写',
|
||||
transcribingDictation: '正在转写听写',
|
||||
voiceControls: '语音',
|
||||
voiceDictation: '语音听写',
|
||||
speakReplies: '朗读回复',
|
||||
stopSpeakingReplies: '停止朗读回复',
|
||||
|
||||
@@ -3276,12 +3276,19 @@ async function openStoredBotChat(name, storedId, summary) {
|
||||
typeof summary?.message_count === 'number' && Number.isFinite(summary.message_count)
|
||||
const expectHistory = hasAuthoritativeCount ? summary.message_count > 0 : true
|
||||
|
||||
// A profile backend that just woke up can lose the hydration-timeout race
|
||||
// even though the session is fine (hermes-agent#89617) — clicking Retry
|
||||
// succeeds because the backend is warm by then. retryHydrationTimeoutOnce
|
||||
// asks the SDK layer to retry that same wait internally, BEFORE it arms the
|
||||
// core stranded-session overlay: a plugin-side retry can't do this because
|
||||
// only host.openSession sees the resume-exhausted latch that overlay reads.
|
||||
await host.openSession(storedId, {
|
||||
profile: name,
|
||||
intent: 'main',
|
||||
awaitHydration: true,
|
||||
expectHistory,
|
||||
keepAllProfilesScope: false
|
||||
keepAllProfilesScope: true,
|
||||
retryHydrationTimeoutOnce: true
|
||||
})
|
||||
|
||||
return storedId
|
||||
@@ -3321,7 +3328,7 @@ function createCanonicalChat(name) {
|
||||
|
||||
if (sid && typeof host.openSession === 'function') {
|
||||
try {
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false })
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true })
|
||||
opened = true
|
||||
} catch {
|
||||
// The stored row may not exist until the kickoff persists it. Retry
|
||||
@@ -3336,7 +3343,7 @@ function createCanonicalChat(name) {
|
||||
await host.request('prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' })
|
||||
|
||||
if (!opened && sid && typeof host.openSession === 'function') {
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false })
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true })
|
||||
}
|
||||
} catch {
|
||||
// The chat already exists. Keep the pin so the next click
|
||||
@@ -7845,7 +7852,7 @@ async function openProfileSession(botName, session, gatewayGeneration) {
|
||||
typeof session?.message_count === 'number' && Number.isFinite(session.message_count)
|
||||
const expectHistory = hasAuthoritativeCount ? session.message_count > 0 : Boolean(session?.preview)
|
||||
|
||||
await host.openSession(id, { profile, awaitHydration: true, expectHistory, keepAllProfilesScope: false })
|
||||
await host.openSession(id, { profile, awaitHydration: true, expectHistory, keepAllProfilesScope: true })
|
||||
if (gatewayGeneration !== $sessionsGatewayGeneration.get()) return
|
||||
$botSelectedSessions.set({ ...$botSelectedSessions.get(), [profile]: id })
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@ test('pin: preferred_session present opens the resolved session and keeps the pi
|
||||
intent: 'main',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
keepAllProfilesScope: false
|
||||
keepAllProfilesScope: true,
|
||||
retryHydrationTimeoutOnce: true
|
||||
}
|
||||
}])
|
||||
assert.equal(runtime.saved.length, 0, 'a live pin must not be rewritten')
|
||||
@@ -214,6 +215,63 @@ test('pin: precise hit but failed hydration keeps the pin and surfaces the failu
|
||||
'must not fork the forever-chat on a hiccup')
|
||||
})
|
||||
|
||||
test('pin: a waking-backend hydration timeout asks the SDK to retry internally', async () => {
|
||||
// The internal retry-and-succeed behavior lives in host.openSession itself
|
||||
// (apps/desktop/src/sdk/index.ts) now, because only that layer sees the
|
||||
// $resumeExhaustedSessionId latch that the core stranded-session overlay
|
||||
// reads — a plugin-side retry can silently resolve while that overlay stays
|
||||
// latched (hermes-agent#89617). This harness stubs host.openSession with a
|
||||
// bare mock, so it can only prove the plugin ASKS for the retry, not that
|
||||
// the overlay never appears; see profile-routing.test.ts for that.
|
||||
const opts = []
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async (id, options) => { opts.push(options) },
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: {
|
||||
id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat',
|
||||
preview: 'latest', started_at: 1, last_active: 2, message_count: 3
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY)
|
||||
|
||||
assert.equal(result, 'pin-1')
|
||||
assert.equal(opts.length, 1)
|
||||
assert.equal(opts[0].retryHydrationTimeoutOnce, true, 'the SDK must own the hydration-timeout retry')
|
||||
})
|
||||
|
||||
test('pin: a persistent hydration timeout still surfaces the failure', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => { throw new Error("Timed out loading ops's session history.") },
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: {
|
||||
id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat',
|
||||
preview: 'latest', started_at: 1, last_active: 2, message_count: 3
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
await assert.rejects(runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY), /Timed out loading/)
|
||||
assert.equal(runtime.saved.length, 0, 'a confirmed-live pin must survive a persistent hydration timeout')
|
||||
})
|
||||
|
||||
// ── transient failures must never destroy the pin ──────────────────────────
|
||||
|
||||
test('transient: profiles.list failure keeps the pin when the direct open works', async () => {
|
||||
|
||||
@@ -90,7 +90,7 @@ test('sessions workspace: opening a stored row uses profile-aware navigation and
|
||||
const runtime = load({ profile: 'default' })
|
||||
await runtime.__sessions.openProfileSession('ops', { id: 'stored-123', message_count: 4 }, 0)
|
||||
assert.deepEqual(plain(runtime.calls), [
|
||||
['openSession', 'stored-123', { profile: 'ops', awaitHydration: true, expectHistory: true, keepAllProfilesScope: false }]
|
||||
['openSession', 'stored-123', { profile: 'ops', awaitHydration: true, expectHistory: true, keepAllProfilesScope: true }]
|
||||
])
|
||||
assert.equal(runtime.__sessions.$botSelectedSessions.get().ops, 'stored-123')
|
||||
})
|
||||
@@ -117,7 +117,7 @@ test('sessions workspace: an empty session with no preview does not demand histo
|
||||
const runtime = load()
|
||||
await runtime.__sessions.openProfileSession('ops', { id: 'stored-empty', message_count: 0 }, 0)
|
||||
assert.deepEqual(plain(runtime.calls), [
|
||||
['openSession', 'stored-empty', { profile: 'ops', awaitHydration: true, expectHistory: false, keepAllProfilesScope: false }]
|
||||
['openSession', 'stored-empty', { profile: 'ops', awaitHydration: true, expectHistory: false, keepAllProfilesScope: true }]
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
+200
-65
@@ -79,6 +79,8 @@ import {
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import type { UsageStats } from '@/types/hermes'
|
||||
|
||||
import { planPluginOpenSession } from './plugin-open-session-plan'
|
||||
|
||||
// -- state: readonly views over the app's live atoms -------------------------
|
||||
|
||||
const readonlyAtom = <T>(atomLike: ReadableAtom<T>): ReadableAtom<T> => atomLike
|
||||
@@ -226,18 +228,26 @@ interface PluginOpenSessionOptions {
|
||||
intent?: OpenSessionIntent
|
||||
keepAllProfilesScope?: boolean
|
||||
profile?: null | string
|
||||
/** A cold profile backend can lose the hydration-timeout race once and still
|
||||
* be fine on a second try. When set, a hydration timeout is retried
|
||||
* internally before it reaches the caller or arms the core stranded-session
|
||||
* overlay ($resumeExhaustedSessionId) — a caller-side retry can't do this
|
||||
* itself because only this SDK layer sees $resumeExhaustedSessionId. */
|
||||
retryHydrationTimeoutOnce?: boolean
|
||||
}
|
||||
|
||||
function waitForFocusedSessionHydration({
|
||||
expectHistory,
|
||||
generation,
|
||||
profile,
|
||||
requireActiveProfile,
|
||||
storedSessionId,
|
||||
timeoutMs
|
||||
}: {
|
||||
expectHistory: boolean
|
||||
generation: number
|
||||
profile: string
|
||||
requireActiveProfile: boolean
|
||||
storedSessionId: string
|
||||
timeoutMs: number
|
||||
}): Promise<void> {
|
||||
@@ -275,7 +285,7 @@ function waitForFocusedSessionHydration({
|
||||
return
|
||||
}
|
||||
|
||||
const profileMatches = normalizeProfileKey($activeGatewayProfile.get()) === profile
|
||||
const profileMatches = !requireActiveProfile || normalizeProfileKey($activeGatewayProfile.get()) === profile
|
||||
const sessionMatches = $selectedStoredSessionId.get() === storedSessionId
|
||||
const runtimeReady = Boolean($activeSessionId.get())
|
||||
const historyPainted = Boolean($messages.get().length)
|
||||
@@ -312,6 +322,64 @@ function waitForFocusedSessionHydration({
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for a profile switch, but never longer than the wake budget.
|
||||
//
|
||||
// ensureGatewayProfile awaits the store's dial, and HermesGateway.connect() has
|
||||
// no dial timeout of its own: a backend that accepts the socket and then never
|
||||
// completes the handshake leaves this promise pending for the life of the
|
||||
// window. That is not merely a slow open. waitForFocusedSessionHydration arms
|
||||
// the only timer on this path, and it is armed AFTER this await returns - so an
|
||||
// unbounded activation means the wake never settles at all, and the pane wedges
|
||||
// with no error, no Retry and no timeout (#89556: `ws accepted` in the gateway
|
||||
// log with no matching `ws closed`).
|
||||
//
|
||||
// The activation gets its OWN budget rather than sharing the hydration one. A
|
||||
// cold profile backend can legitimately spend most of the hydration budget
|
||||
// painting a large transcript - that race is already tight enough to lose
|
||||
// (#89617) - so charging activation to the same clock would turn a wedge into a
|
||||
// regression. The trade is that a wake that is slow in BOTH phases can now take
|
||||
// up to twice the budget before it surfaces; that is a maintainer call and is
|
||||
// called out in the PR rather than buried here.
|
||||
// The caller supplies the dial itself, because WHICH backend to open is a
|
||||
// routing decision (a workspace switch moves chrome; a plain bot navigation
|
||||
// only opens the gateway) while the deadline enforced here is the same either
|
||||
// way.
|
||||
async function awaitProfileActivation(
|
||||
dial: () => Promise<void>,
|
||||
targetProfile: string,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
const activation = dial()
|
||||
let timer: number | undefined
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
activation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
// Same message shape as the hydration timeout on purpose: openSession's
|
||||
// catch keys the core stranded-session surface off this prefix, and a
|
||||
// wedged dial wants exactly that surface. The phase is distinguished in
|
||||
// the [bot-wake] support log, not in the user-facing string.
|
||||
timer = window.setTimeout(
|
||||
() => reject(new Error(`Timed out loading ${targetProfile}'s session history.`)),
|
||||
timeoutMs
|
||||
)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// No extra catch on the abandoned dial: an in-flight activation has no
|
||||
// cancellation handle and keeps running after the budget expires, but
|
||||
// Promise.race subscribes to every input, so a rejection that lands after the
|
||||
// race has settled is already handled and cannot escape as an unhandled
|
||||
// rejection. An explicit `activation.catch()` here was dead code - verified
|
||||
// by mutation: removing it changed no test outcome.
|
||||
}
|
||||
|
||||
export const host = {
|
||||
state: {
|
||||
/** Runtime id of the active chat session (null on a fresh draft). */
|
||||
@@ -375,14 +443,6 @@ export const host = {
|
||||
window.location.hash = path.startsWith('#') ? path : `#${path}`
|
||||
},
|
||||
|
||||
/** Open a stored session the way core surfaces do (focus an existing
|
||||
* tile/main, else load into main). When `profile` names a non-active
|
||||
* profile, its backend is activated first so the resume routes to the
|
||||
* right state.db — the same soft profile swap the unified sidebar does.
|
||||
* `keepAllProfilesScope` (default true) keeps the Sessions sidebar in the
|
||||
* unified all-profiles view instead of narrowing it to the target
|
||||
* profile's sessions — a cross-profile open from a plugin surface is a
|
||||
* navigation, not a scope choice; pass false to also scope the sidebar. */
|
||||
/** Pre-dial a profile's gateway socket in the background — pool-only, no
|
||||
* activation, no navigation, no scope change (openGatewayForProfile; it
|
||||
* already no-ops for shared-remote routes and the primary). Roster UIs
|
||||
@@ -499,80 +559,151 @@ export const host = {
|
||||
ensureAgent: async (connectionId: null | string, profile: string): Promise<void> =>
|
||||
ensureGatewayAgent(connectionId, (profile ?? '').trim() || 'default'),
|
||||
|
||||
/** Open a stored session — optionally pre-activating its profile first. */
|
||||
/** Open a stored session the way core surfaces do. A plugin/Bot Mode open
|
||||
* is navigation, not a workspace or chrome API-home switch —
|
||||
* keepAllProfilesScope defaults true so `$activeGatewayProfile` /
|
||||
* Sessions REST stay on the previous (usually launch) backend while the
|
||||
* bot backend is dialed in the background. The bot forever-chat is hidden
|
||||
* and would otherwise look like every session disappeared. Pass false to
|
||||
* also scope chrome onto that profile and collapse the sidebar. */
|
||||
openSession: async (storedSessionId: string, options: PluginOpenSessionOptions = {}): Promise<void> => {
|
||||
const generation = ++openSessionGeneration
|
||||
const profile = (options.profile ?? '').trim()
|
||||
const targetProfile = normalizeProfileKey(profile || $activeGatewayProfile.get())
|
||||
const expectHistory = options.expectHistory ?? false
|
||||
|
||||
const plan = planPluginOpenSession({
|
||||
activeProfile: $activeGatewayProfile.get(),
|
||||
keepAllProfilesScope: options.keepAllProfilesScope,
|
||||
profile
|
||||
})
|
||||
|
||||
// Wake-path phase timings. Logged ONLY on a hydration timeout (bridged
|
||||
// into desktop.log via the renderer-console tap), so a support bundle
|
||||
// pinpoints WHERE the budget went — profile activation vs hydration —
|
||||
// instead of leaving us to infer it from process spawn timestamps.
|
||||
const wakeStartedAt = Date.now()
|
||||
let profileActiveAt = wakeStartedAt
|
||||
const hydrationTimeoutMs = Math.max(1, options.hydrationTimeoutMs ?? DEFAULT_SESSION_HYDRATION_TIMEOUT_MS)
|
||||
// Which half of the wake a timeout landed in. Only meaningful on the
|
||||
// failure path, where the two phases have different remedies: a stuck dial
|
||||
// is a gateway problem, a slow transcript is a backend-warmup one.
|
||||
let wakePhase: 'activation' | 'hydration' = 'activation'
|
||||
|
||||
if (profile && profile !== $activeGatewayProfile.get()) {
|
||||
await ensureGatewayProfile(profile)
|
||||
profileActiveAt = Date.now()
|
||||
|
||||
if (options.keepAllProfilesScope !== false) {
|
||||
setShowAllProfiles(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (generation !== openSessionGeneration) {
|
||||
throw new Error('Session open was superseded by a newer selection.')
|
||||
}
|
||||
|
||||
if (options.awaitHydration) {
|
||||
// Keep the target-specific overlay visible through transcript hydration,
|
||||
// not merely through the gateway/profile activation that precedes it.
|
||||
$gatewaySwapTarget.set(targetProfile)
|
||||
}
|
||||
// Bounded to 2 attempts (never more): a cold profile backend can lose the
|
||||
// hydration-timeout race once and still be fine moments later, but this is
|
||||
// a caller-opt-in retry of the SAME wait, not a backoff loop.
|
||||
const maxAttempts = options.awaitHydration && options.retryHydrationTimeoutOnce ? 2 : 1
|
||||
|
||||
try {
|
||||
openSession(
|
||||
storedSessionId,
|
||||
(to: string, opts?: { replace?: boolean }) => {
|
||||
const target = to.startsWith('#') ? to : `#${to}`
|
||||
// WHICH backend to dial is the plan's call; HOW LONG to wait is the wake
|
||||
// budget's. A workspace switch moves $activeGatewayProfile / chrome REST;
|
||||
// a plain navigation only opens the bot's gateway so session.resume can
|
||||
// hydrate, leaving chrome on the launch backend.
|
||||
const dial = plan.switchWorkspace
|
||||
? () => ensureGatewayProfile(plan.switchWorkspace as string)
|
||||
: plan.dialWithoutSwitching
|
||||
? () => openGatewayForProfile(plan.dialWithoutSwitching as string)
|
||||
: null
|
||||
|
||||
if (opts?.replace) {
|
||||
window.location.replace(target)
|
||||
} else {
|
||||
window.location.hash = target
|
||||
}
|
||||
},
|
||||
options.intent ?? 'in-place'
|
||||
)
|
||||
if (dial) {
|
||||
// Bounded only on the hydration contract, which is where a budget and a
|
||||
// Retry surface both already exist. A plain open never asked for a
|
||||
// deadline and has nowhere to render one, so it keeps today's
|
||||
// behaviour rather than gaining a rejection its callers cannot handle.
|
||||
await (options.awaitHydration ? awaitProfileActivation(dial, targetProfile, hydrationTimeoutMs) : dial())
|
||||
profileActiveAt = Date.now()
|
||||
}
|
||||
|
||||
// Judge the main surface AFTER the open: on a cold start the persisted
|
||||
// route can already point at this session while selection has not
|
||||
// settled, so a pre-open "already selected" precondition skips the
|
||||
// resume exactly when it is needed (#89206 — blank Bot Chat with the
|
||||
// roster preview intact). The surface is healthy only when this stored
|
||||
// session is selected, a runtime is bound, and the expected transcript
|
||||
// is present; anything less gets an explicit sequenced resume request.
|
||||
// The route-resume effect only honors the request while the route
|
||||
// points at this session, and consumes it alongside any resume the
|
||||
// navigation itself triggers, so a redundant request is a no-op.
|
||||
const surfaceHealthy =
|
||||
$selectedStoredSessionId.get() === storedSessionId &&
|
||||
Boolean($activeSessionId.get()) &&
|
||||
(!expectHistory || $messages.get().length > 0)
|
||||
if (plan.showAllProfiles !== null) {
|
||||
setShowAllProfiles(plan.showAllProfiles)
|
||||
}
|
||||
|
||||
if (options.awaitHydration && !surfaceHealthy) {
|
||||
requestSessionResume(storedSessionId)
|
||||
wakePhase = 'hydration'
|
||||
|
||||
if (generation !== openSessionGeneration) {
|
||||
throw new Error('Session open was superseded by a newer selection.')
|
||||
}
|
||||
|
||||
if (options.awaitHydration) {
|
||||
await waitForFocusedSessionHydration({
|
||||
expectHistory,
|
||||
generation,
|
||||
profile: targetProfile,
|
||||
storedSessionId,
|
||||
timeoutMs: Math.max(1, options.hydrationTimeoutMs ?? DEFAULT_SESSION_HYDRATION_TIMEOUT_MS)
|
||||
})
|
||||
// Keep the target-specific overlay visible through transcript hydration,
|
||||
// not merely through the gateway/profile activation that precedes it.
|
||||
$gatewaySwapTarget.set(targetProfile)
|
||||
}
|
||||
|
||||
// Only the HYDRATION half retries. Activation already failed its own
|
||||
// bounded wait above, and a wedged dial does not get better by dialling
|
||||
// again inside the same wake — that is the Retry surface's job.
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
openSession(
|
||||
storedSessionId,
|
||||
(to: string, opts?: { replace?: boolean }) => {
|
||||
const target = to.startsWith('#') ? to : `#${to}`
|
||||
|
||||
if (opts?.replace) {
|
||||
window.location.replace(target)
|
||||
} else {
|
||||
window.location.hash = target
|
||||
}
|
||||
},
|
||||
options.intent ?? 'in-place'
|
||||
)
|
||||
|
||||
// Judge the main surface AFTER the open: on a cold start the persisted
|
||||
// route can already point at this session while selection has not
|
||||
// settled, so a pre-open "already selected" precondition skips the
|
||||
// resume exactly when it is needed (#89206 — blank Bot Chat with the
|
||||
// roster preview intact). The surface is healthy only when this stored
|
||||
// session is selected, a runtime is bound, and the expected transcript
|
||||
// is present; anything less gets an explicit sequenced resume request.
|
||||
// The route-resume effect only honors the request while the route
|
||||
// points at this session, and consumes it alongside any resume the
|
||||
// navigation itself triggers, so a redundant request is a no-op.
|
||||
const surfaceHealthy =
|
||||
$selectedStoredSessionId.get() === storedSessionId &&
|
||||
Boolean($activeSessionId.get()) &&
|
||||
(!expectHistory || $messages.get().length > 0)
|
||||
|
||||
if (options.awaitHydration && !surfaceHealthy) {
|
||||
requestSessionResume(storedSessionId)
|
||||
}
|
||||
|
||||
if (options.awaitHydration) {
|
||||
await waitForFocusedSessionHydration({
|
||||
expectHistory,
|
||||
generation,
|
||||
profile: targetProfile,
|
||||
// A background dial never moves $activeGatewayProfile, so gating
|
||||
// hydration on it would wait for something that is not coming.
|
||||
requireActiveProfile: plan.requireActiveProfileForHydration,
|
||||
storedSessionId,
|
||||
timeoutMs: hydrationTimeoutMs
|
||||
})
|
||||
}
|
||||
|
||||
break
|
||||
} catch (error) {
|
||||
const retryable =
|
||||
options.awaitHydration &&
|
||||
generation === openSessionGeneration &&
|
||||
attempt < maxAttempts &&
|
||||
error instanceof Error &&
|
||||
error.message.startsWith('Timed out loading ')
|
||||
|
||||
if (!retryable) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Logged per attempt so a support bundle shows the retry happened at
|
||||
// all; the terminal failure is reported once by the catch below.
|
||||
console.warn('[bot-wake] hydration timed out, retrying', {
|
||||
attempt,
|
||||
hydrationWaitMs: Date.now() - profileActiveAt,
|
||||
profile: targetProfile,
|
||||
storedSessionId
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -581,10 +712,14 @@ export const host = {
|
||||
error instanceof Error &&
|
||||
error.message.startsWith('Timed out loading ')
|
||||
) {
|
||||
const timedOutAt = Date.now()
|
||||
|
||||
console.warn('[bot-wake] hydration timed out', {
|
||||
hydrationWaitMs: Date.now() - profileActiveAt,
|
||||
attempts: wakePhase === 'hydration' ? maxAttempts : 1,
|
||||
hydrationWaitMs: wakePhase === 'hydration' ? timedOutAt - profileActiveAt : 0,
|
||||
phase: wakePhase,
|
||||
profile: targetProfile,
|
||||
profileActivationMs: profileActiveAt - wakeStartedAt,
|
||||
profileActivationMs: (wakePhase === 'activation' ? timedOutAt : profileActiveAt) - wakeStartedAt,
|
||||
runtimeBound: Boolean($activeSessionId.get()),
|
||||
selectionSettled: $selectedStoredSessionId.get() === storedSessionId,
|
||||
storedSessionId,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { planPluginOpenSession } from './plugin-open-session-plan'
|
||||
|
||||
describe('planPluginOpenSession', () => {
|
||||
it('defaults to navigation: dial the worker, do not steal chrome', () => {
|
||||
const plan = planPluginOpenSession({
|
||||
activeProfile: 'default',
|
||||
profile: 'worker'
|
||||
})
|
||||
|
||||
expect(plan.switchWorkspace).toBeNull()
|
||||
expect(plan.dialWithoutSwitching).toBe('worker')
|
||||
expect(plan.showAllProfiles).toBe(true)
|
||||
expect(plan.requireActiveProfileForHydration).toBe(false)
|
||||
})
|
||||
|
||||
it('does not steal chrome even when keepAllProfilesScope is explicitly true', () => {
|
||||
const plan = planPluginOpenSession({
|
||||
activeProfile: 'default',
|
||||
keepAllProfilesScope: true,
|
||||
profile: 'worker'
|
||||
})
|
||||
|
||||
expect(plan.switchWorkspace).toBeNull()
|
||||
expect(plan.dialWithoutSwitching).toBe('worker')
|
||||
expect(plan.showAllProfiles).toBe(true)
|
||||
expect(plan.requireActiveProfileForHydration).toBe(false)
|
||||
})
|
||||
|
||||
it('does not re-dial when chrome is already on that profile', () => {
|
||||
const plan = planPluginOpenSession({
|
||||
activeProfile: 'worker',
|
||||
keepAllProfilesScope: true,
|
||||
profile: 'worker'
|
||||
})
|
||||
|
||||
expect(plan.switchWorkspace).toBeNull()
|
||||
expect(plan.dialWithoutSwitching).toBeNull()
|
||||
// Still restores the unified list: re-opening an already-live bot must not
|
||||
// leave Sessions collapsed onto a profile whose forever-chat is hidden.
|
||||
expect(plan.showAllProfiles).toBe(true)
|
||||
})
|
||||
|
||||
it('treats keepAllProfilesScope:false as an explicit workspace switch', () => {
|
||||
const plan = planPluginOpenSession({
|
||||
activeProfile: 'default',
|
||||
keepAllProfilesScope: false,
|
||||
profile: 'worker'
|
||||
})
|
||||
|
||||
expect(plan.switchWorkspace).toBe('worker')
|
||||
expect(plan.dialWithoutSwitching).toBeNull()
|
||||
expect(plan.showAllProfiles).toBe(false)
|
||||
expect(plan.requireActiveProfileForHydration).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an empty profile as neither a dial nor a workspace switch', () => {
|
||||
const plan = planPluginOpenSession({
|
||||
activeProfile: 'default',
|
||||
keepAllProfilesScope: true,
|
||||
profile: ' '
|
||||
})
|
||||
|
||||
expect(plan.switchWorkspace).toBeNull()
|
||||
expect(plan.dialWithoutSwitching).toBeNull()
|
||||
expect(plan.showAllProfiles).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
/** How `host.openSession` should treat a named profile.
|
||||
|
||||
A plugin/Bot Mode open is navigation, not a workspace or chrome API-home
|
||||
switch. `keepAllProfilesScope` defaults true (undefined counts as true).
|
||||
Pass false for an explicit profile workspace switch. */
|
||||
export interface PluginOpenSessionPlan {
|
||||
/** Dial this profile's backend without making it chrome-home. */
|
||||
dialWithoutSwitching: null | string
|
||||
/** Hydration may wait until `$activeGatewayProfile` matches the target. */
|
||||
requireActiveProfileForHydration: boolean
|
||||
/** Unified Sessions list vs collapse onto the switched profile. Null = leave. */
|
||||
showAllProfiles: boolean | null
|
||||
/** Activate this profile as the workspace/API home. Null = do not steal chrome. */
|
||||
switchWorkspace: null | string
|
||||
}
|
||||
|
||||
export function planPluginOpenSession(input: {
|
||||
activeProfile: string
|
||||
keepAllProfilesScope?: boolean
|
||||
profile: string
|
||||
}): PluginOpenSessionPlan {
|
||||
const profile = (input.profile ?? '').trim()
|
||||
const activeProfile = (input.activeProfile ?? '').trim()
|
||||
const keepWorkspace = input.keepAllProfilesScope !== false
|
||||
|
||||
if (!profile) {
|
||||
return {
|
||||
dialWithoutSwitching: null,
|
||||
requireActiveProfileForHydration: false,
|
||||
showAllProfiles: null,
|
||||
switchWorkspace: null
|
||||
}
|
||||
}
|
||||
|
||||
if (keepWorkspace) {
|
||||
return {
|
||||
dialWithoutSwitching: profile !== activeProfile ? profile : null,
|
||||
requireActiveProfileForHydration: false,
|
||||
showAllProfiles: true,
|
||||
switchWorkspace: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dialWithoutSwitching: null,
|
||||
requireActiveProfileForHydration: true,
|
||||
showAllProfiles: false,
|
||||
switchWorkspace: profile
|
||||
}
|
||||
}
|
||||
@@ -102,10 +102,18 @@ vi.mock('@/store/gateway', async () => {
|
||||
const { host } = await import('./index')
|
||||
const { openSession: openSessionCore } = await import('@/app/open-session')
|
||||
const { deleteProfile } = await import('@/hermes')
|
||||
const { requestGatewayForAgent, requestGatewayForProfile, retireLocalProfileGateways } = await import('@/store/gateway')
|
||||
|
||||
const { $activeGatewayProfile, $gatewaySwapTarget, $profiles, ensureGatewayProfile, refreshProfiles } =
|
||||
await import('@/store/profile')
|
||||
const { openGatewayForProfile, requestGatewayForAgent, requestGatewayForProfile, retireLocalProfileGateways } =
|
||||
await import('@/store/gateway')
|
||||
|
||||
const {
|
||||
$activeGatewayProfile,
|
||||
$gatewaySwapTarget,
|
||||
$profiles,
|
||||
ensureGatewayProfile,
|
||||
refreshProfiles,
|
||||
setShowAllProfiles
|
||||
} = await import('@/store/profile')
|
||||
|
||||
const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } = await import('@/store/session-states')
|
||||
|
||||
@@ -290,9 +298,7 @@ describe('connection-aware plugin host APIs', () => {
|
||||
|
||||
describe('profile-aware plugin session opens', () => {
|
||||
it('waits until the target Bot Chat runtime and history are on main before resolving', async () => {
|
||||
vi.mocked(ensureGatewayProfile).mockImplementationOnce(async (target: null | string | undefined) => {
|
||||
$activeGatewayProfile.set(target || 'default')
|
||||
})
|
||||
vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined)
|
||||
|
||||
let resolved = false
|
||||
|
||||
@@ -307,8 +313,12 @@ describe('profile-aware plugin session opens', () => {
|
||||
resolved = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
// Flush a macrotask rather than counting microtask ticks: the profile
|
||||
// activation is now a bounded race, so the number of awaits between the
|
||||
// call and the core open is an implementation detail, and `resolved`
|
||||
// staying false through a full turn of the event loop is the stronger
|
||||
// assertion anyway.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(openSessionCore).toHaveBeenCalledWith('bot-chat', expect.any(Function), 'in-place')
|
||||
expect(resolved).toBe(false)
|
||||
@@ -339,9 +349,7 @@ describe('profile-aware plugin session opens', () => {
|
||||
})
|
||||
|
||||
it('requests an explicit resume on a cold open where selection has not settled (#89206)', async () => {
|
||||
vi.mocked(ensureGatewayProfile).mockImplementationOnce(async (target: null | string | undefined) => {
|
||||
$activeGatewayProfile.set(target || 'default')
|
||||
})
|
||||
vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined)
|
||||
|
||||
// Cold-start shape from the field: the persisted route already points at
|
||||
// the bot's stored session, but no selection, no runtime, no transcript.
|
||||
@@ -356,8 +364,7 @@ describe('profile-aware plugin session opens', () => {
|
||||
hydrationTimeoutMs: 1_000
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// The old pre-open "already selected" precondition skipped this request,
|
||||
// stranding the pane blank until timeout. It must fire now.
|
||||
@@ -395,9 +402,7 @@ describe('profile-aware plugin session opens', () => {
|
||||
})
|
||||
|
||||
it('resolves a history-bearing wake on transcript paint without waiting for the runtime (paint-first)', async () => {
|
||||
vi.mocked(ensureGatewayProfile).mockImplementationOnce(async (target: null | string | undefined) => {
|
||||
$activeGatewayProfile.set(target || 'default')
|
||||
})
|
||||
vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined)
|
||||
|
||||
setMockAtom($selectedStoredSessionId, null)
|
||||
setMockAtom($activeSessionId, null)
|
||||
@@ -468,6 +473,59 @@ describe('profile-aware plugin session opens', () => {
|
||||
expect($gatewaySwapTarget.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('retries a Bot Chat hydration timeout once without ever arming the Retry surface (#89617)', async () => {
|
||||
$activeGatewayProfile.set('hyoseob')
|
||||
|
||||
// First attempt: leave the surface unhealthy so the hydration wait below
|
||||
// times out, exactly like a profile backend still waking up. Second
|
||||
// attempt: the backend is warm now — hydrate immediately. Queued as two
|
||||
// one-shots (not a standing mockImplementation) so this doesn't leak into
|
||||
// later tests via the shared afterEach's vi.clearAllMocks(), which clears
|
||||
// call history but not a standing implementation.
|
||||
vi.mocked(openSessionCore)
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => {
|
||||
setMockAtom($selectedStoredSessionId, 'waking-bot-chat')
|
||||
setMockAtom($activeSessionId, 'runtime-waking')
|
||||
setMockAtom($messages, [{ id: 'history-waking', parts: [], role: 'assistant' }] as never)
|
||||
})
|
||||
|
||||
await host.openSession('waking-bot-chat', {
|
||||
profile: 'hyoseob',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
hydrationTimeoutMs: 1,
|
||||
retryHydrationTimeoutOnce: true
|
||||
})
|
||||
|
||||
expect(openSessionCore).toHaveBeenCalledTimes(2)
|
||||
// The overlay in apps/desktop/src/app/chat/index.tsx is gated purely on
|
||||
// this atom equalling the routed session id — if it were ever set here,
|
||||
// the user would land on "Couldn't load this session" even though the
|
||||
// retry above succeeded underneath, since only a manual resumeSession()
|
||||
// call clears it for the currently-routed session.
|
||||
expect(setResumeExhaustedSessionId).not.toHaveBeenCalled()
|
||||
expect($gatewaySwapTarget.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('still arms the Retry surface when a retried Bot Chat hydration times out twice', async () => {
|
||||
$activeGatewayProfile.set('hyoseob')
|
||||
|
||||
await expect(
|
||||
host.openSession('stranded-bot-chat', {
|
||||
profile: 'hyoseob',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
hydrationTimeoutMs: 1,
|
||||
retryHydrationTimeoutOnce: true
|
||||
})
|
||||
).rejects.toThrow(/timed out loading/i)
|
||||
|
||||
expect(openSessionCore).toHaveBeenCalledTimes(2)
|
||||
expect(setResumeExhaustedSessionId).toHaveBeenCalledWith('stranded-bot-chat')
|
||||
expect($gatewaySwapTarget.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('lets the latest rapid bot selection win and cancels the older hydration wait', async () => {
|
||||
vi.mocked(ensureGatewayProfile).mockImplementation(async (target: null | string | undefined) => {
|
||||
$activeGatewayProfile.set(target || 'default')
|
||||
@@ -500,8 +558,240 @@ describe('profile-aware plugin session opens', () => {
|
||||
|
||||
await second
|
||||
expect(await firstOutcome).toMatch(/superseded/i)
|
||||
expect($activeGatewayProfile.get()).toBe('hyoseob')
|
||||
expect($activeGatewayProfile.get()).toBe('remote-worker')
|
||||
expect($selectedStoredSessionId.get()).toBe('chat-b')
|
||||
expect($gatewaySwapTarget.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps chrome API home on the previous profile when opening a Bot Chat', async () => {
|
||||
$activeGatewayProfile.set('default')
|
||||
|
||||
await host.openSession('bot-chat', {
|
||||
profile: 'worker',
|
||||
keepAllProfilesScope: true
|
||||
})
|
||||
|
||||
expect(ensureGatewayProfile).not.toHaveBeenCalled()
|
||||
expect(openGatewayForProfile).toHaveBeenCalledWith('worker')
|
||||
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
|
||||
expect($activeGatewayProfile.get()).toBe('default')
|
||||
})
|
||||
|
||||
it('defaults keepAllProfilesScope to navigation instead of a workspace switch', async () => {
|
||||
$activeGatewayProfile.set('default')
|
||||
|
||||
await host.openSession('bot-chat', { profile: 'worker' })
|
||||
|
||||
expect(ensureGatewayProfile).not.toHaveBeenCalled()
|
||||
expect(openGatewayForProfile).toHaveBeenCalledWith('worker')
|
||||
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
|
||||
expect($activeGatewayProfile.get()).toBe('default')
|
||||
})
|
||||
|
||||
it('still switches workspace when keepAllProfilesScope is false', async () => {
|
||||
$activeGatewayProfile.set('default')
|
||||
vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined)
|
||||
|
||||
await host.openSession('stored-worker', {
|
||||
profile: 'worker',
|
||||
keepAllProfilesScope: false
|
||||
})
|
||||
|
||||
expect(ensureGatewayProfile).toHaveBeenCalledWith('worker')
|
||||
expect(openGatewayForProfile).not.toHaveBeenCalled()
|
||||
expect(setShowAllProfiles).toHaveBeenCalledWith(false)
|
||||
expect($activeGatewayProfile.get()).toBe('worker')
|
||||
})
|
||||
|
||||
it('keeps the Sessions sidebar in all-profiles even when the bot is already live', async () => {
|
||||
$activeGatewayProfile.set('hyoseob')
|
||||
setMockAtom($selectedStoredSessionId, 'bot-chat')
|
||||
setMockAtom($activeSessionId, 'runtime-live')
|
||||
setMockAtom($messages, [{ id: 'history', parts: [], role: 'assistant' }] as never)
|
||||
|
||||
await host.openSession('bot-chat', {
|
||||
profile: 'hyoseob',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
hydrationTimeoutMs: 1_000
|
||||
})
|
||||
|
||||
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('surfaces a wedged profile activation instead of waiting on it forever (#89556)', async () => {
|
||||
// The dial the store is waiting on never settles - a backend that accepts
|
||||
// the socket and then never completes the handshake. Before this was
|
||||
// bounded, openSession's await sat here for the life of the window and the
|
||||
// hydration timer, which is armed downstream, never got a chance to fire:
|
||||
// the pane wedged with no error and no Retry rather than timing out.
|
||||
vi.mocked(ensureGatewayProfile).mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
|
||||
setMockAtom($selectedStoredSessionId, 'wedged-chat')
|
||||
setMockAtom($activeSessionId, 'runtime-wedged')
|
||||
setMockAtom($messages, [{ id: 'history-1', parts: [], role: 'user' }] as never)
|
||||
|
||||
await expect(
|
||||
host.openSession('wedged-chat', {
|
||||
profile: 'medicina',
|
||||
intent: 'main',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
keepAllProfilesScope: false,
|
||||
hydrationTimeoutMs: 60
|
||||
})
|
||||
).rejects.toThrow("Timed out loading medicina's session history.")
|
||||
|
||||
// The stranded-session surface is the point: a bounded failure the user can
|
||||
// retry, not a frozen pane.
|
||||
expect(setResumeExhaustedSessionId).toHaveBeenCalledWith('wedged-chat')
|
||||
expect($gatewaySwapTarget.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('names the phase in the wake log so a stuck dial is not read as a slow transcript', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
vi.mocked(ensureGatewayProfile).mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
|
||||
await expect(
|
||||
host.openSession('wedged-chat', {
|
||||
profile: 'medicina',
|
||||
intent: 'main',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
keepAllProfilesScope: false,
|
||||
hydrationTimeoutMs: 60
|
||||
})
|
||||
).rejects.toThrow('Timed out loading ')
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[bot-wake] hydration timed out',
|
||||
expect.objectContaining({ phase: 'activation' })
|
||||
)
|
||||
|
||||
const payload = warn.mock.calls.at(-1)?.[1] as { hydrationWaitMs: number; profileActivationMs: number }
|
||||
|
||||
// The whole budget went to activation. Reporting it as hydration wait time
|
||||
// would send a support bundle reader looking at transcript size.
|
||||
expect(payload.profileActivationMs).toBeGreaterThan(0)
|
||||
expect(payload.hydrationWaitMs).toBe(0)
|
||||
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('gives hydration its own full budget after a slow but successful activation', async () => {
|
||||
// Guards the choice of a SEPARATE budget per phase over one shared clock.
|
||||
// A cold profile backend can legitimately spend most of the budget getting
|
||||
// its socket up; if hydration then inherited what was left, this wake would
|
||||
// fail even though the transcript painted well inside the documented
|
||||
// 20s-equivalent window.
|
||||
vi.mocked(ensureGatewayProfile).mockImplementationOnce(
|
||||
async (target: null | string | undefined) =>
|
||||
new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
$activeGatewayProfile.set(target || 'default')
|
||||
resolve()
|
||||
}, 150)
|
||||
})
|
||||
)
|
||||
|
||||
const opening = host.openSession('slow-dial-chat', {
|
||||
profile: 'medicina',
|
||||
intent: 'main',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
keepAllProfilesScope: false,
|
||||
hydrationTimeoutMs: 200
|
||||
})
|
||||
|
||||
// 300ms total is past a single shared 200ms budget and inside hydration's
|
||||
// own one, which only starts once the profile is actually active.
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
setMockAtom($selectedStoredSessionId, 'slow-dial-chat')
|
||||
setMockAtom($activeSessionId, 'runtime-medicina')
|
||||
setMockAtom($messages, [{ id: 'history-1', parts: [], role: 'user' }] as never)
|
||||
|
||||
await expect(opening).resolves.toBeUndefined()
|
||||
expect(setResumeExhaustedSessionId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('absorbs an activation that rejects after its budget has already expired', async () => {
|
||||
// The abandoned race loser keeps running - there is no cancellation handle
|
||||
// for an in-flight dial - so a late rejection must not escape as an
|
||||
// unhandled promise rejection long after the caller gave up.
|
||||
// Node's process-level hook, not window's: under jsdom a rejection that
|
||||
// escapes lands on the runner's process, which is where vitest turns it
|
||||
// into an "Unhandled Rejection" run failure.
|
||||
const unhandled: unknown[] = []
|
||||
|
||||
const onUnhandled = (reason: unknown) => {
|
||||
unhandled.push(reason)
|
||||
}
|
||||
|
||||
const existing = process.listeners('unhandledRejection')
|
||||
|
||||
for (const listener of existing) {
|
||||
process.off('unhandledRejection', listener)
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
|
||||
vi.mocked(ensureGatewayProfile).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => reject(new Error('dial failed after the caller gave up')), 120)
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
host.openSession('late-failure-chat', {
|
||||
profile: 'medicina',
|
||||
intent: 'main',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
keepAllProfilesScope: false,
|
||||
hydrationTimeoutMs: 40
|
||||
})
|
||||
).rejects.toThrow('Timed out loading ')
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
|
||||
for (const listener of existing) {
|
||||
process.on('unhandledRejection', listener)
|
||||
}
|
||||
|
||||
expect(unhandled).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a plain open unbounded, because it has no budget and no Retry surface', async () => {
|
||||
// Deliberate scope line, not an oversight: the activation deadline rides on
|
||||
// the same contract as the hydration one. A caller that never passed
|
||||
// awaitHydration gets exactly the behaviour it had before, since a
|
||||
// rejection here would surface to code with nowhere to render it.
|
||||
vi.mocked(ensureGatewayProfile).mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
|
||||
let settled = 'pending'
|
||||
|
||||
void host
|
||||
.openSession('plain-chat', {
|
||||
profile: 'medicina',
|
||||
intent: 'main',
|
||||
keepAllProfilesScope: false,
|
||||
hydrationTimeoutMs: 40
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
settled = 'resolved'
|
||||
},
|
||||
() => {
|
||||
settled = 'rejected'
|
||||
}
|
||||
)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
expect(settled).toBe('pending')
|
||||
expect(setResumeExhaustedSessionId).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -573,7 +573,9 @@ async function gatewayForProfile(
|
||||
export async function requestGatewayForProfile<T>(
|
||||
profile: string,
|
||||
method: string,
|
||||
params: Record<string, unknown> = {}
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const route = await gatewayForProfile(profile, true)
|
||||
|
||||
@@ -584,7 +586,12 @@ export async function requestGatewayForProfile<T>(
|
||||
|
||||
const routedParams = route.scopeProfile ? { ...params, profile: route.key } : params
|
||||
|
||||
return await route.gateway.request<T>(method, routedParams)
|
||||
// Same arity contract as the ambient path in session-request-router: only
|
||||
// pass the deadline args through when the caller set them, so a plain
|
||||
// profile-routed RPC keeps its two-argument call shape.
|
||||
return await (timeoutMs === undefined && signal === undefined
|
||||
? route.gateway.request<T>(method, routedParams)
|
||||
: route.gateway.request<T>(method, routedParams, timeoutMs, signal))
|
||||
} finally {
|
||||
route.release()
|
||||
}
|
||||
|
||||
@@ -167,6 +167,38 @@ describe('requestForSessionProfile', () => {
|
||||
expect(secondaryGateways[0].request).toHaveBeenCalledWith('session.resume', { session_id: 'stored-loki-chat' })
|
||||
})
|
||||
|
||||
it('forwards timeout and abort signal onto the owning profile socket', async () => {
|
||||
const primary = makePrimary()
|
||||
setPrimaryGateway(primary as never, 'default')
|
||||
installDesktop()
|
||||
await ensureGatewayForProfile('default')
|
||||
|
||||
const ambient = vi.fn(async (method: string, params?: Record<string, unknown>) => ({
|
||||
ambient: true,
|
||||
method,
|
||||
params
|
||||
}))
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
await requestForSessionProfile(
|
||||
'loki',
|
||||
ambient as never,
|
||||
'prompt.submit',
|
||||
{ session_id: 'stored-loki-chat', text: 'hi' },
|
||||
1_800_000,
|
||||
controller.signal
|
||||
)
|
||||
|
||||
expect(ambient).not.toHaveBeenCalled()
|
||||
expect(secondaryGateways[0].request).toHaveBeenCalledWith(
|
||||
'prompt.submit',
|
||||
{ session_id: 'stored-loki-chat', text: 'hi' },
|
||||
1_800_000,
|
||||
controller.signal
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the ambient dispatcher when the active route already serves the owner', async () => {
|
||||
const primary = makePrimary()
|
||||
setPrimaryGateway(primary as never, 'default')
|
||||
|
||||
@@ -40,13 +40,28 @@ export function sessionRpcNeedsProfileRoute(
|
||||
*/
|
||||
export function requestForSessionProfile<T>(
|
||||
ownerProfile: null | string | undefined,
|
||||
ambientRequest: <R>(method: string, params?: Record<string, unknown>) => Promise<R>,
|
||||
ambientRequest: <R>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
signal?: AbortSignal
|
||||
) => Promise<R>,
|
||||
method: string,
|
||||
params: Record<string, unknown> = {}
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (!sessionRpcNeedsProfileRoute(ownerProfile)) {
|
||||
return ambientRequest<T>(method, params)
|
||||
// Forward the extra args only when the caller actually supplied them. The
|
||||
// ambient dispatcher is a plain gateway request whose arity callers assert
|
||||
// on; handing it a trailing `undefined, undefined` on every session RPC
|
||||
// changes the observed call shape for the many callers that never asked
|
||||
// for a deadline (the plugin host bridge in contrib/wiring is the only one
|
||||
// that does).
|
||||
return timeoutMs === undefined && signal === undefined
|
||||
? ambientRequest<T>(method, params)
|
||||
: ambientRequest<T>(method, params, timeoutMs, signal)
|
||||
}
|
||||
|
||||
return requestGatewayForProfile<T>(normKey(ownerProfile), method, params)
|
||||
return requestGatewayForProfile<T>(normKey(ownerProfile), method, params, timeoutMs, signal)
|
||||
}
|
||||
|
||||
@@ -400,6 +400,28 @@ describe('glass is confined to chat windows', () => {
|
||||
// The mode is still the user's choice — only the page rewrite is withheld.
|
||||
expect($translucency.get().mode).toBe('glass')
|
||||
expect(document.documentElement.hasAttribute('data-hermes-glass')).toBe(false)
|
||||
})
|
||||
|
||||
// The HUD paints its band from the app's field mix, so it needs the setting
|
||||
// and the tint number even though its surfaces must not be rewritten. The
|
||||
// two flags are what keep those separable: keying the band off
|
||||
// `data-hermes-glass` would silently never match.
|
||||
it('still publishes the live setting and the tint to a special-purpose window', () => {
|
||||
setSearch('?win=hud')
|
||||
setTranslucency(60)
|
||||
setTranslucencyMode('glass')
|
||||
|
||||
expect(document.documentElement.hasAttribute('data-hermes-glass-on')).toBe(true)
|
||||
expect(document.documentElement.style.getPropertyValue('--translucency-glass-keep')).toBe('40%')
|
||||
})
|
||||
|
||||
it('withdraws both flags when glass is switched off', () => {
|
||||
setSearch('?win=hud')
|
||||
setTranslucency(60)
|
||||
setTranslucencyMode('glass')
|
||||
setTranslucencyMode('clear')
|
||||
|
||||
expect(document.documentElement.hasAttribute('data-hermes-glass-on')).toBe(false)
|
||||
expect(document.documentElement.style.getPropertyValue('--translucency-glass-keep')).toBe('')
|
||||
})
|
||||
|
||||
|
||||
@@ -262,24 +262,38 @@ const applyGlassSurfaces = ({ intensity, mode, scope }: TranslucencyState): void
|
||||
}
|
||||
|
||||
const root = document.documentElement
|
||||
const glassOn = mode === 'glass' && intensity > 0 && GLASS_SUPPORTED && isChatWindow()
|
||||
// Is the user's Glass setting live at all — the same answer in every window.
|
||||
const glassLive = mode === 'glass' && intensity > 0 && GLASS_SUPPORTED
|
||||
// ...and may THIS window's field surfaces be rewritten for it. Only real
|
||||
// chat windows: the HUD, pet overlay, quick entry and wake indicator are
|
||||
// transparent windows that own their backgrounds, and the surface rewrite
|
||||
// would fight them. The HUD still wants the first answer, because its band
|
||||
// paints the app's field mix from `--translucency-glass-keep` and its native
|
||||
// frost is gated on the setting being on (see the `[data-hud-glass]` rules
|
||||
// and hudFrostFor) — which is why these are two flags and not one.
|
||||
const glassOn = glassLive && isChatWindow()
|
||||
// Clear mode fades the whole window uniformly, so overlay text and the
|
||||
// covered transcript blend; styles.css strengthens the overlay scrim while
|
||||
// this attribute is present. Native opacity applies in every window kind, so
|
||||
// no chat-window gate.
|
||||
const clearOn = mode === 'clear' && intensity > 0
|
||||
|
||||
root.toggleAttribute('data-hermes-glass-on', glassLive)
|
||||
root.toggleAttribute('data-hermes-glass', glassOn)
|
||||
root.toggleAttribute('data-hermes-clear', clearOn)
|
||||
|
||||
if (glassOn) {
|
||||
root.setAttribute('data-hermes-glass-scope', scope)
|
||||
if (glassLive) {
|
||||
root.style.setProperty('--translucency-glass-keep', `${glassSurfaceKeep(intensity)}%`)
|
||||
} else {
|
||||
root.removeAttribute('data-hermes-glass-scope')
|
||||
root.style.removeProperty('--translucency-glass-keep')
|
||||
}
|
||||
|
||||
if (glassOn) {
|
||||
root.setAttribute('data-hermes-glass-scope', scope)
|
||||
} else {
|
||||
root.removeAttribute('data-hermes-glass-scope')
|
||||
}
|
||||
|
||||
if (glassOn && scope === 'sidebar') {
|
||||
startRailTracking()
|
||||
} else {
|
||||
|
||||
+74
-152
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user