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,