Skip to content
Open
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
21 changes: 21 additions & 0 deletions src/main/services/discovery/ProjectScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from '@main/types';
import {
analyzeSessionFileMetadata,
extractCustomTitle,
extractCwd,
extractFirstUserMessagePreview,
} from '@main/utils/jsonl';
Expand Down Expand Up @@ -80,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;
Expand Down Expand Up @@ -767,6 +772,7 @@ export class ProjectScanner {
todoData,
createdAt: Math.floor(createdAt),
firstMessage: metadata.firstUserMessage?.text,
customTitle: metadata.customTitle,
messageTimestamp: metadata.firstUserMessage?.timestamp,
hasSubagents,
messageCount: metadata.messageCount,
Expand Down Expand Up @@ -819,12 +825,27 @@ export class ProjectScanner {
? previewTimestampMs
: birthtimeMs;

// Fast scan for /rename custom title (only parses lines containing "custom-title")
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,
projectId,
projectPath,
createdAt: Math.floor(createdAt),
firstMessage: preview?.text,
customTitle,
messageTimestamp: preview?.timestamp,
hasSubagents: false,
messageCount: 0,
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 @@ -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 */
Expand Down
12 changes: 11 additions & 1 deletion src/main/utils/jsonl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// =============================================================================
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>;
if (rawEntry.type === 'custom-title' && typeof rawEntry.customTitle === 'string') {
customTitle = rawEntry.customTitle;
}
Comment on lines +419 to +422
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This type assertion to Record<string, unknown> is necessary because the entry variable was optimistically cast to ChatHistoryEntry when parsed from JSON. However, a custom-title entry is not a valid ChatHistoryEntry, leading to this type-unsound workaround.

A more robust and type-safe approach would be to parse the JSON into a generic type (like unknown or Record<string, unknown>) and then use type guards to safely narrow it down to either a custom-title entry or a ChatHistoryEntry.

While a full fix would involve changing code outside of this diff, I recommend refactoring this in the future to improve type safety and avoid these kinds of workarounds.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Added mtime+size cache matching the existing sessionPreviewCache pattern. Fixed in 481c382.


const parsed = parseChatHistoryEntry(entry);
if (!parsed) {
continue;
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions src/main/utils/metadataExtraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,48 @@ function extractCommandName(content: string): string {
const commandMatch = /<command-name>\/([^<]+)<\/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<string | undefined> {
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<string, unknown>;
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;
}
75 changes: 75 additions & 0 deletions src/renderer/components/chat/SessionInfoBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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 <id>` command.
*/
export const SessionInfoBar: React.FC<SessionInfoBarProps> = ({ 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 (
<div
className="flex shrink-0 items-center gap-2 border-b px-3"
style={{
height: '28px',
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface-raised)',
}}
>
<Hash
className="size-3 shrink-0"
style={{ color: 'var(--color-text-muted)', opacity: 0.7 }}
/>
<span
className="select-all font-mono"
title={sessionId}
style={{
fontSize: '10px',
color: 'var(--color-text-muted)',
opacity: 0.8,
}}
>
{shortId}
</span>
<CopyButton text={sessionId} inline />
<div
className="mx-1 h-3 border-l"
style={{ borderColor: 'var(--color-border)' }}
/>
<button
onClick={() => {
navigator.clipboard.writeText(resumeCommand).catch(() => {
// Clipboard API may be unavailable in some environments
});
}}
className="flex items-center gap-1 rounded px-1 py-0.5 transition-colors hover:opacity-80"
title={resumeCommand}
style={{ color: 'var(--color-text-muted)' }}
>
<Terminal className="size-3" />
<span style={{ fontSize: '10px' }}>Resume</span>
</button>
</div>
);
};
2 changes: 2 additions & 0 deletions src/renderer/components/layout/MiddlePanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,6 +13,7 @@ export const MiddlePanel: React.FC<MiddlePanelProps> = ({ tabId }) => {
return (
<div className="relative flex h-full flex-col">
<SearchBar tabId={tabId} />
<SessionInfoBar tabId={tabId} />
<ChatHistory tabId={tabId} />
</div>
);
Expand Down
6 changes: 3 additions & 3 deletions src/renderer/components/sidebar/SessionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
Expand All @@ -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;
Expand Down Expand Up @@ -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'}
</span>
</div>

Expand Down
7 changes: 4 additions & 3 deletions src/renderer/store/slices/sessionDetailSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,10 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
}
const existingTab = findTabBySession(currentState.openTabs, sessionId);
if (existingTab && detail) {
const newLabel = detail.session.firstMessage
? truncateLabel(detail.session.firstMessage)
: `Session ${sessionId.slice(0, 8)}`;
const newLabel = detail.session.customTitle
?? (detail.session.firstMessage
? truncateLabel(detail.session.firstMessage)
: `Session ${sessionId.slice(0, 8)}`);
currentState.updateTabLabel(existingTab.id, newLabel);
}

Expand Down
Loading
Loading