Create reusable Skeleton component with animate-pulse styling. Replace plain "Loading..." text with structured skeleton placeholders matching card layouts in InitiativeList and inbox page.
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import { useState } from "react";
|
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
import { AlertCircle } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/Skeleton";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { InboxList } from "@/components/InboxList";
|
|
import { QuestionForm } from "@/components/QuestionForm";
|
|
|
|
export const Route = createFileRoute("/inbox")({
|
|
component: InboxPage,
|
|
});
|
|
|
|
function InboxPage() {
|
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
|
|
|
// Live updates: invalidate inbox queries on agent events
|
|
const utils = trpc.useUtils();
|
|
trpc.onAgentUpdate.useSubscription(undefined, {
|
|
onData: () => {
|
|
void utils.listWaitingAgents.invalidate();
|
|
void utils.listMessages.invalidate();
|
|
},
|
|
onError: () => {},
|
|
});
|
|
|
|
// Data fetching
|
|
const agentsQuery = trpc.listWaitingAgents.useQuery();
|
|
const messagesQuery = trpc.listMessages.useQuery({});
|
|
const questionsQuery = trpc.getAgentQuestions.useQuery(
|
|
{ id: selectedAgentId! },
|
|
{ enabled: !!selectedAgentId }
|
|
);
|
|
|
|
// Mutations
|
|
const resumeAgentMutation = trpc.resumeAgent.useMutation({
|
|
onSuccess: () => {
|
|
void utils.listWaitingAgents.invalidate();
|
|
void utils.listMessages.invalidate();
|
|
setSelectedAgentId(null);
|
|
},
|
|
});
|
|
|
|
const respondToMessageMutation = trpc.respondToMessage.useMutation({
|
|
onSuccess: () => {
|
|
void utils.listWaitingAgents.invalidate();
|
|
void utils.listMessages.invalidate();
|
|
setSelectedAgentId(null);
|
|
},
|
|
});
|
|
|
|
// Find selected agent info
|
|
const agents = agentsQuery.data ?? [];
|
|
const messages = messagesQuery.data ?? [];
|
|
const selectedAgent = selectedAgentId
|
|
? agents.find((a) => a.id === selectedAgentId) ?? null
|
|
: null;
|
|
|
|
// Find the latest message for the selected agent
|
|
const selectedMessage = selectedAgentId
|
|
? messages
|
|
.filter((m) => m.senderId === selectedAgentId)
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
)[0] ?? null
|
|
: null;
|
|
|
|
const pendingQuestions = questionsQuery.data ?? null;
|
|
|
|
// Handlers
|
|
function handleRefresh() {
|
|
void utils.listWaitingAgents.invalidate();
|
|
void utils.listMessages.invalidate();
|
|
}
|
|
|
|
function handleSubmitAnswers(answers: Record<string, string>) {
|
|
if (!selectedAgentId) return;
|
|
resumeAgentMutation.mutate({ id: selectedAgentId, answers });
|
|
}
|
|
|
|
function handleDismiss() {
|
|
if (!selectedMessage) return;
|
|
respondToMessageMutation.mutate({
|
|
id: selectedMessage.id,
|
|
response: "Acknowledged",
|
|
});
|
|
}
|
|
|
|
// Loading state
|
|
if (agentsQuery.isLoading && messagesQuery.isLoading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Skeleton header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-6 w-28" />
|
|
<Skeleton className="h-5 w-8 rounded-full" />
|
|
</div>
|
|
<Skeleton className="h-8 w-20" />
|
|
</div>
|
|
{/* Skeleton message rows */}
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Card key={i} className="p-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-3 w-3 rounded-full" />
|
|
<Skeleton className="h-4 w-32" />
|
|
</div>
|
|
<Skeleton className="mt-2 ml-5 h-3 w-full" />
|
|
</div>
|
|
<Skeleton className="h-3 w-16" />
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (agentsQuery.isError || messagesQuery.isError) {
|
|
const errorMessage =
|
|
agentsQuery.error?.message ?? messagesQuery.error?.message ?? "Unknown error";
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
|
<p className="text-sm text-destructive">
|
|
Failed to load inbox: {errorMessage}
|
|
</p>
|
|
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Serialize agents for InboxList (convert Date to string for wire format)
|
|
const serializedAgents = agents.map((a) => ({
|
|
id: a.id,
|
|
name: a.name,
|
|
status: a.status,
|
|
taskId: a.taskId,
|
|
updatedAt: String(a.updatedAt),
|
|
}));
|
|
|
|
// Serialize messages for InboxList
|
|
const serializedMessages = messages.map((m) => ({
|
|
id: m.id,
|
|
senderId: m.senderId,
|
|
content: m.content,
|
|
requiresResponse: m.requiresResponse,
|
|
status: m.status,
|
|
createdAt: String(m.createdAt),
|
|
}));
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
|
|
{/* Left: Inbox List */}
|
|
<InboxList
|
|
agents={serializedAgents}
|
|
messages={serializedMessages}
|
|
selectedAgentId={selectedAgentId}
|
|
onSelectAgent={setSelectedAgentId}
|
|
onRefresh={handleRefresh}
|
|
/>
|
|
|
|
{/* Right: Detail Panel */}
|
|
{selectedAgent && (
|
|
<div className="space-y-4 rounded-lg border border-border p-4">
|
|
{/* Detail Header */}
|
|
<div className="border-b border-border pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-bold">
|
|
{selectedAgent.name}{" "}
|
|
<span className="font-normal text-muted-foreground">
|
|
→ You
|
|
</span>
|
|
</h3>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatRelativeTime(String(selectedAgent.updatedAt))}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Task: {selectedAgent.taskId}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Question Form or Notification Content */}
|
|
{questionsQuery.isLoading && (
|
|
<div className="py-4 text-center text-sm text-muted-foreground">
|
|
Loading questions...
|
|
</div>
|
|
)}
|
|
|
|
{questionsQuery.isError && (
|
|
<div className="py-4 text-center text-sm text-destructive">
|
|
Failed to load questions: {questionsQuery.error.message}
|
|
</div>
|
|
)}
|
|
|
|
{pendingQuestions &&
|
|
pendingQuestions.questions.length > 0 && (
|
|
<QuestionForm
|
|
questions={pendingQuestions.questions.map((q) => ({
|
|
id: q.id,
|
|
question: q.question,
|
|
options: q.options,
|
|
multiSelect: q.multiSelect,
|
|
}))}
|
|
onSubmit={handleSubmitAnswers}
|
|
onCancel={() => setSelectedAgentId(null)}
|
|
isSubmitting={resumeAgentMutation.isPending}
|
|
/>
|
|
)}
|
|
|
|
{resumeAgentMutation.isError && (
|
|
<p className="text-sm text-destructive">
|
|
Error: {resumeAgentMutation.error.message}
|
|
</p>
|
|
)}
|
|
|
|
{/* Notification message (no questions / requiresResponse=false) */}
|
|
{selectedMessage &&
|
|
!selectedMessage.requiresResponse &&
|
|
!questionsQuery.isLoading && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm">{selectedMessage.content}</p>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleDismiss}
|
|
disabled={respondToMessageMutation.isPending}
|
|
>
|
|
{respondToMessageMutation.isPending
|
|
? "Dismissing..."
|
|
: "Dismiss"}
|
|
</Button>
|
|
</div>
|
|
{respondToMessageMutation.isError && (
|
|
<p className="text-sm text-destructive">
|
|
Error: {respondToMessageMutation.error.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* No questions and requires response — message content only */}
|
|
{selectedMessage &&
|
|
selectedMessage.requiresResponse &&
|
|
pendingQuestions &&
|
|
pendingQuestions.questions.length === 0 &&
|
|
!questionsQuery.isLoading && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm">{selectedMessage.content}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Waiting for structured questions...
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty detail panel placeholder */}
|
|
{!selectedAgent && (
|
|
<div className="hidden items-center justify-center rounded-lg border border-dashed border-border p-8 lg:flex">
|
|
<p className="text-sm text-muted-foreground">
|
|
Select an agent to view details
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function formatRelativeTime(isoDate: string): string {
|
|
const now = Date.now();
|
|
const then = new Date(isoDate).getTime();
|
|
const diffMs = now - then;
|
|
const diffSec = Math.floor(diffMs / 1000);
|
|
const diffMin = Math.floor(diffSec / 60);
|
|
const diffHr = Math.floor(diffMin / 60);
|
|
const diffDay = Math.floor(diffHr / 24);
|
|
|
|
if (diffSec < 60) return "just now";
|
|
if (diffMin < 60) return `${diffMin} min ago`;
|
|
if (diffHr < 24) return `${diffHr}h ago`;
|
|
return `${diffDay}d ago`;
|
|
}
|