feat(ui): build interactive session browser component (#13351)

This commit is contained in:
bl-ue
2025-11-21 09:16:56 -07:00
committed by GitHub
parent 3370644ffe
commit b97661553f
9 changed files with 1907 additions and 604 deletions

View File

@@ -0,0 +1,371 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { render } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import type { Config } from '@google/gemini-cli-core';
import { SessionBrowser } from './SessionBrowser.js';
import type { SessionBrowserProps } from './SessionBrowser.js';
import type { SessionInfo } from '../../utils/sessionUtils.js';
// Collect key handlers registered via useKeypress so tests can
// simulate input without going through the full stdin pipeline.
const keypressHandlers: Array<(key: unknown) => void> = [];
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: () => ({ columns: 80, rows: 24 }),
}));
vi.mock('../hooks/useKeypress.js', () => ({
// The real hook subscribes to the KeypressContext. Here we just
// capture the handler so tests can call it directly.
useKeypress: (
handler: (key: unknown) => void,
options: { isActive: boolean },
) => {
if (options?.isActive) {
keypressHandlers.push(handler);
}
},
}));
// Mock the component itself to bypass async loading
vi.mock('./SessionBrowser.js', async (importOriginal) => {
const original = await importOriginal<typeof import('./SessionBrowser.js')>();
const React = await import('react');
const TestSessionBrowser = (
props: SessionBrowserProps & {
testSessions?: SessionInfo[];
testError?: string | null;
},
) => {
const state = original.useSessionBrowserState(
props.testSessions || [],
false, // Not loading
props.testError || null,
);
const moveSelection = original.useMoveSelection(state);
const cycleSortOrder = original.useCycleSortOrder(state);
original.useSessionBrowserInput(
state,
moveSelection,
cycleSortOrder,
props.onResumeSession,
props.onDeleteSession,
props.onExit,
);
return React.createElement(original.SessionBrowserView, { state });
};
return {
...original,
SessionBrowser: TestSessionBrowser,
};
});
// Cast SessionBrowser to a type that includes the test-only props so TypeScript doesn't complain
const TestSessionBrowser = SessionBrowser as unknown as React.FC<
SessionBrowserProps & {
testSessions?: SessionInfo[];
testError?: string | null;
}
>;
const createMockConfig = (overrides: Partial<Config> = {}): Config =>
({
storage: {
getProjectTempDir: () => '/tmp/test',
},
getSessionId: () => 'default-session-id',
...overrides,
}) as Config;
const triggerKey = (
partialKey: Partial<{
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
insertable: boolean;
sequence: string;
}>,
) => {
const handler = keypressHandlers[keypressHandlers.length - 1];
if (!handler) {
throw new Error('No keypress handler registered');
}
const key = {
name: '',
ctrl: false,
meta: false,
shift: false,
paste: false,
insertable: false,
sequence: '',
...partialKey,
};
act(() => {
handler(key);
});
};
const createSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
id: 'session-id',
file: 'session-id',
fileName: 'session-id.json',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messageCount: 1,
displayName: 'Test Session',
firstUserMessage: 'Test Session',
isCurrentSession: false,
index: 0,
...overrides,
});
describe('SessionBrowser component', () => {
beforeEach(() => {
keypressHandlers.length = 0;
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('shows empty state when no sessions exist', () => {
const config = createMockConfig();
const onResumeSession = vi.fn();
const onExit = vi.fn();
const { lastFrame } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onExit={onExit}
testSessions={[]}
/>,
);
expect(lastFrame()).toContain('No auto-saved conversations found.');
expect(lastFrame()).toContain('Press q to exit');
});
it('renders a list of sessions and marks current session as disabled', () => {
const session1 = createSession({
id: 'abc123',
file: 'abc123',
displayName: 'First conversation about cats',
lastUpdated: '2025-01-01T10:05:00Z',
messageCount: 2,
index: 0,
});
const session2 = createSession({
id: 'def456',
file: 'def456',
displayName: 'Second conversation about dogs',
lastUpdated: '2025-01-01T11:30:00Z',
messageCount: 5,
isCurrentSession: true,
index: 1,
});
const config = createMockConfig();
const onResumeSession = vi.fn();
const onExit = vi.fn();
const { lastFrame } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onExit={onExit}
testSessions={[session1, session2]}
/>,
);
const output = lastFrame();
expect(output).toContain('Chat Sessions (2 total');
expect(output).toContain('First conversation about cats');
expect(output).toContain('Second conversation about dogs');
expect(output).toContain('(current)');
});
it('enters search mode, filters sessions, and renders match snippets', async () => {
const searchSession = createSession({
id: 'search1',
file: 'search1',
displayName: 'Query is here and another query.',
firstUserMessage: 'Query is here and another query.',
fullContent: 'Query is here and another query.',
messages: [
{
role: 'user',
content: 'Query is here and another query.',
},
],
index: 0,
});
const otherSession = createSession({
id: 'other',
file: 'other',
displayName: 'Nothing interesting here.',
firstUserMessage: 'Nothing interesting here.',
fullContent: 'Nothing interesting here.',
messages: [
{
role: 'user',
content: 'Nothing interesting here.',
},
],
index: 1,
});
const config = createMockConfig();
const onResumeSession = vi.fn();
const onExit = vi.fn();
const { lastFrame } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onExit={onExit}
testSessions={[searchSession, otherSession]}
/>,
);
expect(lastFrame()).toContain('Chat Sessions (2 total');
// Enter search mode.
triggerKey({ sequence: '/', name: '/' });
await waitFor(() => {
expect(lastFrame()).toContain('Search:');
});
// Type the query "query".
for (const ch of ['q', 'u', 'e', 'r', 'y']) {
triggerKey({ sequence: ch, name: ch, ctrl: false, meta: false });
}
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Chat Sessions (1 total, filtered');
expect(output).toContain('Query is here');
expect(output).not.toContain('Nothing interesting here.');
expect(output).toContain('You:');
expect(output).toContain('query');
expect(output).toContain('(+1 more)');
});
});
it('handles keyboard navigation and resumes the selected session', () => {
const session1 = createSession({
id: 'one',
file: 'one',
displayName: 'First session',
index: 0,
});
const session2 = createSession({
id: 'two',
file: 'two',
displayName: 'Second session',
index: 1,
});
const config = createMockConfig();
const onResumeSession = vi.fn();
const onExit = vi.fn();
const { lastFrame } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onExit={onExit}
testSessions={[session1, session2]}
/>,
);
expect(lastFrame()).toContain('Chat Sessions (2 total');
// Move selection down.
triggerKey({ name: 'down', sequence: '[B' });
// Press Enter.
triggerKey({ name: 'return', sequence: '\r' });
expect(onResumeSession).toHaveBeenCalledTimes(1);
const [resumedSession] = onResumeSession.mock.calls[0];
expect(resumedSession).toEqual(session2);
});
it('does not allow resuming or deleting the current session', () => {
const currentSession = createSession({
id: 'current',
file: 'current',
displayName: 'Current session',
isCurrentSession: true,
index: 0,
});
const otherSession = createSession({
id: 'other',
file: 'other',
displayName: 'Other session',
isCurrentSession: false,
index: 1,
});
const config = createMockConfig();
const onResumeSession = vi.fn();
const onDeleteSession = vi.fn();
const onExit = vi.fn();
render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onDeleteSession={onDeleteSession}
onExit={onExit}
testSessions={[currentSession, otherSession]}
/>,
);
// Active selection is at 0 (current session).
triggerKey({ name: 'return', sequence: '\r' });
expect(onResumeSession).not.toHaveBeenCalled();
// Attempt delete.
triggerKey({ sequence: 'x', name: 'x' });
expect(onDeleteSession).not.toHaveBeenCalled();
});
it('shows an error state when loading sessions fails', () => {
const config = createMockConfig();
const onResumeSession = vi.fn();
const onExit = vi.fn();
const { lastFrame } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onExit={onExit}
testError="storage failure"
/>,
);
const output = lastFrame();
expect(output).toContain('Error: storage failure');
expect(output).toContain('Press q to exit');
});
});

View File

@@ -0,0 +1,933 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path';
import type { Config } from '@google/gemini-cli-core';
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
import {
cleanMessage,
formatRelativeTime,
getSessionFiles,
} from '../../utils/sessionUtils.js';
/**
* Props for the main SessionBrowser component.
*/
export interface SessionBrowserProps {
/** Application configuration object */
config: Config;
/** Callback when user selects a session to resume */
onResumeSession: (session: SessionInfo) => void;
/** Callback when user deletes a session */
onDeleteSession?: (session: SessionInfo) => void;
/** Callback when user exits the session browser */
onExit: () => void;
}
/**
* Centralized state interface for SessionBrowser component.
* Eliminates prop drilling by providing all state in a single object.
*/
export interface SessionBrowserState {
// Data state
/** All loaded sessions */
sessions: SessionInfo[];
/** Sessions after filtering and sorting */
filteredAndSortedSessions: SessionInfo[];
// UI state
/** Whether sessions are currently loading */
loading: boolean;
/** Error message if loading failed */
error: string | null;
/** Index of currently selected session */
activeIndex: number;
/** Current scroll offset for pagination */
scrollOffset: number;
/** Terminal width for layout calculations */
terminalWidth: number;
// Search state
/** Current search query string */
searchQuery: string;
/** Whether user is in search input mode */
isSearchMode: boolean;
/** Whether full content has been loaded for search */
hasLoadedFullContent: boolean;
// Sort state
/** Current sort criteria */
sortOrder: 'date' | 'messages' | 'name';
/** Whether sort order is reversed */
sortReverse: boolean;
// Computed values
/** Total number of filtered sessions */
totalSessions: number;
/** Start index for current page */
startIndex: number;
/** End index for current page */
endIndex: number;
/** Sessions visible on current page */
visibleSessions: SessionInfo[];
// State setters
/** Update sessions array */
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
/** Update loading state */
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
/** Update error state */
setError: React.Dispatch<React.SetStateAction<string | null>>;
/** Update active session index */
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
/** Update scroll offset */
setScrollOffset: React.Dispatch<React.SetStateAction<number>>;
/** Update search query */
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
/** Update search mode state */
setIsSearchMode: React.Dispatch<React.SetStateAction<boolean>>;
/** Update sort order */
setSortOrder: React.Dispatch<
React.SetStateAction<'date' | 'messages' | 'name'>
>;
/** Update sort reverse flag */
setSortReverse: React.Dispatch<React.SetStateAction<boolean>>;
setHasLoadedFullContent: React.Dispatch<React.SetStateAction<boolean>>;
}
const SESSIONS_PER_PAGE = 20;
// Approximate total width reserved for non-message columns and separators
// (prefix, index, message count, age, pipes, and padding) in a session row.
// If the SessionItem layout changes, update this accordingly.
const FIXED_SESSION_COLUMNS_WIDTH = 30;
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
<>
{name}: <Text bold>{shortcut}</Text>
</>
);
/**
* Loading state component displayed while sessions are being loaded.
*/
const SessionBrowserLoading = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>Loading sessions</Text>
</Box>
);
/**
* Error state component displayed when session loading fails.
*/
const SessionBrowserError = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
/**
* Empty state component displayed when no sessions are found.
*/
const SessionBrowserEmpty = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
/**
* Sorts an array of sessions by the specified criteria.
* @param sessions - Array of sessions to sort
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
* @returns New sorted array of sessions
*/
const sortSessions = (
sessions: SessionInfo[],
sortBy: 'date' | 'messages' | 'name',
reverse: boolean,
): SessionInfo[] => {
const sorted = [...sessions].sort((a, b) => {
switch (sortBy) {
case 'date':
return (
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
);
case 'messages':
return b.messageCount - a.messageCount;
case 'name':
return a.displayName.localeCompare(b.displayName);
default:
return 0;
}
});
return reverse ? sorted.reverse() : sorted;
};
/**
* Finds all text matches for a search query within conversation messages.
* Creates TextMatch objects with context (10 chars before/after) and role information.
* @param messages - Array of messages to search through
* @param query - Search query string (case-insensitive)
* @returns Array of TextMatch objects containing match context and metadata
*/
const findTextMatches = (
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
query: string,
): TextMatch[] => {
if (!query.trim()) return [];
const lowerQuery = query.toLowerCase();
const matches: TextMatch[] = [];
for (const message of messages) {
const m = cleanMessage(message.content);
const lowerContent = m.toLowerCase();
let startIndex = 0;
while (true) {
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
if (matchIndex === -1) break;
const contextStart = Math.max(0, matchIndex - 10);
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
const snippet = m.slice(contextStart, contextEnd);
const relativeMatchStart = matchIndex - contextStart;
const relativeMatchEnd = relativeMatchStart + query.length;
let before = snippet.slice(0, relativeMatchStart);
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
let after = snippet.slice(relativeMatchEnd);
if (contextStart > 0) before = '…' + before;
if (contextEnd < m.length) after = after + '…';
matches.push({ before, match, after, role: message.role });
startIndex = matchIndex + 1;
}
}
return matches;
};
/**
* Filters sessions based on a search query, checking titles, IDs, and full content.
* Also populates matchSnippets and matchCount for sessions with content matches.
* @param sessions - Array of sessions to filter
* @param query - Search query string (case-insensitive)
* @returns Filtered array of sessions that match the query
*/
const filterSessions = (
sessions: SessionInfo[],
query: string,
): SessionInfo[] => {
if (!query.trim()) {
return sessions.map((session) => ({
...session,
matchSnippets: undefined,
matchCount: undefined,
}));
}
const lowerQuery = query.toLowerCase();
return sessions.filter((session) => {
const titleMatch =
session.displayName.toLowerCase().includes(lowerQuery) ||
session.id.toLowerCase().includes(lowerQuery) ||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
const contentMatch = session.fullContent
?.toLowerCase()
.includes(lowerQuery);
if (titleMatch || contentMatch) {
if (session.messages) {
session.matchSnippets = findTextMatches(session.messages, query);
session.matchCount = session.matchSnippets.length;
}
return true;
}
return false;
});
};
/**
* Search input display component.
*/
const SearchModeDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray}>Search: </Text>
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
<Text color={Colors.Gray}> (Esc to cancel)</Text>
</Box>
);
/**
* Header component showing session count and sort information.
*/
const SessionListHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" justifyContent="space-between">
<Text color={Colors.AccentPurple}>
Chat Sessions ({state.totalSessions} total
{state.searchQuery ? `, filtered` : ''})
</Text>
<Text color={Colors.Gray}>
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
</Text>
</Box>
);
/**
* Navigation help component showing keyboard shortcuts.
*/
const NavigationHelp = (): React.JSX.Element => (
<Box flexDirection="column">
<Text color={Colors.Gray}>
<Kbd name="Navigate" shortcut="↑/↓" />
{' '}
<Kbd name="Resume" shortcut="Enter" />
{' '}
<Kbd name="Search" shortcut="/" />
{' '}
<Kbd name="Delete" shortcut="x" />
{' '}
<Kbd name="Quit" shortcut="q" />
</Text>
<Text color={Colors.Gray}>
<Kbd name="Sort" shortcut="s" />
{' '}
<Kbd name="Reverse" shortcut="r" />
{' '}
<Kbd name="First/Last" shortcut="g/G" />
</Text>
</Box>
);
/**
* Table header component with column labels and scroll indicators.
*/
const SessionTableHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" marginTop={1}>
<Text>{state.scrollOffset > 0 ? <Text> </Text> : ' '}</Text>
<Box width={5} flexShrink={0}>
<Text color={Colors.Gray} bold>
Index
</Text>
</Box>
<Text color={Colors.Gray}> </Text>
<Box width={4} flexShrink={0}>
<Text color={Colors.Gray} bold>
Msgs
</Text>
</Box>
<Text color={Colors.Gray}> </Text>
<Box width={4} flexShrink={0}>
<Text color={Colors.Gray} bold>
Age
</Text>
</Box>
<Text color={Colors.Gray}> </Text>
<Box flexShrink={0}>
<Text color={Colors.Gray} bold>
{state.searchQuery ? 'Match' : 'Name'}
</Text>
</Box>
</Box>
);
/**
* No results display component for empty search results.
*/
const NoResultsDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray} dimColor>
No sessions found matching &apos;{state.searchQuery}&apos;.
</Text>
</Box>
);
/**
* Match snippet display component for search results.
*/
const MatchSnippetDisplay = ({
session,
textColor,
}: {
session: SessionInfo;
textColor: (color?: string) => string;
}): React.JSX.Element | null => {
if (!session.matchSnippets || session.matchSnippets.length === 0) {
return null;
}
const firstMatch = session.matchSnippets[0];
const rolePrefix = firstMatch.role === 'user' ? 'You: ' : 'Gemini:';
const roleColor = textColor(
firstMatch.role === 'user' ? Colors.AccentGreen : Colors.AccentBlue,
);
return (
<>
<Text color={roleColor} bold>
{rolePrefix}{' '}
</Text>
{firstMatch.before}
<Text color={textColor(Colors.AccentRed)} bold>
{firstMatch.match}
</Text>
{firstMatch.after}
</>
);
};
/**
* Individual session row component.
*/
const SessionItem = ({
session,
state,
terminalWidth,
formatRelativeTime,
}: {
session: SessionInfo;
state: SessionBrowserState;
terminalWidth: number;
formatRelativeTime: (dateString: string, style: 'short' | 'long') => string;
}): React.JSX.Element => {
const originalIndex =
state.startIndex + state.visibleSessions.indexOf(session);
const isActive = originalIndex === state.activeIndex;
const isDisabled = session.isCurrentSession;
const textColor = (c: string = Colors.Foreground) => {
if (isDisabled) {
return Colors.Gray;
}
return isActive ? Colors.AccentPurple : c;
};
const prefix = isActive ? ' ' : ' ';
let additionalInfo = '';
let matchDisplay = null;
// Add "(current)" label for the current session
if (session.isCurrentSession) {
additionalInfo = ' (current)';
}
// Show match snippets if searching and matches exist
if (
state.searchQuery &&
session.matchSnippets &&
session.matchSnippets.length > 0
) {
matchDisplay = (
<MatchSnippetDisplay session={session} textColor={textColor} />
);
if (session.matchCount && session.matchCount > 1) {
additionalInfo += ` (+${session.matchCount - 1} more)`;
}
}
const availableMessageWidth = Math.max(
20,
terminalWidth - FIXED_SESSION_COLUMNS_WIDTH,
);
const truncatedMessage =
matchDisplay ||
(session.displayName.length === 0 ? (
<Text color={textColor(Colors.Gray)} dimColor>
(No messages)
</Text>
) : session.displayName.length > availableMessageWidth ? (
session.displayName.slice(0, availableMessageWidth - 1) + '…'
) : (
session.displayName
));
return (
<Box flexDirection="row">
<Text color={textColor()} dimColor={isDisabled}>
{prefix}
</Text>
<Box width={5}>
<Text color={textColor()} dimColor={isDisabled}>
#{originalIndex + 1}
</Text>
</Box>
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
{' '}
{' '}
</Text>
<Box width={4}>
<Text color={textColor()} dimColor={isDisabled}>
{session.messageCount}
</Text>
</Box>
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
{' '}
{' '}
</Text>
<Box width={4}>
<Text color={textColor()} dimColor={isDisabled}>
{formatRelativeTime(session.lastUpdated, 'short')}
</Text>
</Box>
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
{' '}
{' '}
</Text>
<Box flexGrow={1}>
<Text color={textColor(Colors.Comment)} dimColor={isDisabled}>
{truncatedMessage}
{additionalInfo && (
<Text color={textColor(Colors.Gray)} dimColor bold={false}>
{additionalInfo}
</Text>
)}
</Text>
</Box>
</Box>
);
};
/**
* Session list container component.
*/
const SessionList = ({
state,
formatRelativeTime,
}: {
state: SessionBrowserState;
formatRelativeTime: (dateString: string, style: 'short' | 'long') => string;
}): React.JSX.Element => (
<Box flexDirection="column">
{/* Table Header */}
<Box flexDirection="column">
{!state.isSearchMode && <NavigationHelp />}
<SessionTableHeader state={state} />
</Box>
{state.visibleSessions.map((session) => (
<SessionItem
key={session.id}
session={session}
state={state}
terminalWidth={state.terminalWidth}
formatRelativeTime={formatRelativeTime}
/>
))}
<Text color={Colors.Gray}>
{state.endIndex < state.totalSessions ? <></> : <Text dimColor></Text>}
</Text>
</Box>
);
/**
* Hook to manage all SessionBrowser state.
*/
export const useSessionBrowserState = (
initialSessions: SessionInfo[] = [],
initialLoading = true,
initialError: string | null = null,
): SessionBrowserState => {
const { columns: terminalWidth } = useTerminalSize();
const [sessions, setSessions] = useState<SessionInfo[]>(initialSessions);
const [loading, setLoading] = useState(initialLoading);
const [error, setError] = useState<string | null>(initialError);
const [activeIndex, setActiveIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [sortOrder, setSortOrder] = useState<'date' | 'messages' | 'name'>(
'date',
);
const [sortReverse, setSortReverse] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [isSearchMode, setIsSearchMode] = useState(false);
const [hasLoadedFullContent, setHasLoadedFullContent] = useState(false);
const loadingFullContentRef = useRef(false);
const filteredAndSortedSessions = useMemo(() => {
const filtered = filterSessions(sessions, searchQuery);
return sortSessions(filtered, sortOrder, sortReverse);
}, [sessions, searchQuery, sortOrder, sortReverse]);
// Reset full content flag when search is cleared
useEffect(() => {
if (!searchQuery) {
setHasLoadedFullContent(false);
loadingFullContentRef.current = false;
}
}, [searchQuery]);
const totalSessions = filteredAndSortedSessions.length;
const startIndex = scrollOffset;
const endIndex = Math.min(scrollOffset + SESSIONS_PER_PAGE, totalSessions);
const visibleSessions = filteredAndSortedSessions.slice(startIndex, endIndex);
const state: SessionBrowserState = {
sessions,
setSessions,
loading,
setLoading,
error,
setError,
activeIndex,
setActiveIndex,
scrollOffset,
setScrollOffset,
searchQuery,
setSearchQuery,
isSearchMode,
setIsSearchMode,
hasLoadedFullContent,
setHasLoadedFullContent,
sortOrder,
setSortOrder,
sortReverse,
setSortReverse,
terminalWidth,
filteredAndSortedSessions,
totalSessions,
startIndex,
endIndex,
visibleSessions,
};
return state;
};
/**
* Hook to load sessions on mount.
*/
const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const {
setSessions,
setLoading,
setError,
isSearchMode,
hasLoadedFullContent,
setHasLoadedFullContent,
} = state;
useEffect(() => {
const loadSessions = async () => {
try {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
const sessionData = await getSessionFiles(
chatsDir,
config.getSessionId(),
);
setSessions(sessionData);
setLoading(false);
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to load sessions',
);
setLoading(false);
}
};
loadSessions();
}, [config, setSessions, setLoading, setError]);
useEffect(() => {
const loadFullContent = async () => {
if (isSearchMode && !hasLoadedFullContent) {
try {
const chatsDir = path.join(
config.storage.getProjectTempDir(),
'chats',
);
const sessionData = await getSessionFiles(
chatsDir,
config.getSessionId(),
{ includeFullContent: true },
);
setSessions(sessionData);
setHasLoadedFullContent(true);
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to load full session content',
);
}
}
};
loadFullContent();
}, [
isSearchMode,
hasLoadedFullContent,
config,
setSessions,
setHasLoadedFullContent,
setError,
]);
};
/**
* Hook to handle selection movement.
*/
export const useMoveSelection = (state: SessionBrowserState) => {
const {
totalSessions,
activeIndex,
scrollOffset,
setActiveIndex,
setScrollOffset,
} = state;
return useCallback(
(delta: number) => {
const newIndex = Math.max(
0,
Math.min(totalSessions - 1, activeIndex + delta),
);
setActiveIndex(newIndex);
// Adjust scroll offset if needed
if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
} else if (newIndex >= scrollOffset + SESSIONS_PER_PAGE) {
setScrollOffset(newIndex - SESSIONS_PER_PAGE + 1);
}
},
[totalSessions, activeIndex, scrollOffset, setActiveIndex, setScrollOffset],
);
};
/**
* Hook to handle sort order cycling.
*/
export const useCycleSortOrder = (state: SessionBrowserState) => {
const { sortOrder, setSortOrder } = state;
return useCallback(() => {
const orders: Array<'date' | 'messages' | 'name'> = [
'date',
'messages',
'name',
];
const currentIndex = orders.indexOf(sortOrder);
const nextIndex = (currentIndex + 1) % orders.length;
setSortOrder(orders[nextIndex]);
}, [sortOrder, setSortOrder]);
};
/**
* Hook to handle SessionBrowser input.
*/
export const useSessionBrowserInput = (
state: SessionBrowserState,
moveSelection: (delta: number) => void,
cycleSortOrder: () => void,
onResumeSession: (session: SessionInfo) => void,
onDeleteSession: ((session: SessionInfo) => void) | undefined,
onExit: () => void,
) => {
useKeypress(
(key) => {
if (state.isSearchMode) {
// Search-specific input handling. Only control/symbols here.
if (key.name === 'escape') {
state.setIsSearchMode(false);
state.setSearchQuery('');
state.setActiveIndex(0);
state.setScrollOffset(0);
} else if (key.name === 'backspace') {
state.setSearchQuery((prev) => prev.slice(0, -1));
state.setActiveIndex(0);
state.setScrollOffset(0);
} else if (
key.sequence &&
!key.ctrl &&
!key.meta &&
key.sequence.length === 1
) {
state.setSearchQuery((prev) => prev + key.sequence);
state.setActiveIndex(0);
state.setScrollOffset(0);
}
} else {
// Navigation mode input handling. We're keeping the letter-based controls for non-search
// mode only, because the letters need to act as input for the search.
if (key.sequence === 'g') {
state.setActiveIndex(0);
state.setScrollOffset(0);
} else if (key.sequence === 'G') {
state.setActiveIndex(state.totalSessions - 1);
state.setScrollOffset(
Math.max(0, state.totalSessions - SESSIONS_PER_PAGE),
);
}
// Sorting controls.
else if (key.sequence === 's') {
cycleSortOrder();
} else if (key.sequence === 'r') {
state.setSortReverse(!state.sortReverse);
}
// Searching and exit controls.
else if (key.sequence === '/') {
state.setIsSearchMode(true);
} else if (
key.sequence === 'q' ||
key.sequence === 'Q' ||
key.name === 'escape'
) {
onExit();
}
// Delete session control.
else if (key.sequence === 'x' || key.sequence === 'X') {
const selectedSession =
state.filteredAndSortedSessions[state.activeIndex];
if (
selectedSession &&
!selectedSession.isCurrentSession &&
onDeleteSession
) {
try {
onDeleteSession(selectedSession);
// Remove the session from the state
state.setSessions(
state.sessions.filter((s) => s.id !== selectedSession.id),
);
// Adjust active index if needed
if (
state.activeIndex >=
state.filteredAndSortedSessions.length - 1
) {
state.setActiveIndex(
Math.max(0, state.filteredAndSortedSessions.length - 2),
);
}
} catch (error) {
state.setError(
`Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
}
// less-like u/d controls.
else if (key.sequence === 'd') {
moveSelection(-Math.round(SESSIONS_PER_PAGE / 2));
} else if (key.sequence === 'u') {
moveSelection(Math.round(SESSIONS_PER_PAGE / 2));
}
}
// Handling regardless of search mode.
if (
key.name === 'return' &&
state.filteredAndSortedSessions[state.activeIndex]
) {
const selectedSession =
state.filteredAndSortedSessions[state.activeIndex];
// Don't allow resuming the current session
if (!selectedSession.isCurrentSession) {
onResumeSession(selectedSession);
}
} else if (key.name === 'up') {
moveSelection(-1);
} else if (key.name === 'down') {
moveSelection(1);
} else if (key.name === 'pageup') {
moveSelection(-SESSIONS_PER_PAGE);
} else if (key.name === 'pagedown') {
moveSelection(SESSIONS_PER_PAGE);
}
},
{ isActive: true },
);
};
export function SessionBrowserView({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element {
if (state.loading) {
return <SessionBrowserLoading />;
}
if (state.error) {
return <SessionBrowserError state={state} />;
}
if (state.sessions.length === 0) {
return <SessionBrowserEmpty />;
}
return (
<Box flexDirection="column" paddingX={1}>
<SessionListHeader state={state} />
{state.isSearchMode && <SearchModeDisplay state={state} />}
{state.totalSessions === 0 ? (
<NoResultsDisplay state={state} />
) : (
<SessionList state={state} formatRelativeTime={formatRelativeTime} />
)}
</Box>
);
}
export function SessionBrowser({
config,
onResumeSession,
onDeleteSession,
onExit,
}: SessionBrowserProps): React.JSX.Element {
// Use all our custom hooks
const state = useSessionBrowserState();
useLoadSessions(config, state);
const moveSelection = useMoveSelection(state);
const cycleSortOrder = useCycleSortOrder(state);
useSessionBrowserInput(
state,
moveSelection,
cycleSortOrder,
onResumeSession,
onDeleteSession,
onExit,
);
return <SessionBrowserView state={state} />;
}

View File

@@ -4,44 +4,165 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
import { MessageType, ToolCallStatus } from '../types.js';
import type { MessageRecord } from '@google/gemini-cli-core';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { act } from 'react';
import {
useSessionBrowser,
convertSessionToHistoryFormats,
} from './useSessionBrowser.js';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js';
import type {
Config,
ConversationRecord,
MessageRecord,
} from '@google/gemini-cli-core';
// Mock modules
vi.mock('fs/promises');
vi.mock('path');
vi.mock('../../utils/sessionUtils.js');
const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp';
const MOCKED_CHATS_DIR = '/test/project/temp/chats';
const MOCKED_SESSION_ID = 'test-session-123';
const MOCKED_CURRENT_SESSION_ID = 'current-session-id';
describe('useSessionBrowser', () => {
const mockedFs = vi.mocked(fs);
const mockedPath = vi.mocked(path);
const mockedGetSessionFiles = vi.mocked(getSessionFiles);
const mockConfig = {
storage: {
getProjectTempDir: vi.fn(),
},
setSessionId: vi.fn(),
getSessionId: vi.fn(),
getGeminiClient: vi.fn().mockReturnValue({
getChatRecordingService: vi.fn().mockReturnValue({
deleteSession: vi.fn(),
}),
}),
} as unknown as Config;
const mockOnLoadHistory = vi.fn();
beforeEach(() => {
vi.resetAllMocks();
mockedPath.join.mockImplementation((...args) => args.join('/'));
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
MOCKED_PROJECT_TEMP_DIR,
);
vi.mocked(mockConfig.getSessionId).mockReturnValue(
MOCKED_CURRENT_SESSION_ID,
);
});
it('should successfully resume a session', async () => {
const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json';
const mockConversation: ConversationRecord = {
sessionId: 'existing-session-456',
messages: [{ type: 'user', content: 'Hello' } as MessageRecord],
} as ConversationRecord;
const mockSession = {
id: MOCKED_SESSION_ID,
fileName: MOCKED_FILENAME,
} as SessionInfo;
mockedGetSessionFiles.mockResolvedValue([mockSession]);
mockedFs.readFile.mockResolvedValue(JSON.stringify(mockConversation));
const { result } = renderHook(() =>
useSessionBrowser(mockConfig, mockOnLoadHistory),
);
await act(async () => {
await result.current.handleResumeSession(mockSession);
});
expect(mockedFs.readFile).toHaveBeenCalledWith(
`${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`,
'utf8',
);
expect(mockConfig.setSessionId).toHaveBeenCalledWith(
'existing-session-456',
);
expect(result.current.isSessionBrowserOpen).toBe(false);
expect(mockOnLoadHistory).toHaveBeenCalled();
});
it('should handle file read error', async () => {
const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json';
const mockSession = {
id: MOCKED_SESSION_ID,
fileName: MOCKED_FILENAME,
} as SessionInfo;
mockedFs.readFile.mockRejectedValue(new Error('File not found'));
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() =>
useSessionBrowser(mockConfig, mockOnLoadHistory),
);
await act(async () => {
await result.current.handleResumeSession(mockSession);
});
expect(consoleErrorSpy).toHaveBeenCalled();
expect(result.current.isSessionBrowserOpen).toBe(false);
consoleErrorSpy.mockRestore();
});
it('should handle JSON parse error', async () => {
const MOCKED_FILENAME = 'invalid.json';
const mockSession = {
id: MOCKED_SESSION_ID,
fileName: MOCKED_FILENAME,
} as SessionInfo;
mockedFs.readFile.mockResolvedValue('invalid json');
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() =>
useSessionBrowser(mockConfig, mockOnLoadHistory),
);
await act(async () => {
await result.current.handleResumeSession(mockSession);
});
expect(consoleErrorSpy).toHaveBeenCalled();
expect(result.current.isSessionBrowserOpen).toBe(false);
consoleErrorSpy.mockRestore();
});
});
// The convertSessionToHistoryFormats tests are self-contained and do not need changes.
describe('convertSessionToHistoryFormats', () => {
it('should convert empty messages array', () => {
const result = convertSessionToHistoryFormats([]);
expect(result.uiHistory).toEqual([]);
expect(result.clientHistory).toEqual([]);
});
it('should convert basic user and gemini messages', () => {
it('should convert basic user and model messages', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'Hello',
type: 'user',
},
{
id: 'msg-2',
timestamp: '2025-01-01T00:02:00Z',
content: 'Hi there!',
type: 'gemini',
},
{ type: 'user', content: 'Hello' } as MessageRecord,
{ type: 'gemini', content: 'Hi there' } as MessageRecord,
];
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory).toHaveLength(2);
expect(result.uiHistory[0]).toEqual({
type: MessageType.USER,
text: 'Hello',
});
expect(result.uiHistory[1]).toEqual({
type: MessageType.GEMINI,
text: 'Hi there!',
expect(result.uiHistory[0]).toMatchObject({ type: 'user', text: 'Hello' });
expect(result.uiHistory[1]).toMatchObject({
type: 'gemini',
text: 'Hi there',
});
expect(result.clientHistory).toHaveLength(2);
@@ -51,582 +172,92 @@ describe('convertSessionToHistoryFormats', () => {
});
expect(result.clientHistory[1]).toEqual({
role: 'model',
parts: [{ text: 'Hi there!' }],
parts: [{ text: 'Hi there' }],
});
});
it('should convert system, warning, and error messages to appropriate types', () => {
it('should filter out slash commands from client history but keep in UI', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'System message',
type: 'info',
},
{
id: 'msg-2',
timestamp: '2025-01-01T00:02:00Z',
content: 'Warning message',
type: 'warning',
},
{
id: 'msg-3',
timestamp: '2025-01-01T00:03:00Z',
content: 'Error occurred',
type: 'error',
},
];
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory[0]).toEqual({
type: MessageType.INFO,
text: 'System message',
});
expect(result.uiHistory[1]).toEqual({
type: MessageType.WARNING,
text: 'Warning message',
});
expect(result.uiHistory[2]).toEqual({
type: MessageType.ERROR,
text: 'Error occurred',
});
// System, warning, and error messages should not be included in client history
expect(result.clientHistory).toEqual([]);
});
it('should filter out slash commands from client history', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: '/help',
type: 'user',
},
{
id: 'msg-2',
timestamp: '2025-01-01T00:02:00Z',
content: '?quit',
type: 'user',
},
{
id: 'msg-3',
timestamp: '2025-01-01T00:03:00Z',
content: 'Regular message',
type: 'user',
},
];
const result = convertSessionToHistoryFormats(messages);
// All messages should appear in UI history
expect(result.uiHistory).toHaveLength(3);
// Only non-slash commands should appear in client history
expect(result.clientHistory).toHaveLength(1);
expect(result.clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Regular message' }],
});
});
it('should handle tool calls correctly', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: "I'll help you with that.",
type: 'gemini',
toolCalls: [
{
id: 'tool-1',
name: 'bash',
displayName: 'Execute Command',
description: 'Run bash command',
args: { command: 'ls -la' },
status: 'success',
timestamp: '2025-01-01T00:01:30Z',
resultDisplay: 'total 4\ndrwxr-xr-x 2 user user 4096 Jan 1 00:00 .',
renderOutputAsMarkdown: false,
},
{
id: 'tool-2',
name: 'read',
displayName: 'Read File',
description: 'Read file contents',
args: { path: '/etc/hosts' },
status: 'error',
timestamp: '2025-01-01T00:01:45Z',
resultDisplay: 'Permission denied',
},
],
},
];
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory).toHaveLength(2); // text message + tool group
expect(result.uiHistory[0]).toEqual({
type: MessageType.GEMINI,
text: "I'll help you with that.",
});
expect(result.uiHistory[1].type).toBe('tool_group');
// This if-statement is only necessary because TypeScript can't tell that the toBe() assertion
// protects the .tools access below.
if (result.uiHistory[1].type === 'tool_group') {
expect(result.uiHistory[1].tools).toHaveLength(2);
expect(result.uiHistory[1].tools[0]).toEqual({
callId: 'tool-1',
name: 'Execute Command',
description: 'Run bash command',
renderOutputAsMarkdown: false,
status: ToolCallStatus.Success,
resultDisplay: 'total 4\ndrwxr-xr-x 2 user user 4096 Jan 1 00:00 .',
confirmationDetails: undefined,
});
expect(result.uiHistory[1].tools[1]).toEqual({
callId: 'tool-2',
name: 'Read File',
description: 'Read file contents',
renderOutputAsMarkdown: true, // default value
status: ToolCallStatus.Error,
resultDisplay: 'Permission denied',
confirmationDetails: undefined,
});
}
});
it('should skip empty tool calls arrays', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'Message with empty tools',
type: 'gemini',
toolCalls: [],
},
];
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory).toHaveLength(1); // Only text message
expect(result.uiHistory[0]).toEqual({
type: MessageType.GEMINI,
text: 'Message with empty tools',
});
});
it('should not add tool calls for user messages', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'User message',
type: 'user',
// This would be invalid in real usage, but testing robustness
toolCalls: [
{
id: 'tool-1',
name: 'invalid',
args: {},
status: 'success',
timestamp: '2025-01-01T00:01:30Z',
},
],
} as MessageRecord,
];
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory).toHaveLength(1); // Only user message, no tool group
expect(result.uiHistory[0]).toEqual({
type: MessageType.USER,
text: 'User message',
});
});
it('should handle missing tool call fields gracefully', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'Message with minimal tool',
type: 'gemini',
toolCalls: [
{
id: 'tool-1',
name: 'minimal_tool',
args: {},
status: 'success',
timestamp: '2025-01-01T00:01:30Z',
// Missing optional fields
},
],
},
{ type: 'user', content: '/help' } as MessageRecord,
{ type: 'info', content: 'Help text' } as MessageRecord,
];
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory).toHaveLength(2);
expect(result.uiHistory[1].type).toBe('tool_group');
if (result.uiHistory[1].type === 'tool_group') {
expect(result.uiHistory[1].tools[0]).toEqual({
callId: 'tool-1',
name: 'minimal_tool', // Falls back to name when displayName missing
description: '', // Default empty string
renderOutputAsMarkdown: true, // Default value
status: ToolCallStatus.Success,
resultDisplay: undefined,
confirmationDetails: undefined,
});
} else {
throw new Error('unreachable');
}
expect(result.uiHistory[0]).toMatchObject({ type: 'user', text: '/help' });
expect(result.uiHistory[1]).toMatchObject({
type: 'info',
text: 'Help text',
});
expect(result.clientHistory).toHaveLength(0);
});
describe('tool calls in client history', () => {
it('should convert tool calls to correct Gemini client history format', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'List files',
type: 'user',
},
{
id: 'msg-2',
timestamp: '2025-01-01T00:02:00Z',
content: "I'll list the files for you.",
type: 'gemini',
toolCalls: [
{
id: 'tool-1',
name: 'list_directory',
args: { path: '/home/user' },
result: {
functionResponse: {
id: 'list_directory-1753650620141-f3b8b9e73919d',
name: 'list_directory',
response: {
output: 'file1.txt\nfile2.txt',
},
},
},
status: 'success',
timestamp: '2025-01-01T00:02:30Z',
},
],
},
];
const result = convertSessionToHistoryFormats(messages);
// Should have: user message, model with function call, user with function response
expect(result.clientHistory).toHaveLength(3);
// User message
expect(result.clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'List files' }],
});
// Model message with function call
expect(result.clientHistory[1]).toEqual({
role: 'model',
parts: [
{ text: "I'll list the files for you." },
it('should handle tool calls and responses', () => {
const messages: MessageRecord[] = [
{ type: 'user', content: 'What time is it?' } as MessageRecord,
{
type: 'gemini',
content: '',
toolCalls: [
{
functionCall: {
name: 'list_directory',
args: { path: '/home/user' },
id: 'tool-1',
},
id: 'call_1',
name: 'get_time',
args: {},
status: 'success',
result: '12:00',
},
],
});
} as unknown as MessageRecord,
];
// Function response
expect(result.clientHistory[2]).toEqual({
role: 'user',
parts: [
{
functionResponse: {
id: 'list_directory-1753650620141-f3b8b9e73919d',
name: 'list_directory',
response: { output: 'file1.txt\nfile2.txt' },
},
},
],
});
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory).toHaveLength(2);
expect(result.uiHistory[0]).toMatchObject({
type: 'user',
text: 'What time is it?',
});
expect(result.uiHistory[1]).toMatchObject({
type: 'tool_group',
tools: [
expect.objectContaining({
callId: 'call_1',
name: 'get_time',
status: 'Success',
}),
],
});
it('should handle tool calls without text content', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: '',
type: 'gemini',
toolCalls: [
{
id: 'tool-1',
name: 'bash',
args: { command: 'ls' },
result: 'file1.txt\nfile2.txt',
status: 'success',
timestamp: '2025-01-01T00:01:30Z',
},
],
},
];
const result = convertSessionToHistoryFormats(messages);
expect(result.clientHistory).toHaveLength(2);
// Model message with only function call (no text)
expect(result.clientHistory[0]).toEqual({
role: 'model',
parts: [
{
functionCall: {
name: 'bash',
args: { command: 'ls' },
id: 'tool-1',
},
},
],
});
// Function response
expect(result.clientHistory[1]).toEqual({
role: 'user',
parts: [
{
functionResponse: {
id: 'tool-1',
name: 'bash',
response: {
output: 'file1.txt\nfile2.txt',
},
},
},
],
});
expect(result.clientHistory).toHaveLength(3); // User, Model (call), User (response)
expect(result.clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'What time is it?' }],
});
it('should handle multiple tool calls in one message', () => {
const messages: MessageRecord[] = [
expect(result.clientHistory[1]).toEqual({
role: 'model',
parts: [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'Running multiple commands',
type: 'gemini',
toolCalls: [
{
id: 'tool-1',
name: 'bash',
args: { command: 'pwd' },
result: '/home/user',
status: 'success',
timestamp: '2025-01-01T00:01:30Z',
},
{
id: 'tool-2',
name: 'bash',
args: { command: 'ls' },
result: [
{
functionResponse: {
id: 'tool-2',
name: 'bash',
response: {
output: 'file1.txt',
},
},
},
{
functionResponse: {
id: 'tool-2',
name: 'bash',
response: {
output: 'file2.txt',
},
},
},
],
status: 'success',
timestamp: '2025-01-01T00:01:35Z',
},
],
functionCall: {
name: 'get_time',
args: {},
id: 'call_1',
},
},
];
const result = convertSessionToHistoryFormats(messages);
// Should have: model with both function calls, then one response
expect(result.clientHistory).toHaveLength(2);
// Model message with both function calls
expect(result.clientHistory[0]).toEqual({
role: 'model',
parts: [
{ text: 'Running multiple commands' },
{
functionCall: {
name: 'bash',
args: { command: 'pwd' },
id: 'tool-1',
},
},
{
functionCall: {
name: 'bash',
args: { command: 'ls' },
id: 'tool-2',
},
},
],
});
// First function response
expect(result.clientHistory[1]).toEqual({
role: 'user',
parts: [
{
functionResponse: {
id: 'tool-1',
name: 'bash',
response: { output: '/home/user' },
},
},
{
functionResponse: {
id: 'tool-2',
name: 'bash',
response: { output: 'file1.txt' },
},
},
{
functionResponse: {
id: 'tool-2',
name: 'bash',
response: { output: 'file2.txt' },
},
},
],
});
],
});
it('should handle Part array results from tools', () => {
const messages: MessageRecord[] = [
expect(result.clientHistory[2]).toEqual({
role: 'user',
parts: [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'Reading file',
type: 'gemini',
toolCalls: [
{
id: 'tool-1',
name: 'read_file',
args: { path: 'test.txt' },
result: [
{
functionResponse: {
id: 'tool-1',
name: 'read_file',
response: {
output: 'Hello',
},
},
},
{
functionResponse: {
id: 'tool-1',
name: 'read_file',
response: {
output: ' World',
},
},
},
],
status: 'success',
timestamp: '2025-01-01T00:01:30Z',
},
],
functionResponse: {
id: 'call_1',
name: 'get_time',
response: { output: '12:00' },
},
},
];
const result = convertSessionToHistoryFormats(messages);
expect(result.clientHistory).toHaveLength(2);
// Function response should extract both function responses
expect(result.clientHistory[1]).toEqual({
role: 'user',
parts: [
{
functionResponse: {
id: 'tool-1',
name: 'read_file',
response: {
output: 'Hello',
},
},
},
{
functionResponse: {
id: 'tool-1',
name: 'read_file',
response: {
output: ' World',
},
},
},
],
});
});
it('should skip tool calls without results', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'Testing tool',
type: 'gemini',
toolCalls: [
{
id: 'tool-1',
name: 'test_tool',
args: { arg: 'value' },
// No result field
status: 'error',
timestamp: '2025-01-01T00:01:30Z',
},
],
},
];
const result = convertSessionToHistoryFormats(messages);
// Should only have the model message with function call, no function response
expect(result.clientHistory).toHaveLength(1);
expect(result.clientHistory[0]).toEqual({
role: 'model',
parts: [
{ text: 'Testing tool' },
{
functionCall: {
name: 'test_tool',
args: { arg: 'value' },
id: 'tool-1',
},
},
],
});
],
});
});
});

View File

@@ -4,11 +4,110 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import type { HistoryItemWithoutId } from '../types.js';
import type { ConversationRecord } from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import type {
Config,
ConversationRecord,
ResumedSessionData,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { partListUnionToString } from '@google/gemini-cli-core';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import { MessageType, ToolCallStatus } from '../types.js';
export const useSessionBrowser = (
config: Config,
onLoadHistory: (
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
resumedSessionData: ResumedSessionData,
) => void,
) => {
const [isSessionBrowserOpen, setIsSessionBrowserOpen] = useState(false);
return {
isSessionBrowserOpen,
openSessionBrowser: useCallback(() => {
setIsSessionBrowserOpen(true);
}, []),
closeSessionBrowser: useCallback(() => {
setIsSessionBrowserOpen(false);
}, []),
/**
* Loads a conversation by ID, and reinitializes the chat recording service with it.
*/
handleResumeSession: useCallback(
async (session: SessionInfo) => {
try {
const chatsDir = path.join(
config.storage.getProjectTempDir(),
'chats',
);
const fileName = session.fileName;
const originalFilePath = path.join(chatsDir, fileName);
// Load up the conversation.
const conversation: ConversationRecord = JSON.parse(
await fs.readFile(originalFilePath, 'utf8'),
);
// Use the old session's ID to continue it.
const existingSessionId = conversation.sessionId;
config.setSessionId(existingSessionId);
const resumedSessionData = {
conversation,
filePath: originalFilePath,
};
// We've loaded it; tell the UI about it.
setIsSessionBrowserOpen(false);
const historyData = convertSessionToHistoryFormats(
conversation.messages,
);
onLoadHistory(
historyData.uiHistory,
historyData.clientHistory,
resumedSessionData,
);
} catch (error) {
console.error('Error resuming session:', error);
setIsSessionBrowserOpen(false);
}
},
[config, onLoadHistory],
),
/**
* Deletes a session by ID using the ChatRecordingService.
*/
handleDeleteSession: useCallback(
(session: SessionInfo) => {
try {
const chatRecordingService = config
.getGeminiClient()
?.getChatRecordingService();
if (chatRecordingService) {
chatRecordingService.deleteSession(session.id);
}
} catch (error) {
console.error('Error deleting session:', error);
throw error;
}
},
[config],
),
};
};
/**
* Converts session/conversation data into UI history and Gemini client history formats.
*/

View File

@@ -52,6 +52,8 @@ function createTestSessions(): SessionInfo[] {
fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`,
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Current session',
firstUserMessage: 'Current session',
isCurrentSession: true,
index: 1,
@@ -62,6 +64,8 @@ function createTestSessions(): SessionInfo[] {
fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`,
startTime: oneWeekAgo.toISOString(),
lastUpdated: oneWeekAgo.toISOString(),
messageCount: 10,
displayName: 'Recent session',
firstUserMessage: 'Recent session',
isCurrentSession: false,
index: 2,
@@ -72,6 +76,8 @@ function createTestSessions(): SessionInfo[] {
fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`,
startTime: twoWeeksAgo.toISOString(),
lastUpdated: twoWeeksAgo.toISOString(),
messageCount: 3,
displayName: 'Old session',
firstUserMessage: 'Old session',
isCurrentSession: false,
index: 3,
@@ -82,6 +88,8 @@ function createTestSessions(): SessionInfo[] {
fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`,
startTime: oneMonthAgo.toISOString(),
lastUpdated: oneMonthAgo.toISOString(),
messageCount: 15,
displayName: 'Ancient session',
firstUserMessage: 'Ancient session',
isCurrentSession: false,
index: 4,
@@ -435,6 +443,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}current.json`,
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 1,
displayName: 'Current',
firstUserMessage: 'Current',
isCurrentSession: true,
index: 1,
@@ -445,6 +455,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}5d.json`,
startTime: fiveDaysAgo.toISOString(),
lastUpdated: fiveDaysAgo.toISOString(),
messageCount: 1,
displayName: '5 days old',
firstUserMessage: '5 days',
isCurrentSession: false,
index: 2,
@@ -455,6 +467,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}8d.json`,
startTime: eightDaysAgo.toISOString(),
lastUpdated: eightDaysAgo.toISOString(),
messageCount: 1,
displayName: '8 days old',
firstUserMessage: '8 days',
isCurrentSession: false,
index: 3,
@@ -465,6 +479,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}15d.json`,
startTime: fifteenDaysAgo.toISOString(),
lastUpdated: fifteenDaysAgo.toISOString(),
messageCount: 1,
displayName: '15 days old',
firstUserMessage: '15 days',
isCurrentSession: false,
index: 4,
@@ -549,6 +565,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}current.json`,
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 1,
displayName: 'Current',
firstUserMessage: 'Current',
isCurrentSession: true,
index: 1,
@@ -559,6 +577,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}1d.json`,
startTime: oneDayAgo.toISOString(),
lastUpdated: oneDayAgo.toISOString(),
messageCount: 1,
displayName: '1 day old',
firstUserMessage: '1 day',
isCurrentSession: false,
index: 2,
@@ -569,6 +589,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}7d.json`,
startTime: sevenDaysAgo.toISOString(),
lastUpdated: sevenDaysAgo.toISOString(),
messageCount: 1,
displayName: '7 days old',
firstUserMessage: '7 days',
isCurrentSession: false,
index: 3,
@@ -579,6 +601,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}13d.json`,
startTime: thirteenDaysAgo.toISOString(),
lastUpdated: thirteenDaysAgo.toISOString(),
messageCount: 1,
displayName: '13 days old',
firstUserMessage: '13 days',
isCurrentSession: false,
index: 4,
@@ -637,6 +661,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}current.json`,
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 1,
displayName: 'Current (newest)',
firstUserMessage: 'Current',
isCurrentSession: true,
index: 1,
@@ -652,6 +678,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}${i}d.json`,
startTime: daysAgo.toISOString(),
lastUpdated: daysAgo.toISOString(),
messageCount: 1,
displayName: `${i} days old`,
firstUserMessage: `${i} days`,
isCurrentSession: false,
index: i + 1,
@@ -759,6 +787,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}current.json`,
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 1,
displayName: 'Current',
firstUserMessage: 'Current',
isCurrentSession: true,
index: 1,
@@ -769,6 +799,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}3d.json`,
startTime: threeDaysAgo.toISOString(),
lastUpdated: threeDaysAgo.toISOString(),
messageCount: 1,
displayName: '3 days old',
firstUserMessage: '3 days',
isCurrentSession: false,
index: 2,
@@ -779,6 +811,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}5d.json`,
startTime: fiveDaysAgo.toISOString(),
lastUpdated: fiveDaysAgo.toISOString(),
messageCount: 1,
displayName: '5 days old',
firstUserMessage: '5 days',
isCurrentSession: false,
index: 3,
@@ -789,6 +823,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}7d.json`,
startTime: sevenDaysAgo.toISOString(),
lastUpdated: sevenDaysAgo.toISOString(),
messageCount: 1,
displayName: '7 days old',
firstUserMessage: '7 days',
isCurrentSession: false,
index: 4,
@@ -799,6 +835,8 @@ describe('Session Cleanup', () => {
fileName: `${SESSION_FILE_PREFIX}12d.json`,
startTime: twelveDaysAgo.toISOString(),
lastUpdated: twelveDaysAgo.toISOString(),
messageCount: 1,
displayName: '12 days old',
firstUserMessage: '12 days',
isCurrentSession: false,
index: 5,

View File

@@ -234,6 +234,70 @@ describe('SessionSelector', () => {
expect(result.sessionData.messages[0].content).toBe('Latest session');
});
it('should deduplicate sessions by ID', async () => {
const sessionId = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const sessionOriginal = {
sessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:30:00.000Z',
messages: [
{
type: 'user',
content: 'Original',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
],
};
const sessionDuplicate = {
sessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T11:00:00.000Z', // Newer
messages: [
{
type: 'user',
content: 'Newer Duplicate',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
],
};
// File 1
await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
),
JSON.stringify(sessionOriginal, null, 2),
);
// File 2 (Simulate a copy or newer version with same ID)
await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId.slice(0, 8)}.json`,
),
JSON.stringify(sessionDuplicate, null, 2),
);
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(sessionId);
// Should keep the one with later lastUpdated
expect(sessions[0].lastUpdated).toBe('2024-01-01T11:00:00.000Z');
});
it('should throw error for invalid session identifier', async () => {
const sessionId1 = randomUUID();
@@ -296,7 +360,7 @@ describe('extractFirstUserMessage', () => {
expect(extractFirstUserMessage(messages)).toBe('Hello world');
});
it('should truncate long messages', () => {
it('should not truncate long messages', () => {
const longMessage = 'a'.repeat(150);
const messages = [
{
@@ -308,8 +372,7 @@ describe('extractFirstUserMessage', () => {
] as MessageRecord[];
const result = extractFirstUserMessage(messages);
expect(result).toBe('a'.repeat(97) + '...');
expect(result.length).toBe(100);
expect(result).toBe(longMessage);
});
it('should return "Empty conversation" for no user messages', () => {

View File

@@ -10,8 +10,8 @@ import type {
MessageRecord,
} from '@google/gemini-cli-core';
import {
SESSION_FILE_PREFIX,
partListUnionToString,
SESSION_FILE_PREFIX,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
@@ -22,6 +22,20 @@ import path from 'node:path';
*/
export const RESUME_LATEST = 'latest';
/**
* Represents a text match found during search with surrounding context.
*/
export interface TextMatch {
/** Text content before the match (with ellipsis if truncated) */
before: string;
/** The exact matched text */
match: string;
/** Text content after the match (with ellipsis if truncated) */
after: string;
/** Role of the message author where the match was found */
role: 'user' | 'assistant';
}
/**
* Session information for display and selection purposes.
*/
@@ -34,14 +48,26 @@ export interface SessionInfo {
fileName: string;
/** ISO timestamp when session started */
startTime: string;
/** Total number of messages in the session */
messageCount: number;
/** ISO timestamp when session was last updated */
lastUpdated: string;
/** Display name for the session (typically first user message) */
displayName: string;
/** Cleaned first user message content */
firstUserMessage: string;
/** Whether this is the currently active session */
isCurrentSession: boolean;
/** Display index in the list */
index: number;
/** Full concatenated content (only loaded when needed for search) */
fullContent?: string;
/** Processed messages with normalized roles (only loaded when needed) */
messages?: Array<{ role: 'user' | 'assistant'; content: string }>;
/** Search result snippets when filtering */
matchSnippets?: TextMatch[];
/** Total number of matches found in this session */
matchCount?: number;
}
/**
@@ -60,30 +86,64 @@ export interface SessionFileEntry {
export interface SessionSelectionResult {
sessionPath: string;
sessionData: ConversationRecord;
displayInfo: string;
}
/**
* Cleans and sanitizes message content for display by:
* - Converting newlines to spaces
* - Collapsing multiple whitespace to single spaces
* - Removing non-printable characters (keeping only ASCII 32-126)
* - Trimming leading/trailing whitespace
* @param message - The raw message content to clean
* @returns Sanitized message suitable for display
*/
export const cleanMessage = (message: string): string =>
message
.replace(/\n+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/[^\x20-\x7E]+/g, '') // Non-printable.
.trim();
/**
* Extracts the first meaningful user message from conversation messages.
*/
export const extractFirstUserMessage = (messages: MessageRecord[]): string => {
const userMessage = messages.find((msg) => {
const content = partListUnionToString(msg.content);
return msg.type === 'user' && content?.trim() && content !== '/resume';
});
const userMessage = messages
// First try filtering out slash commands.
.filter((msg) => {
const content = partListUnionToString(msg.content);
return (
!content.startsWith('/') &&
!content.startsWith('?') &&
content.trim().length > 0
);
})
.find((msg) => msg.type === 'user');
let content: string;
if (!userMessage) {
return 'Empty conversation';
// Fallback to first user message even if it's a slash command
const firstMsg = messages.find((msg) => msg.type === 'user');
if (!firstMsg) return 'Empty conversation';
content = cleanMessage(partListUnionToString(firstMsg.content));
} else {
content = cleanMessage(partListUnionToString(userMessage.content));
}
// Truncate long messages for display
const content = partListUnionToString(userMessage.content).trim();
return content.length > 100 ? content.slice(0, 97) + '...' : content;
return content;
};
/**
* Formats a timestamp as relative time (e.g., "2 hours ago", "3 days ago").
* Formats a timestamp as relative time.
* @param timestamp - The timestamp to format
* @param style - 'long' (e.g. "2 hours ago") or 'short' (e.g. "2h")
*/
export const formatRelativeTime = (timestamp: string): string => {
export const formatRelativeTime = (
timestamp: string,
style: 'long' | 'short' = 'long',
): string => {
const now = new Date();
const time = new Date(timestamp);
const diffMs = now.getTime() - time.getTime();
@@ -92,17 +152,34 @@ export const formatRelativeTime = (timestamp: string): string => {
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
} else if (diffHours > 0) {
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
} else if (diffMinutes > 0) {
return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
if (style === 'short') {
if (diffSeconds < 1) return 'now';
if (diffSeconds < 60) return `${diffSeconds}s`;
if (diffMinutes < 60) return `${diffMinutes}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 30) return `${diffDays}d`;
const diffMonths = Math.floor(diffDays / 30);
return diffMonths < 12
? `${diffMonths}mo`
: `${Math.floor(diffMonths / 12)}y`;
} else {
return 'Just now';
if (diffDays > 0) {
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
} else if (diffHours > 0) {
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
} else if (diffMinutes > 0) {
return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
} else {
return 'Just now';
}
}
};
export interface GetSessionOptions {
/** Whether to load full message content (needed for search) */
includeFullContent?: boolean;
}
/**
* Loads all session files (including corrupted ones) from the chats directory.
* @returns Array of session file entries, with sessionInfo null for corrupted files
@@ -110,6 +187,7 @@ export const formatRelativeTime = (timestamp: string): string => {
export const getAllSessionFiles = async (
chatsDir: string,
currentSessionId?: string,
options: GetSessionOptions = {},
): Promise<SessionFileEntry[]> => {
try {
const files = await fs.readdir(chatsDir);
@@ -142,15 +220,37 @@ export const getAllSessionFiles = async (
? file.includes(currentSessionId.slice(0, 8))
: false;
let fullContent: string | undefined;
let messages:
| Array<{ role: 'user' | 'assistant'; content: string }>
| undefined;
if (options.includeFullContent) {
fullContent = content.messages
.map((msg) => partListUnionToString(msg.content))
.join(' ');
messages = content.messages.map((msg) => ({
role:
msg.type === 'user'
? ('user' as const)
: ('assistant' as const),
content: partListUnionToString(msg.content),
}));
}
const sessionInfo: SessionInfo = {
id: content.sessionId,
file: file.replace('.json', ''),
fileName: file,
startTime: content.startTime,
lastUpdated: content.lastUpdated,
messageCount: content.messages.length,
displayName: firstUserMessage,
firstUserMessage,
isCurrentSession,
index: 0, // Will be set after sorting valid sessions
fullContent,
messages,
};
return { fileName: file, sessionInfo };
@@ -179,8 +279,13 @@ export const getAllSessionFiles = async (
export const getSessionFiles = async (
chatsDir: string,
currentSessionId?: string,
options: GetSessionOptions = {},
): Promise<SessionInfo[]> => {
const allFiles = await getAllSessionFiles(chatsDir, currentSessionId);
const allFiles = await getAllSessionFiles(
chatsDir,
currentSessionId,
options,
);
// Filter out corrupted files and extract SessionInfo
const validSessions = allFiles
@@ -190,17 +295,31 @@ export const getSessionFiles = async (
)
.map((entry) => entry.sessionInfo);
// Deduplicate sessions by ID
const uniqueSessionsMap = new Map<string, SessionInfo>();
for (const session of validSessions) {
// If duplicate exists, keep the one with the later lastUpdated timestamp
if (
!uniqueSessionsMap.has(session.id) ||
new Date(session.lastUpdated).getTime() >
new Date(uniqueSessionsMap.get(session.id)!.lastUpdated).getTime()
) {
uniqueSessionsMap.set(session.id, session);
}
}
const uniqueSessions = Array.from(uniqueSessionsMap.values());
// Sort by startTime (oldest first) for stable session numbering
validSessions.sort(
uniqueSessions.sort(
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
);
// Set the correct 1-based indexes after sorting
validSessions.forEach((session, index) => {
uniqueSessions.forEach((session, index) => {
session.index = index + 1;
});
return validSessions;
return uniqueSessions;
};
/**
@@ -318,9 +437,12 @@ export class SessionSelector {
await fs.readFile(sessionPath, 'utf8'),
);
const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`;
return {
sessionPath,
sessionData,
displayInfo,
};
} catch (error) {
throw new Error(

View File

@@ -85,6 +85,8 @@ describe('listSessions', () => {
fileName: 'session-2025-01-18T12-00-00-session-1.json',
startTime: twoDaysAgo.toISOString(),
lastUpdated: twoDaysAgo.toISOString(),
messageCount: 5,
displayName: 'First user message',
firstUserMessage: 'First user message',
isCurrentSession: false,
index: 1,
@@ -95,6 +97,8 @@ describe('listSessions', () => {
fileName: 'session-2025-01-20T11-00-00-session-2.json',
startTime: oneHourAgo.toISOString(),
lastUpdated: oneHourAgo.toISOString(),
messageCount: 10,
displayName: 'Second user message',
firstUserMessage: 'Second user message',
isCurrentSession: false,
index: 2,
@@ -105,6 +109,8 @@ describe('listSessions', () => {
fileName: 'session-2025-01-20T12-00-00-current-s.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 3,
displayName: 'Current session',
firstUserMessage: 'Current session',
isCurrentSession: true,
index: 3,
@@ -163,6 +169,8 @@ describe('listSessions', () => {
fileName: 'session-2.json',
startTime: session2Time.toISOString(), // Middle
lastUpdated: session2Time.toISOString(),
messageCount: 5,
displayName: 'Middle session',
firstUserMessage: 'Middle session',
isCurrentSession: false,
index: 2,
@@ -173,6 +181,8 @@ describe('listSessions', () => {
fileName: 'session-1.json',
startTime: session1Time.toISOString(), // Oldest
lastUpdated: session1Time.toISOString(),
messageCount: 5,
displayName: 'Oldest session',
firstUserMessage: 'Oldest session',
isCurrentSession: false,
index: 1,
@@ -183,6 +193,8 @@ describe('listSessions', () => {
fileName: 'session-3.json',
startTime: session3Time.toISOString(), // Newest
lastUpdated: session3Time.toISOString(),
messageCount: 5,
displayName: 'Newest session',
firstUserMessage: 'Newest session',
isCurrentSession: false,
index: 3,
@@ -219,6 +231,8 @@ describe('listSessions', () => {
fileName: 'session-file.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Test message',
firstUserMessage: 'Test message',
isCurrentSession: false,
index: 1,
@@ -252,6 +266,8 @@ describe('listSessions', () => {
fileName: 'session-file.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Only session',
firstUserMessage: 'Only session',
isCurrentSession: true,
index: 1,
@@ -348,6 +364,8 @@ describe('deleteSession', () => {
fileName: 'session-file-123.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Test session',
firstUserMessage: 'Test session',
isCurrentSession: false,
index: 1,
@@ -381,6 +399,8 @@ describe('deleteSession', () => {
fileName: 'session-file-1.json',
startTime: oneHourAgo.toISOString(),
lastUpdated: oneHourAgo.toISOString(),
messageCount: 5,
displayName: 'First session',
firstUserMessage: 'First session',
isCurrentSession: false,
index: 1,
@@ -391,6 +411,8 @@ describe('deleteSession', () => {
fileName: 'session-file-2.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 10,
displayName: 'Second session',
firstUserMessage: 'Second session',
isCurrentSession: false,
index: 2,
@@ -421,6 +443,8 @@ describe('deleteSession', () => {
fileName: 'session-file-1.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Test session',
firstUserMessage: 'Test session',
isCurrentSession: false,
index: 1,
@@ -449,6 +473,8 @@ describe('deleteSession', () => {
fileName: 'session-file-1.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Test session',
firstUserMessage: 'Test session',
isCurrentSession: false,
index: 1,
@@ -477,6 +503,8 @@ describe('deleteSession', () => {
fileName: 'session-file-1.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Test session',
firstUserMessage: 'Test session',
isCurrentSession: false,
index: 1,
@@ -505,6 +533,8 @@ describe('deleteSession', () => {
fileName: 'current-session-file.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Current session',
firstUserMessage: 'Current session',
isCurrentSession: true,
index: 1,
@@ -533,6 +563,8 @@ describe('deleteSession', () => {
fileName: 'current-session-file.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Current session',
firstUserMessage: 'Current session',
isCurrentSession: true,
index: 1,
@@ -561,6 +593,8 @@ describe('deleteSession', () => {
fileName: 'session-file-1.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Test session',
firstUserMessage: 'Test session',
isCurrentSession: false,
index: 1,
@@ -592,6 +626,8 @@ describe('deleteSession', () => {
fileName: 'session-file-1.json',
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Test session',
firstUserMessage: 'Test session',
isCurrentSession: false,
index: 1,
@@ -626,6 +662,8 @@ describe('deleteSession', () => {
fileName: 'session-file-3.json',
startTime: session3Time.toISOString(), // Newest
lastUpdated: session3Time.toISOString(),
messageCount: 5,
displayName: 'Newest session',
firstUserMessage: 'Newest session',
isCurrentSession: false,
index: 3,
@@ -636,6 +674,8 @@ describe('deleteSession', () => {
fileName: 'session-file-1.json',
startTime: session1Time.toISOString(), // Oldest
lastUpdated: session1Time.toISOString(),
messageCount: 5,
displayName: 'Oldest session',
firstUserMessage: 'Oldest session',
isCurrentSession: false,
index: 1,
@@ -646,6 +686,8 @@ describe('deleteSession', () => {
fileName: 'session-file-2.json',
startTime: session2Time.toISOString(), // Middle
lastUpdated: session2Time.toISOString(),
messageCount: 5,
displayName: 'Middle session',
firstUserMessage: 'Middle session',
isCurrentSession: false,
index: 2,

View File

@@ -30,8 +30,12 @@ export async function listSessions(config: Config): Promise<void> {
.forEach((session, index) => {
const current = session.isCurrentSession ? ', current' : '';
const time = formatRelativeTime(session.lastUpdated);
const title =
session.firstUserMessage.length > 100
? session.firstUserMessage.slice(0, 97) + '...'
: session.firstUserMessage;
console.log(
` ${index + 1}. ${session.firstUserMessage} (${time}${current}) [${session.id}]`,
` ${index + 1}. ${title} (${time}${current}) [${session.id}]`,
);
});
}