feat(canvas): add WebSocket connection status indicator to Toolbar
Adds a live/reconnecting/offline pill to the Toolbar so users can see
at a glance whether the canvas is receiving real-time updates.
Changes:
- canvas/src/store/canvas.ts: add wsStatus ('connected'|'connecting'|
'disconnected') field + setWsStatus action to CanvasState (initial:
'connecting')
- canvas/src/store/socket.ts: wire setWsStatus into ReconnectingSocket —
'connecting' on connect() call, 'connected' in onopen, 'connecting'
in onclose (will reconnect), 'disconnected' in disconnect()
- canvas/src/components/Toolbar.tsx: subscribe to wsStatus; render
WsStatusPill (green "Live" / amber pulsing "Reconnecting" / red
"Offline") after the workspace count section
- canvas/src/store/__tests__/socket.test.ts: add setWsStatus: vi.fn()
to the canvas store mock (global factory, beforeEach reset, and the
mid-test override in the onmessage test)
369/369 canvas tests passing, production build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c9e1a8e6e2
commit
53374ca391
@ -10,6 +10,7 @@ import { showToast } from "@/components/Toaster";
|
|||||||
|
|
||||||
export function Toolbar() {
|
export function Toolbar() {
|
||||||
const nodes = useCanvasStore((s) => s.nodes);
|
const nodes = useCanvasStore((s) => s.nodes);
|
||||||
|
const wsStatus = useCanvasStore((s) => s.wsStatus);
|
||||||
|
|
||||||
const [stopping, setStopping] = useState(false);
|
const [stopping, setStopping] = useState(false);
|
||||||
const [restartingAll, setRestartingAll] = useState(false);
|
const [restartingAll, setRestartingAll] = useState(false);
|
||||||
@ -122,6 +123,11 @@ export function Toolbar() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* WebSocket connection status */}
|
||||||
|
<div className="pl-3 border-l border-zinc-800/60">
|
||||||
|
<WsStatusPill status={wsStatus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stop All — visible when agents have active tasks */}
|
{/* Stop All — visible when agents have active tasks */}
|
||||||
{counts.activeTasks > 0 && (
|
{counts.activeTasks > 0 && (
|
||||||
<button
|
<button
|
||||||
@ -231,6 +237,31 @@ function StatusPill({ color, count, label }: { color: string; count: number; lab
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WsStatusPill({ status }: { status: "connected" | "connecting" | "disconnected" }) {
|
||||||
|
if (status === "connected") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5" title="Real-time updates: connected">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||||
|
<span className="text-[10px] text-zinc-500">Live</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "connecting") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5" title="Real-time updates: reconnecting…">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||||
|
<span className="text-[10px] text-zinc-500">Reconnecting</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5" title="Real-time updates: disconnected">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||||
|
<span className="text-[10px] text-zinc-500">Offline</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HelpRow({ shortcut, text }: { shortcut: string; text: string }) {
|
function HelpRow({ shortcut, text }: { shortcut: string; text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-zinc-800/70 bg-zinc-900/45 px-3 py-2">
|
<div className="flex items-start gap-3 rounded-lg border border-zinc-800/70 bg-zinc-900/45 px-3 py-2">
|
||||||
|
|||||||
@ -8,6 +8,7 @@ vi.mock("../canvas", () => ({
|
|||||||
getState: vi.fn(() => ({
|
getState: vi.fn(() => ({
|
||||||
applyEvent: vi.fn(),
|
applyEvent: vi.fn(),
|
||||||
hydrate: vi.fn(),
|
hydrate: vi.fn(),
|
||||||
|
setWsStatus: vi.fn(),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -80,6 +81,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(useCanvasStore.getState).mockReturnValue({
|
vi.mocked(useCanvasStore.getState).mockReturnValue({
|
||||||
applyEvent: vi.fn(),
|
applyEvent: vi.fn(),
|
||||||
hydrate: vi.fn(),
|
hydrate: vi.fn(),
|
||||||
|
setWsStatus: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof useCanvasStore.getState>);
|
} as unknown as ReturnType<typeof useCanvasStore.getState>);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -180,6 +182,7 @@ describe("WebSocket onmessage", () => {
|
|||||||
vi.mocked(useCanvasStore.getState).mockReturnValue({
|
vi.mocked(useCanvasStore.getState).mockReturnValue({
|
||||||
applyEvent,
|
applyEvent,
|
||||||
hydrate: vi.fn(),
|
hydrate: vi.fn(),
|
||||||
|
setWsStatus: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof useCanvasStore.getState>);
|
} as unknown as ReturnType<typeof useCanvasStore.getState>);
|
||||||
|
|
||||||
const msg = {
|
const msg = {
|
||||||
|
|||||||
@ -70,6 +70,9 @@ interface CanvasState {
|
|||||||
/** Agent-pushed messages keyed by workspace ID. ChatTab consumes and clears these. */
|
/** Agent-pushed messages keyed by workspace ID. ChatTab consumes and clears these. */
|
||||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>>;
|
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>>;
|
||||||
consumeAgentMessages: (workspaceId: string) => Array<{ id: string; content: string; timestamp: string }>;
|
consumeAgentMessages: (workspaceId: string) => Array<{ id: string; content: string; timestamp: string }>;
|
||||||
|
/** WebSocket connection status — drives the live indicator in the Toolbar. */
|
||||||
|
wsStatus: "connected" | "connecting" | "disconnected";
|
||||||
|
setWsStatus: (status: "connected" | "connecting" | "disconnected") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCanvasStore = create<CanvasState>((set, get) => ({
|
export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||||
@ -79,6 +82,8 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
|||||||
panelTab: "chat",
|
panelTab: "chat",
|
||||||
dragOverNodeId: null,
|
dragOverNodeId: null,
|
||||||
contextMenu: null,
|
contextMenu: null,
|
||||||
|
wsStatus: "connecting",
|
||||||
|
setWsStatus: (status) => set({ wsStatus: status }),
|
||||||
|
|
||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
|
||||||
|
|||||||
@ -21,11 +21,13 @@ class ReconnectingSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
|
useCanvasStore.getState().setWsStatus("connecting");
|
||||||
this.ws = new WebSocket(this.url);
|
this.ws = new WebSocket(this.url);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
this.attempt = 0;
|
this.attempt = 0;
|
||||||
this.lastEventTime = Date.now();
|
this.lastEventTime = Date.now();
|
||||||
|
useCanvasStore.getState().setWsStatus("connected");
|
||||||
this.rehydrate();
|
this.rehydrate();
|
||||||
this.startHealthCheck();
|
this.startHealthCheck();
|
||||||
};
|
};
|
||||||
@ -42,6 +44,7 @@ class ReconnectingSocket {
|
|||||||
|
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
this.stopHealthCheck();
|
this.stopHealthCheck();
|
||||||
|
useCanvasStore.getState().setWsStatus("connecting");
|
||||||
const delay = Math.min(1000 * 2 ** this.attempt, 30000);
|
const delay = Math.min(1000 * 2 ** this.attempt, 30000);
|
||||||
this.attempt++;
|
this.attempt++;
|
||||||
setTimeout(() => this.connect(), delay);
|
setTimeout(() => this.connect(), delay);
|
||||||
@ -90,6 +93,7 @@ class ReconnectingSocket {
|
|||||||
this.ws.close();
|
this.ws.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
}
|
}
|
||||||
|
useCanvasStore.getState().setWsStatus("disconnected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user