relay-companion 0.1.7 → 0.1.9
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 +137 -3
- package/overlay/main.cjs +105 -4
- package/overlay/preload.cjs +8 -1
- package/overlay/relayTrayTemplate.png +0 -0
- package/overlay/relayTrayTemplate@2x.png +0 -0
- package/package.json +1 -1
- package/src/mcp.js +15 -1
- package/src/notifications.js +5 -0
package/overlay/inbox.html
CHANGED
|
@@ -57,6 +57,18 @@
|
|
|
57
57
|
}
|
|
58
58
|
@keyframes appIn { from { opacity:0; transform:scale(.97); } to { opacity:1; transform:none; } }
|
|
59
59
|
|
|
60
|
+
/* Dismissal states. `offstage` parks the card invisibly while the OS window is hidden.
|
|
61
|
+
`bye` is the graceful exit (✕ click, or a ghost notification finishing). A `ghost`
|
|
62
|
+
card is the designed notification shown while the pill is dismissed: it emerges like
|
|
63
|
+
a banner (drift down + fade) and NEVER folds back into a pill — it just leaves. */
|
|
64
|
+
.card.offstage { visibility:hidden; animation:none; }
|
|
65
|
+
.card.bye { animation:byeOut .3s var(--settle) both; pointer-events:none; }
|
|
66
|
+
@keyframes byeOut { from { opacity:1; transform:none; } to { opacity:0; transform:scale(.96) translateY(-10px); } }
|
|
67
|
+
.card.ghost { animation:ghostIn .5s var(--soft) both; }
|
|
68
|
+
@keyframes ghostIn { from { opacity:0; transform:translateY(-14px) scale(.98); } to { opacity:1; transform:none; } }
|
|
69
|
+
.card.ghost.bye { animation:ghostOut .38s var(--settle) both; }
|
|
70
|
+
@keyframes ghostOut { from { opacity:1; transform:none; } to { opacity:0; transform:translateY(-12px) scale(.985); } }
|
|
71
|
+
|
|
60
72
|
/* brand lockup — present in BOTH states; no surface, no rule */
|
|
61
73
|
.lockup {
|
|
62
74
|
flex:0 0 44px; display:flex; align-items:center; gap:9px;
|
|
@@ -78,7 +90,9 @@
|
|
|
78
90
|
transition:color .18s var(--settle), opacity .18s var(--settle), width .18s var(--settle), margin .18s var(--settle);
|
|
79
91
|
}
|
|
80
92
|
.lockup:hover .minimize-hint { color:var(--muted-2); }
|
|
81
|
-
.card.collapsed .minimize-hint
|
|
93
|
+
.card.collapsed .minimize-hint,
|
|
94
|
+
.card.peek .minimize-hint {
|
|
95
|
+
/* folded pill and notification banner: tapping the lockup EXPANDS, so no "Minimize" */
|
|
82
96
|
width:0;
|
|
83
97
|
margin-right:0;
|
|
84
98
|
overflow:hidden;
|
|
@@ -89,6 +103,17 @@
|
|
|
89
103
|
color:var(--accent); line-height:1; transition:color .3s var(--settle);
|
|
90
104
|
}
|
|
91
105
|
.count.zero { color:var(--muted-3); }
|
|
106
|
+
/* ✕ — fully hide the overlay; the Relay mark in the menu bar brings it back. Present in
|
|
107
|
+
every card state (expanded, pill, notification) so the pill is always escapable. */
|
|
108
|
+
.close-x {
|
|
109
|
+
flex:0 0 auto; appearance:none; border:0; background:transparent; cursor:pointer;
|
|
110
|
+
width:20px; height:20px; border-radius:6px; padding:0; margin-left:6px; margin-right:-6px;
|
|
111
|
+
display:inline-flex; align-items:center; justify-content:center;
|
|
112
|
+
color:var(--muted-3);
|
|
113
|
+
transition:color .15s var(--settle), background-color .15s var(--settle);
|
|
114
|
+
}
|
|
115
|
+
.close-x:hover { color:var(--ink-2); background:rgba(31,26,23,.06); }
|
|
116
|
+
.close-x:active { color:var(--ink); }
|
|
92
117
|
@keyframes countRoll { from { transform:translateY(-6px); opacity:.3; } to { transform:none; opacity:1; } }
|
|
93
118
|
|
|
94
119
|
/* tab control — individual rounded-rect buttons (the loved control) */
|
|
@@ -412,6 +437,9 @@
|
|
|
412
437
|
<span class="spacer"></span>
|
|
413
438
|
<span class="minimize-hint">Minimize</span>
|
|
414
439
|
<span class="count zero" id="count">0</span>
|
|
440
|
+
<button class="close-x" id="closeX" type="button" aria-label="Hide Relay" title="Hide Relay — reopen from the menu bar">
|
|
441
|
+
<svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M1 1l8 8M9 1L1 9"/></svg>
|
|
442
|
+
</button>
|
|
415
443
|
</div>
|
|
416
444
|
|
|
417
445
|
<nav class="tabs" aria-label="Relay views">
|
|
@@ -484,6 +512,7 @@
|
|
|
484
512
|
<script>
|
|
485
513
|
const cardEl = document.getElementById("card");
|
|
486
514
|
const lockupEl = document.getElementById("lockup");
|
|
515
|
+
const closeXEl = document.getElementById("closeX");
|
|
487
516
|
const countEl = document.getElementById("count");
|
|
488
517
|
const tabEls = [...document.querySelectorAll(".tab")];
|
|
489
518
|
const scrollEl = document.getElementById("scroll");
|
|
@@ -512,7 +541,7 @@
|
|
|
512
541
|
|
|
513
542
|
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
514
543
|
const EXPANDED = { w: 344, h: 524 };
|
|
515
|
-
const PILL = { w:
|
|
544
|
+
const PILL = { w: 178, h: 44 }; // wide enough for mark + word + count + ✕
|
|
516
545
|
const PEEK = { w: 344, h: 118 };
|
|
517
546
|
|
|
518
547
|
// ---------- zero-latency sound (Web Audio, pre-decoded) ----------
|
|
@@ -581,6 +610,7 @@
|
|
|
581
610
|
// ---------- collapse / expand / peek ----------
|
|
582
611
|
let collapsed = false;
|
|
583
612
|
let peeking = false, peekTimer = null;
|
|
613
|
+
let ghost = false, ghostTimer = null; // notification-only mode while the pill is dismissed
|
|
584
614
|
function clearPeek() {
|
|
585
615
|
if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
|
|
586
616
|
peeking = false;
|
|
@@ -594,6 +624,87 @@
|
|
|
594
624
|
springTo(collapsed ? PILL.w : EXPANDED.w, collapsed ? PILL.h : EXPANDED.h);
|
|
595
625
|
playTink();
|
|
596
626
|
}
|
|
627
|
+
// Park the card invisibly at expanded size while the OS window is hidden, so the
|
|
628
|
+
// next appearance (tray reopen / ghost banner) starts from a clean slate.
|
|
629
|
+
function snapSize(w, h) {
|
|
630
|
+
W.v = W.t = w; H.v = H.t = h;
|
|
631
|
+
if (raf) { cancelAnimationFrame(raf); raf = null; cardEl.style.willChange = ""; }
|
|
632
|
+
cardEl.style.width = w + "px";
|
|
633
|
+
cardEl.style.height = h + "px";
|
|
634
|
+
}
|
|
635
|
+
function resetOffstage() {
|
|
636
|
+
ghost = false;
|
|
637
|
+
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
638
|
+
clearPeek();
|
|
639
|
+
collapsed = false;
|
|
640
|
+
cardEl.classList.add("offstage");
|
|
641
|
+
cardEl.classList.remove("collapsed", "ghost", "bye");
|
|
642
|
+
snapSize(EXPANDED.w, EXPANDED.h);
|
|
643
|
+
}
|
|
644
|
+
// ✕ — the card leaves gracefully, then the main process hides the window and keeps
|
|
645
|
+
// it hidden (persisted) until the menu-bar Relay mark is clicked.
|
|
646
|
+
function dismissOverlay() {
|
|
647
|
+
if (cardEl.classList.contains("bye")) return;
|
|
648
|
+
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
649
|
+
if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
|
|
650
|
+
const wasGhost = ghost;
|
|
651
|
+
cardEl.classList.add("bye");
|
|
652
|
+
setInteractive(false);
|
|
653
|
+
setTimeout(() => {
|
|
654
|
+
if (wasGhost) window.relay.notifDone(); // already dismissed; just re-hide the window
|
|
655
|
+
else window.relay.dismiss();
|
|
656
|
+
resetOffstage();
|
|
657
|
+
}, wasGhost ? 380 : 300);
|
|
658
|
+
}
|
|
659
|
+
// A relay arrived while dismissed: the same designed banner, but as a free-standing
|
|
660
|
+
// notification. It emerges at banner size (no pill morph — there is no pill), and
|
|
661
|
+
// when done it fades away instead of folding back into anything.
|
|
662
|
+
function ghostArrival(row) {
|
|
663
|
+
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
664
|
+
clearPeek();
|
|
665
|
+
collapsed = false;
|
|
666
|
+
cardEl.classList.remove("collapsed", "offstage", "bye");
|
|
667
|
+
snapSize(PEEK.w, PEEK.h);
|
|
668
|
+
ghost = true; peeking = true;
|
|
669
|
+
cardEl.classList.add("peek", "ghost");
|
|
670
|
+
const kind = row && row.relayNotificationKind;
|
|
671
|
+
activeView = (kind === "task_request" || kind === "share_approval") ? "tasks" : "relays";
|
|
672
|
+
syncTabs();
|
|
673
|
+
applyView();
|
|
674
|
+
renderAll();
|
|
675
|
+
scrollEl.scrollTop = 0;
|
|
676
|
+
playTink();
|
|
677
|
+
// Leave after ~7s — but never rug-pull someone mid-reply or with the pointer on
|
|
678
|
+
// the banner; check again shortly instead (macOS banners hold while hovered too).
|
|
679
|
+
ghostTimer = setTimeout(function tick() {
|
|
680
|
+
ghostTimer = null;
|
|
681
|
+
if (interactive || quickReplyIsActive()) { ghostTimer = setTimeout(tick, 4000); return; }
|
|
682
|
+
dismissOverlay(); // rides the ghostOut exit, then main re-hides the window
|
|
683
|
+
}, 7000);
|
|
684
|
+
}
|
|
685
|
+
// Menu-bar Relay mark clicked: the card comes back FULLY OPEN (never the pill).
|
|
686
|
+
function trayOpen() {
|
|
687
|
+
const wasOffstage = cardEl.classList.contains("offstage") || cardEl.classList.contains("bye");
|
|
688
|
+
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
689
|
+
ghost = false;
|
|
690
|
+
clearPeek();
|
|
691
|
+
collapsed = false;
|
|
692
|
+
cardEl.classList.remove("collapsed");
|
|
693
|
+
if (wasOffstage) {
|
|
694
|
+
// fresh entrance: snap to expanded and replay the appear animation
|
|
695
|
+
cardEl.classList.remove("ghost", "bye", "offstage");
|
|
696
|
+
snapSize(EXPANDED.w, EXPANDED.h);
|
|
697
|
+
cardEl.style.animation = "none";
|
|
698
|
+
void cardEl.offsetWidth;
|
|
699
|
+
cardEl.style.animation = "";
|
|
700
|
+
} else {
|
|
701
|
+
// visible (maybe mid-banner): keep animation-bearing classes untouched so nothing
|
|
702
|
+
// restarts — just morph to full size on the spring.
|
|
703
|
+
springTo(EXPANDED.w, EXPANDED.h);
|
|
704
|
+
}
|
|
705
|
+
renderAll();
|
|
706
|
+
playTink();
|
|
707
|
+
}
|
|
597
708
|
// A new relay arrived. If folded, peek open like an iOS banner (~7s) then fold back.
|
|
598
709
|
// The peek routes to the tab the item lives in: task_request / share_approval -> Tasks
|
|
599
710
|
// (the clean split), everything else -> Relays.
|
|
@@ -620,6 +731,14 @@
|
|
|
620
731
|
}, 7000);
|
|
621
732
|
}
|
|
622
733
|
function openFull() {
|
|
734
|
+
if (ghost) {
|
|
735
|
+
// Engaging with a ghost banner brings the pill back for real. Keep the .ghost
|
|
736
|
+
// class (removing it while visible would restart the base entrance animation);
|
|
737
|
+
// it only influences which exit animation would play later.
|
|
738
|
+
ghost = false;
|
|
739
|
+
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
740
|
+
window.relay.undismiss();
|
|
741
|
+
}
|
|
623
742
|
clearPeek();
|
|
624
743
|
collapsed = false;
|
|
625
744
|
cardEl.classList.remove("collapsed");
|
|
@@ -627,6 +746,14 @@
|
|
|
627
746
|
playTink();
|
|
628
747
|
}
|
|
629
748
|
|
|
749
|
+
// ---------- the ✕ ----------
|
|
750
|
+
// Swallow the mousedown so the lockup's drag/tap-fold never engages.
|
|
751
|
+
closeXEl.addEventListener("mousedown", (e) => { e.stopPropagation(); });
|
|
752
|
+
closeXEl.addEventListener("click", (e) => {
|
|
753
|
+
e.stopPropagation();
|
|
754
|
+
dismissOverlay();
|
|
755
|
+
});
|
|
756
|
+
|
|
630
757
|
// ---------- drag the lockup to move, tap it to fold ----------
|
|
631
758
|
let dragging = false, offX = 0, offY = 0, downSX = 0, downSY = 0, moved = 0;
|
|
632
759
|
lockupEl.addEventListener("mousedown", (e) => {
|
|
@@ -1418,7 +1545,14 @@
|
|
|
1418
1545
|
}
|
|
1419
1546
|
|
|
1420
1547
|
window.relay.onInbox(onPayload);
|
|
1421
|
-
if (window.relay.onNewRelay) window.relay.onNewRelay((row) =>
|
|
1548
|
+
if (window.relay.onNewRelay) window.relay.onNewRelay((row, opts) => {
|
|
1549
|
+
if (opts && opts.ghost) ghostArrival(row); // pill dismissed -> free-standing banner
|
|
1550
|
+
else notifyArrival(row);
|
|
1551
|
+
});
|
|
1552
|
+
if (window.relay.onOpenFull) window.relay.onOpenFull(() => trayOpen());
|
|
1553
|
+
// The OS window was just re-shown and main re-asserted click-through; reset the
|
|
1554
|
+
// local flag so the next mousemove re-runs the interactivity handshake cleanly.
|
|
1555
|
+
if (window.relay.onShown) window.relay.onShown(() => { interactive = false; });
|
|
1422
1556
|
if (window.relay.onOpenDone) window.relay.onOpenDone((id) => stopOpening(id));
|
|
1423
1557
|
window.relay.refresh().then((p) => {
|
|
1424
1558
|
onPayload(p);
|
package/overlay/main.cjs
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
// on the card inside it, so it stays perfectly smooth. The empty area around the card is
|
|
7
7
|
// click-through, so the overlay never blocks the app behind it.
|
|
8
8
|
//
|
|
9
|
+
// Visibility: the card carries a ✕ that hides the overlay entirely (persisted). The Relay
|
|
10
|
+
// mark in the macOS menu bar (Tray) brings it back fully open. While dismissed, fresh
|
|
11
|
+
// relays still show the designed banner as a "ghost" notification — the window appears
|
|
12
|
+
// just for the banner, which fades away instead of folding back into a pill.
|
|
13
|
+
//
|
|
9
14
|
// Data:
|
|
10
15
|
// - Relay attention rows come from ${RELAY_HOME:-~/.relay-companion}/state.json under
|
|
11
16
|
// `state.packets` (staged by the companion daemon). Read it directly + fs.watch it.
|
|
@@ -14,7 +19,7 @@
|
|
|
14
19
|
// - Mutations (accept/reject/approve/decline/answer) call RelayClient with device-token
|
|
15
20
|
// auth. ack/mark-read writes state.json directly (atomic temp+rename).
|
|
16
21
|
|
|
17
|
-
const { app, BrowserWindow, ipcMain, shell, screen } = require("electron");
|
|
22
|
+
const { app, BrowserWindow, Tray, ipcMain, nativeImage, shell, screen } = require("electron");
|
|
18
23
|
const fs = require("node:fs");
|
|
19
24
|
const os = require("node:os");
|
|
20
25
|
const path = require("node:path");
|
|
@@ -36,11 +41,33 @@ const MARGIN = 8;
|
|
|
36
41
|
const DEFAULT_WEB_BASE = "https://sendrelays.com";
|
|
37
42
|
|
|
38
43
|
let win = null;
|
|
44
|
+
let tray = null;
|
|
39
45
|
let hostRunning = false; // Claude or Codex desktop app is running
|
|
40
46
|
let lastHost = null; // most-recently foregrounded host: "claude" | "codex"
|
|
41
47
|
let lastHostSeenAt = 0;
|
|
42
48
|
let lastSig = "";
|
|
43
49
|
|
|
50
|
+
// Dismissed mode: the user clicked the ✕ on the card, so the overlay stays hidden
|
|
51
|
+
// until they click the Relay menu-bar icon. Persisted across relaunches. While
|
|
52
|
+
// dismissed, a fresh relay still shows the designed notification (a "ghost" peek):
|
|
53
|
+
// the window appears just for the banner, then hides again when it finishes.
|
|
54
|
+
const OVERLAY_PREFS_PATH = path.join(RELAY_HOME, "overlay-prefs.json");
|
|
55
|
+
let dismissed = false;
|
|
56
|
+
let ghostActive = false; // a ghost notification is on screen while dismissed
|
|
57
|
+
let trayForcedVisible = false; // tray click while no host is running still shows the card
|
|
58
|
+
try {
|
|
59
|
+
dismissed = JSON.parse(fs.readFileSync(OVERLAY_PREFS_PATH, "utf8")).dismissed === true;
|
|
60
|
+
} catch {}
|
|
61
|
+
function setDismissed(value) {
|
|
62
|
+
dismissed = Boolean(value);
|
|
63
|
+
try {
|
|
64
|
+
fs.mkdirSync(RELAY_HOME, { recursive: true });
|
|
65
|
+
fs.writeFileSync(OVERLAY_PREFS_PATH, `${JSON.stringify({ dismissed }, null, 2)}\n`);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error("[overlay] prefs write failed:", error && error.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
44
71
|
// The ESM companion modules (config/client) are loaded lazily via dynamic import,
|
|
45
72
|
// because this overlay runs as CommonJS under Electron while the package is ESM.
|
|
46
73
|
let relayModules = null;
|
|
@@ -290,7 +317,20 @@ async function pushInbox(force) {
|
|
|
290
317
|
} else {
|
|
291
318
|
const fresh = rows.filter((r) => r.unread && !knownIds.has(r.id));
|
|
292
319
|
knownIds = new Set(rows.map((r) => r.id));
|
|
293
|
-
if (fresh.length && win && !win.isDestroyed())
|
|
320
|
+
if (fresh.length && win && !win.isDestroyed()) {
|
|
321
|
+
// newest arrival drives the peek. While dismissed, the same designed banner still
|
|
322
|
+
// shows — as a "ghost": the window appears for the notification only, and the
|
|
323
|
+
// renderer fades it out (never folding back into a pill) when it's done.
|
|
324
|
+
if (dismissed) {
|
|
325
|
+
if (hostRunning) {
|
|
326
|
+
ghostActive = true;
|
|
327
|
+
showOverlayWindow();
|
|
328
|
+
win.webContents.send("newRelay", fresh[0], { ghost: true });
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
win.webContents.send("newRelay", fresh[0], { ghost: false });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
294
334
|
}
|
|
295
335
|
const sig = JSON.stringify({
|
|
296
336
|
relays: rows.map((r) => [r.id, r.relayNotificationKind, r.unread, r.title, r.senderName, r.urgency]),
|
|
@@ -646,10 +686,25 @@ function anchorTopRight() {
|
|
|
646
686
|
return { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
|
|
647
687
|
}
|
|
648
688
|
|
|
689
|
+
// Show the overlay window. Electron's forwarded-mousemove stream (the thing that
|
|
690
|
+
// lets the renderer detect hover on a click-through window) can stall across a
|
|
691
|
+
// hide()/show() cycle, leaving the window permanently click-through. Re-assert
|
|
692
|
+
// setIgnoreMouseEvents on every hidden->shown transition and tell the renderer to
|
|
693
|
+
// reset its interactivity handshake.
|
|
694
|
+
function showOverlayWindow() {
|
|
695
|
+
if (!win || win.isDestroyed() || win.isVisible()) return;
|
|
696
|
+
win.showInactive();
|
|
697
|
+
win.setIgnoreMouseEvents(true, { forward: true });
|
|
698
|
+
win.webContents.send("shown");
|
|
699
|
+
}
|
|
700
|
+
|
|
649
701
|
function maybeShow() {
|
|
650
702
|
if (!win || win.isDestroyed()) return;
|
|
651
|
-
|
|
652
|
-
|
|
703
|
+
// Dismissed keeps the window hidden except while a ghost notification is up.
|
|
704
|
+
// trayForcedVisible honors an explicit menu-bar click even when no host runs.
|
|
705
|
+
const wanted = (hostRunning || trayForcedVisible) && (!dismissed || ghostActive);
|
|
706
|
+
if (wanted) {
|
|
707
|
+
showOverlayWindow();
|
|
653
708
|
} else if (win.isVisible()) {
|
|
654
709
|
win.hide();
|
|
655
710
|
}
|
|
@@ -662,6 +717,7 @@ function pollHosts() {
|
|
|
662
717
|
const claude = !err && /\/Claude\.app\/Contents\/MacOS\/Claude(\n|$)/.test(s);
|
|
663
718
|
const codex = !err && /\/Codex\.app\/Contents\/MacOS\/Codex(\n|$)/.test(s);
|
|
664
719
|
hostRunning = claude || codex;
|
|
720
|
+
if (hostRunning) trayForcedVisible = false; // normal host-based visibility takes back over
|
|
665
721
|
maybeShow();
|
|
666
722
|
});
|
|
667
723
|
frontmostBundleId((bundle) => {
|
|
@@ -730,6 +786,29 @@ function createWindow() {
|
|
|
730
786
|
setInterval(pollHosts, 1500);
|
|
731
787
|
}
|
|
732
788
|
|
|
789
|
+
// ---- menu bar (tray) -------------------------------------------------------
|
|
790
|
+
|
|
791
|
+
// The Relay mark lives in the macOS menu bar. Clicking it brings the card back
|
|
792
|
+
// FULLY OPEN (never the folded pill) — the counterpart to the card's ✕, which
|
|
793
|
+
// hides the overlay entirely. The *Template naming makes macOS tint the mark
|
|
794
|
+
// for light/dark menu bars automatically.
|
|
795
|
+
function createTray() {
|
|
796
|
+
const icon = nativeImage.createFromPath(path.join(__dirname, "relayTrayTemplate.png"));
|
|
797
|
+
if (icon.isEmpty()) {
|
|
798
|
+
console.error("[overlay] tray icon missing; menu bar reopen unavailable");
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
tray = new Tray(icon);
|
|
802
|
+
tray.setToolTip("Relay");
|
|
803
|
+
tray.on("click", () => {
|
|
804
|
+
setDismissed(false);
|
|
805
|
+
ghostActive = false;
|
|
806
|
+
if (!hostRunning) trayForcedVisible = true; // an explicit click always gets the card
|
|
807
|
+
maybeShow();
|
|
808
|
+
if (win && !win.isDestroyed()) win.webContents.send("openFull");
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
733
812
|
// ---- ipc -----------------------------------------------------------------
|
|
734
813
|
|
|
735
814
|
ipcMain.handle("relay:get", () => buildPayload());
|
|
@@ -772,6 +851,27 @@ ipcMain.on("relay:interactive", (_e, interactive) => {
|
|
|
772
851
|
ipcMain.on("relay:setPos", (_e, x, y) => {
|
|
773
852
|
if (win && !win.isDestroyed() && Number.isFinite(x) && Number.isFinite(y)) win.setPosition(Math.round(x), Math.round(y));
|
|
774
853
|
});
|
|
854
|
+
// ✕ on the card: hide the overlay entirely until the menu-bar Relay mark is clicked.
|
|
855
|
+
// The renderer plays its exit animation first, then sends this.
|
|
856
|
+
ipcMain.on("relay:dismiss", () => {
|
|
857
|
+
setDismissed(true);
|
|
858
|
+
ghostActive = false;
|
|
859
|
+
trayForcedVisible = false;
|
|
860
|
+
if (win && !win.isDestroyed() && win.isVisible()) win.hide();
|
|
861
|
+
});
|
|
862
|
+
// The user engaged with a ghost notification (tapped the lockup to expand it):
|
|
863
|
+
// the card is fully open again, so leave dismissed mode.
|
|
864
|
+
ipcMain.on("relay:undismiss", () => {
|
|
865
|
+
setDismissed(false);
|
|
866
|
+
ghostActive = false;
|
|
867
|
+
maybeShow();
|
|
868
|
+
});
|
|
869
|
+
// A ghost notification finished its exit animation; re-hide the window if the
|
|
870
|
+
// overlay is still dismissed.
|
|
871
|
+
ipcMain.on("relay:notifDone", () => {
|
|
872
|
+
ghostActive = false;
|
|
873
|
+
if (dismissed && win && !win.isDestroyed() && win.isVisible()) win.hide();
|
|
874
|
+
});
|
|
775
875
|
ipcMain.handle("relay:soundBytes", (_e, name) => {
|
|
776
876
|
const clean = String(name).replace(/[^A-Za-z]/g, "");
|
|
777
877
|
const candidates = [
|
|
@@ -799,6 +899,7 @@ if (!gotSingleInstanceLock) {
|
|
|
799
899
|
app.whenReady().then(() => {
|
|
800
900
|
if (process.platform === "darwin" && app.dock) app.dock.hide(); // accessory: no dock icon, no NC banners
|
|
801
901
|
createWindow();
|
|
902
|
+
createTray();
|
|
802
903
|
});
|
|
803
904
|
|
|
804
905
|
app.on("window-all-closed", () => {
|
package/overlay/preload.cjs
CHANGED
|
@@ -3,8 +3,10 @@ const { contextBridge, ipcRenderer } = require("electron");
|
|
|
3
3
|
contextBridge.exposeInMainWorld("relay", {
|
|
4
4
|
// streams from main -> renderer
|
|
5
5
|
onInbox: (cb) => ipcRenderer.on("inbox", (_e, payload) => cb(payload)),
|
|
6
|
-
onNewRelay: (cb) => ipcRenderer.on("newRelay", (_e, relay) => cb(relay)),
|
|
6
|
+
onNewRelay: (cb) => ipcRenderer.on("newRelay", (_e, relay, opts) => cb(relay, opts || {})),
|
|
7
7
|
onOpenDone: (cb) => ipcRenderer.on("openDone", (_e, id) => cb(id)),
|
|
8
|
+
onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // menu-bar icon clicked
|
|
9
|
+
onShown: (cb) => ipcRenderer.on("shown", () => cb()), // window re-shown; reset hover handshake
|
|
8
10
|
|
|
9
11
|
// pull the full payload on demand
|
|
10
12
|
refresh: () => ipcRenderer.invoke("relay:get"),
|
|
@@ -34,4 +36,9 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
34
36
|
interactive: (v) => ipcRenderer.send("relay:interactive", v),
|
|
35
37
|
setPos: (x, y) => ipcRenderer.send("relay:setPos", x, y),
|
|
36
38
|
soundBytes: (name) => ipcRenderer.invoke("relay:soundBytes", name),
|
|
39
|
+
|
|
40
|
+
// dismissal (the card's ✕ / the menu-bar mark / ghost notifications)
|
|
41
|
+
dismiss: () => ipcRenderer.send("relay:dismiss"),
|
|
42
|
+
undismiss: () => ipcRenderer.send("relay:undismiss"),
|
|
43
|
+
notifDone: () => ipcRenderer.send("relay:notifDone"),
|
|
37
44
|
});
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/src/mcp.js
CHANGED
|
@@ -543,7 +543,21 @@ export async function runMcpServer() {
|
|
|
543
543
|
try {
|
|
544
544
|
return await handleCall(client, req.params.name, req.params.arguments || {});
|
|
545
545
|
} catch (err) {
|
|
546
|
-
|
|
546
|
+
// Surface the API's validation detail so the calling agent can self-correct
|
|
547
|
+
// instead of guessing what "invalid_request" meant.
|
|
548
|
+
let detail = "";
|
|
549
|
+
const issues = err && err.body && Array.isArray(err.body.issues) ? err.body.issues : null;
|
|
550
|
+
if (issues && issues.length) {
|
|
551
|
+
detail =
|
|
552
|
+
"\n" +
|
|
553
|
+
issues
|
|
554
|
+
.slice(0, 8)
|
|
555
|
+
.map((i) => `- ${Array.isArray(i.path) && i.path.length ? i.path.join(".") : "(request)"}: ${i.message}`)
|
|
556
|
+
.join("\n");
|
|
557
|
+
} else if (err && err.body && typeof err.body.instruction === "string") {
|
|
558
|
+
detail = `\n${err.body.instruction}`;
|
|
559
|
+
}
|
|
560
|
+
return { content: [{ type: "text", text: `Relay error: ${err.message}${detail}` }], isError: true };
|
|
547
561
|
}
|
|
548
562
|
});
|
|
549
563
|
|
package/src/notifications.js
CHANGED
|
@@ -287,6 +287,11 @@ function senderForNotification(notification) {
|
|
|
287
287
|
// anyway via RELAY_HIDDEN_KINDS, but the staged senderName must still be coherent.)
|
|
288
288
|
if (notification.kind === "task_request") return "Your agent";
|
|
289
289
|
if (notification.kind === "share_approval") return "Your agent";
|
|
290
|
+
// Task lifecycle notices (completed/cancelled/failed) are the agent run reporting
|
|
291
|
+
// back, not a person — label them as the automation, never "Someone".
|
|
292
|
+
if (notification.kind === "task_completed") return "Your agent";
|
|
293
|
+
if (notification.kind === "task_cancelled") return "Your agent";
|
|
294
|
+
if (notification.kind === "task_failed") return "Your agent";
|
|
290
295
|
if (notification.kind === "connector_reauth") return "Relay connectors";
|
|
291
296
|
return "Someone";
|
|
292
297
|
}
|