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

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 (68) hide show
  1. package/assets/tray-icon.png +0 -0
  2. package/assets/tray-icon@2x.png +0 -0
  3. package/dist/assets/AdvancedSettings-CtYKNNWc.js +1 -0
  4. package/dist/assets/AdvancedSettings-D5VKpkPY.css +1 -0
  5. package/dist/assets/ConnectorsPane-Ctfp9d5s.js +1 -0
  6. package/dist/assets/ConnectorsPane-Dzu2DRTc.css +1 -0
  7. package/dist/assets/DocumentLibrary-CIKp6qld.css +1 -0
  8. package/dist/assets/DocumentLibrary-Fp70O1-8.js +4 -0
  9. package/dist/assets/FileBrowser-3H3DoJIm.js +4 -0
  10. package/dist/assets/FileBrowser-D04I8Pvv.css +1 -0
  11. package/dist/assets/MemoryDashboard-D5HzmtDi.js +1 -0
  12. package/dist/assets/MemoryDashboard-Mo721-BI.css +1 -0
  13. package/dist/assets/MobileAccessModal-B9peVPY4.js +2 -0
  14. package/dist/assets/MobileAccessModal-DuVy9F1u.css +1 -0
  15. package/dist/assets/ScheduleManager-DotJl5JW.css +1 -0
  16. package/dist/assets/ScheduleManager-HcGB9P6m.js +2 -0
  17. package/dist/assets/{jsx-runtime-DVeaULmT.js → apiBase-y00JgRBP.js} +1 -1
  18. package/dist/assets/eye-B6FejHNG.js +1 -0
  19. package/dist/assets/fraunces-latin-BwwriW9Y.woff2 +0 -0
  20. package/dist/assets/index-D2UMLtwY.js +74 -0
  21. package/dist/assets/index-DF5qku9L.css +1 -0
  22. package/dist/assets/inter-latin-C2S99t-D.woff2 +0 -0
  23. package/dist/assets/inter-latin-ext-CFHvXkgd.woff2 +0 -0
  24. package/dist/assets/message-square-Dwm7y3C0.js +1 -0
  25. package/dist/fonts-OFL.txt +101 -0
  26. package/dist/index.html +6 -6
  27. package/main-safety-net.cjs +54 -7
  28. package/main.cjs +276 -120
  29. package/package.json +3 -1
  30. package/preload.cjs +24 -0
  31. package/services/agent-process-manager.cjs +116 -13
  32. package/services/app-icon.cjs +29 -0
  33. package/services/auto-updater.cjs +26 -2
  34. package/services/backend-crash-dialog.cjs +102 -0
  35. package/services/backend-installer-progress-dialog.cjs +71 -43
  36. package/services/backend-installer.cjs +233 -25
  37. package/services/chromium-switches.cjs +29 -0
  38. package/services/keep-awake.cjs +52 -0
  39. package/services/notification-service.cjs +62 -0
  40. package/services/port-manager.cjs +19 -70
  41. package/services/process-tree.cjs +328 -0
  42. package/services/quit-cleanup.cjs +86 -0
  43. package/services/quit-guard.cjs +242 -0
  44. package/services/tray-icons.cjs +174 -0
  45. package/services/tray-manager.cjs +582 -83
  46. package/services/tray-state.cjs +231 -0
  47. package/services/window-keys.cjs +43 -0
  48. package/assets/tray-icon.ico +0 -0
  49. package/dist/assets/AdvancedSettings-5cX0pGZc.js +0 -1
  50. package/dist/assets/AdvancedSettings-Ce6EvcAe.css +0 -1
  51. package/dist/assets/ConnectorsPane-CG7atLb2.css +0 -1
  52. package/dist/assets/ConnectorsPane-CuWxzcBY.js +0 -1
  53. package/dist/assets/DocumentLibrary-BdAKDe8H.js +0 -1
  54. package/dist/assets/DocumentLibrary-DrIP_032.css +0 -1
  55. package/dist/assets/FileBrowser-B8s6hKC5.js +0 -4
  56. package/dist/assets/FileBrowser-Bj6aB6Ro.css +0 -1
  57. package/dist/assets/MemoryDashboard-DQxtAW90.css +0 -1
  58. package/dist/assets/MemoryDashboard-DtolA-HZ.js +0 -1
  59. package/dist/assets/MobileAccessModal-C3nWkpWA.js +0 -2
  60. package/dist/assets/MobileAccessModal-C_t8DG0n.css +0 -1
  61. package/dist/assets/ScheduleManager-CIescbX6.css +0 -1
  62. package/dist/assets/ScheduleManager-HHdD6sko.js +0 -2
  63. package/dist/assets/dm-sans-latin-Xz1IZZA0.woff2 +0 -0
  64. package/dist/assets/index-CFVpPQal.css +0 -1
  65. package/dist/assets/index-DEPbenxC.js +0 -60
  66. package/dist/assets/message-square-Ccmbk96j.js +0 -1
  67. package/dist/assets/space-mono-400-Co7bH5Hm.woff2 +0 -0
  68. package/dist/assets/useEscapeToClose-B8fw5mn-.js +0 -1
package/main.cjs CHANGED
@@ -13,7 +13,17 @@
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
+ session,
25
+ shell,
26
+ } = require("electron");
17
27
  const path = require("path");
18
28
  const fs = require("fs");
19
29
  const os = require("os");
@@ -37,12 +47,18 @@ const _MAIN_LOG_PATH = path.join(_GAIA_DIR, "electron-main.log");
37
47
  // very handler we are trying to install, falling through to Electron's bare
38
48
  // JS-error dialog.
39
49
  let installSafetyNet, installLogTee, _fatalHandler;
50
+ // Set when a fatal error quits the app: the crash dialog was the question,
51
+ // so the quit guard must not ask again.
52
+ let fatalQuitting = false;
40
53
  try {
41
54
  ({ installSafetyNet, installLogTee } = require("./main-safety-net.cjs"));
42
55
  ({ fatal: _fatalHandler } = installSafetyNet({
43
56
  logPath: _MAIN_LOG_PATH,
44
57
  dialogModule: dialog,
45
58
  appModule: app,
59
+ beforeQuit: () => {
60
+ fatalQuitting = true;
61
+ },
46
62
  }));
47
63
  } catch (err) {
48
64
  try { process.stderr.write(`[main] safety-net load failed: ${err.message}\n`); } catch { }
@@ -54,10 +70,21 @@ try {
54
70
 
55
71
  // Services (loaded after app.whenReady)
56
72
  const TrayManager = require("./services/tray-manager.cjs");
73
+ const { appIconPath, appUserModelId } = require("./services/app-icon.cjs");
74
+ const {
75
+ attachWindowGuards,
76
+ confirmQuit,
77
+ fetchWorkInProgress,
78
+ installQuitGuard,
79
+ QUIT,
80
+ RESTART_FOR_UPDATE,
81
+ } = require("./services/quit-guard.cjs");
57
82
  const AgentProcessManager = require("./services/agent-process-manager.cjs");
58
83
  const NotificationService = require("./services/notification-service.cjs");
84
+ const KeepAwake = require("./services/keep-awake.cjs");
59
85
  const PortManager = require("./services/port-manager.cjs");
60
86
  const { isGaiaBackendProcess } = require("./services/backend-orphan.cjs");
87
+ const { showBackendCrashDialog } = require("./services/backend-crash-dialog.cjs");
61
88
  const { buildIndexQuery } = require("./services/index-query.cjs");
62
89
  const backendInstaller = require("./services/backend-installer.cjs");
63
90
  const installerProgressDialog = require("./services/backend-installer-progress-dialog.cjs");
@@ -76,27 +103,14 @@ const {
76
103
  describeBlockedUrl,
77
104
  } = require("./services/link-policy.cjs");
78
105
  const { createStartupDeepLinkQueue } = require("./services/deep-link-queue.cjs");
79
-
80
- // ── F7: Ozone hint (issue #782) ─────────────────────────────────────────────
81
- // Electron-recommended switch for distro-agnostic Linux behaviour: picks
82
- // Wayland on Wayland sessions, X11 elsewhere. Must be set before
83
- // app.whenReady() fires.
84
- app.commandLine.appendSwitch("ozone-platform-hint", "auto");
85
-
86
- // ── F7: --no-sandbox on Linux (issue #782) ───────────────────────────────────
87
- // chrome-sandbox is deleted from the packaged tree by after-pack.cjs so
88
- // Chromium must use its unprivileged user-namespace sandbox on every launch
89
- // path. The .desktop Exec= line already carries --no-sandbox via
90
- // electron-builder.yml linux.executableArgs, but direct `./GAIA.AppImage`
91
- // invocations bypass the .desktop entry. Appending the switch here makes
92
- // all Linux launch paths behave identically.
93
- if (process.platform === "linux") {
94
- app.commandLine.appendSwitch("no-sandbox");
95
-
96
- // Workaround: disable Chromium's Wayland color management which crashes on
97
- // some Wayland compositors that implement the wp_color_manager protocol
98
- // partially. See issue reports about wayland_wp_color_manager.cc SIGTRAP.
99
- app.commandLine.appendSwitch("disable-features", "WaylandColorManagement");
106
+ const { installWindowKeys } = require("./services/window-keys.cjs");
107
+ const { installQuitCleanup } = require("./services/quit-cleanup.cjs");
108
+ const { chromiumSwitches } = require("./services/chromium-switches.cjs");
109
+
110
+ // No ozone switch: Electron 38+ already picks Wayland or X11, and users override with --ozone-platform=x11.
111
+ for (const [name, value] of chromiumSwitches({ platform: process.platform, env: process.env })) {
112
+ if (value === undefined) app.commandLine.appendSwitch(name);
113
+ else app.commandLine.appendSwitch(name, value);
100
114
  }
101
115
 
102
116
  // ── F7: Log tee to ~/.gaia/electron-main.log (issue #782) ───────────────────
@@ -205,6 +219,11 @@ const windowConfig = appConfig.window || {
205
219
  // ── State ──────────────────────────────────────────────────────────────────
206
220
 
207
221
  let backendProcess = null;
222
+ // The in-flight startBackend(), so a quit during startup can wait for the
223
+ // spawn and stop it instead of leaving it behind.
224
+ let backendStarting = null;
225
+ // Set as cleanup() begins; startup stops at its next step once it is.
226
+ let shutdownStarted = false;
208
227
  let backendPort = DEFAULT_BACKEND_PORT;
209
228
  let healthCheckUrl = `http://localhost:${backendPort}/api/health`;
210
229
  let backendStderrTail = [];
@@ -225,6 +244,9 @@ let agentProcessManager = null;
225
244
  /** @type {NotificationService | null} */
226
245
  let notificationService = null;
227
246
 
247
+ /** @type {KeepAwake | null} */
248
+ let keepAwake = null;
249
+
228
250
  /** @type {PortManager} */
229
251
  const portManager = new PortManager({
230
252
  logger: { log: console.log.bind(console), error: console.error.bind(console) },
@@ -386,6 +408,7 @@ async function startBackend() {
386
408
  }
387
409
  const crashed = !isIntentionalKill && code !== 0 && code !== null;
388
410
  backendProcess = null;
411
+ if (trayManager && !isQuitting) trayManager.setBackendAlive(false);
389
412
 
390
413
  if (crashed && !isQuitting) {
391
414
  // Fire-and-forget — don't block the event loop.
@@ -411,46 +434,23 @@ async function startBackend() {
411
434
  }
412
435
 
413
436
  /**
414
- * Show a user-facing crash dialog with the last ~20 stderr lines and
415
- * offer to write a diagnostics bundle. Invoked from child.on("exit")
416
- * when the kill was NOT initiated by us.
437
+ * Tell the user the backend stopped and offer Restart / Save diagnostics /
438
+ * Quit. Invoked from child.on("exit") when the kill was NOT initiated by us.
417
439
  */
418
440
  async function handleBackendCrash(code, signal) {
419
- const tail = backendStderrTail.slice(-20).join("\n") || "(no stderr captured)";
420
- const detail =
421
- `The GAIA backend exited unexpectedly (code=${code}, signal=${signal}).\n\n` +
422
- `Recent log output:\n${tail}`;
423
-
424
441
  try {
425
- const choice = await dialog.showMessageBox({
426
- type: "error",
427
- title: "GAIA backend crashed",
428
- message: "GAIA backend crashed",
429
- detail,
430
- buttons: ["Copy diagnostics", "Quit"],
431
- defaultId: 0,
432
- cancelId: 1,
433
- noLink: true,
442
+ const choice = await showBackendCrashDialog({
443
+ dialog,
444
+ app,
445
+ code,
446
+ signal,
447
+ tailLines: backendStderrTail.slice(-8),
448
+ writeDiagnostics: () => portManager.writeDiagnosticsBundle(),
449
+ // The backend is gone, so there is no running work to ask about.
450
+ beforeQuit: () => { fatalQuitting = true; },
451
+ parent: mainWindow,
434
452
  });
435
-
436
- if (choice.response === 0) {
437
- try {
438
- const bundlePath = await portManager.writeDiagnosticsBundle();
439
- await dialog.showMessageBox({
440
- type: "info",
441
- title: "Diagnostics saved",
442
- message: "Diagnostics bundle written",
443
- detail: `Attach this file to your bug report:\n${bundlePath}`,
444
- buttons: ["OK"],
445
- });
446
- } catch (err) {
447
- console.error("[main] Could not write diagnostics bundle:", err.message);
448
- dialog.showErrorBox(
449
- "Could not write diagnostics",
450
- `Failed to write diagnostics bundle: ${err.message}`
451
- );
452
- }
453
- }
453
+ console.log(`[main] Backend crash: user chose ${choice}`);
454
454
  } catch (err) {
455
455
  console.error("[main] handleBackendCrash failed:", err.message);
456
456
  }
@@ -523,14 +523,57 @@ function reportBlockedLink(url) {
523
523
  );
524
524
  }
525
525
 
526
+ // No system title bar: the app's own top row is the window's top edge, and
527
+ // the native window controls are drawn into it (traffic lights on macOS).
528
+ // Keep the height in sync with --titlebar-h in src/styles/index.css. The
529
+ // renderer sends its canvas and text colors once it paints; until then the
530
+ // controls use the dark canvas, like the window background.
531
+ const TITLE_BAR_HEIGHT = 48;
532
+ const TITLE_BAR_INITIAL = { color: "#1A1917", symbolColor: "#EFECE6" };
533
+ const IS_MAC = process.platform === "darwin";
534
+ const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
535
+
536
+ function titleBarOptions() {
537
+ return IS_MAC
538
+ ? { titleBarStyle: "hidden", trafficLightPosition: { x: 16, y: 16 } }
539
+ : {
540
+ titleBarStyle: "hidden",
541
+ titleBarOverlay: { ...TITLE_BAR_INITIAL, height: TITLE_BAR_HEIGHT },
542
+ };
543
+ }
544
+
545
+ // The renderer sends its canvas and text colors so the controls match the theme.
546
+ ipcMain.handle("window:set-titlebar-colors", (_event, colors) => {
547
+ const { color, symbolColor } = colors || {};
548
+ if (!HEX_COLOR.test(color) || !HEX_COLOR.test(symbolColor)) {
549
+ throw new Error(`Title bar colors must be #rrggbb, got ${JSON.stringify(colors)}`);
550
+ }
551
+ if (!IS_MAC && mainWindow && !mainWindow.isDestroyed()) {
552
+ mainWindow.setTitleBarOverlay({ color, symbolColor });
553
+ }
554
+ return true;
555
+ });
556
+
557
+ // Registered once: createWindow runs again on every macOS reopen, and the session is shared.
558
+ function titleDownloadSaveDialogs() {
559
+ // An export is a blob: download, and Chromium titles its Save dialog with the blob URL.
560
+ session.defaultSession.on("will-download", (_event, item) => {
561
+ item.setSaveDialogOptions({
562
+ title: "Save from GAIA",
563
+ defaultPath: path.join(app.getPath("downloads"), item.getFilename()),
564
+ });
565
+ });
566
+ }
567
+
526
568
  function createWindow() {
527
569
  mainWindow = new BrowserWindow({
570
+ ...titleBarOptions(),
528
571
  width: windowConfig.width,
529
572
  height: windowConfig.height,
530
573
  minWidth: windowConfig.minWidth,
531
574
  minHeight: windowConfig.minHeight,
532
575
  title: APP_NAME,
533
- icon: path.join(__dirname, "assets", process.platform === "win32" ? "icon.ico" : "icon.png"),
576
+ icon: appIconPath(),
534
577
  show: false, // Don't show until ready (prevents flash)
535
578
  webPreferences: {
536
579
  nodeIntegration: false,
@@ -539,7 +582,12 @@ function createWindow() {
539
582
  },
540
583
  });
541
584
 
542
- // Remove default menu bar
585
+ // No File/Edit/View menu: hiding the bar alone still lets Alt bring it back.
586
+ // macOS keeps its menu, which carries copy, paste and quit.
587
+ if (process.platform !== "darwin") {
588
+ Menu.setApplicationMenu(null);
589
+ installWindowKeys(mainWindow.webContents, { devTools: !app.isPackaged });
590
+ }
543
591
  mainWindow.setMenuBarVisibility(false);
544
592
 
545
593
  // Open external links in the default browser. The URL here is already
@@ -567,18 +615,27 @@ function createWindow() {
567
615
  reportBlockedLink(url);
568
616
  });
569
617
 
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
- }
618
+ // ── Minimize-to-tray on close (C4 fix), first-close notice, quit guard ──
619
+ attachWindowGuards(mainWindow, {
620
+ app,
621
+ guard: quitGuard,
622
+ getTray: () => trayManager,
623
+ isQuitting: () => isQuitting,
624
+ platform: process.platform,
625
+ reportNoticeError: (err) => {
626
+ console.error("[main] First-close notice failed:", err.message);
627
+ const where = process.platform === "darwin" ? "menu bar" : "tray";
628
+ dialog.showErrorBox(
629
+ "GAIA is still running",
630
+ `GAIA is still running in the ${where}. Quit from the ${where} menu to stop it.\n\n` +
631
+ `(The usual notice failed: ${err.message})`
632
+ );
633
+ },
578
634
  });
579
635
 
580
636
  mainWindow.on("closed", () => {
581
637
  mainWindow = null;
638
+ if (trayManager) trayManager.windowClosed();
582
639
  });
583
640
 
584
641
  // Show window when ready (unless --minimized or startMinimized config)
@@ -631,9 +688,9 @@ async function loadApp() {
631
688
  * src/styles/index.css, so the dark theme's role values are
632
689
  * mirrored here. Keep them in step with that file. */
633
690
  :root {
634
- --bg-primary: #17161C;
635
- --text-primary: #F0EDE7;
636
- --text-secondary: #AAA5B0;
691
+ --bg-primary: #1A1917;
692
+ --text-primary: #EFECE6;
693
+ --text-secondary: #B3ADA5;
637
694
  }
638
695
  body {
639
696
  font-family: -apple-system, BlinkMacSystemFont, sans-serif;
@@ -664,6 +721,25 @@ async function loadApp() {
664
721
 
665
722
  // ── Services Setup ─────────────────────────────────────────────────────────
666
723
 
724
+ /**
725
+ * Recreate the window after it was closed (macOS keeps the app running).
726
+ * Services are re-pointed at it, not re-created: their IPC handlers stay.
727
+ * @returns {Electron.BrowserWindow}
728
+ */
729
+ function reopenWindow() {
730
+ createWindow();
731
+ // Asked for from the tray or the dock: show it even when starting minimized.
732
+ mainWindow.once("ready-to-show", () => mainWindow && mainWindow.show());
733
+ if (agentProcessManager) agentProcessManager.mainWindow = mainWindow;
734
+ if (trayManager) trayManager.mainWindow = mainWindow;
735
+ if (notificationService) notificationService.mainWindow = mainWindow;
736
+ loadApp().catch((err) => {
737
+ console.error("[main] Failed to load the reopened window:", err.message);
738
+ dialog.showErrorBox("GAIA", `GAIA couldn't reopen its window: ${err.message}`);
739
+ });
740
+ return mainWindow;
741
+ }
742
+
667
743
  function initializeServices() {
668
744
  console.log("[main] Initializing services...");
669
745
 
@@ -676,8 +752,16 @@ function initializeServices() {
676
752
  });
677
753
 
678
754
  // T1: Tray Manager (system tray icon + context menu)
679
- trayManager = new TrayManager(mainWindow, { backendPort });
755
+ trayManager = new TrayManager(mainWindow, {
756
+ backendPort,
757
+ reopenWindow: () => reopenWindow(),
758
+ });
680
759
  trayManager.create();
760
+ try {
761
+ trayManager.syncLoginItem();
762
+ } catch (err) {
763
+ console.error(`[main] Could not read the sign-in start setting: ${err.message}`);
764
+ }
681
765
 
682
766
  // T5: Notification Service (routes agent notifications to OS + renderer)
683
767
  notificationService = new NotificationService(
@@ -686,6 +770,9 @@ function initializeServices() {
686
770
  trayManager
687
771
  );
688
772
 
773
+ // #5226: hold off idle sleep while a chat turn runs
774
+ keepAwake = new KeepAwake();
775
+
689
776
  // #2007: System metrics for the observability dashboard
690
777
  systemMetrics.registerIpcHandlers();
691
778
 
@@ -732,8 +819,19 @@ function setupJumpList() {
732
819
  // ── Backend Bootstrap (Phase A) ───────────────────────────────────────────
733
820
 
734
821
  /** First install found a Lemonade already running: use it, or install GAIA's own? */
735
- async function chooseLemonadeInDialog(found) {
736
- const { response } = await dialog.showMessageBox({
822
+ async function chooseLemonadeInDialog(found, parent) {
823
+ // Modal to the progress window and in front: otherwise it can open behind
824
+ // other windows while the install sits at 70% waiting for it.
825
+ const owner = parent && !parent.isDestroyed() ? parent : null;
826
+ if (owner) {
827
+ if (owner.isMinimized()) owner.restore();
828
+ owner.show();
829
+ owner.moveTop();
830
+ owner.focus();
831
+ // Windows can refuse focus to a background app; the taskbar flash is the hint.
832
+ if (!owner.isFocused()) owner.flashFrame(true);
833
+ }
834
+ const box = {
737
835
  type: "question",
738
836
  title: "Lemonade Server found",
739
837
  message: `A Lemonade Server (v${found.version}) is already running at ${found.url}. Use it instead of installing GAIA's own?`,
@@ -742,7 +840,11 @@ async function chooseLemonadeInDialog(found) {
742
840
  defaultId: 0,
743
841
  cancelId: 1,
744
842
  noLink: true,
745
- });
843
+ };
844
+ const { response } = owner
845
+ ? await dialog.showMessageBox(owner, box)
846
+ : await dialog.showMessageBox(box);
847
+ if (owner && !owner.isDestroyed()) owner.flashFrame(false);
746
848
  return response === 0 ? "external" : "gaia";
747
849
  }
748
850
 
@@ -793,7 +895,7 @@ async function bootstrapBackend() {
793
895
  await backendInstaller.ensureBackend({
794
896
  onProgress: progress.onProgress,
795
897
  isPackaged: app.isPackaged,
796
- chooseLemonade: chooseLemonadeInDialog,
898
+ chooseLemonade: (found) => chooseLemonadeInDialog(found, progress.window),
797
899
  });
798
900
  progress.close();
799
901
  console.log("[main] Backend bootstrap complete");
@@ -1054,6 +1156,11 @@ if (!gotTheSingleInstanceLock) {
1054
1156
  process.exit(0);
1055
1157
  }
1056
1158
 
1159
+ // Before any window: the taskbar reads the ID when a window first appears.
1160
+ if (process.platform === "win32") {
1161
+ app.setAppUserModelId(appUserModelId(app.isPackaged));
1162
+ }
1163
+
1057
1164
  app.on("second-instance", (_event, argv, _cwd) => {
1058
1165
  // A second launch happened while we were running. Surface our window
1059
1166
  // (the user almost certainly wanted to see it). mainWindow may be null
@@ -1111,9 +1218,16 @@ app.whenReady().then(async () => {
1111
1218
  }
1112
1219
 
1113
1220
  // Start the Python backend
1114
- backendProcess = await startBackend();
1221
+ if (shutdownStarted) return;
1222
+ backendStarting = startBackend();
1223
+ backendProcess = await backendStarting;
1224
+ if (shutdownStarted) {
1225
+ console.log("[main] Quit during startup — not opening the window");
1226
+ return;
1227
+ }
1115
1228
 
1116
1229
  // Create the window (hidden until ready-to-show)
1230
+ titleDownloadSaveDialogs();
1117
1231
  createWindow();
1118
1232
  isBootstrapping = false; // progress dialog is gone; window-all-closed may now quit
1119
1233
 
@@ -1125,7 +1239,7 @@ app.whenReady().then(async () => {
1125
1239
  // Any failure here is logged and swallowed — the app continues to run
1126
1240
  // even if auto-update is unavailable.
1127
1241
  try {
1128
- autoUpdater.init(mainWindow);
1242
+ autoUpdater.init(mainWindow, { confirmRestart: confirmUpdateRestart });
1129
1243
  } catch (err) {
1130
1244
  console.error(
1131
1245
  "[main] Failed to init auto-updater:",
@@ -1169,18 +1283,12 @@ app.whenReady().then(async () => {
1169
1283
  }
1170
1284
  }
1171
1285
 
1172
- app.on("activate", async () => {
1286
+ // macOS and Linux: the shutdown is the OS's, so don't ask.
1287
+ powerMonitor.on("shutdown", () => quitGuard.allow());
1288
+
1289
+ app.on("activate", () => {
1173
1290
  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
- }
1291
+ reopenWindow();
1184
1292
  } else if (mainWindow) {
1185
1293
  mainWindow.show();
1186
1294
  }
@@ -1215,33 +1323,57 @@ app.on("window-all-closed", () => {
1215
1323
  // Electron's before-quit does NOT await async handlers.
1216
1324
  // We use will-quit + event.preventDefault() to perform async cleanup, then re-quit.
1217
1325
 
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
- });
1326
+ /** Only while the GAIA service runs: without it, no answer is running. */
1327
+ function workCanBeLost() {
1328
+ return Boolean(trayManager && backendProcess);
1329
+ }
1226
1330
 
1227
- app.on("will-quit", (event) => {
1228
- if (cleanupDone) return; // Cleanup already finished, let the app quit
1331
+ /** Ask about losing work in front of the window, even when it was hidden in the tray. */
1332
+ function askAboutRunningWork(action) {
1333
+ return confirmQuit({
1334
+ action,
1335
+ getWork: () => fetchWorkInProgress(backendPort),
1336
+ showMessageBox: (opts) => {
1337
+ if (!mainWindow || mainWindow.isDestroyed()) return dialog.showMessageBox(opts);
1338
+ if (!mainWindow.isVisible()) mainWindow.show();
1339
+ return dialog.showMessageBox(mainWindow, opts);
1340
+ },
1341
+ });
1342
+ }
1229
1343
 
1230
- event.preventDefault(); // Prevent quit until cleanup is done
1231
- console.log("[main] will-quit: performing async cleanup...");
1344
+ /** The update dialog's "Restart now": the installer starts at once, so ask first. */
1345
+ function confirmUpdateRestart() {
1346
+ return workCanBeLost() ? askAboutRunningWork(RESTART_FOR_UPDATE) : Promise.resolve(true);
1347
+ }
1232
1348
 
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
- });
1349
+ // Every quit asks first while an answer or index job would be lost; an
1350
+ // update restart (asked in confirmUpdateRestart) and an OS shutdown don't.
1351
+ const quitGuard = installQuitGuard({
1352
+ app,
1353
+ updater: electronAutoUpdater,
1354
+ shouldAsk: () => !fatalQuitting && workCanBeLost(),
1355
+ confirm: () => askAboutRunningWork(QUIT),
1356
+ onQuitting: () => {
1357
+ isQuitting = true;
1358
+ // Mark any subsequent backend exit as intentional so we don't pop the
1359
+ // crash dialog during normal shutdown.
1360
+ isIntentionalKill = true;
1361
+ },
1362
+ onError: (err) => {
1363
+ console.error("[main] Quit check failed:", err);
1364
+ dialog.showErrorBox(
1365
+ "GAIA",
1366
+ `GAIA couldn't ask whether to stop the running work: ${err.message}\n\n` +
1367
+ "Quit again within a minute to quit anyway; any answer still being written will be lost."
1368
+ );
1369
+ },
1242
1370
  });
1243
1371
 
1372
+ installQuitCleanup({ app, cleanup });
1373
+
1244
1374
  async function cleanup() {
1375
+ shutdownStarted = true;
1376
+
1245
1377
  // Phase F: tear down auto-updater timers and IPC handlers.
1246
1378
  try {
1247
1379
  autoUpdater.destroy();
@@ -1258,24 +1390,48 @@ async function cleanup() {
1258
1390
  notificationService = null;
1259
1391
  }
1260
1392
 
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;
1393
+ if (keepAwake) {
1394
+ keepAwake.destroy();
1395
+ keepAwake = null;
1270
1396
  }
1271
1397
 
1398
+ // The agents and the backend stop side by side: each waits out its own
1399
+ // grace period, and in series they could overrun the quit's time limit.
1400
+ await Promise.all([stopAgentsForQuit(), stopBackendForQuit()]);
1401
+
1272
1402
  // Destroy tray icon
1273
1403
  if (trayManager) {
1274
1404
  trayManager.destroy();
1275
1405
  trayManager = null;
1276
1406
  }
1407
+ }
1408
+
1409
+ async function stopAgentsForQuit() {
1410
+ if (!agentProcessManager) return;
1411
+ console.log("Stopping all managed agents...");
1412
+ try {
1413
+ await agentProcessManager.stopAll();
1414
+ } catch (err) {
1415
+ console.error("Error stopping agents:", err.message);
1416
+ }
1417
+ agentProcessManager = null;
1418
+ }
1419
+
1420
+ async function stopBackendForQuit() {
1421
+ // A quit during startup: wait for the spawn so it can be stopped below.
1422
+ if (backendStarting) {
1423
+ try {
1424
+ const started = await backendStarting;
1425
+ if (started && !backendProcess && started.exitCode === null && started.signalCode === null) {
1426
+ backendProcess = started;
1427
+ }
1428
+ } catch (err) {
1429
+ console.error("[main] Backend start failed during quit:", err.message);
1430
+ }
1431
+ }
1277
1432
 
1278
- // Stop the Python backend via the port-manager (SIGTERM → wait 3 s → SIGKILL).
1433
+ // Stop the Python backend and everything it started via the port-manager
1434
+ // (SIGTERM → wait 3 s → SIGKILL).
1279
1435
  // F5: previously we did this inline; extracted so main.cjs stays lean and
1280
1436
  // to prevent orphan leaks across runs (issue #782).
1281
1437
  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.10",
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