NousResearch/hermes-agent

Desktop UI Scale setting not syncing with restored zoom

Cluster 63846. Data as of

Nothing open on GitHub. Fix merged in #63995 on 13 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 #63995. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #63995. That is separate from the fix event.
Still open
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.

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.

  • #63995 fix(desktop): sync UI Scale control after zoom restore (supersedes #63842)

    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
      @@ -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,
      @@ -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 {}`
      @@ -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
      @@ -9,6 +9,7 @@ import assert from 'node:assert/strict'
       import { test } from 'vitest'
       
       import {
      +  applyZoomLevel,
         clampZoomLevel,
         installZoomReassertOnWindowEvents,
         percentToZoomLevel,
      @@ -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
      @@ -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']

1 PR closed without merging

From the close comments: done elsewhere 1

  • #63842 fix(desktop): sync UI Scale setting after zoom restore on window load · done elsewhere → #63995
Closed issues · 1
  • Closed#63846[Bug]: UI Scale setting resets to 100% in Appearance settings after restart, despite zoom being correctly restored
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
#63995
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
13 Jul 2026

For your agent

The same facts, typed.