diff --git a/apps/web/src/components/InboxDetailPanel.test.tsx b/apps/web/src/components/InboxDetailPanel.test.tsx new file mode 100644 index 0000000..639a227 --- /dev/null +++ b/apps/web/src/components/InboxDetailPanel.test.tsx @@ -0,0 +1,262 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { InboxDetailPanel } from './InboxDetailPanel' + +// Mock trpc to control getAgentOutput +vi.mock('@/lib/trpc', () => ({ + trpc: { + getAgentOutput: { + useQuery: vi.fn(() => ({ data: [], isLoading: false })), + }, + }, +})) + +// Mock @tanstack/react-router's Link +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, params }: { children: React.ReactNode; params?: Record }) => ( + {children} + ), +})) + +// Mock AgentOutputViewer +vi.mock('./AgentOutputViewer', () => ({ + AgentOutputViewer: () =>
, +})) + +function makeAgent(overrides?: Partial>): { + id: string + name: string + status: string + taskId: string | null + taskName: string | null + phaseName: string | null + initiativeName: string | null + initiativeId: string | null + updatedAt: string +} { + return { + id: 'agent-1', + name: 'Test Agent', + status: 'waiting_for_input', + taskId: 'task-1', + taskName: 'Implement auth', + phaseName: 'Phase 1', + initiativeName: 'My Initiative', + initiativeId: 'init-1', + updatedAt: new Date().toISOString(), + ...overrides, + } +} + +function renderPanel( + agentOverrides?: Parameters[0], + propOverrides?: { + taskDescription?: string | null + isLoadingContext?: boolean + questions?: any[] + isLoadingQuestions?: boolean + message?: { id: string; content: string; requiresResponse: boolean } | null + } +) { + const agent = makeAgent(agentOverrides) + const props = { + taskDescription: null as string | null, + isLoadingContext: false, + questions: [] as any[], + isLoadingQuestions: false, + message: null as { id: string; content: string; requiresResponse: boolean } | null, + questionsError: null, + onBack: vi.fn(), + onSubmitAnswers: vi.fn(), + onDismissQuestions: vi.fn(), + onDismissMessage: vi.fn(), + isSubmitting: false, + isDismissingQuestions: false, + isDismissingMessage: false, + submitError: null, + dismissMessageError: null, + ...propOverrides, + } + return render() +} + +import * as trpcModule from '@/lib/trpc' + +describe('InboxDetailPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: empty output + vi.mocked(trpcModule.trpc.getAgentOutput.useQuery).mockReturnValue({ + data: [], + isLoading: false, + } as any) + }) + + // ------------------------- + // Task name header tests + // ------------------------- + + it('task name header — shows taskName when set', () => { + renderPanel({ taskName: 'Implement auth', taskId: 'some-uuid-1234' }) + + expect(screen.getByText(/Task: Implement auth/)).toBeInTheDocument() + expect(screen.queryByText(/some-uuid-1234/)).not.toBeInTheDocument() + }) + + it('task name header — falls back to taskId when taskName is null', () => { + renderPanel({ taskName: null, taskId: 'abc-uuid' }) + + expect(screen.getByText(/Task:.*abc-uuid/)).toBeInTheDocument() + }) + + it('task name header — shows em dash when both are null', () => { + renderPanel({ taskName: null, taskId: null }) + + expect(screen.getByText(/Task:.*—/)).toBeInTheDocument() + }) + + // ------------------------- + // Context panel tests + // ------------------------- + + it('context panel — loaded, with task, shows all fields', () => { + renderPanel( + { + taskId: 'task-1', + taskName: 'Impl auth', + initiativeName: 'My Initiative', + initiativeId: 'init-1', + phaseName: 'Phase 1', + }, + { + taskDescription: 'Some description', + isLoadingContext: false, + } + ) + + expect(screen.getByText('My Initiative')).toBeInTheDocument() + // Task name in context panel + expect(screen.getAllByText(/Impl auth/).length).toBeGreaterThan(0) + expect(screen.getByText('Some description')).toBeInTheDocument() + expect(screen.getByText(/Phase 1/)).toBeInTheDocument() + }) + + it('context panel — truncates description longer than 300 chars', () => { + const longDescription = 'A'.repeat(301) + renderPanel( + { taskId: 'task-1' }, + { taskDescription: longDescription, isLoadingContext: false } + ) + + const truncated = screen.getByText(/A+…/) + const text = truncated.textContent ?? '' + expect(text.endsWith('…')).toBe(true) + expect(text.length).toBeLessThanOrEqual(301) + }) + + it('context panel — strips HTML tags from description', () => { + renderPanel( + { taskId: 'task-1' }, + { taskDescription: '

Some text

', isLoadingContext: false } + ) + + expect(screen.getByText('Some text')).toBeInTheDocument() + //

should not appear as literal text + expect(screen.queryByText(/

/)).not.toBeInTheDocument() + // The rendered content should not contain raw HTML tags + const container = document.querySelector('body')! + expect(container.innerHTML).not.toContain('<p>') + }) + + it('context panel — shows "No task context available" when taskId is null', () => { + renderPanel({ taskId: null }, { isLoadingContext: false }) + + expect(screen.getByText('No task context available')).toBeInTheDocument() + }) + + it('context panel — shows skeleton when loading', () => { + renderPanel({ taskId: 'task-1' }, { isLoadingContext: true }) + + // Skeletons are rendered by class + const skeletons = document.querySelectorAll('[class*="animate-pulse"]') + expect(skeletons.length).toBeGreaterThan(0) + + // Context content NOT shown + expect(screen.queryByText('No task context available')).not.toBeInTheDocument() + expect(screen.queryByText(/Initiative:/)).not.toBeInTheDocument() + }) + + // ------------------------- + // Logs section tests + // ------------------------- + + it('logs section — hidden when output is empty', () => { + vi.mocked(trpcModule.trpc.getAgentOutput.useQuery).mockReturnValue({ + data: [], + isLoading: false, + } as any) + + renderPanel() + + expect(screen.queryByText('Show agent logs')).not.toBeInTheDocument() + }) + + it('logs section — shows button and badge when chunks exist, clicking shows viewer', () => { + vi.mocked(trpcModule.trpc.getAgentOutput.useQuery).mockReturnValue({ + data: [{}, {}, {}], + isLoading: false, + } as any) + + renderPanel() + + const button = screen.getByText('Show agent logs') + expect(button).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + + fireEvent.click(button) + + expect(screen.getByTestId('agent-output-viewer')).toBeInTheDocument() + }) + + it('logs section — collapses when agent id changes', () => { + vi.mocked(trpcModule.trpc.getAgentOutput.useQuery).mockReturnValue({ + data: [{}, {}, {}], + isLoading: false, + } as any) + + const { rerender } = renderPanel({ id: 'a' }) + + // Open logs + fireEvent.click(screen.getByText('Show agent logs')) + expect(screen.getByTestId('agent-output-viewer')).toBeInTheDocument() + + // Re-render with different agent id + const newAgent = makeAgent({ id: 'b' }) + rerender( + + ) + + // Logs should be collapsed + expect(screen.queryByTestId('agent-output-viewer')).not.toBeInTheDocument() + expect(screen.getByText('Show agent logs')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/InboxDetailPanel.tsx b/apps/web/src/components/InboxDetailPanel.tsx index 85d1666..0aafb2c 100644 --- a/apps/web/src/components/InboxDetailPanel.tsx +++ b/apps/web/src/components/InboxDetailPanel.tsx @@ -1,8 +1,18 @@ import { Link } from "@tanstack/react-router"; import { ChevronLeft } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { QuestionForm } from "@/components/QuestionForm"; +import { AgentOutputViewer } from "@/components/AgentOutputViewer"; import { formatRelativeTime } from "@/lib/utils"; +import { trpc } from "@/lib/trpc"; + +function processDescription(content: string | null): string | null { + if (!content) return null; + const stripped = content.replace(/<[^>]*>/g, ''); + return stripped.length > 300 ? stripped.slice(0, 300) + '…' : stripped; +} interface InboxDetailPanelProps { agent: { @@ -10,8 +20,14 @@ interface InboxDetailPanelProps { name: string; status: string; taskId: string | null; + taskName: string | null; + phaseName: string | null; + initiativeName: string | null; + initiativeId: string | null; updatedAt: string; }; + taskDescription: string | null; + isLoadingContext: boolean; message: { id: string; content: string; @@ -40,6 +56,8 @@ interface InboxDetailPanelProps { export function InboxDetailPanel({ agent, + taskDescription, + isLoadingContext, message, questions, isLoadingQuestions, @@ -54,6 +72,22 @@ export function InboxDetailPanel({ submitError, dismissMessageError, }: InboxDetailPanelProps) { + const [logsOpen, setLogsOpen] = useState(false); + const logsContainerRef = useRef(null); + const outputQuery = trpc.getAgentOutput.useQuery({ id: agent.id }); + + useEffect(() => { + setLogsOpen(false); + }, [agent.id]); + + useEffect(() => { + if (logsOpen && logsContainerRef.current) { + logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight; + } + }, [logsOpen]); + + const processedDescription = processDescription(taskDescription); + return (

{/* Mobile back button */} @@ -82,16 +116,7 @@ export function InboxDetailPanel({

Task:{" "} - {agent.taskId ? ( - - {agent.taskId} - - ) : ( - "\u2014" - )} + {agent.taskName ?? agent.taskId ?? "—"}

{agent.taskId && ( + {/* Related Context Panel */} + {isLoadingContext ? ( +
+ + + +
+ ) : agent.taskId ? ( +
+ {agent.initiativeName && agent.initiativeId && ( +

+ Initiative:{" "} + + {agent.initiativeName} + +

+ )} + {agent.taskName && ( +

Task: {agent.taskName}

+ )} + {processedDescription && ( +

{processedDescription}

+ )} + {agent.phaseName &&

Phase: {agent.phaseName}

} +
+ ) : ( +

No task context available

+ )} + {/* Question Form or Notification Content */} {isLoadingQuestions && (
@@ -166,6 +224,31 @@ export function InboxDetailPanel({

)} + + {/* Agent Logs Section */} + {!logsOpen && (outputQuery.data?.length ?? 0) > 0 && ( +
+ +
+ )} + + {logsOpen && ( +
+ +
+ )}
); } diff --git a/apps/web/src/routes/hq.tsx b/apps/web/src/routes/hq.tsx index 368e6bf..8954b35 100644 --- a/apps/web/src/routes/hq.tsx +++ b/apps/web/src/routes/hq.tsx @@ -219,8 +219,14 @@ export function HeadquartersPage() { name: selectedAgent.name, status: selectedAgent.status, taskId: selectedAgent.taskId ?? null, + taskName: selectedAgent.taskName ?? null, + phaseName: selectedAgent.phaseName ?? null, + initiativeName: selectedAgent.initiativeName ?? null, + initiativeId: selectedAgent.initiativeId ?? null, updatedAt: String(selectedAgent.updatedAt), }} + isLoadingContext={agentsQuery.isLoading} + taskDescription={selectedAgent.taskDescription ?? null} message={ selectedMessage ? {