"use client"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { IconHistory, IconUser, IconRestore, IconLoader2 } from "@tabler/icons-react"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; import { toast } from "@/lib/toast/sonner"; import type { TaskPlanRevision } from "@/lib/types/http"; import { formatPreciseTime } from "@/lib/utils"; import { formatNumber } from "@/lib/i18n/formats"; import { AgentLogo } from "@/components/agent-logo"; import { useAppStore } from "@/components/state-provider"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { WorkflowStepMessageBadge } from "./chat/messages/workflow-step-message-badge"; import { PlanRevisionPreviewDialog } from "./task-plan-preview-dialog"; import { PlanRevisionDiffDialog } from "./task-plan-diff-dialog"; import { RevertConfirmDialog } from "./task-plan-revert-confirm-dialog"; import { TaskPlanRevisionRow } from "./task-plan-revision-row"; import { useTranslation } from "react-i18next"; import { MobileRevisionRestoreConfirmation } from "./task-plan-revision-restore-actions"; import type { TFunction } from "i18next"; type ComparePair = [string | null, string | null]; type TaskPlanRevisionsProps = { taskId: string; revisions: TaskPlanRevision[]; isLoading: boolean; isSaving: boolean; onOpen: () => void; onRevert: (revisionId: string) => Promise; loadRevisionContent: (revisionId: string) => Promise; previewRevisionId: string | null; setPreviewRevision: (revisionId: string | null) => void; /** comparePair / toggleCompareSelection / clearComparePair are still threaded * through so the diff dialog can carry an arbitrary pair, but the popover * UI no longer surfaces a selection mode — pairs are seeded from inside the * preview dialog ("Compare with previous" / "Compare with current"). */ comparePair: ComparePair; toggleCompareSelection: (revisionId: string) => void; clearComparePair: () => void; disabled?: boolean; }; /** Label rendered in the UI for any user-authored revision. Display-only — * the backend still stamps its own author_name on the row. */ /** Catalog key, not copy — resolving at module scope freezes the locale. */ const USER_DISPLAY_NAME_KEY = "common:you"; export function TaskPlanRevisions(props: TaskPlanRevisionsProps) { const { isFinePointer, isMobile } = useResponsiveBreakpoint(); const [open, setOpen] = useState(false); const [confirmTarget, setConfirmTarget] = useState(null); const [rowConfirmTarget, setRowConfirmTarget] = useState(null); const [diffOpen, setDiffOpen] = useState(false); const [mobileTarget, setMobileTarget] = useState(null); const pendingMobileTarget = useRef(null); const historyTriggerRef = useRef(null); const agentName = useActiveAgentBackendName(); const triggerOpenChange = useTriggerOnFirstOpen(setOpen, props.onOpen); const handleOpenChange = useCallback( (nextOpen: boolean) => { if (!nextOpen) setRowConfirmTarget(null); triggerOpenChange(nextOpen); }, [triggerOpenChange], ); const closePopover = useCallback(() => handleOpenChange(false), [handleOpenChange]); const handleRevert = useRevisionRestore(props, closePopover, setDiffOpen); const previewRevision = useMemo( () => props.revisions.find((r) => r.id === props.previewRevisionId) ?? null, [props.revisions, props.previewRevisionId], ); const compareRevisions = useMemo<[TaskPlanRevision | null, TaskPlanRevision | null]>( () => [ props.revisions.find((r) => r.id === props.comparePair[0]) ?? null, props.revisions.find((r) => r.id === props.comparePair[1]) ?? null, ], [props.revisions, props.comparePair], ); const headRevision = props.revisions[0] ?? null; useEffect(() => { if ( mobileTarget && (mobileTarget.task_id !== props.taskId || !props.revisions.slice(1).some((revision) => revision.id === mobileTarget.id)) ) { setMobileTarget(null); } }, [mobileTarget, props.taskId, props.revisions]); return ( <> { const target = pendingMobileTarget.current; pendingMobileTarget.current = null; if (!target || target.task_id !== props.taskId) return; event.preventDefault(); setMobileTarget(target); }} open={open} onOpenChange={handleOpenChange} agentName={agentName} rowConfirmTarget={rowConfirmTarget} isFinePointer={isFinePointer} onRowRevertRequest={(revision) => { if (!isMobile) return setRowConfirmTarget(revision); pendingMobileTarget.current = revision; closePopover(); }} onRowRevertCancel={() => setRowConfirmTarget(null)} onRowRevert={handleRevert} {...props} /> setMobileTarget(null)} onRevert={handleRevert} /> ); } function useRevisionRestore( props: Pick, closePopover: () => void, setDiffOpen: (open: boolean) => void, ) { const { t } = useTranslation(); return useCallback( async (revision: TaskPlanRevision) => { try { const result = await props.onRevert(revision.id); if (result) { toast.success(t("task:planRestoredToV", { revisionnumber: revision.revision_number })); closePopover(); props.setPreviewRevision(null); setDiffOpen(false); } else { // The persistence hook reports a failed restore as null. toast.error(t("task:failedToRestorePlan")); } } catch (err) { toast.error(err instanceof Error ? err.message : t("task:failedToRestorePlan")); } }, [closePopover, props.onRevert, props.setPreviewRevision, setDiffOpen, t], ); } /** Lazy-load revisions on the first popover open; subsequent opens reuse the * cached list. Wraps the open setter so callers don't need a ref of their own. */ function useTriggerOnFirstOpen(setOpen: (v: boolean) => void, onOpen: () => void) { const loadedRef = useRef(false); return useCallback( (next: boolean) => { setOpen(next); if (next && !loadedRef.current) { loadedRef.current = true; onOpen(); } }, [setOpen, onOpen], ); } function RevisionsPopover({ historyTriggerRef, onCloseAutoFocus, open, onOpenChange, agentName, rowConfirmTarget, isFinePointer, onRowRevertRequest, onRowRevertCancel, onRowRevert, revisions, isLoading, isSaving, setPreviewRevision, disabled = false, }: TaskPlanRevisionsProps & { historyTriggerRef: React.RefObject; onCloseAutoFocus: (event: Event) => void; open: boolean; onOpenChange: (next: boolean) => void; agentName: string | null; rowConfirmTarget: TaskPlanRevision | null; isFinePointer: boolean; onRowRevertRequest: (revision: TaskPlanRevision) => void; onRowRevertCancel: () => void; onRowRevert: (revision: TaskPlanRevision) => Promise; }) { const { t } = useTranslation(); const hasRevisions = revisions.length > 0; return (
{t("task:planHistory")} {isLoading && }
{/* Plain overflow-y-auto is more reliable than ScrollArea inside a Popover * — the popover doesn't constrain its child by default, so a fixed * max-height with native scroll keeps the list bounded. */}
setPreviewRevision(rev.id)} />
); } type DialogStackProps = { revisions: TaskPlanRevision[]; previewRevision: TaskPlanRevision | null; compareRevisions: [TaskPlanRevision | null, TaskPlanRevision | null]; diffOpen: boolean; confirmTarget: TaskPlanRevision | null; isSaving: boolean; loadRevisionContent: (revisionId: string) => Promise; headRevision: TaskPlanRevision | null; onRevert: (revision: TaskPlanRevision) => Promise; setConfirmTarget: (rev: TaskPlanRevision | null) => void; setDiffOpen: (v: boolean) => void; setPreviewRevision: (id: string | null) => void; clearComparePair: () => void; toggleCompareSelection: (id: string) => void; }; function RevisionsDialogStack({ revisions, previewRevision, compareRevisions, diffOpen, confirmTarget, isSaving, loadRevisionContent, headRevision, onRevert, setConfirmTarget, setDiffOpen, setPreviewRevision, clearComparePair, toggleCompareSelection, }: DialogStackProps) { const { t } = useTranslation(); const isPreviewCurrent = previewRevision !== null && previewRevision.id === headRevision?.id; const previousRevision = useMemo(() => { if (!previewRevision) return null; // revisions are sorted newest-first by revision_number; previous = the // first entry whose revision_number is strictly less than the previewed one. return revisions.find((r) => r.revision_number < previewRevision.revision_number) ?? null; }, [revisions, previewRevision]); const seedComparePair = useCallback( (a: TaskPlanRevision, b: TaskPlanRevision) => { // Replace any existing pair with [a, b] so the diff opens immediately. clearComparePair(); toggleCompareSelection(a.id); if (a.id !== b.id) toggleCompareSelection(b.id); setPreviewRevision(null); setDiffOpen(true); }, [clearComparePair, toggleCompareSelection, setPreviewRevision, setDiffOpen], ); const onCompareWithCurrent = useCallback(() => { if (!previewRevision || !headRevision) return; seedComparePair(previewRevision, headRevision); }, [previewRevision, headRevision, seedComparePair]); const onCompareWithPrevious = useMemo(() => { if (!previewRevision || !previousRevision) return null; return () => seedComparePair(previousRevision, previewRevision); }, [previewRevision, previousRevision, seedComparePair]); return ( <> setPreviewRevision(null)} onRestore={() => { if (previewRevision) setConfirmTarget(previewRevision); }} onCompareWithPrevious={onCompareWithPrevious} onCompareWithCurrent={onCompareWithCurrent} isCurrent={isPreviewCurrent} /> setDiffOpen(false)} onRestoreOlder={(revisionId) => { const rev = revisions.find((r) => r.id === revisionId); if (rev) setConfirmTarget(rev); }} /> setConfirmTarget(null)} onConfirm={async () => { if (!confirmTarget) return; try { await onRevert(confirmTarget); } finally { setConfirmTarget(null); } }} /> ); } /** Display label for an author. User revisions always render as "You"; * agent revisions use the stored author_name (the agent profile display name). */ function authorLabel(revision: TaskPlanRevision, t: TFunction): string { if (revision.author_kind === "user") return t(USER_DISPLAY_NAME_KEY); return revision.author_name || t("common:agent"); } /** Resolve the active session's agent backend name (e.g. "claude", "codex") so * agent revisions can render the same logo we use elsewhere. Returns null when * there is no active session or its snapshot doesn't carry an agent_name. */ function useActiveAgentBackendName(): string | null { const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const snapshot = useAppStore((state) => activeSessionId ? state.taskSessions.items[activeSessionId]?.agent_profile_snapshot : null, ); return useMemo(() => { const name = (snapshot as { agent_name?: unknown } | null | undefined)?.agent_name; return typeof name === "string" && name.length > 0 ? name : null; }, [snapshot]); } function RevisionList({ revisions, isLoading, isSaving, agentName, rowConfirmTarget, isFinePointer, onRevertRequest, onRevertCancel, onRevert, onRowClick, }: { revisions: TaskPlanRevision[]; isLoading: boolean; isSaving: boolean; agentName: string | null; rowConfirmTarget: TaskPlanRevision | null; isFinePointer: boolean; onRevertRequest: (revision: TaskPlanRevision) => void; onRevertCancel: () => void; onRevert: (revision: TaskPlanRevision) => Promise; onRowClick: (rev: TaskPlanRevision) => void; }) { const { t } = useTranslation(); if (revisions.length === 0 && !isLoading) { return (
{t("task:noRevisionsYetEditsWillAppear")}
); } return (
    {revisions.map((rev, i) => ( ))}
); } function RevisionAuthor({ revision, agentName, }: { revision: TaskPlanRevision; agentName: string | null; }) { const { t } = useTranslation(); if (revision.author_kind === "agent") { return ( <> {agentName ? : null} {revision.author_name} ); } return ( <> {t(USER_DISPLAY_NAME_KEY)} ); } /** Signed, thousands-separated delta string ("+1,234" / "−40,612"), or null * when either side's character count is unknown or unchanged. Uses U+2212 * (minus sign) rather than a hyphen for the negative case, matching the * design's typographic minus. */ function formatContentDelta( current: number | undefined, previous: number | undefined, ): string | null { if (current === undefined || previous === undefined) return null; const delta = current - previous; if (delta === 0) return null; const magnitude = formatNumber(Math.abs(delta)); return delta > 0 ? `+${magnitude}` : `−${magnitude}`; } function RevisionRowBody({ revision, previousRevision, isCurrent, agentName, onRowClick, }: { revision: TaskPlanRevision; previousRevision: TaskPlanRevision | null; isCurrent: boolean; agentName: string | null; onRowClick: (revision: TaskPlanRevision) => void; }) { const { t } = useTranslation(); // Force re-render every 30s so the precise timestamp ("5m ago", "Today, // 14:32", …) refreshes as the revision ages — `formatPreciseTime` derives // from `revision.updated_at` on every render. const [, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 30_000); return () => clearInterval(id); }, []); const timestamp = formatPreciseTime(revision.updated_at); const delta = formatContentDelta(revision.content_length, previousRevision?.content_length); // A coalesced write bumps updated_at without changing created_at (the // original write's timestamp), so a mismatch means this row absorbed a // later edit rather than being written once. const wasCoalesced = revision.updated_at !== revision.created_at; return ( ); }