relay-companion 0.1.61 → 0.1.63

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.
@@ -241,9 +241,10 @@
241
241
  .rk-subject {
242
242
  font-family:var(--serif); font-size:15px; line-height:1.3; color:var(--muted); font-weight:400;
243
243
  margin-top:5px;
244
- white-space:nowrap; overflow:hidden;
245
- -webkit-mask-image:linear-gradient(to right, #000 calc(100% - 26px), transparent);
246
- mask-image:linear-gradient(to right, #000 calc(100% - 26px), transparent);
244
+ /* Two full lines with an ellipsis: titles must be scannable when choosing
245
+ which relay to open — the old right-edge fade cut them after a few words. */
246
+ overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical;
247
+ overflow-wrap:anywhere;
247
248
  transition:color .28s var(--settle);
248
249
  }
249
250
  .row.unread .rk-subject { color:var(--ink); font-weight:500; }
@@ -560,11 +561,13 @@
560
561
  .th-time { flex:0 0 auto; margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--muted-2); font-variant-numeric:tabular-nums; padding-left:6px; }
561
562
  .th-title {
562
563
  font-family:var(--serif); font-size:14px; line-height:1.3; color:var(--muted); margin-top:4px;
563
- white-space:nowrap; overflow:hidden;
564
- -webkit-mask-image:linear-gradient(to right, #000 calc(100% - 26px), transparent);
565
- mask-image:linear-gradient(to right, #000 calc(100% - 26px), transparent);
564
+ overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical;
565
+ overflow-wrap:anywhere;
566
566
  }
567
567
  .th-item.unread .th-title { color:var(--ink); font-weight:500; }
568
+ /* Conversations you started that have no reply yet sit quietly in the list. */
569
+ .th-item.th-sent-only .th-party { color:var(--muted); font-weight:500; }
570
+ .th-item.th-sent-only .th-title { color:var(--muted-2); }
568
571
  .th-detail-head { display:flex; align-items:center; gap:9px; padding:10px 16px 8px; border-bottom:1px solid var(--hair-2); }
569
572
  .th-back {
570
573
  appearance:none; border:0; background:transparent; cursor:pointer; padding:0;
@@ -582,9 +585,8 @@
582
585
  .th-msg-top { display:flex; align-items:baseline; gap:7px; min-width:0; }
583
586
  .th-dir { flex:0 0 auto; font-family:var(--mono); font-size:11px; line-height:1; align-self:center; color:var(--muted-2); }
584
587
  .th-dir.out { color:var(--accent); }
585
- .th-msg-title { font-family:var(--serif); font-size:14px; line-height:1.3; color:var(--muted); margin-top:4px; white-space:nowrap; overflow:hidden;
586
- -webkit-mask-image:linear-gradient(to right, #000 calc(100% - 26px), transparent);
587
- mask-image:linear-gradient(to right, #000 calc(100% - 26px), transparent); }
588
+ .th-msg-title { font-family:var(--serif); font-size:14px; line-height:1.3; color:var(--muted); margin-top:4px;
589
+ overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow-wrap:anywhere; }
588
590
  .th-msg.unread .th-msg-title { color:var(--ink); font-weight:500; }
589
591
 
590
592
  /* Sent-row read receipt chip: "read" sits in a light-green enclosure, "unread" stays quiet. */
@@ -630,7 +632,6 @@
630
632
  <button class="tab active" type="button" data-view="relays">
631
633
  Relays <span class="tab-badge gone" id="relaysBadge">0</span>
632
634
  </button>
633
- <button class="tab" type="button" data-view="threads">Threads</button>
634
635
  <button class="tab" type="button" data-view="sent">Sent</button>
635
636
  <button class="tab" type="button" data-view="contacts">Contacts</button>
636
637
  <button class="mark-all-read gone" id="markAllRead" type="button">Mark all as read</button>
@@ -1428,51 +1429,87 @@
1428
1429
  if (collapsed && !peeking) { pendingRelaysRender = true; return; }
1429
1430
  pendingRelaysRender = false;
1430
1431
  const allRows = (payload.relays || []).filter(isRelayListKind);
1431
- const rows = peeking ? allRows.filter((row) => row.unread) : allRows;
1432
- relaysEmptyEl.classList.toggle("gone", rows.length > 0);
1432
+ if (peeking) {
1433
+ // Notification mode is unchanged: flat unread relay rows, newest first.
1434
+ const rows = allRows.filter((row) => row.unread);
1435
+ relaysEmptyEl.classList.toggle("gone", rows.length > 0);
1436
+ const seen = prevRelayIds;
1437
+ relaysListEl.innerHTML = rows.map((r) => relayRowHtml(r, seen)).join("");
1438
+ prevRelayIds = new Set(rows.map((r) => r.id));
1439
+ wireRelayRows();
1440
+ return;
1441
+ }
1442
+ // Conversation list: everything is a thread; most threads have one message.
1443
+ // A single-inbound-message thread renders as the classic relay row (with the
1444
+ // expand-card open actions); anything with replies — or that only exists on
1445
+ // the sent side — renders as a conversation row that drills into the thread.
1446
+ const byId = new Map(allRows.map((r) => [r.id, r]));
1447
+ const threads = buildThreads();
1448
+ relaysEmptyEl.classList.toggle("gone", threads.length > 0);
1433
1449
  const seen = prevRelayIds;
1434
- relaysListEl.innerHTML = rows.map((r) => {
1435
- const fresh = seen.size > 0 && !seen.has(r.id);
1436
- const kind = r.relayNotificationKind;
1437
- const subject = relaySubject(r);
1438
- const sender = relaySender(r);
1439
- const actions = relayActionsHtml(r);
1440
- // result / task_completed / plain message carry a body preview beneath the subject.
1441
- // (human_question's body IS the subject, so no duplicate preview there.)
1442
- const body = r.bodyMarkdown || r.body || "";
1443
- const preview = kind === "human_question" ? "" : bodyPreview(body, subject, 110);
1444
- const previewHtml = preview ? `<div class="rk-preview">${esc(preview)}</div>` : "";
1445
- const canDelete = kind === "plain_relay" || kind === "human_question" || kind === "task_completed" || kind === "task_cancelled" || kind === "task_failed";
1446
- const deleteButton = canDelete ? `<button class="row-delete" type="button" data-delete="${esc(r.id)}" data-stop="1" aria-label="Move to Recently Deleted" title="Move to Recently Deleted"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button>` : "";
1447
- // Row click toggles this open-actions card; the buttons carry the actual
1448
- // opens ("current" injects into the live Claude session, "new" forces a
1449
- // fresh host session).
1450
- const expanded = r.id === expandedRelayId;
1451
- const openCard = expanded ? `
1452
- <div class="td-card row-open-card" data-stop="1">
1453
- <div class="td-card-actions">
1454
- <button class="td-btn primary" type="button" data-open-current="${esc(r.id)}">Open in current chat</button>
1455
- <button class="td-btn ghost" type="button" data-open-fresh="${esc(r.id)}">Open in new chat</button>
1456
- </div>
1457
- </div>` : "";
1450
+ relaysListEl.innerHTML = threads.map((t) => {
1451
+ const inbound = t.msgs.filter((m) => m.direction === "in");
1452
+ if (t.msgs.length === 1 && inbound.length === 1 && byId.has(inbound[0].id)) {
1453
+ return relayRowHtml(byId.get(inbound[0].id), seen);
1454
+ }
1455
+ const sentOnly = inbound.length === 0;
1458
1456
  return `
1459
- <div class="row ${r.unread ? "unread" : ""}${fresh ? " enter" : ""}${canDelete ? " deletable" : ""}${expanded ? " expanded" : ""}" data-id="${esc(r.id)}" data-opening-id="${esc(r.id)}">
1460
- ${deleteButton}
1461
- <div class="rk-top">
1462
- <span class="rk-dot"></span>
1463
- <span class="rk-from">From</span>
1464
- <span class="rk-sender">${esc(sender)}</span>
1465
- <span class="rk-time">${timeAgo(r.createdAt)}</span>
1466
- </div>
1467
- <div class="rk-subject${kind === "human_question" ? " q" : ""}">${esc(subject)}</div>
1468
- ${previewHtml}
1469
- ${actions}
1470
- ${openCard}
1471
- <div class="row-err" data-err="${esc(r.id)}"></div>
1472
- </div>`;
1457
+ <button class="th-item${t.unreadCount ? " unread" : ""}${sentOnly ? " th-sent-only" : ""}" type="button" data-thread="${esc(t.threadId)}">
1458
+ <span class="th-top">
1459
+ ${t.unreadCount ? '<span class="th-unread"></span>' : ""}
1460
+ <span class="th-party">${sentOnly ? "To " : ""}${esc(t.party)}</span>
1461
+ ${t.msgs.length > 1 ? `<span class="th-count">${t.msgs.length}</span>` : ""}
1462
+ <span class="th-time">${esc(timeAgo(t.latest.at))}</span>
1463
+ </span>
1464
+ <span class="th-title">${esc(t.latest.title)}</span>
1465
+ </button>`;
1473
1466
  }).join("");
1474
- prevRelayIds = new Set(rows.map((r) => r.id));
1467
+ prevRelayIds = new Set(allRows.map((r) => r.id));
1475
1468
  wireRelayRows();
1469
+ for (const el of relaysListEl.querySelectorAll(".th-item")) {
1470
+ el.addEventListener("click", () => openThreadDetail(el.getAttribute("data-thread")));
1471
+ }
1472
+ }
1473
+
1474
+ // The classic relay row (used flat while peeking, and for single-message
1475
+ // conversations in the unified list). Row click toggles the open-actions
1476
+ // card; the buttons carry the actual opens.
1477
+ function relayRowHtml(r, seen) {
1478
+ const fresh = seen.size > 0 && !seen.has(r.id);
1479
+ const kind = r.relayNotificationKind;
1480
+ const subject = relaySubject(r);
1481
+ const sender = relaySender(r);
1482
+ const actions = relayActionsHtml(r);
1483
+ // result / task_completed / plain message carry a body preview beneath the subject.
1484
+ // (human_question's body IS the subject, so no duplicate preview there.)
1485
+ const body = r.bodyMarkdown || r.body || "";
1486
+ const preview = kind === "human_question" ? "" : bodyPreview(body, subject, 110);
1487
+ const previewHtml = preview ? `<div class="rk-preview">${esc(preview)}</div>` : "";
1488
+ const canDelete = kind === "plain_relay" || kind === "human_question" || kind === "task_completed" || kind === "task_cancelled" || kind === "task_failed";
1489
+ const deleteButton = canDelete ? `<button class="row-delete" type="button" data-delete="${esc(r.id)}" data-stop="1" aria-label="Move to Recently Deleted" title="Move to Recently Deleted"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button>` : "";
1490
+ const expanded = r.id === expandedRelayId;
1491
+ const openCard = expanded ? `
1492
+ <div class="td-card row-open-card" data-stop="1">
1493
+ <div class="td-card-actions">
1494
+ <button class="td-btn primary" type="button" data-open-current="${esc(r.id)}">Open in current chat</button>
1495
+ <button class="td-btn ghost" type="button" data-open-fresh="${esc(r.id)}">Open in new chat</button>
1496
+ </div>
1497
+ </div>` : "";
1498
+ return `
1499
+ <div class="row ${r.unread ? "unread" : ""}${fresh ? " enter" : ""}${canDelete ? " deletable" : ""}${expanded ? " expanded" : ""}" data-id="${esc(r.id)}" data-opening-id="${esc(r.id)}">
1500
+ ${deleteButton}
1501
+ <div class="rk-top">
1502
+ <span class="rk-dot"></span>
1503
+ <span class="rk-from">From</span>
1504
+ <span class="rk-sender">${esc(sender)}</span>
1505
+ <span class="rk-time">${timeAgo(r.createdAt)}</span>
1506
+ </div>
1507
+ <div class="rk-subject${kind === "human_question" ? " q" : ""}">${esc(subject)}</div>
1508
+ ${previewHtml}
1509
+ ${actions}
1510
+ ${openCard}
1511
+ <div class="row-err" data-err="${esc(r.id)}"></div>
1512
+ </div>`;
1476
1513
  }
1477
1514
 
1478
1515
  // The white up-arrow inside the round prussian Send button.
@@ -2359,9 +2396,12 @@
2359
2396
  unread: Boolean(r.unread),
2360
2397
  });
2361
2398
  }
2399
+ // A self-send exists on BOTH sides with the same relay id; keep only the
2400
+ // inbound copy (the richer local row) so the thread doesn't show doubles.
2401
+ const inboundIds = new Set(msgs.map((m) => m.id));
2362
2402
  for (const s of payload.sent || []) {
2363
2403
  const id = s.relayId || s.id;
2364
- if (!id) continue;
2404
+ if (!id || inboundIds.has(id)) continue;
2365
2405
  msgs.push({
2366
2406
  id, threadId: s.threadId || id, direction: "out",
2367
2407
  title: sentSubject(s), party: sentRecipient(s), at: s.createdAt,
@@ -2440,12 +2480,23 @@
2440
2480
  }
2441
2481
  thBackEl.addEventListener("click", () => {
2442
2482
  threadDetailId = null;
2443
- renderThreads();
2483
+ activeView = "relays";
2484
+ syncTabs();
2485
+ applyView();
2486
+ renderAll();
2444
2487
  });
2488
+ function openThreadDetail(threadId) {
2489
+ threadDetailId = threadId;
2490
+ activeView = "threads";
2491
+ syncTabs();
2492
+ applyView();
2493
+ }
2445
2494
 
2446
2495
  // ---------- views / tabs ----------
2447
2496
  function syncTabs() {
2448
- const tabView = activeView === "taskDetail" ? "sent" : activeView;
2497
+ // taskDetail belongs to Sent; the thread-detail view belongs to Relays —
2498
+ // conversations ARE the Relays tab now, a thread is just its drill-down.
2499
+ const tabView = activeView === "taskDetail" ? "sent" : activeView === "threads" ? "relays" : activeView;
2449
2500
  for (const tab of tabEls) tab.classList.toggle("active", tab.getAttribute("data-view") === tabView);
2450
2501
  }
2451
2502
  function applyView() {
@@ -2455,10 +2506,13 @@
2455
2506
  taskDetailViewEl.classList.toggle("hidden", activeView !== "taskDetail");
2456
2507
  contactsViewEl.classList.toggle("hidden", activeView !== "contacts");
2457
2508
  if (activeView === "threads") {
2458
- threadDetailId = null; // always land on the thread list
2509
+ // Entered only by clicking a conversation row (threadDetailId set there).
2459
2510
  renderThreads();
2460
2511
  if (window.relay.refreshSent) window.relay.refreshSent().catch(() => {}); // outbound half of threads
2461
2512
  }
2513
+ if (activeView === "relays" && window.relay.refreshSent) {
2514
+ window.relay.refreshSent().catch(() => {}); // conversations include the sent side
2515
+ }
2462
2516
  if (activeView !== "taskDetail" && taskDetailTimer) {
2463
2517
  clearInterval(taskDetailTimer);
2464
2518
  taskDetailTimer = null;
package/overlay/main.cjs CHANGED
@@ -358,6 +358,20 @@ function isRelayTaskWebTarget(pathOrUrl) {
358
358
 
359
359
  // ---- state.json reading (Relay attention rows) ---------------------------
360
360
 
361
+ // The daemon's staging ledger (~/.relay/task-ledger.json plainRelays map) must be
362
+ // invalidated whenever the packet store is reset, or its dedupe permanently blocks
363
+ // re-staging everything the wipe destroyed.
364
+ function clearPlainRelayLedger() {
365
+ try {
366
+ const dir = process.env.RELAY_CONFIG_DIR || path.join(os.homedir(), ".relay");
367
+ const ledgerPath = path.join(dir, "task-ledger.json");
368
+ const ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf8")) || {};
369
+ ledger.plainRelays = {};
370
+ fs.writeFileSync(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`);
371
+ console.error(`[overlay] ${new Date().toISOString()} cleared plainRelays ledger after store reset`);
372
+ } catch {}
373
+ }
374
+
361
375
  function emptyStoreFor(current) {
362
376
  return {
363
377
  version: 1,
@@ -392,6 +406,7 @@ function scheduleAccountReset(current) {
392
406
  if (!stillWrongUser && !stillWrongEmail) return; // a concurrent writer fixed it; wipe nothing
393
407
  console.error(`[overlay] ${new Date().toISOString()} account mismatch: resetting local store`);
394
408
  writeStateAtomic(emptyStoreFor(current));
409
+ clearPlainRelayLedger(); // a wiped store makes the dedupe ledger a lie: without this, the daemon never re-stages the server's relays and the inbox stays empty forever
395
410
  });
396
411
  } finally {
397
412
  accountResetScheduled = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
4
4
  "description": "Relay companion for ordinary messages, with dormant coordination features available only by explicit opt-in.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { currentUser, readConfig, webUrl } from "./config.js";
4
+ import { currentUser, readConfig, taskLedgerPath, webUrl } from "./config.js";
5
5
  import { relayBinPath } from "./install.js";
6
6
  import { withJsonLock, withJsonLockStrict } from "./state-lock.cjs";
7
7
  import inboxItemIds from "./inbox-item-id.cjs";
@@ -340,6 +340,15 @@ export function resetCompanionStateForAccount(
340
340
  },
341
341
  };
342
342
  writeCompanionState(statePath, next);
343
+ // The staging ledger belongs to the store that was just destroyed. Left
344
+ // intact, its dedupe would permanently block re-staging every relay the
345
+ // reset wiped, leaving the inbox empty while the server still has them.
346
+ try {
347
+ const ledgerFile = taskLedgerPath();
348
+ const ledger = JSON.parse(fs.readFileSync(ledgerFile, "utf8")) || {};
349
+ ledger.plainRelays = {};
350
+ fs.writeFileSync(ledgerFile, `${JSON.stringify(ledger, null, 2)}\n`);
351
+ } catch {}
343
352
  return { reset: true, statePath };
344
353
  });
345
354
  }