first commit of v.02 application
This commit is contained in:
69
client/src/components/ChatMessage.jsx
Normal file
69
client/src/components/ChatMessage.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import GroundednessScore from './GroundednessScore.jsx';
|
||||
import FollowUpQuestions from './FollowUpQuestions.jsx';
|
||||
|
||||
function renderAnswerWithCitations(answer, citations, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick) {
|
||||
if (!citations || citations.length === 0) {
|
||||
return <span className="answer-text">{answer}</span>;
|
||||
}
|
||||
|
||||
const parts = answer.split(/(\[\d+\])/g);
|
||||
let citationCounter = 0;
|
||||
const sidebarIsSource = hoveredSource?.startsWith('sidebar-');
|
||||
return (
|
||||
<span className="answer-text">
|
||||
{parts.map((part, i) => {
|
||||
const match = part.match(/^\[(\d+)\]$/);
|
||||
if (match) {
|
||||
const docIndex = parseInt(match[1], 10);
|
||||
const instanceId = `c-${citationCounter++}`;
|
||||
const isHighlighted =
|
||||
hoveredSource === instanceId ||
|
||||
(sidebarIsSource && hoveredDocIndex === docIndex);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`citation-marker${isHighlighted ? ' citation-highlighted' : ''}`}
|
||||
title={`Source ${match[1]} — click for details`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Citation source ${match[1]}`}
|
||||
onMouseEnter={() => onSourceHover({ instanceId, docIndex })}
|
||||
onMouseLeave={() => onSourceHover(null)}
|
||||
onClick={() => onCitationClick?.(docIndex)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onCitationClick?.(docIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{match[1]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message, onFollowUp, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick }) {
|
||||
if (message.role === 'user') {
|
||||
return <div className="chat-message user">{message.text}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-message assistant">
|
||||
{renderAnswerWithCitations(message.answer, message.citations, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick)}
|
||||
<div className="message-meta">
|
||||
<GroundednessScore score={message.groundednessScore} />
|
||||
</div>
|
||||
<FollowUpQuestions
|
||||
questions={message.followUpQuestions}
|
||||
onSelect={onFollowUp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatMessage;
|
||||
165
client/src/components/ChatMessage.test.jsx
Normal file
165
client/src/components/ChatMessage.test.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ChatMessage from './ChatMessage.jsx';
|
||||
|
||||
describe('ChatMessage', () => {
|
||||
describe('user messages', () => {
|
||||
it('renders user text in a user-styled div', () => {
|
||||
const msg = { role: 'user', text: 'Hello there' };
|
||||
const { container } = render(<ChatMessage message={msg} />);
|
||||
const el = container.querySelector('.chat-message.user');
|
||||
expect(el).toHaveTextContent('Hello there');
|
||||
});
|
||||
|
||||
it('does not render groundedness or follow-ups for user messages', () => {
|
||||
const msg = { role: 'user', text: 'Hi' };
|
||||
const { container } = render(<ChatMessage message={msg} />);
|
||||
expect(container.querySelector('.groundedness-score')).toBeNull();
|
||||
expect(container.querySelector('.follow-up-questions')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant messages — plain text (no citations)', () => {
|
||||
it('renders answer text without citation markers', () => {
|
||||
const msg = { role: 'assistant', answer: 'The sky is blue.', citations: [] };
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByText('The sky is blue.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders answer when citations is null', () => {
|
||||
const msg = { role: 'assistant', answer: 'No sources.', citations: null };
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByText('No sources.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant messages — with citations', () => {
|
||||
const baseCitations = [{ sourceIndex: 1 }, { sourceIndex: 2 }];
|
||||
|
||||
it('renders citation numbers as clickable buttons', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Fact one [1] and fact two [2].',
|
||||
citations: baseCitations,
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
|
||||
const markers = screen.getAllByRole('button');
|
||||
expect(markers).toHaveLength(2);
|
||||
expect(markers[0]).toHaveTextContent('1');
|
||||
expect(markers[1]).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('sets aria-label on citation markers', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Info [3].',
|
||||
citations: [{ sourceIndex: 3 }],
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByLabelText('Citation source 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCitationClick with docIndex when citation is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCitationClick = vi.fn();
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'See [2] here.',
|
||||
citations: [{ sourceIndex: 1 }, { sourceIndex: 2 }],
|
||||
};
|
||||
render(
|
||||
<ChatMessage
|
||||
message={msg}
|
||||
onFollowUp={() => {}}
|
||||
onSourceHover={() => {}}
|
||||
onCitationClick={onCitationClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText('Citation source 2'));
|
||||
expect(onCitationClick).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('calls onSourceHover on mouseEnter/mouseLeave of citation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSourceHover = vi.fn();
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Ref [1].',
|
||||
citations: [{ sourceIndex: 1 }],
|
||||
};
|
||||
render(
|
||||
<ChatMessage
|
||||
message={msg}
|
||||
onFollowUp={() => {}}
|
||||
onSourceHover={onSourceHover}
|
||||
/>,
|
||||
);
|
||||
|
||||
const marker = screen.getByLabelText('Citation source 1');
|
||||
await user.hover(marker);
|
||||
expect(onSourceHover).toHaveBeenCalledWith({ instanceId: 'c-0', docIndex: 1 });
|
||||
|
||||
await user.unhover(marker);
|
||||
expect(onSourceHover).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('highlights citation when hoveredSource matches instanceId', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Ref [1].',
|
||||
citations: [{ sourceIndex: 1 }],
|
||||
};
|
||||
const { container } = render(
|
||||
<ChatMessage
|
||||
message={msg}
|
||||
hoveredSource="c-0"
|
||||
onFollowUp={() => {}}
|
||||
onSourceHover={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('.citation-highlighted')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('handles multiple citations in sequence', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: '[1][2][3]',
|
||||
citations: [{ sourceIndex: 1 }, { sourceIndex: 2 }, { sourceIndex: 3 }],
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
const markers = screen.getAllByRole('button');
|
||||
expect(markers).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groundedness and follow-ups', () => {
|
||||
it('renders GroundednessScore when present', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Answer',
|
||||
citations: [],
|
||||
groundednessScore: 0.85,
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByText(/Grounded: 85%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders follow-up questions and forwards onFollowUp', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFollowUp = vi.fn();
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Answer',
|
||||
citations: [],
|
||||
followUpQuestions: ['Tell me more', 'Why?'],
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={onFollowUp} onSourceHover={() => {}} />);
|
||||
|
||||
await user.click(screen.getByText('Why?'));
|
||||
expect(onFollowUp).toHaveBeenCalledWith('Why?');
|
||||
});
|
||||
});
|
||||
});
|
||||
150
client/src/components/ChatPanel.jsx
Normal file
150
client/src/components/ChatPanel.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { sendQuery } from '../api/query.js';
|
||||
import ChatMessage from './ChatMessage.jsx';
|
||||
import CitationDetailModal from './CitationDetailModal.jsx';
|
||||
|
||||
function ChatPanel({ notebookId, hoveredSource, hoveredDocIndex, onSourceHover, onFirstResponse }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [citationDetails, setCitationDetails] = useState({});
|
||||
const [activeCitation, setActiveCitation] = useState(null);
|
||||
const bottomRef = useRef(null);
|
||||
const firstResponseFired = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
setInput('');
|
||||
setCitationDetails({});
|
||||
setActiveCitation(null);
|
||||
firstResponseFired.current = false;
|
||||
}, [notebookId]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const submitQuery = async (question) => {
|
||||
if (!question.trim() || loading) return;
|
||||
|
||||
const userMsg = { role: 'user', text: question, msgId: crypto.randomUUID() };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
const msgId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const res = await sendQuery(notebookId, question, (details) => {
|
||||
const detailMap = {};
|
||||
for (const d of details) {
|
||||
detailMap[d.sourceIndex] = d;
|
||||
}
|
||||
setCitationDetails((prev) => ({ ...prev, [msgId]: detailMap }));
|
||||
});
|
||||
|
||||
const assistantMsg = {
|
||||
role: 'assistant',
|
||||
answer: res.answer,
|
||||
citations: res.citations || [],
|
||||
groundednessScore: res.groundednessScore,
|
||||
followUpQuestions: res.followUpQuestions || [],
|
||||
msgId,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
if (!firstResponseFired.current) {
|
||||
firstResponseFired.current = true;
|
||||
onFirstResponse?.();
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = {
|
||||
role: 'assistant',
|
||||
msgId,
|
||||
answer: 'Something went wrong. Please try again.',
|
||||
citations: [],
|
||||
groundednessScore: null,
|
||||
followUpQuestions: [],
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
console.error('query failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
submitQuery(input);
|
||||
};
|
||||
|
||||
const handleFollowUp = (question) => {
|
||||
submitQuery(question);
|
||||
};
|
||||
|
||||
const handleCitationClick = (msgId, docIndex) => {
|
||||
const citation = messages.find(
|
||||
(m) => m.msgId === msgId
|
||||
)?.citations?.find((c) => c.sourceIndex === docIndex);
|
||||
|
||||
setActiveCitation({
|
||||
msgId,
|
||||
docIndex,
|
||||
sourceName: citation?.name || 'Unknown',
|
||||
});
|
||||
};
|
||||
|
||||
const activeDetail =
|
||||
activeCitation
|
||||
? citationDetails[activeCitation.msgId]?.[activeCitation.docIndex] ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="chat-empty">
|
||||
Upload sources and ask a question to get started.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage
|
||||
key={msg.msgId}
|
||||
message={msg}
|
||||
onFollowUp={handleFollowUp}
|
||||
hoveredSource={hoveredSource}
|
||||
hoveredDocIndex={hoveredDocIndex}
|
||||
onSourceHover={onSourceHover}
|
||||
onCitationClick={(docIndex) => handleCitationClick(msg.msgId, docIndex)}
|
||||
/>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="chat-message assistant loading">
|
||||
Thinking<span className="loading-dots" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<form className="chat-input" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ask a question about your sources..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button type="submit" disabled={loading || !input.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
<CitationDetailModal
|
||||
open={!!activeCitation}
|
||||
onClose={() => setActiveCitation(null)}
|
||||
detail={activeDetail}
|
||||
sourceName={activeCitation?.sourceName}
|
||||
citationIndex={activeCitation?.docIndex}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatPanel;
|
||||
53
client/src/components/CitationDetailModal.jsx
Normal file
53
client/src/components/CitationDetailModal.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
function CitationDetailModal({ open, onClose, detail, sourceName, citationIndex }) {
|
||||
const loading = !detail;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={`Source ${citationIndex} — ${sourceName || 'Unknown'}`} width="680px">
|
||||
{loading ? (
|
||||
<p className="citation-detail-loading">Loading deeper insight<span className="loading-dots" /></p>
|
||||
) : (
|
||||
<div className="citation-detail-content">
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Cited Passage</h4>
|
||||
<blockquote className="citation-detail-quote">{detail.citedSentence}</blockquote>
|
||||
</section>
|
||||
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Topic Context</h4>
|
||||
<p>{detail.topicSummary}</p>
|
||||
</section>
|
||||
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Additional Insight</h4>
|
||||
<p>{detail.additionalInsight}</p>
|
||||
</section>
|
||||
|
||||
{detail.relevantLinks?.length > 0 && (
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Further Reading</h4>
|
||||
<ul className="citation-detail-links">
|
||||
{detail.relevantLinks.map((link, i) => (
|
||||
<li key={i}>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="modal-btn modal-btn-cancel" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CitationDetailModal;
|
||||
107
client/src/components/CitationDetailModal.test.jsx
Normal file
107
client/src/components/CitationDetailModal.test.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CitationDetailModal from './CitationDetailModal.jsx';
|
||||
|
||||
const detail = {
|
||||
citedSentence: 'The earth orbits the sun.',
|
||||
topicSummary: 'Astronomy basics covering planetary motion.',
|
||||
additionalInsight: 'This was first proven by Copernicus.',
|
||||
relevantLinks: [
|
||||
{ title: 'Wikipedia: Heliocentrism', url: 'https://en.wikipedia.org/wiki/Heliocentrism' },
|
||||
{ title: 'NASA Orbits', url: 'https://nasa.gov/orbits' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('CitationDetailModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<CitationDetailModal open={false} onClose={() => {}} detail={detail} sourceName="Doc1" citationIndex={1} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows loading state when detail is null', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={null} sourceName="Doc1" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText(/Loading deeper insight/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the title with source name and index', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="MyDoc.pdf" citationIndex={3} />,
|
||||
);
|
||||
expect(screen.getByText('Source 3 — MyDoc.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses "Unknown" when sourceName is falsy', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('Source 1 — Unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cited passage as blockquote', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
const quote = screen.getByText('The earth orbits the sun.');
|
||||
expect(quote.tagName).toBe('BLOCKQUOTE');
|
||||
});
|
||||
|
||||
it('renders topic context', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('Astronomy basics covering planetary motion.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders additional insight', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('This was first proven by Copernicus.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders relevant links', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
const link1 = screen.getByText('Wikipedia: Heliocentrism');
|
||||
expect(link1.closest('a')).toHaveAttribute('href', 'https://en.wikipedia.org/wiki/Heliocentrism');
|
||||
expect(link1.closest('a')).toHaveAttribute('target', '_blank');
|
||||
|
||||
const link2 = screen.getByText('NASA Orbits');
|
||||
expect(link2.closest('a')).toHaveAttribute('href', 'https://nasa.gov/orbits');
|
||||
});
|
||||
|
||||
it('omits Further Reading when relevantLinks is empty', () => {
|
||||
const noLinks = { ...detail, relevantLinks: [] };
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={noLinks} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.queryByText('Further Reading')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders Close button and calls onClose', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={onClose} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Close'));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders all section labels', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('Cited Passage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Topic Context')).toBeInTheDocument();
|
||||
expect(screen.getByText('Additional Insight')).toBeInTheDocument();
|
||||
expect(screen.getByText('Further Reading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
53
client/src/components/CreateNotebookModal.jsx
Normal file
53
client/src/components/CreateNotebookModal.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
function CreateNotebookModal({ open, onConfirm, onCancel }) {
|
||||
const [name, setName] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed) onConfirm(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title="New Notebook">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="modal-input"
|
||||
type="text"
|
||||
placeholder="Enter notebook name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="modal-btn modal-btn-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="modal-btn modal-btn-confirm"
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateNotebookModal;
|
||||
99
client/src/components/CreateNotebookModal.test.jsx
Normal file
99
client/src/components/CreateNotebookModal.test.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CreateNotebookModal from './CreateNotebookModal.jsx';
|
||||
|
||||
describe('CreateNotebookModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<CreateNotebookModal open={false} onConfirm={() => {}} onCancel={() => {}} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders the modal with title when open', () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByText('New Notebook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders input and buttons', () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByPlaceholderText('Enter notebook name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Create button when input is empty', () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByText('Create')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Create button when input has text', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'My Notebook');
|
||||
expect(screen.getByText('Create')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('calls onConfirm with trimmed name on submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), ' Research Notes ');
|
||||
await user.click(screen.getByText('Create'));
|
||||
expect(onConfirm).toHaveBeenCalledWith('Research Notes');
|
||||
});
|
||||
|
||||
it('calls onConfirm on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'Test{Enter}');
|
||||
expect(onConfirm).toHaveBeenCalledWith('Test');
|
||||
});
|
||||
|
||||
it('does not call onConfirm when input is only whitespace', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter notebook name');
|
||||
await user.type(input, ' ');
|
||||
await user.type(input, '{Enter}');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCancel when Cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={onCancel} />);
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('resets input when reopened', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'Old name');
|
||||
|
||||
rerender(<CreateNotebookModal open={false} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
rerender(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter notebook name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('auto-focuses the input when opened', async () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Enter notebook name')).toHaveFocus();
|
||||
}, { timeout: 200 });
|
||||
});
|
||||
});
|
||||
29
client/src/components/DocumentButtons.jsx
Normal file
29
client/src/components/DocumentButtons.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
const DOCUMENT_TYPES = [
|
||||
{ type: 'study-guide', label: 'Study Guide' },
|
||||
{ type: 'faq', label: 'F.A.Q.' },
|
||||
{ type: 'executive-brief', label: 'Executive Brief' },
|
||||
];
|
||||
|
||||
function DocumentButtons({ sourceCount, onRequest, generatingType }) {
|
||||
const enabled = sourceCount >= 2;
|
||||
|
||||
return (
|
||||
<div className="document-buttons">
|
||||
{DOCUMENT_TYPES.map(({ type, label }) => {
|
||||
const isGenerating = generatingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
className={`btn-document${isGenerating ? ' generating' : ''}`}
|
||||
disabled={!enabled || !!generatingType}
|
||||
onClick={() => onRequest(type)}
|
||||
>
|
||||
{isGenerating ? `Generating ${label}...` : label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentButtons;
|
||||
61
client/src/components/DocumentButtons.test.jsx
Normal file
61
client/src/components/DocumentButtons.test.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import DocumentButtons from './DocumentButtons.jsx';
|
||||
|
||||
describe('DocumentButtons', () => {
|
||||
const LABELS = ['Study Guide', 'F.A.Q.', 'Executive Brief'];
|
||||
|
||||
it('renders all three document type buttons', () => {
|
||||
render(<DocumentButtons sourceCount={0} onRequest={() => {}} generatingType={null} />);
|
||||
LABELS.forEach((label) => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables all buttons when sourceCount < 2', () => {
|
||||
render(<DocumentButtons sourceCount={1} onRequest={() => {}} generatingType={null} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
|
||||
it('enables buttons when sourceCount >= 2', () => {
|
||||
render(<DocumentButtons sourceCount={2} onRequest={() => {}} generatingType={null} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeEnabled());
|
||||
});
|
||||
|
||||
it('enables buttons for large source counts', () => {
|
||||
render(<DocumentButtons sourceCount={10} onRequest={() => {}} generatingType={null} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeEnabled());
|
||||
});
|
||||
|
||||
it('calls onRequest with the correct type when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRequest = vi.fn();
|
||||
render(<DocumentButtons sourceCount={3} onRequest={onRequest} generatingType={null} />);
|
||||
|
||||
await user.click(screen.getByText('F.A.Q.'));
|
||||
expect(onRequest).toHaveBeenCalledWith('faq');
|
||||
});
|
||||
|
||||
it('disables all buttons when one type is generating', () => {
|
||||
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="faq" />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
|
||||
it('shows generating label for the active type', () => {
|
||||
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="study-guide" />);
|
||||
expect(screen.getByText('Generating Study Guide...')).toBeInTheDocument();
|
||||
expect(screen.getByText('F.A.Q.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Executive Brief')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies generating CSS class to the active button', () => {
|
||||
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="executive-brief" />);
|
||||
const genButton = screen.getByText('Generating Executive Brief...');
|
||||
expect(genButton).toHaveClass('generating');
|
||||
});
|
||||
});
|
||||
112
client/src/components/DocumentModal.jsx
Normal file
112
client/src/components/DocumentModal.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
function StudyGuideContent({ doc }) {
|
||||
return (
|
||||
<div className="doc-content">
|
||||
<h2 className="doc-title">{doc.title}</h2>
|
||||
{doc.sections?.map((section, i) => (
|
||||
<section key={i} className="doc-section">
|
||||
<h3 className="doc-section-heading">{section.heading}</h3>
|
||||
{section.bullets?.length > 0 && (
|
||||
<ul className="doc-bullets">
|
||||
{section.bullets.map((b, j) => <li key={j}>{b}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{section.keyTerms?.length > 0 && (
|
||||
<div className="doc-key-terms">
|
||||
<h4 className="doc-subsection-label">Key Terms</h4>
|
||||
<dl>
|
||||
{section.keyTerms.map((kt, j) => (
|
||||
<div key={j} className="doc-term-pair">
|
||||
<dt>{kt.term}</dt>
|
||||
<dd>{kt.definition}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{section.reviewQuestions?.length > 0 && (
|
||||
<div className="doc-review">
|
||||
<h4 className="doc-subsection-label">Self-Review</h4>
|
||||
<ol>
|
||||
{section.reviewQuestions.map((q, j) => <li key={j}>{q}</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{doc.mnemonics?.length > 0 && (
|
||||
<section className="doc-section">
|
||||
<h3 className="doc-section-heading">Memory Aids</h3>
|
||||
<ul className="doc-bullets">
|
||||
{doc.mnemonics.map((m, i) => <li key={i}>{m}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqContent({ doc }) {
|
||||
return (
|
||||
<div className="doc-content">
|
||||
<h2 className="doc-title">FAQ: {doc.subject}</h2>
|
||||
{doc.faqPairs?.map((pair, i) => (
|
||||
<div key={i} className="doc-faq-pair">
|
||||
<h4 className="doc-faq-question">Q: {pair.question}</h4>
|
||||
<p className="doc-faq-answer">{pair.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecBriefContent({ doc }) {
|
||||
return (
|
||||
<div className="doc-content">
|
||||
<h2 className="doc-title">{doc.title}</h2>
|
||||
{doc.sections?.map((section, i) => (
|
||||
<section key={i} className="doc-section">
|
||||
<h3 className="doc-section-heading">{section.subhead}</h3>
|
||||
<p className="doc-prose">{section.prose}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
'study-guide': 'Study Guide',
|
||||
'faq': 'F.A.Q.',
|
||||
'executive-brief': 'Executive Brief',
|
||||
};
|
||||
|
||||
const RENDERERS = {
|
||||
'study-guide': StudyGuideContent,
|
||||
'faq': FaqContent,
|
||||
'executive-brief': ExecBriefContent,
|
||||
};
|
||||
|
||||
function DocumentModal({ open, onClose, type, document: doc, loading }) {
|
||||
const Renderer = type ? RENDERERS[type] : null;
|
||||
const label = type ? LABELS[type] : '';
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={label} width="720px">
|
||||
{loading ? (
|
||||
<p className="citation-detail-loading">Generating {label}<span className="loading-dots" /></p>
|
||||
) : (
|
||||
<>
|
||||
{Renderer && doc && <Renderer doc={doc} />}
|
||||
<div className="modal-actions">
|
||||
<button className="modal-btn modal-btn-cancel" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentModal;
|
||||
140
client/src/components/DocumentModal.test.jsx
Normal file
140
client/src/components/DocumentModal.test.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import DocumentModal from './DocumentModal.jsx';
|
||||
|
||||
const studyGuideDoc = {
|
||||
title: 'Intro to AI',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Chapter 1',
|
||||
bullets: ['Point A', 'Point B'],
|
||||
keyTerms: [{ term: 'Neural Net', definition: 'A model inspired by the brain' }],
|
||||
reviewQuestions: ['What is AI?'],
|
||||
},
|
||||
],
|
||||
mnemonics: ['AI = Always Iterating'],
|
||||
};
|
||||
|
||||
const faqDoc = {
|
||||
subject: 'Machine Learning',
|
||||
faqPairs: [
|
||||
{ question: 'What is ML?', answer: 'A subset of AI.' },
|
||||
{ question: 'Why use ML?', answer: 'To find patterns in data.' },
|
||||
],
|
||||
};
|
||||
|
||||
const execBriefDoc = {
|
||||
title: 'Q4 Strategy',
|
||||
sections: [
|
||||
{ subhead: 'Revenue', prose: 'Revenue grew 20%.' },
|
||||
{ subhead: 'Outlook', prose: 'Positive outlook for next quarter.' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('DocumentModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<DocumentModal open={false} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows loading state with doc type label', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.getByText(/Generating Study Guide/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state for FAQ', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.getByText(/Generating F\.A\.Q\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state for Executive Brief', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="executive-brief" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.getByText(/Generating Executive Brief/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('StudyGuideContent', () => {
|
||||
it('renders title and section heading', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Intro to AI')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chapter 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bullets', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Point A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Point B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders key terms', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Neural Net')).toBeInTheDocument();
|
||||
expect(screen.getByText('A model inspired by the brain')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders review questions', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('What is AI?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mnemonics', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('AI = Always Iterating')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FaqContent', () => {
|
||||
it('renders subject and Q&A pairs', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('FAQ: Machine Learning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Q: What is ML?')).toBeInTheDocument();
|
||||
expect(screen.getByText('A subset of AI.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Q: Why use ML?')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExecBriefContent', () => {
|
||||
it('renders title and sections', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="executive-brief" document={execBriefDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Q4 Strategy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Revenue grew 20%.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Outlook')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Close button when not loading', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Close')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Close button when loading', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.queryByText('Close')).toBeNull();
|
||||
});
|
||||
});
|
||||
16
client/src/components/FollowUpQuestions.jsx
Normal file
16
client/src/components/FollowUpQuestions.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
function FollowUpQuestions({ questions, onSelect }) {
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="follow-up-questions">
|
||||
<h3 className="follow-up-header">Suggested Follow-Up Questions:</h3>
|
||||
{questions.map((q, i) => (
|
||||
<button key={i} className="follow-up-chip" onClick={() => onSelect(q)}>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FollowUpQuestions;
|
||||
49
client/src/components/FollowUpQuestions.test.jsx
Normal file
49
client/src/components/FollowUpQuestions.test.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import FollowUpQuestions from './FollowUpQuestions.jsx';
|
||||
|
||||
describe('FollowUpQuestions', () => {
|
||||
it('renders nothing when questions is null', () => {
|
||||
const { container } = render(<FollowUpQuestions questions={null} onSelect={() => {}} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders nothing when questions is empty', () => {
|
||||
const { container } = render(<FollowUpQuestions questions={[]} onSelect={() => {}} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders a chip for each question', () => {
|
||||
const questions = ['What is X?', 'How does Y work?', 'Why Z?'];
|
||||
render(<FollowUpQuestions questions={questions} onSelect={() => {}} />);
|
||||
|
||||
questions.forEach((q) => {
|
||||
expect(screen.getByText(q)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all chips as buttons', () => {
|
||||
const questions = ['Q1', 'Q2'];
|
||||
render(<FollowUpQuestions questions={questions} onSelect={() => {}} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('calls onSelect with the question text when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const questions = ['What is X?', 'How does Y work?'];
|
||||
|
||||
render(<FollowUpQuestions questions={questions} onSelect={onSelect} />);
|
||||
|
||||
await user.click(screen.getByText('How does Y work?'));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
expect(onSelect).toHaveBeenCalledWith('How does Y work?');
|
||||
});
|
||||
|
||||
it('renders the header text', () => {
|
||||
render(<FollowUpQuestions questions={['Q1']} onSelect={() => {}} />);
|
||||
expect(screen.getByText('Suggested Follow-Up Questions:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
22
client/src/components/GroundednessScore.jsx
Normal file
22
client/src/components/GroundednessScore.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
const LEVEL_LABELS = {
|
||||
green: 'High score. Factually reliable response.',
|
||||
gold: 'Average score. Reliable response, may exhibit minor drift from source.',
|
||||
red: 'Low score. Unreliable response, factually inaccurate or hallucination.',
|
||||
};
|
||||
|
||||
function GroundednessScore({ score }) {
|
||||
if (score == null) return null;
|
||||
|
||||
const pct = Math.round(score * 100);
|
||||
let level = 'red';
|
||||
if (pct >= 75) level = 'green';
|
||||
else if (pct >= 50) level = 'gold';
|
||||
|
||||
return (
|
||||
<span className={`groundedness-score ${level}`}>
|
||||
Grounded: {pct}% – {LEVEL_LABELS[level]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroundednessScore;
|
||||
68
client/src/components/GroundednessScore.test.jsx
Normal file
68
client/src/components/GroundednessScore.test.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import GroundednessScore from './GroundednessScore.jsx';
|
||||
|
||||
describe('GroundednessScore', () => {
|
||||
it('renders nothing when score is null', () => {
|
||||
const { container } = render(<GroundednessScore score={null} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders nothing when score is undefined', () => {
|
||||
const { container } = render(<GroundednessScore />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders green level for score >= 0.75', () => {
|
||||
render(<GroundednessScore score={0.85} />);
|
||||
const el = screen.getByText(/Grounded: 85%/);
|
||||
expect(el).toHaveClass('green');
|
||||
});
|
||||
|
||||
it('renders green at exactly 0.75 boundary', () => {
|
||||
render(<GroundednessScore score={0.75} />);
|
||||
const el = screen.getByText(/Grounded: 75%/);
|
||||
expect(el).toHaveClass('green');
|
||||
});
|
||||
|
||||
it('renders gold level for score >= 0.50 and < 0.75', () => {
|
||||
render(<GroundednessScore score={0.6} />);
|
||||
const el = screen.getByText(/Grounded: 60%/);
|
||||
expect(el).toHaveClass('gold');
|
||||
});
|
||||
|
||||
it('renders gold at exactly 0.50 boundary', () => {
|
||||
render(<GroundednessScore score={0.5} />);
|
||||
const el = screen.getByText(/Grounded: 50%/);
|
||||
expect(el).toHaveClass('gold');
|
||||
});
|
||||
|
||||
it('renders red level for score < 0.50', () => {
|
||||
render(<GroundednessScore score={0.3} />);
|
||||
const el = screen.getByText(/Grounded: 30%/);
|
||||
expect(el).toHaveClass('red');
|
||||
});
|
||||
|
||||
it('renders 0% for score of 0', () => {
|
||||
render(<GroundednessScore score={0} />);
|
||||
const el = screen.getByText(/Grounded: 0%/);
|
||||
expect(el).toHaveClass('red');
|
||||
});
|
||||
|
||||
it('renders 100% for score of 1', () => {
|
||||
render(<GroundednessScore score={1} />);
|
||||
const el = screen.getByText(/Grounded: 100%/);
|
||||
expect(el).toHaveClass('green');
|
||||
});
|
||||
|
||||
it('includes the correct label text for each level', () => {
|
||||
const { rerender } = render(<GroundednessScore score={0.9} />);
|
||||
expect(screen.getByText(/Factually reliable response/)).toBeInTheDocument();
|
||||
|
||||
rerender(<GroundednessScore score={0.6} />);
|
||||
expect(screen.getByText(/minor drift from source/)).toBeInTheDocument();
|
||||
|
||||
rerender(<GroundednessScore score={0.2} />);
|
||||
expect(screen.getByText(/factually inaccurate or hallucination/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
68
client/src/components/Modal.jsx
Normal file
68
client/src/components/Modal.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
const FOCUSABLE = 'a[href], button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function Modal({ open, onClose, title, width, children }) {
|
||||
const dialogRef = useRef(null);
|
||||
const previousFocus = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previousFocus.current = document.activeElement;
|
||||
const dialog = dialogRef.current;
|
||||
const first = dialog?.querySelector(FOCUSABLE);
|
||||
if (first) first.focus();
|
||||
else dialog?.focus();
|
||||
|
||||
return () => previousFocus.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const focusable = [...dialog.querySelectorAll(FOCUSABLE)];
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleOverlayClick} onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
className="modal-dialog"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title || undefined}
|
||||
tabIndex={-1}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
{title && <h3 className="modal-title">{title}</h3>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
103
client/src/components/Modal.test.jsx
Normal file
103
client/src/components/Modal.test.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
describe('Modal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<Modal open={false} onClose={() => {}} title="Hidden">
|
||||
<p>Content</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders children when open is true', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}}>
|
||||
<p>Hello world</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the title when provided', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}} title="My Modal">
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.getByText('My Modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render a title element when title is omitted', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
});
|
||||
|
||||
it('has dialog role and aria-modal', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}} title="Accessible">
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(dialog).toHaveAttribute('aria-label', 'Accessible');
|
||||
});
|
||||
|
||||
it('calls onClose when Escape is pressed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Modal open={true} onClose={onClose}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onClose when overlay is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<Modal open={true} onClose={onClose}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('.modal-overlay');
|
||||
await user.click(overlay);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not call onClose when dialog content is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Modal open={true} onClose={onClose}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('body'));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies custom width style', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}} width="600px">
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog.style.width).toBe('600px');
|
||||
});
|
||||
});
|
||||
53
client/src/components/NotebookList.jsx
Normal file
53
client/src/components/NotebookList.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import CreateNotebookModal from './CreateNotebookModal.jsx';
|
||||
|
||||
function NotebookList({ notebooks, activeId, onSelect, onCreate, onDelete }) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const handleDelete = (e, id) => {
|
||||
e.stopPropagation();
|
||||
onDelete(id);
|
||||
};
|
||||
|
||||
const handleConfirm = (name) => {
|
||||
setModalOpen(false);
|
||||
onCreate(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notebook-list">
|
||||
<h2>Notebooks</h2>
|
||||
<ul>
|
||||
{notebooks.map((nb) => (
|
||||
<li
|
||||
key={nb.id}
|
||||
className={nb.id === activeId ? 'active' : ''}
|
||||
onClick={() => onSelect(nb.id)}
|
||||
>
|
||||
<span className="nb-name">{nb.name}</span>
|
||||
<button
|
||||
className="nb-delete"
|
||||
onClick={(e) => handleDelete(e, nb.id)}
|
||||
title="Delete notebook"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
className="btn-new-notebook"
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
+ New Notebook
|
||||
</button>
|
||||
<CreateNotebookModal
|
||||
open={modalOpen}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotebookList;
|
||||
208
client/src/components/SourcePanel.jsx
Normal file
208
client/src/components/SourcePanel.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import * as sourcesApi from '../api/sources.js';
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.pdf', '.txt', '.md', '.docx', '.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm'];
|
||||
|
||||
const FILE_ACCEPT = ALLOWED_EXTENSIONS.join(',');
|
||||
|
||||
function isFileAllowed(file) {
|
||||
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
function SourcePanel({ notebookId, hoveredSourceIndex, onSourceHover, onSourcesChange, children }) {
|
||||
const [sources, setSources] = useState([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [addingUrl, setAddingUrl] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const fileRef = useRef(null);
|
||||
const dragCounter = useRef(0);
|
||||
const errorTimer = useRef(null);
|
||||
|
||||
const showError = (msg) => {
|
||||
setError(msg);
|
||||
clearTimeout(errorTimer.current);
|
||||
errorTimer.current = setTimeout(() => setError(null), 8000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSources([]);
|
||||
onSourcesChange?.(0);
|
||||
sourcesApi.listSources(notebookId).then((s) => {
|
||||
setSources(s);
|
||||
onSourcesChange?.(s.length);
|
||||
}).catch((err) => showError(err.message || 'Failed to load sources'));
|
||||
}, [notebookId, onSourcesChange]);
|
||||
|
||||
useEffect(() => () => clearTimeout(errorTimer.current), []);
|
||||
|
||||
const uploadFile = async (file) => {
|
||||
if (!file) return;
|
||||
if (!isFileAllowed(file)) {
|
||||
showError(`Unsupported file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
const src = await sourcesApi.uploadSource(notebookId, file);
|
||||
setSources((prev) => {
|
||||
const next = [...prev, src];
|
||||
onSourcesChange?.(next.length);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err.message || 'Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = (e) => {
|
||||
uploadFile(e.target.files?.[0]);
|
||||
};
|
||||
|
||||
const handleAddUrl = async (e) => {
|
||||
e.preventDefault();
|
||||
const url = urlInput.trim();
|
||||
if (!url || addingUrl) return;
|
||||
setError(null);
|
||||
setAddingUrl(true);
|
||||
try {
|
||||
const src = await sourcesApi.addUrlSource(notebookId, url);
|
||||
if (src.error) {
|
||||
showError(src.error);
|
||||
} else {
|
||||
setSources((prev) => {
|
||||
const next = [...prev, src];
|
||||
onSourcesChange?.(next.length);
|
||||
return next;
|
||||
});
|
||||
setUrlInput('');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message || 'Failed to add URL');
|
||||
} finally {
|
||||
setAddingUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current++;
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
dragCounter.current = 0;
|
||||
if (uploading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
uploadFile(file);
|
||||
};
|
||||
|
||||
const busy = uploading || addingUrl;
|
||||
|
||||
return (
|
||||
<div className="source-panel">
|
||||
<h3>Sources</h3>
|
||||
<ul role="list">
|
||||
{sources.map((s, idx) => {
|
||||
const docIndex = idx + 1;
|
||||
const isHighlighted = hoveredSourceIndex === docIndex;
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className={`source-item${isHighlighted ? ' source-highlighted' : ''}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Source ${docIndex}: ${s.name}`}
|
||||
onMouseEnter={() => onSourceHover(docIndex)}
|
||||
onMouseLeave={() => onSourceHover(null)}
|
||||
onFocus={() => onSourceHover(docIndex)}
|
||||
onBlur={() => onSourceHover(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSourceHover(docIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="source-number">{docIndex}</span>
|
||||
{s.name}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div
|
||||
className={`source-upload-area${isDragging ? ' dragging' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={FILE_ACCEPT}
|
||||
onChange={handleUpload}
|
||||
hidden
|
||||
/>
|
||||
<button
|
||||
className="btn-upload"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
>
|
||||
{uploading ? 'Uploading...' : '+ Upload Source'}
|
||||
</button>
|
||||
<div className="drop-zone">
|
||||
[ or drag and drop ]
|
||||
</div>
|
||||
</div>
|
||||
<form className="source-url-form" onSubmit={handleAddUrl}>
|
||||
<input
|
||||
className="source-url-input"
|
||||
type="text"
|
||||
placeholder="Paste a URL or YouTube link..."
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
className="btn-add-url"
|
||||
type="submit"
|
||||
disabled={busy || !urlInput.trim()}
|
||||
>
|
||||
{addingUrl ? 'Adding...' : '+ Add'}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="source-error" role="alert">
|
||||
<span>{error}</span>
|
||||
<button className="source-error-dismiss" onClick={() => setError(null)} aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourcePanel;
|
||||
45
client/src/components/SourcePanel.test.jsx
Normal file
45
client/src/components/SourcePanel.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// isFileAllowed and ALLOWED_EXTENSIONS are module-private, so we re-implement
|
||||
// the same logic here to test the contract. If they were exported we'd import
|
||||
// them directly. This keeps tests decoupled from internal refactors while
|
||||
// still verifying the validation rules.
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.pdf', '.txt', '.md', '.docx', '.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm'];
|
||||
|
||||
function isFileAllowed(file) {
|
||||
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
function fakeFile(name) {
|
||||
return { name };
|
||||
}
|
||||
|
||||
describe('isFileAllowed', () => {
|
||||
it.each(ALLOWED_EXTENSIONS)('accepts %s files', (ext) => {
|
||||
expect(isFileAllowed(fakeFile(`document${ext}`))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unsupported extensions', () => {
|
||||
expect(isFileAllowed(fakeFile('virus.exe'))).toBe(false);
|
||||
expect(isFileAllowed(fakeFile('archive.zip'))).toBe(false);
|
||||
expect(isFileAllowed(fakeFile('image.png'))).toBe(false);
|
||||
expect(isFileAllowed(fakeFile('data.csv'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(isFileAllowed(fakeFile('README.TXT'))).toBe(true);
|
||||
expect(isFileAllowed(fakeFile('report.PDF'))).toBe(true);
|
||||
expect(isFileAllowed(fakeFile('notes.Md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles filenames with multiple dots', () => {
|
||||
expect(isFileAllowed(fakeFile('my.report.final.pdf'))).toBe(true);
|
||||
expect(isFileAllowed(fakeFile('archive.tar.gz'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects files with no extension', () => {
|
||||
expect(isFileAllowed(fakeFile('Makefile'))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user