local-operator-ui 0.19.1 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/out/main/index.js +174 -14
  2. package/package.json +4 -2
package/out/main/index.js CHANGED
@@ -4047,6 +4047,22 @@ function registerDesktopIPC(window, expectedUrl, request, streams, media, notifi
4047
4047
  });
4048
4048
  }
4049
4049
  }
4050
+ function presentWindow(window, show) {
4051
+ if (show === "never") return;
4052
+ if (window.isMinimized()) window.restore();
4053
+ if (show === "inactive") window.showInactive();
4054
+ else window.show();
4055
+ }
4056
+ function raiseWindow(window, show) {
4057
+ if (show === "never") return;
4058
+ if (window.isMinimized()) window.restore();
4059
+ if (show === "inactive") {
4060
+ window.showInactive();
4061
+ return;
4062
+ }
4063
+ window.show();
4064
+ window.focus();
4065
+ }
4050
4066
  const NOTIFY_TTL_MS = 10 * 60 * 1e3;
4051
4067
  const MAX_DEDUPE_KEYS = 2048;
4052
4068
  const CONTRACT_PROBE_ATTEMPTS = 3;
@@ -4065,9 +4081,10 @@ function gateBody(kind, gateTitle, gateDetail) {
4065
4081
  return subject || GATE_BODIES[kind] || GATE_BODIES.approval;
4066
4082
  }
4067
4083
  class DesktopNotifier {
4068
- constructor(window, request) {
4084
+ constructor(window, request, windowRaise = "focus") {
4069
4085
  this.window = window;
4070
4086
  this.request = request;
4087
+ this.windowRaise = windowRaise;
4071
4088
  }
4072
4089
  delivered = /* @__PURE__ */ new Map();
4073
4090
  /** Last known frontend epoch per session, to key dedupe on owner epoch. */
@@ -4256,6 +4273,7 @@ class DesktopNotifier {
4256
4273
  /** Called by the stream relay for every parsed frame it forwards. */
4257
4274
  observe(sessionId2, frame) {
4258
4275
  if (!this.canNotify) return;
4276
+ if (this.windowRaise === "never") return;
4259
4277
  if (frame.type === "snapshot") {
4260
4278
  this.epochs.set(sessionId2, frame.payload.frontend.epoch);
4261
4279
  const gate = frame.payload.frontend.snapshot.pending_gate;
@@ -4479,9 +4497,7 @@ class DesktopNotifier {
4479
4497
  notification.on("click", () => {
4480
4498
  const target2 = this.window();
4481
4499
  if (!target2) return;
4482
- if (target2.isMinimized()) target2.restore();
4483
- target2.show();
4484
- target2.focus();
4500
+ raiseWindow(target2, this.windowRaise);
4485
4501
  target2.webContents.send("desktop-open-conversation", { sessionId: sessionId2 });
4486
4502
  });
4487
4503
  notification.show();
@@ -7522,6 +7538,105 @@ ${pipRun.stderr.trim()}`,
7522
7538
  }
7523
7539
  }
7524
7540
  }
7541
+ const WINDOW_MODES = ["normal", "headless", "inactive"];
7542
+ const WINDOW_MODE_ENV = "LOCAL_OPERATOR_UI_WINDOW_MODE";
7543
+ const WINDOW_SIZE_ENV = "LOCAL_OPERATOR_UI_WINDOW_SIZE";
7544
+ const WINDOW_MODE_FLAG = "--window-mode";
7545
+ const WINDOW_SIZE_FLAG = "--window-size";
7546
+ const WINDOW_MIN_WIDTH = 800;
7547
+ const WINDOW_MIN_HEIGHT = 600;
7548
+ const WINDOW_MAX_EDGE = 16384;
7549
+ const DEFAULT_WINDOW_WIDTH = 1380;
7550
+ const DEFAULT_WINDOW_HEIGHT = 900;
7551
+ const WINDOW_BEHAVIOUR = {
7552
+ normal: { show: "focus", focusable: true, backgroundThrottling: true },
7553
+ inactive: { show: "inactive", focusable: true, backgroundThrottling: false },
7554
+ headless: { show: "never", focusable: false, backgroundThrottling: false }
7555
+ };
7556
+ function readFlag(argv, name) {
7557
+ for (let index = 0; index < argv.length; index += 1) {
7558
+ const argument = argv[index];
7559
+ if (argument === name) {
7560
+ const next = argv[index + 1];
7561
+ return { found: true, value: next?.startsWith("--") ? void 0 : next };
7562
+ }
7563
+ if (argument.startsWith(`${name}=`)) {
7564
+ return { found: true, value: argument.slice(name.length + 1) };
7565
+ }
7566
+ }
7567
+ return { found: false, value: void 0 };
7568
+ }
7569
+ function parseWindowMode(value) {
7570
+ if (value === void 0) return null;
7571
+ const normalized = value.trim().toLowerCase();
7572
+ return WINDOW_MODES.includes(normalized) ? normalized : null;
7573
+ }
7574
+ const WINDOW_SIZE_PATTERN = /^(\d+)\s*[x×]\s*(\d+)$/i;
7575
+ function parseWindowSize(value) {
7576
+ if (value === void 0) return null;
7577
+ const match = value.trim().match(WINDOW_SIZE_PATTERN);
7578
+ if (!match) return null;
7579
+ const width = Number(match[1]);
7580
+ const height = Number(match[2]);
7581
+ if (width <= 0 || height <= 0) return null;
7582
+ return { width, height };
7583
+ }
7584
+ function resolveWindowLaunchPlan(input = {}) {
7585
+ const env = input.env ?? {};
7586
+ const argv = input.argv ?? [];
7587
+ const problems = [];
7588
+ const modeFlag = readFlag(argv, WINDOW_MODE_FLAG);
7589
+ const modeRaw = modeFlag.found ? modeFlag.value : env[WINDOW_MODE_ENV];
7590
+ const parsedMode = parseWindowMode(modeRaw);
7591
+ if (modeFlag.found && modeFlag.value === void 0) {
7592
+ problems.push(
7593
+ `${WINDOW_MODE_FLAG} needs a value: ${WINDOW_MODES.join("|")}`
7594
+ );
7595
+ } else if (parsedMode === null && modeRaw !== void 0) {
7596
+ problems.push(
7597
+ `${modeFlag.found ? WINDOW_MODE_FLAG : WINDOW_MODE_ENV}="${modeRaw}" is not one of ${WINDOW_MODES.join("|")}; using normal`
7598
+ );
7599
+ }
7600
+ const mode = parsedMode ?? "normal";
7601
+ const sizeFlag = readFlag(argv, WINDOW_SIZE_FLAG);
7602
+ const sizeRaw = sizeFlag.found ? sizeFlag.value : env[WINDOW_SIZE_ENV];
7603
+ const parsedSize = parseWindowSize(sizeRaw);
7604
+ if (sizeFlag.found && sizeFlag.value === void 0) {
7605
+ problems.push(`${WINDOW_SIZE_FLAG} needs a value: <width>x<height>`);
7606
+ } else if (parsedSize === null && sizeRaw !== void 0) {
7607
+ problems.push(
7608
+ `${sizeFlag.found ? WINDOW_SIZE_FLAG : WINDOW_SIZE_ENV}="${sizeRaw}" is not <width>x<height>; using ${DEFAULT_WINDOW_WIDTH}x${DEFAULT_WINDOW_HEIGHT}`
7609
+ );
7610
+ }
7611
+ const requested = parsedSize ?? {
7612
+ width: DEFAULT_WINDOW_WIDTH,
7613
+ height: DEFAULT_WINDOW_HEIGHT
7614
+ };
7615
+ const width = Math.min(
7616
+ Math.max(requested.width, WINDOW_MIN_WIDTH),
7617
+ WINDOW_MAX_EDGE
7618
+ );
7619
+ const height = Math.min(
7620
+ Math.max(requested.height, WINDOW_MIN_HEIGHT),
7621
+ WINDOW_MAX_EDGE
7622
+ );
7623
+ if (width !== requested.width || height !== requested.height) {
7624
+ problems.push(
7625
+ `window size ${requested.width}x${requested.height} clamped to ${width}x${height} (floor ${WINDOW_MIN_WIDTH}x${WINDOW_MIN_HEIGHT}, ceiling ${WINDOW_MAX_EDGE})`
7626
+ );
7627
+ }
7628
+ return {
7629
+ mode,
7630
+ ...WINDOW_BEHAVIOUR[mode],
7631
+ width,
7632
+ height,
7633
+ problems
7634
+ };
7635
+ }
7636
+ function describeWindowLaunch(plan) {
7637
+ const behaviour = plan.show === "never" ? "window created and never shown" : plan.show === "inactive" ? "window shown without activating the app" : "window shown and focused";
7638
+ return `window mode ${plan.mode}: ${plan.width}x${plan.height}, ${behaviour}, page throttling ${plan.backgroundThrottling ? "on" : "off"}`;
7639
+ }
7525
7640
  const BASE64_FILE_EXTENSIONS = ["csv", "tsv", "xls", "xlsx", "ods"];
7526
7641
  electron.app.setName("Local Operator");
7527
7642
  const image = electron.nativeImage.createFromPath(icon);
@@ -7659,16 +7774,28 @@ function createApplicationMenu() {
7659
7774
  }
7660
7775
  function createWindow() {
7661
7776
  const mainWindow2 = new electron.BrowserWindow({
7662
- width: 1380,
7663
- height: 900,
7777
+ width: windowLaunch.width,
7778
+ height: windowLaunch.height,
7664
7779
  // The layout is verified down to 800x600 and not below: the app rail,
7665
7780
  // the per-route list pane and the canvas all have their own minimums,
7666
7781
  // and past this point they start taking room from each other rather
7667
7782
  // than from the window. The auth popup below sets its own floor the
7668
- // same way.
7669
- minWidth: 800,
7670
- minHeight: 600,
7783
+ // same way. The floor lives in `window-mode.ts` because that is what
7784
+ // clamps a requested `--window-size` to it.
7785
+ minWidth: WINDOW_MIN_WIDTH,
7786
+ minHeight: WINDOW_MIN_HEIGHT,
7671
7787
  show: false,
7788
+ /*
7789
+ * `headless` is never shown, so no key window can be made out of it; this
7790
+ * keeps that true of the window object itself rather than of the code
7791
+ * path. It is NOT a safety net against a stray `show()`: on macOS
7792
+ * `NativeWindowMac::Show()` activates the app for any non-panel window
7793
+ * whatever `focusable` says (measured — a non-focusable window, shown,
7794
+ * made the app frontmost while `isFocused()` still read false). The guard
7795
+ * against a window being raised is that `window-raise.ts` is the only
7796
+ * module that raises one, and this flag is the second line of defence.
7797
+ */
7798
+ focusable: windowLaunch.focusable,
7672
7799
  autoHideMenuBar: true,
7673
7800
  title: "Local Operator",
7674
7801
  icon,
@@ -7682,7 +7809,17 @@ function createWindow() {
7682
7809
  nodeIntegration: false,
7683
7810
  contextIsolation: true,
7684
7811
  webSecurity: true,
7685
- allowRunningInsecureContent: false
7812
+ allowRunningInsecureContent: false,
7813
+ // Chromium throttles timers and animation frames in a window it
7814
+ // believes nobody is looking at, which would make a headless test
7815
+ // run measure a different app than the one users get. Measured on
7816
+ // Electron 35 / macOS: a `show: false` window does keep
7817
+ // `visibilityState` at "visible" and keeps rAF ticking, but that
7818
+ // comes from Electron's MacWebContentsOcclusion default rather
7819
+ // than from anything this app controls, so hold the page at full
7820
+ // rate explicitly instead of trusting the platform. `normal`
7821
+ // keeps Electron's own default.
7822
+ backgroundThrottling: windowLaunch.backgroundThrottling
7686
7823
  }
7687
7824
  });
7688
7825
  mainWindow2.webContents.on("console-message", (_event, level, message) => {
@@ -7691,7 +7828,16 @@ function createWindow() {
7691
7828
  write.call(logger, `[renderer] ${message}`, LogFileType.BACKEND);
7692
7829
  });
7693
7830
  mainWindow2.on("ready-to-show", () => {
7694
- mainWindow2.show();
7831
+ presentWindow(mainWindow2, windowLaunch.show);
7832
+ if (windowLaunch.mode === "normal") return;
7833
+ setTimeout(() => {
7834
+ if (!mainWindow2 || mainWindow2.isDestroyed()) return;
7835
+ const [width, height] = mainWindow2.getSize();
7836
+ const [contentWidth, contentHeight] = mainWindow2.getContentSize();
7837
+ console.log(
7838
+ `[window-mode] state: visible=${mainWindow2.isVisible()} focused=${mainWindow2.isFocused()} focusable=${mainWindow2.isFocusable()} size=${width}x${height} content=${contentWidth}x${contentHeight}`
7839
+ );
7840
+ }, 1500);
7695
7841
  });
7696
7842
  mainWindow2.webContents.setWindowOpenHandler((details) => {
7697
7843
  const url = new URL(details.url);
@@ -7762,6 +7908,17 @@ function createWindow() {
7762
7908
  }
7763
7909
  const backendService = new BackendServiceManager();
7764
7910
  const backendInstaller = new BackendInstaller();
7911
+ const windowLaunch = resolveWindowLaunchPlan({
7912
+ env: process.env,
7913
+ argv: process.argv
7914
+ });
7915
+ for (const problem of windowLaunch.problems) {
7916
+ logger.warn(`[window-mode] ${problem}`, LogFileType.BACKEND);
7917
+ console.log(`[window-mode] ${problem}`);
7918
+ }
7919
+ if (windowLaunch.mode !== "normal") {
7920
+ console.log(`[window-mode] ${describeWindowLaunch(windowLaunch)}`);
7921
+ }
7765
7922
  let mainWindow = null;
7766
7923
  const zoomInFromEvent = () => {
7767
7924
  if (mainWindow) {
@@ -7789,8 +7946,7 @@ if (!gotTheLock) {
7789
7946
  } else {
7790
7947
  electron.app.on("second-instance", (_event, commandLine) => {
7791
7948
  if (mainWindow) {
7792
- if (mainWindow.isMinimized()) mainWindow.restore();
7793
- mainWindow.focus();
7949
+ raiseWindow(mainWindow, windowLaunch.show);
7794
7950
  }
7795
7951
  });
7796
7952
  }
@@ -7810,7 +7966,11 @@ electron.app.whenReady().then(async () => {
7810
7966
  () => mainWindow,
7811
7967
  (input) => backendService.requestDesktop(input)
7812
7968
  );
7813
- const desktopNotifier = new DesktopNotifier(() => mainWindow, sendDesktop);
7969
+ const desktopNotifier = new DesktopNotifier(
7970
+ () => mainWindow,
7971
+ sendDesktop,
7972
+ windowLaunch.show
7973
+ );
7814
7974
  backendService.onBackendReady(() => {
7815
7975
  void desktopNotifier.refreshNotificationContract();
7816
7976
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-operator-ui",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
4
4
  "productName": "Local Operator",
5
5
  "description": "User interface for AI agent assistants that run Python code safely on your device through an intuitive chat interface. Supports local and cloud LLM models with built-in security features.",
6
6
  "main": "./out/main/index.js",
@@ -50,7 +50,9 @@
50
50
  },
51
51
  "scripts": {
52
52
  "start": "electron-vite preview",
53
+ "app:headless": "LOCAL_OPERATOR_UI_WINDOW_MODE=headless electron .",
53
54
  "dev": "npx dotenv-cli -e .env -- bash -c 'export $(grep -v '^#' .env | xargs) && electron-vite dev'",
55
+ "dev:headless": "LOCAL_OPERATOR_UI_WINDOW_MODE=headless pnpm dev",
54
56
  "build": "electron-vite build",
55
57
  "prebuild:npm": "rm -rf out/main out/preload",
56
58
  "build:npm": "LOCAL_OPERATOR_UI_NO_BYTECODE=true electron-vite build",
@@ -65,7 +67,7 @@
65
67
  "check-types:main": "tsc --noEmit -p tsconfig.node.json",
66
68
  "check-types:renderer": "tsc --noEmit -p tsconfig.app.json",
67
69
  "check-types": "pnpm run check-types:main && pnpm run check-types:renderer",
68
- "test:desktop": "node --test scripts/linux-sandbox.test.mjs scripts/capture-evidence.test.mjs scripts/desktop-contract.test.mjs scripts/transcript-reducer.test.mjs scripts/transcript-paging.test.mjs scripts/provider-state.test.mjs scripts/stale-agent.test.mjs scripts/desktop-renderer-transport.test.mjs scripts/provider-grid-error-copy.test.mjs scripts/settings-time-to-error.test.mjs scripts/backend-error-surfaces.test.mjs scripts/canonical-chat.test.mjs scripts/run-detail-model.test.mjs scripts/chat-title.test.mjs scripts/tool-row.test.mjs scripts/attachment-url.test.mjs scripts/desktop-notifier.test.mjs scripts/python-bytecode-cache.test.mjs scripts/update-robustness.test.mjs scripts/usage-view.test.mjs scripts/usage-container.test.mjs scripts/session-status.test.mjs scripts/evidence-manifest.test.mjs",
70
+ "test:desktop": "node --test scripts/linux-sandbox.test.mjs scripts/capture-evidence.test.mjs scripts/desktop-contract.test.mjs scripts/transcript-reducer.test.mjs scripts/transcript-paging.test.mjs scripts/provider-state.test.mjs scripts/stale-agent.test.mjs scripts/desktop-renderer-transport.test.mjs scripts/provider-grid-error-copy.test.mjs scripts/settings-time-to-error.test.mjs scripts/backend-error-surfaces.test.mjs scripts/canonical-chat.test.mjs scripts/run-detail-model.test.mjs scripts/chat-title.test.mjs scripts/tool-row.test.mjs scripts/attachment-url.test.mjs scripts/desktop-notifier.test.mjs scripts/python-bytecode-cache.test.mjs scripts/update-robustness.test.mjs scripts/usage-view.test.mjs scripts/usage-container.test.mjs scripts/session-status.test.mjs scripts/window-mode.test.mjs scripts/evidence-manifest.test.mjs",
69
71
  "check-themes": "node scripts/generate-theme-css.mjs --check && node scripts/contrast-contract.mjs",
70
72
  "check-evidence": "node scripts/check-evidence.mjs",
71
73
  "check-edit-diffs": "node scripts/check-edit-diffs.mjs",