import { Button } from "@/components/ui/button";
import { useI18n } from "@/i18n/I18nProvider";
import type { TKey } from "@/i18n/dict";
import {
type WorkerJob,
ackEvent,
createApplyAdviceJob,
getJob,
unackEvent,
updateAdviceText,
} from "@/lib/queries";
import { supabase } from "@/lib/supabase";
import type { Severity, WorkerEvent } from "@/types";
import { formatDistanceToNow } from "date-fns";
import {
Check,
Clipboard,
ClipboardCheck,
Pencil,
Sparkles,
Undo2,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
const SEV_COLOR: Record<Severity, string> = {
info: "text-muted-foreground",
suggest: "text-cyan-600",
warn: "text-yellow-600",
block: "text-red-600",
};
type EventRowProps = {
event: WorkerEvent;
onAck?: (event: WorkerEvent) => void;
onUnack?: (event: WorkerEvent) => void;
};
export function EventRow({ event, onAck, onUnack }: EventRowProps) {
const { t } = useI18n();
const ts = formatDistanceToNow(new Date(event.created_at), { addSuffix: true });
const initialText = extractText(event) ?? "";
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initialText);
const [savedText, setSavedText] = useState(initialText);
const [copied, setCopied] = useState(false);
const [confirming, setConfirming] = useState(false);
const [applyJob, setApplyJob] = useState<WorkerJob | null>(null);
const [undoVisible, setUndoVisible] = useState(false);
const undoTimer = useRef<number | null>(null);
// Keep savedText in sync if the row updates from realtime
useEffect(() => {
const t = extractText(event) ?? "";
setSavedText(t);
if (!editing) setDraft(t);
}, [event, editing]);
// Subscribe to apply job updates
useEffect(() => {
if (!applyJob || !supabase) return;
if (applyJob.status === "done" || applyJob.status === "error") return;
const channel = supabase
.channel(`apply_job:${applyJob.id}`)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "worker_jobs",
filter: `id=eq.${applyJob.id}`,
},
(payload) => setApplyJob(payload.new as WorkerJob),
)
.subscribe();
return () => {
supabase?.removeChannel(channel);
};
}, [applyJob]);
// Polling fallback
useEffect(() => {
if (!applyJob) return;
if (applyJob.status === "done" || applyJob.status === "error") return;
const t = setInterval(async () => {
try {
const fresh = await getJob(applyJob.id);
if (fresh) setApplyJob(fresh);
} catch {
/* ignore */
}
}, 4000);
return () => clearInterval(t);
}, [applyJob]);
// When apply succeeds: auto-ack with Undo window
useEffect(() => {
if (applyJob?.status !== "done") return;
if (event.acked_at) return; // already acked, skip
(async () => {
try {
await ackEvent(event.id, "apply");
setUndoVisible(true);
if (undoTimer.current) window.clearTimeout(undoTimer.current);
undoTimer.current = window.setTimeout(() => {
setUndoVisible(false);
undoTimer.current = null;
}, 10_000);
} catch {
/* parent will surface the error via realtime */
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [applyJob?.status]);
const acked = !!event.acked_at;
const showAck = !!onAck && !acked && !editing;
const showUnack = !!onUnack && acked && !editing;
const isAdvice = event.event_type === "advice";
const sevKey = `severity.${event.severity}` as TKey;
const evKey = `event.${event.event_type}` as TKey;
async function handleCopy() {
try {
await navigator.clipboard.writeText(savedText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard blocked */
}
}
async function handleSave() {
try {
await updateAdviceText(event.id, draft);
setSavedText(draft);
setEditing(false);
} catch {
/* keep editing on error */
}
}
function handleCancel() {
setDraft(savedText);
setEditing(false);
}
async function handleApply() {
setConfirming(false);
try {
const j = await createApplyAdviceJob(event.id, event.project, savedText);
setApplyJob(j);
} catch {
/* ignore */
}
}
async function handleUndoApplyAck() {
try {
await unackEvent(event.id);
setUndoVisible(false);
if (undoTimer.current) {
window.clearTimeout(undoTimer.current);
undoTimer.current = null;
}
} catch {
/* ignore */
}
}
const applying =
applyJob?.status === "pending" || applyJob?.status === "running";
return (
<div
className={`border-b py-2 px-3 text-sm flex items-start gap-3 hover:bg-muted/40 ${
acked ? "opacity-60" : ""
}`}
>
<span
className={`text-[10px] uppercase font-semibold w-14 mt-0.5 ${
SEV_COLOR[event.severity] ?? "text-muted-foreground"
}`}
>
{t(sevKey)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-medium ${acked ? "line-through" : ""}`}>{t(evKey)}</span>
{event.path && (
<span className="text-muted-foreground font-mono text-xs truncate">
{event.path}
</span>
)}
{acked && (
<span className="text-[10px] uppercase text-green-600 border border-green-300 rounded px-1">
{t("event.acked")}
{event.acked_by ? ` ${t("event.acked.by", { who: event.acked_by })}` : ""}
</span>
)}
</div>
{/* Body — view or edit mode */}
{editing ? (
<div className="mt-1 space-y-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
className="w-full min-h-[80px] rounded border bg-background p-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSave}>
{t("event.action.save")}
</Button>
<Button size="sm" variant="ghost" onClick={handleCancel}>
{t("event.action.cancel")}
</Button>
</div>
</div>
) : savedText ? (
<div className="text-muted-foreground mt-0.5">{savedText}</div>
) : null}
{/* Apply job status */}
{applyJob?.status === "running" && (
<div className="mt-1 text-xs text-cyan-600">
<Sparkles size={12} className="inline" /> {t("event.apply.running")}
</div>
)}
{applyJob?.status === "done" && (
<div className="mt-1 text-xs text-green-600">
{t("event.apply.done", {
files:
((applyJob.output?.files_changed as string[] | undefined) ?? []).join(", ") ||
"—",
})}
</div>
)}
{applyJob?.status === "error" && (
<div className="mt-1 text-xs text-red-600">
{t("event.apply.failed", { error: applyJob.error ?? "?" })}
</div>
)}
{/* Auto-ack Undo strip */}
{undoVisible && (
<div className="mt-1 text-xs text-green-700 inline-flex items-center gap-2 bg-green-50 border border-green-200 rounded px-2 py-1">
{t("event.apply.acked")}
<button
type="button"
onClick={handleUndoApplyAck}
className="underline underline-offset-2"
>
{t("event.apply.undo")}
</button>
</div>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-muted-foreground whitespace-nowrap">{ts}</span>
{isAdvice && !editing && savedText && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopy();
}}
title={t("event.action.copy")}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
{copied ? <ClipboardCheck size={14} className="text-green-600" /> : <Clipboard size={14} />}
</button>
)}
{isAdvice && !editing && !acked && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditing(true);
}}
title={t("event.action.edit")}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Pencil size={14} />
</button>
)}
{isAdvice && !editing && !acked && !applying && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setConfirming(true);
}}
title={t("event.action.apply")}
className="p-1 rounded hover:bg-muted text-cyan-600 hover:text-cyan-700"
>
<Sparkles size={14} />
</button>
)}
{showAck && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onAck?.(event);
}}
title={t("event.action.ack")}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Check size={14} />
</button>
)}
{showUnack && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUnack?.(event);
}}
title={t("event.action.unack")}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Undo2 size={14} />
</button>
)}
</div>
{/* Confirmation modal */}
{confirming && (
<div
className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4"
onClick={() => setConfirming(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setConfirming(false);
}}
>
<div
className="bg-background border rounded-lg p-5 max-w-md w-full space-y-3"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<h3 className="font-semibold">{t("event.apply.confirm.title")}</h3>
<button
type="button"
onClick={() => setConfirming(false)}
className="text-muted-foreground hover:text-foreground"
>
<X size={16} />
</button>
</div>
<p className="text-sm text-muted-foreground">
{t("event.apply.confirm.body", { project: event.project })}
</p>
<pre className="rounded border bg-muted/40 p-2 text-xs whitespace-pre-wrap max-h-40 overflow-auto">
{savedText}
</pre>
<div className="flex items-center justify-end gap-2">
<Button size="sm" variant="ghost" onClick={() => setConfirming(false)}>
{t("event.action.cancel")}
</Button>
<Button size="sm" onClick={handleApply} className="gap-2">
<Sparkles size={14} />
{t("event.apply.confirm.go")}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
function extractText(ev: WorkerEvent): string | null {
const p = ev.payload as Record<string, unknown>;
if (typeof p?.text === "string") return p.text;
if (typeof p?.error === "string") return `error: ${p.error}`;
return null;
}