feat(FN-3447): add planning session rewind capability with modal back actio
This merge delivers four major features: a planning session rewind system (FN-3447, steps 1–4) with a new backend route for rolling back sessions, modal back-action wiring, and updated typing; workspace verification gates (FN-3385) for agent prompt editing; an agents view org chart spacing rework (F Fusion-Task-Id: FN-3447
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
startPlanningStreaming,
|
||||
createPlanningDraft,
|
||||
respondToPlanning,
|
||||
rewindPlanningSession,
|
||||
retryPlanningSession,
|
||||
createTaskFromPlanning,
|
||||
connectPlanningStream,
|
||||
@@ -1499,16 +1500,45 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}
|
||||
}, [broadcastCompleted, handleClose, view, onTasksCreated, projectId]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (view.type === "question" && responseHistory.length > 0) {
|
||||
// Remove last response and go back
|
||||
const previousResponses = responseHistory.slice(0, -1);
|
||||
setResponseHistory(previousResponses);
|
||||
// Note: We don't actually have a way to go back in the backend,
|
||||
// so we just reset to the question from the initial session
|
||||
const handleBack = useCallback(async () => {
|
||||
if (view.type !== "question" || responseHistory.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = view.session.sessionId;
|
||||
setError(null);
|
||||
setView({ type: "loading" });
|
||||
|
||||
try {
|
||||
const rewound = await rewindPlanningSession(sessionId, projectId, sessionTabId);
|
||||
setResponseHistory(rewound.history.map((entry) => {
|
||||
if (entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)) {
|
||||
return entry.response as QuestionResponse;
|
||||
}
|
||||
return { [entry.question.id]: entry.response };
|
||||
}));
|
||||
setConversationHistory(rewound.history.map((entry) => ({
|
||||
question: entry.question,
|
||||
response:
|
||||
entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)
|
||||
? (entry.response as Record<string, unknown>)
|
||||
: { [entry.question.id]: entry.response },
|
||||
thinkingOutput: entry.thinkingOutput,
|
||||
})));
|
||||
setStreamingOutput("");
|
||||
setView({
|
||||
type: "question",
|
||||
session: {
|
||||
...view.session,
|
||||
currentQuestion: rewound.currentQuestion,
|
||||
summary: null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Failed to go back to the previous question");
|
||||
setView({ type: "question", session: view.session });
|
||||
}
|
||||
}, [view, responseHistory]);
|
||||
}, [projectId, responseHistory.length, sessionTabId, view]);
|
||||
|
||||
const getProgress = () => {
|
||||
if (view.type === "question") {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
mockCreatePlanningDraft,
|
||||
mockConnectPlanningStream,
|
||||
mockRespondToPlanning,
|
||||
mockRewindPlanningSession,
|
||||
mockRetryPlanningSession,
|
||||
mockCancelPlanning,
|
||||
mockStopPlanningGeneration,
|
||||
@@ -55,8 +56,8 @@ vi.mock("../../api", () => ({
|
||||
createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args),
|
||||
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
||||
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||
rewindPlanningSession: (...args: any[]) => mockRewindPlanningSession(...args),
|
||||
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args), cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
@@ -121,6 +122,7 @@ describe("PlanningModeModal", () => {
|
||||
// the sidebar render rule (preview while title === placeholder) behaves
|
||||
// realistically in tests.
|
||||
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
|
||||
mockRewindPlanningSession.mockResolvedValue({ currentQuestion: mockQuestion, history: [] });
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
@@ -583,6 +585,66 @@ describe("PlanningModeModal", () => {
|
||||
expect(screen.queryByPlaceholderText("Add any extra context or direction...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rewinds to the previous question when Back is clicked", async () => {
|
||||
let streamHandlers: any;
|
||||
const secondQuestion: PlanningQuestion = {
|
||||
id: "q-requirements",
|
||||
type: "text",
|
||||
question: "What are the key requirements?",
|
||||
};
|
||||
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
setTimeout(() => {
|
||||
handlers.onQuestion?.(mockQuestion);
|
||||
}, 10);
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
mockRespondToPlanning.mockImplementationOnce(async () => {
|
||||
setTimeout(() => {
|
||||
streamHandlers?.onQuestion?.(secondQuestion);
|
||||
}, 10);
|
||||
return { type: "question", data: secondQuestion };
|
||||
});
|
||||
|
||||
mockRewindPlanningSession.mockResolvedValueOnce({
|
||||
currentQuestion: mockQuestion,
|
||||
history: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await screen.findByText("What is the scope?");
|
||||
fireEvent.click(screen.getByText("Medium"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await screen.findByText("What are the key requirements?");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRewindPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
|
||||
});
|
||||
expect(await screen.findByText("What is the scope?")).toBeInTheDocument();
|
||||
expect(screen.queryByText("What are the key requirements?")).toBeNull();
|
||||
});
|
||||
|
||||
it("includes _comment in response when comment is filled", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
|
||||
@@ -6,6 +6,7 @@ export const mockStartPlanningStreaming = vi.fn();
|
||||
export const mockCreatePlanningDraft = vi.fn();
|
||||
export const mockConnectPlanningStream = vi.fn();
|
||||
export const mockRespondToPlanning = vi.fn();
|
||||
export const mockRewindPlanningSession = vi.fn();
|
||||
export const mockRetryPlanningSession = vi.fn();
|
||||
export const mockCancelPlanning = vi.fn();
|
||||
export const mockStopPlanningGeneration = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user