FN-5892: dedupe planning sessions and persist history deletion

Prevent duplicate planning-history rows and keep deletions visible only after the backend confirms persistence.

- dedupe Planning Mode history by session id across initial loads, live updates, archive toggles, and draft creation
- make deleteAiSession surface backend/content-type errors instead of silently succeeding on failed deletes
- keep failed history deletions visible, refresh the list, and show an error toast when persistence fails
- add dashboard tests covering session deduplication, delete API failures, and persistence-aware Planning Mode deletion behavior
- document the history dedupe/delete semantics and add a patch changeset for @runfusion/fusion

Files changed:
 .changeset/fn-5892-planning-history-fix.md         |   5 +
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/api/__tests__/deleteAiSession.test.ts      |  54 +++++
 packages/dashboard/app/api/legacy.ts               |  41 +++-
 packages/dashboard/app/components/PlanningModeModal.tsx |  70 ++++---
 packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx  |  11 +
 packages/dashboard/app/components/__tests__/PlanningModeModal.dedupeSessionsById.test.ts   |  44 ++++
 packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx   |  11 +
 packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx       | 226 ++++++++++++++++++++-
 packages/dashboard/app/components/__tests__/PlanningModeModal.test-helpers.ts    |   1 +
 10 files changed, 439 insertions(+), 26 deletions(-)

Fusion-Task-Id: FN-5892

Fusion-Task-Lineage: 7453bc34-6d57-4d67-ac92-8fb50084fbee
This commit is contained in:
gsxdsm
2026-06-02 12:42:04 -07:00
parent a6989ed7f4
commit dab1569ac3
10 changed files with 439 additions and 26 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix Planning Mode session history so duplicate AI-session rows are collapsed by session id and deleting a history entry only succeeds when the server-side delete persists.

View File

@@ -113,7 +113,7 @@ Planning Mode now includes branch controls on the summary screen before you crea
These values are sent with the Planning Mode create-task request as `branchSelection`, so created tasks persist branch/base-branch settings consistently with other branch-aware task creation flows.
Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer.
Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer. History rows are deduplicated by session id even if the initial load and live session updates arrive out of order, and deleting a history entry now waits for the server delete to persist (failures keep the row visible and surface an error instead of silently disappearing until refresh).
## New Task Modal Branch Strategy

View File

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiRequestError, deleteAiSession } from "../legacy";
describe("deleteAiSession", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("resolves on 200 responses", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await expect(deleteAiSession("session-1")).resolves.toBeUndefined();
});
it("treats 404 responses as idempotent success", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: "Session not found" }), {
status: 404,
headers: { "content-type": "application/json" },
}),
);
await expect(deleteAiSession("missing-session")).resolves.toBeUndefined();
});
it("rejects on non-404 server failures", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: "Delete failed", details: { retryable: false } }), {
status: 500,
headers: { "content-type": "application/json" },
}),
);
await expect(deleteAiSession("session-1")).rejects.toEqual(
expect.objectContaining<ApiRequestError>({
name: "ApiRequestError",
message: "Delete failed",
status: 500,
details: { retryable: false },
}),
);
});
it("rejects on network failures", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
await expect(deleteAiSession("session-1")).rejects.toThrow("Failed to fetch");
});
});

View File

@@ -8226,10 +8226,49 @@ export function forceAcquireSessionLock(sessionId: string, tabId: string): Promi
}
export async function deleteAiSession(id: string): Promise<void> {
await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), {
const url = buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`);
const res = await fetch(url, {
method: "DELETE",
headers: withTokenHeader(),
});
if (res.ok || res.status === 404) {
return;
}
const contentType = res.headers.get("content-type") ?? "";
const bodyText = await res.text();
const isJson = contentType.includes("application/json");
const isHtml = contentType.includes("text/html") || looksLikeHtml(bodyText);
if (isHtml) {
throw new Error(
`API returned HTML instead of JSON for ${url}. ` +
`The endpoint may not be properly configured. (${res.status} ${res.statusText})`
);
}
if (!isJson) {
const preview = bodyText.length > 160 ? `${bodyText.slice(0, 160)}...` : bodyText;
throw new Error(
`API returned ${contentType || "an unknown content type"} instead of JSON for ${url}. ` +
`(${res.status} ${res.statusText})${preview ? ` Response: ${preview}` : ""}`
);
}
let data: unknown;
try {
data = bodyText ? JSON.parse(bodyText) : null;
} catch {
throw new Error(`API returned invalid JSON for ${url}. (${res.status} ${res.statusText})`);
}
const payload = data as { error?: string; details?: Record<string, unknown> } | null;
throw new ApiRequestError(
payload?.error || `Request failed for ${url}: ${res.status} ${res.statusText}`,
res.status,
payload?.details,
);
}
export function pingSession(sessionId: string, projectId?: string): Promise<{ ok: boolean }> {

View File

@@ -51,6 +51,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
import { useToast } from "../hooks/useToast";
import { getSessionTabId } from "../utils/getSessionTabId";
interface PlanningModeModalProps {
@@ -102,6 +103,36 @@ function areStringArraysEqual(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function parseSessionUpdatedAt(updatedAt: string): number {
const parsed = Date.parse(updatedAt);
return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
}
export function dedupeSessionsById(sessions: AiSessionSummary[]): AiSessionSummary[] {
const byId = new Map<string, { session: AiSessionSummary; updatedAtMs: number; firstSeen: number }>();
sessions.forEach((session, index) => {
const updatedAtMs = parseSessionUpdatedAt(session.updatedAt);
const existing = byId.get(session.id);
if (!existing) {
byId.set(session.id, { session, updatedAtMs, firstSeen: index });
return;
}
if (updatedAtMs > existing.updatedAtMs) {
byId.set(session.id, {
session,
updatedAtMs,
firstSeen: existing.firstSeen,
});
}
});
return [...byId.values()]
.sort((left, right) => right.updatedAtMs - left.updatedAtMs || left.firstSeen - right.firstSeen)
.map(({ session }) => session);
}
function buildCompactPlanningSubtaskDrafts(
originalSubtasks: SubtaskItem[],
editedSubtasks: SubtaskItem[],
@@ -260,6 +291,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const { addToast } = useToast();
const { pushNav } = useNavigationHistoryContext();
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
@@ -892,10 +924,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
includeCompleted: true,
includeArchived: showArchived,
});
const planning = all
.filter((s) => s.type === "planning")
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
setPlanningSessions(planning);
const planning = all.filter((s) => s.type === "planning");
setPlanningSessions(dedupeSessionsById(planning));
} catch {
// Best-effort: list errors should not block the modal
} finally {
@@ -941,11 +971,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
const updated = JSON.parse(e.data) as AiSessionSummary;
if (updated.type !== "planning") return;
setPlanningSessions((prev) => {
const idx = prev.findIndex((s) => s.id === updated.id);
const next = idx >= 0 ? [...prev.slice(0, idx), updated, ...prev.slice(idx + 1)] : [updated, ...prev];
return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
});
setPlanningSessions((prev) => dedupeSessionsById([updated, ...prev]));
} catch {
// ignore malformed payload
}
@@ -954,7 +980,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const handleDeleted = (e: MessageEvent) => {
try {
const id = JSON.parse(e.data) as string;
setPlanningSessions((prev) => prev.filter((s) => s.id !== id));
setPlanningSessions((prev) => dedupeSessionsById(prev.filter((s) => s.id !== id)));
} catch {
// ignore malformed payload
}
@@ -1135,8 +1161,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
await deleteAiSession(sessionId);
} catch {
// best-effort: SSE will reconcile if the delete actually succeeded
} catch (err) {
addToast(getErrorMessage(err) || "Failed to delete session", "error");
void refreshSessionsList();
setPendingDeleteId(null);
return;
}
// Broadcast completion so sibling consumers (BackgroundTasksIndicator's
@@ -1150,7 +1179,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
timestamp: Date.now(),
});
setPlanningSessions((prev) => prev.filter((s) => s.id !== sessionId));
setPlanningSessions((prev) => dedupeSessionsById(prev.filter((s) => s.id !== sessionId)));
if (selectedSessionId === sessionId) {
streamConnectionRef.current?.close();
@@ -1161,7 +1190,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
setPendingDeleteId(null);
},
[broadcastCompleted, planningSessions, projectId, resetDetailState, selectedSessionId, sessionTabId],
[addToast, broadcastCompleted, planningSessions, projectId, refreshSessionsList, resetDetailState, selectedSessionId, sessionTabId],
);
const handleArchiveSession = useCallback(
@@ -1184,9 +1213,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// unarchiving keep it visible with the new flag flipped.
setPlanningSessions((prev) => {
if (!wasArchived && !showArchived) {
return prev.filter((s) => s.id !== sessionId);
return dedupeSessionsById(prev.filter((s) => s.id !== sessionId));
}
return prev.map((s) => (s.id === sessionId ? { ...s, archived: !wasArchived } : s));
return dedupeSessionsById(prev.map((s) => (s.id === sessionId ? { ...s, archived: !wasArchived } : s)));
});
if (!wasArchived && selectedSessionId === sessionId && !showArchived) {
// The currently-open archived session is no longer in the visible list;
@@ -1661,7 +1690,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
onTasksCreated(result.tasks);
// Server cleans up the planning session after task creation; mirror that
// locally so reopen doesn't try to load a 404 and the footer count drops.
setPlanningSessions((prev) => prev.filter((s) => s.id !== completedSessionId));
setPlanningSessions((prev) => dedupeSessionsById(prev.filter((s) => s.id !== completedSessionId)));
broadcastCompleted({
sessionId: completedSessionId,
status: "complete",
@@ -1850,9 +1879,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
.then((response) => {
draftSessionIdRef.current = response.sessionId;
setPlanningSessions((prev) => {
if (prev.some((s) => s.id === response.sessionId)) {
return prev;
}
const draft: AiSessionSummary = {
id: response.sessionId,
type: "planning",
@@ -1864,9 +1890,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
updatedAt: new Date().toISOString(),
archived: false,
};
return [draft, ...prev].sort(
(a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt),
);
return dedupeSessionsById([draft, ...prev]);
});
setSelectedSessionId(response.sessionId);
})

View File

@@ -28,6 +28,16 @@ import {
mockModels,
} from "./PlanningModeModal.test-helpers";
const mockAddToast = vi.fn();
vi.mock("../../hooks/useToast", () => ({
useToast: () => ({
addToast: mockAddToast,
removeToast: vi.fn(),
toasts: [],
}),
}));
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
@@ -92,6 +102,7 @@ describe("PlanningModeModal autosize", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAddToast.mockReset();
mockConfirm.mockResolvedValue(true);
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { AiSessionSummary } from "../../api";
import { dedupeSessionsById } from "../PlanningModeModal";
function makeSession(id: string, updatedAt: string, title = id): AiSessionSummary {
return {
id,
type: "planning",
status: "complete",
title,
projectId: null,
lockedByTab: null,
updatedAt,
archived: false,
};
}
describe("dedupeSessionsById", () => {
it("keeps the most recently updated session for duplicate ids", () => {
const sessions = [
makeSession("session-1", "2026-01-01T00:00:00.000Z", "older"),
makeSession("session-2", "2026-01-03T00:00:00.000Z", "second"),
makeSession("session-1", "2026-01-04T00:00:00.000Z", "newer"),
];
expect(dedupeSessionsById(sessions)).toEqual([
makeSession("session-1", "2026-01-04T00:00:00.000Z", "newer"),
makeSession("session-2", "2026-01-03T00:00:00.000Z", "second"),
]);
});
it("preserves stable newest-first ordering when timestamps tie", () => {
const sessions = [
makeSession("session-a", "2026-01-02T00:00:00.000Z", "first"),
makeSession("session-b", "2026-01-02T00:00:00.000Z", "second"),
makeSession("session-a", "2026-01-02T00:00:00.000Z", "ignored duplicate"),
];
expect(dedupeSessionsById(sessions)).toEqual([
makeSession("session-a", "2026-01-02T00:00:00.000Z", "first"),
makeSession("session-b", "2026-01-02T00:00:00.000Z", "second"),
]);
});
});

View File

@@ -4,6 +4,16 @@ import * as api from "../../api";
import { PlanningModeModal } from "../PlanningModeModal";
import { TaskDetailModal } from "../TaskDetailModal";
const mockAddToast = vi.fn();
vi.mock("../../hooks/useToast", () => ({
useToast: () => ({
addToast: mockAddToast,
removeToast: vi.fn(),
toasts: [],
}),
}));
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
@@ -113,6 +123,7 @@ describe("PlanningModeModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAddToast.mockReset();
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
MockEventSource.reset();

View File

@@ -9,7 +9,7 @@ vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
});
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
import * as api from "../../api";
import { PlanningModeModal } from "../PlanningModeModal";
import { PlanningModeModal, dedupeSessionsById } from "../PlanningModeModal";
import { TaskDetailModal } from "../TaskDetailModal";
import { useSessionLock } from "../../hooks/useSessionLock";
import { getSessionTabId } from "../../utils/getSessionTabId";
@@ -46,6 +46,7 @@ import {
mockRejectPlan,
mockRefineTask,
mockFetchAiSessions,
mockDeleteAiSession,
mockConfirm,
mockUseViewportMode,
mockUseMobileKeyboard,
@@ -59,6 +60,16 @@ import {
mockViewport,
} from "./PlanningModeModal.test-helpers";
const mockAddToast = vi.fn();
vi.mock("../../hooks/useToast", () => ({
useToast: () => ({
addToast: mockAddToast,
removeToast: vi.fn(),
toasts: [],
}),
}));
vi.mock("../../api", () => ({
startPlanning: (...args: any[]) => mockStartPlanning(...args),
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
@@ -95,6 +106,7 @@ vi.mock("../../api", () => ({
updateGlobalSettings: vi.fn().mockResolvedValue({}),
duplicateTask: vi.fn().mockResolvedValue({}),
fetchAiSessions: (...args: any[]) => mockFetchAiSessions(...args),
deleteAiSession: (...args: any[]) => mockDeleteAiSession(...args),
}));
vi.mock("../../hooks/useConfirm", () => ({
@@ -121,6 +133,7 @@ describe("PlanningModeModal", () => {
vi.clearAllMocks();
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
mockAddToast.mockReset();
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource as any);
window.sessionStorage.clear();
@@ -139,6 +152,7 @@ describe("PlanningModeModal", () => {
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
mockFetchAiSession.mockResolvedValue(null);
mockFetchAiSessions.mockResolvedValue([]);
mockDeleteAiSession.mockResolvedValue(undefined);
mockParseConversationHistory.mockImplementation((raw: string) => {
if (!raw) return [];
try {
@@ -2092,4 +2106,214 @@ describe("PlanningModeModal", () => {
});
});
describe("Session history", () => {
it("renders only one row when fetch and SSE deliver the same session id", async () => {
mockFetchAiSessions.mockResolvedValueOnce([
{
id: "session-dup",
type: "planning",
status: "complete",
title: "Duplicate session",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-01T00:00:00.000Z",
archived: false,
},
]);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Duplicate session")).toHaveLength(1);
});
await waitFor(() => {
expect(MockEventSource.instances).toHaveLength(1);
});
act(() => {
MockEventSource.instances[0]?.emit("ai_session:updated", {
id: "session-dup",
type: "planning",
status: "complete",
title: "Duplicate session",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-02T00:00:00.000Z",
archived: false,
});
});
await waitFor(() => {
expect(screen.getAllByText("Duplicate session")).toHaveLength(1);
});
});
it("removes a session after a successful delete", async () => {
mockFetchAiSessions.mockResolvedValueOnce([
{
id: "session-delete",
type: "planning",
status: "complete",
title: "Delete me",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-01T00:00:00.000Z",
archived: false,
},
]);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(screen.getByText("Delete me")).toBeDefined();
});
const sidebar = screen.getByLabelText("Planning sessions");
fireEvent.click(within(sidebar).getAllByTitle("Delete session")[0]!);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(mockDeleteAiSession).toHaveBeenCalledWith("session-delete");
expect(screen.queryByText("Delete me")).toBeNull();
});
});
it("reconciles the session row and shows a toast when delete fails", async () => {
const sessions = [
{
id: "session-delete-fail",
type: "planning",
status: "complete",
title: "Still here",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-01T00:00:00.000Z",
archived: false,
},
];
mockFetchAiSessions.mockResolvedValueOnce(sessions).mockResolvedValueOnce(sessions);
mockDeleteAiSession.mockRejectedValueOnce(new Error("Delete failed"));
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(screen.getByText("Still here")).toBeDefined();
});
const sidebar = screen.getByLabelText("Planning sessions");
fireEvent.click(within(sidebar).getAllByTitle("Delete session")[0]!);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(mockDeleteAiSession).toHaveBeenCalledWith("session-delete-fail");
expect(mockAddToast).toHaveBeenCalledWith("Delete failed", "error");
expect(mockFetchAiSessions).toHaveBeenCalledTimes(2);
expect(screen.getByText("Still here")).toBeDefined();
});
});
});
describe("dedupeSessionsById export", () => {
it("keeps the newest session for duplicate ids while preserving stable order on ties", () => {
expect(
dedupeSessionsById([
{
id: "session-a",
type: "planning",
status: "complete",
title: "older",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-01T00:00:00.000Z",
archived: false,
},
{
id: "session-b",
type: "planning",
status: "complete",
title: "peer",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-02T00:00:00.000Z",
archived: false,
},
{
id: "session-a",
type: "planning",
status: "complete",
title: "newer",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-03T00:00:00.000Z",
archived: false,
},
{
id: "session-c",
type: "planning",
status: "complete",
title: "tie-first",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-02T00:00:00.000Z",
archived: false,
},
]),
).toEqual([
{
id: "session-a",
type: "planning",
status: "complete",
title: "newer",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-03T00:00:00.000Z",
archived: false,
},
{
id: "session-b",
type: "planning",
status: "complete",
title: "peer",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-02T00:00:00.000Z",
archived: false,
},
{
id: "session-c",
type: "planning",
status: "complete",
title: "tie-first",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-02T00:00:00.000Z",
archived: false,
},
]);
});
});
});

View File

@@ -31,6 +31,7 @@ export const mockApprovePlan = vi.fn();
export const mockRejectPlan = vi.fn();
export const mockRefineTask = vi.fn();
export const mockFetchAiSessions = vi.fn();
export const mockDeleteAiSession = vi.fn();
export const mockConfirm = vi.fn();