Merge pull request #81552 from NousResearch/bb/hud-mode
HUD mode: a chrome-free floating chat for the desktop app
This commit is contained in:
commit
31cedb4830
|
|
@ -9034,6 +9034,289 @@ function closePetOverlay() {
|
|||
petOverlayWindow = null
|
||||
}
|
||||
|
||||
// ── HUD mode ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The chrome-free floating chat: a transparent, frameless, always-on-top
|
||||
// window showing only the composer and its scrollback, so Hermes can be driven
|
||||
// while the user works in another app.
|
||||
//
|
||||
// Unlike the pet overlay / quick entry, this is a FULL app renderer with its
|
||||
// own gateway — the same thing createInstanceWindow() spawns, reshaped. That
|
||||
// is deliberate: the HUD renders the real chat surface, so its composer is the
|
||||
// app's composer (slash commands, attachments, queue, voice) instead of a
|
||||
// lookalike that drifts. Entering HUD mode hides the main window; leaving
|
||||
// restores it.
|
||||
let hudWindow = null
|
||||
|
||||
// Whether the main window was visible when HUD mode was entered, so exiting
|
||||
// puts the desktop back as it was rather than raising a window the user had
|
||||
// already minimized.
|
||||
let hudRestoreMainWindow = false
|
||||
|
||||
// The session the HUD is currently on, reported by its renderer whenever the
|
||||
// selection changes. Leaving HUD mode is a HANDOFF, not just a window close:
|
||||
// the gateway binds a session's event stream to exactly one socket, so the
|
||||
// turn the HUD started is streaming to the HUD's socket and the app window
|
||||
// hears nothing. The app has to re-resume that session to take the stream
|
||||
// back, and it can only do that if it knows which session to ask for — the
|
||||
// HUD may have switched sessions, or started a new one the app has never
|
||||
// seen. Main is the only party that outlives the HUD's renderer, so it holds
|
||||
// the id and hands it over in the close broadcast.
|
||||
let hudSessionId = null
|
||||
|
||||
// A wide, short bar parked near the bottom of the active display — the shape
|
||||
// of a game chat frame, and where one belongs. Defaults only: once the user
|
||||
// moves or resizes the HUD, hud-state.json wins (same pattern as the main
|
||||
// window's window-state.json).
|
||||
const HUD_WIDTH = 620
|
||||
const HUD_HEIGHT = 320
|
||||
const HUD_BOTTOM_MARGIN = 72
|
||||
const HUD_STATE_PATH = path.join(app.getPath('userData'), 'hud-state.json')
|
||||
|
||||
function readHudState() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(HUD_STATE_PATH, 'utf8'))
|
||||
|
||||
if (
|
||||
[raw?.x, raw?.y, raw?.width, raw?.height].every(v => Number.isFinite(v)) &&
|
||||
raw.width >= 380 &&
|
||||
raw.height >= 160
|
||||
) {
|
||||
return raw
|
||||
}
|
||||
} catch {
|
||||
// First run / unreadable — fall through to defaults.
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function persistHudState() {
|
||||
if (!hudWindow || hudWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { x, y, width, height } = hudWindow.getNormalBounds()
|
||||
fs.mkdirSync(path.dirname(HUD_STATE_PATH), { recursive: true })
|
||||
writeFileAtomic(HUD_STATE_PATH, JSON.stringify({ x, y, width, height }, null, 2))
|
||||
} catch (err) {
|
||||
rememberLog(`[hud-state] persist failed: ${err?.message || err}`)
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePersistHudState = debounce(persistHudState, 250)
|
||||
|
||||
function hudBounds() {
|
||||
// Remembered spot first — validated against the LIVE displays so a HUD
|
||||
// parked on an unplugged monitor comes back on-screen instead of lost.
|
||||
const saved = readHudState()
|
||||
|
||||
if (saved) {
|
||||
const onScreen = screen.getAllDisplays().some(d => {
|
||||
const a = d.workArea
|
||||
|
||||
return (
|
||||
saved.x < a.x + a.width - 40 &&
|
||||
saved.x + saved.width > a.x + 40 &&
|
||||
saved.y < a.y + a.height - 40 &&
|
||||
saved.y + saved.height > a.y + 40
|
||||
)
|
||||
})
|
||||
|
||||
if (onScreen) {
|
||||
return saved
|
||||
}
|
||||
}
|
||||
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const area = display?.workArea
|
||||
|
||||
if (!area) {
|
||||
return { width: HUD_WIDTH, height: HUD_HEIGHT, x: undefined, y: undefined }
|
||||
}
|
||||
|
||||
const width = Math.min(HUD_WIDTH, area.width)
|
||||
const height = Math.min(HUD_HEIGHT, area.height)
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
x: Math.round(area.x + (area.width - width) / 2),
|
||||
y: Math.round(Math.max(area.y, area.y + area.height - height - HUD_BOTTOM_MARGIN))
|
||||
}
|
||||
}
|
||||
|
||||
function hudUrl(sessionId) {
|
||||
const query = '?win=hud'
|
||||
const route = sessionId ? `#/${encodeURIComponent(sessionId)}` : '#/'
|
||||
|
||||
if (DEV_SERVER) {
|
||||
return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/${query}${route}`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(resolveRendererIndex()).toString()}${query}${route}`
|
||||
}
|
||||
|
||||
// Tell every window whether the HUD is up, so a toggle in any of them reads
|
||||
// the truth even when the HUD is closed from its own side (⌘W / its exit row).
|
||||
// Carries the HUD's session so the app window can re-home onto it on the way
|
||||
// out (see hudSessionId).
|
||||
function broadcastHudState(open) {
|
||||
const payload = { open, sessionId: hudSessionId }
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('hermes:hud:changed', payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function spawnHudWindow(sessionId) {
|
||||
const win = new BrowserWindow({
|
||||
...hudBounds(),
|
||||
minWidth: 380,
|
||||
minHeight: 160,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: true,
|
||||
movable: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
// Same rationale as the pet overlay: on Windows/Linux keep the helper out
|
||||
// of the taskbar/alt-tab list; on macOS use an NSPanel so the frameless
|
||||
// window never becomes the app's cmd-tab anchor.
|
||||
skipTaskbar: !IS_MAC,
|
||||
hasShadow: false,
|
||||
alwaysOnTop: true,
|
||||
type: IS_MAC ? 'panel' : undefined,
|
||||
// Clips the vibrancy layer to the HUD's silhouette rather than a hard
|
||||
// rectangle — the frost stops where the window's corners do.
|
||||
roundedCorners: true,
|
||||
// Vibrancy must keep rendering while the window is BLURRED: streaming under
|
||||
// another app is the whole feature, and the default 'followWindow' kills
|
||||
// the frost the moment something else takes focus.
|
||||
visualEffectState: 'active',
|
||||
hiddenInMissionControl: IS_MAC,
|
||||
show: false,
|
||||
backgroundColor: '#00000000',
|
||||
// The full chat webPreferences — this window streams a real transcript, so
|
||||
// it needs everything a chat window needs (preload bridge, autoplay for
|
||||
// voice, the shared throttling contract).
|
||||
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
|
||||
})
|
||||
|
||||
win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver')
|
||||
win.setHiddenInMissionControl?.(true)
|
||||
|
||||
try {
|
||||
win.setVisibleOnAllWorkspaces(
|
||||
true,
|
||||
IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined
|
||||
)
|
||||
} catch {
|
||||
// Not supported everywhere — best effort.
|
||||
}
|
||||
|
||||
// Streaming into a window that is ALWAYS blurred (the user is in another
|
||||
// app) is the entire feature, so it gets the same stream-aware unthrottling
|
||||
// every chat window does.
|
||||
streamThrottle.register(win)
|
||||
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))
|
||||
|
||||
// Remember where the user parks and sizes it (debounced — these fire many
|
||||
// times mid-drag).
|
||||
win.on('moved', schedulePersistHudState)
|
||||
win.on('resized', schedulePersistHudState)
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
win.show()
|
||||
win.focus()
|
||||
|
||||
// Step the app aside: the HUD IS the surface now.
|
||||
if (hudRestoreMainWindow && mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.hide()
|
||||
}
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
if (hudWindow === win) {
|
||||
hudWindow = null
|
||||
}
|
||||
|
||||
// Closed from its own side (⌘W) — put the app back so the user is never
|
||||
// left with no surface, and correct every window's toggle.
|
||||
restoreMainWindowFromHud()
|
||||
broadcastHudState(false)
|
||||
})
|
||||
|
||||
loadWindowUrl(win, hudUrl(sessionId), 'HUD')
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
// Put the app window back the way HUD mode found it.
|
||||
function restoreMainWindowFromHud() {
|
||||
if (!hudRestoreMainWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
hudRestoreMainWindow = false
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
}
|
||||
|
||||
function openHudWindow(sessionId) {
|
||||
if (hudWindow && !hudWindow.isDestroyed()) {
|
||||
// Already up, but pointed somewhere else — switch it rather than just
|
||||
// raising it. Asking for HUD mode from another tab means "put THIS
|
||||
// conversation in the HUD", and a plain focus leaves the wrong one there.
|
||||
if (sessionId && sessionId !== hudSessionId) {
|
||||
hudSessionId = sessionId
|
||||
hudWindow.webContents.send('hermes:hud:goto', sessionId)
|
||||
// Keep every window's idea of where the HUD is pointed in step, so the
|
||||
// toggle keeps reading "switch" vs "dismiss" correctly.
|
||||
broadcastHudState(true)
|
||||
}
|
||||
|
||||
focusWindow(hudWindow)
|
||||
|
||||
return hudWindow
|
||||
}
|
||||
|
||||
hudRestoreMainWindow = Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible())
|
||||
hudSessionId = sessionId || null
|
||||
hudWindow = spawnHudWindow(sessionId)
|
||||
broadcastHudState(true)
|
||||
|
||||
return hudWindow
|
||||
}
|
||||
|
||||
function closeHudWindow() {
|
||||
const win = hudWindow
|
||||
hudWindow = null
|
||||
|
||||
if (win && !win.isDestroyed()) {
|
||||
// Null'd first so the 'closed' handler doesn't broadcast a second time.
|
||||
win.removeAllListeners('closed')
|
||||
win.close()
|
||||
}
|
||||
|
||||
restoreMainWindowFromHud()
|
||||
broadcastHudState(false)
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
focusWindow(mainWindow)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quick Entry ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A global shortcut summons a small frameless always-on-top composer from
|
||||
|
|
@ -9702,6 +9985,51 @@ ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
|
|||
|
||||
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
|
||||
})
|
||||
|
||||
// --- HUD mode (chrome-free floating chat) -----------------------------------
|
||||
ipcMain.handle('hermes:hud:open', async (_event, request) => {
|
||||
openHudWindow(typeof request?.sessionId === 'string' ? request.sessionId : null)
|
||||
|
||||
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) => {
|
||||
if (hudWindow && !hudWindow.isDestroyed() && IS_MAC) {
|
||||
hudWindow.setVibrancy(on ? 'hud' : null)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// Let clicks fall through the HUD wherever it isn't really there. An
|
||||
// always-on-top window eats every click inside its rectangle, and most of that
|
||||
// rectangle is a faded-out band over whatever the user is actually working in.
|
||||
// `forward` keeps mousemove flowing so the renderer can re-arm when the cursor
|
||||
// reaches the bar.
|
||||
ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => {
|
||||
if (hudWindow && !hudWindow.isDestroyed()) {
|
||||
hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
|
||||
}
|
||||
})
|
||||
|
||||
// The HUD renderer reporting which session it is on, so the close broadcast
|
||||
// can hand it back to the app window (see hudSessionId).
|
||||
ipcMain.on('hermes:hud:session', (event, sessionId) => {
|
||||
if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) {
|
||||
hudSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null
|
||||
}
|
||||
})
|
||||
ipcMain.handle('hermes:hud:close', async () => {
|
||||
closeHudWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
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
|
||||
|
|
@ -12001,6 +12329,17 @@ app.on('before-quit', event => {
|
|||
closePetOverlay()
|
||||
wakeIndicatorController.close()
|
||||
|
||||
// Same for the HUD — an always-on-top panel outliving the app would leave a
|
||||
// floating composer with nothing behind it. Close it directly rather than via
|
||||
// closeHudWindow(): that also re-shows the main window, which is wrong on the
|
||||
// way out (and `hudRestoreMainWindow` may still be armed from entering HUD).
|
||||
if (hudWindow && !hudWindow.isDestroyed()) {
|
||||
hudWindow.removeAllListeners('closed')
|
||||
hudWindow.destroy()
|
||||
}
|
||||
|
||||
hudWindow = null
|
||||
|
||||
// Same for the Quick Entry composer — and release its global accelerator so a
|
||||
// quitting Hermes never keeps another app's chord hostage.
|
||||
closeQuickEntryWindow()
|
||||
|
|
|
|||
|
|
@ -46,6 +46,30 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
|
||||
}
|
||||
},
|
||||
// HUD mode: the chrome-free floating chat. A full app renderer (own gateway)
|
||||
// sized as a floating bar, so it mounts the real composer. Main owns the
|
||||
// window; `onChanged` keeps every window's toggle truthful.
|
||||
hud: {
|
||||
open: request => ipcRenderer.invoke('hermes:hud:open', request),
|
||||
close: () => ipcRenderer.invoke('hermes:hud:close'),
|
||||
setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore),
|
||||
setVibrancy: on => ipcRenderer.invoke('hermes:hud:vibrancy', on),
|
||||
// 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),
|
||||
onGoto: callback => {
|
||||
const listener = (_event, sessionId) => callback(sessionId)
|
||||
ipcRenderer.on('hermes:hud:goto', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:hud:goto', listener)
|
||||
},
|
||||
onChanged: callback => {
|
||||
const listener = (_event, state) => callback(state)
|
||||
ipcRenderer.on('hermes:hud:changed', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:hud:changed', listener)
|
||||
}
|
||||
},
|
||||
// Quick Entry: the global-hotkey mini composer window. Main owns the OS
|
||||
// shortcut + the persisted preference; the quick window only captures text
|
||||
// and hands it back, and the primary renderer submits it through the normal
|
||||
|
|
|
|||
|
|
@ -98,9 +98,10 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc
|
|||
}
|
||||
|
||||
/**
|
||||
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
|
||||
* global UI zoom; the pet overlay and the Quick Entry composer opt out because
|
||||
* they size their own OS window and inheriting zoom would crop/overflow them.
|
||||
* Zoom-wiring decision per window kind. Chat windows (main + session + the HUD)
|
||||
* keep global UI zoom; the pet overlay and the Quick Entry composer opt out
|
||||
* because they size their own OS window and inheriting zoom would crop or
|
||||
* overflow them.
|
||||
*
|
||||
* Extracted so the "helper windows opt out, everything else opts in" contract is
|
||||
* unit-testable without booting a BrowserWindow or reading source.
|
||||
|
|
|
|||
|
|
@ -9,10 +9,18 @@ import type { TriggerState } from './text-utils'
|
|||
export const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
// Above the stack breakpoint but still cramped: the model pill sheds its label
|
||||
// for its chevron icon (freeing ~120px) so the controls stop crowding the input
|
||||
// before the whole row has to stack. Progressive collapse: full pill → icon
|
||||
// pill → stacked.
|
||||
export const COMPOSER_COMPACT_PILL_PX = 440
|
||||
// for its chevron icon so the controls stop crowding the input before the whole
|
||||
// row has to stack. Progressive collapse: full pill → icon pill → stacked.
|
||||
//
|
||||
// Sized off what the controls actually cost, because guessing put the two
|
||||
// stages on top of each other. With the full pill the controls take ~284px
|
||||
// (pill 111 + the icon cluster), so at the old 440 the inline input was ~156px
|
||||
// — barely over its 128px minimum. A few words wrapped, wrapping is what
|
||||
// stacks the row, and the pill's chevron arrived at the same moment the row
|
||||
// gave up, which is the one thing progressive collapse is supposed to avoid.
|
||||
// At 560 the label goes while the input still has ~276px, and the ~110px the
|
||||
// chevron frees is spent keeping the row single for another stretch.
|
||||
export const COMPOSER_COMPACT_PILL_PX = 560
|
||||
|
||||
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
|
||||
// vertical padding). Anything taller means the text wrapped to a second line,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,14 @@ import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useSta
|
|||
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
|
||||
import {
|
||||
type ComposerAttachment,
|
||||
type ComposerDraftSyncMode,
|
||||
onComposerDraftSyncRequest,
|
||||
reloadPersistedDrafts,
|
||||
stashSessionDraft,
|
||||
takeSessionDraft
|
||||
} from '@/store/composer'
|
||||
import { isBrowsingHistory } from '@/store/composer-input-history'
|
||||
|
||||
import {
|
||||
|
|
@ -391,6 +398,38 @@ export function useComposerDraft({
|
|||
}
|
||||
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// The HUD handoff's two verbs. Entering HUD mode flushes this editor's text
|
||||
// into the shared stash so the HUD's composer boots with it; leaving repaints
|
||||
// from the stash so whatever the HUD typed (or sent, clearing it) is what the
|
||||
// app window shows. The per-session swap effect above can't cover either one:
|
||||
// the session scope doesn't change, so it never re-consults the stash.
|
||||
const syncDraft = (mode: ComposerDraftSyncMode) => {
|
||||
if (mode === 'flush') {
|
||||
window.clearTimeout(draftPersistTimerRef.current)
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(draftScopeRef.current, syncDraftFromEditor())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reloadPersistedDrafts()
|
||||
const stashed = takeSessionDraft(draftScopeRef.current)
|
||||
loadIntoComposer(stashed.text, stashed.attachments)
|
||||
}
|
||||
|
||||
const syncDraftRef = useRef(syncDraft)
|
||||
syncDraftRef.current = syncDraft
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
onComposerDraftSyncRequest(({ mode, target: requested }) => {
|
||||
if (requested === target) {
|
||||
syncDraftRef.current(mode)
|
||||
}
|
||||
}),
|
||||
[target]
|
||||
)
|
||||
|
||||
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
|
||||
// inside the debounce/rAF window would drop trailing keystrokes without this.
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
COMPOSER_SURFACE_HEIGHT_VAR,
|
||||
setSurfaceVar
|
||||
} from '@/app/chat/surface-vars'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
|
||||
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
|
||||
|
|
@ -43,7 +42,6 @@ export function useComposerMetrics({
|
|||
const [tight, setTight] = useState(false)
|
||||
// Wider than `tight`: the pill goes icon-only before the row has to stack.
|
||||
const [compactPill, setCompactPill] = useState(false)
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
|
||||
// Edge signals, not the live text: these only re-render when emptiness / the
|
||||
// presence of a non-trailing newline actually flips, so typing within a line
|
||||
|
|
@ -191,7 +189,17 @@ export function useComposerMetrics({
|
|||
}
|
||||
}, [composerRef])
|
||||
|
||||
// Pill compacts on real width (tile/pane), OR when stacked for any reason
|
||||
// (viewport-narrow / wrapped) so the controls row never over-runs.
|
||||
return { compactPill: compactPill || narrow || tight, stacked: expanded || narrow || tight }
|
||||
// Both decisions come from the composer's OWN measured width, never the
|
||||
// viewport's. There used to be a `(max-width: 30rem)` media query in here as
|
||||
// well, and it quietly outranked everything: any window under 480px stacked
|
||||
// the row AND compacted the pill in the same instant, regardless of how much
|
||||
// room the composer actually had. That collapsed the whole progressive ladder
|
||||
// into one step for small windows — HUD mode is ~470px, so it never saw the
|
||||
// ladder at all — and it disagreed with the measured breakpoints (320 to
|
||||
// stack) by 160px. The ResizeObserver knows the real width; the viewport is
|
||||
// not a proxy for it.
|
||||
//
|
||||
// The pill still compacts whenever the row stacks, so the controls row can't
|
||||
// over-run once it has the width to itself.
|
||||
return { compactPill: compactPill || tight, stacked: expanded || tight }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import {
|
|||
sessionPinId,
|
||||
shouldMigrateComposerScope
|
||||
} from '@/store/session'
|
||||
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
|
||||
import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { primaryRouteSelectedSessionId, routeSessionId } from '../routes'
|
||||
|
|
@ -135,7 +135,7 @@ function ChatHeader({
|
|||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// are compact side panels — they drop the session-actions header + border
|
||||
// entirely. A brand-new draft has nothing to pin/delete/rename either.
|
||||
if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||
if (isAuxiliaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -397,7 +397,7 @@ export const ChatView = memo(function ChatView({
|
|||
// scratch window, not the full-height empty state.
|
||||
const showIntro =
|
||||
isPrimary &&
|
||||
!isSecondaryWindow() &&
|
||||
!isAuxiliaryWindow() &&
|
||||
freshDraftReady &&
|
||||
!isRoutedSessionView &&
|
||||
!selectedSessionId &&
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/re
|
|||
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
|
||||
import { watchSessionPins } from '@/store/session-pin-sync'
|
||||
import { $statusbarVisible } from '@/store/statusbar-prefs'
|
||||
import { isHudWindow } from '@/store/windows'
|
||||
|
||||
import type { SessionDragPayload } from '../chat/composer/inline-refs'
|
||||
import { watchPreviewTiles } from '../chat/preview-tile'
|
||||
|
|
@ -69,6 +70,7 @@ import {
|
|||
watchSessionTiles,
|
||||
WorkspaceTabMenu
|
||||
} from '../chat/session-tile'
|
||||
import { HudShell } from '../hud/hud-shell'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
|
||||
import { $workspaceIsPage } from '../routes'
|
||||
|
||||
|
|
@ -688,6 +690,18 @@ export function ContribController() {
|
|||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const statusbarVisible = useStore($statusbarVisible)
|
||||
|
||||
// HUD mode is the SAME app with its frame removed: the wiring (gateway,
|
||||
// sessions, streams, submit) mounts identically, and only the shell around
|
||||
// the chat surface differs. Branching here rather than at the window entry
|
||||
// is what keeps the HUD's composer the real composer.
|
||||
if (isHudWindow()) {
|
||||
return (
|
||||
<ContribWiring>
|
||||
<HudShell />
|
||||
</ContribWiring>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen min-h-0 flex-col bg-background"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { setPetScale } from '@/store/pet-gallery'
|
|||
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
|
||||
import { $sessions } from '@/store/session'
|
||||
import { $attentionSessionIds } from '@/store/session-states'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { isAuxiliaryWindow } from '@/store/windows'
|
||||
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ export function usePetBridge({ requestGateway, resumeSession, submitText }: PetB
|
|||
requestGatewayRef.current = requestGateway
|
||||
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
if (isAuxiliaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from '@/store/quick-entry'
|
||||
import { $gatewayState, $sessions } from '@/store/session'
|
||||
import { sessionTileDelegate } from '@/store/session-states'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { isAuxiliaryWindow } from '@/store/windows'
|
||||
|
||||
interface QuickEntryBridgeParams {
|
||||
startFreshSessionDraft: () => void
|
||||
|
|
@ -57,7 +57,7 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic
|
|||
startFreshRef.current = startFreshSessionDraft
|
||||
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
if (isAuxiliaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic
|
|||
// Push gateway truth into the quick window whenever it changes: connection
|
||||
// state gates its input; the recent-session list feeds its target picker.
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
if (isAuxiliaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ import {
|
|||
} from '@/store/session'
|
||||
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
|
||||
import { armWakeWord, stopClientCapture } from '@/store/wake-word'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { isAuxiliaryWindow, isHudWindow } from '@/store/windows'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { closeWorkspaceTab } from '../chat/close-tab'
|
||||
|
|
@ -77,6 +77,7 @@ import { CommandPalette } from '../command-palette'
|
|||
import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
|
||||
import { useKeybinds } from '../hooks/use-keybinds'
|
||||
import { useHudHandoff } from '../hud/handoff'
|
||||
import { ModelPickerOverlay } from '../model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
|
||||
import { mainChatOccupied, openSession } from '../open-session'
|
||||
|
|
@ -624,6 +625,9 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
// session / new session), and it hears gateway truth from this window.
|
||||
useQuickEntryBridge({ startFreshSessionDraft, submitText })
|
||||
|
||||
// Leaving HUD mode hands this window the session back (see hud/handoff).
|
||||
useHudHandoff({ navigate, resumeSession })
|
||||
|
||||
// Clear a failed turn's red error banner. Errors are renderer-local (never
|
||||
// persisted): a bare error placeholder is dropped entirely; a partial-output
|
||||
// failure keeps its content and sheds the error. Both the runtime cache AND
|
||||
|
|
@ -1006,18 +1010,23 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
|
||||
tools={rightTitlebarTools}
|
||||
/>
|
||||
{/* HUD mode has no titlebar to hang these off — the clusters are
|
||||
`fixed`, so without this they'd float over the chat as orphaned
|
||||
buttons. Exits are the ⌘⇧H toggle and ⌘W. */}
|
||||
{!isHudWindow() && (
|
||||
<TitlebarControls
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
|
||||
tools={rightTitlebarTools}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* The full real overlay set (mirrors DesktopController's `overlays`). */}
|
||||
<RemoteDisplayBanner />
|
||||
{!isSecondaryWindow() && <DesktopInstallOverlay />}
|
||||
{!isSecondaryWindow() && (
|
||||
{!isAuxiliaryWindow() && <DesktopInstallOverlay />}
|
||||
{!isAuxiliaryWindow() && (
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
onCompleted={() => {
|
||||
|
|
@ -1113,11 +1122,13 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
{/* Toasts above everything. */}
|
||||
<NotificationStack />
|
||||
|
||||
{/* Petdex floating mascot — renders nothing unless installed + enabled. */}
|
||||
<FloatingPet />
|
||||
{/* Petdex floating mascot — renders nothing unless installed + enabled.
|
||||
Never in the HUD: that window is the chat bar and nothing else. */}
|
||||
{!isHudWindow() && <FloatingPet />}
|
||||
|
||||
{/* Single persistent xterm host chasing the terminal pane's slot rect. */}
|
||||
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||
{/* Single persistent xterm host chasing the terminal pane's slot rect.
|
||||
The HUD has no terminal pane, so it has nothing to chase. */}
|
||||
{!isHudWindow() && <PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />}
|
||||
</ContribWiringContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
|||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { hudTargetSessionId } from '@/app/hud/handoff'
|
||||
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import {
|
||||
|
|
@ -24,6 +25,7 @@ import {
|
|||
findPrevious as findPreviousMatch,
|
||||
openFindBar
|
||||
} from '@/store/find-in-page'
|
||||
import { toggleHud } from '@/store/hud'
|
||||
import { $capture, $comboIndex, endCapture, setBinding } from '@/store/keybinds'
|
||||
import {
|
||||
requestSessionSearchFocus,
|
||||
|
|
@ -227,6 +229,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
'view.toggleReview': toggleReview,
|
||||
'view.toggleStatusbar': toggleStatusbarVisible,
|
||||
'view.showFiles': showFiles,
|
||||
'view.toggleHud': () => toggleHud(hudTargetSessionId()),
|
||||
'view.showTerminal': () => togglePaneVisible('terminal'),
|
||||
// Create first so the pane's open-effect ensure sees a non-empty set and
|
||||
// doesn't also spawn one — net effect is exactly one fresh terminal.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { type RefObject, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Let clicks fall through the HUD everywhere it isn't really there.
|
||||
*
|
||||
* The one thing about HUD mode that CSS cannot express, because it is a
|
||||
* property of the OS WINDOW rather than of the page. It reads the engaged state
|
||||
* off the DOM (`:focus-within`) rather than keeping a second copy, so there is
|
||||
* one answer to "is the HUD in use" and the stylesheet owns it.
|
||||
*
|
||||
* An always-on-top window eats every click inside its rectangle, visible or
|
||||
* not — and most of the HUD's rectangle is a faded-out band over whatever the
|
||||
* user is actually working in. `pointer-events: none` doesn't help: that is a
|
||||
* page-level property, and the click never reaches the page.
|
||||
*
|
||||
* So the window itself is made mouse-transparent except where it is genuinely
|
||||
* interactive: wherever the cursor is over something, and whenever anything in
|
||||
* the window holds focus. `forward: true` keeps mousemove flowing while
|
||||
* ignoring, which is what lets it re-arm when the cursor comes back to the bar.
|
||||
*/
|
||||
export function useHudClickThrough(rootRef: RefObject<HTMLElement | null>): void {
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
const setIgnoreMouse = window.hermesDesktop?.hud?.setIgnoreMouse
|
||||
|
||||
if (!root || !setIgnoreMouse) {
|
||||
return
|
||||
}
|
||||
|
||||
let ignoring: boolean | null = null
|
||||
// Where the cursor was last seen, so a focus change can re-decide without
|
||||
// waiting for the next move (blurring with the cursor parked on the bar
|
||||
// must not make the bar untouchable until you jiggle the mouse).
|
||||
let point: { x: number; y: number } | null = null
|
||||
|
||||
// Hit-test rather than enumerate. Everything the HUD doesn't want to catch
|
||||
// — the shell's dead space, the sheet, the faded band — is already
|
||||
// `pointer-events: none`, so anything the document hands back at this point
|
||||
// is something real: the bar, a control, the exit chip, a popover, a dialog.
|
||||
// Listing those instead is how links and dialogs ended up unclickable, since
|
||||
// portalled overlays live outside the shell and moving focus into one takes
|
||||
// `:focus-within` with it.
|
||||
const overSomething = () => {
|
||||
if (!point) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hit = document.elementFromPoint(point.x, point.y)
|
||||
|
||||
return Boolean(hit) && hit !== root && hit !== document.body && hit !== document.documentElement
|
||||
}
|
||||
|
||||
// Focus is asked of the document, not of the shell. A dialog or popover is
|
||||
// portalled to `document.body`, so focus entering one leaves the shell's
|
||||
// `:focus-within` — and a HUD that decides it is unused the moment it opens
|
||||
// a dialog goes mouse-transparent underneath it. Nothing but the HUD lives
|
||||
// in this window, so any focus at all is the HUD in use. `hasFocus` gates it
|
||||
// so a stale `activeElement` — the composer keeps it after you click away to
|
||||
// another app — can't pin the HUD solid forever.
|
||||
const focused = () => document.hasFocus() && document.activeElement !== document.body
|
||||
|
||||
const apply = () => {
|
||||
const next = !focused() && !overSomething()
|
||||
|
||||
if (ignoring !== next) {
|
||||
ignoring = next
|
||||
setIgnoreMouse(next)
|
||||
}
|
||||
}
|
||||
|
||||
const onMove = (event: MouseEvent) => {
|
||||
point = { x: event.clientX, y: event.clientY }
|
||||
apply()
|
||||
}
|
||||
|
||||
apply()
|
||||
window.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('focusin', apply)
|
||||
document.addEventListener('focusout', apply)
|
||||
|
||||
return () => {
|
||||
setIgnoreMouse(false)
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('focusin', apply)
|
||||
document.removeEventListener('focusout', apply)
|
||||
}
|
||||
}, [rootRef])
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
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'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* fading tint and the top went pale exactly where it should have been
|
||||
* disappearing, which is what sent the whole thing round in circles. Uniform
|
||||
* panel, uniform frost.
|
||||
*
|
||||
* It still cannot animate, so it is switched while the tint is at full strength
|
||||
* and can hide the change: on the moment the band is engaged, off the moment the
|
||||
* hold ends and the opacity fade begins. Letting it outlive the fade leaves bare
|
||||
* untinted frost on screen — a grey blurred rectangle that pops out at the end
|
||||
* instead of a band fading away.
|
||||
*
|
||||
* Engaged means the caret is in the composer, matching the stylesheet. Merely
|
||||
* holding window focus does not count: activating a window restores focus to
|
||||
* whatever had it last, so grabbing the bar to drag the HUD would otherwise
|
||||
* read as sitting down to use it. Queried live rather than tracked from
|
||||
* document.activeElement, which stays put when the window is blurred and would
|
||||
* latch the frost on forever once the user had ever typed here.
|
||||
*/
|
||||
export function useHudGlass(rootRef: RefObject<HTMLElement | null>, engaged: boolean): void {
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
const setVibrancy = window.hermesDesktop?.hud?.setVibrancy
|
||||
|
||||
if (!root || !setVibrancy) {
|
||||
return
|
||||
}
|
||||
|
||||
let on: boolean | null = null
|
||||
|
||||
const apply = () => {
|
||||
const next = engaged || root.querySelector(TYPING_SELECTOR) !== null
|
||||
|
||||
if (on !== next) {
|
||||
on = next
|
||||
void setVibrancy(next)
|
||||
}
|
||||
}
|
||||
|
||||
apply()
|
||||
root.addEventListener('focusin', apply)
|
||||
root.addEventListener('focusout', apply)
|
||||
|
||||
return () => {
|
||||
void setVibrancy(false)
|
||||
root.removeEventListener('focusin', apply)
|
||||
root.removeEventListener('focusout', apply)
|
||||
}
|
||||
}, [engaged, rootRef])
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* HUD ⇄ app-window handoff.
|
||||
*
|
||||
* The gateway binds a session's event stream to exactly ONE socket — the last
|
||||
* one to submit or resume it (`session["transport"]`). The HUD is a full
|
||||
* renderer with its own socket, so entering HUD mode moves that binding to the
|
||||
* HUD and the app window stops hearing the session entirely: no deltas, no
|
||||
* turn-complete, no draft clear. Nothing to poll for either, since mid-turn
|
||||
* there is nothing persisted to re-pull.
|
||||
*
|
||||
* So leaving HUD mode is a re-home, not a window close. The app window resumes
|
||||
* the session the HUD ended on — the existing hydration path, which rebinds the
|
||||
* transport, reconciles the transcript, and picks up an in-flight turn — and
|
||||
* repaints its composer from the shared draft stash the HUD has been writing.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { reloadPersistedDrafts, requestComposerDraftSync } from '@/store/composer'
|
||||
import { reportHudSession, watchHudState } from '@/store/hud'
|
||||
import { $selectedStoredSessionId } from '@/store/session'
|
||||
import { focusOpenSession, sessionTileDelegate } from '@/store/session-states'
|
||||
import { isHudWindow } from '@/store/windows'
|
||||
|
||||
import { getActiveComposer } from '../chat/composer/focus'
|
||||
import { openSession, type OpenSessionNavigate } from '../open-session'
|
||||
import { sessionRoute } from '../routes'
|
||||
|
||||
/** Session tiles route on `tile:<storedSessionId>` (see session-tile.tsx). */
|
||||
const TILE_TARGET_PREFIX = 'tile:'
|
||||
|
||||
/**
|
||||
* The conversation HUD mode should open on: whichever chat surface the user is
|
||||
* actually looking at.
|
||||
*
|
||||
* `$selectedStoredSessionId` is the WORKSPACE pane's session, so reading it
|
||||
* alone sent the main tab into the HUD no matter which tile was fronted — the
|
||||
* tabs exist precisely so that isn't the same question. `getActiveComposer()`
|
||||
* already answers it for the focus bus, healing to the visible surface when its
|
||||
* cached claim is buried or gone, and a tile's routing key IS its stored
|
||||
* session id.
|
||||
*/
|
||||
export function hudTargetSessionId(): null | string {
|
||||
const target = getActiveComposer()
|
||||
const tile = target.startsWith(TILE_TARGET_PREFIX) ? target.slice(TILE_TARGET_PREFIX.length) : null
|
||||
|
||||
return tile || $selectedStoredSessionId.get()
|
||||
}
|
||||
|
||||
interface HudHandoffParams {
|
||||
navigate: OpenSessionNavigate
|
||||
resumeSession: (storedSessionId: string) => unknown
|
||||
}
|
||||
|
||||
/** App-window side: take the session back when the HUD goes away. Also keeps
|
||||
* the titlebar toggle honest when the HUD is closed from its own side. */
|
||||
export function useHudHandoff({ navigate, resumeSession }: HudHandoffParams): void {
|
||||
const paramsRef = useRef<HudHandoffParams>({ navigate, resumeSession })
|
||||
paramsRef.current = { navigate, resumeSession }
|
||||
|
||||
useEffect(() => {
|
||||
// The HUD's own renderer mounts the same wiring; it is the window going
|
||||
// away, so it has nothing to re-home.
|
||||
if (isHudWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
return watchHudState(hudSessionId => {
|
||||
// The HUD may have typed or sent since this window last read the stash.
|
||||
reloadPersistedDrafts()
|
||||
|
||||
const selected = $selectedStoredSessionId.get()
|
||||
const target = hudSessionId ?? selected
|
||||
|
||||
// Somewhere other than the workspace pane. If it is an open tile, front
|
||||
// it and re-resume THROUGH the tile delegate: the ordinary resume path
|
||||
// enforces "a session is either main or a tile, never both" and would
|
||||
// close the tile to take it into main, quietly rearranging tabs the user
|
||||
// opened on purpose. Otherwise it's a session this window has never seen
|
||||
// — route to it and let the route resume do the rest, including loading
|
||||
// its draft as the composer's scope swaps.
|
||||
if (target && target !== selected) {
|
||||
const delegate = focusOpenSession(target) === 'tile' ? sessionTileDelegate() : null
|
||||
|
||||
if (delegate) {
|
||||
void delegate.resumeTile(target).catch(() => undefined)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
openSession(target, paramsRef.current.navigate)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Same session, so the composer's scope never changes and its
|
||||
// per-session swap effect will never re-consult the stash. Repaint it.
|
||||
requestComposerDraftSync('reload')
|
||||
|
||||
if (target) {
|
||||
void paramsRef.current.resumeSession(target)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
/** HUD side: follow a retarget. Asking for HUD mode from another tab while the
|
||||
* HUD is already up switches the conversation showing in it. */
|
||||
export function useHudGoto(navigate: OpenSessionNavigate): void {
|
||||
const navigateRef = useRef(navigate)
|
||||
navigateRef.current = navigate
|
||||
|
||||
useEffect(() => window.hermesDesktop?.hud?.onGoto?.(id => navigateRef.current(sessionRoute(id))), [])
|
||||
}
|
||||
|
||||
/** HUD side: keep main told which session this window is on. */
|
||||
export function useReportHudSession(): void {
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
|
||||
useEffect(() => {
|
||||
if (isHudWindow()) {
|
||||
reportHudSession(selectedStoredSessionId)
|
||||
}
|
||||
}, [selectedStoredSessionId])
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type CSSProperties, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
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 { WiredPane } from '../contrib/wiring'
|
||||
import { titlebarButtonClass } from '../shell/titlebar'
|
||||
|
||||
import { useHudClickThrough } from './click-through'
|
||||
import { useHudGlass } from './glass'
|
||||
import { useHudGoto, useReportHudSession } from './handoff'
|
||||
|
||||
/** How long the transcript lingers at its glanceable opacity — after a turn
|
||||
* lands, or after you let go of the composer — before it goes. This is the ONLY
|
||||
* hold: the CSS carries no transition-delay, because two stacked holds read as
|
||||
* a third fade state that nobody asked for. Focus keeps it open past this. */
|
||||
const HUD_RECENT_HOLD_MS = 700
|
||||
|
||||
/** Band visibility timings, published to CSS as custom properties so this
|
||||
* module and the stylesheet cannot drift apart. Reveal is quick — it is an
|
||||
* answer to the user; the fade lingers, then goes slowly. */
|
||||
const HUD_REVEAL_MS = 110
|
||||
const HUD_FADE_MS = 180
|
||||
|
||||
/** The step DOWN to the glanceable opacity when you let go. Deliberately slower
|
||||
* than the fade that follows it — easing off is a softer gesture than leaving,
|
||||
* and matching them made the two read as one long dissolve. */
|
||||
const HUD_DIM_MS = Math.round(HUD_FADE_MS * 1.5)
|
||||
|
||||
/** The sheet rolling shut. Shorter than the fade so the panel is already gone
|
||||
* while the last of the text is still going — it reads as the transcript being
|
||||
* drawn down into the bar rather than the two dissolving in lockstep. */
|
||||
const HUD_COLLAPSE_MS = Math.round(HUD_FADE_MS * 0.66)
|
||||
|
||||
/** Breathing room the sheet keeps above the first row, so the fade has
|
||||
* somewhere to land. Published to CSS, and used here to work out how much of
|
||||
* the window the HUD actually occupies. */
|
||||
const HUD_SHEET_OVERHANG_PX = 12
|
||||
|
||||
/** Composer on top, transcript always hanging below it — Spotlight's shape,
|
||||
* rather than flipping to follow the screen edge the HUD is parked against. */
|
||||
const HUD_THREAD_ALWAYS_BELOW = true
|
||||
|
||||
/**
|
||||
* True for a hold window after any conversation activity (a message landing,
|
||||
* a stream flushing, a turn starting or ending). The CSS uses it — alongside
|
||||
* :focus-within — to decide whether the thread is visible; idle HUD mode is
|
||||
* just the Spotlight bar.
|
||||
*
|
||||
* $messages replaces ~30×/s mid-stream, so activity RESTARTS the timer on
|
||||
* every flush — the thread stays up while a reply is writing and for the hold
|
||||
* window after it finishes, without a per-flush re-render (state only changes
|
||||
* on the false↔true edges).
|
||||
*/
|
||||
function useRecentActivity(): [boolean, () => void] {
|
||||
const [recent, setRecent] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const bumpRef = useRef(() => {})
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- timer handle, not an atom mirror
|
||||
useEffect(() => {
|
||||
let signature = ''
|
||||
|
||||
const bump = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
|
||||
setRecent(true)
|
||||
timerRef.current = setTimeout(() => setRecent(false), HUD_RECENT_HOLD_MS)
|
||||
}
|
||||
|
||||
// Gated on the transcript actually CHANGING, not on the atom being written.
|
||||
// $messages is republished for plenty of reasons that aren't new content
|
||||
// (session sync, re-renders, relative timestamps), and re-arming the hold on
|
||||
// every one of those latched the band open permanently — the fade simply
|
||||
// never got to start.
|
||||
const onMessages = () => {
|
||||
const messages = $messages.get()
|
||||
const last = messages[messages.length - 1]
|
||||
const next = `${messages.length}:${last?.id ?? ''}:${last ? chatMessageText(last).length : 0}`
|
||||
|
||||
if (next === signature) {
|
||||
return
|
||||
}
|
||||
|
||||
signature = next
|
||||
bump()
|
||||
}
|
||||
|
||||
bumpRef.current = bump
|
||||
|
||||
// subscribe() fires immediately, so a HUD opened onto an existing
|
||||
// conversation starts with the thread showing, then fades.
|
||||
const offMessages = $messages.subscribe(onMessages)
|
||||
const offBusy = $busy.subscribe(busy => busy && bump())
|
||||
|
||||
return () => {
|
||||
offMessages()
|
||||
offBusy()
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return [recent, () => bumpRef.current()]
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the HUD must stay up regardless of the hold timer.
|
||||
*
|
||||
* The fade is built for an idle transcript, and there are states where leaving
|
||||
* is the wrong answer: a clarify/approval/sudo/secret prompt is a question you
|
||||
* have to answer, and a running turn is progress you asked to watch. Letting
|
||||
* either fade hands you a surface you cannot use — the band goes to zero opacity
|
||||
* and the window goes mouse-transparent under it, so the prompt is neither
|
||||
* readable nor clickable.
|
||||
*
|
||||
* `recent` alone doesn't cover it: it re-arms on transcript changes, so a long
|
||||
* tool call with no visible output would time out mid-turn.
|
||||
*/
|
||||
function useHudHeld(): boolean {
|
||||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
const busy = useStore($busy)
|
||||
|
||||
return awaitingInput || busy
|
||||
}
|
||||
|
||||
/**
|
||||
* HUD mode's shell — the chrome-free floating chat.
|
||||
*
|
||||
* Deliberately almost nothing: it mounts the SAME wired chat surface the
|
||||
* workspace pane does, so the composer here IS the app's composer (slash
|
||||
* commands, `@` refs, attachments, queue, voice, model pill) and the transcript
|
||||
* is the app's transcript, rendered by the app's renderer. Only the frame
|
||||
* changes — no titlebar, no statusbar, no pane tree, no sidebars.
|
||||
*
|
||||
* The shape is macOS Spotlight: at rest, the centered composer bar is the
|
||||
* whole interface. The thread renders as bare text above it and is
|
||||
* visibility-gated like a game chat frame — shown while a turn is recent or the
|
||||
* composer has focus, faded out otherwise (see the `[data-hud-shell]` CSS and
|
||||
* `useRecentActivity`).
|
||||
*/
|
||||
export function HudShell() {
|
||||
const { t } = useI18n()
|
||||
const [recent, holdBand] = useRecentActivity()
|
||||
const held = useHudHeld()
|
||||
|
||||
// Main holds the session id on this window's behalf, so leaving HUD mode can
|
||||
// hand the app window back whatever conversation ended up here.
|
||||
useReportHudSession()
|
||||
useHudGoto(useNavigate())
|
||||
|
||||
// Which screen EDGE the window is parked against. Parked tight to the top,
|
||||
// the composer flips to the window's top edge and the thread grows DOWN
|
||||
// (data-hud-edge). Computed here from window.screenY — no IPC: the renderer
|
||||
// always knows where its window is. Polled because the DOM has no
|
||||
// window-move event; 300ms is imperceptible for a layout flip.
|
||||
//
|
||||
// EDGE-tight, not a midpoint rule: the first cut compared topGap<bottomGap,
|
||||
// which flips the layout the moment the window crosses the vertical center
|
||||
// of the screen — reported (correctly) as "flips way too early". Now it
|
||||
// flips to 'top' only when the HUD is actually parked against the top, and
|
||||
// back once it clearly leaves — the gap between the two thresholds is
|
||||
// hysteresis so the layout can't flutter while it's dragged along the line.
|
||||
const [edge, setEdge] = useState<'bottom' | 'top'>('top')
|
||||
|
||||
useEffect(() => {
|
||||
// Measured on the WINDOW, and flush-only. Flipping is what lets the bar
|
||||
// reach the top of the screen at all: the window's top edge can sit against
|
||||
// the menu bar, and the flip moves the composer to that edge. Keying it off
|
||||
// the visible panel instead meant it could never fire — the panel hugs the
|
||||
// bar at the bottom of the window — and took the top of the screen away
|
||||
// with it. FLIP_OFF is just enough hysteresis that the 300ms poll can't
|
||||
// flutter on sub-pixel jitter while parked.
|
||||
const FLIP_ON = 0
|
||||
const FLIP_OFF = 4
|
||||
|
||||
const measure = () => {
|
||||
// TRYING IT: the bar stays on top and the transcript always hangs below,
|
||||
// wherever the HUD is parked. Flip the constant to re-enable the
|
||||
// edge-aware layout (the CSS for both orientations is still here).
|
||||
if (HUD_THREAD_ALWAYS_BELOW) {
|
||||
setEdge('top')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// availTop ≈ menu bar / notch inset on macOS; screenY is in full-screen
|
||||
// coordinates, so "parked at the top" means screenY ≈ availTop, not 0.
|
||||
const availTop = (window.screen as { availTop?: number }).availTop ?? 0
|
||||
const topGap = window.screenY - availTop
|
||||
|
||||
setEdge(prev => (topGap <= FLIP_ON ? 'top' : topGap >= FLIP_OFF ? 'bottom' : prev))
|
||||
}
|
||||
|
||||
measure()
|
||||
const timer = setInterval(measure, 300)
|
||||
window.addEventListener('resize', measure)
|
||||
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
window.removeEventListener('resize', measure)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Whether the thread actually overflows its band. Gates the band's no-drag
|
||||
// carve-out (styles.css): a band with nothing to scroll stays part of the
|
||||
// window's drag region, so a short conversation never blocks moving the HUD.
|
||||
const [scrollable, setScrollable] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
let viewport: HTMLElement | null = null
|
||||
const ro = new ResizeObserver(() => measure())
|
||||
|
||||
const measure = () => {
|
||||
const el = viewport ?? root.querySelector<HTMLElement>('[data-slot="aui_thread-viewport"]')
|
||||
|
||||
if (el !== viewport) {
|
||||
viewport = el
|
||||
|
||||
if (el) {
|
||||
ro.observe(el)
|
||||
|
||||
if (el.firstElementChild) {
|
||||
ro.observe(el.firstElementChild)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setScrollable(Boolean(el && el.scrollHeight > el.clientHeight + 4))
|
||||
|
||||
// How tall the band actually needs to be. The transcript is packed to the
|
||||
// bottom, so this is the distance from the topmost visible row down to the
|
||||
// bar — which is 0 on a fresh session, and the glass then collapses behind
|
||||
// the bar instead of painting an empty slab over the whole window.
|
||||
//
|
||||
// Written straight to the element rather than through state: it changes on
|
||||
// every stream flush, and the sheet resizing must not re-render the tree.
|
||||
const rows = el?.querySelectorAll<HTMLElement>('[data-slot="aui_thread-content"] > *:not([data-slot])')
|
||||
const box = el?.getBoundingClientRect()
|
||||
|
||||
// Measured from the bar outward, so it flips with the layout: parked at
|
||||
// the bottom the transcript grows up from the bar, parked at the top it
|
||||
// hangs down from it.
|
||||
const span =
|
||||
!rows?.length || !box
|
||||
? 0
|
||||
: root.dataset.hudEdge === 'top'
|
||||
? rows[rows.length - 1].getBoundingClientRect().bottom - box.top
|
||||
: box.bottom - rows[0].getBoundingClientRect().top
|
||||
|
||||
root.style.setProperty('--hud-band-height', `${Math.max(0, Math.round(span))}px`)
|
||||
|
||||
// …and the bar's real height, which is what the thread has to clear.
|
||||
// --composer-measured-height would be the obvious source, but it is a
|
||||
// surface var that never lands here, so the clearance silently fell back
|
||||
// to the root estimate and reserved ~20px more than the bar occupies —
|
||||
// a visible hole under the last message.
|
||||
const bar = root.querySelector<HTMLElement>('[data-slot="composer-dock"]')
|
||||
const barHeight = bar?.getBoundingClientRect().height ?? 0
|
||||
|
||||
if (bar) {
|
||||
ro.observe(bar)
|
||||
root.style.setProperty('--hud-bar-height', `${Math.round(barHeight)}px`)
|
||||
}
|
||||
|
||||
void barHeight
|
||||
}
|
||||
|
||||
// The viewport mounts async (lazy chat surface); poll briefly until it
|
||||
// exists, then let the ResizeObserver own it.
|
||||
measure()
|
||||
const probe = setInterval(measure, 500)
|
||||
|
||||
return () => {
|
||||
clearInterval(probe)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useHudGlass(rootRef, recent || held)
|
||||
useHudClickThrough(rootRef)
|
||||
|
||||
// Force the HOST layers transparent. index.html's pre-paint script writes an
|
||||
// opaque themed background onto <html> as an INLINE style (the anti-white-
|
||||
// flash trick), and an inline style beats any stylesheet rule — so without
|
||||
// this the window is a solid slab and every translucent panel below is just
|
||||
// glass over a white wall. A style tag with `!important` is what the pet
|
||||
// overlay and quick entry already do; they get it at mount because they are
|
||||
// bespoke roots, and the HUD needs the same because it is not.
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = 'html,body,#root{background:transparent !important;}'
|
||||
document.head.appendChild(style)
|
||||
|
||||
return () => style.remove()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-screen w-screen flex-col overflow-hidden"
|
||||
data-hud-edge={edge}
|
||||
data-hud-recent={recent || held ? '' : undefined}
|
||||
data-hud-scrollable={scrollable ? '' : undefined}
|
||||
data-hud-shell
|
||||
// Letting go of the composer re-arms the hold, so the transcript steps
|
||||
// down to its glanceable opacity and lingers there instead of jumping
|
||||
// straight from full to gone.
|
||||
onBlur={holdBand}
|
||||
ref={rootRef}
|
||||
style={
|
||||
{
|
||||
'--hud-fade': `${HUD_FADE_MS}ms`,
|
||||
'--hud-collapse': `${HUD_COLLAPSE_MS}ms`,
|
||||
'--hud-dim': `${HUD_DIM_MS}ms`,
|
||||
'--hud-reveal': `${HUD_REVEAL_MS}ms`,
|
||||
'--hud-sheet-overhang': `${HUD_SHEET_OVERHANG_PX}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{/* The band's sheet, on a layer of its own so it can carry the fade
|
||||
without the app's chat surface having to know about it. FIRST child so
|
||||
it paints behind the transcript. */}
|
||||
<div aria-hidden data-hud-glass />
|
||||
|
||||
<WiredPane part="chatRoutes" />
|
||||
|
||||
{/* The top fade band, as a drag handle. Its text is masked to nothing up
|
||||
there, so handing the band's mouse input to the window manager costs
|
||||
no readable content — and it gives the HUD a grab area that isn't the
|
||||
composer.
|
||||
|
||||
LAST child on purpose. Electron collects draggable regions by walking
|
||||
the layout tree in order, uniting `drag` rects and subtracting
|
||||
`no-drag` ones, so later elements win. Above `WiredPane` this strip
|
||||
was silently subtracted away by the scrollback's full-height `no-drag`
|
||||
rect (z-index does not enter into it — the region math is rect-based,
|
||||
not paint-order-based). */}
|
||||
<div aria-hidden data-hud-drag-strip />
|
||||
|
||||
{/* The way back. HUD mode has no titlebar, so without this the only
|
||||
exits are ⌘⇧H and ⌘W — both invisible. Floats over the scrollback
|
||||
(which is short and top-fades, so it rarely collides with text) and
|
||||
carves itself out of the drag region so the click lands. */}
|
||||
<Tip label={t.titlebar.exitHud}>
|
||||
<Button
|
||||
aria-label={t.titlebar.exitHud}
|
||||
className={`${titlebarButtonClass} absolute right-1.5 top-1.5 z-20 bg-transparent [-webkit-app-region:no-drag]`}
|
||||
data-hud-exit=""
|
||||
onClick={closeHud}
|
||||
size="icon-titlebar"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="screen-normal" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -656,12 +656,21 @@ export function useMessageStream({
|
|||
const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden)
|
||||
const lastVisible = [...nextMessages].reverse().find(m => !m.hidden)
|
||||
const unresolvedUserTail = lastVisible?.role === 'user'
|
||||
// Having streamed the reply normally means this window owns the whole
|
||||
// turn and re-reading stored history would be wasted work. That only
|
||||
// holds for a turn it STARTED: an adopted one (resumed onto a session
|
||||
// already running elsewhere) arrives reply-first, with no prompt row,
|
||||
// so it has to hydrate or the user's own message never shows up.
|
||||
shouldHydrate =
|
||||
!completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText)
|
||||
!completionError &&
|
||||
!hasInlineError &&
|
||||
!unresolvedUserTail &&
|
||||
(state.adoptedRunningTurn || !state.sawAssistantPayload || !finalText)
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages: nextMessages,
|
||||
adoptedRunningTurn: false,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
awaitingResponse: false,
|
||||
|
|
|
|||
|
|
@ -963,6 +963,7 @@ describe('resumeSession failure recovery', () => {
|
|||
interimBoundaryPending: false,
|
||||
interrupted: false,
|
||||
messages: [],
|
||||
adoptedRunningTurn: false,
|
||||
model: '',
|
||||
needsInput: false,
|
||||
pendingBranchGroup: null,
|
||||
|
|
|
|||
|
|
@ -767,8 +767,17 @@ export function useSessionActions({
|
|||
} else {
|
||||
const runtimeInfo = applyRuntimeInfo(activated.info)
|
||||
|
||||
let activatedMessages =
|
||||
activated.messages.length || activated.inflight || activated.queued
|
||||
// `omit_messages` means the response carries NO transcript, not
|
||||
// an empty one — the cache is the base and the live projection is
|
||||
// a tail to graft onto it. Reconciling against the empty list
|
||||
// instead rebuilds the thread out of the projection alone, so
|
||||
// activating a session that is mid-turn somewhere else (leaving
|
||||
// HUD mode is exactly that) collapsed the whole conversation down
|
||||
// to the in-flight prompt until the turn finished and the
|
||||
// post-turn hydrate restored it.
|
||||
let activatedMessages = activated.messages_omitted
|
||||
? appendLiveSessionProjection(cachedViewState.messages, activated)
|
||||
: activated.messages.length || activated.inflight || activated.queued
|
||||
? reconcileAuthoritativeMessages(activated.messages, cachedViewState.messages, activated)
|
||||
: cachedViewState.messages
|
||||
|
||||
|
|
@ -805,7 +814,11 @@ export function useSessionActions({
|
|||
...(runtimeInfo ?? {}),
|
||||
messages: activatedMessages,
|
||||
busy: running,
|
||||
awaitingResponse: running
|
||||
awaitingResponse: running,
|
||||
// Adopting someone else's turn: we'll stream its reply
|
||||
// without ever having received its prompt, so the settle
|
||||
// path must not take the "I saw it all" shortcut.
|
||||
adoptedRunningTurn: state.adoptedRunningTurn || running
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
|
|
@ -965,7 +978,15 @@ export function useSessionActions({
|
|||
? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages)
|
||||
: currentMessages
|
||||
|
||||
const resumedMessages = reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed)
|
||||
// Omitted, not empty — same trap as the activate path above.
|
||||
// The REST prefetch IS the transcript here; the resume payload
|
||||
// only contributes the live tail, so graft rather than rebuild.
|
||||
// (Without a usable prefetch there is nothing better to stand
|
||||
// on, so the projection alone remains the degraded fallback.)
|
||||
const resumedMessages =
|
||||
resumed.messages_omitted && prefetchApplied && prefetchMatchesResumedSession
|
||||
? appendLiveSessionProjection(localSnapshot, resumed)
|
||||
: reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed)
|
||||
|
||||
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
|
||||
})()
|
||||
|
|
@ -1022,6 +1043,7 @@ export function useSessionActions({
|
|||
messages: messagesForView,
|
||||
busy: resumedRunning,
|
||||
awaitingResponse: resumedRunning && !recoveredInFlightTail,
|
||||
adoptedRunningTurn: state.adoptedRunningTurn || resumedRunning,
|
||||
...(inFlightRecovery.applied
|
||||
? {
|
||||
sawAssistantPayload: true,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { hudTargetSessionId } from '@/app/hud/handoff'
|
||||
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
|
||||
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -11,6 +12,7 @@ import { useI18n } from '@/i18n'
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { toggleHud } from '@/store/hud'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$sidebarOpen,
|
||||
|
|
@ -19,7 +21,7 @@ import {
|
|||
toggleSidebarOpen
|
||||
} from '@/store/layout'
|
||||
|
||||
import { appViewForPath, isOverlayView, SETTINGS_ROUTE } from '../routes'
|
||||
import { appViewForPath, isOverlayView } from '../routes'
|
||||
|
||||
import { titlebarButtonClass } from './titlebar'
|
||||
|
||||
|
|
@ -180,6 +182,20 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
},
|
||||
title: t.titlebar.layoutEditorTitle
|
||||
},
|
||||
{
|
||||
// No `title`: TitlebarToolButton passes `title` to TipKeybindLabel as a
|
||||
// text OVERRIDE, so a long sentence there replaces the short label and
|
||||
// crowds the ⌘⇧H hint off the tooltip. Label only — the hint is appended
|
||||
// from the action registry, same as every other tool here.
|
||||
actionId: 'view.toggleHud',
|
||||
icon: <Codicon name="comment-discussion" />,
|
||||
id: 'hud',
|
||||
label: t.titlebar.enterHud,
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
toggleHud(hudTargetSessionId())
|
||||
}
|
||||
},
|
||||
{
|
||||
active: hapticsMuted,
|
||||
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
|
||||
|
|
@ -187,16 +203,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
|
||||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
actionId: 'keybinds.openPanel',
|
||||
icon: <Codicon name="keyboard" />,
|
||||
id: 'keybinds',
|
||||
label: t.titlebar.openKeybinds,
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
navigate(`${SETTINGS_ROUTE}?tab=keybinds`)
|
||||
}
|
||||
},
|
||||
{
|
||||
actionId: 'nav.settings',
|
||||
icon: <Codicon name="settings-gear" />,
|
||||
|
|
|
|||
|
|
@ -187,6 +187,12 @@ export interface ClientSessionState {
|
|||
awaitingResponse: boolean
|
||||
streamId: string | null
|
||||
sawAssistantPayload: boolean
|
||||
/** This window picked up a turn it did not start — it resumed onto a session
|
||||
* that was already running somewhere else (leaving HUD mode, opening a
|
||||
* pop-out mid-turn). It therefore holds the reply but never received the
|
||||
* prompt, so the usual "I streamed it, my transcript is complete" shortcut
|
||||
* is false and the turn must hydrate from stored history when it settles. */
|
||||
adoptedRunningTurn: boolean
|
||||
pendingBranchGroup: string | null
|
||||
interrupted: boolean
|
||||
/** True after message.interim finalized a bubble in the still-running turn. */
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useI18n } from '@/i18n'
|
|||
import { messagePaintWeight } from '@/lib/render-weight'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$threadScrolledUp,
|
||||
onScrollToBottomRequest,
|
||||
onThreadEditClose,
|
||||
onThreadEditOpen,
|
||||
|
|
@ -410,6 +411,23 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
// Floating jump button (outside this subtree) → return to the bottom.
|
||||
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom])
|
||||
|
||||
// Waking from display: hidden (HUD mode hides the main window; OS hide does
|
||||
// the same to any window): rAF and ResizeObserver were frozen the whole
|
||||
// time, so the virtualizer's measurements — and scrollTop itself — are
|
||||
// stale. If the user was following the bottom, re-anchor once visible;
|
||||
// leave a scrolled-up reader exactly where they were.
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible' && !$threadScrolledUp.get()) {
|
||||
requestAnimationFrame(() => void scrollToBottom())
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
return () => document.removeEventListener('visibilitychange', onVisible)
|
||||
}, [scrollToBottom])
|
||||
|
||||
const endEditHold = useCallback(() => {
|
||||
scrollRef.current?.removeAttribute('data-editing')
|
||||
}, [scrollRef])
|
||||
|
|
|
|||
|
|
@ -62,6 +62,19 @@ declare global {
|
|||
onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void
|
||||
onControl: (callback: (payload: PetOverlayControl) => void) => () => void
|
||||
}
|
||||
// HUD mode: the chrome-free floating chat. A FULL app renderer with its
|
||||
// own gateway (like an instance window), sized and skinned as a floating
|
||||
// bar — so it mounts the real composer rather than a lookalike. Main
|
||||
// owns the window; `onChanged` keeps every window's toggle truthful.
|
||||
hud?: {
|
||||
open: (request?: { sessionId?: null | string }) => Promise<{ ok: boolean }>
|
||||
close: () => Promise<{ ok: boolean }>
|
||||
setIgnoreMouse: (ignore: boolean) => void
|
||||
setVibrancy: (on: 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
|
||||
}
|
||||
// Quick Entry: a global-hotkey mini composer window. Main owns the OS
|
||||
// shortcut registration + the persisted preference (it must restore the
|
||||
// shortcut on a cold launch without the renderer visiting Settings), so
|
||||
|
|
|
|||
|
|
@ -172,7 +172,8 @@ export const ar = defineLocale({
|
|||
unmuteHaptics: 'تفعيل الاهتزازات',
|
||||
openSettings: 'فتح الإعدادات',
|
||||
openStarmap: 'فتح خريطة الذاكرة',
|
||||
openKeybinds: 'اختصارات لوحة المفاتيح',
|
||||
enterHud: 'وضع HUD',
|
||||
exitHud: 'إنهاء وضع HUD',
|
||||
layoutEditor: 'محرر التخطيط',
|
||||
layoutEditorTitle: 'محرر التخطيط — انقر مع ⌘ لإعادة ضبط التخطيط'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -202,7 +202,8 @@ export const en: Translations = {
|
|||
unmuteHaptics: 'Unmute haptics',
|
||||
openSettings: 'Open settings',
|
||||
openStarmap: 'Open memory graph',
|
||||
openKeybinds: 'Keyboard shortcuts',
|
||||
enterHud: 'HUD mode',
|
||||
exitHud: 'Exit HUD mode',
|
||||
layoutEditor: 'Layout editor',
|
||||
layoutEditorTitle: 'Layout editor — ⌘-click resets the layout'
|
||||
},
|
||||
|
|
@ -261,6 +262,7 @@ export const en: Translations = {
|
|||
'view.toggleReview': 'Toggle review pane',
|
||||
'view.toggleStatusbar': 'Toggle status bar',
|
||||
'view.showFiles': 'Show file browser',
|
||||
'view.toggleHud': 'Toggle HUD mode',
|
||||
'view.showTerminal': 'Toggle terminal',
|
||||
'view.newTerminal': 'New terminal',
|
||||
'view.nextTerminal': 'Next terminal',
|
||||
|
|
|
|||
|
|
@ -244,7 +244,8 @@ export interface Translations {
|
|||
unmuteHaptics: string
|
||||
openSettings: string
|
||||
openStarmap: string
|
||||
openKeybinds: string
|
||||
enterHud: string
|
||||
exitHud: string
|
||||
layoutEditor: string
|
||||
layoutEditorTitle: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,7 +197,8 @@ export const zh: Translations = {
|
|||
unmuteHaptics: '开启触感反馈',
|
||||
openSettings: '打开设置',
|
||||
openStarmap: '打开记忆图谱',
|
||||
openKeybinds: '键盘快捷键',
|
||||
enterHud: 'HUD 模式',
|
||||
exitHud: '退出 HUD 模式',
|
||||
layoutEditor: '布局编辑器',
|
||||
layoutEditorTitle: '布局编辑器 — ⌘ 点击重置布局'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export function createClientSessionState(
|
|||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
sawAssistantPayload: false,
|
||||
adoptedRunningTurn: false,
|
||||
pendingBranchGroup: null,
|
||||
interrupted: false,
|
||||
interimBoundaryPending: false,
|
||||
|
|
|
|||
|
|
@ -116,6 +116,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
|||
// ⌘G — "g" for git; the review pane is the source-control view.
|
||||
{ id: 'view.toggleReview', category: 'view', defaults: ['mod+g'] },
|
||||
{ id: 'view.showFiles', category: 'view', defaults: [] },
|
||||
// ⌘⇧H — "h" for HUD. Enters/leaves the chrome-free floating chat: the app
|
||||
// window steps aside and a composer + live reply float over whatever the
|
||||
// user is working in. Ships bound because the whole point is leaving the app
|
||||
// without reaching for it — but the titlebar button is the discoverable door.
|
||||
{ id: 'view.toggleHud', category: 'view', defaults: ['mod+shift+h'] },
|
||||
// Control+` everywhere (literal `ctrl`, NOT `mod`): ⌘` is macOS-reserved for
|
||||
// cycling app windows, so VS Code/Cursor/Zed bind the terminal to Ctrl+` on
|
||||
// every platform. Off macOS `ctrl` folds to `mod` (= Ctrl), so it's unchanged.
|
||||
|
|
|
|||
|
|
@ -151,6 +151,84 @@ function loadPersistedDraftTexts(): [string, SessionDraft][] {
|
|||
|
||||
const draftsBySession = new Map<string, SessionDraft>(loadPersistedDraftTexts())
|
||||
|
||||
/**
|
||||
* Re-read the persisted drafts written by ANOTHER window into this one's map.
|
||||
*
|
||||
* Drafts are per-renderer state backed by shared localStorage, and the map
|
||||
* above is read exactly once at module load. Two windows on the same session
|
||||
* (HUD mode ⇄ the app window) therefore diverge the moment either one types:
|
||||
* whichever window mounted first keeps its stale copy forever, so text typed
|
||||
* in the HUD is simply gone when you return to the app.
|
||||
*
|
||||
* Merge, don't clobber — the local map may hold attachments (never persisted)
|
||||
* that the incoming text-only snapshot can't know about.
|
||||
*/
|
||||
export function reloadPersistedDrafts(): void {
|
||||
const incoming = new Map(loadPersistedDraftTexts())
|
||||
|
||||
for (const [key, draft] of incoming) {
|
||||
const local = draftsBySession.get(key)
|
||||
draftsBySession.set(key, local?.attachments.length ? { ...local, text: draft.text } : draft)
|
||||
}
|
||||
|
||||
// A key that vanished from storage was cleared (sent) in the other window.
|
||||
for (const key of [...draftsBySession.keys()]) {
|
||||
if (!incoming.has(key)) {
|
||||
draftsBySession.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localStorage `storage` events fire across Electron BrowserWindows of the
|
||||
// same origin, so the other window's write is the sync signal.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', event => {
|
||||
if (event.key === SESSION_DRAFTS_STORAGE_KEY) {
|
||||
reloadPersistedDrafts()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a composer's live text into the shared stash (`flush`), or repaint it
|
||||
* from the stash (`reload`).
|
||||
*
|
||||
* Both halves of the HUD handoff need this. The stash is the only draft state
|
||||
* two windows share, but a mounted composer only consults it when its session
|
||||
* scope changes — so entering HUD mode has to flush the app window's in-editor
|
||||
* text down to the stash before the HUD boots and reads it, and leaving has to
|
||||
* repaint the app's editor from whatever the HUD left behind (usually empty,
|
||||
* because the HUD sent it).
|
||||
*
|
||||
* Dispatched synchronously, unlike the focus bus: the flush must complete
|
||||
* before the HUD window is created.
|
||||
*/
|
||||
const DRAFT_SYNC_EVENT = 'hermes:composer-draft-sync'
|
||||
|
||||
export type ComposerDraftSyncMode = 'flush' | 'reload'
|
||||
|
||||
interface ComposerDraftSyncDetail {
|
||||
mode: ComposerDraftSyncMode
|
||||
target: string
|
||||
}
|
||||
|
||||
export function requestComposerDraftSync(mode: ComposerDraftSyncMode, target = 'main'): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent<ComposerDraftSyncDetail>(DRAFT_SYNC_EVENT, { detail: { mode, target } }))
|
||||
}
|
||||
}
|
||||
|
||||
export function onComposerDraftSyncRequest(handler: (detail: ComposerDraftSyncDetail) => void): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
const listener = (event: Event) => handler((event as CustomEvent<ComposerDraftSyncDetail>).detail)
|
||||
window.addEventListener(DRAFT_SYNC_EVENT, listener)
|
||||
|
||||
return () => window.removeEventListener(DRAFT_SYNC_EVENT, listener)
|
||||
}
|
||||
|
||||
function persistDraftTexts() {
|
||||
try {
|
||||
const entries = [...draftsBySession]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* HUD mode — the chrome-free floating chat.
|
||||
*
|
||||
* A transparent, frameless, always-on-top window showing nothing but the REAL
|
||||
* composer with the reply scrolling above it, so Hermes can be driven while
|
||||
* the user works in another app (Figma, a browser).
|
||||
*
|
||||
* It is NOT a puppet window. Unlike the pet overlay / quick entry, the HUD is
|
||||
* a full app renderer with its own gateway — the same thing `openWindow()`
|
||||
* spawns, just reshaped. That is the whole design: the HUD renders `ChatView`
|
||||
* in its `hud` variant, so the composer IS the app's composer (attachments,
|
||||
* slash commands, queue, voice, model pill) rather than a lookalike that
|
||||
* drifts. This module owns only the mode flag and the window lifecycle.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { requestComposerDraftSync } from '@/store/composer'
|
||||
import { isHudWindow } from '@/store/windows'
|
||||
|
||||
/** Whether a HUD window is currently up. In the HUD's own renderer this is
|
||||
* always true (it IS the HUD); in the main window it tracks the child so the
|
||||
* titlebar toggle reads correctly.
|
||||
*
|
||||
* Deliberately NOT persisted. The HUD is a live window main owns, so it can
|
||||
* never outlive the app — a remembered `true` from the last run just makes the
|
||||
* first toggle a no-op ("the button does nothing after a restart"). Main
|
||||
* broadcasts the truth on every change, which is the only authority there is. */
|
||||
export const $hudActive = atom(isHudWindow())
|
||||
|
||||
/** True only in the HUD window itself — the renderer flag that swaps the app
|
||||
* shell for the slim floating layout. Constant for the window's life, so it
|
||||
* never invalidates a render path mid-session. */
|
||||
export const $hudMode = atom(isHudWindow())
|
||||
|
||||
/** Which conversation the HUD is showing, as far as this window knows. Lets the
|
||||
* toggle tell "switch the HUD to this tab" apart from "dismiss the HUD". */
|
||||
export const $hudSession = atom<null | string>(null)
|
||||
|
||||
/** True when the shell exposes HUD mode (desktop only). */
|
||||
export const canUseHud = (): boolean =>
|
||||
typeof window !== 'undefined' && typeof window.hermesDesktop?.hud?.open === 'function'
|
||||
|
||||
export function openHud(sessionId?: null | string): void {
|
||||
const api = window.hermesDesktop?.hud
|
||||
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
// Push whatever is half-typed here into the shared draft stash BEFORE the
|
||||
// HUD window exists, so its composer boots with the text rather than racing
|
||||
// a cross-window storage event that lands after it has already painted.
|
||||
requestComposerDraftSync('flush')
|
||||
|
||||
$hudActive.set(true)
|
||||
$hudSession.set(sessionId ?? null)
|
||||
void api.open({ sessionId: sessionId ?? null })
|
||||
}
|
||||
|
||||
/** Leave HUD mode. Callable from either window — main closes the child, the
|
||||
* HUD closes itself; both restore the app window. */
|
||||
export function closeHud(): void {
|
||||
const api = window.hermesDesktop?.hud
|
||||
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
$hudActive.set(false)
|
||||
$hudSession.set(null)
|
||||
void api.close()
|
||||
}
|
||||
|
||||
export const toggleHud = (sessionId?: null | string) => ($hudActive.get() ? closeHud() : openHud(sessionId))
|
||||
|
||||
/** Tell main which session this HUD is on. Main holds it (the HUD's renderer
|
||||
* doesn't outlive the window) and hands it back in the close broadcast so the
|
||||
* app window knows what to re-home onto. */
|
||||
export const reportHudSession = (sessionId: null | string): void => window.hermesDesktop?.hud?.setSession?.(sessionId)
|
||||
|
||||
/**
|
||||
* Track the HUD window's real state so the titlebar toggle can't go stale when
|
||||
* the HUD is closed from its own side (⌘W, its exit button), and hand the
|
||||
* app window the session the HUD ended on. Returns a disposer; no-ops outside
|
||||
* Electron.
|
||||
*/
|
||||
export function watchHudState(onClosed?: (sessionId: null | string) => void): () => void {
|
||||
const off = window.hermesDesktop?.hud?.onChanged?.(({ open, sessionId }) => {
|
||||
$hudActive.set(open)
|
||||
$hudSession.set(open ? sessionId : null)
|
||||
|
||||
if (!open) {
|
||||
onClosed?.(sessionId)
|
||||
}
|
||||
})
|
||||
|
||||
return off ?? (() => {})
|
||||
}
|
||||
|
|
@ -29,6 +29,31 @@ export function isSecondaryWindow(): boolean {
|
|||
|
||||
let watchWindowCache: boolean | null = null
|
||||
|
||||
// A "hud" window is HUD mode: the chrome-free floating chat. Unlike the pet
|
||||
// overlay / quick entry it is a FULL app renderer with its own gateway — the
|
||||
// flag only tells the shell to render the slim floating layout (composer +
|
||||
// scrollback) instead of the pane tree, so the composer it shows is the real
|
||||
// one. Read from location.search for the same reason as the flag above.
|
||||
let hudWindowCache: boolean | null = null
|
||||
|
||||
export function isHudWindow(): boolean {
|
||||
if (hudWindowCache !== null) {
|
||||
return hudWindowCache
|
||||
}
|
||||
|
||||
let result = false
|
||||
|
||||
try {
|
||||
result = new URLSearchParams(window.location.search).get('win') === 'hud'
|
||||
} catch {
|
||||
result = false
|
||||
}
|
||||
|
||||
hudWindowCache = result
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// A "watch" window spectates a session that is being driven elsewhere (a
|
||||
// running subagent). It resumes lazily — the gateway registers history + a
|
||||
// transport for the live mirror without building an agent, so opening it is
|
||||
|
|
@ -51,6 +76,13 @@ export function isWatchWindow(): boolean {
|
|||
return result
|
||||
}
|
||||
|
||||
// True for any window that is NOT the primary app instance — a secondary
|
||||
// session window or the HUD. Single-claim channels (the quick-entry capture
|
||||
// bridge, the pet overlay control bridge) and the install/onboarding overlays
|
||||
// belong to the primary alone: two windows answering one channel turns one
|
||||
// keystroke into N prompts, and a HUD is the last place to paint onboarding.
|
||||
export const isAuxiliaryWindow = (): boolean => isSecondaryWindow() || isHudWindow()
|
||||
|
||||
// True when running inside the Electron desktop shell (the preload bridge is
|
||||
// present). The "open in new window" affordance is desktop-only.
|
||||
export function canOpenSessionWindow(): boolean {
|
||||
|
|
|
|||
|
|
@ -2264,3 +2264,619 @@ button[data-slot='aui_msg-reactions'] svg {
|
|||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── HUD mode ────────────────────────────────────────────────────────────────
|
||||
The chrome-free floating chat (`?win=hud`). Same renderer, same components,
|
||||
same transcript as any other window — this block only removes the frame and
|
||||
makes the window see-through. It deliberately does NOT restyle how messages
|
||||
render: the transcript is the app's transcript, and a HUD that reinvents
|
||||
bubbles is a second design to keep in sync. Layout and surface only. */
|
||||
|
||||
/* The HOST LAYERS are made transparent at mount, not here: index.html's
|
||||
pre-paint script sets an opaque themed background on <html> as an INLINE
|
||||
style (the anti-white-flash trick), and an inline style beats a stylesheet
|
||||
rule. Against that opaque wall every translucent panel below is just glass
|
||||
over white — which is exactly how this shipped: a plain opaque slab. The
|
||||
pet overlay and quick entry inject a style tag at mount for the same
|
||||
reason; HudShell does it too. See `hud-shell.tsx`. */
|
||||
|
||||
[data-hud-shell] {
|
||||
background: transparent;
|
||||
/* Empty space drags the window; anything that needs the mouse carves itself
|
||||
back out below. */
|
||||
-webkit-app-region: drag;
|
||||
/* Arrive decisively and settle; leave gently and get out of the way. Linear
|
||||
both directions is what made this feel mechanical. */
|
||||
--hud-ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--hud-ease-exit: cubic-bezier(0.4, 0, 0.7, 0.2);
|
||||
}
|
||||
|
||||
/* Chat surface carries nothing in HUD mode — the visual is the BAND below. */
|
||||
[data-hud-shell] [data-chat-surface] {
|
||||
background: transparent !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* The scrollback — the chat BAND, straight from the nous-viz2d chat (which is
|
||||
the reference for this whole surface): a fixed-height smoked strip hugging
|
||||
the input, tinted from the theme, with the text simply sitting in it.
|
||||
|
||||
- viz2d: `bg-black/20` at rest → `bg-black/60` on hover / focus-within /
|
||||
pending. Same mechanism here, theme-aware: the tint is the theme's own
|
||||
background colour, so it reads correctly on dark and light themes alike
|
||||
and text keeps its normal contrast against it.
|
||||
- VISIBILITY keeps the WoW fade: shown while a turn is recent/streaming
|
||||
(`data-hud-recent`) or the composer is focused, melted away otherwise.
|
||||
viz2d's band never hides — ours does, because idle HUD mode is just the
|
||||
Spotlight bar.
|
||||
- Focus steps the tint to fully solid: typing means reading conditions.
|
||||
|
||||
Reveal is fast, fade-out is slow — the reveal is an answer to the user, the
|
||||
fade is furniture leaving the room. */
|
||||
[data-hud-shell] [data-slot='composer-bounds'] {
|
||||
position: absolute !important;
|
||||
/* Anchored to the bar and sized to the transcript, exactly like the sheet
|
||||
behind it — same box, same clock — so the text is clipped away as the panel
|
||||
rolls rather than fading in place.
|
||||
|
||||
Edge to edge with no dead margins, and it never computes where the bar's
|
||||
top is: the bar simply covers this box's bottom. Every earlier attempt
|
||||
derived that edge from --composer-surface-measured-height, which is the
|
||||
composer's published height, rounded, while the real box is fractional —
|
||||
so the math left a hairline that appeared and vanished as the composer grew
|
||||
("the gap is 0px until two lines"). What keeps the TEXT off the bar is the
|
||||
app's own composer clearance, below. */
|
||||
inset: 0 !important;
|
||||
height: auto !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
flex: none !important;
|
||||
border: 0 !important;
|
||||
/* Rounded on top to match the window's own corners; square at the bottom,
|
||||
where the bar covers it. */
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
/* No fill of its own — the sheet is its own layer behind this one
|
||||
([data-hud-glass]), so it can be sized to the transcript while this box
|
||||
stays the full window for the drag region and the scroll container. */
|
||||
background: transparent !important;
|
||||
/* Three opacity states: gone at rest, half strength when a turn is recent or
|
||||
you've just let go of the composer (there to glance at, not to demand
|
||||
attention), full while you're actually reading it. */
|
||||
opacity: 0;
|
||||
translate: 0 var(--hud-exit-shift, 2.5rem);
|
||||
transition:
|
||||
opacity var(--hud-fade) var(--hud-ease-exit),
|
||||
translate var(--hud-collapse) var(--hud-ease-exit);
|
||||
}
|
||||
|
||||
/* Shown: recent turn / streaming, or a focused composer.
|
||||
|
||||
POINTER EVENTS ARE THE GATE: unfocused, the band is a ghost you can't touch
|
||||
— clicks in that region fall through to the shell's drag region (so the
|
||||
whole upper window is a drag handle at rest) and the band can't eat a
|
||||
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. */
|
||||
[data-hud-shell] [data-hud-glass] {
|
||||
/* The exit chip rides this box, not the band — the band is the whole window,
|
||||
so anchoring there left the chip stranded in empty space above a short
|
||||
transcript. */
|
||||
anchor-name: --hud-band;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
background: color-mix(in srgb, var(--dt-card) 55%, 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
|
||||
every frame and looked coarse — and it leaves the box alone, which matters
|
||||
because the one time the transcript's own container was collapsed the
|
||||
messages spilled out of a zero-height box. Translate rather than scale: the
|
||||
panel keeps its proportions on the way out instead of being squashed.
|
||||
|
||||
The height below still tracks the transcript; only the offset animates on
|
||||
exit, and it finishes ahead of the fade so the panel has left while the last
|
||||
of the text is still going. */
|
||||
height: min(100%, calc(var(--hud-band-height, 0px) + var(--hud-sheet-overhang, 0.75rem)));
|
||||
translate: 0 var(--hud-exit-shift, 2.5rem);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--hud-fade) var(--hud-ease-exit),
|
||||
translate var(--hud-collapse) var(--hud-ease-exit),
|
||||
height var(--hud-reveal) var(--hud-ease-enter),
|
||||
background-color var(--hud-dim) var(--hud-ease-enter);
|
||||
}
|
||||
|
||||
/* Engaged: as tall as the transcript needs, capped at the window. An empty
|
||||
session measures 0, so it stays rolled up. */
|
||||
[data-hud-shell][data-hud-recent] [data-hud-glass],
|
||||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] {
|
||||
translate: none;
|
||||
opacity: 1;
|
||||
transition-duration: var(--hud-reveal);
|
||||
}
|
||||
|
||||
/* 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
|
||||
to use it, and the transcript flew open every time it was moved. Hit-testing
|
||||
below stays on :focus-within — being permissive about what can be clicked is
|
||||
harmless, being permissive about what lights up is not. */
|
||||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] {
|
||||
background: color-mix(in srgb, var(--dt-card) 92%, transparent);
|
||||
}
|
||||
|
||||
/* Flipped, the sheet hangs from the bar instead of standing on it. */
|
||||
[data-hud-shell][data-hud-edge='top'] [data-hud-glass] {
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
--hud-exit-shift: -2.5rem;
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
[data-hud-shell] [data-slot='composer-bounds'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Clickable whenever it's actually on screen, not only while the caret is in
|
||||
the composer — a link in the transcript is worth clicking the moment you can
|
||||
see it. Faded out it goes back to `none`, which is what lets clicks over the
|
||||
HUD reach whatever you're really working in. */
|
||||
[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'],
|
||||
[data-hud-shell]:focus-within [data-slot='composer-bounds'] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Three states, not two. A turn landing brings the transcript up, but only part
|
||||
way — it is there to be glanced at over whatever you are actually doing, and
|
||||
full-strength text over another app reads as the HUD demanding attention it
|
||||
hasn't earned. Focus is what promotes it to properly readable; the hold and
|
||||
fade then take it away from wherever it was. */
|
||||
[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'] {
|
||||
opacity: 0.5;
|
||||
translate: none;
|
||||
/* Same duration AND curve as the sheet's tint step, or the two visibly ease
|
||||
apart on the way down. */
|
||||
transition-duration: var(--hud-dim);
|
||||
transition-timing-function: var(--hud-ease-enter);
|
||||
}
|
||||
|
||||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-slot='composer-bounds'] {
|
||||
opacity: 1;
|
||||
translate: none;
|
||||
transition-duration: var(--hud-reveal);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
/* User messages ride the band like every other line. In the app each sticky
|
||||
user row paints an OPAQUE chat-surface slab behind it (so a stuck bubble can
|
||||
slide over scrolled text) and the bubble is solid --dt-user-bubble — inside
|
||||
the smoked band both read as fully opaque cards that ignore every fade. The
|
||||
slab goes unconditionally; the bubble smokes at rest and returns solid when
|
||||
engaged. ONE variable carries the fill so there is no specificity fight
|
||||
between rest/engaged rules — the states just move the var. */
|
||||
[data-hud-shell] {
|
||||
--hud-bubble-fill: color-mix(in srgb, var(--dt-user-bubble) 65%, transparent);
|
||||
}
|
||||
|
||||
/* ONE opacity law for the whole band: solid on focus, material otherwise.
|
||||
Bubbles going solid on `recent` too made them opaque cards floating on the
|
||||
62% band mid-stream — the band only steps solid on focus, so nothing inside
|
||||
it may step earlier. */
|
||||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) {
|
||||
--hud-bubble-fill: var(--dt-user-bubble);
|
||||
}
|
||||
|
||||
[data-hud-shell] [data-slot='aui_user-message-root'] {
|
||||
background: transparent !important;
|
||||
/* Not sticky in HUD mode: the band is a short log, and a pinned bubble
|
||||
spends a third of it on a line you already read. Static rows scroll away
|
||||
like everything else (the app keeps its sticky behavior — this is the
|
||||
HUD's call, not a global one). */
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
/* User bubbles: the ONE thing in the band that paints its own opaque fill
|
||||
(bg-(--dt-user-bubble) on USER_BUBBLE_BASE_CLASS), so it survives every
|
||||
fade the band applies to its own background and reads as a solid card
|
||||
floating on glass. Two things are needed, and only doing one of them is
|
||||
why this kept coming back:
|
||||
- the FILL follows the band's state (--hud-bubble-fill), and
|
||||
- the BORDER does too; an opaque hairline outlines the card even when
|
||||
the fill is right.
|
||||
Fill and border both ride the same variable, so they cannot desync. */
|
||||
[data-hud-shell] .composer-human-message {
|
||||
background: var(--hud-bubble-fill) !important;
|
||||
border-color: color-mix(in srgb, var(--ui-stroke-secondary) 45%, transparent) !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
transition:
|
||||
background-color var(--hud-reveal) var(--hud-ease-enter),
|
||||
border-color 350ms ease;
|
||||
}
|
||||
|
||||
/* The bubble's own container also paints the chat surface behind it. */
|
||||
[data-hud-shell] .composer-human-message-container {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* ONE glass element in HUD mode: [data-hud-glass]. Everything else drops its
|
||||
backdrop-filter — the composer tree ships several Tailwind backdrop-blur
|
||||
utilities (surface glass, fallback surface, attachment spinners, voice
|
||||
pills) that are invisible inside the opaque app but paint as smeared halos
|
||||
around the pill's rounded corners on a transparent window.
|
||||
|
||||
Keep this exemption in step with whatever carries the glass: pointing it at
|
||||
the wrong element silently deletes the blur, which looks exactly like
|
||||
backdrop-filter not working at all. */
|
||||
[data-hud-shell] *:not([data-hud-glass], [data-hud-glass] *) {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* No session header, no jump-to-bottom pill, no timeline rail. */
|
||||
[data-hud-shell] header,
|
||||
[data-hud-shell] [data-slot='thread-timeline'],
|
||||
[data-hud-shell] .thread-jump-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* SCROLLING vs DRAGGING — pick one, per element, per STATE.
|
||||
`-webkit-app-region: drag` hands an element's mouse input to the window
|
||||
manager, wheel included — and the region math is rect-based, so a `no-drag`
|
||||
band subtracts its rectangle even when it's pointer-transparent. The band is
|
||||
most of the window, so giving its rect away permanently made the HUD nearly
|
||||
immovable in practice (the composer almost always has focus while in use).
|
||||
|
||||
So the carve-out needs BOTH: composer focused AND actual overflow to scroll
|
||||
(data-hud-scrollable, set by HudShell). A short conversation leaves the
|
||||
whole window grabbable; the band only becomes a scroll surface when
|
||||
scrolling is a real thing you could do with it. */
|
||||
[data-hud-shell][data-hud-scrollable]:focus-within [data-slot='composer-bounds'] {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* ── Edge flip ───────────────────────────────────────────────────────────────
|
||||
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, text melts at the bottom, and
|
||||
the drag strip moves to the bottom dead zone. Same four surfaces, same
|
||||
variables — only the anchors swap. */
|
||||
[data-hud-shell][data-hud-edge='top'] [data-slot='composer-dock'] {
|
||||
top: 0 !important;
|
||||
bottom: auto !important;
|
||||
}
|
||||
|
||||
[data-hud-shell][data-hud-edge='top'] [data-slot='composer-bounds'] {
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Flipped, the bar is at the TOP, so the clearance has to be too — the app's
|
||||
clearance element only pads the end of the thread. */
|
||||
[data-hud-shell][data-hud-edge='top'] [data-chat-surface] {
|
||||
--thread-last-message-clearance: 0.25rem;
|
||||
}
|
||||
|
||||
[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-content'] {
|
||||
padding-block-start: calc(var(--hud-bar-height, var(--composer-fallback-height)) + 0.25rem) !important;
|
||||
}
|
||||
|
||||
[data-hud-shell][data-hud-edge='top'] [data-hud-drag-strip] {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* The drag handle is the top strip of the WINDOW, above the band. The band
|
||||
itself scrolls (a drag region eats wheel events, so it can never be the
|
||||
handle); this strip is dead space either way, so it drags. */
|
||||
[data-hud-shell] [data-hud-drag-strip] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2rem;
|
||||
z-index: 10;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* Tighten the thread on every side. The docked chat's gutters (px-6 py-8 plus
|
||||
a 1.5rem inline pad) are sized for a full window column; in a bar a few
|
||||
lines tall they're most of the surface. Bottom pad drops to a hairline —
|
||||
the band already ends at the bar, so any block-end padding here reads as a
|
||||
gap between the last message and the composer. */
|
||||
[data-hud-shell] [data-slot='aui_thread-content'] {
|
||||
padding-inline: 0.6rem !important;
|
||||
padding-block: 0.35rem 0.25rem !important;
|
||||
/* A chat frame fills from the bottom. The docked thread hangs from the top
|
||||
because it is always taller than its content is short; the band is a few
|
||||
lines tall, so a two-message conversation left a dead gap between the last
|
||||
reply and the composer. Filling the viewport and packing to the end puts
|
||||
that slack ABOVE the text, where it's just more transparent window. */
|
||||
min-height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Parked at the top the band hangs below the bar, so it fills downward. */
|
||||
[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-content'] {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Space under the last message — the app's own measured-dock clearance, minus
|
||||
the 2rem of breathing room a full window can afford. This is what holds the
|
||||
text off the bar now that the band runs the whole window height, and it is
|
||||
measured from the live dock, so it tracks the composer growing to two, three,
|
||||
ten rows with no hairline and no hole. */
|
||||
[data-hud-shell] [data-chat-surface] {
|
||||
--thread-last-message-clearance: var(--hud-bar-height, var(--composer-fallback-height));
|
||||
}
|
||||
|
||||
/* Same for the dock's own bottom pad — this var also feeds
|
||||
--composer-measured-height, so the thread and the composer stay in
|
||||
agreement from one value. */
|
||||
[data-hud-shell] {
|
||||
/* Conversation rhythm, tightened for a bar a few lines tall. The docked
|
||||
thread's spacing is sized for a full-height column; in the HUD that air is
|
||||
most of the band, and the ramp eats the older turns before you can read
|
||||
them. Same knobs the app uses, just smaller here. */
|
||||
--conversation-turn-gap: 0.1875rem;
|
||||
--turn-block-gap: 0.375rem;
|
||||
--paragraph-gap: 0.4rem;
|
||||
--composer-shell-pad-block-end: 0px;
|
||||
/* The viewport fills the band, period. The app's calc shortens the viewport
|
||||
to leave the dock's strips visible below it; the HUD hides those strips, so
|
||||
applying it here just strands a dead zone at the end of the thread that
|
||||
grows as you type. Room for the bar comes from the clearance instead. */
|
||||
--thread-viewport-height: 100%;
|
||||
}
|
||||
|
||||
/* The composer BAR is the drag handle: it's the one surface that isn't a
|
||||
scroll container, so it's the only one that can be. Its frame drags; the
|
||||
controls carve themselves back out (a drag region eats their clicks too). */
|
||||
[data-hud-shell] [data-slot='composer-dock'] {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* Focusables, not a list of the controls that happen to be in the bar today —
|
||||
a drag region eats the clicks of anything it covers, so anything missed here
|
||||
is a dead control. */
|
||||
[data-hud-shell] [data-slot='composer-rich-input'],
|
||||
[data-hud-shell]
|
||||
[data-slot='composer-dock']
|
||||
:is(
|
||||
a[href],
|
||||
button,
|
||||
input,
|
||||
label,
|
||||
select,
|
||||
textarea,
|
||||
[contenteditable],
|
||||
[role='button'],
|
||||
[role='combobox'],
|
||||
[role='menuitem'],
|
||||
[role='switch'],
|
||||
[tabindex]:not([tabindex='-1'])
|
||||
) {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* HUD mode is the input and the log — nothing else. The dock stacks
|
||||
[micro-action pills] · [status stack] · [composer] · [underside slot], and
|
||||
only the composer belongs here: the status stack (todos, subagents,
|
||||
background tasks, queue, the coding/cwd/git-branch row) and the strips
|
||||
around it are ambient chrome for a full window, and in a bar a few lines
|
||||
tall they'd shove the input off screen.
|
||||
|
||||
Written as "keep the composer, hide its siblings" rather than a list of
|
||||
selectors, because those siblings carry no data-slot of their own — a list
|
||||
would silently miss whatever gets added to the dock next. */
|
||||
[data-hud-shell] [data-slot='composer-dock'] > *:not([data-slot='composer-root']) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The coding/cwd/git-branch strip is NOT a dock sibling — it renders inside
|
||||
`composer-surface` (it's meant to inherit the composer's width and top
|
||||
radius), so the rule above never reaches it. Same intent, own selector. */
|
||||
[data-hud-shell] .coding-status-bar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The composer is the Spotlight bar: centered, capped width, the ONE
|
||||
permanent object on screen. When the thread is faded out, this is all HUD
|
||||
mode is.
|
||||
|
||||
The dock is `absolute bottom-0 left-1/2 -translate-x-1/2` against the chat
|
||||
surface. Two traps live in that, and both silently ate earlier attempts:
|
||||
- `margin-bottom` does NOTHING to an absolutely-positioned box anchored to
|
||||
an edge; the lift has to come from `bottom`.
|
||||
- Tailwind centers it with `left-1/2` + a translate, so BOTH `transform`
|
||||
and `translate` (the standalone property) must be cancelled — absolute
|
||||
centering here is left/right 0 + capped width + margin-inline auto.
|
||||
|
||||
Do NOT restyle `position`: the controls row (model pill, mic, send) resolves
|
||||
against the nearest positioned ancestor, so making the dock static re-anchors
|
||||
that row and the composer tears in half — input in flow at the top, controls
|
||||
stranded at the bottom. */
|
||||
[data-hud-shell] [data-slot='composer-dock'],
|
||||
[data-hud-shell] [data-slot='composer-dock'][data-popped-out] {
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
top: auto !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
transform: none !important;
|
||||
translate: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* The bar is never "away". In the docked app the composer dims to 30% while
|
||||
you read back through the thread, and comes back on hover or focus — sensible
|
||||
when it's a panel at the bottom of a big window you're scrolling past. Here
|
||||
it IS the interface: the one permanent, fully-present object, whatever the
|
||||
transcript above it is doing. */
|
||||
[data-hud-shell] [data-slot='composer-fade'] {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
[data-hud-shell] [data-slot='composer-root'] {
|
||||
/* Opaque, so anything docked to the composer (status stack, queue panel)
|
||||
paints the same solid fill the bar does — they share this var. */
|
||||
--composer-fill: var(--dt-card);
|
||||
/* The base rule adds 5px of transparent grab margin for the peel-out drag,
|
||||
and the dock compensates with +10px of width. Neither applies here (the
|
||||
HUD composer doesn't peel out), and left in they inset the card unevenly
|
||||
inside its own float. */
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* The composer surface paints TWO layers: the surface itself, plus an
|
||||
`absolute inset-0 -z-10` fill+glass div behind it (composerFill /
|
||||
composerSurfaceGlass). Two stacked fills read as a box inside a box, so the
|
||||
backing layer goes and the surface carries the single fill. */
|
||||
[data-hud-shell] [data-slot='composer-surface'] > .pointer-events-none.absolute.inset-0 {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Opaque, pill-rounded — the Spotlight bar. Same surface tokens the composer
|
||||
already uses; only the radius steps up so a lone floating bar reads as an
|
||||
object rather than a snippet of app chrome.
|
||||
|
||||
ALWAYS fully opaque. It's the input — the one thing that must never be
|
||||
compromised by whatever's behind the window, in any state. The idle/engaged
|
||||
breathing belongs to the thread and the chat surface; the bar just sits
|
||||
there, solid, like Spotlight does. */
|
||||
[data-hud-shell] [data-slot='composer-surface'] {
|
||||
border: 1px solid var(--ui-stroke-secondary) !important;
|
||||
/* Same radius as the band's top and the window's own corners, so the HUD
|
||||
reads as one object however the band is behaving. Its top corners let a
|
||||
sliver of band through, which is the point — the glass wraps the bar's
|
||||
shoulders instead of stopping in a straight line above it. */
|
||||
border-radius: 0.75rem !important;
|
||||
background: var(--dt-card) !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* The exit button: a real control, visible at rest, on a card chip so it
|
||||
reads against any backdrop.
|
||||
|
||||
Square (aspect-ratio 1/1 on the app's titlebar button size) and INSET from
|
||||
the band's corner — tucked flush it read as a mistake; a chip floating in
|
||||
the corner with its own margin reads as intentional. anchor() to the band:
|
||||
calc()-based attempts drifted because the composer's measured vars are
|
||||
inline-scoped and stale outside [data-chat-surface]. */
|
||||
|
||||
[data-hud-shell] [data-hud-exit] {
|
||||
position-anchor: --hud-band;
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
right: calc(anchor(right) + 0.5rem);
|
||||
top: calc(anchor(top) + 0.5rem);
|
||||
aspect-ratio: 1;
|
||||
height: auto;
|
||||
background: var(--dt-card) !important;
|
||||
border: 1px solid var(--ui-stroke-tertiary);
|
||||
border-radius: 0.5rem;
|
||||
/* Part of the band, so it lives and dies with it — same states, same timing.
|
||||
Left visible at rest it is a lone chip hovering in empty space a hundred
|
||||
pixels above the bar; revealed on hovering the window it pops up whenever
|
||||
the cursor crosses the HUD on its way somewhere else. */
|
||||
opacity: 0;
|
||||
transition: opacity var(--hud-fade) var(--hud-ease-exit);
|
||||
/* And it takes clicks on exactly the same terms as the band it sits in —
|
||||
visible or nothing. A faded-out control that still takes clicks is an
|
||||
invisible button floating over the app behind; a lit one that doesn't is
|
||||
the dead chip you get from keying this to focus alone. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-hud-shell][data-hud-recent] [data-hud-exit],
|
||||
[data-hud-shell]:focus-within [data-hud-exit] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Flipped, the sheet's top corner is behind the bar — drop the chip clear of
|
||||
it so it stays in the transcript rather than under the composer. */
|
||||
[data-hud-shell][data-hud-edge='top'] [data-hud-exit] {
|
||||
top: calc(anchor(top) + var(--hud-bar-height, var(--composer-fallback-height)) + 0.5rem);
|
||||
}
|
||||
|
||||
[data-hud-shell][data-hud-recent] [data-hud-exit],
|
||||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-exit] {
|
||||
opacity: 0.75;
|
||||
transition-duration: var(--hud-reveal);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
[data-hud-shell][data-hud-recent] [data-hud-exit]:hover,
|
||||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-exit]:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The dock's fade-to-surface gradient assumes a chat column behind it; over a
|
||||
transparent HUD it's a grey smear. */
|
||||
[data-hud-shell] [data-slot='composer-root'] > .pointer-events-none {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Portaled overlays (model picker, menus, dialogs, tooltips, the command
|
||||
palette) mount into <body>, OUTSIDE [data-hud-shell] — so they never inherit
|
||||
its `no-drag` carve-outs. And Electron's draggable region is RECT math, not
|
||||
paint order: the shell's window-sized `drag` rect covers wherever the menu
|
||||
opens, and anything that doesn't explicitly subtract itself is handed to the
|
||||
window manager. The menu rendered, sat correctly, and swallowed every click
|
||||
("I can't even select a model"). Carve every portal back out. */
|
||||
html:has([data-hud-shell]) body > *:not(#root) {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Popovers in a ~320px-tall window. Two separate problems:
|
||||
|
||||
1. Radix clamps the popover to --radix-*-available-height, but fixed
|
||||
max-heights INSIDE the panels (the model catalog's max-h-[max(150px,
|
||||
30dvh)] list) don't shrink with it, so the list keeps its own floor and
|
||||
the menu overflows the window.
|
||||
2. The window IS the collision boundary, so even a correctly-clamped menu
|
||||
has only ~320px to live in.
|
||||
|
||||
(1) is ours to fix in CSS; inner scrollers yield to the window instead of
|
||||
their own floor. (2) is why the menus are compact here rather than roomy —
|
||||
a HUD is a small window and a 400px menu cannot fit in it. */
|
||||
html:has([data-hud-shell]) [data-slot='dropdown-menu-content'] [class*='max-h-'],
|
||||
html:has([data-hud-shell]) [data-slot='popover-content'] [class*='max-h-'] {
|
||||
max-height: max(5rem, calc(100dvh - 9rem)) !important;
|
||||
}
|
||||
|
||||
/* The menu panels themselves: never taller than the window minus the bar. */
|
||||
html:has([data-hud-shell]) [data-slot='dropdown-menu-content'],
|
||||
html:has([data-hud-shell]) [data-slot='popover-content'] {
|
||||
max-height: calc(100dvh - 4.5rem) !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Hide the scrollbar — a HUD with a visible track stops reading as an overlay. */
|
||||
[data-hud-shell] [data-slot='composer-bounds'] * {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
[data-hud-shell] [data-slot='composer-bounds'] *::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue