{"repo":"NousResearch/hermes-agent","public_id":"63846","display_label":"Desktop UI Scale setting not syncing with restored zoom","summary":"These items concern the desktop app's UI Scale control in Appearance settings showing 100% after restart even though the zoom level is restored. One is a bug report and two are pull requests proposing to sync the control after zoom restore, with one noted as superseding an earlier PR.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":63995,"merged_at":"2026-07-13T21:43:42+00:00"},"resolution":{"state":"resolved","by":"pr-63995"},"freshness":{"state":"verified","last_verified_at":"2026-07-13T21:43:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":63846,"pr_number":63995,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-13T21:43:42+00:00"}],"members":[{"kind":"issue","number":63846,"title":"[Bug]: UI Scale setting resets to 100% in Appearance settings after restart, despite zoom being correctly restored","url":null,"state":"closed"},{"kind":"pr","number":63842,"title":"fix(desktop): sync UI Scale setting after zoom restore on window load","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63995","number":63995}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":63995,"title":"fix(desktop): sync UI Scale control after zoom restore (supersedes #63842)","url":null,"state":"merged","close_reason":{"category":"merged"},"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":["@@ -4705,7 +4705,7 @@ function installPreviewShortcut(window) {\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 {\n-  clampZoomLevel,\n+  applyZoomLevel,\n   installZoomReassertOnWindowEvents,\n   percentToZoomLevel,\n   ZOOM_STORAGE_KEY,","@@ -4718,11 +4718,9 @@ function setAndPersistZoomLevel(window, zoomLevel) {\n     return\n   }\n \n-  const next = clampZoomLevel(zoomLevel)\n-  window.webContents.setZoomLevel(next)\n-  // Keep any open settings UI in sync, including changes made via the\n-  // keyboard shortcuts or the View menu.\n-  window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) })\n+  // Apply + notify in one funnel so the settings UI stays in sync, including\n+  // changes made via the keyboard shortcuts or the View menu.\n+  const next = applyZoomLevel(window.webContents, zoomLevel)\n   window.webContents\n     .executeJavaScript(\n       `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`","@@ -4744,8 +4742,9 @@ function restorePersistedZoomLevel(window) {\n         return\n       }\n \n-      const level = clampZoomLevel(Number(stored))\n-      window.webContents.setZoomLevel(level)\n+      // Notify the renderer too — otherwise the Appearance UI Scale control\n+      // can stay stuck at 100% even though the window zoom was restored.\n+      applyZoomLevel(window.webContents, Number(stored))\n     })\n     .catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))\n }"]},{"file":"apps/desktop/electron/zoom.test.ts","hunks":["@@ -9,6 +9,7 @@ import assert from 'node:assert/strict'\n import { test } from 'vitest'\n \n import {\n+  applyZoomLevel,\n   clampZoomLevel,\n   installZoomReassertOnWindowEvents,\n   percentToZoomLevel,","@@ -118,3 +119,42 @@ test('unknown window kinds default to chat (zoom enabled)', () => {\n   assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true })\n   assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true })\n })\n+\n+// The UI Scale settings control drifts out of sync after a restart when zoom\n+// is applied to the window but the renderer is never told: its $zoomPercent\n+// store (see store/zoom.ts) only updates from zoom.get() (once, on load) and\n+// 'hermes:zoom:changed' events. applyZoomLevel is the single funnel every zoom\n+// path (user set, restore-on-load, lifecycle re-assert) shares, so applying a\n+// level always notifies — the regression can't come back by forgetting a send.\n+function fakeWebContents() {\n+  const calls: Array<[string, ...unknown[]]> = []\n+\n+  return {\n+    calls,\n+    setZoomLevel: (level: number) => calls.push(['setZoomLevel', level]),\n+    send: (channel: string, payload: unknown) => calls.push(['send', channel, payload])\n+  }\n+}\n+\n+test('applyZoomLevel applies the level then notifies the renderer', () => {\n+  const wc = fakeWebContents()\n+  const applied = applyZoomLevel(wc, 3)\n+\n+  assert.equal(applied, 3)\n+  assert.deepEqual(wc.calls, [\n+    ['setZoomLevel', 3],\n+    ['send', 'hermes:zoom:changed', { level: 3, percent: zoomLevelToPercent(3) }]\n+  ])\n+})\n+\n+test('applyZoomLevel clamps garbage before applying and notifying', () => {\n+  const wc = fakeWebContents()\n+  const applied = applyZoomLevel(wc, 999)\n+  const clamped = clampZoomLevel(999)\n+\n+  assert.equal(applied, clamped)\n+  assert.deepEqual(wc.calls, [\n+    ['setZoomLevel', clamped],\n+    ['send', 'hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }]\n+  ])\n+})"]},{"file":"apps/desktop/electron/zoom.ts","hunks":["@@ -32,6 +32,22 @@ export function percentToZoomLevel(percent) {\n   return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))\n }\n \n+/**\n+ * Apply a clamped zoom level to a webContents AND notify the renderer, in that\n+ * order. Every path that changes zoom (user action, restore-on-load, lifecycle\n+ * re-assert) funnels through here so the settings UI Scale control can never\n+ * drift from the actually-applied level — the bug where restore set the level\n+ * but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%.\n+ * Returns the clamped level so callers can persist it.\n+ */\n+export function applyZoomLevel(webContents, level) {\n+  const clamped = clampZoomLevel(level)\n+  webContents.setZoomLevel(clamped)\n+  webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) })\n+\n+  return clamped\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"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}