feat(sessions): add /resume slash command to open the session browser (#13621)

This commit is contained in:
bl-ue
2025-11-25 11:54:09 -07:00
committed by GitHub
parent 098e5c281c
commit 94c3eecb99
16 changed files with 142 additions and 36 deletions

View File

@@ -75,6 +75,7 @@ vi.mock('../ui/commands/modelCommand.js', () => ({
}));
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
vi.mock('../ui/commands/resumeCommand.js', () => ({ resumeCommand: {} }));
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));

View File

@@ -32,6 +32,7 @@ import { policiesCommand } from '../ui/commands/policiesCommand.js';
import { profileCommand } from '../ui/commands/profileCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
@@ -82,6 +83,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(isDevelopment ? [profileCommand] : []),
quitCommand,
restoreCommand(this.config),
resumeCommand,
statsCommand,
themeCommand,
toolsCommand,

View File

@@ -148,6 +148,10 @@ const mockUIActions: UIActions = {
closeSettingsDialog: vi.fn(),
closeModelDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openSessionBrowser: vi.fn(),
closeSessionBrowser: vi.fn(),
handleResumeSession: vi.fn(),
handleDeleteSession: vi.fn(),
closePermissionsDialog: vi.fn(),
setShellModeActive: vi.fn(),
vimHandleInput: vi.fn(),

View File

@@ -99,6 +99,7 @@ import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
import type { SessionInfo } from '../utils/sessionUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useSessionStats } from './contexts/SessionContext.js';
@@ -108,9 +109,10 @@ import {
useExtensionUpdates,
} from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useSessionResume } from './hooks/useSessionResume.js';
import { type ExtensionManager } from '../config/extension-manager.js';
import { requestConsentInteractive } from '../config/extensions/consent.js';
import { useSessionBrowser } from './hooks/useSessionBrowser.js';
import { useSessionResume } from './hooks/useSessionResume.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
@@ -436,7 +438,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Session browser and resume functionality
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
useSessionResume({
const { loadHistoryForResume } = useSessionResume({
config,
historyManager,
refreshStatic,
@@ -445,6 +447,20 @@ export const AppContainer = (props: AppContainerProps) => {
resumedSessionData,
isAuthenticating,
});
const {
isSessionBrowserOpen,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
handleDeleteSession: handleDeleteSessionSync,
} = useSessionBrowser(config, loadHistoryForResume);
// Wrap handleDeleteSession to return a Promise for UIActions interface
const handleDeleteSession = useCallback(
async (session: SessionInfo): Promise<void> => {
handleDeleteSessionSync(session);
},
[handleDeleteSessionSync],
);
// Create handleAuthSelect wrapper for backward compatibility
const handleAuthSelect = useCallback(
@@ -570,6 +586,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openEditorDialog,
openPrivacyNotice: () => setShowPrivacyNotice(true),
openSettingsDialog,
openSessionBrowser,
openModelDialog,
openPermissionsDialog,
quit: (messages: HistoryItem[]) => {
@@ -590,6 +607,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openThemeDialog,
openEditorDialog,
openSettingsDialog,
openSessionBrowser,
openModelDialog,
setQuittingMessages,
setDebugMessage,
@@ -1330,6 +1348,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showPrivacyNotice ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
isSessionBrowserOpen ||
isAuthDialogOpen ||
authState === AuthState.AwaitingApiKeyInput;
@@ -1402,6 +1421,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
debugMessage,
quittingMessages,
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
permissionsDialogProps,
@@ -1492,6 +1512,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
debugMessage,
quittingMessages,
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
permissionsDialogProps,
@@ -1601,6 +1622,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
popAllMessages,
handleApiKeySubmit,
@@ -1632,6 +1657,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
popAllMessages,
handleApiKeySubmit,

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
OpenDialogActionReturn,
CommandContext,
SlashCommand,
} from './types.js';
import { CommandKind } from './types.js';
export const resumeCommand: SlashCommand = {
name: 'resume',
description: 'Browse and resume auto-saved conversations',
kind: CommandKind.BUILT_IN,
action: async (
_context: CommandContext,
_args: string,
): Promise<OpenDialogActionReturn> => ({
type: 'dialog',
dialog: 'sessionBrowser',
}),
};

View File

@@ -123,6 +123,7 @@ export interface OpenDialogActionReturn {
| 'editor'
| 'privacy'
| 'settings'
| 'sessionBrowser'
| 'model'
| 'permissions';
}

View File

@@ -20,6 +20,7 @@ import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { theme } from '../semantic-colors.js';
@@ -210,6 +211,16 @@ export const DialogManager = ({
/>
);
}
if (uiState.isSessionBrowserOpen) {
return (
<SessionBrowser
config={config}
onResumeSession={uiActions.handleResumeSession}
onDeleteSession={uiActions.handleDeleteSession}
onExit={uiActions.closeSessionBrowser}
/>
);
}
if (uiState.isPermissionsDialogOpen) {
return (

View File

@@ -86,6 +86,12 @@ const mockSlashCommands: SlashCommand[] = [
},
],
},
{
name: 'resume',
description: 'Browse and resume sessions',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
},
];
describe('InputPrompt', () => {

View File

@@ -57,7 +57,10 @@ vi.mock('./SessionBrowser.js', async (importOriginal) => {
moveSelection,
cycleSortOrder,
props.onResumeSession,
props.onDeleteSession,
props.onDeleteSession ??
(async () => {
// no-op delete handler for tests that don't care about deletion
}),
props.onExit,
);
@@ -146,12 +149,14 @@ describe('SessionBrowser component', () => {
it('shows empty state when no sessions exist', () => {
const config = createMockConfig();
const onResumeSession = vi.fn();
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
const { lastFrame } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onDeleteSession={onDeleteSession}
onExit={onExit}
testSessions={[]}
/>,
@@ -181,12 +186,14 @@ describe('SessionBrowser component', () => {
const config = createMockConfig();
const onResumeSession = vi.fn();
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
const { lastFrame } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onDeleteSession={onDeleteSession}
onExit={onExit}
testSessions={[session1, session2]}
/>,
@@ -230,12 +237,14 @@ describe('SessionBrowser component', () => {
const config = createMockConfig();
const onResumeSession = vi.fn();
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
const { lastFrame } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onDeleteSession={onDeleteSession}
onExit={onExit}
testSessions={[searchSession, otherSession]}
/>,
@@ -279,12 +288,14 @@ describe('SessionBrowser component', () => {
const config = createMockConfig();
const onResumeSession = vi.fn();
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
const { lastFrame } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onDeleteSession={onDeleteSession}
onExit={onExit}
testSessions={[session1, session2]}
/>,
@@ -323,7 +334,7 @@ describe('SessionBrowser component', () => {
const config = createMockConfig();
const onResumeSession = vi.fn();
const onDeleteSession = vi.fn();
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
render(
@@ -348,12 +359,14 @@ describe('SessionBrowser component', () => {
it('shows an error state when loading sessions fails', () => {
const config = createMockConfig();
const onResumeSession = vi.fn();
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
const { lastFrame } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onDeleteSession={onDeleteSession}
onExit={onExit}
testError="storage failure"
/>,

View File

@@ -28,7 +28,7 @@ export interface SessionBrowserProps {
/** Callback when user selects a session to resume */
onResumeSession: (session: SessionInfo) => void;
/** Callback when user deletes a session */
onDeleteSession?: (session: SessionInfo) => void;
onDeleteSession: (session: SessionInfo) => Promise<void>;
/** Callback when user exits the session browser */
onExit: () => void;
}
@@ -463,9 +463,11 @@ const SessionItem = ({
}
}
// 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,
terminalWidth - FIXED_SESSION_COLUMNS_WIDTH - reservedForMeta,
);
const truncatedMessage =
@@ -759,7 +761,7 @@ export const useSessionBrowserInput = (
moveSelection: (delta: number) => void,
cycleSortOrder: () => void,
onResumeSession: (session: SessionInfo) => void,
onDeleteSession: ((session: SessionInfo) => void) | undefined,
onDeleteSession: (session: SessionInfo) => Promise<void>,
onExit: () => void,
) => {
useKeypress(
@@ -817,38 +819,35 @@ export const useSessionBrowserInput = (
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),
if (selectedSession && !selectedSession.isCurrentSession) {
onDeleteSession(selectedSession)
.then(() => {
// Remove the session from the state
state.setSessions(
state.sessions.filter((s) => s.id !== selectedSession.id),
);
}
} catch (error) {
state.setError(
`Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
// 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') {
else if (key.sequence === 'u') {
moveSelection(-Math.round(SESSIONS_PER_PAGE / 2));
} else if (key.sequence === 'u') {
} else if (key.sequence === 'd') {
moveSelection(Math.round(SESSIONS_PER_PAGE / 2));
}
}

View File

@@ -12,6 +12,7 @@ import { type AuthType, type EditorType } from '@google/gemini-cli-core';
import { type LoadableSettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js';
import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js';
import type { SessionInfo } from '../../utils/sessionUtils.js';
export interface UIActions {
handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void;
@@ -45,6 +46,10 @@ export interface UIActions {
handleProQuotaChoice: (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
openSessionBrowser: () => void;
closeSessionBrowser: () => void;
handleResumeSession: (session: SessionInfo) => Promise<void>;
handleDeleteSession: (session: SessionInfo) => Promise<void>;
setQueueErrorMessage: (message: string | null) => void;
popAllMessages: (onPop: (messages: string | undefined) => void) => void;
handleApiKeySubmit: (apiKey: string) => Promise<void>;

View File

@@ -59,6 +59,7 @@ export interface UIState {
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
isSessionBrowserOpen: boolean;
isModelDialogOpen: boolean;
isPermissionsDialogOpen: boolean;
permissionsDialogProps: { targetDirectory?: string } | null;

View File

@@ -190,6 +190,7 @@ describe('useSlashCommandProcessor', () => {
openEditorDialog: vi.fn(),
openPrivacyNotice: vi.fn(),
openSettingsDialog: vi.fn(),
openSessionBrowser: vi.fn(),
openModelDialog: mockOpenModelDialog,
openPermissionsDialog: vi.fn(),
quit: mockSetQuittingMessages,

View File

@@ -52,6 +52,7 @@ interface SlashCommandProcessorActions {
openEditorDialog: () => void;
openPrivacyNotice: () => void;
openSettingsDialog: () => void;
openSessionBrowser: () => void;
openModelDialog: () => void;
openPermissionsDialog: (props?: { targetDirectory?: string }) => void;
quit: (messages: HistoryItem[]) => void;
@@ -410,6 +411,9 @@ export const useSlashCommandProcessor = (
case 'privacy':
actions.openPrivacyNotice();
return { type: 'handled' };
case 'sessionBrowser':
actions.openSessionBrowser();
return { type: 'handled' };
case 'settings':
actions.openSettingsDialog();
return { type: 'handled' };

View File

@@ -91,12 +91,16 @@ export const useSessionBrowser = (
*/
handleDeleteSession: useCallback(
(session: SessionInfo) => {
// Note: Chat sessions are stored on disk using a filename derived from
// the session, e.g. "session-<timestamp>-<sessionIdPrefix>.json".
// The ChatRecordingService.deleteSession API expects this file basename
// (without the ".json" extension), not the full session UUID.
try {
const chatRecordingService = config
.getGeminiClient()
?.getChatRecordingService();
if (chatRecordingService) {
chatRecordingService.deleteSession(session.id);
chatRecordingService.deleteSession(session.file);
}
} catch (error) {
console.error('Error deleting session:', error);