# NousResearch/hermes-agent · Desktop UI Scale setting not syncing with restored zoom

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #63995. Verify it addresses your case.
- **Recommended:** read PR #63995 (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: `resolved` · deciding PR(s): `pr-63995`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: 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

- #63846: \[Bug\]: UI Scale setting resets to 100% in Appearance settings after restart, despite zoom being correctly restored

## Prior & competing PRs

- **#63995: fix(desktop): sync UI Scale control after zoom restore (supersedes #63842)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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
    @@ -4705,7 +4705,7 @@ function installPreviewShortcut(window) {
     // 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,
    +  applyZoomLevel,
       installZoomReassertOnWindowEvents,
       percentToZoomLevel,
       ZOOM_STORAGE_KEY,
    ```

    ```diff
    @@ -4718,11 +4718,9 @@ function setAndPersistZoomLevel(window, zoomLevel) {
         return
       }
     
    -  const next = clampZoomLevel(zoomLevel)
    -  window.webContents.setZoomLevel(next)
    -  // Keep any open settings UI in sync, including changes made via the
    -  // keyboard shortcuts or the View menu.
    -  window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) })
    +  // Apply + notify in one funnel so the settings UI stays in sync, including
    +  // changes made via the keyboard shortcuts or the View menu.
    +  const next = applyZoomLevel(window.webContents, zoomLevel)
       window.webContents
         .executeJavaScript(
           `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
    ```

    ```diff
    @@ -4744,8 +4742,9 @@ function restorePersistedZoomLevel(window) {
             return
           }
     
    -      const level = clampZoomLevel(Number(stored))
    -      window.webContents.setZoomLevel(level)
    +      // Notify the renderer too — otherwise the Appearance UI Scale control
    +      // can stay stuck at 100% even though the window zoom was restored.
    +      applyZoomLevel(window.webContents, Number(stored))
         })
         .catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
     }
    ```
  - `apps/desktop/electron/zoom.test.ts`

    ```diff
    @@ -9,6 +9,7 @@ import assert from 'node:assert/strict'
     import { test } from 'vitest'
     
     import {
    +  applyZoomLevel,
       clampZoomLevel,
       installZoomReassertOnWindowEvents,
       percentToZoomLevel,
    ```

    ```diff
    @@ -118,3 +119,42 @@ test('unknown window kinds default to chat (zoom enabled)', () => {
       assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true })
       assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true })
     })
    +
    +// The UI Scale settings control drifts out of sync after a restart when zoom
    +// is applied to the window but the renderer is never told: its $zoomPercent
    +// store (see store/zoom.ts) only updates from zoom.get() (once, on load) and
    +// 'hermes:zoom:changed' events. applyZoomLevel is the single funnel every zoom
    +// path (user set, restore-on-load, lifecycle re-assert) shares, so applying a
    +// level always notifies — the regression can't come back by forgetting a send.
    +function fakeWebContents() {
    +  const calls: Array<[string, ...unknown[]]> = []
    +
    +  return {
    +    calls,
    +    setZoomLevel: (level: number) => calls.push(['setZoomLevel', level]),
    +    send: (channel: string, payload: unknown) => calls.push(['send', channel, payload])
    +  }
    +}
    +
    +test('applyZoomLevel applies the level then notifies the renderer', () => {
    +  const wc = fakeWebContents()
    +  const applied = applyZoomLevel(wc, 3)
    +
    +  assert.equal(applied, 3)
    +  assert.deepEqual(wc.calls, [
    +    ['setZoomLevel', 3],
    +    ['send', 'hermes:zoom:changed', { level: 3, percent: zoomLevelToPercent(3) }]
    +  ])
    +})
    +
    +test('applyZoomLevel clamps garbage before applying and notifying', () => {
    +  const wc = fakeWebContents()
    +  const applied = applyZoomLevel(wc, 999)
    +  const clamped = clampZoomLevel(999)
    +
    +  assert.equal(applied, clamped)
    +  assert.deepEqual(wc.calls, [
    +    ['setZoomLevel', clamped],
    +    ['send', 'hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }]
    +  ])
    +})
    ```
  - `apps/desktop/electron/zoom.ts`

    ```diff
    @@ -32,6 +32,22 @@ export function percentToZoomLevel(percent) {
       return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
     }
     
    +/**
    + * Apply a clamped zoom level to a webContents AND notify the renderer, in that
    + * order. Every path that changes zoom (user action, restore-on-load, lifecycle
    + * re-assert) funnels through here so the settings UI Scale control can never
    + * drift from the actually-applied level — the bug where restore set the level
    + * but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%.
    + * Returns the clamped level so callers can persist it.
    + */
    +export function applyZoomLevel(webContents, level) {
    +  const clamped = clampZoomLevel(level)
    +  webContents.setZoomLevel(clamped)
    +  webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) })
    +
    +  return clamped
    +}
    +
     // 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']
    ```
- **#63842: fix(desktop): sync UI Scale setting after zoom restore on window load**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#63995`

## Fix status

- Merged to main in PR #63995 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-13`.
