relay-companion 0.1.39 → 0.1.41
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/bin/relay.js +55 -6
- package/overlay/host-select.cjs +40 -3
- package/overlay/inbox.html +61 -22
- package/overlay/main.cjs +315 -55
- package/overlay/preload.cjs +1 -0
- package/overlay/relayAppIcon.svg +27 -0
- package/overlay/visibility.cjs +60 -5
- package/package.json +2 -1
- package/src/auto-update.js +235 -102
- package/src/codex-desktop.js +102 -12
- package/src/desktop-migration.js +260 -0
- package/src/install.js +293 -10
- package/src/pill-control.js +49 -0
- package/src/postinstall.js +307 -0
- package/src/task-daemon.js +45 -21
package/bin/relay.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
6
7
|
import { spawnSync, spawn } from "node:child_process";
|
|
@@ -10,8 +11,9 @@ import { RelayClient } from "../src/client.js";
|
|
|
10
11
|
import { writeConfig, readConfig, apiUrl, DEFAULT_API_URL, DEFAULT_WEB_URL } from "../src/config.js";
|
|
11
12
|
import { runTaskDaemon, pollTaskRuntimeOnce } from "../src/task-daemon.js";
|
|
12
13
|
import { runMcpServer } from "../src/mcp.js";
|
|
13
|
-
import { runSetupInstall, runUninstall } from "../src/install.js";
|
|
14
|
+
import { repairDesktopSurfaces, runSetupInstall, runUninstall } from "../src/install.js";
|
|
14
15
|
import { runUpdateOnce } from "../src/auto-update.js";
|
|
16
|
+
import { pillStatusPath, waitForPillReady } from "../src/pill-control.js";
|
|
15
17
|
import { liveToolRequirement, requiredLiveHosts, shouldRequireLiveTools } from "../src/setup-activation.js";
|
|
16
18
|
import { openRelay, openTask } from "../src/materializer.js";
|
|
17
19
|
import { resetCompanionStateForAccount } from "../src/notifications.js";
|
|
@@ -35,6 +37,16 @@ function parseFlags(argv) {
|
|
|
35
37
|
return { flags, positional };
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
function companionVersion() {
|
|
41
|
+
try {
|
|
42
|
+
const require = createRequire(import.meta.url);
|
|
43
|
+
const pkg = require("../package.json");
|
|
44
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
45
|
+
} catch {
|
|
46
|
+
return "unknown";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
async function prompt(question, fallback = "") {
|
|
39
51
|
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
40
52
|
const answer = (await rl.question(question)).trim();
|
|
@@ -97,7 +109,7 @@ function printActivationStatus(activation) {
|
|
|
97
109
|
}
|
|
98
110
|
|
|
99
111
|
function statusAreaReopenText() {
|
|
100
|
-
if (process.platform === "darwin") return "the Relay icon in the menu bar";
|
|
112
|
+
if (process.platform === "darwin") return "the Relay app in Spotlight or the Relay icon in the menu bar";
|
|
101
113
|
if (process.platform === "win32") return "the Relay icon in the system tray near the clock";
|
|
102
114
|
return "the Relay icon in the system tray";
|
|
103
115
|
}
|
|
@@ -180,6 +192,22 @@ async function cmdInstall() {
|
|
|
180
192
|
await applyInstall({ requireLiveTools: shouldRequireLiveTools(), requiredHosts: requiredLiveHosts() });
|
|
181
193
|
}
|
|
182
194
|
|
|
195
|
+
function cmdRepairDesktop(flags = {}) {
|
|
196
|
+
const reload = !flags["no-restart"];
|
|
197
|
+
const repaired = repairDesktopSurfaces({ reload });
|
|
198
|
+
if (!repaired.ok) {
|
|
199
|
+
const failures = [
|
|
200
|
+
!repaired.daemon?.ok && `daemon: ${repaired.daemon?.reason || "install failed"}`,
|
|
201
|
+
!repaired.pill?.ok && `pill: ${repaired.pill?.reason || "install failed"}`,
|
|
202
|
+
].filter(Boolean);
|
|
203
|
+
throw new Error(`Could not repair Relay desktop services (${failures.join(", ")}).`);
|
|
204
|
+
}
|
|
205
|
+
const appText = repaired.pill?.appPath ? ` Relay.app is installed at ${repaired.pill.appPath}.` : "";
|
|
206
|
+
const restartText = reload ? "Relay background services were reloaded." : "Relay background service files were refreshed without restarting them.";
|
|
207
|
+
console.log(`${restartText}${appText}`);
|
|
208
|
+
return repaired;
|
|
209
|
+
}
|
|
210
|
+
|
|
183
211
|
/** Remove the Relay tools from both agents and stop the background daemon. */
|
|
184
212
|
async function cmdUninstall() {
|
|
185
213
|
runUninstall();
|
|
@@ -274,8 +302,8 @@ async function cmdAnswerQuestion(flags) {
|
|
|
274
302
|
console.log("Sent answer to Relay. The waiting task agent will resume on the next daemon poll.");
|
|
275
303
|
}
|
|
276
304
|
|
|
277
|
-
/** Launch the desktop Relay companion pill
|
|
278
|
-
function cmdPill() {
|
|
305
|
+
/** Launch (or signal) the desktop Relay companion pill and verify it is visible. */
|
|
306
|
+
async function cmdPill() {
|
|
279
307
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
280
308
|
const overlayMain = path.resolve(here, "../overlay/main.cjs");
|
|
281
309
|
// require("electron") returns the path to the electron binary when resolved under node.
|
|
@@ -293,25 +321,44 @@ function cmdPill() {
|
|
|
293
321
|
if (typeof electronPath !== "string") {
|
|
294
322
|
throw new Error("Could not resolve the Electron binary. Run `npm install` in packages/companion.");
|
|
295
323
|
}
|
|
296
|
-
const
|
|
324
|
+
const reopenNonce = `cli-${process.pid}-${randomUUID()}`;
|
|
325
|
+
let spawnError = null;
|
|
326
|
+
const child = spawn(electronPath, [overlayMain, "--relay-reopen", reopenNonce], {
|
|
297
327
|
detached: true,
|
|
298
328
|
stdio: "ignore",
|
|
299
329
|
env: { ...process.env },
|
|
300
330
|
});
|
|
331
|
+
child.on("error", (error) => {
|
|
332
|
+
spawnError = error;
|
|
333
|
+
});
|
|
301
334
|
child.unref();
|
|
302
|
-
|
|
335
|
+
const result = await waitForPillReady(reopenNonce);
|
|
336
|
+
if (!result.ok) {
|
|
337
|
+
const detail = spawnError ? ` ${spawnError.message || spawnError}` : "";
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Relay was started but did not confirm that its pill became visible.${detail} Check ${pillStatusPath()} and ~/.relay/pill.log.`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
console.log(`Relay pill is visible. Reopen it from ${statusAreaReopenText()} after hiding it.`);
|
|
303
343
|
}
|
|
304
344
|
|
|
305
345
|
async function main() {
|
|
306
346
|
const [command, ...rest] = process.argv.slice(2);
|
|
307
347
|
const { flags, positional } = parseFlags(rest);
|
|
308
348
|
switch (command) {
|
|
349
|
+
case "version":
|
|
350
|
+
case "--version":
|
|
351
|
+
case "-v":
|
|
352
|
+
console.log(companionVersion());
|
|
353
|
+
return;
|
|
309
354
|
case "setup":
|
|
310
355
|
return cmdSetup(flags);
|
|
311
356
|
case "pair":
|
|
312
357
|
return cmdPair(flags);
|
|
313
358
|
case "install":
|
|
314
359
|
return cmdInstall();
|
|
360
|
+
case "repair-desktop":
|
|
361
|
+
return cmdRepairDesktop(flags);
|
|
315
362
|
case "uninstall":
|
|
316
363
|
return cmdUninstall();
|
|
317
364
|
case "whoami":
|
|
@@ -341,10 +388,12 @@ async function main() {
|
|
|
341
388
|
"",
|
|
342
389
|
"Usage:",
|
|
343
390
|
" relay setup [--code CODE] [--name NAME] Pair this machine and add Relay to Claude Code + Codex",
|
|
391
|
+
" relay version Print the installed Relay companion version",
|
|
344
392
|
" relay setup --code CODE --open-relay TOKEN --host codex|claude",
|
|
345
393
|
" relay setup --interactive Prompt for API URL and device name during setup",
|
|
346
394
|
" relay install Add Relay to your agents (device already paired)",
|
|
347
395
|
" relay uninstall Remove Relay from your agents and stop the daemon",
|
|
396
|
+
" relay repair-desktop [--no-restart] Repair Relay.app and background services without changing MCP/account state",
|
|
348
397
|
" relay pair [--api URL] [--web URL] [--code CODE] Pair this machine only (no agent install)",
|
|
349
398
|
" relay tasks Pull and process task agent runtime once",
|
|
350
399
|
" relay open <id> --host claude|codex Materialize a staged Relay row into a native agent session",
|
package/overlay/host-select.cjs
CHANGED
|
@@ -11,12 +11,22 @@
|
|
|
11
11
|
// desktop it breaks"). The running-process signal (from `ps`, which is Space-agnostic)
|
|
12
12
|
// is the reliable tiebreaker, so it now sits ahead of the installed-preference default.
|
|
13
13
|
|
|
14
|
+
const CLAUDE_DESKTOP_BUNDLE = "com.anthropic.claudefordesktop";
|
|
15
|
+
const CODEX_DESKTOP_BUNDLES = ["com.openai.codex", "com.openai.chat"];
|
|
16
|
+
|
|
14
17
|
function hostFromBundle(bundle) {
|
|
15
|
-
if (bundle ===
|
|
16
|
-
if (bundle
|
|
18
|
+
if (bundle === CLAUDE_DESKTOP_BUNDLE) return "claude";
|
|
19
|
+
if (CODEX_DESKTOP_BUNDLES.includes(bundle)) return "codex";
|
|
17
20
|
return null;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
function activationBundleCandidates(host, observedBundle = null) {
|
|
24
|
+
if (host === "claude") return [CLAUDE_DESKTOP_BUNDLE];
|
|
25
|
+
if (host !== "codex") return [];
|
|
26
|
+
const observed = CODEX_DESKTOP_BUNDLES.includes(observedBundle) ? observedBundle : null;
|
|
27
|
+
return [...new Set([observed, ...CODEX_DESKTOP_BUNDLES].filter(Boolean))];
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
function isRelayBundle(bundle) {
|
|
21
31
|
const clean = String(bundle || "").toLowerCase();
|
|
22
32
|
return clean.includes("relay") || clean.includes("electron");
|
|
@@ -103,4 +113,31 @@ function runningHostsFromProcessList(text, platform = process.platform) {
|
|
|
103
113
|
};
|
|
104
114
|
}
|
|
105
115
|
|
|
106
|
-
|
|
116
|
+
// Terminal Claude Code is useful diagnostic state, but it is not a desktop surface
|
|
117
|
+
// Relay can foreground: Relay's Claude materializer opens a claude:// resume link in
|
|
118
|
+
// Claude Desktop. Keep this separate from runningHostsFromProcessList so a terminal-
|
|
119
|
+
// only process never diverts a click away from a usable Codex/ChatGPT desktop window.
|
|
120
|
+
function terminalClaudeCodeRunningFromProcessList(text, platform = process.platform) {
|
|
121
|
+
const lines = String(text || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
122
|
+
if (platform === "darwin") {
|
|
123
|
+
return lines.some(
|
|
124
|
+
(line) =>
|
|
125
|
+
!/\.app\/Contents\//i.test(line) &&
|
|
126
|
+
/(?:^|\/)claude(?:-code)?(?:\.exe)?$/i.test(line),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (platform === "win32") {
|
|
130
|
+
return lines.some((line) => /(?:^|[\\/\"])(?:claude-code|claude)(?:\.exe)?(?:\"|$)/i.test(line));
|
|
131
|
+
}
|
|
132
|
+
return lines.some((line) => /(?:^|\/)claude(?:-code)?$/i.test(line));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
hostFromBundle,
|
|
137
|
+
activationBundleCandidates,
|
|
138
|
+
isRelayBundle,
|
|
139
|
+
defaultClickHost,
|
|
140
|
+
chooseClickHost,
|
|
141
|
+
runningHostsFromProcessList,
|
|
142
|
+
terminalClaudeCodeRunningFromProcessList,
|
|
143
|
+
};
|
package/overlay/inbox.html
CHANGED
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
}
|
|
101
101
|
.count {
|
|
102
102
|
font-family:var(--mono); font-size:13px; font-weight:500; font-variant-numeric:tabular-nums;
|
|
103
|
-
color:var(--accent); line-height:1; transition:color .3s var(--settle);
|
|
103
|
+
color:var(--accent); line-height:1; white-space:nowrap; transition:color .3s var(--settle);
|
|
104
104
|
}
|
|
105
105
|
.count.zero { color:var(--muted-3); }
|
|
106
106
|
/* ✕ — fully hide the overlay; the Relay mark in the OS status area brings it back.
|
|
@@ -651,7 +651,7 @@
|
|
|
651
651
|
|
|
652
652
|
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
653
653
|
const EXPANDED = { w: 344, h: 524 };
|
|
654
|
-
const PILL = { w:
|
|
654
|
+
const PILL = { w: 244, h: 44 }; // wide enough for mark + word + "N unopened" + ✕
|
|
655
655
|
const PEEK = { w: 344, h: 118 };
|
|
656
656
|
|
|
657
657
|
// ---------- zero-latency sound (Web Audio, pre-decoded) ----------
|
|
@@ -720,16 +720,19 @@
|
|
|
720
720
|
// ---------- collapse / expand / peek ----------
|
|
721
721
|
let collapsed = false;
|
|
722
722
|
let peeking = false, peekTimer = null;
|
|
723
|
+
let notificationDurationMs = 7000;
|
|
724
|
+
let activeNotificationIds = [];
|
|
723
725
|
let ghost = false, ghostTimer = null; // notification-only mode while the pill is dismissed
|
|
724
726
|
function clearPeek() {
|
|
725
727
|
if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
|
|
726
728
|
peeking = false;
|
|
727
|
-
cardEl.classList.remove("peek");
|
|
729
|
+
cardEl.classList.remove("peek", "notifying");
|
|
728
730
|
}
|
|
729
731
|
function setCollapsed(v) {
|
|
730
732
|
clearPeek();
|
|
731
733
|
if (v === collapsed) return;
|
|
732
734
|
collapsed = v;
|
|
735
|
+
refreshCountLabel();
|
|
733
736
|
cardEl.classList.toggle("collapsed", collapsed);
|
|
734
737
|
springTo(collapsed ? PILL.w : EXPANDED.w, collapsed ? PILL.h : EXPANDED.h);
|
|
735
738
|
playTink();
|
|
@@ -747,6 +750,7 @@
|
|
|
747
750
|
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
748
751
|
clearPeek();
|
|
749
752
|
collapsed = false;
|
|
753
|
+
refreshCountLabel();
|
|
750
754
|
cardEl.classList.add("offstage");
|
|
751
755
|
cardEl.classList.remove("collapsed", "ghost", "bye");
|
|
752
756
|
snapSize(EXPANDED.w, EXPANDED.h);
|
|
@@ -767,6 +771,10 @@
|
|
|
767
771
|
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
768
772
|
if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
|
|
769
773
|
const wasGhost = ghost;
|
|
774
|
+
if (!wasGhost && activeNotificationIds.length && window.relay.attentionDone) {
|
|
775
|
+
window.relay.attentionDone(activeNotificationIds);
|
|
776
|
+
activeNotificationIds = [];
|
|
777
|
+
}
|
|
770
778
|
cardEl.classList.add("bye");
|
|
771
779
|
setInteractive(false);
|
|
772
780
|
byeTimer = setTimeout(() => {
|
|
@@ -784,6 +792,7 @@
|
|
|
784
792
|
cancelBye(); // a banner arriving mid-exit must not be torn down by the old exit
|
|
785
793
|
clearPeek();
|
|
786
794
|
collapsed = false;
|
|
795
|
+
refreshCountLabel();
|
|
787
796
|
cardEl.classList.remove("collapsed", "offstage", "bye");
|
|
788
797
|
ghost = true; peeking = true;
|
|
789
798
|
cardEl.classList.add("peek", "ghost");
|
|
@@ -801,11 +810,13 @@
|
|
|
801
810
|
ghostTimer = null;
|
|
802
811
|
if (interactive || quickReplyIsActive()) { ghostTimer = setTimeout(tick, 4000); return; }
|
|
803
812
|
dismissOverlay(); // rides the ghostOut exit, then main re-hides the window
|
|
804
|
-
},
|
|
813
|
+
}, notificationDurationMs);
|
|
805
814
|
}
|
|
806
815
|
// Status-area Relay mark clicked: the card comes back FULLY OPEN (never the pill).
|
|
807
816
|
function trayOpen() {
|
|
808
817
|
const wasOffstage = cardEl.classList.contains("offstage") || cardEl.classList.contains("bye");
|
|
818
|
+
const completedNotificationIds = activeNotificationIds;
|
|
819
|
+
activeNotificationIds = [];
|
|
809
820
|
cancelBye(); // a reopen mid-exit must survive: the pending dismiss would hide us again
|
|
810
821
|
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
811
822
|
ghost = false;
|
|
@@ -825,30 +836,38 @@
|
|
|
825
836
|
springTo(EXPANDED.w, EXPANDED.h);
|
|
826
837
|
}
|
|
827
838
|
renderAll();
|
|
839
|
+
if (completedNotificationIds.length && window.relay.attentionDone) window.relay.attentionDone(completedNotificationIds);
|
|
828
840
|
playTink();
|
|
829
841
|
}
|
|
830
842
|
// The banner must show its WHOLE first row — a clipped preview reads as broken.
|
|
831
843
|
// Measure after render; QR question rows are tall, so allow room before capping.
|
|
832
844
|
function peekHeight() {
|
|
833
|
-
const
|
|
834
|
-
const
|
|
835
|
-
return Math.min(44 +
|
|
836
|
-
}
|
|
837
|
-
//
|
|
838
|
-
// The
|
|
839
|
-
|
|
840
|
-
|
|
845
|
+
const stack = [...relaysListEl.querySelectorAll(".row")];
|
|
846
|
+
const rowsHeight = stack.reduce((sum, row) => sum + row.offsetHeight, 0);
|
|
847
|
+
return Math.min(44 + rowsHeight + 6, EXPANDED.h);
|
|
848
|
+
}
|
|
849
|
+
// New relays always enter the notification stack, including from a fully expanded
|
|
850
|
+
// card. The main process has already pushed the new payload, so render/sizing uses
|
|
851
|
+
// the latest unopened rows rather than the previous inbox.
|
|
852
|
+
function notifyArrival(rows) {
|
|
841
853
|
if (peekTimer) clearTimeout(peekTimer);
|
|
854
|
+
cancelBye();
|
|
855
|
+
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
856
|
+
ghost = false;
|
|
842
857
|
peeking = true;
|
|
843
858
|
collapsed = false;
|
|
844
|
-
|
|
845
|
-
|
|
859
|
+
refreshCountLabel();
|
|
860
|
+
activeNotificationIds = (Array.isArray(rows) ? rows : [rows]).filter(Boolean).map((row) => row.id).filter(Boolean);
|
|
861
|
+
cardEl.classList.remove("collapsed", "peek", "offstage", "ghost", "bye");
|
|
862
|
+
// Keep the exact production expanded UI (tabs, typography, separators). This
|
|
863
|
+
// class is state-only for tests; it intentionally has no visual CSS of its own.
|
|
864
|
+
cardEl.classList.add("notifying");
|
|
846
865
|
activeView = "relays";
|
|
847
866
|
syncTabs();
|
|
848
867
|
applyView();
|
|
849
868
|
renderAll();
|
|
850
869
|
scrollEl.scrollTop = 0;
|
|
851
|
-
springTo(
|
|
870
|
+
springTo(EXPANDED.w, EXPANDED.h);
|
|
852
871
|
playTink();
|
|
853
872
|
peekTimer = setTimeout(function tick() {
|
|
854
873
|
peekTimer = null;
|
|
@@ -858,10 +877,14 @@
|
|
|
858
877
|
// hold while hovered too). Mirrors the ghost-notification guard.
|
|
859
878
|
if (interactive || quickReplyIsActive()) { peekTimer = setTimeout(tick, 4000); return; }
|
|
860
879
|
peeking = false;
|
|
861
|
-
cardEl.classList.remove("peek");
|
|
880
|
+
cardEl.classList.remove("peek", "notifying");
|
|
862
881
|
collapsed = true; cardEl.classList.add("collapsed");
|
|
882
|
+
refreshCountLabel();
|
|
863
883
|
springTo(PILL.w, PILL.h);
|
|
864
|
-
|
|
884
|
+
renderAll(); // restore the normal full list behind the now-folded pill
|
|
885
|
+
if (activeNotificationIds.length && window.relay.attentionDone) window.relay.attentionDone(activeNotificationIds);
|
|
886
|
+
activeNotificationIds = [];
|
|
887
|
+
}, notificationDurationMs);
|
|
865
888
|
}
|
|
866
889
|
function openFull() {
|
|
867
890
|
if (ghost) {
|
|
@@ -872,10 +895,15 @@
|
|
|
872
895
|
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
873
896
|
window.relay.undismiss();
|
|
874
897
|
}
|
|
898
|
+
const completedNotificationIds = activeNotificationIds;
|
|
899
|
+
activeNotificationIds = [];
|
|
875
900
|
clearPeek();
|
|
876
901
|
collapsed = false;
|
|
902
|
+
refreshCountLabel();
|
|
877
903
|
cardEl.classList.remove("collapsed");
|
|
878
904
|
springTo(EXPANDED.w, EXPANDED.h);
|
|
905
|
+
renderAll();
|
|
906
|
+
if (completedNotificationIds.length && window.relay.attentionDone) window.relay.attentionDone(completedNotificationIds);
|
|
879
907
|
playTink();
|
|
880
908
|
}
|
|
881
909
|
|
|
@@ -1159,13 +1187,19 @@
|
|
|
1159
1187
|
else window.relay.open(id);
|
|
1160
1188
|
}
|
|
1161
1189
|
|
|
1190
|
+
let unreadCount = 0;
|
|
1191
|
+
function refreshCountLabel() {
|
|
1192
|
+
const label = collapsed ? `${unreadCount} unopened` : String(unreadCount);
|
|
1193
|
+
if (countEl.textContent !== label) {
|
|
1194
|
+
countEl.textContent = label;
|
|
1195
|
+
if (!REDUCED) { countEl.style.animation = "none"; void countEl.offsetWidth; countEl.style.animation = "countRoll .2s var(--settle)"; }
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1162
1198
|
function setCount(n) {
|
|
1199
|
+
unreadCount = n;
|
|
1163
1200
|
cardEl.classList.toggle("has-unread", n > 0);
|
|
1164
1201
|
countEl.classList.toggle("zero", n === 0);
|
|
1165
|
-
|
|
1166
|
-
countEl.textContent = String(n);
|
|
1167
|
-
if (!REDUCED) { countEl.style.animation = "none"; void countEl.offsetWidth; countEl.style.animation = "countRoll .2s var(--settle)"; }
|
|
1168
|
-
}
|
|
1202
|
+
refreshCountLabel();
|
|
1169
1203
|
}
|
|
1170
1204
|
|
|
1171
1205
|
function setBadge(el, n) {
|
|
@@ -1199,7 +1233,8 @@
|
|
|
1199
1233
|
// focused textarea and lose what the user is typing. Defer and re-render on blur/submit.
|
|
1200
1234
|
if (quickReplyIsActive()) { pendingRelaysRender = true; return; }
|
|
1201
1235
|
pendingRelaysRender = false;
|
|
1202
|
-
const
|
|
1236
|
+
const allRows = (payload.relays || []).filter(isRelayListKind);
|
|
1237
|
+
const rows = peeking ? allRows.filter((row) => row.unread) : allRows;
|
|
1203
1238
|
relaysEmptyEl.classList.toggle("gone", rows.length > 0);
|
|
1204
1239
|
const seen = prevRelayIds;
|
|
1205
1240
|
relaysListEl.innerHTML = rows.map((r) => {
|
|
@@ -2115,6 +2150,10 @@
|
|
|
2115
2150
|
tasks: Array.isArray(next.tasks) ? next.tasks : [],
|
|
2116
2151
|
contacts: Array.isArray(next.contacts) ? next.contacts : (payload.contacts || []),
|
|
2117
2152
|
};
|
|
2153
|
+
const configuredDuration = Number(next.ui && next.ui.notificationDurationMs);
|
|
2154
|
+
notificationDurationMs = Number.isFinite(configuredDuration) && configuredDuration >= 250
|
|
2155
|
+
? configuredDuration
|
|
2156
|
+
: 7000;
|
|
2118
2157
|
renderAll();
|
|
2119
2158
|
}
|
|
2120
2159
|
|