story-kit: accept 145_story_persist_chat_history_to_localstorage_across_rebuilds
This commit is contained in:
@@ -6,12 +6,19 @@ name: "Persist chat history to localStorage across rebuilds"
|
|||||||
|
|
||||||
## User Story
|
## 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
|
## 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
|
## 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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/react";
|
} 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 { api } from "../api/client";
|
||||||
import type { Message } from "../types";
|
import type { Message } from "../types";
|
||||||
import { Chat } from "./Chat";
|
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
// 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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
// 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)", () => {
|
describe("Chat activity status indicator (Bug 140)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
capturedWsHandlers = null;
|
capturedWsHandlers = null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|||||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
import type { PipelineState } from "../api/client";
|
import type { PipelineState } from "../api/client";
|
||||||
import { api, ChatWebSocket } from "../api/client";
|
import { api, ChatWebSocket } from "../api/client";
|
||||||
|
import { useChatHistory } from "../hooks/useChatHistory";
|
||||||
import type { Message, ProviderConfig, ToolCall } from "../types";
|
import type { Message, ProviderConfig, ToolCall } from "../types";
|
||||||
import { AgentPanel } from "./AgentPanel";
|
import { AgentPanel } from "./AgentPanel";
|
||||||
import { ChatHeader } from "./ChatHeader";
|
import { ChatHeader } from "./ChatHeader";
|
||||||
@@ -55,7 +56,7 @@ interface ChatProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const { messages, setMessages, clearMessages } = useChatHistory(projectPath);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [model, setModel] = useState("llama3.1");
|
const [model, setModel] = useState("llama3.1");
|
||||||
@@ -458,7 +459,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
console.error("Failed to cancel chat:", e);
|
console.error("Failed to cancel chat:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages([]);
|
clearMessages();
|
||||||
setStreamingContent("");
|
setStreamingContent("");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setActivityStatus(null);
|
setActivityStatus(null);
|
||||||
|
|||||||
146
frontend/src/hooks/useChatHistory.test.ts
Normal file
146
frontend/src/hooks/useChatHistory.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/hooks/useChatHistory.ts
Normal file
67
frontend/src/hooks/useChatHistory.ts
Normal file
@@ -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<Message[]>(() =>
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user