Files
openclaw-market/components/dashboard/lobster-feed.tsx
richarjiang fa4c458eda init
2026-03-13 11:00:01 +08:00

152 lines
5.1 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useSSE } from "@/hooks/use-sse";
interface FeedItem {
id: string;
type: "task" | "online" | "offline";
lobsterName: string;
city?: string;
country?: string;
summary?: string;
durationMs?: number;
timestamp: number;
}
export function LobsterFeed() {
const [items, setItems] = useState<FeedItem[]>([]);
const handleEvent = useCallback((event: { type: string; data: Record<string, unknown> }) => {
if (event.type === "task" || event.type === "online" || event.type === "offline") {
const newItem: FeedItem = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
type: event.type as FeedItem["type"],
lobsterName: (event.data.lobsterName as string) ?? "Unknown",
city: event.data.city as string | undefined,
country: event.data.country as string | undefined,
summary: event.data.summary as string | undefined,
durationMs: event.data.durationMs as number | undefined,
timestamp: Date.now(),
};
setItems((prev) => [newItem, ...prev].slice(0, 50));
}
}, []);
useSSE({
url: "/api/v1/stream",
onEvent: handleEvent,
});
// Load initial recent tasks
useEffect(() => {
const fetchRecent = async () => {
try {
const res = await fetch("/api/v1/lobsters?limit=10");
if (res.ok) {
const data = await res.json();
const feedItems: FeedItem[] = (data.lobsters ?? [])
.filter((l: Record<string, unknown>) => l.lastTask)
.map((l: Record<string, unknown>) => {
const task = l.lastTask as Record<string, unknown>;
return {
id: `init-${l.id}`,
type: "task" as const,
lobsterName: l.name as string,
city: l.city as string,
country: l.country as string,
summary: task.summary as string,
durationMs: task.durationMs as number,
timestamp: new Date(task.timestamp as string).getTime(),
};
});
setItems(feedItems);
}
} catch {
// will populate via SSE
}
};
fetchRecent();
}, []);
const formatDuration = (ms?: number) => {
if (!ms) return "";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
const getIcon = (type: string) => {
switch (type) {
case "task":
return "⚡";
case "online":
return "🟢";
case "offline":
return "⭕";
default:
return "🦞";
}
};
return (
<Card className="border-white/5">
<CardHeader className="pb-2">
<CardTitle>Live Feed</CardTitle>
</CardHeader>
<CardContent className="max-h-[400px] overflow-y-auto p-4 pt-0">
<AnimatePresence initial={false}>
{items.length === 0 ? (
<p className="py-8 text-center text-xs text-[var(--text-muted)]">
Waiting for lobster activity...
</p>
) : (
items.map((item) => (
<motion.div
key={item.id}
initial={{ opacity: 0, height: 0, y: -10 }}
animate={{ opacity: 1, height: "auto", y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="border-b border-white/5 py-2.5 last:border-0"
>
<div className="flex items-start gap-2">
<span className="mt-0.5 text-sm">{getIcon(item.type)}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-medium text-[var(--accent-cyan)]">
{item.lobsterName}
</span>
{item.city && (
<span className="text-xs text-[var(--text-muted)]">
{item.city}, {item.country}
</span>
)}
</div>
{item.summary && (
<p className="mt-0.5 truncate text-xs text-[var(--text-secondary)]">
{item.summary}
</p>
)}
<div className="mt-1 flex items-center gap-2">
{item.durationMs && (
<Badge variant="secondary">{formatDuration(item.durationMs)}</Badge>
)}
<span className="text-[10px] text-[var(--text-muted)]">
{new Date(item.timestamp).toLocaleTimeString()}
</span>
</div>
</div>
</div>
</motion.div>
))
)}
</AnimatePresence>
</CardContent>
</Card>
);
}