diff --git a/apps/web/src/components/hq/HQWaitingForInputSection.tsx b/apps/web/src/components/hq/HQWaitingForInputSection.tsx
deleted file mode 100644
index 6a23e33..0000000
--- a/apps/web/src/components/hq/HQWaitingForInputSection.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { useNavigate } from '@tanstack/react-router'
-import { Card } from '@/components/ui/card'
-import { Button } from '@/components/ui/button'
-import {
- Tooltip,
- TooltipTrigger,
- TooltipContent,
- TooltipProvider,
-} from '@/components/ui/tooltip'
-import { formatRelativeTime } from '@/lib/utils'
-import type { WaitingForInputItem } from './types'
-
-interface Props {
- items: WaitingForInputItem[]
-}
-
-export function HQWaitingForInputSection({ items }: Props) {
- const navigate = useNavigate()
-
- return (
-
-
- Waiting for Input
-
-
- {items.map((item) => {
- const truncated =
- item.questionText.slice(0, 120) +
- (item.questionText.length > 120 ? '…' : '')
-
- return (
-
-
-
- {item.agentName}
- {item.initiativeName && (
- · {item.initiativeName}
- )}
-
-
-
-
- {truncated}
-
- {item.questionText}
-
-
-
- waiting {formatRelativeTime(item.waitingSince)}
-
-
-
-
- )
- })}
-
-
- )
-}
diff --git a/apps/web/src/routes/hq.test.tsx b/apps/web/src/routes/hq.test.tsx
index 066cd0d..c9f3ae2 100644
--- a/apps/web/src/routes/hq.test.tsx
+++ b/apps/web/src/routes/hq.test.tsx
@@ -4,9 +4,24 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
const mockUseQuery = vi.hoisted(() => vi.fn())
+const mockListWaitingAgentsQuery = vi.hoisted(() => vi.fn())
+const mockListMessagesQuery = vi.hoisted(() => vi.fn())
+const mockGetAgentQuestionsQuery = vi.hoisted(() => vi.fn())
+const mockResumeAgentMutation = vi.hoisted(() => vi.fn())
+const mockStopAgentMutation = vi.hoisted(() => vi.fn())
+const mockRespondToMessageMutation = vi.hoisted(() => vi.fn())
+const mockUseUtils = vi.hoisted(() => vi.fn())
+
vi.mock('@/lib/trpc', () => ({
trpc: {
getHeadquartersDashboard: { useQuery: mockUseQuery },
+ listWaitingAgents: { useQuery: mockListWaitingAgentsQuery },
+ listMessages: { useQuery: mockListMessagesQuery },
+ getAgentQuestions: { useQuery: mockGetAgentQuestionsQuery },
+ resumeAgent: { useMutation: mockResumeAgentMutation },
+ stopAgent: { useMutation: mockStopAgentMutation },
+ respondToMessage: { useMutation: mockRespondToMessageMutation },
+ useUtils: mockUseUtils,
},
}))
@@ -15,8 +30,33 @@ vi.mock('@/hooks', () => ({
LiveUpdateRule: undefined,
}))
-vi.mock('@/components/hq/HQWaitingForInputSection', () => ({
- HQWaitingForInputSection: ({ items }: any) => {items.length}
,
+vi.mock('@/components/InboxList', () => ({
+ InboxList: ({ agents, selectedAgentId, onSelectAgent }: any) => (
+
+ {agents.map((a: any) => (
+
+ ))}
+
+ ),
+}))
+
+vi.mock('@/components/InboxDetailPanel', () => ({
+ InboxDetailPanel: ({ agent, onSubmitAnswers, onDismissQuestions, onDismissMessage, onBack }: any) => (
+
+ {agent.name}
+
+
+
+
+
+ ),
}))
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
@@ -56,6 +96,16 @@ const emptyData = {
describe('HeadquartersPage', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockListWaitingAgentsQuery.mockReturnValue({ data: [], isLoading: false })
+ mockListMessagesQuery.mockReturnValue({ data: [], isLoading: false })
+ mockGetAgentQuestionsQuery.mockReturnValue({ data: null, isLoading: false, isError: false })
+ mockResumeAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
+ mockStopAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
+ mockRespondToMessageMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
+ mockUseUtils.mockReturnValue({
+ listWaitingAgents: { invalidate: vi.fn() },
+ listMessages: { invalidate: vi.fn() },
+ })
})
it('renders skeleton loading state', () => {
@@ -68,7 +118,7 @@ describe('HeadquartersPage', () => {
const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]')
expect(skeletons.length).toBeGreaterThan(0)
// No section components
- expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
@@ -93,24 +143,25 @@ describe('HeadquartersPage', () => {
render()
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
- expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
})
- it('renders WaitingForInput section when items exist', () => {
+ it('renders InboxList when waitingForInput items exist', () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
})
+ mockListWaitingAgentsQuery.mockReturnValue({
+ data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
+ isLoading: false,
+ })
render()
- expect(screen.getByTestId('waiting')).toBeInTheDocument()
- expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
- expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
- expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
+ expect(screen.getByTestId('inbox-list')).toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
@@ -127,9 +178,13 @@ describe('HeadquartersPage', () => {
blockedPhases: [{ id: '6' }],
},
})
+ mockListWaitingAgentsQuery.mockReturnValue({
+ data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
+ isLoading: false,
+ })
render()
- expect(screen.getByTestId('waiting')).toBeInTheDocument()
+ expect(screen.getByTestId('inbox-list')).toBeInTheDocument()
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument()
@@ -160,4 +215,41 @@ describe('HeadquartersPage', () => {
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
+
+ it('shows InboxDetailPanel when an agent is selected from InboxList', async () => {
+ mockUseQuery.mockReturnValue({
+ isLoading: false,
+ isError: false,
+ data: { ...emptyData, waitingForInput: [{ id: '1' }] },
+ })
+ mockListWaitingAgentsQuery.mockReturnValue({
+ data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
+ isLoading: false,
+ })
+ render()
+
+ fireEvent.click(screen.getByTestId('agent-a1'))
+
+ expect(await screen.findByTestId('inbox-detail')).toBeInTheDocument()
+ expect(screen.getByTestId('selected-agent-name')).toHaveTextContent('Agent 1')
+ })
+
+ it('clears selection when Back is clicked in InboxDetailPanel', async () => {
+ mockUseQuery.mockReturnValue({
+ isLoading: false,
+ isError: false,
+ data: { ...emptyData, waitingForInput: [{ id: '1' }] },
+ })
+ mockListWaitingAgentsQuery.mockReturnValue({
+ data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
+ isLoading: false,
+ })
+ render()
+
+ fireEvent.click(screen.getByTestId('agent-a1'))
+ expect(screen.getByTestId('inbox-detail')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: /back/i }))
+ expect(screen.queryByTestId('inbox-detail')).not.toBeInTheDocument()
+ })
})
diff --git a/apps/web/src/routes/hq.tsx b/apps/web/src/routes/hq.tsx
index d1f881e..368e6bf 100644
--- a/apps/web/src/routes/hq.tsx
+++ b/apps/web/src/routes/hq.tsx
@@ -1,10 +1,13 @@
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 { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection";
+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";
@@ -19,12 +22,108 @@ 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(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) {
+ 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 (
{data.waitingForInput.length > 0 && (
-
+
+
+ Waiting for Input
+
+
+ {/* Left: agent list — hidden on mobile when an agent is selected */}
+
+
+
+ {/* Right: detail panel */}
+ {selectedAgent ? (
+
({
+ 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
+ }
+ />
+ ) : (
+
+
No agent selected
+
Select an agent from the list to answer their questions
+
+ )}
+
+
)}
{(data.pendingReviewInitiatives.length > 0 ||
data.pendingReviewPhases.length > 0) && (