relay-companion 0.1.89 → 0.1.91

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.
@@ -251,11 +251,11 @@
251
251
 
252
252
  /* subject: single-line serif, right-edge fade mask */
253
253
  .rk-subject {
254
- font-family:var(--serif); font-size:15px; line-height:1.3; color:var(--muted); font-weight:400;
254
+ font-family:var(--serif); font-size:15px; line-height:1.32; color:var(--muted); font-weight:400;
255
255
  margin-top:5px;
256
256
  /* Two full lines with an ellipsis: titles must be scannable when choosing
257
257
  which relay to open — the old right-edge fade cut them after a few words. */
258
- overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical;
258
+ overflow:hidden; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical;
259
259
  overflow-wrap:anywhere;
260
260
  transition:color .28s var(--settle);
261
261
  }
@@ -703,7 +703,8 @@
703
703
  the reused .open-actions light-built panel — never from borders. */
704
704
  .sv-head { display:flex; align-items:center; justify-content:space-between; padding:10px 16px 8px; }
705
705
  .sv-title { font-family:var(--serif); font-size:16px; font-weight:500; color:var(--ink); }
706
- .sv-account { display:flex; align-items:center; gap:12px; padding:4px 16px 0; }
706
+ /* First element in the pane now (no header above), so it owns the top inset. */
707
+ .sv-account { display:flex; align-items:center; gap:12px; padding:16px 16px 0; }
707
708
  .sv-avatar {
708
709
  flex:0 0 auto; width:38px; height:38px; border-radius:50%;
709
710
  background:var(--accent-soft); color:var(--accent);
@@ -742,6 +743,8 @@
742
743
  .sv-err { margin-top:8px; padding:0 16px; }
743
744
  .sv-note { padding:10px 16px 0; font-size:11.5px; color:#1a7f37; line-height:1.4; }
744
745
  .sv-version { padding:20px 16px 12px; font-family:var(--mono); font-size:10.5px; color:var(--muted-3); }
746
+ .sv-update { display:flex; flex-direction:column; gap:8px; align-items:flex-start; }
747
+ .sv-update-btn { width:auto; padding:7px 12px; border-radius:8px; font-family:var(--sans); font-size:12px; }
745
748
  /* signed-out call-to-action on the Relays empty state — quiet filled pill (th-count language) */
746
749
  .sv-signin-banner {
747
750
  appearance:none; border:0; cursor:pointer; margin-top:2px;
@@ -1561,6 +1564,10 @@
1561
1564
  // The relay row whose open-actions card is expanded. Like openingIds, this
1562
1565
  // survives the innerHTML rebuilds renderRelays performs on every push.
1563
1566
  let expandedRelayId = null;
1567
+ // A conversation expands its actions IN PLACE, exactly like a single relay.
1568
+ // Taking over the whole pane (the old thread-detail push) lost the user's
1569
+ // place in the list for what is really a three-item menu.
1570
+ let expandedThreadId = null;
1564
1571
  function openingTargets(id) {
1565
1572
  return [...document.querySelectorAll("[data-opening-id]")].filter((e) => e.getAttribute("data-opening-id") === id);
1566
1573
  }
@@ -1676,8 +1683,13 @@
1676
1683
  if (t.msgs.length === 1 && inbound.length === 1 && byId.has(inbound[0].id)) {
1677
1684
  return relayRowHtml(byId.get(inbound[0].id), seen);
1678
1685
  }
1686
+ const tExpanded = t.threadId === expandedThreadId;
1687
+ const newestInbound = [...t.msgs].reverse().find((m) => m.direction === "in") || t.msgs[t.msgs.length - 1];
1688
+ const tMenu = tExpanded && newestInbound
1689
+ ? openActionsHtml(newestInbound.id, { thread: true, threadId: t.threadId, messageCount: t.msgs.length })
1690
+ : "";
1679
1691
  return `
1680
- <div class="th-item${t.unreadCount ? " unread" : ""} deletable" role="button" tabindex="0" data-thread="${esc(t.threadId)}" data-party="${esc(t.party)}">
1692
+ <div class="th-item${t.unreadCount ? " unread" : ""}${tExpanded ? " expanded" : ""} deletable" role="button" tabindex="0" data-thread="${esc(t.threadId)}" data-party="${esc(t.party)}">
1681
1693
  ${threadDeleteButton(t)}
1682
1694
  <span class="th-top">
1683
1695
  ${t.unreadCount ? '<span class="th-unread"></span>' : ""}
@@ -1686,13 +1698,21 @@
1686
1698
  <span class="th-time">${esc(timeAgo(t.latest.at))}</span>
1687
1699
  </span>
1688
1700
  <span class="th-title">${esc(t.latest.title)}</span>
1701
+ ${tMenu}
1702
+ ${newestInbound ? `<div class="row-err${rowNotes.get(newestInbound.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(newestInbound.id)}" data-stop="1">${esc(rowNotes.get(newestInbound.id)?.text || "")}</div>` : ""}
1689
1703
  </div>`;
1690
1704
  }).join("");
1691
1705
  prevRelayIds = new Set(allRows.map((r) => r.id));
1692
1706
  wireRelayRows();
1693
1707
  for (const el of relaysListEl.querySelectorAll(".th-item")) {
1694
- el.addEventListener("click", () => openThreadDetail(el.getAttribute("data-thread"), el.getAttribute("data-party") || ""));
1708
+ el.addEventListener("click", () => {
1709
+ const id = el.getAttribute("data-thread");
1710
+ expandedThreadId = expandedThreadId === id ? null : id;
1711
+ expandedRelayId = null; // only one menu open at a time
1712
+ renderRelays();
1713
+ });
1695
1714
  }
1715
+ wireInlineThreadActions();
1696
1716
  }
1697
1717
 
1698
1718
  // Grow the window as the user scrolls toward the end. Cheap: each extension
@@ -1711,7 +1731,7 @@
1711
1731
  // Preview plus the two host-open actions, as one small text-first menu.
1712
1732
  // Preview is the primary, non-navigation path; the other actions hand the
1713
1733
  // relay to a current or fresh host session.
1714
- function openActionsHtml(id, { thread, previewable = true } = {}) {
1734
+ function openActionsHtml(id, { thread, previewable = true, threadId = "", messageCount = 0 } = {}) {
1715
1735
  const preview = thread ? `data-thread-preview="${esc(id)}"` : `data-preview="${esc(id)}"`;
1716
1736
  const cur = thread ? `data-thread-open-current="${esc(id)}"` : `data-open-current="${esc(id)}"`;
1717
1737
  const fresh = thread ? `data-thread-open-fresh="${esc(id)}"` : `data-open-fresh="${esc(id)}"`;
@@ -1724,9 +1744,37 @@
1724
1744
  <button class="oa-item${previewable ? "" : " oa-primary"}" type="button" role="menuitem" ${cur}>${thread ? "Open Thread in Current Chat" : "Open in Current Chat"}</button>
1725
1745
  <div class="oa-sep" role="separator"></div>
1726
1746
  <button class="oa-item" type="button" role="menuitem" ${fresh}>${thread ? "Open Thread in New Chat" : "Open in New Chat"}</button>
1747
+ ${threadId ? `<div class="oa-sep" role="separator"></div>
1748
+ <button class="oa-item" type="button" role="menuitem" data-thread-history="${esc(threadId)}">Read the Conversation${messageCount ? ` (${messageCount})` : ""}</button>` : ""}
1727
1749
  </div>`;
1728
1750
  }
1729
1751
 
1752
+
1753
+ // The inline conversation menu reuses the thread actions; scoped to the list so
1754
+ // the detail view's own copy keeps working unchanged.
1755
+ function wireInlineThreadActions() {
1756
+ for (const btn of relaysListEl.querySelectorAll("[data-thread-preview]")) {
1757
+ const id = btn.getAttribute("data-thread-preview");
1758
+ btn.addEventListener("click", () => {
1759
+ expandedThreadId = null;
1760
+ window.relay.preview(id);
1761
+ renderRelays();
1762
+ });
1763
+ }
1764
+ for (const btn of relaysListEl.querySelectorAll("[data-thread-open-current]")) {
1765
+ btn.addEventListener("click", () => openRelayFromUI(btn.getAttribute("data-thread-open-current"), "relay", "current"));
1766
+ }
1767
+ for (const btn of relaysListEl.querySelectorAll("[data-thread-open-fresh]")) {
1768
+ btn.addEventListener("click", () => openRelayFromUI(btn.getAttribute("data-thread-open-fresh"), "relay", "fresh"));
1769
+ }
1770
+ for (const btn of relaysListEl.querySelectorAll("[data-thread-history]")) {
1771
+ btn.addEventListener("click", () => {
1772
+ const el = btn.closest(".th-item");
1773
+ openThreadDetail(btn.getAttribute("data-thread-history"), el ? el.getAttribute("data-party") || "" : "");
1774
+ });
1775
+ }
1776
+ }
1777
+
1730
1778
  // Conversations get the same trash affordance as single relays — the unified
1731
1779
  // list made that inconsistent (a 1-message conversation renders as a relay row
1732
1780
  // WITH delete; a 2-message one rendered as a conversation row without). One
@@ -1747,11 +1795,11 @@
1747
1795
  const subject = relaySubject(r);
1748
1796
  const sender = relaySender(r);
1749
1797
  const actions = relayActionsHtml(r);
1750
- // result / task_completed / plain message carry a body preview beneath the subject.
1751
- // (human_question's body IS the subject, so no duplicate preview there.)
1752
- const body = r.bodyMarkdown || r.body || "";
1753
- const preview = kind === "human_question" ? "" : bodyPreview(body, subject, 110);
1754
- const previewHtml = preview ? `<div class="rk-preview">${esc(preview)}</div>` : "";
1798
+ // Cards show the TITLE ONLY. The old truncated body preview leaked the
1799
+ // agent-oriented detail layer — often unreadable out of context — and cost a
1800
+ // third of the row to say little. Titles now carry what/why/ask (see
1801
+ // relay_send's title guidance), so the detail waits for Preview or an open.
1802
+ const previewHtml = "";
1755
1803
  const canDelete = kind === "plain_relay" || kind === "human_question" || kind === "task_completed" || kind === "task_cancelled" || kind === "task_failed";
1756
1804
  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>` : "";
1757
1805
  const expanded = r.id === expandedRelayId;
@@ -2988,7 +3036,15 @@
2988
3036
  const threadActions = threadOpenTarget
2989
3037
  ? openActionsHtml(threadOpenTarget.id, { thread: true, previewable: Boolean(newestInbound) })
2990
3038
  : "";
2991
- thHistoryEl.innerHTML = threadActions + thread.msgs.map((m) => `
3039
+ // Open-status notes ("Handed to your current chat…" / "✓ Delivered" /
3040
+ // open errors) must render HERE too: users who live in the thread view
3041
+ // click these buttons and previously got zero feedback — a working
3042
+ // hand-off was indistinguishable from a dead one (Sven, 2026-08-05,
3043
+ // twice). Rendered from rowNotes so every payload-push rebuild keeps it.
3044
+ const threadNote = threadOpenTarget
3045
+ ? `<div class="row-err${rowNotes.get(threadOpenTarget.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(threadOpenTarget.id)}" data-stop="1">${esc(rowNotes.get(threadOpenTarget.id)?.text || "")}</div>`
3046
+ : "";
3047
+ thHistoryEl.innerHTML = threadActions + threadNote + thread.msgs.map((m) => `
2992
3048
  <button class="th-msg${m.unread ? " unread" : ""}" type="button" data-msg="${esc(m.id)}" data-dir="${m.direction}">
2993
3049
  <span class="th-msg-top">
2994
3050
  <span class="th-dir${m.direction === "out" ? " out" : ""}">${m.direction === "out" ? "←" : "→"}</span>
@@ -3065,7 +3121,6 @@
3065
3121
  if (info.paired) {
3066
3122
  const name = info.name || info.email || "Relay account";
3067
3123
  html += `
3068
- <div class="sv-head"><span class="sv-title">Your account.</span></div>
3069
3124
  <div class="sv-account">
3070
3125
  <span class="sv-avatar">${esc(cvInitials(info.name, info.email))}</span>
3071
3126
  <span class="sv-id">
@@ -3090,11 +3145,28 @@
3090
3145
  if (svPairOpen) html += svPairRowHtml();
3091
3146
  if (svNote) html += `<div class="sv-note">${esc(svNote)}</div>`;
3092
3147
  html += `<div class="row-err sv-err" id="svErr">${esc(svError)}</div>`;
3093
- html += `<div class="sv-version">${esc(info.version ? `Relay ${info.version}` : "Relay")}</div>`;
3148
+ // Version line doubles as the manual update backstop: if the auto-updater
3149
+ // has not refreshed this machine, say so here and make it one click.
3150
+ if (info.updateAvailable) {
3151
+ html += `<div class="sv-version sv-update">
3152
+ <span>Relay ${esc(info.version || "")} · update ${esc(info.updateAvailable)} ready</span>
3153
+ <button class="oa-item oa-primary sv-update-btn" type="button" id="svUpdate" ${info.updating ? "disabled" : ""}>${info.updating ? "Updating…" : "Update and Restart"}</button>
3154
+ </div>`;
3155
+ } else {
3156
+ html += `<div class="sv-version">${esc(info.version ? `Relay ${info.version}` : "Relay")}</div>`;
3157
+ }
3094
3158
  settingsViewEl.innerHTML = html;
3095
3159
  wireSettings();
3096
3160
  }
3097
3161
  function wireSettings() {
3162
+ const upd = document.getElementById("svUpdate");
3163
+ if (upd) {
3164
+ upd.addEventListener("click", async () => {
3165
+ upd.disabled = true;
3166
+ upd.textContent = "Updating…";
3167
+ try { await window.relay.updateNow(); } catch {}
3168
+ });
3169
+ }
3098
3170
  for (const z of settingsViewEl.querySelectorAll('[data-stop="1"]')) {
3099
3171
  z.addEventListener("click", (e) => e.stopPropagation());
3100
3172
  }
package/overlay/main.cjs CHANGED
@@ -451,6 +451,10 @@ function accountInfo() {
451
451
  email: user.email || "",
452
452
  deviceName: String(cfg.deviceName || "").trim() || os.hostname(),
453
453
  version: pillVersion(),
454
+ // Surfaced in the Settings card so a stale machine is visible in the app,
455
+ // not only in the tray menu.
456
+ updateAvailable: availableUpdate || "",
457
+ updating: updateInFlight,
454
458
  setupPath: "/app/setup",
455
459
  };
456
460
  }
@@ -2330,12 +2334,19 @@ function anchorTopRight() {
2330
2334
  const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) || screen.getPrimaryDisplay();
2331
2335
  const wa = display.workArea;
2332
2336
  const anchor = { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
2333
- if (process.env.RELAY_OVERLAY_TEST === "1") {
2334
- // Sandboxed harness runs must never materialize under the user's parked
2335
- // cursor: a pointer resting on the card holds notifications open BY DESIGN
2336
- // (macOS banners do the same), which would wedge every dwell-based scenario
2337
- // when the mouse happens to sit in the top-right corner. Flip the sandbox
2338
- // window to the opposite edge instead. Production placement is unchanged.
2337
+ if (process.env.RELAY_OVERLAY_TEST === "1" || process.env.RELAY_OVERLAY_PERF === "1") {
2338
+ // A harness run must not take over the developer's screen. Park sandbox
2339
+ // windows just off the bottom-right of the work area: still a REAL composited
2340
+ // window on the same display — so paint timing, dwell timers and hit tests
2341
+ // measure exactly what production does — but out of sight while the suite
2342
+ // runs, and never under the user's parked cursor (a pointer resting on the
2343
+ // card holds notifications open BY DESIGN, which wedged dwell scenarios).
2344
+ // RELAY_OVERLAY_TEST_ONSCREEN=1 puts it back on screen for eyeball debugging.
2345
+ if (process.env.RELAY_OVERLAY_TEST_ONSCREEN !== "1") {
2346
+ anchor.x = wa.x + wa.width - 8;
2347
+ anchor.y = wa.y + wa.height - 8;
2348
+ return anchor;
2349
+ }
2339
2350
  try {
2340
2351
  const p = screen.getCursorScreenPoint();
2341
2352
  const overlaps =
@@ -3086,6 +3097,47 @@ function quitRelayCompletely() {
3086
3097
  } catch {}
3087
3098
  }
3088
3099
 
3100
+
3101
+ // ---- update availability (the manual backstop) -----------------------------
3102
+ // The daemon self-updates, but an update can be pending for a while (the
3103
+ // restart cooldown), or fail quietly, and then a machine silently runs old
3104
+ // code — exactly what happened to a Windows user who sat nine versions behind.
3105
+ // The pill therefore checks the registry itself and, when a newer version
3106
+ // exists, surfaces it where a person will actually see it: the tray menu and
3107
+ // the Settings card, each offering a one-click "restart now" that runs the
3108
+ // same installer with manual intent (which bypasses the cooldown).
3109
+ let availableUpdate = null; // version string when newer than what we run
3110
+ let updateInFlight = false;
3111
+ async function checkForUpdate() {
3112
+ try {
3113
+ const mod = await import(pathToFileURL(path.join(__dirname, "..", "src", "auto-update.js")).href);
3114
+ const latest = await mod.fetchLatestVersion({});
3115
+ const current = pillVersion();
3116
+ availableUpdate = latest && mod.isNewerVersion(latest, current) ? latest : null;
3117
+ if (availableUpdate) syncTray();
3118
+ } catch {}
3119
+ }
3120
+ function runUpdateNow() {
3121
+ if (updateInFlight) return;
3122
+ updateInFlight = true;
3123
+ try {
3124
+ const child = spawn(process.execPath, [RELAY_CLI, "update"], {
3125
+ detached: true,
3126
+ stdio: "ignore",
3127
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
3128
+ });
3129
+ child.on("error", (error) => {
3130
+ updateInFlight = false;
3131
+ console.error(`[overlay] ${new Date().toISOString()} manual update failed:`, error && error.message);
3132
+ });
3133
+ child.unref();
3134
+ } catch (error) {
3135
+ updateInFlight = false;
3136
+ console.error(`[overlay] ${new Date().toISOString()} manual update spawn failed:`, error && error.message);
3137
+ }
3138
+ if (win && !win.isDestroyed()) win.webContents.send("updateStarted");
3139
+ }
3140
+
3089
3141
  function createTray() {
3090
3142
  const icon = createTrayIcon();
3091
3143
  if (!icon || icon.isEmpty()) {
@@ -3116,6 +3168,12 @@ function createTray() {
3116
3168
  { label: pillIsOnScreen() ? "Hide Relay" : "Show Relay", click: toggleFromTray },
3117
3169
  ...previewItem,
3118
3170
  { type: "separator" },
3171
+ // Mirrors what good menu-bar apps do: state the version you are on,
3172
+ // and when a newer one is waiting make restarting a single click.
3173
+ ...(availableUpdate
3174
+ ? [{ label: `Update ready (${availableUpdate}) — restart now?`, click: runUpdateNow }]
3175
+ : [{ label: `Relay ${pillVersion()}`, enabled: false }]),
3176
+ { type: "separator" },
3119
3177
  { label: "Quit Relay", click: quitRelayCompletely },
3120
3178
  ]),
3121
3179
  );
@@ -3235,6 +3293,10 @@ ipcMain.handle("relay:contactsSearch", (_e, q) => groupCall((c) => c.searchConta
3235
3293
  ipcMain.handle("relay:accountInfo", () => accountInfo());
3236
3294
  ipcMain.handle("relay:pairWithCode", (_e, input) => pairWithCode(input));
3237
3295
  ipcMain.handle("relay:signOut", () => signOutAccount());
3296
+ ipcMain.handle("relay:updateNow", () => {
3297
+ runUpdateNow();
3298
+ return { ok: true };
3299
+ });
3238
3300
 
3239
3301
  // The overlay is normally focusable:false so it never steals keyboard focus from
3240
3302
  // Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
@@ -3427,6 +3489,10 @@ if (!gotSingleInstanceLock) {
3427
3489
  if (process.platform === "darwin" && app.dock) app.dock.hide(); // accessory: no dock icon, no NC banners
3428
3490
  createWindow();
3429
3491
  createTray();
3492
+ // Update availability: one check after boot, then hourly. Cheap (a dist-tags
3493
+ // GET) and it only ever surfaces UI — the daemon still owns actual updating.
3494
+ setTimeout(() => checkForUpdate(), 20000).unref?.();
3495
+ setInterval(() => checkForUpdate(), 60 * 60 * 1000).unref?.();
3430
3496
  installActiveSpaceWatcher();
3431
3497
  installPowerAttentionLifecycle();
3432
3498
  // Display topology changes are the remaining event that can strand the
@@ -68,6 +68,8 @@ contextBridge.exposeInMainWorld("relay", {
68
68
  accountInfo: () => ipcRenderer.invoke("relay:accountInfo"),
69
69
  pairWithCode: (input) => ipcRenderer.invoke("relay:pairWithCode", input),
70
70
  signOut: () => ipcRenderer.invoke("relay:signOut"),
71
+ // Manual update backstop, when the auto-updater has not refreshed this machine.
72
+ updateNow: () => ipcRenderer.invoke("relay:updateNow"),
71
73
 
72
74
 
73
75
  // window plumbing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.89",
3
+ "version": "0.1.91",
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": {
package/src/mcp.js CHANGED
@@ -38,12 +38,12 @@ export const TOOLS = [
38
38
  title: {
39
39
  type: "string",
40
40
  description:
41
- "The concise, high-level human-facing takeaway. This is shown first and most prominently, so keep it to one clear phrase or sentence.",
41
+ "The human-readable layer of the relay, and the ONLY thing shown on the recipient's card — write it to carry real information, not to name a topic. In as few words as it honestly takes (aim ~8-20 words, hard stop around 140 characters), answer: what is this, why does this person care, is anything expected of them, and by when. Value-centric and human, never a detail dump: 'Postmark account still pending approval — DNS is ready, nothing needed from you until they reply' beats 'Postmark update'. State the ask explicitly when there is one, and say so plainly when there is not. The recipient reads this alone, decides whether to act, and only then opens the relay for the full detail in bodyMarkdown — so a title that merely labels the subject forces a pointless open.",
42
42
  },
43
43
  bodyMarkdown: {
44
44
  type: "string",
45
45
  description:
46
- "The complete human-facing detail you formulate from the sender's request and relevant context. Use readable Markdown (short paragraphs, headings, bullets, tables, or code where useful) and do not merely repeat the title. This is the message body, not a private recipient-agent briefing.",
46
+ "The high-detail layer, read AFTER the title earns the open. Formulate the complete human-facing detail from the sender's request and relevant context in readable Markdown (short paragraphs, headings, bullets, tables, or code where useful). Never merely repeat the title; the title already carried the what/why/ask, so this is the substance behind it. This is the message body, not a private recipient-agent briefing.",
47
47
  },
48
48
  userInstructions: { type: "string", description: "Optional private instruction for how the recipient's agent should handle a handoff." },
49
49
  targetSurfaces: {