Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions core/chat/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
14 changes: 12 additions & 2 deletions core/chat/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -91,6 +91,7 @@ export interface OpenChatParams {
db: Database
paperSlug: string
backend?: AcpBackend
chatSessionId?: number
}

export interface AskOpenResult {
Expand Down Expand Up @@ -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 }
Expand Down
82 changes: 81 additions & 1 deletion core/chat/repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
})
})
131 changes: 131 additions & 0 deletions core/chat/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
63 changes: 58 additions & 5 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
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
Expand Down
15 changes: 13 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -72,7 +73,17 @@ const api = {
highlightsDelete: (id: string): Promise<void> => 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<AskOpenResult> => 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<AskOpenResult> => ipcRenderer.invoke('vellum:ask-open', slugOrParams, sessionId),
// [L2-02] Cross-paper chat library methods
chatListSessions: (options?: ListChatSessionsOptions): Promise<ChatSessionSummary[]> =>
ipcRenderer.invoke('vellum:chat-list-sessions', options),
chatDeleteSession: (id: number): Promise<void> => ipcRenderer.invoke('vellum:chat-delete-session', id),
chatRenameSession: (params: { id: number; title: string }): Promise<void> =>
ipcRenderer.invoke('vellum:chat-rename-session', params),
// "New chat": fresh session, empty history, fresh agent conversation.
askNewChat: (params: { slug: string; backend: AcpBackend }): Promise<AskOpenResult> =>
ipcRenderer.invoke('vellum:ask-new-chat', params),
Expand All @@ -94,4 +105,4 @@ contextBridge.exposeInMainWorld('vellum', api)

export type VellumApi = typeof api

export type { CollectionRecord, CollectionTreeItem }
export type { CollectionRecord, CollectionTreeItem, ChatSessionSummary, ListChatSessionsOptions }
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading