feat(19-04): wire inbox page with data fetching, detail panel, and answer submission
Replaces stub inbox page with full implementation connecting InboxList and QuestionForm components to tRPC backend. Two-column layout with agent list on left and detail panel on right. Handles answer submission via resumeAgent mutation, notification dismissal via respondToMessage, and loading/error states following patterns from initiative detail page.
This commit is contained in:
@@ -1,14 +1,265 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { InboxList } from "@/components/InboxList";
|
||||
import { QuestionForm } from "@/components/QuestionForm";
|
||||
|
||||
export const Route = createFileRoute('/inbox')({
|
||||
export const Route = createFileRoute("/inbox")({
|
||||
component: InboxPage,
|
||||
})
|
||||
});
|
||||
|
||||
function InboxPage() {
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
|
||||
// Data fetching
|
||||
const agentsQuery = trpc.listWaitingAgents.useQuery();
|
||||
const messagesQuery = trpc.listMessages.useQuery({});
|
||||
const questionsQuery = trpc.getAgentQuestions.useQuery(
|
||||
{ id: selectedAgentId! },
|
||||
{ enabled: !!selectedAgentId }
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
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="flex items-center justify-center py-12 text-muted-foreground">
|
||||
Loading inbox...
|
||||
</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>
|
||||
<h1 className="text-2xl font-bold">Agent Inbox</h1>
|
||||
<p className="text-muted-foreground mt-1">Content coming in Phase 19</p>
|
||||
<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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user