# NousResearch/hermes-agent · Desktop UI zoom level persistence and reset fixes

## Open now

- 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.

## TL;DR

- 9 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 2 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #61925. Verify it addresses your case.
- **Recommended:** read PR #61925 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-94396`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #48658: \[Desktop Bug\]: Zoom level resets unexpectedly when switching sessions/routes
- #60693: \[GUI\] Interface zoom setting at 110% intermittently resets back to 100%
- #61081: Windows desktop: UI scaling resets after minimizing and restoring the window
- #62634: \[Bug\]: UI scaling resets to 100% when switching profiles on Windows Desktop

## Prior & competing PRs

- **#61925: fix(desktop): re-apply UI zoom on show/restore, scoped to chat windows (supersedes #61245)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/electron/main.ts`, `apps/desktop/electron/zoom.test.ts`, `apps/desktop/electron/zoom.ts`
  - fix hunks:
  - `apps/desktop/electron/main.ts`

    ```diff
    @@ -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()) {
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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()) {
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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')
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/e2e/zoom-preservation.spec.ts`, `apps/desktop/electron/main.ts`, `apps/desktop/electron/zoom.test.ts`, `apps/desktop/electron/zoom.ts`
- **#60912: fix(desktop): keep interface zoom across reloads**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `57dfebe3d`
- **#61245: fix(desktop): re-apply UI zoom after minimize/restore on Windows**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#61925`
- **#75015: fix(desktop): preserve UI scale across hash-route navigation**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#94396`, `#94396`, `#76627`

## Fix status

- Merged to main in PR #61925 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-08-25`.
- Subsystem: `apps/desktop`
