relay-companion 0.1.90 → 0.1.92

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
  }
@@ -743,6 +743,8 @@
743
743
  .sv-err { margin-top:8px; padding:0 16px; }
744
744
  .sv-note { padding:10px 16px 0; font-size:11.5px; color:#1a7f37; line-height:1.4; }
745
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; }
746
748
  /* signed-out call-to-action on the Relays empty state — quiet filled pill (th-count language) */
747
749
  .sv-signin-banner {
748
750
  appearance:none; border:0; cursor:pointer; margin-top:2px;
@@ -1697,6 +1699,7 @@
1697
1699
  </span>
1698
1700
  <span class="th-title">${esc(t.latest.title)}</span>
1699
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>` : ""}
1700
1703
  </div>`;
1701
1704
  }).join("");
1702
1705
  prevRelayIds = new Set(allRows.map((r) => r.id));
@@ -1792,11 +1795,11 @@
1792
1795
  const subject = relaySubject(r);
1793
1796
  const sender = relaySender(r);
1794
1797
  const actions = relayActionsHtml(r);
1795
- // result / task_completed / plain message carry a body preview beneath the subject.
1796
- // (human_question's body IS the subject, so no duplicate preview there.)
1797
- const body = r.bodyMarkdown || r.body || "";
1798
- const preview = kind === "human_question" ? "" : bodyPreview(body, subject, 110);
1799
- 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 = "";
1800
1803
  const canDelete = kind === "plain_relay" || kind === "human_question" || kind === "task_completed" || kind === "task_cancelled" || kind === "task_failed";
1801
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>` : "";
1802
1805
  const expanded = r.id === expandedRelayId;
@@ -2942,6 +2945,7 @@
2942
2945
  // relays plus the live Sent list. Local data only — the pill shows the
2943
2946
  // user's own exchanges; the API's /v1/threads serves web and agents.
2944
2947
  let threadDetailId = null; // threadId while a thread's history is open
2948
+ let threadDetailScrolledFor = null; // thread already auto-scrolled to newest (reset on leave)
2945
2949
  let threadDetailPartyHint = ""; // who it's with, for the header while messages load
2946
2950
  function threadMessages() {
2947
2951
  const msgs = [];
@@ -3033,7 +3037,19 @@
3033
3037
  const threadActions = threadOpenTarget
3034
3038
  ? openActionsHtml(threadOpenTarget.id, { thread: true, previewable: Boolean(newestInbound) })
3035
3039
  : "";
3036
- thHistoryEl.innerHTML = threadActions + thread.msgs.map((m) => `
3040
+ // Open-status notes ("Handed to your current chat…" / "✓ Delivered" /
3041
+ // open errors) must render HERE too: users who live in the thread view
3042
+ // click these buttons and previously got zero feedback — a working
3043
+ // hand-off was indistinguishable from a dead one (Sven, 2026-08-05,
3044
+ // twice). Rendered from rowNotes so every payload-push rebuild keeps it.
3045
+ const threadNote = threadOpenTarget
3046
+ ? `<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>`
3047
+ : "";
3048
+ // Chat-shaped, like every messaging app (and Claude itself — Sven's field
3049
+ // note): messages read oldest -> newest downward, the open actions sit at
3050
+ // the BOTTOM next to the newest message, and entering the thread lands you
3051
+ // at the newest message, not the stalest.
3052
+ thHistoryEl.innerHTML = thread.msgs.map((m) => `
3037
3053
  <button class="th-msg${m.unread ? " unread" : ""}" type="button" data-msg="${esc(m.id)}" data-dir="${m.direction}">
3038
3054
  <span class="th-msg-top">
3039
3055
  <span class="th-dir${m.direction === "out" ? " out" : ""}">${m.direction === "out" ? "←" : "→"}</span>
@@ -3041,7 +3057,13 @@
3041
3057
  <span class="th-time">${esc(timeAgo(m.at))}</span>
3042
3058
  </span>
3043
3059
  <span class="th-msg-title">${esc(m.title)}</span>
3044
- </button>`).join("");
3060
+ </button>`).join("") + threadActions + threadNote;
3061
+ // Land at the newest message when ENTERING the thread; rebuilds of the same
3062
+ // open thread (payload pushes) keep the user's scroll position untouched.
3063
+ if (threadDetailScrolledFor !== thread.threadId) {
3064
+ threadDetailScrolledFor = thread.threadId;
3065
+ requestAnimationFrame(() => { scrollEl.scrollTop = scrollEl.scrollHeight; });
3066
+ }
3045
3067
  for (const el of thHistoryEl.querySelectorAll(".th-msg")) {
3046
3068
  el.addEventListener("click", () => {
3047
3069
  const id = el.getAttribute("data-msg");
@@ -3057,6 +3079,7 @@
3057
3079
  }
3058
3080
  thBackEl.addEventListener("click", () => {
3059
3081
  threadDetailId = null;
3082
+ threadDetailScrolledFor = null;
3060
3083
  activeView = "relays";
3061
3084
  syncTabs();
3062
3085
  applyView();
@@ -3134,11 +3157,28 @@
3134
3157
  if (svPairOpen) html += svPairRowHtml();
3135
3158
  if (svNote) html += `<div class="sv-note">${esc(svNote)}</div>`;
3136
3159
  html += `<div class="row-err sv-err" id="svErr">${esc(svError)}</div>`;
3137
- html += `<div class="sv-version">${esc(info.version ? `Relay ${info.version}` : "Relay")}</div>`;
3160
+ // Version line doubles as the manual update backstop: if the auto-updater
3161
+ // has not refreshed this machine, say so here and make it one click.
3162
+ if (info.updateAvailable) {
3163
+ html += `<div class="sv-version sv-update">
3164
+ <span>Relay ${esc(info.version || "")} · update ${esc(info.updateAvailable)} ready</span>
3165
+ <button class="oa-item oa-primary sv-update-btn" type="button" id="svUpdate" ${info.updating ? "disabled" : ""}>${info.updating ? "Updating…" : "Update and Restart"}</button>
3166
+ </div>`;
3167
+ } else {
3168
+ html += `<div class="sv-version">${esc(info.version ? `Relay ${info.version}` : "Relay")}</div>`;
3169
+ }
3138
3170
  settingsViewEl.innerHTML = html;
3139
3171
  wireSettings();
3140
3172
  }
3141
3173
  function wireSettings() {
3174
+ const upd = document.getElementById("svUpdate");
3175
+ if (upd) {
3176
+ upd.addEventListener("click", async () => {
3177
+ upd.disabled = true;
3178
+ upd.textContent = "Updating…";
3179
+ try { await window.relay.updateNow(); } catch {}
3180
+ });
3181
+ }
3142
3182
  for (const z of settingsViewEl.querySelectorAll('[data-stop="1"]')) {
3143
3183
  z.addEventListener("click", (e) => e.stopPropagation());
3144
3184
  }
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
  }
@@ -3093,6 +3097,47 @@ function quitRelayCompletely() {
3093
3097
  } catch {}
3094
3098
  }
3095
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
+
3096
3141
  function createTray() {
3097
3142
  const icon = createTrayIcon();
3098
3143
  if (!icon || icon.isEmpty()) {
@@ -3123,6 +3168,12 @@ function createTray() {
3123
3168
  { label: pillIsOnScreen() ? "Hide Relay" : "Show Relay", click: toggleFromTray },
3124
3169
  ...previewItem,
3125
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" },
3126
3177
  { label: "Quit Relay", click: quitRelayCompletely },
3127
3178
  ]),
3128
3179
  );
@@ -3242,6 +3293,10 @@ ipcMain.handle("relay:contactsSearch", (_e, q) => groupCall((c) => c.searchConta
3242
3293
  ipcMain.handle("relay:accountInfo", () => accountInfo());
3243
3294
  ipcMain.handle("relay:pairWithCode", (_e, input) => pairWithCode(input));
3244
3295
  ipcMain.handle("relay:signOut", () => signOutAccount());
3296
+ ipcMain.handle("relay:updateNow", () => {
3297
+ runUpdateNow();
3298
+ return { ok: true };
3299
+ });
3245
3300
 
3246
3301
  // The overlay is normally focusable:false so it never steals keyboard focus from
3247
3302
  // Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
@@ -3434,6 +3489,10 @@ if (!gotSingleInstanceLock) {
3434
3489
  if (process.platform === "darwin" && app.dock) app.dock.hide(); // accessory: no dock icon, no NC banners
3435
3490
  createWindow();
3436
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?.();
3437
3496
  installActiveSpaceWatcher();
3438
3497
  installPowerAttentionLifecycle();
3439
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.90",
3
+ "version": "0.1.92",
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: {