devist 0.23.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
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;
}