story-kit: accept 145_story_persist_chat_history_to_localstorage_across_rebuilds
This commit is contained in:
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