relay-companion 0.1.45 → 0.1.46

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.
@@ -152,6 +152,14 @@
152
152
  }
153
153
  .tab.active .tab-badge { color:var(--accent); }
154
154
  .tab-badge.gone { display:none; }
155
+ .mark-all-read {
156
+ appearance:none; margin-left:auto; border:0; background:transparent; cursor:pointer;
157
+ padding:0 2px; color:var(--muted-2); font-family:var(--sans); font-size:10.5px; font-weight:500;
158
+ transition:color .15s var(--settle), opacity .15s var(--settle);
159
+ }
160
+ .mark-all-read:hover { color:var(--accent); }
161
+ .mark-all-read:disabled { opacity:.45; cursor:default; color:var(--muted-3); }
162
+ .mark-all-read.gone { visibility:hidden; pointer-events:none; }
155
163
 
156
164
  /* scrolling content layer (host for each view) */
157
165
  .scroll {
@@ -562,6 +570,7 @@
562
570
  </button>
563
571
  <button class="tab" type="button" data-view="sent">Sent</button>
564
572
  <button class="tab" type="button" data-view="contacts">Contacts</button>
573
+ <button class="mark-all-read gone" id="markAllRead" type="button">Mark all as read</button>
565
574
  </nav>
566
575
 
567
576
  <div class="scroll" id="scroll">
@@ -636,6 +645,7 @@
636
645
  const relaysListEl = document.getElementById("relaysList");
637
646
  const relaysEmptyEl = document.getElementById("relaysEmpty");
638
647
  const relaysBadgeEl = document.getElementById("relaysBadge");
648
+ const markAllReadEl = document.getElementById("markAllRead");
639
649
  const sentViewEl = document.getElementById("sentView");
640
650
  const sentListEl = document.getElementById("sentList");
641
651
  const sentEmptyEl = document.getElementById("sentEmpty");
@@ -661,7 +671,7 @@
661
671
 
662
672
  const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
663
673
  const EXPANDED = { w: 344, h: 524 };
664
- const PILL = { w: 244, h: 44 }; // wide enough for mark + word + "N unopened" + ✕
674
+ const PILL = { w: 244, h: 44 }; // wide enough for mark + word + "N unread" + ✕
665
675
  const PEEK = { w: 344, h: 118 };
666
676
 
667
677
  // ---------- zero-latency sound (Web Audio, pre-decoded) ----------
@@ -858,7 +868,7 @@
858
868
  }
859
869
  // New relays always enter the notification stack, including from a fully expanded
860
870
  // card. The main process has already pushed the new payload, so render/sizing uses
861
- // the latest unopened rows rather than the previous inbox.
871
+ // the latest unread rows rather than the previous inbox.
862
872
  function notifyArrival(rows) {
863
873
  if (peekTimer) clearTimeout(peekTimer);
864
874
  cancelBye();
@@ -1199,7 +1209,7 @@
1199
1209
 
1200
1210
  let unreadCount = 0;
1201
1211
  function refreshCountLabel() {
1202
- const label = collapsed ? `${unreadCount} unopened` : String(unreadCount);
1212
+ const label = collapsed ? `${unreadCount} unread` : String(unreadCount);
1203
1213
  if (countEl.textContent !== label) {
1204
1214
  countEl.textContent = label;
1205
1215
  if (!REDUCED) { countEl.style.animation = "none"; void countEl.offsetWidth; countEl.style.animation = "countRoll .2s var(--settle)"; }
@@ -2155,11 +2165,27 @@
2155
2165
  const unread = (payload.relays || []).filter((r) => r.unread && isRelayListKind(r)).length;
2156
2166
  setCount(unread);
2157
2167
  setBadge(relaysBadgeEl, unread);
2168
+ markAllReadEl.classList.toggle("gone", activeView !== "relays" || unread === 0);
2169
+ markAllReadEl.disabled = unread === 0;
2158
2170
  if (activeView === "relays") renderRelays();
2159
2171
  else if (activeView === "sent") renderSent();
2160
2172
  // contacts renders from its own load path
2161
2173
  }
2162
2174
 
2175
+ markAllReadEl.addEventListener("click", async () => {
2176
+ if (markAllReadEl.disabled || unreadCount === 0) return;
2177
+ markAllReadEl.disabled = true;
2178
+ markAllReadEl.textContent = "Marking…";
2179
+ const result = await window.relay.markAllRead();
2180
+ markAllReadEl.textContent = "Mark all as read";
2181
+ if (!result || !result.ok) {
2182
+ markAllReadEl.disabled = false;
2183
+ markAllReadEl.title = (result && result.error) || "Could not mark relays as read.";
2184
+ return;
2185
+ }
2186
+ markAllReadEl.title = "";
2187
+ });
2188
+
2163
2189
  function syncDismissControl() {
2164
2190
  const ui = (payload && payload.ui) || {};
2165
2191
  const canDismiss = ui.canDismiss !== false;
package/overlay/main.cjs CHANGED
@@ -374,6 +374,38 @@ function visibleRelayRows(rows) {
374
374
  return rows.filter((row) => !RELAY_HIDDEN_KINDS.has(row && row.relayNotificationKind));
375
375
  }
376
376
 
377
+ async function markAllVisibleRelaysRead() {
378
+ try {
379
+ // Server first so the website and other devices share the same read cutoff.
380
+ const client = await relayClient();
381
+ const result = await client.markAllRead({ idempotencyKey: idempotencyKey("inbox_read_all") });
382
+ const cutoffMs = new Date(result.readAllAt || "").getTime();
383
+ let marked = 0;
384
+ withJsonLock(STATE_PATH, () => {
385
+ const store = readStore();
386
+ for (const [id, row] of Object.entries(store.packets || {})) {
387
+ if (!row || row.direction !== "inbound" || row.state === "read") continue;
388
+ if (RELAY_HIDDEN_KINDS.has(row.relayNotificationKind)) continue;
389
+ const reference = row.deliveryReferenceAt ||
390
+ (row.relayNotificationKind === "plain_relay" ? row.updatedAt : row.createdAt);
391
+ const referenceMs = new Date(reference || "").getTime();
392
+ if (Number.isFinite(cutoffMs) && Number.isFinite(referenceMs) && referenceMs > cutoffMs) continue;
393
+ row.state = "read";
394
+ activeAttentionIds.delete(id);
395
+ marked += 1;
396
+ }
397
+ writeStateAtomic(store);
398
+ });
399
+ writeOverlayPrefs();
400
+ await pushInbox(true);
401
+ return { ok: true, marked, readAllAt: result.readAllAt };
402
+ } catch (error) {
403
+ const message = error && error.message ? error.message : String(error);
404
+ console.error("[overlay] mark all read failed:", message);
405
+ return { ok: false, error: message };
406
+ }
407
+ }
408
+
377
409
  // ---- sent relays (live) --------------------------------------------------
378
410
 
379
411
  let sentCache = [];
@@ -1631,6 +1663,7 @@ ipcMain.on("relay:openTask", (_e, taskId) => openTaskDetail(taskId));
1631
1663
  ipcMain.on("relay:openUrl", (_e, url) => openUrlTarget(url));
1632
1664
  ipcMain.on("relay:ack", (_e, id) => ackPacket(id));
1633
1665
  ipcMain.handle("relay:delete", (_e, id) => deletePacket(id));
1666
+ ipcMain.handle("relay:markAllRead", () => markAllVisibleRelaysRead());
1634
1667
  ipcMain.handle("relay:refreshSent", async () => {
1635
1668
  await refreshSent();
1636
1669
  await pushInbox(true);
@@ -16,6 +16,7 @@ contextBridge.exposeInMainWorld("relay", {
16
16
  openSent: (id) => ipcRenderer.send("relay:openSent", id),
17
17
  ack: (id) => ipcRenderer.send("relay:ack", id),
18
18
  deleteRelay: (id) => ipcRenderer.invoke("relay:delete", id),
19
+ markAllRead: () => ipcRenderer.invoke("relay:markAllRead"),
19
20
  openUrl: (url) => ipcRenderer.send("relay:openUrl", url),
20
21
  refreshSent: () => ipcRenderer.invoke("relay:refreshSent"),
21
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.45",
3
+ "version": "0.1.46",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/client.js CHANGED
@@ -57,6 +57,10 @@ export class RelayClient {
57
57
  return this.#req("GET", "/v1/inbox");
58
58
  }
59
59
 
60
+ markAllRead(payload = {}) {
61
+ return this.#req("POST", "/v1/inbox/read-all", payload);
62
+ }
63
+
60
64
  sent() {
61
65
  return this.#req("GET", "/v1/sent");
62
66
  }
@@ -198,12 +198,20 @@ export function buildHumanNotifications({ user, tasks = [], relays = [], connect
198
198
  }
199
199
  const suppressed = new Set(inboxState.suppressedItemIds || []);
200
200
  const restoredAtByItemId = inboxState.restoredAtByItemId || {};
201
+ const readAllAtMs = inboxState.readAllAt ? new Date(inboxState.readAllAt).getTime() : Number.NaN;
201
202
  return notifications
202
203
  .filter((notification) => !suppressed.has(notification.id))
203
- .map((notification) => ({
204
- ...notification,
205
- deliveryVersion: restoredAtByItemId[notification.id] || "",
206
- }));
204
+ .map((notification) => {
205
+ const deliveryVersion = restoredAtByItemId[notification.id] || "";
206
+ const deliveryReferenceAt = deliveryVersion || notification.createdAt || "";
207
+ const deliveryMs = new Date(deliveryReferenceAt).getTime();
208
+ return {
209
+ ...notification,
210
+ deliveryVersion,
211
+ deliveryReferenceAt,
212
+ read: Number.isFinite(readAllAtMs) && Number.isFinite(deliveryMs) && deliveryMs <= readAllAtMs,
213
+ };
214
+ });
207
215
  }
208
216
 
209
217
  export function freshNotifications(ledger, notifications) {
@@ -270,6 +278,28 @@ export function removeSuppressedCompanionItems(itemIds, { statePath = companionS
270
278
  });
271
279
  }
272
280
 
281
+ /** Reconcile an account-wide mark-all-read watermark onto already-staged rows. */
282
+ export function markCompanionItemsReadThrough(readAllAt, { statePath = companionStatePath() } = {}) {
283
+ const cutoffMs = new Date(readAllAt || "").getTime();
284
+ if (!Number.isFinite(cutoffMs)) return 0;
285
+ return withJsonLock(statePath, () => {
286
+ const state = readCompanionState(statePath);
287
+ state.packets ||= {};
288
+ let changed = 0;
289
+ for (const row of Object.values(state.packets)) {
290
+ if (!row || row.direction !== "inbound" || row.state === "read") continue;
291
+ const reference = row.deliveryReferenceAt ||
292
+ (row.relayNotificationKind === "plain_relay" ? row.updatedAt : row.createdAt);
293
+ const referenceMs = new Date(reference || "").getTime();
294
+ if (!Number.isFinite(referenceMs) || referenceMs > cutoffMs) continue;
295
+ row.state = "read";
296
+ changed += 1;
297
+ }
298
+ if (changed) writeCompanionState(statePath, state);
299
+ return changed;
300
+ });
301
+ }
302
+
273
303
  function accountMarker({ user = currentUser(), deviceId = readConfig().deviceId || "" } = {}) {
274
304
  return {
275
305
  userId: user?.id || "",
@@ -413,7 +443,7 @@ function relayPacketFromNotification(notification, { now = new Date().toISOStrin
413
443
  id: notification.id,
414
444
  createdAt: notification.createdAt || now,
415
445
  updatedAt: now,
416
- state: "unread",
446
+ state: notification.read ? "read" : "unread",
417
447
  sender,
418
448
  recipient: notificationRecipient(),
419
449
  source: {
@@ -448,7 +478,7 @@ export function relayCompanionRowFromNotification(
448
478
  return {
449
479
  id: notification.id,
450
480
  direction: "inbound",
451
- state: "unread",
481
+ state: notification.read ? "read" : "unread",
452
482
  kind: "message",
453
483
  // Default unknown notification kinds to a neutral "message", never "task_completed"
454
484
  // (which would falsely mark an unknown row as a finished task). main.cjs readRelays
@@ -461,6 +491,7 @@ export function relayCompanionRowFromNotification(
461
491
  briefingMarkdown: packetMarkdownForNotification(notification),
462
492
  bodyMarkdown: notification.body || notification.title || "",
463
493
  createdAt: notification.createdAt || now,
494
+ deliveryReferenceAt: notification.deliveryReferenceAt || notification.createdAt || now,
464
495
  updatedAt: now,
465
496
  stagedAt: now,
466
497
  actionUrl,
@@ -490,7 +521,7 @@ export function stageRelayCompanionItem(notification, { statePath = companionSta
490
521
  const item = {
491
522
  ...existing,
492
523
  ...relayCompanionRowFromNotification(notification, { contentPath }),
493
- state: existing.state === "read" ? "read" : "unread",
524
+ state: existing.state === "read" || packet.state === "read" ? "read" : "unread",
494
525
  };
495
526
  state.packets[item.id] = item;
496
527
  writeCompanionState(statePath, state);
@@ -602,6 +633,7 @@ export function stagePlainRelayItem(
602
633
  bodyMarkdown: packet?.bodyMarkdown || item.preview || "",
603
634
  createdAt: item.createdAt || packet?.createdAt || now,
604
635
  updatedAt: item.updatedAt || now,
636
+ deliveryReferenceAt: item.updatedAt || item.createdAt || now,
605
637
  stagedAt: now,
606
638
  actionUrl: "/app/relays",
607
639
  action: { type: "open", url: "/app/relays" },
@@ -14,6 +14,7 @@ import {
14
14
  defaultStageRelayCompanionItem,
15
15
  freshNotifications,
16
16
  markNotificationsProcessed,
17
+ markCompanionItemsReadThrough,
17
18
  removeSuppressedCompanionItems,
18
19
  reconcileAnsweredQuestions,
19
20
  stagePlainRelayItem as defaultStagePlainRelayItem,
@@ -174,6 +175,8 @@ async function pollHumanNotifications({ client, ledger, stageCompanionItem, stag
174
175
  const events = await pollVisibleTaskEvents({ client, ledger, tasks, log });
175
176
  const removed = removeSuppressedCompanionItems(relays.inboxState?.suppressedItemIds || []);
176
177
  if (removed) log(`removed ${removed} Relay item(s) moved to Recently Deleted elsewhere`);
178
+ const markedRead = markCompanionItemsReadThrough(relays.inboxState?.readAllAt);
179
+ if (markedRead) log(`marked ${markedRead} Relay item(s) read from another surface`);
177
180
  const notifications = buildHumanNotifications({
178
181
  user: me.user,
179
182
  tasks: tasks.tasks || [],