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.
- package/out/main/index.js +174 -14
- 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
|
-
|
|
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:
|
|
7663
|
-
height:
|
|
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
|
-
|
|
7670
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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",
|