From 93072cc8dcdd803a62354f1b003e062fc64df668 Mon Sep 17 00:00:00 2001 From: "Ivan Milosavljevic (TheJavaGuy)" Date: Sat, 10 Oct 2026 21:09:57 +0200 Subject: [PATCH] feat: show session titles (agent-name/custom-title/ai-title) in sidebar, tabs and palette Session list, tab labels and command palette now prefer the session title over the first user message: 1. last non-empty agent-name / custom-title entry 2. else last non-empty ai-title 3. else first user message (previous behavior) Titles are collected in the existing single streaming pass of analyzeSessionFileMetadata, so no extra file read or cache is added. Tab labels are synced on fetch and on in-place refresh (before the chunk fingerprint early return, since a rename only appends a title line). Tabs renamed by hand are flagged (labelSetByUser) and never overwritten. --- src/main/services/discovery/ProjectScanner.ts | 2 + src/main/types/domain.ts | 2 + src/main/utils/jsonl.ts | 21 ++++ .../components/layout/SortableTab.tsx | 2 +- .../components/search/CommandPalette.tsx | 5 +- .../components/sidebar/SessionItem.tsx | 10 +- .../store/slices/sessionDetailSlice.ts | 40 +++++-- src/renderer/store/slices/tabSlice.ts | 7 +- src/renderer/types/tabs.ts | 3 + src/renderer/utils/sessionTitle.ts | 15 +++ test/main/utils/jsonl.test.ts | 109 ++++++++++++++++++ .../renderer/store/sessionDetailSlice.test.ts | 82 +++++++++++++ test/renderer/utils/sessionTitle.test.ts | 23 ++++ 13 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 src/renderer/utils/sessionTitle.ts create mode 100644 test/renderer/store/sessionDetailSlice.test.ts create mode 100644 test/renderer/utils/sessionTitle.test.ts diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 32e4b551..3433ad70 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -762,6 +762,7 @@ export class ProjectScanner { createdAt: Math.floor(createdAt), updatedAt: Math.floor(effectiveMtime), firstMessage: metadata.firstUserMessage?.text, + customTitle: metadata.customTitle, messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents, messageCount: metadata.messageCount, @@ -833,6 +834,7 @@ export class ProjectScanner { createdAt: Math.floor(createdAt), updatedAt: Math.floor(effectiveMtime), firstMessage: metadata.firstUserMessage?.text, + customTitle: metadata.customTitle, messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents: false, messageCount: metadata.messageCount, diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index c50f1a44..b8965627 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -93,6 +93,8 @@ export interface Session { updatedAt?: number; /** First user message text (for preview) */ firstMessage?: string; + /** Session title: session name (agent-name/custom-title) or Claude-generated ai-title */ + 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 8c90181e..7838aab7 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -351,6 +351,8 @@ export interface SessionFileMetadata { /** Per-phase token breakdown */ phaseBreakdown?: PhaseTokenBreakdown[]; hasDisplayableContent: boolean; + /** Session title: last session name (agent-name/custom-title), else last ai-title */ + customTitle?: string; } /** @@ -384,6 +386,8 @@ export async function analyzeSessionFileMetadata( // After a UserGroup, await the first main-thread assistant message to count the AIGroup let awaitingAIGroup = false; let gitBranch: string | null = null; + let userTitle: string | undefined; + let aiTitle: string | undefined; let activityIndex = 0; let lastEndingIndex = -1; @@ -412,6 +416,22 @@ export async function analyzeSessionFileMetadata( continue; } + // Title entries are not chat entries (parseChatHistoryEntry ignores them); last one wins. + const record = entry as unknown as Record; + if (record.type === 'agent-name' || record.type === 'custom-title') { + const value = record.type === 'agent-name' ? record.agentName : record.customTitle; + if (typeof value === 'string' && value.trim()) { + userTitle = value.trim(); + } + continue; + } + if (record.type === 'ai-title') { + if (typeof record.aiTitle === 'string' && record.aiTitle.trim()) { + aiTitle = record.aiTitle.trim(); + } + continue; + } + const parsed = parseChatHistoryEntry(entry); if (!parsed) { continue; @@ -644,5 +664,6 @@ export async function analyzeSessionFileMetadata( compactionCount: compactionPhases.length > 0 ? compactionPhases.length : undefined, phaseBreakdown, hasDisplayableContent, + customTitle: userTitle ?? aiTitle, }; } diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index c18e9091..d38d4931 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -62,7 +62,7 @@ export const SortableTab = ({ const commitRename = useCallback(() => { const trimmed = renameInputRef.current?.value.trim() ?? ''; if (trimmed && trimmed !== tab.label) { - updateTabLabel(tab.id, trimmed); + updateTabLabel(tab.id, trimmed, { setByUser: true }); } onRenameComplete?.(); }, [tab.label, tab.id, updateTabLabel, onRenameComplete]); diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx index 3d2fd320..82c5e95e 100644 --- a/src/renderer/components/search/CommandPalette.tsx +++ b/src/renderer/components/search/CommandPalette.tsx @@ -12,6 +12,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { formatModifierShortcut } from '@renderer/utils/keyboardUtils'; +import { getSessionTitle } from '@renderer/utils/sessionTitle'; import { createLogger } from '@shared/utils/logger'; import { isSessionIdFragment, isUUID } from '@shared/utils/sessionIdValidator'; import { formatDistanceToNow } from 'date-fns'; @@ -726,7 +727,7 @@ export const CommandPalette = (): React.JSX.Element | null => { sessionIdMatch.projectId ?? 'Unknown' } - sessionTitle={sessionIdMatch.session.firstMessage ?? ''} + sessionTitle={getSessionTitle(sessionIdMatch.session) ?? ''} messageCount={sessionIdMatch.session.messageCount} createdAt={sessionIdMatch.session.createdAt} sessionId={query.trim()} @@ -747,7 +748,7 @@ export const CommandPalette = (): React.JSX.Element | null => { (null); + const sessionTitle = getSessionTitle(session); + const sessionLabel = sessionTitle?.slice(0, 50) ?? 'Session'; + const handleClick = (event: React.MouseEvent): void => { if (!activeProjectId) return; @@ -178,7 +182,7 @@ export const SessionItem = React.memo(function SessionItem({ type: 'session', sessionId: session.id, projectId: activeProjectId, - label: session.firstMessage?.slice(0, 50) ?? 'Session', + label: sessionLabel, }, forceNewTab ? { forceNewTab } : { replaceActiveTab: true } ); @@ -191,8 +195,6 @@ export const SessionItem = React.memo(function SessionItem({ setContextMenu({ x: e.clientX, y: e.clientY }); }, []); - const sessionLabel = session.firstMessage?.slice(0, 50) ?? 'Session'; - const handleOpenInCurrentPane = useCallback(() => { if (!activeProjectId) return; openTab( @@ -271,7 +273,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'} + {sessionTitle ?? 'Untitled'} diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index 4ca380de..33d4033c 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -4,7 +4,7 @@ import { api } from '@renderer/api'; import { asEnhancedChunkArray } from '@renderer/types/data'; -import { findTabBySession, truncateLabel } from '@renderer/types/tabs'; +import { truncateLabel } from '@renderer/types/tabs'; import { processSessionClaudeMd } from '@renderer/utils/claudeMdTracker'; import { processSessionContextWithPhases } from '@renderer/utils/contextTracker'; import { @@ -12,6 +12,7 @@ import { incrementalUpdateConversation, transformChunksToConversation, } from '@renderer/utils/groupTransformer'; +import { getSessionTitle } from '@renderer/utils/sessionTitle'; import { createLogger } from '@shared/utils/logger'; import { isSessionDetailUnchanged } from '@shared/utils/sessionDetailResponse'; @@ -52,7 +53,7 @@ import type { ContextStats, MentionedFileInfo, } from '@renderer/types/contextInjection'; -import type { ClaudeMdFileInfo, SessionDetail } from '@renderer/types/data'; +import type { ClaudeMdFileInfo, Session, SessionDetail } from '@renderer/types/data'; import type { AIGroup, SessionConversation } from '@renderer/types/groups'; import type { AgentConfig } from '@shared/types/api'; import type { StateCreator } from 'zustand'; @@ -89,6 +90,29 @@ function createEmptyTabSessionData(): TabSessionData { }; } +/** + * Apply the session's title as the label of every tab showing it, skipping + * tabs the user renamed by hand. + */ +function syncSessionTabLabels( + state: AppState, + sessionId: string, + session: Session | undefined +): void { + const title = session ? getSessionTitle(session) : undefined; + const label = title ? truncateLabel(title) : `Session ${sessionId.slice(0, 8)}`; + for (const tab of getAllTabs(state.paneLayout)) { + if ( + tab.type === 'session' && + tab.sessionId === sessionId && + !tab.labelSetByUser && + tab.label !== label + ) { + state.updateTabLabel(tab.id, label); + } + } +} + // ============================================================================= // Slice Interface // ============================================================================= @@ -262,12 +286,8 @@ export const createSessionDetailSlice: StateCreator void; - updateTabLabel: (tabId: string, label: string) => void; + updateTabLabel: (tabId: string, label: string, options?: { setByUser?: boolean }) => void; // Multi-select actions setSelectedTabIds: (ids: string[]) => void; @@ -426,12 +426,13 @@ export const createTabSlice: StateCreator = (set, ge set(syncFromLayout(newLayout)); }, - // Update a tab's label (used by sessionDetailSlice after fetching session data) - updateTabLabel: (tabId: string, label: string) => { + // Update a tab's label (session title sync, or a manual rename with setByUser) + updateTabLabel: (tabId: string, label: string, options?: { setByUser?: boolean }) => { const { paneLayout } = get(); const newLayout = updateTabInLayout(paneLayout, tabId, (tab) => ({ ...tab, label, + ...(options?.setByUser ? { labelSetByUser: true } : {}), })); set(syncFromLayout(newLayout)); }, diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts index 1cc9a3df..4a264ee7 100644 --- a/src/renderer/types/tabs.ts +++ b/src/renderer/types/tabs.ts @@ -89,6 +89,9 @@ export interface Tab { /** Display name for the tab (max 50 chars) */ label: string; + /** Whether the user renamed this tab; automatic session-title labels must not overwrite it */ + labelSetByUser?: boolean; + /** Unix timestamp when tab was opened */ createdAt: number; diff --git a/src/renderer/utils/sessionTitle.ts b/src/renderer/utils/sessionTitle.ts new file mode 100644 index 00000000..f4405d52 --- /dev/null +++ b/src/renderer/utils/sessionTitle.ts @@ -0,0 +1,15 @@ +/** + * Session title resolution for display (sidebar, tabs, command palette). + */ + +import type { Session } from '@renderer/types/data'; + +/** + * Returns the session's display title: custom title (user rename or + * Claude-generated) when non-empty, otherwise the first user message. + */ +export function getSessionTitle( + session: Pick +): string | undefined { + return session.customTitle?.trim() || session.firstMessage; +} diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index c7a1cf05..c6438edc 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -182,5 +182,114 @@ describe('jsonl', () => { } } }); + + describe('customTitle', () => { + const userLine = { + type: 'user', + uuid: 'u1', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'hello world' }, + isMeta: false, + }; + const assistantLine = { + type: 'assistant', + uuid: 'a1', + timestamp: '2026-01-01T00:00:01.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] }, + }; + + async function analyzeLines(lines: object[]) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-title-')); + try { + const filePath = path.join(tempDir, 'session.jsonl'); + const content = lines.map((line) => JSON.stringify(line)).join('\n'); + fs.writeFileSync(filePath, `${content}\n`, 'utf8'); + return await analyzeSessionFileMetadata(filePath); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + + it('should use agent-name as title', async () => { + const result = await analyzeLines([ + userLine, + { type: 'agent-name', agentName: 'my-rename', sessionId: 's1' }, + ]); + expect(result.customTitle).toBe('my-rename'); + }); + + it('should use custom-title as title', async () => { + const result = await analyzeLines([ + userLine, + { type: 'custom-title', customTitle: 'Legacy rename', sessionId: 's1' }, + ]); + expect(result.customTitle).toBe('Legacy rename'); + }); + + it('should use ai-title when no user title exists', async () => { + const result = await analyzeLines([ + userLine, + { type: 'ai-title', aiTitle: 'Generated title', sessionId: 's1' }, + ]); + expect(result.customTitle).toBe('Generated title'); + }); + + it('should prefer user title over ai-title regardless of line order', async () => { + const aiFirst = await analyzeLines([ + { type: 'ai-title', aiTitle: 'Generated title' }, + { type: 'agent-name', agentName: 'my-rename' }, + ]); + const aiLast = await analyzeLines([ + { type: 'agent-name', agentName: 'my-rename' }, + { type: 'ai-title', aiTitle: 'Generated title' }, + ]); + expect(aiFirst.customTitle).toBe('my-rename'); + expect(aiLast.customTitle).toBe('my-rename'); + }); + + it('should use the last of multiple renames', async () => { + const result = await analyzeLines([ + { type: 'agent-name', agentName: 'first' }, + { type: 'custom-title', customTitle: 'second' }, + { type: 'agent-name', agentName: 'third' }, + ]); + expect(result.customTitle).toBe('third'); + }); + + it('should ignore empty and whitespace-only titles', async () => { + const result = await analyzeLines([ + { type: 'ai-title', aiTitle: ' Generated title ' }, + { type: 'agent-name', agentName: 'my-rename' }, + { type: 'agent-name', agentName: ' ' }, + { type: 'custom-title', customTitle: '' }, + { type: 'ai-title', aiTitle: ' ' }, + ]); + expect(result.customTitle).toBe('my-rename'); + + const aiOnly = await analyzeLines([ + { type: 'ai-title', aiTitle: ' Generated title ' }, + { type: 'agent-name', agentName: ' ' }, + ]); + expect(aiOnly.customTitle).toBe('Generated title'); + }); + + it('should return undefined when there are no title lines', async () => { + const result = await analyzeLines([userLine, assistantLine]); + expect(result.customTitle).toBeUndefined(); + }); + + it('should not count title lines as messages', async () => { + const withoutTitles = await analyzeLines([userLine, assistantLine]); + const withTitles = await analyzeLines([ + userLine, + { type: 'ai-title', aiTitle: 'Generated title' }, + assistantLine, + { type: 'agent-name', agentName: 'my-rename' }, + ]); + expect(withTitles.messageCount).toBe(withoutTitles.messageCount); + expect(withTitles.messageCount).toBe(2); + expect(withTitles.firstUserMessage?.text).toBe('hello world'); + }); + }); }); }); diff --git a/test/renderer/store/sessionDetailSlice.test.ts b/test/renderer/store/sessionDetailSlice.test.ts new file mode 100644 index 00000000..1a7be543 --- /dev/null +++ b/test/renderer/store/sessionDetailSlice.test.ts @@ -0,0 +1,82 @@ +/** + * Session detail slice unit tests. + * Tests tab label sync from session titles. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { installMockElectronAPI, type MockElectronAPI } from '../../mocks/electronAPI'; + +import { createTestStore, type TestStore } from './storeTestUtils'; + +describe('sessionDetailSlice tab labels', () => { + let store: TestStore; + let mockAPI: MockElectronAPI; + + function detailWithTitle(sessionId: string, customTitle?: string) { + return { + session: { id: sessionId, firstMessage: 'first message', customTitle }, + chunks: [], + processes: [], + } as never; + } + + function openSessionTab(sessionId: string): string { + store.getState().openTab({ + type: 'session', + sessionId, + projectId: 'project-1', + label: 'first message', + }); + return store.getState().activeTabId!; + } + + function tabLabel(tabId: string): string | undefined { + return store.getState().openTabs.find((tab) => tab.id === tabId)?.label; + } + + beforeEach(() => { + mockAPI = installMockElectronAPI(); + store = createTestStore(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('labels the tab with the session title on fetch', async () => { + const tabId = openSessionTab('fetch-session'); + store.setState({ selectedSessionId: 'fetch-session' }); + mockAPI.getSessionDetail.mockResolvedValue(detailWithTitle('fetch-session', 'Renamed')); + + await store.getState().fetchSessionDetail('project-1', 'fetch-session', tabId); + + expect(tabLabel(tabId)).toBe('Renamed'); + }); + + it('updates the tab label on refresh even when chunks are unchanged', async () => { + const tabId = openSessionTab('refresh-session'); + mockAPI.getSessionDetail.mockResolvedValueOnce(detailWithTitle('refresh-session')); + await store.getState().refreshSessionInPlace('project-1', 'refresh-session'); + expect(tabLabel(tabId)).toBe('first message'); + + mockAPI.getSessionDetail.mockResolvedValueOnce( + detailWithTitle('refresh-session', 'Renamed later') + ); + await store.getState().refreshSessionInPlace('project-1', 'refresh-session'); + + expect(tabLabel(tabId)).toBe('Renamed later'); + }); + + it('keeps a label the user set by hand', async () => { + const tabId = openSessionTab('manual-session'); + store.getState().updateTabLabel(tabId, 'My label', { setByUser: true }); + mockAPI.getSessionDetail.mockResolvedValueOnce( + detailWithTitle('manual-session', 'Generated title') + ); + + await store.getState().refreshSessionInPlace('project-1', 'manual-session'); + + expect(tabLabel(tabId)).toBe('My label'); + }); +}); diff --git a/test/renderer/utils/sessionTitle.test.ts b/test/renderer/utils/sessionTitle.test.ts new file mode 100644 index 00000000..a20d7f78 --- /dev/null +++ b/test/renderer/utils/sessionTitle.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { getSessionTitle } from '@renderer/utils/sessionTitle'; + +describe('getSessionTitle', () => { + it('prefers the custom title over the first message', () => { + expect(getSessionTitle({ customTitle: 'Renamed', firstMessage: 'hello' })).toBe('Renamed'); + }); + + it('trims the custom title', () => { + expect(getSessionTitle({ customTitle: ' Renamed ', firstMessage: 'hello' })).toBe('Renamed'); + }); + + it('falls back to the first message when the custom title is empty or whitespace', () => { + expect(getSessionTitle({ customTitle: ' ', firstMessage: 'hello' })).toBe('hello'); + expect(getSessionTitle({ customTitle: '', firstMessage: 'hello' })).toBe('hello'); + expect(getSessionTitle({ firstMessage: 'hello' })).toBe('hello'); + }); + + it('returns undefined when neither is set', () => { + expect(getSessionTitle({})).toBeUndefined(); + }); +});