mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 13:53:02 -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 { 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', () => ({
|
||||
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;
|
||||
process.stdin.isTTY = true; // Make it interactive to avoid validation error
|
||||
process.argv = ['node', 'script.js', '--resume'];
|
||||
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
|
||||
@@ -53,7 +53,10 @@ import {
|
||||
|
||||
import { loadSandboxConfig } from './sandboxConfig.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 { createPolicyEngineConfig } from './policy.js';
|
||||
@@ -81,7 +84,11 @@ export interface CliArgs {
|
||||
experimentalAcp: boolean | undefined;
|
||||
extensions: string[] | 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;
|
||||
deleteSession: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
@@ -224,14 +231,14 @@ export async function parseArguments(
|
||||
// one, and not being passed at all.
|
||||
skipValidation: true,
|
||||
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 => {
|
||||
// When --resume passed with a value (`gemini --resume 123`): value = "123" (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
|
||||
// `yargsInstance.argv.resume` is undefined.
|
||||
if (value === '') {
|
||||
return RESUME_LATEST;
|
||||
return RESUME_LAST_IN_CURRENT_FOLDER;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
@@ -109,6 +109,9 @@ import { runDeferredCommand } from './deferred.js';
|
||||
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
type ResumedSessionDataWithNotice = ResumedSessionData & {
|
||||
resumeNotice?: string;
|
||||
};
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
order: string | undefined,
|
||||
@@ -677,17 +680,19 @@ export async function main() {
|
||||
];
|
||||
|
||||
// Handle --resume flag
|
||||
let resumedSessionData: ResumedSessionData | undefined = undefined;
|
||||
let resumedSessionData: ResumedSessionDataWithNotice | undefined = undefined;
|
||||
if (argv.resume) {
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
try {
|
||||
const result = await sessionSelector.resolveSession(argv.resume);
|
||||
resumedSessionData = {
|
||||
const resolvedSessionData: ResumedSessionDataWithNotice = {
|
||||
conversation: result.sessionData,
|
||||
filePath: result.sessionPath,
|
||||
resumeNotice: result.resumeNotice,
|
||||
};
|
||||
resumedSessionData = resolvedSessionData;
|
||||
// Use the existing session ID to continue recording to the same session
|
||||
config.setSessionId(resumedSessionData.conversation.sessionId);
|
||||
config.setSessionId(resolvedSessionData.conversation.sessionId);
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
|
||||
@@ -221,6 +221,52 @@ describe('SessionBrowser component', () => {
|
||||
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 () => {
|
||||
const searchSession = createSession({
|
||||
id: 'search1',
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface SessionBrowserState {
|
||||
endIndex: number;
|
||||
/** Sessions visible on current page */
|
||||
visibleSessions: SessionInfo[];
|
||||
/** Latest session id in the current folder (if any) */
|
||||
lastSessionInCurrentFolderId?: string;
|
||||
|
||||
// State setters
|
||||
/** 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 = ({
|
||||
session,
|
||||
lastSessionInCurrentFolderId,
|
||||
}: {
|
||||
session: SessionInfo | undefined;
|
||||
lastSessionInCurrentFolderId?: string;
|
||||
}): React.JSX.Element | null => {
|
||||
if (!session) {
|
||||
return null;
|
||||
@@ -462,10 +489,38 @@ const SessionDetailsPanel = ({
|
||||
{formatTimestamp(session.lastUpdated)}
|
||||
</Text>
|
||||
</Text>
|
||||
{session.id === lastSessionInCurrentFolderId && (
|
||||
<Text color={Colors.AccentGreen}>
|
||||
This is your latest session in this folder.
|
||||
</Text>
|
||||
)}
|
||||
</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.
|
||||
*/
|
||||
@@ -518,6 +573,8 @@ const SessionItem = ({
|
||||
state.startIndex + state.visibleSessions.indexOf(session);
|
||||
const isActive = originalIndex === state.activeIndex;
|
||||
const isDisabled = session.isCurrentSession;
|
||||
const isLastSessionInCurrentFolder =
|
||||
session.id === state.lastSessionInCurrentFolderId;
|
||||
const isCurrentFolder = isSessionFromCurrentFolder(session.projectRoot);
|
||||
const activeColor = isCurrentFolder ? Colors.AccentCyan : Colors.AccentPurple;
|
||||
const textColor = (c: string = Colors.Foreground) => {
|
||||
@@ -535,6 +592,9 @@ const SessionItem = ({
|
||||
if (session.isCurrentSession) {
|
||||
additionalInfo = ' (current)';
|
||||
}
|
||||
if (isLastSessionInCurrentFolder) {
|
||||
additionalInfo += ' [last in folder]';
|
||||
}
|
||||
|
||||
// Show match snippets if searching and matches exist
|
||||
if (
|
||||
@@ -606,7 +666,15 @@ const SessionItem = ({
|
||||
<Text color={textColor(Colors.Comment)} dimColor={isDisabled}>
|
||||
{truncatedMessage}
|
||||
{additionalInfo && (
|
||||
<Text color={textColor(Colors.Gray)} dimColor bold={false}>
|
||||
<Text
|
||||
color={
|
||||
isLastSessionInCurrentFolder && !isDisabled
|
||||
? Colors.AccentGreen
|
||||
: textColor(Colors.Gray)
|
||||
}
|
||||
dimColor={isDisabled}
|
||||
bold={false}
|
||||
>
|
||||
{additionalInfo}
|
||||
</Text>
|
||||
)}
|
||||
@@ -686,6 +754,10 @@ export const useSessionBrowserState = (
|
||||
}, [searchQuery]);
|
||||
|
||||
const totalSessions = filteredAndSortedSessions.length;
|
||||
const lastSessionInCurrentFolderId = useMemo(
|
||||
() => getLastSessionInCurrentFolderId(sessions),
|
||||
[sessions],
|
||||
);
|
||||
const startIndex = scrollOffset;
|
||||
const endIndex = Math.min(scrollOffset + SESSIONS_PER_PAGE, totalSessions);
|
||||
const visibleSessions = filteredAndSortedSessions.slice(startIndex, endIndex);
|
||||
@@ -718,6 +790,7 @@ export const useSessionBrowserState = (
|
||||
terminalWidth,
|
||||
filteredAndSortedSessions,
|
||||
totalSessions,
|
||||
lastSessionInCurrentFolderId,
|
||||
startIndex,
|
||||
endIndex,
|
||||
visibleSessions,
|
||||
@@ -734,6 +807,8 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
setSessions,
|
||||
setLoading,
|
||||
setError,
|
||||
setActiveIndex,
|
||||
setScrollOffset,
|
||||
isSearchMode,
|
||||
hasLoadedFullContent,
|
||||
setHasLoadedFullContent,
|
||||
@@ -747,6 +822,25 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
config.getProjectRoot(),
|
||||
);
|
||||
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);
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -758,7 +852,14 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
loadSessions();
|
||||
}, [config, setSessions, setLoading, setError]);
|
||||
}, [
|
||||
config,
|
||||
setSessions,
|
||||
setLoading,
|
||||
setError,
|
||||
setActiveIndex,
|
||||
setScrollOffset,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFullContent = async () => {
|
||||
@@ -1056,6 +1157,7 @@ export function SessionBrowserView({
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<SessionListHeader state={state} />
|
||||
<CurrentFolderSessionHint state={state} />
|
||||
|
||||
{state.isSearchMode && <SearchModeDisplay state={state} />}
|
||||
{state.isRenameMode && <RenameModeDisplay state={state} />}
|
||||
@@ -1066,7 +1168,12 @@ export function SessionBrowserView({
|
||||
<SessionList state={state} formatRelativeTime={formatRelativeTime} />
|
||||
)}
|
||||
|
||||
{!state.isSearchMode && <SessionDetailsPanel session={selectedSession} />}
|
||||
{!state.isSearchMode && (
|
||||
<SessionDetailsPanel
|
||||
session={selectedSession}
|
||||
lastSessionInCurrentFolderId={state.lastSessionInCurrentFolderId}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ import type {
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
type ResumedSessionDataWithNotice = ResumedSessionData & {
|
||||
resumeNotice?: string;
|
||||
};
|
||||
|
||||
describe('useSessionResume', () => {
|
||||
// Mock dependencies
|
||||
@@ -75,7 +80,7 @@ describe('useSessionResume', () => {
|
||||
{ role: 'model' as const, parts: [{ text: 'Hi there!' }] },
|
||||
];
|
||||
|
||||
const resumedData: ResumedSessionData = {
|
||||
const resumedData: ResumedSessionDataWithNotice = {
|
||||
conversation: {
|
||||
sessionId: 'test-123',
|
||||
projectHash: 'project-123',
|
||||
@@ -130,7 +135,7 @@ describe('useSessionResume', () => {
|
||||
const clientHistory = [
|
||||
{ role: 'user' as const, parts: [{ text: 'Hello' }] },
|
||||
];
|
||||
const resumedData: ResumedSessionData = {
|
||||
const resumedData: ResumedSessionDataWithNotice = {
|
||||
conversation: {
|
||||
sessionId: 'test-123',
|
||||
projectHash: 'project-123',
|
||||
@@ -157,7 +162,7 @@ describe('useSessionResume', () => {
|
||||
it('should handle empty history arrays', async () => {
|
||||
const { result } = renderHook(() => useSessionResume(getDefaultProps()));
|
||||
|
||||
const resumedData: ResumedSessionData = {
|
||||
const resumedData: ResumedSessionDataWithNotice = {
|
||||
conversation: {
|
||||
sessionId: 'test-123',
|
||||
projectHash: 'project-123',
|
||||
@@ -178,6 +183,35 @@ describe('useSessionResume', () => {
|
||||
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 () => {
|
||||
const mockAddDirectories = vi
|
||||
.fn()
|
||||
|
||||
@@ -11,17 +11,22 @@ import {
|
||||
type ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
|
||||
|
||||
type ResumedSessionDataWithNotice = ResumedSessionData & {
|
||||
resumeNotice?: string;
|
||||
};
|
||||
|
||||
interface UseSessionResumeParams {
|
||||
config: Config;
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
refreshStatic: () => void;
|
||||
isGeminiClientInitialized: boolean;
|
||||
setQuittingMessages: (messages: null) => void;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
resumedSessionData?: ResumedSessionDataWithNotice;
|
||||
isAuthenticating: boolean;
|
||||
}
|
||||
|
||||
@@ -54,7 +59,7 @@ export function useSessionResume({
|
||||
async (
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
resumedData: ResumedSessionData,
|
||||
resumedData: ResumedSessionDataWithNotice,
|
||||
) => {
|
||||
// Wait for the client.
|
||||
if (!isGeminiClientInitialized) {
|
||||
@@ -84,6 +89,17 @@ export function useSessionResume({
|
||||
|
||||
// Give the history to the Gemini client.
|
||||
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
|
||||
|
||||
if (resumedData.resumeNotice) {
|
||||
historyManagerRef.current.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: resumedData.resumeNotice,
|
||||
},
|
||||
Date.now(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
RESUME_LAST_IN_CURRENT_FOLDER,
|
||||
SessionSelector,
|
||||
extractFirstUserMessage,
|
||||
formatRelativeTime,
|
||||
@@ -49,11 +50,14 @@ describe('SessionSelector', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const createChatsDir = async () => {
|
||||
const projectDir = path.join(tmpDir, 'project-a');
|
||||
const createChatsDir = async (
|
||||
projectId = 'project-a',
|
||||
root = projectRoot,
|
||||
) => {
|
||||
const projectDir = path.join(tmpDir, projectId);
|
||||
const chatsDir = path.join(projectDir, 'chats');
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -245,6 +249,146 @@ describe('SessionSelector', () => {
|
||||
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 () => {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
|
||||
@@ -31,9 +31,14 @@ import {
|
||||
|
||||
/**
|
||||
* 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';
|
||||
/**
|
||||
* 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.
|
||||
@@ -157,6 +162,7 @@ export interface SessionSelectionResult {
|
||||
sessionPath: string;
|
||||
sessionData: ConversationRecord;
|
||||
displayInfo: string;
|
||||
resumeNotice?: string;
|
||||
}
|
||||
|
||||
export interface RenameSessionResult {
|
||||
@@ -638,6 +644,35 @@ export async function deleteSessionArtifacts(session: SessionInfo): Promise<void
|
||||
export class SessionSelector {
|
||||
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.
|
||||
*/
|
||||
@@ -707,20 +742,31 @@ export class SessionSelector {
|
||||
async resolveSession(resumeArg: string): Promise<SessionSelectionResult> {
|
||||
let selectedSession: SessionInfo;
|
||||
|
||||
if (resumeArg === RESUME_LATEST) {
|
||||
let resumeNotice: string | undefined;
|
||||
if (resumeArg === RESUME_LAST_IN_CURRENT_FOLDER) {
|
||||
const sessions = await this.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
throw new Error('No previous sessions found.');
|
||||
}
|
||||
|
||||
// Sort by startTime (oldest first, so newest sessions get highest numbers)
|
||||
sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
const currentFolderSession =
|
||||
this.getLatestSessionFromCurrentFolder(sessions);
|
||||
if (currentFolderSession) {
|
||||
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 {
|
||||
try {
|
||||
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(
|
||||
sessionInfo: SessionInfo,
|
||||
resumeNotice?: string,
|
||||
): Promise<SessionSelectionResult> {
|
||||
const sessionPath = sessionInfo.sessionPath;
|
||||
|
||||
@@ -758,6 +805,7 @@ export class SessionSelector {
|
||||
sessionPath: sessionInfo.sessionPath,
|
||||
sessionData,
|
||||
displayInfo,
|
||||
resumeNotice,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
|
||||
Reference in New Issue
Block a user