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) && (