mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 22:02:59 -07:00
Prefer current-folder default resume and surface it in UI
This commit is contained in:
@@ -26,7 +26,9 @@ import * as ServerConfig from '@google/gemini-cli-core';
|
|||||||
|
|
||||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||||
import { ExtensionManager } from './extension-manager.js';
|
import { ExtensionManager } from './extension-manager.js';
|
||||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
import {
|
||||||
|
RESUME_LAST_IN_CURRENT_FOLDER,
|
||||||
|
} from '../utils/sessionUtils.js';
|
||||||
|
|
||||||
vi.mock('./trustedFolders.js', () => ({
|
vi.mock('./trustedFolders.js', () => ({
|
||||||
isWorkspaceTrusted: vi.fn(() => ({ isTrusted: true, source: 'file' })), // Default to trusted
|
isWorkspaceTrusted: vi.fn(() => ({ isTrusted: true, source: 'file' })), // Default to trusted
|
||||||
@@ -548,14 +550,27 @@ describe('parseArguments', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return RESUME_LATEST constant when --resume is passed without a value', async () => {
|
it('should return RESUME_LAST_IN_CURRENT_FOLDER when --resume is passed without a value', async () => {
|
||||||
const originalIsTTY = process.stdin.isTTY;
|
const originalIsTTY = process.stdin.isTTY;
|
||||||
process.stdin.isTTY = true; // Make it interactive to avoid validation error
|
process.stdin.isTTY = true; // Make it interactive to avoid validation error
|
||||||
process.argv = ['node', 'script.js', '--resume'];
|
process.argv = ['node', 'script.js', '--resume'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const argv = await parseArguments(createTestMergedSettings());
|
const argv = await parseArguments(createTestMergedSettings());
|
||||||
expect(argv.resume).toBe(RESUME_LATEST);
|
expect(argv.resume).toBe(RESUME_LAST_IN_CURRENT_FOLDER);
|
||||||
|
expect(argv.resume).toBe('__last_in_current_folder__');
|
||||||
|
} finally {
|
||||||
|
process.stdin.isTTY = originalIsTTY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep explicit --resume latest unchanged', async () => {
|
||||||
|
const originalIsTTY = process.stdin.isTTY;
|
||||||
|
process.stdin.isTTY = true;
|
||||||
|
process.argv = ['node', 'script.js', '--resume', 'latest'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const argv = await parseArguments(createTestMergedSettings());
|
||||||
expect(argv.resume).toBe('latest');
|
expect(argv.resume).toBe('latest');
|
||||||
} finally {
|
} finally {
|
||||||
process.stdin.isTTY = originalIsTTY;
|
process.stdin.isTTY = originalIsTTY;
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ import {
|
|||||||
|
|
||||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||||
import { resolvePath } from '../utils/resolvePath.js';
|
import { resolvePath } from '../utils/resolvePath.js';
|
||||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
import {
|
||||||
|
RESUME_LAST_IN_CURRENT_FOLDER,
|
||||||
|
RESUME_LATEST,
|
||||||
|
} from '../utils/sessionUtils.js';
|
||||||
|
|
||||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||||
import { createPolicyEngineConfig } from './policy.js';
|
import { createPolicyEngineConfig } from './policy.js';
|
||||||
@@ -81,7 +84,11 @@ export interface CliArgs {
|
|||||||
experimentalAcp: boolean | undefined;
|
experimentalAcp: boolean | undefined;
|
||||||
extensions: string[] | undefined;
|
extensions: string[] | undefined;
|
||||||
listExtensions: boolean | undefined;
|
listExtensions: boolean | undefined;
|
||||||
resume: string | typeof RESUME_LATEST | undefined;
|
resume:
|
||||||
|
| string
|
||||||
|
| typeof RESUME_LATEST
|
||||||
|
| typeof RESUME_LAST_IN_CURRENT_FOLDER
|
||||||
|
| undefined;
|
||||||
listSessions: boolean | undefined;
|
listSessions: boolean | undefined;
|
||||||
deleteSession: string | undefined;
|
deleteSession: string | undefined;
|
||||||
includeDirectories: string[] | undefined;
|
includeDirectories: string[] | undefined;
|
||||||
@@ -224,14 +231,14 @@ export async function parseArguments(
|
|||||||
// one, and not being passed at all.
|
// one, and not being passed at all.
|
||||||
skipValidation: true,
|
skipValidation: true,
|
||||||
description:
|
description:
|
||||||
'Resume a previous session by name, index, UUID, or "latest" (e.g. --resume my-chat-abc12)',
|
'Resume a previous session by name, index, UUID, or "latest". If passed without a value, resumes the most recent session from the current folder when available.',
|
||||||
coerce: (value: string): string => {
|
coerce: (value: string): string => {
|
||||||
// When --resume passed with a value (`gemini --resume 123`): value = "123" (string)
|
// When --resume passed with a value (`gemini --resume 123`): value = "123" (string)
|
||||||
// When --resume passed without a value (`gemini --resume`): value = "" (string)
|
// When --resume passed without a value (`gemini --resume`): value = "" (string)
|
||||||
// When --resume not passed at all: this `coerce` function is not called at all, and
|
// When --resume not passed at all: this `coerce` function is not called at all, and
|
||||||
// `yargsInstance.argv.resume` is undefined.
|
// `yargsInstance.argv.resume` is undefined.
|
||||||
if (value === '') {
|
if (value === '') {
|
||||||
return RESUME_LATEST;
|
return RESUME_LAST_IN_CURRENT_FOLDER;
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ import { runDeferredCommand } from './deferred.js';
|
|||||||
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
|
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
|
||||||
|
|
||||||
const SLOW_RENDER_MS = 200;
|
const SLOW_RENDER_MS = 200;
|
||||||
|
type ResumedSessionDataWithNotice = ResumedSessionData & {
|
||||||
|
resumeNotice?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function validateDnsResolutionOrder(
|
export function validateDnsResolutionOrder(
|
||||||
order: string | undefined,
|
order: string | undefined,
|
||||||
@@ -677,17 +680,19 @@ export async function main() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Handle --resume flag
|
// Handle --resume flag
|
||||||
let resumedSessionData: ResumedSessionData | undefined = undefined;
|
let resumedSessionData: ResumedSessionDataWithNotice | undefined = undefined;
|
||||||
if (argv.resume) {
|
if (argv.resume) {
|
||||||
const sessionSelector = new SessionSelector(config);
|
const sessionSelector = new SessionSelector(config);
|
||||||
try {
|
try {
|
||||||
const result = await sessionSelector.resolveSession(argv.resume);
|
const result = await sessionSelector.resolveSession(argv.resume);
|
||||||
resumedSessionData = {
|
const resolvedSessionData: ResumedSessionDataWithNotice = {
|
||||||
conversation: result.sessionData,
|
conversation: result.sessionData,
|
||||||
filePath: result.sessionPath,
|
filePath: result.sessionPath,
|
||||||
|
resumeNotice: result.resumeNotice,
|
||||||
};
|
};
|
||||||
|
resumedSessionData = resolvedSessionData;
|
||||||
// Use the existing session ID to continue recording to the same session
|
// Use the existing session ID to continue recording to the same session
|
||||||
config.setSessionId(resumedSessionData.conversation.sessionId);
|
config.setSessionId(resolvedSessionData.conversation.sessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
coreEvents.emitFeedback(
|
coreEvents.emitFeedback(
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@@ -221,6 +221,52 @@ describe('SessionBrowser component', () => {
|
|||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('highlights the latest session in the current folder', () => {
|
||||||
|
const currentFolderLatest = createSession({
|
||||||
|
id: 'folder-latest',
|
||||||
|
sessionName: 'folder-latest-abcde',
|
||||||
|
projectRoot: process.cwd(),
|
||||||
|
lastUpdated: '2025-01-03T10:00:00Z',
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
const currentFolderOlder = createSession({
|
||||||
|
id: 'folder-older',
|
||||||
|
sessionName: 'folder-older-abcde',
|
||||||
|
projectRoot: process.cwd(),
|
||||||
|
lastUpdated: '2025-01-01T10:00:00Z',
|
||||||
|
index: 1,
|
||||||
|
});
|
||||||
|
const otherFolder = createSession({
|
||||||
|
id: 'other-folder',
|
||||||
|
sessionName: 'other-folder-abcde',
|
||||||
|
projectRoot: '/tmp/other-project',
|
||||||
|
lastUpdated: '2025-01-04T10:00:00Z',
|
||||||
|
index: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = createMockConfig();
|
||||||
|
const onResumeSession = vi.fn();
|
||||||
|
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const onRenameSession = vi.fn();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<TestSessionBrowser
|
||||||
|
config={config}
|
||||||
|
onResumeSession={onResumeSession}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
onRenameSession={onRenameSession}
|
||||||
|
onExit={onExit}
|
||||||
|
testSessions={[currentFolderLatest, currentFolderOlder, otherFolder]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastFrame()).toContain(
|
||||||
|
'Latest in this folder: folder-latest-abcde',
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toContain('[last in folder]');
|
||||||
|
});
|
||||||
|
|
||||||
it('enters search mode, filters sessions, and renders match snippets', async () => {
|
it('enters search mode, filters sessions, and renders match snippets', async () => {
|
||||||
const searchSession = createSession({
|
const searchSession = createSession({
|
||||||
id: 'search1',
|
id: 'search1',
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export interface SessionBrowserState {
|
|||||||
endIndex: number;
|
endIndex: number;
|
||||||
/** Sessions visible on current page */
|
/** Sessions visible on current page */
|
||||||
visibleSessions: SessionInfo[];
|
visibleSessions: SessionInfo[];
|
||||||
|
/** Latest session id in the current folder (if any) */
|
||||||
|
lastSessionInCurrentFolderId?: string;
|
||||||
|
|
||||||
// State setters
|
// State setters
|
||||||
/** Update sessions array */
|
/** Update sessions array */
|
||||||
@@ -432,10 +434,35 @@ const isSessionFromCurrentFolder = (projectRoot?: string): boolean => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseTimestamp = (timestamp: string): number => {
|
||||||
|
const parsed = new Date(timestamp).getTime();
|
||||||
|
return Number.isNaN(parsed) ? 0 : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLastSessionInCurrentFolderId = (
|
||||||
|
sessions: SessionInfo[],
|
||||||
|
): string | undefined => {
|
||||||
|
const currentFolderSessions = sessions.filter((session) =>
|
||||||
|
isSessionFromCurrentFolder(session.projectRoot),
|
||||||
|
);
|
||||||
|
if (currentFolderSessions.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = currentFolderSessions.reduce((best, candidate) =>
|
||||||
|
parseTimestamp(candidate.lastUpdated) > parseTimestamp(best.lastUpdated)
|
||||||
|
? candidate
|
||||||
|
: best,
|
||||||
|
);
|
||||||
|
return latest.id;
|
||||||
|
};
|
||||||
|
|
||||||
const SessionDetailsPanel = ({
|
const SessionDetailsPanel = ({
|
||||||
session,
|
session,
|
||||||
|
lastSessionInCurrentFolderId,
|
||||||
}: {
|
}: {
|
||||||
session: SessionInfo | undefined;
|
session: SessionInfo | undefined;
|
||||||
|
lastSessionInCurrentFolderId?: string;
|
||||||
}): React.JSX.Element | null => {
|
}): React.JSX.Element | null => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
@@ -462,10 +489,38 @@ const SessionDetailsPanel = ({
|
|||||||
{formatTimestamp(session.lastUpdated)}
|
{formatTimestamp(session.lastUpdated)}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
{session.id === lastSessionInCurrentFolderId && (
|
||||||
|
<Text color={Colors.AccentGreen}>
|
||||||
|
This is your latest session in this folder.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CurrentFolderSessionHint = ({
|
||||||
|
state,
|
||||||
|
}: {
|
||||||
|
state: SessionBrowserState;
|
||||||
|
}): React.JSX.Element | null => {
|
||||||
|
if (!state.lastSessionInCurrentFolderId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestCurrentFolderSession = state.sessions.find(
|
||||||
|
(session) => session.id === state.lastSessionInCurrentFolderId,
|
||||||
|
);
|
||||||
|
if (!latestCurrentFolderSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color={Colors.AccentGreen}>
|
||||||
|
Latest in this folder: {latestCurrentFolderSession.sessionName}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match snippet display component for search results.
|
* Match snippet display component for search results.
|
||||||
*/
|
*/
|
||||||
@@ -518,6 +573,8 @@ const SessionItem = ({
|
|||||||
state.startIndex + state.visibleSessions.indexOf(session);
|
state.startIndex + state.visibleSessions.indexOf(session);
|
||||||
const isActive = originalIndex === state.activeIndex;
|
const isActive = originalIndex === state.activeIndex;
|
||||||
const isDisabled = session.isCurrentSession;
|
const isDisabled = session.isCurrentSession;
|
||||||
|
const isLastSessionInCurrentFolder =
|
||||||
|
session.id === state.lastSessionInCurrentFolderId;
|
||||||
const isCurrentFolder = isSessionFromCurrentFolder(session.projectRoot);
|
const isCurrentFolder = isSessionFromCurrentFolder(session.projectRoot);
|
||||||
const activeColor = isCurrentFolder ? Colors.AccentCyan : Colors.AccentPurple;
|
const activeColor = isCurrentFolder ? Colors.AccentCyan : Colors.AccentPurple;
|
||||||
const textColor = (c: string = Colors.Foreground) => {
|
const textColor = (c: string = Colors.Foreground) => {
|
||||||
@@ -535,6 +592,9 @@ const SessionItem = ({
|
|||||||
if (session.isCurrentSession) {
|
if (session.isCurrentSession) {
|
||||||
additionalInfo = ' (current)';
|
additionalInfo = ' (current)';
|
||||||
}
|
}
|
||||||
|
if (isLastSessionInCurrentFolder) {
|
||||||
|
additionalInfo += ' [last in folder]';
|
||||||
|
}
|
||||||
|
|
||||||
// Show match snippets if searching and matches exist
|
// Show match snippets if searching and matches exist
|
||||||
if (
|
if (
|
||||||
@@ -606,7 +666,15 @@ const SessionItem = ({
|
|||||||
<Text color={textColor(Colors.Comment)} dimColor={isDisabled}>
|
<Text color={textColor(Colors.Comment)} dimColor={isDisabled}>
|
||||||
{truncatedMessage}
|
{truncatedMessage}
|
||||||
{additionalInfo && (
|
{additionalInfo && (
|
||||||
<Text color={textColor(Colors.Gray)} dimColor bold={false}>
|
<Text
|
||||||
|
color={
|
||||||
|
isLastSessionInCurrentFolder && !isDisabled
|
||||||
|
? Colors.AccentGreen
|
||||||
|
: textColor(Colors.Gray)
|
||||||
|
}
|
||||||
|
dimColor={isDisabled}
|
||||||
|
bold={false}
|
||||||
|
>
|
||||||
{additionalInfo}
|
{additionalInfo}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -686,6 +754,10 @@ export const useSessionBrowserState = (
|
|||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
const totalSessions = filteredAndSortedSessions.length;
|
const totalSessions = filteredAndSortedSessions.length;
|
||||||
|
const lastSessionInCurrentFolderId = useMemo(
|
||||||
|
() => getLastSessionInCurrentFolderId(sessions),
|
||||||
|
[sessions],
|
||||||
|
);
|
||||||
const startIndex = scrollOffset;
|
const startIndex = scrollOffset;
|
||||||
const endIndex = Math.min(scrollOffset + SESSIONS_PER_PAGE, totalSessions);
|
const endIndex = Math.min(scrollOffset + SESSIONS_PER_PAGE, totalSessions);
|
||||||
const visibleSessions = filteredAndSortedSessions.slice(startIndex, endIndex);
|
const visibleSessions = filteredAndSortedSessions.slice(startIndex, endIndex);
|
||||||
@@ -718,6 +790,7 @@ export const useSessionBrowserState = (
|
|||||||
terminalWidth,
|
terminalWidth,
|
||||||
filteredAndSortedSessions,
|
filteredAndSortedSessions,
|
||||||
totalSessions,
|
totalSessions,
|
||||||
|
lastSessionInCurrentFolderId,
|
||||||
startIndex,
|
startIndex,
|
||||||
endIndex,
|
endIndex,
|
||||||
visibleSessions,
|
visibleSessions,
|
||||||
@@ -734,6 +807,8 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
|||||||
setSessions,
|
setSessions,
|
||||||
setLoading,
|
setLoading,
|
||||||
setError,
|
setError,
|
||||||
|
setActiveIndex,
|
||||||
|
setScrollOffset,
|
||||||
isSearchMode,
|
isSearchMode,
|
||||||
hasLoadedFullContent,
|
hasLoadedFullContent,
|
||||||
setHasLoadedFullContent,
|
setHasLoadedFullContent,
|
||||||
@@ -747,6 +822,25 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
|||||||
config.getProjectRoot(),
|
config.getProjectRoot(),
|
||||||
);
|
);
|
||||||
setSessions(sessionData);
|
setSessions(sessionData);
|
||||||
|
const latestCurrentFolderSessionId =
|
||||||
|
getLastSessionInCurrentFolderId(sessionData);
|
||||||
|
if (latestCurrentFolderSessionId) {
|
||||||
|
const sortedByDefault = sortSessions(sessionData, 'date', false);
|
||||||
|
const targetIndex = sortedByDefault.findIndex(
|
||||||
|
(session) => session.id === latestCurrentFolderSessionId,
|
||||||
|
);
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
setActiveIndex(targetIndex);
|
||||||
|
setScrollOffset(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
targetIndex >= SESSIONS_PER_PAGE
|
||||||
|
? targetIndex - SESSIONS_PER_PAGE + 1
|
||||||
|
: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@@ -758,7 +852,14 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
loadSessions();
|
loadSessions();
|
||||||
}, [config, setSessions, setLoading, setError]);
|
}, [
|
||||||
|
config,
|
||||||
|
setSessions,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
setActiveIndex,
|
||||||
|
setScrollOffset,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadFullContent = async () => {
|
const loadFullContent = async () => {
|
||||||
@@ -1056,6 +1157,7 @@ export function SessionBrowserView({
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
<SessionListHeader state={state} />
|
<SessionListHeader state={state} />
|
||||||
|
<CurrentFolderSessionHint state={state} />
|
||||||
|
|
||||||
{state.isSearchMode && <SearchModeDisplay state={state} />}
|
{state.isSearchMode && <SearchModeDisplay state={state} />}
|
||||||
{state.isRenameMode && <RenameModeDisplay state={state} />}
|
{state.isRenameMode && <RenameModeDisplay state={state} />}
|
||||||
@@ -1066,7 +1168,12 @@ export function SessionBrowserView({
|
|||||||
<SessionList state={state} formatRelativeTime={formatRelativeTime} />
|
<SessionList state={state} formatRelativeTime={formatRelativeTime} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!state.isSearchMode && <SessionDetailsPanel session={selectedSession} />}
|
{!state.isSearchMode && (
|
||||||
|
<SessionDetailsPanel
|
||||||
|
session={selectedSession}
|
||||||
|
lastSessionInCurrentFolderId={state.lastSessionInCurrentFolderId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import type {
|
|||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
import type { HistoryItemWithoutId } from '../types.js';
|
import type { HistoryItemWithoutId } from '../types.js';
|
||||||
|
import { MessageType } from '../types.js';
|
||||||
|
|
||||||
|
type ResumedSessionDataWithNotice = ResumedSessionData & {
|
||||||
|
resumeNotice?: string;
|
||||||
|
};
|
||||||
|
|
||||||
describe('useSessionResume', () => {
|
describe('useSessionResume', () => {
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
@@ -75,7 +80,7 @@ describe('useSessionResume', () => {
|
|||||||
{ role: 'model' as const, parts: [{ text: 'Hi there!' }] },
|
{ role: 'model' as const, parts: [{ text: 'Hi there!' }] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const resumedData: ResumedSessionData = {
|
const resumedData: ResumedSessionDataWithNotice = {
|
||||||
conversation: {
|
conversation: {
|
||||||
sessionId: 'test-123',
|
sessionId: 'test-123',
|
||||||
projectHash: 'project-123',
|
projectHash: 'project-123',
|
||||||
@@ -130,7 +135,7 @@ describe('useSessionResume', () => {
|
|||||||
const clientHistory = [
|
const clientHistory = [
|
||||||
{ role: 'user' as const, parts: [{ text: 'Hello' }] },
|
{ role: 'user' as const, parts: [{ text: 'Hello' }] },
|
||||||
];
|
];
|
||||||
const resumedData: ResumedSessionData = {
|
const resumedData: ResumedSessionDataWithNotice = {
|
||||||
conversation: {
|
conversation: {
|
||||||
sessionId: 'test-123',
|
sessionId: 'test-123',
|
||||||
projectHash: 'project-123',
|
projectHash: 'project-123',
|
||||||
@@ -157,7 +162,7 @@ describe('useSessionResume', () => {
|
|||||||
it('should handle empty history arrays', async () => {
|
it('should handle empty history arrays', async () => {
|
||||||
const { result } = renderHook(() => useSessionResume(getDefaultProps()));
|
const { result } = renderHook(() => useSessionResume(getDefaultProps()));
|
||||||
|
|
||||||
const resumedData: ResumedSessionData = {
|
const resumedData: ResumedSessionDataWithNotice = {
|
||||||
conversation: {
|
conversation: {
|
||||||
sessionId: 'test-123',
|
sessionId: 'test-123',
|
||||||
projectHash: 'project-123',
|
projectHash: 'project-123',
|
||||||
@@ -178,6 +183,35 @@ describe('useSessionResume', () => {
|
|||||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith([], resumedData);
|
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith([], resumedData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add an info notice to history when resumeNotice is provided', async () => {
|
||||||
|
const { result } = renderHook(() => useSessionResume(getDefaultProps()));
|
||||||
|
|
||||||
|
const resumedData: ResumedSessionDataWithNotice = {
|
||||||
|
conversation: {
|
||||||
|
sessionId: 'test-123',
|
||||||
|
projectHash: 'project-123',
|
||||||
|
startTime: '2025-01-01T00:00:00Z',
|
||||||
|
lastUpdated: '2025-01-01T01:00:00Z',
|
||||||
|
messages: [] as MessageRecord[],
|
||||||
|
},
|
||||||
|
filePath: '/path/to/session.json',
|
||||||
|
resumeNotice: 'Resumed most recent session in this folder: test-session-abcde',
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.loadHistoryForResume([], [], resumedData);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: resumedData.resumeNotice,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should restore directories from resumed session data', async () => {
|
it('should restore directories from resumed session data', async () => {
|
||||||
const mockAddDirectories = vi
|
const mockAddDirectories = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -11,17 +11,22 @@ import {
|
|||||||
type ResumedSessionData,
|
type ResumedSessionData,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type { Part } from '@google/genai';
|
import type { Part } from '@google/genai';
|
||||||
|
import { MessageType } from '../types.js';
|
||||||
import type { HistoryItemWithoutId } from '../types.js';
|
import type { HistoryItemWithoutId } from '../types.js';
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
|
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
|
||||||
|
|
||||||
|
type ResumedSessionDataWithNotice = ResumedSessionData & {
|
||||||
|
resumeNotice?: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface UseSessionResumeParams {
|
interface UseSessionResumeParams {
|
||||||
config: Config;
|
config: Config;
|
||||||
historyManager: UseHistoryManagerReturn;
|
historyManager: UseHistoryManagerReturn;
|
||||||
refreshStatic: () => void;
|
refreshStatic: () => void;
|
||||||
isGeminiClientInitialized: boolean;
|
isGeminiClientInitialized: boolean;
|
||||||
setQuittingMessages: (messages: null) => void;
|
setQuittingMessages: (messages: null) => void;
|
||||||
resumedSessionData?: ResumedSessionData;
|
resumedSessionData?: ResumedSessionDataWithNotice;
|
||||||
isAuthenticating: boolean;
|
isAuthenticating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +59,7 @@ export function useSessionResume({
|
|||||||
async (
|
async (
|
||||||
uiHistory: HistoryItemWithoutId[],
|
uiHistory: HistoryItemWithoutId[],
|
||||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||||
resumedData: ResumedSessionData,
|
resumedData: ResumedSessionDataWithNotice,
|
||||||
) => {
|
) => {
|
||||||
// Wait for the client.
|
// Wait for the client.
|
||||||
if (!isGeminiClientInitialized) {
|
if (!isGeminiClientInitialized) {
|
||||||
@@ -84,6 +89,17 @@ export function useSessionResume({
|
|||||||
|
|
||||||
// Give the history to the Gemini client.
|
// Give the history to the Gemini client.
|
||||||
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
|
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
|
||||||
|
|
||||||
|
if (resumedData.resumeNotice) {
|
||||||
|
historyManagerRef.current.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: resumedData.resumeNotice,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
coreEvents.emitFeedback(
|
coreEvents.emitFeedback(
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
RESUME_LAST_IN_CURRENT_FOLDER,
|
||||||
SessionSelector,
|
SessionSelector,
|
||||||
extractFirstUserMessage,
|
extractFirstUserMessage,
|
||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
@@ -49,11 +50,14 @@ describe('SessionSelector', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const createChatsDir = async () => {
|
const createChatsDir = async (
|
||||||
const projectDir = path.join(tmpDir, 'project-a');
|
projectId = 'project-a',
|
||||||
|
root = projectRoot,
|
||||||
|
) => {
|
||||||
|
const projectDir = path.join(tmpDir, projectId);
|
||||||
const chatsDir = path.join(projectDir, 'chats');
|
const chatsDir = path.join(projectDir, 'chats');
|
||||||
await fs.mkdir(chatsDir, { recursive: true });
|
await fs.mkdir(chatsDir, { recursive: true });
|
||||||
await fs.writeFile(path.join(projectDir, '.project_root'), projectRoot);
|
await fs.writeFile(path.join(projectDir, '.project_root'), root);
|
||||||
return chatsDir;
|
return chatsDir;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,6 +249,146 @@ describe('SessionSelector', () => {
|
|||||||
expect(result.sessionData.messages[0].content).toBe('Latest session');
|
expect(result.sessionData.messages[0].content).toBe('Latest session');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should resolve the latest session from the current folder for bare --resume', async () => {
|
||||||
|
const sessionInCurrentFolderOld = randomUUID();
|
||||||
|
const sessionInCurrentFolderLatest = randomUUID();
|
||||||
|
const sessionInOtherFolderLatest = randomUUID();
|
||||||
|
|
||||||
|
const projectAChatsDir = await createChatsDir('project-a', projectRoot);
|
||||||
|
const projectBChatsDir = await createChatsDir(
|
||||||
|
'project-b',
|
||||||
|
'/workspace/project-b',
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(
|
||||||
|
projectAChatsDir,
|
||||||
|
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionInCurrentFolderOld.slice(0, 8)}.json`,
|
||||||
|
),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
sessionId: sessionInCurrentFolderOld,
|
||||||
|
projectHash: 'project-a-hash',
|
||||||
|
startTime: '2024-01-01T10:00:00.000Z',
|
||||||
|
lastUpdated: '2024-01-01T10:05:00.000Z',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
content: 'old current-folder session',
|
||||||
|
id: 'msg1',
|
||||||
|
timestamp: '2024-01-01T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(
|
||||||
|
projectAChatsDir,
|
||||||
|
`${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionInCurrentFolderLatest.slice(0, 8)}.json`,
|
||||||
|
),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
sessionId: sessionInCurrentFolderLatest,
|
||||||
|
projectHash: 'project-a-hash',
|
||||||
|
startTime: '2024-01-01T11:00:00.000Z',
|
||||||
|
lastUpdated: '2024-01-01T11:30:00.000Z',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
content: 'latest current-folder session',
|
||||||
|
id: 'msg1',
|
||||||
|
timestamp: '2024-01-01T11:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(
|
||||||
|
projectBChatsDir,
|
||||||
|
`${SESSION_FILE_PREFIX}2024-01-01T12-00-${sessionInOtherFolderLatest.slice(0, 8)}.json`,
|
||||||
|
),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
sessionId: sessionInOtherFolderLatest,
|
||||||
|
projectHash: 'project-b-hash',
|
||||||
|
startTime: '2024-01-01T12:00:00.000Z',
|
||||||
|
lastUpdated: '2024-01-01T12:30:00.000Z',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
content: 'latest other-folder session',
|
||||||
|
id: 'msg1',
|
||||||
|
timestamp: '2024-01-01T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionSelector = new SessionSelector(config);
|
||||||
|
const result = await sessionSelector.resolveSession(
|
||||||
|
RESUME_LAST_IN_CURRENT_FOLDER,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.sessionData.sessionId).toBe(sessionInCurrentFolderLatest);
|
||||||
|
expect(result.resumeNotice).toContain(
|
||||||
|
'Resumed most recent session in this folder',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to global latest when current folder has no sessions for bare --resume', async () => {
|
||||||
|
const sessionInOtherFolderLatest = randomUUID();
|
||||||
|
const projectBChatsDir = await createChatsDir(
|
||||||
|
'project-b',
|
||||||
|
'/workspace/project-b',
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(
|
||||||
|
projectBChatsDir,
|
||||||
|
`${SESSION_FILE_PREFIX}2024-01-01T12-00-${sessionInOtherFolderLatest.slice(0, 8)}.json`,
|
||||||
|
),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
sessionId: sessionInOtherFolderLatest,
|
||||||
|
projectHash: 'project-b-hash',
|
||||||
|
startTime: '2024-01-01T12:00:00.000Z',
|
||||||
|
lastUpdated: '2024-01-01T12:30:00.000Z',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
content: 'latest other-folder session',
|
||||||
|
id: 'msg1',
|
||||||
|
timestamp: '2024-01-01T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionSelector = new SessionSelector(config);
|
||||||
|
const result = await sessionSelector.resolveSession(
|
||||||
|
RESUME_LAST_IN_CURRENT_FOLDER,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.sessionData.sessionId).toBe(sessionInOtherFolderLatest);
|
||||||
|
expect(result.resumeNotice).toContain(
|
||||||
|
'No previous sessions found in this folder',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should deduplicate sessions by ID', async () => {
|
it('should deduplicate sessions by ID', async () => {
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Constant for the resume "latest" identifier.
|
* Constant for the resume "latest" identifier.
|
||||||
* Used when --resume is passed without a value to select the most recent session.
|
* Used when --resume latest is passed to select the most recent session globally.
|
||||||
*/
|
*/
|
||||||
export const RESUME_LATEST = 'latest';
|
export const RESUME_LATEST = 'latest';
|
||||||
|
/**
|
||||||
|
* Internal identifier used when --resume is passed without a value.
|
||||||
|
* Prefers the most recent session from the current folder, falling back to global latest.
|
||||||
|
*/
|
||||||
|
export const RESUME_LAST_IN_CURRENT_FOLDER = '__last_in_current_folder__';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error codes for session-related errors.
|
* Error codes for session-related errors.
|
||||||
@@ -157,6 +162,7 @@ export interface SessionSelectionResult {
|
|||||||
sessionPath: string;
|
sessionPath: string;
|
||||||
sessionData: ConversationRecord;
|
sessionData: ConversationRecord;
|
||||||
displayInfo: string;
|
displayInfo: string;
|
||||||
|
resumeNotice?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenameSessionResult {
|
export interface RenameSessionResult {
|
||||||
@@ -638,6 +644,35 @@ export async function deleteSessionArtifacts(session: SessionInfo): Promise<void
|
|||||||
export class SessionSelector {
|
export class SessionSelector {
|
||||||
constructor(private config: Config) {}
|
constructor(private config: Config) {}
|
||||||
|
|
||||||
|
private getLatestSession(sessions: SessionInfo[]): SessionInfo {
|
||||||
|
const sorted = [...sessions].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||||
|
);
|
||||||
|
return sorted[sorted.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLatestSessionFromCurrentFolder(
|
||||||
|
sessions: SessionInfo[],
|
||||||
|
): SessionInfo | undefined {
|
||||||
|
const currentProjectRoot = this.config.getProjectRoot();
|
||||||
|
const matchingSessions = sessions.filter(
|
||||||
|
(session) =>
|
||||||
|
session.projectRoot &&
|
||||||
|
path.resolve(session.projectRoot) === path.resolve(currentProjectRoot),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingSessions.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...matchingSessions].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.lastUpdated).getTime() - new Date(b.lastUpdated).getTime(),
|
||||||
|
);
|
||||||
|
return sorted[sorted.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all available sessions globally across projects.
|
* Lists all available sessions globally across projects.
|
||||||
*/
|
*/
|
||||||
@@ -707,20 +742,31 @@ export class SessionSelector {
|
|||||||
async resolveSession(resumeArg: string): Promise<SessionSelectionResult> {
|
async resolveSession(resumeArg: string): Promise<SessionSelectionResult> {
|
||||||
let selectedSession: SessionInfo;
|
let selectedSession: SessionInfo;
|
||||||
|
|
||||||
if (resumeArg === RESUME_LATEST) {
|
let resumeNotice: string | undefined;
|
||||||
|
if (resumeArg === RESUME_LAST_IN_CURRENT_FOLDER) {
|
||||||
const sessions = await this.listSessions();
|
const sessions = await this.listSessions();
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
throw new Error('No previous sessions found.');
|
throw new Error('No previous sessions found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by startTime (oldest first, so newest sessions get highest numbers)
|
const currentFolderSession =
|
||||||
sessions.sort(
|
this.getLatestSessionFromCurrentFolder(sessions);
|
||||||
(a, b) =>
|
if (currentFolderSession) {
|
||||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
selectedSession = currentFolderSession;
|
||||||
);
|
resumeNotice = `Resumed most recent session in this folder: ${selectedSession.sessionName}`;
|
||||||
|
} else {
|
||||||
|
selectedSession = this.getLatestSession(sessions);
|
||||||
|
resumeNotice = `No previous sessions found in this folder. Resumed latest global session: ${selectedSession.sessionName}`;
|
||||||
|
}
|
||||||
|
} else if (resumeArg === RESUME_LATEST) {
|
||||||
|
const sessions = await this.listSessions();
|
||||||
|
|
||||||
selectedSession = sessions[sessions.length - 1];
|
if (sessions.length === 0) {
|
||||||
|
throw new Error('No previous sessions found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedSession = this.getLatestSession(sessions);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
selectedSession = await this.findSession(resumeArg);
|
selectedSession = await this.findSession(resumeArg);
|
||||||
@@ -736,7 +782,7 @@ export class SessionSelector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.selectSession(selectedSession);
|
return this.selectSession(selectedSession, resumeNotice);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -744,6 +790,7 @@ export class SessionSelector {
|
|||||||
*/
|
*/
|
||||||
private async selectSession(
|
private async selectSession(
|
||||||
sessionInfo: SessionInfo,
|
sessionInfo: SessionInfo,
|
||||||
|
resumeNotice?: string,
|
||||||
): Promise<SessionSelectionResult> {
|
): Promise<SessionSelectionResult> {
|
||||||
const sessionPath = sessionInfo.sessionPath;
|
const sessionPath = sessionInfo.sessionPath;
|
||||||
|
|
||||||
@@ -758,6 +805,7 @@ export class SessionSelector {
|
|||||||
sessionPath: sessionInfo.sessionPath,
|
sessionPath: sessionInfo.sessionPath,
|
||||||
sessionData,
|
sessionData,
|
||||||
displayInfo,
|
displayInfo,
|
||||||
|
resumeNotice,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
Reference in New Issue
Block a user