relay-companion 0.1.44 → 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
@@ -44,6 +44,7 @@ const {
44
44
  recoverInterruptedAttentionPrefs,
45
45
  } = require("./visibility.cjs");
46
46
  const { withJsonLock } = require("../src/state-lock.cjs");
47
+ const { canonicalInboxItemId, packetIdsForCanonicalItem } = require("../src/inbox-item-id.cjs");
47
48
  const {
48
49
  reinforceSpacePresence,
49
50
  resetWindowZoom,
@@ -373,6 +374,38 @@ function visibleRelayRows(rows) {
373
374
  return rows.filter((row) => !RELAY_HIDDEN_KINDS.has(row && row.relayNotificationKind));
374
375
  }
375
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
+
376
409
  // ---- sent relays (live) --------------------------------------------------
377
410
 
378
411
  let sentCache = [];
@@ -660,18 +693,23 @@ async function deletePacket(packetId) {
660
693
  if (!packetId) return { ok: false, error: "Missing relay id." };
661
694
  const row = rowById(packetId);
662
695
  if (!row) return { ok: true };
696
+ const itemId = canonicalInboxItemId(packetId, row);
663
697
  try {
664
698
  // Server first: a transient network/API error must never make a recoverable
665
699
  // item disappear locally without actually entering Recently Deleted.
666
700
  const client = await relayClient();
667
- await client.deleteInboxItem(packetId, { idempotencyKey: idempotencyKey("inbox_delete") });
701
+ await client.deleteInboxItem(itemId, { idempotencyKey: idempotencyKey("inbox_delete") });
702
+ let removedPacketIds = [];
668
703
  withJsonLock(STATE_PATH, () => {
669
704
  const store = readStore();
670
- if (store.packets) delete store.packets[packetId];
705
+ removedPacketIds = packetIdsForCanonicalItem(store.packets, itemId);
706
+ for (const id of removedPacketIds) delete store.packets[id];
671
707
  writeStateAtomic(store);
672
708
  });
673
- presentedRelayIds.delete(packetId);
674
- activeAttentionIds.delete(packetId);
709
+ for (const id of removedPacketIds) {
710
+ presentedRelayIds.delete(id);
711
+ activeAttentionIds.delete(id);
712
+ }
675
713
  writeOverlayPrefs();
676
714
  await pushInbox(true);
677
715
  return { ok: true };
@@ -1625,6 +1663,7 @@ ipcMain.on("relay:openTask", (_e, taskId) => openTaskDetail(taskId));
1625
1663
  ipcMain.on("relay:openUrl", (_e, url) => openUrlTarget(url));
1626
1664
  ipcMain.on("relay:ack", (_e, id) => ackPacket(id));
1627
1665
  ipcMain.handle("relay:delete", (_e, id) => deletePacket(id));
1666
+ ipcMain.handle("relay:markAllRead", () => markAllVisibleRelaysRead());
1628
1667
  ipcMain.handle("relay:refreshSent", async () => {
1629
1668
  await refreshSent();
1630
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.44",
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
  }
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Old companion releases staged task notifications with a timestamp suffix.
5
+ * The API intentionally uses the stable condition id, so normalize both old
6
+ * and current rows before deleting, suppressing, or comparing them.
7
+ */
8
+ function canonicalInboxItemId(packetId, row = {}) {
9
+ const kind = String(row.relayNotificationKind || "");
10
+ if (kind === "human_question" && row.messageId) {
11
+ return `human_question:${row.messageId}`;
12
+ }
13
+ if (["task_completed", "task_cancelled", "task_failed"].includes(kind) && row.taskId) {
14
+ return `${kind}:${row.taskId}`;
15
+ }
16
+ return String(packetId || "");
17
+ }
18
+
19
+ function packetIdsForCanonicalItem(packets, canonicalItemId) {
20
+ return Object.entries(packets || {})
21
+ .filter(([packetId, row]) => canonicalInboxItemId(packetId, row) === canonicalItemId)
22
+ .map(([packetId]) => packetId);
23
+ }
24
+
25
+ module.exports = { canonicalInboxItemId, packetIdsForCanonicalItem };
@@ -4,6 +4,9 @@ import path from "node:path";
4
4
  import { currentUser, readConfig, webUrl } from "./config.js";
5
5
  import { relayBinPath } from "./install.js";
6
6
  import { withJsonLock } from "./state-lock.cjs";
7
+ import inboxItemIds from "./inbox-item-id.cjs";
8
+
9
+ const { canonicalInboxItemId } = inboxItemIds;
7
10
 
8
11
  const DEFAULT_COMPANION_STATE = {
9
12
  version: 1,
@@ -195,12 +198,20 @@ export function buildHumanNotifications({ user, tasks = [], relays = [], connect
195
198
  }
196
199
  const suppressed = new Set(inboxState.suppressedItemIds || []);
197
200
  const restoredAtByItemId = inboxState.restoredAtByItemId || {};
201
+ const readAllAtMs = inboxState.readAllAt ? new Date(inboxState.readAllAt).getTime() : Number.NaN;
198
202
  return notifications
199
203
  .filter((notification) => !suppressed.has(notification.id))
200
- .map((notification) => ({
201
- ...notification,
202
- deliveryVersion: restoredAtByItemId[notification.id] || "",
203
- }));
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
+ });
204
215
  }
205
216
 
206
217
  export function freshNotifications(ledger, notifications) {
@@ -257,8 +268,8 @@ export function removeSuppressedCompanionItems(itemIds, { statePath = companionS
257
268
  const state = readCompanionState(statePath);
258
269
  state.packets ||= {};
259
270
  let removed = 0;
260
- for (const id of suppressed) {
261
- if (!state.packets[id]) continue;
271
+ for (const [id, row] of Object.entries(state.packets)) {
272
+ if (!suppressed.has(canonicalInboxItemId(id, row))) continue;
262
273
  delete state.packets[id];
263
274
  removed += 1;
264
275
  }
@@ -267,6 +278,28 @@ export function removeSuppressedCompanionItems(itemIds, { statePath = companionS
267
278
  });
268
279
  }
269
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
+
270
303
  function accountMarker({ user = currentUser(), deviceId = readConfig().deviceId || "" } = {}) {
271
304
  return {
272
305
  userId: user?.id || "",
@@ -410,7 +443,7 @@ function relayPacketFromNotification(notification, { now = new Date().toISOStrin
410
443
  id: notification.id,
411
444
  createdAt: notification.createdAt || now,
412
445
  updatedAt: now,
413
- state: "unread",
446
+ state: notification.read ? "read" : "unread",
414
447
  sender,
415
448
  recipient: notificationRecipient(),
416
449
  source: {
@@ -445,7 +478,7 @@ export function relayCompanionRowFromNotification(
445
478
  return {
446
479
  id: notification.id,
447
480
  direction: "inbound",
448
- state: "unread",
481
+ state: notification.read ? "read" : "unread",
449
482
  kind: "message",
450
483
  // Default unknown notification kinds to a neutral "message", never "task_completed"
451
484
  // (which would falsely mark an unknown row as a finished task). main.cjs readRelays
@@ -458,6 +491,7 @@ export function relayCompanionRowFromNotification(
458
491
  briefingMarkdown: packetMarkdownForNotification(notification),
459
492
  bodyMarkdown: notification.body || notification.title || "",
460
493
  createdAt: notification.createdAt || now,
494
+ deliveryReferenceAt: notification.deliveryReferenceAt || notification.createdAt || now,
461
495
  updatedAt: now,
462
496
  stagedAt: now,
463
497
  actionUrl,
@@ -487,7 +521,7 @@ export function stageRelayCompanionItem(notification, { statePath = companionSta
487
521
  const item = {
488
522
  ...existing,
489
523
  ...relayCompanionRowFromNotification(notification, { contentPath }),
490
- state: existing.state === "read" ? "read" : "unread",
524
+ state: existing.state === "read" || packet.state === "read" ? "read" : "unread",
491
525
  };
492
526
  state.packets[item.id] = item;
493
527
  writeCompanionState(statePath, state);
@@ -599,6 +633,7 @@ export function stagePlainRelayItem(
599
633
  bodyMarkdown: packet?.bodyMarkdown || item.preview || "",
600
634
  createdAt: item.createdAt || packet?.createdAt || now,
601
635
  updatedAt: item.updatedAt || now,
636
+ deliveryReferenceAt: item.updatedAt || item.createdAt || now,
602
637
  stagedAt: now,
603
638
  actionUrl: "/app/relays",
604
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 || [],