relay-companion 0.1.91 → 0.1.93

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.
@@ -1564,6 +1564,7 @@
1564
1564
  // The relay row whose open-actions card is expanded. Like openingIds, this
1565
1565
  // survives the innerHTML rebuilds renderRelays performs on every push.
1566
1566
  let expandedRelayId = null;
1567
+ let expandedSentId = null; // Sent row whose open-actions menu is expanded
1567
1568
  // A conversation expands its actions IN PLACE, exactly like a single relay.
1568
1569
  // Taking over the whole pane (the old thread-detail push) lost the user's
1569
1570
  // place in the list for what is really a three-item menu.
@@ -1588,7 +1589,11 @@
1588
1589
  openingIds.set(id, performance.now());
1589
1590
  for (const el of openingTargets(id)) el.classList.add("opening");
1590
1591
  setTimeout(() => stopOpening(id), 6000); // safety: never spin forever if openDone is missed
1591
- if (source === "sent" && window.relay.openSent) window.relay.openSent(id);
1592
+ // Sent rows carry the same three modes as received relays; each has its own
1593
+ // IPC so the sent copy is staged/forged before the open.
1594
+ if (source === "sent" && mode === "current" && window.relay.openSentInCurrent) window.relay.openSentInCurrent(id);
1595
+ else if (source === "sent" && mode === "fresh" && window.relay.openSentFresh) window.relay.openSentFresh(id);
1596
+ else if (source === "sent" && window.relay.openSent) window.relay.openSent(id);
1592
1597
  else if (mode === "current" && window.relay.openInCurrent) window.relay.openInCurrent(id);
1593
1598
  else if (mode === "fresh" && window.relay.openFresh) window.relay.openFresh(id);
1594
1599
  else window.relay.open(id);
@@ -1731,10 +1736,12 @@
1731
1736
  // Preview plus the two host-open actions, as one small text-first menu.
1732
1737
  // Preview is the primary, non-navigation path; the other actions hand the
1733
1738
  // relay to a current or fresh host session.
1734
- function openActionsHtml(id, { thread, previewable = true, threadId = "", messageCount = 0 } = {}) {
1735
- const preview = thread ? `data-thread-preview="${esc(id)}"` : `data-preview="${esc(id)}"`;
1736
- const cur = thread ? `data-thread-open-current="${esc(id)}"` : `data-open-current="${esc(id)}"`;
1737
- const fresh = thread ? `data-thread-open-fresh="${esc(id)}"` : `data-open-fresh="${esc(id)}"`;
1739
+ function openActionsHtml(id, { thread, previewable = true, threadId = "", messageCount = 0, sent = false } = {}) {
1740
+ // Sent rows use their own data-* namespace so the Sent list's wiring stays
1741
+ // independent of the Relays list (same menu, same three actions, own IPC).
1742
+ const preview = sent ? `data-sent-preview="${esc(id)}"` : thread ? `data-thread-preview="${esc(id)}"` : `data-preview="${esc(id)}"`;
1743
+ const cur = sent ? `data-sent-open-current="${esc(id)}"` : thread ? `data-thread-open-current="${esc(id)}"` : `data-open-current="${esc(id)}"`;
1744
+ const fresh = sent ? `data-sent-open-fresh="${esc(id)}"` : thread ? `data-thread-open-fresh="${esc(id)}"` : `data-open-fresh="${esc(id)}"`;
1738
1745
  const previewItem = previewable
1739
1746
  ? `<button class="oa-item oa-primary" type="button" role="menuitem" ${preview}>${thread ? "Preview Latest Received Relay" : "Preview"}</button><div class="oa-sep" role="separator"></div>`
1740
1747
  : "";
@@ -2102,8 +2109,10 @@
2102
2109
  const preview = bodyPreview(body, subject, 118);
2103
2110
  const detail = sentDetail(r);
2104
2111
  const klass = sentClass(r);
2112
+ const expanded = id === expandedSentId;
2113
+ const openCard = expanded ? openActionsHtml(id, { sent: true }) : "";
2105
2114
  return `
2106
- <div class="row sent ${klass}" data-sent-id="${esc(id)}" data-opening-id="${esc(id)}">
2115
+ <div class="row sent ${klass}${expanded ? " expanded" : ""}" data-sent-id="${esc(id)}" data-opening-id="${esc(id)}">
2107
2116
  <div class="rk-top">
2108
2117
  <span class="rk-dot"></span>
2109
2118
  <span class="rk-from">To</span>
@@ -2114,16 +2123,46 @@
2114
2123
  <div class="rk-subject">${esc(subject)}</div>
2115
2124
  ${preview ? `<div class="rk-preview">${esc(preview)}</div>` : ""}
2116
2125
  <div class="row-note">${esc(detail)}</div>
2126
+ ${openCard}
2127
+ <div class="row-err${rowNotes.get(id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(id)}">${esc(rowNotes.get(id)?.text || "")}</div>
2117
2128
  </div>`;
2118
2129
  }).join("");
2119
2130
  wireSentRows();
2120
2131
  }
2132
+ // A sent row expands into the SAME three-action menu as a received relay:
2133
+ // Preview (read your own message in place), Open in Current Chat, Open in
2134
+ // New Chat — each routed to the sent-side IPC.
2121
2135
  function wireSentRows() {
2122
2136
  for (const el of sentListEl.querySelectorAll(".row.sent")) {
2123
2137
  const id = el.getAttribute("data-sent-id");
2124
2138
  if (openingIds.has(id)) el.classList.add("opening");
2125
2139
  el.addEventListener("click", () => {
2126
- openRelayFromUI(id, "sent");
2140
+ expandedSentId = expandedSentId === id ? null : id;
2141
+ renderSent();
2142
+ });
2143
+ }
2144
+ for (const z of sentListEl.querySelectorAll('[data-stop="1"]')) {
2145
+ z.addEventListener("click", (e) => e.stopPropagation());
2146
+ }
2147
+ for (const btn of sentListEl.querySelectorAll("[data-sent-preview]")) {
2148
+ btn.addEventListener("click", () => {
2149
+ expandedSentId = null;
2150
+ window.relay.preview(btn.getAttribute("data-sent-preview"));
2151
+ renderSent();
2152
+ });
2153
+ }
2154
+ for (const btn of sentListEl.querySelectorAll("[data-sent-open-current]")) {
2155
+ const id = btn.getAttribute("data-sent-open-current");
2156
+ btn.addEventListener("click", () => {
2157
+ expandedSentId = null;
2158
+ openRelayFromUI(id, "sent", "current");
2159
+ });
2160
+ }
2161
+ for (const btn of sentListEl.querySelectorAll("[data-sent-open-fresh]")) {
2162
+ const id = btn.getAttribute("data-sent-open-fresh");
2163
+ btn.addEventListener("click", () => {
2164
+ expandedSentId = null;
2165
+ openRelayFromUI(id, "sent", "fresh");
2127
2166
  });
2128
2167
  }
2129
2168
  }
@@ -2945,6 +2984,7 @@
2945
2984
  // relays plus the live Sent list. Local data only — the pill shows the
2946
2985
  // user's own exchanges; the API's /v1/threads serves web and agents.
2947
2986
  let threadDetailId = null; // threadId while a thread's history is open
2987
+ let threadDetailScrolledFor = null; // thread already auto-scrolled to newest (reset on leave)
2948
2988
  let threadDetailPartyHint = ""; // who it's with, for the header while messages load
2949
2989
  function threadMessages() {
2950
2990
  const msgs = [];
@@ -3044,7 +3084,11 @@
3044
3084
  const threadNote = threadOpenTarget
3045
3085
  ? `<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
3086
  : "";
3047
- thHistoryEl.innerHTML = threadActions + threadNote + thread.msgs.map((m) => `
3087
+ // Chat-shaped, like every messaging app (and Claude itself — Sven's field
3088
+ // note): messages read oldest -> newest downward, the open actions sit at
3089
+ // the BOTTOM next to the newest message, and entering the thread lands you
3090
+ // at the newest message, not the stalest.
3091
+ thHistoryEl.innerHTML = thread.msgs.map((m) => `
3048
3092
  <button class="th-msg${m.unread ? " unread" : ""}" type="button" data-msg="${esc(m.id)}" data-dir="${m.direction}">
3049
3093
  <span class="th-msg-top">
3050
3094
  <span class="th-dir${m.direction === "out" ? " out" : ""}">${m.direction === "out" ? "←" : "→"}</span>
@@ -3052,7 +3096,13 @@
3052
3096
  <span class="th-time">${esc(timeAgo(m.at))}</span>
3053
3097
  </span>
3054
3098
  <span class="th-msg-title">${esc(m.title)}</span>
3055
- </button>`).join("");
3099
+ </button>`).join("") + threadActions + threadNote;
3100
+ // Land at the newest message when ENTERING the thread; rebuilds of the same
3101
+ // open thread (payload pushes) keep the user's scroll position untouched.
3102
+ if (threadDetailScrolledFor !== thread.threadId) {
3103
+ threadDetailScrolledFor = thread.threadId;
3104
+ requestAnimationFrame(() => { scrollEl.scrollTop = scrollEl.scrollHeight; });
3105
+ }
3056
3106
  for (const el of thHistoryEl.querySelectorAll(".th-msg")) {
3057
3107
  el.addEventListener("click", () => {
3058
3108
  const id = el.getAttribute("data-msg");
@@ -3068,6 +3118,7 @@
3068
3118
  }
3069
3119
  thBackEl.addEventListener("click", () => {
3070
3120
  threadDetailId = null;
3121
+ threadDetailScrolledFor = null;
3071
3122
  activeView = "relays";
3072
3123
  syncTabs();
3073
3124
  applyView();
package/overlay/main.cjs CHANGED
@@ -1341,7 +1341,11 @@ function previewPayloadForPacket(packetId) {
1341
1341
  const id = String(packetId || "");
1342
1342
  if (!id) return null;
1343
1343
  const row = rowById(id);
1344
- if (!row || row.direction !== "inbound" || RELAY_HIDDEN_KINDS.has(row.relayNotificationKind)) return null;
1344
+ if (!row || row.direction !== "inbound" || RELAY_HIDDEN_KINDS.has(row.relayNotificationKind)) {
1345
+ // A Sent row lives in sentCache, not the staged packet store — read your own
1346
+ // outbound message without leaving the pill, exactly like an inbound preview.
1347
+ return previewPayloadForSent(id);
1348
+ }
1345
1349
  return {
1346
1350
  relayId: id,
1347
1351
  title: String(row.title || row.displayTitle || "Relay"),
@@ -1352,6 +1356,27 @@ function previewPayloadForPacket(packetId) {
1352
1356
  };
1353
1357
  }
1354
1358
 
1359
+ // The preview payload for one of the user's own sent relays. `unread:false`
1360
+ // always: a sent message has no read state of its own to retire, and the
1361
+ // preview's ack path must never touch the recipient's receipts.
1362
+ function previewPayloadForSent(relayId) {
1363
+ const id = String(relayId || "");
1364
+ if (!id) return null;
1365
+ const item = (sentCache || []).find((row) => String(row.relayId || row.id || "") === id);
1366
+ if (!item) return null;
1367
+ const recipient = item.recipient || {};
1368
+ const to = recipient.name || recipient.email || item.groupName || "the recipient";
1369
+ return {
1370
+ relayId: id,
1371
+ title: String(item.title || item.displayTitle || item.preview || "Relay"),
1372
+ bodyMarkdown: String(item.bodyMarkdown || item.preview || ""),
1373
+ senderName: `You → ${to}`,
1374
+ createdAt: String(item.createdAt || ""),
1375
+ unread: false,
1376
+ outbound: true,
1377
+ };
1378
+ }
1379
+
1355
1380
  // Find an approvalId for a share_approval row. The staged row may not carry it,
1356
1381
  // in which case the renderer routes to the web detail page (Review) instead.
1357
1382
  function approvalIdForRow(row) {
@@ -2038,13 +2063,34 @@ function threadInfoFor(row) {
2038
2063
  return { threadId, threadCount: ids.size };
2039
2064
  }
2040
2065
 
2041
- async function openPacketInCurrent(packetId) {
2066
+ async function openPacketInCurrent(packetId, { sent = false } = {}) {
2042
2067
  if (!packetId) return;
2043
- const row = rowById(packetId);
2068
+ let row = rowById(packetId);
2069
+ if (sent) {
2070
+ // A sent relay has no staged inbound row: forge its `sent_<relayId>` copy
2071
+ // exactly as the fresh-open path does, then inject THAT. The renderer keeps
2072
+ // addressing the row by its relayId, so spinner/notes stay on the right row.
2073
+ const sentItem = (sentCache || []).find((item) => String(item.relayId || item.id || "") === String(packetId));
2074
+ if (!sentItem) {
2075
+ console.error("[overlay] sent relay missing from cache:", packetId);
2076
+ if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId);
2077
+ return;
2078
+ }
2079
+ try {
2080
+ const stageSentRelayItem = await loadSentStager();
2081
+ const staged = stageSentRelayItem({ item: sentItem, sender: account() }, { statePath: STATE_PATH });
2082
+ row = rowById(staged.itemId) || row;
2083
+ } catch (error) {
2084
+ console.error("[overlay] sent staging for in-chat open failed:", packetId, error && error.message);
2085
+ if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId);
2086
+ return;
2087
+ }
2088
+ }
2044
2089
  if (win && !win.isDestroyed()) win.webContents.send("opening", packetId);
2045
2090
  const finish = () => {
2046
- // Same read-state contract as a normal open: ack fires the receipts.
2047
- ackPacket(packetId);
2091
+ // Sent relays carry no unread state of their own; acking one would touch the
2092
+ // inbound copy of a self-send. Only inbound opens ack.
2093
+ if (!sent) ackPacket(packetId);
2048
2094
  if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId); // stop the row spinner
2049
2095
  };
2050
2096
  // Confirm the staging in the UI: without this, a successful injection is
@@ -2061,7 +2107,7 @@ async function openPacketInCurrent(packetId) {
2061
2107
  console.error(`[overlay] openInCurrent fallback for ${packetId}: ${note}`);
2062
2108
  if (win && !win.isDestroyed()) win.webContents.send("openError", packetId, `${note} — opening a new chat instead.`);
2063
2109
  }
2064
- openPacket(packetId, { fresh: true }).catch((error) =>
2110
+ openPacket(packetId, { fresh: true, sent }).catch((error) =>
2065
2111
  console.error("[overlay] openInCurrent fresh fallback failed:", error && error.message),
2066
2112
  );
2067
2113
  };
@@ -3189,6 +3235,20 @@ ipcMain.handle("relay:get", () => buildPayload());
3189
3235
  ipcMain.on("relay:open", (_e, id) => {
3190
3236
  openPacket(id).catch((error) => console.error("[overlay] open failed:", error && error.message));
3191
3237
  });
3238
+ // Sent rows get the same three actions as received relays. "In current chat"
3239
+ // stages the sent copy through the SAME injection path (the materializer's
3240
+ // sent stager already forges a `sent_<relayId>` row), so the agent opens the
3241
+ // user's own message in the chat they are looking at.
3242
+ ipcMain.on("relay:openSentInCurrent", (_e, id) => {
3243
+ openPacketInCurrent(id, { sent: true }).catch((error) =>
3244
+ console.error("[overlay] sent open-in-current failed:", error && error.message),
3245
+ );
3246
+ });
3247
+ ipcMain.on("relay:openSentFresh", (_e, id) => {
3248
+ openPacket(id, { sent: true, fresh: true }).catch((error) =>
3249
+ console.error("[overlay] sent fresh open failed:", error && error.message),
3250
+ );
3251
+ });
3192
3252
  ipcMain.on("relay:openSent", (_e, id) => {
3193
3253
  openPacket(id, { sent: true }).catch((error) => console.error("[overlay] sent open failed:", error && error.message));
3194
3254
  });
@@ -29,6 +29,9 @@ contextBridge.exposeInMainWorld("relay", {
29
29
  // Relay rows
30
30
  open: (id) => ipcRenderer.send("relay:open", id),
31
31
  openSent: (id) => ipcRenderer.send("relay:openSent", id),
32
+ // Sent rows get the same open-actions menu as received relays.
33
+ openSentInCurrent: (id) => ipcRenderer.send("relay:openSentInCurrent", id),
34
+ openSentFresh: (id) => ipcRenderer.send("relay:openSentFresh", id),
32
35
  // expand-card actions: inject into the live Claude session / force a fresh one
33
36
  preview: (id) => ipcRenderer.send("relay:preview", id),
34
37
  openInCurrent: (id) => ipcRenderer.send("relay:openInCurrent", id),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
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
@@ -103,9 +103,56 @@ export const TOOLS = [
103
103
  {
104
104
  name: "relay_groups_list",
105
105
  description:
106
- "List this human's Relay contact groups — named rosters of their saved contacts (e.g. 'Founders', 'Family') with each group's members. Use it when the human addresses several people at once or names a roster, then pass the chosen group's id to relay_send as recipient.groupId: the message fans out to every member and the whole roster shares ONE conversation thread where every member's reply reaches everyone (true group chat). Groups are managed on the Relay website (Contacts -> Groups); this tool is read-only.",
106
+ "List this human's Relay contact groups — named rosters of their saved contacts (e.g. 'Founders', 'Family') with each group's members. Use it when the human addresses several people at once or names a roster, then pass the chosen group's id to relay_send as recipient.groupId: the message fans out to every member and the whole roster shares ONE conversation thread where every member's reply reaches everyone (true group chat). To CHANGE a roster, use relay_group_create / relay_group_update / relay_group_delete — always list first so you act on a real groupId.",
107
107
  inputSchema: { type: "object", properties: {} },
108
108
  },
109
+ {
110
+ name: "relay_group_create",
111
+ description:
112
+ "Create a Relay contact group (a named roster) for this human, optionally with its starting members. Use it when they ask for a new group ('make a Founders group with Sven and Shane') — group names are unique per person, so call relay_groups_list first and use relay_group_update when the roster already exists. Members are contactIds from relay_contacts_search; look up every name BEFORE calling, and ask the human which person they meant rather than guessing between similar matches. Returns the created group with its members.",
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: {
116
+ name: { type: "string", description: "The roster's display name, e.g. 'Founders'." },
117
+ memberContactIds: {
118
+ type: "array",
119
+ items: { type: "string" },
120
+ description: "Optional starting members: exact contactIds from relay_contacts_search.",
121
+ },
122
+ idempotencyKey: { type: "string" },
123
+ },
124
+ required: ["name", "idempotencyKey"],
125
+ },
126
+ },
127
+ {
128
+ name: "relay_group_update",
129
+ description:
130
+ "Rename a Relay contact group and/or add and remove its members — the same edits the human can make in the Relay pill and website. Pass any combination of name, addContactIds and removeContactIds; each member change is applied independently, so a partial failure still reports what landed. Members are contactIds from relay_contacts_search. Removing a member only takes them off the roster: it never deletes the contact and never touches past conversations. Returns the updated group.",
131
+ inputSchema: {
132
+ type: "object",
133
+ properties: {
134
+ groupId: { type: "string", description: "The group's id (grp_...) from relay_groups_list or relay_contacts_search." },
135
+ name: { type: "string", description: "New display name. Omit to leave the name unchanged." },
136
+ addContactIds: { type: "array", items: { type: "string" }, description: "contactIds to add to the roster." },
137
+ removeContactIds: { type: "array", items: { type: "string" }, description: "contactIds to take off the roster." },
138
+ idempotencyKey: { type: "string" },
139
+ },
140
+ required: ["groupId", "idempotencyKey"],
141
+ },
142
+ },
143
+ {
144
+ name: "relay_group_delete",
145
+ description:
146
+ "Delete a Relay contact group. This removes the roster only — every member remains in the human's contact book and past group conversations stay intact — but the group can no longer be addressed as a recipient. Destructive and not undoable: confirm with the human first, and never delete a group they did not explicitly name.",
147
+ inputSchema: {
148
+ type: "object",
149
+ properties: {
150
+ groupId: { type: "string", description: "The group's id (grp_...) from relay_groups_list." },
151
+ idempotencyKey: { type: "string" },
152
+ },
153
+ required: ["groupId", "idempotencyKey"],
154
+ },
155
+ },
109
156
  {
110
157
  name: "relay_contact_update",
111
158
  description:
@@ -501,6 +548,11 @@ export const MESSAGE_ONLY_TOOL_NAMES = new Set([
501
548
  "relay_contacts_search",
502
549
  "relay_contact_update",
503
550
  "relay_groups_list",
551
+ // Group management is ordinary-messaging functionality (the pill and website
552
+ // expose it in messages-only mode), so it belongs in this profile too.
553
+ "relay_group_create",
554
+ "relay_group_update",
555
+ "relay_group_delete",
504
556
  "relay_inbox_list",
505
557
  "relay_thread_fetch",
506
558
  "relay_acknowledge",
@@ -558,6 +610,59 @@ export async function handleCall(client, name, args, { mode = DEFAULT_COMPANION_
558
610
  return text(await client.searchContacts(args.query));
559
611
  case "relay_groups_list":
560
612
  return text(await client.groups());
613
+ case "relay_group_create": {
614
+ const group = await client.createGroup({ name: args.name });
615
+ const ids = Array.isArray(args.memberContactIds) ? args.memberContactIds.filter(Boolean) : [];
616
+ // Members are added one call at a time (the API's own shape). Report per-id
617
+ // outcomes instead of failing the whole create: a group that exists with
618
+ // three of four members is a real, recoverable state the agent must see.
619
+ const added = [];
620
+ const failed = [];
621
+ let current = group;
622
+ for (const contactId of ids) {
623
+ try {
624
+ current = await client.addGroupMember(group.id, contactId);
625
+ added.push(contactId);
626
+ } catch (err) {
627
+ failed.push({ contactId, error: err?.message || String(err) });
628
+ }
629
+ }
630
+ return text({ group: current, added, ...(failed.length ? { failed } : {}) });
631
+ }
632
+ case "relay_group_update": {
633
+ const groupId = args.groupId;
634
+ let current = null;
635
+ const added = [];
636
+ const removed = [];
637
+ const failed = [];
638
+ if (typeof args.name === "string" && args.name.trim()) {
639
+ current = await client.renameGroup(groupId, { name: args.name.trim() });
640
+ }
641
+ for (const contactId of Array.isArray(args.addContactIds) ? args.addContactIds.filter(Boolean) : []) {
642
+ try {
643
+ current = await client.addGroupMember(groupId, contactId);
644
+ added.push(contactId);
645
+ } catch (err) {
646
+ failed.push({ contactId, op: "add", error: err?.message || String(err) });
647
+ }
648
+ }
649
+ for (const contactId of Array.isArray(args.removeContactIds) ? args.removeContactIds.filter(Boolean) : []) {
650
+ try {
651
+ current = await client.removeGroupMember(groupId, contactId);
652
+ removed.push(contactId);
653
+ } catch (err) {
654
+ failed.push({ contactId, op: "remove", error: err?.message || String(err) });
655
+ }
656
+ }
657
+ // No-op calls still return the group, so the agent always sees real state.
658
+ if (!current) {
659
+ const all = await client.groups();
660
+ current = (all.groups || []).find((g) => g.id === groupId) || null;
661
+ }
662
+ return text({ group: current, added, removed, ...(failed.length ? { failed } : {}) });
663
+ }
664
+ case "relay_group_delete":
665
+ return text(await client.deleteGroup(args.groupId));
561
666
  case "relay_contact_update":
562
667
  return text(
563
668
  await client.updateContact(args.contactId, {