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.
- package/overlay/inbox.html +95 -22
- 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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1581
|
-
|
|
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" : ""}
|
|
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">${
|
|
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 };
|
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
|
});
|