first commit of v.02 application

This commit is contained in:
KS Jannette
2026-05-07 23:20:30 -04:00
commit a6b0a95dfc
98 changed files with 21028 additions and 0 deletions

26
client/eslint.config.js Normal file
View File

@@ -0,0 +1,26 @@
import js from '@eslint/js';
import globals from 'globals';
import react from 'eslint-plugin-react';
export default [
js.configs.recommended,
{
files: ['src/**/*.{js,jsx}'],
plugins: { react },
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: { jsx: true },
},
globals: {
...globals.browser,
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'react/jsx-uses-vars': 'error',
'react/jsx-uses-react': 'error',
},
},
];

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='50' fill='black'/></svg>">
<title>NotebookLM Clone</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

6080
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
client/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "notebook-clone-web",
"version": "0.1.0",
"private": true,
"engines": { "node": ">=18" },
"type": "module",
"scripts": {
"dev": "vite --port 5173",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint src/",
"fmt": "prettier --write .",
"fmt:check": "prettier --check ."
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^9.22.0",
"eslint-plugin-react": "^7.37.5",
"jsdom": "^28.1.0",
"prettier": "^3.5.3",
"vite": "^6.2.0",
"vitest": "^3.0.0"
}
}

111
client/src/App.jsx Normal file
View File

@@ -0,0 +1,111 @@
import { useState } from 'react';
import NotebookList from './components/NotebookList.jsx';
import SourcePanel from './components/SourcePanel.jsx';
import DocumentButtons from './components/DocumentButtons.jsx';
import ChatPanel from './components/ChatPanel.jsx';
import DocumentModal from './components/DocumentModal.jsx';
import { useNotebook } from './hooks/useNotebook.js';
import { generateDocument } from './api/documents.js';
function App() {
const {
notebooks,
activeNotebook,
selectNotebook,
createNotebook,
deleteNotebook,
} = useNotebook();
const [hoverState, setHoverState] = useState(null);
const [sourceCount, setSourceCount] = useState(0);
const [generatingType, setGeneratingType] = useState(null);
const [docModal, setDocModal] = useState({ open: false, type: null, document: null, loading: false });
const [chatReady, setChatReady] = useState(false);
const handleSelectNotebook = (id) => {
setChatReady(false);
selectNotebook(id);
};
const handleSourceHover = (val) => {
if (val === null) {
setHoverState(null);
} else if (typeof val === 'number') {
setHoverState({ instanceId: `sidebar-${val}`, docIndex: val });
} else {
setHoverState(val);
}
};
const handleDocumentRequest = async (type) => {
setGeneratingType(type);
setDocModal({ open: true, type, document: null, loading: true });
try {
const res = await generateDocument(activeNotebook.id, type);
setDocModal({ open: true, type, document: res.document, loading: false });
} catch (err) {
console.error('document generation failed', err);
setDocModal({ open: false, type: null, document: null, loading: false });
} finally {
setGeneratingType(null);
}
};
const hoveredDocIndex = hoverState?.docIndex ?? null;
const hoveredInstanceId = hoverState?.instanceId ?? null;
return (
<div className="app">
<aside className="sidebar">
<NotebookList
notebooks={notebooks}
activeId={activeNotebook?.id}
onSelect={handleSelectNotebook}
onCreate={createNotebook}
onDelete={deleteNotebook}
/>
{activeNotebook && (
<>
<SourcePanel
notebookId={activeNotebook.id}
hoveredSourceIndex={hoveredDocIndex}
onSourceHover={handleSourceHover}
onSourcesChange={setSourceCount}
>
{chatReady && (
<DocumentButtons
sourceCount={sourceCount}
onRequest={handleDocumentRequest}
generatingType={generatingType}
/>
)}
</SourcePanel>
</>
)}
</aside>
<main className="main">
{activeNotebook ? (
<ChatPanel
notebookId={activeNotebook.id}
hoveredSource={hoveredInstanceId}
hoveredDocIndex={hoveredDocIndex}
onSourceHover={handleSourceHover}
onFirstResponse={() => setChatReady(true)}
/>
) : (
<p>Select or create a notebook to get started.</p>
)}
</main>
<DocumentModal
open={docModal.open}
onClose={() => setDocModal({ open: false, type: null, document: null, loading: false })}
type={docModal.type}
document={docModal.document}
loading={docModal.loading}
/>
</div>
);
}
export default App;

9
client/src/api/client.js Normal file
View File

@@ -0,0 +1,9 @@
'use strict';
export async function handleResponse(res) {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { handleResponse } from './client.js';
function fakeResponse(status, body, ok = status >= 200 && status < 300) {
return {
ok,
status,
json: () => Promise.resolve(body),
};
}
describe('handleResponse', () => {
it('returns parsed JSON on success', async () => {
const data = { id: 1, name: 'test' };
const result = await handleResponse(fakeResponse(200, data));
expect(result).toEqual(data);
});
it('throws with body.error when present', async () => {
const res = fakeResponse(400, { error: 'Bad input' }, false);
await expect(handleResponse(res)).rejects.toThrow('Bad input');
});
it('throws with status code when body has no error field', async () => {
const res = fakeResponse(500, {}, false);
await expect(handleResponse(res)).rejects.toThrow('Request failed: 500');
});
it('throws with status code when body JSON parsing fails', async () => {
const res = {
ok: false,
status: 502,
json: () => Promise.reject(new Error('parse error')),
};
await expect(handleResponse(res)).rejects.toThrow('Request failed: 502');
});
it('returns empty object body on success', async () => {
const result = await handleResponse(fakeResponse(200, {}));
expect(result).toEqual({});
});
it('returns array body on success', async () => {
const data = [1, 2, 3];
const result = await handleResponse(fakeResponse(200, data));
expect(result).toEqual([1, 2, 3]);
});
});

View File

@@ -0,0 +1,14 @@
'use strict';
import { handleResponse } from './client.js';
const BASE = '/api/documents';
export async function generateDocument(notebookId, type) {
const res = await fetch(`${BASE}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId, type }),
});
return handleResponse(res);
}

View File

@@ -0,0 +1,24 @@
'use strict';
import { handleResponse } from './client.js';
const BASE = '/api/notebooks';
export async function listNotebooks() {
const res = await fetch(BASE);
return handleResponse(res);
}
export async function createNotebook(name) {
const res = await fetch(BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
return handleResponse(res);
}
export async function deleteNotebook(id) {
const res = await fetch(`${BASE}/${id}`, { method: 'DELETE' });
return handleResponse(res);
}

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { listNotebooks, createNotebook, deleteNotebook } from './notebooks.js';
const okResponse = (body) => ({
ok: true,
status: 200,
json: () => Promise.resolve(body),
});
describe('notebooks API', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('listNotebooks', () => {
it('fetches GET /api/notebooks', async () => {
const data = [{ id: '1', name: 'NB' }];
fetch.mockResolvedValue(okResponse(data));
const result = await listNotebooks();
expect(fetch).toHaveBeenCalledWith('/api/notebooks');
expect(result).toEqual(data);
});
});
describe('createNotebook', () => {
it('sends POST with name in JSON body', async () => {
const nb = { id: '2', name: 'New' };
fetch.mockResolvedValue(okResponse(nb));
const result = await createNotebook('New');
expect(fetch).toHaveBeenCalledWith('/api/notebooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New' }),
});
expect(result).toEqual(nb);
});
});
describe('deleteNotebook', () => {
it('sends DELETE to /api/notebooks/:id', async () => {
fetch.mockResolvedValue(okResponse({}));
await deleteNotebook('abc-123');
expect(fetch).toHaveBeenCalledWith('/api/notebooks/abc-123', {
method: 'DELETE',
});
});
});
it('throws on non-ok response', async () => {
fetch.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Server down' }),
});
await expect(listNotebooks()).rejects.toThrow('Server down');
});
});

36
client/src/api/query.js Normal file
View File

@@ -0,0 +1,36 @@
'use strict';
import { handleResponse } from './client.js';
const BASE = '/api/query';
const CITATION_DETAIL_BASE = '/api/citation-detail';
export async function sendQuery(notebookId, question, onCitationDetails) {
const res = await fetch(BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId, question }),
});
const data = await handleResponse(res);
if (data.citations?.length && onCitationDetails) {
Promise.all(
data.citations.map((c) =>
fetch(CITATION_DETAIL_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chunkTexts: c.chunkTexts,
sourceName: c.name,
answer: data.answer,
citationIndex: c.sourceIndex,
}),
})
.then((r) => handleResponse(r))
.then((detail) => ({ sourceIndex: c.sourceIndex, ...detail }))
)
).then(onCitationDetails);
}
return data;
}

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { sendQuery } from './query.js';
const okResponse = (body) => ({
ok: true,
status: 200,
json: () => Promise.resolve(body),
});
describe('sendQuery', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it('sends POST to /api/query with notebookId and question', async () => {
const data = { answer: 'The answer', citations: [] };
fetch.mockResolvedValue(okResponse(data));
const result = await sendQuery('nb-1', 'What is AI?');
expect(fetch).toHaveBeenCalledWith('/api/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId: 'nb-1', question: 'What is AI?' }),
});
expect(result).toEqual(data);
});
it('returns data without calling onCitationDetails when no citations', async () => {
const data = { answer: 'Answer', citations: [] };
fetch.mockResolvedValue(okResponse(data));
const onCitationDetails = vi.fn();
await sendQuery('nb-1', 'Q?', onCitationDetails);
expect(onCitationDetails).not.toHaveBeenCalled();
});
it('returns data without calling onCitationDetails when citations is null', async () => {
const data = { answer: 'Answer', citations: null };
fetch.mockResolvedValue(okResponse(data));
const onCitationDetails = vi.fn();
await sendQuery('nb-1', 'Q?', onCitationDetails);
expect(onCitationDetails).not.toHaveBeenCalled();
});
it('fetches citation details and calls onCitationDetails', async () => {
const queryData = {
answer: 'The answer [1]',
citations: [
{ sourceIndex: 1, name: 'doc.pdf', chunkTexts: ['chunk A'] },
{ sourceIndex: 2, name: 'doc2.pdf', chunkTexts: ['chunk B'] },
],
};
const detailA = { citedSentence: 'A', topicSummary: 'T1' };
const detailB = { citedSentence: 'B', topicSummary: 'T2' };
fetch
.mockResolvedValueOnce(okResponse(queryData))
.mockResolvedValueOnce(okResponse(detailA))
.mockResolvedValueOnce(okResponse(detailB));
const onCitationDetails = vi.fn();
const result = await sendQuery('nb-1', 'Q?', onCitationDetails);
expect(result).toEqual(queryData);
// Citation detail fetches happen async — wait for the promise
await vi.waitFor(() => {
expect(onCitationDetails).toHaveBeenCalledOnce();
});
const details = onCitationDetails.mock.calls[0][0];
expect(details).toHaveLength(2);
expect(details[0]).toEqual({ sourceIndex: 1, ...detailA });
expect(details[1]).toEqual({ sourceIndex: 2, ...detailB });
});
it('sends correct body for each citation detail request', async () => {
const queryData = {
answer: 'Answer text',
citations: [
{ sourceIndex: 1, name: 'src.pdf', chunkTexts: ['c1', 'c2'] },
],
};
const detail = { citedSentence: 'S' };
fetch
.mockResolvedValueOnce(okResponse(queryData))
.mockResolvedValueOnce(okResponse(detail));
const onCitationDetails = vi.fn();
await sendQuery('nb-1', 'Q?', onCitationDetails);
await vi.waitFor(() => {
expect(fetch).toHaveBeenCalledTimes(2);
});
expect(fetch).toHaveBeenCalledWith('/api/citation-detail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chunkTexts: ['c1', 'c2'],
sourceName: 'src.pdf',
answer: 'Answer text',
citationIndex: 1,
}),
});
});
it('does not fetch citation details when no callback provided', async () => {
const queryData = {
answer: 'Answer',
citations: [{ sourceIndex: 1, name: 'x', chunkTexts: ['c'] }],
};
fetch.mockResolvedValue(okResponse(queryData));
await sendQuery('nb-1', 'Q?');
// Only 1 fetch call (the query itself), no citation detail fetches
expect(fetch).toHaveBeenCalledTimes(1);
});
it('throws on non-ok response from query endpoint', async () => {
fetch.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Internal error' }),
});
await expect(sendQuery('nb-1', 'Q?')).rejects.toThrow('Internal error');
});
});

30
client/src/api/sources.js Normal file
View File

@@ -0,0 +1,30 @@
'use strict';
import { handleResponse } from './client.js';
const BASE = '/api/sources';
export async function listSources(notebookId) {
const res = await fetch(`${BASE}?notebookId=${notebookId}`);
return handleResponse(res);
}
export async function uploadSource(notebookId, file) {
const formData = new FormData();
formData.append('file', file);
formData.append('notebookId', notebookId);
const res = await fetch(BASE, {
method: 'POST',
body: formData,
});
return handleResponse(res);
}
export async function addUrlSource(notebookId, url) {
const res = await fetch(`${BASE}/url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId, url }),
});
return handleResponse(res);
}

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { listSources, uploadSource, addUrlSource } from './sources.js';
const okResponse = (body) => ({
ok: true,
status: 200,
json: () => Promise.resolve(body),
});
describe('sources API', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('listSources', () => {
it('fetches GET /api/sources with notebookId query param', async () => {
const data = [{ id: 's1', name: 'file.pdf' }];
fetch.mockResolvedValue(okResponse(data));
const result = await listSources('nb-42');
expect(fetch).toHaveBeenCalledWith('/api/sources?notebookId=nb-42');
expect(result).toEqual(data);
});
});
describe('uploadSource', () => {
it('sends POST with FormData containing file and notebookId', async () => {
const source = { id: 's2', name: 'doc.pdf' };
fetch.mockResolvedValue(okResponse(source));
const fakeFile = new File(['content'], 'doc.pdf', { type: 'application/pdf' });
const result = await uploadSource('nb-1', fakeFile);
expect(fetch).toHaveBeenCalledWith('/api/sources', {
method: 'POST',
body: expect.any(FormData),
});
const formData = fetch.mock.calls[0][1].body;
expect(formData.get('notebookId')).toBe('nb-1');
expect(formData.get('file')).toBeInstanceOf(File);
expect(result).toEqual(source);
});
});
describe('addUrlSource', () => {
it('sends POST to /api/sources/url with JSON body', async () => {
const source = { id: 's3', name: 'example.com' };
fetch.mockResolvedValue(okResponse(source));
const result = await addUrlSource('nb-5', 'https://example.com');
expect(fetch).toHaveBeenCalledWith('/api/sources/url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId: 'nb-5', url: 'https://example.com' }),
});
expect(result).toEqual(source);
});
});
it('throws on non-ok response', async () => {
fetch.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ error: 'Bad request' }),
});
await expect(listSources('nb-1')).rejects.toThrow('Bad request');
});
});

View 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;

View 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?');
});
});
});

View 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;

View 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;

View 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();
});
});

View 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;

View 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 });
});
});

View 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;

View 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');
});
});

View 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;

View 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();
});
});

View 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;

View 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();
});
});

View 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}% &ndash; {LEVEL_LABELS[level]}
</span>
);
}
export default GroundednessScore;

View 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();
});
});

View 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;

View 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');
});
});

View 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"
>
&times;
</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;

View 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;

View 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);
});
});

View File

@@ -0,0 +1,50 @@
import { useState, useEffect, useCallback } from 'react';
import * as notebooksApi from '../api/notebooks.js';
export function useNotebook() {
const [notebooks, setNotebooks] = useState([]);
const [activeNotebook, setActiveNotebook] = useState(null);
useEffect(() => {
notebooksApi.listNotebooks().then(setNotebooks).catch(console.error);
}, []);
const selectNotebook = useCallback(
(id) => {
const nb = notebooks.find((n) => n.id === id) || null;
setActiveNotebook(nb);
},
[notebooks],
);
const createNotebook = useCallback(async (name) => {
if (!name) return;
const nb = await notebooksApi.createNotebook(name);
setNotebooks((prev) => [...prev, nb]);
setActiveNotebook(nb);
}, []);
const deleteNotebook = useCallback(
async (id) => {
try {
await notebooksApi.deleteNotebook(id);
} catch (err) {
console.error('Failed to delete notebook', err);
return;
}
setNotebooks((prev) => prev.filter((n) => n.id !== id));
if (activeNotebook?.id === id) {
setActiveNotebook(null);
}
},
[activeNotebook],
);
return {
notebooks,
activeNotebook,
selectNotebook,
createNotebook,
deleteNotebook,
};
}

View File

@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
vi.mock('../api/notebooks.js', () => ({
listNotebooks: vi.fn(),
createNotebook: vi.fn(),
deleteNotebook: vi.fn(),
}));
import { useNotebook } from './useNotebook.js';
import * as notebooksApi from '../api/notebooks.js';
const notebooks = [
{ id: 'nb-1', name: 'Research' },
{ id: 'nb-2', name: 'Personal' },
];
beforeEach(() => {
vi.resetAllMocks();
notebooksApi.listNotebooks.mockResolvedValue(notebooks);
notebooksApi.createNotebook.mockImplementation(async (name) => ({
id: `nb-${Date.now()}`,
name,
}));
notebooksApi.deleteNotebook.mockResolvedValue({});
});
describe('useNotebook', () => {
it('loads notebooks on mount', async () => {
const { result } = renderHook(() => useNotebook());
await waitFor(() => {
expect(result.current.notebooks).toEqual(notebooks);
});
expect(notebooksApi.listNotebooks).toHaveBeenCalledOnce();
});
it('starts with no active notebook', () => {
const { result } = renderHook(() => useNotebook());
expect(result.current.activeNotebook).toBeNull();
});
it('selectNotebook sets the active notebook', async () => {
const { result } = renderHook(() => useNotebook());
await waitFor(() => {
expect(result.current.notebooks).toHaveLength(2);
});
act(() => {
result.current.selectNotebook('nb-2');
});
expect(result.current.activeNotebook).toEqual({ id: 'nb-2', name: 'Personal' });
});
it('selectNotebook sets null for unknown id', async () => {
const { result } = renderHook(() => useNotebook());
await waitFor(() => {
expect(result.current.notebooks).toHaveLength(2);
});
act(() => {
result.current.selectNotebook('nb-2');
});
expect(result.current.activeNotebook).not.toBeNull();
act(() => {
result.current.selectNotebook('nonexistent');
});
expect(result.current.activeNotebook).toBeNull();
});
it('createNotebook calls API and adds to list', async () => {
const newNb = { id: 'nb-new', name: 'New One' };
notebooksApi.createNotebook.mockResolvedValue(newNb);
const { result } = renderHook(() => useNotebook());
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
await act(async () => {
await result.current.createNotebook('New One');
});
expect(notebooksApi.createNotebook).toHaveBeenCalledWith('New One');
expect(result.current.notebooks).toHaveLength(3);
expect(result.current.notebooks[2]).toEqual(newNb);
expect(result.current.activeNotebook).toEqual(newNb);
});
it('createNotebook does nothing for empty name', async () => {
const { result } = renderHook(() => useNotebook());
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
await act(async () => {
await result.current.createNotebook('');
});
expect(notebooksApi.createNotebook).not.toHaveBeenCalled();
expect(result.current.notebooks).toHaveLength(2);
});
it('deleteNotebook removes from list', async () => {
const { result } = renderHook(() => useNotebook());
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
await act(async () => {
await result.current.deleteNotebook('nb-1');
});
expect(notebooksApi.deleteNotebook).toHaveBeenCalledWith('nb-1');
expect(result.current.notebooks).toHaveLength(1);
expect(result.current.notebooks[0].id).toBe('nb-2');
});
it('deleteNotebook clears activeNotebook if it was the deleted one', async () => {
const { result } = renderHook(() => useNotebook());
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
act(() => {
result.current.selectNotebook('nb-1');
});
expect(result.current.activeNotebook?.id).toBe('nb-1');
await act(async () => {
await result.current.deleteNotebook('nb-1');
});
expect(result.current.activeNotebook).toBeNull();
});
it('deleteNotebook preserves activeNotebook if different one deleted', async () => {
const { result } = renderHook(() => useNotebook());
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
act(() => {
result.current.selectNotebook('nb-2');
});
await act(async () => {
await result.current.deleteNotebook('nb-1');
});
expect(result.current.activeNotebook).toEqual({ id: 'nb-2', name: 'Personal' });
});
it('deleteNotebook does not remove from list on API error', async () => {
notebooksApi.deleteNotebook.mockRejectedValue(new Error('Server error'));
vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useNotebook());
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
await act(async () => {
await result.current.deleteNotebook('nb-1');
});
expect(result.current.notebooks).toHaveLength(2);
console.error.mockRestore();
});
it('handles listNotebooks API failure gracefully', async () => {
notebooksApi.listNotebooks.mockRejectedValue(new Error('Network error'));
vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useNotebook());
await waitFor(() => {
expect(notebooksApi.listNotebooks).toHaveBeenCalled();
});
expect(result.current.notebooks).toEqual([]);
console.error.mockRestore();
});
});

873
client/src/index.css Normal file
View File

@@ -0,0 +1,873 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap');
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #f8f9fa;
--surface: #ffffff;
--sidebar-bg: #000000;
--sidebar-text: #e240ff;
--sidebar-hover: #7b7b7b;
--sidebar-active: #3c3d3e;
--primary: #000000;
--primary-hover: #d43aa6;
--border: #dee2e6;
--text: #212529;
--text-muted: #6c757d;
--danger: #dc3545;
--green: #3eff05;
--gold: #e6a817;
--red: #dc3545;
--radius: 8px;
--radius-sm: 4px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
}
html,
body,
#root {
height: 100%;
}
body {
font-family: 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
}
/* ── Layout ────────────────────────────────────────── */
.app {
display: flex;
height: 100%;
}
.sidebar {
width: 340px;
min-width: 320px;
background: var(--sidebar-bg);
color: var(--sidebar-text);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main>p {
margin: auto;
color: var(--text-muted);
font-size: 16px;
}
/* ── Notebook List ─────────────────────────────────── */
.notebook-list {
padding: 20px 16px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.notebook-list h2 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #e240ff;
margin-bottom: 12px;
}
.notebook-list ul {
list-style: none;
margin-bottom: 8px;
}
.notebook-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.notebook-list li:hover {
background: var(--sidebar-hover);
}
.notebook-list li.active {
background: var(--sidebar-active);
color: #fff;
font-weight: 500;
}
.notebook-list li .nb-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notebook-list li .nb-delete {
opacity: 0;
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
line-height: 1;
transition: opacity 0.15s, color 0.15s;
}
.notebook-list li:hover .nb-delete {
opacity: 1;
}
.notebook-list li .nb-delete:hover {
color: var(--danger);
}
.btn-new-notebook {
width: 100%;
padding: 8px;
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: var(--radius-sm);
background: none;
color: var(--sidebar-text);
cursor: pointer;
font-size: 13px;
transition: border-color 0.15s, color 0.15s;
}
.btn-new-notebook:hover {
border-color: rgba(255, 255, 255, 0.5);
color: #fff;
}
/* ── Source Panel ───────────────────────────────────── */
.source-panel {
padding: 16px;
flex: 1;
overflow-y: auto;
}
.source-panel h3 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #e240ff;
margin-bottom: 12px;
}
.source-panel ul {
list-style: none;
}
.source-panel li {
padding: 6px 10px;
font-size: 13px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.75);
}
.source-panel li .source-number {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
width: 20px;
height: 20px;
min-width: 20px;
border-radius: 50%;
background: var(--primary);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.25);
transition: background 0.15s, color 0.15s;
}
.source-panel li {
cursor: pointer;
transition: color 0.15s;
}
.source-panel li:hover .source-number,
.source-panel li.source-highlighted .source-number {
background: transparent;
border-color: #04d9ff;
color: #04d9ff;
}
.source-panel li:hover,
.source-panel li.source-highlighted {
color: #04d9ff;
}
.source-upload-area {
margin-top: 8px;
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: var(--radius-sm);
transition: border-color 0.15s, background 0.15s;
}
.source-upload-area.dragging {
border-color: var(--sidebar-text);
background: rgba(255, 255, 255, 0.05);
}
.btn-upload {
width: 100%;
padding: 8px;
border: none;
background: none;
color: var(--sidebar-text);
cursor: pointer;
font-size: 13px;
transition: color 0.15s;
}
.btn-upload:hover {
color: #fff;
}
.btn-upload:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.drop-zone {
padding: 0px 0px 12px 8px;
text-align: center;
color: var(--sidebar-text);
font-size: 13px;
cursor: default;
}
.source-url-form {
display: flex;
gap: 6px;
padding: 8px 0 0;
margin-top: 4px;
}
.source-url-input {
flex: 1;
padding: 7px 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.06);
color: #fff;
font-size: 12px;
outline: none;
transition: border-color 0.15s;
}
.source-url-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.source-url-input:focus {
border-color: rgba(255, 255, 255, 0.4);
}
.btn-add-url {
padding: 7px 12px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
background: none;
color: var(--sidebar-text);
cursor: pointer;
font-size: 12px;
white-space: nowrap;
transition: border-color 0.15s, color 0.15s;
}
.btn-add-url:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.5);
color: #fff;
}
.btn-add-url:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.source-error {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding: 8px 10px;
border-radius: var(--radius-sm);
background: rgba(255, 60, 60, 0.12);
border: 1px solid rgba(255, 60, 60, 0.3);
color: #ff6b6b;
font-size: 12px;
line-height: 1.4;
animation: source-error-in 0.2s ease-out;
}
.source-error span {
flex: 1;
}
.source-error-dismiss {
background: none;
border: none;
color: #ff6b6b;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
opacity: 0.7;
transition: opacity 0.15s;
}
.source-error-dismiss:hover {
opacity: 1;
}
@keyframes source-error-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Document Buttons ─────────────────────────────── */
.document-buttons {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
}
.btn-document {
padding: 10px 16px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: var(--radius-sm);
background: transparent;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
}
.btn-document:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.5);
}
.btn-document:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-document.generating {
border-color: #e240ff;
color: #e240ff;
opacity: 1;
}
/* ── Chat Panel ────────────────────────────────────── */
.chat-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
.chat-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 15px;
}
.chat-input {
display: flex;
gap: 8px;
padding: 16px 32px 24px;
border-top: 1px solid var(--border);
background: var(--surface);
}
.chat-input input {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.chat-input input:focus {
border-color: var(--primary);
}
.chat-input button {
padding: 10px 20px;
background: var(--primary);
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.chat-input button:hover {
background: var(--primary-hover);
}
.chat-input button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Chat Message ──────────────────────────────────── */
.chat-message {
margin-bottom: 20px;
max-width: 720px;
}
.chat-message.user {
background: var(--primary);
color: #fff;
padding: 10px 16px;
border-radius: var(--radius) var(--radius) var(--radius-sm) var(--radius);
margin-left: auto;
max-width: 480px;
width: fit-content;
}
.chat-message.assistant {
background: var(--surface);
border: 1px solid var(--border);
padding: 16px 20px;
border-radius: var(--radius-sm) var(--radius) var(--radius) var(--radius);
box-shadow: var(--shadow);
}
.chat-message.assistant .answer-text {
white-space: pre-wrap;
line-height: 1.65;
text-align: left;
}
.citation-marker {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary);
color: #fff;
vertical-align: super;
margin: 0 1px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.citation-marker:hover,
.citation-marker.citation-highlighted {
background: transparent;
outline: 2px solid #04d9ff;
color: #04d9ff;
}
.message-meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
/* ── Groundedness Score ────────────────────────────── */
.groundedness-score {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 100px;
white-space: nowrap;
}
.groundedness-score.green {
background: rgba(40, 167, 69, 0.1);
color: var(--green);
}
.groundedness-score.gold {
background: rgba(230, 168, 23, 0.1);
color: var(--gold);
}
.groundedness-score.red {
background: rgba(220, 53, 69, 0.1);
color: var(--red);
}
/* ── Follow-up Questions ───────────────────────────── */
.follow-up-questions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.follow-up-header {
margin-bottom: 8px;
font-size: 1rem;
}
.follow-up-chip {
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 100px;
background: var(--bg);
color: var(--text);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.follow-up-chip:hover {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
/* ── Loading indicator ─────────────────────────────── */
.loading-dots::after {
content: '';
animation: dots 1.2s steps(4, end) infinite;
}
@keyframes dots {
0% {
content: '';
}
25% {
content: '.';
}
50% {
content: '..';
}
75% {
content: '...';
}
}
.chat-message.loading {
color: var(--text-muted);
font-style: italic;
}
/* ── Modal ────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background: #1e1e1e;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius);
padding: 24px;
width: 360px;
max-width: 90vw;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: #fff;
margin-bottom: 16px;
}
.modal-input {
width: 100%;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-sm);
background: #2a2a2a;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.modal-input:focus {
border-color: rgba(255, 255, 255, 0.5);
}
.modal-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.modal-btn {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: none;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.modal-btn-cancel {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
}
.modal-btn-cancel:hover {
background: rgba(255, 255, 255, 0.15);
}
.modal-btn-confirm {
background: #fff;
color: #000;
}
.modal-btn-confirm:hover {
opacity: 0.9;
}
.modal-btn-confirm:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ── Citation Detail Modal ────────────────────────── */
.citation-detail-loading {
color: rgba(255, 255, 255, 0.6);
font-style: italic;
padding: 12px 0;
}
.citation-detail-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.citation-detail-section h4.citation-detail-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #e240ff;
margin-bottom: 6px;
}
.citation-detail-section p {
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
line-height: 1.6;
}
.citation-detail-quote {
border-left: 3px solid #e240ff;
padding: 8px 14px;
margin: 0;
background: rgba(255, 255, 255, 0.04);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
line-height: 1.6;
font-style: italic;
}
.citation-detail-links {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.citation-detail-links a {
color: #04d9ff;
text-decoration: none;
font-size: 13px;
transition: opacity 0.15s;
}
.citation-detail-links a:hover {
opacity: 0.75;
text-decoration: underline;
}
/* ── Document Modal Content ───────────────────────── */
.doc-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.doc-title {
font-size: 20px;
font-weight: 700;
color: #fff;
margin-bottom: 4px;
}
.doc-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.doc-section-heading {
font-size: 15px;
font-weight: 600;
color: #e240ff;
}
.doc-subsection-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 4px;
}
.doc-bullets {
padding-left: 20px;
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
line-height: 1.6;
}
.doc-bullets li {
margin-bottom: 4px;
}
.doc-key-terms dl {
display: flex;
flex-direction: column;
gap: 6px;
}
.doc-term-pair {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.5;
}
.doc-term-pair dt {
font-weight: 600;
color: #04d9ff;
min-width: 120px;
flex-shrink: 0;
}
.doc-term-pair dd {
color: rgba(255, 255, 255, 0.8);
}
.doc-review ol {
padding-left: 20px;
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
line-height: 1.6;
}
.doc-review ol li {
margin-bottom: 4px;
}
.doc-faq-pair {
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.doc-faq-pair:last-child {
border-bottom: none;
}
.doc-faq-question {
font-size: 14px;
font-weight: 600;
color: #e240ff;
margin-bottom: 4px;
}
.doc-faq-answer {
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
line-height: 1.6;
}
.doc-prose {
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
line-height: 1.7;
white-space: pre-wrap;
}

10
client/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);

7
client/src/test/setup.js Normal file
View File

@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});

19
client/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.js'],
},
});