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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ Configure the widget via `window.ClaudiusConfig`:
| `subtitle` | `"Ask me anything"` | Header subtitle |
| `welcomeMessage` | `"Hi! How can I help you today?"` | First message shown |
| `placeholder` | `"Type your message..."` | Input placeholder |
| `persistMessages` | `true` | Save chat history to localStorage |
| `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 |
| `theme` | `"light"` | Color scheme: `"light"`, `"dark"`, or `"auto"` |
| `accentColor` | `"#2563eb"` | Primary brand color override |

Expand Down
3 changes: 3 additions & 0 deletions widget/src/components/ChatWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ChatWidgetProps {
welcomeMessage?: string;
placeholder?: string;
persistMessages?: boolean;
storageKeyPrefix?: string;
theme?: "light" | "dark" | "auto";
accentColor?: string;
position?: WidgetPosition;
Expand All @@ -35,6 +36,7 @@ export function ChatWidget({
welcomeMessage,
placeholder,
persistMessages,
storageKeyPrefix,
theme = "light",
accentColor,
position = "bottom-right",
Expand All @@ -51,6 +53,7 @@ export function ChatWidget({
const { messages, isLoading, error, sendMessage } = useChat({
apiUrl,
persistMessages,
storageKeyPrefix,
translations,
});
const toggleRef = useRef<HTMLButtonElement>(null);
Expand Down
6 changes: 6 additions & 0 deletions widget/src/embed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface ClaudiusConfig {
welcomeMessage?: string;
placeholder?: string;
persistMessages?: boolean;
storageKeyPrefix?: string;
theme?: "light" | "dark" | "auto";
accentColor?: string;
position?: WidgetPosition;
Expand Down Expand Up @@ -40,6 +41,7 @@ function init() {
welcomeMessage={config.welcomeMessage}
placeholder={config.placeholder}
persistMessages={config.persistMessages}
storageKeyPrefix={config.storageKeyPrefix}
theme={config.theme}
accentColor={config.accentColor}
position={config.position}
Expand All @@ -60,6 +62,7 @@ class ClaudiusChat extends HTMLElement {
"welcome-message",
"placeholder",
"persist-messages",
"storage-key-prefix",
"theme",
"accent-color",
"position",
Expand Down Expand Up @@ -109,6 +112,9 @@ class ClaudiusChat extends HTMLElement {
welcomeMessage={this.getAttribute("welcome-message") ?? undefined}
placeholder={this.getAttribute("placeholder") ?? undefined}
persistMessages={persistMessages}
storageKeyPrefix={
this.getAttribute("storage-key-prefix") ?? undefined
}
theme={
(this.getAttribute("theme") as "light" | "dark" | "auto") ?? undefined
}
Expand Down
103 changes: 94 additions & 9 deletions widget/src/hooks/__tests__/useChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ globalThis.fetch = mockFetch;
describe("useChat", () => {
beforeEach(() => {
mockFetch.mockReset();
localStorage.clear();
sessionStorage.clear();
});

it("starts with empty messages and not loading", () => {
Expand Down Expand Up @@ -138,10 +138,11 @@ describe("conversation persistence", () => {

beforeEach(() => {
mockFetch.mockReset();
sessionStorage.clear();
localStorage.clear();
});

it("saves messages to localStorage after receiving a reply", async () => {
it("saves messages to sessionStorage after receiving a reply", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
Expand All @@ -157,7 +158,7 @@ describe("conversation persistence", () => {
await result.current.sendMessage("Hi there");
});

const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY)!);
expect(stored).toHaveLength(2);
expect(stored[0]).toMatchObject({ role: "user", content: "Hi there" });
expect(stored[1]).toMatchObject({
Expand All @@ -166,12 +167,31 @@ describe("conversation persistence", () => {
});
});

it("restores messages from localStorage on mount", () => {
it("does not write to localStorage", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ reply: "Hello!" }),
});

const { result } = renderHook(() =>
useChat({ apiUrl: "https://test.workers.dev" })
);

await act(async () => {
await result.current.sendMessage("Hi");
});

expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});

it("restores messages from sessionStorage on mount", () => {
const savedMessages = [
{ id: "msg-1", role: "user", content: "Hello" },
{ id: "msg-2", role: "assistant", content: "Hi there!" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(savedMessages));
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(savedMessages));

const { result } = renderHook(() =>
useChat({ apiUrl: "https://test.workers.dev" })
Expand All @@ -188,12 +208,12 @@ describe("conversation persistence", () => {
});
});

it("clears localStorage when clearMessages is called", () => {
it("clears sessionStorage when clearMessages is called", () => {
const savedMessages = [
{ id: "msg-1", role: "user", content: "Hello" },
{ id: "msg-2", role: "assistant", content: "Hi there!" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(savedMessages));
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(savedMessages));

const { result } = renderHook(() =>
useChat({ apiUrl: "https://test.workers.dev" })
Expand All @@ -203,7 +223,7 @@ describe("conversation persistence", () => {
result.current.clearMessages();
});

expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
expect(result.current.messages).toEqual([]);
});

Expand All @@ -223,7 +243,72 @@ describe("conversation persistence", () => {
await result.current.sendMessage("Hi");
});

expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
expect(result.current.messages).toHaveLength(2);
});
});

describe("storage key prefix", () => {
beforeEach(() => {
mockFetch.mockReset();
sessionStorage.clear();
});

it("uses a custom prefix when provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ reply: "Hi!" }),
});

const { result } = renderHook(() =>
useChat({
apiUrl: "https://test.workers.dev",
storageKeyPrefix: "myapp:widget-a",
})
);

await act(async () => {
await result.current.sendMessage("Hello");
});

expect(
sessionStorage.getItem("myapp:widget-a:test.workers.dev")
).not.toBeNull();
expect(
sessionStorage.getItem("claudius:messages:test.workers.dev")
).toBeNull();
});

it("isolates history between widgets with different prefixes on the same apiUrl", () => {
sessionStorage.setItem(
"myapp:widget-a:test.workers.dev",
JSON.stringify([{ id: "a-1", role: "user", content: "from A" }])
);
sessionStorage.setItem(
"myapp:widget-b:test.workers.dev",
JSON.stringify([{ id: "b-1", role: "user", content: "from B" }])
);

const a = renderHook(() =>
useChat({
apiUrl: "https://test.workers.dev",
storageKeyPrefix: "myapp:widget-a",
})
);
const b = renderHook(() =>
useChat({
apiUrl: "https://test.workers.dev",
storageKeyPrefix: "myapp:widget-b",
})
);

expect(a.result.current.messages).toEqual([
{ id: "a-1", role: "user", content: "from A" },
]);
expect(b.result.current.messages).toEqual([
{ id: "b-1", role: "user", content: "from B" },
]);
});
});
33 changes: 25 additions & 8 deletions widget/src/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ChatApiError, DebounceError } from "../api/errors";
interface UseChatOptions {
apiUrl: string;
persistMessages?: boolean;
storageKeyPrefix?: string;
translations?: ClaudiusTranslations;
}

Expand All @@ -19,20 +20,31 @@ interface UseChatReturn {
}

const MAX_PERSISTED_MESSAGES = 200;
const DEFAULT_STORAGE_KEY_PREFIX = "claudius:messages";

function getStorageKey(apiUrl: string): string {
function getStorageKey(prefix: string, apiUrl: string): string {
let host: string;
try {
host = new URL(apiUrl).host;
} catch {
host = apiUrl;
}
return `claudius:messages:${host}`;
return `${prefix}:${host}`;
}

function getSessionStorage(): Storage | null {
try {
return typeof sessionStorage !== "undefined" ? sessionStorage : null;
} catch {
return null;
}
}

function loadMessages(storageKey: string): ChatMessage[] {
const storage = getSessionStorage();
if (!storage) return [];
try {
const raw = localStorage.getItem(storageKey);
const raw = storage.getItem(storageKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
Expand All @@ -45,14 +57,15 @@ function loadMessages(storageKey: string): ChatMessage[] {
export function useChat({
apiUrl,
persistMessages = true,
storageKeyPrefix = DEFAULT_STORAGE_KEY_PREFIX,
translations,
}: UseChatOptions): UseChatReturn {
const client = useMemo(
() => new ChatApiClient(apiUrl, { debounceMs: 0 }),
[apiUrl],
);

const storageKey = getStorageKey(apiUrl);
const storageKey = getStorageKey(storageKeyPrefix, apiUrl);

const initialMessages = persistMessages ? loadMessages(storageKey) : [];

Expand All @@ -67,11 +80,13 @@ export function useChat({
const saveMessages = useCallback(
(msgs: ChatMessage[]) => {
if (!persistMessages) return;
const storage = getSessionStorage();
if (!storage) return;
try {
const toSave = msgs.slice(-MAX_PERSISTED_MESSAGES);
localStorage.setItem(storageKey, JSON.stringify(toSave));
storage.setItem(storageKey, JSON.stringify(toSave));
} catch {
// localStorage may be unavailable in private browsing
// sessionStorage may be unavailable or quota-exceeded
}
},
[persistMessages, storageKey]
Expand Down Expand Up @@ -161,10 +176,12 @@ export function useChat({
setMessages([]);
setError(null);
if (persistMessages) {
const storage = getSessionStorage();
if (!storage) return;
try {
localStorage.removeItem(storageKey);
storage.removeItem(storageKey);
} catch {
// localStorage may be unavailable
// sessionStorage may be unavailable
}
}
}, [persistMessages, storageKey]);
Expand Down
Loading