From 0d46c864694842fed23f42ea29cfb25f2f8608fa Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 17 Mar 2026 15:40:13 +0000 Subject: [PATCH] story-kit: merge 264_bug_claude_code_session_id_not_persisted_across_browser_refresh --- .mcp.json | 2 +- frontend/src/components/Chat.test.tsx | 111 ++++++++++++++++++++++++++ frontend/src/components/Chat.tsx | 31 ++++++- 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/.mcp.json b/.mcp.json index a36a88a..22f1f26 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "story-kit": { "type": "http", - "url": "http://localhost:3001/mcp" + "url": "http://localhost:3010/mcp" } } } diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index 29bbf5f..39208c0 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -1275,3 +1275,114 @@ describe("Remove bubble styling from streaming messages (Story 163)", () => { expect(styleAttr).not.toContain("background: transparent"); }); }); + +describe("Bug 264: Claude Code session ID persisted across browser refresh", () => { + const PROJECT_PATH = "/tmp/project"; + const SESSION_KEY = `storykit-claude-session-id:${PROJECT_PATH}`; + const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`; + + beforeEach(() => { + capturedWsHandlers = null; + lastSendChatArgs = null; + localStorage.clear(); + setupMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("AC1: session_id is persisted to localStorage when onSessionId fires", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + act(() => { + capturedWsHandlers?.onSessionId("test-session-abc"); + }); + + await waitFor(() => { + expect(localStorage.getItem(SESSION_KEY)).toBe("test-session-abc"); + }); + }); + + it("AC2: after remount, next sendChat includes session_id from localStorage", async () => { + // Step 1: Render, receive a session ID, then unmount (simulate refresh) + localStorage.setItem(SESSION_KEY, "persisted-session-xyz"); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify([ + { role: "user", content: "Prior message" }, + { role: "assistant", content: "Prior reply" }, + ]), + ); + + const { unmount } = render( + , + ); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + unmount(); + + // Step 2: Remount (simulates page reload) + capturedWsHandlers = null; + lastSendChatArgs = null; + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + // Prior messages should be visible + expect(await screen.findByText("Prior message")).toBeInTheDocument(); + + // Step 3: Send a new message — config should include session_id + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "Continue" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + expect(lastSendChatArgs).not.toBeNull(); + expect( + (lastSendChatArgs?.config as Record).session_id, + ).toBe("persisted-session-xyz"); + }); + + it("AC3: clearing the session also clears the persisted session_id", async () => { + localStorage.setItem(SESSION_KEY, "session-to-clear"); + + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const newSessionBtn = screen.getByText(/New Session/); + await act(async () => { + fireEvent.click(newSessionBtn); + }); + + expect(localStorage.getItem(SESSION_KEY)).toBeNull(); + + confirmSpy.mockRestore(); + }); + + it("AC1: storage key is scoped to project path", async () => { + const otherPath = "/other/project"; + const otherKey = `storykit-claude-session-id:${otherPath}`; + localStorage.setItem(otherKey, "other-session"); + + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + act(() => { + capturedWsHandlers?.onSessionId("my-session"); + }); + + await waitFor(() => { + expect(localStorage.getItem(SESSION_KEY)).toBe("my-session"); + }); + + // Other project's session should be untouched + expect(localStorage.getItem(otherKey)).toBe("other-session"); + }); +}); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 8b9e605..b656594 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -171,7 +171,16 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { merge: [], done: [], }); - const [claudeSessionId, setClaudeSessionId] = useState(null); + const [claudeSessionId, setClaudeSessionId] = useState(() => { + try { + return ( + localStorage.getItem(`storykit-claude-session-id:${projectPath}`) ?? + null + ); + } catch { + return null; + } + }); const [activityStatus, setActivityStatus] = useState(null); const [permissionQueue, setPermissionQueue] = useState< { @@ -247,6 +256,21 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }; }, [messages, streamingContent, model]); + useEffect(() => { + try { + if (claudeSessionId !== null) { + localStorage.setItem( + `storykit-claude-session-id:${projectPath}`, + claudeSessionId, + ); + } else { + localStorage.removeItem(`storykit-claude-session-id:${projectPath}`); + } + } catch { + // Ignore — quota or security errors. + } + }, [claudeSessionId, projectPath]); + useEffect(() => { api .getOllamaModels() @@ -664,6 +688,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { setLoading(false); setActivityStatus(null); setClaudeSessionId(null); + try { + localStorage.removeItem(`storykit-claude-session-id:${projectPath}`); + } catch { + // Ignore — quota or security errors. + } } };