story-kit: merge 264_bug_claude_code_session_id_not_persisted_across_browser_refresh
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"story-kit": {
|
"story-kit": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://localhost:3001/mcp"
|
"url": "http://localhost:3010/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1275,3 +1275,114 @@ describe("Remove bubble styling from streaming messages (Story 163)", () => {
|
|||||||
expect(styleAttr).not.toContain("background: transparent");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -171,7 +171,16 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
merge: [],
|
merge: [],
|
||||||
done: [],
|
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 [activityStatus, setActivityStatus] = useState<string | null>(null);
|
||||||
const [permissionQueue, setPermissionQueue] = useState<
|
const [permissionQueue, setPermissionQueue] = useState<
|
||||||
{
|
{
|
||||||
@@ -247,6 +256,21 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
};
|
};
|
||||||
}, [messages, streamingContent, model]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
.getOllamaModels()
|
.getOllamaModels()
|
||||||
@@ -664,6 +688,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setActivityStatus(null);
|
setActivityStatus(null);
|
||||||
setClaudeSessionId(null);
|
setClaudeSessionId(null);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(`storykit-claude-session-id:${projectPath}`);
|
||||||
|
} catch {
|
||||||
|
// Ignore — quota or security errors.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user