relay-companion 0.1.78 → 0.1.79

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
  }
@@ -949,14 +956,25 @@
949
956
  for (let i = 0; i < n; i++) { const a = step(W, h), b = step(H, h); moving = moving || a || b; }
950
957
  cardEl.style.width = W.v.toFixed(2) + "px";
951
958
  cardEl.style.height = H.v.toFixed(2) + "px";
952
- publishCardSize(W.v, H.v);
959
+ // The hit rect only needs to be roughly right DURING the animation (main
960
+ // re-reads it at the end); sending it every frame was 60 cross-process
961
+ // messages a second competing with the very frames it describes.
962
+ publishCardSizeThrottled(W.v, H.v);
953
963
  if (moving) raf = requestAnimationFrame(frame);
954
964
  else { raf = null; cardEl.style.willChange = ""; publishCardSize(W.t, H.t); }
955
965
  }
966
+ let lastPublishAt = 0;
967
+ function publishCardSizeThrottled(w, h) {
968
+ const now = performance.now();
969
+ if (now - lastPublishAt < 80) return;
970
+ lastPublishAt = now;
971
+ publishCardSize(w, h);
972
+ }
956
973
  function springTo(w, h) {
957
974
  W.t = w; H.t = h;
958
975
  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";
976
+ // will-change on width/height buys nothing (they are layout, not composited)
977
+ // and costs a layer promotion; containment on .scroll is what makes this cheap.
960
978
  if (!raf) { lastT = performance.now(); raf = requestAnimationFrame(frame); }
961
979
  }
962
980
 
@@ -1238,16 +1256,14 @@
1238
1256
  // Counted, not stored as a target: between bursts the state is always re-derived
1239
1257
  // from the live `collapsed`, so it can never drift out of sync with the paths that
1240
1258
  // 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
- }
1259
+ // Applied SYNCHRONOUSLY on the tap. The old path deferred to requestAnimationFrame
1260
+ // to coalesce bursts, but a renderer busy laying out the list could deliver that
1261
+ // frame late — the click appeared to do nothing, which is precisely the complaint
1262
+ // it was meant to prevent. Coalescing is no longer needed: springTo only RETARGETS
1263
+ // the running spring, so a burst of taps moves the target rather than queuing
1264
+ // animations, and parity stays correct because each tap toggles exactly once.
1247
1265
  function requestToggle() {
1248
- pendingToggles += 1;
1249
- if (pendingToggles > 1) return; // reconcile already scheduled
1250
- requestAnimationFrame(reconcileCollapsed);
1266
+ setCollapsed(!collapsed);
1251
1267
  }
1252
1268
  window.addEventListener("mouseup", () => {
1253
1269
  if (!dragging) return;
@@ -1577,9 +1593,19 @@
1577
1593
  // expand-card open actions); anything with replies — or that only exists on
1578
1594
  // the sent side — renders as a conversation row that drills into the thread.
1579
1595
  const byId = new Map(allRows.map((r) => [r.id, r]));
1580
- const threads = buildThreads();
1581
- relaysEmptyEl.classList.toggle("gone", threads.length > 0);
1596
+ const allThreads = buildThreads();
1597
+ relaysEmptyEl.classList.toggle("gone", allThreads.length > 0);
1582
1598
  const seen = prevRelayIds;
1599
+ // WINDOWED RENDER. Rebuilding every conversation cost ~21ms of blocking
1600
+ // main-thread work on a real inbox (150 rows, 134KB of HTML) — longer than
1601
+ // a frame, so any rebuild landing during the open/close animation dropped
1602
+ // frames, and one landing on a click delayed the click itself. Only the
1603
+ // rows a person can actually see are built; scrolling extends the window.
1604
+ // Nothing is hidden: reaching the end reveals the next page.
1605
+ const windowSize = Math.max(RENDER_WINDOW, renderedThreadCount);
1606
+ const threads = allThreads.slice(0, windowSize);
1607
+ renderedThreadCount = threads.length;
1608
+ hasMoreThreads = allThreads.length > threads.length;
1583
1609
  relaysListEl.innerHTML = threads.map((t) => {
1584
1610
  const inbound = t.msgs.filter((m) => m.direction === "in");
1585
1611
  if (t.msgs.length === 1 && inbound.length === 1 && byId.has(inbound[0].id)) {
@@ -1605,6 +1631,19 @@
1605
1631
  }
1606
1632
  }
1607
1633
 
1634
+ // Grow the window as the user scrolls toward the end. Cheap: each extension
1635
+ // renders one more page rather than the whole inbox.
1636
+ const RENDER_WINDOW = 25;
1637
+ let renderedThreadCount = 0;
1638
+ let hasMoreThreads = false;
1639
+ scrollEl.addEventListener("scroll", () => {
1640
+ if (!hasMoreThreads || activeView !== "relays") return;
1641
+ const nearEnd = scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 240;
1642
+ if (!nearEnd) return;
1643
+ renderedThreadCount += RENDER_WINDOW;
1644
+ renderRelays();
1645
+ }, { passive: true });
1646
+
1608
1647
  // Preview plus the two host-open actions, as one small text-first menu.
1609
1648
  // Preview is the primary, non-navigation path; the other actions hand the
1610
1649
  // relay to a current or fresh host session.
@@ -3071,6 +3110,43 @@
3071
3110
  else if (activeView === "sent") renderSent();
3072
3111
  }, 60000);
3073
3112
 
3113
+ // ---------- self-diagnosis ----------
3114
+ // A frame gap this long is not "slow", it is a visible freeze: the card stops
3115
+ // moving and clicks appear dead. Record what the UI was doing so the next real
3116
+ // occurrence on a real machine identifies its own cause (~/.relay/perf.log).
3117
+ const STALL_MS = 250;
3118
+ let lastRenderCostMs = 0;
3119
+ (function watchFrames() {
3120
+ let last = performance.now();
3121
+ function tick(now) {
3122
+ const gap = now - last;
3123
+ last = now;
3124
+ if (gap > STALL_MS && window.relay.reportStall) {
3125
+ window.relay.reportStall({
3126
+ ms: gap,
3127
+ kind: "frame",
3128
+ rows: relaysListEl ? relaysListEl.children.length : 0,
3129
+ animating: Boolean(raf),
3130
+ collapsed,
3131
+ lastRenderMs: lastRenderCostMs,
3132
+ });
3133
+ }
3134
+ requestAnimationFrame(tick);
3135
+ }
3136
+ requestAnimationFrame(tick);
3137
+ })();
3138
+ // Time the list rebuild itself, so a stall report says whether rendering caused it.
3139
+ const __renderRelays = renderRelays;
3140
+ renderRelays = function timedRenderRelays(...args) {
3141
+ const t0 = performance.now();
3142
+ const out = __renderRelays.apply(this, args);
3143
+ lastRenderCostMs = performance.now() - t0;
3144
+ if (lastRenderCostMs > STALL_MS && window.relay.reportStall) {
3145
+ window.relay.reportStall({ ms: lastRenderCostMs, kind: "render", rows: relaysListEl ? relaysListEl.children.length : 0, collapsed });
3146
+ }
3147
+ return out;
3148
+ };
3149
+
3074
3150
  // LAST statement on purpose: every listener above is registered, so main may
3075
3151
  // now deliver queued arrivals without a single one being dropped (FM-1).
3076
3152
  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.79",
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": {