Prefer current-folder default resume and surface it in UI

This commit is contained in:
Dmitry Lyalin
2026-02-13 13:23:25 -05:00
parent d166645107
commit 354391f005
9 changed files with 452 additions and 30 deletions
+18 -3
View File
@@ -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;
+11 -4
View File
@@ -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;
}, },
+8 -3
View File
@@ -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()
+18 -2
View File
@@ -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',
+147 -3
View File
@@ -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();
+57 -9
View File
@@ -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(