Merge branch 'cw/merge-hq-inbox' into cw-merge-1772829971134

This commit is contained in:
Lukas May
2026-03-06 21:46:11 +01:00
7 changed files with 348 additions and 346 deletions

View File

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

View File

@@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
import { AppLayout } from './AppLayout'
// Mock dependencies
vi.mock('@tanstack/react-router', () => ({
Link: ({ children, to }: any) => {
const content = typeof children === 'function' ? children({ isActive: false }) : children
return <a href={to}>{content}</a>
},
}))
vi.mock('@/components/ThemeToggle', () => ({ ThemeToggle: () => null }))
vi.mock('@/components/HealthDot', () => ({ HealthDot: () => null }))
vi.mock('@/components/NavBadge', () => ({
NavBadge: ({ count }: { count: number }) => (
count > 0 ? <span data-testid="nav-badge">{count}</span> : null
),
}))
const mockUseQuery = vi.hoisted(() => vi.fn())
vi.mock('@/lib/trpc', () => ({
trpc: {
listAgents: { useQuery: mockUseQuery },
},
}))
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockReturnValue({ data: [] })
})
describe('AppLayout navItems', () => {
it('renders HQ nav link', () => {
render(<AppLayout connectionState="connected">{null}</AppLayout>)
expect(screen.getByRole('link', { name: /hq/i })).toBeInTheDocument()
})
it('does not render Inbox nav link', () => {
render(<AppLayout connectionState="connected">{null}</AppLayout>)
expect(screen.queryByRole('link', { name: /inbox/i })).not.toBeInTheDocument()
})
it('shows badge on HQ when agents are waiting_for_input', () => {
mockUseQuery.mockReturnValue({
data: [
{ id: '1', status: 'waiting_for_input' },
{ id: '2', status: 'running' },
],
})
render(<AppLayout connectionState="connected">{null}</AppLayout>)
// NavBadge rendered next to HQ link (count=1)
const hqLink = screen.getByRole('link', { name: /hq/i })
const badge = within(hqLink).getByTestId('nav-badge')
expect(badge).toHaveTextContent('1')
})
it('does not show questions badge on any Inbox link (Inbox removed)', () => {
mockUseQuery.mockReturnValue({
data: [{ id: '1', status: 'waiting_for_input' }],
})
render(<AppLayout connectionState="connected">{null}</AppLayout>)
expect(screen.queryByRole('link', { name: /inbox/i })).not.toBeInTheDocument()
})
})

View File

@@ -7,12 +7,11 @@ import { trpc } from '@/lib/trpc'
import type { ConnectionState } from '@/hooks/useConnectionStatus'
const navItems = [
{ label: 'HQ', to: '/hq', badgeKey: null },
{ label: 'HQ', to: '/hq', badgeKey: 'questions' as const },
{ label: 'Initiatives', to: '/initiatives', badgeKey: null },
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
{ label: 'Errands', to: '/errands', badgeKey: null },
{ label: 'Radar', to: '/radar', badgeKey: null },
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
{ label: 'Settings', to: '/settings', badgeKey: null },
] as const

View File

@@ -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) => <div data-testid="waiting">{items.length}</div>,
vi.mock('@/components/InboxList', () => ({
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', () => ({
@@ -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(<HeadquartersPage />)
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(<HeadquartersPage />)
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(<HeadquartersPage />)
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(<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()
})
})

View File

@@ -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<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
@@ -97,7 +196,80 @@ export function HeadquartersPage() {
) : (
<div className="space-y-8">
{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.pendingReviewPhases.length > 0) && (

View File

@@ -1,269 +1,7 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle, MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { InboxList } from "@/components/InboxList";
import { InboxDetailPanel } from "@/components/InboxDetailPanel";
import { useLiveUpdates } from "@/hooks";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/inbox")({
component: InboxPage,
});
function InboxPage() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
// Single SSE stream for live updates
useLiveUpdates([
{ prefix: 'agent:', invalidate: ['listWaitingAgents', 'listMessages'] },
]);
const utils = trpc.useUtils();
// Data fetching
const agentsQuery = trpc.listWaitingAgents.useQuery();
const messagesQuery = trpc.listMessages.useQuery({});
const questionsQuery = trpc.getAgentQuestions.useQuery(
{ id: selectedAgentId! },
{ enabled: !!selectedAgentId }
);
// Mutations
const resumeAgentMutation = trpc.resumeAgent.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Answer submitted");
},
onError: () => {
toast.error("Failed to submit answer");
beforeLoad: () => {
throw redirect({ to: "/hq" });
},
});
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");
},
});
// Find selected agent info
const agents = agentsQuery.data ?? [];
const messages = messagesQuery.data ?? [];
const selectedAgent = selectedAgentId
? agents.find((a) => a.id === selectedAgentId) ?? null
: null;
// Find the latest message for the selected agent
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;
// Handlers
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",
});
}
// Loading state
if (agentsQuery.isLoading && messagesQuery.isLoading) {
return (
<div className="space-y-4">
{/* Skeleton header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-28" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
<Skeleton className="h-8 w-20" />
</div>
{/* Skeleton message rows */}
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="mt-2 ml-5 h-3 w-full" />
</div>
<Skeleton className="h-3 w-16" />
</div>
</Card>
))}
</div>
</div>
);
}
// Error state
if (agentsQuery.isError || messagesQuery.isError) {
const errorMessage =
agentsQuery.error?.message ?? messagesQuery.error?.message ?? "Unknown error";
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">
Failed to load inbox: {errorMessage}
</p>
<Button variant="outline" size="sm" onClick={handleRefresh}>
Retry
</Button>
</div>
);
}
// Serialize agents for InboxList (convert Date to string for wire format)
const serializedAgents = agents.map((a) => ({
id: a.id,
name: a.name,
status: a.status,
taskId: a.taskId,
updatedAt: String(a.updatedAt),
}));
// Serialize messages for InboxList
const serializedMessages = messages.map((m) => ({
id: m.id,
senderId: m.senderId,
content: m.content,
requiresResponse: m.requiresResponse,
status: m.status,
createdAt: String(m.createdAt),
}));
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="space-y-6"
>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
{/* Left: Inbox List -- hidden on mobile when agent selected */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
className={selectedAgent ? "hidden lg:block" : undefined}
>
<InboxList
agents={serializedAgents}
messages={serializedMessages}
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
onRefresh={handleRefresh}
/>
</motion.div>
{/* Right: Detail Panel */}
{selectedAgent && (
<InboxDetailPanel
agent={{
id: selectedAgent.id,
name: selectedAgent.name,
status: selectedAgent.status,
taskId: selectedAgent.taskId,
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
}
/>
)}
{/* Empty detail panel placeholder */}
{!selectedAgent && (
<div className="hidden flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-8 lg:flex">
<MessageSquare className="h-10 w-10 text-muted-foreground/30" />
<div className="space-y-1 text-center">
<p className="text-sm font-medium text-muted-foreground">No message selected</p>
<p className="text-xs text-muted-foreground/70">Select an agent from the inbox to view details</p>
</div>
</div>
)}
</div>
</motion.div>
);
}

File diff suppressed because one or more lines are too long