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