refactor(ui): extract SessionBrowser list components

This commit is contained in:
Abhi
2026-03-13 22:41:07 -04:00
parent 4fcc3b7647
commit 78750e8251
7 changed files with 399 additions and 227 deletions
@@ -6,9 +6,7 @@
import type React from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import { Box } from 'ink';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path';
@@ -107,239 +105,16 @@ export interface SessionBrowserState {
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;
import { SearchModeDisplay } from './SessionBrowser/SearchModeDisplay.js';
import { NavigationHelp } from './SessionBrowser/NavigationHelp.js';
import { SessionListHeader } from './SessionBrowser/SessionListHeader.js';
import { NoResultsDisplay } from './SessionBrowser/NoResultsDisplay.js';
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
import { SessionList } from './SessionBrowser/SessionList.js';
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
/**
* 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>
);
/**
* 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 ? theme.ui.focus : 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)`;
}
}
// Reserve a few characters for metadata like " (current)" so the name doesn't wrap awkwardly.
const reservedForMeta = additionalInfo ? additionalInfo.length + 1 : 0;
const availableMessageWidth = Math.max(
20,
terminalWidth - FIXED_SESSION_COLUMNS_WIDTH - reservedForMeta,
);
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"
backgroundColor={isActive ? theme.background.focus : undefined}
>
<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.
*/
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionInfo } from '../../../utils/sessionUtils.js';
/**
* Match snippet display component for search results.
*/
export 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>
<Text color={roleColor} bold>
{rolePrefix}{' '}
</Text>
{firstMatch.before}
<Text color={textColor(Colors.AccentRed)} bold>
{firstMatch.match}
</Text>
{firstMatch.after}
</Text>
);
};
@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { SessionList } from './SessionList.js';
import { SessionItem } from './SessionItem.js';
import { SessionTableHeader } from './SessionTableHeader.js';
import { MatchSnippetDisplay } from './MatchSnippetDisplay.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
import type { SessionInfo } from '../../../utils/sessionUtils.js';
describe('SessionBrowser List Components', () => {
const mockSession: SessionInfo = {
id: '1',
file: 'session-1',
fileName: 'session-1.json',
startTime: new Date().toISOString(),
displayName: 'Test Session',
firstUserMessage: 'Test Session',
messageCount: 5,
lastUpdated: new Date().toISOString(),
isCurrentSession: false,
index: 1,
};
const mockState = {
totalSessions: 1,
startIndex: 0,
endIndex: 1,
visibleSessions: [mockSession],
activeIndex: 0,
scrollOffset: 0,
terminalWidth: 80,
searchQuery: '',
isSearchMode: false,
} as SessionBrowserState;
it('SessionTableHeader renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(
<SessionTableHeader state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('MatchSnippetDisplay returns null when no snippets', () => {
const { lastFrame } = render(
<MatchSnippetDisplay session={mockSession} textColor={(c) => c || ''} />,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('MatchSnippetDisplay renders correctly with snippets', async () => {
const sessionWithSnippets = {
...mockSession,
matchSnippets: [
{
role: 'user' as const,
before: 'hello ',
match: 'world',
after: ' !',
},
],
};
const { lastFrame, waitUntilReady } = render(
<MatchSnippetDisplay
session={sessionWithSnippets}
textColor={(c) => c || ''}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionItem renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(
<SessionItem
session={mockSession}
state={mockState}
terminalWidth={80}
formatRelativeTime={() => '10m ago'}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionList renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(
<SessionList state={mockState} formatRelativeTime={() => '10m ago'} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import { theme } from '../../semantic-colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
import type { SessionInfo } from '../../../utils/sessionUtils.js';
import { MatchSnippetDisplay } from './MatchSnippetDisplay.js';
const FIXED_SESSION_COLUMNS_WIDTH = 30;
/**
* Individual session row component.
*/
export 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 ? theme.ui.focus : 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)`;
}
}
// Reserve a few characters for metadata like " (current)" so the name doesn't wrap awkwardly.
const reservedForMeta = additionalInfo ? additionalInfo.length + 1 : 0;
const availableMessageWidth = Math.max(
20,
terminalWidth - FIXED_SESSION_COLUMNS_WIDTH - reservedForMeta,
);
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"
backgroundColor={isActive ? theme.background.focus : undefined}
>
<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>
);
};
@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
import { SessionItem } from './SessionItem.js';
import { SessionTableHeader } from './SessionTableHeader.js';
import { NavigationHelp } from './NavigationHelp.js';
/**
* Session list container component.
*/
export 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>
);
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
/**
* Table header component with column labels and scroll indicators.
*/
export 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>
);
@@ -0,0 +1,29 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SessionBrowser List Components > MatchSnippetDisplay renders correctly with snippets 1`] = `
"You: hello world !
"
`;
exports[`SessionBrowser List Components > SessionItem renders correctly 1`] = `
" #1 │ 5 │ 10m │ Test Session
ago
"
`;
exports[`SessionBrowser List Components > SessionList renders correctly 1`] = `
"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
Sort: s Reverse: r First/Last: g/G
Index │ Msgs │ Age │ Name
#1 │ 5 │ 10m │ Test Session
ago
"
`;
exports[`SessionBrowser List Components > SessionTableHeader renders correctly 1`] = `
"
Index │ Msgs │ Age │ Name
"
`;