fix(coach): delete-with-undo replaces the arm-forever confirm

The history rail + /coach/conversations armed a row on the first tap
and only disarmed on a successful delete — no timeout, no
outside-click reset — so a row armed minutes earlier deleted on a
later stray tap, and there was no way back once it fired. Replace it
with the same delayed-commit delete-with-undo the Documents surface
already uses: one tap hides the row and schedules the real DELETE a
few seconds out; the toast's Undo cancels it. Centralised in
useDeleteCoachConversationWithUndo so both surfaces, which carried
byte-identical arm/confirm logic, share one implementation.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 03:52:32 +02:00
1 parent 9531bbdcec
commit 8c385b9ac2
9 files changed
+136 -59

No files matched your search

+1 -1
View File
@@ -2262,7 +2262,7 @@
"historySearchPlaceholder": "Unterhaltungen durchsuchen…",
"historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.",
"historyDeleteAria": "Unterhaltung löschen",
"historyDeleteConfirm": "Diese Unterhaltung löschen? Das lässt sich nicht rückgängig machen.",
"historyDeleted": "Unterhaltung gelöscht",
"sourcesTitle": "Worauf ich zugreife",
"sourcesFresh": "Aktuell",
"sourcesStale": "Veraltet",
+1 -1
View File
@@ -2262,7 +2262,7 @@
"historySearchPlaceholder": "Search conversations…",
"historyEmpty": "Your conversations will appear here. Ask anything to start.",
"historyDeleteAria": "Delete conversation",
"historyDeleteConfirm": "Delete this conversation? This cannot be undone.",
"historyDeleted": "Conversation deleted",
"sourcesTitle": "What I can see",
"sourcesFresh": "Fresh",
"sourcesStale": "Stale",
+1 -1
View File
@@ -2262,7 +2262,7 @@
"historySearchPlaceholder": "Buscar en las conversaciones…",
"historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.",
"historyDeleteAria": "Eliminar conversación",
"historyDeleteConfirm": "¿Eliminar esta conversación? No se puede deshacer.",
"historyDeleted": "Conversación eliminada",
"sourcesTitle": "A qué accedo",
"sourcesFresh": "Actuales",
"sourcesStale": "Desactualizados",
+1 -1
View File
@@ -2262,7 +2262,7 @@
"historySearchPlaceholder": "Rechercher dans les conversations…",
"historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.",
"historyDeleteAria": "Supprimer la conversation",
"historyDeleteConfirm": "Supprimer cette conversation ? Action irréversible.",
"historyDeleted": "Conversation supprimée",
"sourcesTitle": "Ce à quoi j’accède",
"sourcesFresh": "À jour",
"sourcesStale": "Obsolètes",
+1 -1
View File
@@ -2262,7 +2262,7 @@
"historySearchPlaceholder": "Cerca nelle conversazioni…",
"historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.",
"historyDeleteAria": "Elimina conversazione",
"historyDeleteConfirm": "Eliminare questa conversazione? Non si può annullare.",
"historyDeleted": "Conversazione eliminata",
"sourcesTitle": "A cosa accedo",
"sourcesFresh": "Aggiornate",
"sourcesStale": "Datate",
+1 -1
View File
@@ -2262,7 +2262,7 @@
"historySearchPlaceholder": "Szukaj w rozmowach…",
"historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.",
"historyDeleteAria": "Usuń rozmowę",
"historyDeleteConfirm": "Usunąć tę rozmowę? Tego nie da się cofnąć.",
"historyDeleted": "Rozmowa usunięta",
"sourcesTitle": "Do czego mam dostęp",
"sourcesFresh": "Aktualne",
"sourcesStale": "Nieaktualne",
+20 -24
View File
@@ -10,6 +10,7 @@ import {
Target,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -18,7 +19,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachConversations,
useDeleteCoachConversation,
useDeleteCoachConversationWithUndo,
} from "@/components/insights/coach-panel/use-coach";
import type { CoachConversationDTO } from "@/lib/ai/coach/types";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
@@ -102,16 +103,17 @@ function CoachConversationsBody() {
const router = useRouter();
const { conversations, isLoading, isError, refetch } =
useCoachConversations();
const deleteMutation = useDeleteCoachConversation();
const { pendingDeleteIds, requestDelete, undoDelete } =
useDeleteCoachConversationWithUndo();
const [filter, setFilter] = useState<string>("");
const [confirmId, setConfirmId] = useState<string | null>(null);
// Mirror the rail's title substring filter.
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return conversations;
return conversations.filter((c) => c.title.toLowerCase().includes(q));
}, [conversations, filter]);
const visible = conversations.filter((c) => !pendingDeleteIds.has(c.id));
if (!q) return visible;
return visible.filter((c) => c.title.toLowerCase().includes(q));
}, [conversations, filter, pendingDeleteIds]);
const groups = useMemo(() => groupByRecency(filtered), [filtered]);
@@ -120,13 +122,17 @@ function CoachConversationsBody() {
router.push(`/coach?c=${id}`);
}
// v1.30.1 M5 — one tap deletes (with an Undo toast); see
// `use-coach.ts`'s `useDeleteCoachConversationWithUndo` for the
// rationale — this mirrors the rail's identical fix.
function handleDeleteRequest(id: string) {
if (confirmId === id) {
deleteMutation.mutate(id);
setConfirmId(null);
} else {
setConfirmId(id);
}
requestDelete(id);
toast.success(t("insights.coach.historyDeleted"), {
action: {
label: t("common.undo"),
onClick: () => undoDelete(id),
},
});
}
return (
@@ -212,7 +218,6 @@ function CoachConversationsBody() {
</h2>
<ul className="flex flex-col gap-1">
{group.conversations.map((c) => {
const isConfirming = confirmId === c.id;
return (
<li
key={c.id}
@@ -260,18 +265,9 @@ function CoachConversationsBody() {
variant="ghost"
size="icon-lg"
onClick={() => handleDeleteRequest(c.id)}
aria-label={
isConfirming
? t("insights.coach.historyDeleteConfirm")
: t("insights.coach.historyDeleteAria")
}
aria-label={t("insights.coach.historyDeleteAria")}
data-slot="coach-conversations-delete"
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"shrink-0",
isConfirming &&
"text-destructive hover:text-destructive",
)}
className="shrink-0"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import { MessagesSquare, Paperclip, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -10,7 +11,10 @@ import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { COACH_SCROLLBAR } from "./message-thread";
import { useCoachConversations, useDeleteCoachConversation } from "./use-coach";
import {
useCoachConversations,
useDeleteCoachConversationWithUndo,
} from "./use-coach";
/**
* v1.4.20 phase B2b — conversation history rail.
@@ -52,26 +56,31 @@ export function HistoryRail({
}: HistoryRailProps) {
const { t } = useTranslations();
const { conversations, isLoading } = useCoachConversations();
const deleteMutation = useDeleteCoachConversation();
const { pendingDeleteIds, requestDelete, undoDelete } =
useDeleteCoachConversationWithUndo();
const [filter, setFilter] = useState<string>("");
const [confirmId, setConfirmId] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return conversations;
return conversations.filter((c) => c.title.toLowerCase().includes(q));
}, [conversations, filter]);
const visible = conversations.filter((c) => !pendingDeleteIds.has(c.id));
if (!q) return visible;
return visible.filter((c) => c.title.toLowerCase().includes(q));
}, [conversations, filter, pendingDeleteIds]);
// v1.30.1 M5 — a single tap hides the row and schedules the real
// delete; the toast's Undo action cancels it within the grace
// window. Replaces the old arm-then-tap-again confirm, which never
// disarmed. If the user just deleted the active thread, the drawer
// is listening to `onSelect` for navigation; we leave it to the
// parent to clear `activeId` when the conversation disappears.
function handleDeleteRequest(id: string) {
if (confirmId === id) {
deleteMutation.mutate(id);
setConfirmId(null);
// If the user just deleted the active thread, the drawer is
// listening to `onSelect` for navigation; we leave it to the
// parent to clear `activeId` when the conversation disappears.
} else {
setConfirmId(id);
}
requestDelete(id);
toast.success(t("insights.coach.historyDeleted"), {
action: {
label: t("common.undo"),
onClick: () => undoDelete(id),
},
});
}
return (
@@ -141,7 +150,6 @@ export function HistoryRail({
) : (
filtered.map((c) => {
const isActive = c.id === activeId;
const isConfirming = confirmId === c.id;
return (
<div
key={c.id}
@@ -187,26 +195,18 @@ export function HistoryRail({
</button>
{/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0`
reveal: touch surfaces have no hover, so the delete
affordance was invisible on mobile. The button now
affordance was invisible on mobile. The button
sits at 44 px always-visible per the WCAG 2.5.5
floor; confirming state stays styled via the
`data-confirming` attribute. */}
floor. v1.30.1 M5 — one tap deletes (with an Undo
toast); the old arm/confirm double-tap is gone. */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleDeleteRequest(c.id)}
aria-label={
isConfirming
? t("insights.coach.historyDeleteConfirm")
: t("insights.coach.historyDeleteAria")
}
aria-label={t("insights.coach.historyDeleteAria")}
data-slot="coach-history-delete"
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-11 shrink-0",
isConfirming && "text-destructive hover:text-destructive",
)}
className="size-11 shrink-0"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
@@ -128,6 +128,87 @@ export function useDeleteCoachConversation() {
});
}
// 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;
/**
* 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
* `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.
*
* 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.
*/
export function useDeleteCoachConversationWithUndo() {
const deleteMutation = useDeleteCoachConversation();
const [pendingDeleteIds, setPendingDeleteIds] = useState<Set<string>>(
() => new Set(),
);
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
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();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on unmount only
}, []);
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);
},
[deleteMutation],
);
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;
});
}, []);
return { pendingDeleteIds, requestDelete, undoDelete };
}
/**
* v1.29.x (S7) — attach a stored document to an EXISTING fenced (or about-to-be-
* fenced) conversation. On success invalidates the conversation detail + rail so