devist 0.18.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
import InboxCard from "@/components/InboxCard";
import { useI18n } from "@/i18n/I18nProvider";
import { ackEvent, listEventsWithAck, unackEvent } from "@/lib/queries";
import { useRealtimeWorkerEvents } from "@/lib/realtime";
import type { WorkerEvent } from "@/types";
import { useCallback, useEffect, useState } from "react";

const ACTIONABLE_SEVS = ["suggest", "warn", "block"] as const;

export default function Inbox() {
  const { t } = useI18n();
  const [events, setEvents] = useState<WorkerEvent[]>([]);
  const [includeAcked, setIncludeAcked] = useState(false);
  const [includeInfo, setIncludeInfo] = useState(false);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let alive = true;
    setLoading(true);
    listEventsWithAck({
      eventType: "advice",
      limit: 100,
      includeAcked,
      severities: includeInfo
        ? [...ACTIONABLE_SEVS, "info"]
        : [...ACTIONABLE_SEVS],
    })
      .then((r) => alive && setEvents(r))
      .catch((e) => alive && setError(String(e?.message ?? e)))
      .finally(() => alive && setLoading(false));
    return () => {
      alive = false;
    };
  }, [includeAcked, includeInfo]);

  useRealtimeWorkerEvents({
    onInsert: (ev) => {
      if (ev.event_type !== "advice") return;
      if (!includeInfo && ev.severity === "info") return;
      setEvents((prev) => [ev, ...prev]);
    },
    onUpdate: (ev) => {
      setEvents((prev) =>
        prev.map((e) => (e.id === ev.id ? { ...e, ...ev } : e)),
      );
    },
  });

  const handleAck = useCallback(async (ev: WorkerEvent) => {
    // optimistic
    setEvents((prev) =>
      prev.map((e) =>
        e.id === ev.id ? { ...e, acked_at: new Date().toISOString() } : e,
      ),
    );
    try {
      await ackEvent(ev.id);
    } catch (err) {
      setError(String((err as Error)?.message ?? err));
    }
  }, []);

  const handleUnack = useCallback(async (ev: WorkerEvent) => {
    setEvents((prev) =>
      prev.map((e) => (e.id === ev.id ? { ...e, acked_at: null } : e)),
    );
    try {
      await unackEvent(ev.id);
    } catch (err) {
      setError(String((err as Error)?.message ?? err));
    }
  }, []);

  const visible = includeAcked ? events : events.filter((e) => !e.acked_at);

  return (
    <div className="p-6 space-y-4">
      <header className="flex items-end justify-between">
        <h1 className="text-xl font-semibold">{t("inbox.title")}</h1>
        <div className="flex items-center gap-4">
          <label className="flex items-center gap-2 text-xs text-muted-foreground">
            <input
              type="checkbox"
              checked={includeInfo}
              onChange={(e) => setIncludeInfo(e.target.checked)}
            />
            {t("inbox.showInfo")}
          </label>
          <label className="flex items-center gap-2 text-xs text-muted-foreground">
            <input
              type="checkbox"
              checked={includeAcked}
              onChange={(e) => setIncludeAcked(e.target.checked)}
            />
            {t("inbox.showAcked")}
          </label>
        </div>
      </header>

      {error && (
        <div className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-700">
          {error}
        </div>
      )}

      <div className="rounded-md border bg-card">
        {loading && <div className="p-4 text-sm text-muted-foreground">{t("common.loading")}</div>}
        {!loading && visible.length === 0 && (
          <div className="p-4 text-sm text-muted-foreground">
            {includeAcked ? t("inbox.empty.none") : t("inbox.empty.zero")}{" "}
            {t("inbox.empty.hint")}
          </div>
        )}
        {visible.map((ev) => (
          <InboxCard
            key={ev.id}
            event={ev}
            onAck={handleAck}
            onUnack={handleUnack}
          />
        ))}
      </div>
    </div>
  );
}