huskies: merge 1058
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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", {
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: /https:\/\/example\.com\/oauth\/login/,
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "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
|
||||
|
||||
@@ -11,6 +11,9 @@ import { formatToolActivity } from "../utils/chatUtils";
|
||||
|
||||
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>>;
|
||||
|
||||
interface UseChatWebSocketParams {
|
||||
@@ -32,6 +35,8 @@ interface ReconciliationEvent {
|
||||
export interface UseChatWebSocketResult {
|
||||
wsRef: React.MutableRefObject<ChatWebSocket | null>;
|
||||
wsConnected: boolean;
|
||||
wsConnectivity: WsConnectivity;
|
||||
wsDisconnectedAt: Date | null;
|
||||
streamingContent: string;
|
||||
setStreamingContent: SetState<string>;
|
||||
streamingThinking: string;
|
||||
@@ -87,6 +92,9 @@ export function useChatWebSocket({
|
||||
}: UseChatWebSocketParams): UseChatWebSocketResult {
|
||||
const wsRef = useRef<ChatWebSocket | null>(null);
|
||||
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 [streamingThinking, setStreamingThinking] = useState("");
|
||||
const [activityStatus, setActivityStatus] = useState<string | null>(null);
|
||||
@@ -162,14 +170,6 @@ export function useChatWebSocket({
|
||||
console.error("WebSocket error:", message);
|
||||
setLoading(false);
|
||||
setActivityStatus(null);
|
||||
const markdownMessage = message.replace(
|
||||
/(https?:\/\/[^\s]+)/g,
|
||||
"[$1]($1)",
|
||||
);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: markdownMessage },
|
||||
]);
|
||||
if (queuedMessagesRef.current.length > 0) {
|
||||
const batch = queuedMessagesRef.current.map((item) => item.text);
|
||||
queuedMessagesRef.current = [];
|
||||
@@ -261,18 +261,34 @@ export function useChatWebSocket({
|
||||
},
|
||||
onConnected: () => {
|
||||
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 () => {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
window.clearTimeout(failedTimerRef.current);
|
||||
failedTimerRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
wsRef,
|
||||
wsConnected,
|
||||
wsConnectivity,
|
||||
wsDisconnectedAt,
|
||||
streamingContent,
|
||||
setStreamingContent,
|
||||
streamingThinking,
|
||||
|
||||
Reference in New Issue
Block a user