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,
|
component: InboxPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function InboxPage() {
|
function InboxPage() {
|
||||||
return (
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Agent Inbox</h1>
|
// Data fetching
|
||||||
<p className="text-muted-foreground mt-1">Content coming in Phase 19</p>
|
const agentsQuery = trpc.listWaitingAgents.useQuery();
|
||||||
</div>
|
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 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