fix(FN-1817): enrich session list with lastMessagePreview and restore active session on remount
- Add lastMessagePreview field to ChatSession to show message previews in session list - Restore active session state on component remount via stored lastSessionId - Add chat routes for session management (create, list, read, update/delete) - Add tests for useChat hook, chat store, and chat routes - Remove unused serveCommand assignment in serve.ts command
This commit is contained in:
@@ -23,6 +23,19 @@ vi.mock("../../api", () => ({
|
||||
]),
|
||||
}));
|
||||
|
||||
// Mock the projectStorage module
|
||||
vi.mock("../../utils/projectStorage", () => ({
|
||||
getScopedItem: vi.fn(),
|
||||
setScopedItem: vi.fn(),
|
||||
removeScopedItem: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as projectStorageModule from "../../utils/projectStorage";
|
||||
|
||||
const mockGetScopedItem = vi.mocked(projectStorageModule.getScopedItem);
|
||||
const mockSetScopedItem = vi.mocked(projectStorageModule.setScopedItem);
|
||||
const mockRemoveScopedItem = vi.mocked(projectStorageModule.removeScopedItem);
|
||||
|
||||
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
||||
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
||||
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
||||
@@ -542,4 +555,146 @@ describe("useChat", () => {
|
||||
expect(result.current.sessions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("active session persistence", () => {
|
||||
beforeEach(() => {
|
||||
// Default: no saved session
|
||||
mockGetScopedItem.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("restores active session from localStorage when it matches a loaded session", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
||||
|
||||
// Simulate a saved session in localStorage
|
||||
mockGetScopedItem.mockReturnValue("session-001");
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe("session-001");
|
||||
});
|
||||
|
||||
// Verify messages were loaded
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50 }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-select when saved session does not exist in loaded sessions", async () => {
|
||||
mockFetchChatSessions.mockResolvedValueOnce({
|
||||
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
||||
});
|
||||
|
||||
// Simulate a saved session that no longer exists
|
||||
mockGetScopedItem.mockReturnValue("non-existent-session");
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Should not have an active session since the saved one doesn't exist
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession).toBeNull();
|
||||
});
|
||||
|
||||
// Messages should not be loaded since no session is selected
|
||||
expect(mockFetchChatMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists session ID to localStorage when selecting a session", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetScopedItem).toHaveBeenCalledWith(
|
||||
"kb-chat-active-session",
|
||||
"session-001",
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("removes session ID from localStorage when deselecting", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
// First select a session
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe("session-001");
|
||||
});
|
||||
|
||||
// Reset the mock to track the removal call
|
||||
mockSetScopedItem.mockClear();
|
||||
|
||||
// Now deselect
|
||||
act(() => {
|
||||
result.current.selectSession("");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveScopedItem).toHaveBeenCalledWith(
|
||||
"kb-chat-active-session",
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses undefined projectId when not provided", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetScopedItem).toHaveBeenCalledWith(
|
||||
"kb-chat-active-session",
|
||||
"session-001",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
fetchAgents,
|
||||
type ChatSessionListResponse,
|
||||
} from "../api";
|
||||
import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage";
|
||||
import type { Agent } from "@fusion/core";
|
||||
|
||||
const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session";
|
||||
|
||||
export interface ChatSessionInfo {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
@@ -133,6 +136,24 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
refreshSessions();
|
||||
}, [refreshSessions]);
|
||||
|
||||
// Restore active session from localStorage after initial load
|
||||
// Uses a ref to avoid circular dependency with selectSession
|
||||
const selectSessionRef = useRef<(id: string) => void>(() => {
|
||||
/* noop - will be replaced after selectSession is defined */
|
||||
});
|
||||
useEffect(() => {
|
||||
if (sessionsLoading) return; // Wait for sessions to load
|
||||
|
||||
const savedSessionId = getScopedItem(ACTIVE_SESSION_STORAGE_KEY, projectId);
|
||||
if (savedSessionId) {
|
||||
// Check if the saved session exists in the loaded sessions
|
||||
const session = sessions.find((s) => s.id === savedSessionId);
|
||||
if (session) {
|
||||
selectSessionRef.current(savedSessionId);
|
||||
}
|
||||
}
|
||||
}, [sessionsLoading, sessions, projectId]);
|
||||
|
||||
// Load messages when active session changes
|
||||
const loadMessages = useCallback(
|
||||
async (sessionId: string, opts?: { offset?: number }) => {
|
||||
@@ -180,10 +201,21 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
} else {
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
// Persist active session to localStorage
|
||||
if (id) {
|
||||
setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId);
|
||||
} else {
|
||||
removeScopedItem(ACTIVE_SESSION_STORAGE_KEY, projectId);
|
||||
}
|
||||
},
|
||||
[sessions, loadMessages],
|
||||
[sessions, loadMessages, projectId],
|
||||
);
|
||||
|
||||
// Update the ref to point to the actual selectSession function
|
||||
// This is needed to avoid circular dependencies in useEffect
|
||||
selectSessionRef.current = selectSession;
|
||||
|
||||
// Create a new session
|
||||
const createSession = useCallback(
|
||||
async (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }) => {
|
||||
|
||||
@@ -88,9 +88,10 @@ describe("projectStorage", () => {
|
||||
"kb-subtask-last-description",
|
||||
"kb-mission-last-goal",
|
||||
"kb-usage-view-mode",
|
||||
"kb-chat-active-session",
|
||||
]),
|
||||
);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(14);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("has no overlap between global and project-scoped keys", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
||||
"kb-subtask-last-description",
|
||||
"kb-mission-last-goal",
|
||||
"kb-usage-view-mode",
|
||||
"kb-chat-active-session",
|
||||
];
|
||||
|
||||
export function scopedKey(baseKey: string, projectId?: string): string {
|
||||
|
||||
Reference in New Issue
Block a user