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>
This commit is contained in:
@@ -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 (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
||||||
Waiting for Input
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{items.map((item) => {
|
|
||||||
const truncated =
|
|
||||||
item.questionText.slice(0, 120) +
|
|
||||||
(item.questionText.length > 120 ? '…' : '')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-bold text-sm">
|
|
||||||
{item.agentName}
|
|
||||||
{item.initiativeName && (
|
|
||||||
<span className="font-normal text-muted-foreground"> · {item.initiativeName}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<p className="text-sm text-muted-foreground truncate">{truncated}</p>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent forceMount>{item.questionText}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
waiting {formatRelativeTime(item.waitingSince)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate({ to: '/inbox' })}
|
|
||||||
>
|
|
||||||
Answer
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,24 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|||||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
|
||||||
const mockUseQuery = vi.hoisted(() => vi.fn())
|
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', () => ({
|
vi.mock('@/lib/trpc', () => ({
|
||||||
trpc: {
|
trpc: {
|
||||||
getHeadquartersDashboard: { useQuery: mockUseQuery },
|
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,
|
LiveUpdateRule: undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/hq/HQWaitingForInputSection', () => ({
|
vi.mock('@/components/InboxList', () => ({
|
||||||
HQWaitingForInputSection: ({ items }: any) => <div data-testid="waiting">{items.length}</div>,
|
InboxList: ({ agents, selectedAgentId, onSelectAgent }: any) => (
|
||||||
|
<div data-testid="inbox-list">
|
||||||
|
{agents.map((a: any) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
data-testid={`agent-${a.id}`}
|
||||||
|
aria-selected={selectedAgentId === a.id}
|
||||||
|
onClick={() => onSelectAgent(a.id)}
|
||||||
|
>
|
||||||
|
{a.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/InboxDetailPanel', () => ({
|
||||||
|
InboxDetailPanel: ({ agent, onSubmitAnswers, onDismissQuestions, onDismissMessage, onBack }: any) => (
|
||||||
|
<div data-testid="inbox-detail">
|
||||||
|
<span data-testid="selected-agent-name">{agent.name}</span>
|
||||||
|
<button onClick={() => onSubmitAnswers({ q1: 'answer' })}>Submit</button>
|
||||||
|
<button onClick={onDismissQuestions}>Dismiss Questions</button>
|
||||||
|
<button onClick={onDismissMessage}>Dismiss Message</button>
|
||||||
|
<button onClick={onBack}>Back</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
|
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
|
||||||
@@ -56,6 +96,16 @@ const emptyData = {
|
|||||||
describe('HeadquartersPage', () => {
|
describe('HeadquartersPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
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', () => {
|
it('renders skeleton loading state', () => {
|
||||||
@@ -68,7 +118,7 @@ describe('HeadquartersPage', () => {
|
|||||||
const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]')
|
const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]')
|
||||||
expect(skeletons.length).toBeGreaterThan(0)
|
expect(skeletons.length).toBeGreaterThan(0)
|
||||||
// No section components
|
// 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-review')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
||||||
@@ -93,24 +143,25 @@ describe('HeadquartersPage', () => {
|
|||||||
render(<HeadquartersPage />)
|
render(<HeadquartersPage />)
|
||||||
|
|
||||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
|
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-review')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders WaitingForInput section when items exist', () => {
|
it('renders InboxList when waitingForInput items exist', () => {
|
||||||
mockUseQuery.mockReturnValue({
|
mockUseQuery.mockReturnValue({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
|
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(<HeadquartersPage />)
|
render(<HeadquartersPage />)
|
||||||
|
|
||||||
expect(screen.getByTestId('waiting')).toBeInTheDocument()
|
expect(screen.getByTestId('inbox-list')).toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,9 +178,13 @@ describe('HeadquartersPage', () => {
|
|||||||
blockedPhases: [{ id: '6' }],
|
blockedPhases: [{ id: '6' }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
mockListWaitingAgentsQuery.mockReturnValue({
|
||||||
|
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
render(<HeadquartersPage />)
|
render(<HeadquartersPage />)
|
||||||
|
|
||||||
expect(screen.getByTestId('waiting')).toBeInTheDocument()
|
expect(screen.getByTestId('inbox-list')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
|
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument()
|
expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument()
|
||||||
@@ -160,4 +215,41 @@ describe('HeadquartersPage', () => {
|
|||||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('empty-state')).not.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(<HeadquartersPage />)
|
||||||
|
|
||||||
|
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(<HeadquartersPage />)
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { useLiveUpdates, type LiveUpdateRule } from "@/hooks";
|
import { useLiveUpdates, type LiveUpdateRule } from "@/hooks";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
|
||||||
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
|
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
|
||||||
import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection";
|
import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection";
|
||||||
@@ -19,12 +22,108 @@ const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [
|
|||||||
{ prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] },
|
{ prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] },
|
||||||
{ prefix: "phase:", invalidate: ["getHeadquartersDashboard"] },
|
{ prefix: "phase:", invalidate: ["getHeadquartersDashboard"] },
|
||||||
{ prefix: "agent:", invalidate: ["getHeadquartersDashboard"] },
|
{ prefix: "agent:", invalidate: ["getHeadquartersDashboard"] },
|
||||||
|
{ prefix: "agent:", invalidate: ["listWaitingAgents", "listMessages"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function HeadquartersPage() {
|
export function HeadquartersPage() {
|
||||||
useLiveUpdates(HQ_LIVE_UPDATE_RULES);
|
useLiveUpdates(HQ_LIVE_UPDATE_RULES);
|
||||||
const query = trpc.getHeadquartersDashboard.useQuery();
|
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) {
|
if (query.isLoading) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -97,7 +196,80 @@ export function HeadquartersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{data.waitingForInput.length > 0 && (
|
{data.waitingForInput.length > 0 && (
|
||||||
<HQWaitingForInputSection items={data.waitingForInput} />
|
<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.pendingReviewInitiatives.length > 0 ||
|
||||||
data.pendingReviewPhases.length > 0) && (
|
data.pendingReviewPhases.length > 0) && (
|
||||||
|
|||||||
Reference in New Issue
Block a user