{"repo":"NousResearch/hermes-agent","public_id":"48658","display_label":"Desktop UI zoom level persistence and reset fixes","summary":"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.","subsystem":"apps/desktop","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":61925},"resolution":{"state":"partially_resolved","by":"pr-94396"},"freshness":{"state":"verified","last_verified_at":"2026-08-25T03:01:48+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1788888888888889},"fix_edges":[{"issue_number":38854,"pr_number":94396,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":48658,"pr_number":94396,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61081,"pr_number":61925,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":79863,"pr_number":94396,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":48658,"title":"[Desktop Bug]: Zoom level resets unexpectedly when switching sessions/routes","url":null,"state":"closed"},{"kind":"issue","number":60693,"title":"[GUI] Interface zoom setting at 110% intermittently resets back to 100%","url":null,"state":"closed"},{"kind":"issue","number":61081,"title":"Windows desktop: UI scaling resets after minimizing and restoring the window","url":null,"state":"closed"},{"kind":"issue","number":62634,"title":"[Bug]: UI scaling resets to 100% when switching profiles on Windows Desktop","url":null,"state":"closed"},{"kind":"pr","number":60912,"title":"fix(desktop): keep interface zoom across reloads","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"57dfebe3d","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":61245,"title":"fix(desktop): re-apply UI zoom after minimize/restore on Windows","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61925","number":61925}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":61925,"title":"fix(desktop): re-apply UI zoom on show/restore, scoped to chat windows (supersedes #61245)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/main.ts","apps/desktop/electron/zoom.test.ts","apps/desktop/electron/zoom.ts"],"fix_hunks":[{"file":"apps/desktop/electron/main.ts","hunks":["@@ -4743,7 +4743,13 @@ function installPreviewShortcut(window) {\n // survives reloads/restarts) rather than a main-process JSON file. The main\n // process owns setZoomLevel, so we mirror each change into localStorage and\n // read it back on did-finish-load to re-apply after reloads or crash recovery.\n-import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom'\n+import {\n+  clampZoomLevel,\n+  installZoomReassertOnWindowEvents,\n+  percentToZoomLevel,\n+  ZOOM_STORAGE_KEY,\n+  zoomLevelToPercent\n+} from './zoom'\n \n function setAndPersistZoomLevel(window, zoomLevel) {\n   if (!window || window.isDestroyed()) {","@@ -6507,10 +6513,21 @@ async function startHermes() {\n // security posture: external links open in the OS browser, in-app navigation\n // stays confined to the dev server / packaged file URL, and the preview /\n // devtools / zoom / context-menu affordances behave identically everywhere.\n-function wireCommonWindowHandlers(win) {\n+//\n+// `zoom` is opt-out for the pet overlay: it sizes its own OS window to fit the\n+// sprite in unzoomed CSS px (overlayWindowSize -> setBounds) and has its own\n+// Alt+wheel scale, so inheriting the global UI zoom would render the mascot\n+// larger than its window and crop it. Chat windows keep zoom on.\n+function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) {\n   installPreviewShortcut(win)\n   installDevToolsShortcut(win)\n-  installZoomShortcuts(win)\n+  if (zoom) {\n+    installZoomShortcuts(win)\n+    // Re-apply persisted zoom on show/restore (Windows drops webContents zoom on\n+    // minimize/restore) and on first load (reloads / crash recovery).\n+    installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win))\n+    win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win))\n+  }\n   installContextMenu(win)\n   win.webContents.setWindowOpenHandler(details => {\n     openExternalUrl(details.url)","@@ -6702,7 +6719,9 @@ function spawnPetOverlayWindow(bounds) {\n     // Not supported everywhere — best effort.\n   }\n \n-  wireCommonWindowHandlers(win)\n+  // Pet overlay opts out of global UI zoom (see wireCommonWindowHandlers): it\n+  // owns its window-fit + scale, and inheriting zoom would crop the sprite.\n+  wireCommonWindowHandlers(win, { zoom: false })\n \n   win.once('ready-to-show', () => {\n     if (!win.isDestroyed()) {","@@ -6893,7 +6912,8 @@ function createWindow() {\n   }\n \n   mainWindow.webContents.once('did-finish-load', () => {\n-    restorePersistedZoomLevel(mainWindow)\n+    // Zoom restore is handled by wireCommonWindowHandlers (shared with session\n+    // windows); no need to reapply it here.\n     broadcastBootProgress()\n     sendWindowStateChanged()\n     startHermes().catch(error => rememberLog(error.stack || error.message))"]},{"file":"apps/desktop/electron/zoom.test.ts","hunks":["@@ -5,9 +5,19 @@\n  */\n \n import assert from 'node:assert/strict'\n+import fs from 'node:fs'\n+import path from 'node:path'\n import test from 'node:test'\n+import { fileURLToPath } from 'node:url'\n \n-import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom'\n+import {\n+  clampZoomLevel,\n+  installZoomReassertOnWindowEvents,\n+  percentToZoomLevel,\n+  ZOOM_REASSERT_WINDOW_EVENTS,\n+  ZOOM_STORAGE_KEY,\n+  zoomLevelToPercent\n+} from './zoom'\n \n test('storage key stays stable so persisted zoom survives upgrades', () => {\n   assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')","@@ -53,3 +63,63 @@ test('extreme percentages clamp to the level bounds', () => {\n   assert.equal(percentToZoomLevel(1), -9)\n   assert.equal(percentToZoomLevel(1_000_000), 9)\n })\n+\n+test('installZoomReassertOnWindowEvents wires show and restore', () => {\n+  const handlers = new Map()\n+  const win = {\n+    isDestroyed: () => false,\n+    on(event, listener) {\n+      handlers.set(event, listener)\n+    }\n+  }\n+  let calls = 0\n+  installZoomReassertOnWindowEvents(win, () => {\n+    calls += 1\n+  })\n+\n+  assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS])\n+  handlers.get('show')()\n+  handlers.get('restore')()\n+  assert.equal(calls, 2)\n+})\n+\n+test('installZoomReassertOnWindowEvents skips destroyed windows', () => {\n+  const handlers = new Map()\n+  let destroyed = false\n+  const win = {\n+    isDestroyed: () => destroyed,\n+    on(event, listener) {\n+      handlers.set(event, listener)\n+    }\n+  }\n+  let calls = 0\n+  installZoomReassertOnWindowEvents(win, () => {\n+    calls += 1\n+  })\n+  destroyed = true\n+  handlers.get('show')()\n+  assert.equal(calls, 0)\n+})\n+\n+// Source assertion (see windows-child-process.test.ts for the established\n+// pattern): wireCommonWindowHandlers lives in the electron main entry with heavy\n+// Electron deps, so we assert the wiring contract against source rather than\n+// booting a BrowserWindow. Locks in that the pet overlay opts OUT of global UI\n+// zoom while chat windows keep it — the whole reason this fix is scoped.\n+test('pet overlay opts out of global UI zoom; chat windows keep it', () => {\n+  const electronDir = path.dirname(fileURLToPath(import.meta.url))\n+  const source = fs.readFileSync(path.join(electronDir, 'main.ts'), 'utf8').replace(/\\r\\n/g, '\\n')\n+\n+  // The shared helper gates all zoom wiring behind an opt-out flag.\n+  assert.match(source, /function wireCommonWindowHandlers\\(win, \\{ zoom = true \\}/)\n+\n+  // The pet overlay window is the only caller that disables zoom.\n+  assert.match(source, /wireCommonWindowHandlers\\(win, \\{ zoom: false \\}\\)/)\n+\n+  // Zoom restore now flows through the shared helper, so createWindow must not\n+  // reassert it directly (that would double-fire and drift from session windows).\n+  const finishLoad = source.indexOf(\"mainWindow.webContents.once('did-finish-load'\")\n+  assert.notEqual(finishLoad, -1, 'missing mainWindow did-finish-load handler')\n+  const snippet = source.slice(finishLoad, finishLoad + 300)\n+  assert.doesNotMatch(snippet, /restorePersistedZoomLevel\\(mainWindow\\)/)\n+})"]},{"file":"apps/desktop/electron/zoom.ts","hunks":["@@ -31,3 +31,22 @@ export function percentToZoomLevel(percent) {\n \n   return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))\n }\n+\n+// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized\n+// and restored. Re-apply the persisted level on these lifecycle transitions.\n+export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore']\n+\n+export function installZoomReassertOnWindowEvents(win, reassert) {\n+  if (!win?.on) {\n+    return\n+  }\n+\n+  for (const event of ZOOM_REASSERT_WINDOW_EVENTS) {\n+    win.on(event, () => {\n+      if (win.isDestroyed?.()) {\n+        return\n+      }\n+      reassert()\n+    })\n+  }\n+}\n"]}]},{"kind":"pr","number":75015,"title":"fix(desktop): preserve UI scale across hash-route navigation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#94396","number":94396},{"kind":"pr","value":"#94396","number":94396},{"kind":"pr","value":"#76627","number":76627}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":94396,"title":"fix(desktop): keep UI scale across in-page route navigation","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/e2e/zoom-preservation.spec.ts","apps/desktop/electron/main.ts","apps/desktop/electron/zoom.test.ts","apps/desktop/electron/zoom.ts"]}],"trust":{"default_tier":"verified","includes_suggested":false}}