mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
9531bbdcec
commit
8c385b9ac2
9 files changed
+136
-59
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user