Skip to content

Commit 215bd2a

Browse files
authored
feat(ui): build interactive session browser component (#13351)
1 parent 0876bbd commit 215bd2a

File tree

9 files changed

+1895
-592
lines changed

9 files changed

+1895
-592
lines changed
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8+
import { act } from 'react';
9+
import { render } from '../../test-utils/render.js';
10+
import { waitFor } from '../../test-utils/async.js';
11+
import type { Config } from '@google/gemini-cli-core';
12+
import { SessionBrowser } from './SessionBrowser.js';
13+
import type { SessionBrowserProps } from './SessionBrowser.js';
14+
import type { SessionInfo } from '../../utils/sessionUtils.js';
15+
16+
// Collect key handlers registered via useKeypress so tests can
17+
// simulate input without going through the full stdin pipeline.
18+
const keypressHandlers: Array<(key: unknown) => void> = [];
19+
20+
vi.mock('../hooks/useTerminalSize.js', () => ({
21+
useTerminalSize: () => ({ columns: 80, rows: 24 }),
22+
}));
23+
24+
vi.mock('../hooks/useKeypress.js', () => ({
25+
// The real hook subscribes to the KeypressContext. Here we just
26+
// capture the handler so tests can call it directly.
27+
useKeypress: (
28+
handler: (key: unknown) => void,
29+
options: { isActive: boolean },
30+
) => {
31+
if (options?.isActive) {
32+
keypressHandlers.push(handler);
33+
}
34+
},
35+
}));
36+
37+
// Mock the component itself to bypass async loading
38+
vi.mock('./SessionBrowser.js', async (importOriginal) => {
39+
const original = await importOriginal<typeof import('./SessionBrowser.js')>();
40+
const React = await import('react');
41+
42+
const TestSessionBrowser = (
43+
props: SessionBrowserProps & {
44+
testSessions?: SessionInfo[];
45+
testError?: string | null;
46+
},
47+
) => {
48+
const state = original.useSessionBrowserState(
49+
props.testSessions || [],
50+
false, // Not loading
51+
props.testError || null,
52+
);
53+
const moveSelection = original.useMoveSelection(state);
54+
const cycleSortOrder = original.useCycleSortOrder(state);
55+
original.useSessionBrowserInput(
56+
state,
57+
moveSelection,
58+
cycleSortOrder,
59+
props.onResumeSession,
60+
props.onDeleteSession,
61+
props.onExit,
62+
);
63+
64+
return React.createElement(original.SessionBrowserView, { state });
65+
};
66+
67+
return {
68+
...original,
69+
SessionBrowser: TestSessionBrowser,
70+
};
71+
});
72+
73+
// Cast SessionBrowser to a type that includes the test-only props so TypeScript doesn't complain
74+
const TestSessionBrowser = SessionBrowser as unknown as React.FC<
75+
SessionBrowserProps & {
76+
testSessions?: SessionInfo[];
77+
testError?: string | null;
78+
}
79+
>;
80+
81+
const createMockConfig = (overrides: Partial<Config> = {}): Config =>
82+
({
83+
storage: {
84+
getProjectTempDir: () => '/tmp/test',
85+
},
86+
getSessionId: () => 'default-session-id',
87+
...overrides,
88+
}) as Config;
89+
90+
const triggerKey = (
91+
partialKey: Partial<{
92+
name: string;
93+
ctrl: boolean;
94+
meta: boolean;
95+
shift: boolean;
96+
paste: boolean;
97+
insertable: boolean;
98+
sequence: string;
99+
}>,
100+
) => {
101+
const handler = keypressHandlers[keypressHandlers.length - 1];
102+
if (!handler) {
103+
throw new Error('No keypress handler registered');
104+
}
105+
106+
const key = {
107+
name: '',
108+
ctrl: false,
109+
meta: false,
110+
shift: false,
111+
paste: false,
112+
insertable: false,
113+
sequence: '',
114+
...partialKey,
115+
};
116+
117+
act(() => {
118+
handler(key);
119+
});
120+
};
121+
122+
const createSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
123+
id: 'session-id',
124+
file: 'session-id',
125+
fileName: 'session-id.json',
126+
startTime: new Date().toISOString(),
127+
lastUpdated: new Date().toISOString(),
128+
messageCount: 1,
129+
displayName: 'Test Session',
130+
firstUserMessage: 'Test Session',
131+
isCurrentSession: false,
132+
index: 0,
133+
...overrides,
134+
});
135+
136+
describe('SessionBrowser component', () => {
137+
beforeEach(() => {
138+
keypressHandlers.length = 0;
139+
vi.clearAllMocks();
140+
});
141+
142+
afterEach(() => {
143+
vi.restoreAllMocks();
144+
});
145+
146+
it('shows empty state when no sessions exist', () => {
147+
const config = createMockConfig();
148+
const onResumeSession = vi.fn();
149+
const onExit = vi.fn();
150+
151+
const { lastFrame } = render(
152+
<TestSessionBrowser
153+
config={config}
154+
onResumeSession={onResumeSession}
155+
onExit={onExit}
156+
testSessions={[]}
157+
/>,
158+
);
159+
160+
expect(lastFrame()).toContain('No auto-saved conversations found.');
161+
expect(lastFrame()).toContain('Press q to exit');
162+
});
163+
164+
it('renders a list of sessions and marks current session as disabled', () => {
165+
const session1 = createSession({
166+
id: 'abc123',
167+
file: 'abc123',
168+
displayName: 'First conversation about cats',
169+
lastUpdated: '2025-01-01T10:05:00Z',
170+
messageCount: 2,
171+
index: 0,
172+
});
173+
const session2 = createSession({
174+
id: 'def456',
175+
file: 'def456',
176+
displayName: 'Second conversation about dogs',
177+
lastUpdated: '2025-01-01T11:30:00Z',
178+
messageCount: 5,
179+
isCurrentSession: true,
180+
index: 1,
181+
});
182+
183+
const config = createMockConfig();
184+
const onResumeSession = vi.fn();
185+
const onExit = vi.fn();
186+
187+
const { lastFrame } = render(
188+
<TestSessionBrowser
189+
config={config}
190+
onResumeSession={onResumeSession}
191+
onExit={onExit}
192+
testSessions={[session1, session2]}
193+
/>,
194+
);
195+
196+
const output = lastFrame();
197+
expect(output).toContain('Chat Sessions (2 total');
198+
expect(output).toContain('First conversation about cats');
199+
expect(output).toContain('Second conversation about dogs');
200+
expect(output).toContain('(current)');
201+
});
202+
203+
it('enters search mode, filters sessions, and renders match snippets', async () => {
204+
const searchSession = createSession({
205+
id: 'search1',
206+
file: 'search1',
207+
displayName: 'Query is here and another query.',
208+
firstUserMessage: 'Query is here and another query.',
209+
fullContent: 'Query is here and another query.',
210+
messages: [
211+
{
212+
role: 'user',
213+
content: 'Query is here and another query.',
214+
},
215+
],
216+
index: 0,
217+
});
218+
219+
const otherSession = createSession({
220+
id: 'other',
221+
file: 'other',
222+
displayName: 'Nothing interesting here.',
223+
firstUserMessage: 'Nothing interesting here.',
224+
fullContent: 'Nothing interesting here.',
225+
messages: [
226+
{
227+
role: 'user',
228+
content: 'Nothing interesting here.',
229+
},
230+
],
231+
index: 1,
232+
});
233+
234+
const config = createMockConfig();
235+
const onResumeSession = vi.fn();
236+
const onExit = vi.fn();
237+
238+
const { lastFrame } = render(
239+
<TestSessionBrowser
240+
config={config}
241+
onResumeSession={onResumeSession}
242+
onExit={onExit}
243+
testSessions={[searchSession, otherSession]}
244+
/>,
245+
);
246+
247+
expect(lastFrame()).toContain('Chat Sessions (2 total');
248+
249+
// Enter search mode.
250+
triggerKey({ sequence: '/', name: '/' });
251+
252+
await waitFor(() => {
253+
expect(lastFrame()).toContain('Search:');
254+
});
255+
256+
// Type the query "query".
257+
for (const ch of ['q', 'u', 'e', 'r', 'y']) {
258+
triggerKey({ sequence: ch, name: ch, ctrl: false, meta: false });
259+
}
260+
261+
await waitFor(() => {
262+
const output = lastFrame();
263+
expect(output).toContain('Chat Sessions (1 total, filtered');
264+
expect(output).toContain('Query is here');
265+
expect(output).not.toContain('Nothing interesting here.');
266+
267+
expect(output).toContain('You:');
268+
expect(output).toContain('query');
269+
expect(output).toContain('(+1 more)');
270+
});
271+
});
272+
273+
it('handles keyboard navigation and resumes the selected session', () => {
274+
const session1 = createSession({
275+
id: 'one',
276+
file: 'one',
277+
displayName: 'First session',
278+
index: 0,
279+
});
280+
const session2 = createSession({
281+
id: 'two',
282+
file: 'two',
283+
displayName: 'Second session',
284+
index: 1,
285+
});
286+
287+
const config = createMockConfig();
288+
const onResumeSession = vi.fn();
289+
const onExit = vi.fn();
290+
291+
const { lastFrame } = render(
292+
<TestSessionBrowser
293+
config={config}
294+
onResumeSession={onResumeSession}
295+
onExit={onExit}
296+
testSessions={[session1, session2]}
297+
/>,
298+
);
299+
300+
expect(lastFrame()).toContain('Chat Sessions (2 total');
301+
302+
// Move selection down.
303+
triggerKey({ name: 'down', sequence: '[B' });
304+
305+
// Press Enter.
306+
triggerKey({ name: 'return', sequence: '\r' });
307+
308+
expect(onResumeSession).toHaveBeenCalledTimes(1);
309+
const [resumedSession] = onResumeSession.mock.calls[0];
310+
expect(resumedSession).toEqual(session2);
311+
});
312+
313+
it('does not allow resuming or deleting the current session', () => {
314+
const currentSession = createSession({
315+
id: 'current',
316+
file: 'current',
317+
displayName: 'Current session',
318+
isCurrentSession: true,
319+
index: 0,
320+
});
321+
const otherSession = createSession({
322+
id: 'other',
323+
file: 'other',
324+
displayName: 'Other session',
325+
isCurrentSession: false,
326+
index: 1,
327+
});
328+
329+
const config = createMockConfig();
330+
const onResumeSession = vi.fn();
331+
const onDeleteSession = vi.fn();
332+
const onExit = vi.fn();
333+
334+
render(
335+
<TestSessionBrowser
336+
config={config}
337+
onResumeSession={onResumeSession}
338+
onDeleteSession={onDeleteSession}
339+
onExit={onExit}
340+
testSessions={[currentSession, otherSession]}
341+
/>,
342+
);
343+
344+
// Active selection is at 0 (current session).
345+
triggerKey({ name: 'return', sequence: '\r' });
346+
expect(onResumeSession).not.toHaveBeenCalled();
347+
348+
// Attempt delete.
349+
triggerKey({ sequence: 'x', name: 'x' });
350+
expect(onDeleteSession).not.toHaveBeenCalled();
351+
});
352+
353+
it('shows an error state when loading sessions fails', () => {
354+
const config = createMockConfig();
355+
const onResumeSession = vi.fn();
356+
const onExit = vi.fn();
357+
358+
const { lastFrame } = render(
359+
<TestSessionBrowser
360+
config={config}
361+
onResumeSession={onResumeSession}
362+
onExit={onExit}
363+
testError="storage failure"
364+
/>,
365+
);
366+
367+
const output = lastFrame();
368+
expect(output).toContain('Error: storage failure');
369+
expect(output).toContain('Press q to exit');
370+
});
371+
});

0 commit comments

Comments
 (0)