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:
Fusion
2026-04-16 05:47:34 -07:00
committed by gsxdsm
parent 6e784c3700
commit 7e05e8962f
8 changed files with 381 additions and 2 deletions

View File

@@ -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,
);
});
});
});
});

View File

@@ -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 }) => {

View File

@@ -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", () => {

View File

@@ -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 {

View File

@@ -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", () => {

View File

@@ -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) {