From 7d7e02f7b07db3154a257b15909be578c3e7837c Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 14 May 2026 18:58:53 +0000 Subject: [PATCH] huskies: merge 1058 --- frontend/src/api/client/websocket.ts | 4 ++ .../src/components/Chat.commands.test.tsx | 20 ++++---- frontend/src/components/Chat.tsx | 4 ++ frontend/src/components/ChatHeader.test.tsx | 50 +++++++++++++++++++ frontend/src/components/ChatHeader.tsx | 41 +++++++++++++++ frontend/src/hooks/useChatWebSocket.ts | 32 +++++++++--- 6 files changed, 133 insertions(+), 18 deletions(-) diff --git a/frontend/src/api/client/websocket.ts b/frontend/src/api/client/websocket.ts index 8367a092..6859f6b5 100644 --- a/frontend/src/api/client/websocket.ts +++ b/frontend/src/api/client/websocket.ts @@ -59,6 +59,7 @@ export class ChatWebSocket { ) => void; private onStatusUpdate?: (event: StatusEvent) => void; private onConnected?: () => void; + private onDisconnected?: () => void; private connected = false; private closeTimer?: number; private wsPath = DEFAULT_WS_PATH; @@ -169,6 +170,7 @@ export class ChatWebSocket { }; this.socket.onclose = () => { if (this.shouldReconnect && this.connected) { + this.onDisconnected?.(); this._scheduleReconnect(); } }; @@ -215,6 +217,7 @@ export class ChatWebSocket { onLogEntry?: (timestamp: string, level: string, message: string) => void; onStatusUpdate?: (event: StatusEvent) => void; onConnected?: () => void; + onDisconnected?: () => void; }, wsPath = DEFAULT_WS_PATH, ) { @@ -236,6 +239,7 @@ export class ChatWebSocket { this.onLogEntry = handlers.onLogEntry; this.onStatusUpdate = handlers.onStatusUpdate; this.onConnected = handlers.onConnected; + this.onDisconnected = handlers.onDisconnected; this.wsPath = wsPath; this.shouldReconnect = true; diff --git a/frontend/src/components/Chat.commands.test.tsx b/frontend/src/components/Chat.commands.test.tsx index 7e58a0d0..943d4d3e 100644 --- a/frontend/src/components/Chat.commands.test.tsx +++ b/frontend/src/components/Chat.commands.test.tsx @@ -471,13 +471,13 @@ describe("Slash command handling (Story 374)", () => { }); }); -describe("Bug 450: WebSocket error messages displayed in chat", () => { +describe("Story 1058: WebSocket errors do not appear in chat", () => { beforeEach(() => { capturedWsHandlers = null; setupMocks(); }); - it("AC1: WebSocket error message is shown in chat as an assistant message", async () => { + it("does not add a chat message when onError is called", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); @@ -487,11 +487,11 @@ describe("Bug 450: WebSocket error messages displayed in chat", () => { }); expect( - await screen.findByText("Something went wrong on the server."), - ).toBeInTheDocument(); + screen.queryByText("Something went wrong on the server."), + ).not.toBeInTheDocument(); }); - it("AC2: OAuth login URL in WebSocket error is rendered as a clickable link", async () => { + it("does not add a chat message for errors containing a URL", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); @@ -502,10 +502,10 @@ describe("Bug 450: WebSocket error messages displayed in chat", () => { ); }); - const link = await screen.findByRole("link", { - name: /https:\/\/example\.com\/oauth\/login/, - }); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute("href", "https://example.com/oauth/login"); + expect( + screen.queryByRole("link", { + name: /https:\/\/example\.com\/oauth\/login/, + }), + ).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 2b02f4c3..97a3f36e 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -84,6 +84,8 @@ export function Chat({ const { wsRef, wsConnected, + wsConnectivity, + wsDisconnectedAt, streamingContent, setStreamingContent, streamingThinking, @@ -376,6 +378,8 @@ export function Chat({ enableTools={enableTools} onToggleTools={setEnableTools} wsConnected={wsConnected} + wsConnectivity={wsConnectivity} + wsDisconnectedAt={wsDisconnectedAt} oauthStatus={oauthStatus} onShowBotConfig={() => setView("bot-config")} onShowSettings={() => setView("settings")} diff --git a/frontend/src/components/ChatHeader.test.tsx b/frontend/src/components/ChatHeader.test.tsx index e8e671bf..dd01cf41 100644 --- a/frontend/src/components/ChatHeader.test.tsx +++ b/frontend/src/components/ChatHeader.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import type { WsConnectivity } from "../hooks/useChatWebSocket"; import { ChatHeader } from "./ChatHeader"; vi.mock("../api/client", () => ({ @@ -21,6 +22,8 @@ interface ChatHeaderProps { enableTools: boolean; onToggleTools: (enabled: boolean) => void; wsConnected: boolean; + wsConnectivity?: WsConnectivity; + wsDisconnectedAt?: Date | null; } function makeProps(overrides: Partial = {}): ChatHeaderProps { @@ -289,6 +292,53 @@ describe("ChatHeader", () => { }); }); + // ── Connectivity indicator ──────────────────────────────────────────────── + + it("does not render connectivity dot when wsConnectivity is not provided", () => { + render(); + expect(screen.queryByTestId("ws-connectivity-dot")).not.toBeInTheDocument(); + }); + + it("renders green dot with title 'Connected' when connected", () => { + render(); + const dot = screen.getByTestId("ws-connectivity-dot"); + expect(dot).toBeInTheDocument(); + expect(dot).toHaveAttribute("title", "Connected"); + expect(dot.style.backgroundColor).toBe("rgb(76, 175, 80)"); + }); + + it("renders amber dot with title 'Reconnecting…' when reconnecting", () => { + render(); + const dot = screen.getByTestId("ws-connectivity-dot"); + expect(dot).toHaveAttribute("title", "Reconnecting…"); + expect(dot.style.backgroundColor).toBe("rgb(245, 166, 35)"); + }); + + it("renders amber dot with title 'Connecting…' when connecting", () => { + render(); + const dot = screen.getByTestId("ws-connectivity-dot"); + expect(dot).toHaveAttribute("title", "Connecting…"); + expect(dot.style.backgroundColor).toBe("rgb(245, 166, 35)"); + }); + + it("renders red dot with title 'Disconnected' when failed with no timestamp", () => { + render(); + const dot = screen.getByTestId("ws-connectivity-dot"); + expect(dot).toHaveAttribute("title", "Disconnected"); + expect(dot.style.backgroundColor).toBe("rgb(229, 57, 53)"); + }); + + it("renders red dot with 'Disconnected since HH:MM' when failed with timestamp", () => { + const disconnectedAt = new Date("2026-05-14T14:30:00"); + render( + , + ); + const dot = screen.getByTestId("ws-connectivity-dot"); + expect(dot.getAttribute("title")).toMatch(/Disconnected since/); + }); + it("clears reconnecting state when wsConnected transitions to true", async () => { const { api } = await import("../api/client"); vi.mocked(api.rebuildAndRestart).mockRejectedValue( diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index dc38873f..a97a0fae 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { OAuthStatus } from "../api/client"; import { api } from "../api/client"; +import type { WsConnectivity } from "../hooks/useChatWebSocket"; const { useState, useEffect } = React; @@ -33,6 +34,8 @@ interface ChatHeaderProps { enableTools: boolean; onToggleTools: (enabled: boolean) => void; wsConnected: boolean; + wsConnectivity?: WsConnectivity; + wsDisconnectedAt?: Date | null; oauthStatus?: OAuthStatus | null; onShowBotConfig?: () => void; onShowSettings?: () => void; @@ -59,6 +62,8 @@ export function ChatHeader({ enableTools, onToggleTools, wsConnected, + wsConnectivity, + wsDisconnectedAt, oauthStatus = null, onShowBotConfig, onShowSettings, @@ -117,6 +122,28 @@ export function ChatHeader({ const rebuildButtonDisabled = rebuildStatus === "building" || rebuildStatus === "reconnecting"; + const connectivityDotColor = + wsConnectivity === "connected" + ? "#4caf50" + : wsConnectivity === "failed" + ? "#e53935" + : wsConnectivity !== undefined + ? "#f5a623" + : undefined; + + const connectivityTitle = + wsConnectivity === "connected" + ? "Connected" + : wsConnectivity === "reconnecting" + ? "Reconnecting…" + : wsConnectivity === "failed" + ? wsDisconnectedAt + ? `Disconnected since ${wsDisconnectedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` + : "Disconnected" + : wsConnectivity === "connecting" + ? "Connecting…" + : undefined; + return ( <> {/* Confirmation dialog overlay */} @@ -347,6 +374,20 @@ export function ChatHeader({
+ {connectivityDotColor !== undefined && ( +
+ )} {oauthStatus !== null && (!oauthStatus.authenticated || oauthStatus.expired) && (