diff --git a/src/components/GroupedHistoryView/ProjectGroup.tsx b/src/components/GroupedHistoryView/ProjectGroup.tsx index 3980a6671..e3c70d2c5 100644 --- a/src/components/GroupedHistoryView/ProjectGroup.tsx +++ b/src/components/GroupedHistoryView/ProjectGroup.tsx @@ -134,6 +134,11 @@ export default function ProjectGroup({ const taskIdsList = project.tasks ?.map((t) => t.task_id) .filter(Boolean) || [project.project_id]; + const taskQuestions = Object.fromEntries( + (project.tasks || []) + .filter((task) => task.task_id && task.question) + .map((task) => [task.task_id, task.question]) + ); setIsLoadingProject(true); try { @@ -144,7 +149,8 @@ export default function ProjectGroup({ question, historyId, taskIdsList, - project.project_name + project.project_name, + taskQuestions ); } catch (error) { console.error('Failed to load project:', error); diff --git a/src/components/HistorySidebar/index.tsx b/src/components/HistorySidebar/index.tsx index 501df6759..2b1da53ec 100644 --- a/src/components/HistorySidebar/index.tsx +++ b/src/components/HistorySidebar/index.tsx @@ -146,6 +146,11 @@ export default function HistorySidebar() { const taskIdsList = project?.tasks.map( (task: HistoryTask) => task.task_id ) || [projectId]; + const taskQuestions = Object.fromEntries( + (project?.tasks || []) + .filter((task: HistoryTask) => task.task_id && task.question) + .map((task: HistoryTask) => [task.task_id, task.question]) + ); // If no tasks to replay, create an empty project if (!taskIdsList || taskIdsList.length === 0) { @@ -165,7 +170,8 @@ export default function HistorySidebar() { question, historyId, taskIdsList, - project?.project_name + project?.project_name, + taskQuestions ); }; diff --git a/src/components/SearchHistoryDialog.tsx b/src/components/SearchHistoryDialog.tsx index 1e33a03fc..21ef69c00 100644 --- a/src/components/SearchHistoryDialog.tsx +++ b/src/components/SearchHistoryDialog.tsx @@ -50,7 +50,10 @@ export function SearchHistoryDialog() { projectId: string, question: string, historyId: string, - project?: { tasks: { task_id: string }[]; project_name?: string } + project?: { + tasks: { task_id: string; question?: string }[]; + project_name?: string; + } ) => { const existingProject = projectStore.getProjectById(projectId); if (existingProject) { @@ -63,6 +66,11 @@ export function SearchHistoryDialog() { const taskIdsList = project?.tasks ?.map((t) => t.task_id) .filter(Boolean) || [projectId]; + const taskQuestions = Object.fromEntries( + (project?.tasks || []) + .filter((task) => task.task_id && task.question) + .map((task) => [task.task_id, task.question as string]) + ); await loadProjectFromHistory( projectStore, navigate, @@ -70,7 +78,8 @@ export function SearchHistoryDialog() { question, historyId, taskIdsList, - project?.project_name + project?.project_name, + taskQuestions ); } }; diff --git a/src/lib/replay.ts b/src/lib/replay.ts index 29e19cbfe..1b649c36c 100644 --- a/src/lib/replay.ts +++ b/src/lib/replay.ts @@ -16,6 +16,29 @@ import { ChatStore } from '@/store/chatStore'; import { ProjectStore } from '@/store/projectStore'; import { NavigateFunction } from 'react-router-dom'; +const getTaskQuestion = (task: ChatStore['tasks'][string] | undefined) => { + if (!task?.messages?.length) { + return ''; + } + + const firstUserMessage = task.messages.find((message) => { + return ( + message.role === 'user' && + typeof message.content === 'string' && + message.content.trim().length > 0 + ); + }); + + if (firstUserMessage?.content) { + return firstUserMessage.content.trim(); + } + + const firstMessage = task.messages[0]; + return typeof firstMessage?.content === 'string' + ? firstMessage.content.trim() + : ''; +}; + /** * Load project from history with final state (no animation). * Waits for loading to complete before navigating. @@ -28,6 +51,8 @@ import { NavigateFunction } from 'react-router-dom'; * @param historyId - The history ID * @param taskIdsList - Optional list of task IDs (defaults to [projectId]) * @param projectName - Optional project display name + * @param taskQuestions - Optional taskId-to-question map used to preserve + * each task's original prompt when loading multi-task projects from history */ export const loadProjectFromHistory = async ( projectStore: ProjectStore, @@ -36,7 +61,8 @@ export const loadProjectFromHistory = async ( question: string, historyId: string, taskIdsList?: string[], - projectName?: string + projectName?: string, + taskQuestions?: Record ) => { const taskIds = taskIdsList || [projectId]; await projectStore.loadProjectFromHistory( @@ -44,7 +70,8 @@ export const loadProjectFromHistory = async ( question, projectId, historyId, - projectName + projectName, + taskQuestions ); navigate({ pathname: '/' }); }; @@ -94,48 +121,11 @@ export const replayActiveTask = async ( return; } - // Extract the very first available question from all chat stores and tasks - let question = ''; - let earliestTimestamp = Infinity; - - // Get the project data to access all chat stores - const project = projectStore.projects[projectId]; - if (project && project.chatStores) { - Object.entries(project.chatStores).forEach( - ([chatStoreId, chatStoreData]: [string, any]) => { - const timestamp = project.chatStoreTimestamps[chatStoreId] || 0; - const chatState = chatStoreData.getState(); - - if (chatState.tasks) { - Object.values(chatState.tasks).forEach((task: any) => { - // Check messages for user content - if (task.messages && task.messages.length > 0) { - const userMessage = task.messages.find( - (msg: any) => msg.role === 'user' - ); - if ( - userMessage && - userMessage.content && - timestamp < earliestTimestamp - ) { - question = userMessage.content.trim(); - earliestTimestamp = timestamp; - } - } - }); - } - } - ); - } + let question = getTaskQuestion(chatStore.tasks[taskId]); - // Fallback to current task's first message if no question found - if ( - !question && - chatStore.tasks[taskId] && - chatStore.tasks[taskId].messages[0] - ) { - question = chatStore.tasks[taskId].messages[0].content; - console.log('[REPLAY] question fall back to ', question); + if (!question) { + console.log('[REPLAY] No user question found on active task, using fallback'); + question = chatStore.tasks[taskId]?.messages?.[0]?.content || ''; } const historyId = projectStore.getHistoryId(projectId); diff --git a/src/pages/Projects/Project.tsx b/src/pages/Projects/Project.tsx index f520308c9..42e6cfdac 100644 --- a/src/pages/Projects/Project.tsx +++ b/src/pages/Projects/Project.tsx @@ -193,7 +193,10 @@ export default function Project() { projectId: string, question: string, historyId: string, - project?: { tasks: { task_id: string }[]; project_name?: string } + project?: { + tasks: { task_id: string; question?: string }[]; + project_name?: string; + } ) => { const existingProject = projectStore.getProjectById(projectId); if (existingProject) { @@ -204,6 +207,11 @@ export default function Project() { const taskIdsList = project?.tasks ?.map((t) => t.task_id) .filter(Boolean) || [projectId]; + const taskQuestions = Object.fromEntries( + (project?.tasks || []) + .filter((task) => task.task_id && task.question) + .map((task) => [task.task_id, task.question as string]) + ); await loadProjectFromHistory( projectStore, navigate, @@ -211,7 +219,8 @@ export default function Project() { question, historyId, taskIdsList, - project?.project_name + project?.project_name, + taskQuestions ); } }; diff --git a/src/store/projectStore.ts b/src/store/projectStore.ts index a5b4ff249..38010d44f 100644 --- a/src/store/projectStore.ts +++ b/src/store/projectStore.ts @@ -96,7 +96,8 @@ interface ProjectStore { question: string, projectId: string, historyId?: string, - projectName?: string + projectName?: string, + taskQuestions?: Record ) => Promise; // Project-level queued messages management @@ -635,7 +636,8 @@ const projectStore = create()((set, get) => ({ question: string, projectId: string, historyId?: string, - projectName?: string + projectName?: string, + taskQuestions?: Record ) => { const { projects, removeProject, createProject, createChatStore } = get(); @@ -679,7 +681,8 @@ const projectStore = create()((set, get) => ({ const chatStore = project.chatStores[chatId]; if (chatStore) { try { - await chatStore.getState().replay(taskId, question, 0); + const taskQuestion = taskQuestions?.[taskId] || question; + await chatStore.getState().replay(taskId, taskQuestion, 0); console.log(`[ProjectStore] Loaded task ${taskId}`); } catch (error) { console.error( diff --git a/test/integration/chatStore/replayComplete.test.tsx b/test/integration/chatStore/replayComplete.test.tsx index 574e0c803..47e176ed8 100644 --- a/test/integration/chatStore/replayComplete.test.tsx +++ b/test/integration/chatStore/replayComplete.test.tsx @@ -26,7 +26,11 @@ import '../../mocks/sse.mock'; import '../../../src/store/chatStore'; import useChatStoreAdapter from '../../../src/hooks/useChatStoreAdapter'; -import { replayActiveTask, replayProject } from '../../../src/lib'; +import { + loadProjectFromHistory, + replayActiveTask, + replayProject, +} from '../../../src/lib'; import { useProjectStore } from '../../../src/store/projectStore'; import { createSSESequence, @@ -973,4 +977,171 @@ describe('Issue #619 - Duplicate Task Boxes after replay', () => { // Verify navigation was called for replay expect(mockNavigate).toHaveBeenCalledWith('/'); }); + + it('should replay the active task question instead of the earliest project question', async () => { + const { result, rerender } = renderHook(() => useChatStoreAdapter()); + + const firstQuestion = 'First task question'; + const secondQuestion = 'Second task question'; + const projectId = result.current.projectStore.activeProjectId as string; + + const replaySequence = createSSESequence([ + { + event: { + step: 'confirmed', + data: { question: 'Server replay fallback' }, + }, + delay: 50, + }, + { + event: { + step: 'end', + data: '--- Replay complete ---', + }, + delay: 100, + }, + ]); + + mockFetchEventSource.mockImplementation( + async (url: string, options: any) => { + if (!options.onmessage) return; + + if (url.includes('/api/chat/steps/playback/')) { + await replaySequence(options.onmessage); + } + } + ); + + await act(async () => { + const { chatStore, projectStore } = result.current; + + chatStore.addMessages(chatStore.activeTaskId, { + id: generateUniqueId(), + role: 'user', + content: firstQuestion, + }); + chatStore.setHasMessages(chatStore.activeTaskId, true); + + const secondChatResult = projectStore.appendInitChatStore( + projectId, + undefined, + 'Second' + ); + expect(secondChatResult).not.toBeNull(); + + const { taskId: secondTaskId, chatStore: secondChatStore } = + secondChatResult!; + + secondChatStore.getState().addMessages(secondTaskId, { + id: generateUniqueId(), + role: 'user', + content: secondQuestion, + }); + secondChatStore.getState().setHasMessages(secondTaskId, true); + projectStore.setActiveChatStore(projectId, Object.keys( + projectStore.getProjectById(projectId)!.chatStores + ).at(-1)!); + + rerender(); + }); + + const chatStores = result.current.projectStore.getAllChatStores(projectId); + const secondChatStore = chatStores[chatStores.length - 1].chatStore; + + await act(async () => { + await replayActiveTask( + secondChatStore!.getState() as any, + result.current.projectStore, + mockNavigate + ); + rerender(); + }); + + await waitFor(() => { + rerender(); + const projects = result.current.projectStore.getAllProjects(); + const replayProject = projects.find((project: any) => + project.name.includes(`Replay Project ${secondQuestion}`) + ); + + expect(replayProject).toBeDefined(); + + const replayChatStores = result.current.projectStore.getAllChatStores( + replayProject!.id + ); + const replayChatStore = replayChatStores[1].chatStore; + const replayTaskId = replayChatStore.getState().activeTaskId; + const replayTask = replayChatStore.getState().tasks[replayTaskId]; + + expect(replayTask.messages[0].content).toBe(secondQuestion); + expect(replayTask.messages[0].content).not.toBe(firstQuestion); + }); + }); + + it('should preserve per-task questions when loading multi-task history', async () => { + const { result, rerender } = renderHook(() => useChatStoreAdapter()); + + const firstQuestion = 'History task one'; + const secondQuestion = 'History task two'; + + mockFetchEventSource.mockImplementation( + async (_url: string, options: any) => { + if (!options.onmessage) return; + + const sequence = createSSESequence([ + { + event: { + step: 'confirmed', + data: { question: 'History confirmed' }, + }, + delay: 10, + }, + { + event: { + step: 'end', + data: '--- History load complete ---', + }, + delay: 20, + }, + ]); + + await sequence(options.onmessage); + } + ); + + await act(async () => { + await loadProjectFromHistory( + result.current.projectStore, + mockNavigate, + 'history-project', + firstQuestion, + 'history-id', + ['history-task-1', 'history-task-2'], + 'Loaded Project', + { + 'history-task-1': firstQuestion, + 'history-task-2': secondQuestion, + } + ); + rerender(); + }); + + await waitFor(() => { + rerender(); + const chatStores = result.current.projectStore.getAllChatStores( + 'history-project' + ); + + expect(chatStores).toHaveLength(3); + + const prompts = chatStores.slice(1).map(({ chatStore }) => { + const activeTaskId = chatStore.getState().activeTaskId; + return chatStore.getState().tasks[activeTaskId].messages[0].content; + }); + + expect(prompts).toEqual([firstQuestion, secondQuestion]); + }); + + expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/' }); + }); }); diff --git a/test/mocks/authStore.mock.ts b/test/mocks/authStore.mock.ts index 4c9cc4ab4..3c5da08f1 100644 --- a/test/mocks/authStore.mock.ts +++ b/test/mocks/authStore.mock.ts @@ -43,5 +43,6 @@ vi.mock('../../src/store/authStore', () => ({ share_token: null, workerListData: {}, })), + getWorkerList: vi.fn(() => []), useWorkerList: vi.fn(() => []), })); diff --git a/test/mocks/proxy.mock.ts b/test/mocks/proxy.mock.ts index c1c3791ca..46dccaa40 100644 --- a/test/mocks/proxy.mock.ts +++ b/test/mocks/proxy.mock.ts @@ -24,6 +24,7 @@ const mockImplementation = { }), fetchPut: vi.fn(() => Promise.resolve({ success: true })), getBaseURL: vi.fn(() => Promise.resolve('http://localhost:8000')), + waitForBackendReady: vi.fn(() => Promise.resolve(true)), proxyFetchPost: vi.fn((url, _data) => { // Mock history creation if (url.includes('/api/chat/history')) { @@ -61,7 +62,10 @@ const mockImplementation = { return Promise.resolve([]); } // Mock snapshots - return empty array to prevent the error - if (url.includes('/api/chat/snapshots')) { + if ( + url.includes('/api/chat/snapshots') || + url.includes('/api/v1/chat/snapshots') + ) { return Promise.resolve([]); } return Promise.resolve({}); @@ -75,4 +79,5 @@ vi.mock('../../src/api/http', () => mockImplementation); vi.mock('@/api/http', () => mockImplementation); // Export the mocked functions for use in tests -export const { proxyFetchGet, proxyFetchPost, fetchPost } = mockImplementation; +export const { proxyFetchGet, proxyFetchPost, fetchPost, waitForBackendReady } = + mockImplementation;