diff --git a/README.md b/README.md index d054081..4735744 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/widget/src/components/ChatWidget.tsx b/widget/src/components/ChatWidget.tsx index f642046..f5dd880 100644 --- a/widget/src/components/ChatWidget.tsx +++ b/widget/src/components/ChatWidget.tsx @@ -22,6 +22,7 @@ export interface ChatWidgetProps { welcomeMessage?: string; placeholder?: string; persistMessages?: boolean; + storageKeyPrefix?: string; theme?: "light" | "dark" | "auto"; accentColor?: string; position?: WidgetPosition; @@ -35,6 +36,7 @@ export function ChatWidget({ welcomeMessage, placeholder, persistMessages, + storageKeyPrefix, theme = "light", accentColor, position = "bottom-right", @@ -51,6 +53,7 @@ export function ChatWidget({ const { messages, isLoading, error, sendMessage } = useChat({ apiUrl, persistMessages, + storageKeyPrefix, translations, }); const toggleRef = useRef(null); diff --git a/widget/src/embed.tsx b/widget/src/embed.tsx index 3a13a50..f39abfd 100644 --- a/widget/src/embed.tsx +++ b/widget/src/embed.tsx @@ -9,6 +9,7 @@ interface ClaudiusConfig { welcomeMessage?: string; placeholder?: string; persistMessages?: boolean; + storageKeyPrefix?: string; theme?: "light" | "dark" | "auto"; accentColor?: string; position?: WidgetPosition; @@ -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} @@ -60,6 +62,7 @@ class ClaudiusChat extends HTMLElement { "welcome-message", "placeholder", "persist-messages", + "storage-key-prefix", "theme", "accent-color", "position", @@ -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 } diff --git a/widget/src/hooks/__tests__/useChat.test.ts b/widget/src/hooks/__tests__/useChat.test.ts index 9d68ad7..28bc480 100644 --- a/widget/src/hooks/__tests__/useChat.test.ts +++ b/widget/src/hooks/__tests__/useChat.test.ts @@ -8,7 +8,7 @@ globalThis.fetch = mockFetch; describe("useChat", () => { beforeEach(() => { mockFetch.mockReset(); - localStorage.clear(); + sessionStorage.clear(); }); it("starts with empty messages and not loading", () => { @@ -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, @@ -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({ @@ -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" }) @@ -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" }) @@ -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([]); }); @@ -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" }, + ]); + }); +}); diff --git a/widget/src/hooks/useChat.ts b/widget/src/hooks/useChat.ts index 12ad00a..67974bb 100644 --- a/widget/src/hooks/useChat.ts +++ b/widget/src/hooks/useChat.ts @@ -7,6 +7,7 @@ import { ChatApiError, DebounceError } from "../api/errors"; interface UseChatOptions { apiUrl: string; persistMessages?: boolean; + storageKeyPrefix?: string; translations?: ClaudiusTranslations; } @@ -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 []; @@ -45,6 +57,7 @@ function loadMessages(storageKey: string): ChatMessage[] { export function useChat({ apiUrl, persistMessages = true, + storageKeyPrefix = DEFAULT_STORAGE_KEY_PREFIX, translations, }: UseChatOptions): UseChatReturn { const client = useMemo( @@ -52,7 +65,7 @@ export function useChat({ [apiUrl], ); - const storageKey = getStorageKey(apiUrl); + const storageKey = getStorageKey(storageKeyPrefix, apiUrl); const initialMessages = persistMessages ? loadMessages(storageKey) : []; @@ -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] @@ -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]);