Desktop window state persistence across launches

NousResearch/hermes-agent · cluster 38830 · apps/desktop

Data as of

Nothing open on GitHub. Fix merged in #52086. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #52086. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #52086. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and pull requests concern saving and restoring the desktop window's size, position, and maximized state between launches. Members include bug reports about the window resetting to defaults and multiple PRs implementing or fixing the persistence behavior.

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.

  • #52086 feat(desktop): remember window size/position/maximized across launches (salvage #39154)

    merged to main

    files: apps/desktop/electron/main.cjs, apps/desktop/electron/window-state.cjs, apps/desktop/electron/window-state.test.cjs, apps/desktop/package.json

    Show the diff
    • apps/desktop/electron/main.cjs
      @@ -12,6 +12,7 @@ const {
         powerMonitor,
         protocol,
         safeStorage,
      +  screen,
         session,
         shell,
         systemPreferences
      @@ -67,6 +68,13 @@ const {
         uninstallArgsForMode
       } = require('./desktop-uninstall.cjs')
       const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs')
      +const {
      +  MIN_WIDTH: WINDOW_MIN_WIDTH,
      +  MIN_HEIGHT: WINDOW_MIN_HEIGHT,
      +  sanitizeWindowState,
      +  computeWindowOptions,
      +  debounce
      +} = require('./window-state.cjs')
       const {
         authModeFromStatus,
         buildGatewayWsUrl,
      @@ -320,6 +328,7 @@ const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1
       
       const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
       const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
      +const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json')
       // active-profile.json records which Hermes profile the desktop launches its
       // local backend as. When set, startHermes() passes `hermes --profile <name>
       // dashboard …`, which deterministically pins HERMES_HOME (see
      @@ -1522,6 +1531,36 @@ function writeDesktopUpdateConfig(config) {
         writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
       }
       
      +// ─── Main-window geometry persistence (window-state.json) ──────────────────
      +
      +function readWindowState() {
      +  try {
      +    return sanitizeWindowState(JSON.parse(fs.readFileSync(DESKTOP_WINDOW_STATE_PATH, 'utf8')))
      +  } catch {
      +    return null
      +  }
      +}
      +
      +// Persist the window's restored (non-maximized) bounds plus its maximized flag.
      +// getNormalBounds() keeps the pre-maximize size, so un-maximizing next session
      +// lands back where the user actually sized the window.
      +function persistWindowState() {
      +  if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) return
      +  try {
      +    const { x, y, width, height } = mainWindow.getNormalBounds()
      +    fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true })
      +    writeFileAtomic(
      +      DESKTOP_WINDOW_STATE_PATH,
      +      JSON.stringify({ x, y, width, height, isMaximized: mainWindow.isMaximized() }, null, 2)
      +    )
      +  } catch (err) {
      +    rememberLog(`[window-state] persist failed: ${err?.message || err}`)
      +  }
      +}
      +
      +// resized/moved fire many times mid-drag on Linux; debounce to one write.
      +const schedulePersistWindowState = debounce(persistWindowState, 250)
      +
       // Match the backend's source resolution but bias toward a real git checkout.
       // Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT.
       // HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree.
      @@ -5523,11 +5562,11 @@ function closePetOverlay() {
       
       function createWindow() {
         const icon = getAppIconPath()
      +  const savedWindowState = readWindowState()
         mainWindow = new BrowserWindow({
      -    width: 1220,
      -    height: 800,
      -    minWidth: 400,
      -    minHeight: 620,
      +    ...computeWindowOptions(savedWindowState, screen.getAllDisplays()),
      +    minWidth: WINDOW_MIN_WIDTH,
      +    minHeight: WINDOW_MIN_HEIGHT,
           title: 'Hermes',
           // Frameless title bar on every platform so the renderer can paint the
           // "hide sidebar" button (and other left-side titlebar tools) flush with
      @@ -5569,6 +5608,8 @@ function createWindow() {
           }
         }
       
      +  if (savedWindowState?.isMaximized) mainWindow.maximize()
      +
         mainWindow.once('ready-to-show', () => {
           if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show()
         })
      @@ -5578,6 +5619,14 @@ function createWindow() {
         mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
         mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
       
      +  // Reopen where the user left off. resized/moved settle once per drag; close is
      +  // the cross-platform backstop, flushed synchronously before the window is gone.
      +  mainWindow.on('resized', schedulePersistWindowState)
      +  mainWindow.on('moved', schedulePersistWindowState)
      +  mainWindow.on('maximize', schedulePersistWindowState)
      +  mainWindow.on('unmaximize', schedulePersistWindowState)
      +  mainWindow.on('close', () => schedulePersistWindowState.flush())
      +
         // The overlay rides the main window — closing the app's primary window must
         // tear it down too (otherwise it strands as an orphan that blocks
         // window-all-closed from quitting on Windows/Linux).
    • apps/desktop/electron/window-state.cjs
      @@ -0,0 +1,117 @@
      +/**
      + * Pure geometry helpers for window-state.json — restoring the main window's
      + * size, position, and maximized flag across launches. Side-effect-free so the
      + * part that actually matters (rejecting garbage + off-screen bounds) is
      + * unit-testable without booting Electron; main.cjs owns the file I/O and the
      + * live `screen` displays.
      + */
      +
      +// Defaults mirror the historical hardcoded BrowserWindow size; MIN_* mirror its
      +// minWidth/minHeight so a restored size never undershoots what the live window
      +// allows. A fresh install (no saved state) is byte-identical to before.
      +const DEFAULT_WIDTH = 1220
      +const DEFAULT_HEIGHT = 800
      +const MIN_WIDTH = 400
      +const MIN_HEIGHT = 620
      +
      +// Keep at least this much of the window over a display work area before we trust
      +// a saved position, so the title bar stays grabbable after a monitor unplugs.
      +const MIN_VISIBLE = 48
      +
      +const finite = v => typeof v === 'number' && Number.isFinite(v)
      +const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi))
      +
      +// Parse raw JSON → clean state, or null if garbage. width/height are required
      +// and floored; x/y survive only as a finite pair; isMaximized is strict.
      +function sanitizeWindowState(raw) {
      +  if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null
      +
      +  const state = {
      +    width: Math.max(MIN_WIDTH, Math.round(raw.width)),
      +    height: Math.max(MIN_HEIGHT, Math.round(raw.height)),
      +    isMaximized: raw.isMaximized === true
      +  }
      +  if (finite(raw.x) && finite(raw.y)) {
      +    state.x = Math.round(raw.x)
      +    state.y = Math.round(raw.y)
      +  }
      +  return state
      +}
      +
      +// True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both
      +// axes. `displays` is Electron's screen.getAllDisplays() shape.
      +function onScreen(bounds, displays) {
      +  if (!Array.isArray(displays)) return false
      +  return displays.some(({ workArea: a } = {}) => {
      +    if (!a) return false
      +    const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
      +    const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
      +    return x >= MIN_VISIBLE && y >= MIN_VISIBLE
      +  })
      +}
      +
      +// Sanitized state (or null) → BrowserWindow size/position options. Always sets
      +// width/height, capped to the largest current display so a size saved on a
      +// since-disconnected bigger monitor can't exceed any screen the user now has.
      +// Sets x/y only when still on-screen; otherwise Electron centers the window.
      +function computeWindowOptions(state, displays) {
      +  const opts = {
      +    width: finite(state?.width) ? state.width : DEFAULT_WIDTH,
      +    height: finite(state?.height) ? state.height : DEFAULT_HEIGHT
      +  }
      +
      +  const cap = (Array.isArray(displays) ? displays : []).reduce(
      +    (m, { workArea: a } = {}) =>
      +      a && finite(a.width) && finite(a.height)
      +        ? { width: Math.max(m.width, a.width), height: Math.max(m.height, a.height) }
      +        : m,
      +    { width: 0, height: 0 }
      +  )
      +  if (cap.width && cap.height) {
      +    opts.width = clamp(opts.width, MIN_WIDTH, cap.width)
      +    opts.height = clamp(opts.height, MIN_HEIGHT, cap.height)
      +  }
      +
      +  if (
      +    state &&
      +    finite(state.x) &&
      +    finite(state.y) &&
      +    onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays)
      +  ) {
      +    opts.x = state.x
      +    opts.y = state.y
      +  }
      +  return opts
      +}
      +
      +// Trailing debounce: collapse a burst of resize/move events (Linux fires many
      +// mid-drag) into a single run `delayMs` after the last. `.flush()` runs now and
      +// cancels the pending timer — used on close, before the window is gone.
      +function debounce(fn, delayMs) {
      +  let timer = null
      +  const debounced = () => {
      +    clearTimeout(timer)
      +    timer = setTimeout(() => {
      +      timer = null
      +      fn()
      +    }, delayMs)
      +  }
      +  debounced.flush = () => {
      +    clearTimeout(timer)
      +    timer = null
      +    fn()
      +  }
      +  return debounced
      +}
      +
      +module.exports = {
      +  DEFAULT_WIDTH,
      +  DEFAULT_HEIGHT,
      +  MIN_WIDTH,
      +  MIN_HEIGHT,
      +  MIN_VISIBLE,
      +  sanitizeWindowState,
      +  onScreen,
      +  computeWindowOptions,
      +  debounce
      +}
    • apps/desktop/electron/window-state.test.cjs
      @@ -0,0 +1,135 @@
      +/**
      + * Unit tests for the pure window-state geometry helpers. These cover the logic
      + * that protects the user: garbage rejection, off-screen fallback, oversized
      + * clamping, and the debounce that collapses mid-drag write storms.
      + */
      +
      +const test = require('node:test')
      +const assert = require('node:assert/strict')
      +
      +const {
      +  DEFAULT_WIDTH,
      +  DEFAULT_HEIGHT,
      +  MIN_WIDTH,
      +  MIN_HEIGHT,
      +  sanitizeWindowState,
      +  onScreen,
      +  computeWindowOptions,
      +  debounce
      +} = require('./window-state.cjs')
      +
      +// A single 1920×1080 monitor (work area trimmed for the taskbar).
      +const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }]
      +// A laptop panel left behind after a bigger external monitor is unplugged.
      +const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }]
      +
      +// ─── sanitizeWindowState ───────────────────────────────────────────────────
      +
      +test('sanitizeWindowState rejects missing/garbage input', () => {
      +  for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) {
      +    assert.equal(sanitizeWindowState(bad), null)
      +  }
      +})
      +
      +test('sanitizeWindowState keeps a valid full state and rounds HiDPI fractions', () => {
      +  assert.deepEqual(sanitizeWindowState({ x: 100.6, y: 50.2, width: 1400.4, height: 900.7, isMaximized: true }), {
      +    x: 101,
      +    y: 50,
      +    width: 1400,
      +    height: 901,
      +    isMaximized: true
      +  })
      +})
      +
      +test('sanitizeWindowState floors size to the minimums', () => {
      +  const state = sanitizeWindowState({ width: 10, height: 10 })
      +  assert.equal(state.width, MIN_WIDTH)
      +  assert.equal(state.height, MIN_HEIGHT)
      +})
      +
      +test('sanitizeWindowState drops a partial position but keeps the size', () => {
      +  assert.deepEqual(sanitizeWindowState({ x: 100, width: 1400, height: 900 }), {
      +    width: 1400,
      +    height: 900,
      +    isMaximized: false
      +  })
      +})
      +
      +test('sanitizeWindowState treats isMaximized strictly', () => {
      +  assert.equal(sanitizeWindowState({ width: 1400, height: 900, isMaximized: 'yes' }).isMaximized, false)
      +})
      +
      +// ─── onScreen ──────────────────────────────────────────────────────────────
      +
      +test('onScreen accepts a window on the primary or a secondary display', () => {
      +  const dual = [...PRIMARY, { workArea: { x: 1920, y: 0, width: 2560, height: 1400 } }]
      +  assert.equal(onScreen({ x: 100, y: 100, width: 1220, height: 800 }, PRIMARY), true)
      +  assert.equal(onScreen({ x: 2200, y: 200, width: 1220, height: 800 }, dual), true)
      +})
      +
      +test('onScreen rejects off-screen, slivers, and bad input', () => {
      +  assert.equal(onScreen({ x: 3000, y: 100, width: 1220, height: 800 }, PRIMARY), false) // past right edge
      +  assert.equal(onScreen({ x: 100, y: -900, width: 1220, height: 800 }, PRIMARY), false) // above top
      +  assert.equal(onScreen({ x: 1910, y: 100, width: 1220, height: 800 }, PRIMARY), false) // ~10px sliver
      +  assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, []), false)
      +  assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, null), false)
      +})
      +
      +// ─── computeWindowOptions ──────────────────────────────────────────────────
      +
      +test('computeWindowOptions falls back to defaults with no saved state', () => {
      +  assert.deepEqual(computeWindowOptions(null, PRIMARY), { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT })
      +})
      +
      +test('computeWindowOptions restores an on-screen position', () => {
      +  const saved = sanitizeWindowState({ x: 200, y: 150, width: 1400, height: 900 })
      +  assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900, x: 200, y: 150 })
      +})
      +
      +test('computeWindowOptions keeps the size but drops an off-screen position', () => {
      +  const saved = sanitizeWindowState({ x: 5000, y: 150, width: 1400, height: 900 })
      +  assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900 })
      +})
      +
      +test('computeWindowOptions clamps a size larger than the only display', () => {
      +  const saved = sanitizeWindowState({ width: 2560, height: 1440 })
      +  assert.deepEqual(computeWindowOptions(saved, LAPTOP), { width: 1366, height: 728 })
      +})
      +
      +test('computeWindowOptions keeps the MIN floor on a sub-minimum display', () => {
      +  const tiny = [{ workArea: { x: 0, y: 0, width: 360, height: 480 } }]
      +  const saved = sanitizeWindowState({ width: 2000, height: 1500 })
      +  assert.deepEqual(computeWindowOptions(saved, tiny), { width: MIN_WIDTH, height: MIN_HEIGHT })
      +})
      +
      +test('computeWindowOptions does not clamp when displays are unknown', () => {
      +  const saved = sanitizeWindowState({ width: 2560, height: 1440 })
      +  assert.deepEqual(computeWindowOptions(saved, []), { width: 2560, height: 1440 })
      +})
      +
      +// ─── debounce ──────────────────────────────────────────────────────────────
      +
      +test('debounce coalesces a burst into one trailing run', t => {
      +  t.mock.timers.enable({ apis: ['setTimeout'] })
      +  let calls = 0
      +  const d = debounce(() => { calls += 1 }, 250)
      +
      +  d(); d(); d()
      +  assert.equal(calls, 0)
      +  t.mock.timers.tick(249)
      +  assert.equal(calls, 0)
      +  t.mock.timers.tick(1)
      +  assert.equal(calls, 1)
      +})
      +
      +test('debounce.flush runs now and cancels the pending timer', t => {
      +  t.mock.timers.enable({ apis: ['setTimeout'] })
      +  let calls = 0
      +  const d = debounce(() => { calls += 1 }, 250)
      +
      +  d()
      +  d.flush()
      +  assert.equal(calls, 1)
      +  t.mock.timers.tick(1000)
      +  assert.equal(calls, 1)
      +})
    • apps/desktop/package.json
      @@ -37,7 +37,7 @@
           "test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
           "test:desktop:existing": "node scripts/test-desktop.mjs existing",
           "test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
      -    "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs",
      +    "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs",
           "typecheck": "tsc -p . --noEmit",
           "lint": "eslint src/ electron/",
           "lint:fix": "eslint src/ electron/ --fix",

2 PRs closed without merging

From the close comments: superseded by another PR 2

  • #39154 feat(desktop): remember window size, position, and maximized state · superseded by another PR → #52086
  • #48909 fix(desktop): persist and restore main window size/position (closes #48539) · superseded by another PR → #39154
Closed issues · 3
  • Closed#38830[Feature]: Remember window state (maximized, restored, position, size) on Windows desktop
  • Closed#39101Desktop: Window bounds not persisted — always reopens at default 1220×800
  • Closed#48539[Bug]: Desktop window doesn't remember its size or position — resets to default on every launch
How this cluster was grouped
  • Link evidence (partly structural links): 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.34: 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

Subsystem
apps/desktop
Merged PRs
#52086
Closed issues
3
Attempts
2 PRs closed without merging
Fix link checked
24 Jun 2026

For your agent

The same facts, typed.