Files
Codewalkers/apps/web/src/routes/hq.tsx
Lukas May e8d332e04b feat: embed InboxList + InboxDetailPanel inline on HQ page
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>
2026-03-06 21:45:26 +01:00

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>
);
}