diff --git a/core/chat/manager.test.ts b/core/chat/manager.test.ts index 9bf1afe..3d01e44 100644 --- a/core/chat/manager.test.ts +++ b/core/chat/manager.test.ts @@ -220,4 +220,19 @@ describe('ChatManager', () => { expect(messages).toHaveLength(1) expect(messages[0]?.role).toBe('user') }) + it('reopens an exact past chat session when chatSessionId is provided [L2-02]', async () => { + const client = new FakeClient([]) + const manager = new ChatManager(client) + + const first = manager.openChat({ db, paperSlug: slug }) + const second = await manager.newChat({ db, paperSlug: slug }) + + // Without chatSessionId, openChat defaults to latest (second) + const latest = manager.openChat({ db, paperSlug: slug }) + expect(latest.session.id).toBe(second.id) + + // With explicit chatSessionId, openChat reopens the first session + const reopened = manager.openChat({ db, paperSlug: slug, chatSessionId: first.session.id }) + expect(reopened.session.id).toBe(first.session.id) + }) }) diff --git a/core/chat/manager.ts b/core/chat/manager.ts index 0494308..76af266 100644 --- a/core/chat/manager.ts +++ b/core/chat/manager.ts @@ -20,7 +20,7 @@ import type { Database } from 'better-sqlite3' import type { AcpBackend, AcpClient, AcpSession, AcpUpdate } from '../acp/client.js' import type { ChatMessage, ChatSession } from './repo.js' -import { addChatMessage, createChatSession, getChatMessages, getLatestChatSession } from './repo.js' +import { addChatMessage, createChatSession, getChatMessages, getChatSession, getLatestChatSession } from './repo.js' const DEFAULT_BACKEND: AcpBackend = 'claude' @@ -91,6 +91,7 @@ export interface OpenChatParams { db: Database paperSlug: string backend?: AcpBackend + chatSessionId?: number } export interface AskOpenResult { @@ -131,7 +132,16 @@ export class ChatManager { */ openChat(params: OpenChatParams): AskOpenResult { const backend = params.backend ?? DEFAULT_BACKEND - const existing = getLatestChatSession(params.db, params.paperSlug) + let existing: ChatSession | undefined + if (params.chatSessionId) { + const candidate = getChatSession(params.db, params.chatSessionId) + if (candidate && candidate.paperSlug === params.paperSlug) { + existing = candidate + } + } + if (!existing) { + existing = getLatestChatSession(params.db, params.paperSlug) + } const session = existing ?? createChatSession(params.db, { paperSlug: params.paperSlug, backend }) const messages = getChatMessages(params.db, session.id) return { session, messages } diff --git a/core/chat/repo.test.ts b/core/chat/repo.test.ts index b4e9ab2..a51a579 100644 --- a/core/chat/repo.test.ts +++ b/core/chat/repo.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { openDb } from '../store/db.js' import { upsertPaper } from '../library/repo.js' -import { addChatMessage, createChatSession, getChatMessages, getLatestChatSession } from './repo.js' +import { addChatMessage, createChatSession, deleteChatSession, getChatMessages, getLatestChatSession, listChatSessions, updateChatSessionTitle } from './repo.js' describe('chat repo', () => { let db: Database @@ -61,4 +61,84 @@ describe('chat repo', () => { expect(getChatMessages(db, a.id)).toHaveLength(1) expect(getChatMessages(db, b.id)).toHaveLength(1) }) + + it('lists chat sessions across papers with paper title and stable fallback title [L2-02]', () => { + upsertPaper(db, { + slug: 'bert-paper', + title: 'BERT: Pre-training of Deep Bidirectional Transformers', + authors: ['Jacob Devlin'], + addedAt: new Date().toISOString(), + }) + + const s1 = createChatSession(db, { paperSlug: 'attention-is-all-you-need', backend: 'claude' }) + addChatMessage(db, { sessionId: s1.id, role: 'user', content: 'Explain multi-head attention mechanism in simple terms' }) + addChatMessage(db, { sessionId: s1.id, role: 'assistant', content: 'Multi-head attention allows the model to jointly attend to information...' }) + + const s2 = createChatSession(db, { paperSlug: 'bert-paper', backend: 'codex', title: 'BERT fine-tuning discussion' }) + addChatMessage(db, { sessionId: s2.id, role: 'user', content: 'How does masked LM work?' }) + + const sessions = listChatSessions(db) + expect(sessions).toHaveLength(2) + + // s2 was created after s1 and has recent message + const bertSession = sessions.find((s) => s.id === s2.id)! + expect(bertSession.paperTitle).toBe('BERT: Pre-training of Deep Bidirectional Transformers') + expect(bertSession.title).toBe('BERT fine-tuning discussion') + expect(bertSession.backend).toBe('codex') + expect(bertSession.messageCount).toBe(1) + expect(bertSession.preview).toBe('How does masked LM work?') + + const transformerSession = sessions.find((s) => s.id === s1.id)! + expect(transformerSession.paperTitle).toBe('Attention Is All You Need') + expect(transformerSession.title).toBe('Explain multi-head attention mechanism in simple terms') + expect(transformerSession.backend).toBe('claude') + expect(transformerSession.messageCount).toBe(2) + }) + + it('filters sessions by search term across title, paper, and message content [L2-02]', () => { + const s1 = createChatSession(db, { paperSlug: 'attention-is-all-you-need', backend: 'claude', title: 'Attention Deep Dive' }) + addChatMessage(db, { sessionId: s1.id, role: 'user', content: 'What about positional encodings?' }) + + const s2 = createChatSession(db, { paperSlug: 'attention-is-all-you-need', backend: 'codex' }) + addChatMessage(db, { sessionId: s2.id, role: 'user', content: 'Optimizer hyperparameters' }) + + const matchedByMessage = listChatSessions(db, { search: 'positional' }) + expect(matchedByMessage).toHaveLength(1) + expect(matchedByMessage[0].id).toBe(s1.id) + + const matchedByTitle = listChatSessions(db, { search: 'Deep Dive' }) + expect(matchedByTitle).toHaveLength(1) + expect(matchedByTitle[0].id).toBe(s1.id) + + const noMatches = listChatSessions(db, { search: 'quantum' }) + expect(noMatches).toHaveLength(0) + }) + + it('gracefully handles orphaned sessions when paper record is missing [L2-02]', () => { + // Disable FK temporarily to simulate missing paper + db.pragma('foreign_keys = OFF') + const orphaned = createChatSession(db, { paperSlug: 'deleted-paper', backend: 'claude' }) + addChatMessage(db, { sessionId: orphaned.id, role: 'user', content: 'Hello' }) + db.pragma('foreign_keys = ON') + + const sessions = listChatSessions(db) + const target = sessions.find((s) => s.id === orphaned.id) + expect(target).toBeDefined() + expect(target?.paperSlug).toBe('deleted-paper') + expect(target?.paperTitle).toBeNull() + }) + + it('updates title and deletes chat session with cascade [L2-02]', () => { + const s = createChatSession(db, { paperSlug: 'attention-is-all-you-need', backend: 'claude' }) + addChatMessage(db, { sessionId: s.id, role: 'user', content: 'First message' }) + + updateChatSessionTitle(db, s.id, 'Custom Renamed Title') + let found = listChatSessions(db).find((item) => item.id === s.id) + expect(found?.title).toBe('Custom Renamed Title') + + deleteChatSession(db, s.id) + found = listChatSessions(db).find((item) => item.id === s.id) + expect(found).toBeUndefined() + expect(getChatMessages(db, s.id)).toHaveLength(0) + }) }) diff --git a/core/chat/repo.ts b/core/chat/repo.ts index e5ddead..67a28f0 100644 --- a/core/chat/repo.ts +++ b/core/chat/repo.ts @@ -143,3 +143,134 @@ export function addChatMessage( createdAt, } } + +export interface ChatSessionSummary { + id: number + paperSlug: string + paperTitle: string | null + backend: string + title: string + preview: string | null + messageCount: number + createdAt: string + lastActiveAt: string +} + +export interface ListChatSessionsOptions { + search?: string + limit?: number +} + +/** + * List all chat sessions across papers, ordered by most recent activity. + * Stable title fallback is derived from first user message if title is not set. + * Returns paper title from `papers` table (or null if paper was deleted). + */ +export function listChatSessions( + db: Database, + options: ListChatSessionsOptions = {}, +): ChatSessionSummary[] { + const { search, limit = 100 } = options + + let sql = ` + SELECT + cs.id, + cs.paper_slug AS paperSlug, + p.title AS paperTitle, + cs.backend, + cs.title, + cs.created_at AS createdAt, + COUNT(cm.id) AS messageCount, + MAX(cm.created_at) AS lastMessageAt, + ( + SELECT content FROM chat_messages + WHERE session_id = cs.id AND role = 'user' + ORDER BY id ASC LIMIT 1 + ) AS firstUserMessage, + ( + SELECT content FROM chat_messages + WHERE session_id = cs.id + ORDER BY id DESC LIMIT 1 + ) AS lastMessage + FROM chat_sessions cs + LEFT JOIN papers p ON cs.paper_slug = p.slug + LEFT JOIN chat_messages cm ON cs.id = cm.session_id + ` + + const conditions: string[] = [] + const params: unknown[] = [] + + if (search && search.trim().length > 0) { + const pattern = `%${search.trim().toLowerCase()}%` + conditions.push(`( + LOWER(COALESCE(cs.title, '')) LIKE ? OR + LOWER(COALESCE(p.title, '')) LIKE ? OR + LOWER(cs.paper_slug) LIKE ? OR + cs.id IN (SELECT session_id FROM chat_messages WHERE LOWER(content) LIKE ?) + )`) + params.push(pattern, pattern, pattern, pattern) + } + + if (conditions.length > 0) { + sql += ` WHERE ${conditions.join(' AND ')}` + } + + sql += ` + GROUP BY cs.id + ORDER BY COALESCE(MAX(cm.created_at), cs.created_at) DESC + LIMIT ? + ` + params.push(limit) + + const rows = db.prepare(sql).all(...params) as Array<{ + id: number + paperSlug: string + paperTitle: string | null + backend: string + title: string | null + createdAt: string + messageCount: number + lastMessageAt: string | null + firstUserMessage: string | null + lastMessage: string | null + }> + + return rows.map((row) => { + let displayTitle = row.title + if (!displayTitle || displayTitle.trim().length === 0) { + if (row.firstUserMessage && row.firstUserMessage.trim().length > 0) { + const cleaned = row.firstUserMessage.trim().replace(/\s+/g, ' ') + displayTitle = cleaned.length > 60 ? cleaned.slice(0, 57) + '...' : cleaned + } else { + displayTitle = `Chat #${row.id}` + } + } + + let preview = row.lastMessage + if (preview && preview.length > 100) { + preview = preview.slice(0, 97) + '...' + } + + return { + id: row.id, + paperSlug: row.paperSlug, + paperTitle: row.paperTitle, + backend: row.backend, + title: displayTitle, + preview: preview ?? null, + messageCount: Number(row.messageCount), + createdAt: row.createdAt, + lastActiveAt: row.lastMessageAt || row.createdAt, + } + }) +} + +/** Update the title of a chat session. */ +export function updateChatSessionTitle(db: Database, id: number, title: string): void { + db.prepare('UPDATE chat_sessions SET title = ? WHERE id = ?').run(title.trim(), id) +} + +/** Delete a chat session and cascade delete its messages. */ +export function deleteChatSession(db: Database, id: number): void { + db.prepare('DELETE FROM chat_sessions WHERE id = ?').run(id) +} diff --git a/electron/main.ts b/electron/main.ts index a4bdaba..311566b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,3 +1,5 @@ +import { deleteChatSession, listChatSessions, updateChatSessionTitle } from '../core/chat/repo.js' +import type { ChatSessionSummary } from '../core/chat/repo.js' import { app, BrowserWindow, ipcMain } from 'electron' import type { IpcMainInvokeEvent } from 'electron' import { randomUUID } from 'node:crypto' @@ -266,11 +268,62 @@ function requireSlug(value: unknown, channel: string): string { return value } -// Open (or reload) the Ask tab for a paper: returns the most recent chat -// session for this slug + its full history, creating a fresh session if -// none exists yet. Cheap — never touches the ACP layer. -ipcMain.handle('vellum:ask-open', (_event, slug: unknown): AskOpenResult => { - return getChatManager().openChat({ db: getDb(), paperSlug: requireSlug(slug, 'vellum:ask-open') }) +function parseAskOpenParams(value: unknown, maybeSessionId?: unknown): { slug: string; sessionId?: number } { + if (typeof value === 'object' && value !== null) { + const candidate = value as Record + const slug = requireSlug(candidate['slug'], 'vellum:ask-open') + const sessionId = typeof candidate['sessionId'] === 'number' && Number.isInteger(candidate['sessionId']) + ? candidate['sessionId'] + : undefined + return { slug, sessionId } + } + const slug = requireSlug(value, 'vellum:ask-open') + const sessionId = typeof maybeSessionId === 'number' && Number.isInteger(maybeSessionId) + ? maybeSessionId + : undefined + return { slug, sessionId } +} + +// Open (or reload) the Ask tab for a paper: returns the requested or most +// recent chat session for this slug + its full history. +ipcMain.handle('vellum:ask-open', (_event, slugOrParams: unknown, maybeSessionId?: unknown): AskOpenResult => { + const { slug, sessionId } = parseAskOpenParams(slugOrParams, maybeSessionId) + return getChatManager().openChat({ db: getDb(), paperSlug: slug, chatSessionId: sessionId }) +}) + +// [L2-02] Cross-paper chat library IPC handlers +ipcMain.handle('vellum:chat-list-sessions', (_event, options: unknown): ChatSessionSummary[] => { + let search: string | undefined + let limit: number | undefined + if (typeof options === 'object' && options !== null) { + const opts = options as Record + if (typeof opts['search'] === 'string') search = opts['search'] + if (typeof opts['limit'] === 'number' && Number.isInteger(opts['limit']) && opts['limit'] > 0) { + limit = opts['limit'] + } + } + return listChatSessions(getDb(), { search, limit }) +}) + +ipcMain.handle('vellum:chat-delete-session', (_event, id: unknown): void => { + if (typeof id !== 'number' || !Number.isInteger(id)) { + throw new Error('vellum:chat-delete-session: id must be an integer') + } + deleteChatSession(getDb(), id) +}) + +ipcMain.handle('vellum:chat-rename-session', (_event, params: unknown): void => { + if (typeof params !== 'object' || params === null) { + throw new Error('vellum:chat-rename-session: params must be an object') + } + const { id, title } = params as Record + if (typeof id !== 'number' || !Number.isInteger(id)) { + throw new Error('vellum:chat-rename-session: id must be an integer') + } + if (typeof title !== 'string' || title.trim().length === 0) { + throw new Error('vellum:chat-rename-session: title must be a non-empty string') + } + updateChatSessionTitle(getDb(), id, title) }) // "New chat" action: fresh chat_sessions row + disposes any cached ACP diff --git a/electron/preload.ts b/electron/preload.ts index 89e1ea6..6f7dd51 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,3 +1,4 @@ +import type { ChatSessionSummary, ListChatSessionsOptions } from '../core/chat/repo.js' import { contextBridge, ipcRenderer } from 'electron' import type { IpcRendererEvent } from 'electron' import type { AskOpenResult, AskStartParams, AskStreamEvent } from '../core/chat/manager.js' @@ -72,7 +73,17 @@ const api = { highlightsDelete: (id: string): Promise => ipcRenderer.invoke('vellum:highlights-delete', id), // [P1-10] Ask tab — grounded chat over ACP. ----------------------------- // Open (or reload) the most recent chat session + history for a paper. - askOpen: (slug: string): Promise => ipcRenderer.invoke('vellum:ask-open', slug), + // Open (or reload) a chat session + history for a paper. + askOpen: ( + slugOrParams: string | { slug: string; sessionId?: number }, + sessionId?: number, + ): Promise => ipcRenderer.invoke('vellum:ask-open', slugOrParams, sessionId), + // [L2-02] Cross-paper chat library methods + chatListSessions: (options?: ListChatSessionsOptions): Promise => + ipcRenderer.invoke('vellum:chat-list-sessions', options), + chatDeleteSession: (id: number): Promise => ipcRenderer.invoke('vellum:chat-delete-session', id), + chatRenameSession: (params: { id: number; title: string }): Promise => + ipcRenderer.invoke('vellum:chat-rename-session', params), // "New chat": fresh session, empty history, fresh agent conversation. askNewChat: (params: { slug: string; backend: AcpBackend }): Promise => ipcRenderer.invoke('vellum:ask-new-chat', params), @@ -94,4 +105,4 @@ contextBridge.exposeInMainWorld('vellum', api) export type VellumApi = typeof api -export type { CollectionRecord, CollectionTreeItem } +export type { CollectionRecord, CollectionTreeItem, ChatSessionSummary, ListChatSessionsOptions } diff --git a/package.json b/package.json index ef29e6a..8dde598 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "smoke:acp": "tsx core/acp/smoke.ts", "test:gate:reading": "node test/reading-gate.mjs", "test:gate:collections": "node test/collections-live.mjs", + "test:gate:chats": "node test/chats-live.mjs", "dist": "electron-vite build && electron-builder" }, "dependencies": { diff --git a/src/app/App.tsx b/src/app/App.tsx index bf7d4e5..a851975 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,3 +1,4 @@ +import type { ChatSessionSummary } from '../../core/chat/repo' import { useEffect, useState } from 'react' import { IngestModal } from './IngestModal' import { Library } from './Library' @@ -46,6 +47,24 @@ export function App(): JSX.Element { const [injectedPrompt, setInjectedPrompt] = useState(null) const [selectedCollectionId, setSelectedCollectionId] = useState(null) const [selectedCollectionName, setSelectedCollectionName] = useState(null) + const [selectedChatSessionId, setSelectedChatSessionId] = useState(null) + const [chatBanner, setChatBanner] = useState(null) + + async function handleSelectChatSession(session: ChatSessionSummary): Promise { + setSelectedChatSessionId(session.id) + try { + const paper = await window.vellum.getPaper(session.paperSlug) + if (paper) { + setChatBanner(null) + openPaper({ slug: paper.slug, title: paper.title }) + setRightPanelTab('Ask') + } else { + setChatBanner(`Chat "${session.title}" references paper "${session.paperSlug}" which was not found in your library.`) + } + } catch { + setChatBanner(`Could not load paper for chat "${session.title}".`) + } + } function handleSelectCollection(id: number | null, name: string | null): void { setSelectedCollectionId(id) @@ -114,8 +133,16 @@ export function App(): JSX.Element { onNavChange={handleNavChange} selectedCollectionId={selectedCollectionId} onSelectCollection={handleSelectCollection} + selectedSessionId={selectedChatSessionId} + onSelectSession={handleSelectChatSession} />
+ {chatBanner && ( +
+ {chatBanner} + +
+ )} {view === 'library' ? ( { }), ) }) + it('reopens exact chat session when targetSessionId is passed [L2-02]', async () => { + askOpen.mockResolvedValue({ + session: { id: 42, paperSlug: 'p1', backend: 'claude', title: 'Prior Discussion', createdAt: 't' }, + messages: [ + { id: 10, sessionId: 42, role: 'user', content: 'Target session question', createdAt: 't' }, + ], + }) + + render() + + expect(await screen.findByText('Target session question')).toBeInTheDocument() + expect(askOpen).toHaveBeenCalledWith({ slug: 'p1', sessionId: 42 }) + }) }) diff --git a/src/app/AskPanel.tsx b/src/app/AskPanel.tsx index e6a8712..c652a4a 100644 --- a/src/app/AskPanel.tsx +++ b/src/app/AskPanel.tsx @@ -43,9 +43,11 @@ interface AskPanelProps { slug: string /** [R1-05] Contextual prompt injected from PDF text selection (Add to chat or Explain) */ injectedPrompt?: InjectedPrompt | null + /** [L2-02] Target chat session ID to restore from Chats library */ + targetSessionId?: number | null } -export function AskPanel({ slug, injectedPrompt }: AskPanelProps): JSX.Element { +export function AskPanel({ slug, injectedPrompt, targetSessionId }: AskPanelProps): JSX.Element { const [sessionId, setSessionId] = useState(null) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') @@ -68,7 +70,7 @@ export function AskPanel({ slug, injectedPrompt }: AskPanelProps): JSX.Element { activeRequestId.current = null window.vellum - .askOpen(slug) + .askOpen(targetSessionId ? { slug, sessionId: targetSessionId } : slug) .then((result) => { if (cancelled) return setSessionId(result.session.id) @@ -88,7 +90,7 @@ export function AskPanel({ slug, injectedPrompt }: AskPanelProps): JSX.Element { return () => { cancelled = true } - }, [slug]) + }, [slug, targetSessionId]) // Single subscription for the lifetime of the component. Main broadcasts // to this window; we filter by `activeRequestId.current` so events from an diff --git a/src/app/ChatsList.module.css b/src/app/ChatsList.module.css new file mode 100644 index 0000000..9108994 --- /dev/null +++ b/src/app/ChatsList.module.css @@ -0,0 +1,164 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; + box-sizing: border-box; +} + +.searchHeader { + padding: 8px 10px; + border-bottom: 1px solid var(--border-color, #2d3748); +} + +.searchInput { + width: 100%; + padding: 6px 10px; + background-color: var(--bg-input, #1a202c); + border: 1px solid var(--border-color, #4a5568); + border-radius: 4px; + color: var(--text-color, #e2e8f0); + font-size: 12px; + outline: none; + box-sizing: border-box; +} + +.searchInput:focus { + border-color: var(--color-primary, #3182ce); +} + +.list { + flex: 1; + overflow-y: auto; + list-style: none; + margin: 0; + padding: 6px 0; +} + +.item { + position: relative; + display: flex; + flex-direction: column; + padding: 8px 12px; + cursor: pointer; + border-left: 3px solid transparent; + transition: background-color 0.15s ease; + user-select: none; +} + +.item:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.item:focus-visible, +.itemActive { + background-color: rgba(66, 153, 225, 0.15); + border-left-color: #3182ce; + outline: none; +} + +.itemTitleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} + +.itemTitle { + font-size: 13px; + font-weight: 600; + color: #f7fafc; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.deleteBtn { + opacity: 0; + background: transparent; + border: none; + color: #a0aec0; + font-size: 12px; + padding: 2px 4px; + border-radius: 3px; + cursor: pointer; + transition: opacity 0.15s ease, color 0.15s ease; +} + +.item:hover .deleteBtn, +.item:focus-within .deleteBtn { + opacity: 1; +} + +.deleteBtn:hover { + color: #fc8181; + background-color: rgba(254, 178, 178, 0.1); +} + +.itemPaper { + font-size: 11px; + color: #a0aec0; + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.missingPaper { + font-style: italic; + color: #e53e3e; +} + +.itemMeta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 5px; + font-size: 10px; + color: #718096; +} + +.badge { + display: inline-block; + padding: 1px 5px; + border-radius: 3px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badgeClaude { + background-color: rgba(214, 158, 46, 0.2); + color: #ecc94b; +} + +.badgeCodex { + background-color: rgba(72, 187, 120, 0.2); + color: #68d391; +} + +.badgeGeneric { + background-color: rgba(160, 174, 192, 0.2); + color: #cbd5e0; +} + +.msgCount { + color: #a0aec0; +} + +.timestamp { + margin-left: auto; +} + +.emptyState, +.errorState { + padding: 24px 16px; + text-align: center; + font-size: 12px; + color: #a0aec0; +} + +.errorState { + color: #fc8181; +} diff --git a/src/app/ChatsList.test.tsx b/src/app/ChatsList.test.tsx new file mode 100644 index 0000000..1675a52 --- /dev/null +++ b/src/app/ChatsList.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ChatsList } from './ChatsList' +import type { ChatSessionSummary } from '../../core/chat/repo' + +describe('ChatsList [L2-02]', () => { + const mockSessions: ChatSessionSummary[] = [ + { + id: 1, + paperSlug: 'attention-is-all-you-need', + paperTitle: 'Attention Is All You Need', + backend: 'claude', + title: 'Explain Transformer architecture', + preview: 'The Transformer uses self-attention', + messageCount: 4, + createdAt: '2026-03-01T10:00:00.000Z', + lastActiveAt: '2026-03-01T10:05:00.000Z', + }, + { + id: 2, + paperSlug: 'missing-paper-slug', + paperTitle: null, + backend: 'codex', + title: 'Orphaned Chat Discussion', + preview: 'Testing missing paper', + messageCount: 1, + createdAt: '2026-03-01T09:00:00.000Z', + lastActiveAt: '2026-03-01T09:00:00.000Z', + }, + ] + + beforeEach(() => { + window.vellum = { + ...window.vellum, + chatListSessions: vi.fn().mockResolvedValue(mockSessions), + chatDeleteSession: vi.fn().mockResolvedValue(undefined), + } as any + vi.spyOn(window, 'confirm').mockReturnValue(true) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders chat sessions list with titles, papers, backends, and message counts', async () => { + const onSelect = vi.fn() + render() + + expect(await screen.findByText('Explain Transformer architecture')).toBeInTheDocument() + expect(screen.getByText(/Attention Is All You Need/)).toBeInTheDocument() + expect(screen.getByText('claude')).toBeInTheDocument() + expect(screen.getByText('4 turns')).toBeInTheDocument() + + // Missing paper fallback + expect(screen.getByText('Orphaned Chat Discussion')).toBeInTheDocument() + expect(screen.getByText(/Paper not in library/)).toBeInTheDocument() + expect(screen.getByText('codex')).toBeInTheDocument() + }) + + it('invokes onSelectSession when clicking a session item', async () => { + const onSelect = vi.fn() + render() + + const item = await screen.findByText('Explain Transformer architecture') + fireEvent.click(item) + + expect(onSelect).toHaveBeenCalledWith(mockSessions[0]) + }) + + it('supports keyboard navigation with Arrow keys and Enter', async () => { + const onSelect = vi.fn() + render() + + await screen.findByText('Explain Transformer architecture') + const container = screen.getByRole('listbox') + + fireEvent.keyDown(container, { key: 'ArrowDown' }) + fireEvent.keyDown(container, { key: 'Enter' }) + + expect(onSelect).toHaveBeenCalledWith(mockSessions[0]) + }) + + it('deletes a chat session when the delete action is confirmed', async () => { + const onSelect = vi.fn() + render() + + await screen.findByText('Explain Transformer architecture') + const deleteBtn = screen.getByRole('button', { name: 'Delete chat Explain Transformer architecture' }) + fireEvent.click(deleteBtn) + + await waitFor(() => { + expect(window.confirm).toHaveBeenCalled() + expect(window.vellum.chatDeleteSession).toHaveBeenCalledWith(1) + }) + }) + + it('filters sessions by search input', async () => { + const onSelect = vi.fn() + render() + + await screen.findByText('Explain Transformer architecture') + const searchInput = screen.getByRole('searchbox', { name: 'Search chats' }) + fireEvent.change(searchInput, { target: { value: 'Transformer' } }) + + await waitFor(() => { + expect(window.vellum.chatListSessions).toHaveBeenCalledWith({ search: 'Transformer' }) + }) + }) +}) diff --git a/src/app/ChatsList.tsx b/src/app/ChatsList.tsx new file mode 100644 index 0000000..d38163f --- /dev/null +++ b/src/app/ChatsList.tsx @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { ChatSessionSummary } from '../../core/chat/repo' +import styles from './ChatsList.module.css' + +interface ChatsListProps { + selectedSessionId: number | null + onSelectSession: (session: ChatSessionSummary) => void +} + +function formatRelativeTime(dateStr: string): string { + try { + const d = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const diffMins = Math.floor(diffMs / 60000) + if (diffMins < 1) return 'just now' + if (diffMins < 60) return `${diffMins}m ago` + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) return `${diffHours}h ago` + const diffDays = Math.floor(diffHours / 24) + if (diffDays < 7) return `${diffDays}d ago` + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + } catch { + return dateStr + } +} + +export function ChatsList({ selectedSessionId, onSelectSession }: ChatsListProps): JSX.Element { + const [sessions, setSessions] = useState([]) + const [search, setSearch] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [focusedIndex, setFocusedIndex] = useState(-1) + const listRef = useRef(null) + + const fetchSessions = useCallback(async () => { + try { + const data = await window.vellum.chatListSessions({ search }) + setSessions(data) + setError(null) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, [search]) + + useEffect(() => { + void fetchSessions() + const interval = setInterval(fetchSessions, 2000) + return () => clearInterval(interval) + }, [fetchSessions]) + + const handleDelete = async (e: React.MouseEvent, session: ChatSessionSummary) => { + e.stopPropagation() + const confirmed = window.confirm(`Delete conversation "${session.title}"?`) + if (!confirmed) return + try { + await window.vellum.chatDeleteSession(session.id) + await fetchSessions() + } catch (err) { + console.error('Failed to delete chat session:', err) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (sessions.length === 0) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setFocusedIndex((prev) => { + const next = prev < sessions.length - 1 ? prev + 1 : 0 + return next + }) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setFocusedIndex((prev) => { + const next = prev > 0 ? prev - 1 : sessions.length - 1 + return next + }) + } else if (e.key === 'Enter' && focusedIndex >= 0 && focusedIndex < sessions.length) { + e.preventDefault() + onSelectSession(sessions[focusedIndex]) + } + } + + return ( +
+
+ setSearch(e.target.value)} + /> +
+ + {error ? ( +
+ Failed to load chats: {error} +
+ ) : loading && sessions.length === 0 ? ( +
Loading chats…
+ ) : sessions.length === 0 ? ( +
+ {search ? `No chats matching "${search}"` : 'No conversations yet.'} +
+ ) : ( +
    + {sessions.map((session, index) => { + const isSelected = selectedSessionId === session.id + const isFocused = focusedIndex === index + const backendClass = + session.backend === 'claude' + ? styles.badgeClaude + : session.backend === 'codex' + ? styles.badgeCodex + : styles.badgeGeneric + + return ( +
  • onSelectSession(session)} + onFocus={() => setFocusedIndex(index)} + > +
    + + {session.title} + + +
    + +
    + 📄 {session.paperTitle || '(Paper not in library)'} +
    + +
    + {session.backend} + + {session.messageCount} {session.messageCount === 1 ? 'turn' : 'turns'} + + + {formatRelativeTime(session.lastActiveAt)} + +
    +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/app/RightPanel.tsx b/src/app/RightPanel.tsx index 2b6c254..6c9b27d 100644 --- a/src/app/RightPanel.tsx +++ b/src/app/RightPanel.tsx @@ -35,6 +35,8 @@ interface RightPanelProps { onJumpToHighlight?: (highlight: HighlightRecord) => void /** [R1-05] Injected prompt from PDF text selection actions */ injectedPrompt?: InjectedPrompt | null + /** [L2-02] Target chat session to open in AskPanel */ + targetSessionId?: number | null } export function RightPanel({ @@ -44,6 +46,7 @@ export function RightPanel({ slug, onJumpToHighlight, injectedPrompt, + targetSessionId, }: RightPanelProps): JSX.Element { const [internalTab, setInternalTab] = useState(defaultTab) const activeTab = controlledActiveTab ?? internalTab @@ -74,7 +77,7 @@ export function RightPanel({
- {renderTabContent(activeTab, slug, onJumpToHighlight, injectedPrompt)} + {renderTabContent(activeTab, slug, onJumpToHighlight, injectedPrompt, targetSessionId)}
) @@ -85,10 +88,11 @@ function renderTabContent( slug: string | undefined, onJumpToHighlight: ((highlight: HighlightRecord) => void) | undefined, injectedPrompt: InjectedPrompt | null | undefined, + targetSessionId?: number | null, ): JSX.Element { switch (tab) { case 'Ask': - if (slug) return + if (slug) return return (

Open a paper to start asking questions.

diff --git a/src/app/Sidebar.test.tsx b/src/app/Sidebar.test.tsx index eed100b..d2d82b2 100644 --- a/src/app/Sidebar.test.tsx +++ b/src/app/Sidebar.test.tsx @@ -18,14 +18,14 @@ describe('Sidebar', () => { expect(screen.getByText('All Papers')).toBeInTheDocument() }) - it('switches to Chats and shows its "coming soon" stub linking [P2-06]', async () => { + it('switches to Chats and renders the active ChatsList [L2-02]', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('tab', { name: 'Chats' })) - expect(screen.getByText(/Chats — coming soon/i)).toBeInTheDocument() - expect(screen.getByText(/P2-06/)).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Chats' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('searchbox', { name: 'Search chats' })).toBeInTheDocument() }) it('shows the Trash stub linking [P2-08]', async () => { diff --git a/src/app/Sidebar.tsx b/src/app/Sidebar.tsx index 6facb5e..7117998 100644 --- a/src/app/Sidebar.tsx +++ b/src/app/Sidebar.tsx @@ -1,3 +1,5 @@ +import { ChatsList } from './ChatsList' +import type { ChatSessionSummary } from '../../core/chat/repo' // Left sidebar — workspace switcher + primary nav (Create / Home / Library / // Search) + a Files/Chats view toggle + footer (Trash / Usage). [P1-07] was // layout-only for nav; [P1-14] fills in the rest of anara's sidebar chrome as @@ -30,12 +32,16 @@ interface SidebarProps { onNavChange?: (item: NavItem) => void selectedCollectionId?: number | null onSelectCollection?: (id: number | null, name: string | null) => void + selectedSessionId?: number | null + onSelectSession?: (session: ChatSessionSummary) => void } export function Sidebar({ onNavChange, selectedCollectionId = null, onSelectCollection, + selectedSessionId = null, + onSelectSession, }: SidebarProps = {}): JSX.Element { const [active, setActive] = useState('Home') const [libraryView, setLibraryView] = useState('Files') @@ -95,7 +101,12 @@ export function Sidebar({ }} /> ) : ( - + { + onSelectSession?.(session) + }} + /> )}
diff --git a/test/chats-live.mjs b/test/chats-live.mjs new file mode 100644 index 0000000..e07218f --- /dev/null +++ b/test/chats-live.mjs @@ -0,0 +1,143 @@ +// Live Electron verification script for [L2-02] Cross-paper Chats library and session reopening +import { _electron as electron } from 'playwright' +import path from 'path' +import { fileURLToPath } from 'url' +import assert from 'node:assert' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(__dirname, '..') + +async function runLiveVerification() { + console.log('=== [L2-02] Live Chats Library Verification Starting ===') + + // 1. Launch instance 1 + console.log('1. Launching Electron App (Instance 1)...') + const app = await electron.launch({ + args: ['--no-sandbox', path.join(ROOT, 'out/main/index.cjs')], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '' }, + }) + + const window = await app.firstWindow() + await window.waitForLoadState('domcontentloaded') + + // Verify IPC bridge + const pong = await window.evaluate(() => window.vellum.ping()) + assert.strictEqual(pong, 'pong') + + // Ingest sample papers + const pdf1 = path.join(ROOT, 'core/ingest/fixtures/sample.pdf') + const pdf2 = path.join(ROOT, 'core/ingest/fixtures/sample2.pdf') + + const p1 = await window.evaluate((p) => window.vellum.ingest(p), pdf1) + const p2 = await window.evaluate((p) => window.vellum.ingest(p), pdf2) + console.log(`Ingested papers: ${p1.slug}, ${p2.slug}`) + + // Create chat sessions on both papers + const chat1 = await window.evaluate((slug) => window.vellum.askOpen(slug), p1.slug) + console.log(`Created chat 1 on ${p1.slug} (ID: ${chat1.session.id}, backend: ${chat1.session.backend})`) + + // Rename chat 1 + await window.evaluate( + ({ id, title }) => window.vellum.chatRenameSession({ id, title }), + { id: chat1.session.id, title: 'Attention Deep Dive' } + ) + + // Create a new chat on paper 1 with codex + const chat2 = await window.evaluate( + (slug) => window.vellum.askNewChat({ slug, backend: 'codex' }), + p1.slug + ) + console.log(`Created chat 2 on ${p1.slug} (ID: ${chat2.session.id}, backend: ${chat2.session.backend})`) + await window.evaluate( + ({ id, title }) => window.vellum.chatRenameSession({ id, title }), + { id: chat2.session.id, title: 'Codex Exploration' } + ) + + // Create chat on paper 2 with claude + const chat3 = await window.evaluate((slug) => window.vellum.askOpen(slug), p2.slug) + console.log(`Created chat 3 on ${p2.slug} (ID: ${chat3.session.id}, backend: ${chat3.session.backend})`) + await window.evaluate( + ({ id, title }) => window.vellum.chatRenameSession({ id, title }), + { id: chat3.session.id, title: 'Paper Two Overview' } + ) + + // List sessions via bridge + const sessions = await window.evaluate(() => window.vellum.chatListSessions()) + assert(sessions.length >= 3, 'Should have at least 3 chat sessions') + const foundChat1 = sessions.find((s) => s.id === chat1.session.id) + assert(foundChat1, 'Chat 1 exists in sessions list') + assert.strictEqual(foundChat1.title, 'Attention Deep Dive') + assert.strictEqual(foundChat1.paperSlug, p1.slug) + assert.strictEqual(foundChat1.backend, 'claude') + + const foundChat2 = sessions.find((s) => s.id === chat2.session.id) + assert(foundChat2, 'Chat 2 exists in sessions list') + assert.strictEqual(foundChat2.title, 'Codex Exploration') + assert.strictEqual(foundChat2.backend, 'codex') + console.log('Bridge queries confirmed sessions across papers and backends.') + + // UI Verification: switch sidebar to Chats + await window.click('button[role="tab"]:has-text("Chats")') + await window.waitForTimeout(500) + + // Verify ChatsList rendered in UI + const chatItems = await window.$$eval('li[role="option"]', (els) => els.map((el) => el.textContent)) + assert(chatItems.some((text) => text.includes('Attention Deep Dive')), 'UI lists Attention Deep Dive') + assert(chatItems.some((text) => text.includes('Codex Exploration')), 'UI lists Codex Exploration') + assert(chatItems.some((text) => text.includes('Paper Two Overview')), 'UI lists Paper Two Overview') + console.log('UI Chats list verified with all titles and backends.') + + // Test search in ChatsList + await window.fill('input[aria-label="Search chats"]', 'Codex') + await window.waitForTimeout(300) + const filteredItems = await window.$$eval('li[role="option"]', (els) => els.map((el) => el.textContent)) + assert.strictEqual(filteredItems.length, 1, 'Search filters to 1 chat') + assert(filteredItems[0].includes('Codex Exploration')) + console.log('Chats search filtering verified.') + + // Clear search + await window.fill('input[aria-label="Search chats"]', '') + await window.waitForTimeout(300) + + // Reopen chat 1 by clicking on its item + console.log('Testing session reopening from Chats library...') + await window.click('li[role="option"]:has-text("Attention Deep Dive")') + await window.waitForTimeout(500) + + // Confirm that reader/Ask panel is active + const askTab = await window.$('button[role="tab"][aria-selected="true"]:has-text("Ask")') + assert(askTab, 'Ask tab should be selected after opening chat') + console.log('Session reopening into AskPanel verified.') + + // Close instance 1 + await app.close() + console.log('Instance 1 closed.') + + // Launch instance 2 to verify persistence across restarts + console.log('2. Launching Electron App (Instance 2) for persistence verification...') + const app2 = await electron.launch({ + args: ['--no-sandbox', path.join(ROOT, 'out/main/index.cjs')], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '' }, + }) + const window2 = await app2.firstWindow() + await window2.waitForLoadState('domcontentloaded') + + const sessionsAfterRestart = await window2.evaluate(() => window.vellum.chatListSessions()) + const rChat1 = sessionsAfterRestart.find((s) => s.id === chat1.session.id) + const rChat2 = sessionsAfterRestart.find((s) => s.id === chat2.session.id) + assert(rChat1, 'Chat 1 persisted across restart') + assert.strictEqual(rChat1.title, 'Attention Deep Dive') + assert.strictEqual(rChat1.backend, 'claude') + assert(rChat2, 'Chat 2 persisted across restart') + assert.strictEqual(rChat2.title, 'Codex Exploration') + assert.strictEqual(rChat2.backend, 'codex') + console.log('Persistence across restarts verified: Sessions and backends intact.') + + await app2.close() + console.log('=== [L2-02] LIVE CHATS LIBRARY VERIFICATION SUCCESSFUL ===') +} + +runLiveVerification().catch((err) => { + console.error('LIVE VERIFICATION FAILED:', err) + process.exit(1) +})