Replaces HQWaitingForInputSection with a two-column inline panel that lets users answer agent questions directly from HQ without navigating to /inbox. Adds SSE invalidation for listWaitingAgents/listMessages, useState for agent selection, and all required mutations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import { createFileRoute } from "@tanstack/react-router";
|
|
import { useState } from "react";
|
|
import { motion } from "motion/react";
|
|
import { toast } from "sonner";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { useLiveUpdates, type LiveUpdateRule } from "@/hooks";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Button } from "@/components/ui/button";
|
|
import { InboxList } from "@/components/InboxList";
|
|
import { InboxDetailPanel } from "@/components/InboxDetailPanel";
|
|
import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
|
|
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
|
|
import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection";
|
|
import { HQBlockedSection } from "@/components/hq/HQBlockedSection";
|
|
import { HQEmptyState } from "@/components/hq/HQEmptyState";
|
|
|
|
export const Route = createFileRoute("/hq")({
|
|
component: HeadquartersPage,
|
|
});
|
|
|
|
const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [
|
|
{ prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] },
|
|
{ prefix: "phase:", invalidate: ["getHeadquartersDashboard"] },
|
|
{ prefix: "agent:", invalidate: ["getHeadquartersDashboard"] },
|
|
{ prefix: "agent:", invalidate: ["listWaitingAgents", "listMessages"] },
|
|
];
|
|
|
|
export function HeadquartersPage() {
|
|
useLiveUpdates(HQ_LIVE_UPDATE_RULES);
|
|
const query = trpc.getHeadquartersDashboard.useQuery();
|
|
|
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
|
|
|
const utils = trpc.useUtils();
|
|
const agentsQuery = trpc.listWaitingAgents.useQuery();
|
|
const messagesQuery = trpc.listMessages.useQuery({});
|
|
const questionsQuery = trpc.getAgentQuestions.useQuery(
|
|
{ id: selectedAgentId! },
|
|
{ enabled: !!selectedAgentId }
|
|
);
|
|
|
|
const resumeAgentMutation = trpc.resumeAgent.useMutation({
|
|
onSuccess: () => {
|
|
setSelectedAgentId(null);
|
|
toast.success("Answer submitted");
|
|
},
|
|
onError: () => {
|
|
toast.error("Failed to submit answer");
|
|
},
|
|
});
|
|
|
|
const dismissQuestionsMutation = trpc.stopAgent.useMutation({
|
|
onSuccess: () => {
|
|
setSelectedAgentId(null);
|
|
toast.success("Questions dismissed");
|
|
},
|
|
onError: () => {
|
|
toast.error("Failed to dismiss questions");
|
|
},
|
|
});
|
|
|
|
const respondToMessageMutation = trpc.respondToMessage.useMutation({
|
|
onSuccess: () => {
|
|
setSelectedAgentId(null);
|
|
toast.success("Response sent");
|
|
},
|
|
onError: () => {
|
|
toast.error("Failed to send response");
|
|
},
|
|
});
|
|
|
|
const agents = agentsQuery.data ?? [];
|
|
const messages = messagesQuery.data ?? [];
|
|
const selectedAgent = selectedAgentId
|
|
? agents.find((a) => a.id === selectedAgentId) ?? null
|
|
: null;
|
|
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;
|
|
|
|
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 handleDismissQuestions() {
|
|
if (!selectedAgentId) return;
|
|
dismissQuestionsMutation.mutate({ id: selectedAgentId });
|
|
}
|
|
|
|
function handleDismiss() {
|
|
if (!selectedMessage) return;
|
|
respondToMessageMutation.mutate({
|
|
id: selectedMessage.id,
|
|
response: "Acknowledged",
|
|
});
|
|
}
|
|
|
|
const serializedAgents = agents.map((a) => ({
|
|
id: a.id,
|
|
name: a.name,
|
|
status: a.status,
|
|
taskId: a.taskId ?? "",
|
|
updatedAt: String(a.updatedAt),
|
|
}));
|
|
|
|
const serializedMessages = messages.map((m) => ({
|
|
id: m.id,
|
|
senderId: m.senderId,
|
|
content: m.content,
|
|
requiresResponse: m.requiresResponse,
|
|
status: m.status,
|
|
createdAt: String(m.createdAt),
|
|
}));
|
|
|
|
if (query.isLoading) {
|
|
return (
|
|
<motion.div
|
|
className="mx-auto max-w-4xl space-y-6"
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Headquarters</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Items waiting for your attention.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
if (query.isError) {
|
|
return (
|
|
<motion.div
|
|
className="mx-auto max-w-4xl space-y-6"
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<div className="flex flex-col items-center justify-center py-24 gap-3 text-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
Failed to load headquarters data.
|
|
</p>
|
|
<Button variant="outline" size="sm" onClick={() => query.refetch()}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
const data = query.data!;
|
|
|
|
const hasAny =
|
|
data.waitingForInput.length > 0 ||
|
|
data.pendingReviewInitiatives.length > 0 ||
|
|
data.pendingReviewPhases.length > 0 ||
|
|
data.planningInitiatives.length > 0 ||
|
|
data.resolvingConflicts.length > 0 ||
|
|
data.blockedPhases.length > 0;
|
|
|
|
return (
|
|
<motion.div
|
|
className="mx-auto max-w-4xl space-y-6"
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Headquarters</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Items waiting for your attention.
|
|
</p>
|
|
</div>
|
|
|
|
{!hasAny ? (
|
|
<HQEmptyState />
|
|
) : (
|
|
<div className="space-y-8">
|
|
{data.waitingForInput.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
Waiting for Input
|
|
</h2>
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
|
|
{/* Left: agent list — hidden on mobile when an agent is selected */}
|
|
<div className={selectedAgent ? "hidden lg:block" : undefined}>
|
|
<InboxList
|
|
agents={serializedAgents}
|
|
messages={serializedMessages}
|
|
selectedAgentId={selectedAgentId}
|
|
onSelectAgent={setSelectedAgentId}
|
|
onRefresh={handleRefresh}
|
|
/>
|
|
</div>
|
|
{/* Right: detail panel */}
|
|
{selectedAgent ? (
|
|
<InboxDetailPanel
|
|
agent={{
|
|
id: selectedAgent.id,
|
|
name: selectedAgent.name,
|
|
status: selectedAgent.status,
|
|
taskId: selectedAgent.taskId ?? null,
|
|
updatedAt: String(selectedAgent.updatedAt),
|
|
}}
|
|
message={
|
|
selectedMessage
|
|
? {
|
|
id: selectedMessage.id,
|
|
content: selectedMessage.content,
|
|
requiresResponse: selectedMessage.requiresResponse,
|
|
}
|
|
: null
|
|
}
|
|
questions={
|
|
pendingQuestions
|
|
? pendingQuestions.questions.map((q) => ({
|
|
id: q.id,
|
|
question: q.question,
|
|
options: q.options,
|
|
multiSelect: q.multiSelect,
|
|
}))
|
|
: null
|
|
}
|
|
isLoadingQuestions={questionsQuery.isLoading}
|
|
questionsError={
|
|
questionsQuery.isError ? questionsQuery.error.message : null
|
|
}
|
|
onBack={() => setSelectedAgentId(null)}
|
|
onSubmitAnswers={handleSubmitAnswers}
|
|
onDismissQuestions={handleDismissQuestions}
|
|
onDismissMessage={handleDismiss}
|
|
isSubmitting={resumeAgentMutation.isPending}
|
|
isDismissingQuestions={dismissQuestionsMutation.isPending}
|
|
isDismissingMessage={respondToMessageMutation.isPending}
|
|
submitError={
|
|
resumeAgentMutation.isError
|
|
? resumeAgentMutation.error.message
|
|
: null
|
|
}
|
|
dismissMessageError={
|
|
respondToMessageMutation.isError
|
|
? respondToMessageMutation.error.message
|
|
: null
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="hidden flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-8 lg:flex">
|
|
<p className="text-sm font-medium text-muted-foreground">No agent selected</p>
|
|
<p className="text-xs text-muted-foreground/70">Select an agent from the list to answer their questions</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(data.pendingReviewInitiatives.length > 0 ||
|
|
data.pendingReviewPhases.length > 0) && (
|
|
<HQNeedsReviewSection
|
|
initiatives={data.pendingReviewInitiatives}
|
|
phases={data.pendingReviewPhases}
|
|
/>
|
|
)}
|
|
{data.planningInitiatives.length > 0 && (
|
|
<HQNeedsApprovalSection items={data.planningInitiatives} />
|
|
)}
|
|
{data.resolvingConflicts.length > 0 && (
|
|
<HQResolvingConflictsSection items={data.resolvingConflicts} />
|
|
)}
|
|
{data.blockedPhases.length > 0 && (
|
|
<HQBlockedSection items={data.blockedPhases} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
}
|