fix: preserve task prompts in replay and history

This commit is contained in:
09Catho 2026-05-09 20:49:10 +05:30
parent c0a257fe9c
commit 22955708c7
9 changed files with 255 additions and 55 deletions

View file

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

View file

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

View file

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

View file

@ -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<string, string>
) => {
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;
let question = getTaskQuestion(chatStore.tasks[taskId]);
// 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;
}
}
});
}
}
);
}
// 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);

View file

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

View file

@ -96,7 +96,8 @@ interface ProjectStore {
question: string,
projectId: string,
historyId?: string,
projectName?: string
projectName?: string,
taskQuestions?: Record<string, string>
) => Promise<string>;
// Project-level queued messages management
@ -635,7 +636,8 @@ const projectStore = create<ProjectStore>()((set, get) => ({
question: string,
projectId: string,
historyId?: string,
projectName?: string
projectName?: string,
taskQuestions?: Record<string, string>
) => {
const { projects, removeProject, createProject, createChatStore } = get();
@ -679,7 +681,8 @@ const projectStore = create<ProjectStore>()((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(

View file

@ -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: '/' });
});
});

View file

@ -43,5 +43,6 @@ vi.mock('../../src/store/authStore', () => ({
share_token: null,
workerListData: {},
})),
getWorkerList: vi.fn(() => []),
useWorkerList: vi.fn(() => []),
}));

View file

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