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:
Fusion
2026-05-04 19:56:48 -07:00
committed by gsxdsm
parent 79bf77bd5e
commit cb3ab98e70
9 changed files with 261 additions and 10 deletions

View File

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

View File

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

View File

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