Restore codebase deleted by bad auto-commit e4227cf

Commit e4227cf (a story creation auto-commit) erroneously deleted 175
files from master's tree, likely due to a race condition between
concurrent git operations. This commit re-adds all files from the
working directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dave
2026-03-22 19:07:07 +00:00
parent 89f776b978
commit f610ef6046
174 changed files with 84280 additions and 0 deletions

View File

@@ -0,0 +1,275 @@
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 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();
});
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();
});
// --- 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);
});
});

View File

@@ -0,0 +1,117 @@
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));
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed as Message[];
} catch {
return [];
}
}
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 {
const pruned = pruneMessages(messages, limit);
if (pruned.length === 0) {
localStorage.removeItem(storageKey(projectPath));
} else {
localStorage.setItem(storageKey(projectPath), JSON.stringify(pruned));
}
} 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 [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 or limit change.
useEffect(() => {
saveMessages(projectPathRef.current, messages, maxMessages);
}, [messages, maxMessages]);
const setMessages = useCallback(
(update: Message[] | ((prev: Message[]) => Message[])) => {
setMessagesState(update);
},
[],
);
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
// 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,
maxMessages,
setMaxMessages,
} as const;
}