diff --git a/db.js b/db.js index c069b6b..4fabae4 100644 --- a/db.js +++ b/db.js @@ -48,7 +48,8 @@ db.exec(` created TEXT, modified TEXT, messageCount INTEGER DEFAULT 0, - slug TEXT + slug TEXT, + aiTitle TEXT ) `); @@ -85,6 +86,19 @@ const migrations = [ try { db.exec('DROP TABLE IF EXISTS search_fts'); } catch {} searchFtsRecreated = true; }, + // v3: Add aiTitle column for AI-generated session titles. Clear cache so a + // re-index repopulates the column. Also clear session_meta.name entries that + // were clobbered by AI titles in v0.0.29 (when ai-title was written into the + // user-name column). We cannot tell with certainty which names came from an + // AI title vs a manual rename, but the safe heuristic is: drop names whose + // value matches the JSONL aiTitle on next index. That post-index cleanup is + // not done here — instead we accept that any pre-fix AI-title pollution + // remains until the user renames manually, and only future indexes are clean. + (db) => { + try { db.exec('ALTER TABLE session_cache ADD COLUMN aiTitle TEXT'); } catch {} + try { db.exec('DELETE FROM session_cache'); } catch {} + try { db.exec('DELETE FROM cache_meta'); } catch {} + }, ]; const currentDbVersion = (() => { @@ -138,13 +152,14 @@ const stmts = { cacheCount: db.prepare('SELECT COUNT(*) as cnt FROM session_cache'), cacheGetAll: db.prepare('SELECT * FROM session_cache'), cacheUpsert: db.prepare(` - INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(sessionId) DO UPDATE SET folder = excluded.folder, projectPath = excluded.projectPath, summary = excluded.summary, firstPrompt = excluded.firstPrompt, created = excluded.created, modified = excluded.modified, - messageCount = excluded.messageCount, slug = excluded.slug + messageCount = excluded.messageCount, slug = excluded.slug, + aiTitle = excluded.aiTitle `), cacheGetByFolder: db.prepare('SELECT sessionId, modified FROM session_cache WHERE folder = ?'), cacheGetFolder: db.prepare('SELECT folder FROM session_cache WHERE sessionId = ?'), @@ -231,7 +246,7 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => { stmts.cacheUpsert.run( s.sessionId, s.folder, s.projectPath, s.summary, s.firstPrompt, s.created, s.modified, s.messageCount || 0, - s.slug || null + s.slug || null, s.aiTitle || null ); } }); diff --git a/public/app.js b/public/app.js index 7631359..9ab01b8 100644 --- a/public/app.js +++ b/public/app.js @@ -745,7 +745,7 @@ function openNewSession(project) { } async function showTerminalHeader(session) { - const displayName = cleanDisplayName(session.name || session.summary); + const displayName = cleanDisplayName(session.name || session.aiTitle || session.summary); terminalHeaderName.textContent = displayName; terminalHeaderId.textContent = session.sessionId; terminalHeader.style.display = ''; diff --git a/public/dialogs.js b/public/dialogs.js index 836ce2d..433d61c 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -331,7 +331,7 @@ async function showResumeSessionDialog(session) { ``; } - const sessionName = session.name || session.summary || session.sessionId.slice(0, 8); + const sessionName = session.name || session.aiTitle || session.summary || session.sessionId.slice(0, 8); dialog.innerHTML = `

Resume Session — ${escapeHtml(sessionName)}

diff --git a/public/grid-view.js b/public/grid-view.js index a79d15f..56eea95 100644 --- a/public/grid-view.js +++ b/public/grid-view.js @@ -16,7 +16,7 @@ function wrapInGridCard(sessionId) { const session = sessionMap.get(sessionId) || (entry && entry.session); if (!session || !entry) return; - const displayName = cleanDisplayName(session.name || session.summary) || sessionId; + const displayName = cleanDisplayName(session.name || session.aiTitle || session.summary) || sessionId; const shortProject = session.projectPath ? session.projectPath.split('/').filter(Boolean).slice(-2).join('/') : ''; // Create card wrapper diff --git a/public/jsonl-viewer.js b/public/jsonl-viewer.js index 0758968..9c28bab 100644 --- a/public/jsonl-viewer.js +++ b/public/jsonl-viewer.js @@ -559,7 +559,7 @@ async function showJsonlViewer(session) { terminalArea.style.display = 'none'; jsonlViewer.style.display = 'flex'; - const displayName = session.name || session.summary || session.sessionId; + const displayName = session.name || session.aiTitle || session.summary || session.sessionId; jsonlViewerTitle.textContent = displayName; jsonlViewerSessionId.textContent = session.sessionId; jsonlViewerBody.innerHTML = ''; diff --git a/public/sidebar.js b/public/sidebar.js index a26916f..45985dd 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -29,7 +29,7 @@ function buildSlugGroup(slug, sessions) { const bTime = lastActivityTime.get(b.sessionId) || new Date(b.modified); return bTime > aTime ? b : a; }); - const displayName = cleanDisplayName(mostRecent.name || mostRecent.summary || slug); + const displayName = cleanDisplayName(mostRecent.name || mostRecent.aiTitle || mostRecent.summary || slug); const mostRecentTime = lastActivityTime.get(mostRecent.sessionId) || new Date(mostRecent.modified); const timeStr = formatDate(mostRecentTime); @@ -654,7 +654,7 @@ function buildSessionItem(session) { const modified = lastActivityTime.get(session.sessionId) || new Date(session.modified); const timeStr = formatDate(modified); - const displayName = cleanDisplayName(session.name || session.summary); + const displayName = cleanDisplayName(session.name || session.aiTitle || session.summary); const row = document.createElement('div'); row.className = 'session-row'; @@ -746,7 +746,7 @@ function startRename(summaryEl, session) { const input = document.createElement('input'); input.type = 'text'; input.className = 'session-rename-input'; - input.value = session.name || session.summary; + input.value = session.name || session.aiTitle || session.summary; summaryEl.replaceWith(input); input.focus(); @@ -754,13 +754,14 @@ function startRename(summaryEl, session) { const save = async () => { const newName = input.value.trim(); - const nameToSave = (newName && newName !== session.summary) ? newName : null; + const fallback = session.aiTitle || session.summary; + const nameToSave = (newName && newName !== fallback) ? newName : null; await window.api.renameSession(session.sessionId, nameToSave); session.name = nameToSave; const newSummary = document.createElement('div'); newSummary.className = 'session-summary'; - newSummary.textContent = nameToSave || session.summary; + newSummary.textContent = nameToSave || fallback; newSummary.addEventListener('dblclick', (e) => { e.stopPropagation(); startRename(newSummary, session); @@ -775,7 +776,7 @@ function startRename(summaryEl, session) { input.removeEventListener('blur', save); const restored = document.createElement('div'); restored.className = 'session-summary'; - restored.textContent = session.name || session.summary; + restored.textContent = session.name || session.aiTitle || session.summary; restored.addEventListener('dblclick', (ev) => { ev.stopPropagation(); startRename(restored, session); diff --git a/read-session-file.js b/read-session-file.js index 1123289..bb42318 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -49,7 +49,7 @@ function readSessionFile(filePath, folder, projectPath) { summary, firstPrompt: summary, created: stat.birthtime.toISOString(), modified: stat.mtime.toISOString(), - messageCount, textContent, slug, customTitle: customTitle || aiTitle, + messageCount, textContent, slug, customTitle, aiTitle, }; } catch { return null; diff --git a/session-cache.js b/session-cache.js index 2215a4f..f066004 100644 --- a/session-cache.js +++ b/session-cache.js @@ -110,7 +110,10 @@ function refreshFolder(folder) { const s = readSessionFile(filePath, folder, projectPath); if (s) { sessionsToUpsert.push(s); - const name = s.customTitle || getMeta(s.sessionId)?.name || ''; + // Title precedence: user rename (session_meta.name) > JSONL custom-title > JSONL ai-title. + // Only customTitle (Claude /title) promotes to session_meta.name — AI titles must NEVER + // be written there or they'd overwrite the user's UI rename on the next index pass. + const name = getMeta(s.sessionId)?.name || s.customTitle || s.aiTitle || ''; searchEntriesToUpsert.push({ id: s.sessionId, type: 'session', folder: s.folder, title: (name ? name + ' ' : '') + s.summary, body: s.textContent, @@ -193,6 +196,7 @@ function buildProjectsFromCache(showArchived) { messageCount: row.messageCount, projectPath: row.projectPath, slug: row.slug || null, + aiTitle: row.aiTitle || null, name: meta?.name || null, starred: meta?.starred || 0, archived: meta?.archived || 0, @@ -330,11 +334,13 @@ function populateCacheViaWorker() { sessionCount += sessions.length; upsertCachedSessions(sessions); for (const s of sessions) { + // Only JSONL custom-title (genuine user title) promotes to the DB name column. + // AI titles must not — see refreshFolder for the rationale. if (s.customTitle) setName(s.sessionId, s.customTitle); } upsertSearchEntries(sessions.map(s => { - // customTitle comes from jsonl; fall back to session_meta.name (set via rename) - const name = s.customTitle || getMeta(s.sessionId)?.name || ''; + // Search title precedence matches the sidebar: user rename > custom-title > ai-title. + const name = getMeta(s.sessionId)?.name || s.customTitle || s.aiTitle || ''; return { id: s.sessionId, type: 'session', folder: s.folder, title: (name ? name + ' ' : '') + s.summary,