From 0db11f9952f97921810ba386c17cc71d55e7fb35 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 01:38:49 -0500 Subject: [PATCH 01/15] fix(desktop): keep the transcript whole when resuming a running session Resuming a session that is mid-turn somewhere else collapsed the thread down to the in-flight prompt, and the user's own message never appeared at all until a reload. Two wrong premises in the resume path, neither specific to any one surface: `omit_messages` was read as "empty transcript" rather than "no transcript in this response". Desktop asks session.resume/activate to omit messages because REST is the transcript authority, so mid-turn the live projection got reconciled against an empty list and rebuilt the thread out of itself. The response already carries `messages_omitted`; nothing read it. It now grafts the projection onto the cache (or the REST prefetch) instead. The settle path skipped hydration whenever the window had streamed the reply, on the assumption that streaming a reply means owning the whole turn. True for a turn you started, false for one you adopted: it arrives reply-first with no prompt row, and nothing ever backfilled it. Sessions now carry `adoptedRunningTurn`, set when a resume lands on an already-running turn and consumed when it settles. --- .../session/hooks/use-message-stream/index.ts | 11 ++++++- .../hooks/use-session-actions.test.tsx | 1 + .../hooks/use-session-actions/index.ts | 30 ++++++++++++++++--- apps/desktop/src/app/types.ts | 6 ++++ apps/desktop/src/lib/chat-runtime.ts | 1 + 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index a73852f6e923f..979bbc1e2f5e2 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -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, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 1bdb521efabcb..cb3f6df916d2b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -937,6 +937,7 @@ describe('resumeSession failure recovery', () => { interimBoundaryPending: false, interrupted: false, messages: [], + adoptedRunningTurn: false, model: '', needsInput: false, pendingBranchGroup: null, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 0f13a375c0837..b09db251ccae7 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -735,8 +735,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 @@ -773,7 +782,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 ) @@ -933,7 +946,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 })() @@ -990,6 +1011,7 @@ export function useSessionActions({ messages: messagesForView, busy: resumedRunning, awaitingResponse: resumedRunning && !recoveredInFlightTail, + adoptedRunningTurn: state.adoptedRunningTurn || resumedRunning, ...(inFlightRecovery.applied ? { sawAssistantPayload: true, diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index f7452607f7c83..743c2b95be019 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -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. */ diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 99585a3c7642f..c581daaea9c7e 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -52,6 +52,7 @@ export function createClientSessionState( awaitingResponse: false, streamId: null, sawAssistantPayload: false, + adoptedRunningTurn: false, pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, From 8560dc6b97777752cdad1c5c882462b2ce74be5e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 01:38:57 -0500 Subject: [PATCH 02/15] feat(desktop): let a composer draft move between windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drafts are per-renderer state backed by shared localStorage, and the map is read once at module load, so two windows on the same session diverge the moment either types. Adds the two verbs a handoff needs: `reloadPersistedDrafts` to merge another window's writes in (keeping local attachments, which are never persisted), and a draft-sync bus so a composer can be told to flush its live text down to the stash or repaint from it. Dispatched synchronously, unlike the focus bus — a flush has to complete before the window that will read it is created. --- .../chat/composer/hooks/use-composer-draft.ts | 41 +++++++++- apps/desktop/src/store/composer.ts | 78 +++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index b5bf87dc4660b..c32db0f6c2211 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -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) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 7f32348cd367e..6783db9637abd 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -151,6 +151,84 @@ function loadPersistedDraftTexts(): [string, SessionDraft][] { const draftsBySession = new Map(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(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).detail) + window.addEventListener(DRAFT_SYNC_EVENT, listener) + + return () => window.removeEventListener(DRAFT_SYNC_EVENT, listener) +} + function persistDraftTexts() { try { const entries = [...draftsBySession] From 7b0dbd2242e8921da9c2e30afa87ff349b433d42 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 01:38:57 -0500 Subject: [PATCH 03/15] feat(desktop): HUD mode window and its session handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transparent, frameless, always-on-top window that renders the real chat surface, so its composer is the app's composer rather than a lookalike that drifts. Main owns the window, its remembered geometry, and click-through. Leaving is a handoff, not a window close. The gateway binds a session's event stream to exactly one socket, so a turn started in the HUD streams only there and the app window hears nothing — no deltas, no turn-complete, no draft clear, and nothing to poll for mid-turn. So the app re-resumes the session the HUD ended on, which rebinds the transport, reconciles the transcript, and picks up an in-flight turn. Main carries the session id across, since it is the only party that outlives the HUD's renderer. --- apps/desktop/electron/main.ts | 309 ++++++++++++++++++++++++++++ apps/desktop/electron/preload.ts | 17 ++ apps/desktop/electron/zoom.ts | 7 +- apps/desktop/src/app/hud/handoff.ts | 81 ++++++++ apps/desktop/src/global.d.ts | 11 + apps/desktop/src/store/hud.ts | 92 +++++++++ apps/desktop/src/store/windows.ts | 32 +++ 7 files changed, 546 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/app/hud/handoff.ts create mode 100644 apps/desktop/src/store/hud.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 116c11bfc5330..4fc687ebd3091 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9034,6 +9034,273 @@ 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 window to a rounded silhouette rather than a hard rectangle. + roundedCorners: true, + 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()) { + 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 +9969,37 @@ 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 } +}) + +// 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 @@ -11994,6 +12292,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() diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index dd9537b26fd73..2406798e40a6a 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -46,6 +46,23 @@ 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), + // 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), + 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 diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index 4a6d0db6bbf6b..9dfdbbc527753 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -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. diff --git a/apps/desktop/src/app/hud/handoff.ts b/apps/desktop/src/app/hud/handoff.ts new file mode 100644 index 0000000000000..0b385f10b742b --- /dev/null +++ b/apps/desktop/src/app/hud/handoff.ts @@ -0,0 +1,81 @@ +/** + * 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 { isHudWindow } from '@/store/windows' + +import { openSession, type OpenSessionNavigate } from '../open-session' + +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({ 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 + + // The HUD switched sessions (or started one this window has never seen): + // route to it and let the route resume do the rest, including loading + // that session's draft as the composer's scope swaps. + if (target && target !== selected) { + 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: keep main told which session this window is on. */ +export function useReportHudSession(): void { + const selectedStoredSessionId = useStore($selectedStoredSessionId) + + useEffect(() => { + if (isHudWindow()) { + reportHudSession(selectedStoredSessionId) + } + }, [selectedStoredSessionId]) +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index fbea2424abf22..6d2a570a1db1e 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -62,6 +62,17 @@ 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 + setSession: (sessionId: null | string) => 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 diff --git a/apps/desktop/src/store/hud.ts b/apps/desktop/src/store/hud.ts new file mode 100644 index 0000000000000..e16f1f11a7a2c --- /dev/null +++ b/apps/desktop/src/store/hud.ts @@ -0,0 +1,92 @@ +/** + * 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()) + +/** 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) + 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) + 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) + + if (!open) { + onClosed?.(sessionId) + } + }) + + return off ?? (() => {}) +} diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index 349b29d12ba0b..75d48097748a1 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -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 { From e8b83f37c83caa47631c7a3e3671744fa2ea7d50 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 01:39:06 -0500 Subject: [PATCH 04/15] =?UTF-8?q?feat(desktop):=20the=20HUD=20surface=20?= =?UTF-8?q?=E2=80=94=20Spotlight=20bar=20with=20a=20fading=20chat=20band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer half. HUD mode reuses the app's own chat surface and only changes the frame around it: no titlebar, no statusbar, no pane tree. The band and the bar tile the window between them with no dead margins, and the band runs the window's full height with the opaque bar sitting on its bottom edge, so there is no seam to compute and none to drift as the composer grows. Visibility is a WoW chat frame: the transcript shows while a turn is recent or the composer has focus, then holds and fades. A bottom-anchored gradient mask carries the fade on both the sheet and the text, and flattens away on focus so nothing is dimmed while you are reading. Only the composer never fades — it is the interface. The window is mouse-transparent everywhere it isn't really there, so clicks over the faded band reach the app behind it; `pointer-events` can't do this, since the click never reaches the page at all. --- apps/desktop/src/app/chat/index.tsx | 6 +- apps/desktop/src/app/contrib/controller.tsx | 14 + .../src/app/contrib/hooks/use-pet-bridge.ts | 4 +- .../contrib/hooks/use-quick-entry-bridge.ts | 6 +- apps/desktop/src/app/contrib/wiring.tsx | 37 +- apps/desktop/src/app/hud/click-through.ts | 75 +++ apps/desktop/src/app/hud/hud-shell.tsx | 253 +++++++ .../components/assistant-ui/thread/list.tsx | 18 + apps/desktop/src/styles.css | 629 ++++++++++++++++++ 9 files changed, 1021 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src/app/hud/click-through.ts create mode 100644 apps/desktop/src/app/hud/hud-shell.tsx diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 2ed1169f4e168..f8c2c86a6790c 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -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 && diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index c66ac3679959b..1bd11309a1063 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -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 ( + + + + ) + } + return ( { - if (isSecondaryWindow()) { + if (isAuxiliaryWindow()) { return } diff --git a/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts b/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts index dbd886e18fa98..5fbfd102b156e 100644 --- a/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts +++ b/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts @@ -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 } diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 02f95d9765a9d..fbb6558c530cd 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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 @@ -983,7 +987,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { // Pane-registered tools (preview's monitor/devtools cluster) anchor flush // against the static system cluster — in the tree layout the titlebar band // sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply. - const SYSTEM_TOOL_COUNT = 4 + const SYSTEM_TOOL_COUNT = 5 const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))` @@ -1006,18 +1010,23 @@ export function ContribWiring({ children }: { children: ReactNode }) { } as CSSProperties } > - 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() && ( + navigate(SETTINGS_ROUTE)} + tools={rightTitlebarTools} + /> + )} {children} {/* The full real overlay set (mirrors DesktopController's `overlays`). */} - {!isSecondaryWindow() && } - {!isSecondaryWindow() && ( + {!isAuxiliaryWindow() && } + {!isAuxiliaryWindow() && ( { @@ -1113,11 +1122,13 @@ export function ContribWiring({ children }: { children: ReactNode }) { {/* Toasts above everything. */} - {/* Petdex floating mascot — renders nothing unless installed + enabled. */} - + {/* Petdex floating mascot — renders nothing unless installed + enabled. + Never in the HUD: that window is the chat bar and nothing else. */} + {!isHudWindow() && } - {/* Single persistent xterm host chasing the terminal pane's slot rect. */} - + {/* Single persistent xterm host chasing the terminal pane's slot rect. + The HUD has no terminal pane, so it has nothing to chase. */} + {!isHudWindow() && } ) } diff --git a/apps/desktop/src/app/hud/click-through.ts b/apps/desktop/src/app/hud/click-through.ts new file mode 100644 index 0000000000000..1c6eaf9ed93c2 --- /dev/null +++ b/apps/desktop/src/app/hud/click-through.ts @@ -0,0 +1,75 @@ +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: the bar, always, and everything else only while the composer + * holds focus — the same line the band and its exit chip draw with + * `pointer-events`. `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): 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 + + const overBar = () => { + const bar = root.querySelector('[data-slot="composer-dock"]') + + if (!bar || !point) { + return false + } + + const rect = bar.getBoundingClientRect() + + return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom + } + + const apply = () => { + const next = !root.matches(':focus-within') && !overBar() + + if (ignoring !== next) { + ignoring = next + setIgnoreMouse(next) + } + } + + const onMove = (event: MouseEvent) => { + point = { x: event.clientX, y: event.clientY } + apply() + } + + apply() + window.addEventListener('mousemove', onMove) + root.addEventListener('focusin', apply) + root.addEventListener('focusout', apply) + + return () => { + setIgnoreMouse(false) + window.removeEventListener('mousemove', onMove) + root.removeEventListener('focusin', apply) + root.removeEventListener('focusout', apply) + } + }, [rootRef]) +} diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx new file mode 100644 index 0000000000000..49d3309b36e80 --- /dev/null +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -0,0 +1,253 @@ +import { type CSSProperties, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { closeHud } from '@/store/hud' +import { $busy, $messages } from '@/store/session' + +import { WiredPane } from '../contrib/wiring' +import { titlebarButtonClass } from '../shell/titlebar' + +import { useHudClickThrough } from './click-through' +import { useReportHudSession } from './handoff' + +/** How long the thread stays visible after the last activity before it starts + * fading (WoW chat frame behavior). Focus holds it open past this. */ +const HUD_RECENT_HOLD_MS = 6_000 + +/** 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 = 150 +const HUD_FADE_DELAY_MS = 3_000 +const HUD_FADE_MS = 1_200 + +/** + * 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 { + const [recent, setRecent] = useState(false) + const timerRef = useRef | null>(null) + + // eslint-disable-next-line no-restricted-syntax -- timer handle, not an atom mirror + useEffect(() => { + const bump = () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + + setRecent(true) + timerRef.current = setTimeout(() => setRecent(false), HUD_RECENT_HOLD_MS) + } + + // subscribe() fires immediately, so a HUD opened onto an existing + // conversation starts with the thread showing, then fades. + const offMessages = $messages.subscribe(bump) + const offBusy = $busy.subscribe(busy => busy && bump()) + + return () => { + offMessages() + offBusy() + + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + + return recent +} + +/** + * 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 = useRecentActivity() + + // 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() + + // 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('bottom') + + useEffect(() => { + // ZERO tolerance by explicit request: top-mode only when the window is + // flush against the usable top (gap 0 — macOS won't let it overlap the + // menu bar, so flush IS availTop). Tiny FLIP_OFF so the 300ms poll can't + // flutter on sub-pixel jitter while parked. + const FLIP_ON = 0 + const FLIP_OFF = 4 + + const measure = () => { + // 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(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('[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)) + } + + // 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() + } + }, []) + + useHudClickThrough(rootRef) + + // Force the HOST layers transparent. index.html's pre-paint script writes an + // opaque themed background onto 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 ( +
+ {/* 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. */} +
+ + + + {/* 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). */} +
+ + {/* 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. */} + + + +
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 304b3eb3ec017..0caf309e855c9 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -19,6 +19,7 @@ import { useI18n } from '@/i18n' import { messageRenderWeight } from '@/lib/render-weight' import { cn } from '@/lib/utils' import { + $threadScrolledUp, onScrollToBottomRequest, onThreadEditClose, onThreadEditOpen, @@ -383,6 +384,23 @@ const ThreadMessageListInner: FC = ({ // 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]) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 2ee38e0b6584a..70188469a8d14 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2264,3 +2264,632 @@ 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 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; + /* The band's fade, shared by the glass layer and the transcript so the two + can never ramp differently. + + Bottom-anchored and deliberately NOT linear — the WoW chat frame's shape. + A straight ramp starts eating the newest reply immediately, which is the + one thing that must stay legible, and it leaves the whole band looking + half-dissolved. Instead: most of it is FULLY solid, then it collapses in + one short stretch, and the top fifth is nothing at all. That empty cap is + what removes the edge — any alpha still alive at the window boundary reads + as a line, and with the frost riding this same mask it would be a line of + blur, which is worse. */ + --hud-thread-mask-stops: #000 0%, #000 58%, rgb(0 0 0 / 0.42) 71%, transparent 82%; + --hud-thread-mask: linear-gradient(to top, var(--hud-thread-mask-stops)); +} + +[data-hud-shell][data-hud-edge='top'] { + --hud-thread-mask: linear-gradient(to bottom, var(--hud-thread-mask-stops)); +} + +/* 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 color, so it smokes dark on dark themes and frosts light on + light ones, and text keeps its normal contrast against it. + - VISIBILITY keeps the WoW fade this HUD already had: shown while a turn + is recent/streaming (`data-hud-recent`), on band hover, or while 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; + left: 0 !important; + right: 0 !important; + /* The band and the bar tile the window between them, with no dead margins — + the band runs edge to edge and the bar covers the bottom of it. There is + then no seam to get wrong: the band does not stop at the bar, it runs the + window's FULL height and the opaque bar sits on top of its bottom edge. + Every earlier attempt computed that edge from + --composer-surface-measured-height, which is the composer's published + height, rounded; 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"). Nothing to compute, nothing to drift. 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 (roundedCorners clips the + frost to the same silhouette); square at the bottom, where the bar covers it. */ + border-radius: 0.75rem 0.75rem 0 0; + /* No fill and no blur of its own — the glass is its own layer behind this + one ([data-hud-glass]). Chromium will not honour `mask-image` and + `backdrop-filter` on the SAME element: the mask ends up applying to the + backdrop rather than the content, which shows up as the sheet fading on + the gradient while the text stays stubbornly solid. Two elements, two + masks, no interaction. */ + background: transparent !important; + opacity: 0; + /* WoW linger: reveal is quick, but on losing focus the band HOLDS, then fades + out slow. The timings come from hud-shell.tsx, which uses the same numbers + to decide when to drop the vibrancy layer. Reveal rules zero the delay. */ + transition: + opacity var(--hud-fade) ease var(--hud-fade-delay), + background-color 350ms ease; +} + +/* 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. + + There is no desktop blur under it, and that is a hard limit rather than a + gap. macOS vibrancy is composited by WindowServer BELOW the web contents, + after this process has finished drawing, so nothing in the DOM can shape it — + mask, clip-path, stacking, none of it reaches. Leaving vibrancy on under a + masked tint is worse than no blur: the frost stays a flat slab, so the top of + the band goes pale exactly where it should be disappearing. And CSS + backdrop-filter is not a substitute; verified on the real window rather than + assumed, it produces no blur whatsoever, because a transparent window's + backdrop root is the document and the desktop was never in it. + + The one real way out is native — NSVisualEffectView.maskImage takes an alpha + mask for precisely this — which needs a small addon over + getNativeWindowHandle() and buys a cross-dissolve between frosted and sharp + rather than a true blur-radius ramp. Its own change, not this one. */ +[data-hud-shell] [data-hud-glass] { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + border-radius: 0.75rem 0.75rem 0 0; + background: color-mix(in srgb, var(--dt-background) 62%, transparent); + opacity: 0; + transition: opacity var(--hud-fade) ease var(--hud-fade-delay); + -webkit-mask-image: var(--hud-thread-mask); + mask-image: var(--hud-thread-mask); + -webkit-mask-position: bottom; + mask-position: bottom; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +[data-hud-shell][data-hud-recent] [data-hud-glass], +[data-hud-shell]:focus-within [data-hud-glass] { + opacity: 1; + transition-duration: var(--hud-reveal); + transition-delay: 0s; +} + +/* Engaged, the ramp gets out of the way: you are reading the transcript, so + nothing in it should be dimmed. Done by SCALING the mask rather than swapping + it — `mask-image` animates discretely and would snap, while `mask-size` + interpolates. Blown up and pinned to the bottom, the band only ever shows the + gradient's solid end. Reveal-speed both ways, so letting go of the composer + brings the ramp straight back instead of waiting out the fade's hold. */ +[data-hud-shell]:focus-within [data-slot='aui_thread-viewport'] { + -webkit-mask-size: 100% 600%; + mask-size: 100% 600%; +} + +/* Engaged: the band goes solid — typing means reading conditions. */ +[data-hud-shell]:focus-within [data-hud-glass] { + background: var(--dt-background); +} + +[data-hud-shell][data-hud-edge='top'] [data-hud-glass] { + border-radius: 0 0 0.75rem 0.75rem; +} + +[data-hud-shell] [data-slot='composer-bounds'] { + pointer-events: none; +} + +[data-hud-shell]:focus-within [data-slot='composer-bounds'] { + pointer-events: auto; +} + +[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'], +[data-hud-shell]:focus-within [data-slot='composer-bounds'] { + opacity: 1; + transition-duration: var(--hud-reveal); + transition-delay: 0s; +} + +/* Typing: the band goes fully solid — viz2d's focus step, taken to its end. */ +[data-hud-shell]:focus-within [data-slot='composer-bounds'] { + background: var(--dt-background) !important; +} + +/* 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]:focus-within { + --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 350ms ease, + 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(--composer-measured-height) + 0.25rem) !important; +} + +[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-viewport'] { + -webkit-mask-position: top; + mask-position: top; +} + +[data-hud-shell][data-hud-edge='top'] [data-hud-drag-strip] { + top: auto; + bottom: 0; +} + +/* Depth by age — the WoW chat frame read, as a MASK over the whole band. + Anchored at the bottom: the newest turn is fully solid against the bar and + everything above it thins out, so older lines read as ghosts and the band's + top edge melts into the desktop instead of stopping at a line. + (https://polypane.app/blog/my-take-on-fading-content-using-transparent-gradients-in-css/ + — a mask, not an overlaid gradient, because the HUD has no solid background + to fake the fade against.) + + On the BAND, not the thread viewport inside it, so the glass tint fades on + the same ramp as the text it is behind. Masking only the text leaves the + sheet ending in a hard rectangle with ghost words floating on it. + + The ramp is the DISENGAGED look; focus scales it away (see the glass layer's + focus rule, which does the same thing to the sheet). + + On the scroll VIEWPORT, not the band that contains it. A mask on the band + stopped reaching the transcript once the glass moved to its own + backdrop-filter layer — the text kept painting at full strength while the + sheet ramped. The viewport is the box the text actually lives in, and masking + it there survives whatever the compositor does with the layer above. */ +[data-hud-shell] [data-slot='aui_thread-viewport'] { + -webkit-mask-image: var(--hud-thread-mask); + mask-image: var(--hud-thread-mask); + -webkit-mask-position: bottom; + mask-position: bottom; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + transition: + -webkit-mask-size var(--hud-reveal) ease, + mask-size var(--hud-reveal) ease; +} + +/* 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.8rem 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(--composer-measured-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; +} + +[data-hud-shell] [data-slot='composer-rich-input'], +[data-hud-shell] [data-slot='composer-dock'] button, +[data-hud-shell] [data-slot='composer-dock'] input, +[data-hud-shell] [data-slot='composer-dock'] textarea, +[data-hud-shell] [data-slot='composer-dock'] [contenteditable], +[data-hud-shell] [data-slot='composer-dock'] [role='button'] { + -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-slot='composer-bounds'] { + anchor-name: --hud-band; +} + +[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) ease var(--hud-fade-delay); + /* And it is untouchable on exactly the same terms as the band it sits in — + engaged or nothing. A faded-out control that still takes clicks is just an + invisible button floating over the app behind. */ + pointer-events: none; +} + +[data-hud-shell]:focus-within [data-hud-exit] { + pointer-events: auto; +} + +[data-hud-shell][data-hud-recent] [data-hud-exit], +[data-hud-shell]:focus-within [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]:focus-within [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 , 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; +} From 6a01b429d7fe303652d213581f7db7f505491b00 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 01:39:06 -0500 Subject: [PATCH 05/15] feat(desktop): reach HUD mode from the titlebar and a keybind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘⇧H plus a titlebar tool, since the whole point is leaving the app without reaching for it. Keeps the keyboard-shortcuts button it sat next to. --- apps/desktop/src/app/hooks/use-keybinds.ts | 2 ++ apps/desktop/src/app/shell/titlebar-controls.tsx | 16 ++++++++++++++++ apps/desktop/src/i18n/ar.ts | 2 ++ apps/desktop/src/i18n/en.ts | 3 +++ apps/desktop/src/i18n/types.ts | 2 ++ apps/desktop/src/i18n/zh.ts | 2 ++ apps/desktop/src/lib/keybinds/actions.ts | 5 +++++ 7 files changed, 32 insertions(+) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 8a30c3049ff1e..c9c5df71143f2 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -24,6 +24,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 +228,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'view.toggleReview': toggleReview, 'view.toggleStatusbar': toggleStatusbarVisible, 'view.showFiles': showFiles, + 'view.toggleHud': () => toggleHud($selectedStoredSessionId.get()), '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. diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 6c3c3e5fc5fdb..9aa02aa67cce5 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -11,6 +11,8 @@ 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 { $selectedStoredSessionId } from '@/store/session' import { $fileBrowserOpen, $sidebarOpen, @@ -187,6 +189,20 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics, onSelect: toggleHaptics }, + { + // 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: , + id: 'hud', + label: t.titlebar.enterHud, + onSelect: () => { + triggerHaptic('open') + toggleHud($selectedStoredSessionId.get()) + } + }, { actionId: 'keybinds.openPanel', icon: , diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 65ba2f448dddf..02e7229400b40 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -173,6 +173,8 @@ export const ar = defineLocale({ openSettings: 'فتح الإعدادات', openStarmap: 'فتح خريطة الذاكرة', openKeybinds: 'اختصارات لوحة المفاتيح', + enterHud: 'وضع HUD', + exitHud: 'إنهاء وضع HUD', layoutEditor: 'محرر التخطيط', layoutEditorTitle: 'محرر التخطيط — انقر مع ⌘ لإعادة ضبط التخطيط' }, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index ee3eeed7df751..03ae6b5b2c403 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -203,6 +203,8 @@ export const en: Translations = { 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 +263,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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d29f3d38e0178..e9ca76d857bc0 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -245,6 +245,8 @@ export interface Translations { openSettings: string openStarmap: string openKeybinds: string + enterHud: string + exitHud: string layoutEditor: string layoutEditorTitle: string } diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index bb063e5752d35..e38735e7885e3 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -198,6 +198,8 @@ export const zh: Translations = { openSettings: '打开设置', openStarmap: '打开记忆图谱', openKeybinds: '键盘快捷键', + enterHud: 'HUD 模式', + exitHud: '退出 HUD 模式', layoutEditor: '布局编辑器', layoutEditorTitle: '布局编辑器 — ⌘ 点击重置布局' }, diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 392292bd7c2f7..be2d9d1317674 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -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. From f9860b050875d3e0c45fe33a29fe39c2ba3f7aee Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 01:42:49 -0500 Subject: [PATCH 06/15] fix(desktop): size the HUD band to its transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty session painted a full-height slab of glass with nothing in it. The sheet now grows up from the bar to fit the transcript and caps at the window, so a fresh chat is just the bar. Measured from the topmost row down to the bar and written straight to the element — it changes on every stream flush, and the sheet resizing must not re-render the tree. --- apps/desktop/src/app/hud/hud-shell.tsx | 12 ++++++++++++ apps/desktop/src/styles.css | 9 ++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx index 49d3309b36e80..95a1ae7e81c6d 100644 --- a/apps/desktop/src/app/hud/hud-shell.tsx +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -165,6 +165,18 @@ export function HudShell() { } 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('[data-slot="aui_thread-content"] > *:not([data-slot])') + const top = rows?.length ? rows[0].getBoundingClientRect().top : null + const height = top === null || !el ? 0 : Math.max(0, el.getBoundingClientRect().bottom - top) + root.style.setProperty('--hud-band-height', `${Math.round(height)}px`) } // The viewport mounts async (lazy chat surface); poll briefly until it diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 70188469a8d14..c110ff03d3277 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2393,7 +2393,14 @@ button[data-slot='aui_msg-reactions'] svg { rather than a true blur-radius ramp. Its own change, not this one. */ [data-hud-shell] [data-hud-glass] { position: absolute; - inset: 0; + right: 0; + bottom: 0; + left: 0; + /* Grows up from the bar to fit the transcript, capped at the window. An empty + session measures 0 and the sheet collapses behind the bar, rather than + painting a full-height slab with nothing in it. The extra is breathing room + above the first row so the fade has somewhere to happen. */ + height: min(100%, calc(var(--hud-band-height, 0px) + 2rem)); z-index: 0; pointer-events: none; border-radius: 0.75rem 0.75rem 0 0; From d57927f3bbb5eb54b9cd95a6864fad1b69a72f4c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 01:45:02 -0500 Subject: [PATCH 07/15] fix(desktop): stop the composer's two collapse stages landing together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model pill shed its label at 440px and the row stacked at almost the same width, so the chevron bought nothing — which is the one thing a progressive collapse is meant to avoid. Sized off what the controls actually cost rather than a guess. With the full pill they take ~284px, so at 440 the inline input was ~156px against a 128px minimum: a few words wrapped, and wrapping is what stacks the row. Compacting at 560 sheds the label while the input still has ~276px, and spends the ~110px the chevron frees on keeping the row single for another stretch. Global, not HUD-only. --- .../src/app/chat/composer/composer-utils.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 21e3c1ac4a12e..617e650511191 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -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, From 6ada733f9de677b7802fb67a24d35fd4707dae29 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 01:59:25 -0500 Subject: [PATCH 08/15] =?UTF-8?q?fix(desktop):=20HUD=20sizing=20=E2=80=94?= =?UTF-8?q?=20empty=20band,=20stranded=20exit=20chip,=20early=20stacking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things the band's content-sizing exposed, all of them stale assumptions from when it was a full-window box. A leftover `:focus-within` rule still painted the band's own background, so focusing an empty session filled the whole window with a solid slab even though the sheet had correctly collapsed to nothing. The exit chip anchored to the band rather than the sheet, so on a short transcript it floated in empty space at the window's corner. It now rides the sheet, and drops clear of the bar when the layout is flipped. Bottom clearance read `--composer-measured-height`, a surface var that never reaches here, so it silently fell back to the root estimate and reserved ~20px more than the bar occupies. The bar's real height is measured alongside the band's. Top-edge mode was never re-checked after the sheet started sizing itself: the sheet stayed pinned to the bottom while the transcript hung from the top. Both the anchor and the measurement now flip with the layout. --- .../composer/hooks/use-composer-metrics.ts | 18 ++++++++--- apps/desktop/src/app/hud/hud-shell.tsx | 28 +++++++++++++++-- apps/desktop/src/styles.css | 31 +++++++++++-------- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index 7353dc102e6bb..8c09b2f45f84c 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -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 } } diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx index 95a1ae7e81c6d..edd9613bd7281 100644 --- a/apps/desktop/src/app/hud/hud-shell.tsx +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -174,9 +174,31 @@ export function HudShell() { // 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('[data-slot="aui_thread-content"] > *:not([data-slot])') - const top = rows?.length ? rows[0].getBoundingClientRect().top : null - const height = top === null || !el ? 0 : Math.max(0, el.getBoundingClientRect().bottom - top) - root.style.setProperty('--hud-band-height', `${Math.round(height)}px`) + 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('[data-slot="composer-dock"]') + + if (bar) { + ro.observe(bar) + root.style.setProperty('--hud-bar-height', `${Math.round(bar.getBoundingClientRect().height)}px`) + } } // The viewport mounts async (lazy chat surface); poll briefly until it diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index c110ff03d3277..1daca630bcd2e 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2392,6 +2392,10 @@ button[data-slot='aui_msg-reactions'] svg { getNativeWindowHandle() and buys a cross-dissolve between frosted and sharp rather than a true blur-radius ramp. Its own change, not this one. */ [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; @@ -2399,8 +2403,8 @@ button[data-slot='aui_msg-reactions'] svg { /* Grows up from the bar to fit the transcript, capped at the window. An empty session measures 0 and the sheet collapses behind the bar, rather than painting a full-height slab with nothing in it. The extra is breathing room - above the first row so the fade has somewhere to happen. */ - height: min(100%, calc(var(--hud-band-height, 0px) + 2rem)); + above the first row so the fade has somewhere to land. */ + height: min(100%, calc(var(--hud-band-height, 0px) + 0.75rem)); z-index: 0; pointer-events: none; border-radius: 0.75rem 0.75rem 0 0; @@ -2430,6 +2434,7 @@ button[data-slot='aui_msg-reactions'] svg { interpolates. Blown up and pinned to the bottom, the band only ever shows the gradient's solid end. Reveal-speed both ways, so letting go of the composer brings the ramp straight back instead of waiting out the fade's hold. */ +[data-hud-shell]:focus-within [data-hud-glass], [data-hud-shell]:focus-within [data-slot='aui_thread-viewport'] { -webkit-mask-size: 100% 600%; mask-size: 100% 600%; @@ -2441,6 +2446,8 @@ button[data-slot='aui_msg-reactions'] svg { } [data-hud-shell][data-hud-edge='top'] [data-hud-glass] { + top: 0; + bottom: auto; border-radius: 0 0 0.75rem 0.75rem; } @@ -2459,11 +2466,6 @@ button[data-slot='aui_msg-reactions'] svg { transition-delay: 0s; } -/* Typing: the band goes fully solid — viz2d's focus step, taken to its end. */ -[data-hud-shell]:focus-within [data-slot='composer-bounds'] { - background: var(--dt-background) !important; -} - /* 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 @@ -2574,7 +2576,7 @@ button[data-slot='aui_msg-reactions'] svg { } [data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-content'] { - padding-block-start: calc(var(--composer-measured-height) + 0.25rem) !important; + padding-block-start: calc(var(--hud-bar-height, var(--composer-fallback-height)) + 0.25rem) !important; } [data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-viewport'] { @@ -2641,7 +2643,7 @@ button[data-slot='aui_msg-reactions'] svg { gap between the last message and the composer. */ [data-hud-shell] [data-slot='aui_thread-content'] { padding-inline: 0.6rem !important; - padding-block: 0.8rem 0.25rem !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 @@ -2662,7 +2664,7 @@ button[data-slot='aui_msg-reactions'] svg { 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(--composer-measured-height); + --thread-last-message-clearance: var(--hud-bar-height, var(--composer-fallback-height)); } /* Same for the dock's own bottom pad — this var also feeds @@ -2808,9 +2810,6 @@ button[data-slot='aui_msg-reactions'] svg { 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-slot='composer-bounds'] { - anchor-name: --hud-band; -} [data-hud-shell] [data-hud-exit] { position-anchor: --hud-band; @@ -2839,6 +2838,12 @@ button[data-slot='aui_msg-reactions'] svg { 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]:focus-within [data-hud-exit] { opacity: 0.75; From ba4456ab00171d3b0f93875929f7c99173421693 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 02:01:32 -0500 Subject: [PATCH 09/15] fix(desktop): don't flip the HUD's fade when the bar parks at the top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parked at the top the whole gradient flipped with the layout, which inverted the thing it exists to do. The transcript reads oldest-to-newest downward wherever the bar is, so the newest turn is at the bottom either way — flipping the mask faded the newest reply and left the oldest solid, leaving a stray bubble hanging over the desktop with the reply dissolved beneath it. Only the anchor flips now: the sheet hangs from the bar instead of standing on it, the thread's padding moves, and the exit chip drops clear. The fade always runs away from the newest message. --- apps/desktop/src/styles.css | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 1daca630bcd2e..89c9d196d68a4 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2300,10 +2300,6 @@ button[data-slot='aui_msg-reactions'] svg { --hud-thread-mask: linear-gradient(to top, var(--hud-thread-mask-stops)); } -[data-hud-shell][data-hud-edge='top'] { - --hud-thread-mask: linear-gradient(to bottom, var(--hud-thread-mask-stops)); -} - /* Chat surface carries nothing in HUD mode — the visual is the BAND below. */ [data-hud-shell] [data-chat-surface] { background: transparent !important; @@ -2445,6 +2441,11 @@ button[data-slot='aui_msg-reactions'] svg { background: var(--dt-background); } +/* Flipped, the sheet hangs from the bar instead of standing on it. The MASK + does not flip with it: the transcript still reads oldest-to-newest downward, + so the newest turn is at the bottom either way and the fade always runs away + from it. Flipping the gradient too faded the newest reply and left the oldest + solid, which reads as the band having come apart. */ [data-hud-shell][data-hud-edge='top'] [data-hud-glass] { top: 0; bottom: auto; @@ -2579,11 +2580,6 @@ button[data-slot='aui_msg-reactions'] svg { padding-block-start: calc(var(--hud-bar-height, var(--composer-fallback-height)) + 0.25rem) !important; } -[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-viewport'] { - -webkit-mask-position: top; - mask-position: top; -} - [data-hud-shell][data-hud-edge='top'] [data-hud-drag-strip] { top: auto; bottom: 0; From 0e260b3c8c8fa89e775e48e9ac101f2909e4f1b2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 02:09:58 -0500 Subject: [PATCH 10/15] fix(desktop): grow the HUD band smoothly, and flip on the visible panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sheet is sized to the transcript, so it changed height on every reply with no transition — the panel twitched to each new size instead of growing into it. Animated on the reveal timing. The edge flip was reading the window, which stopped being the same thing as the HUD once the sheet started sizing itself: a tall window's top edge reaches the screen top while the visible bar is still well down the display, so it flipped far too early. It now measures the visible panel, against a threshold proportional to the display rather than an exact-flush rule — the HUD hugs its bar, so its visible top can never actually touch the screen edge and a zero-tolerance test would never fire at all. --- apps/desktop/src/app/hud/hud-shell.tsx | 46 ++++++++++++++++++-------- apps/desktop/src/styles.css | 9 +++-- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx index edd9613bd7281..c8b9dc76e3243 100644 --- a/apps/desktop/src/app/hud/hud-shell.tsx +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -24,6 +24,11 @@ const HUD_REVEAL_MS = 150 const HUD_FADE_DELAY_MS = 3_000 const HUD_FADE_MS = 1_200 +/** 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 + /** * 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 @@ -100,25 +105,36 @@ export function HudShell() { // EDGE-tight, not a midpoint rule: the first cut compared topGap('bottom') + // How much of the window the HUD actually occupies. The sheet is sized to the + // transcript, so a tall window can be mostly empty above it — the flip has to + // measure the visible panel, not a window edge that reached the screen top + // long before the bar looked anywhere near it. + const visibleHeightRef = useRef(0) useEffect(() => { - // ZERO tolerance by explicit request: top-mode only when the window is - // flush against the usable top (gap 0 — macOS won't let it overlap the - // menu bar, so flush IS availTop). Tiny FLIP_OFF so the 300ms poll can't - // flutter on sub-pixel jitter while parked. - const FLIP_ON = 0 - const FLIP_OFF = 4 + // ZERO tolerance by explicit request: top-mode only when the HUD is flush + // against the usable top (gap 0 — macOS won't let it overlap the menu bar, + // so flush IS availTop). Tiny FLIP_OFF so the 300ms poll can't flutter on + // sub-pixel jitter while parked. + // Proportional to the display, not an absolute pixel count. The HUD hugs + // its bar, so its visible top can never actually reach the screen edge — + // an exact-flush rule would simply never fire. "Parked at the top" is a + // band near the top instead, with hysteresis so dragging along the line + // can't flutter the layout. + const usableHeight = (window.screen as { availHeight?: number }).availHeight || window.screen.height || 1 + const FLIP_ON = usableHeight * 0.12 + const FLIP_OFF = usableHeight * 0.18 const measure = () => { // 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 + const visibleTop = window.screenY + Math.max(0, window.innerHeight - visibleHeightRef.current) + const topGap = visibleTop - availTop setEdge(prev => (topGap <= FLIP_ON ? 'top' : topGap >= FLIP_OFF ? 'bottom' : prev)) } @@ -194,11 +210,14 @@ export function HudShell() { // to the root estimate and reserved ~20px more than the bar occupies — // a visible hole under the last message. const bar = root.querySelector('[data-slot="composer-dock"]') + const barHeight = bar?.getBoundingClientRect().height ?? 0 if (bar) { ro.observe(bar) - root.style.setProperty('--hud-bar-height', `${Math.round(bar.getBoundingClientRect().height)}px`) + root.style.setProperty('--hud-bar-height', `${Math.round(barHeight)}px`) } + + void barHeight } // The viewport mounts async (lazy chat surface); poll briefly until it @@ -241,7 +260,8 @@ export function HudShell() { { '--hud-fade-delay': `${HUD_FADE_DELAY_MS}ms`, '--hud-fade': `${HUD_FADE_MS}ms`, - '--hud-reveal': `${HUD_REVEAL_MS}ms` + '--hud-reveal': `${HUD_REVEAL_MS}ms`, + '--hud-sheet-overhang': `${HUD_SHEET_OVERHANG_PX}px` } as CSSProperties } > diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 89c9d196d68a4..418419fc4a7eb 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2400,13 +2400,18 @@ button[data-slot='aui_msg-reactions'] svg { session measures 0 and the sheet collapses behind the bar, rather than painting a full-height slab with nothing in it. The extra is breathing room above the first row so the fade has somewhere to land. */ - height: min(100%, calc(var(--hud-band-height, 0px) + 0.75rem)); + height: min(100%, calc(var(--hud-band-height, 0px) + var(--hud-sheet-overhang, 0.75rem))); z-index: 0; pointer-events: none; border-radius: 0.75rem 0.75rem 0 0; background: color-mix(in srgb, var(--dt-background) 62%, transparent); opacity: 0; - transition: opacity var(--hud-fade) ease var(--hud-fade-delay); + /* Height is animated because it changes on every reply. Snapping to each new + size reads as the panel twitching; growing into it reads as the transcript + filling up. */ + transition: + opacity var(--hud-fade) ease var(--hud-fade-delay), + height var(--hud-reveal) ease; -webkit-mask-image: var(--hud-thread-mask); mask-image: var(--hud-thread-mask); -webkit-mask-position: bottom; From a3d57f18c24f851641671e014c42261b6fd08c66 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 02:28:58 -0500 Subject: [PATCH 11/15] fix(desktop): open HUD mode on the tab you're looking at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both entry points read $selectedStoredSessionId, which is the WORKSPACE pane's session — so whichever tile was fronted, the main tab went into the HUD. Tabs exist precisely so those aren't the same question. `getActiveComposer()` already answers it for the focus bus, healing to the visible surface when its cached claim is buried, and a tile's routing key is its stored session id. One resolver now, shared by the titlebar button and ⌘⇧H. Coming back re-resumes through the tile delegate when the target is an open tile. The ordinary resume path enforces "a session is either main or a tile, never both" and would have closed the tile to take it into main, quietly rearranging tabs the user opened on purpose. --- apps/desktop/src/app/hooks/use-keybinds.ts | 3 +- apps/desktop/src/app/hud/handoff.ts | 41 +++++++++++++- .../src/app/shell/titlebar-controls.tsx | 4 +- apps/desktop/src/styles.css | 55 ++++++++----------- 4 files changed, 65 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index c9c5df71143f2..1e2107f517686 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -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 { @@ -228,7 +229,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'view.toggleReview': toggleReview, 'view.toggleStatusbar': toggleStatusbarVisible, 'view.showFiles': showFiles, - 'view.toggleHud': () => toggleHud($selectedStoredSessionId.get()), + '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. diff --git a/apps/desktop/src/app/hud/handoff.ts b/apps/desktop/src/app/hud/handoff.ts index 0b385f10b742b..750a6ce1664e1 100644 --- a/apps/desktop/src/app/hud/handoff.ts +++ b/apps/desktop/src/app/hud/handoff.ts @@ -20,10 +20,33 @@ 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' +/** Session tiles route on `tile:` (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 @@ -49,10 +72,22 @@ export function useHudHandoff({ navigate, resumeSession }: HudHandoffParams): vo const selected = $selectedStoredSessionId.get() const target = hudSessionId ?? selected - // The HUD switched sessions (or started one this window has never seen): - // route to it and let the route resume do the rest, including loading - // that session's draft as the composer's scope swaps. + // 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 diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 9aa02aa67cce5..1ed2b1c520779 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -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' @@ -12,7 +13,6 @@ import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' import { toggleHud } from '@/store/hud' -import { $selectedStoredSessionId } from '@/store/session' import { $fileBrowserOpen, $sidebarOpen, @@ -200,7 +200,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: label: t.titlebar.enterHud, onSelect: () => { triggerHaptic('open') - toggleHud($selectedStoredSessionId.get()) + toggleHud(hudTargetSessionId()) } }, { diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 418419fc4a7eb..cca92ef8a505f 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2294,8 +2294,7 @@ button[data-slot='aui_msg-reactions'] svg { half-dissolved. Instead: most of it is FULLY solid, then it collapses in one short stretch, and the top fifth is nothing at all. That empty cap is what removes the edge — any alpha still alive at the window boundary reads - as a line, and with the frost riding this same mask it would be a line of - blur, which is worse. */ + as a line. */ --hud-thread-mask-stops: #000 0%, #000 58%, rgb(0 0 0 / 0.42) 71%, transparent 82%; --hud-thread-mask: linear-gradient(to top, var(--hud-thread-mask-stops)); } @@ -2312,12 +2311,12 @@ button[data-slot='aui_msg-reactions'] svg { - 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 color, so it smokes dark on dark themes and frosts light on - light ones, and text keeps its normal contrast against it. - - VISIBILITY keeps the WoW fade this HUD already had: shown while a turn - is recent/streaming (`data-hud-recent`), on band hover, or while the - composer is focused; melted away otherwise. viz2d's band never hides — - ours does, because idle HUD mode is just the Spotlight bar. + 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 @@ -2342,20 +2341,17 @@ button[data-slot='aui_msg-reactions'] svg { max-width: none !important; flex: none !important; border: 0 !important; - /* Rounded on top to match the window's own corners (roundedCorners clips the - frost to the same silhouette); square at the bottom, where the bar covers it. */ + /* 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 and no blur of its own — the glass is its own layer behind this - one ([data-hud-glass]). Chromium will not honour `mask-image` and - `backdrop-filter` on the SAME element: the mask ends up applying to the - backdrop rather than the content, which shows up as the sheet fading on - the gradient while the text stays stubbornly solid. Two elements, two - masks, no interaction. */ + /* 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; opacity: 0; /* WoW linger: reveal is quick, but on losing focus the band HOLDS, then fades - out slow. The timings come from hud-shell.tsx, which uses the same numbers - to decide when to drop the vibrancy layer. Reveal rules zero the delay. */ + out slow. The timings are published by hud-shell.tsx so the two can't + drift. Reveal rules zero the delay. */ transition: opacity var(--hud-fade) ease var(--hud-fade-delay), background-color 350ms ease; @@ -2373,20 +2369,15 @@ button[data-slot='aui_msg-reactions'] svg { mask as the text, so the whole surface ramps out together and the band has no top edge at all. - There is no desktop blur under it, and that is a hard limit rather than a - gap. macOS vibrancy is composited by WindowServer BELOW the web contents, - after this process has finished drawing, so nothing in the DOM can shape it — - mask, clip-path, stacking, none of it reaches. Leaving vibrancy on under a - masked tint is worse than no blur: the frost stays a flat slab, so the top of - the band goes pale exactly where it should be disappearing. And CSS - backdrop-filter is not a substitute; verified on the real window rather than - assumed, it produces no blur whatsoever, because a transparent window's - backdrop root is the document and the desktop was never in it. - - The one real way out is native — NSVisualEffectView.maskImage takes an alpha - mask for precisely this — which needs a small addon over - getNativeWindowHandle() and buys a cross-dissolve between frosted and sharp - rather than a true blur-radius ramp. Its own change, not this one. */ + 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 From f444e0c5e7bba9a298933b834853b970933975b0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 03:51:59 -0500 Subject: [PATCH 12/15] feat(desktop): point an open HUD at the tab you toggle from Asking for HUD mode from another tab used to just raise whatever the HUD already had, so the conversation you were looking at never arrived. Main now retargets the window and tells every renderer where it is pointed, so the toggle keeps reading "switch" rather than "dismiss". --- apps/desktop/electron/main.ts | 11 +++++++++++ apps/desktop/electron/preload.ts | 7 +++++++ apps/desktop/src/app/hud/handoff.ts | 10 ++++++++++ apps/desktop/src/global.d.ts | 2 ++ apps/desktop/src/store/hud.ts | 7 +++++++ 5 files changed, 37 insertions(+) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4fc687ebd3091..7be534f59b347 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9270,6 +9270,17 @@ function restoreMainWindowFromHud() { 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 diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 2406798e40a6a..fbce8197951f1 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -53,9 +53,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', { 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) diff --git a/apps/desktop/src/app/hud/handoff.ts b/apps/desktop/src/app/hud/handoff.ts index 750a6ce1664e1..dfd41a57f9a45 100644 --- a/apps/desktop/src/app/hud/handoff.ts +++ b/apps/desktop/src/app/hud/handoff.ts @@ -25,6 +25,7 @@ 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:` (see session-tile.tsx). */ const TILE_TARGET_PREFIX = 'tile:' @@ -104,6 +105,15 @@ export function useHudHandoff({ navigate, resumeSession }: HudHandoffParams): vo }, []) } +/** 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) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 6d2a570a1db1e..d33d17f682ca2 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -70,7 +70,9 @@ declare global { 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 diff --git a/apps/desktop/src/store/hud.ts b/apps/desktop/src/store/hud.ts index e16f1f11a7a2c..6135081508e7d 100644 --- a/apps/desktop/src/store/hud.ts +++ b/apps/desktop/src/store/hud.ts @@ -33,6 +33,10 @@ export const $hudActive = atom(isHudWindow()) * 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) + /** True when the shell exposes HUD mode (desktop only). */ export const canUseHud = (): boolean => typeof window !== 'undefined' && typeof window.hermesDesktop?.hud?.open === 'function' @@ -50,6 +54,7 @@ export function openHud(sessionId?: null | string): void { requestComposerDraftSync('flush') $hudActive.set(true) + $hudSession.set(sessionId ?? null) void api.open({ sessionId: sessionId ?? null }) } @@ -63,6 +68,7 @@ export function closeHud(): void { } $hudActive.set(false) + $hudSession.set(null) void api.close() } @@ -82,6 +88,7 @@ export const reportHudSession = (sessionId: null | string): void => window.herme 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) From 10c1530599cd7cbdc91e6f963e09b1773d87a83e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 03:52:02 -0500 Subject: [PATCH 13/15] refactor(desktop): put the HUD toggle beside the layout editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HUD mode is a layout choice, so it belongs with the other one. The keyboard-shortcuts button goes away with it — it was a second door to a settings tab that the command palette and the keybind itself already open. --- apps/desktop/src/app/contrib/wiring.tsx | 2 +- .../src/app/shell/titlebar-controls.tsx | 22 +++++-------------- apps/desktop/src/i18n/ar.ts | 1 - apps/desktop/src/i18n/en.ts | 1 - apps/desktop/src/i18n/types.ts | 1 - apps/desktop/src/i18n/zh.ts | 1 - 6 files changed, 7 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index fbb6558c530cd..b663ec611e3e6 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -987,7 +987,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { // Pane-registered tools (preview's monitor/devtools cluster) anchor flush // against the static system cluster — in the tree layout the titlebar band // sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply. - const SYSTEM_TOOL_COUNT = 5 + const SYSTEM_TOOL_COUNT = 4 const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))` diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 1ed2b1c520779..ee9b566bbdd63 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -21,7 +21,7 @@ import { toggleSidebarOpen } from '@/store/layout' -import { appViewForPath, isOverlayView, SETTINGS_ROUTE } from '../routes' +import { appViewForPath, isOverlayView } from '../routes' import { titlebarButtonClass } from './titlebar' @@ -182,13 +182,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: }, title: t.titlebar.layoutEditorTitle }, - { - active: hapticsMuted, - icon: , - id: 'haptics', - label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics, - onSelect: toggleHaptics - }, { // No `title`: TitlebarToolButton passes `title` to TipKeybindLabel as a // text OVERRIDE, so a long sentence there replaces the short label and @@ -204,14 +197,11 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: } }, { - actionId: 'keybinds.openPanel', - icon: , - id: 'keybinds', - label: t.titlebar.openKeybinds, - onSelect: () => { - triggerHaptic('open') - navigate(`${SETTINGS_ROUTE}?tab=keybinds`) - } + active: hapticsMuted, + icon: , + id: 'haptics', + label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics, + onSelect: toggleHaptics }, { actionId: 'nav.settings', diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 02e7229400b40..9a6853609d06c 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -172,7 +172,6 @@ export const ar = defineLocale({ unmuteHaptics: 'تفعيل الاهتزازات', openSettings: 'فتح الإعدادات', openStarmap: 'فتح خريطة الذاكرة', - openKeybinds: 'اختصارات لوحة المفاتيح', enterHud: 'وضع HUD', exitHud: 'إنهاء وضع HUD', layoutEditor: 'محرر التخطيط', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 03ae6b5b2c403..f897e4c1ae04f 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -202,7 +202,6 @@ 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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index e9ca76d857bc0..9693ab312e7a9 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -244,7 +244,6 @@ export interface Translations { unmuteHaptics: string openSettings: string openStarmap: string - openKeybinds: string enterHud: string exitHud: string layoutEditor: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index e38735e7885e3..53d170e798ad1 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -197,7 +197,6 @@ export const zh: Translations = { unmuteHaptics: '开启触感反馈', openSettings: '打开设置', openStarmap: '打开记忆图谱', - openKeybinds: '键盘快捷键', enterHud: 'HUD 模式', exitHud: '退出 HUD 模式', layoutEditor: '布局编辑器', From 4500b43914de4ef6fec8dc13f3a1189b43fd674d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 03:52:17 -0500 Subject: [PATCH 14/15] feat(desktop): frost the HUD band and fade it in three states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band is real macOS vibrancy now rather than backdrop-filter, which reaches nothing in a transparent window — its backdrop root is the document, and the desktop was never in it. Vibrancy composites below the web contents, so it can see the desktop, and it can't be masked or clipped from the page. That rules out the gradient the band used to carry and settles it as a flat panel: uniform tint, uniform frost. The fade does the work the gradient was doing. A landing turn brings the transcript half way up to be glanced at, focus promotes it to properly readable, and the hold takes it back down and then away — the panel sliding behind the bar as the last of the text goes. It only fades from an idle transcript. A running turn or a question waiting on you holds it open, because a prompt that fades out is one you can neither read nor answer, and the hold timer alone would expire through a long tool call that prints nothing. --- apps/desktop/electron/main.ts | 21 ++- apps/desktop/src/app/hud/glass.ts | 64 +++++++ apps/desktop/src/app/hud/hud-shell.tsx | 138 +++++++++++---- apps/desktop/src/styles.css | 221 ++++++++++++------------- 4 files changed, 291 insertions(+), 153 deletions(-) create mode 100644 apps/desktop/src/app/hud/glass.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7be534f59b347..5cc26eceaed9e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9191,8 +9191,13 @@ function spawnHudWindow(sessionId) { hasShadow: false, alwaysOnTop: true, type: IS_MAC ? 'panel' : undefined, - // Clips the window to a rounded silhouette rather than a hard rectangle. + // 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', @@ -9988,6 +9993,20 @@ ipcMain.handle('hermes:hud:open', async (_event, request) => { 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. diff --git a/apps/desktop/src/app/hud/glass.ts b/apps/desktop/src/app/hud/glass.ts new file mode 100644 index 0000000000000..a12692ef03702 --- /dev/null +++ b/apps/desktop/src/app/hud/glass.ts @@ -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, 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]) +} diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx index c8b9dc76e3243..dee6a30af7c85 100644 --- a/apps/desktop/src/app/hud/hud-shell.tsx +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -1,34 +1,54 @@ +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 { useReportHudSession } from './handoff' +import { useHudGlass } from './glass' +import { useHudGoto, useReportHudSession } from './handoff' -/** How long the thread stays visible after the last activity before it starts - * fading (WoW chat frame behavior). Focus holds it open past this. */ -const HUD_RECENT_HOLD_MS = 6_000 +/** 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 = 150 -const HUD_FADE_DELAY_MS = 3_000 -const HUD_FADE_MS = 1_200 +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 @@ -40,12 +60,16 @@ const HUD_SHEET_OVERHANG_PX = 12 * window after it finishes, without a per-flush re-render (state only changes * on the false↔true edges). */ -function useRecentActivity(): boolean { +function useRecentActivity(): [boolean, () => void] { const [recent, setRecent] = useState(false) const timerRef = useRef | 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) @@ -55,9 +79,29 @@ function useRecentActivity(): boolean { 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(bump) + const offMessages = $messages.subscribe(onMessages) const offBusy = $busy.subscribe(busy => busy && bump()) return () => { @@ -70,7 +114,27 @@ function useRecentActivity(): boolean { } }, []) - return recent + 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 } /** @@ -90,11 +154,13 @@ function useRecentActivity(): boolean { */ export function HudShell() { const { t } = useI18n() - const recent = useRecentActivity() + 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 @@ -108,33 +174,33 @@ export function HudShell() { // 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'>('bottom') - // How much of the window the HUD actually occupies. The sheet is sized to the - // transcript, so a tall window can be mostly empty above it — the flip has to - // measure the visible panel, not a window edge that reached the screen top - // long before the bar looked anywhere near it. - const visibleHeightRef = useRef(0) + const [edge, setEdge] = useState<'bottom' | 'top'>('top') useEffect(() => { - // ZERO tolerance by explicit request: top-mode only when the HUD is flush - // against the usable top (gap 0 — macOS won't let it overlap the menu bar, - // so flush IS availTop). Tiny FLIP_OFF so the 300ms poll can't flutter on - // sub-pixel jitter while parked. - // Proportional to the display, not an absolute pixel count. The HUD hugs - // its bar, so its visible top can never actually reach the screen edge — - // an exact-flush rule would simply never fire. "Parked at the top" is a - // band near the top instead, with hysteresis so dragging along the line - // can't flutter the layout. - const usableHeight = (window.screen as { availHeight?: number }).availHeight || window.screen.height || 1 - const FLIP_ON = usableHeight * 0.12 - const FLIP_OFF = usableHeight * 0.18 + // 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 visibleTop = window.screenY + Math.max(0, window.innerHeight - visibleHeightRef.current) - const topGap = visibleTop - availTop + const topGap = window.screenY - availTop setEdge(prev => (topGap <= FLIP_ON ? 'top' : topGap >= FLIP_OFF ? 'bottom' : prev)) } @@ -231,6 +297,7 @@ export function HudShell() { } }, []) + useHudGlass(rootRef, recent || held) useHudClickThrough(rootRef) // Force the HOST layers transparent. index.html's pre-paint script writes an @@ -252,14 +319,19 @@ export function HudShell() {
Date: Sat, 8 Aug 2026 03:52:17 -0500 Subject: [PATCH 15/15] fix(desktop): stop the HUD going click-through under its own dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window decided nothing was there whenever focus left the composer, which is exactly what opening a dialog or clicking a link does — a portalled overlay lives outside the shell, so `:focus-within` goes with it and the window turned mouse-transparent underneath the thing you had just opened. The old hit test only knew about the bar's rectangle, too. It asks the document instead. Everything the HUD deliberately doesn't catch is already `pointer-events: none`, so whatever comes back under the cursor is something real, and focus is read at the document rather than the shell. --- apps/desktop/src/app/hud/click-through.ts | 43 +++++++++++++++-------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/hud/click-through.ts b/apps/desktop/src/app/hud/click-through.ts index 1c6eaf9ed93c2..d833529b8b9c2 100644 --- a/apps/desktop/src/app/hud/click-through.ts +++ b/apps/desktop/src/app/hud/click-through.ts @@ -14,10 +14,9 @@ import { type RefObject, useEffect } from 'react' * page-level property, and the click never reaches the page. * * So the window itself is made mouse-transparent except where it is genuinely - * interactive: the bar, always, and everything else only while the composer - * holds focus — the same line the band and its exit chip draw with - * `pointer-events`. `forward: true` keeps mousemove flowing while ignoring, - * which is what lets it re-arm when the cursor comes back to the bar. + * 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): void { useEffect(() => { @@ -34,20 +33,34 @@ export function useHudClickThrough(rootRef: RefObject): void // must not make the bar untouchable until you jiggle the mouse). let point: { x: number; y: number } | null = null - const overBar = () => { - const bar = root.querySelector('[data-slot="composer-dock"]') - - if (!bar || !point) { + // 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 rect = bar.getBoundingClientRect() + const hit = document.elementFromPoint(point.x, point.y) - return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom + 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 = !root.matches(':focus-within') && !overBar() + const next = !focused() && !overSomething() if (ignoring !== next) { ignoring = next @@ -62,14 +75,14 @@ export function useHudClickThrough(rootRef: RefObject): void apply() window.addEventListener('mousemove', onMove) - root.addEventListener('focusin', apply) - root.addEventListener('focusout', apply) + document.addEventListener('focusin', apply) + document.addEventListener('focusout', apply) return () => { setIgnoreMouse(false) window.removeEventListener('mousemove', onMove) - root.removeEventListener('focusin', apply) - root.removeEventListener('focusout', apply) + document.removeEventListener('focusin', apply) + document.removeEventListener('focusout', apply) } }, [rootRef]) }