feat(19-02): create InboxList component with filter/sort controls
InboxList joins agents with their latest messages, provides filter (all/waiting/completed) and sort (newest/oldest) controls, renders MessageCard instances, and shows empty state when no messages match.
This commit is contained in:
182
packages/web/src/components/InboxList.tsx
Normal file
182
packages/web/src/components/InboxList.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { MessageCard } from "@/components/MessageCard";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
taskId: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
senderId: string | null;
|
||||||
|
content: string;
|
||||||
|
requiresResponse: boolean;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterValue = "all" | "waiting" | "completed";
|
||||||
|
type SortValue = "newest" | "oldest";
|
||||||
|
|
||||||
|
interface InboxListProps {
|
||||||
|
agents: Agent[];
|
||||||
|
messages: Message[];
|
||||||
|
selectedAgentId: string | null;
|
||||||
|
onSelectAgent: (agentId: string) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JoinedEntry {
|
||||||
|
agent: Agent;
|
||||||
|
message: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboxList({
|
||||||
|
agents,
|
||||||
|
messages,
|
||||||
|
selectedAgentId,
|
||||||
|
onSelectAgent,
|
||||||
|
onRefresh,
|
||||||
|
}: InboxListProps) {
|
||||||
|
const [filter, setFilter] = useState<FilterValue>("all");
|
||||||
|
const [sort, setSort] = useState<SortValue>("newest");
|
||||||
|
|
||||||
|
// Join agents with their latest message (match message.senderId to agent.id)
|
||||||
|
const joined = useMemo(() => {
|
||||||
|
const latestByAgent = new Map<string, Message>();
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.senderId === null) continue;
|
||||||
|
const existing = latestByAgent.get(msg.senderId);
|
||||||
|
if (!existing || new Date(msg.createdAt) > new Date(existing.createdAt)) {
|
||||||
|
latestByAgent.set(msg.senderId, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: JoinedEntry[] = [];
|
||||||
|
for (const agent of agents) {
|
||||||
|
const msg = latestByAgent.get(agent.id);
|
||||||
|
if (msg) {
|
||||||
|
entries.push({ agent, message: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}, [agents, messages]);
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
switch (filter) {
|
||||||
|
case "waiting":
|
||||||
|
return joined.filter((e) => e.message.requiresResponse);
|
||||||
|
case "completed":
|
||||||
|
return joined.filter((e) => !e.message.requiresResponse);
|
||||||
|
default:
|
||||||
|
return joined;
|
||||||
|
}
|
||||||
|
}, [joined, filter]);
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const items = [...filtered];
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const ta = new Date(a.message.createdAt).getTime();
|
||||||
|
const tb = new Date(b.message.createdAt).getTime();
|
||||||
|
return sort === "newest" ? tb - ta : ta - tb;
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}, [filtered, sort]);
|
||||||
|
|
||||||
|
const filterOptions: { value: FilterValue; label: string }[] = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "waiting", label: "Waiting" },
|
||||||
|
{ value: "completed", label: "Completed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sortOptions: { value: SortValue; label: string }[] = [
|
||||||
|
{ value: "newest", label: "Newest" },
|
||||||
|
{ value: "oldest", label: "Oldest" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">Agent Inbox</h2>
|
||||||
|
<Badge variant="secondary">{joined.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||||
|
<RefreshCw className="mr-1 h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter and Sort controls */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Filter:</span>
|
||||||
|
{filterOptions.map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
variant={filter === opt.value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => setFilter(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Sort:</span>
|
||||||
|
{sortOptions.map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
variant={sort === opt.value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn("h-7 px-2 text-xs")}
|
||||||
|
onClick={() => setSort(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message list or empty state */}
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-16">
|
||||||
|
<p className="text-lg font-medium text-muted-foreground">
|
||||||
|
No pending messages
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Agents will appear here when they have questions or status updates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sorted.map((entry) => (
|
||||||
|
<MessageCard
|
||||||
|
key={entry.message.id}
|
||||||
|
agentName={entry.agent.name}
|
||||||
|
agentStatus={entry.agent.status}
|
||||||
|
preview={entry.message.content}
|
||||||
|
timestamp={entry.message.createdAt}
|
||||||
|
requiresResponse={entry.message.requiresResponse}
|
||||||
|
isSelected={selectedAgentId === entry.agent.id}
|
||||||
|
onClick={() => onSelectAgent(entry.agent.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user