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:
Lukas May
2026-03-07 00:31:32 +01:00
parent 7e6921f01e
commit b708977ef1
3 changed files with 361 additions and 10 deletions

View 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('&lt;p&gt;')
})
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()
})
})

View File

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

View File

@@ -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
? {