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 }> {
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user