NousResearch/hermes-agent

Windows desktop UI zoom resets after minimize/restore

Cluster 61081. Data as of

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

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue 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 unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

Three members center on UI zoom/scaling in the Windows desktop app being lost after the window is minimized and restored. Two are fix proposals that re-apply zoom on restore/show (one scoped to chat windows and noted as superseding another PR), and one describes the issue itself.

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()
      +    })
      +  }
      +}

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #61245 fix(desktop): re-apply UI zoom after minimize/restore on Windows · superseded by another PR → #61925
Closed issues · 1
  • Closed#61081Windows desktop: UI scaling resets after minimizing and restoring the window
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: 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

Merged PRs
#61925
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
10 Jul 2026

For your agent

The same facts, typed.