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.
*/