story-kit: create 174_story_matrix_chatbot_interface_for_story_kit
This commit is contained in:
@@ -5,12 +5,20 @@ import { useChatHistory } from "./useChatHistory";
|
||||
|
||||
const PROJECT = "/tmp/test-project";
|
||||
const STORAGE_KEY = `storykit-chat-history:${PROJECT}`;
|
||||
const LIMIT_KEY = `storykit-chat-history-limit:${PROJECT}`;
|
||||
|
||||
const sampleMessages: Message[] = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there!" },
|
||||
];
|
||||
|
||||
function makeMessages(count: number): Message[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
role: "user" as const,
|
||||
content: `Message ${i + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("useChatHistory", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -143,4 +151,125 @@ describe("useChatHistory", () => {
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
// --- Story 179: Chat history pruning tests ---
|
||||
|
||||
it("S179: default limit of 200 is applied when saving to localStorage", () => {
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
expect(result.current.maxMessages).toBe(200);
|
||||
});
|
||||
|
||||
it("S179: messages are pruned from the front when exceeding the limit", () => {
|
||||
// Set a small limit to make testing practical
|
||||
localStorage.setItem(LIMIT_KEY, "3");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
const fiveMessages = makeMessages(5);
|
||||
|
||||
act(() => {
|
||||
result.current.setMessages(fiveMessages);
|
||||
});
|
||||
|
||||
// localStorage should contain only the last 3 messages
|
||||
const stored: Message[] = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEY) ?? "[]",
|
||||
);
|
||||
expect(stored).toEqual(fiveMessages.slice(-3));
|
||||
expect(stored).toHaveLength(3);
|
||||
expect(stored[0].content).toBe("Message 3");
|
||||
});
|
||||
|
||||
it("S179: messages under the limit are not pruned", () => {
|
||||
localStorage.setItem(LIMIT_KEY, "10");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
const threeMessages = makeMessages(3);
|
||||
|
||||
act(() => {
|
||||
result.current.setMessages(threeMessages);
|
||||
});
|
||||
|
||||
const stored: Message[] = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEY) ?? "[]",
|
||||
);
|
||||
expect(stored).toEqual(threeMessages);
|
||||
expect(stored).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("S179: limit is configurable via localStorage key", () => {
|
||||
localStorage.setItem(LIMIT_KEY, "5");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
expect(result.current.maxMessages).toBe(5);
|
||||
});
|
||||
|
||||
it("S179: setMaxMessages updates the limit and persists it", () => {
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
act(() => {
|
||||
result.current.setMaxMessages(50);
|
||||
});
|
||||
|
||||
expect(result.current.maxMessages).toBe(50);
|
||||
expect(localStorage.getItem(LIMIT_KEY)).toBe("50");
|
||||
});
|
||||
|
||||
it("S179: a limit of 0 means unlimited (no pruning)", () => {
|
||||
localStorage.setItem(LIMIT_KEY, "0");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
const manyMessages = makeMessages(500);
|
||||
|
||||
act(() => {
|
||||
result.current.setMessages(manyMessages);
|
||||
});
|
||||
|
||||
const stored: Message[] = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEY) ?? "[]",
|
||||
);
|
||||
expect(stored).toHaveLength(500);
|
||||
expect(stored).toEqual(manyMessages);
|
||||
});
|
||||
|
||||
it("S179: changing the limit re-prunes messages on next save", () => {
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
const tenMessages = makeMessages(10);
|
||||
|
||||
act(() => {
|
||||
result.current.setMessages(tenMessages);
|
||||
});
|
||||
|
||||
// All 10 saved (default limit 200 > 10)
|
||||
let stored: Message[] = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEY) ?? "[]",
|
||||
);
|
||||
expect(stored).toHaveLength(10);
|
||||
|
||||
// Now lower the limit — the effect re-runs and prunes
|
||||
act(() => {
|
||||
result.current.setMaxMessages(3);
|
||||
});
|
||||
|
||||
stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
|
||||
expect(stored).toHaveLength(3);
|
||||
expect(stored[0].content).toBe("Message 8");
|
||||
});
|
||||
|
||||
it("S179: invalid limit in localStorage falls back to default", () => {
|
||||
localStorage.setItem(LIMIT_KEY, "not-a-number");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
expect(result.current.maxMessages).toBe(200);
|
||||
});
|
||||
|
||||
it("S179: negative limit in localStorage falls back to default", () => {
|
||||
localStorage.setItem(LIMIT_KEY, "-5");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
expect(result.current.maxMessages).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,37 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Message } from "../types";
|
||||
|
||||
const STORAGE_KEY_PREFIX = "storykit-chat-history:";
|
||||
const LIMIT_KEY_PREFIX = "storykit-chat-history-limit:";
|
||||
const DEFAULT_LIMIT = 200;
|
||||
|
||||
function storageKey(projectPath: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${projectPath}`;
|
||||
}
|
||||
|
||||
function limitKey(projectPath: string): string {
|
||||
return `${LIMIT_KEY_PREFIX}${projectPath}`;
|
||||
}
|
||||
|
||||
function loadLimit(projectPath: string): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(limitKey(projectPath));
|
||||
if (raw === null) return DEFAULT_LIMIT;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_LIMIT;
|
||||
return Math.floor(parsed);
|
||||
} catch {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
}
|
||||
|
||||
function saveLimit(projectPath: string, limit: number): void {
|
||||
try {
|
||||
localStorage.setItem(limitKey(projectPath), String(limit));
|
||||
} catch {
|
||||
// Ignore — quota or security errors.
|
||||
}
|
||||
}
|
||||
|
||||
function loadMessages(projectPath: string): Message[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(projectPath));
|
||||
@@ -19,12 +45,22 @@ function loadMessages(projectPath: string): Message[] {
|
||||
}
|
||||
}
|
||||
|
||||
function saveMessages(projectPath: string, messages: Message[]): void {
|
||||
function pruneMessages(messages: Message[], limit: number): Message[] {
|
||||
if (limit === 0 || messages.length <= limit) return messages;
|
||||
return messages.slice(-limit);
|
||||
}
|
||||
|
||||
function saveMessages(
|
||||
projectPath: string,
|
||||
messages: Message[],
|
||||
limit: number,
|
||||
): void {
|
||||
try {
|
||||
if (messages.length === 0) {
|
||||
const pruned = pruneMessages(messages, limit);
|
||||
if (pruned.length === 0) {
|
||||
localStorage.removeItem(storageKey(projectPath));
|
||||
} else {
|
||||
localStorage.setItem(storageKey(projectPath), JSON.stringify(messages));
|
||||
localStorage.setItem(storageKey(projectPath), JSON.stringify(pruned));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to persist chat history to localStorage:", e);
|
||||
@@ -35,15 +71,18 @@ export function useChatHistory(projectPath: string) {
|
||||
const [messages, setMessagesState] = useState<Message[]>(() =>
|
||||
loadMessages(projectPath),
|
||||
);
|
||||
const [maxMessages, setMaxMessagesState] = useState<number>(() =>
|
||||
loadLimit(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.
|
||||
// Persist whenever messages or limit change.
|
||||
useEffect(() => {
|
||||
saveMessages(projectPathRef.current, messages);
|
||||
}, [messages]);
|
||||
saveMessages(projectPathRef.current, messages, maxMessages);
|
||||
}, [messages, maxMessages]);
|
||||
|
||||
const setMessages = useCallback(
|
||||
(update: Message[] | ((prev: Message[]) => Message[])) => {
|
||||
@@ -52,6 +91,11 @@ export function useChatHistory(projectPath: string) {
|
||||
[],
|
||||
);
|
||||
|
||||
const setMaxMessages = useCallback((limit: number) => {
|
||||
setMaxMessagesState(limit);
|
||||
saveLimit(projectPathRef.current, limit);
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessagesState([]);
|
||||
// Eagerly remove from storage so clearSession doesn't depend on the
|
||||
@@ -63,5 +107,11 @@ export function useChatHistory(projectPath: string) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { messages, setMessages, clearMessages } as const;
|
||||
return {
|
||||
messages,
|
||||
setMessages,
|
||||
clearMessages,
|
||||
maxMessages,
|
||||
setMaxMessages,
|
||||
} as const;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user