"use client"; import { useState, useCallback, useEffect, useMemo, useRef, type ReactNode } 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@kandev/ui/dialog"; import { toast } from "sonner"; import type { TaskPlanRevision } from "@/lib/types/http"; import { formatPreciseTime } from "@/lib/utils"; import { AgentLogo } from "@/components/agent-logo"; import { useAppStore } from "@/components/state-provider"; import { PlanRevisionPreviewDialog } from "./task-plan-preview-dialog"; import { PlanRevisionDiffDialog } from "./task-plan-diff-dialog"; 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. */ const USER_DISPLAY_NAME = "You"; export function TaskPlanRevisions(props: TaskPlanRevisionsProps) { const [open, setOpen] = useState(false); const [confirmTarget, setConfirmTarget] = useState(null); const [diffOpen, setDiffOpen] = useState(false); const agentName = useActiveAgentBackendName(); const handleOpenChange = useTriggerOnFirstOpen(setOpen, props.onOpen); 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; return ( <> setOpen(false)} /> ); } /** 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({ open, onOpenChange, agentName, confirmTargetSetter, revisions, isLoading, setPreviewRevision, disabled = false, }: TaskPlanRevisionsProps & { open: boolean; onOpenChange: (next: boolean) => void; agentName: string | null; confirmTargetSetter: (rev: TaskPlanRevision | null) => void; }) { const hasRevisions = revisions.length > 0; return (
Plan history {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: (revisionId: string) => Promise; setConfirmTarget: (rev: TaskPlanRevision | null) => void; setDiffOpen: (v: boolean) => void; setPreviewRevision: (id: string | null) => void; clearComparePair: () => void; toggleCompareSelection: (id: string) => void; closePopover: () => void; }; function RevisionsDialogStack({ revisions, previewRevision, compareRevisions, diffOpen, confirmTarget, isSaving, loadRevisionContent, headRevision, onRevert, setConfirmTarget, setDiffOpen, setPreviewRevision, clearComparePair, toggleCompareSelection, closePopover, }: DialogStackProps) { 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 handleRevert = useCallback( async (revision: TaskPlanRevision) => { try { const result = await onRevert(revision.id); if (result) { toast.success(`Plan restored to v${revision.revision_number}`); closePopover(); setPreviewRevision(null); setDiffOpen(false); } else { // `revertTo` (the hook impl) swallows errors and returns null, so // we surface the failure here too — without this branch the dialog // closes silently on failure and the user has no feedback. toast.error("Failed to restore plan"); } } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to restore plan"); } }, [onRevert, setPreviewRevision, setDiffOpen, closePopover], ); 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 handleRevert(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): string { if (revision.author_kind === "user") return USER_DISPLAY_NAME; return revision.author_name || "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, agentName, onRevertClick, onRowClick, }: { revisions: TaskPlanRevision[]; isLoading: boolean; agentName: string | null; onRevertClick: (rev: TaskPlanRevision) => void; onRowClick: (rev: TaskPlanRevision) => void; }) { if (revisions.length === 0 && !isLoading) { return (
No revisions yet. Edits will appear here.
); } return (
    {revisions.map((rev, i) => ( ))}
); } function RevisionAuthor({ revision, agentName, }: { revision: TaskPlanRevision; agentName: string | null; }) { if (revision.author_kind === "agent") { return ( <> {agentName ? : null} {revision.author_name} ); } return ( <> {USER_DISPLAY_NAME} ); } function RevisionRow({ revision, isCurrent, agentName, onRevertClick, onRowClick, }: { revision: TaskPlanRevision; isCurrent: boolean; agentName: string | null; onRevertClick: (rev: TaskPlanRevision) => void; onRowClick: (rev: TaskPlanRevision) => void; }) { // 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); return (
  • {!isCurrent && ( )}
  • ); } function RevertConfirmDialog({ target, isSaving, onCancel, onConfirm, }: { target: TaskPlanRevision | null; isSaving: boolean; onCancel: () => void; onConfirm: () => void | Promise; }): ReactNode { const open = target !== null; return ( { if (!v) onCancel(); }} > Restore to version {target?.revision_number}? This creates a new version with v{target?.revision_number}'s content. Nothing is lost; the current version stays in history. ); }