story-kit: merge 264_bug_claude_code_session_id_not_persisted_across_browser_refresh

This commit is contained in:
Dave
2026-03-17 15:40:13 +00:00
parent a439f8fdcb
commit 0d46c86469
3 changed files with 142 additions and 2 deletions

View File

@@ -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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
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(
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
unmount();
// Step 2: Remount (simulates page reload)
capturedWsHandlers = null;
lastSendChatArgs = null;
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
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<string, unknown>).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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
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");
});
});

View File

@@ -171,7 +171,16 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
merge: [],
done: [],
});
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(() => {
try {
return (
localStorage.getItem(`storykit-claude-session-id:${projectPath}`) ??
null
);
} catch {
return null;
}
});
const [activityStatus, setActivityStatus] = useState<string | null>(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.
}
}
};