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;
+}