@amd-gaia/agent-ui 0.25.0 → 0.25.1-rc.1

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 (60) hide show
  1. package/dist/assets/AdvancedSettings-D5VKpkPY.css +1 -0
  2. package/dist/assets/AdvancedSettings-a1nOM-rC.js +1 -0
  3. package/dist/assets/ConnectorsPane-DTThsQBD.js +1 -0
  4. package/dist/assets/ConnectorsPane-Dzu2DRTc.css +1 -0
  5. package/dist/assets/DocumentLibrary-BUpXL-MU.js +1 -0
  6. package/dist/assets/DocumentLibrary-Cj9MJ9yW.css +1 -0
  7. package/dist/assets/FileBrowser-CKiG031v.js +4 -0
  8. package/dist/assets/FileBrowser-D04I8Pvv.css +1 -0
  9. package/dist/assets/MemoryDashboard-BB849Xjw.js +1 -0
  10. package/dist/assets/MemoryDashboard-DgugVDad.css +1 -0
  11. package/dist/assets/MobileAccessModal-9bTRthf4.js +2 -0
  12. package/dist/assets/MobileAccessModal-DuVy9F1u.css +1 -0
  13. package/dist/assets/ScheduleManager-DUhiMn-U.js +2 -0
  14. package/dist/assets/ScheduleManager-GwVTsKQO.css +1 -0
  15. package/dist/assets/{jsx-runtime-DVeaULmT.js → apiBase-y00JgRBP.js} +1 -1
  16. package/dist/assets/eye-B6FejHNG.js +1 -0
  17. package/dist/assets/index-B3UtHCtQ.css +1 -0
  18. package/dist/assets/index-I_UyOSz2.js +71 -0
  19. package/dist/assets/inter-latin-C2S99t-D.woff2 +0 -0
  20. package/dist/assets/inter-latin-ext-CFHvXkgd.woff2 +0 -0
  21. package/dist/assets/message-square-Dwm7y3C0.js +1 -0
  22. package/dist/index.html +6 -6
  23. package/main-safety-net.cjs +54 -7
  24. package/main.cjs +217 -58
  25. package/package.json +3 -1
  26. package/preload.cjs +24 -0
  27. package/services/agent-process-manager.cjs +101 -13
  28. package/services/auto-updater.cjs +26 -2
  29. package/services/backend-installer-progress-dialog.cjs +9 -9
  30. package/services/keep-awake.cjs +52 -0
  31. package/services/notification-service.cjs +62 -0
  32. package/services/port-manager.cjs +14 -70
  33. package/services/process-tree.cjs +305 -0
  34. package/services/quit-cleanup.cjs +77 -0
  35. package/services/quit-guard.cjs +242 -0
  36. package/services/tray-icons.cjs +174 -0
  37. package/services/tray-manager.cjs +582 -83
  38. package/services/tray-state.cjs +231 -0
  39. package/services/window-keys.cjs +43 -0
  40. package/assets/tray-icon.ico +0 -0
  41. package/dist/assets/AdvancedSettings-5cX0pGZc.js +0 -1
  42. package/dist/assets/AdvancedSettings-Ce6EvcAe.css +0 -1
  43. package/dist/assets/ConnectorsPane-CG7atLb2.css +0 -1
  44. package/dist/assets/ConnectorsPane-CuWxzcBY.js +0 -1
  45. package/dist/assets/DocumentLibrary-BdAKDe8H.js +0 -1
  46. package/dist/assets/DocumentLibrary-DrIP_032.css +0 -1
  47. package/dist/assets/FileBrowser-B8s6hKC5.js +0 -4
  48. package/dist/assets/FileBrowser-Bj6aB6Ro.css +0 -1
  49. package/dist/assets/MemoryDashboard-DQxtAW90.css +0 -1
  50. package/dist/assets/MemoryDashboard-DtolA-HZ.js +0 -1
  51. package/dist/assets/MobileAccessModal-C3nWkpWA.js +0 -2
  52. package/dist/assets/MobileAccessModal-C_t8DG0n.css +0 -1
  53. package/dist/assets/ScheduleManager-CIescbX6.css +0 -1
  54. package/dist/assets/ScheduleManager-HHdD6sko.js +0 -2
  55. package/dist/assets/dm-sans-latin-Xz1IZZA0.woff2 +0 -0
  56. package/dist/assets/index-CFVpPQal.css +0 -1
  57. package/dist/assets/index-DEPbenxC.js +0 -60
  58. package/dist/assets/message-square-Ccmbk96j.js +0 -1
  59. package/dist/assets/space-mono-400-Co7bH5Hm.woff2 +0 -0
  60. package/dist/assets/useEscapeToClose-B8fw5mn-.js +0 -1
@@ -0,0 +1 @@
1
+ import{l as e}from"./apiBase-y00JgRBP.js";var t={name:`message-square`,size:24,node:[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]]};t.node;var n=e(t);export{n as t};
package/dist/index.html CHANGED
@@ -3,15 +3,15 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <!-- Graphite canvas; rewritten per theme by applyTheme(). Dark is the
7
- designed default, so an unset preference stays dark. -->
8
- <meta name="theme-color" content="#17161C" />
6
+ <!-- Canvas color; rewritten per theme and palette by applyTheme() and
7
+ syncTitleBar(). With no saved preference the app follows the OS. -->
8
+ <meta name="theme-color" content="#1A1917" />
9
9
  <link rel="icon" type="image/png" href="./favicon.png" />
10
10
  <title>GAIA Agent UI</title>
11
- <script type="module" crossorigin src="./assets/index-DEPbenxC.js"></script>
11
+ <script type="module" crossorigin src="./assets/index-I_UyOSz2.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-hePW80VL.js">
13
- <link rel="modulepreload" crossorigin href="./assets/jsx-runtime-DVeaULmT.js">
14
- <link rel="stylesheet" crossorigin href="./assets/index-CFVpPQal.css">
13
+ <link rel="modulepreload" crossorigin href="./assets/apiBase-y00JgRBP.js">
14
+ <link rel="stylesheet" crossorigin href="./assets/index-B3UtHCtQ.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -13,6 +13,11 @@
13
13
  * - process.on('unhandledRejection') catches rejected app.whenReady() chain.
14
14
  * - installLogTee() attaches stream.on('error') so stream errors are handled
15
15
  * before they can become uncaughtException (root-cause fix).
16
+ *
17
+ * A fatal error quits through app.quit(), not process.exit(): only the quit
18
+ * path (will-quit in main.cjs) stops the backend and the agents, so exiting
19
+ * directly left them running after the window was gone (#3498). A timer
20
+ * still forces the exit if that cleanup hangs.
16
21
  */
17
22
 
18
23
  "use strict";
@@ -60,6 +65,9 @@ function appendLog(logPath, msg) {
60
65
  }
61
66
  }
62
67
 
68
+ /** How long a fatal error's quit may take before the process is ended outright (ms). */
69
+ const FATAL_EXIT_GRACE_MS = 25000;
70
+
63
71
  // ── Core installer ────────────────────────────────────────────────────────────
64
72
 
65
73
  /**
@@ -70,24 +78,63 @@ function appendLog(logPath, msg) {
70
78
  * @param {object} opts.dialogModule - Electron dialog (injected for tests).
71
79
  * @param {object} opts.appModule - Electron app EventEmitter (injected).
72
80
  * @param {Function} [opts.homedirFn] - Override for os.homedir (tests).
81
+ * @param {Function} [opts.beforeQuit] - Called before the fatal quit, e.g.
82
+ * so the quit guard does not ask the user to confirm it.
83
+ * @param {number} [opts.exitGraceMs] - Override for FATAL_EXIT_GRACE_MS.
73
84
  */
74
- function installSafetyNet({ logPath, dialogModule, appModule, homedirFn }) {
85
+ function installSafetyNet({
86
+ logPath,
87
+ dialogModule,
88
+ appModule,
89
+ homedirFn,
90
+ beforeQuit,
91
+ exitGraceMs = FATAL_EXIT_GRACE_MS,
92
+ }) {
75
93
  const homedir = homedirFn || (() => os.homedir());
76
94
 
77
95
  // Per-handler re-entry guard (closure-scoped — each installSafetyNet
78
96
  // call gets its own, intentionally; see test_main_error_handling.js).
79
97
  let _inFatalHandler = false;
80
98
 
99
+ // Quit through will-quit so its cleanup stops the backend and agents; the
100
+ // timer ends the process if that cleanup never finishes.
101
+ function quitAfterFatal() {
102
+ const timer = setTimeout(() => {
103
+ appendLog(
104
+ logPath,
105
+ `[${new Date().toISOString()}] FATAL_EXIT cleanup did not finish within ${exitGraceMs} ms; exiting`
106
+ );
107
+ process.exit(1);
108
+ }, exitGraceMs);
109
+ if (typeof beforeQuit === "function") {
110
+ try {
111
+ beforeQuit();
112
+ } catch (hookErr) {
113
+ appendLog(logPath, `[${new Date().toISOString()}] FATAL_QUIT beforeQuit failed: ${hookErr.stack || hookErr}`);
114
+ }
115
+ }
116
+ try {
117
+ appModule.quit();
118
+ } catch (quitErr) {
119
+ clearTimeout(timer);
120
+ appendLog(logPath, `[${new Date().toISOString()}] FATAL_QUIT app.quit() failed, exiting now: ${quitErr.stack || quitErr}`);
121
+ process.exit(1);
122
+ }
123
+ }
124
+
81
125
  function fatal(err) {
126
+ const stack = (err && err.stack) ? err.stack : String(err);
127
+ const ts = new Date().toISOString();
128
+
129
+ // A second fatal while the first is still showing its dialog or quitting
130
+ // (an EPIPE from a child that died during cleanup, say) is logged only:
131
+ // exiting here would skip the cleanup that is already under way.
82
132
  if (_inFatalHandler) {
83
- try { process.exit(2); } catch { }
133
+ appendLog(logPath, `[${ts}] FATAL (while already quitting) ${stack}`);
84
134
  return;
85
135
  }
86
136
  _inFatalHandler = true;
87
137
 
88
- const stack = (err && err.stack) ? err.stack : String(err);
89
- const ts = new Date().toISOString();
90
-
91
138
  // Write to log BEFORE showing dialog so the entry survives even if
92
139
  // dialog.showErrorBox itself crashes.
93
140
  appendLog(logPath, `[${ts}] FATAL ${stack}`);
@@ -112,7 +159,7 @@ function installSafetyNet({ logPath, dialogModule, appModule, homedirFn }) {
112
159
  }
113
160
  } catch { } // intentional: fatal path, no upstream
114
161
 
115
- try { process.exit(1); } catch { } // intentional: fatal path
162
+ quitAfterFatal();
116
163
  }
117
164
 
118
165
  // Wire process-level handlers.
@@ -179,4 +226,4 @@ function installLogTee({ stream, logPath }) {
179
226
  });
180
227
  }
181
228
 
182
- module.exports = { installSafetyNet, installLogTee };
229
+ module.exports = { installSafetyNet, installLogTee, FATAL_EXIT_GRACE_MS };
package/main.cjs CHANGED
@@ -13,7 +13,16 @@
13
13
  // services/notification-service.js — Desktop notifications + permission prompts (T5)
14
14
  // preload.cjs — contextBridge for IPC channels (T0/T1)
15
15
 
16
- const { app, BrowserWindow, dialog, shell } = require("electron");
16
+ const {
17
+ app,
18
+ autoUpdater: electronAutoUpdater,
19
+ BrowserWindow,
20
+ Menu,
21
+ dialog,
22
+ ipcMain,
23
+ powerMonitor,
24
+ shell,
25
+ } = require("electron");
17
26
  const path = require("path");
18
27
  const fs = require("fs");
19
28
  const os = require("os");
@@ -37,12 +46,18 @@ const _MAIN_LOG_PATH = path.join(_GAIA_DIR, "electron-main.log");
37
46
  // very handler we are trying to install, falling through to Electron's bare
38
47
  // JS-error dialog.
39
48
  let installSafetyNet, installLogTee, _fatalHandler;
49
+ // Set when a fatal error quits the app: the crash dialog was the question,
50
+ // so the quit guard must not ask again.
51
+ let fatalQuitting = false;
40
52
  try {
41
53
  ({ installSafetyNet, installLogTee } = require("./main-safety-net.cjs"));
42
54
  ({ fatal: _fatalHandler } = installSafetyNet({
43
55
  logPath: _MAIN_LOG_PATH,
44
56
  dialogModule: dialog,
45
57
  appModule: app,
58
+ beforeQuit: () => {
59
+ fatalQuitting = true;
60
+ },
46
61
  }));
47
62
  } catch (err) {
48
63
  try { process.stderr.write(`[main] safety-net load failed: ${err.message}\n`); } catch { }
@@ -54,8 +69,17 @@ try {
54
69
 
55
70
  // Services (loaded after app.whenReady)
56
71
  const TrayManager = require("./services/tray-manager.cjs");
72
+ const {
73
+ attachWindowGuards,
74
+ confirmQuit,
75
+ fetchWorkInProgress,
76
+ installQuitGuard,
77
+ QUIT,
78
+ RESTART_FOR_UPDATE,
79
+ } = require("./services/quit-guard.cjs");
57
80
  const AgentProcessManager = require("./services/agent-process-manager.cjs");
58
81
  const NotificationService = require("./services/notification-service.cjs");
82
+ const KeepAwake = require("./services/keep-awake.cjs");
59
83
  const PortManager = require("./services/port-manager.cjs");
60
84
  const { isGaiaBackendProcess } = require("./services/backend-orphan.cjs");
61
85
  const { buildIndexQuery } = require("./services/index-query.cjs");
@@ -76,6 +100,8 @@ const {
76
100
  describeBlockedUrl,
77
101
  } = require("./services/link-policy.cjs");
78
102
  const { createStartupDeepLinkQueue } = require("./services/deep-link-queue.cjs");
103
+ const { installWindowKeys } = require("./services/window-keys.cjs");
104
+ const { installQuitCleanup } = require("./services/quit-cleanup.cjs");
79
105
 
80
106
  // ── F7: Ozone hint (issue #782) ─────────────────────────────────────────────
81
107
  // Electron-recommended switch for distro-agnostic Linux behaviour: picks
@@ -205,6 +231,11 @@ const windowConfig = appConfig.window || {
205
231
  // ── State ──────────────────────────────────────────────────────────────────
206
232
 
207
233
  let backendProcess = null;
234
+ // The in-flight startBackend(), so a quit during startup can wait for the
235
+ // spawn and stop it instead of leaving it behind.
236
+ let backendStarting = null;
237
+ // Set as cleanup() begins; startup stops at its next step once it is.
238
+ let shutdownStarted = false;
208
239
  let backendPort = DEFAULT_BACKEND_PORT;
209
240
  let healthCheckUrl = `http://localhost:${backendPort}/api/health`;
210
241
  let backendStderrTail = [];
@@ -225,6 +256,9 @@ let agentProcessManager = null;
225
256
  /** @type {NotificationService | null} */
226
257
  let notificationService = null;
227
258
 
259
+ /** @type {KeepAwake | null} */
260
+ let keepAwake = null;
261
+
228
262
  /** @type {PortManager} */
229
263
  const portManager = new PortManager({
230
264
  logger: { log: console.log.bind(console), error: console.error.bind(console) },
@@ -386,6 +420,7 @@ async function startBackend() {
386
420
  }
387
421
  const crashed = !isIntentionalKill && code !== 0 && code !== null;
388
422
  backendProcess = null;
423
+ if (trayManager && !isQuitting) trayManager.setBackendAlive(false);
389
424
 
390
425
  if (crashed && !isQuitting) {
391
426
  // Fire-and-forget — don't block the event loop.
@@ -523,8 +558,40 @@ function reportBlockedLink(url) {
523
558
  );
524
559
  }
525
560
 
561
+ // No system title bar: the app's own top row is the window's top edge, and
562
+ // the native window controls are drawn into it (traffic lights on macOS).
563
+ // Keep the height in sync with --titlebar-h in src/styles/index.css. The
564
+ // renderer sends its canvas and text colors once it paints; until then the
565
+ // controls use the dark canvas, like the window background.
566
+ const TITLE_BAR_HEIGHT = 48;
567
+ const TITLE_BAR_INITIAL = { color: "#1A1917", symbolColor: "#EFECE6" };
568
+ const IS_MAC = process.platform === "darwin";
569
+ const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
570
+
571
+ function titleBarOptions() {
572
+ return IS_MAC
573
+ ? { titleBarStyle: "hidden", trafficLightPosition: { x: 16, y: 16 } }
574
+ : {
575
+ titleBarStyle: "hidden",
576
+ titleBarOverlay: { ...TITLE_BAR_INITIAL, height: TITLE_BAR_HEIGHT },
577
+ };
578
+ }
579
+
580
+ // The renderer sends its canvas and text colors so the controls match the theme.
581
+ ipcMain.handle("window:set-titlebar-colors", (_event, colors) => {
582
+ const { color, symbolColor } = colors || {};
583
+ if (!HEX_COLOR.test(color) || !HEX_COLOR.test(symbolColor)) {
584
+ throw new Error(`Title bar colors must be #rrggbb, got ${JSON.stringify(colors)}`);
585
+ }
586
+ if (!IS_MAC && mainWindow && !mainWindow.isDestroyed()) {
587
+ mainWindow.setTitleBarOverlay({ color, symbolColor });
588
+ }
589
+ return true;
590
+ });
591
+
526
592
  function createWindow() {
527
593
  mainWindow = new BrowserWindow({
594
+ ...titleBarOptions(),
528
595
  width: windowConfig.width,
529
596
  height: windowConfig.height,
530
597
  minWidth: windowConfig.minWidth,
@@ -539,7 +606,12 @@ function createWindow() {
539
606
  },
540
607
  });
541
608
 
542
- // Remove default menu bar
609
+ // No File/Edit/View menu: hiding the bar alone still lets Alt bring it back.
610
+ // macOS keeps its menu, which carries copy, paste and quit.
611
+ if (process.platform !== "darwin") {
612
+ Menu.setApplicationMenu(null);
613
+ installWindowKeys(mainWindow.webContents, { devTools: !app.isPackaged });
614
+ }
543
615
  mainWindow.setMenuBarVisibility(false);
544
616
 
545
617
  // Open external links in the default browser. The URL here is already
@@ -567,18 +639,27 @@ function createWindow() {
567
639
  reportBlockedLink(url);
568
640
  });
569
641
 
570
- // ── Minimize-to-tray on close (C4 fix) ──────────────────────────────
571
- // Intercept window close — hide instead of closing when tray mode is active
572
- mainWindow.on("close", (event) => {
573
- if (!isQuitting && trayManager && trayManager.minimizeToTray) {
574
- event.preventDefault();
575
- mainWindow.hide();
576
- console.log("[main] Window hidden to tray");
577
- }
642
+ // ── Minimize-to-tray on close (C4 fix), first-close notice, quit guard ──
643
+ attachWindowGuards(mainWindow, {
644
+ app,
645
+ guard: quitGuard,
646
+ getTray: () => trayManager,
647
+ isQuitting: () => isQuitting,
648
+ platform: process.platform,
649
+ reportNoticeError: (err) => {
650
+ console.error("[main] First-close notice failed:", err.message);
651
+ const where = process.platform === "darwin" ? "menu bar" : "tray";
652
+ dialog.showErrorBox(
653
+ "GAIA is still running",
654
+ `GAIA is still running in the ${where}. Quit from the ${where} menu to stop it.\n\n` +
655
+ `(The usual notice failed: ${err.message})`
656
+ );
657
+ },
578
658
  });
579
659
 
580
660
  mainWindow.on("closed", () => {
581
661
  mainWindow = null;
662
+ if (trayManager) trayManager.windowClosed();
582
663
  });
583
664
 
584
665
  // Show window when ready (unless --minimized or startMinimized config)
@@ -631,9 +712,9 @@ async function loadApp() {
631
712
  * src/styles/index.css, so the dark theme's role values are
632
713
  * mirrored here. Keep them in step with that file. */
633
714
  :root {
634
- --bg-primary: #17161C;
635
- --text-primary: #F0EDE7;
636
- --text-secondary: #AAA5B0;
715
+ --bg-primary: #1A1917;
716
+ --text-primary: #EFECE6;
717
+ --text-secondary: #B3ADA5;
637
718
  }
638
719
  body {
639
720
  font-family: -apple-system, BlinkMacSystemFont, sans-serif;
@@ -664,6 +745,25 @@ async function loadApp() {
664
745
 
665
746
  // ── Services Setup ─────────────────────────────────────────────────────────
666
747
 
748
+ /**
749
+ * Recreate the window after it was closed (macOS keeps the app running).
750
+ * Services are re-pointed at it, not re-created: their IPC handlers stay.
751
+ * @returns {Electron.BrowserWindow}
752
+ */
753
+ function reopenWindow() {
754
+ createWindow();
755
+ // Asked for from the tray or the dock: show it even when starting minimized.
756
+ mainWindow.once("ready-to-show", () => mainWindow && mainWindow.show());
757
+ if (agentProcessManager) agentProcessManager.mainWindow = mainWindow;
758
+ if (trayManager) trayManager.mainWindow = mainWindow;
759
+ if (notificationService) notificationService.mainWindow = mainWindow;
760
+ loadApp().catch((err) => {
761
+ console.error("[main] Failed to load the reopened window:", err.message);
762
+ dialog.showErrorBox("GAIA", `GAIA couldn't reopen its window: ${err.message}`);
763
+ });
764
+ return mainWindow;
765
+ }
766
+
667
767
  function initializeServices() {
668
768
  console.log("[main] Initializing services...");
669
769
 
@@ -676,8 +776,16 @@ function initializeServices() {
676
776
  });
677
777
 
678
778
  // T1: Tray Manager (system tray icon + context menu)
679
- trayManager = new TrayManager(mainWindow, { backendPort });
779
+ trayManager = new TrayManager(mainWindow, {
780
+ backendPort,
781
+ reopenWindow: () => reopenWindow(),
782
+ });
680
783
  trayManager.create();
784
+ try {
785
+ trayManager.syncLoginItem();
786
+ } catch (err) {
787
+ console.error(`[main] Could not read the sign-in start setting: ${err.message}`);
788
+ }
681
789
 
682
790
  // T5: Notification Service (routes agent notifications to OS + renderer)
683
791
  notificationService = new NotificationService(
@@ -686,6 +794,9 @@ function initializeServices() {
686
794
  trayManager
687
795
  );
688
796
 
797
+ // #5226: hold off idle sleep while a chat turn runs
798
+ keepAwake = new KeepAwake();
799
+
689
800
  // #2007: System metrics for the observability dashboard
690
801
  systemMetrics.registerIpcHandlers();
691
802
 
@@ -1111,7 +1222,13 @@ app.whenReady().then(async () => {
1111
1222
  }
1112
1223
 
1113
1224
  // Start the Python backend
1114
- backendProcess = await startBackend();
1225
+ if (shutdownStarted) return;
1226
+ backendStarting = startBackend();
1227
+ backendProcess = await backendStarting;
1228
+ if (shutdownStarted) {
1229
+ console.log("[main] Quit during startup — not opening the window");
1230
+ return;
1231
+ }
1115
1232
 
1116
1233
  // Create the window (hidden until ready-to-show)
1117
1234
  createWindow();
@@ -1125,7 +1242,7 @@ app.whenReady().then(async () => {
1125
1242
  // Any failure here is logged and swallowed — the app continues to run
1126
1243
  // even if auto-update is unavailable.
1127
1244
  try {
1128
- autoUpdater.init(mainWindow);
1245
+ autoUpdater.init(mainWindow, { confirmRestart: confirmUpdateRestart });
1129
1246
  } catch (err) {
1130
1247
  console.error(
1131
1248
  "[main] Failed to init auto-updater:",
@@ -1169,18 +1286,12 @@ app.whenReady().then(async () => {
1169
1286
  }
1170
1287
  }
1171
1288
 
1172
- app.on("activate", async () => {
1289
+ // macOS and Linux: the shutdown is the OS's, so don't ask.
1290
+ powerMonitor.on("shutdown", () => quitGuard.allow());
1291
+
1292
+ app.on("activate", () => {
1173
1293
  if (BrowserWindow.getAllWindows().length === 0) {
1174
- createWindow();
1175
- // Re-wire existing services to the new window (don't re-create — IPC handlers are already registered)
1176
- if (agentProcessManager) agentProcessManager.mainWindow = mainWindow;
1177
- if (trayManager) trayManager.mainWindow = mainWindow;
1178
- if (notificationService) notificationService.mainWindow = mainWindow;
1179
- try {
1180
- await loadApp();
1181
- } catch (err) {
1182
- console.error("[main] Failed to load app on activate:", err.message);
1183
- }
1294
+ reopenWindow();
1184
1295
  } else if (mainWindow) {
1185
1296
  mainWindow.show();
1186
1297
  }
@@ -1215,33 +1326,57 @@ app.on("window-all-closed", () => {
1215
1326
  // Electron's before-quit does NOT await async handlers.
1216
1327
  // We use will-quit + event.preventDefault() to perform async cleanup, then re-quit.
1217
1328
 
1218
- let cleanupDone = false;
1219
-
1220
- app.on("before-quit", () => {
1221
- isQuitting = true;
1222
- // Mark any subsequent backend exit as intentional so we don't pop the
1223
- // crash dialog during normal shutdown.
1224
- isIntentionalKill = true;
1225
- });
1329
+ /** Only while the GAIA service runs: without it, no answer is running. */
1330
+ function workCanBeLost() {
1331
+ return Boolean(trayManager && backendProcess);
1332
+ }
1226
1333
 
1227
- app.on("will-quit", (event) => {
1228
- if (cleanupDone) return; // Cleanup already finished, let the app quit
1334
+ /** Ask about losing work in front of the window, even when it was hidden in the tray. */
1335
+ function askAboutRunningWork(action) {
1336
+ return confirmQuit({
1337
+ action,
1338
+ getWork: () => fetchWorkInProgress(backendPort),
1339
+ showMessageBox: (opts) => {
1340
+ if (!mainWindow || mainWindow.isDestroyed()) return dialog.showMessageBox(opts);
1341
+ if (!mainWindow.isVisible()) mainWindow.show();
1342
+ return dialog.showMessageBox(mainWindow, opts);
1343
+ },
1344
+ });
1345
+ }
1229
1346
 
1230
- event.preventDefault(); // Prevent quit until cleanup is done
1231
- console.log("[main] will-quit: performing async cleanup...");
1347
+ /** The update dialog's "Restart now": the installer starts at once, so ask first. */
1348
+ function confirmUpdateRestart() {
1349
+ return workCanBeLost() ? askAboutRunningWork(RESTART_FOR_UPDATE) : Promise.resolve(true);
1350
+ }
1232
1351
 
1233
- cleanup().then(() => {
1234
- cleanupDone = true;
1235
- console.log("[main] Cleanup complete, quitting...");
1236
- app.quit(); // Re-trigger quit — cleanupDone prevents infinite loop
1237
- }).catch((err) => {
1238
- console.error("[main] Cleanup error:", err.message);
1239
- cleanupDone = true;
1240
- app.quit();
1241
- });
1352
+ // Every quit asks first while an answer or index job would be lost; an
1353
+ // update restart (asked in confirmUpdateRestart) and an OS shutdown don't.
1354
+ const quitGuard = installQuitGuard({
1355
+ app,
1356
+ updater: electronAutoUpdater,
1357
+ shouldAsk: () => !fatalQuitting && workCanBeLost(),
1358
+ confirm: () => askAboutRunningWork(QUIT),
1359
+ onQuitting: () => {
1360
+ isQuitting = true;
1361
+ // Mark any subsequent backend exit as intentional so we don't pop the
1362
+ // crash dialog during normal shutdown.
1363
+ isIntentionalKill = true;
1364
+ },
1365
+ onError: (err) => {
1366
+ console.error("[main] Quit check failed:", err);
1367
+ dialog.showErrorBox(
1368
+ "GAIA",
1369
+ `GAIA couldn't ask whether to stop the running work: ${err.message}\n\n` +
1370
+ "Quit again within a minute to quit anyway; any answer still being written will be lost."
1371
+ );
1372
+ },
1242
1373
  });
1243
1374
 
1375
+ installQuitCleanup({ app, cleanup });
1376
+
1244
1377
  async function cleanup() {
1378
+ shutdownStarted = true;
1379
+
1245
1380
  // Phase F: tear down auto-updater timers and IPC handlers.
1246
1381
  try {
1247
1382
  autoUpdater.destroy();
@@ -1258,24 +1393,48 @@ async function cleanup() {
1258
1393
  notificationService = null;
1259
1394
  }
1260
1395
 
1261
- // Stop all managed OS agents gracefully
1262
- if (agentProcessManager) {
1263
- console.log("Stopping all managed agents...");
1264
- try {
1265
- await agentProcessManager.stopAll();
1266
- } catch (err) {
1267
- console.error("Error stopping agents:", err.message);
1268
- }
1269
- agentProcessManager = null;
1396
+ if (keepAwake) {
1397
+ keepAwake.destroy();
1398
+ keepAwake = null;
1270
1399
  }
1271
1400
 
1401
+ // The agents and the backend stop side by side: each waits out its own
1402
+ // grace period, and in series they could overrun the quit's time limit.
1403
+ await Promise.all([stopAgentsForQuit(), stopBackendForQuit()]);
1404
+
1272
1405
  // Destroy tray icon
1273
1406
  if (trayManager) {
1274
1407
  trayManager.destroy();
1275
1408
  trayManager = null;
1276
1409
  }
1410
+ }
1411
+
1412
+ async function stopAgentsForQuit() {
1413
+ if (!agentProcessManager) return;
1414
+ console.log("Stopping all managed agents...");
1415
+ try {
1416
+ await agentProcessManager.stopAll();
1417
+ } catch (err) {
1418
+ console.error("Error stopping agents:", err.message);
1419
+ }
1420
+ agentProcessManager = null;
1421
+ }
1422
+
1423
+ async function stopBackendForQuit() {
1424
+ // A quit during startup: wait for the spawn so it can be stopped below.
1425
+ if (backendStarting) {
1426
+ try {
1427
+ const started = await backendStarting;
1428
+ if (started && !backendProcess && started.exitCode === null && started.signalCode === null) {
1429
+ backendProcess = started;
1430
+ }
1431
+ } catch (err) {
1432
+ console.error("[main] Backend start failed during quit:", err.message);
1433
+ }
1434
+ }
1277
1435
 
1278
- // Stop the Python backend via the port-manager (SIGTERM → wait 3 s → SIGKILL).
1436
+ // Stop the Python backend and everything it started via the port-manager
1437
+ // (SIGTERM → wait 3 s → SIGKILL).
1279
1438
  // F5: previously we did this inline; extracted so main.cjs stays lean and
1280
1439
  // to prevent orphan leaks across runs (issue #782).
1281
1440
  if (backendProcess) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.25.0",
3
+ "version": "0.25.1-rc.1",
4
4
  "type": "module",
5
5
  "productName": "GAIA Agent UI",
6
6
  "description": "Chat with the GAIA agent - local and private by default on AMD Ryzen AI, cloud models when you choose them",
@@ -59,12 +59,14 @@
59
59
  "prepublishOnly": "npm run build",
60
60
  "test": "vitest run",
61
61
  "test:watch": "vitest",
62
+ "test:visual": "vite build && playwright test -c visual/playwright.config.ts",
62
63
  "gen:api-types": "node scripts/gen-api-types.mjs"
63
64
  },
64
65
  "dependencies": {
65
66
  "electron-updater": "^6.8.3"
66
67
  },
67
68
  "devDependencies": {
69
+ "@playwright/test": "1.63.0",
68
70
  "@testing-library/jest-dom": "^7.0.0",
69
71
  "@testing-library/react": "^16.1.0",
70
72
  "@testing-library/user-event": "^14.5.2",
package/preload.cjs CHANGED
@@ -14,6 +14,7 @@
14
14
  * install:* — First-run backend install progress (Phase A)
15
15
  * gaia:update:* — Auto-update status & manual check (Phase F)
16
16
  * system:* — System metrics for the observability dashboard (#2007)
17
+ * power:* — Keep the PC awake while a chat turn runs (#5226)
17
18
  */
18
19
 
19
20
  const { contextBridge, ipcRenderer } = require("electron");
@@ -53,6 +54,10 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
53
54
  tray: {
54
55
  getConfig: () => ipcRenderer.invoke("tray:get-config"),
55
56
  setConfig: (cfg) => ipcRenderer.invoke("tray:set-config", cfg),
57
+ // What GAIA is doing, for the tray icon, tooltip and menu.
58
+ setActivity: (activity) => ipcRenderer.invoke("tray:set-activity", activity),
59
+ // Tray menu actions: open-chat, new-chat, open-settings, refresh-status.
60
+ onCommand: (cb) => onEvent("tray:command", cb),
56
61
  onNavigate: (cb) => onEvent("tray:navigate", cb),
57
62
  },
58
63
 
@@ -63,6 +68,16 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
63
68
  respondPermission: (id, action, remember) =>
64
69
  ipcRenderer.invoke("notification:respond", id, action, remember),
65
70
  onNotification: (cb) => onEvent("notification:new", cb),
71
+ // Chat alerts (#5226)
72
+ show: (payload) => ipcRenderer.invoke("notification:show", payload),
73
+ flash: () => ipcRenderer.invoke("notification:flash"),
74
+ setAttentionCount: (count) =>
75
+ ipcRenderer.invoke("notification:attention", count),
76
+ },
77
+
78
+ // ── Keep the PC awake while GAIA works (#5226) ────────────────────────
79
+ power: {
80
+ setKeepAwake: (on) => ipcRenderer.invoke("power:keep-awake", on),
66
81
  },
67
82
 
68
83
  // ── System metrics for the observability dashboard (#2007) ────────────
@@ -71,6 +86,15 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
71
86
  },
72
87
  });
73
88
 
89
+ // ── Window frame ─────────────────────────────────────────────────────────
90
+ // The app draws its own top row; this says how the native controls sit in it.
91
+ contextBridge.exposeInMainWorld("gaiaWindow", {
92
+ /** "mac": traffic lights at the top left; "overlay": controls at the top right. */
93
+ titleBar: process.platform === "darwin" ? "mac" : "overlay",
94
+ /** Match the native window controls to the app: { color, symbolColor } as #rrggbb. */
95
+ setColors: (colors) => ipcRenderer.invoke("window:set-titlebar-colors", colors),
96
+ });
97
+
74
98
  // ── Install progress (Phase A) ──────────────────────────────────────────
75
99
  // Exposed as a separate global so the progress window can use it without
76
100
  // pulling in the full gaiaAPI surface (and so it keeps working if an