Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,9 +377,9 @@
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)

// Keep the renderer running at full speed while the window is in the background
// or occluded. The chat transcript streams to screen through a
// requestAnimationFrame-gated flush; Chromium pauses rAF (and clamps timers)
// for backgrounded/occluded renderers, so without these the live answer stalls
// or occluded. The chat transcript streams to screen through a bounded timer
// flush; Chromium clamps timers for backgrounded/occluded renderers, so without
// these the live answer stalls
// whenever the window loses focus (switching to your editor mid-turn, detached
// devtools, another window covering it) and only paints on refocus or refresh.
// `backgroundThrottling: false` on the BrowserWindow covers the blurred case;
Expand Down Expand Up @@ -4939,6 +4939,8 @@
function getWindowState(win = mainWindow) {
return {
isFullscreen: Boolean(win?.isFullScreen?.()),
isMinimized: Boolean(win?.isMinimized?.()),
isVisible: Boolean(win?.isVisible?.()),
nativeOverlayWidth: getNativeOverlayWidth(),
windowButtonPosition: getWindowButtonPosition()
}
Expand Down Expand Up @@ -8898,9 +8900,9 @@
show: false,
backgroundColor: getWindowBackgroundColor(),
// Shared with the secondary session windows (chatWindowWebPreferences) so
// both keep `backgroundThrottling: false` — the chat transcript streams via
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
// windows, stalling the live answer until refocus. See session-windows.ts.
// both keep `backgroundThrottling: false` — the chat transcript uses a
// bounded timer flush that Chromium clamps for blurred windows, stalling
// the live answer until refocus. See session-windows.ts.
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
})

Expand Down Expand Up @@ -8968,6 +8970,10 @@
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
mainWindow.on('minimize', () => sendWindowStateChanged())
mainWindow.on('restore', () => sendWindowStateChanged())
mainWindow.on('hide', () => sendWindowStateChanged())
mainWindow.on('show', () => sendWindowStateChanged())

// Reopen where the user left off. resized/moved settle once per drag; close is
// the cross-platform backstop, flushed synchronously before the window is gone.
Expand Down Expand Up @@ -10182,7 +10188,7 @@

ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
const current = readQuickEntrySettings()
const next = sanitizeQuickEntrySettings({

Check warning on line 10191 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
enabled: patch?.enabled === undefined ? current.enabled : patch.enabled === true,
shortcut: typeof patch?.shortcut === 'string' && patch.shortcut.trim() ? patch.shortcut : current.shortcut
})
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/electron/session-windows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ test('registry trims the session id before keying', () => {

test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
// Regression: secondary session windows used to omit this flag, so a streamed
// answer stalled until the window regained focus (Chromium pauses the
// requestAnimationFrame-gated transcript flush for backgrounded windows).
// answer stalled until the window regained focus (Chromium clamps the
// transcript flush timer for backgrounded windows).
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')

assert.equal(prefs.backgroundThrottling, false)
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/electron/session-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// false`, so a streamed answer stalled until the window regained focus.
//
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
// blurred/occluded windows. A streaming chat app must keep painting in the
// screen through a bounded timer flush, which Chromium clamps for blurred/
// occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath: string) {
Expand Down
18 changes: 10 additions & 8 deletions apps/desktop/src/app/chat/composer/status-stack/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $billingBlock } from '@/store/billing-block'
import {
Expand Down Expand Up @@ -84,17 +85,18 @@ interface ComposerStatusStackProps {
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
const { t } = useI18n()
const navigate = useNavigate()
const itemsBySession = useStore($statusItemsBySession)
const previewsBySession = useStore($previewStatusBySession)
// Subscribe to THIS session's slice only. Both maps churn on other
// sessions' activity (subagent ticks, background polls, preview updates in
// any tile); a whole-map `useStore` re-rendered every mounted stack — one
// per open tile — on all of it. The per-key arrays are referentially stable
// across unrelated writes, so the slice hook bails out unless OUR session's
// items actually changed.
const items = useSessionSlice($statusItemsBySession, sessionId)
const previews = useSessionSlice($previewStatusBySession, sessionId)
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)

const groups = useMemo(
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
[itemsBySession, sessionId]
)

const previews = sessionId ? (previewsBySession[sessionId] ?? []) : []
const groups = useMemo(() => groupStatusItems(items), [items])

// Seed from the registry on session open; event-driven refreshes (terminal /
// process tool completions) live in use-message-stream.
Expand Down
47 changes: 38 additions & 9 deletions apps/desktop/src/app/chat/session-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { atom, computed } from 'nanostores'
import { useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'

import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useModelControls } from '@/app/session/hooks/use-model-controls'
Expand Down Expand Up @@ -424,6 +424,40 @@ export function stackSessionTilesIntoMain(): void {
}
}

/** The three scalars the tab menu actually renders, derived from the stored
* row. Subscribing to `$sessions` + `$projectTree` wholesale re-rendered
* every tab's menu wrapper on ANY session-list or tree churn (polls, title
* updates in other sessions) — for a context menu that's almost never open.
* Same class as the TreeGroup fix (#72245): derive narrowly, bail out unless
* the derived values change. */
function useTileMenuRow(storedSessionId: string): { pinId: string; profile?: string; title: string } {
const cache = useRef<{ key: string; value: { pinId: string; profile?: string; title: string } } | null>(null)

const subscribe = useCallback((onChange: () => void) => {
const offSessions = $sessions.listen(onChange)
const offTree = $projectTree.listen(onChange)

return () => {
offSessions()
offTree()
}
}, [])

return useSyncExternalStore(subscribe, () => {
const stored = tileStoredRow(storedSessionId)
const pinId = stored ? sessionPinId(stored) : storedSessionId
const title = tileTitle(storedSessionId)
const profile = stored?.profile
const key = `${pinId}\u0000${title}\u0000${profile ?? ''}`

if (cache.current?.key !== key) {
cache.current = { key, value: { pinId, profile, title } }
}

return cache.current.value
})
}

/** A session TAB's context menu: the full session verb set (pin, copy id, new
* window, branch, rename, archive, delete) — the SAME menu a sidebar row
* gets, targeted through the tile delegate (whose verbs are generic over
Expand All @@ -445,13 +479,8 @@ export function SessionTabMenu({
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
tabPaneId: string
}) {
// Subscribe for reactivity; the row is read imperatively via tileStoredRow
// (which spans both sources), so the values themselves are unused here.
useStore($sessions)
useStore($projectTree)
const { pinId, profile, title } = useTileMenuRow(storedSessionId)
const pinnedSessionIds = useStore($pinnedSessionIds)
const stored = tileStoredRow(storedSessionId)
const pinId = stored ? sessionPinId(stored) : storedSessionId
const pinned = pinnedSessionIds.includes(pinId)

return (
Expand All @@ -464,11 +493,11 @@ export function SessionTabMenu({
onHideTabBar={onHideTabBar}
onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
pinned={pinned}
profile={stored?.profile}
profile={profile}
sessionId={storedSessionId}
surface="tab"
tabPaneId={tabPaneId}
title={tileTitle(storedSessionId)}
title={title}
>
{children}
</SessionContextMenu>
Expand Down
14 changes: 11 additions & 3 deletions apps/desktop/src/app/model-picker-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
import type { ModelSelection } from '@/app/shell/model-menu-panel'
import { ModelPickerDialog } from '@/components/model-picker'
import type { HermesGateway } from '@/hermes'
import { useStoreSelector } from '@/lib/use-session-slice'
import {
$activeSessionId,
$currentModel,
Expand All @@ -24,15 +25,22 @@ export function ModelPickerOverlay({ gateway, onSelect, profile }: ModelPickerOv
const primaryModel = useStore($currentModel)
const primaryProvider = useStore($currentProvider)
const focusedRuntimeId = useStore($focusedRuntimeId)
const focusedState = useStore($focusedSessionState)
// `$focusedSessionState` is a projection of `$sessionStates`, republished on
// EVERY message delta — and this overlay is mounted app-wide. Only two
// fields are read off it, so subscribing to the whole object re-rendered
// this component (and the un-memoized closed dialog below) per token while
// the focused session streamed. Select each scalar so an unchanged
// model/provider bails out instead — same fix as the statusbar (#72163).
const focusedModel = useStoreSelector($focusedSessionState, state => state?.model ?? null)
const focusedProvider = useStoreSelector($focusedSessionState, state => state?.provider ?? null)
const gatewayOpen = useStore($gatewayState) === 'open'
const open = useStore($modelPickerOpen)

// Prefer the focused tile's runtime when the overlay opens from a tile that
// lacked a live menu (gateway closed → fallback path).
const sessionId = focusedRuntimeId ?? primarySessionId
const currentModel = focusedRuntimeId && focusedState ? focusedState.model : primaryModel
const currentProvider = focusedRuntimeId && focusedState ? focusedState.provider : primaryProvider
const currentModel = focusedRuntimeId && focusedModel !== null ? focusedModel : primaryModel
const currentProvider = focusedRuntimeId && focusedProvider !== null ? focusedProvider : primaryProvider

if (!gatewayOpen) {
return null
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ export function PetOverlayApp() {
<PetBubble />
</div>
<div style={{ lineHeight: 0, position: 'relative' }}>
<PetSprite info={info} />
<PetSprite info={info} pauseWhenUnfocused={false} />

{/* Hearts on the popped-out pet — identical to in-window. */}
<PetHeartField
Expand Down
Loading
Loading