From c856bfc9c11d1a13b37ebfb82c88b16e3f16d41f Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Thu, 7 May 2026 20:59:45 -0400 Subject: [PATCH] feat(persistence): use sessionStorage with configurable key prefix Switch chat history persistence from localStorage to sessionStorage so conversations survive page navigation within the tab but clear on tab close. Add storageKeyPrefix option (default claudius:messages) so multiple widgets on the same page can keep separate histories. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 3 +- widget/src/components/ChatWidget.tsx | 3 + widget/src/embed.tsx | 6 ++ widget/src/hooks/__tests__/useChat.test.ts | 103 +++++++++++++++++++-- widget/src/hooks/useChat.ts | 33 +++++-- 5 files changed, 130 insertions(+), 18 deletions(-) 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]);