import { useI18n } from "@/i18n/I18nProvider";
import type { TKey } from "@/i18n/dict";
import { dateFnsLocale } from "@/lib/dateFns";
import {
createApplyAdviceJob,
createExplainAdviceJob,
getJob,
markAdviceIntentional,
} from "@/lib/queries";
import type { Severity, WorkerEvent } from "@/types";
import { formatDistanceToNow } from "date-fns";
import {
Check,
CheckCircle2,
Clipboard,
ClipboardCheck,
HelpCircle,
Sparkles,
Undo2,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
const SEV_COLOR: Record<Severity, string> = {
info: "text-muted-foreground border-muted",
suggest: "text-cyan-700 border-cyan-300 bg-cyan-50",
warn: "text-yellow-700 border-yellow-300 bg-yellow-50",
block: "text-red-700 border-red-300 bg-red-50",
};
type Props = {
event: WorkerEvent;
onConfirm?: (event: WorkerEvent) => void;
onUnconfirm?: (event: WorkerEvent) => void;
};
type Panel = null | "intentional" | "explain";
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 120_000;
export default function InboxCard({ event, onConfirm, onUnconfirm }: Props) {
const { t, lang } = useI18n();
const [copied, setCopied] = useState(false);
const [panel, setPanel] = useState<Panel>(null);
const [intentReason, setIntentReason] = useState("");
const [intentSaving, setIntentSaving] = useState(false);
const [explanation, setExplanation] = useState<string | null>(null);
const [explainLoading, setExplainLoading] = useState(false);
const [explainError, setExplainError] = useState<string | null>(null);
const [explainQuestion, setExplainQuestion] = useState("");
const [applyState, setApplyState] = useState<
null | "running" | "done" | "error"
>(null);
const [applyMessage, setApplyMessage] = useState<string | null>(null);
const cancelPollRef = useRef<boolean>(false);
const text = extractText(event) ?? "";
const title = deriveTitle(text);
const confirmed = !!event.confirmed_at;
const sevKey = `severity.${event.severity}` as TKey;
const ts = formatDistanceToNow(new Date(event.created_at), {
addSuffix: true,
locale: dateFnsLocale(lang),
});
useEffect(
() => () => {
cancelPollRef.current = true;
},
[],
);
async function handleCopy() {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard blocked */
}
}
async function handleApply() {
setApplyState("running");
setApplyMessage(null);
try {
const job = await createApplyAdviceJob(event.id, event.project, text);
const final = await pollJob(job.id);
if (final?.status === "done") {
const out = (final.output ?? {}) as Record<string, unknown>;
const files = (out.files_changed as string[] | undefined) ?? [];
setApplyState("done");
setApplyMessage(
t("event.apply.done", {
files: files.length ? files.join(", ") : "—",
}),
);
} else if (final?.status === "error") {
setApplyState("error");
setApplyMessage(
t("event.apply.failed", { error: final.error ?? "unknown" }),
);
}
} catch (e) {
setApplyState("error");
setApplyMessage(
t("event.apply.failed", { error: (e as Error)?.message ?? "" }),
);
}
}
async function handleExplain(question?: string) {
setExplainLoading(true);
setExplainError(null);
setExplanation(null);
try {
const job = await createExplainAdviceJob(
event.id,
event.project,
text,
question,
);
const final = await pollJob(job.id);
if (final?.status === "done") {
const out = (final.output ?? {}) as Record<string, unknown>;
setExplanation((out.explanation as string) ?? "");
} else {
setExplainError(final?.error ?? "unknown");
}
} catch (e) {
setExplainError((e as Error)?.message ?? String(e));
} finally {
setExplainLoading(false);
}
}
async function handleSaveIntentional() {
if (!intentReason.trim()) return;
setIntentSaving(true);
try {
await markAdviceIntentional(
event.id,
event.project,
text,
intentReason.trim(),
);
setPanel(null);
setIntentReason("");
// Optimistic local confirm — parent state will also update via realtime.
if (onConfirm) onConfirm(event);
} catch (e) {
setExplainError((e as Error)?.message ?? String(e));
} finally {
setIntentSaving(false);
}
}
function togglePanel(next: Panel) {
setPanel((prev) => (prev === next ? null : next));
// Auto-fire explain on first open with no question.
if (next === "explain" && panel !== "explain" && explanation === null && !explainLoading) {
handleExplain();
}
}
return (
<article
className={`block border-b last:border-b-0 ${
confirmed ? "opacity-60" : ""
} hover:bg-muted/20`}
>
<div className="px-4 py-3 space-y-2">
{/* Header row */}
<header className="flex items-center justify-between gap-3 text-xs">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<span
className={`uppercase font-semibold border rounded px-1.5 py-0.5 ${SEV_COLOR[event.severity]}`}
>
{t(sevKey)}
</span>
<Link
to={`/dashboard/projects/${encodeURIComponent(event.project)}`}
className="font-medium text-foreground truncate hover:underline"
>
{event.project}
</Link>
{event.path && (
<>
<span className="text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground truncate">
{event.path}
</span>
</>
)}
{confirmed && (
<span className="uppercase text-green-600 border border-green-300 rounded px-1">
{t("event.confirmed")}
{event.confirmed_by ? ` ${t("event.confirmed.by", { who: event.confirmed_by })}` : ""}
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-muted-foreground whitespace-nowrap">{ts}</span>
{text && (
<button
type="button"
onClick={handleCopy}
title={t("event.action.copy")}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
{copied ? (
<ClipboardCheck size={13} className="text-green-600" />
) : (
<Clipboard size={13} />
)}
</button>
)}
{onUnconfirm && confirmed && (
<button
type="button"
onClick={() => onUnconfirm(event)}
title={t("event.action.unconfirm")}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Undo2 size={13} />
</button>
)}
</div>
</header>
{/* Body */}
{text && (
<div className="space-y-1">
{title && (
<div className={`text-xs font-medium ${confirmed ? "line-through" : ""}`}>
{title}
</div>
)}
{text !== title && (
<p className="text-xs text-muted-foreground leading-relaxed whitespace-pre-wrap">
{text}
</p>
)}
</div>
)}
{/* Reaction footer (only when not yet confirmed) */}
{!confirmed && (
<footer className="flex flex-wrap gap-1.5 pt-1 text-xs">
<ActionButton
icon={<Sparkles size={12} />}
label={t("advice.action.apply")}
onClick={handleApply}
loading={applyState === "running"}
disabled={applyState === "done"}
/>
{onConfirm && (
<ActionButton
icon={<Check size={12} />}
label={t("advice.action.done")}
onClick={() => onConfirm(event)}
/>
)}
<ActionButton
icon={<X size={12} />}
label={t("advice.action.intentional")}
onClick={() => togglePanel("intentional")}
active={panel === "intentional"}
/>
<ActionButton
icon={<HelpCircle size={12} />}
label={t("advice.action.explain")}
onClick={() => togglePanel("explain")}
active={panel === "explain"}
/>
</footer>
)}
{applyMessage && (
<div
className={`text-xs px-2 py-1.5 rounded border ${
applyState === "done"
? "border-green-300 bg-green-50 text-green-800"
: applyState === "error"
? "border-red-300 bg-red-50 text-red-800"
: "border-muted bg-muted/30"
}`}
>
<CheckCircle2 size={11} className="inline mr-1" />
{applyMessage}
</div>
)}
{/* Intentional panel */}
{panel === "intentional" && (
<div className="rounded border border-muted bg-muted/20 p-3 space-y-2">
<div className="text-xs font-medium">
{t("advice.intentional.heading")}
</div>
<textarea
value={intentReason}
onChange={(e) => setIntentReason(e.target.value)}
placeholder={t("advice.intentional.placeholder")}
rows={2}
className="w-full resize-none rounded border bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => {
setPanel(null);
setIntentReason("");
}}
className="text-xs px-2 py-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted"
>
{t("advice.intentional.cancel")}
</button>
<button
type="button"
onClick={handleSaveIntentional}
disabled={!intentReason.trim() || intentSaving}
className="text-xs px-2 py-1 rounded bg-foreground text-background hover:opacity-90 disabled:opacity-40"
>
{intentSaving ? "…" : t("advice.intentional.save")}
</button>
</div>
</div>
)}
{/* Explain panel */}
{panel === "explain" && (
<div className="rounded border border-muted bg-muted/20 p-3 space-y-2">
<div className="text-xs font-medium">
{t("advice.explain.heading")}
</div>
{explainLoading && (
<div className="text-xs text-muted-foreground italic">
{t("advice.explain.loading")}
</div>
)}
{explainError && (
<div className="text-xs text-red-700">
{t("advice.explain.error", { error: explainError })}
</div>
)}
{explanation && (
<p className="text-xs leading-relaxed whitespace-pre-wrap">
{explanation}
</p>
)}
{/* Follow-up question — only after first explanation lands */}
{explanation !== null && !explainLoading && (
<div className="flex gap-1.5 pt-1">
<input
type="text"
value={explainQuestion}
onChange={(e) => setExplainQuestion(e.target.value)}
placeholder={t("advice.explain.placeholder")}
className="flex-1 rounded border bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === "Enter" && explainQuestion.trim()) {
handleExplain(explainQuestion.trim());
setExplainQuestion("");
}
}}
/>
<button
type="button"
onClick={() => {
if (explainQuestion.trim()) {
handleExplain(explainQuestion.trim());
setExplainQuestion("");
}
}}
disabled={!explainQuestion.trim()}
className="text-xs px-2 py-1 rounded bg-foreground text-background hover:opacity-90 disabled:opacity-40"
>
{t("advice.explain.ask")}
</button>
</div>
)}
</div>
)}
</div>
</article>
);
}
function ActionButton({
icon,
label,
onClick,
loading = false,
disabled = false,
active = false,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
loading?: boolean;
disabled?: boolean;
active?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
className={`inline-flex items-center gap-1 px-2 py-1 rounded border text-xs transition-colors ${
active
? "bg-foreground text-background border-foreground"
: "bg-background text-muted-foreground border-muted hover:text-foreground hover:bg-muted/50"
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
{icon}
{loading ? "…" : label}
</button>
);
}
async function pollJob(id: number) {
const start = Date.now();
while (Date.now() - start < POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
const job = await getJob(id);
if (!job) continue;
if (job.status === "done" || job.status === "error") return job;
}
throw new Error("job timed out");
}
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;
}
function deriveTitle(text: string): string {
if (!text) return "";
const oneLine = text.split(/\n/, 1)[0]?.trim() ?? "";
const sentenceEnd = oneLine.search(/[.!?。!?]\s|$/);
const candidate = sentenceEnd > 0 ? oneLine.slice(0, sentenceEnd + 1) : oneLine;
return candidate.length > 80 ? `${candidate.slice(0, 80)}…` : candidate;
}