huskies: merge 1058
This commit is contained in:
@@ -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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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> = {}): ChatHeaderProps {
|
||||
@@ -289,6 +292,53 @@ describe("ChatHeader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Connectivity indicator ────────────────────────────────────────────────
|
||||
|
||||
it("does not render connectivity dot when wsConnectivity is not provided", () => {
|
||||
render(<ChatHeader {...makeProps()} />);
|
||||
expect(screen.queryByTestId("ws-connectivity-dot")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders green dot with title 'Connected' when connected", () => {
|
||||
render(<ChatHeader {...makeProps({ wsConnectivity: "connected" })} />);
|
||||
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(<ChatHeader {...makeProps({ wsConnectivity: "reconnecting" })} />);
|
||||
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(<ChatHeader {...makeProps({ wsConnectivity: "connecting" })} />);
|
||||
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(<ChatHeader {...makeProps({ wsConnectivity: "failed" })} />);
|
||||
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(
|
||||
<ChatHeader
|
||||
{...makeProps({ wsConnectivity: "failed", wsDisconnectedAt: disconnectedAt })}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||
{connectivityDotColor !== undefined && (
|
||||
<div
|
||||
data-testid="ws-connectivity-dot"
|
||||
title={connectivityTitle}
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: connectivityDotColor,
|
||||
flexShrink: 0,
|
||||
cursor: "default",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{oauthStatus !== null &&
|
||||
(!oauthStatus.authenticated || oauthStatus.expired) && (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user