Desktop UI zoom level persistence and reset fixes

NousResearch/hermes-agent · cluster 48658 · apps/desktop

Data as of

Nothing open on GitHub. Fix merged in #61925. Check it covers your case.

The short answer

0open issues
0open PRs
9items grouped
Known?
Yes. 9 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #61925. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #94396. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and pull requests address a recurring problem where the desktop application's UI zoom/scale setting resets unexpectedly across various triggers, including minimize/restore, window resize, navigation, and reloads. Multiple fix attempts explore different persistence strategies such as localStorage, JSON storage, and window state saving.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #61925 fix(desktop): re-apply UI zoom on show/restore, scoped to chat windows (supersedes #61245)

    merged to main

    files: apps/desktop/electron/main.ts, apps/desktop/electron/zoom.test.ts, apps/desktop/electron/zoom.ts

    Show the diff
    • apps/desktop/electron/main.ts
      @@ -4743,7 +4743,13 @@ function installPreviewShortcut(window) {
       // survives reloads/restarts) rather than a main-process JSON file. The main
       // process owns setZoomLevel, so we mirror each change into localStorage and
       // read it back on did-finish-load to re-apply after reloads or crash recovery.
      -import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom'
      +import {
      +  clampZoomLevel,
      +  installZoomReassertOnWindowEvents,
      +  percentToZoomLevel,
      +  ZOOM_STORAGE_KEY,
      +  zoomLevelToPercent
      +} from './zoom'
       
       function setAndPersistZoomLevel(window, zoomLevel) {
         if (!window || window.isDestroyed()) {
      @@ -6507,10 +6513,21 @@ async function startHermes() {
       // security posture: external links open in the OS browser, in-app navigation
       // stays confined to the dev server / packaged file URL, and the preview /
       // devtools / zoom / context-menu affordances behave identically everywhere.
      -function wireCommonWindowHandlers(win) {
      +//
      +// `zoom` is opt-out for the pet overlay: it sizes its own OS window to fit the
      +// sprite in unzoomed CSS px (overlayWindowSize -> setBounds) and has its own
      +// Alt+wheel scale, so inheriting the global UI zoom would render the mascot
      +// larger than its window and crop it. Chat windows keep zoom on.
      +function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) {
         installPreviewShortcut(win)
         installDevToolsShortcut(win)
      -  installZoomShortcuts(win)
      +  if (zoom) {
      +    installZoomShortcuts(win)
      +    // Re-apply persisted zoom on show/restore (Windows drops webContents zoom on
      +    // minimize/restore) and on first load (reloads / crash recovery).
      +    installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win))
      +    win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win))
      +  }
         installContextMenu(win)
         win.webContents.setWindowOpenHandler(details => {
           openExternalUrl(details.url)
      @@ -6702,7 +6719,9 @@ function spawnPetOverlayWindow(bounds) {
           // Not supported everywhere — best effort.
         }
       
      -  wireCommonWindowHandlers(win)
      +  // Pet overlay opts out of global UI zoom (see wireCommonWindowHandlers): it
      +  // owns its window-fit + scale, and inheriting zoom would crop the sprite.
      +  wireCommonWindowHandlers(win, { zoom: false })
       
         win.once('ready-to-show', () => {
           if (!win.isDestroyed()) {
      @@ -6893,7 +6912,8 @@ function createWindow() {
         }
       
         mainWindow.webContents.once('did-finish-load', () => {
      -    restorePersistedZoomLevel(mainWindow)
      +    // Zoom restore is handled by wireCommonWindowHandlers (shared with session
      +    // windows); no need to reapply it here.
           broadcastBootProgress()
           sendWindowStateChanged()
           startHermes().catch(error => rememberLog(error.stack || error.message))
    • apps/desktop/electron/zoom.test.ts
      @@ -5,9 +5,19 @@
        */
       
       import assert from 'node:assert/strict'
      +import fs from 'node:fs'
      +import path from 'node:path'
       import test from 'node:test'
      +import { fileURLToPath } from 'node:url'
       
      -import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom'
      +import {
      +  clampZoomLevel,
      +  installZoomReassertOnWindowEvents,
      +  percentToZoomLevel,
      +  ZOOM_REASSERT_WINDOW_EVENTS,
      +  ZOOM_STORAGE_KEY,
      +  zoomLevelToPercent
      +} from './zoom'
       
       test('storage key stays stable so persisted zoom survives upgrades', () => {
         assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')
      @@ -53,3 +63,63 @@ test('extreme percentages clamp to the level bounds', () => {
         assert.equal(percentToZoomLevel(1), -9)
         assert.equal(percentToZoomLevel(1_000_000), 9)
       })
      +
      +test('installZoomReassertOnWindowEvents wires show and restore', () => {
      +  const handlers = new Map()
      +  const win = {
      +    isDestroyed: () => false,
      +    on(event, listener) {
      +      handlers.set(event, listener)
      +    }
      +  }
      +  let calls = 0
      +  installZoomReassertOnWindowEvents(win, () => {
      +    calls += 1
      +  })
      +
      +  assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS])
      +  handlers.get('show')()
      +  handlers.get('restore')()
      +  assert.equal(calls, 2)
      +})
      +
      +test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
      +  const handlers = new Map()
      +  let destroyed = false
      +  const win = {
      +    isDestroyed: () => destroyed,
      +    on(event, listener) {
      +      handlers.set(event, listener)
      +    }
      +  }
      +  let calls = 0
      +  installZoomReassertOnWindowEvents(win, () => {
      +    calls += 1
      +  })
      +  destroyed = true
      +  handlers.get('show')()
      +  assert.equal(calls, 0)
      +})
      +
      +// Source assertion (see windows-child-process.test.ts for the established
      +// pattern): wireCommonWindowHandlers lives in the electron main entry with heavy
      +// Electron deps, so we assert the wiring contract against source rather than
      +// booting a BrowserWindow. Locks in that the pet overlay opts OUT of global UI
      +// zoom while chat windows keep it — the whole reason this fix is scoped.
      +test('pet overlay opts out of global UI zoom; chat windows keep it', () => {
      +  const electronDir = path.dirname(fileURLToPath(import.meta.url))
      +  const source = fs.readFileSync(path.join(electronDir, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
      +
      +  // The shared helper gates all zoom wiring behind an opt-out flag.
      +  assert.match(source, /function wireCommonWindowHandlers\(win, \{ zoom = true \}/)
      +
      +  // The pet overlay window is the only caller that disables zoom.
      +  assert.match(source, /wireCommonWindowHandlers\(win, \{ zoom: false \}\)/)
      +
      +  // Zoom restore now flows through the shared helper, so createWindow must not
      +  // reassert it directly (that would double-fire and drift from session windows).
      +  const finishLoad = source.indexOf("mainWindow.webContents.once('did-finish-load'")
      +  assert.notEqual(finishLoad, -1, 'missing mainWindow did-finish-load handler')
      +  const snippet = source.slice(finishLoad, finishLoad + 300)
      +  assert.doesNotMatch(snippet, /restorePersistedZoomLevel\(mainWindow\)/)
      +})
    • apps/desktop/electron/zoom.ts
      @@ -31,3 +31,22 @@ export function percentToZoomLevel(percent) {
       
         return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
       }
      +
      +// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized
      +// and restored. Re-apply the persisted level on these lifecycle transitions.
      +export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore']
      +
      +export function installZoomReassertOnWindowEvents(win, reassert) {
      +  if (!win?.on) {
      +    return
      +  }
      +
      +  for (const event of ZOOM_REASSERT_WINDOW_EVENTS) {
      +    win.on(event, () => {
      +      if (win.isDestroyed?.()) {
      +        return
      +      }
      +      reassert()
      +    })
      +  }
      +}
  • #94396 fix(desktop): keep UI scale across in-page route navigation

    merged to main

    files: apps/desktop/e2e/zoom-preservation.spec.ts, apps/desktop/electron/main.ts, apps/desktop/electron/zoom.test.ts, apps/desktop/electron/zoom.ts

3 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1 · superseded by another PR 1

  • #60912 fix(desktop): keep interface zoom across reloads · done elsewhere → 57dfebe
  • #61245 fix(desktop): re-apply UI zoom after minimize/restore on Windows · superseded by another PR → #61925
  • #75015 fix(desktop): preserve UI scale across hash-route navigation · merged via another change → #94396, #76627
Closed issues · 4
  • Closed#48658[Desktop Bug]: Zoom level resets unexpectedly when switching sessions/routes
  • Closed#60693[GUI] Interface zoom setting at 110% intermittently resets back to 100%
  • Closed#61081Windows desktop: UI scaling resets after minimizing and restoring the window
  • Closed#62634[Bug]: UI scaling resets to 100% when switching profiles on Windows Desktop
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.18: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#61925, #94396
Closed issues
4
Attempts
3 PRs closed without merging
Fix link checked
25 Aug 2026

For your agent

The same facts, typed.