feat: artifacts panel with tabbed markdown rendering

This commit is contained in:
Ken
2026-05-26 20:24:12 +00:00
parent c1533fe25c
commit a3635a9eb7
2 changed files with 408 additions and 0 deletions
+125
View File
@@ -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 (
<div
style={{
padding: '2rem',
color: '#6a6a8a',
textAlign: 'center',
}}
>
Select a session to view artifacts
</div>
)
}
const count = artifacts.length
const countLabel = count === 1 ? '1 artifact' : `${count} artifacts`
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header bar */}
<div
style={{
padding: '0.5rem 0.75rem',
borderBottom: '1px solid #2a2a4a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontSize: '0.8rem', color: '#8888aa' }}>{countLabel}</span>
<button
title="Refresh"
onClick={refresh}
style={{
background: 'none',
border: 'none',
color: '#7aa2f7',
cursor: 'pointer',
}}
>
<IconRefresh size={16} />
</button>
</div>
{/* Tab strip — only when there are artifacts */}
{artifacts.length > 0 && (
<div
style={{
display: 'flex',
gap: '2px',
padding: '0.3rem 0.5rem',
overflowX: 'auto',
borderBottom: '1px solid #2a2a4a',
}}
>
{artifacts.map((artifact) => {
const isActive = activeArtifact?.name === artifact.name
return (
<button
key={artifact.name}
onClick={() => loadArtifact(artifact.name)}
style={{
background: isActive ? '#2a3a5a' : 'transparent',
color: isActive ? '#ffffff' : '#8888aa',
padding: '0.3rem 0.6rem',
borderRadius: '4px',
fontSize: '0.75rem',
whiteSpace: 'nowrap',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
}}
>
<IconFileText size={14} />
{artifact.name}
</button>
)
})}
</div>
)}
{/* Content area */}
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem' }}>
{activeArtifact ? (
<div className="chat-message assistant">
<ReactMarkdown>{activeArtifact.content}</ReactMarkdown>
</div>
) : artifacts.length > 0 ? (
<div
style={{ color: '#6a6a8a', textAlign: 'center', paddingTop: '2rem' }}
>
Click an artifact tab to view it
</div>
) : (
<div
style={{ color: '#6a6a8a', textAlign: 'center', paddingTop: '2rem' }}
>
No artifacts yet. The researcher will create them during research.
</div>
)}
</div>
</div>
)
}
+283
View File
@@ -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 }) => <div>{children}</div>,
}))
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(<ArtifactsPanel sessionId={null} />)
expect(screen.getByText('Select a session to view artifacts')).toBeTruthy()
})
it('placeholder has padding 2rem', () => {
const { container } = render(<ArtifactsPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.padding).toBe('2rem')
})
it('placeholder has color #6a6a8a (rgb(106, 106, 138))', () => {
const { container } = render(<ArtifactsPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.color).toBe('rgb(106, 106, 138)')
})
it('placeholder is text-centered', () => {
const { container } = render(<ArtifactsPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.textAlign).toBe('center')
})
it('does not call refresh when sessionId is null', () => {
render(<ArtifactsPanel sessionId={null} />)
expect(mockRefresh).not.toHaveBeenCalled()
})
})
// ─── With session — useEffect ─────────────────────────────────────────
describe('with sessionId — useEffect', () => {
it('calls refresh when sessionId is set', async () => {
await act(async () => {
render(<ArtifactsPanel sessionId="sess-1" />)
})
expect(mockRefresh).toHaveBeenCalledTimes(1)
})
it('does not call refresh when sessionId is null', async () => {
await act(async () => {
render(<ArtifactsPanel sessionId={null} />)
})
expect(mockRefresh).not.toHaveBeenCalled()
})
})
// ─── Header bar ───────────────────────────────────────────────────────
describe('header bar', () => {
it('shows "0 artifacts" when artifacts is empty', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('2 artifacts')).toBeTruthy()
})
it('renders Refresh button with title "Refresh"', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByTitle('Refresh')).toBeTruthy()
})
it('Refresh button has color #7aa2f7 (rgb(122, 162, 247))', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
const btn = screen.getByTitle('Refresh') as HTMLButtonElement
expect(btn.style.color).toBe('rgb(122, 162, 247)')
})
it('Refresh button has no background', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
// 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(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
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(<ArtifactsPanel sessionId="sess-1" />)
// 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(<ArtifactsPanel sessionId="sess-1" />)
expect(container.querySelector('.chat-message.assistant')).toBeTruthy()
})
})
// ─── useArtifacts integration ─────────────────────────────────────────
describe('useArtifacts integration', () => {
it('passes sessionId to useArtifacts', () => {
render(<ArtifactsPanel sessionId="sess-42" />)
expect(mockUseArtifacts).toHaveBeenCalledWith('sess-42')
})
it('passes null to useArtifacts when sessionId is null', () => {
render(<ArtifactsPanel sessionId={null} />)
expect(mockUseArtifacts).toHaveBeenCalledWith(null)
})
})
})