Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions site/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@ import {
wsLatency,
wsReconnectIn,
wsConnected,
wsReconnectCount,
wsStatus,
wsLastReconnectAt,
Comment on lines +356 to +357
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

site/ui/modules/api.js currently does not export wsStatus or wsLastReconnectAt, so this import will throw at module-evaluation time and break the site build. Mirror the new signals + connectWebSocket/disconnectWebSocket status updates into site/ui/modules/api.js (or stop importing these names here if the site bundle shouldn’t have the badge).

Suggested change
wsStatus,
wsLastReconnectAt,

Copilot uses AI. Check for mistakes.
loadingCount,
} from "./modules/api.js";
import {
Expand Down Expand Up @@ -814,6 +817,39 @@ function inferUiConnected() {
/* ═══════════════════════════════════════════════
* Header
* ═══════════════════════════════════════════════ */
function ConnectionBadge() {
const status = wsStatus.value;
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
const reconnectCount = wsReconnectCount.value;
const lastReconnectAt = wsLastReconnectAt.value;
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed

let label = "Offline";
let badgeClass = "offline";
if (status === "connected") {
label = "Connected";
badgeClass = "connected";
} else if (status === "reconnecting") {
label = reconnectCount > 0 ? `Reconnecting... ${reconnectCount}` : "Reconnecting...";
badgeClass = "reconnecting";
}

const lastReconnectLabel =
lastReconnectAt != null
? new Date(lastReconnectAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit", second: "2-digit" })
: "never";
const tooltip = `Connection status: ${label}; last reconnect ${lastReconnectLabel}`;

return html`
<div
class=${`connection-badge ${badgeClass}`}
title=${tooltip}
aria-label=${tooltip}
>
<span class=${`connection-badge-dot ${badgeClass} ${status === "reconnecting" ? "connection-badge-pulse" : ""}`}></span>
<span class="connection-badge-label">${label}</span>
</div>
`;
}

function Header() {
const isConn = inferUiConnected();
const user = getTelegramUser();
Expand Down Expand Up @@ -858,6 +894,7 @@ function Header() {
</${Box}>
<${Box} sx=${{flexGrow: 1}} />
<${Stack} direction="row" spacing=${1} alignItems="center">
<${ConnectionBadge} />
<${Chip}
size="small"
label=${connLabel}
Expand Down
13 changes: 13 additions & 0 deletions site/ui/modules/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export const wsLatency = signal(null);
export const wsReconnectIn = signal(null);
/** Reactive signal: number of reconnections since last user-initiated action */
export const wsReconnectCount = signal(0);
/** Reactive signal: WebSocket badge status for portal header */
export const wsStatus = signal("offline");
/** Reactive signal: timestamp of the last successful (re)connect */
export const wsLastReconnectAt = signal(null);
/** Reactive signal: count of in-flight apiFetch calls (drives top loading bar) */
export const loadingCount = signal(0);

Expand Down Expand Up @@ -249,6 +253,8 @@ export function connectWebSocket() {
socket.addEventListener("open", () => {
wsConnected.value = true;
wsLatency.value = null;
wsStatus.value = "connected";
wsLastReconnectAt.value = Date.now();
retryMs = 1000; // reset backoff on successful connect
clearCountdown();
startPing();
Expand Down Expand Up @@ -299,6 +305,7 @@ export function connectWebSocket() {
socket.addEventListener("close", () => {
wsConnected.value = false;
wsLatency.value = null;
wsStatus.value = "reconnecting";
ws = null;
stopPing();
wsReconnectCount.value += 1;
Expand All @@ -314,13 +321,18 @@ export function connectWebSocket() {

socket.addEventListener("error", () => {
wsConnected.value = false;
wsLatency.value = null;
if (!reconnectTimer && (!ws || ws.readyState !== WebSocket.CONNECTING)) {
wsStatus.value = "offline";
}
});
}

/**
* Disconnect the WebSocket and cancel any pending reconnect.
*/
export function disconnectWebSocket() {
wsStatus.value = "offline";
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
Expand All @@ -337,6 +349,7 @@ export function disconnectWebSocket() {
}
wsConnected.value = false;
wsLatency.value = null;
wsStatus.value = "offline";
}

/**
Expand Down
59 changes: 59 additions & 0 deletions site/ui/styles.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,62 @@
:root {
--ws-badge-connected: #22c55e;
--ws-badge-reconnecting: #f59e0b;
--ws-badge-offline: #ef4444;
}

.connection-badge {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border, rgba(148, 163, 184, 0.24));
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
line-height: 1;
color: var(--text-secondary, #cbd5e1);
background: var(--bg-elevated, rgba(15, 23, 42, 0.24));
}

.connection-badge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 8px;
background: var(--ws-badge-offline);
}

.connection-badge-dot.connected {
background: var(--ws-badge-connected);
}

.connection-badge-dot.reconnecting {
background: var(--ws-badge-reconnecting);
}

.connection-badge-dot.offline {
background: var(--ws-badge-offline);
}

.connection-badge-label {
white-space: nowrap;
}

.connection-badge-pulse {
animation: connection-badge-pulse 1s ease-in-out infinite;
}

@keyframes connection-badge-pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.35); opacity: 0.55; }
100% { transform: scale(1); opacity: 1; }
}

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new pulsing animation should respect prefers-reduced-motion. There’s already a reduced-motion section in site/ui/styles/layout.css disabling other connection pulses; add a similar rule for .connection-badge-pulse to avoid continuous animation for users who prefer reduced motion.

Suggested change
@media (prefers-reduced-motion: reduce) {
.connection-badge-pulse {
animation: none;
}
}

Copilot uses AI. Check for mistakes.
@media (prefers-reduced-motion: reduce) {
.connection-badge-pulse {
animation: none;
}
}

/* @import directives moved to <link> tags in index.html for parallel loading */

.floating-call-restore {
Expand Down
47 changes: 47 additions & 0 deletions tests/ui-connection-badge.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

describe("portal websocket connection badge", () => {
it("tracks explicit websocket badge signals and reconnect metadata", () => {
const apiSource = readFileSync(resolve(process.cwd(), "ui/modules/api.js"), "utf8");

expect(apiSource).toContain("wsStatus = signal");
expect(apiSource).toContain("wsLastReconnectAt = signal");
Comment on lines +6 to +10
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only inspects ui/modules/api.js, but this PR also updates site/ui/app.js to use the new signals. Add coverage that site/ui/modules/api.js exports/updates the same badge signals so regressions like missing exports in the site bundle are caught.

Copilot uses AI. Check for mistakes.
expect(apiSource).toContain('"connected"');
expect(apiSource).toContain('"reconnecting"');
expect(apiSource).toContain('"offline"');
expect(apiSource).toContain('wsLastReconnectAt.value = Date.now()');
});

it("site/ui/modules/api.js exports wsStatus and wsLastReconnectAt badge signals", () => {
const siteApiSource = readFileSync(resolve(process.cwd(), "site/ui/modules/api.js"), "utf8");

expect(siteApiSource).toContain("export const wsStatus");
expect(siteApiSource).toContain("export const wsLastReconnectAt");
expect(siteApiSource).toContain('"connected"');
expect(siteApiSource).toContain('"reconnecting"');
expect(siteApiSource).toContain('"offline"');
expect(siteApiSource).toContain('wsLastReconnectAt.value = Date.now()');
});

it("renders a connection badge in the portal header before settings", () => {
const appSource = readFileSync(resolve(process.cwd(), "ui/app.js"), "utf8");

expect(appSource).toContain("function ConnectionBadge()");
expect(appSource).toContain("connection-badge");
expect(appSource).toContain("<${ConnectionBadge} />");
expect(appSource).toContain("last reconnect");
expect(appSource).toContain("Reconnecting...");
});

it("defines themed badge color css variables and pulse animation", () => {
const stylesSource = readFileSync(resolve(process.cwd(), "ui/styles.css"), "utf8");

expect(stylesSource).toContain("--ws-badge-connected");
expect(stylesSource).toContain("--ws-badge-reconnecting");
expect(stylesSource).toContain("--ws-badge-offline");
expect(stylesSource).toContain("connection-badge-dot");
expect(stylesSource).toContain("connection-badge-pulse");
});
});
37 changes: 37 additions & 0 deletions ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ import {
wsLatency,
wsReconnectIn,
wsConnected,
wsReconnectCount,
wsStatus,
wsLastReconnectAt,
loadingCount,
} from "./modules/api.js";
import {
Expand Down Expand Up @@ -841,6 +844,39 @@ function inferUiConnected() {
/* ═══════════════════════════════════════════════
* Header
* ═══════════════════════════════════════════════ */
function ConnectionBadge() {
const status = wsStatus.value;
const reconnectCount = wsReconnectCount.value;
const lastReconnectAt = wsLastReconnectAt.value;

let label = "Offline";
let badgeClass = "offline";
if (status === "connected") {
label = "Connected";
badgeClass = "connected";
} else if (status === "reconnecting") {
label = reconnectCount > 0 ? `Reconnecting... ${reconnectCount}` : "Reconnecting...";
badgeClass = "reconnecting";
}

const lastReconnectLabel =
lastReconnectAt != null
? new Date(lastReconnectAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit", second: "2-digit" })
: "never";
const tooltip = `Connection status: ${label}; last reconnect ${lastReconnectLabel}`;

return html`
<div
class=${`connection-badge ${badgeClass}`}
title=${tooltip}
aria-label=${tooltip}
>
<span class=${`connection-badge-dot ${badgeClass} ${status === "reconnecting" ? "connection-badge-pulse" : ""}`}></span>
<span class="connection-badge-label">${label}</span>
</div>
`;
}

function Header() {
const isConn = inferUiConnected();
const user = getTelegramUser();
Expand Down Expand Up @@ -903,6 +939,7 @@ function Header() {
</${Box}>
<${Box} sx=${{flexGrow: 1}} />
<${Stack} direction="row" spacing=${1} alignItems="center">
<${ConnectionBadge} />
<${Chip}
size="small"
label=${connLabel}
Expand Down
13 changes: 13 additions & 0 deletions ui/modules/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export const wsLatency = signal(null);
export const wsReconnectIn = signal(null);
/** Reactive signal: number of reconnections since last user-initiated action */
export const wsReconnectCount = signal(0);
/** Reactive signal: WebSocket badge status for portal header */
export const wsStatus = signal("offline");
/** Reactive signal: timestamp of the last successful (re)connect */
export const wsLastReconnectAt = signal(null);
/** Reactive signal: count of in-flight apiFetch calls (drives top loading bar) */
export const loadingCount = signal(0);

Expand Down Expand Up @@ -323,6 +327,8 @@ export function connectWebSocket() {
socket.addEventListener("open", () => {
wsConnected.value = true;
wsLatency.value = null;
wsStatus.value = "connected";
wsLastReconnectAt.value = Date.now();
retryMs = 1000; // reset backoff on successful connect
clearCountdown();
startPing();
Expand Down Expand Up @@ -373,6 +379,7 @@ export function connectWebSocket() {
socket.addEventListener("close", () => {
wsConnected.value = false;
wsLatency.value = null;
wsStatus.value = "reconnecting";
ws = null;
stopPing();
wsReconnectCount.value += 1;
Expand All @@ -388,13 +395,18 @@ export function connectWebSocket() {

socket.addEventListener("error", () => {
wsConnected.value = false;
wsLatency.value = null;
if (!reconnectTimer && (!ws || ws.readyState !== WebSocket.CONNECTING)) {
wsStatus.value = "offline";
}
});
}

/**
* Disconnect the WebSocket and cancel any pending reconnect.
*/
export function disconnectWebSocket() {
wsStatus.value = "offline";
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
Expand All @@ -411,6 +423,7 @@ export function disconnectWebSocket() {
}
wsConnected.value = false;
wsLatency.value = null;
wsStatus.value = "offline";
}

/**
Expand Down
59 changes: 59 additions & 0 deletions ui/styles.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,62 @@
:root {
--ws-badge-connected: #22c55e;
--ws-badge-reconnecting: #f59e0b;
--ws-badge-offline: #ef4444;
}

.connection-badge {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border, rgba(148, 163, 184, 0.24));
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
line-height: 1;
color: var(--text-secondary, #cbd5e1);
background: var(--bg-elevated, rgba(15, 23, 42, 0.24));
}

.connection-badge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 8px;
background: var(--ws-badge-offline);
}

.connection-badge-dot.connected {
background: var(--ws-badge-connected);
}

.connection-badge-dot.reconnecting {
background: var(--ws-badge-reconnecting);
}

.connection-badge-dot.offline {
background: var(--ws-badge-offline);
}

.connection-badge-label {
white-space: nowrap;
}

.connection-badge-pulse {
animation: connection-badge-pulse 1s ease-in-out infinite;
}

@keyframes connection-badge-pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.35); opacity: 0.55; }
100% { transform: scale(1); opacity: 1; }
}

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new pulsing animation should respect prefers-reduced-motion. There’s already a reduced-motion section in ui/styles/layout.css disabling other connection pulses; add a similar rule for .connection-badge-pulse to avoid continuous animation for users who prefer reduced motion.

Suggested change
@media (prefers-reduced-motion: reduce) {
.connection-badge-pulse {
animation: none;
}
}

Copilot uses AI. Check for mistakes.
@media (prefers-reduced-motion: reduce) {
.connection-badge-pulse {
animation: none;
}
}

/* @import directives moved to <link> tags in index.html for parallel loading */

.floating-call-restore {
Expand Down
Loading