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