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