huskies: merge 1058

This commit is contained in:
dave
2026-05-14 18:58:53 +00:00
parent 595777f366
commit 7d7e02f7b0
6 changed files with 133 additions and 18 deletions
+10 -10
View File
@@ -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();
});
});
+4
View File
@@ -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(
+41
View File
@@ -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