From 6f7338dfdbaf6a10648eced7dd408e8f41e7fb6c Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 24 Feb 2026 17:03:04 +0000 Subject: [PATCH] story-kit: accept 145_story_persist_chat_history_to_localstorage_across_rebuilds --- ...history_to_localstorage_across_rebuilds.md | 13 +- frontend/src/components/Chat.test.tsx | 99 +++++++++++- frontend/src/components/Chat.tsx | 5 +- frontend/src/hooks/useChatHistory.test.ts | 146 ++++++++++++++++++ frontend/src/hooks/useChatHistory.ts | 67 ++++++++ 5 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 frontend/src/hooks/useChatHistory.test.ts create mode 100644 frontend/src/hooks/useChatHistory.ts diff --git a/.story_kit/work/5_archived/145_story_persist_chat_history_to_localstorage_across_rebuilds.md b/.story_kit/work/5_archived/145_story_persist_chat_history_to_localstorage_across_rebuilds.md index 4333aca..30781e3 100644 --- a/.story_kit/work/5_archived/145_story_persist_chat_history_to_localstorage_across_rebuilds.md +++ b/.story_kit/work/5_archived/145_story_persist_chat_history_to_localstorage_across_rebuilds.md @@ -6,12 +6,19 @@ name: "Persist chat history to localStorage across rebuilds" ## User Story -As a ..., I want ..., so that ... +As a developer using the Story Kit web UI, I want my chat history to persist across page reloads and Vite HMR rebuilds, so that I don't lose my conversation context during development. ## Acceptance Criteria -- [ ] TODO +- [ ] AC1: Chat messages are restored from localStorage on component mount (surviving page reload / HMR rebuild) +- [ ] AC2: Chat messages are saved to localStorage whenever the message history updates (via WebSocket `onUpdate` or user sending a message) +- [ ] AC3: Clearing the session via "New Session" button also removes persisted messages from localStorage +- [ ] AC4: localStorage quota errors are handled gracefully (fail silently with console.warn, never crash the app) +- [ ] AC5: Messages are stored under a key scoped to the project path so different projects have separate histories ## Out of Scope -- TBD +- Server-side persistence of chat history +- Multi-tab synchronization of chat state +- Compression or size management of stored messages +- Persisting streaming content or loading state diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index 8385932..d59f1ab 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -6,7 +6,7 @@ import { waitFor, } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "../api/client"; import type { Message } from "../types"; import { Chat } from "./Chat"; @@ -395,6 +395,103 @@ describe("Chat reconciliation banner", () => { }); }); +describe("Chat localStorage persistence (Story 145)", () => { + const PROJECT_PATH = "/tmp/project"; + const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`; + + beforeEach(() => { + capturedWsHandlers = null; + localStorage.clear(); + setupMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("AC1: restores persisted messages on mount", async () => { + const saved: Message[] = [ + { role: "user", content: "Previously saved question" }, + { role: "assistant", content: "Previously saved answer" }, + ]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); + + render(); + + expect( + await screen.findByText("Previously saved question"), + ).toBeInTheDocument(); + expect( + await screen.findByText("Previously saved answer"), + ).toBeInTheDocument(); + }); + + it("AC2: persists messages when WebSocket onUpdate fires", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const history: Message[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + ]; + + act(() => { + capturedWsHandlers?.onUpdate(history); + }); + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + expect(stored).toEqual(history); + }); + + it("AC3: clears localStorage when New Session is clicked", async () => { + const saved: Message[] = [ + { role: "user", content: "Old message" }, + { role: "assistant", content: "Old reply" }, + ]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); + + // Stub window.confirm to auto-approve the clear dialog + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + + render(); + + // Wait for the persisted message to appear + expect(await screen.findByText("Old message")).toBeInTheDocument(); + + // Click "New Session" button + const newSessionBtn = screen.getByText(/New Session/); + await act(async () => { + fireEvent.click(newSessionBtn); + }); + + // localStorage should be cleared + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + + // Messages should be gone from the UI + expect(screen.queryByText("Old message")).not.toBeInTheDocument(); + + confirmSpy.mockRestore(); + }); + + it("AC5: uses project-scoped storage key", async () => { + const otherKey = "storykit-chat-history:/other/project"; + localStorage.setItem( + otherKey, + JSON.stringify([{ role: "user", content: "Other project msg" }]), + ); + + render(); + + // Should NOT show the other project's messages + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + expect(screen.queryByText("Other project msg")).not.toBeInTheDocument(); + + // Other project's data should still be in storage + expect(localStorage.getItem(otherKey)).not.toBeNull(); + }); +}); + describe("Chat activity status indicator (Bug 140)", () => { beforeEach(() => { capturedWsHandlers = null; diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 4849293..3a385da 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -4,6 +4,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import type { PipelineState } from "../api/client"; import { api, ChatWebSocket } from "../api/client"; +import { useChatHistory } from "../hooks/useChatHistory"; import type { Message, ProviderConfig, ToolCall } from "../types"; import { AgentPanel } from "./AgentPanel"; import { ChatHeader } from "./ChatHeader"; @@ -55,7 +56,7 @@ interface ChatProps { } export function Chat({ projectPath, onCloseProject }: ChatProps) { - const [messages, setMessages] = useState([]); + const { messages, setMessages, clearMessages } = useChatHistory(projectPath); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [model, setModel] = useState("llama3.1"); @@ -458,7 +459,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { console.error("Failed to cancel chat:", e); } - setMessages([]); + clearMessages(); setStreamingContent(""); setLoading(false); setActivityStatus(null); diff --git a/frontend/src/hooks/useChatHistory.test.ts b/frontend/src/hooks/useChatHistory.test.ts new file mode 100644 index 0000000..7886531 --- /dev/null +++ b/frontend/src/hooks/useChatHistory.test.ts @@ -0,0 +1,146 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Message } from "../types"; +import { useChatHistory } from "./useChatHistory"; + +const PROJECT = "/tmp/test-project"; +const STORAGE_KEY = `storykit-chat-history:${PROJECT}`; + +const sampleMessages: Message[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, +]; + +describe("useChatHistory", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("AC1: restores messages from localStorage on mount", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages)); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + + expect(result.current.messages).toEqual(sampleMessages); + }); + + it("AC1: returns empty array when localStorage has no data", () => { + const { result } = renderHook(() => useChatHistory(PROJECT)); + + expect(result.current.messages).toEqual([]); + }); + + it("AC1: returns empty array when localStorage contains invalid JSON", () => { + localStorage.setItem(STORAGE_KEY, "not-json{{{"); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + + expect(result.current.messages).toEqual([]); + }); + + it("AC1: returns empty array when localStorage contains a non-array", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: "array" })); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + + expect(result.current.messages).toEqual([]); + }); + + it("AC2: saves messages to localStorage when setMessages is called with an array", () => { + const { result } = renderHook(() => useChatHistory(PROJECT)); + + act(() => { + result.current.setMessages(sampleMessages); + }); + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + expect(stored).toEqual(sampleMessages); + }); + + it("AC2: saves messages to localStorage when setMessages is called with updater function", () => { + const { result } = renderHook(() => useChatHistory(PROJECT)); + + act(() => { + result.current.setMessages(() => sampleMessages); + }); + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + expect(stored).toEqual(sampleMessages); + }); + + it("AC3: clearMessages removes messages from state and localStorage", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages)); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + + expect(result.current.messages).toEqual(sampleMessages); + + act(() => { + result.current.clearMessages(); + }); + + expect(result.current.messages).toEqual([]); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it("AC4: handles localStorage quota errors gracefully", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const setItemSpy = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new DOMException("QuotaExceededError"); + }); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + + // Should not throw + act(() => { + result.current.setMessages(sampleMessages); + }); + + // State should still update even though storage failed + expect(result.current.messages).toEqual(sampleMessages); + expect(warnSpy).toHaveBeenCalledWith( + "Failed to persist chat history to localStorage:", + expect.any(DOMException), + ); + + warnSpy.mockRestore(); + setItemSpy.mockRestore(); + }); + + it("AC5: scopes storage key to project path", () => { + const projectA = "/projects/a"; + const projectB = "/projects/b"; + const keyA = `storykit-chat-history:${projectA}`; + const keyB = `storykit-chat-history:${projectB}`; + + const messagesA: Message[] = [{ role: "user", content: "From project A" }]; + const messagesB: Message[] = [{ role: "user", content: "From project B" }]; + + localStorage.setItem(keyA, JSON.stringify(messagesA)); + localStorage.setItem(keyB, JSON.stringify(messagesB)); + + const { result: resultA } = renderHook(() => useChatHistory(projectA)); + const { result: resultB } = renderHook(() => useChatHistory(projectB)); + + expect(resultA.current.messages).toEqual(messagesA); + expect(resultB.current.messages).toEqual(messagesB); + }); + + it("AC2: removes localStorage key when messages are set to empty array", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages)); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + + act(() => { + result.current.setMessages([]); + }); + + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/useChatHistory.ts b/frontend/src/hooks/useChatHistory.ts new file mode 100644 index 0000000..87b9cc7 --- /dev/null +++ b/frontend/src/hooks/useChatHistory.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Message } from "../types"; + +const STORAGE_KEY_PREFIX = "storykit-chat-history:"; + +function storageKey(projectPath: string): string { + return `${STORAGE_KEY_PREFIX}${projectPath}`; +} + +function loadMessages(projectPath: string): Message[] { + try { + const raw = localStorage.getItem(storageKey(projectPath)); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed as Message[]; + } catch { + return []; + } +} + +function saveMessages(projectPath: string, messages: Message[]): void { + try { + if (messages.length === 0) { + localStorage.removeItem(storageKey(projectPath)); + } else { + localStorage.setItem(storageKey(projectPath), JSON.stringify(messages)); + } + } catch (e) { + console.warn("Failed to persist chat history to localStorage:", e); + } +} + +export function useChatHistory(projectPath: string) { + const [messages, setMessagesState] = useState(() => + loadMessages(projectPath), + ); + const projectPathRef = useRef(projectPath); + + // Keep the ref in sync so the effect closure always has the latest path. + projectPathRef.current = projectPath; + + // Persist whenever messages change. + useEffect(() => { + saveMessages(projectPathRef.current, messages); + }, [messages]); + + const setMessages = useCallback( + (update: Message[] | ((prev: Message[]) => Message[])) => { + setMessagesState(update); + }, + [], + ); + + const clearMessages = useCallback(() => { + setMessagesState([]); + // Eagerly remove from storage so clearSession doesn't depend on the + // effect firing before the component unmounts or re-renders. + try { + localStorage.removeItem(storageKey(projectPathRef.current)); + } catch { + // Ignore — quota or security errors. + } + }, []); + + return { messages, setMessages, clearMessages } as const; +}