huskies: merge 1058
This commit is contained in:
@@ -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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user