relay-companion 0.1.403 → 0.1.404

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.403",
3
+ "version": "0.1.404",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "relay-companion",
9
- "version": "0.1.403",
9
+ "version": "0.1.404",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/claude-agent-sdk": "0.3.195",
@@ -94,6 +94,7 @@
94
94
  Elevation is three tokens: the card's own drop shadow (this one), the
95
95
  folded pill's lighter cousin, and one popover shadow — nothing else casts. */
96
96
  .card {
97
+ --relay-loop-state:paused;
97
98
  /* The native window preserves its current screen-space top-right point while
98
99
  it changes size. Anchor the visible sheet to that same point so a folded
99
100
  pill never lands at the old left edge and then jumps home when AppKit
@@ -109,6 +110,7 @@
109
110
  transition:box-shadow var(--t-slow) var(--soft), border-radius var(--t-slow) var(--settle);
110
111
  animation:appIn var(--t-slow) var(--soft) both;
111
112
  }
113
+ .card[data-continuous-motion="running"] { --relay-loop-state:running; }
112
114
  .card.collapsed {
113
115
  border-radius:var(--r-fold);
114
116
  box-shadow:var(--shadow-pill);
@@ -499,7 +501,7 @@
499
501
  mask:radial-gradient(farthest-side, #0000 calc(100% - 1.6px), #000 0);
500
502
  opacity:0; transition:opacity var(--t-base) var(--settle); pointer-events:none;
501
503
  }
502
- .row.opening .avatar::after, .th-item.opening .avatar::after { opacity:1; animation:dotSpin .7s linear infinite; }
504
+ .row.opening .avatar::after, .th-item.opening .avatar::after { opacity:1; animation:dotSpin .7s linear infinite; animation-play-state:var(--relay-loop-state); }
503
505
  @keyframes dotSpin { to { transform:rotate(1turn); } }
504
506
  .rk-top { display:flex; align-items:baseline; gap:7px; }
505
507
  .rk-sender { font-size:13px; font-weight:600; color:var(--ink); letter-spacing:.1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
@@ -1253,7 +1255,7 @@
1253
1255
  }
1254
1256
  .agent-working-indicator { display:inline-flex; align-items:center; gap:3px; margin-left:7px; vertical-align:2px; }
1255
1257
  .agent-working-dot { width:5px; height:5px; border-radius:50%; background:currentColor; opacity:.35;
1256
- animation:agentWorkingPulse 1.2s var(--settle) infinite; }
1258
+ animation:agentWorkingPulse 1.2s var(--settle) infinite; animation-play-state:var(--relay-loop-state); }
1257
1259
  .agent-working-dot:nth-child(2) { animation-delay:.16s; }
1258
1260
  .agent-working-dot:nth-child(3) { animation-delay:.32s; }
1259
1261
  @keyframes agentWorkingPulse {
@@ -1308,7 +1310,7 @@
1308
1310
  .th-host-context { overflow:hidden; color:var(--muted-2); font-family:var(--sans); font-size:10px;
1309
1311
  text-overflow:ellipsis; white-space:nowrap; }
1310
1312
  .th-host-arrow { color:var(--host-color); font-family:var(--sans); font-size:17px; line-height:1; }
1311
- .th-msg.opening .th-host-logo { animation:hostOpenBreathe .8s ease-in-out infinite alternate; }
1313
+ .th-msg.opening .th-host-logo { animation:hostOpenBreathe .8s ease-in-out infinite alternate; animation-play-state:var(--relay-loop-state); }
1312
1314
  @keyframes hostOpenBreathe { to { opacity:.42; transform:scale(.94); } }
1313
1315
  @media (hover:none), (pointer:coarse) {
1314
1316
  .th-host-disclosure { display:flex; }
@@ -1809,7 +1811,7 @@
1809
1811
  .rd-act { display:flex; gap:6px; align-items:center; min-width:0; font-family:var(--sans); font-size:14px; line-height:21px;
1810
1812
  font-weight:445; color:color-mix(in oklab, var(--ink) 60%, transparent); }
1811
1813
  .rd-act .act-icon { width:16px; height:16px; flex:0 0 16px; color:inherit; }
1812
- .rd-act.live .act-icon { animation:rdLivePulse 1.35s ease-in-out infinite; }
1814
+ .rd-act.live .act-icon { animation:rdLivePulse 1.35s ease-in-out infinite; animation-play-state:var(--relay-loop-state); }
1813
1815
  .rd-act .act-stack { display:flex; flex-direction:column; min-width:0; max-width:100%; }
1814
1816
  .rd-act .act-copy { display:inline-flex; align-items:baseline; gap:4px; min-width:0; max-width:100%; }
1815
1817
  .rd-act .averb { flex:none; font-weight:400; white-space:nowrap; }
@@ -1822,7 +1824,7 @@
1822
1824
  .rd-live-tail { display:flex; align-items:center; gap:7px; min-height:21px; font-family:var(--sans); font-size:14px; line-height:21px;
1823
1825
  color:color-mix(in oklab, var(--ink) 64%, transparent); }
1824
1826
  .rd-live-tail-dot { width:6px; height:6px; flex:0 0 6px; border-radius:999px; background:var(--provider, var(--accent));
1825
- animation:rdLivePulse 1.35s ease-in-out infinite; }
1827
+ animation:rdLivePulse 1.35s ease-in-out infinite; animation-play-state:var(--relay-loop-state); }
1826
1828
  @keyframes rdLivePulse { 0%,100% { opacity:.34; } 50% { opacity:1; } }
1827
1829
  .rd-said { align-self:flex-start; width:100%; font-family:var(--sans); font-size:14px; line-height:21px; font-weight:445; color:var(--ink); overflow-wrap:anywhere; }
1828
1830
  /* Codex conversation geometry: the agent and its work own the left edge;
@@ -2130,9 +2132,12 @@
2130
2132
  .tb-delete:hover { color:var(--danger); background:var(--danger-soft); }
2131
2133
  .tb-delete:disabled { opacity:.35; cursor:default; }
2132
2134
  .tb-working { display:inline-flex; align-items:center; color:var(--accent); font-family:var(--sans); font-size:10.5px; font-weight:500; white-space:nowrap; }
2133
- .tb-working-orb { position:relative; width:6px; height:6px; flex:0 0 6px; margin-right:7px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent); animation:taskWorkingBreathe 1.8s cubic-bezier(.4,0,.2,1) infinite; }
2135
+ .tb-working-orb { position:relative; width:6px; height:6px; flex:0 0 6px; margin-right:7px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent); animation:taskWorkingBreathe 1.8s cubic-bezier(.4,0,.2,1) infinite; animation-play-state:var(--relay-loop-state); }
2134
2136
  .tb-working-orb::after { content:""; position:absolute; left:1px; top:1px; width:2px; height:2px; border-radius:50%; background:color-mix(in srgb, white 70%, var(--accent)); opacity:.7; }
2135
- @keyframes taskWorkingBreathe { 0%,100% { opacity:.62; transform:scale(.88); box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 8%, transparent); } 50% { opacity:1; transform:scale(1); box-shadow:0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent); } }
2137
+ /* Keep the live cue on Chromium's compositor. Animating box-shadow repaints
2138
+ the transparent always-on-top window and can monopolize Electron's main /
2139
+ renderer IPC path even after the card folds. */
2140
+ @keyframes taskWorkingBreathe { 0%,100% { opacity:.62; transform:scale(.88); } 50% { opacity:1; transform:scale(1); } }
2136
2141
  @media (prefers-reduced-motion:reduce) {
2137
2142
  .tb-working-orb, .task-zero.celebrate .tz-tile { animation:none; opacity:1; }
2138
2143
  .task-zero, .tz-glow, .tz-mark, .tz-tile, .tz-title, .tz-sub, .tb-completed-reveal, .tb-completed-toggle svg { transition-duration:.001s; }
@@ -2457,10 +2462,10 @@
2457
2462
  .su-checks { margin:20px 0 0; display:grid; gap:9px; color:var(--muted); font-size:12px; }
2458
2463
  .su-check { display:flex; align-items:center; gap:8px; }
2459
2464
  .su-check-mark { color:var(--accent); font-weight:700; }
2460
- .su-check-active .su-check-mark { width:13px; height:13px; border:1.5px solid var(--hair); border-top-color:var(--accent); border-radius:50%; animation:suSpin .8s linear infinite; }
2465
+ .su-check-active .su-check-mark { width:13px; height:13px; border:1.5px solid var(--hair); border-top-color:var(--accent); border-radius:50%; animation:suSpin .8s linear infinite; animation-play-state:var(--relay-loop-state); }
2461
2466
  .su-note { margin-top:12px; min-height:17px; color:var(--muted); font-size:11px; line-height:1.45; }
2462
2467
  .su-error { color:var(--danger); }
2463
- .su-spinner { width:24px; height:24px; margin-bottom:14px; border:2px solid var(--hair); border-top-color:var(--accent); border-radius:50%; animation:suSpin .8s linear infinite; }
2468
+ .su-spinner { width:24px; height:24px; margin-bottom:14px; border:2px solid var(--hair); border-top-color:var(--accent); border-radius:50%; animation:suSpin .8s linear infinite; animation-play-state:var(--relay-loop-state); }
2464
2469
  @keyframes suSpin { to { transform:rotate(360deg); } }
2465
2470
  @media (prefers-reduced-motion:reduce) { .su-spinner { animation:none; border-top-color:var(--hair); } }
2466
2471
  @media (prefers-reduced-motion:reduce) { .su-check-active .su-check-mark { animation:none; border-top-color:var(--hair); } }
@@ -2964,6 +2969,34 @@
2964
2969
 
2965
2970
  // ---------- collapse / expand / peek ----------
2966
2971
  let collapsed = false;
2972
+ // Folding the transparent window hides its content visually, but opacity and
2973
+ // pointer-events do not stop animations, frame callbacks, or run polling.
2974
+ // This is the single lifecycle contract for all continuous renderer work;
2975
+ // finite fold/open transitions deliberately remain independent.
2976
+ const continuousMotionListeners = new Set();
2977
+ let surfaceRenderDeferred = false;
2978
+ let surfacePaintCount = 0;
2979
+ function rendererSurfaceActive() {
2980
+ return document.visibilityState === "visible"
2981
+ && !collapsed
2982
+ && !cardEl.classList.contains("offstage")
2983
+ && !cardEl.classList.contains("bye");
2984
+ }
2985
+ function syncContinuousMotion() {
2986
+ const active = rendererSurfaceActive();
2987
+ const next = active ? "running" : "paused";
2988
+ const changed = cardEl.dataset.continuousMotion !== next;
2989
+ cardEl.dataset.continuousMotion = next;
2990
+ if (changed) for (const listener of continuousMotionListeners) listener(active);
2991
+ return active;
2992
+ }
2993
+ function reconcileSurfaceActivity() {
2994
+ const active = syncContinuousMotion();
2995
+ if (active) flushDeferredSurfaceRender();
2996
+ else syncRunMirrors();
2997
+ }
2998
+ new MutationObserver(reconcileSurfaceActivity).observe(cardEl, { attributes:true, attributeFilter:["class"] });
2999
+ document.addEventListener("visibilitychange", reconcileSurfaceActivity);
2967
3000
  let readerOpenNow = false; // the reader holds the card at READER size
2968
3001
  let readerMorphInFlight = false;
2969
3002
  let readerMorphSnapshot = null;
@@ -3171,11 +3204,17 @@
3171
3204
  // One face, one transition: the count and compact controls track the same
3172
3205
  // state from the first shrinking frame through native-window settlement.
3173
3206
  cardEl.classList.toggle("collapsed", collapsed);
3207
+ syncContinuousMotion();
3174
3208
  const openSize = readerOpenNow ? READER : EXPANDED;
3175
3209
  springTo(collapsed ? PILL.w : openSize.w, collapsed ? PILL.h : openSize.h);
3176
3210
  playTink();
3177
3211
  // List rebuilds are deferred while folded; catch up now that the card is visible.
3178
- if (!collapsed) flushPendingRelaysRender();
3212
+ if (!collapsed) {
3213
+ flushPendingRelaysRender();
3214
+ flushDeferredSurfaceRender();
3215
+ } else {
3216
+ syncRunMirrors();
3217
+ }
3179
3218
  }
3180
3219
  // Park the card invisibly at expanded size while the OS window is hidden, so the
3181
3220
  // next appearance (tray reopen / ghost banner) starts from a clean slate.
@@ -3195,6 +3234,8 @@
3195
3234
  refreshCountLabel();
3196
3235
  cardEl.classList.add("offstage");
3197
3236
  cardEl.classList.remove("collapsed", "ghost", "bye");
3237
+ syncContinuousMotion();
3238
+ syncRunMirrors();
3198
3239
  snapSize(EXPANDED.w, EXPANDED.h);
3199
3240
  }
3200
3241
  // ✕ — the card leaves gracefully, then the main process hides the window and keeps
@@ -3234,6 +3275,8 @@
3234
3275
  cardEl.classList.remove("peek", "notifying");
3235
3276
  collapsed = true;
3236
3277
  cardEl.classList.add("collapsed");
3278
+ syncContinuousMotion();
3279
+ syncRunMirrors();
3237
3280
  refreshCountLabel();
3238
3281
  springTo(PILL.w, PILL.h);
3239
3282
  renderAll();
@@ -5879,6 +5922,11 @@
5879
5922
  // Every render rebuilds the stream element, so re-attach the poll to whatever
5880
5923
  // is on screen now and drop the ones whose card just closed.
5881
5924
  function syncRunMirrors() {
5925
+ if (!rendererSurfaceActive()) {
5926
+ for (const id of [...runFeedTimers.keys()]) stopRunFeed(id);
5927
+ for (const id of [...runScrollControllers.keys()]) destroyWorkScroller(id);
5928
+ return;
5929
+ }
5882
5930
  const open = new Set();
5883
5931
  for (const el of document.querySelectorAll("[data-run-stream]")) {
5884
5932
  const id = el.getAttribute("data-run-stream");
@@ -13101,6 +13149,7 @@
13101
13149
  // frame target. It removes the blank-frame and double-rebuild paths while
13102
13150
  // preserving applyView as the sole visibility writer.
13103
13151
  const enteringView = applyView._last !== activeView;
13152
+ const paintSurface = rendererSurfaceActive();
13104
13153
  const credentialRecovery = ["unavailable", "missing", "corrupt"].includes(payload.account?.credentialStatus);
13105
13154
  const tutorialPending = !credentialRecovery && payload.account?.paired !== false && payload.ui?.setupTutorialPending === true;
13106
13155
  if (credentialRecovery) {
@@ -13114,13 +13163,20 @@
13114
13163
  cardEl.classList.toggle("signup", needsSignup);
13115
13164
  signupViewEl?.classList.toggle("gone", !needsSignup);
13116
13165
  if (needsSignup) {
13117
- renderSignup();
13166
+ if (paintSurface) {
13167
+ renderSignup();
13168
+ surfacePaintCount += 1;
13169
+ surfaceRenderDeferred = false;
13170
+ } else {
13171
+ surfaceRenderDeferred = true;
13172
+ }
13118
13173
  syncCardSize(false);
13119
13174
  if (!credentialRecovery && payload.account?.paired === false && !signupStateLoaded && window.relay.installationAuthState) {
13120
13175
  signupStateLoaded = true;
13121
13176
  window.relay.installationAuthState().then((state) => {
13122
13177
  applyInstallationState(state);
13123
- renderSignup();
13178
+ if (rendererSurfaceActive()) renderSignup();
13179
+ else surfaceRenderDeferred = true;
13124
13180
  if (["google", "approval", "finishing"].includes(signupStage)) pollInstallationState();
13125
13181
  }).catch(() => {});
13126
13182
  }
@@ -13156,23 +13212,35 @@
13156
13212
  markAllReadEl.classList.toggle("gone", activeView !== "relays" || unread === 0);
13157
13213
  markAllReadEl.disabled = unread === 0;
13158
13214
  setBadge(tasksBadgeEl, requestsUnreadCount());
13159
- if (activeView === "chat") renderChat();
13160
- if (activeView === "relays") renderRelays();
13161
- else if (activeView === "slack") renderSlack();
13162
- else if (activeView === "threads") renderThreads();
13163
- else if (activeView === "sent") renderSent();
13164
- else if (activeView === "tasks") renderTasksBoard();
13165
- else if (activeView === "reader") renderReader();
13166
- else if (activeView === "requestDetail") renderRequestDetail();
13167
- else if (activeView === "settings" && enteringView) renderSettings();
13215
+ if (paintSurface) {
13216
+ if (activeView === "chat") renderChat();
13217
+ if (activeView === "relays") renderRelays();
13218
+ else if (activeView === "slack") renderSlack();
13219
+ else if (activeView === "threads") renderThreads();
13220
+ else if (activeView === "sent") renderSent();
13221
+ else if (activeView === "tasks") renderTasksBoard();
13222
+ else if (activeView === "reader") renderReader();
13223
+ else if (activeView === "requestDetail") renderRequestDetail();
13224
+ else if (activeView === "settings" && enteringView) renderSettings();
13225
+ surfacePaintCount += 1;
13226
+ surfaceRenderDeferred = false;
13227
+ } else {
13228
+ surfaceRenderDeferred = true;
13229
+ }
13168
13230
  applyView();
13169
13231
  // Every render rebuilds the mirror's element, so re-attach the poll to
13170
13232
  // whatever is on screen now and drop the ones whose card just closed.
13171
13233
  syncRunMirrors();
13172
- if (activeView === "tasks" || activeView === "reader") loadSchedules();
13234
+ if (paintSurface && (activeView === "tasks" || activeView === "reader")) loadSchedules();
13173
13235
  // contacts renders from its own load path
13174
13236
  }
13175
13237
 
13238
+ function flushDeferredSurfaceRender() {
13239
+ if (!surfaceRenderDeferred || !rendererSurfaceActive()) return;
13240
+ surfaceRenderDeferred = false;
13241
+ renderAll();
13242
+ }
13243
+
13176
13244
  markAllReadEl.addEventListener("click", async () => {
13177
13245
  if (markAllReadEl.disabled || unreadCount === 0) return;
13178
13246
  markAllReadEl.disabled = true;
@@ -13299,7 +13367,11 @@
13299
13367
  if (window.relay.onOpenFull) window.relay.onOpenFull(() => trayOpen());
13300
13368
  // The OS window was just re-shown; reset notification dwell state until the
13301
13369
  // pointer enters the card again.
13302
- if (window.relay.onShown) window.relay.onShown(() => { interactive = false; });
13370
+ if (window.relay.onShown) window.relay.onShown(() => {
13371
+ interactive = false;
13372
+ syncContinuousMotion();
13373
+ flushDeferredSurfaceRender();
13374
+ });
13303
13375
  if (window.relay.onOpenDone) window.relay.onOpenDone((id) => { stopOpening(id); retireOpeningNote(id); });
13304
13376
  // Visible confirmation for "Open in current chat". Two honest states:
13305
13377
  // waiting — staged for an idle chat; ONLY that chat's next turn can ingest
@@ -13377,7 +13449,7 @@
13377
13449
  // re-pushed. Skip while the pointer is on the card (a rebuild under the pointer wipes
13378
13450
  // hover state) and while dragging; the next tick catches up.
13379
13451
  setInterval(() => {
13380
- if (dragging || interactive) return;
13452
+ if (dragging || interactive || !rendererSurfaceActive()) return;
13381
13453
  if (activeView === "relays") renderRelays();
13382
13454
  else if (activeView === "sent") renderSent();
13383
13455
  }, 60000);
@@ -13386,13 +13458,20 @@
13386
13458
  // A frame gap this long is not "slow", it is a visible freeze: the card stops
13387
13459
  // moving and clicks appear dead. Record what the UI was doing so the next real
13388
13460
  // occurrence on a real machine identifies its own cause (~/.relay/perf.log).
13461
+ // The watcher itself must obey the surface lifecycle: an always-running rAF
13462
+ // keeps Chromium producing frames for a folded transparent window.
13389
13463
  const STALL_MS = 250;
13390
13464
  let lastRenderCostMs = 0;
13391
- (function watchFrames() {
13392
- let last = performance.now();
13465
+ let frameWatchStats = () => ({ active:false, ticks:0 });
13466
+ (function installFrameWatcher() {
13467
+ let watchRaf = null;
13468
+ let last = 0;
13469
+ let ticks = 0;
13393
13470
  function tick(now) {
13471
+ if (!rendererSurfaceActive()) { watchRaf = null; return; }
13394
13472
  const gap = now - last;
13395
13473
  last = now;
13474
+ ticks += 1;
13396
13475
  if (gap > STALL_MS && window.relay.reportStall) {
13397
13476
  window.relay.reportStall({
13398
13477
  ms: gap,
@@ -13403,9 +13482,21 @@
13403
13482
  lastRenderMs: lastRenderCostMs,
13404
13483
  });
13405
13484
  }
13406
- requestAnimationFrame(tick);
13485
+ watchRaf = requestAnimationFrame(tick);
13407
13486
  }
13408
- requestAnimationFrame(tick);
13487
+ function setActive(active) {
13488
+ if (!active) {
13489
+ if (watchRaf !== null) cancelAnimationFrame(watchRaf);
13490
+ watchRaf = null;
13491
+ return;
13492
+ }
13493
+ if (watchRaf !== null) return;
13494
+ last = performance.now();
13495
+ watchRaf = requestAnimationFrame(tick);
13496
+ }
13497
+ continuousMotionListeners.add(setActive);
13498
+ frameWatchStats = () => ({ active:watchRaf !== null, ticks });
13499
+ setActive(syncContinuousMotion());
13409
13500
  })();
13410
13501
  // Time the list rebuild itself, so a stall report says whether rendering caused it.
13411
13502
  const __renderRelays = renderRelays;
@@ -13423,6 +13514,34 @@
13423
13514
  // schema is deliberately allowed to migrate. Install this only after the
13424
13515
  // complete renderer has initialized, and never expose it in production.
13425
13516
  if (window.relay.isTestOverlay) {
13517
+ window.__relayMotionTest = {
13518
+ state: () => {
13519
+ const loopAnimations = document.getAnimations({ subtree:true }).filter((animation) => {
13520
+ try { return animation.effect && animation.effect.getTiming().iterations === Infinity; }
13521
+ catch { return false; }
13522
+ });
13523
+ return {
13524
+ active:rendererSurfaceActive(),
13525
+ motion:cardEl.dataset.continuousMotion,
13526
+ collapsed,
13527
+ activeView,
13528
+ deferred:surfaceRenderDeferred,
13529
+ surfacePaintCount,
13530
+ frameWatch:frameWatchStats(),
13531
+ loopAnimations:loopAnimations.map((animation) => animation.playState),
13532
+ };
13533
+ },
13534
+ setCollapsed: (value) => { setCollapsed(Boolean(value)); return collapsed; },
13535
+ setView: (view) => {
13536
+ activeView = String(view || "relays");
13537
+ commitNavigation();
13538
+ return activeView;
13539
+ },
13540
+ render: (count = 1) => {
13541
+ for (let i = 0; i < Math.max(1, Number(count) || 1); i += 1) renderAll();
13542
+ return surfacePaintCount;
13543
+ },
13544
+ };
13426
13545
  window.__relayParityOpenRequest = (fixture) => {
13427
13546
  if (!fixture?.id) throw new Error("A Task fixture id is required");
13428
13547
  payload.relays = [...(payload.relays || []).filter((row) => row.id !== fixture.id), fixture];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.403",
3
+ "version": "0.1.404",
4
4
  "description": "Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.",
5
5
  "homepage": "https://sendrelays.com/get-started",
6
6
  "repository": {