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
2 changes: 2 additions & 0 deletions src/main/services/discovery/ProjectScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/main/types/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
21 changes: 21 additions & 0 deletions src/main/utils/jsonl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>;
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;
Expand Down Expand Up @@ -644,5 +664,6 @@ export async function analyzeSessionFileMetadata(
compactionCount: compactionPhases.length > 0 ? compactionPhases.length : undefined,
phaseBreakdown,
hasDisplayableContent,
customTitle: userTitle ?? aiTitle,
};
}
2 changes: 1 addition & 1 deletion src/renderer/components/layout/SortableTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/components/search/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()}
Expand All @@ -747,7 +748,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
<SessionIdMatchItem
key={match.session.id}
projectName={projectNameByWorktreeId.get(match.projectId) ?? match.projectId}
sessionTitle={match.session.firstMessage ?? ''}
sessionTitle={getSessionTitle(match.session) ?? ''}
messageCount={match.session.messageCount}
createdAt={match.session.createdAt}
sessionId={match.session.id}
Expand Down
10 changes: 6 additions & 4 deletions src/renderer/components/sidebar/SessionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React, { useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

import { useStore } from '@renderer/store';
import { getSessionTitle } from '@renderer/utils/sessionTitle';
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
import { formatDistanceToNowStrict } from 'date-fns';
import { EyeOff, MessageSquare, Pin } from 'lucide-react';
Expand Down Expand Up @@ -161,6 +162,9 @@ export const SessionItem = React.memo(function SessionItem({

const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);

const sessionTitle = getSessionTitle(session);
const sessionLabel = sessionTitle?.slice(0, 50) ?? 'Session';

const handleClick = (event: React.MouseEvent): void => {
if (!activeProjectId) return;

Expand All @@ -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 }
);
Expand All @@ -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(
Expand Down Expand Up @@ -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'}
</span>
</div>

Expand Down
40 changes: 32 additions & 8 deletions src/renderer/store/slices/sessionDetailSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

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 {
extractFileReferences,
incrementalUpdateConversation,
transformChunksToConversation,
} from '@renderer/utils/groupTransformer';
import { getSessionTitle } from '@renderer/utils/sessionTitle';
import { createLogger } from '@shared/utils/logger';
import { isSessionDetailUnchanged } from '@shared/utils/sessionDetailResponse';

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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
// =============================================================================
Expand Down Expand Up @@ -262,12 +286,8 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
});
return;
}
const existingTab = findTabBySession(currentState.openTabs, sessionId);
if (existingTab && detail) {
const newLabel = detail.session.firstMessage
? truncateLabel(detail.session.firstMessage)
: `Session ${sessionId.slice(0, 8)}`;
currentState.updateTabLabel(existingTab.id, newLabel);
if (detail) {
syncSessionTabLabels(currentState, sessionId, detail.session);
}

// Phase 1 set: conversation renders immediately, stats are null (filled in Phase 2)
Expand Down Expand Up @@ -595,6 +615,10 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
sessionFileFingerprint.set(refreshKey, detail.fingerprint);
}

// Sync tab labels before the chunk-fingerprint early return: a session
// rename only appends a title entry, which doesn't change chunks.
syncSessionTabLabels(get(), sessionId, detail.session);

// Transform chunks to conversation - validate with type guard
const isOngoing = detail.session?.isOngoing ?? false;
const enhancedChunks = asEnhancedChunkArray(detail.chunks);
Expand Down
7 changes: 4 additions & 3 deletions src/renderer/store/slices/tabSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export interface TabSlice {

// Per-tab UI state actions
setTabContextPanelVisible: (tabId: string, visible: boolean) => void;
updateTabLabel: (tabId: string, label: string) => void;
updateTabLabel: (tabId: string, label: string, options?: { setByUser?: boolean }) => void;

// Multi-select actions
setSelectedTabIds: (ids: string[]) => void;
Expand Down Expand Up @@ -426,12 +426,13 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (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));
},
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/types/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
15 changes: 15 additions & 0 deletions src/renderer/utils/sessionTitle.ts
Original file line number Diff line number Diff line change
@@ -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<Session, 'customTitle' | 'firstMessage'>
): string | undefined {
return session.customTitle?.trim() || session.firstMessage;
}
Loading