feat: add context panel, logs section, and task name fix to InboxDetailPanel
Enriches the HQ inbox detail view so users can answer agent questions without navigating away from the inbox: - Replace raw task UUID in header with human-readable task name (falls back to UUID, then em dash when both null) - Add related context panel showing initiative link, task name, truncated/HTML-stripped description, and phase name; shows skeleton while loading, "No task context available" when agent has no task - Add collapsible agent logs section using AgentOutputViewer; hidden when no output exists, resets on agent change - Wire new props (taskName, phaseName, initiativeName, initiativeId, taskDescription, isLoadingContext) in hq.tsx from listWaitingAgents - Add 11 tests covering all new behaviors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
262
apps/web/src/components/InboxDetailPanel.test.tsx
Normal file
262
apps/web/src/components/InboxDetailPanel.test.tsx
Normal file
@@ -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<string, string> }) => (
|
||||
<a href={params?.initiativeId ?? '#'}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock AgentOutputViewer
|
||||
vi.mock('./AgentOutputViewer', () => ({
|
||||
AgentOutputViewer: () => <div data-testid="agent-output-viewer" />,
|
||||
}))
|
||||
|
||||
function makeAgent(overrides?: Partial<ReturnType<typeof makeAgent>>): {
|
||||
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<typeof makeAgent>[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(<InboxDetailPanel agent={agent} {...props} />)
|
||||
}
|
||||
|
||||
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: '<p>Some text</p>', isLoadingContext: false }
|
||||
)
|
||||
|
||||
expect(screen.getByText('Some text')).toBeInTheDocument()
|
||||
// <p> should not appear as literal text
|
||||
expect(screen.queryByText(/<p>/)).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(
|
||||
<InboxDetailPanel
|
||||
agent={newAgent}
|
||||
taskDescription={null}
|
||||
isLoadingContext={false}
|
||||
questions={[]}
|
||||
isLoadingQuestions={false}
|
||||
questionsError={null}
|
||||
message={null}
|
||||
onBack={vi.fn()}
|
||||
onSubmitAnswers={vi.fn()}
|
||||
onDismissQuestions={vi.fn()}
|
||||
onDismissMessage={vi.fn()}
|
||||
isSubmitting={false}
|
||||
isDismissingQuestions={false}
|
||||
isDismissingMessage={false}
|
||||
submitError={null}
|
||||
dismissMessageError={null}
|
||||
/>
|
||||
)
|
||||
|
||||
// Logs should be collapsed
|
||||
expect(screen.queryByTestId('agent-output-viewer')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Show agent logs')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||||
{/* Mobile back button */}
|
||||
@@ -82,16 +116,7 @@ export function InboxDetailPanel({
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Task:{" "}
|
||||
{agent.taskId ? (
|
||||
<Link
|
||||
to="/initiatives"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{agent.taskId}
|
||||
</Link>
|
||||
) : (
|
||||
"\u2014"
|
||||
)}
|
||||
{agent.taskName ?? agent.taskId ?? "—"}
|
||||
</p>
|
||||
{agent.taskId && (
|
||||
<Link
|
||||
@@ -103,6 +128,39 @@ export function InboxDetailPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Related Context Panel */}
|
||||
{isLoadingContext ? (
|
||||
<div className="space-y-2 py-2">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="h-3 w-56" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
) : agent.taskId ? (
|
||||
<div className="space-y-1 rounded-md bg-muted/40 p-3 text-xs text-muted-foreground">
|
||||
{agent.initiativeName && agent.initiativeId && (
|
||||
<p>
|
||||
Initiative:{" "}
|
||||
<Link
|
||||
to="/initiatives/$initiativeId"
|
||||
params={{ initiativeId: agent.initiativeId }}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{agent.initiativeName}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{agent.taskName && (
|
||||
<p>Task: <span className="text-foreground">{agent.taskName}</span></p>
|
||||
)}
|
||||
{processedDescription && (
|
||||
<p className="text-muted-foreground">{processedDescription}</p>
|
||||
)}
|
||||
{agent.phaseName && <p>Phase: {agent.phaseName}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No task context available</p>
|
||||
)}
|
||||
|
||||
{/* Question Form or Notification Content */}
|
||||
{isLoadingQuestions && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
@@ -166,6 +224,31 @@ export function InboxDetailPanel({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Logs Section */}
|
||||
{!logsOpen && (outputQuery.data?.length ?? 0) > 0 && (
|
||||
<div className="pt-2">
|
||||
<button
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setLogsOpen(true)}
|
||||
>
|
||||
Show agent logs
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] tabular-nums">
|
||||
{outputQuery.isLoading ? "…" : outputQuery.data?.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logsOpen && (
|
||||
<div
|
||||
className="mt-2 overflow-y-auto rounded-md"
|
||||
style={{ maxHeight: 300 }}
|
||||
ref={logsContainerRef}
|
||||
>
|
||||
<AgentOutputViewer agentId={agent.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user