mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(ui): build interactive session browser component (#13351)
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 '{state.searchQuery}'.
|
||||||
|
</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} />;
|
||||||
|
}
|
||||||
@@ -4,44 +4,165 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { MessageType, ToolCallStatus } from '../types.js';
|
import { renderHook } from '../../test-utils/render.js';
|
||||||
import type { MessageRecord } from '@google/gemini-cli-core';
|
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', () => {
|
describe('convertSessionToHistoryFormats', () => {
|
||||||
it('should convert empty messages array', () => {
|
it('should convert empty messages array', () => {
|
||||||
const result = convertSessionToHistoryFormats([]);
|
const result = convertSessionToHistoryFormats([]);
|
||||||
|
|
||||||
expect(result.uiHistory).toEqual([]);
|
expect(result.uiHistory).toEqual([]);
|
||||||
expect(result.clientHistory).toEqual([]);
|
expect(result.clientHistory).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should convert basic user and gemini messages', () => {
|
it('should convert basic user and model messages', () => {
|
||||||
const messages: MessageRecord[] = [
|
const messages: MessageRecord[] = [
|
||||||
{
|
{ type: 'user', content: 'Hello' } as MessageRecord,
|
||||||
id: 'msg-1',
|
{ type: 'gemini', content: 'Hi there' } as MessageRecord,
|
||||||
timestamp: '2025-01-01T00:01:00Z',
|
|
||||||
content: 'Hello',
|
|
||||||
type: 'user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'msg-2',
|
|
||||||
timestamp: '2025-01-01T00:02:00Z',
|
|
||||||
content: 'Hi there!',
|
|
||||||
type: 'gemini',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = convertSessionToHistoryFormats(messages);
|
const result = convertSessionToHistoryFormats(messages);
|
||||||
|
|
||||||
expect(result.uiHistory).toHaveLength(2);
|
expect(result.uiHistory).toHaveLength(2);
|
||||||
expect(result.uiHistory[0]).toEqual({
|
expect(result.uiHistory[0]).toMatchObject({ type: 'user', text: 'Hello' });
|
||||||
type: MessageType.USER,
|
expect(result.uiHistory[1]).toMatchObject({
|
||||||
text: 'Hello',
|
type: 'gemini',
|
||||||
});
|
text: 'Hi there',
|
||||||
expect(result.uiHistory[1]).toEqual({
|
|
||||||
type: MessageType.GEMINI,
|
|
||||||
text: 'Hi there!',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.clientHistory).toHaveLength(2);
|
expect(result.clientHistory).toHaveLength(2);
|
||||||
@@ -51,582 +172,92 @@ describe('convertSessionToHistoryFormats', () => {
|
|||||||
});
|
});
|
||||||
expect(result.clientHistory[1]).toEqual({
|
expect(result.clientHistory[1]).toEqual({
|
||||||
role: 'model',
|
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[] = [
|
const messages: MessageRecord[] = [
|
||||||
{
|
{ type: 'user', content: '/help' } as MessageRecord,
|
||||||
id: 'msg-1',
|
{ type: 'info', content: 'Help text' } as MessageRecord,
|
||||||
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
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = convertSessionToHistoryFormats(messages);
|
const result = convertSessionToHistoryFormats(messages);
|
||||||
|
|
||||||
expect(result.uiHistory).toHaveLength(2);
|
expect(result.uiHistory).toHaveLength(2);
|
||||||
expect(result.uiHistory[1].type).toBe('tool_group');
|
expect(result.uiHistory[0]).toMatchObject({ type: 'user', text: '/help' });
|
||||||
if (result.uiHistory[1].type === 'tool_group') {
|
expect(result.uiHistory[1]).toMatchObject({
|
||||||
expect(result.uiHistory[1].tools[0]).toEqual({
|
type: 'info',
|
||||||
callId: 'tool-1',
|
text: 'Help text',
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('tool calls in client history', () => {
|
expect(result.clientHistory).toHaveLength(0);
|
||||||
it('should convert tool calls to correct Gemini client history format', () => {
|
});
|
||||||
|
|
||||||
|
it('should handle tool calls and responses', () => {
|
||||||
const messages: MessageRecord[] = [
|
const messages: MessageRecord[] = [
|
||||||
|
{ type: 'user', content: 'What time is it?' } as 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',
|
type: 'gemini',
|
||||||
|
content: '',
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
{
|
{
|
||||||
id: 'tool-1',
|
id: 'call_1',
|
||||||
name: 'list_directory',
|
name: 'get_time',
|
||||||
args: { path: '/home/user' },
|
args: {},
|
||||||
result: {
|
|
||||||
functionResponse: {
|
|
||||||
id: 'list_directory-1753650620141-f3b8b9e73919d',
|
|
||||||
name: 'list_directory',
|
|
||||||
response: {
|
|
||||||
output: 'file1.txt\nfile2.txt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 'success',
|
status: 'success',
|
||||||
timestamp: '2025-01-01T00:02:30Z',
|
result: '12:00',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
} as unknown as MessageRecord,
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = convertSessionToHistoryFormats(messages);
|
const result = convertSessionToHistoryFormats(messages);
|
||||||
|
|
||||||
// Should have: user message, model with function call, user with function response
|
expect(result.uiHistory).toHaveLength(2);
|
||||||
expect(result.clientHistory).toHaveLength(3);
|
expect(result.uiHistory[0]).toMatchObject({
|
||||||
|
type: 'user',
|
||||||
// User message
|
text: 'What time is it?',
|
||||||
expect(result.clientHistory[0]).toEqual({
|
});
|
||||||
role: 'user',
|
expect(result.uiHistory[1]).toMatchObject({
|
||||||
parts: [{ text: 'List files' }],
|
type: 'tool_group',
|
||||||
|
tools: [
|
||||||
|
expect.objectContaining({
|
||||||
|
callId: 'call_1',
|
||||||
|
name: 'get_time',
|
||||||
|
status: 'Success',
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Model message with function call
|
expect(result.clientHistory).toHaveLength(3); // User, Model (call), User (response)
|
||||||
|
expect(result.clientHistory[0]).toEqual({
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'What time is it?' }],
|
||||||
|
});
|
||||||
expect(result.clientHistory[1]).toEqual({
|
expect(result.clientHistory[1]).toEqual({
|
||||||
role: 'model',
|
role: 'model',
|
||||||
parts: [
|
parts: [
|
||||||
{ text: "I'll list the files for you." },
|
|
||||||
{
|
{
|
||||||
functionCall: {
|
functionCall: {
|
||||||
name: 'list_directory',
|
name: 'get_time',
|
||||||
args: { path: '/home/user' },
|
args: {},
|
||||||
id: 'tool-1',
|
id: 'call_1',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Function response
|
|
||||||
expect(result.clientHistory[2]).toEqual({
|
expect(result.clientHistory[2]).toEqual({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
id: 'list_directory-1753650620141-f3b8b9e73919d',
|
id: 'call_1',
|
||||||
name: 'list_directory',
|
name: 'get_time',
|
||||||
response: { output: 'file1.txt\nfile2.txt' },
|
response: { output: '12:00' },
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple tool calls in one message', () => {
|
|
||||||
const messages: MessageRecord[] = [
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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[] = [
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -4,11 +4,110 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
import type { HistoryItemWithoutId } from '../types.js';
|
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 type { Part } from '@google/genai';
|
||||||
import { partListUnionToString } from '@google/gemini-cli-core';
|
import { partListUnionToString } from '@google/gemini-cli-core';
|
||||||
|
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||||
import { MessageType, ToolCallStatus } from '../types.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.
|
* Converts session/conversation data into UI history and Gemini client history formats.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ function createTestSessions(): SessionInfo[] {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`,
|
fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`,
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Current session',
|
||||||
firstUserMessage: 'Current session',
|
firstUserMessage: 'Current session',
|
||||||
isCurrentSession: true,
|
isCurrentSession: true,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -62,6 +64,8 @@ function createTestSessions(): SessionInfo[] {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`,
|
fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`,
|
||||||
startTime: oneWeekAgo.toISOString(),
|
startTime: oneWeekAgo.toISOString(),
|
||||||
lastUpdated: oneWeekAgo.toISOString(),
|
lastUpdated: oneWeekAgo.toISOString(),
|
||||||
|
messageCount: 10,
|
||||||
|
displayName: 'Recent session',
|
||||||
firstUserMessage: 'Recent session',
|
firstUserMessage: 'Recent session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 2,
|
index: 2,
|
||||||
@@ -72,6 +76,8 @@ function createTestSessions(): SessionInfo[] {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`,
|
fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`,
|
||||||
startTime: twoWeeksAgo.toISOString(),
|
startTime: twoWeeksAgo.toISOString(),
|
||||||
lastUpdated: twoWeeksAgo.toISOString(),
|
lastUpdated: twoWeeksAgo.toISOString(),
|
||||||
|
messageCount: 3,
|
||||||
|
displayName: 'Old session',
|
||||||
firstUserMessage: 'Old session',
|
firstUserMessage: 'Old session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 3,
|
index: 3,
|
||||||
@@ -82,6 +88,8 @@ function createTestSessions(): SessionInfo[] {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`,
|
fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`,
|
||||||
startTime: oneMonthAgo.toISOString(),
|
startTime: oneMonthAgo.toISOString(),
|
||||||
lastUpdated: oneMonthAgo.toISOString(),
|
lastUpdated: oneMonthAgo.toISOString(),
|
||||||
|
messageCount: 15,
|
||||||
|
displayName: 'Ancient session',
|
||||||
firstUserMessage: 'Ancient session',
|
firstUserMessage: 'Ancient session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 4,
|
index: 4,
|
||||||
@@ -435,6 +443,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: 'Current',
|
||||||
firstUserMessage: 'Current',
|
firstUserMessage: 'Current',
|
||||||
isCurrentSession: true,
|
isCurrentSession: true,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -445,6 +455,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
||||||
startTime: fiveDaysAgo.toISOString(),
|
startTime: fiveDaysAgo.toISOString(),
|
||||||
lastUpdated: fiveDaysAgo.toISOString(),
|
lastUpdated: fiveDaysAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: '5 days old',
|
||||||
firstUserMessage: '5 days',
|
firstUserMessage: '5 days',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 2,
|
index: 2,
|
||||||
@@ -455,6 +467,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}8d.json`,
|
fileName: `${SESSION_FILE_PREFIX}8d.json`,
|
||||||
startTime: eightDaysAgo.toISOString(),
|
startTime: eightDaysAgo.toISOString(),
|
||||||
lastUpdated: eightDaysAgo.toISOString(),
|
lastUpdated: eightDaysAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: '8 days old',
|
||||||
firstUserMessage: '8 days',
|
firstUserMessage: '8 days',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 3,
|
index: 3,
|
||||||
@@ -465,6 +479,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}15d.json`,
|
fileName: `${SESSION_FILE_PREFIX}15d.json`,
|
||||||
startTime: fifteenDaysAgo.toISOString(),
|
startTime: fifteenDaysAgo.toISOString(),
|
||||||
lastUpdated: fifteenDaysAgo.toISOString(),
|
lastUpdated: fifteenDaysAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: '15 days old',
|
||||||
firstUserMessage: '15 days',
|
firstUserMessage: '15 days',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 4,
|
index: 4,
|
||||||
@@ -549,6 +565,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: 'Current',
|
||||||
firstUserMessage: 'Current',
|
firstUserMessage: 'Current',
|
||||||
isCurrentSession: true,
|
isCurrentSession: true,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -559,6 +577,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}1d.json`,
|
fileName: `${SESSION_FILE_PREFIX}1d.json`,
|
||||||
startTime: oneDayAgo.toISOString(),
|
startTime: oneDayAgo.toISOString(),
|
||||||
lastUpdated: oneDayAgo.toISOString(),
|
lastUpdated: oneDayAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: '1 day old',
|
||||||
firstUserMessage: '1 day',
|
firstUserMessage: '1 day',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 2,
|
index: 2,
|
||||||
@@ -569,6 +589,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
||||||
startTime: sevenDaysAgo.toISOString(),
|
startTime: sevenDaysAgo.toISOString(),
|
||||||
lastUpdated: sevenDaysAgo.toISOString(),
|
lastUpdated: sevenDaysAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: '7 days old',
|
||||||
firstUserMessage: '7 days',
|
firstUserMessage: '7 days',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 3,
|
index: 3,
|
||||||
@@ -579,6 +601,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}13d.json`,
|
fileName: `${SESSION_FILE_PREFIX}13d.json`,
|
||||||
startTime: thirteenDaysAgo.toISOString(),
|
startTime: thirteenDaysAgo.toISOString(),
|
||||||
lastUpdated: thirteenDaysAgo.toISOString(),
|
lastUpdated: thirteenDaysAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: '13 days old',
|
||||||
firstUserMessage: '13 days',
|
firstUserMessage: '13 days',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 4,
|
index: 4,
|
||||||
@@ -637,6 +661,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: 'Current (newest)',
|
||||||
firstUserMessage: 'Current',
|
firstUserMessage: 'Current',
|
||||||
isCurrentSession: true,
|
isCurrentSession: true,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -652,6 +678,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}${i}d.json`,
|
fileName: `${SESSION_FILE_PREFIX}${i}d.json`,
|
||||||
startTime: daysAgo.toISOString(),
|
startTime: daysAgo.toISOString(),
|
||||||
lastUpdated: daysAgo.toISOString(),
|
lastUpdated: daysAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: `${i} days old`,
|
||||||
firstUserMessage: `${i} days`,
|
firstUserMessage: `${i} days`,
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: i + 1,
|
index: i + 1,
|
||||||
@@ -759,6 +787,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: 'Current',
|
||||||
firstUserMessage: 'Current',
|
firstUserMessage: 'Current',
|
||||||
isCurrentSession: true,
|
isCurrentSession: true,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -769,6 +799,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}3d.json`,
|
fileName: `${SESSION_FILE_PREFIX}3d.json`,
|
||||||
startTime: threeDaysAgo.toISOString(),
|
startTime: threeDaysAgo.toISOString(),
|
||||||
lastUpdated: threeDaysAgo.toISOString(),
|
lastUpdated: threeDaysAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: '3 days old',
|
||||||
firstUserMessage: '3 days',
|
firstUserMessage: '3 days',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 2,
|
index: 2,
|
||||||
@@ -779,6 +811,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
||||||
startTime: fiveDaysAgo.toISOString(),
|
startTime: fiveDaysAgo.toISOString(),
|
||||||
lastUpdated: fiveDaysAgo.toISOString(),
|
lastUpdated: fiveDaysAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: '5 days old',
|
||||||
firstUserMessage: '5 days',
|
firstUserMessage: '5 days',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 3,
|
index: 3,
|
||||||
@@ -789,6 +823,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
||||||
startTime: sevenDaysAgo.toISOString(),
|
startTime: sevenDaysAgo.toISOString(),
|
||||||
lastUpdated: sevenDaysAgo.toISOString(),
|
lastUpdated: sevenDaysAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: '7 days old',
|
||||||
firstUserMessage: '7 days',
|
firstUserMessage: '7 days',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 4,
|
index: 4,
|
||||||
@@ -799,6 +835,8 @@ describe('Session Cleanup', () => {
|
|||||||
fileName: `${SESSION_FILE_PREFIX}12d.json`,
|
fileName: `${SESSION_FILE_PREFIX}12d.json`,
|
||||||
startTime: twelveDaysAgo.toISOString(),
|
startTime: twelveDaysAgo.toISOString(),
|
||||||
lastUpdated: twelveDaysAgo.toISOString(),
|
lastUpdated: twelveDaysAgo.toISOString(),
|
||||||
|
messageCount: 1,
|
||||||
|
displayName: '12 days old',
|
||||||
firstUserMessage: '12 days',
|
firstUserMessage: '12 days',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 5,
|
index: 5,
|
||||||
|
|||||||
@@ -234,6 +234,70 @@ describe('SessionSelector', () => {
|
|||||||
expect(result.sessionData.messages[0].content).toBe('Latest session');
|
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 () => {
|
it('should throw error for invalid session identifier', async () => {
|
||||||
const sessionId1 = randomUUID();
|
const sessionId1 = randomUUID();
|
||||||
|
|
||||||
@@ -296,7 +360,7 @@ describe('extractFirstUserMessage', () => {
|
|||||||
expect(extractFirstUserMessage(messages)).toBe('Hello world');
|
expect(extractFirstUserMessage(messages)).toBe('Hello world');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should truncate long messages', () => {
|
it('should not truncate long messages', () => {
|
||||||
const longMessage = 'a'.repeat(150);
|
const longMessage = 'a'.repeat(150);
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
@@ -308,8 +372,7 @@ describe('extractFirstUserMessage', () => {
|
|||||||
] as MessageRecord[];
|
] as MessageRecord[];
|
||||||
|
|
||||||
const result = extractFirstUserMessage(messages);
|
const result = extractFirstUserMessage(messages);
|
||||||
expect(result).toBe('a'.repeat(97) + '...');
|
expect(result).toBe(longMessage);
|
||||||
expect(result.length).toBe(100);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return "Empty conversation" for no user messages', () => {
|
it('should return "Empty conversation" for no user messages', () => {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import type {
|
|||||||
MessageRecord,
|
MessageRecord,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
SESSION_FILE_PREFIX,
|
|
||||||
partListUnionToString,
|
partListUnionToString,
|
||||||
|
SESSION_FILE_PREFIX,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -22,6 +22,20 @@ import path from 'node:path';
|
|||||||
*/
|
*/
|
||||||
export const RESUME_LATEST = 'latest';
|
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.
|
* Session information for display and selection purposes.
|
||||||
*/
|
*/
|
||||||
@@ -34,14 +48,26 @@ export interface SessionInfo {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
/** ISO timestamp when session started */
|
/** ISO timestamp when session started */
|
||||||
startTime: string;
|
startTime: string;
|
||||||
|
/** Total number of messages in the session */
|
||||||
|
messageCount: number;
|
||||||
/** ISO timestamp when session was last updated */
|
/** ISO timestamp when session was last updated */
|
||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
|
/** Display name for the session (typically first user message) */
|
||||||
|
displayName: string;
|
||||||
/** Cleaned first user message content */
|
/** Cleaned first user message content */
|
||||||
firstUserMessage: string;
|
firstUserMessage: string;
|
||||||
/** Whether this is the currently active session */
|
/** Whether this is the currently active session */
|
||||||
isCurrentSession: boolean;
|
isCurrentSession: boolean;
|
||||||
/** Display index in the list */
|
/** Display index in the list */
|
||||||
index: number;
|
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 {
|
export interface SessionSelectionResult {
|
||||||
sessionPath: string;
|
sessionPath: string;
|
||||||
sessionData: ConversationRecord;
|
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.
|
* Extracts the first meaningful user message from conversation messages.
|
||||||
*/
|
*/
|
||||||
export const extractFirstUserMessage = (messages: MessageRecord[]): string => {
|
export const extractFirstUserMessage = (messages: MessageRecord[]): string => {
|
||||||
const userMessage = messages.find((msg) => {
|
const userMessage = messages
|
||||||
|
// First try filtering out slash commands.
|
||||||
|
.filter((msg) => {
|
||||||
const content = partListUnionToString(msg.content);
|
const content = partListUnionToString(msg.content);
|
||||||
return msg.type === 'user' && content?.trim() && content !== '/resume';
|
return (
|
||||||
});
|
!content.startsWith('/') &&
|
||||||
|
!content.startsWith('?') &&
|
||||||
|
content.trim().length > 0
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.find((msg) => msg.type === 'user');
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
|
||||||
if (!userMessage) {
|
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
|
return content;
|
||||||
const content = partListUnionToString(userMessage.content).trim();
|
|
||||||
return content.length > 100 ? content.slice(0, 97) + '...' : 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 now = new Date();
|
||||||
const time = new Date(timestamp);
|
const time = new Date(timestamp);
|
||||||
const diffMs = now.getTime() - time.getTime();
|
const diffMs = now.getTime() - time.getTime();
|
||||||
@@ -92,6 +152,17 @@ export const formatRelativeTime = (timestamp: string): string => {
|
|||||||
const diffHours = Math.floor(diffMinutes / 60);
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
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 {
|
||||||
if (diffDays > 0) {
|
if (diffDays > 0) {
|
||||||
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||||
} else if (diffHours > 0) {
|
} else if (diffHours > 0) {
|
||||||
@@ -101,8 +172,14 @@ export const formatRelativeTime = (timestamp: string): string => {
|
|||||||
} else {
|
} else {
|
||||||
return 'Just now';
|
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.
|
* Loads all session files (including corrupted ones) from the chats directory.
|
||||||
* @returns Array of session file entries, with sessionInfo null for corrupted files
|
* @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 (
|
export const getAllSessionFiles = async (
|
||||||
chatsDir: string,
|
chatsDir: string,
|
||||||
currentSessionId?: string,
|
currentSessionId?: string,
|
||||||
|
options: GetSessionOptions = {},
|
||||||
): Promise<SessionFileEntry[]> => {
|
): Promise<SessionFileEntry[]> => {
|
||||||
try {
|
try {
|
||||||
const files = await fs.readdir(chatsDir);
|
const files = await fs.readdir(chatsDir);
|
||||||
@@ -142,15 +220,37 @@ export const getAllSessionFiles = async (
|
|||||||
? file.includes(currentSessionId.slice(0, 8))
|
? file.includes(currentSessionId.slice(0, 8))
|
||||||
: false;
|
: 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 = {
|
const sessionInfo: SessionInfo = {
|
||||||
id: content.sessionId,
|
id: content.sessionId,
|
||||||
file: file.replace('.json', ''),
|
file: file.replace('.json', ''),
|
||||||
fileName: file,
|
fileName: file,
|
||||||
startTime: content.startTime,
|
startTime: content.startTime,
|
||||||
lastUpdated: content.lastUpdated,
|
lastUpdated: content.lastUpdated,
|
||||||
|
messageCount: content.messages.length,
|
||||||
|
displayName: firstUserMessage,
|
||||||
firstUserMessage,
|
firstUserMessage,
|
||||||
isCurrentSession,
|
isCurrentSession,
|
||||||
index: 0, // Will be set after sorting valid sessions
|
index: 0, // Will be set after sorting valid sessions
|
||||||
|
fullContent,
|
||||||
|
messages,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { fileName: file, sessionInfo };
|
return { fileName: file, sessionInfo };
|
||||||
@@ -179,8 +279,13 @@ export const getAllSessionFiles = async (
|
|||||||
export const getSessionFiles = async (
|
export const getSessionFiles = async (
|
||||||
chatsDir: string,
|
chatsDir: string,
|
||||||
currentSessionId?: string,
|
currentSessionId?: string,
|
||||||
|
options: GetSessionOptions = {},
|
||||||
): Promise<SessionInfo[]> => {
|
): Promise<SessionInfo[]> => {
|
||||||
const allFiles = await getAllSessionFiles(chatsDir, currentSessionId);
|
const allFiles = await getAllSessionFiles(
|
||||||
|
chatsDir,
|
||||||
|
currentSessionId,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
// Filter out corrupted files and extract SessionInfo
|
// Filter out corrupted files and extract SessionInfo
|
||||||
const validSessions = allFiles
|
const validSessions = allFiles
|
||||||
@@ -190,17 +295,31 @@ export const getSessionFiles = async (
|
|||||||
)
|
)
|
||||||
.map((entry) => entry.sessionInfo);
|
.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
|
// 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(),
|
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set the correct 1-based indexes after sorting
|
// Set the correct 1-based indexes after sorting
|
||||||
validSessions.forEach((session, index) => {
|
uniqueSessions.forEach((session, index) => {
|
||||||
session.index = index + 1;
|
session.index = index + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
return validSessions;
|
return uniqueSessions;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -318,9 +437,12 @@ export class SessionSelector {
|
|||||||
await fs.readFile(sessionPath, 'utf8'),
|
await fs.readFile(sessionPath, 'utf8'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionPath,
|
sessionPath,
|
||||||
sessionData,
|
sessionData,
|
||||||
|
displayInfo,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ describe('listSessions', () => {
|
|||||||
fileName: 'session-2025-01-18T12-00-00-session-1.json',
|
fileName: 'session-2025-01-18T12-00-00-session-1.json',
|
||||||
startTime: twoDaysAgo.toISOString(),
|
startTime: twoDaysAgo.toISOString(),
|
||||||
lastUpdated: twoDaysAgo.toISOString(),
|
lastUpdated: twoDaysAgo.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'First user message',
|
||||||
firstUserMessage: 'First user message',
|
firstUserMessage: 'First user message',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -95,6 +97,8 @@ describe('listSessions', () => {
|
|||||||
fileName: 'session-2025-01-20T11-00-00-session-2.json',
|
fileName: 'session-2025-01-20T11-00-00-session-2.json',
|
||||||
startTime: oneHourAgo.toISOString(),
|
startTime: oneHourAgo.toISOString(),
|
||||||
lastUpdated: oneHourAgo.toISOString(),
|
lastUpdated: oneHourAgo.toISOString(),
|
||||||
|
messageCount: 10,
|
||||||
|
displayName: 'Second user message',
|
||||||
firstUserMessage: 'Second user message',
|
firstUserMessage: 'Second user message',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 2,
|
index: 2,
|
||||||
@@ -105,6 +109,8 @@ describe('listSessions', () => {
|
|||||||
fileName: 'session-2025-01-20T12-00-00-current-s.json',
|
fileName: 'session-2025-01-20T12-00-00-current-s.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 3,
|
||||||
|
displayName: 'Current session',
|
||||||
firstUserMessage: 'Current session',
|
firstUserMessage: 'Current session',
|
||||||
isCurrentSession: true,
|
isCurrentSession: true,
|
||||||
index: 3,
|
index: 3,
|
||||||
@@ -163,6 +169,8 @@ describe('listSessions', () => {
|
|||||||
fileName: 'session-2.json',
|
fileName: 'session-2.json',
|
||||||
startTime: session2Time.toISOString(), // Middle
|
startTime: session2Time.toISOString(), // Middle
|
||||||
lastUpdated: session2Time.toISOString(),
|
lastUpdated: session2Time.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Middle session',
|
||||||
firstUserMessage: 'Middle session',
|
firstUserMessage: 'Middle session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 2,
|
index: 2,
|
||||||
@@ -173,6 +181,8 @@ describe('listSessions', () => {
|
|||||||
fileName: 'session-1.json',
|
fileName: 'session-1.json',
|
||||||
startTime: session1Time.toISOString(), // Oldest
|
startTime: session1Time.toISOString(), // Oldest
|
||||||
lastUpdated: session1Time.toISOString(),
|
lastUpdated: session1Time.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Oldest session',
|
||||||
firstUserMessage: 'Oldest session',
|
firstUserMessage: 'Oldest session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -183,6 +193,8 @@ describe('listSessions', () => {
|
|||||||
fileName: 'session-3.json',
|
fileName: 'session-3.json',
|
||||||
startTime: session3Time.toISOString(), // Newest
|
startTime: session3Time.toISOString(), // Newest
|
||||||
lastUpdated: session3Time.toISOString(),
|
lastUpdated: session3Time.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Newest session',
|
||||||
firstUserMessage: 'Newest session',
|
firstUserMessage: 'Newest session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 3,
|
index: 3,
|
||||||
@@ -219,6 +231,8 @@ describe('listSessions', () => {
|
|||||||
fileName: 'session-file.json',
|
fileName: 'session-file.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Test message',
|
||||||
firstUserMessage: 'Test message',
|
firstUserMessage: 'Test message',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -252,6 +266,8 @@ describe('listSessions', () => {
|
|||||||
fileName: 'session-file.json',
|
fileName: 'session-file.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Only session',
|
||||||
firstUserMessage: 'Only session',
|
firstUserMessage: 'Only session',
|
||||||
isCurrentSession: true,
|
isCurrentSession: true,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -348,6 +364,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-123.json',
|
fileName: 'session-file-123.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Test session',
|
||||||
firstUserMessage: 'Test session',
|
firstUserMessage: 'Test session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -381,6 +399,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-1.json',
|
fileName: 'session-file-1.json',
|
||||||
startTime: oneHourAgo.toISOString(),
|
startTime: oneHourAgo.toISOString(),
|
||||||
lastUpdated: oneHourAgo.toISOString(),
|
lastUpdated: oneHourAgo.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'First session',
|
||||||
firstUserMessage: 'First session',
|
firstUserMessage: 'First session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -391,6 +411,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-2.json',
|
fileName: 'session-file-2.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 10,
|
||||||
|
displayName: 'Second session',
|
||||||
firstUserMessage: 'Second session',
|
firstUserMessage: 'Second session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 2,
|
index: 2,
|
||||||
@@ -421,6 +443,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-1.json',
|
fileName: 'session-file-1.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Test session',
|
||||||
firstUserMessage: 'Test session',
|
firstUserMessage: 'Test session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -449,6 +473,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-1.json',
|
fileName: 'session-file-1.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Test session',
|
||||||
firstUserMessage: 'Test session',
|
firstUserMessage: 'Test session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -477,6 +503,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-1.json',
|
fileName: 'session-file-1.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Test session',
|
||||||
firstUserMessage: 'Test session',
|
firstUserMessage: 'Test session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -505,6 +533,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'current-session-file.json',
|
fileName: 'current-session-file.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Current session',
|
||||||
firstUserMessage: 'Current session',
|
firstUserMessage: 'Current session',
|
||||||
isCurrentSession: true,
|
isCurrentSession: true,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -533,6 +563,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'current-session-file.json',
|
fileName: 'current-session-file.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Current session',
|
||||||
firstUserMessage: 'Current session',
|
firstUserMessage: 'Current session',
|
||||||
isCurrentSession: true,
|
isCurrentSession: true,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -561,6 +593,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-1.json',
|
fileName: 'session-file-1.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Test session',
|
||||||
firstUserMessage: 'Test session',
|
firstUserMessage: 'Test session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -592,6 +626,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-1.json',
|
fileName: 'session-file-1.json',
|
||||||
startTime: now.toISOString(),
|
startTime: now.toISOString(),
|
||||||
lastUpdated: now.toISOString(),
|
lastUpdated: now.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Test session',
|
||||||
firstUserMessage: 'Test session',
|
firstUserMessage: 'Test session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -626,6 +662,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-3.json',
|
fileName: 'session-file-3.json',
|
||||||
startTime: session3Time.toISOString(), // Newest
|
startTime: session3Time.toISOString(), // Newest
|
||||||
lastUpdated: session3Time.toISOString(),
|
lastUpdated: session3Time.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Newest session',
|
||||||
firstUserMessage: 'Newest session',
|
firstUserMessage: 'Newest session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 3,
|
index: 3,
|
||||||
@@ -636,6 +674,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-1.json',
|
fileName: 'session-file-1.json',
|
||||||
startTime: session1Time.toISOString(), // Oldest
|
startTime: session1Time.toISOString(), // Oldest
|
||||||
lastUpdated: session1Time.toISOString(),
|
lastUpdated: session1Time.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Oldest session',
|
||||||
firstUserMessage: 'Oldest session',
|
firstUserMessage: 'Oldest session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 1,
|
index: 1,
|
||||||
@@ -646,6 +686,8 @@ describe('deleteSession', () => {
|
|||||||
fileName: 'session-file-2.json',
|
fileName: 'session-file-2.json',
|
||||||
startTime: session2Time.toISOString(), // Middle
|
startTime: session2Time.toISOString(), // Middle
|
||||||
lastUpdated: session2Time.toISOString(),
|
lastUpdated: session2Time.toISOString(),
|
||||||
|
messageCount: 5,
|
||||||
|
displayName: 'Middle session',
|
||||||
firstUserMessage: 'Middle session',
|
firstUserMessage: 'Middle session',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index: 2,
|
index: 2,
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ export async function listSessions(config: Config): Promise<void> {
|
|||||||
.forEach((session, index) => {
|
.forEach((session, index) => {
|
||||||
const current = session.isCurrentSession ? ', current' : '';
|
const current = session.isCurrentSession ? ', current' : '';
|
||||||
const time = formatRelativeTime(session.lastUpdated);
|
const time = formatRelativeTime(session.lastUpdated);
|
||||||
|
const title =
|
||||||
|
session.firstUserMessage.length > 100
|
||||||
|
? session.firstUserMessage.slice(0, 97) + '...'
|
||||||
|
: session.firstUserMessage;
|
||||||
console.log(
|
console.log(
|
||||||
` ${index + 1}. ${session.firstUserMessage} (${time}${current}) [${session.id}]`,
|
` ${index + 1}. ${title} (${time}${current}) [${session.id}]`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user