From 3cac453364ca3770a3dd17406e8a246e232e86f2 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Wed, 4 Feb 2026 21:55:42 +0100 Subject: [PATCH] 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. --- packages/web/src/routes/inbox.tsx | 265 +++++++++++++++++++++++++++++- 1 file changed, 258 insertions(+), 7 deletions(-) diff --git a/packages/web/src/routes/inbox.tsx b/packages/web/src/routes/inbox.tsx index d7e3928..23d0f43 100644 --- a/packages/web/src/routes/inbox.tsx +++ b/packages/web/src/routes/inbox.tsx @@ -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(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) { + 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 ( +
+ Loading inbox... +
+ ); + } + + // Error state + if (agentsQuery.isError || messagesQuery.isError) { + const errorMessage = + agentsQuery.error?.message ?? messagesQuery.error?.message ?? "Unknown error"; + return ( +
+ +

+ Failed to load inbox: {errorMessage} +

+ +
+ ); + } + + // 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 ( -
-

Agent Inbox

-

Content coming in Phase 19

+
+
+ {/* Left: Inbox List */} + + + {/* Right: Detail Panel */} + {selectedAgent && ( +
+ {/* Detail Header */} +
+
+

+ {selectedAgent.name}{" "} + + → You + +

+ + {formatRelativeTime(String(selectedAgent.updatedAt))} + +
+

+ Task: {selectedAgent.taskId} +

+
+ + {/* Question Form or Notification Content */} + {questionsQuery.isLoading && ( +
+ Loading questions... +
+ )} + + {questionsQuery.isError && ( +
+ Failed to load questions: {questionsQuery.error.message} +
+ )} + + {pendingQuestions && + pendingQuestions.questions.length > 0 && ( + ({ + id: q.id, + question: q.question, + options: q.options, + multiSelect: q.multiSelect, + }))} + onSubmit={handleSubmitAnswers} + onCancel={() => setSelectedAgentId(null)} + isSubmitting={resumeAgentMutation.isPending} + /> + )} + + {resumeAgentMutation.isError && ( +

+ Error: {resumeAgentMutation.error.message} +

+ )} + + {/* Notification message (no questions / requiresResponse=false) */} + {selectedMessage && + !selectedMessage.requiresResponse && + !questionsQuery.isLoading && ( +
+

{selectedMessage.content}

+
+ +
+ {respondToMessageMutation.isError && ( +

+ Error: {respondToMessageMutation.error.message} +

+ )} +
+ )} + + {/* No questions and requires response — message content only */} + {selectedMessage && + selectedMessage.requiresResponse && + pendingQuestions && + pendingQuestions.questions.length === 0 && + !questionsQuery.isLoading && ( +
+

{selectedMessage.content}

+

+ Waiting for structured questions... +

+
+ )} +
+ )} + + {/* Empty detail panel placeholder */} + {!selectedAgent && ( +
+

+ Select an agent to view details +

+
+ )} +
- ) + ); +} + +// --------------------------------------------------------------------------- +// 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`; }