devist 0.20.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
import { useI18n } from "@/i18n/I18nProvider";
import type { TKey } from "@/i18n/dict";
import { dateFnsLocale } from "@/lib/dateFns";
import type { Severity, WorkerEvent } from "@/types";
import { formatDistanceToNow } from "date-fns";
import { Check, Clipboard, ClipboardCheck, Undo2 } from "lucide-react";
import { 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;
  onAck?: (event: WorkerEvent) => void;
  onUnack?: (event: WorkerEvent) => void;
};

/**
 * Card layout for the Inbox view: header row with project + severity +
 * time + actions, body row with derived title + full advice text. The
 * full body spans the card width for easier reading.
 */
export default function InboxCard({ event, onAck, onUnack }: Props) {
  const { t, lang } = useI18n();
  const [copied, setCopied] = useState(false);

  const text = extractText(event) ?? "";
  const title = deriveTitle(text);
  const acked = !!event.acked_at;
  const sevKey = `severity.${event.severity}` as TKey;

  const ts = formatDistanceToNow(new Date(event.created_at), {
    addSuffix: true,
    locale: dateFnsLocale(lang),
  });

  async function handleCopy(e: React.MouseEvent) {
    e.preventDefault();
    e.stopPropagation();
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch {
      /* clipboard blocked */
    }
  }

  return (
    <Link
      to={`/dashboard/projects/${encodeURIComponent(event.project)}`}
      className={`block border-b last:border-b-0 hover:bg-muted/30 ${
        acked ? "opacity-60" : ""
      }`}
    >
      <article className="px-4 py-3 space-y-2">
        {/* Header row: severity · project · path | time + actions */}
        <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>
            <span className="font-medium text-foreground truncate">
              {event.project}
            </span>
            {event.path && (
              <>
                <span className="text-muted-foreground">·</span>
                <span className="font-mono text-muted-foreground truncate">
                  {event.path}
                </span>
              </>
            )}
            {acked && (
              <span className="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>
          <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>
            )}
            {onAck && !acked && (
              <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={13} />
              </button>
            )}
            {onUnack && acked && (
              <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={13} />
              </button>
            )}
          </div>
        </header>

        {/* Body — title + full text, spans full card width */}
        {text && (
          <div className="space-y-1">
            {title && (
              <div className={`text-xs font-medium ${acked ? "line-through" : ""}`}>
                {title}
              </div>
            )}
            {text !== title && (
              <p className="text-xs text-muted-foreground leading-relaxed whitespace-pre-wrap">
                {text}
              </p>
            )}
          </div>
        )}
      </article>
    </Link>
  );
}

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;
}

/** Use the first sentence (up to 80 chars) of the advice as a title. */
function deriveTitle(text: string): string {
  if (!text) return "";
  const oneLine = text.split(/\n/, 1)[0]?.trim() ?? "";
  // Stop at first sentence end if it's short enough; otherwise truncate.
  const sentenceEnd = oneLine.search(/[.!?。!?]\s|$/);
  const candidate = sentenceEnd > 0 ? oneLine.slice(0, sentenceEnd + 1) : oneLine;
  return candidate.length > 80 ? `${candidate.slice(0, 80)}…` : candidate;
}