relay-companion 0.1.39 → 0.1.40

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.
@@ -100,7 +100,7 @@
100
100
  }
101
101
  .count {
102
102
  font-family:var(--mono); font-size:13px; font-weight:500; font-variant-numeric:tabular-nums;
103
- color:var(--accent); line-height:1; transition:color .3s var(--settle);
103
+ color:var(--accent); line-height:1; white-space:nowrap; transition:color .3s var(--settle);
104
104
  }
105
105
  .count.zero { color:var(--muted-3); }
106
106
  /* ✕ — fully hide the overlay; the Relay mark in the OS status area brings it back.
@@ -651,7 +651,7 @@
651
651
 
652
652
  const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
653
653
  const EXPANDED = { w: 344, h: 524 };
654
- const PILL = { w: 178, h: 44 }; // wide enough for mark + word + count + ✕
654
+ const PILL = { w: 244, h: 44 }; // wide enough for mark + word + "N unopened" + ✕
655
655
  const PEEK = { w: 344, h: 118 };
656
656
 
657
657
  // ---------- zero-latency sound (Web Audio, pre-decoded) ----------
@@ -720,16 +720,19 @@
720
720
  // ---------- collapse / expand / peek ----------
721
721
  let collapsed = false;
722
722
  let peeking = false, peekTimer = null;
723
+ let notificationDurationMs = 7000;
724
+ let activeNotificationIds = [];
723
725
  let ghost = false, ghostTimer = null; // notification-only mode while the pill is dismissed
724
726
  function clearPeek() {
725
727
  if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
726
728
  peeking = false;
727
- cardEl.classList.remove("peek");
729
+ cardEl.classList.remove("peek", "notifying");
728
730
  }
729
731
  function setCollapsed(v) {
730
732
  clearPeek();
731
733
  if (v === collapsed) return;
732
734
  collapsed = v;
735
+ refreshCountLabel();
733
736
  cardEl.classList.toggle("collapsed", collapsed);
734
737
  springTo(collapsed ? PILL.w : EXPANDED.w, collapsed ? PILL.h : EXPANDED.h);
735
738
  playTink();
@@ -747,6 +750,7 @@
747
750
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
748
751
  clearPeek();
749
752
  collapsed = false;
753
+ refreshCountLabel();
750
754
  cardEl.classList.add("offstage");
751
755
  cardEl.classList.remove("collapsed", "ghost", "bye");
752
756
  snapSize(EXPANDED.w, EXPANDED.h);
@@ -767,6 +771,10 @@
767
771
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
768
772
  if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
769
773
  const wasGhost = ghost;
774
+ if (!wasGhost && activeNotificationIds.length && window.relay.attentionDone) {
775
+ window.relay.attentionDone(activeNotificationIds);
776
+ activeNotificationIds = [];
777
+ }
770
778
  cardEl.classList.add("bye");
771
779
  setInteractive(false);
772
780
  byeTimer = setTimeout(() => {
@@ -784,6 +792,7 @@
784
792
  cancelBye(); // a banner arriving mid-exit must not be torn down by the old exit
785
793
  clearPeek();
786
794
  collapsed = false;
795
+ refreshCountLabel();
787
796
  cardEl.classList.remove("collapsed", "offstage", "bye");
788
797
  ghost = true; peeking = true;
789
798
  cardEl.classList.add("peek", "ghost");
@@ -801,11 +810,13 @@
801
810
  ghostTimer = null;
802
811
  if (interactive || quickReplyIsActive()) { ghostTimer = setTimeout(tick, 4000); return; }
803
812
  dismissOverlay(); // rides the ghostOut exit, then main re-hides the window
804
- }, 7000);
813
+ }, notificationDurationMs);
805
814
  }
806
815
  // Status-area Relay mark clicked: the card comes back FULLY OPEN (never the pill).
807
816
  function trayOpen() {
808
817
  const wasOffstage = cardEl.classList.contains("offstage") || cardEl.classList.contains("bye");
818
+ const completedNotificationIds = activeNotificationIds;
819
+ activeNotificationIds = [];
809
820
  cancelBye(); // a reopen mid-exit must survive: the pending dismiss would hide us again
810
821
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
811
822
  ghost = false;
@@ -825,30 +836,38 @@
825
836
  springTo(EXPANDED.w, EXPANDED.h);
826
837
  }
827
838
  renderAll();
839
+ if (completedNotificationIds.length && window.relay.attentionDone) window.relay.attentionDone(completedNotificationIds);
828
840
  playTink();
829
841
  }
830
842
  // The banner must show its WHOLE first row — a clipped preview reads as broken.
831
843
  // Measure after render; QR question rows are tall, so allow room before capping.
832
844
  function peekHeight() {
833
- const first = scrollEl.querySelector(".row, .task");
834
- const rowH = first ? first.offsetHeight : 74;
835
- return Math.min(44 + rowH + 6, 340);
836
- }
837
- // A new relay arrived. If folded, peek open like an iOS banner (~7s) then fold back.
838
- // The peek routes to Relays. Task/share rows are hidden until that product surface is ready.
839
- function notifyArrival(row) {
840
- if (!collapsed && !peeking) return;
845
+ const stack = [...relaysListEl.querySelectorAll(".row")];
846
+ const rowsHeight = stack.reduce((sum, row) => sum + row.offsetHeight, 0);
847
+ return Math.min(44 + rowsHeight + 6, EXPANDED.h);
848
+ }
849
+ // New relays always enter the notification stack, including from a fully expanded
850
+ // card. The main process has already pushed the new payload, so render/sizing uses
851
+ // the latest unopened rows rather than the previous inbox.
852
+ function notifyArrival(rows) {
841
853
  if (peekTimer) clearTimeout(peekTimer);
854
+ cancelBye();
855
+ if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
856
+ ghost = false;
842
857
  peeking = true;
843
858
  collapsed = false;
844
- cardEl.classList.remove("collapsed");
845
- cardEl.classList.add("peek");
859
+ refreshCountLabel();
860
+ activeNotificationIds = (Array.isArray(rows) ? rows : [rows]).filter(Boolean).map((row) => row.id).filter(Boolean);
861
+ cardEl.classList.remove("collapsed", "peek", "offstage", "ghost", "bye");
862
+ // Keep the exact production expanded UI (tabs, typography, separators). This
863
+ // class is state-only for tests; it intentionally has no visual CSS of its own.
864
+ cardEl.classList.add("notifying");
846
865
  activeView = "relays";
847
866
  syncTabs();
848
867
  applyView();
849
868
  renderAll();
850
869
  scrollEl.scrollTop = 0;
851
- springTo(PEEK.w, peekHeight());
870
+ springTo(EXPANDED.w, EXPANDED.h);
852
871
  playTink();
853
872
  peekTimer = setTimeout(function tick() {
854
873
  peekTimer = null;
@@ -858,10 +877,14 @@
858
877
  // hold while hovered too). Mirrors the ghost-notification guard.
859
878
  if (interactive || quickReplyIsActive()) { peekTimer = setTimeout(tick, 4000); return; }
860
879
  peeking = false;
861
- cardEl.classList.remove("peek");
880
+ cardEl.classList.remove("peek", "notifying");
862
881
  collapsed = true; cardEl.classList.add("collapsed");
882
+ refreshCountLabel();
863
883
  springTo(PILL.w, PILL.h);
864
- }, 7000);
884
+ renderAll(); // restore the normal full list behind the now-folded pill
885
+ if (activeNotificationIds.length && window.relay.attentionDone) window.relay.attentionDone(activeNotificationIds);
886
+ activeNotificationIds = [];
887
+ }, notificationDurationMs);
865
888
  }
866
889
  function openFull() {
867
890
  if (ghost) {
@@ -872,10 +895,15 @@
872
895
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
873
896
  window.relay.undismiss();
874
897
  }
898
+ const completedNotificationIds = activeNotificationIds;
899
+ activeNotificationIds = [];
875
900
  clearPeek();
876
901
  collapsed = false;
902
+ refreshCountLabel();
877
903
  cardEl.classList.remove("collapsed");
878
904
  springTo(EXPANDED.w, EXPANDED.h);
905
+ renderAll();
906
+ if (completedNotificationIds.length && window.relay.attentionDone) window.relay.attentionDone(completedNotificationIds);
879
907
  playTink();
880
908
  }
881
909
 
@@ -1159,13 +1187,19 @@
1159
1187
  else window.relay.open(id);
1160
1188
  }
1161
1189
 
1190
+ let unreadCount = 0;
1191
+ function refreshCountLabel() {
1192
+ const label = collapsed ? `${unreadCount} unopened` : String(unreadCount);
1193
+ if (countEl.textContent !== label) {
1194
+ countEl.textContent = label;
1195
+ if (!REDUCED) { countEl.style.animation = "none"; void countEl.offsetWidth; countEl.style.animation = "countRoll .2s var(--settle)"; }
1196
+ }
1197
+ }
1162
1198
  function setCount(n) {
1199
+ unreadCount = n;
1163
1200
  cardEl.classList.toggle("has-unread", n > 0);
1164
1201
  countEl.classList.toggle("zero", n === 0);
1165
- if (countEl.textContent !== String(n)) {
1166
- countEl.textContent = String(n);
1167
- if (!REDUCED) { countEl.style.animation = "none"; void countEl.offsetWidth; countEl.style.animation = "countRoll .2s var(--settle)"; }
1168
- }
1202
+ refreshCountLabel();
1169
1203
  }
1170
1204
 
1171
1205
  function setBadge(el, n) {
@@ -1199,7 +1233,8 @@
1199
1233
  // focused textarea and lose what the user is typing. Defer and re-render on blur/submit.
1200
1234
  if (quickReplyIsActive()) { pendingRelaysRender = true; return; }
1201
1235
  pendingRelaysRender = false;
1202
- const rows = (payload.relays || []).filter(isRelayListKind);
1236
+ const allRows = (payload.relays || []).filter(isRelayListKind);
1237
+ const rows = peeking ? allRows.filter((row) => row.unread) : allRows;
1203
1238
  relaysEmptyEl.classList.toggle("gone", rows.length > 0);
1204
1239
  const seen = prevRelayIds;
1205
1240
  relaysListEl.innerHTML = rows.map((r) => {
@@ -2115,6 +2150,10 @@
2115
2150
  tasks: Array.isArray(next.tasks) ? next.tasks : [],
2116
2151
  contacts: Array.isArray(next.contacts) ? next.contacts : (payload.contacts || []),
2117
2152
  };
2153
+ const configuredDuration = Number(next.ui && next.ui.notificationDurationMs);
2154
+ notificationDurationMs = Number.isFinite(configuredDuration) && configuredDuration >= 250
2155
+ ? configuredDuration
2156
+ : 7000;
2118
2157
  renderAll();
2119
2158
  }
2120
2159
 
package/overlay/main.cjs CHANGED
@@ -20,7 +20,7 @@
20
20
  // - Mutations (accept/reject/approve/decline/answer) call RelayClient with device-token
21
21
  // auth. ack/mark-read writes state.json directly (atomic temp+rename).
22
22
 
23
- const { app, BrowserWindow, Menu, Tray, ipcMain, nativeImage, shell, screen, systemPreferences } = require("electron");
23
+ const { app, BrowserWindow, Menu, Tray, ipcMain, nativeImage, powerMonitor, shell, screen, systemPreferences } = require("electron");
24
24
  const fs = require("node:fs");
25
25
  const os = require("node:os");
26
26
  const path = require("node:path");
@@ -30,7 +30,7 @@ const { execFile, execFileSync, spawn, pathToFileURL } = (() => {
30
30
  return { execFile: cp.execFile, execFileSync: cp.execFileSync, spawn: cp.spawn, pathToFileURL: url.pathToFileURL };
31
31
  })();
32
32
  const { hostFromBundle, chooseClickHost, runningHostsFromProcessList } = require("./host-select.cjs");
33
- const { overlayWanted, createHostRunningTracker, shouldIgnoreDismiss } = require("./visibility.cjs");
33
+ const { overlayWanted, createHostRunningTracker, shouldIgnoreDismiss, boundedPresentedRelayIds } = require("./visibility.cjs");
34
34
  const { withJsonLock } = require("../src/state-lock.cjs");
35
35
  const {
36
36
  reinforceSpacePresence,
@@ -61,25 +61,51 @@ let lastHostSeenAt = 0;
61
61
  let lastSig = "";
62
62
 
63
63
  // Dismissed mode: the user clicked the ✕ on the card, so the overlay stays hidden
64
- // until they click the Relay status-area icon. Persisted across relaunches. While
65
- // dismissed, a fresh relay still shows the designed notification (a "ghost" peek):
66
- // the window appears just for the banner, then hides again when it finishes.
64
+ // until they click the Relay status-area icon OR a genuinely new relay arrives. A new
65
+ // relay latches the pill visible until the user dismisses it again, so an arrival can
66
+ // never spend seven seconds behind a lock screen and then disappear for good.
67
67
  const OVERLAY_PREFS_PATH = path.join(RELAY_HOME, "overlay-prefs.json");
68
- let dismissed = false;
69
- let ghostActive = false; // a ghost notification is on screen while dismissed
70
- let trayForcedVisible = false; // tray click while no host is running still shows the card
68
+ let overlayPrefs = {};
71
69
  try {
72
- dismissed = JSON.parse(fs.readFileSync(OVERLAY_PREFS_PATH, "utf8")).dismissed === true;
70
+ overlayPrefs = JSON.parse(fs.readFileSync(OVERLAY_PREFS_PATH, "utf8")) || {};
73
71
  } catch {}
74
- function setDismissed(value) {
75
- dismissed = Boolean(value);
72
+ let dismissed = overlayPrefs.dismissed === true;
73
+ let attentionLatched = overlayPrefs.attentionLatched === true;
74
+ const presentedRelayIds = new Set(
75
+ Array.isArray(overlayPrefs.presentedRelayIds) ? overlayPrefs.presentedRelayIds.filter(Boolean).map(String) : [],
76
+ );
77
+ let ghostActive = false; // a ghost notification is on screen while dismissed
78
+ let trayForcedVisible = false; // tray click while no host is running still shows the card
79
+ const PRESENTED_RELAY_CAP = 500;
80
+ function writeOverlayPrefs() {
76
81
  try {
77
82
  fs.mkdirSync(RELAY_HOME, { recursive: true });
78
- fs.writeFileSync(OVERLAY_PREFS_PATH, `${JSON.stringify({ dismissed }, null, 2)}\n`);
83
+ fs.writeFileSync(
84
+ OVERLAY_PREFS_PATH,
85
+ `${JSON.stringify({ dismissed, attentionLatched, presentedRelayIds: [...presentedRelayIds] }, null, 2)}\n`,
86
+ );
79
87
  } catch (error) {
80
88
  console.error("[overlay] prefs write failed:", error && error.message);
81
89
  }
82
90
  }
91
+ function setDismissed(value) {
92
+ dismissed = Boolean(value);
93
+ writeOverlayPrefs();
94
+ }
95
+ function markRelaysPresented(ids, retainedUnreadIds = []) {
96
+ const current = [...presentedRelayIds];
97
+ const bounded = boundedPresentedRelayIds(
98
+ current,
99
+ ids,
100
+ retainedUnreadIds,
101
+ PRESENTED_RELAY_CAP,
102
+ );
103
+ const changed = bounded.length !== current.length || bounded.some((id, index) => id !== current[index]);
104
+ if (!changed) return;
105
+ presentedRelayIds.clear();
106
+ for (const id of bounded) presentedRelayIds.add(id);
107
+ writeOverlayPrefs();
108
+ }
83
109
 
84
110
  function reopenSurfaceName() {
85
111
  if (process.platform === "darwin") return "the Relay icon in the menu bar";
@@ -403,6 +429,7 @@ function buildPayload() {
403
429
  ui: {
404
430
  canDismiss: trayAvailable,
405
431
  reopenSurface: reopenSurfaceName(),
432
+ notificationDurationMs: Number(process.env.RELAY_OVERLAY_NOTIFICATION_MS) || 7000,
406
433
  },
407
434
  relays: readRelays(),
408
435
  sent: sentCache,
@@ -411,10 +438,74 @@ function buildPayload() {
411
438
  };
412
439
  }
413
440
 
414
- let knownIds = null; // null until first load, so we never "notify" for rows already present
415
441
  // Pushes are serialized: overlapping timers (fs.watch + safety poll + sent refresh)
416
442
  // must not interleave sends, or the renderer can paint an older payload last.
417
443
  let pushChain = Promise.resolve();
444
+ const USER_IDLE_THRESHOLD_SECONDS = 15;
445
+ let systemSuspended = false;
446
+ let screenLocked = false;
447
+ let loginSessionActive = true;
448
+ let deferredAttention = false;
449
+ let activeAttentionIds = new Set();
450
+ let testAwayOverride = null;
451
+
452
+ function userIsAway() {
453
+ if (testAwayOverride !== null) return testAwayOverride;
454
+ if (process.env.RELAY_OVERLAY_TEST_FORCE_ACTIVE === "1") return false;
455
+ if (systemSuspended || screenLocked || !loginSessionActive) return true;
456
+ try {
457
+ const state = powerMonitor.getSystemIdleState(USER_IDLE_THRESHOLD_SECONDS);
458
+ return state === "idle" || state === "locked";
459
+ } catch {
460
+ return false;
461
+ }
462
+ }
463
+
464
+ // If the machine locks or sleeps while a notification is still on screen, it was
465
+ // probably never seen. Put the whole active stack back into the unpresented pool so
466
+ // unlock/resume can show it again instead of merely revealing the collapsed pill.
467
+ function requeueActiveAttention() {
468
+ if (!activeAttentionIds.size) return;
469
+ for (const id of activeAttentionIds) presentedRelayIds.delete(id);
470
+ activeAttentionIds = new Set();
471
+ deferredAttention = true;
472
+ writeOverlayPrefs();
473
+ }
474
+
475
+ function reconcileAttentionAfterReturn() {
476
+ if (userIsAway()) return;
477
+ deferredAttention = false;
478
+ refreshOverlayForActiveSpace({ force: true });
479
+ pushInbox(true).catch((error) => console.error("[overlay] return reconciliation failed:", error && error.message));
480
+ }
481
+
482
+ function scheduleReturnReconciliation() {
483
+ // The daemon and pill are independent launchd jobs. Reconcile now for already-
484
+ // staged rows, then again across the daemon's next poll for post-wake arrivals.
485
+ for (const delay of [0, 1200, 4500]) setTimeout(reconcileAttentionAfterReturn, delay);
486
+ }
487
+
488
+ function presentUnopenedStack(payload, unpresented) {
489
+ if (!unpresented.length || userIsAway() || !win || win.isDestroyed()) return false;
490
+ const stack = visibleRelayRows(payload.relays).filter((row) => row.unread);
491
+ if (!stack.length) return false;
492
+
493
+ // A new relay revokes an old dismissal. The resulting pill stays latched on screen
494
+ // after the notification collapses, even with no Claude/Codex host running, until
495
+ // the user explicitly dismisses it again.
496
+ dismissed = false;
497
+ attentionLatched = true;
498
+ ghostActive = false;
499
+ trayForcedVisible = false;
500
+ deferredAttention = false;
501
+ maybeShow({ force: true });
502
+ win.webContents.send("newRelay", stack, { ghost: false, stacked: true });
503
+ activeAttentionIds = new Set(stack.map((row) => row.id));
504
+ markRelaysPresented(unpresented.map((row) => row.id), stack.map((row) => row.id));
505
+ syncTray();
506
+ return true;
507
+ }
508
+
418
509
  function pushInbox(force) {
419
510
  pushChain = pushChain.then(() => pushInboxNow(force)).catch((error) => {
420
511
  console.error("[overlay] pushInbox failed:", error && error.message);
@@ -426,26 +517,10 @@ async function pushInboxNow(force) {
426
517
  const payload = buildPayload();
427
518
  const rows = payload.relays;
428
519
  const notifiableRows = visibleRelayRows(rows);
429
- if (knownIds === null) {
430
- knownIds = new Set(notifiableRows.map((r) => r.id));
431
- } else {
432
- const fresh = notifiableRows.filter((r) => r.unread && !knownIds.has(r.id));
433
- knownIds = new Set(notifiableRows.map((r) => r.id));
434
- if (fresh.length && win && !win.isDestroyed()) {
435
- // newest arrival drives the peek. While dismissed, the same designed banner still
436
- // shows — as a "ghost": the window appears for the notification only, and the
437
- // renderer fades it out (never folding back into a pill) when it's done.
438
- if (trayAvailable && dismissed) {
439
- if (hostRunning) {
440
- ghostActive = true;
441
- showOverlayWindow();
442
- win.webContents.send("newRelay", fresh[0], { ghost: true });
443
- }
444
- } else {
445
- win.webContents.send("newRelay", fresh[0], { ghost: false });
446
- }
447
- }
448
- }
520
+ const unreadIds = notifiableRows.filter((row) => row.unread).map((row) => row.id);
521
+ markRelaysPresented([], unreadIds); // prune old read/deleted history without evicting current unread ids
522
+ const unpresented = notifiableRows.filter((row) => row.unread && !presentedRelayIds.has(row.id));
523
+ if (unpresented.length && userIsAway()) deferredAttention = true;
449
524
  const sig = JSON.stringify({
450
525
  // Include the answered-state fields: a question answered on the web / in a host
451
526
  // session flips quickReply/humanAnswered without changing unread/title, and
@@ -473,9 +548,13 @@ async function pushInboxNow(force) {
473
548
  });
474
549
  // Unchanged data: skip the send entirely. Re-sending identical payloads made the
475
550
  // renderer rebuild its DOM every few seconds (hover flicker, restarted transitions).
476
- if (!force && sig === lastSig) return;
477
- lastSig = sig;
478
- if (win && !win.isDestroyed()) win.webContents.send("inbox", payload);
551
+ // When data did change, the full payload MUST reach the renderer before the arrival
552
+ // signal; otherwise notification sizing/rendering runs against the previous inbox.
553
+ if (force || sig !== lastSig) {
554
+ lastSig = sig;
555
+ if (win && !win.isDestroyed()) win.webContents.send("inbox", payload);
556
+ }
557
+ presentUnopenedStack(payload, unpresented);
479
558
  }
480
559
 
481
560
  // Refresh state-derived rows only (fast path used by fs.watch + the safety poll).
@@ -1107,7 +1186,7 @@ function maybeShow({ force = false, reposition = true } = {}) {
1107
1186
  if (!win || win.isDestroyed()) return;
1108
1187
  // Dismissed keeps the window hidden except while a ghost notification is up.
1109
1188
  // trayForcedVisible honors an explicit status-area click even when no host runs.
1110
- const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive });
1189
+ const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive, attentionLatched });
1111
1190
  if (wanted) {
1112
1191
  showOverlayWindow({ force, reposition });
1113
1192
  } else if (win.isVisible()) {
@@ -1210,7 +1289,7 @@ function createWindow() {
1210
1289
  setTimeout(() => {
1211
1290
  if (!win || win.isDestroyed()) return;
1212
1291
  lastSig = ""; // force a full repaint after reload
1213
- knownIds = null;
1292
+ requeueActiveAttention();
1214
1293
  win.webContents.reload();
1215
1294
  }, delay);
1216
1295
  });
@@ -1270,6 +1349,40 @@ function installActiveSpaceWatcher() {
1270
1349
  );
1271
1350
  }
1272
1351
 
1352
+ function installPowerAttentionLifecycle() {
1353
+ powerMonitor.on("suspend", () => {
1354
+ systemSuspended = true;
1355
+ requeueActiveAttention();
1356
+ });
1357
+ powerMonitor.on("resume", () => {
1358
+ systemSuspended = false;
1359
+ scheduleReturnReconciliation();
1360
+ });
1361
+ powerMonitor.on("lock-screen", () => {
1362
+ screenLocked = true;
1363
+ requeueActiveAttention();
1364
+ });
1365
+ powerMonitor.on("unlock-screen", () => {
1366
+ screenLocked = false;
1367
+ scheduleReturnReconciliation();
1368
+ });
1369
+ if (process.platform === "darwin") {
1370
+ powerMonitor.on("user-did-resign-active", () => {
1371
+ loginSessionActive = false;
1372
+ requeueActiveAttention();
1373
+ });
1374
+ powerMonitor.on("user-did-become-active", () => {
1375
+ loginSessionActive = true;
1376
+ scheduleReturnReconciliation();
1377
+ });
1378
+ }
1379
+ // Idle has no edge event. Rows noticed while the user is away set this cheap gate;
1380
+ // the first mouse/keyboard activity flushes the durable unpresented set.
1381
+ setInterval(() => {
1382
+ if (deferredAttention && !userIsAway()) reconcileAttentionAfterReturn();
1383
+ }, 1000);
1384
+ }
1385
+
1273
1386
  // ---- OS status area (tray/menu bar) ----------------------------------------
1274
1387
 
1275
1388
  // The Relay mark lives in the OS status area: macOS menu bar, Windows system tray.
@@ -1317,7 +1430,10 @@ function pillIsOnScreen() {
1317
1430
  // Persists the dismissal (like the card's ✕) so it stays hidden until the icon is
1318
1431
  // clicked again — reachable because the tray exists whenever this runs.
1319
1432
  function hideFromTray() {
1320
- setDismissed(true);
1433
+ dismissed = true;
1434
+ attentionLatched = false;
1435
+ activeAttentionIds = new Set();
1436
+ writeOverlayPrefs();
1321
1437
  ghostActive = false;
1322
1438
  trayForcedVisible = false;
1323
1439
  if (win && !win.isDestroyed() && win.isVisible()) win.hide();
@@ -1457,7 +1573,10 @@ ipcMain.on("relay:dismiss", () => {
1457
1573
  if (win && !win.isDestroyed()) win.webContents.send("openFull");
1458
1574
  return;
1459
1575
  }
1460
- setDismissed(true);
1576
+ dismissed = true;
1577
+ attentionLatched = false;
1578
+ activeAttentionIds = new Set();
1579
+ writeOverlayPrefs();
1461
1580
  ghostActive = false;
1462
1581
  trayForcedVisible = false;
1463
1582
  if (win && !win.isDestroyed() && win.isVisible()) win.hide();
@@ -1475,6 +1594,19 @@ ipcMain.on("relay:notifDone", () => {
1475
1594
  ghostActive = false;
1476
1595
  if (dismissed && win && !win.isDestroyed() && win.isVisible()) win.hide();
1477
1596
  });
1597
+ // The notification stack completed its visible interval and folded into the pill.
1598
+ // Main tracks this boundary so a later lock does not replay a stack the user already
1599
+ // had a full chance to see.
1600
+ ipcMain.on("relay:attentionDone", (_event, ids) => {
1601
+ const completed = new Set(Array.isArray(ids) ? ids.map(String) : []);
1602
+ const completesCurrentStack = !completed.size || [...activeAttentionIds].every((id) => completed.has(id));
1603
+ if (!completesCurrentStack) return;
1604
+ // If the user became idle while the animation ran, assume it was missed and leave
1605
+ // it pending for their next input. This covers walking away just before an arrival,
1606
+ // not only explicit sleep/lock events.
1607
+ if (userIsAway()) requeueActiveAttention();
1608
+ else activeAttentionIds = new Set();
1609
+ });
1478
1610
  ipcMain.handle("relay:soundBytes", (_e, name) => {
1479
1611
  const clean = String(name).replace(/[^A-Za-z]/g, "");
1480
1612
  const candidates = [
@@ -1504,6 +1636,7 @@ if (!gotSingleInstanceLock) {
1504
1636
  createWindow();
1505
1637
  createTray();
1506
1638
  installActiveSpaceWatcher();
1639
+ installPowerAttentionLifecycle();
1507
1640
  // Test seam: the e2e harness (test/e2e-overlay.mjs) drives the real state machine
1508
1641
  // over the main-process inspector. Never set outside the harness.
1509
1642
  if (process.env.RELAY_OVERLAY_TEST === "1") {
@@ -1514,12 +1647,27 @@ if (!gotSingleInstanceLock) {
1514
1647
  maybeShow,
1515
1648
  refreshOverlayForActiveSpace,
1516
1649
  pollHosts,
1650
+ setAway: (away) => {
1651
+ testAwayOverride = Boolean(away);
1652
+ if (testAwayOverride) requeueActiveAttention();
1653
+ else scheduleReturnReconciliation();
1654
+ },
1517
1655
  setSentCache: (items) => {
1518
1656
  sentCache = Array.isArray(items) ? items : [];
1519
1657
  return pushInbox(true);
1520
1658
  },
1521
1659
  getWin: () => win,
1522
- state: () => ({ dismissed, ghostActive, trayForcedVisible, hostRunning, visible: win && !win.isDestroyed() ? win.isVisible() : null }),
1660
+ state: () => ({
1661
+ dismissed,
1662
+ attentionLatched,
1663
+ ghostActive,
1664
+ trayForcedVisible,
1665
+ hostRunning,
1666
+ deferredAttention,
1667
+ presentedRelayIds: [...presentedRelayIds],
1668
+ activeAttentionIds: [...activeAttentionIds],
1669
+ visible: win && !win.isDestroyed() ? win.isVisible() : null,
1670
+ }),
1523
1671
  };
1524
1672
  }
1525
1673
  });
@@ -45,4 +45,5 @@ contextBridge.exposeInMainWorld("relay", {
45
45
  dismiss: () => ipcRenderer.send("relay:dismiss"),
46
46
  undismiss: () => ipcRenderer.send("relay:undismiss"),
47
47
  notifDone: () => ipcRenderer.send("relay:notifDone"),
48
+ attentionDone: (ids) => ipcRenderer.send("relay:attentionDone", Array.isArray(ids) ? ids : []),
48
49
  });
@@ -11,9 +11,10 @@ function overlayWanted({
11
11
  trayAvailable = false,
12
12
  dismissed = false,
13
13
  ghostActive = false,
14
+ attentionLatched = false,
14
15
  } = {}) {
15
16
  const dismissedEffective = trayAvailable && dismissed;
16
- return (hostRunning || trayForcedVisible) && (!dismissedEffective || ghostActive);
17
+ return (hostRunning || trayForcedVisible || attentionLatched) && (!dismissedEffective || ghostActive);
17
18
  }
18
19
 
19
20
  // Debounced "a host is running" tracker. The OFF transition requires `missThreshold`
@@ -51,4 +52,24 @@ function shouldIgnoreDismiss({ now, lastTrayShowAt, windowMs = 250 } = {}) {
51
52
  return delta >= 0 && delta < windowMs;
52
53
  }
53
54
 
54
- module.exports = { overlayWanted, createHostRunningTracker, shouldIgnoreDismiss };
55
+ // Keep presentation history bounded without ever evicting a relay that is still
56
+ // unread. Evicting an unread id would make the next safety poll treat it as new and
57
+ // could create an endless re-notification loop on a large inbox.
58
+ function boundedPresentedRelayIds(currentIds, newlyPresentedIds, retainedUnreadIds, cap = 500) {
59
+ const ids = new Set(Array.isArray(currentIds) ? currentIds.map(String) : []);
60
+ for (const value of newlyPresentedIds || []) {
61
+ const id = String(value || "");
62
+ if (!id) continue;
63
+ ids.delete(id);
64
+ ids.add(id);
65
+ }
66
+ const retained = new Set(Array.from(retainedUnreadIds || [], String));
67
+ while (ids.size > cap) {
68
+ const evictable = [...ids].find((id) => !retained.has(id));
69
+ if (!evictable) break;
70
+ ids.delete(evictable);
71
+ }
72
+ return [...ids];
73
+ }
74
+
75
+ module.exports = { overlayWanted, createHostRunningTracker, shouldIgnoreDismiss, boundedPresentedRelayIds };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {