"use client"; /** * "Revert turn" button rendered at the bottom of every completed * assistant turn that has at least one reversible action. * * The button reads from the unified ``useAgentActionsQuery`` cache * (the SAME react-query cache the agent-actions sheet and the inline * Revert button consume) filtered by ``chat_turn_id``. It shows a * confirmation dialog summarising "N reversible / M total" and, on * confirm, calls ``POST /threads/{id}/revert-turn/{chat_turn_id}``. * * The route returns a per-action result list and never collapses the * batch into a 4xx — so we render any failed/not_reversible rows inline * with their messages. */ import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { CheckIcon, RotateCcw, XCircleIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { getToolDisplayName } from "@/contracts/enums/toolIcons"; import { applyRevertTurnResultsToCache, useAgentActionsQuery, } from "@/hooks/use-agent-actions-query"; import { agentActionsApiService, type RevertTurnActionResult, } from "@/lib/apis/agent-actions-api.service"; import { AppError } from "@/lib/error"; import { cn } from "@/lib/utils"; interface RevertTurnButtonProps { chatTurnId: string | null | undefined; } export function RevertTurnButton({ chatTurnId }: RevertTurnButtonProps) { const session = useAtomValue(chatSessionStateAtom); const threadId = session?.threadId ?? null; const queryClient = useQueryClient(); const { findByChatTurnId } = useAgentActionsQuery(threadId); const [isReverting, setIsReverting] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [resultsOpen, setResultsOpen] = useState(false); const [results, setResults] = useState([]); const actions = useMemo(() => findByChatTurnId(chatTurnId), [findByChatTurnId, chatTurnId]); const reversibleCount = useMemo( () => actions.filter( (a) => a.reversible && (a.reverted_by_action_id === null || a.reverted_by_action_id === undefined) && !a.is_revert_action && (a.error === null || a.error === undefined) ).length, [actions] ); const totalCount = useMemo(() => actions.filter((a) => !a.is_revert_action).length, [actions]); if (!chatTurnId) return null; if (reversibleCount === 0) return null; if (!threadId) return null; const handleRevertTurn = async () => { setIsReverting(true); try { const response = await agentActionsApiService.revertTurn(threadId, chatTurnId); setResults(response.results); const revertedEntries = response.results .filter((r) => r.status === "reverted" || r.status === "already_reverted") .map((r) => ({ id: r.action_id, newActionId: r.new_action_id ?? null })); if (revertedEntries.length > 0) { applyRevertTurnResultsToCache(queryClient, threadId, revertedEntries); } if (response.status === "ok") { toast.success( response.reverted === 1 ? "Reverted 1 action." : `Reverted ${response.reverted} actions.` ); } else { // Every "not undone" bucket counts as a failure for the // user-facing summary. ``skipped`` rows are batch // artefacts (revert rows themselves) and intentionally // excluded from the failure tally. const failureCount = response.failed + response.not_reversible + (response.permission_denied ?? 0); toast.warning( `Reverted ${response.reverted} of ${response.total}. ${failureCount} could not be undone.` ); setResultsOpen(true); } } catch (err) { if (err instanceof AppError && err.status === 503) { return; } const message = err instanceof AppError ? err.message : err instanceof Error ? err.message : "Failed to revert turn."; toast.error(message); } finally { setIsReverting(false); setConfirmOpen(false); } }; return ( <> Revert this turn? This will undo {reversibleCount} of {totalCount} action {totalCount === 1 ? "" : "s"} from this turn in reverse order. The chat history and any read-only actions are preserved. Some rows may not be reversible — partial success is normal. Cancel { e.preventDefault(); handleRevertTurn(); }} disabled={isReverting} > {isReverting ? "Reverting…" : "Revert turn"} Revert results Some actions could not be reverted. Review per-row outcomes below.
    {results.map((r) => ( ))}
setResultsOpen(false)}>Close
); } function RevertResultRow({ result }: { result: RevertTurnActionResult }) { const isOk = result.status === "reverted" || result.status === "already_reverted"; const Icon = isOk ? CheckIcon : XCircleIcon; return (
  • {getToolDisplayName(result.tool_name)}{" "} {result.status.replace(/_/g, " ")}

    {(result.message || result.error) && (

    {result.error ?? result.message}

    )}
  • ); }