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:
Lukas May
2026-02-04 21:55:42 +01:00
parent d6cf309091
commit 3cac453364

View File

@@ -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`;
}