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
+4
View File
@@ -59,6 +59,7 @@ export class ChatWebSocket {
) => void; ) => void;
private onStatusUpdate?: (event: StatusEvent) => void; private onStatusUpdate?: (event: StatusEvent) => void;
private onConnected?: () => void; private onConnected?: () => void;
private onDisconnected?: () => void;
private connected = false; private connected = false;
private closeTimer?: number; private closeTimer?: number;
private wsPath = DEFAULT_WS_PATH; private wsPath = DEFAULT_WS_PATH;
@@ -169,6 +170,7 @@ export class ChatWebSocket {
}; };
this.socket.onclose = () => { this.socket.onclose = () => {
if (this.shouldReconnect && this.connected) { if (this.shouldReconnect && this.connected) {
this.onDisconnected?.();
this._scheduleReconnect(); this._scheduleReconnect();
} }
}; };
@@ -215,6 +217,7 @@ export class ChatWebSocket {
onLogEntry?: (timestamp: string, level: string, message: string) => void; onLogEntry?: (timestamp: string, level: string, message: string) => void;
onStatusUpdate?: (event: StatusEvent) => void; onStatusUpdate?: (event: StatusEvent) => void;
onConnected?: () => void; onConnected?: () => void;
onDisconnected?: () => void;
}, },
wsPath = DEFAULT_WS_PATH, wsPath = DEFAULT_WS_PATH,
) { ) {
@@ -236,6 +239,7 @@ export class ChatWebSocket {
this.onLogEntry = handlers.onLogEntry; this.onLogEntry = handlers.onLogEntry;
this.onStatusUpdate = handlers.onStatusUpdate; this.onStatusUpdate = handlers.onStatusUpdate;
this.onConnected = handlers.onConnected; this.onConnected = handlers.onConnected;
this.onDisconnected = handlers.onDisconnected;
this.wsPath = wsPath; this.wsPath = wsPath;
this.shouldReconnect = true; this.shouldReconnect = true;
@@ -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(() => { beforeEach(() => {
capturedWsHandlers = null; capturedWsHandlers = null;
setupMocks(); 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()} />); render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
@@ -487,11 +487,11 @@ describe("Bug 450: WebSocket error messages displayed in chat", () => {
}); });
expect( expect(
await screen.findByText("Something went wrong on the server."), screen.queryByText("Something went wrong on the server."),
).toBeInTheDocument(); ).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()} />); render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); 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", { expect(
screen.queryByRole("link", {
name: /https:\/\/example\.com\/oauth\/login/, name: /https:\/\/example\.com\/oauth\/login/,
}); }),
expect(link).toBeInTheDocument(); ).not.toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://example.com/oauth/login");
}); });
}); });
+4
View File
@@ -84,6 +84,8 @@ export function Chat({
const { const {
wsRef, wsRef,
wsConnected, wsConnected,
wsConnectivity,
wsDisconnectedAt,
streamingContent, streamingContent,
setStreamingContent, setStreamingContent,
streamingThinking, streamingThinking,
@@ -376,6 +378,8 @@ export function Chat({
enableTools={enableTools} enableTools={enableTools}
onToggleTools={setEnableTools} onToggleTools={setEnableTools}
wsConnected={wsConnected} wsConnected={wsConnected}
wsConnectivity={wsConnectivity}
wsDisconnectedAt={wsDisconnectedAt}
oauthStatus={oauthStatus} oauthStatus={oauthStatus}
onShowBotConfig={() => setView("bot-config")} onShowBotConfig={() => setView("bot-config")}
onShowSettings={() => setView("settings")} onShowSettings={() => setView("settings")}
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { WsConnectivity } from "../hooks/useChatWebSocket";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
vi.mock("../api/client", () => ({ vi.mock("../api/client", () => ({
@@ -21,6 +22,8 @@ interface ChatHeaderProps {
enableTools: boolean; enableTools: boolean;
onToggleTools: (enabled: boolean) => void; onToggleTools: (enabled: boolean) => void;
wsConnected: boolean; wsConnected: boolean;
wsConnectivity?: WsConnectivity;
wsDisconnectedAt?: Date | null;
} }
function makeProps(overrides: Partial<ChatHeaderProps> = {}): ChatHeaderProps { 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 () => { it("clears reconnecting state when wsConnected transitions to true", async () => {
const { api } = await import("../api/client"); const { api } = await import("../api/client");
vi.mocked(api.rebuildAndRestart).mockRejectedValue( vi.mocked(api.rebuildAndRestart).mockRejectedValue(
+41
View File
@@ -1,6 +1,7 @@
import * as React from "react"; import * as React from "react";
import type { OAuthStatus } from "../api/client"; import type { OAuthStatus } from "../api/client";
import { api } from "../api/client"; import { api } from "../api/client";
import type { WsConnectivity } from "../hooks/useChatWebSocket";
const { useState, useEffect } = React; const { useState, useEffect } = React;
@@ -33,6 +34,8 @@ interface ChatHeaderProps {
enableTools: boolean; enableTools: boolean;
onToggleTools: (enabled: boolean) => void; onToggleTools: (enabled: boolean) => void;
wsConnected: boolean; wsConnected: boolean;
wsConnectivity?: WsConnectivity;
wsDisconnectedAt?: Date | null;
oauthStatus?: OAuthStatus | null; oauthStatus?: OAuthStatus | null;
onShowBotConfig?: () => void; onShowBotConfig?: () => void;
onShowSettings?: () => void; onShowSettings?: () => void;
@@ -59,6 +62,8 @@ export function ChatHeader({
enableTools, enableTools,
onToggleTools, onToggleTools,
wsConnected, wsConnected,
wsConnectivity,
wsDisconnectedAt,
oauthStatus = null, oauthStatus = null,
onShowBotConfig, onShowBotConfig,
onShowSettings, onShowSettings,
@@ -117,6 +122,28 @@ export function ChatHeader({
const rebuildButtonDisabled = const rebuildButtonDisabled =
rebuildStatus === "building" || rebuildStatus === "reconnecting"; 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 ( return (
<> <>
{/* Confirmation dialog overlay */} {/* Confirmation dialog overlay */}
@@ -347,6 +374,20 @@ export function ChatHeader({
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}> <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 !== null &&
(!oauthStatus.authenticated || oauthStatus.expired) && ( (!oauthStatus.authenticated || oauthStatus.expired) && (
<button <button
+24 -8
View File
@@ -11,6 +11,9 @@ import { formatToolActivity } from "../utils/chatUtils";
const { useEffect, useRef, useState } = React; const { useEffect, useRef, useState } = React;
/** Connectivity state of the WebSocket connection. */
export type WsConnectivity = "connecting" | "connected" | "reconnecting" | "failed";
type SetState<T> = React.Dispatch<React.SetStateAction<T>>; type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
interface UseChatWebSocketParams { interface UseChatWebSocketParams {
@@ -32,6 +35,8 @@ interface ReconciliationEvent {
export interface UseChatWebSocketResult { export interface UseChatWebSocketResult {
wsRef: React.MutableRefObject<ChatWebSocket | null>; wsRef: React.MutableRefObject<ChatWebSocket | null>;
wsConnected: boolean; wsConnected: boolean;
wsConnectivity: WsConnectivity;
wsDisconnectedAt: Date | null;
streamingContent: string; streamingContent: string;
setStreamingContent: SetState<string>; setStreamingContent: SetState<string>;
streamingThinking: string; streamingThinking: string;
@@ -87,6 +92,9 @@ export function useChatWebSocket({
}: UseChatWebSocketParams): UseChatWebSocketResult { }: UseChatWebSocketParams): UseChatWebSocketResult {
const wsRef = useRef<ChatWebSocket | null>(null); const wsRef = useRef<ChatWebSocket | null>(null);
const [wsConnected, setWsConnected] = useState(false); const [wsConnected, setWsConnected] = useState(false);
const [wsConnectivity, setWsConnectivity] = useState<WsConnectivity>("connecting");
const [wsDisconnectedAt, setWsDisconnectedAt] = useState<Date | null>(null);
const failedTimerRef = useRef<number | undefined>(undefined);
const [streamingContent, setStreamingContent] = useState(""); const [streamingContent, setStreamingContent] = useState("");
const [streamingThinking, setStreamingThinking] = useState(""); const [streamingThinking, setStreamingThinking] = useState("");
const [activityStatus, setActivityStatus] = useState<string | null>(null); const [activityStatus, setActivityStatus] = useState<string | null>(null);
@@ -162,14 +170,6 @@ export function useChatWebSocket({
console.error("WebSocket error:", message); console.error("WebSocket error:", message);
setLoading(false); setLoading(false);
setActivityStatus(null); setActivityStatus(null);
const markdownMessage = message.replace(
/(https?:\/\/[^\s]+)/g,
"[$1]($1)",
);
setMessages((prev) => [
...prev,
{ role: "assistant", content: markdownMessage },
]);
if (queuedMessagesRef.current.length > 0) { if (queuedMessagesRef.current.length > 0) {
const batch = queuedMessagesRef.current.map((item) => item.text); const batch = queuedMessagesRef.current.map((item) => item.text);
queuedMessagesRef.current = []; queuedMessagesRef.current = [];
@@ -261,18 +261,34 @@ export function useChatWebSocket({
}, },
onConnected: () => { onConnected: () => {
setWsConnected(true); setWsConnected(true);
setWsConnectivity("connected");
setWsDisconnectedAt(null);
window.clearTimeout(failedTimerRef.current);
failedTimerRef.current = undefined;
},
onDisconnected: () => {
setWsConnectivity("reconnecting");
setWsDisconnectedAt(new Date());
window.clearTimeout(failedTimerRef.current);
failedTimerRef.current = window.setTimeout(() => {
setWsConnectivity("failed");
}, 30_000);
}, },
}); });
return () => { return () => {
ws.close(); ws.close();
wsRef.current = null; wsRef.current = null;
window.clearTimeout(failedTimerRef.current);
failedTimerRef.current = undefined;
}; };
}, []); }, []);
return { return {
wsRef, wsRef,
wsConnected, wsConnected,
wsConnectivity,
wsDisconnectedAt,
streamingContent, streamingContent,
setStreamingContent, setStreamingContent,
streamingThinking, streamingThinking,