devist 0.6.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 { ackEvent, listEventsWithAck, unackEvent } from "@/lib/queries";
import { useRealtimeWorkerEvents } from "@/lib/realtime";
import type { WorkerEvent } from "@/types";
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";

export default function Inbox() {
  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);
    listEventsWithAck({ eventType: "advice", limit: 100, includeAcked })
      .then((r) => alive && setEvents(r))
      .catch((e) => alive && setError(String(e?.message ?? e)))
      .finally(() => alive && setLoading(false));
    return () => {
      alive = false;
    };
  }, [includeAcked]);

  useRealtimeWorkerEvents({
    onInsert: (ev) => {
      if (ev.event_type !== "advice") 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">
        <div>
          <h1 className="text-xl font-semibold">Advice inbox</h1>
          <p className="text-sm text-muted-foreground">
            AI-generated suggestions across all projects, newest first.
          </p>
        </div>
        <label className="flex items-center gap-2 text-xs text-muted-foreground">
          <input
            type="checkbox"
            checked={includeAcked}
            onChange={(e) => setIncludeAcked(e.target.checked)}
          />
          Show acked
        </label>
      </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">Loading…</div>}
        {!loading && visible.length === 0 && (
          <div className="p-4 text-sm text-muted-foreground">
            {includeAcked ? "No advice yet." : "Inbox zero."} The worker generates
            advice after idle bursts; check{" "}
            <code className="font-mono">~/.devist/worker/worker.log</code>.
          </div>
        )}
        {visible.map((ev) => (
          <Link
            key={ev.id}
            to={`/projects/${encodeURIComponent(ev.project)}`}
            className="block"
          >
            <EventRow event={ev} onAck={handleAck} onUnack={handleUnack} />
          </Link>
        ))}
      </div>
    </div>
  );
}