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 {