relay-companion 0.1.78 → 0.1.80

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.
@@ -170,6 +170,13 @@
170
170
  .scroll {
171
171
  flex:1 1 auto; min-height:0; width:344px;
172
172
  overflow-y:auto; overscroll-behavior:contain;
173
+ /* The card's open/close animation changes its HEIGHT every frame. Without
174
+ containment each of those frames re-lays-out every row in this scroller
175
+ (150+ on a real inbox) — ~60 full reflows a second, which is what made
176
+ opening judder and delayed the click that starts it. `contain` tells the
177
+ engine this subtree's layout can't affect (or be affected by) the box
178
+ around it, so resizing the card no longer touches the rows. */
179
+ contain:layout paint style;
173
180
  padding:0 0 8px;
174
181
  transition:opacity .3s var(--settle) .06s;
175
182
  }
@@ -603,9 +610,6 @@
603
610
  color:#fff; background:var(--accent);
604
611
  box-shadow:0 2px 5px -2px color-mix(in srgb, var(--accent) 55%, transparent);
605
612
  }
606
- .th-item.th-sent-only .th-count {
607
- color:var(--muted); background:rgba(31,26,23,.055); box-shadow:none;
608
- }
609
613
  .th-unread {
610
614
  flex:0 0 auto; align-self:center; min-width:7px; height:7px; border-radius:999px;
611
615
  background:var(--accent); font-size:0;
@@ -617,9 +621,6 @@
617
621
  overflow-wrap:anywhere;
618
622
  }
619
623
  .th-item.unread .th-title { color:var(--ink); font-weight:500; }
620
- /* Conversations you started that have no reply yet sit quietly in the list. */
621
- .th-item.th-sent-only .th-party { color:var(--muted); font-weight:500; }
622
- .th-item.th-sent-only .th-title { color:var(--muted-2); }
623
624
  .th-detail-head { display:flex; align-items:center; gap:9px; padding:10px 16px 8px; border-bottom:1px solid var(--hair-2); }
624
625
  .th-back {
625
626
  appearance:none; border:0; background:transparent; cursor:pointer; padding:0;
@@ -949,14 +950,25 @@
949
950
  for (let i = 0; i < n; i++) { const a = step(W, h), b = step(H, h); moving = moving || a || b; }
950
951
  cardEl.style.width = W.v.toFixed(2) + "px";
951
952
  cardEl.style.height = H.v.toFixed(2) + "px";
952
- publishCardSize(W.v, H.v);
953
+ // The hit rect only needs to be roughly right DURING the animation (main
954
+ // re-reads it at the end); sending it every frame was 60 cross-process
955
+ // messages a second competing with the very frames it describes.
956
+ publishCardSizeThrottled(W.v, H.v);
953
957
  if (moving) raf = requestAnimationFrame(frame);
954
958
  else { raf = null; cardEl.style.willChange = ""; publishCardSize(W.t, H.t); }
955
959
  }
960
+ let lastPublishAt = 0;
961
+ function publishCardSizeThrottled(w, h) {
962
+ const now = performance.now();
963
+ if (now - lastPublishAt < 80) return;
964
+ lastPublishAt = now;
965
+ publishCardSize(w, h);
966
+ }
956
967
  function springTo(w, h) {
957
968
  W.t = w; H.t = h;
958
969
  if (REDUCED) { W.v = w; H.v = h; cardEl.style.width = w + "px"; cardEl.style.height = h + "px"; publishCardSize(w, h); return; }
959
- cardEl.style.willChange = "width, height";
970
+ // will-change on width/height buys nothing (they are layout, not composited)
971
+ // and costs a layer promotion; containment on .scroll is what makes this cheap.
960
972
  if (!raf) { lastT = performance.now(); raf = requestAnimationFrame(frame); }
961
973
  }
962
974
 
@@ -1238,16 +1250,14 @@
1238
1250
  // Counted, not stored as a target: between bursts the state is always re-derived
1239
1251
  // from the live `collapsed`, so it can never drift out of sync with the paths that
1240
1252
  // fold/expand the card on their own (tray open, notification fold, offstage reset).
1241
- let pendingToggles = 0;
1242
- function reconcileCollapsed() {
1243
- const odd = pendingToggles % 2 === 1;
1244
- pendingToggles = 0;
1245
- if (odd) setCollapsed(!collapsed);
1246
- }
1253
+ // Applied SYNCHRONOUSLY on the tap. The old path deferred to requestAnimationFrame
1254
+ // to coalesce bursts, but a renderer busy laying out the list could deliver that
1255
+ // frame late the click appeared to do nothing, which is precisely the complaint
1256
+ // it was meant to prevent. Coalescing is no longer needed: springTo only RETARGETS
1257
+ // the running spring, so a burst of taps moves the target rather than queuing
1258
+ // animations, and parity stays correct because each tap toggles exactly once.
1247
1259
  function requestToggle() {
1248
- pendingToggles += 1;
1249
- if (pendingToggles > 1) return; // reconcile already scheduled
1250
- requestAnimationFrame(reconcileCollapsed);
1260
+ setCollapsed(!collapsed);
1251
1261
  }
1252
1262
  window.addEventListener("mouseup", () => {
1253
1263
  if (!dragging) return;
@@ -1577,21 +1587,34 @@
1577
1587
  // expand-card open actions); anything with replies — or that only exists on
1578
1588
  // the sent side — renders as a conversation row that drills into the thread.
1579
1589
  const byId = new Map(allRows.map((r) => [r.id, r]));
1580
- const threads = buildThreads();
1581
- relaysEmptyEl.classList.toggle("gone", threads.length > 0);
1590
+ // Relays is an INBOX: it lists conversations someone sent you. A thread with
1591
+ // no inbound message is something you sent and nobody has answered — that
1592
+ // belongs in Sent, not here. (Once a reply arrives the whole exchange,
1593
+ // including your side of it, appears here as one conversation.)
1594
+ const allThreads = buildThreads().filter((t) => (t.msgs || []).some((m) => m.direction === "in"));
1595
+ relaysEmptyEl.classList.toggle("gone", allThreads.length > 0);
1582
1596
  const seen = prevRelayIds;
1597
+ // WINDOWED RENDER. Rebuilding every conversation cost ~21ms of blocking
1598
+ // main-thread work on a real inbox (150 rows, 134KB of HTML) — longer than
1599
+ // a frame, so any rebuild landing during the open/close animation dropped
1600
+ // frames, and one landing on a click delayed the click itself. Only the
1601
+ // rows a person can actually see are built; scrolling extends the window.
1602
+ // Nothing is hidden: reaching the end reveals the next page.
1603
+ const windowSize = Math.max(RENDER_WINDOW, renderedThreadCount);
1604
+ const threads = allThreads.slice(0, windowSize);
1605
+ renderedThreadCount = threads.length;
1606
+ hasMoreThreads = allThreads.length > threads.length;
1583
1607
  relaysListEl.innerHTML = threads.map((t) => {
1584
1608
  const inbound = t.msgs.filter((m) => m.direction === "in");
1585
1609
  if (t.msgs.length === 1 && inbound.length === 1 && byId.has(inbound[0].id)) {
1586
1610
  return relayRowHtml(byId.get(inbound[0].id), seen);
1587
1611
  }
1588
- const sentOnly = inbound.length === 0;
1589
1612
  return `
1590
- <div class="th-item${t.unreadCount ? " unread" : ""}${sentOnly ? " th-sent-only" : ""} deletable" role="button" tabindex="0" data-thread="${esc(t.threadId)}" data-party="${esc(t.party)}">
1613
+ <div class="th-item${t.unreadCount ? " unread" : ""} deletable" role="button" tabindex="0" data-thread="${esc(t.threadId)}" data-party="${esc(t.party)}">
1591
1614
  ${threadDeleteButton(t)}
1592
1615
  <span class="th-top">
1593
1616
  ${t.unreadCount ? '<span class="th-unread"></span>' : ""}
1594
- <span class="th-party">${sentOnly ? "To " : ""}${esc(t.party)}</span>
1617
+ <span class="th-party">${esc(t.party)}</span>
1595
1618
  ${t.msgs.length > 1 ? `<span class="th-count">${t.msgs.length}</span>` : ""}
1596
1619
  <span class="th-time">${esc(timeAgo(t.latest.at))}</span>
1597
1620
  </span>
@@ -1605,6 +1628,19 @@
1605
1628
  }
1606
1629
  }
1607
1630
 
1631
+ // Grow the window as the user scrolls toward the end. Cheap: each extension
1632
+ // renders one more page rather than the whole inbox.
1633
+ const RENDER_WINDOW = 25;
1634
+ let renderedThreadCount = 0;
1635
+ let hasMoreThreads = false;
1636
+ scrollEl.addEventListener("scroll", () => {
1637
+ if (!hasMoreThreads || activeView !== "relays") return;
1638
+ const nearEnd = scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 240;
1639
+ if (!nearEnd) return;
1640
+ renderedThreadCount += RENDER_WINDOW;
1641
+ renderRelays();
1642
+ }, { passive: true });
1643
+
1608
1644
  // Preview plus the two host-open actions, as one small text-first menu.
1609
1645
  // Preview is the primary, non-navigation path; the other actions hand the
1610
1646
  // relay to a current or fresh host session.
@@ -3071,6 +3107,43 @@
3071
3107
  else if (activeView === "sent") renderSent();
3072
3108
  }, 60000);
3073
3109
 
3110
+ // ---------- self-diagnosis ----------
3111
+ // A frame gap this long is not "slow", it is a visible freeze: the card stops
3112
+ // moving and clicks appear dead. Record what the UI was doing so the next real
3113
+ // occurrence on a real machine identifies its own cause (~/.relay/perf.log).
3114
+ const STALL_MS = 250;
3115
+ let lastRenderCostMs = 0;
3116
+ (function watchFrames() {
3117
+ let last = performance.now();
3118
+ function tick(now) {
3119
+ const gap = now - last;
3120
+ last = now;
3121
+ if (gap > STALL_MS && window.relay.reportStall) {
3122
+ window.relay.reportStall({
3123
+ ms: gap,
3124
+ kind: "frame",
3125
+ rows: relaysListEl ? relaysListEl.children.length : 0,
3126
+ animating: Boolean(raf),
3127
+ collapsed,
3128
+ lastRenderMs: lastRenderCostMs,
3129
+ });
3130
+ }
3131
+ requestAnimationFrame(tick);
3132
+ }
3133
+ requestAnimationFrame(tick);
3134
+ })();
3135
+ // Time the list rebuild itself, so a stall report says whether rendering caused it.
3136
+ const __renderRelays = renderRelays;
3137
+ renderRelays = function timedRenderRelays(...args) {
3138
+ const t0 = performance.now();
3139
+ const out = __renderRelays.apply(this, args);
3140
+ lastRenderCostMs = performance.now() - t0;
3141
+ if (lastRenderCostMs > STALL_MS && window.relay.reportStall) {
3142
+ window.relay.reportStall({ ms: lastRenderCostMs, kind: "render", rows: relaysListEl ? relaysListEl.children.length : 0, collapsed });
3143
+ }
3144
+ return out;
3145
+ };
3146
+
3074
3147
  // LAST statement on purpose: every listener above is registered, so main may
3075
3148
  // now deliver queued arrivals without a single one being dropped (FM-1).
3076
3149
  if (window.relay.rendererReady) window.relay.rendererReady();
package/overlay/main.cjs CHANGED
@@ -3019,6 +3019,50 @@ ipcMain.on("relay:setFocusable", (_e, focusable) => {
3019
3019
  // It only publishes the card's current SIZE (origin is fixed by the top-right anchor)
3020
3020
  // so main can derive the clickable rect. Stale sizes are safe: the rect is clamped to
3021
3021
  // the expanded card, and a size arriving late only shifts the hit edge by a few px.
3022
+ // Self-diagnosing stalls. Sandbox benchmarks kept showing a healthy pill while
3023
+ // the real one felt frozen for seconds, so the product records its own hitches:
3024
+ // the renderer reports any frame gap or click-to-response delay over the
3025
+ // threshold, main appends one line to ~/.relay/perf.log with what was happening.
3026
+ // The next real freeze explains itself instead of needing a reproduction.
3027
+ const PERF_LOG_PATH = path.join(
3028
+ process.env.RELAY_CONFIG_DIR || path.join(os.homedir(), ".relay"),
3029
+ "perf.log",
3030
+ );
3031
+ ipcMain.on("relay:stall", (_e, info) => {
3032
+ try {
3033
+ const d = info && typeof info === "object" ? info : {};
3034
+ const line = JSON.stringify({
3035
+ at: new Date().toISOString(),
3036
+ where: "renderer",
3037
+ ms: Math.round(Number(d.ms) || 0),
3038
+ kind: String(d.kind || "frame").slice(0, 24),
3039
+ rows: Number(d.rows) || 0,
3040
+ animating: d.animating === true,
3041
+ collapsed: d.collapsed === true,
3042
+ lastRenderMs: Math.round(Number(d.lastRenderMs) || 0),
3043
+ version: pillVersion(),
3044
+ });
3045
+ fs.appendFileSync(PERF_LOG_PATH, `${line}\n`);
3046
+ } catch {}
3047
+ });
3048
+ // Main-process side of the same detector: a blocked main loop delays the click
3049
+ // hit-test and every IPC the renderer is waiting on.
3050
+ {
3051
+ let lastTick = Date.now();
3052
+ setInterval(() => {
3053
+ const now = Date.now();
3054
+ const drift = now - lastTick - 500;
3055
+ lastTick = now;
3056
+ if (drift < 400) return;
3057
+ try {
3058
+ fs.appendFileSync(
3059
+ PERF_LOG_PATH,
3060
+ `${JSON.stringify({ at: new Date().toISOString(), where: "main", ms: Math.round(drift), version: pillVersion() })}\n`,
3061
+ );
3062
+ } catch {}
3063
+ }, 500).unref?.();
3064
+ }
3065
+
3022
3066
  ipcMain.on("relay:cardSize", (_e, w, h) => {
3023
3067
  if (!Number.isFinite(w) || !Number.isFinite(h)) return;
3024
3068
  cardSize = { w, h };
@@ -79,4 +79,7 @@ contextBridge.exposeInMainWorld("relay", {
79
79
  // Fired as the renderer script's last statement: every listener above is
80
80
  // registered, so main may deliver arrivals without dropping them.
81
81
  rendererReady: () => ipcRenderer.send("relay:rendererReady"),
82
+ // Self-diagnosis: the renderer reports its own hitches so a real freeze on a
83
+ // real machine writes evidence instead of needing a live reproduction.
84
+ reportStall: (info) => ipcRenderer.send("relay:stall", info || {}),
82
85
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.78",
3
+ "version": "0.1.80",
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": {