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