From a3635a9eb7f2f34b03b9213c1abf4cd4750e1ee5 Mon Sep 17 00:00:00 2001 From: Ken Date: Tue, 26 May 2026 20:24:12 +0000 Subject: [PATCH] feat: artifacts panel with tabbed markdown rendering --- frontend/src/components/ArtifactsPanel.tsx | 125 +++++++++ frontend/src/test/ArtifactsPanel.test.tsx | 283 +++++++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100644 frontend/src/components/ArtifactsPanel.tsx create mode 100644 frontend/src/test/ArtifactsPanel.test.tsx diff --git a/frontend/src/components/ArtifactsPanel.tsx b/frontend/src/components/ArtifactsPanel.tsx new file mode 100644 index 0000000..29cf1c4 --- /dev/null +++ b/frontend/src/components/ArtifactsPanel.tsx @@ -0,0 +1,125 @@ +import { useEffect } from 'react' +import ReactMarkdown from 'react-markdown' +import { IconFileText, IconRefresh } from '@tabler/icons-react' +import { useArtifacts } from '../hooks/useArtifacts' + +export interface ArtifactsPanelProps { + sessionId: string | null +} + +export function ArtifactsPanel({ sessionId }: ArtifactsPanelProps) { + const { artifacts, activeArtifact, refresh, loadArtifact } = useArtifacts(sessionId) + + useEffect(() => { + if (sessionId) { + refresh() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]) + + if (!sessionId) { + return ( +
+ Select a session to view artifacts +
+ ) + } + + const count = artifacts.length + const countLabel = count === 1 ? '1 artifact' : `${count} artifacts` + + return ( +
+ {/* Header bar */} +
+ {countLabel} + +
+ + {/* Tab strip — only when there are artifacts */} + {artifacts.length > 0 && ( +
+ {artifacts.map((artifact) => { + const isActive = activeArtifact?.name === artifact.name + return ( + + ) + })} +
+ )} + + {/* Content area */} +
+ {activeArtifact ? ( +
+ {activeArtifact.content} +
+ ) : artifacts.length > 0 ? ( +
+ Click an artifact tab to view it +
+ ) : ( +
+ No artifacts yet. The researcher will create them during research. +
+ )} +
+
+ ) +} diff --git a/frontend/src/test/ArtifactsPanel.test.tsx b/frontend/src/test/ArtifactsPanel.test.tsx new file mode 100644 index 0000000..384a8b5 --- /dev/null +++ b/frontend/src/test/ArtifactsPanel.test.tsx @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ArtifactContent, Artifact } from '../lib/types' + +// Must be hoisted before imports +vi.mock('react-markdown', () => ({ + default: ({ children }: { children: string }) =>
{children}
, +})) + +vi.mock('../hooks/useArtifacts') + +import { ArtifactsPanel } from '../components/ArtifactsPanel' +import { useArtifacts } from '../hooks/useArtifacts' + +const mockUseArtifacts = vi.mocked(useArtifacts) + +const mockRefresh = vi.fn() +const mockLoadArtifact = vi.fn() + +function makeHookReturn( + artifacts: Artifact[] = [], + activeArtifact: ArtifactContent | null = null +) { + return { + artifacts, + activeArtifact, + refresh: mockRefresh, + loadArtifact: mockLoadArtifact, + } +} + +describe('ArtifactsPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseArtifacts.mockReturnValue(makeHookReturn()) + }) + + // ─── No session placeholder ─────────────────────────────────────────── + + describe('no-session placeholder', () => { + it('renders placeholder text when sessionId is null', () => { + render() + expect(screen.getByText('Select a session to view artifacts')).toBeTruthy() + }) + + it('placeholder has padding 2rem', () => { + const { container } = render() + const div = container.firstElementChild as HTMLElement + expect(div.style.padding).toBe('2rem') + }) + + it('placeholder has color #6a6a8a (rgb(106, 106, 138))', () => { + const { container } = render() + const div = container.firstElementChild as HTMLElement + expect(div.style.color).toBe('rgb(106, 106, 138)') + }) + + it('placeholder is text-centered', () => { + const { container } = render() + const div = container.firstElementChild as HTMLElement + expect(div.style.textAlign).toBe('center') + }) + + it('does not call refresh when sessionId is null', () => { + render() + expect(mockRefresh).not.toHaveBeenCalled() + }) + }) + + // ─── With session — useEffect ───────────────────────────────────────── + + describe('with sessionId — useEffect', () => { + it('calls refresh when sessionId is set', async () => { + await act(async () => { + render() + }) + expect(mockRefresh).toHaveBeenCalledTimes(1) + }) + + it('does not call refresh when sessionId is null', async () => { + await act(async () => { + render() + }) + expect(mockRefresh).not.toHaveBeenCalled() + }) + }) + + // ─── Header bar ─────────────────────────────────────────────────────── + + describe('header bar', () => { + it('shows "0 artifacts" when artifacts is empty', () => { + render() + expect(screen.getByText('0 artifacts')).toBeTruthy() + }) + + it('shows "1 artifact" (singular) when there is one artifact', () => { + mockUseArtifacts.mockReturnValue( + makeHookReturn([{ name: 'report.md', session_id: 'sess-1', size: 100 }]) + ) + render() + expect(screen.getByText('1 artifact')).toBeTruthy() + }) + + it('shows "2 artifacts" (plural) when there are two artifacts', () => { + mockUseArtifacts.mockReturnValue( + makeHookReturn([ + { name: 'report.md', session_id: 'sess-1', size: 100 }, + { name: 'summary.md', session_id: 'sess-1', size: 200 }, + ]) + ) + render() + expect(screen.getByText('2 artifacts')).toBeTruthy() + }) + + it('renders Refresh button with title "Refresh"', () => { + render() + expect(screen.getByTitle('Refresh')).toBeTruthy() + }) + + it('Refresh button has color #7aa2f7 (rgb(122, 162, 247))', () => { + render() + const btn = screen.getByTitle('Refresh') as HTMLButtonElement + expect(btn.style.color).toBe('rgb(122, 162, 247)') + }) + + it('Refresh button has no background', () => { + render() + const btn = screen.getByTitle('Refresh') as HTMLButtonElement + // background: 'none' normalized as empty string or 'none' + expect( + btn.style.background === 'none' || btn.style.background === '' + ).toBe(true) + }) + + it('clicking Refresh button calls refresh()', async () => { + render() + await userEvent.click(screen.getByTitle('Refresh')) + // refresh is also called on mount, so check at least once from click + expect(mockRefresh).toHaveBeenCalled() + }) + }) + + // ─── No artifacts empty state ───────────────────────────────────────── + + describe('no artifacts empty state', () => { + it('shows "No artifacts yet" message when artifacts array is empty', () => { + render() + expect( + screen.getByText( + 'No artifacts yet. The researcher will create them during research.' + ) + ).toBeTruthy() + }) + + it('does not render tab strip when there are no artifacts', () => { + const { container } = render() + // No buttons other than Refresh should be present + const buttons = container.querySelectorAll('button') + expect(buttons.length).toBe(1) // only the Refresh button + }) + }) + + // ─── Tab strip ──────────────────────────────────────────────────────── + + describe('tab strip', () => { + const artifacts: Artifact[] = [ + { name: 'report.md', session_id: 'sess-1', size: 100 }, + { name: 'summary.md', session_id: 'sess-1', size: 200 }, + ] + + it('renders a tab button for each artifact', () => { + mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts)) + const { container } = render() + const buttons = container.querySelectorAll('button') + // Refresh + 2 artifact tabs + expect(buttons.length).toBe(3) + }) + + it('each tab shows the artifact name', () => { + mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts)) + render() + expect(screen.getByText('report.md')).toBeTruthy() + expect(screen.getByText('summary.md')).toBeTruthy() + }) + + it('clicking a tab calls loadArtifact with the artifact name', async () => { + mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts)) + render() + await userEvent.click(screen.getByText('report.md')) + expect(mockLoadArtifact).toHaveBeenCalledWith('report.md') + }) + + it('active tab has background #2a3a5a and color #ffffff', () => { + const activeArtifact: ArtifactContent = { + name: 'report.md', + session_id: 'sess-1', + content: '# Report', + } + mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts, activeArtifact)) + const { container } = render() + const buttons = container.querySelectorAll('button') + // buttons[0] is Refresh, buttons[1] is first artifact tab + const activeTab = buttons[1] as HTMLElement + expect(activeTab.style.background).toBe('rgb(42, 58, 90)') + expect(activeTab.style.color).toBe('rgb(255, 255, 255)') + }) + + it('inactive tab has transparent background and color #8888aa', () => { + const activeArtifact: ArtifactContent = { + name: 'report.md', + session_id: 'sess-1', + content: '# Report', + } + mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts, activeArtifact)) + const { container } = render() + const buttons = container.querySelectorAll('button') + // buttons[2] is the second (inactive) artifact tab + const inactiveTab = buttons[2] as HTMLElement + expect(inactiveTab.style.color).toBe('rgb(136, 136, 170)') + }) + + it('shows "Click an artifact tab to view it" when artifacts exist but none selected', () => { + mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts, null)) + render() + expect(screen.getByText('Click an artifact tab to view it')).toBeTruthy() + }) + }) + + // ─── Content area with active artifact ─────────────────────────────── + + describe('content area — active artifact', () => { + it('renders the artifact content inside ReactMarkdown', () => { + const activeArtifact: ArtifactContent = { + name: 'report.md', + session_id: 'sess-1', + content: '# Research Report\n\nFindings here.', + } + mockUseArtifacts.mockReturnValue( + makeHookReturn( + [{ name: 'report.md', session_id: 'sess-1', size: 100 }], + activeArtifact + ) + ) + const { container } = render() + // The mock renders children in a div; check text content directly to handle newlines + const contentWrapper = container.querySelector('.chat-message.assistant') as HTMLElement + expect(contentWrapper).toBeTruthy() + expect(contentWrapper.textContent).toContain('# Research Report') + expect(contentWrapper.textContent).toContain('Findings here.') + }) + + it('renders content inside a div with class "chat-message assistant"', () => { + const activeArtifact: ArtifactContent = { + name: 'report.md', + session_id: 'sess-1', + content: '# Report', + } + mockUseArtifacts.mockReturnValue( + makeHookReturn( + [{ name: 'report.md', session_id: 'sess-1', size: 100 }], + activeArtifact + ) + ) + const { container } = render() + expect(container.querySelector('.chat-message.assistant')).toBeTruthy() + }) + }) + + // ─── useArtifacts integration ───────────────────────────────────────── + + describe('useArtifacts integration', () => { + it('passes sessionId to useArtifacts', () => { + render() + expect(mockUseArtifacts).toHaveBeenCalledWith('sess-42') + }) + + it('passes null to useArtifacts when sessionId is null', () => { + render() + expect(mockUseArtifacts).toHaveBeenCalledWith(null) + }) + }) +})