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.
- package/overlay/inbox.html +89 -13
- package/overlay/main.cjs +44 -0
- package/overlay/preload.cjs +3 -0
- package/package.json +1 -1
package/overlay/inbox.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
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
|
|
1581
|
-
relaysEmptyEl.classList.toggle("gone",
|
|
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 };
|
package/overlay/preload.cjs
CHANGED
|
@@ -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
|
});
|