Surface the event-bus WebSocket state in the UI

SDK (sdk-event-bus amended on the fork): a new `WsState` enum
(`Stopped`/`Connecting`/`Connected`/`Reconnecting`) is broadcast through a
`watch::Sender` inside `EventBusClient`, exposed via `state()` for
observers. Transitions are emitted at every step of the reconnect loop,
plus a `Drop` guard guarantees a final `Stopped` even on a panic in the
caller's task tree. Two new SDK tests cover the initial value and
multi-subscriber broadcast.

Bridge: capture `bus_client.state()` at start, mirror it into a serde
`WsStatus` field on `BridgeStats`, and clear on stop.

UI: add a third stat card "Realtime" with a colored dot (green Connected,
orange pulsing Connecting/Reconnecting, gray Off) reading
`stats.ws_status`. Stats grid switches from 2 to 3 columns.

Submodule pointer bumped to the rebuilt `tutabridge-integration` which
cherry-picks all six SDK branches on `upstream/master`; the move-mails
fixup (use `make_test_facade` so its test compiles alongside the blob
branch) is re-applied. 341/341 SDK lib tests pass, 154/154 bridge tests
pass.
This commit is contained in:
Anthony
2026-05-28 13:43:05 +02:00
parent cfb030272d
commit c9ea7c655f
7 changed files with 101 additions and 7 deletions
+24 -1
View File
@@ -230,10 +230,33 @@
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.ws-badge {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--brand);
font-size: 14px;
font-weight: 500;
color: var(--on-surface);
line-height: 1;
}
.ws-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.ws-dot.pulsing {
animation: breathe 2s ease-in-out infinite;
}
.stat-card {
display: flex;
flex-direction: column;
+32 -1
View File
@@ -1,7 +1,21 @@
import { useState } from "react";
import type { BridgeStatus, BridgeStats } from "../types";
import type { BridgeStatus, BridgeStats, WsStatus } from "../types";
import { isError } from "../types";
function wsBadge(status: WsStatus): { color: string; label: string; pulsing: boolean } {
switch (status) {
case "Connected":
return { color: "var(--green)", label: "Connected", pulsing: false };
case "Connecting":
return { color: "var(--orange)", label: "Connecting…", pulsing: true };
case "Reconnecting":
return { color: "var(--orange)", label: "Reconnecting…", pulsing: true };
case "Stopped":
default:
return { color: "var(--gray)", label: "Off", pulsing: false };
}
}
interface Props {
status: BridgeStatus | null;
stats: BridgeStats;
@@ -122,6 +136,23 @@ export function Dashboard({ status, stats, hasSavedSession, loading, onStart, on
</span>
<span className="stat-label">Uptime</span>
</div>
<div className="stat-card">
<span className="ws-badge">
{(() => {
const b = wsBadge(stats.ws_status);
return (
<>
<span
className={"ws-dot" + (b.pulsing ? " pulsing" : "")}
style={{ background: b.color }}
/>
<span className="ws-text">{b.label}</span>
</>
);
})()}
</span>
<span className="stat-label">Realtime</span>
</div>
</div>
</div>
);
+5 -1
View File
@@ -9,7 +9,11 @@ const POLL_INTERVAL = 1000;
export function useBridge() {
const [config, setConfig] = useState<Config | null>(null);
const [status, setStatus] = useState<BridgeStatus | null>(null);
const [stats, setStats] = useState<BridgeStats>({ uptime_secs: null, mails_synced: 0 });
const [stats, setStats] = useState<BridgeStats>({
uptime_secs: null,
mails_synced: 0,
ws_status: "Stopped",
});
const [hasSavedSession, setHasSavedSession] = useState(false);
const [bridgePassword, setBridgePassword] = useState<string | null>(null);
const [logs, setLogs] = useState<string[]>([]);
+3
View File
@@ -9,9 +9,12 @@ export interface Config {
export type BridgeStatus = "Stopped" | "Starting" | "Running" | { Error: string };
export type WsStatus = "Stopped" | "Connecting" | "Connected" | "Reconnecting";
export interface BridgeStats {
uptime_secs: number | null;
mails_synced: number;
ws_status: WsStatus;
}
export function statusLabel(status: BridgeStatus): string {