From 3e62d582569859c324aac5b538c2e5324ca6998e Mon Sep 17 00:00:00 2001 From: Domenico Bochicchio Date: Mon, 23 Mar 2026 07:30:43 +0100 Subject: [PATCH 1/2] feat: display session ID and custom title in chat UI Add SessionInfoBar component between SearchBar and ChatHistory showing the session UUID (truncated, full on hover), copy-to-clipboard, and a Resume button that copies `claude --resume `. Parse custom-title JSONL entries written by /rename and surface them in the sidebar, tab label, and session detail. Both light and deep metadata paths extract the title, with the light path using a fast string-match scan to avoid full JSON parsing. Closes #115 --- src/main/services/discovery/ProjectScanner.ts | 6 + src/main/types/domain.ts | 2 + src/main/utils/jsonl.ts | 12 +- src/main/utils/metadataExtraction.ts | 45 ++++++ .../components/chat/SessionInfoBar.tsx | 71 +++++++++ .../components/layout/MiddlePanel.tsx | 2 + .../components/sidebar/SessionItem.tsx | 6 +- .../store/slices/sessionDetailSlice.ts | 7 +- test/main/utils/jsonl.test.ts | 110 +++++++++++++ .../components/SessionInfoBar.test.ts | 145 ++++++++++++++++++ 10 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 src/renderer/components/chat/SessionInfoBar.tsx create mode 100644 test/renderer/components/SessionInfoBar.test.ts diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 312ef294..e80c40e6 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -28,6 +28,7 @@ import { } from '@main/types'; import { analyzeSessionFileMetadata, + extractCustomTitle, extractCwd, extractFirstUserMessagePreview, } from '@main/utils/jsonl'; @@ -767,6 +768,7 @@ export class ProjectScanner { todoData, createdAt: Math.floor(createdAt), firstMessage: metadata.firstUserMessage?.text, + customTitle: metadata.customTitle, messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents, messageCount: metadata.messageCount, @@ -819,12 +821,16 @@ export class ProjectScanner { ? previewTimestampMs : birthtimeMs; + // Fast scan for /rename custom title (only parses lines containing "custom-title") + const customTitle = await extractCustomTitle(filePath, this.fsProvider); + return { id: sessionId, projectId, projectPath, createdAt: Math.floor(createdAt), firstMessage: preview?.text, + customTitle, messageTimestamp: preview?.timestamp, hasSubagents: false, messageCount: 0, diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index a14b87fd..0fe64351 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -91,6 +91,8 @@ export interface Session { createdAt: number; /** First user message text (for preview) */ firstMessage?: string; + /** Custom title set via /rename command */ + customTitle?: string; /** Timestamp of first user message (RFC3339) */ messageTimestamp?: string; /** Whether this session has subagents */ diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index d998438f..bede8cd5 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -38,7 +38,7 @@ const logger = createLogger('Util:jsonl'); const defaultProvider = new LocalFileSystemProvider(); // Re-export for backwards compatibility -export { extractCwd, extractFirstUserMessagePreview } from './metadataExtraction'; +export { extractCustomTitle, extractCwd, extractFirstUserMessagePreview } from './metadataExtraction'; export { checkMessagesOngoing } from './sessionStateDetection'; // ============================================================================= @@ -344,6 +344,8 @@ export interface SessionFileMetadata { messageCount: number; isOngoing: boolean; gitBranch: string | null; + /** Custom title set via /rename command */ + customTitle?: string; /** Total context consumed (compaction-aware) */ contextConsumption?: number; /** Number of compaction events */ @@ -381,6 +383,7 @@ export async function analyzeSessionFileMetadata( let firstCommandMessage: { text: string; timestamp: string } | null = null; let messageCount = 0; let hasDisplayableContent = false; + let customTitle: string | undefined; // After a UserGroup, await the first main-thread assistant message to count the AIGroup let awaitingAIGroup = false; let gitBranch: string | null = null; @@ -412,6 +415,12 @@ export async function analyzeSessionFileMetadata( continue; } + // Detect custom-title entries (standalone, no uuid — not part of ChatHistoryEntry union) + const rawEntry = entry as unknown as Record; + if (rawEntry.type === 'custom-title' && typeof rawEntry.customTitle === 'string') { + customTitle = rawEntry.customTitle; + } + const parsed = parseChatHistoryEntry(entry); if (!parsed) { continue; @@ -636,6 +645,7 @@ export async function analyzeSessionFileMetadata( messageCount, isOngoing: lastEndingIndex === -1 ? hasAnyOngoingActivity : hasActivityAfterLastEnding, gitBranch, + customTitle, contextConsumption, compactionCount: compactionPhases.length > 0 ? compactionPhases.length : undefined, phaseBreakdown, diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index e43c80d0..147bec20 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -205,3 +205,48 @@ function extractCommandName(content: string): string { const commandMatch = /\/([^<]+)<\/command-name>/.exec(content); return commandMatch ? `/${commandMatch[1]}` : '/command'; } + +/** + * Extract the last custom title from a session JSONL file. + * Scans the full file but only JSON-parses lines containing "custom-title", + * so the cost is minimal even for large files. + */ +export async function extractCustomTitle( + filePath: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { + if (!(await fsProvider.exists(filePath))) { + return undefined; + } + + const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + let customTitle: string | undefined; + + try { + for await (const line of rl) { + // Fast string check — skip JSON parse for non-matching lines + if (!line.includes('"custom-title"')) continue; + + try { + const entry = JSON.parse(line) as Record; + if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { + customTitle = entry.customTitle; + } + } catch { + // Malformed line, skip + } + } + } catch (error) { + logger.debug(`Error extracting custom title from ${filePath}:`, error); + } finally { + rl.close(); + fileStream.destroy(); + } + + return customTitle; +} diff --git a/src/renderer/components/chat/SessionInfoBar.tsx b/src/renderer/components/chat/SessionInfoBar.tsx new file mode 100644 index 00000000..21714658 --- /dev/null +++ b/src/renderer/components/chat/SessionInfoBar.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import { CopyButton } from '@renderer/components/common/CopyButton'; +import { useStore } from '@renderer/store'; +import { Hash, Terminal } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +interface SessionInfoBarProps { + readonly tabId?: string; +} + +/** + * Compact info strip showing the current session UUID with copy actions. + * Sits between SearchBar and ChatHistory in MiddlePanel so users can + * quickly grab the session ID or `claude --resume ` command. + */ +export const SessionInfoBar: React.FC = ({ tabId }) => { + const { sessionDetail } = useStore( + useShallow((s) => { + const td = tabId ? s.tabSessionData[tabId] : null; + return { sessionDetail: td?.sessionDetail ?? s.sessionDetail }; + }), + ); + + if (!sessionDetail) return null; + + const sessionId = sessionDetail.session.id; + const resumeCommand = `claude --resume ${sessionId}`; + const shortId = sessionId.slice(0, 8); + + return ( +
+ + + {shortId} + + +
+ +
+ ); +}; diff --git a/src/renderer/components/layout/MiddlePanel.tsx b/src/renderer/components/layout/MiddlePanel.tsx index 8579bed3..ba666353 100644 --- a/src/renderer/components/layout/MiddlePanel.tsx +++ b/src/renderer/components/layout/MiddlePanel.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ChatHistory } from '../chat/ChatHistory'; +import { SessionInfoBar } from '../chat/SessionInfoBar'; import { SearchBar } from '../search/SearchBar'; interface MiddlePanelProps { @@ -12,6 +13,7 @@ export const MiddlePanel: React.FC = ({ tabId }) => { return (
+
); diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index b2bba28f..0b6dc133 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -178,7 +178,7 @@ export const SessionItem = React.memo(function SessionItem({ type: 'session', sessionId: session.id, projectId: activeProjectId, - label: session.firstMessage?.slice(0, 50) ?? 'Session', + label: session.customTitle ?? session.firstMessage?.slice(0, 50) ?? 'Session', }, forceNewTab ? { forceNewTab } : { replaceActiveTab: true } ); @@ -191,7 +191,7 @@ export const SessionItem = React.memo(function SessionItem({ setContextMenu({ x: e.clientX, y: e.clientY }); }, []); - const sessionLabel = session.firstMessage?.slice(0, 50) ?? 'Session'; + const sessionLabel = session.customTitle ?? session.firstMessage?.slice(0, 50) ?? 'Session'; const handleOpenInCurrentPane = useCallback(() => { if (!activeProjectId) return; @@ -271,7 +271,7 @@ export const SessionItem = React.memo(function SessionItem({ className="truncate text-[13px] font-medium leading-tight" style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }} > - {session.firstMessage ?? 'Untitled'} + {session.customTitle ?? session.firstMessage ?? 'Untitled'}
diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index 7a1de354..a7f70418 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -398,9 +398,10 @@ export const createSessionDetailSlice: StateCreator { } } }); + + it('should extract customTitle from custom-title JSONL entries', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-meta-')); + try { + const filePath = path.join(tempDir, 'session.jsonl'); + const lines = [ + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'hello world' }, + isMeta: false, + }), + JSON.stringify({ + type: 'custom-title', + customTitle: 'my-renamed-session', + sessionId: 'sess-123', + }), + JSON.stringify({ + type: 'agent-name', + agentName: 'my-renamed-session', + sessionId: 'sess-123', + }), + ]; + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + + const result = await analyzeSessionFileMetadata(filePath); + + expect(result.customTitle).toBe('my-renamed-session'); + expect(result.firstUserMessage?.text).toBe('hello world'); + } finally { + try { + fs.rmSync(tempDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }); + } catch { + // Best-effort cleanup; ignore ENOTEMPTY on Windows when dir is in use + } + } + }); + + it('should return undefined customTitle when no custom-title entry exists', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-meta-')); + try { + const filePath = path.join(tempDir, 'session.jsonl'); + const lines = [ + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'hello world' }, + isMeta: false, + }), + ]; + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + + const result = await analyzeSessionFileMetadata(filePath); + + expect(result.customTitle).toBeUndefined(); + } finally { + try { + fs.rmSync(tempDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }); + } catch { + // Best-effort cleanup; ignore ENOTEMPTY on Windows when dir is in use + } + } + }); + + it('should use the last custom-title when renamed multiple times', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-meta-')); + try { + const filePath = path.join(tempDir, 'session.jsonl'); + const lines = [ + JSON.stringify({ + type: 'custom-title', + customTitle: 'first-name', + sessionId: 'sess-123', + }), + JSON.stringify({ + type: 'custom-title', + customTitle: 'second-name', + sessionId: 'sess-123', + }), + ]; + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + + const result = await analyzeSessionFileMetadata(filePath); + + expect(result.customTitle).toBe('second-name'); + } finally { + try { + fs.rmSync(tempDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }); + } catch { + // Best-effort cleanup; ignore ENOTEMPTY on Windows when dir is in use + } + } + }); }); }); diff --git a/test/renderer/components/SessionInfoBar.test.ts b/test/renderer/components/SessionInfoBar.test.ts new file mode 100644 index 00000000..0ecf3da1 --- /dev/null +++ b/test/renderer/components/SessionInfoBar.test.ts @@ -0,0 +1,145 @@ +/** + * SessionInfoBar unit tests. + * Verifies the store-driven logic that determines session info bar visibility + * and the data it surfaces (session ID, resume command). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { installMockElectronAPI } from '../../mocks/electronAPI'; + +import { createTestStore, type TestStore } from '../store/storeTestUtils'; + +import type { SessionDetail } from '../../../src/renderer/types/data'; + +/** Minimal SessionDetail stub with only the fields SessionInfoBar reads. */ +function makeSessionDetail(id: string): SessionDetail { + return { + session: { + id, + projectId: 'proj-1', + projectPath: '/home/user/project', + createdAt: Date.now(), + updatedAt: Date.now(), + messageCount: 1, + }, + messages: [], + chunks: [], + processes: [], + metrics: { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheRead: 0, + totalCacheCreation: 0, + totalCost: 0, + totalDuration: 0, + turnCount: 0, + toolUseCount: 0, + messageCount: 0, + }, + } as unknown as SessionDetail; +} + +describe('SessionInfoBar store integration', () => { + let store: TestStore; + + beforeEach(() => { + installMockElectronAPI(); + store = createTestStore(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return null sessionDetail when no session is loaded', () => { + const state = store.getState(); + expect(state.sessionDetail).toBeNull(); + }); + + it('should expose session ID when sessionDetail is set', () => { + const detail = makeSessionDetail('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + store.setState({ sessionDetail: detail }); + + const state = store.getState(); + expect(state.sessionDetail).not.toBeNull(); + expect(state.sessionDetail!.session.id).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + }); + + it('should build correct resume command from session ID', () => { + const sessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const detail = makeSessionDetail(sessionId); + store.setState({ sessionDetail: detail }); + + const resumeCommand = `claude --resume ${store.getState().sessionDetail!.session.id}`; + expect(resumeCommand).toBe(`claude --resume ${sessionId}`); + }); + + it('should use per-tab sessionDetail when tabId is provided', () => { + const globalDetail = makeSessionDetail('global-session-id'); + const tabDetail = makeSessionDetail('tab-session-id'); + + store.setState({ + sessionDetail: globalDetail, + tabSessionData: { + 'tab-1': { + sessionDetail: tabDetail, + conversation: null, + conversationLoading: false, + sessionDetailLoading: false, + sessionDetailError: null, + sessionClaudeMdStats: null, + sessionContextStats: null, + sessionPhaseInfo: null, + visibleAIGroupId: null, + selectedAIGroup: null, + }, + }, + }); + + // Simulates the selector logic from SessionInfoBar: + // const td = tabId ? s.tabSessionData[tabId] : null; + // return { sessionDetail: td?.sessionDetail ?? s.sessionDetail }; + const tabId = 'tab-1'; + const state = store.getState(); + const td = tabId ? state.tabSessionData[tabId] : null; + const resolved = td?.sessionDetail ?? state.sessionDetail; + + expect(resolved).not.toBeNull(); + expect(resolved!.session.id).toBe('tab-session-id'); + }); + + it('should fall back to global sessionDetail when tab has no data', () => { + const globalDetail = makeSessionDetail('global-session-id'); + + store.setState({ + sessionDetail: globalDetail, + tabSessionData: {}, + }); + + const tabId = 'nonexistent-tab'; + const state = store.getState(); + const td = tabId ? state.tabSessionData[tabId] : null; + const resolved = td?.sessionDetail ?? state.sessionDetail; + + expect(resolved).not.toBeNull(); + expect(resolved!.session.id).toBe('global-session-id'); + }); + + it('should expose customTitle when set on sessionDetail', () => { + const detail = makeSessionDetail('session-with-title'); + detail.session.customTitle = 'my-custom-name'; + store.setState({ sessionDetail: detail }); + + const state = store.getState(); + expect(state.sessionDetail!.session.customTitle).toBe('my-custom-name'); + }); + + it('should have undefined customTitle when not set', () => { + const detail = makeSessionDetail('session-no-title'); + store.setState({ sessionDetail: detail }); + + const state = store.getState(); + expect(state.sessionDetail!.session.customTitle).toBeUndefined(); + }); +}); From 481c382ca1f051f3692fd699ab22b905e501c370 Mon Sep 17 00:00:00 2001 From: Domenico Bochicchio Date: Mon, 23 Mar 2026 07:43:30 +0100 Subject: [PATCH 2/2] fix: cache customTitle extraction and handle clipboard errors Cache extractCustomTitle results by mtime+size in the light metadata path, matching the existing sessionPreviewCache pattern. Avoids re-streaming JSONL files on every sidebar refresh when nothing changed. Add .catch() to the Resume button clipboard call to prevent unhandled promise rejections when the Clipboard API is unavailable. --- src/main/services/discovery/ProjectScanner.ts | 17 ++++++++++++++++- src/renderer/components/chat/SessionInfoBar.tsx | 6 +++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index e80c40e6..9d44b85f 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -81,6 +81,10 @@ export class ProjectScanner { string, { mtimeMs: number; size: number; preview: { text: string; timestamp: string } | null } >(); + private readonly sessionCustomTitleCache = new Map< + string, + { mtimeMs: number; size: number; customTitle: string | undefined } + >(); /** Cached project list for search — avoids re-scanning disk on every query */ private searchProjectCache: { projects: Project[]; timestamp: number } | null = null; @@ -822,7 +826,18 @@ export class ProjectScanner { : birthtimeMs; // Fast scan for /rename custom title (only parses lines containing "custom-title") - const customTitle = await extractCustomTitle(filePath, this.fsProvider); + const cachedCustomTitle = this.sessionCustomTitleCache.get(filePath); + const customTitle = + cachedCustomTitle?.mtimeMs === effectiveMtime && cachedCustomTitle.size === effectiveSize + ? cachedCustomTitle.customTitle + : await extractCustomTitle(filePath, this.fsProvider); + if (cachedCustomTitle?.mtimeMs !== effectiveMtime || cachedCustomTitle.size !== effectiveSize) { + this.sessionCustomTitleCache.set(filePath, { + mtimeMs: effectiveMtime, + size: effectiveSize, + customTitle, + }); + } return { id: sessionId, diff --git a/src/renderer/components/chat/SessionInfoBar.tsx b/src/renderer/components/chat/SessionInfoBar.tsx index 21714658..fd0ce444 100644 --- a/src/renderer/components/chat/SessionInfoBar.tsx +++ b/src/renderer/components/chat/SessionInfoBar.tsx @@ -58,7 +58,11 @@ export const SessionInfoBar: React.FC = ({ tabId }) => { style={{ borderColor: 'var(--color-border)' }} />