diff --git a/e2e/coach-conversation-delete.spec.ts b/e2e/coach-conversation-delete.spec.ts new file mode 100644 index 000000000..03b544dce --- /dev/null +++ b/e2e/coach-conversation-delete.spec.ts @@ -0,0 +1,188 @@ +import { expect, test } from "./setup/test"; +import type { Route } from "@playwright/test"; +import { randomBytes } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import { hash } from "@node-rs/argon2"; +import pg from "pg"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; + +const KEEP_ID = "conversation-delete-keep"; +const DROP_ID = "conversation-delete-drop"; + +function row(id: string, title: string) { + return { + id, + title, + createdAt: "2026-07-20T10:00:00.000Z", + updatedAt: "2026-07-20T10:00:00.000Z", + messageCount: 2, + fenced: false, + attachments: [], + documentTitle: null, + }; +} + +/** + * A deleted conversation must stay deleted. The delete waits out an undo + * window before it is sent; a reload inside that window used to drop it, and + * once the window ran out the row showed again until the refetch landed. + */ +test.describe("Coach conversation delete", () => { + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + + test.use({ storageState: STORAGE_STATE_PATH }); + + async function serveConversations( + page: import("@playwright/test").Page, + deleteDelayMs: number, + ) { + const state = { + rows: [row(KEEP_ID, "Kept thread"), row(DROP_ID, "Dropped thread")], + deletes: [] as string[], + }; + // Context-level, so a `keepalive` request sent while the page unloads is + // still answered here. + await page + .context() + .route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/, + async (route: Route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === "DELETE") { + const id = decodeURIComponent(url.pathname.split("/").pop() ?? ""); + state.deletes.push(id); + state.rows = state.rows.filter((r) => r.id !== id); + if (deleteDelayMs > 0) await delay(deleteDelayMs); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: { deleted: true }, error: null }), + }); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { conversations: state.rows, nextCursor: null }, + error: null, + }), + }); + }, + ); + return state; + } + + test("the row does not come back when the window runs out", async ({ + page, + }) => { + // A slow DELETE holds the refetch back, which is when the row used to + // reappear. + const state = await serveConversations(page, 2_000); + await page.goto("/coach/conversations", { waitUntil: "domcontentloaded" }); + + const items = page.locator('[data-slot="coach-conversations-item"]'); + await expect(items).toHaveCount(2, { timeout: 10_000 }); + await items + .filter({ hasText: "Dropped thread" }) + .locator('[data-slot="coach-conversations-delete"]') + .click(); + await expect(items).toHaveCount(1); + + await expect + .poll(() => state.deletes, { timeout: 15_000 }) + .toEqual([DROP_ID]); + // Sampled while the DELETE is still in flight. + for (let i = 0; i < 6; i += 1) { + expect(await items.count()).toBe(1); + await delay(250); + } + await expect(items).toContainText("Kept thread"); + }); +}); + +/** + * The real server and database, not a mock: the request that matters here is + * sent with `keepalive` while the page unloads, which Playwright's routing + * never sees. The account is the spec's own, so the conversation it seeds + * cannot surface in another spec's Coach. + */ +test.describe("Coach conversation delete against the database", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test("a reload inside the undo window still deletes", async ({ + page, + }, testInfo) => { + const dbUrl = process.env.DATABASE_URL; + test.skip(!dbUrl, "DATABASE_URL is required to seed the conversation"); + + const suffix = `${testInfo.project.name}-${randomBytes(4).toString("hex")}`; + const username = `e2e-coach-delete-${suffix}`; + const password = "Cd5!Vq8nTz3wLp7K"; + const userId = `e2e-coach-delete-user-${suffix}`; + const conversationId = `e2e-coach-delete-${suffix}`; + const pool = new pg.Pool({ connectionString: dbUrl }); + try { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', now(), now(), now(), true)`, + [ + userId, + username, + `${username}@healthlog.test`, + await hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }), + ], + ); + // A legacy readable title keeps the seed free of the encryption key. + await pool.query( + `INSERT INTO coach_conversations (id, user_id, title, updated_at) + VALUES ($1, $2, 'Dropped thread', now())`, + [conversationId, userId], + ); + + const login = await page.request.post("/api/auth/login", { + data: { email: username, password }, + }); + expect(login.status()).toBe(200); + + await page.goto("/coach/conversations", { + waitUntil: "domcontentloaded", + }); + const items = page.locator('[data-slot="coach-conversations-item"]'); + await expect(items).toHaveCount(1, { timeout: 15_000 }); + await items.locator('[data-slot="coach-conversations-delete"]').click(); + await expect(items).toHaveCount(0); + + // Well inside the undo window. + await page.reload({ waitUntil: "domcontentloaded" }); + + await expect + .poll(async () => { + const { rowCount } = await pool.query( + "SELECT 1 FROM coach_conversations WHERE id = $1", + [conversationId], + ); + return rowCount; + }) + .toBe(0); + await expect( + page.locator('[data-slot="coach-conversations-empty"]'), + ).toBeVisible({ timeout: 15_000 }); + await expect(items).toHaveCount(0); + } finally { + await pool.query("DELETE FROM users WHERE id = $1", [userId]); + await pool.end(); + } + }); +}); diff --git a/messages/de.json b/messages/de.json index 795904f21..24af981b8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2909,6 +2909,7 @@ "historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.", "historyDeleteAria": "Unterhaltung löschen", "historyDeleted": "Unterhaltung gelöscht", + "historyDeleteFailed": "Die Unterhaltung konnte nicht gelöscht werden.", "rename": { "action": "Unterhaltung umbenennen", "input": "Titel der Unterhaltung", diff --git a/messages/en.json b/messages/en.json index a30ac986b..22b659e8d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2909,6 +2909,7 @@ "historyEmpty": "Your conversations will appear here. Ask anything to start.", "historyDeleteAria": "Delete conversation", "historyDeleted": "Conversation deleted", + "historyDeleteFailed": "The conversation could not be deleted.", "rename": { "action": "Rename conversation", "input": "Conversation title", diff --git a/messages/es.json b/messages/es.json index c46dc490e..eef939a96 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2909,6 +2909,7 @@ "historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.", "historyDeleteAria": "Eliminar conversación", "historyDeleted": "Conversación eliminada", + "historyDeleteFailed": "No se pudo eliminar la conversación.", "rename": { "action": "Renombrar conversación", "input": "Título de la conversación", diff --git a/messages/fr.json b/messages/fr.json index 3b61b97e9..ddd5aaf77 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2909,6 +2909,7 @@ "historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.", "historyDeleteAria": "Supprimer la conversation", "historyDeleted": "Conversation supprimée", + "historyDeleteFailed": "La conversation n’a pas pu être supprimée.", "rename": { "action": "Renommer la conversation", "input": "Titre de la conversation", diff --git a/messages/it.json b/messages/it.json index 62db0cedd..7c4972d96 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2909,6 +2909,7 @@ "historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.", "historyDeleteAria": "Elimina conversazione", "historyDeleted": "Conversazione eliminata", + "historyDeleteFailed": "Non è stato possibile eliminare la conversazione.", "rename": { "action": "Rinomina conversazione", "input": "Titolo della conversazione", diff --git a/messages/ko.json b/messages/ko.json index 244aa0850..a6b1eabce 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2909,6 +2909,7 @@ "historyEmpty": "대화가 여기에 표시돼요. 무엇이든 물어보며 시작하세요.", "historyDeleteAria": "대화 삭제", "historyDeleted": "대화를 삭제했어요", + "historyDeleteFailed": "대화를 삭제하지 못했어요.", "rename": { "action": "대화 이름 바꾸기", "input": "대화 제목", diff --git a/messages/pl.json b/messages/pl.json index 2c66a69ef..e8c943516 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2909,6 +2909,7 @@ "historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.", "historyDeleteAria": "Usuń rozmowę", "historyDeleted": "Rozmowa usunięta", + "historyDeleteFailed": "Nie udało się usunąć rozmowy.", "rename": { "action": "Zmień nazwę rozmowy", "input": "Tytuł rozmowy", diff --git a/src/components/insights/coach-panel/__tests__/use-coach-delete.test.ts b/src/components/insights/coach-panel/__tests__/use-coach-delete.test.ts new file mode 100644 index 000000000..09d83dbe8 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/use-coach-delete.test.ts @@ -0,0 +1,199 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + CoachConversationDTO, + CoachConversationsPage, +} from "@/lib/ai/coach/types"; +import { queryKeys } from "@/lib/query-keys"; +import { + CONVERSATION_DELETE_UNDO_MS, + commitCoachConversationDelete, + createCoachConversationDeleteQueue, + deleteCoachConversationRequest, + removeCoachConversationFromCaches, +} from "../use-coach"; + +function conversation(id: string): CoachConversationDTO { + return { + id, + title: id, + createdAt: "2026-07-20T10:00:00.000Z", + updatedAt: "2026-07-20T10:00:00.000Z", + messageCount: 2, + fenced: false, + }; +} + +function page( + ...conversations: CoachConversationDTO[] +): CoachConversationsPage { + return { conversations, nextCursor: null }; +} + +describe("Coach conversation delete queue", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("commits when the undo window runs out", () => { + const commit = vi.fn(); + const queue = createCoachConversationDeleteQueue(commit); + queue.request("c1"); + vi.advanceTimersByTime(CONVERSATION_DELETE_UNDO_MS - 1); + expect(commit).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(commit).toHaveBeenCalledExactlyOnceWith("c1"); + }); + + it("commits a scheduled delete when the page goes away inside the window", () => { + // The defect: a delete followed by a reload, a closed tab or a + // backgrounded app inside the window lived only in a timer that never + // fired, so no request was sent and the conversation came back. + const commit = vi.fn(); + const queue = createCoachConversationDeleteQueue(commit); + queue.request("c1"); + queue.request("c2"); + queue.flush(); + expect(commit.mock.calls).toEqual([["c1"], ["c2"]]); + + // Committed once: neither the timer nor a second flush repeats it. + vi.advanceTimersByTime(CONVERSATION_DELETE_UNDO_MS); + queue.flush(); + expect(commit).toHaveBeenCalledTimes(2); + }); + + it("undo cancels inside the window and reports false once committed", () => { + const commit = vi.fn(); + const queue = createCoachConversationDeleteQueue(commit); + queue.request("c1"); + expect(queue.undo("c1")).toBe(true); + queue.flush(); + vi.advanceTimersByTime(CONVERSATION_DELETE_UNDO_MS); + expect(commit).not.toHaveBeenCalled(); + + queue.request("c2"); + queue.flush(); + expect(queue.undo("c2")).toBe(false); + expect(commit).toHaveBeenCalledExactlyOnceWith("c2"); + }); +}); + +describe("Coach conversation delete caches and request", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + it("removes the row from the head list and every history variant at once", () => { + // The hide filter is released only after this, so no cached list may + // still hold the row: the paginated history used to keep it until the + // refetch, and the row reappeared for that long. + const client = new QueryClient(); + client.setQueryData( + queryKeys.coachConversations(), + page(conversation("c1"), conversation("c2")), + ); + client.setQueryData(queryKeys.coachConversationHistory(""), { + pages: [page(conversation("c2")), page(conversation("c1"))], + pageParams: [null, "cursor-1"], + }); + client.setQueryData(queryKeys.coachConversationHistory("c1"), { + pages: [page(conversation("c1"))], + pageParams: [null], + }); + + const snapshot = removeCoachConversationFromCaches(client, "c1"); + + expect( + client + .getQueryData(queryKeys.coachConversations()) + ?.conversations.map((c) => c.id), + ).toEqual(["c2"]); + for (const search of ["", "c1"]) { + const data = client.getQueryData<{ pages: CoachConversationsPage[] }>( + queryKeys.coachConversationHistory(search), + ); + expect( + data?.pages.flatMap((p) => p.conversations.map((c) => c.id)), + ).not.toContain("c1"); + } + expect(snapshot).toHaveLength(3); + }); + + it("sends the DELETE with keepalive so it outlives the page", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ data: { deleted: true }, error: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + + await deleteCoachConversationRequest("c 1"); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toBe("/api/insights/chat/c%201"); + expect(init.method).toBe("DELETE"); + expect(init.keepalive).toBe(true); + }); + + function seeded(): QueryClient { + const client = new QueryClient(); + client.setQueryData( + queryKeys.coachConversations(), + page(conversation("c1"), conversation("c2")), + ); + return client; + } + + function respond(status: number) { + global.fetch = vi.fn( + async () => + new Response( + JSON.stringify( + status === 200 + ? { data: { deleted: true }, error: null } + : { data: null, error: "coach.conversation.notFound" }, + ), + { status, headers: { "Content-Type": "application/json" } }, + ), + ) as unknown as typeof fetch; + } + + const headIds = (client: QueryClient) => + client + .getQueryData(queryKeys.coachConversations()) + ?.conversations.map((c) => c.id); + + it("treats a 404 as already deleted", async () => { + // A delete re-sent after a reload meets a row the first request already + // removed. + respond(404); + const client = seeded(); + const onFailed = vi.fn(); + commitCoachConversationDelete(client, "c1", onFailed); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onFailed).not.toHaveBeenCalled(); + expect(headIds(client)).toEqual(["c2"]); + }); + + it("restores the lists and reports a failed delete", async () => { + respond(500); + const client = seeded(); + const onFailed = vi.fn(); + commitCoachConversationDelete(client, "c1", onFailed); + expect(headIds(client)).toEqual(["c2"]); + await vi.waitFor(() => expect(onFailed).toHaveBeenCalledOnce()); + expect(headIds(client)).toEqual(["c1", "c2"]); + }); +}); diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index c0d3168b9..1590b56cc 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -121,8 +121,9 @@ export function HistoryRail({ action: { label: t("common.undo"), onClick: () => { - undoDelete(id); - if (wasActive) onUndoDeleteActive?.(id); + // A delete already committed (window over, or the page was + // hidden meanwhile) has nothing to reopen. + if (undoDelete(id) && wasActive) onUndoDeleteActive?.(id); }, }, }); diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 35162008b..1572f2880 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -1,6 +1,13 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { useInfiniteQuery, useMutation, @@ -31,6 +38,7 @@ import type { import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action"; import { useTranslations } from "@/lib/i18n/context"; import { + ApiError, apiDelete, apiFetchRaw, apiGet, @@ -285,121 +293,307 @@ export function useCoachConversation(id: string | null) { }); } +function removeConversationFromCache(data: unknown, id: string): unknown { + if (!data || typeof data !== "object") return data; + + if ("pages" in data && Array.isArray(data.pages)) { + let changed = false; + const pages = data.pages.map((page) => { + const next = removeConversationFromCache(page, id); + changed ||= next !== page; + return next; + }); + return changed ? { ...data, pages } : data; + } + + if ("conversations" in data && Array.isArray(data.conversations)) { + const conversations = data.conversations.filter( + (conversation) => + !( + conversation && + typeof conversation === "object" && + "id" in conversation && + conversation.id === id + ), + ); + return conversations.length === data.conversations.length + ? data + : { ...data, conversations }; + } + return data; +} + /** - * Delete a conversation with optimistic removal from the rail cache. - * Rolls back if the server responds with a non-2xx. + * Drop one conversation from every materialised list cache: the head list + * the auto-open reads and every search variant of the paginated history + * below the same prefix. Synchronous on purpose, so the row is gone from the + * caches before any hide filter that covered it is released. Returns the + * previous data for a rollback. */ -export function useDeleteCoachConversation() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (id: string) => { - await apiDelete(`/api/insights/chat/${id}`); - return id; - }, - onMutate: async (id: string) => { - await queryClient.cancelQueries({ queryKey: QUERY_KEYS.list() }); - const previous = queryClient.getQueryData( - QUERY_KEYS.list(), - ); - if (previous) { - queryClient.setQueryData(QUERY_KEYS.list(), { - ...previous, - conversations: previous.conversations.filter((c) => c.id !== id), - }); - } - return { previous }; - }, - onError: (_err, _id, ctx) => { - if (ctx?.previous) { - queryClient.setQueryData(QUERY_KEYS.list(), ctx.previous); - } - }, - onSettled: (_data, _err, id) => { - queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }); - if (typeof id === "string") { - queryClient.removeQueries({ queryKey: QUERY_KEYS.one(id) }); - } - }, +export function removeCoachConversationFromCaches( + queryClient: QueryClient, + id: string, +): CoachConversationRenameSnapshot { + void queryClient.cancelQueries({ queryKey: QUERY_KEYS.list() }); + const snapshot = queryClient.getQueriesData({ queryKey: QUERY_KEYS.list() }); + for (const [key, previous] of snapshot) { + queryClient.setQueryData(key, removeConversationFromCache(previous, id)); + } + return snapshot; +} + +/** + * The DELETE itself. `keepalive` lets the request outlive the page, which is + * what a delete committed from `pagehide` or a backgrounded app needs. + */ +export function deleteCoachConversationRequest(id: string): Promise { + return apiDelete(`/api/insights/chat/${encodeURIComponent(id)}`, undefined, { + keepalive: true, }); } // v1.30.1 M5 — delayed-commit delete window, in milliseconds. Long // enough to read + tap Undo, short enough that the eventual DELETE // isn't a surprise days later. -const CONVERSATION_DELETE_UNDO_MS = 6000; +export const CONVERSATION_DELETE_UNDO_MS = 6000; + +export interface CoachConversationDeleteQueue { + /** Schedule the delete; it commits when the window runs out. */ + request(id: string): void; + /** Cancel a scheduled delete. False once it has been committed. */ + undo(id: string): boolean; + /** Commit every scheduled delete now. */ + flush(): void; +} /** - * v1.30.1 M5 — replaces the old "tap once to arm, tap the same row - * again to delete" confirm, which never disarmed (a row armed minutes - * earlier deleted on a later stray tap) and had no way back once fired. - * Mirrors the Documents delete-with-undo pattern instead: a single tap - * hides the row immediately and schedules the real + * The undo window as plain state, apart from React. A delete that is still + * waiting when the page goes away is committed by `flush`, never dropped: + * the window exists to offer an Undo, not to make the delete conditional on + * the tab staying open for another six seconds. + */ +export function createCoachConversationDeleteQueue( + commit: (id: string) => void, + delayMs: number = CONVERSATION_DELETE_UNDO_MS, +): CoachConversationDeleteQueue { + const timers = new Map>(); + const fire = (id: string) => { + const timer = timers.get(id); + if (timer === undefined) return; + clearTimeout(timer); + timers.delete(id); + commit(id); + }; + return { + request(id) { + if (timers.has(id)) return; + timers.set( + id, + setTimeout(() => fire(id), delayMs), + ); + }, + undo(id) { + const timer = timers.get(id); + if (timer === undefined) return false; + clearTimeout(timer); + timers.delete(id); + return true; + }, + flush() { + for (const id of [...timers.keys()]) fire(id); + }, + }; +} + +/** + * Conversations this page has deleted or is deleting, shared by every + * surface: a row hidden in the Coach panel is hidden in the settings card + * too. An id stays here once its delete went through; the server no longer + * has the row, so hiding it costs nothing and closes the gap before the + * refetch lands. + */ +let hiddenConversationIds: ReadonlySet = new Set(); +const NO_HIDDEN_CONVERSATIONS: ReadonlySet = new Set(); +const hiddenConversationListeners = new Set<() => void>(); + +function setConversationHidden(id: string, hidden: boolean): void { + if (hiddenConversationIds.has(id) === hidden) return; + const next = new Set(hiddenConversationIds); + if (hidden) next.add(id); + else next.delete(id); + hiddenConversationIds = next; + for (const listener of hiddenConversationListeners) listener(); +} + +function subscribeHiddenConversations(listener: () => void): () => void { + hiddenConversationListeners.add(listener); + return () => hiddenConversationListeners.delete(listener); +} + +/** + * Deletes sent but not yet confirmed, kept in `sessionStorage` so they + * outlive a reload. A reload inside the undo window sends the DELETE with + * `keepalive` from the old page, and the new page's list read can reach the + * server before it: without the journal that read brings the row back. The + * new page hides every journaled id and sends its DELETE again; a 404 then + * means it is already gone. + */ +const DELETE_JOURNAL_KEY = "healthlog:coach-conversation-deletes"; + +function readDeleteJournal(): string[] { + try { + const raw = window.sessionStorage.getItem(DELETE_JOURNAL_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) + ? parsed.filter((id): id is string => typeof id === "string") + : []; + } catch { + return []; + } +} + +function writeDeleteJournal(id: string, present: boolean): void { + try { + const ids = new Set(readDeleteJournal()); + if (present) ids.add(id); + else ids.delete(id); + if (ids.size === 0) { + window.sessionStorage.removeItem(DELETE_JOURNAL_KEY); + } else { + window.sessionStorage.setItem( + DELETE_JOURNAL_KEY, + JSON.stringify([...ids]), + ); + } + } catch { + // Storage unavailable: the keepalive request still carries the delete. + } +} + +const deletesInFlight = new Set(); + +/** + * Send one delete. Synchronous up to the request, so a commit made from + * `pagehide` is on the wire before the page is torn down. Every list cache + * drops the row first; a failure restores them, shows the row again and + * calls `onFailed`. A 404 is success: the row is gone, which is the point. + */ +export function commitCoachConversationDelete( + queryClient: QueryClient, + id: string, + onFailed: () => void, +): void { + if (deletesInFlight.has(id)) return; + deletesInFlight.add(id); + setConversationHidden(id, true); + writeDeleteJournal(id, true); + const request = deleteCoachConversationRequest(id); + const snapshot = removeCoachConversationFromCaches(queryClient, id); + request + .catch((error: unknown) => { + if (error instanceof ApiError && error.status === 404) return; + throw error; + }) + .then( + () => { + queryClient.removeQueries({ queryKey: QUERY_KEYS.one(id) }); + }, + () => { + restoreCoachConversationRename(queryClient, snapshot); + setConversationHidden(id, false); + onFailed(); + }, + ) + .finally(() => { + deletesInFlight.delete(id); + writeDeleteJournal(id, false); + void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }); + }); +} + +/** + * v1.30.1 M5 — one tap hides the row and schedules the real * `DELETE /api/insights/chat/[id]` after `CONVERSATION_DELETE_UNDO_MS`; - * calling `undoDelete` within that window cancels the network call and - * un-hides the row. `pendingDeleteIds` is a client-only hide filter — - * consumers subtract it from whatever list they render. + * `undoDelete` within that window cancels it. `pendingDeleteIds` is the + * hide filter every surface subtracts from what it renders. * - * Centralised here (rather than duplicated in the rail + the standalone - * `/coach/conversations` page, which previously carried byte-identical - * arm/confirm logic) so both surfaces share one implementation. + * The delete is never conditional on the page surviving the window. A + * scheduled delete commits early when the page is hidden (an installed app + * sent to the background, a tab switched away), on `pagehide` (reload, + * close, navigation away) and when the owning component unmounts; the + * request goes out with `keepalive`, and a delete the page could not confirm + * is sent again by the next one (see the journal above). Before that, a + * delete followed by a reload inside the window was never sent, and the + * conversation came back. + * + * Shared by every surface that deletes a Coach conversation: the Coach + * panel's rail, `/coach/conversations` and the settings memory card. */ export function useDeleteCoachConversationWithUndo() { - const deleteMutation = useDeleteCoachConversation(); - const [pendingDeleteIds, setPendingDeleteIds] = useState>( - () => new Set(), + const queryClient = useQueryClient(); + const { t } = useTranslations(); + const pendingDeleteIds = useSyncExternalStore( + subscribeHiddenConversations, + () => hiddenConversationIds, + () => NO_HIDDEN_CONVERSATIONS, + ); + + // Kept current by an effect, so the queue below is created once and still + // commits with the latest translator. + const commitRef = useRef<(id: string) => void>(() => {}); + useEffect(() => { + const failedMessage = t("insights.coach.historyDeleteFailed"); + commitRef.current = (id) => + commitCoachConversationDelete(queryClient, id, () => + toast.error(failedMessage), + ); + }); + + // Created on first use, from a handler or an effect, never during render. + const queueRef = useRef(null); + const getQueue = useCallback( + () => + (queueRef.current ??= createCoachConversationDeleteQueue((id) => + commitRef.current(id), + )), + [], ); - const timers = useRef(new Map>()); useEffect(() => { - // A closed drawer / unmounted page must not silently lose a - // scheduled delete: fire any still-pending ones immediately rather - // than leaking the timer (or worse, never deleting at all). - const timerMap = timers.current; - return () => { - for (const [id, timer] of timerMap) { - clearTimeout(timer); - deleteMutation.mutate(id); - } - timerMap.clear(); + // Finish what a previous page of this tab could not confirm. + for (const id of readDeleteJournal()) commitRef.current(id); + + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") getQueue().flush(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on unmount only - }, []); + const onPageHide = () => getQueue().flush(); + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("pagehide", onPageHide); + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("pagehide", onPageHide); + // A closed drawer or a left page commits what it scheduled. + getQueue().flush(); + }; + }, [getQueue]); const requestDelete = useCallback( (id: string) => { - setPendingDeleteIds((prev) => { - const next = new Set(prev); - next.add(id); - return next; - }); - const timer = setTimeout(() => { - timers.current.delete(id); - setPendingDeleteIds((prev) => { - if (!prev.has(id)) return prev; - const next = new Set(prev); - next.delete(id); - return next; - }); - deleteMutation.mutate(id); - }, CONVERSATION_DELETE_UNDO_MS); - timers.current.set(id, timer); + setConversationHidden(id, true); + getQueue().request(id); }, - [deleteMutation], + [getQueue], ); - const undoDelete = useCallback((id: string) => { - const timer = timers.current.get(id); - if (timer) { - clearTimeout(timer); - timers.current.delete(id); - } - setPendingDeleteIds((prev) => { - if (!prev.has(id)) return prev; - const next = new Set(prev); - next.delete(id); - return next; - }); - }, []); + const undoDelete = useCallback( + (id: string): boolean => { + // Too late once committed: the row stays hidden, as the server has it. + const undone = getQueue().undo(id); + if (undone) setConversationHidden(id, false); + return undone; + }, + [getQueue], + ); return { pendingDeleteIds, requestDelete, undoDelete }; }