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 }> {