feat: artifacts panel with tabbed markdown rendering
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user