);
};
-export const AgentDetailTimeline: FC = (props) => {
- return ;
-};
-
interface AgentDetailInputProps {
store: ChatStoreHandle;
compressionThreshold: number | undefined;
@@ -218,6 +128,7 @@ interface AgentDetailInputProps {
// File parts from the message being edited, converted to
// File objects and pre-populated into attachments.
editingFileBlocks?: readonly TypesGen.ChatMessagePart[];
+ // MCP server picker state.
mcpServers?: readonly TypesGen.MCPServerConfig[];
selectedMCPServerIds?: readonly string[];
onMCPSelectionChange?: (ids: string[]) => void;
diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx
index add36f96f0..dcd5a29a1f 100644
--- a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx
+++ b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx
@@ -8,6 +8,7 @@ import type { ComponentProps, FC } from "react";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import type { ModelSelectorOption } from "#/components/ai-elements";
+import type { ChatDetailError } from "../utils/usageLimitMessage";
import { createChatStore } from "./AgentDetail/ChatContext";
import {
AgentDetailLoadingView,
@@ -97,11 +98,8 @@ const StoryAgentDetailView: FC = ({ editing, ...overrides }) => {
const props = {
agentId: AGENT_ID,
chatTitle: "Help me refactor",
- chatErrorReasons: {} as ComponentProps<
- typeof AgentDetailView
- >["chatErrorReasons"],
+ persistedError: undefined as ChatDetailError | undefined,
parentChat: undefined as TypesGen.Chat | undefined,
- chatRecord: buildChat(),
isArchived: false,
hasWorkspace: true,
store: createChatStore(),
@@ -187,13 +185,7 @@ export const Default: Story = {
/** Archived agent displays the read-only banner below the top bar. */
export const Archived: Story = {
- render: () => (
-
- ),
+ render: () => ,
};
/** Shows the parent chat link in the top bar when a parent exists. */
@@ -209,8 +201,12 @@ export const WithParentChat: Story = {
export const WithError: Story = {
render: () => (
),
diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.tsx
index 31b4c45143..3db8c57597 100644
--- a/site/src/pages/AgentsPage/components/AgentDetailView.tsx
+++ b/site/src/pages/AgentsPage/components/AgentDetailView.tsx
@@ -9,11 +9,7 @@ import type { ModelSelectorOption } from "#/components/ai-elements";
import { Button } from "#/components/Button/Button";
import type { ChatDetailError } from "../utils/usageLimitMessage";
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
-import {
- selectChatStatus,
- useChatSelector,
- type useChatStore,
-} from "./AgentDetail/ChatContext";
+import type { useChatStore } from "./AgentDetail/ChatContext";
import { AgentDetailTopBar } from "./AgentDetail/TopBar";
import { AgentDetailInput, AgentDetailTimeline } from "./AgentDetailContent";
import {
@@ -55,8 +51,7 @@ interface AgentDetailViewProps {
agentId: string;
chatTitle: string | undefined;
parentChat: TypesGen.Chat | undefined;
- chatErrorReasons: Record;
- chatRecord: TypesGen.Chat | undefined;
+ persistedError: ChatDetailError | undefined;
isArchived: boolean;
hasWorkspace: boolean;
@@ -139,8 +134,7 @@ export const AgentDetailView: FC = ({
agentId,
chatTitle,
parentChat,
- chatErrorReasons,
- chatRecord,
+ persistedError,
isArchived,
hasWorkspace,
store,
@@ -192,7 +186,6 @@ export const AgentDetailView: FC = ({
null,
);
const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded;
- const chatStatus = useChatSelector(store, selectChatStatus);
// Compute local diff stats from git watcher unified diffs.
@@ -269,13 +262,9 @@ export const AgentDetailView: FC = ({
>
{
});
});
+describe("chatDetailErrorsEqual", () => {
+ it("compares matching errors by value", () => {
+ const left: ChatDetailError = {
+ kind: "rate_limit",
+ message: "Slow down.",
+ provider: "anthropic",
+ retryable: true,
+ statusCode: 429,
+ };
+
+ expect(chatDetailErrorsEqual(left, { ...left })).toBe(true);
+ });
+
+ it("treats missing and mismatched errors as different", () => {
+ const error: ChatDetailError = {
+ kind: "generic",
+ message: "Provider request failed.",
+ };
+
+ expect(chatDetailErrorsEqual(error, null)).toBe(false);
+ expect(chatDetailErrorsEqual(error, { ...error, statusCode: 500 })).toBe(
+ false,
+ );
+ });
+});
+
describe("isUsageLimitData", () => {
it("accepts a fully populated valid payload", () => {
const error: ChatDetailError = {
diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts
index 3775ccf08d..00193a6eb3 100644
--- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts
+++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts
@@ -13,11 +13,43 @@ interface UsageLimitData {
/**
* Typed classification for errors surfaced in the agent detail view.
* - "usage-limit": the user hit a spending cap (409 + valid usage data).
- * - "generic": any other error (stream failures, last_error, etc.).
+ * - other kinds come from normalized stream/provider failures such as
+ * "generic", "overloaded", "rate_limit", or "timeout".
*/
export type ChatDetailError = {
message: string;
- kind: "generic" | "usage-limit";
+ kind:
+ | "usage-limit"
+ | "generic"
+ | "overloaded"
+ | "rate_limit"
+ | "timeout"
+ | (string & {});
+ provider?: string;
+ retryable?: boolean;
+ statusCode?: number;
+};
+
+/**
+ * Compare two chat-detail errors by their user-visible fields.
+ */
+export const chatDetailErrorsEqual = (
+ left: ChatDetailError | null | undefined,
+ right: ChatDetailError | null | undefined,
+): boolean => {
+ if (left === right) {
+ return true;
+ }
+ if (!left || !right) {
+ return false;
+ }
+ return (
+ left.kind === right.kind &&
+ left.message === right.message &&
+ left.provider === right.provider &&
+ left.retryable === right.retryable &&
+ left.statusCode === right.statusCode
+ );
};
/**
diff --git a/site/src/utils/OneWayWebSocket.ts b/site/src/utils/OneWayWebSocket.ts
index e0d677637b..fb2dcd3a3a 100644
--- a/site/src/utils/OneWayWebSocket.ts
+++ b/site/src/utils/OneWayWebSocket.ts
@@ -46,7 +46,7 @@ type OneWayEventCallback = (
payload: OneWayEventPayloadMap[TEvent],
) => void;
-interface OneWayWebSocketApi {
+export interface OneWayWebSocketApi {
get url(): string;
addEventListener: (
diff --git a/site/src/utils/reconnectingWebSocket.test.ts b/site/src/utils/reconnectingWebSocket.test.ts
index 0f1e2b03cc..47444c0461 100644
--- a/site/src/utils/reconnectingWebSocket.test.ts
+++ b/site/src/utils/reconnectingWebSocket.test.ts
@@ -1,9 +1,12 @@
-import { createReconnectingWebSocket } from "./reconnectingWebSocket";
+import {
+ createReconnectingWebSocket,
+ type ReconnectSchedule,
+} from "./reconnectingWebSocket";
/**
- * Minimal mock that satisfies the {@link Closable} interface used by
- * the reconnection utility. Each instance records every
- * `addEventListener` call and exposes helpers to fire those events.
+ * Minimal mock that satisfies the {@link Closable} interface used by the
+ * reconnection utility. Each instance records every `addEventListener`
+ * call and exposes helpers to fire those events.
*/
function createMockSocket() {
const listeners: Record void>> = {};
@@ -27,8 +30,19 @@ function createMockSocket() {
return socket;
}
+const expectReconnectSchedule = (
+ event: { reconnect: ReconnectSchedule; now: number },
+ expected: { attempt: number; delayMs: number },
+) => {
+ expect(event.reconnect).toMatchObject(expected);
+ expect(Date.parse(event.reconnect.retryingAt) - event.now).toBe(
+ expected.delayMs,
+ );
+};
+
beforeEach(() => {
vi.useFakeTimers();
+ vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
});
afterEach(() => {
@@ -69,7 +83,11 @@ describe("createReconnectingWebSocket", () => {
activeSocket = createMockSocket();
return activeSocket;
});
- const onDisconnect = vi.fn();
+ const disconnects: Array<{ reconnect: ReconnectSchedule; now: number }> =
+ [];
+ const onDisconnect = vi.fn((reconnect: ReconnectSchedule) => {
+ disconnects.push({ reconnect, now: Date.now() });
+ });
createReconnectingWebSocket({
connect,
@@ -84,7 +102,7 @@ describe("createReconnectingWebSocket", () => {
// First disconnect — should schedule reconnect after 1000ms.
activeSocket.emit("close");
expect(onDisconnect).toHaveBeenCalledTimes(1);
- expect(onDisconnect).toHaveBeenLastCalledWith(0);
+ expectReconnectSchedule(disconnects[0]!, { attempt: 1, delayMs: 1000 });
vi.advanceTimersByTime(999);
expect(connect).toHaveBeenCalledTimes(1);
@@ -94,7 +112,7 @@ describe("createReconnectingWebSocket", () => {
// Second disconnect — delay should be 2000ms.
activeSocket.emit("close");
expect(onDisconnect).toHaveBeenCalledTimes(2);
- expect(onDisconnect).toHaveBeenLastCalledWith(1);
+ expectReconnectSchedule(disconnects[1]!, { attempt: 2, delayMs: 2000 });
vi.advanceTimersByTime(1999);
expect(connect).toHaveBeenCalledTimes(2);
@@ -103,6 +121,7 @@ describe("createReconnectingWebSocket", () => {
// Third disconnect — delay should be 4000ms.
activeSocket.emit("close");
+ expectReconnectSchedule(disconnects[2]!, { attempt: 3, delayMs: 4000 });
vi.advanceTimersByTime(3999);
expect(connect).toHaveBeenCalledTimes(3);
vi.advanceTimersByTime(1);
@@ -123,8 +142,8 @@ describe("createReconnectingWebSocket", () => {
factor: 2,
});
- // Disconnect enough times that the uncapped delay would
- // exceed maxMs: 1000, 2000, 4000, 8000 → capped at 5000.
+ // Disconnect enough times that the uncapped delay would exceed
+ // maxMs: 1000, 2000, 4000, 8000 → capped at 5000.
for (let i = 0; i < 3; i++) {
activeSocket.emit("close");
vi.runOnlyPendingTimers();
@@ -163,8 +182,7 @@ describe("createReconnectingWebSocket", () => {
activeSocket.emit("open");
activeSocket.emit("close");
- // Next reconnect should use the base delay (1000ms), not
- // 4000ms.
+ // Next reconnect should use the base delay (1000ms), not 4000ms.
vi.advanceTimersByTime(999);
expect(connect).toHaveBeenCalledTimes(3);
vi.advanceTimersByTime(1);
@@ -206,14 +224,14 @@ describe("createReconnectingWebSocket", () => {
createReconnectingWebSocket({ connect });
- const firstSocket = sockets[0];
+ const firstSocket = sockets[0]!;
firstSocket.emit("close");
vi.runOnlyPendingTimers();
- // The connect function creates a new socket. The old socket
- // was already "closed" by the browser, but on a fresh
- // reconnection the utility closes the previous one if it's
- // still the active reference.
+ // The connect function creates a new socket. The old socket was
+ // already "closed" by the browser, but on a fresh reconnection
+ // the utility closes the previous one if it's still the active
+ // reference.
expect(connect).toHaveBeenCalledTimes(2);
});
@@ -265,9 +283,9 @@ describe("createReconnectingWebSocket", () => {
dispose();
dispose();
- // close is idempotent on real WebSockets, so calling it
- // multiple times is harmless. The important thing is that
- // no reconnection is scheduled after the first dispose.
+ // close is idempotent on real WebSockets, so calling it multiple
+ // times is harmless. The important thing is that no reconnection is
+ // scheduled after the first dispose.
expect(connect).toHaveBeenCalledTimes(1);
});
diff --git a/site/src/utils/reconnectingWebSocket.ts b/site/src/utils/reconnectingWebSocket.ts
index 294832ab43..47ed0c976c 100644
--- a/site/src/utils/reconnectingWebSocket.ts
+++ b/site/src/utils/reconnectingWebSocket.ts
@@ -17,8 +17,10 @@
* onOpen() {
* console.log("connected");
* },
- * onDisconnect() {
- * console.log("disconnected, will reconnect automatically");
+ * onDisconnect(reconnect) {
+ * console.log(
+ * `disconnected, reconnecting in ${reconnect.delayMs}ms`,
+ * );
* },
* });
*
@@ -37,9 +39,20 @@ const RECONNECT_MAX_MS = 10_000;
const RECONNECT_FACTOR = 2;
/**
- * A minimal WebSocket-like interface that the reconnection utility
- * can manage. Both native `WebSocket` and `OneWayWebSocket` satisfy
- * this contract.
+ * Metadata for the reconnect attempt that was just scheduled.
+ * `attempt` is 1-based and user-facing: `1` means the first retry after
+ * the connection dropped.
+ */
+export type ReconnectSchedule = {
+ attempt: number;
+ delayMs: number;
+ retryingAt: string;
+};
+
+/**
+ * A minimal WebSocket-like interface that the reconnection utility can
+ * manage. Both native `WebSocket` and `OneWayWebSocket` satisfy this
+ * contract.
*/
interface Closable {
addEventListener(event: string, handler: (...args: unknown[]) => void): void;
@@ -63,24 +76,18 @@ interface ReconnectingWebSocketOptions {
connect: () => TSocket;
/**
- * Called when a connection succeeds (the socket fires `open`).
- * The backoff counter is reset before this callback runs.
+ * Called when a connection succeeds (the socket fires `open`). The
+ * backoff counter is reset before this callback runs.
*/
onOpen?: (socket: TSocket) => void;
/**
- * Called on the first disconnect after a successful connection or
- * on a connection failure. Fires at most once per socket instance
- * (browsers fire both `error` and `close`; only the first is
- * forwarded). A reconnection is scheduled automatically after
- * this callback returns.
- *
- * @param attempt - The zero-based reconnection attempt counter
- * *before* it is incremented for the upcoming retry. A value of
- * `0` means this is the first disconnect since the last
- * successful connection.
+ * Called on the first disconnect after a successful connection or on a
+ * connection failure. Fires at most once per socket instance (browsers
+ * fire both `error` and `close`; only the first is forwarded). The
+ * callback receives the reconnect attempt that was just scheduled.
*/
- onDisconnect?: (attempt: number) => void;
+ onDisconnect?: (reconnect: ReconnectSchedule) => void;
/** Base delay in milliseconds. Defaults to {@link RECONNECT_BASE_MS}. */
baseMs?: number;
@@ -92,22 +99,40 @@ interface ReconnectingWebSocketOptions {
factor?: number;
}
+const getReconnectSchedule = ({
+ attempt,
+ baseMs,
+ maxMs,
+ factor,
+}: {
+ attempt: number;
+ baseMs: number;
+ maxMs: number;
+ factor: number;
+}): ReconnectSchedule => {
+ const delayMs = Math.min(baseMs * factor ** (attempt - 1), maxMs);
+ return {
+ attempt,
+ delayMs,
+ retryingAt: new Date(Date.now() + delayMs).toISOString(),
+ };
+};
+
/**
* Creates a self-reconnecting WebSocket connection with capped
* exponential backoff.
*
* The returned function disposes of the connection: it closes the
- * active socket (if any), cancels any pending reconnection timer,
- * and prevents further reconnection attempts. It is safe to call
- * the dispose function more than once.
+ * active socket (if any), cancels any pending reconnection timer, and
+ * prevents further reconnection attempts. It is safe to call the
+ * dispose function more than once.
*
* Backoff delay formula:
* ```
- * delay = min(baseMs * factor ^ attempt, maxMs)
+ * delay = min(baseMs * factor ^ (attempt - 1), maxMs)
* ```
*
- * The attempt counter resets to `0` whenever a connection
- * successfully opens.
+ * The reconnect attempt counter resets after a successful `open`.
*
* @returns A dispose function that tears down the connection.
*/
@@ -124,25 +149,23 @@ export function createReconnectingWebSocket(
} = options;
let disposed = false;
- let reconnectAttempt = 0;
+ let lastReconnectAttempt = 0;
let reconnectTimer: ReturnType | null = null;
let activeSocket: TSocket | null = null;
- // Schedule a reconnect with capped exponential backoff.
- // Does nothing if the connection has been disposed.
- const scheduleReconnect = () => {
+ const scheduleReconnect = (reconnect: ReconnectSchedule) => {
if (disposed) {
return;
}
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
}
- const delay = Math.min(baseMs * factor ** reconnectAttempt, maxMs);
- reconnectAttempt += 1;
- reconnectTimer = setTimeout(connect, delay);
+ lastReconnectAttempt = reconnect.attempt;
+ reconnectTimer = setTimeout(connect, reconnect.delayMs);
};
function connect() {
+ reconnectTimer = null;
if (disposed) {
return;
}
@@ -155,20 +178,26 @@ export function createReconnectingWebSocket(
const handleOpen = () => {
// Connection succeeded — reset backoff.
- reconnectAttempt = 0;
+ lastReconnectAttempt = 0;
onOpen?.(socket);
};
const handleDisconnect = () => {
- // Guard against duplicate calls: browsers fire both
- // "error" and "close" on a failed WebSocket, so we
- // only process the first event per socket instance.
+ // Guard against duplicate calls: browsers fire both "error"
+ // and "close" on a failed WebSocket, so we only process the
+ // first event per socket instance.
if (activeSocket !== socket || disposed) {
return;
}
activeSocket = null;
- onDisconnect?.(reconnectAttempt);
- scheduleReconnect();
+ const reconnect = getReconnectSchedule({
+ attempt: lastReconnectAttempt + 1,
+ baseMs,
+ maxMs,
+ factor,
+ });
+ onDisconnect?.(reconnect);
+ scheduleReconnect(reconnect);
};
socket.addEventListener("open", handleOpen);