relay-companion 0.1.7 → 0.1.8

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.
@@ -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: 168, h: 44 };
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,11 @@
1418
1545
  }
1419
1546
 
1420
1547
  window.relay.onInbox(onPayload);
1421
- if (window.relay.onNewRelay) window.relay.onNewRelay((row) => notifyArrival(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());
1422
1553
  if (window.relay.onOpenDone) window.relay.onOpenDone((id) => stopOpening(id));
1423
1554
  window.relay.refresh().then((p) => {
1424
1555
  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()) win.webContents.send("newRelay", fresh[0]); // newest arrival drives the peek
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
+ if (!win.isVisible()) win.showInactive();
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]),
@@ -648,7 +688,10 @@ function anchorTopRight() {
648
688
 
649
689
  function maybeShow() {
650
690
  if (!win || win.isDestroyed()) return;
651
- if (hostRunning) {
691
+ // Dismissed keeps the window hidden except while a ghost notification is up.
692
+ // trayForcedVisible honors an explicit menu-bar click even when no host runs.
693
+ const wanted = (hostRunning || trayForcedVisible) && (!dismissed || ghostActive);
694
+ if (wanted) {
652
695
  if (!win.isVisible()) win.showInactive();
653
696
  } else if (win.isVisible()) {
654
697
  win.hide();
@@ -662,6 +705,7 @@ function pollHosts() {
662
705
  const claude = !err && /\/Claude\.app\/Contents\/MacOS\/Claude(\n|$)/.test(s);
663
706
  const codex = !err && /\/Codex\.app\/Contents\/MacOS\/Codex(\n|$)/.test(s);
664
707
  hostRunning = claude || codex;
708
+ if (hostRunning) trayForcedVisible = false; // normal host-based visibility takes back over
665
709
  maybeShow();
666
710
  });
667
711
  frontmostBundleId((bundle) => {
@@ -730,6 +774,29 @@ function createWindow() {
730
774
  setInterval(pollHosts, 1500);
731
775
  }
732
776
 
777
+ // ---- menu bar (tray) -------------------------------------------------------
778
+
779
+ // The Relay mark lives in the macOS menu bar. Clicking it brings the card back
780
+ // FULLY OPEN (never the folded pill) — the counterpart to the card's ✕, which
781
+ // hides the overlay entirely. The *Template naming makes macOS tint the mark
782
+ // for light/dark menu bars automatically.
783
+ function createTray() {
784
+ const icon = nativeImage.createFromPath(path.join(__dirname, "relayTrayTemplate.png"));
785
+ if (icon.isEmpty()) {
786
+ console.error("[overlay] tray icon missing; menu bar reopen unavailable");
787
+ return;
788
+ }
789
+ tray = new Tray(icon);
790
+ tray.setToolTip("Relay");
791
+ tray.on("click", () => {
792
+ setDismissed(false);
793
+ ghostActive = false;
794
+ if (!hostRunning) trayForcedVisible = true; // an explicit click always gets the card
795
+ maybeShow();
796
+ if (win && !win.isDestroyed()) win.webContents.send("openFull");
797
+ });
798
+ }
799
+
733
800
  // ---- ipc -----------------------------------------------------------------
734
801
 
735
802
  ipcMain.handle("relay:get", () => buildPayload());
@@ -772,6 +839,27 @@ ipcMain.on("relay:interactive", (_e, interactive) => {
772
839
  ipcMain.on("relay:setPos", (_e, x, y) => {
773
840
  if (win && !win.isDestroyed() && Number.isFinite(x) && Number.isFinite(y)) win.setPosition(Math.round(x), Math.round(y));
774
841
  });
842
+ // ✕ on the card: hide the overlay entirely until the menu-bar Relay mark is clicked.
843
+ // The renderer plays its exit animation first, then sends this.
844
+ ipcMain.on("relay:dismiss", () => {
845
+ setDismissed(true);
846
+ ghostActive = false;
847
+ trayForcedVisible = false;
848
+ if (win && !win.isDestroyed() && win.isVisible()) win.hide();
849
+ });
850
+ // The user engaged with a ghost notification (tapped the lockup to expand it):
851
+ // the card is fully open again, so leave dismissed mode.
852
+ ipcMain.on("relay:undismiss", () => {
853
+ setDismissed(false);
854
+ ghostActive = false;
855
+ maybeShow();
856
+ });
857
+ // A ghost notification finished its exit animation; re-hide the window if the
858
+ // overlay is still dismissed.
859
+ ipcMain.on("relay:notifDone", () => {
860
+ ghostActive = false;
861
+ if (dismissed && win && !win.isDestroyed() && win.isVisible()) win.hide();
862
+ });
775
863
  ipcMain.handle("relay:soundBytes", (_e, name) => {
776
864
  const clean = String(name).replace(/[^A-Za-z]/g, "");
777
865
  const candidates = [
@@ -799,6 +887,7 @@ if (!gotSingleInstanceLock) {
799
887
  app.whenReady().then(() => {
800
888
  if (process.platform === "darwin" && app.dock) app.dock.hide(); // accessory: no dock icon, no NC banners
801
889
  createWindow();
890
+ createTray();
802
891
  });
803
892
 
804
893
  app.on("window-all-closed", () => {
@@ -3,8 +3,9 @@ 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
8
9
 
9
10
  // pull the full payload on demand
10
11
  refresh: () => ipcRenderer.invoke("relay:get"),
@@ -34,4 +35,9 @@ contextBridge.exposeInMainWorld("relay", {
34
35
  interactive: (v) => ipcRenderer.send("relay:interactive", v),
35
36
  setPos: (x, y) => ipcRenderer.send("relay:setPos", x, y),
36
37
  soundBytes: (name) => ipcRenderer.invoke("relay:soundBytes", name),
38
+
39
+ // dismissal (the card's ✕ / the menu-bar mark / ghost notifications)
40
+ dismiss: () => ipcRenderer.send("relay:dismiss"),
41
+ undismiss: () => ipcRenderer.send("relay:undismiss"),
42
+ notifDone: () => ipcRenderer.send("relay:notifDone"),
37
43
  });
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {