devist 0.17.2

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

const ALL_SEVERITIES: Severity[] = ["info", "suggest", "warn", "block"];

export default function ProjectTimeline() {
  const { t } = useI18n();
  const { name } = useParams<{ name: string }>();
  const project = useMemo(() => decodeURIComponent(name ?? ""), [name]);
  const [events, setEvents] = useState<WorkerEvent[]>([]);
  const [filter, setFilter] = useState<Severity[]>(ALL_SEVERITIES);
  const [includeAcked, setIncludeAcked] = useState(true);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let alive = true;
    setLoading(true);
    setError(null);
    listEventsWithAck({
      project,
      severities: filter,
      limit: 100,
      includeAcked: true,
    })
      .then((r) => alive && setEvents(r))
      .catch((e) => alive && setError(String(e?.message ?? e)))
      .finally(() => alive && setLoading(false));
    return () => {
      alive = false;
    };
  }, [project, filter]);

  useRealtimeWorkerEvents(
    {
      onInsert: (ev) => {
        if (!filter.includes(ev.severity)) return;
        setEvents((prev) => [ev, ...prev]);
      },
      onUpdate: (ev) => {
        setEvents((prev) =>
          prev.map((e) => (e.id === ev.id ? { ...e, ...ev } : e)),
        );
      },
    },
    project,
  );

  const handleAck = useCallback(async (ev: WorkerEvent) => {
    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));
    }
  }, []);

  function toggle(sev: Severity) {
    setFilter((prev) =>
      prev.includes(sev) ? prev.filter((s) => s !== sev) : [...prev, sev],
    );
  }

  async function loadMore() {
    if (events.length === 0) return;
    const before = events[events.length - 1].created_at;
    const more = await listEventsWithAck({
      project,
      severities: filter,
      limit: 100,
      before,
      includeAcked: true,
    });
    setEvents([...events, ...more]);
  }

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

  return (
    <div className="p-6 space-y-4">
      <header className="flex items-center justify-between gap-4 flex-wrap">
        <div>
          <h1 className="text-xl font-semibold font-mono">{project}</h1>
          <p className="text-sm text-muted-foreground">
            {t("timeline.loaded", { n: visible.length, total: events.length })}
          </p>
        </div>
        <div className="flex items-center gap-3">
          <label className="flex items-center gap-2 text-xs text-muted-foreground">
            <input
              type="checkbox"
              checked={includeAcked}
              onChange={(e) => setIncludeAcked(e.target.checked)}
            />
            {t("timeline.showAcked")}
          </label>
          <div className="flex gap-1">
            {ALL_SEVERITIES.map((s) => (
              <button
                key={s}
                type="button"
                onClick={() => toggle(s)}
                className={`text-xs px-2 py-1 rounded border ${
                  filter.includes(s)
                    ? "bg-foreground text-background"
                    : "bg-background text-muted-foreground"
                }`}
              >
                {t(`severity.${s}` as const)}
              </button>
            ))}
          </div>
        </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 && events.length === 0 && (
          <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">{t("timeline.empty")}</div>
        )}
        {visible.map((ev) => (
          <EventRow
            key={ev.id}
            event={ev}
            onAck={ev.event_type === "advice" ? handleAck : undefined}
            onUnack={ev.event_type === "advice" ? handleUnack : undefined}
          />
        ))}
      </div>

      {events.length >= 100 && (
        <div className="flex justify-center">
          <Button variant="ghost" size="sm" onClick={loadMore}>
            {t("timeline.loadMore")}
          </Button>
        </div>
      )}
    </div>
  );
}