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:
54
packages/dashboard/app/api/__tests__/deleteAiSession.test.ts
Normal file
54
packages/dashboard/app/api/__tests__/deleteAiSession.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 }> {
|
||||
|
||||
Reference in New Issue
Block a user