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 { Link } from "@tanstack/react-router";
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { QuestionForm } from "@/components/QuestionForm";
|
import { QuestionForm } from "@/components/QuestionForm";
|
||||||
|
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
||||||
import { formatRelativeTime } from "@/lib/utils";
|
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 {
|
interface InboxDetailPanelProps {
|
||||||
agent: {
|
agent: {
|
||||||
@@ -10,8 +20,14 @@ interface InboxDetailPanelProps {
|
|||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
|
taskName: string | null;
|
||||||
|
phaseName: string | null;
|
||||||
|
initiativeName: string | null;
|
||||||
|
initiativeId: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
taskDescription: string | null;
|
||||||
|
isLoadingContext: boolean;
|
||||||
message: {
|
message: {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -40,6 +56,8 @@ interface InboxDetailPanelProps {
|
|||||||
|
|
||||||
export function InboxDetailPanel({
|
export function InboxDetailPanel({
|
||||||
agent,
|
agent,
|
||||||
|
taskDescription,
|
||||||
|
isLoadingContext,
|
||||||
message,
|
message,
|
||||||
questions,
|
questions,
|
||||||
isLoadingQuestions,
|
isLoadingQuestions,
|
||||||
@@ -54,6 +72,22 @@ export function InboxDetailPanel({
|
|||||||
submitError,
|
submitError,
|
||||||
dismissMessageError,
|
dismissMessageError,
|
||||||
}: InboxDetailPanelProps) {
|
}: 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 (
|
return (
|
||||||
<div className="space-y-4 rounded-lg border border-border p-4">
|
<div className="space-y-4 rounded-lg border border-border p-4">
|
||||||
{/* Mobile back button */}
|
{/* Mobile back button */}
|
||||||
@@ -82,16 +116,7 @@ export function InboxDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Task:{" "}
|
Task:{" "}
|
||||||
{agent.taskId ? (
|
{agent.taskName ?? agent.taskId ?? "—"}
|
||||||
<Link
|
|
||||||
to="/initiatives"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{agent.taskId}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
"\u2014"
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
{agent.taskId && (
|
{agent.taskId && (
|
||||||
<Link
|
<Link
|
||||||
@@ -103,6 +128,39 @@ export function InboxDetailPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Question Form or Notification Content */}
|
||||||
{isLoadingQuestions && (
|
{isLoadingQuestions && (
|
||||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||||
@@ -166,6 +224,31 @@ export function InboxDetailPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,8 +219,14 @@ export function HeadquartersPage() {
|
|||||||
name: selectedAgent.name,
|
name: selectedAgent.name,
|
||||||
status: selectedAgent.status,
|
status: selectedAgent.status,
|
||||||
taskId: selectedAgent.taskId ?? null,
|
taskId: selectedAgent.taskId ?? null,
|
||||||
|
taskName: selectedAgent.taskName ?? null,
|
||||||
|
phaseName: selectedAgent.phaseName ?? null,
|
||||||
|
initiativeName: selectedAgent.initiativeName ?? null,
|
||||||
|
initiativeId: selectedAgent.initiativeId ?? null,
|
||||||
updatedAt: String(selectedAgent.updatedAt),
|
updatedAt: String(selectedAgent.updatedAt),
|
||||||
}}
|
}}
|
||||||
|
isLoadingContext={agentsQuery.isLoading}
|
||||||
|
taskDescription={selectedAgent.taskDescription ?? null}
|
||||||
message={
|
message={
|
||||||
selectedMessage
|
selectedMessage
|
||||||
? {
|
? {
|
||||||
|
|||||||
Reference in New Issue
Block a user