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.
- package/overlay/inbox.html +61 -22
- package/overlay/main.cjs +189 -41
- package/overlay/preload.cjs +1 -0
- package/overlay/visibility.cjs +23 -2
- package/package.json +1 -1
package/overlay/inbox.html
CHANGED
|
@@ -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:
|
|
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
|
-
},
|
|
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
|
|
834
|
-
const
|
|
835
|
-
return Math.min(44 +
|
|
836
|
-
}
|
|
837
|
-
//
|
|
838
|
-
// The
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
845
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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
|
|
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
|
-
|
|
70
|
+
overlayPrefs = JSON.parse(fs.readFileSync(OVERLAY_PREFS_PATH, "utf8")) || {};
|
|
73
71
|
} catch {}
|
|
74
|
-
|
|
75
|
-
|
|
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(
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: () => ({
|
|
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
|
});
|
package/overlay/preload.cjs
CHANGED
|
@@ -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
|
});
|
package/overlay/visibility.cjs
CHANGED
|
@@ -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
|
-
|
|
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 };
|