Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Configure the widget via `window.ClaudiusConfig`:
| `placeholder` | `"Type your message..."` | Input placeholder |
| `persistMessages` | `true` | Save chat history to `sessionStorage` (survives page navigation, clears on tab close) |
| `storageKeyPrefix` | `"claudius:messages"` | Storage key prefix; set to a unique value per widget when embedding multiple widgets on one page |
| `requestTimeoutMs` | `30000` | Per-attempt request timeout in ms. The widget aborts and surfaces a retryable timeout error. Set to `0` to disable. |
| `theme` | `"light"` | Color scheme: `"light"`, `"dark"`, or `"auto"` |
| `accentColor` | `"#2563eb"` | Primary brand color override |

Expand Down
113 changes: 108 additions & 5 deletions widget/src/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,14 @@ describe("ChatApiClient", () => {
const client = new ChatApiClient(BASE_URL, { debounceMs: 0 });
await client.sendMessage(mockMessages);

expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: mockMessages }),
});
expect(mockFetch).toHaveBeenCalledWith(
`${BASE_URL}/api/chat`,
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: mockMessages }),
}),
);
});

it("returns typed ChatResponse on success", async () => {
Expand Down Expand Up @@ -291,6 +294,106 @@ describe("ChatApiClient", () => {
});
});

describe("timeout", () => {
it("aborts and throws ChatApiError(code=TIMEOUT) when the request exceeds timeoutMs", async () => {
vi.useFakeTimers();

// Fetch never resolves on its own; the client aborts via AbortSignal.
mockFetch.mockImplementation(
(_input: RequestInfo, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
});
}),
);

const client = new ChatApiClient(BASE_URL, {
debounceMs: 0,
maxRetries: 0,
timeoutMs: 5_000,
});

const promise = client.sendMessage(mockMessages).catch((e: unknown) => e);

// Trigger the timeout AbortController.
await vi.advanceTimersByTimeAsync(5_000);

const error = await promise;
expect(error).toBeInstanceOf(ChatApiError);
expect(error).toMatchObject({ status: 0, code: "TIMEOUT" });
});

it("treats TIMEOUT as retryable and recovers on the next attempt", async () => {
vi.useFakeTimers();

let callCount = 0;
mockFetch.mockImplementation(
(_input: RequestInfo, init?: RequestInit) => {
callCount += 1;
if (callCount === 1) {
// First call: hang until aborted by the client's timeout.
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
});
});
}
// Second call: succeed.
return Promise.resolve(
createMockResponse({
ok: true,
status: 200,
body: { reply: "OK" },
}),
);
},
);

const client = new ChatApiClient(BASE_URL, {
debounceMs: 0,
maxRetries: 1,
timeoutMs: 1_000,
});

const promise = client.sendMessage(mockMessages);

// Advance past the timeout to abort attempt 1.
await vi.advanceTimersByTimeAsync(1_000);
// Advance past the network-error backoff (1s for first retry).
await vi.advanceTimersByTimeAsync(1_000);

const result = await promise;
expect(result).toEqual({ reply: "OK" });
expect(callCount).toBe(2);
});

it("does not abort when timeoutMs is 0", async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
body: { reply: "OK" },
}),
);

const client = new ChatApiClient(BASE_URL, {
debounceMs: 0,
timeoutMs: 0,
});

const result = await client.sendMessage(mockMessages);
expect(result).toEqual({ reply: "OK" });
// When timeoutMs is 0, fetch is called without a signal.
const call = mockFetch.mock.calls[0];
expect(call[1].signal).toBeUndefined();
});
});

describe("debounce", () => {
it("rejects rapid calls within debounce window with DebounceError", async () => {
vi.useFakeTimers();
Expand Down
62 changes: 52 additions & 10 deletions widget/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,28 @@ import { ChatApiError, DebounceError } from "./errors";
export interface ChatApiClientOptions {
maxRetries?: number;
debounceMs?: number;
/**
* Per-attempt request timeout in milliseconds. Aborts the in-flight fetch
* via AbortController and surfaces a retryable ChatApiError with code
* "TIMEOUT". Set to 0 to disable. Defaults to 30000 (30s).
*/
timeoutMs?: number;
}

const DEFAULT_TIMEOUT_MS = 30_000;

export class ChatApiClient {
private readonly baseUrl: string;
private readonly maxRetries: number;
private readonly debounceMs: number;
private readonly timeoutMs: number;
private lastSendTime = 0;

constructor(baseUrl: string, options?: ChatApiClientOptions) {
this.baseUrl = baseUrl;
this.maxRetries = options?.maxRetries ?? 2;
this.debounceMs = options?.debounceMs ?? 300;
this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
}

async sendMessage(messages: ChatMessage[]): Promise<ChatResponse> {
Expand All @@ -36,24 +46,22 @@ export class ChatApiClient {

for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
const response = await fetch(`${this.baseUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
const response = await this.fetchWithTimeout(messages);

if (response.ok) {
return (await response.json()) as ChatResponse;
}

const body = (await response.json()) as ChatErrorResponse;
const body = (await response.json().catch(() => ({}))) as
| ChatErrorResponse
| Record<string, never>;
const retryAfterHeader = response.headers.get("Retry-After");
const retryAfter = retryAfterHeader
? Number(retryAfterHeader)
: undefined;

lastError = new ChatApiError(
body.error,
body.error ?? `Request failed with status ${response.status}`,
response.status,
body.code,
retryAfter,
Expand All @@ -69,7 +77,7 @@ export class ChatApiClient {
}
} catch (error) {
if (error instanceof ChatApiError) {
if (!this.isRetryable(error.status)) {
if (!this.isRetryable(error.status, error.code)) {
throw error;
}
lastError = error;
Expand All @@ -78,10 +86,11 @@ export class ChatApiClient {
await this.delay(delayMs);
}
} else {
// Network error
// Network error (fetch threw, e.g. DNS / offline / CORS).
lastError = new ChatApiError(
"Failed to connect. Please try again.",
0,
"NETWORK_ERROR",
);
if (attempt < this.maxRetries) {
const delayMs = this.getRetryDelay(null, attempt);
Expand All @@ -94,7 +103,40 @@ export class ChatApiClient {
throw lastError!;
}

private isRetryable(status: number): boolean {
private async fetchWithTimeout(messages: ChatMessage[]): Promise<Response> {
if (this.timeoutMs <= 0) {
return fetch(`${this.baseUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
}

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
return await fetch(`${this.baseUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
signal: controller.signal,
});
} catch (err) {
if (controller.signal.aborted) {
throw new ChatApiError(
"Request timed out. Please try again.",
0,
"TIMEOUT",
);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}

private isRetryable(status: number, code?: string): boolean {
if (code === "TIMEOUT" || code === "NETWORK_ERROR") return true;
return status === 429 || status === 503;
}

Expand Down
7 changes: 6 additions & 1 deletion widget/src/components/ChatWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ChatWidgetProps {
placeholder?: string;
persistMessages?: boolean;
storageKeyPrefix?: string;
requestTimeoutMs?: number;
theme?: "light" | "dark" | "auto";
accentColor?: string;
position?: WidgetPosition;
Expand All @@ -37,6 +38,7 @@ export function ChatWidget({
placeholder,
persistMessages,
storageKeyPrefix,
requestTimeoutMs,
theme = "light",
accentColor,
position = "bottom-right",
Expand All @@ -50,10 +52,11 @@ export function ChatWidget({
[translationOverrides]
);

const { messages, isLoading, error, sendMessage } = useChat({
const { messages, isLoading, error, canRetry, sendMessage, retry } = useChat({
apiUrl,
persistMessages,
storageKeyPrefix,
timeoutMs: requestTimeoutMs,
translations,
});
const toggleRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -108,7 +111,9 @@ export function ChatWidget({
messages={messages}
isLoading={isLoading}
error={error}
canRetry={canRetry}
onSend={sendMessage}
onRetry={retry}
onClose={handleClose}
title={title ?? translations.title}
subtitle={subtitle ?? translations.subtitle}
Expand Down
35 changes: 27 additions & 8 deletions widget/src/components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ interface ChatWindowProps {
messages: ChatMessageData[];
isLoading: boolean;
error: string | null;
canRetry?: boolean;
onSend: (message: string) => void;
onRetry?: () => void;
onClose: () => void;
title?: string;
subtitle?: string;
Expand All @@ -24,13 +26,15 @@ interface ChatWindowProps {
isMobile?: boolean;
}

function TypingIndicator() {
function TypingIndicator({ label }: { label: string }) {
// motion-safe: variant disables the bounce when prefers-reduced-motion is
// set; the dots remain visible as a static status pill.
return (
<div role="status" aria-live="polite" className="mr-auto flex max-w-[85%]">
<div role="status" aria-live="polite" aria-label={label} className="mr-auto flex max-w-[85%]">
<div className="flex gap-1 rounded-2xl rounded-bl-sm bg-claudius-light dark:bg-gray-800 px-4 py-3">
<span className="h-2 w-2 animate-bounce rounded-full bg-claudius-gray [animation-delay:0ms]" />
<span className="h-2 w-2 animate-bounce rounded-full bg-claudius-gray [animation-delay:150ms]" />
<span className="h-2 w-2 animate-bounce rounded-full bg-claudius-gray [animation-delay:300ms]" />
<span className="h-2 w-2 motion-safe:animate-bounce rounded-full bg-claudius-gray [animation-delay:0ms]" />
<span className="h-2 w-2 motion-safe:animate-bounce rounded-full bg-claudius-gray [animation-delay:150ms]" />
<span className="h-2 w-2 motion-safe:animate-bounce rounded-full bg-claudius-gray [animation-delay:300ms]" />
</div>
</div>
);
Expand All @@ -47,7 +51,9 @@ export function ChatWindow({
messages,
isLoading,
error,
canRetry = false,
onSend,
onRetry,
onClose,
title = "Chat",
subtitle = "Ask me anything",
Expand Down Expand Up @@ -194,14 +200,27 @@ export function ChatWindow({
/>
))}

{isLoading && <TypingIndicator />}
{isLoading && (
<TypingIndicator
label={translations?.typingIndicator ?? "Assistant is typing"}
/>
)}

{error && (
<div
role="alert"
className="mx-auto max-w-[90%] rounded-lg bg-red-50 dark:bg-red-900/30 px-3 py-2 text-center text-xs text-red-600 dark:text-red-400"
className="mx-auto flex max-w-[90%] flex-col items-center gap-2 rounded-lg bg-red-50 dark:bg-red-900/30 px-3 py-2 text-center text-xs text-red-600 dark:text-red-400"
>
{error}
<span>{error}</span>
{canRetry && onRetry && !isLoading && (
<button
type="button"
onClick={onRetry}
className="rounded-button bg-red-600 px-3 py-1 text-xs font-semibold text-white transition-colors hover:bg-red-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
{translations?.errorRetry ?? "Retry"}
</button>
)}
</div>
)}
</div>
Expand Down
Loading
Loading