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 { EventRow } from "@/components/EventRow";
import ProjectHistory from "@/components/ProjectHistory";
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";

// Activity tab shows actionable severities only. Info goes to History tab.
const ACTIONABLE_SEVS: Severity[] = ["suggest", "warn", "block"];
type Tab = "activity" | "history";

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

  useEffect(() => {
    let alive = true;
    setLoading(true);
    setError(null);
    listEventsWithAck({
      project,
      severities: ACTIONABLE_SEVS,
      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]);

  useRealtimeWorkerEvents(
    {
      onInsert: (ev) => {
        if (!ACTIONABLE_SEVS.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));
    }
  }, []);

  async function loadMore() {
    if (events.length === 0) return;
    const before = events[events.length - 1].created_at;
    const more = await listEventsWithAck({
      project,
      severities: ACTIONABLE_SEVS,
      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="space-y-3">
        <div className="flex items-center justify-between gap-4 flex-wrap">
          <h1 className="text-xl font-semibold font-mono">{project}</h1>
          {tab === "activity" && (
            <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>
          )}
        </div>

        {/* Tabs */}
        <div className="flex border-b">
          {(["activity", "history"] as const).map((k) => (
            <button
              key={k}
              type="button"
              onClick={() => setTab(k)}
              className={`px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${
                tab === k
                  ? "border-foreground text-foreground font-medium"
                  : "border-transparent text-muted-foreground hover:text-foreground"
              }`}
            >
              {t(`timeline.tab.${k}` as const)}
            </button>
          ))}
        </div>
      </header>

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

      {tab === "activity" ? (
        <>
          <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>
          )}
        </>
      ) : (
        <ProjectHistory project={project} />
      )}
    </div>
  );
}