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