@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
@@ -4,26 +4,48 @@
4
4
  /**
5
5
  * GAIA Agent UI — Tray Manager (T1)
6
6
  *
7
- * Manages the Electron system tray icon, context menu, and minimize-to-tray
8
- * behaviour. Co-located alongside main.cjs per the T0 co-location decision.
7
+ * The system tray icon: what GAIA is doing right now, what needs the user,
8
+ * and the menu to act on it. Co-located alongside main.cjs per the T0
9
+ * co-location decision.
9
10
  *
10
- * Responsibilities:
11
- * - Create Tray instance with GAIA icon on app startup
12
- * - Build context menu (Show Window, Open in Browser, Quit)
13
- * - Handle "minimize to tray" on window close (configurable)
14
- * - Handle "show window" on tray click / double-click
15
- * - Expose IPC handlers for renderer to query/update tray config
11
+ * - The icon and tooltip follow the state the window reports over
12
+ * `tray:set-activity` (idle, working, waiting for the model, needs you,
13
+ * error); see tray-state.cjs and tray-icons.cjs.
14
+ * - The menu: status line, Needs you, New chat, recent chats, Free memory,
15
+ * Settings, Quit. Menu actions reach the window over `tray:command`.
16
+ * - Windows gets a taskbar overlay for needs-you and unread.
17
+ * - Tray settings (close to tray, start at login, start minimized) are read
18
+ * and saved over `tray:get-config` / `tray:set-config`.
16
19
  */
17
20
 
18
- const { Tray, Menu, nativeImage, ipcMain, app, shell } = require("electron");
21
+ const { Tray, Menu, nativeImage, ipcMain, app, dialog } = require("electron");
19
22
  const path = require("path");
20
23
  const fs = require("fs");
21
24
  const os = require("os");
25
+ const { drawStateIcon, drawOverlayIcon, STATES } = require("./tray-icons.cjs");
26
+ const { validateActivity, deriveTrayView, buildMenuTemplate } = require("./tray-state.cjs");
27
+ const { backendJson, fetchWorkInProgress } = require("./quit-guard.cjs");
28
+
29
+ /** Why the model can't be freed right now, from the backend's work list; null when it can. */
30
+ function describeBusy(work) {
31
+ const answers = work.answers.length;
32
+ const docs = work.indexing.length;
33
+ if (answers === 0 && docs === 0) return null;
34
+ const parts = [];
35
+ if (answers > 0) parts.push(answers === 1 ? "An answer is being written." : `${answers} answers are being written.`);
36
+ if (docs > 0) parts.push(docs === 1 ? "A document is being indexed." : `${docs} documents are being indexed.`);
37
+ return parts.join(" ");
38
+ }
22
39
 
23
40
  // ── Constants ────────────────────────────────────────────────────────────
24
41
 
25
42
  const GAIA_DIR = path.join(os.homedir(), ".gaia");
26
43
  const CONFIG_PATH = path.join(GAIA_DIR, "tray-config.json");
44
+ const LINUX_AUTOSTART_FILE = "gaia-agent-ui.desktop";
45
+ // The Run value the Windows installer writes (installer/nsis/installer.nsh,
46
+ // ${PRODUCT_NAME}); the switch owns that same entry, command line included.
47
+ const WIN_LOGIN_ITEM_NAME = "GAIA";
48
+ const WIN_LOGIN_ITEM_ARGS = ["--minimized"];
27
49
 
28
50
  // ── Default config ───────────────────────────────────────────────────────
29
51
 
@@ -34,8 +56,52 @@ const DEFAULT_CONFIG = {
34
56
  startOnLogin: false,
35
57
  },
36
58
  agents: {},
59
+ notices: {
60
+ firstClose: false,
61
+ },
62
+ // Whether the user has set the sign-in switch; until then the OS decides it.
63
+ choices: {
64
+ startOnLogin: false,
65
+ },
37
66
  };
38
67
 
68
+ /**
69
+ * Tray icon bitmaps per platform: the size the tray draws at (DIP) and each
70
+ * scale's source asset. Windows trays are 16 DIP; Linux StatusNotifier hosts
71
+ * scale a 32px icon down, which stays sharp where upscaling blurs; macOS
72
+ * menu-bar extras are template images the OS tints for light and dark.
73
+ */
74
+ const ICON_SPECS = {
75
+ win32: {
76
+ template: false,
77
+ reps: [
78
+ { scale: 1, px: 16, file: "tray-icon.png" },
79
+ { scale: 1.25, px: 20, file: "tray-icon.png" },
80
+ { scale: 1.5, px: 24, file: "tray-icon.png" },
81
+ { scale: 2, px: 32, file: "tray-icon.png" },
82
+ ],
83
+ },
84
+ darwin: {
85
+ template: true,
86
+ reps: [
87
+ { scale: 1, px: 16, file: "tray-iconTemplate.png" },
88
+ { scale: 2, px: 32, file: "tray-iconTemplate@2x.png" },
89
+ ],
90
+ },
91
+ linux: {
92
+ template: false,
93
+ reps: [
94
+ { scale: 1, px: 32, file: "tray-icon.png" },
95
+ { scale: 2, px: 64, file: "tray-icon@2x.png" },
96
+ ],
97
+ },
98
+ };
99
+
100
+ const OVERLAY_REPS = [
101
+ { scale: 1, px: 16 },
102
+ { scale: 2, px: 32 },
103
+ ];
104
+
39
105
  const AGENT_LOG_LEVELS = ["debug", "info", "warn", "error"];
40
106
 
41
107
  function isPlainObject(value) {
@@ -74,13 +140,24 @@ function validateAgents(agents, where) {
74
140
  return out;
75
141
  }
76
142
 
143
+ /**
144
+ * A path as one quoted Exec= argument: the Desktop Entry spec escapes " ` $ \
145
+ * inside the quotes, then escapes every backslash again as a string value.
146
+ */
147
+ function desktopExecArg(file) {
148
+ // "%" starts a field code (%f, %u…) anywhere in Exec=, so it is doubled.
149
+ const quoted = `"${file.replace(/%/g, "%%").replace(/["`$\\]/g, (c) => `\\${c}`)}"`;
150
+ return quoted.replace(/\\/g, "\\\\");
151
+ }
152
+
77
153
  // ── TrayManager ──────────────────────────────────────────────────────────
78
154
 
79
155
  class TrayManager {
80
156
  /**
81
157
  * @param {Electron.BrowserWindow} mainWindow
82
158
  * @param {object} [options]
83
- * @param {number} [options.backendPort=4200] - Backend port for "Open in Browser"
159
+ * @param {number} [options.backendPort=4200] - The GAIA service's port, for Free memory
160
+ * @param {() => Electron.BrowserWindow} options.reopenWindow - Recreate a closed window (macOS keeps the app running without one)
84
161
  */
85
162
  constructor(mainWindow, options = {}) {
86
163
  /** @type {Electron.BrowserWindow} */
@@ -89,17 +166,45 @@ class TrayManager {
89
166
  /** @type {number} */
90
167
  this._backendPort = options.backendPort || 4200;
91
168
 
169
+ if (typeof options.reopenWindow !== "function") {
170
+ throw new TypeError("TrayManager needs options.reopenWindow to bring back a closed window");
171
+ }
172
+ this._reopenWindow = options.reopenWindow;
173
+
174
+ /** Menu commands waiting for a reopened window to start listening. */
175
+ this._pendingCommands = [];
176
+
92
177
  /** @type {Electron.Tray | null} */
93
178
  this.tray = null;
94
179
 
95
180
  /** @type {number} Unread notifications reflected in the tooltip/badge. */
96
181
  this._notificationCount = 0;
182
+ /** Chats waiting on the user or finished unread, per the renderer's alert settings. */
183
+ this._attentionCount = 0;
184
+
185
+ /** Last snapshot from the window; null until it first reports. */
186
+ this._activity = null;
187
+
188
+ /** False once the GAIA service process has exited. */
189
+ this._backendAlive = true;
190
+
191
+ this._freeingMemory = false;
192
+
193
+ /** @type {ReturnType<typeof deriveTrayView> | null} */
194
+ this._view = null;
195
+ this._menuKey = null;
196
+ this._overlayKey = null;
97
197
 
98
198
  /** @type {object} */
99
199
  this.config = this._loadConfig();
100
200
 
201
+ this._iconSpec = ICON_SPECS[process.platform] || ICON_SPECS.linux;
202
+
203
+ /** @type {Map<string, Electron.NativeImage>} One image per tray state, built on first use. */
204
+ this._icons = new Map();
205
+
101
206
  /** @type {Electron.NativeImage} */
102
- this._icon = this._loadTrayIcon();
207
+ this._icon = this._iconFor("idle");
103
208
 
104
209
  this._registerIpcHandlers();
105
210
  }
@@ -111,16 +216,13 @@ class TrayManager {
111
216
  if (this.tray) return;
112
217
 
113
218
  this.tray = new Tray(this._icon);
114
- // Re-apply any count that arrived before the tray existed.
115
- this.setNotificationCount(this._notificationCount);
116
219
 
117
- // Single-click: show/focus window
220
+ // Windows: a click shows the window. macOS and most Linux trays open the
221
+ // menu on click instead, where "Show GAIA" does the same.
118
222
  this.tray.on("click", () => this._showWindow());
119
-
120
- // Double-click (Windows): show/focus window
121
223
  this.tray.on("double-click", () => this._showWindow());
122
224
 
123
- this._rebuildContextMenu();
225
+ this._render();
124
226
  console.log("[tray] System tray icon created");
125
227
  }
126
228
 
@@ -133,14 +235,16 @@ class TrayManager {
133
235
  console.log("[tray] System tray icon destroyed");
134
236
  }
135
237
 
136
- /** Update the context menu. */
238
+ /** Rebuild the icon, tooltip and menu from the current state. */
137
239
  refresh() {
138
- this._rebuildContextMenu();
240
+ this._menuKey = null;
241
+ this._overlayKey = null;
242
+ this._render();
139
243
  }
140
244
 
141
245
  /**
142
- * Reflect the unread-notification count in the tray tooltip (and the dock /
143
- * launcher badge where the platform has one).
246
+ * Reflect the unread-notification count in the tray tooltip, the dock /
247
+ * launcher badge, and the Windows taskbar overlay.
144
248
  *
145
249
  * @param {number} count Unread notifications; negative/NaN is treated as 0.
146
250
  */
@@ -153,23 +257,72 @@ class TrayManager {
153
257
  console.log(`[tray] Unread notifications: ${unread}`);
154
258
  }
155
259
  this._notificationCount = unread;
260
+ this._renderBadge();
261
+ }
156
262
 
157
- const trayAlive =
158
- this.tray &&
159
- !(typeof this.tray.isDestroyed === "function" && this.tray.isDestroyed());
160
- if (trayAlive) {
161
- this.tray.setToolTip(
162
- unread > 0
163
- ? `GAIA — ${unread} unread notification${unread === 1 ? "" : "s"}`
164
- : "GAIA"
165
- );
263
+ /**
264
+ * Reflect how many chats need the user (#5226): waiting on a permission or
265
+ * a question, or finished and unread. Shares the badge with notifications.
266
+ *
267
+ * @param {number} count Chats needing attention; must be a non-negative integer.
268
+ */
269
+ setAttentionCount(count) {
270
+ if (!Number.isInteger(count) || count < 0) {
271
+ throw new TypeError(`setAttentionCount expects a non-negative integer, got ${count}`);
166
272
  }
273
+ this._attentionCount = count;
274
+ this._renderBadge();
275
+ }
167
276
 
277
+ _renderBadge() {
278
+ const unread = this._notificationCount;
279
+ const chats = this._attentionCount;
168
280
  // Dock (macOS) / Unity launcher (Linux) badge. Windows has no
169
- // app.setBadgeCount equivalent — the tooltip above is the badge there.
281
+ // app.setBadgeCount equivalent; the taskbar overlay stands in for it.
170
282
  if (process.platform !== "win32" && typeof app.setBadgeCount === "function") {
171
- app.setBadgeCount(unread);
283
+ app.setBadgeCount(unread + chats);
172
284
  }
285
+ this._render();
286
+ }
287
+
288
+ /**
289
+ * Take a snapshot of what GAIA is doing from the window.
290
+ * @throws {TypeError} When the snapshot is malformed; nothing changes then.
291
+ */
292
+ setActivity(raw) {
293
+ this._activity = validateActivity(raw);
294
+ this._render();
295
+ // The window reports once its tray bridge listens, so held commands can go.
296
+ const win = this.mainWindow;
297
+ if (this._pendingCommands.length && win && !win.isDestroyed()) {
298
+ for (const command of this._pendingCommands.splice(0)) {
299
+ win.webContents.send("tray:command", command);
300
+ }
301
+ }
302
+ }
303
+
304
+ /** The window was closed (not hidden): what it last reported no longer holds. */
305
+ windowClosed() {
306
+ this._activity = null;
307
+ // A new window has no overlay yet, whatever the last one showed.
308
+ this._overlayKey = null;
309
+ this._render();
310
+ }
311
+
312
+ /** Mark the GAIA service process as running or exited. */
313
+ setBackendAlive(alive) {
314
+ this._backendAlive = Boolean(alive);
315
+ this._render();
316
+ }
317
+
318
+ /** @returns {string} The tray state now showing: one of tray-icons.STATES. */
319
+ get state() {
320
+ return this._view ? this._view.state : "idle";
321
+ }
322
+
323
+ /** @returns {number} The chat count last passed to setAttentionCount. */
324
+ get attentionCount() {
325
+ return this._attentionCount;
173
326
  }
174
327
 
175
328
  /** @returns {number} The unread count last passed to setNotificationCount. */
@@ -192,68 +345,276 @@ class TrayManager {
192
345
  return this.config.tray.startOnLogin;
193
346
  }
194
347
 
195
- // ── Private: Context Menu ────────────────────────────────────────────
348
+ /** @returns {boolean} Whether the one-time "still running in the tray" notice is still due. */
349
+ get closeNoticeDue() {
350
+ return !this.config.notices.firstClose;
351
+ }
352
+
353
+ /**
354
+ * Tell the user, once, that closing the window left GAIA running.
355
+ * Saved as shown before it opens, so a crash mid-dialog never repeats it.
356
+ * @returns {Promise<boolean>} True when the user chose to quit from the notice.
357
+ */
358
+ async showCloseNotice() {
359
+ if (!this.closeNoticeDue) return false;
360
+ const next = { ...this.config, notices: { ...this.config.notices, firstClose: true } };
361
+ this._saveConfig(next);
362
+ this.config = next;
363
+ const where = process.platform === "darwin" ? "menu bar" : "tray";
364
+ const { response } = await dialog.showMessageBox({
365
+ type: "info",
366
+ title: "GAIA is still running",
367
+ message: `GAIA is still running in the ${where}.`,
368
+ detail: `Quit from the ${where} menu to stop it. Closing the window keeps your model loaded, so the next chat starts quickly.`,
369
+ buttons: ["OK", "Quit GAIA"],
370
+ defaultId: 0,
371
+ cancelId: 0,
372
+ noLink: true,
373
+ });
374
+ return response === 1;
375
+ }
376
+
377
+ // ── Private: Rendering ───────────────────────────────────────────────
196
378
 
197
- _rebuildContextMenu() {
198
- if (!this.tray) return;
379
+ _render() {
380
+ this._view = deriveTrayView(this._activity, {
381
+ backendAlive: this._backendAlive,
382
+ unread: this._notificationCount,
383
+ attention: this._attentionCount,
384
+ });
385
+ const trayAlive =
386
+ this.tray &&
387
+ !(typeof this.tray.isDestroyed === "function" && this.tray.isDestroyed());
388
+ if (trayAlive) {
389
+ const icon = this._iconFor(this._view.state);
390
+ if (icon !== this._icon) {
391
+ this._icon = icon;
392
+ this.tray.setImage(icon);
393
+ console.log(`[tray] State: ${this._view.state}`);
394
+ }
395
+ this.tray.setToolTip(this._view.tooltip);
396
+ // Linux trays never show the tooltip and only redraw the menu on
397
+ // setContextMenu, so the menu is rebuilt whenever its text changes.
398
+ const menuKey = JSON.stringify([
399
+ this._view.statusLine,
400
+ this._view.needsYou,
401
+ this._view.recent,
402
+ this._view.canFreeMemory,
403
+ this._view.freeMemoryHint,
404
+ ]);
405
+ if (menuKey !== this._menuKey) {
406
+ this._menuKey = menuKey;
407
+ this.tray.setContextMenu(
408
+ Menu.buildFromTemplate(buildMenuTemplate(this._view, this._menuActions()))
409
+ );
410
+ }
411
+ }
412
+ this._renderOverlay();
413
+ }
199
414
 
200
- const contextMenu = Menu.buildFromTemplate([
201
- {
202
- label: "Show Window",
203
- click: () => this._showWindow(),
204
- },
205
- {
206
- label: "Open in Browser",
207
- click: () => shell.openExternal(`http://localhost:${this._backendPort}`),
208
- },
209
- { type: "separator" },
210
- {
211
- label: "Quit",
212
- click: () => this._quit(),
415
+ /** Windows has no badge, so the taskbar button carries needs-you and unread. */
416
+ _renderOverlay() {
417
+ if (process.platform !== "win32") return;
418
+ const win = this.mainWindow;
419
+ if (!win || win.isDestroyed() || typeof win.setOverlayIcon !== "function") return;
420
+ const needs = this._view.state === "needs-you" ? this._view.needsYou.length : 0;
421
+ const unread = this._notificationCount;
422
+ let key = "none";
423
+ if (needs > 0) key = `needs-you:${needs}`;
424
+ else if (unread > 0) key = `unread:${unread}`;
425
+ if (key === this._overlayKey) return;
426
+ this._overlayKey = key;
427
+ if (needs > 0) {
428
+ win.setOverlayIcon(
429
+ this._overlayImage("needs-you"),
430
+ `${needs} ${needs === 1 ? "chat needs" : "chats need"} you`
431
+ );
432
+ } else if (unread > 0) {
433
+ win.setOverlayIcon(
434
+ this._overlayImage("unread"),
435
+ `${unread} unread notification${unread === 1 ? "" : "s"}`
436
+ );
437
+ } else {
438
+ win.setOverlayIcon(null, "");
439
+ }
440
+ }
441
+
442
+ _menuActions() {
443
+ return {
444
+ openChat: (sessionId) => this._command({ type: "open-chat", sessionId }),
445
+ newChat: () => this._command({ type: "new-chat" }),
446
+ showWindow: () => this._showWindow(),
447
+ freeMemory: () => {
448
+ // A menu click has no caller to report to, so the user hears about it.
449
+ this._freeMemory().catch((err) => {
450
+ console.error("[tray] Free memory failed:", err);
451
+ dialog.showErrorBox("GAIA", `Free memory failed: ${err.message}`);
452
+ });
213
453
  },
214
- ]);
215
- this.tray.setContextMenu(contextMenu);
454
+ openSettings: () => this._command({ type: "open-settings", section: "general" }),
455
+ quit: () => this._quit(),
456
+ };
457
+ }
458
+
459
+ /** Show the window and hand it a menu action. */
460
+ _command(command) {
461
+ const reopened = this._showWindow();
462
+ if (reopened) {
463
+ this._pendingCommands.push(command);
464
+ return;
465
+ }
466
+ this.mainWindow.webContents.send("tray:command", command);
467
+ }
468
+
469
+ // ── Private: Free memory ─────────────────────────────────────────────
470
+
471
+ async _freeMemory() {
472
+ if (this._freeingMemory || !this._view || !this._view.canFreeMemory) return;
473
+ // Set before the first dialog, so a second click can't open a second one.
474
+ this._freeingMemory = true;
475
+ try {
476
+ await this._freeMemoryFlow(this._view.modelName);
477
+ } finally {
478
+ this._freeingMemory = false;
479
+ }
480
+ }
481
+
482
+ async _freeMemoryFlow(model) {
483
+ const failed = (message, detail) =>
484
+ dialog.showMessageBox({
485
+ type: "error",
486
+ title: "Free memory",
487
+ message,
488
+ detail,
489
+ buttons: ["OK"],
490
+ noLink: true,
491
+ });
492
+
493
+ // The window only knows its own chats; the backend knows every answer
494
+ // and index job, so ask it now rather than poll it all the time.
495
+ let work;
496
+ try {
497
+ work = await fetchWorkInProgress(this._backendPort);
498
+ } catch (err) {
499
+ await failed("GAIA couldn't check whether the model is in use.", err.message);
500
+ return;
501
+ }
502
+ const busy = describeBusy(work);
503
+ if (busy) {
504
+ await dialog.showMessageBox({
505
+ type: "info",
506
+ title: "Free memory",
507
+ message: "The model is in use.",
508
+ detail: `${busy} Try again when it has finished.`,
509
+ buttons: ["OK"],
510
+ noLink: true,
511
+ });
512
+ return;
513
+ }
514
+
515
+ const { response } = await dialog.showMessageBox({
516
+ type: "question",
517
+ title: "Free memory?",
518
+ message: `Unload ${model} from memory?`,
519
+ detail:
520
+ "This frees the memory the model uses. Your next message, here or in another GAIA app, loads it again, which takes a moment.",
521
+ buttons: ["Free memory", "Cancel"],
522
+ defaultId: 0,
523
+ cancelId: 1,
524
+ noLink: true,
525
+ });
526
+ if (response !== 0) return;
527
+ try {
528
+ await backendJson(this._backendPort, "/api/system/unload-model", {
529
+ method: "POST",
530
+ body: { model_name: model },
531
+ timeoutMs: 30000,
532
+ });
533
+ } catch (err) {
534
+ await failed("GAIA couldn't free the model's memory.", err.message);
535
+ return;
536
+ }
537
+ console.log(`[tray] Unloaded ${model} to free memory`);
538
+ // The backend just confirmed it; the window's next status read agrees.
539
+ if (this._activity) this._activity = { ...this._activity, model: null };
540
+ this._render();
541
+ const win = this.mainWindow;
542
+ if (win && !win.isDestroyed()) {
543
+ win.webContents.send("tray:command", { type: "refresh-status" });
544
+ }
216
545
  }
217
546
 
218
547
  // ── Private: Window management ───────────────────────────────────────
219
548
 
549
+ /** @returns {boolean} True when the window had to be recreated. */
220
550
  _showWindow() {
221
- if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
222
-
551
+ if (!this.mainWindow || this.mainWindow.isDestroyed()) {
552
+ this.mainWindow = this._reopenWindow();
553
+ this._overlayKey = null;
554
+ this._renderOverlay();
555
+ return true;
556
+ }
223
557
  if (this.mainWindow.isMinimized()) {
224
558
  this.mainWindow.restore();
225
559
  }
226
560
  this.mainWindow.show();
227
561
  this.mainWindow.focus();
562
+ return false;
228
563
  }
229
564
 
230
- async _quit() {
565
+ _quit() {
231
566
  console.log("[tray] Quit requested");
567
+ // main.cjs's before-quit asks first when work would be lost.
232
568
  app.quit();
233
569
  }
234
570
 
235
- // ── Private: Icon loading ────────────────────────────────────────────
571
+ // ── Private: Icons ───────────────────────────────────────────────────
236
572
 
237
573
  /**
238
- * Pick the platform-appropriate tray icon. The menu bar is ~22pt tall, so
239
- * each platform gets a purpose-built small asset — never the 4K app icon.
574
+ * The tray image for a state: the logo from the platform's small assets
575
+ * (never the 4K app icon) with that state's badge drawn on.
240
576
  * @returns {Electron.NativeImage}
241
577
  */
242
- _loadTrayIcon() {
243
- if (process.platform === "win32") {
244
- return this._loadIcon("tray-icon.ico");
578
+ _iconFor(state) {
579
+ if (!STATES.includes(state)) throw new Error(`[tray] unknown tray state "${state}"`);
580
+ const cached = this._icons.get(state);
581
+ if (cached) return cached;
582
+ const spec = this._iconSpec;
583
+ const image = nativeImage.createEmpty();
584
+ for (const rep of spec.reps) {
585
+ let source = this._loadIcon(rep.file);
586
+ if (source.getSize().width !== rep.px) {
587
+ source = source.resize({ width: rep.px, height: rep.px, quality: "best" });
588
+ }
589
+ const buffer = drawStateIcon(
590
+ { width: rep.px, height: rep.px, buffer: source.toBitmap() },
591
+ state,
592
+ { template: spec.template }
593
+ );
594
+ image.addRepresentation({ scaleFactor: rep.scale, width: rep.px, height: rep.px, buffer });
245
595
  }
246
- if (process.platform === "darwin") {
247
- // macOS menu-bar extras must be template images: the OS tints the
248
- // alpha mask for light/dark mode. Electron auto-discovers the @2x
249
- // retina variant by name.
250
- const img = this._loadIcon("tray-iconTemplate.png");
251
- img.setTemplateImage(true);
252
- return img;
596
+ // macOS menu-bar extras must be template images: the OS tints the
597
+ // alpha mask for light/dark mode.
598
+ if (spec.template) image.setTemplateImage(true);
599
+ this._icons.set(state, image);
600
+ return image;
601
+ }
602
+
603
+ _overlayImage(kind) {
604
+ const key = `overlay:${kind}`;
605
+ const cached = this._icons.get(key);
606
+ if (cached) return cached;
607
+ const image = nativeImage.createEmpty();
608
+ for (const rep of OVERLAY_REPS) {
609
+ image.addRepresentation({
610
+ scaleFactor: rep.scale,
611
+ width: rep.px,
612
+ height: rep.px,
613
+ buffer: drawOverlayIcon(kind, rep.px),
614
+ });
253
615
  }
254
- // Linux: full-colour 32px PNG (+@2x); StatusNotifier hosts scale it
255
- // down to their 16-24px slot, which stays sharp where upscaling blurs.
256
- return this._loadIcon("tray-icon.png");
616
+ this._icons.set(key, image);
617
+ return image;
257
618
  }
258
619
 
259
620
  _loadIcon(filename) {
@@ -283,7 +644,13 @@ class TrayManager {
283
644
  console.warn("[tray] Could not load tray config:", err.message);
284
645
  }
285
646
  if (!isPlainObject(loaded)) {
286
- return { ...DEFAULT_CONFIG, tray: { ...DEFAULT_CONFIG.tray }, agents: {} };
647
+ return {
648
+ ...DEFAULT_CONFIG,
649
+ tray: { ...DEFAULT_CONFIG.tray },
650
+ agents: {},
651
+ notices: { ...DEFAULT_CONFIG.notices },
652
+ choices: { ...DEFAULT_CONFIG.choices },
653
+ };
287
654
  }
288
655
 
289
656
  // Scoped to the agents section: one bad entry must not cost the user their
@@ -312,11 +679,25 @@ class TrayManager {
312
679
  }
313
680
  }
314
681
 
682
+ const storedNotices = isPlainObject(loaded.notices) ? loaded.notices : {};
683
+ const notices = { ...DEFAULT_CONFIG.notices };
684
+ for (const key of Object.keys(DEFAULT_CONFIG.notices)) {
685
+ if (typeof storedNotices[key] === "boolean") notices[key] = storedNotices[key];
686
+ }
687
+
688
+ const storedChoices = isPlainObject(loaded.choices) ? loaded.choices : {};
689
+ const choices = { ...DEFAULT_CONFIG.choices };
690
+ for (const key of Object.keys(DEFAULT_CONFIG.choices)) {
691
+ if (typeof storedChoices[key] === "boolean") choices[key] = storedChoices[key];
692
+ }
693
+
315
694
  return {
316
695
  ...DEFAULT_CONFIG,
317
696
  ...loaded,
318
697
  tray,
319
698
  agents,
699
+ notices,
700
+ choices,
320
701
  };
321
702
  }
322
703
 
@@ -336,6 +717,10 @@ class TrayManager {
336
717
  // ── Private: IPC handlers ────────────────────────────────────────────
337
718
 
338
719
  _registerIpcHandlers() {
720
+ ipcMain.handle("tray:set-activity", (_event, activity) => {
721
+ this.setActivity(activity);
722
+ });
723
+
339
724
  ipcMain.handle("tray:get-config", () => {
340
725
  return this.config;
341
726
  });
@@ -367,33 +752,147 @@ class TrayManager {
367
752
  if (tray === null && agents === null) {
368
753
  return this.config;
369
754
  }
755
+ const loginChanged =
756
+ tray !== null && tray.startOnLogin !== this.config.tray.startOnLogin;
370
757
  const next = {
371
758
  ...this.config,
372
759
  tray: tray ?? this.config.tray,
373
760
  agents: agents ? { ...this.config.agents, ...agents } : this.config.agents,
761
+ choices: loginChanged
762
+ ? { ...this.config.choices, startOnLogin: true }
763
+ : this.config.choices,
374
764
  };
375
- this._saveConfig(next);
376
- this.config = next;
377
-
378
- if (tray !== null && "startOnLogin" in cfg.tray) {
379
- this._applyLoginItemSetting(tray.startOnLogin);
765
+ // The OS login item first: if it can't be set, nothing is saved, so the
766
+ // toggle never claims a state the OS doesn't have.
767
+ if (loginChanged) this._applyLoginItemSetting(tray.startOnLogin);
768
+ try {
769
+ this._saveConfig(next);
770
+ } catch (err) {
771
+ // Keep the OS and the saved toggle in step: undo the login item.
772
+ if (loginChanged) this._applyLoginItemSetting(this.config.tray.startOnLogin);
773
+ throw err;
380
774
  }
775
+ this.config = next;
381
776
 
382
777
  return this.config;
383
778
  });
384
779
  }
385
780
 
386
- /** Register/unregister the app from OS login startup. */
781
+ /**
782
+ * Line the sign-in switch up with what the OS will actually do. Call once
783
+ * at startup. Until the user sets the switch, the OS decides it (the
784
+ * Windows installer turns sign-in start on). Once they turn it off, an
785
+ * entry that came back (a reinstall or update rewrites it) is removed again.
786
+ */
787
+ syncLoginItem() {
788
+ // A dev build's exe is electron itself; never touch the installed app's entry.
789
+ if (!app.isPackaged) return;
790
+ const osOn = this._loginItemOn();
791
+ const wanted = this.config.tray.startOnLogin;
792
+ if (osOn === wanted) return;
793
+ if (this.config.choices.startOnLogin && !wanted) {
794
+ try {
795
+ this._applyLoginItemSetting(false);
796
+ return;
797
+ } catch (err) {
798
+ console.error(`[tray] Sign-in start is back on and could not be turned off again: ${err.message}`);
799
+ }
800
+ }
801
+ this.config = { ...this.config, tray: { ...this.config.tray, startOnLogin: osOn } };
802
+ console.log(`[tray] Start at sign-in is ${osOn ? "on" : "off"} in the OS; the switch follows it`);
803
+ }
804
+
805
+ /** The executable and arguments the login item launches. */
806
+ _loginItemQuery() {
807
+ const args = process.platform === "win32" ? WIN_LOGIN_ITEM_ARGS : [];
808
+ return { path: app.getPath("exe"), args };
809
+ }
810
+
811
+ /** Whether GAIA will start at sign-in, read from the OS. */
812
+ _loginItemOn() {
813
+ if (process.platform === "linux") {
814
+ return fs.existsSync(this._linuxAutostartFile());
815
+ }
816
+ const now = app.getLoginItemSettings(this._loginItemQuery());
817
+ if (process.platform !== "win32") return Boolean(now.openAtLogin);
818
+ // openAtLogin reads the value named after the AppUserModelId, not ours.
819
+ // A Run value disabled in Task Manager is listed with enabled: false.
820
+ const item = (now.launchItems || []).find(
821
+ (i) => i.name === WIN_LOGIN_ITEM_NAME && i.scope === "user"
822
+ );
823
+ return Boolean(item && item.enabled);
824
+ }
825
+
826
+ /**
827
+ * Register or remove GAIA as a login item. On Windows that is the same Run
828
+ * value the installer writes, launched into the tray; elsewhere "Start in
829
+ * the tray" decides whether that launch shows the window.
830
+ * @throws {Error} When the OS refused; the caller's save is skipped.
831
+ */
387
832
  _applyLoginItemSetting(enabled) {
833
+ if (process.platform === "linux") {
834
+ this._applyLinuxAutostart(enabled);
835
+ } else {
836
+ const what = enabled ? "add GAIA to" : "remove GAIA from";
837
+ try {
838
+ const name = process.platform === "win32" ? { name: WIN_LOGIN_ITEM_NAME } : {};
839
+ app.setLoginItemSettings({ ...this._loginItemQuery(), ...name, openAtLogin: enabled });
840
+ } catch (err) {
841
+ throw new Error(`Could not ${what} the apps that start at sign-in: ${err.message}`);
842
+ }
843
+ // The OS can refuse without throwing; read back what it actually has.
844
+ if (process.platform === "darwin" && enabled) {
845
+ if (app.getLoginItemSettings(this._loginItemQuery()).status === "requires-approval") {
846
+ throw new Error(
847
+ "macOS needs your approval to start GAIA at sign-in. Allow it in System Settings → General → Login Items, then turn this on again."
848
+ );
849
+ }
850
+ }
851
+ const now = this._loginItemOn();
852
+ if (now !== enabled) {
853
+ throw new Error(
854
+ `Could not ${what} the apps that start at sign-in: the system still reports it ${now ? "on" : "off"}. ` +
855
+ "Check your startup apps in the system settings."
856
+ );
857
+ }
858
+ }
859
+ console.log(`[tray] Login item ${enabled ? "enabled" : "disabled"}`);
860
+ }
861
+
862
+ _linuxAutostartFile() {
863
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
864
+ return path.join(configHome, "autostart", LINUX_AUTOSTART_FILE);
865
+ }
866
+
867
+ /** Electron has no login items on Linux; desktops read XDG autostart entries. */
868
+ _applyLinuxAutostart(enabled) {
869
+ const file = this._linuxAutostartFile();
870
+ // An AppImage's exe is a temporary mount; APPIMAGE is the file the user runs.
871
+ const exe = process.env.APPIMAGE || app.getPath("exe");
388
872
  try {
389
- app.setLoginItemSettings({
390
- openAtLogin: enabled,
391
- path: app.getPath("exe"),
392
- args: enabled ? ["--minimized"] : [],
393
- });
394
- console.log(`[tray] Login item ${enabled ? "enabled" : "disabled"}`);
873
+ if (enabled) {
874
+ fs.mkdirSync(path.dirname(file), { recursive: true });
875
+ fs.writeFileSync(
876
+ file,
877
+ [
878
+ "[Desktop Entry]",
879
+ "Type=Application",
880
+ "Name=GAIA",
881
+ "Comment=Start GAIA when you sign in",
882
+ `Exec=${desktopExecArg(exe)}`,
883
+ "X-GNOME-Autostart-enabled=true",
884
+ "",
885
+ ].join("\n"),
886
+ "utf8"
887
+ );
888
+ } else if (fs.existsSync(file)) {
889
+ fs.unlinkSync(file);
890
+ }
395
891
  } catch (err) {
396
- console.warn("[tray] Could not set login item:", err.message);
892
+ throw new Error(
893
+ `Could not ${enabled ? "write" : "remove"} the autostart entry ${file}: ${err.message}. ` +
894
+ "Check that the folder is writable."
895
+ );
397
896
  }
398
897
  }
399
898
  }