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 {
|
||||
|
||||
@@ -83,6 +83,7 @@ const mockDeleteSession = vi.fn();
|
||||
const mockAddMessage = vi.fn();
|
||||
const mockGetMessages = vi.fn();
|
||||
const mockGetMessage = vi.fn();
|
||||
const mockGetLastMessageForSessions = vi.fn().mockReturnValue(new Map());
|
||||
|
||||
// Mock AgentStore
|
||||
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -101,6 +102,7 @@ vi.mock("@fusion/core", () => {
|
||||
addMessage = mockAddMessage;
|
||||
getMessages = mockGetMessages;
|
||||
getMessage = mockGetMessage;
|
||||
getLastMessageForSessions = mockGetLastMessageForSessions;
|
||||
},
|
||||
AgentStore: class MockAgentStore {
|
||||
init = mockAgentStoreInit;
|
||||
@@ -202,6 +204,7 @@ const mockChatStoreInstance = {
|
||||
addMessage: mockAddMessage,
|
||||
getMessages: mockGetMessages,
|
||||
getMessage: mockGetMessage,
|
||||
getLastMessageForSessions: mockGetLastMessageForSessions,
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
@@ -252,6 +255,7 @@ describe("Chat API Routes", () => {
|
||||
mockAddMessage.mockReset();
|
||||
mockGetMessages.mockReset();
|
||||
mockGetMessage.mockReset();
|
||||
mockGetLastMessageForSessions.mockReset();
|
||||
mockSendMessage.mockReset();
|
||||
mockAgentStoreInit.mockResolvedValue(undefined);
|
||||
mockAgentStoreGetAgent.mockReset();
|
||||
@@ -260,6 +264,7 @@ describe("Chat API Routes", () => {
|
||||
// Setup default mocks
|
||||
mockListSessions.mockReturnValue([]);
|
||||
mockGetMessages.mockReturnValue([]);
|
||||
mockGetLastMessageForSessions.mockReturnValue(new Map());
|
||||
|
||||
// Default agent mock - agent with model config
|
||||
mockAgentStoreGetAgent.mockResolvedValue({
|
||||
@@ -351,6 +356,75 @@ describe("Chat API Routes", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect((response.body as any).sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("enriches sessions with lastMessagePreview and lastMessageAt", async () => {
|
||||
const sessionWithId = { ...sampleSession, id: "chat-abc123" };
|
||||
mockListSessions.mockReturnValue([sessionWithId]);
|
||||
|
||||
// Mock last message for the session
|
||||
const mockLastMessage = {
|
||||
id: "msg-001",
|
||||
sessionId: "chat-abc123",
|
||||
role: "assistant",
|
||||
content: "Hello, how can I help you?",
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
createdAt: "2026-04-15T10:00:00.000Z",
|
||||
};
|
||||
mockGetLastMessageForSessions.mockReturnValue(
|
||||
new Map([["chat-abc123", mockLastMessage]]),
|
||||
);
|
||||
|
||||
const response = await request(app, "GET", "/api/chat/sessions");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetLastMessageForSessions).toHaveBeenCalledWith(["chat-abc123"]);
|
||||
const enrichedSession = (response.body as any).sessions[0];
|
||||
expect(enrichedSession.lastMessagePreview).toBe("Hello, how can I help you?");
|
||||
expect(enrichedSession.lastMessageAt).toBe("2026-04-15T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("truncates long lastMessagePreview to 100 chars", async () => {
|
||||
const sessionWithId = { ...sampleSession, id: "chat-abc123" };
|
||||
mockListSessions.mockReturnValue([sessionWithId]);
|
||||
|
||||
// Mock a long message
|
||||
const longContent = "A".repeat(150);
|
||||
const mockLastMessage = {
|
||||
id: "msg-001",
|
||||
sessionId: "chat-abc123",
|
||||
role: "assistant",
|
||||
content: longContent,
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
createdAt: "2026-04-15T10:00:00.000Z",
|
||||
};
|
||||
mockGetLastMessageForSessions.mockReturnValue(
|
||||
new Map([["chat-abc123", mockLastMessage]]),
|
||||
);
|
||||
|
||||
const response = await request(app, "GET", "/api/chat/sessions");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const enrichedSession = (response.body as any).sessions[0];
|
||||
expect(enrichedSession.lastMessagePreview).toBe("A".repeat(100) + "…");
|
||||
expect(enrichedSession.lastMessagePreview).toHaveLength(101);
|
||||
});
|
||||
|
||||
it("does not add lastMessagePreview when session has no messages", async () => {
|
||||
const sessionWithId = { ...sampleSession, id: "chat-abc123" };
|
||||
mockListSessions.mockReturnValue([sessionWithId]);
|
||||
|
||||
// No messages for this session
|
||||
mockGetLastMessageForSessions.mockReturnValue(new Map());
|
||||
|
||||
const response = await request(app, "GET", "/api/chat/sessions");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const enrichedSession = (response.body as any).sessions[0];
|
||||
expect(enrichedSession.lastMessagePreview).toBeUndefined();
|
||||
expect(enrichedSession.lastMessageAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/chat/sessions", () => {
|
||||
|
||||
@@ -8074,6 +8074,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* GET /api/chat/sessions
|
||||
* List chat sessions with optional filtering.
|
||||
* Query params: projectId?, status?, agentId?
|
||||
*
|
||||
* Response is enriched with lastMessagePreview and lastMessageAt for each session.
|
||||
*/
|
||||
router.get("/chat/sessions", rateLimit(RATE_LIMITS.api), async (req, res) => {
|
||||
try {
|
||||
@@ -8094,6 +8096,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
...(agentId && { agentId }),
|
||||
});
|
||||
|
||||
// Enrich sessions with last message preview
|
||||
if (sessions.length > 0) {
|
||||
const sessionIds = sessions.map((s) => s.id);
|
||||
const lastMessages = chatStore.getLastMessageForSessions(sessionIds);
|
||||
|
||||
for (const session of sessions) {
|
||||
const lastMessage = lastMessages.get(session.id);
|
||||
if (lastMessage) {
|
||||
// Truncate content to 100 chars for preview
|
||||
const content = lastMessage.content || "";
|
||||
(session as any).lastMessagePreview =
|
||||
content.length > 100 ? content.slice(0, 100) + "…" : content;
|
||||
(session as any).lastMessageAt = lastMessage.createdAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ sessions });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
Reference in New Issue
Block a user