diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index ee79e0d59..f6bd50ae6 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -14,6 +14,7 @@ import { qwenOAuth2Events, MCPServerConfig, SessionService, + SESSION_TITLE_MAX_LENGTH, tokenLimit, type Config, type ConversationRecord, @@ -296,17 +297,29 @@ class QwenAgent implements Agent { ): Promise { const cwd = params.cwd || process.cwd(); const numericCursor = params.cursor ? Number(params.cursor) : undefined; + + // The ACP spec's ListSessionsRequest doesn't include a page-size field, + // so the SDK's zod validator strips any top-level `size` the client sends + // before it reaches this handler. Carry page size through `_meta.size` + // (same pattern filesystem.ts uses for `_meta.bom` / `_meta.encoding`). + const metaSize = params._meta?.['size']; + const size = + typeof metaSize === 'number' && metaSize > 0 + ? Math.floor(metaSize) + : undefined; + const result = await runWithAcpRuntimeOutputDir(this.settings, cwd, () => { const sessionService = new SessionService(cwd); return sessionService.listSessions({ cursor: Number.isNaN(numericCursor) ? undefined : numericCursor, + size, }); }); const sessions: SessionInfo[] = result.items.map((item) => ({ cwd: item.cwd, sessionId: item.sessionId, - title: item.prompt || '(session)', + title: item.customTitle || item.prompt || '(session)', updatedAt: new Date(item.mtime).toISOString(), })); @@ -403,19 +416,74 @@ class QwenAgent implements Agent { method: string, params: Record, ): Promise> { - if (method === 'getAccountInfo') { - const sessionId = params['sessionId'] as string | undefined; - const session = sessionId ? this.sessions.get(sessionId) : undefined; - const config = session ? session.getConfig() : this.config; - const cfg = config.getContentGeneratorConfig(); - return { - authType: cfg?.authType ?? config.getAuthType() ?? null, - model: cfg?.model ?? config.getModel() ?? null, - baseUrl: cfg?.baseUrl ?? null, - apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null, - }; + const cwd = (params['cwd'] as string) || process.cwd(); + const SESSION_ID_RE = /^[0-9a-fA-F-]{32,36}$/; + + switch (method) { + case 'deleteSession': { + const sessionId = params['sessionId'] as string; + if (!sessionId || !SESSION_ID_RE.test(sessionId)) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const success = await runWithAcpRuntimeOutputDir( + this.settings, + cwd, + async () => { + const sessionService = new SessionService(cwd); + return sessionService.removeSession(sessionId); + }, + ); + return { success }; + } + case 'renameSession': { + const sessionId = params['sessionId'] as string; + const title = params['title'] as string; + if (!sessionId || !SESSION_ID_RE.test(sessionId)) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + if (!title || typeof title !== 'string') { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing title', + ); + } + if (title.length > SESSION_TITLE_MAX_LENGTH) { + throw RequestError.invalidParams( + undefined, + `Title too long (max ${SESSION_TITLE_MAX_LENGTH} chars)`, + ); + } + const success = await runWithAcpRuntimeOutputDir( + this.settings, + cwd, + async () => { + const sessionService = new SessionService(cwd); + return sessionService.renameSession(sessionId, title); + }, + ); + return { success }; + } + case 'getAccountInfo': { + const sessionId = params['sessionId'] as string | undefined; + const session = sessionId ? this.sessions.get(sessionId) : undefined; + const config = session ? session.getConfig() : this.config; + const cfg = config.getContentGeneratorConfig(); + return { + authType: cfg?.authType ?? config.getAuthType() ?? null, + model: cfg?.model ?? config.getModel() ?? null, + baseUrl: cfg?.baseUrl ?? null, + apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null, + }; + } + default: + throw RequestError.methodNotFound(method); } - throw RequestError.methodNotFound(method); } // --- private helpers --- diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f6844f688..bc3a8f6f2 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -62,7 +62,7 @@ const SESSION_ID_REGEX = * Accepts a standard UUID, or a UUID followed by `-agent-{suffix}` * (used by Arena to give each agent a deterministic session ID). */ -function isValidSessionId(value: string): boolean { +export function isValidSessionId(value: string): boolean { return SESSION_ID_REGEX.test(value); } @@ -612,9 +612,7 @@ export async function parseArguments(): Promise { ) { return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; } - if (argv['resume'] && !isValidSessionId(argv['resume'] as string)) { - return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; - } + // --resume accepts either a session UUID or a custom title if (argv['jsonFd'] != null && argv['jsonFile'] != null) { return '--json-fd and --json-file are mutually exclusive. Use one or the other.'; } @@ -1082,6 +1080,9 @@ export async function loadCliConfig( } if (argv.resume) { + // By the time we get here, argv.resume has been resolved to a valid + // session UUID by gemini.tsx (which handles custom title lookup and + // the interactive picker for ambiguous matches). sessionId = argv.resume; sessionData = await sessionService.loadSession(argv.resume); if (!sessionData) { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 5ed93cc26..52a28f619 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -12,6 +12,7 @@ import { logUserPrompt, QWEN_CODE_SIMPLE_ENV_VAR, Storage, + SessionService, type Config, createDebugLogger, } from '@qwen-code/qwen-code-core'; @@ -431,23 +432,52 @@ export async function main() { } } - // Handle --resume without a session ID by showing the session picker. - // Set the runtime output dir early so the picker can find sessions stored - // under a custom runtimeOutputDir (setRuntimeBaseDir is idempotent and will - // be called again inside loadCliConfig). - if (argv.resume === '') { + // Handle --resume without a session ID, or with a custom title, by showing + // the session picker. Set the runtime output dir early so the picker can find + // sessions stored under a custom runtimeOutputDir (setRuntimeBaseDir is + // idempotent and will be called again inside loadCliConfig). + if (argv.resume !== undefined) { Storage.setRuntimeBaseDir( settings.merged.advanced?.runtimeOutputDir, process.cwd(), ); - const selectedSessionId = await showResumeSessionPicker(); - if (!selectedSessionId) { - // User cancelled or no sessions available - process.exit(0); + + let resolvedSessionId: string | undefined; + + if (argv.resume === '') { + // No argument — show picker + resolvedSessionId = await showResumeSessionPicker(); + } else if (!cliConfig.isValidSessionId(argv.resume)) { + // Non-UUID argument — treat as custom title search + const sessionService = new SessionService(process.cwd()); + const matches = await sessionService.findSessionsByTitle(argv.resume); + if (matches.length === 1) { + resolvedSessionId = matches[0].sessionId; + } else if (matches.length > 1) { + // Multiple matches — show picker to let user choose + writeStderrLine( + `Multiple sessions found with title "${argv.resume}". Please select one:`, + ); + resolvedSessionId = await showResumeSessionPicker( + process.cwd(), + matches, + ); + } + // matches.length === 0 → resolvedSessionId stays undefined, handled below } - // Update argv with the selected session ID - argv = { ...argv, resume: selectedSessionId }; + if (resolvedSessionId !== undefined) { + argv = { ...argv, resume: resolvedSessionId }; + } else if (argv.resume === '' || !cliConfig.isValidSessionId(argv.resume)) { + // User cancelled the picker or no sessions found for the title + if (argv.resume !== '') { + writeStderrLine(`No saved session found with title "${argv.resume}".`); + process.exit(1); + } else { + process.exit(0); + } + } + // else: argv.resume is already a valid UUID, pass through to loadCliConfig } // We are now past the logic handling potentially launching a child process diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 05bd26981..cdde266b8 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -15,6 +15,7 @@ import { authCommand } from '../ui/commands/authCommand.js'; import { btwCommand } from '../ui/commands/btwCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; +import { deleteCommand } from '../ui/commands/deleteCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { contextCommand } from '../ui/commands/contextCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; @@ -41,6 +42,7 @@ import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { trustCommand } from '../ui/commands/trustCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { recapCommand } from '../ui/commands/recapCommand.js'; +import { renameCommand } from '../ui/commands/renameCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; @@ -97,6 +99,7 @@ export class BuiltinCommandLoader implements ICommandLoader { compressCommand, contextCommand, copyCommand, + deleteCommand, docsCommand, doctorCommand, directoryCommand, @@ -120,6 +123,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ...(this.config?.getFolderTrust() ? [trustCommand] : []), quitCommand, recapCommand, + renameCommand, restoreCommand(this.config), resumeCommand, skillsCommand, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 3ea3e65b8..332b4114b 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -65,6 +65,7 @@ export const createMockCommandContext = ( extensionsUpdateState: new Map(), setExtensionsUpdateState: vi.fn(), reloadCommands: vi.fn(), + setSessionName: vi.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, session: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index bd5a4a3dd..89bb17bef 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -72,6 +72,7 @@ import { useModelCommand } from './hooks/useModelCommand.js'; import { useArenaCommand } from './hooks/useArenaCommand.js'; import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; import { useResumeCommand } from './hooks/useResumeCommand.js'; +import { useDeleteCommand } from './hooks/useDeleteCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { CompactModeProvider } from './contexts/CompactModeContext.js'; @@ -320,6 +321,14 @@ export const AppContainer = (props: AppContainerProps) => { config, ); historyManager.loadHistory(historyItems); + + // Restore session name tag from custom title + const title = config + .getSessionService() + .getSessionTitle(config.getSessionId()); + if (title) { + setSessionName(title); + } } // Fire SessionStart event after config is initialized @@ -566,8 +575,12 @@ export const AppContainer = (props: AppContainerProps) => { const { activeArenaDialog, openArenaDialog, closeArenaDialog } = useArenaCommand(); + // Session name state (set via /rename, restored on /resume) + const [sessionName, setSessionName] = useState(null); + const { isResumeDialogOpen, + resumeMatchedSessions, openResumeDialog, closeResumeDialog, handleResume, @@ -575,9 +588,20 @@ export const AppContainer = (props: AppContainerProps) => { config, historyManager, startNewSession, + setSessionName, remount: refreshStatic, }); + const { + isDeleteDialogOpen, + openDeleteDialog, + closeDeleteDialog, + handleDelete, + } = useDeleteCommand({ + config, + addItem: historyManager.addItem, + }); + const { toggleVimEnabled } = useVimMode(); const { @@ -627,6 +651,8 @@ export const AppContainer = (props: AppContainerProps) => { openMcpDialog, openHooksDialog, openResumeDialog, + handleResume, + openDeleteDialog, }), [ openAuthDialog, @@ -648,6 +674,8 @@ export const AppContainer = (props: AppContainerProps) => { openMcpDialog, openHooksDialog, openResumeDialog, + handleResume, + openDeleteDialog, ], ); @@ -677,6 +705,7 @@ export const AppContainer = (props: AppContainerProps) => { extensionsUpdateStateInternal, isConfigInitialized, logger, + setSessionName, ); // onDebugMessage should log to debug logfile, not update footer debugMessage @@ -1996,6 +2025,7 @@ export const AppContainer = (props: AppContainerProps) => { isHooksDialogOpen || isApprovalModeDialogOpen || isResumeDialogOpen || + isDeleteDialogOpen || isExtensionsManagerDialogOpen; dialogsVisibleRef.current = dialogsVisible; @@ -2039,6 +2069,8 @@ export const AppContainer = (props: AppContainerProps) => { isPermissionsDialogOpen, isApprovalModeDialogOpen, isResumeDialogOpen, + resumeMatchedSessions, + isDeleteDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -2122,6 +2154,9 @@ export const AppContainer = (props: AppContainerProps) => { // Real-time token display streamingResponseLengthRef, isReceivingContent, + // Session name + sessionName, + setSessionName, // Prompt suggestion promptSuggestion, dismissPromptSuggestion, @@ -2149,6 +2184,8 @@ export const AppContainer = (props: AppContainerProps) => { isPermissionsDialogOpen, isApprovalModeDialogOpen, isResumeDialogOpen, + resumeMatchedSessions, + isDeleteDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -2233,6 +2270,9 @@ export const AppContainer = (props: AppContainerProps) => { // Real-time token display streamingResponseLengthRef, isReceivingContent, + // Session name + sessionName, + setSessionName, // Prompt suggestion promptSuggestion, dismissPromptSuggestion, @@ -2296,6 +2336,10 @@ export const AppContainer = (props: AppContainerProps) => { openResumeDialog, closeResumeDialog, handleResume, + // Delete session dialog + openDeleteDialog, + closeDeleteDialog, + handleDelete, // Feedback dialog openFeedbackDialog, closeFeedbackDialog, @@ -2356,6 +2400,10 @@ export const AppContainer = (props: AppContainerProps) => { openResumeDialog, closeResumeDialog, handleResume, + // Delete session dialog + openDeleteDialog, + closeDeleteDialog, + handleDelete, // Feedback dialog openFeedbackDialog, closeFeedbackDialog, diff --git a/packages/cli/src/ui/commands/deleteCommand.test.ts b/packages/cli/src/ui/commands/deleteCommand.test.ts new file mode 100644 index 000000000..81c4bfd34 --- /dev/null +++ b/packages/cli/src/ui/commands/deleteCommand.test.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { deleteCommand } from './deleteCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('deleteCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should have the correct name and description', () => { + expect(deleteCommand.name).toBe('delete'); + expect(deleteCommand.description).toBe('Delete a previous session'); + }); + + it('should return a dialog action to open the delete dialog', async () => { + const result = await deleteCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'delete', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/deleteCommand.ts b/packages/cli/src/ui/commands/deleteCommand.ts new file mode 100644 index 000000000..194283a63 --- /dev/null +++ b/packages/cli/src/ui/commands/deleteCommand.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +export const deleteCommand: SlashCommand = { + name: 'delete', + kind: CommandKind.BUILT_IN, + get description() { + return t('Delete a previous session'); + }, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'delete', + }), +}; diff --git a/packages/cli/src/ui/commands/renameCommand.test.ts b/packages/cli/src/ui/commands/renameCommand.test.ts new file mode 100644 index 000000000..bc334c8b3 --- /dev/null +++ b/packages/cli/src/ui/commands/renameCommand.test.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renameCommand } from './renameCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('renameCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should have the correct name and description', () => { + expect(renameCommand.name).toBe('rename'); + expect(renameCommand.description).toBe('Rename the current conversation'); + }); + + it('should return error when config is not available', async () => { + mockContext.services.config = null; + + const result = await renameCommand.action!(mockContext, 'my-feature'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config is not available.', + }); + }); + + it('should return error when no name is provided and auto-generate fails', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: vi.fn().mockResolvedValue(true), + }), + getGeminiClient: vi.fn().mockReturnValue({ + getHistory: vi.fn().mockReturnValue([]), + }), + getContentGenerator: vi.fn(), + getModel: vi.fn(), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not generate a title. Usage: /rename ', + }); + }); + + it('should return error when only whitespace is provided and auto-generate fails', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: vi.fn().mockResolvedValue(true), + }), + getGeminiClient: vi.fn().mockReturnValue({ + getHistory: vi.fn().mockReturnValue([]), + }), + getContentGenerator: vi.fn(), + getModel: vi.fn(), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, ' '); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not generate a title. Usage: /rename ', + }); + }); + + it('should rename via ChatRecordingService when available', async () => { + const mockRecordCustomTitle = vi.fn().mockReturnValue(true); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: mockRecordCustomTitle, + }), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: vi.fn().mockResolvedValue(true), + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, 'my-feature'); + + expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Session renamed to "my-feature"', + }); + }); + + it('should fall back to SessionService when ChatRecordingService is unavailable', async () => { + const mockRenameSession = vi.fn().mockResolvedValue(true); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: mockRenameSession, + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, 'my-feature'); + + expect(mockRenameSession).toHaveBeenCalledWith( + 'test-session-id', + 'my-feature', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Session renamed to "my-feature"', + }); + }); + + it('should return error when SessionService fallback fails', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: vi.fn().mockResolvedValue(false), + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, 'my-feature'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to rename session.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/renameCommand.ts b/packages/cli/src/ui/commands/renameCommand.ts new file mode 100644 index 000000000..e1a594331 --- /dev/null +++ b/packages/cli/src/ui/commands/renameCommand.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/genai'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { + getResponseText, + SESSION_TITLE_MAX_LENGTH, +} from '@qwen-code/qwen-code-core'; +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +const MAX_TITLE_LENGTH = SESSION_TITLE_MAX_LENGTH; + +/** + * Extracts a short text summary from conversation history for title generation. + * Takes the last few user/assistant messages, truncated to ~1000 chars. + */ +function extractConversationText(history: Content[]): string { + const texts: string[] = []; + // Walk backwards to get the most recent context + for (let i = history.length - 1; i >= 0 && texts.length < 6; i--) { + const content = history[i]; + const role = content.role === 'user' ? 'User' : 'Assistant'; + for (const part of content.parts ?? []) { + if ('text' in part && part.text) { + texts.unshift(`${role}: ${part.text}`); + break; + } + } + } + const joined = texts.join('\n'); + return joined.length > 1000 ? joined.slice(-1000) : joined; +} + +/** + * Calls the LLM to generate a short session title from conversation history. + */ +async function generateSessionTitle( + config: Config, + signal?: AbortSignal, +): Promise { + try { + const history = config.getGeminiClient().getHistory(true); + const conversationText = extractConversationText(history); + if (!conversationText) { + return null; + } + + const response = await config.getContentGenerator().generateContent( + { + model: config.getModel(), + contents: [ + { + role: 'user', + parts: [{ text: conversationText }], + }, + ], + config: { + systemInstruction: { + role: 'user', + parts: [ + { + text: 'Generate a short kebab-case name (2-4 words) that captures the main topic of this conversation. Use lowercase words separated by hyphens. Examples: "fix-login-bug", "add-auth-feature", "refactor-api-client". Reply with ONLY the kebab-case name, nothing else.', + }, + ], + }, + abortSignal: signal, + }, + }, + 'rename_generate_title', + ); + + const text = getResponseText(response)?.trim(); + if (!text) { + return null; + } + // Clean up: take first line, remove quotes/backticks + const cleaned = text.split('\n')[0].replace(/["`']/g, '').trim(); + return cleaned.length > 0 && cleaned.length <= MAX_TITLE_LENGTH + ? cleaned + : null; + } catch { + return null; + } +} + +export const renameCommand: SlashCommand = { + name: 'rename', + altNames: ['tag'], + kind: CommandKind.BUILT_IN, + get description() { + return t('Rename the current conversation'); + }, + action: async (context, args): Promise => { + const { config } = context.services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config is not available.'), + }; + } + + let name = args.trim().replace(/[\r\n]+/g, ' '); + + // If no name provided, auto-generate one from conversation history + if (!name) { + const dots = ['.', '..', '...']; + let dotIndex = 0; + const baseText = t('Generating session name'); + context.ui.setPendingItem({ + type: 'info', + text: baseText + dots[dotIndex], + }); + const timer = setInterval(() => { + dotIndex = (dotIndex + 1) % dots.length; + context.ui.setPendingItem({ + type: 'info', + text: baseText + dots[dotIndex], + }); + }, 500); + const generated = await generateSessionTitle(config, context.abortSignal); + clearInterval(timer); + context.ui.setPendingItem(null); + if (!generated) { + return { + type: 'message', + messageType: 'error', + content: t('Could not generate a title. Usage: /rename '), + }; + } + name = generated; + } + + if (name.length > MAX_TITLE_LENGTH) { + return { + type: 'message', + messageType: 'error', + content: t('Name is too long. Maximum {{max}} characters.', { + max: String(MAX_TITLE_LENGTH), + }), + }; + } + + // Record the custom title in the current session's JSONL file + const chatRecordingService = config.getChatRecordingService(); + if (chatRecordingService) { + const ok = chatRecordingService.recordCustomTitle(name); + if (!ok) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to rename session.'), + }; + } + } else { + // Fallback: write via SessionService for non-recording sessions + const sessionId = config.getSessionId(); + const sessionService = config.getSessionService(); + const success = await sessionService.renameSession(sessionId, name); + if (!success) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to rename session.'), + }; + } + } + + // Update the UI tag in the input prompt + context.ui.setSessionName(name); + + return { + type: 'message', + messageType: 'info', + content: t('Session renamed to "{{name}}"', { name }), + }; + }, +}; diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts index 7fe14ab09..fed298b8c 100644 --- a/packages/cli/src/ui/commands/resumeCommand.test.ts +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -4,35 +4,167 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { resumeCommand } from './resumeCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +vi.mock('../../config/config.js', () => ({ + isValidSessionId: vi.fn((value: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + ), + ), +})); + describe('resumeCommand', () => { let mockContext: CommandContext; beforeEach(() => { + vi.clearAllMocks(); mockContext = createMockCommandContext(); }); - it('should return a dialog action to open the resume dialog', async () => { - // Ensure the command has an action to test. - if (!resumeCommand.action) { - throw new Error('The resume command must have an action.'); - } - - const result = await resumeCommand.action(mockContext, ''); - - // Assert that the action returns the correct object to trigger the resume dialog. - expect(result).toEqual({ - type: 'dialog', - dialog: 'resume', - }); - }); - it('should have the correct name and description', () => { expect(resumeCommand.name).toBe('resume'); expect(resumeCommand.description).toBe('Resume a previous session'); }); + + it('should have "continue" as an alias', () => { + expect(resumeCommand.altNames).toContain('continue'); + }); + + it('should return dialog action when no args provided', async () => { + const result = await resumeCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + }); + }); + + it('should return error when config is not available and args given', async () => { + mockContext.services.config = null; + + const result = await resumeCommand.action!(mockContext, 'some-arg'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config is not available.', + }); + }); + + it('should resume directly when valid UUID is provided and session exists', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ + sessionExists: vi.fn().mockResolvedValue(true), + }), + getTargetDir: vi.fn().mockReturnValue('/test'), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, sessionId); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + sessionId, + }); + }); + + it('should return error when valid UUID is provided but session does not exist', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ + sessionExists: vi.fn().mockResolvedValue(false), + }), + getTargetDir: vi.fn().mockReturnValue('/test'), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, sessionId); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `No session found with ID "${sessionId}".`, + }); + }); + + it('should resume directly when custom title has single match', async () => { + const matchedSessionId = '550e8400-e29b-41d4-a716-446655440000'; + + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ + sessionExists: vi.fn().mockResolvedValue(false), + findSessionsByTitle: vi + .fn() + .mockResolvedValue([{ sessionId: matchedSessionId }]), + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, 'my-feature'); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + sessionId: matchedSessionId, + }); + }); + + it('should show picker when custom title has multiple matches', async () => { + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ + sessionExists: vi.fn().mockResolvedValue(false), + findSessionsByTitle: vi + .fn() + .mockResolvedValue([{ sessionId: 'id-1' }, { sessionId: 'id-2' }]), + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, 'shared-name'); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + matchedSessions: [{ sessionId: 'id-1' }, { sessionId: 'id-2' }], + }); + }); + + it('should return error when custom title has no matches', async () => { + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ + sessionExists: vi.fn().mockResolvedValue(false), + findSessionsByTitle: vi.fn().mockResolvedValue([]), + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, 'nonexistent'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No session found with title "nonexistent".', + }); + }); }); diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 4f0fa7dd1..8046e52fa 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -6,17 +6,69 @@ import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; +import { isValidSessionId } from '../../config/config.js'; import { t } from '../../i18n/index.js'; export const resumeCommand: SlashCommand = { name: 'resume', + altNames: ['continue'], kind: CommandKind.BUILT_IN, commandType: 'local-jsx', get description() { return t('Resume a previous session'); }, - action: async (): Promise => ({ - type: 'dialog', - dialog: 'resume', - }), + action: async (context, args): Promise => { + const arg = args.trim(); + + // No argument — show picker + if (!arg) { + return { type: 'dialog', dialog: 'resume' }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config is not available.'), + }; + } + + // Try as session UUID + if (isValidSessionId(arg)) { + const sessionService = config.getSessionService(); + const exists = await sessionService.sessionExists(arg); + if (exists) { + return { type: 'dialog', dialog: 'resume', sessionId: arg }; + } + return { + type: 'message', + messageType: 'error', + content: t('No session found with ID "{{id}}".', { id: arg }), + }; + } + + // Try as custom title + const sessionService = config.getSessionService(); + const matches = await sessionService.findSessionsByTitle(arg); + + if (matches.length === 1) { + return { + type: 'dialog', + dialog: 'resume', + sessionId: matches[0].sessionId, + }; + } + + if (matches.length > 1) { + // Multiple matches — show picker with only the matching sessions + return { type: 'dialog', dialog: 'resume', matchedSessions: matches }; + } + + return { + type: 'message', + messageType: 'error', + content: t('No session found with title "{{title}}".', { title: arg }), + }; + }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index f851857c9..ee7a1c096 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -6,7 +6,12 @@ import type { MutableRefObject, ReactNode } from 'react'; import type { Content, PartListUnion } from '@google/genai'; -import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core'; +import type { + Config, + GitService, + Logger, + SessionListItem, +} from '@qwen-code/qwen-code-core'; import type { HistoryItemWithoutId, HistoryItem, @@ -86,6 +91,7 @@ export interface CommandContext { toggleVimEnabled: () => Promise; setGeminiMdFileCount: (count: number) => void; reloadCommands: () => void; + setSessionName: (name: string | null) => void; extensionsUpdateState: Map; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void; @@ -148,6 +154,12 @@ export interface StreamMessagesActionReturn { export interface OpenDialogActionReturn { type: 'dialog'; + /** Optional session ID to pass directly to the dialog handler (e.g., for /resume ). */ + sessionId?: string; + + /** Pre-filtered sessions for the picker (e.g., multiple title matches from /resume ). */ + matchedSessions?: SessionListItem[]; + dialog: | 'help' | 'arena_start' @@ -167,6 +179,7 @@ export interface OpenDialogActionReturn { | 'permissions' | 'approval-mode' | 'resume' + | 'delete' | 'extensions_manage' | 'hooks' | 'mcp'; diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index fc9763cbb..a0e44591a 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -27,6 +27,7 @@ import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; +import stringWidth from 'string-width'; import { cpSlice, cpLen } from '../utils/textUtils.js'; import { theme } from '../semantic-colors.js'; @@ -69,6 +70,8 @@ export interface BaseTextInputProps { prefix?: React.ReactNode; /** Border color for the input box. */ borderColor?: string; + /** Label rendered on the top border line (right-aligned). Plain string for width calculation. */ + topRightLabel?: string; /** Whether keyboard handling is active. Defaults to true. */ isActive?: boolean; /** @@ -129,6 +132,7 @@ export const BaseTextInput: React.FC<BaseTextInputProps> = ({ placeholder, prefix, borderColor, + topRightLabel, isActive = true, renderLine = defaultRenderLine, }) => { @@ -246,47 +250,61 @@ export const BaseTextInput: React.FC<BaseTextInputProps> = ({ <Text color={theme.text.accent}>{'> '}</Text> ); - return ( - <Box - borderStyle="single" - borderTop={true} - borderBottom={true} - borderLeft={false} - borderRight={false} - borderColor={resolvedBorderColor} - > - {resolvedPrefix} - <Box flexGrow={1} flexDirection="column"> - {buffer.text.length === 0 && placeholder ? ( - showCursor ? ( - <Text> - {chalk.inverse(placeholder.slice(0, 1))} - <Text color={theme.text.secondary}>{placeholder.slice(1)}</Text> - </Text> - ) : ( - <Text color={theme.text.secondary}>{placeholder}</Text> - ) - ) : ( - linesToRender.map((lineText, idx) => { - const absoluteVisualIndex = scrollVisualRow + idx; - const isOnCursorLine = absoluteVisualIndex === cursorVisualRow; + const columns = process.stdout.columns || 80; + // Build the top border line: ─────── label ── + // Label takes: 1 space + text + 1 space + 2 trailing dashes = label.length + 4 + const labelWidth = topRightLabel ? stringWidth(topRightLabel) + 4 : 0; + const dashCount = Math.max(1, columns - labelWidth); + const topBorderLine = topRightLabel + ? `${'─'.repeat(dashCount)} ${topRightLabel} ${'─'.repeat(2)}` + : '─'.repeat(columns); - return ( - <Box key={idx} height={1}> - {renderLine({ - lineText, - isOnCursorLine, - cursorCol: cursorVisualCol, - showCursor, - visualLineIndex: idx, - absoluteVisualIndex, - buffer, - scrollVisualRow, - })} - </Box> - ); - }) - )} + return ( + <Box flexDirection="column"> + <Text color={resolvedBorderColor} wrap="truncate-end"> + {topBorderLine} + </Text> + <Box + borderStyle="single" + borderTop={false} + borderBottom={true} + borderLeft={false} + borderRight={false} + borderColor={resolvedBorderColor} + > + {resolvedPrefix} + <Box flexGrow={1} flexDirection="column"> + {buffer.text.length === 0 && placeholder ? ( + showCursor ? ( + <Text> + {chalk.inverse(placeholder.slice(0, 1))} + <Text color={theme.text.secondary}>{placeholder.slice(1)}</Text> + </Text> + ) : ( + <Text color={theme.text.secondary}>{placeholder}</Text> + ) + ) : ( + linesToRender.map((lineText, idx) => { + const absoluteVisualIndex = scrollVisualRow + idx; + const isOnCursorLine = absoluteVisualIndex === cursorVisualRow; + + return ( + <Box key={idx} height={1}> + {renderLine({ + lineText, + isOnCursorLine, + cursorCol: cursorVisualCol, + showCursor, + visualLineIndex: idx, + absoluteVisualIndex, + buffer, + scrollVisualRow, + })} + </Box> + ); + }) + )} + </Box> </Box> </Box> ); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 27d0c5aaa..dc0c64d27 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -44,6 +44,7 @@ import { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; import { HooksManagementDialog } from './hooks/HooksManagementDialog.js'; import { SessionPicker } from './SessionPicker.js'; import { MemoryDialog } from './MemoryDialog.js'; +import { t } from '../../i18n/index.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -379,6 +380,19 @@ export const DialogManager = ({ currentBranch={uiState.branchName} onSelect={uiActions.handleResume} onCancel={uiActions.closeResumeDialog} + initialSessions={uiState.resumeMatchedSessions} + /> + ); + } + + if (uiState.isDeleteDialogOpen) { + return ( + <SessionPicker + sessionService={config.getSessionService()} + currentBranch={uiState.branchName} + onSelect={uiActions.handleDelete} + onCancel={uiActions.closeDeleteDialog} + title={t('Delete Session')} /> ); } diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 5bbeba1e0..71ce5c420 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1248,6 +1248,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ } prefix={prefixNode} borderColor={borderColor} + topRightLabel={uiState.sessionName || undefined} isActive={!isEmbeddedShellFocused} renderLine={renderLineWithHighlighting} /> diff --git a/packages/cli/src/ui/components/SessionPicker.tsx b/packages/cli/src/ui/components/SessionPicker.tsx index 8c2703973..33b62c06b 100644 --- a/packages/cli/src/ui/components/SessionPicker.tsx +++ b/packages/cli/src/ui/components/SessionPicker.tsx @@ -25,11 +25,22 @@ export interface SessionPickerProps { onCancel: () => void; currentBranch?: string; + /** + * Custom title for the picker header. Defaults to "Resume Session". + */ + title?: string; + /** * Scroll mode. When true, keep selection centered (fullscreen-style). * Defaults to true so dialog + standalone behave identically. */ centerSelection?: boolean; + + /** + * Pre-filtered sessions to display instead of loading all sessions. + * When provided, skips initial load and disables pagination. + */ + initialSessions?: SessionData[]; } const PREFIX_CHARS = { @@ -81,7 +92,7 @@ function SessionListItemView({ ? prefixChars.scrollDown : prefixChars.normal; - const promptText = session.prompt || '(empty prompt)'; + const promptText = session.customTitle || session.prompt || '(empty prompt)'; const truncatedPrompt = truncateText(promptText, maxPromptWidth); return ( @@ -122,7 +133,9 @@ export function SessionPicker(props: SessionPickerProps) { onSelect, onCancel, currentBranch, + title, centerSelection = true, + initialSessions, } = props; const { columns: width, rows: height } = useTerminalSize(); @@ -146,6 +159,7 @@ export function SessionPicker(props: SessionPickerProps) { onCancel, maxVisibleItems, centerSelection, + initialSessions, isActive: true, }); @@ -167,7 +181,7 @@ export function SessionPicker(props: SessionPickerProps) { {/* Header row */} <Box paddingX={1}> <Text bold color={theme.text.primary}> - {t('Resume Session')} + {title ?? t('Resume Session')} </Text> {picker.filterByBranch && currentBranch && ( <Text color={theme.text.secondary}> diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx index 244258d79..ba9a3f21f 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -6,7 +6,11 @@ import { useState } from 'react'; import { render, Box, useApp } from 'ink'; -import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core'; +import { + getGitBranch, + SessionService, + type SessionListItem, +} from '@qwen-code/qwen-code-core'; import { KeypressProvider } from '../contexts/KeypressContext.js'; import { SessionPicker } from './SessionPicker.js'; import { writeStdoutLine } from '../../utils/stdioHelpers.js'; @@ -16,6 +20,7 @@ interface StandalonePickerScreenProps { onSelect: (sessionId: string) => void; onCancel: () => void; currentBranch?: string; + initialSessions?: SessionListItem[]; } function StandalonePickerScreen({ @@ -23,6 +28,7 @@ function StandalonePickerScreen({ onSelect, onCancel, currentBranch, + initialSessions, }: StandalonePickerScreenProps): React.JSX.Element { const { exit } = useApp(); const [isExiting, setIsExiting] = useState(false); @@ -49,6 +55,7 @@ function StandalonePickerScreen({ }} currentBranch={currentBranch} centerSelection={true} + initialSessions={initialSessions} /> ); } @@ -67,6 +74,7 @@ function clearScreen(): void { */ export async function showResumeSessionPicker( cwd: string = process.cwd(), + initialSessions?: SessionListItem[], ): Promise<string | undefined> { const sessionService = new SessionService(cwd); const hasSession = await sessionService.loadLastSession(); @@ -104,6 +112,7 @@ export async function showResumeSessionPicker( selectedId = undefined; }} currentBranch={getGitBranch(cwd)} + initialSessions={initialSessions} /> </KeypressProvider>, { diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 4df29a062..6cd627160 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── (r:) commit ──────────────────────────────────────────────────────────────────────────────────────────────────── git commit -m "feat: add search" in src/app" `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── (r:) commit ──────────────────────────────────────────────────────────────────────────────────────────────────── git commit -m "feat: add search" in src/app" `; exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── > Type your message or @path/to/file ────────────────────────────────────────────────────────────────────────────────────────────────────" `; exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── ! Type your message or @path/to/file ────────────────────────────────────────────────────────────────────────────────────────────────────" `; exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── * Type your message or @path/to/file ────────────────────────────────────────────────────────────────────────────────────────────────────" `; exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── > Type your message or @path/to/file ────────────────────────────────────────────────────────────────────────────────────────────────────" `; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 5de8efa54..5aac4e66a 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -101,6 +101,10 @@ export interface UIActions { openResumeDialog: () => void; closeResumeDialog: () => void; handleResume: (sessionId: string) => void; + // Delete session dialog + openDeleteDialog: () => void; + closeDeleteDialog: () => void; + handleDelete: (sessionId: string) => void; // Feedback dialog openFeedbackDialog: () => void; closeFeedbackDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 252bb43fc..b2450ec2c 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -25,6 +25,7 @@ import type { IdeContext, ApprovalMode, IdeInfo, + SessionListItem, } from '@qwen-code/qwen-code-core'; import type { DOMElement } from 'ink'; import type { SessionStatsState } from '../contexts/SessionContext.js'; @@ -61,6 +62,8 @@ export interface UIState { isPermissionsDialogOpen: boolean; isApprovalModeDialogOpen: boolean; isResumeDialogOpen: boolean; + resumeMatchedSessions: SessionListItem[] | undefined; + isDeleteDialogOpen: boolean; slashCommands: readonly SlashCommand[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; @@ -148,6 +151,9 @@ export interface UIState { streamingResponseLengthRef: React.RefObject<number>; // True = receiving content (↓), false = waiting for API response (↑) isReceivingContent: boolean; + // Session custom name (set via /rename) + sessionName: string | null; + setSessionName: (name: string | null) => void; // Prompt suggestion promptSuggestion: string | null; /** Dismiss prompt suggestion (clears state, aborts speculation) */ diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 4ccba4192..5d9cb0ba0 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -25,6 +25,7 @@ import { SlashCommandStatus, ToolConfirmationOutcome, IdeClient, + type SessionListItem, } from '@qwen-code/qwen-code-core'; import { useSessionStats } from '../contexts/SessionContext.js'; import type { @@ -72,6 +73,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'reset', 'new', 'resume', + 'delete', 'btw', ]); @@ -86,7 +88,9 @@ interface SlashCommandProcessorActions { openTrustDialog: () => void; openPermissionsDialog: () => void; openApprovalModeDialog: () => void; - openResumeDialog: () => void; + openResumeDialog: (matchedSessions?: SessionListItem[]) => void; + handleResume: (sessionId: string) => void; + openDeleteDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; @@ -117,6 +121,7 @@ export const useSlashCommandProcessor = ( extensionsUpdateState: Map<string, ExtensionUpdateStatus>, isConfigInitialized: boolean, logger: Logger | null, + setSessionName?: (name: string | null) => void, ) => { const { stats: sessionStats, startNewSession } = useSessionStats(); const [commands, setCommands] = useState<readonly SlashCommand[]>([]); @@ -271,6 +276,7 @@ export const useSlashCommandProcessor = ( clearItems(); clearScreen(); refreshStatic(); + setSessionName?.(null); }, loadHistory, setDebugMessage: actions.setDebugMessage, @@ -284,6 +290,7 @@ export const useSlashCommandProcessor = ( toggleVimEnabled, setGeminiMdFileCount, reloadCommands, + setSessionName: setSessionName ?? (() => {}), extensionsUpdateState, dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest: @@ -316,6 +323,7 @@ export const useSlashCommandProcessor = ( sessionShellAllowlist, setGeminiMdFileCount, reloadCommands, + setSessionName, extensionsUpdateState, isIdleRef, ], @@ -559,7 +567,14 @@ export const useSlashCommandProcessor = ( actions.openApprovalModeDialog(); return { type: 'handled' }; case 'resume': - actions.openResumeDialog(); + if (result.sessionId) { + actions.handleResume(result.sessionId); + } else { + actions.openResumeDialog(result.matchedSessions); + } + return { type: 'handled' }; + case 'delete': + actions.openDeleteDialog(); return { type: 'handled' }; case 'extensions_manage': actions.openExtensionsManagerDialog(); diff --git a/packages/cli/src/ui/hooks/useDeleteCommand.ts b/packages/cli/src/ui/hooks/useDeleteCommand.ts new file mode 100644 index 000000000..668dd41bf --- /dev/null +++ b/packages/cli/src/ui/hooks/useDeleteCommand.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import { t } from '../../i18n/index.js'; + +export interface UseDeleteCommandOptions { + config: Config | null; + addItem: UseHistoryManagerReturn['addItem']; +} + +export interface UseDeleteCommandResult { + isDeleteDialogOpen: boolean; + openDeleteDialog: () => void; + closeDeleteDialog: () => void; + handleDelete: (sessionId: string) => void; +} + +export function useDeleteCommand( + options?: UseDeleteCommandOptions, +): UseDeleteCommandResult { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const openDeleteDialog = useCallback(() => { + setIsDeleteDialogOpen(true); + }, []); + + const closeDeleteDialog = useCallback(() => { + setIsDeleteDialogOpen(false); + }, []); + + const { config, addItem } = options ?? {}; + + const handleDelete = useCallback( + async (sessionId: string) => { + if (!config) { + return; + } + + // Close dialog immediately. + closeDeleteDialog(); + + // Prevent deleting the current session. + if (sessionId === config.getSessionId()) { + addItem?.( + { + type: 'info', + text: t('Cannot delete the current active session.'), + }, + Date.now(), + ); + return; + } + + try { + const sessionService = config.getSessionService(); + const success = await sessionService.removeSession(sessionId); + + if (success) { + addItem?.( + { + type: 'info', + text: t('Session deleted successfully.'), + }, + Date.now(), + ); + } else { + addItem?.( + { + type: 'error', + text: t('Failed to delete session. Session not found.'), + }, + Date.now(), + ); + } + } catch { + addItem?.( + { + type: 'error', + text: t('Failed to delete session.'), + }, + Date.now(), + ); + } + }, + [closeDeleteDialog, config, addItem], + ); + + return { + isDeleteDialogOpen, + openDeleteDialog, + closeDeleteDialog, + handleDelete, + }; +} diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index ce3a6305e..4d5a5a68d 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -51,6 +51,9 @@ vi.mock('@qwen-code/qwen-code-core', () => { }) ); } + getSessionTitle(_sessionId: string) { + return undefined; + } } return { diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 81c2de962..0dcc3b54c 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -8,6 +8,7 @@ import { useState, useCallback } from 'react'; import { SessionService, type Config, + type SessionListItem, SessionStartSource, type PermissionMode, } from '@qwen-code/qwen-code-core'; @@ -18,12 +19,15 @@ export interface UseResumeCommandOptions { config: Config | null; historyManager: Pick<UseHistoryManagerReturn, 'clearItems' | 'loadHistory'>; startNewSession: (sessionId: string) => void; + setSessionName?: (name: string | null) => void; remount?: () => void; } export interface UseResumeCommandResult { isResumeDialogOpen: boolean; - openResumeDialog: () => void; + /** Pre-filtered sessions for the picker (when multiple title matches). */ + resumeMatchedSessions: SessionListItem[] | undefined; + openResumeDialog: (matchedSessions?: SessionListItem[]) => void; closeResumeDialog: () => void; /** * Async — the implementation awaits SessionService and SessionStart hooks. @@ -38,16 +42,25 @@ export function useResumeCommand( options?: UseResumeCommandOptions, ): UseResumeCommandResult { const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); + const [resumeMatchedSessions, setResumeMatchedSessions] = useState< + SessionListItem[] | undefined + >(); - const openResumeDialog = useCallback(() => { - setIsResumeDialogOpen(true); - }, []); + const openResumeDialog = useCallback( + (matchedSessions?: SessionListItem[]) => { + setResumeMatchedSessions(matchedSessions); + setIsResumeDialogOpen(true); + }, + [], + ); const closeResumeDialog = useCallback(() => { setIsResumeDialogOpen(false); + setResumeMatchedSessions(undefined); }, []); - const { config, historyManager, startNewSession, remount } = options ?? {}; + const { config, historyManager, startNewSession, setSessionName, remount } = + options ?? {}; const handleResume = useCallback( async (sessionId: string) => { @@ -69,6 +82,10 @@ export function useResumeCommand( // Start new session in UI context. startNewSession(sessionId); + // Restore session name tag from custom title. + const customTitle = sessionService.getSessionTitle(sessionId); + setSessionName?.(customTitle ?? null); + // Reset UI history. const uiHistoryItems = buildResumedHistoryItems(sessionData, config); historyManager.clearItems(); @@ -94,11 +111,19 @@ export function useResumeCommand( // Refresh terminal UI. remount?.(); }, - [closeResumeDialog, config, historyManager, startNewSession, remount], + [ + closeResumeDialog, + config, + historyManager, + startNewSession, + setSessionName, + remount, + ], ); return { isResumeDialogOpen, + resumeMatchedSessions, openResumeDialog, closeResumeDialog, handleResume, diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts index 7d451466a..98bba2f65 100644 --- a/packages/cli/src/ui/hooks/useSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -37,6 +37,13 @@ export interface UseSessionPickerOptions { * If false, uses follow mode (scrolls when selection reaches edge). */ centerSelection?: boolean; + /** + * Pre-filtered sessions to display instead of loading from sessionService. + * When provided, skips the initial listSessions() call and disables + * pagination (load-more). Used by /resume <title> when multiple sessions + * match the given title. + */ + initialSessions?: SessionListItem[]; /** * Enable/disable input handling. */ @@ -63,16 +70,18 @@ export function useSessionPicker({ onCancel, maxVisibleItems, centerSelection = false, + initialSessions, isActive = true, }: UseSessionPickerOptions): UseSessionPickerResult { + const hasInitialSessions = initialSessions !== undefined; const [selectedIndex, setSelectedIndex] = useState(0); - const [sessionState, setSessionState] = useState<SessionState>({ - sessions: [], - hasMore: true, - nextCursor: undefined, - }); + const [sessionState, setSessionState] = useState<SessionState>( + hasInitialSessions + ? { sessions: initialSessions, hasMore: false, nextCursor: undefined } + : { sessions: [], hasMore: true, nextCursor: undefined }, + ); const [filterByBranch, setFilterByBranch] = useState(false); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(!hasInitialSessions); // For follow mode (non-centered) const [followScrollOffset, setFollowScrollOffset] = useState(0); @@ -112,9 +121,9 @@ export function useSessionPicker({ const showScrollDown = scrollOffset + maxVisibleItems < filteredSessions.length; - // Initial load + // Initial load — skip when pre-filtered sessions are provided useEffect(() => { - if (!sessionService) { + if (!sessionService || hasInitialSessions) { return; } @@ -134,7 +143,7 @@ export function useSessionPicker({ }; void loadInitialSessions(); - }, [sessionService]); + }, [sessionService, hasInitialSessions]); const loadMoreSessions = useCallback(async () => { if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) { diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 782506fd8..52ba3701a 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] { toggleVimEnabled: async () => false, setGeminiMdFileCount: (_count) => {}, reloadCommands: () => {}, + setSessionName: () => {}, extensionsUpdateState: new Map(), dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {}, addConfirmUpdateExtensionRequest: (_request) => {}, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b7fa8191c..ed548562b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1331,6 +1331,13 @@ export class Config { sessionId?: string, sessionData?: ResumedSessionData, ): string { + // Finalize the outgoing session before switching. + try { + this.chatRecordingService?.finalize(); + } catch { + // Best-effort — don't block session switch + } + this.sessionId = sessionId ?? randomUUID(); this.sessionData = sessionData; setDebugLogSession(this); @@ -1597,6 +1604,13 @@ export class Config { return; } try { + // Finalize the current session's metadata before cleanup. + try { + this.chatRecordingService?.finalize(); + } catch { + // Best-effort — don't block shutdown + } + this.skillManager?.stopWatching(); if (this.toolRegistry) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 99837225f..a318e4730 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -290,6 +290,7 @@ export { ConditionalRulesRegistry } from './utils/rulesDiscovery.js'; export type { RuleFile } from './utils/rulesDiscovery.js'; export { OpenAILogger, openaiLogger } from './utils/openaiLogger.js'; export * from './utils/partUtils.js'; +export * from './utils/sessionStorageUtils.js'; export * from './utils/pathReader.js'; export * from './utils/paths.js'; export * from './utils/projectSummary.js'; diff --git a/packages/core/src/services/chatRecordingService.customTitle.test.ts b/packages/core/src/services/chatRecordingService.customTitle.test.ts new file mode 100644 index 000000000..479aa58c7 --- /dev/null +++ b/packages/core/src/services/chatRecordingService.customTitle.test.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { + ChatRecordingService, + type ChatRecord, +} from './chatRecordingService.js'; +import * as jsonl from '../utils/jsonl-utils.js'; + +vi.mock('node:path'); +vi.mock('node:child_process'); +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn(), + createHash: vi.fn(() => ({ + update: vi.fn(() => ({ + digest: vi.fn(() => 'mocked-hash'), + })), + })), +})); +vi.mock('../utils/jsonl-utils.js'); + +describe('ChatRecordingService - recordCustomTitle', () => { + let chatRecordingService: ChatRecordingService; + let mockConfig: Config; + + let uuidCounter = 0; + + beforeEach(() => { + uuidCounter = 0; + + mockConfig = { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + storage: { + getProjectTempDir: vi + .fn() + .mockReturnValue('/test/project/root/.qwen/tmp/hash'), + getProjectDir: vi + .fn() + .mockReturnValue('/test/project/root/.qwen/projects/test-project'), + }, + getModel: vi.fn().mockReturnValue('qwen-plus'), + getDebugMode: vi.fn().mockReturnValue(false), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn().mockReturnValue({ + displayName: 'Test Tool', + description: 'A test tool', + isOutputMarkdown: false, + }), + }), + getResumedSessionData: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + vi.mocked(randomUUID).mockImplementation( + () => + `00000000-0000-0000-0000-00000000000${++uuidCounter}` as `${string}-${string}-${string}-${string}-${string}`, + ); + vi.mocked(path.join).mockImplementation((...args) => args.join('/')); + vi.mocked(path.dirname).mockImplementation((p) => { + const parts = p.split('/'); + parts.pop(); + return parts.join('/'); + }); + vi.mocked(execSync).mockReturnValue('main\n'); + vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + + chatRecordingService = new ChatRecordingService(mockConfig); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should record a custom title as a system record', () => { + chatRecordingService.recordCustomTitle('my-feature'); + + expect(jsonl.writeLineSync).toHaveBeenCalledOnce(); + + const writtenRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + expect(writtenRecord.type).toBe('system'); + expect(writtenRecord.subtype).toBe('custom_title'); + expect(writtenRecord.systemPayload).toEqual({ + customTitle: 'my-feature', + }); + expect(writtenRecord.sessionId).toBe('test-session-id'); + }); + + it('should maintain parent chain when recording title after other records', () => { + chatRecordingService.recordUserMessage([{ text: 'hello' }]); + chatRecordingService.recordCustomTitle('my-feature'); + + expect(jsonl.writeLineSync).toHaveBeenCalledTimes(2); + + const userRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + const titleRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[1][1] as ChatRecord; + + expect(titleRecord.parentUuid).toBe(userRecord.uuid); + }); + + it('should include correct metadata in the record', () => { + chatRecordingService.recordCustomTitle('test-title'); + + const writtenRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + + expect(writtenRecord.cwd).toBe('/test/project/root'); + expect(writtenRecord.version).toBe('1.0.0'); + expect(writtenRecord.gitBranch).toBe('main'); + expect(writtenRecord.uuid).toBeDefined(); + expect(writtenRecord.timestamp).toBeDefined(); + }); + + describe('finalize', () => { + it('should re-append cached custom title to EOF', () => { + chatRecordingService.recordCustomTitle('my-feature'); + vi.mocked(jsonl.writeLineSync).mockClear(); + + chatRecordingService.finalize(); + + expect(jsonl.writeLineSync).toHaveBeenCalledOnce(); + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + expect(record.type).toBe('system'); + expect(record.subtype).toBe('custom_title'); + expect(record.systemPayload).toEqual({ customTitle: 'my-feature' }); + }); + + it('should not write anything when no custom title was set', () => { + chatRecordingService.finalize(); + + expect(jsonl.writeLineSync).not.toHaveBeenCalled(); + }); + + it('should re-append the latest title after multiple renames', () => { + chatRecordingService.recordCustomTitle('first-name'); + chatRecordingService.recordCustomTitle('second-name'); + vi.mocked(jsonl.writeLineSync).mockClear(); + + chatRecordingService.finalize(); + + expect(jsonl.writeLineSync).toHaveBeenCalledOnce(); + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + expect(record.systemPayload).toEqual({ customTitle: 'second-name' }); + }); + }); +}); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index b4b2c6fa0..0565c7ad2 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -59,7 +59,8 @@ export interface ChatRecord { | 'ui_telemetry' | 'at_command' | 'notification' - | 'cron'; + | 'cron' + | 'custom_title'; /** Working directory at time of message */ cwd: string; /** CLI version for compatibility tracking */ @@ -100,6 +101,7 @@ export interface ChatRecord { | SlashCommandRecordPayload | UiTelemetryRecordPayload | AtCommandRecordPayload + | CustomTitleRecordPayload | NotificationRecordPayload; } @@ -148,6 +150,14 @@ export interface AtCommandRecordPayload { userText?: string; } +/** + * Stored payload for custom title set via /rename. + */ +export interface CustomTitleRecordPayload { + /** The custom title for the session */ + customTitle: string; +} + /** * Stored payload for UI telemetry replay. */ @@ -183,11 +193,30 @@ export class ChatRecordingService { /** UUID of the last written record in the chain */ private lastRecordUuid: string | null = null; private readonly config: Config; + /** In-memory cache of the current session's custom title (for re-append on exit) */ + private currentCustomTitle: string | undefined; constructor(config: Config) { this.config = config; this.lastRecordUuid = config.getResumedSessionData()?.lastCompletedUuid ?? null; + + // On resume, load the cached custom title from the session file and + // immediately re-append it to EOF. This keeps the title within the + // 64KB tail window even as new messages push it deeper into the file. + // Without this, a crash mid-session could lose the title if the exit + // re-append never runs. + if (config.getResumedSessionData()) { + try { + const sessionService = config.getSessionService(); + this.currentCustomTitle = sessionService.getSessionTitle( + config.getSessionId(), + ); + this.finalize(); + } catch { + // Best-effort — don't block construction + } + } } /** @@ -481,6 +510,59 @@ export class ChatRecordingService { } } + /** + * Records a custom title for the session (set via /rename). + * Appended as a system record so it persists with the session data. + * Also caches the title in memory for re-append on shutdown. + * + * @returns true if the record was written successfully, false on I/O error. + */ + recordCustomTitle(customTitle: string): boolean { + try { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle }, + }; + + this.appendRecord(record); + this.currentCustomTitle = customTitle; + return true; + } catch (error) { + debugLogger.error('Error saving custom title record:', error); + return false; + } + } + + /** + * Finalizes the current session by re-appending cached metadata to EOF. + * + * Call this whenever leaving the current session — whether switching to + * another session, shutting down the process, or any other transition. + * This single entry point replaces scattered re-append calls and ensures + * the custom_title record stays within the last 64KB tail window that + * readSessionTitleFromFile() scans. + * + * Best-effort: errors are logged but never thrown. + */ + finalize(): void { + if (!this.currentCustomTitle) { + return; + } + try { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: this.currentCustomTitle }, + }; + this.appendRecord(record); + } catch (error) { + debugLogger.error('Error finalizing session metadata:', error); + } + } + /** * Records @-command metadata as a system record for UI reconstruction. */ diff --git a/packages/core/src/services/sessionService.rename.test.ts b/packages/core/src/services/sessionService.rename.test.ts new file mode 100644 index 000000000..5ba227cee --- /dev/null +++ b/packages/core/src/services/sessionService.rename.test.ts @@ -0,0 +1,506 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from 'vitest'; +import { getProjectHash } from '../utils/paths.js'; +import { SessionService } from './sessionService.js'; +import type { ChatRecord } from './chatRecordingService.js'; +import * as jsonl from '../utils/jsonl-utils.js'; + +vi.mock('node:path'); +vi.mock('../utils/paths.js'); +vi.mock('../utils/jsonl-utils.js'); + +describe('SessionService - rename and custom title', () => { + let sessionService: SessionService; + + let readdirSyncSpy: MockInstance<typeof fs.readdirSync>; + let statSyncSpy: MockInstance<typeof fs.statSync>; + + let readSyncSpy: MockInstance<typeof fs.readSync>; + + const sessionIdA = '550e8400-e29b-41d4-a716-446655440000'; + const sessionIdB = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + const recordA1: ChatRecord = { + uuid: 'a1', + parentUuid: null, + sessionId: sessionIdA, + timestamp: '2024-01-01T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hello session a' }] }, + cwd: '/test/project/root', + version: '1.0.0', + gitBranch: 'main', + }; + + const recordB1: ChatRecord = { + uuid: 'b1', + parentUuid: null, + sessionId: sessionIdB, + timestamp: '2024-01-02T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hi session b' }] }, + cwd: '/test/project/root', + version: '1.0.0', + gitBranch: 'feature', + }; + + beforeEach(() => { + vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); + vi.mocked(path.join).mockImplementation((...args) => args.join('/')); + vi.mocked(path.dirname).mockImplementation((p) => { + const parts = p.split('/'); + parts.pop(); + return parts.join('/'); + }); + + sessionService = new SessionService('/test/project/root'); + + readdirSyncSpy = vi.spyOn(fs, 'readdirSync').mockReturnValue([]); + statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation( + () => + ({ + mtimeMs: Date.now(), + size: 100, + isFile: () => true, + }) as unknown as fs.Stats, + ); + vi.spyOn(fs, 'openSync').mockReturnValue(42); + readSyncSpy = vi.spyOn(fs, 'readSync').mockReturnValue(0); + vi.spyOn(fs, 'closeSync').mockImplementation(() => undefined); + + vi.mocked(jsonl.read).mockResolvedValue([]); + vi.mocked(jsonl.readLines).mockResolvedValue([]); + vi.mocked(jsonl.writeLineSync).mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('renameSession', () => { + it('should append a custom_title record to the session file', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const result = await sessionService.renameSession( + sessionIdA, + 'my-feature', + ); + + expect(result).toBe(true); + expect(jsonl.writeLineSync).toHaveBeenCalledOnce(); + + const writtenRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + expect(writtenRecord.type).toBe('system'); + expect(writtenRecord.subtype).toBe('custom_title'); + expect(writtenRecord.systemPayload).toEqual({ + customTitle: 'my-feature', + }); + expect(writtenRecord.sessionId).toBe(sessionIdA); + }); + + it('should return false when session does not exist', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([]); + + const result = await sessionService.renameSession( + '00000000-0000-0000-0000-000000000000', + 'test', + ); + + expect(result).toBe(false); + expect(jsonl.writeLineSync).not.toHaveBeenCalled(); + }); + + it('should return false for session from different project', async () => { + const differentProjectRecord: ChatRecord = { + ...recordA1, + cwd: '/different/project', + }; + vi.mocked(jsonl.readLines).mockResolvedValue([differentProjectRecord]); + vi.mocked(getProjectHash).mockImplementation((cwd: string) => + cwd === '/test/project/root' + ? 'test-project-hash' + : 'other-project-hash', + ); + + const result = await sessionService.renameSession( + sessionIdA, + 'my-feature', + ); + + expect(result).toBe(false); + expect(jsonl.writeLineSync).not.toHaveBeenCalled(); + }); + + it('should handle file not found error', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(jsonl.readLines).mockRejectedValue(error); + + const result = await sessionService.renameSession( + '00000000-0000-0000-0000-000000000000', + 'test', + ); + + expect(result).toBe(false); + }); + }); + + describe('getSessionTitle', () => { + it('should return custom title from session file tail', () => { + const titleRecord = JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'my-feature' }, + }); + + statSyncSpy.mockReturnValue({ + size: titleRecord.length + 1, + mtimeMs: Date.now(), + } as unknown as fs.Stats); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(titleRecord + '\n'); + data.copy(buffer); + return data.length; + }, + ); + + const title = sessionService.getSessionTitle(sessionIdA); + expect(title).toBe('my-feature'); + }); + + it('should return last custom title when multiple exist', () => { + const line1 = JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'old-name' }, + }); + const line2 = JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'new-name' }, + }); + const content = line1 + '\n' + line2 + '\n'; + + statSyncSpy.mockReturnValue({ + size: content.length, + mtimeMs: Date.now(), + } as unknown as fs.Stats); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(content); + data.copy(buffer); + return data.length; + }, + ); + + const title = sessionService.getSessionTitle(sessionIdA); + expect(title).toBe('new-name'); + }); + + it('should return undefined when no custom title exists', () => { + const userRecord = JSON.stringify({ + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }); + + statSyncSpy.mockReturnValue({ + size: userRecord.length + 1, + mtimeMs: Date.now(), + } as unknown as fs.Stats); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(userRecord + '\n'); + data.copy(buffer); + return data.length; + }, + ); + + const title = sessionService.getSessionTitle(sessionIdA); + expect(title).toBeUndefined(); + }); + + it('should return undefined when file does not exist', () => { + statSyncSpy.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + const title = sessionService.getSessionTitle(sessionIdA); + expect(title).toBeUndefined(); + }); + }); + + describe('findSessionsByTitle', () => { + const now = Date.now(); + + function setupSessionFiles( + sessions: Array<{ + id: string; + record: ChatRecord; + mtime: number; + titleContent?: string; + }>, + ) { + readdirSyncSpy.mockReturnValue( + sessions.map((s) => `${s.id}.jsonl`) as unknown as Array< + fs.Dirent<Buffer> + >, + ); + + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + const p = filePath.toString(); + const session = sessions.find((s) => p.includes(s.id)); + return { + mtimeMs: session?.mtime ?? now, + size: session?.titleContent?.length ?? 100, + isFile: () => true, + } as unknown as fs.Stats; + }); + + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + const session = sessions.find((s) => filePath.includes(s.id)); + return session ? [session.record] : []; + }, + ); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, _buffer: any) => + // For simplicity, return empty content (no title by default) + // Individual tests can override this + 0, + ); + } + + it('should find session by exact custom title (case-insensitive)', async () => { + const titleContent = + JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'My-Feature' }, + }) + '\n'; + + setupSessionFiles([ + { id: sessionIdA, record: recordA1, mtime: now, titleContent }, + ]); + + // Override readSync to return title for session A + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(titleContent); + data.copy(buffer); + return data.length; + }, + ); + + const matches = await sessionService.findSessionsByTitle('my-feature'); + + expect(matches).toHaveLength(1); + expect(matches[0].sessionId).toBe(sessionIdA); + }); + + it('should return empty array when no session matches', async () => { + setupSessionFiles([{ id: sessionIdA, record: recordA1, mtime: now }]); + + const matches = await sessionService.findSessionsByTitle('nonexistent'); + + expect(matches).toHaveLength(0); + }); + + it('should not skip matches when multiple sessions share the same mtime (regression for PR #3093 review)', async () => { + // Three sessions sharing identical mtimes would fall on the page + // boundary of a paginated listSessions() and the third would be + // dropped by the strict `mtime < cursor` filter. Verify the exhaustive + // scan path returns all three. + const sessionIdC = '7ba7b810-9dad-11d1-80b4-00c04fd430c9'; + const recordC1: ChatRecord = { + uuid: 'c1', + parentUuid: null, + sessionId: sessionIdC, + timestamp: '2024-01-03T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hi session c' }] }, + cwd: '/test/project/root', + version: '1.0.0', + gitBranch: 'main', + }; + + const titleContent = + JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'shared-name' }, + }) + '\n'; + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + `${sessionIdB}.jsonl`, + `${sessionIdC}.jsonl`, + ] as unknown as Array<fs.Dirent<Buffer>>); + + const sharedMtime = now; + statSyncSpy.mockImplementation( + () => + ({ + mtimeMs: sharedMtime, + size: titleContent.length, + isFile: () => true, + }) as unknown as fs.Stats, + ); + + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes(sessionIdA)) return [recordA1]; + if (filePath.includes(sessionIdB)) return [recordB1]; + if (filePath.includes(sessionIdC)) return [recordC1]; + return []; + }, + ); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(titleContent); + data.copy(buffer); + return data.length; + }, + ); + + const matches = await sessionService.findSessionsByTitle('shared-name'); + + expect(matches).toHaveLength(3); + const matchedIds = matches.map((m) => m.sessionId).sort(); + expect(matchedIds).toEqual([sessionIdA, sessionIdB, sessionIdC].sort()); + }); + + it('should return multiple matches for duplicate titles', async () => { + const titleContent = + JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'shared-name' }, + }) + '\n'; + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + `${sessionIdB}.jsonl`, + ] as unknown as Array<fs.Dirent<Buffer>>); + + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + const p = filePath.toString(); + return { + mtimeMs: p.includes(sessionIdB) ? now : now - 1000, + size: titleContent.length, + isFile: () => true, + } as unknown as fs.Stats; + }); + + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes(sessionIdA)) return [recordA1]; + return [recordB1]; + }, + ); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(titleContent); + data.copy(buffer); + return data.length; + }, + ); + + const matches = await sessionService.findSessionsByTitle('shared-name'); + + expect(matches).toHaveLength(2); + }); + }); + + describe('listSessions with customTitle', () => { + it('should include customTitle in session list items', async () => { + const now = Date.now(); + const titleContent = + JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'my-feature' }, + }) + '\n'; + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array<fs.Dirent<Buffer>>); + + statSyncSpy.mockReturnValue({ + mtimeMs: now, + size: titleContent.length, + isFile: () => true, + } as unknown as fs.Stats); + + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(titleContent); + data.copy(buffer); + return data.length; + }, + ); + + const result = await sessionService.listSessions(); + + expect(result.items).toHaveLength(1); + expect(result.items[0].customTitle).toBe('my-feature'); + }); + + it('should return undefined customTitle when none set', async () => { + const now = Date.now(); + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array<fs.Dirent<Buffer>>); + + statSyncSpy.mockReturnValue({ + mtimeMs: now, + size: 100, + isFile: () => true, + } as unknown as fs.Stats); + + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, _buffer: any) => 0, + ); + + const result = await sessionService.listSessions(); + + expect(result.items).toHaveLength(1); + expect(result.items[0].customTitle).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 33a516319..19b7af1f5 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -8,6 +8,7 @@ import { Storage } from '../config/storage.js'; import { getProjectHash } from '../utils/paths.js'; import path from 'node:path'; import fs from 'node:fs'; +import { randomUUID } from 'node:crypto'; import readline from 'node:readline'; import type { Content, Part } from '@google/genai'; import * as jsonl from '../utils/jsonl-utils.js'; @@ -18,6 +19,7 @@ import type { } from './chatRecordingService.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { readLastJsonStringFieldSync } from '../utils/sessionStorageUtils.js'; const debugLogger = createDebugLogger('SESSION'); @@ -42,6 +44,8 @@ export interface SessionListItem { filePath: string; /** Number of messages in the session (unique message UUIDs) */ messageCount: number; + /** Custom title set via /rename, if any */ + customTitle?: string; } /** @@ -105,6 +109,12 @@ export interface ResumedSessionData { */ const MAX_FILES_TO_PROCESS = 10000; +/** + * Maximum character length for a session custom title. + * Shared across CLI, WebUI, VSCode, and ACP. + */ +export const SESSION_TITLE_MAX_LENGTH = 200; + /** * Pattern for validating session file names. * Session files are named as `${sessionId}.jsonl` where sessionId is a UUID-like identifier @@ -113,6 +123,11 @@ const MAX_FILES_TO_PROCESS = 10000; const SESSION_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.jsonl$/; /** Maximum number of lines to scan when looking for the first prompt text. */ const MAX_PROMPT_SCAN_LINES = 10; +/** + * Maximum bytes to read from head/tail of a session file. + * Used by readLastRecordUuid which still does its own tail read. + */ +const TAIL_READ_SIZE = 64 * 1024; /** * Service for managing chat sessions. @@ -138,6 +153,63 @@ export class SessionService { return path.join(this.storage.getProjectDir(), 'chats'); } + /** + * Reads the session title from a JSONL file. + * + * Delegates to {@link readLastJsonStringFieldSync}, which scans the tail + * window first (fast path; almost always hits because finalize() re-appends + * the title on every lifecycle event) and falls back to a full-file scan + * when the tail has no match. The `custom_title` line-marker guards against + * false matches from user content that happens to include a `customTitle` + * field. + */ + private readSessionTitleFromFile(filePath: string): string | undefined { + return readLastJsonStringFieldSync(filePath, 'customTitle', 'custom_title'); + } + + /** + * Reads the UUID of the last record in a session JSONL file. + * Uses a tail-read strategy for efficiency. + */ + private readLastRecordUuid(filePath: string): string | null { + try { + const stats = fs.statSync(filePath); + const fileSize = stats.size; + const readStart = Math.max(0, fileSize - TAIL_READ_SIZE); + const readLength = Math.min(fileSize, TAIL_READ_SIZE); + + const fd = fs.openSync(filePath, 'r'); + let buffer: Buffer; + try { + buffer = Buffer.alloc(readLength); + fs.readSync(fd, buffer, 0, readLength, readStart); + } finally { + fs.closeSync(fd); + } + + const tail = buffer.toString('utf-8'); + const lines = tail.split('\n'); + + // Walk backwards to find the last valid record + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (!trimmed) continue; + try { + const record = JSON.parse(trimmed) as ChatRecord; + if (record.uuid) { + return record.uuid; + } + } catch { + continue; + } + } + + return null; + } catch { + return null; + } + } + /** * Extracts the first user prompt text from a Content object. */ @@ -302,6 +374,7 @@ export class SessionService { gitBranch: firstRecord.gitBranch, filePath, messageCount, + customTitle: this.readSessionTitleFromFile(filePath), }); } @@ -483,6 +556,9 @@ export class SessionService { * @returns true if removed, false if not found */ async removeSession(sessionId: string): Promise<boolean> { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + return false; + } const chatsDir = this.getChatsDir(); const filePath = path.join(chatsDir, `${sessionId}.jsonl`); @@ -508,6 +584,159 @@ export class SessionService { } } + /** + * Renames a session by appending a custom_title system record to its JSONL file. + * + * @param sessionId The session ID to rename + * @param title The new custom title + * @returns true if renamed successfully, false if session not found + */ + async renameSession(sessionId: string, title: string): Promise<boolean> { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + return false; + } + const chatsDir = this.getChatsDir(); + const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + + try { + // Verify the file exists and belongs to this project + const records = await jsonl.readLines<ChatRecord>(filePath, 1); + if (records.length === 0) { + return false; + } + + const recordProjectHash = getProjectHash(records[0].cwd); + if (recordProjectHash !== this.projectHash) { + return false; + } + + // Read the last record's UUID so the custom_title record is properly + // chained into the parent history. reconstructHistory() walks from the + // tail record upward via parentUuid; a null parentUuid would sever the + // chain and cause the session to appear empty on next load. + const lastUuid = this.readLastRecordUuid(filePath); + + // Append a custom_title system record + const titleRecord: ChatRecord = { + uuid: randomUUID(), + parentUuid: lastUuid, + sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'custom_title', + cwd: records[0].cwd, + version: records[0].version, + systemPayload: { customTitle: title }, + }; + jsonl.writeLineSync(filePath, titleRecord); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } + } + + /** + * Gets the custom title for a session by reading from its JSONL file. + * + * @param sessionId The session ID to look up + * @returns The custom title, or undefined if none set + */ + getSessionTitle(sessionId: string): string | undefined { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + return undefined; + } + const chatsDir = this.getChatsDir(); + const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + return this.readSessionTitleFromFile(filePath); + } + + /** + * Finds sessions by custom title. + * Returns all matching sessions ordered by most recent first. + * + * @param title The custom title to search for (case-insensitive exact match) + * @returns Array of matching session list items + */ + async findSessionsByTitle(title: string): Promise<SessionListItem[]> { + const normalizedTitle = title.toLowerCase().trim(); + const matches: SessionListItem[] = []; + const chatsDir = this.getChatsDir(); + + // Scan all session files directly rather than paging through + // listSessions(): the mtime-only cursor there uses a strict `<` boundary, + // so sessions that share an mtime with the page's last entry are skipped, + // which would silently drop valid title matches. + let fileNames: string[]; + try { + fileNames = fs.readdirSync(chatsDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return matches; + } + throw error; + } + + const files: Array<{ name: string; mtime: number }> = []; + for (const name of fileNames) { + if (!SESSION_FILE_PATTERN.test(name)) continue; + const filePath = path.join(chatsDir, name); + try { + const stats = fs.statSync(filePath); + files.push({ name, mtime: stats.mtimeMs }); + } catch { + continue; + } + } + + // Sort most-recent first, with filename as a stable tie-breaker so runs + // are deterministic even when multiple files share an mtime. + files.sort((a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name)); + + let filesProcessed = 0; + for (const file of files) { + if (filesProcessed >= MAX_FILES_TO_PROCESS) break; + filesProcessed++; + + const filePath = path.join(chatsDir, file.name); + + // Cheap check first: tail-read the title and skip non-matches before + // doing the full hydration work (first-record read, project filter, + // message count, prompt extraction). + const customTitle = this.readSessionTitleFromFile(filePath); + if (customTitle?.toLowerCase().trim() !== normalizedTitle) continue; + + const records = await jsonl.readLines<ChatRecord>( + filePath, + MAX_PROMPT_SCAN_LINES, + ); + if (records.length === 0) continue; + const firstRecord = records[0]; + + const recordProjectHash = getProjectHash(firstRecord.cwd); + if (recordProjectHash !== this.projectHash) continue; + + const messageCount = await this.countSessionMessages(filePath); + const prompt = this.extractFirstPromptFromRecords(records); + + matches.push({ + sessionId: firstRecord.sessionId, + cwd: firstRecord.cwd, + startTime: firstRecord.timestamp, + mtime: file.mtime, + prompt, + gitBranch: firstRecord.gitBranch, + filePath, + messageCount, + customTitle, + }); + } + + return matches; + } + /** * Loads the most recent session for the current project. * Combines listSessions and loadSession for convenience. @@ -529,6 +758,9 @@ export class SessionService { * @returns true if session exists and belongs to current project */ async sessionExists(sessionId: string): Promise<boolean> { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + return false; + } const chatsDir = this.getChatsDir(); const filePath = path.join(chatsDir, `${sessionId}.jsonl`); diff --git a/packages/core/src/utils/secure-browser-launcher.test.ts b/packages/core/src/utils/secure-browser-launcher.test.ts index 4e986b078..cd6490330 100644 --- a/packages/core/src/utils/secure-browser-launcher.test.ts +++ b/packages/core/src/utils/secure-browser-launcher.test.ts @@ -208,7 +208,7 @@ describe('secure-browser-launcher', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await expect( - openBrowserSecurely('https://example.com') + openBrowserSecurely('https://example.com'), ).resolves.toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith( @@ -243,4 +243,4 @@ describe('secure-browser-launcher', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/packages/core/src/utils/secure-browser-launcher.ts b/packages/core/src/utils/secure-browser-launcher.ts index facfa329b..3a6e3219a 100644 --- a/packages/core/src/utils/secure-browser-launcher.ts +++ b/packages/core/src/utils/secure-browser-launcher.ts @@ -137,7 +137,9 @@ export async function openBrowserSecurely(url: string): Promise<void> { // Log the URL so the user can open it manually instead of crashing. /* eslint-disable no-console */ - console.warn(`Failed to open browser automatically. Please open this URL manually: ${url}`); + console.warn( + `Failed to open browser automatically. Please open this URL manually: ${url}`, + ); /* eslint-enable no-console */ return; } @@ -190,4 +192,4 @@ export function shouldLaunchBrowser(): boolean { // For non-Linux OSes, we generally assume a GUI is available // unless other signals (like SSH) suggest otherwise. return true; -} \ No newline at end of file +} diff --git a/packages/core/src/utils/sessionStorageUtils.test.ts b/packages/core/src/utils/sessionStorageUtils.test.ts new file mode 100644 index 000000000..1e8cf9eb7 --- /dev/null +++ b/packages/core/src/utils/sessionStorageUtils.test.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + extractJsonStringField, + extractLastJsonStringField, + LITE_READ_BUF_SIZE, + readLastJsonStringFieldSync, + unescapeJsonString, +} from './sessionStorageUtils.js'; + +describe('sessionStorageUtils', () => { + describe('unescapeJsonString', () => { + it('should return string as-is when no escapes', () => { + expect(unescapeJsonString('hello world')).toBe('hello world'); + }); + + it('should unescape JSON escape sequences', () => { + expect(unescapeJsonString('hello\\nworld')).toBe('hello\nworld'); + expect(unescapeJsonString('tab\\there')).toBe('tab\there'); + expect(unescapeJsonString('quote\\"here')).toBe('quote"here'); + }); + + it('should handle backslash', () => { + expect(unescapeJsonString('path\\\\to\\\\file')).toBe('path\\to\\file'); + }); + }); + + describe('extractJsonStringField', () => { + it('should extract field without space after colon', () => { + const text = '{"customTitle":"my-feature"}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('my-feature'); + }); + + it('should extract field with space after colon', () => { + const text = '{"customTitle": "my-feature"}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('my-feature'); + }); + + it('should return first match', () => { + const text = '{"customTitle":"first"}\n{"customTitle":"second"}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('first'); + }); + + it('should return undefined when field not found', () => { + const text = '{"type":"user","message":"hello"}'; + expect(extractJsonStringField(text, 'customTitle')).toBeUndefined(); + }); + + it('should handle escaped characters in value', () => { + const text = '{"customTitle":"hello\\nworld"}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('hello\nworld'); + }); + + it('should handle escaped quotes in value', () => { + const text = '{"customTitle":"say \\"hi\\""}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('say "hi"'); + }); + + it('should work on truncated/partial lines', () => { + // Simulates reading from middle of a file where first line is cut + const text = 'tle":"partial"}\n{"customTitle":"complete"}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('complete'); + }); + }); + + describe('extractLastJsonStringField', () => { + it('should return last occurrence', () => { + const text = '{"customTitle":"old-name"}\n{"customTitle":"new-name"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBe('new-name'); + }); + + it('should handle single occurrence', () => { + const text = '{"customTitle":"only-one"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBe('only-one'); + }); + + it('should return undefined when not found', () => { + const text = '{"type":"user"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBeUndefined(); + }); + + it('should handle mixed spacing styles', () => { + const text = '{"customTitle":"no-space"}\n{"customTitle": "with-space"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBe( + 'with-space', + ); + }); + + it('should return globally last match when mixed patterns interleave', () => { + // Bug fix: previously returned "middle" because the second pattern + // ("key": "value") scan overwrote the result from the first pattern. + const text = + '{"customTitle":"old"}\n{"customTitle": "middle"}\n{"customTitle":"newest"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBe('newest'); + }); + + it('should filter by lineContains when provided', () => { + const text = [ + '{"type":"user","content":"I set customTitle to \\"customTitle\\":\\"fake\\""}', + '{"subtype":"custom_title","customTitle":"real-title"}', + ].join('\n'); + expect( + extractLastJsonStringField(text, 'customTitle', 'custom_title'), + ).toBe('real-title'); + }); + + it('should ignore matches on lines without lineContains marker', () => { + const text = + '{"role":"assistant","customTitle":"spoofed"}\n{"subtype":"custom_title","customTitle":"legit"}'; + expect( + extractLastJsonStringField(text, 'customTitle', 'custom_title'), + ).toBe('legit'); + }); + + it('should return undefined when lineContains excludes all matches', () => { + const text = '{"customTitle":"no-subtype-here"}'; + expect( + extractLastJsonStringField(text, 'customTitle', 'custom_title'), + ).toBeUndefined(); + }); + + it('should not confuse different field names', () => { + const text = '{"otherField":"other-value"}\n{"customTitle":"user-name"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBe('user-name'); + expect(extractLastJsonStringField(text, 'otherField')).toBe( + 'other-value', + ); + }); + + it('should handle many occurrences', () => { + const lines = Array.from( + { length: 10 }, + (_, i) => `{"customTitle":"title-${i}"}`, + ).join('\n'); + expect(extractLastJsonStringField(lines, 'customTitle')).toBe('title-9'); + }); + }); + + describe('readLastJsonStringFieldSync', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sst-readlast-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeFile(name: string, content: string): string { + const p = path.join(tmpDir, name); + fs.writeFileSync(p, content); + return p; + } + + it('returns undefined for a missing file', () => { + const p = path.join(tmpDir, 'does-not-exist.jsonl'); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBeUndefined(); + }); + + it('returns undefined for an empty file', () => { + const p = writeFile('empty.jsonl', ''); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBeUndefined(); + }); + + it('returns the only match for a small file', () => { + const p = writeFile( + 'small.jsonl', + '{"type":"user"}\n{"subtype":"custom_title","customTitle":"only"}\n', + ); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('only'); + }); + + it('returns the last match when the tail contains the field', () => { + const p = writeFile( + 'tail-hit.jsonl', + [ + '{"subtype":"custom_title","customTitle":"old"}', + '{"subtype":"custom_title","customTitle":"new"}', + '', + ].join('\n'), + ); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('new'); + }); + + it('falls back to full-file scan when tail has no match (Phase 2)', () => { + // Build a file whose custom_title record is near the start, followed by + // enough filler bytes (> LITE_READ_BUF_SIZE) that the tail window is + // entirely filler. The old head+tail reader would have hit this via the + // head window; this test verifies the new tail-first + full-scan + // strategy still resolves it. + const titleLine = + '{"subtype":"custom_title","customTitle":"buried-in-middle"}'; + const filler = '{"type":"user","message":"' + 'x'.repeat(256) + '"}'; + // ~4x the tail window, guaranteed to push the title line out of tail. + const fillerCount = Math.ceil((LITE_READ_BUF_SIZE * 4) / filler.length); + const content = + titleLine + + '\n' + + Array.from({ length: fillerCount }, () => filler).join('\n') + + '\n'; + + const p = writeFile('phase2.jsonl', content); + expect(fs.statSync(p).size).toBeGreaterThan(LITE_READ_BUF_SIZE * 3); + + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('buried-in-middle'); + }); + + it('returns the last occurrence even when multiple land in the full-scan region', () => { + const early = '{"subtype":"custom_title","customTitle":"first-rename"}'; + const middle = '{"subtype":"custom_title","customTitle":"second-rename"}'; + const filler = '{"type":"user","message":"' + 'x'.repeat(256) + '"}'; + const fillerCount = Math.ceil((LITE_READ_BUF_SIZE * 3) / filler.length); + + const content = + early + + '\n' + + middle + + '\n' + + Array.from({ length: fillerCount }, () => filler).join('\n') + + '\n'; + + const p = writeFile('phase2-multi.jsonl', content); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('second-rename'); + }); + + it('respects the lineContains filter when scanning', () => { + const p = writeFile( + 'filter.jsonl', + [ + '{"type":"user","customTitle":"spoofed-in-user-content"}', + '{"subtype":"custom_title","customTitle":"legit"}', + '', + ].join('\n'), + ); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('legit'); + }); + + it('returns undefined when neither phase finds the field', () => { + const line = '{"type":"user","message":"' + 'x'.repeat(512) + '"}'; + const lineCount = Math.ceil((LITE_READ_BUF_SIZE * 3) / line.length); + const content = + Array.from({ length: lineCount }, () => line).join('\n') + '\n'; + const p = writeFile('no-title.jsonl', content); + + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBeUndefined(); + }); + + it('handles a final line without a trailing newline', () => { + const p = writeFile( + 'no-trailing-newline.jsonl', + '{"type":"user"}\n{"subtype":"custom_title","customTitle":"last"}', + ); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('last'); + }); + }); +}); diff --git a/packages/core/src/utils/sessionStorageUtils.ts b/packages/core/src/utils/sessionStorageUtils.ts new file mode 100644 index 000000000..c91c8615d --- /dev/null +++ b/packages/core/src/utils/sessionStorageUtils.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Portable session storage utilities for efficient session metadata reading. + * + * Provides string-level JSON field extraction (no full parse) and head/tail + * file reading for fast session metadata access on large JSONL files. + */ + +import fs from 'node:fs'; + +/** Size of the head/tail buffer for lite metadata reads (64KB). */ +export const LITE_READ_BUF_SIZE = 64 * 1024; + +// --------------------------------------------------------------------------- +// JSON string field extraction — no full parse, works on truncated lines +// --------------------------------------------------------------------------- + +/** + * Unescape a JSON string value extracted as raw text. + * Only allocates a new string when escape sequences are present. + */ +export function unescapeJsonString(raw: string): string { + if (!raw.includes('\\')) return raw; + try { + return JSON.parse(`"${raw}"`); + } catch { + return raw; + } +} + +/** + * Extracts a simple JSON string field value from raw text without full parsing. + * Looks for `"key":"value"` or `"key": "value"` patterns. + * Returns the first match, or undefined if not found. + */ +export function extractJsonStringField( + text: string, + key: string, +): string | undefined { + const patterns = [`"${key}":"`, `"${key}": "`]; + for (const pattern of patterns) { + const idx = text.indexOf(pattern); + if (idx < 0) continue; + + const valueStart = idx + pattern.length; + let i = valueStart; + while (i < text.length) { + if (text[i] === '\\') { + i += 2; + continue; + } + if (text[i] === '"') { + return unescapeJsonString(text.slice(valueStart, i)); + } + i++; + } + } + return undefined; +} + +/** + * Like extractJsonStringField but finds the LAST occurrence. + * Useful for fields that are appended (customTitle, aiTitle, etc.) + * where the most recent entry should win. + * + * When `lineContains` is provided, only matches on lines that also contain + * the given substring are considered. This prevents false matches from user + * content that happens to contain the same key pattern. + */ +export function extractLastJsonStringField( + text: string, + key: string, + lineContains?: string, +): string | undefined { + const patterns = [`"${key}":"`, `"${key}": "`]; + let lastValue: string | undefined; + let lastOffset = -1; + for (const pattern of patterns) { + let searchFrom = 0; + while (true) { + const idx = text.indexOf(pattern, searchFrom); + if (idx < 0) break; + + // If lineContains is specified, verify the current line contains it + if (lineContains) { + const lineStart = text.lastIndexOf('\n', idx) + 1; + const lineEnd = text.indexOf('\n', idx); + const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd); + if (!line.includes(lineContains)) { + searchFrom = idx + pattern.length; + continue; + } + } + + const valueStart = idx + pattern.length; + let i = valueStart; + while (i < text.length) { + if (text[i] === '\\') { + i += 2; + continue; + } + if (text[i] === '"') { + if (idx > lastOffset) { + lastValue = unescapeJsonString(text.slice(valueStart, i)); + lastOffset = idx; + } + break; + } + i++; + } + searchFrom = i + 1; + } + } + return lastValue; +} + +// --------------------------------------------------------------------------- +// File I/O — tail-first scan with full-file fallback +// --------------------------------------------------------------------------- + +/** + * Reads a JSON string field value from a JSONL file, returning the latest + * occurrence (last in file order). + * + * Two-phase strategy: + * 1. Scan the last LITE_READ_BUF_SIZE bytes of the file; if the field is + * present, return it immediately. This is the common path because + * ChatRecordingService.finalize() re-appends metadata records to EOF + * on every session lifecycle event, keeping the latest title near the + * end of the file. + * 2. If the tail window has no match, stream the entire file in chunks + * and return the last hit. This guarantees we never miss a record that + * landed between the head and tail windows in a large file — a blind + * spot the previous head+tail approach had. + * + * Phase 2 is a full-file scan and is intentionally slower; it is only paid + * when Phase 1 misses. + * + * Returns `undefined` on any I/O error or when the field is not found. + * + * @param lineContains Optional substring that must appear on the same line + * as the matched field. See {@link extractLastJsonStringField}. + */ +export function readLastJsonStringFieldSync( + filePath: string, + key: string, + lineContains?: string, +): string | undefined { + let fd: number | undefined; + try { + const stats = fs.statSync(filePath); + const fileSize = stats.size; + if (fileSize === 0) return undefined; + + fd = fs.openSync(filePath, 'r'); + + // Phase 1: tail window — fast path. + const tailLength = Math.min(fileSize, LITE_READ_BUF_SIZE); + const tailOffset = fileSize - tailLength; + const tailBuffer = Buffer.alloc(tailLength); + const tailBytes = fs.readSync(fd, tailBuffer, 0, tailLength, tailOffset); + if (tailBytes > 0) { + const tailText = tailBuffer.toString('utf-8', 0, tailBytes); + const tailHit = extractLastJsonStringField(tailText, key, lineContains); + if (tailHit !== undefined) { + return tailHit; + } + } + + // If the whole file already fit in the tail window, there is nothing left + // to scan. + if (tailOffset === 0) return undefined; + + // Phase 2: stream the whole file and return the last hit. Scanning from + // offset 0 (rather than [0, tailOffset)) avoids the edge case where a + // single record straddles the Phase 1/Phase 2 boundary — duplicate work + // on the tail bytes is harmless because we only care about the final + // match. + let lastHit: string | undefined; + let readOffset = 0; + let carry = ''; + while (readOffset < fileSize) { + const toRead = Math.min(LITE_READ_BUF_SIZE, fileSize - readOffset); + const buf = Buffer.alloc(toRead); + const bytesRead = fs.readSync(fd, buf, 0, toRead, readOffset); + if (bytesRead === 0) break; + readOffset += bytesRead; + + const chunk = carry + buf.toString('utf-8', 0, bytesRead); + const lastNewline = chunk.lastIndexOf('\n'); + if (lastNewline < 0) { + // No newline yet — the entire chunk is a partial line; keep carrying. + carry = chunk; + continue; + } + + const complete = chunk.slice(0, lastNewline + 1); + carry = chunk.slice(lastNewline + 1); + + const hit = extractLastJsonStringField(complete, key, lineContains); + if (hit !== undefined) lastHit = hit; + } + + // Final trailing line without a newline terminator. + if (carry) { + const hit = extractLastJsonStringField(carry, key, lineContains); + if (hit !== undefined) lastHit = hit; + } + + return lastHit; + } catch { + return undefined; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + // best-effort: we already have our result (or decided there is none) + } + } + } +} diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 9e2d6f6a9..3550d1fa1 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -523,7 +523,12 @@ export class AcpConnection { params['cursor'] = String(options.cursor); } if (options?.size !== undefined) { - params['size'] = options.size; + // ACP ListSessionsRequest schema has no `size` field; the SDK's zod + // validator strips unknown top-level keys, so the agent would never + // see it. Carry it via `_meta` instead, matching the pattern used for + // other Qwen Code ACP extensions. + const existingMeta = (params['_meta'] ?? {}) as Record<string, unknown>; + params['_meta'] = { ...existingMeta, size: options.size }; } const response = await conn.unstable_listSessions( params as Parameters<typeof conn.unstable_listSessions>[0], @@ -539,6 +544,38 @@ export class AcpConnection { } } + async deleteSession(sessionId: string): Promise<{ success: boolean }> { + const conn = this.ensureConnection(); + try { + const result = await conn.extMethod('deleteSession', { + sessionId, + cwd: this.workingDir, + }); + return result as { success: boolean }; + } catch (error) { + console.error('[ACP] Failed to delete session:', error); + throw error; + } + } + + async renameSession( + sessionId: string, + title: string, + ): Promise<{ success: boolean }> { + const conn = this.ensureConnection(); + try { + const result = await conn.extMethod('renameSession', { + sessionId, + title, + cwd: this.workingDir, + }); + return result as { success: boolean }; + } catch (error) { + console.error('[ACP] Failed to rename session:', error); + throw error; + } + } + async switchSession(sessionId: string): Promise<void> { console.log('[ACP] Switching to session:', sessionId); this.sessionId = sessionId; diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 72d2b4b03..426558ece 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -716,6 +716,32 @@ export class QwenAgentManager { } } + /** + * Delete a session by ID via ACP. + */ + async deleteSession(sessionId: string): Promise<boolean> { + try { + const res = await this.connection.deleteSession(sessionId); + return res.success; + } catch (error) { + console.error('[QwenAgentManager] Failed to delete session:', error); + return false; + } + } + + /** + * Rename a session via ACP. + */ + async renameSession(sessionId: string, title: string): Promise<boolean> { + try { + const res = await this.connection.renameSession(sessionId, title); + return res.success; + } catch (error) { + console.error('[QwenAgentManager] Failed to rename session:', error); + return false; + } + } + // Read CLI JSONL session file and convert to ChatMessage[] for UI private async readJsonlMessages(filePath: string): Promise<ChatMessage[]> { const fs = await import('fs'); diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 478da7c7b..93303c6d8 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -8,8 +8,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as readline from 'readline'; +import * as crypto from 'crypto'; import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js'; import { truncatePanelTitle } from '../webview/utils/panelTitleUtils.js'; +import { getGitBranch } from '@qwen-code/qwen-code-core/src/utils/gitUtils.js'; export interface QwenMessage { id: string; @@ -37,6 +39,7 @@ export interface QwenSession { filePath?: string; messageCount?: number; firstUserText?: string; + customTitle?: string; cwd?: string; } @@ -182,6 +185,10 @@ export class QwenSessionReader { * Get session title (based on first user message) */ getSessionTitle(session: QwenSession): string { + // Prefer custom title set via /rename + if (session.customTitle) { + return session.customTitle; + } // Prefer cached prompt text to avoid loading messages for JSONL sessions const text = session.firstUserText ? session.firstUserText @@ -219,6 +226,7 @@ export class QwenSessionReader { let sessionId: string | undefined; let startTime: string | undefined; let firstUserText: string | undefined; + let customTitle: string | undefined; let cwd: string | undefined; for await (const line of rl) { @@ -265,6 +273,19 @@ export class QwenSessionReader { firstUserText = text; } } + + // Extract custom title from system records (last one wins) + if ( + type === 'system' && + obj.subtype === 'custom_title' && + typeof obj.systemPayload === 'object' && + obj.systemPayload !== null + ) { + const payload = obj.systemPayload as Record<string, unknown>; + if (typeof payload.customTitle === 'string') { + customTitle = payload.customTitle; + } + } } // Ensure stream is closed @@ -287,6 +308,7 @@ export class QwenSessionReader { filePath, messageCount: seenUuids.size, firstUserText, + customTitle, cwd, }; } catch (error) { @@ -325,23 +347,110 @@ export class QwenSessionReader { } } + /** + * Reads the UUID of the last record in a JSONL file via tail-read. + */ + private readLastRecordUuid(filePath: string): string | null { + try { + const TAIL_SIZE = 64 * 1024; + const stats = fs.statSync(filePath); + const readStart = Math.max(0, stats.size - TAIL_SIZE); + const readLength = Math.min(stats.size, TAIL_SIZE); + + const fd = fs.openSync(filePath, 'r'); + let buffer: Buffer; + try { + buffer = Buffer.alloc(readLength); + fs.readSync(fd, buffer, 0, readLength, readStart); + } finally { + fs.closeSync(fd); + } + + const lines = buffer.toString('utf-8').split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (!trimmed) { + continue; + } + try { + const record = JSON.parse(trimmed); + if (record.uuid) { + return record.uuid; + } + } catch { + continue; + } + } + return null; + } catch { + return null; + } + } + /** * Delete session file */ - async deleteSession( - sessionId: string, - _workingDir: string, - ): Promise<boolean> { + async deleteSession(sessionId: string, workingDir: string): Promise<boolean> { try { - const session = await this.getSession(sessionId, _workingDir); - if (session && session.filePath) { - fs.unlinkSync(session.filePath); - return true; + const session = await this.getSession(sessionId, workingDir); + if (!session || !session.filePath) { + return false; } - return false; + // Verify the session belongs to the current project + const expectedHash = getProjectHash(workingDir); + if (session.projectHash && session.projectHash !== expectedHash) { + return false; + } + fs.unlinkSync(session.filePath); + return true; } catch (error) { console.error('[QwenSessionReader] Failed to delete session:', error); return false; } } + + /** + * Rename session by appending a custom_title system record to the JSONL file. + */ + async renameSession( + sessionId: string, + title: string, + workingDir: string, + ): Promise<boolean> { + try { + const session = await this.getSession(sessionId, workingDir); + if (!session || !session.filePath) { + return false; + } + // Verify the session belongs to the current project + const expectedHash = getProjectHash(workingDir); + if (session.projectHash && session.projectHash !== expectedHash) { + return false; + } + + // Read the last record's UUID so the custom_title record is properly + // chained into the parent history (reconstructHistory walks from tail). + const lastUuid = this.readLastRecordUuid(session.filePath); + + const cwd = session.cwd || workingDir; + const record = JSON.stringify({ + uuid: crypto.randomUUID(), + parentUuid: lastUuid, + sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'custom_title', + cwd, + version: 'vscode', + gitBranch: getGitBranch(cwd), + systemPayload: { customTitle: title }, + }); + + fs.appendFileSync(session.filePath, record + '\n'); + return true; + } catch (error) { + console.error('[QwenSessionReader] Failed to rename session:', error); + return false; + } + } } diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 59ce30cd0..8e6963c07 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -970,12 +970,14 @@ export const App: React.FC = () => { return ( <div className="chat-container relative"> {/* Top-level loading overlay */} - {isLoading && ( + {(isLoading || sessionManagement.isSwitchingSession) && ( <div className="bg-background/80 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm"> <div className="text-center"> <div className="border-primary mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div> <p className="text-muted-foreground text-sm"> - Preparing Qwen Code... + {sessionManagement.isSwitchingSession + ? 'Loading conversation...' + : 'Preparing Qwen Code...'} </p> </div> </div> @@ -991,6 +993,8 @@ export const App: React.FC = () => { sessionManagement.handleSwitchSession(sessionId); sessionManagement.setSessionSearchQuery(''); }} + onRenameSession={sessionManagement.handleRenameSession} + onDeleteSession={sessionManagement.handleDeleteSession} onClose={() => sessionManagement.setShowSessionSelector(false)} hasMore={sessionManagement.hasMore} isLoading={sessionManagement.isLoading} @@ -1009,7 +1013,7 @@ export const App: React.FC = () => { ref={messagesContainerRef} className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[140px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]" > - {!hasContent && !isLoading ? ( + {!hasContent && !isLoading && !sessionManagement.isSwitchingSession ? ( isAuthenticated === false ? ( <Onboarding /> ) : isAuthenticated === null ? ( diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx index 97d027346..bd4691dff 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -16,61 +16,57 @@ import { ProviderSetupForm } from './ProviderSetupForm.js'; * VSCode Onboarding page. */ export const Onboarding: FC = () => ( - <div - className="flex flex-col flex-1 min-h-0 px-6" - style={{ - alignItems: 'center', - justifyContent: 'center', - textAlign: 'center', - }} - > - {/* Logo + title block — sits above the card for visual breathing room */} - <div className="flex flex-col items-center gap-3 mb-6"> - <img - src={iconUrl} - alt="Qwen Code" - className="w-12 h-12 object-contain" - /> - <div className="text-center"> - <h1 - className="text-base font-semibold" - style={{ color: 'var(--app-primary-foreground)' }} - > - Qwen Code - </h1> - <p - className="text-xs mt-1" - style={{ color: 'var(--app-secondary-foreground)' }} - > - AI-powered coding assistant for your editor - </p> - </div> - </div> - - {/* Setup card */} - <div - className="w-full max-w-[300px] rounded-lg border p-4" - style={{ - backgroundColor: 'var(--app-input-secondary-background)', - borderColor: 'var(--app-input-border)', - }} - > + <div + className="flex flex-col flex-1 min-h-0 px-6" + style={{ + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + }} + > + {/* Logo + title block — sits above the card for visual breathing room */} + <div className="flex flex-col items-center gap-3 mb-6"> + <img src={iconUrl} alt="Qwen Code" className="w-12 h-12 object-contain" /> + <div className="text-center"> + <h1 + className="text-base font-semibold" + style={{ color: 'var(--app-primary-foreground)' }} + > + Qwen Code + </h1> <p - className="text-xs mb-3 text-center" + className="text-xs mt-1" style={{ color: 'var(--app-secondary-foreground)' }} > - Connect a model provider to get started + AI-powered coding assistant for your editor </p> - <ProviderSetupForm /> </div> - - {/* Subtle hint below the card */} - <p - className="text-[10px] mt-4 text-center max-w-[260px]" - style={{ color: 'var(--app-secondary-foreground)', opacity: 0.6 }} - > - Supports Alibaba Cloud Coding Plan, ModelStudio API Key, and - OpenAI-compatible endpoints - </p> </div> - ); + + {/* Setup card */} + <div + className="w-full max-w-[300px] rounded-lg border p-4" + style={{ + backgroundColor: 'var(--app-input-secondary-background)', + borderColor: 'var(--app-input-border)', + }} + > + <p + className="text-xs mb-3 text-center" + style={{ color: 'var(--app-secondary-foreground)' }} + > + Connect a model provider to get started + </p> + <ProviderSetupForm /> + </div> + + {/* Subtle hint below the card */} + <p + className="text-[10px] mt-4 text-center max-w-[260px]" + style={{ color: 'var(--app-secondary-foreground)', opacity: 0.6 }} + > + Supports Alibaba Cloud Coding Plan, ModelStudio API Key, and + OpenAI-compatible endpoints + </p> + </div> +); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index 591a69493..6dc7bea52 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -186,6 +186,52 @@ describe('SessionMessageHandler', () => { ]); }); + it('keeps currentConversationId aligned with the archived sessionId when session/load falls back to a new ACP session', async () => { + const archivedSessionId = 'archived-session'; + const agentManager = { + isConnected: true, + currentSessionId: 'old-acp-session', + getSessionList: vi + .fn() + .mockResolvedValue([{ id: archivedSessionId, cwd: '/workspace' }]), + loadSessionViaAcp: vi + .fn() + .mockRejectedValue(new Error('session not found on server')), + getSessionMessages: vi.fn().mockResolvedValue([]), + createNewSession: vi.fn().mockResolvedValue('new-acp-session'), + }; + const conversationStore = { + createConversation: vi.fn(), + getConversation: vi.fn(), + addMessage: vi.fn(), + }; + const sendToWebView = vi.fn(); + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'switchQwenSession', + data: { sessionId: archivedSessionId }, + }); + + // Backend-tracked current session must match the sessionId the webview sees, + // otherwise rename/delete/title-update flows will target the wrong session + // during the fallback window (see PR #3093 review). + expect(handler.getCurrentConversationId()).toBe(archivedSessionId); + expect(agentManager.createNewSession).toHaveBeenCalled(); + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'qwenSessionSwitched', + data: expect.objectContaining({ sessionId: archivedSessionId }), + }), + ); + }); + it('forces a fresh ACP session when the webview requests a new session', async () => { const agentManager = { isConnected: true, diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 2d8d29168..7f5e72ae2 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -32,6 +32,8 @@ export class SessionMessageHandler extends BaseMessageHandler { 'switchQwenSession', 'getQwenSessions', 'resumeSession', + 'deleteQwenSession', + 'renameQwenSession', 'cancelStreaming', // UI action: open a new chat tab (new WebviewPanel) 'openNewChatTab', @@ -95,6 +97,17 @@ export class SessionMessageHandler extends BaseMessageHandler { await this.handleResumeSession((data?.sessionId as string) || ''); break; + case 'deleteQwenSession': + await this.handleDeleteQwenSession((data?.sessionId as string) || ''); + break; + + case 'renameQwenSession': + await this.handleRenameQwenSession( + (data?.sessionId as string) || '', + (data?.title as string) || '', + ); + break; + case 'openNewChatTab': // Open a brand new chat tab (WebviewPanel) via the extension command // This does not alter the current conversation in this tab; the new tab @@ -497,6 +510,21 @@ export class SessionMessageHandler extends BaseMessageHandler { } this.sendStreamEnd(undefined, myRequestId); + + // After first message, sync ACP session ID to webview for session list highlighting + const acpSessionId = this.agentManager.currentSessionId; + if (acpSessionId && acpSessionId !== this.currentConversationId) { + this.currentConversationId = acpSessionId; + this.sendToWebView({ + type: 'sessionTitleUpdated', + data: { + sessionId: acpSessionId, + title: + displayText.substring(0, 50) + + (displayText.length > 50 ? '...' : ''), + }, + }); + } } catch (error) { console.error('[SessionMessageHandler] Error sending message:', error); @@ -654,12 +682,20 @@ export class SessionMessageHandler extends BaseMessageHandler { type: 'qwenSessionSwitched', data: { sessionId, messages }, }); + this.sendToWebView({ + type: 'sessionLoadComplete', + data: { sessionId }, + }); vscode.window.showInformationMessage( 'Showing cached session content. Configure your provider to interact with the AI.', ); return; } else if (choice !== 'auth') { - // User dismissed; do nothing + // User dismissed; clear loading state + this.sendToWebView({ + type: 'sessionLoadComplete', + data: { sessionId }, + }); return; } } @@ -704,6 +740,12 @@ export class SessionMessageHandler extends BaseMessageHandler { // Reset title flag when switching sessions this.isTitleSet = false; + // Notify webview that session history has finished loading + this.sendToWebView({ + type: 'sessionLoadComplete', + data: { sessionId }, + }); + // Successfully loaded session, return early to avoid fallback logic return; } catch (loadError) { @@ -733,19 +775,28 @@ export class SessionMessageHandler extends BaseMessageHandler { // If we are connected, try to create a fresh ACP session so user can interact if (this.agentManager.isConnected) { try { - const newAcpSessionId = await this.agentManager.createNewSession( - workingDir, - { - forceNew: true, - }, - ); + await this.agentManager.createNewSession(workingDir, { + forceNew: true, + }); - this.currentConversationId = newAcpSessionId; + // Keep the viewed session identity aligned with what the webview sees + // (the archived sessionId). The live ACP session lives on + // agentManager.currentSessionId; the sync-on-first-message path + // (see streamEnd handler) will flip both sides to the ACP id once + // the user actually sends a message. Setting currentConversationId + // to the new ACP id here would desync the backend from the webview + // and cause rename/delete/title-update flows to target the wrong + // session during the fallback window. + this.currentConversationId = sessionId; this.sendToWebView({ type: 'qwenSessionSwitched', data: { sessionId, messages, session: sessionDetails }, }); + this.sendToWebView({ + type: 'sessionLoadComplete', + data: { sessionId }, + }); // Only show the cache warning if we actually fell back to local cache // and didn't successfully load via ACP @@ -792,6 +843,10 @@ export class SessionMessageHandler extends BaseMessageHandler { type: 'qwenSessionSwitched', data: { sessionId, messages, session: sessionDetails }, }); + this.sendToWebView({ + type: 'sessionLoadComplete', + data: { sessionId }, + }); vscode.window.showWarningMessage( 'Showing cached session content. Configure your provider to interact with the AI.', ); @@ -983,6 +1038,98 @@ export class SessionMessageHandler extends BaseMessageHandler { } } + /** + * Handle delete session request + */ + private async handleDeleteQwenSession(sessionId: string): Promise<void> { + try { + if ( + sessionId === this.currentConversationId || + sessionId === this.agentManager.currentSessionId + ) { + this.sendToWebView({ + type: 'error', + data: { message: 'Cannot delete the current active session.' }, + }); + return; + } + + const success = await this.agentManager.deleteSession(sessionId); + if (success) { + this.sendToWebView({ + type: 'sessionDeleted', + data: { sessionId }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: 'Failed to delete session.' }, + }); + } + } catch (error) { + const errorMsg = this.getErrorMessage(error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to delete session: ${errorMsg}` }, + }); + } + } + + /** + * Handle rename session request + */ + private async handleRenameQwenSession( + sessionId: string, + title: string, + ): Promise<void> { + try { + const trimmedTitle = title.trim().replace(/[\r\n]+/g, ' '); + if (!trimmedTitle) { + this.sendToWebView({ + type: 'error', + data: { message: 'Please provide a name.' }, + }); + return; + } + // Matches SESSION_TITLE_MAX_LENGTH from @qwen-code/qwen-code-core/sessionService + if (trimmedTitle.length > 200) { + this.sendToWebView({ + type: 'error', + data: { message: 'Name is too long. Maximum 200 characters.' }, + }); + return; + } + + const success = await this.agentManager.renameSession( + sessionId, + trimmedTitle, + ); + if (success) { + this.sendToWebView({ + type: 'sessionRenamed', + data: { sessionId, title: trimmedTitle }, + }); + if (sessionId === this.currentConversationId) { + this.sendToWebView({ + type: 'sessionTitleUpdated', + data: { sessionId, title: trimmedTitle }, + }); + } + } else { + this.sendToWebView({ + type: 'error', + data: { message: 'Failed to rename session.' }, + }); + } + } catch (error) { + const errorMsg = this.getErrorMessage(error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to rename session: ${errorMsg}` }, + }); + } + } + /** * Set approval mode via agent (ACP session/set_mode) */ diff --git a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts index 2f69772d9..2b863cd45 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import type { VSCodeAPI } from '../../hooks/useVSCode.js'; /** @@ -23,9 +23,40 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { const [nextCursor, setNextCursor] = useState<number | undefined>(undefined); const [hasMore, setHasMore] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(false); + const [isSwitchingSession, setIsSwitchingSessionRaw] = + useState<boolean>(false); + const switchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const SWITCH_TIMEOUT_MS = 15000; const PAGE_SIZE = 20; + const setIsSwitchingSession = useCallback((value: boolean) => { + setIsSwitchingSessionRaw(value); + if (switchTimeoutRef.current) { + clearTimeout(switchTimeoutRef.current); + switchTimeoutRef.current = null; + } + if (value) { + switchTimeoutRef.current = setTimeout(() => { + console.warn( + '[useSessionManagement] Switch session timed out, clearing loading state', + ); + setIsSwitchingSessionRaw(false); + switchTimeoutRef.current = null; + }, SWITCH_TIMEOUT_MS); + } + }, []); + + useEffect( + () => () => { + if (switchTimeoutRef.current) { + clearTimeout(switchTimeoutRef.current); + switchTimeoutRef.current = null; + } + }, + [], + ); + /** * Filter session list */ @@ -98,12 +129,39 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { } console.log('[useSessionManagement] Switching to session:', sessionId); + setIsSwitchingSession(true); vscode.postMessage({ type: 'switchQwenSession', data: { sessionId }, }); }, - [currentSessionId, vscode], + [currentSessionId, vscode, setIsSwitchingSession], + ); + + /** + * Delete session + */ + const handleDeleteSession = useCallback( + (sessionId: string) => { + vscode.postMessage({ + type: 'deleteQwenSession', + data: { sessionId }, + }); + }, + [vscode], + ); + + /** + * Rename session + */ + const handleRenameSession = useCallback( + (sessionId: string, title: string) => { + vscode.postMessage({ + type: 'renameQwenSession', + data: { sessionId, title }, + }); + }, + [vscode], ); return { @@ -117,6 +175,7 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { nextCursor, hasMore, isLoading, + isSwitchingSession, // State setters setQwenSessions, @@ -127,11 +186,14 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { setNextCursor, setHasMore, setIsLoading, + setIsSwitchingSession, // Operations handleLoadQwenSessions, handleNewQwenSession, handleSwitchSession, handleLoadMoreSessions, + handleDeleteSession, + handleRenameSession, }; }; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index af65c39df..8ad6cf865 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -47,6 +47,7 @@ interface UseWebViewMessagesProps { setNextCursor: (cursor: number | undefined) => void; setHasMore: (hasMore: boolean) => void; setIsLoading: (loading: boolean) => void; + setIsSwitchingSession: (switching: boolean) => void; }; // File context @@ -672,6 +673,7 @@ export const useWebViewMessages = ({ clearInsightState(); } handlers.messageHandling.clearWaitingForResponse(); + handlers.sessionManagement.setIsSwitchingSession(false); // Display error message to user so they know what went wrong const errorMessage = (message?.data?.message as string) || @@ -1026,6 +1028,11 @@ export const useWebViewMessages = ({ lastPlanSnapshotRef.current = null; break; + case 'sessionLoadComplete': + case 'sessionExpired': + handlers.sessionManagement.setIsSwitchingSession(false); + break; + case 'conversationCleared': clearInsightState(); resetConversationState({ @@ -1053,6 +1060,35 @@ export const useWebViewMessages = ({ break; } + case 'sessionDeleted': { + const deletedId = message.data?.sessionId as string; + if (deletedId) { + handlers.sessionManagement.setQwenSessions( + (prev: Array<Record<string, unknown>>) => + prev.filter( + (s) => s.sessionId !== deletedId && s.id !== deletedId, + ), + ); + } + break; + } + + case 'sessionRenamed': { + const renamedId = message.data?.sessionId as string; + const newTitle = message.data?.title as string; + if (renamedId && newTitle) { + handlers.sessionManagement.setQwenSessions( + (prev: Array<Record<string, unknown>>) => + prev.map((s) => + s.sessionId === renamedId || s.id === renamedId + ? { ...s, title: newTitle, name: newTitle } + : s, + ), + ); + } + break; + } + case 'activeEditorChanged': { const fileName = message.data?.fileName as string | null; const filePath = message.data?.filePath as string | null; diff --git a/packages/webui/src/components/layout/SessionSelector.tsx b/packages/webui/src/components/layout/SessionSelector.tsx index 7012770ff..0586a87fe 100644 --- a/packages/webui/src/components/layout/SessionSelector.tsx +++ b/packages/webui/src/components/layout/SessionSelector.tsx @@ -8,7 +8,7 @@ */ import type { FC } from 'react'; -import { Fragment } from 'react'; +import { Fragment, useState, useRef, useEffect } from 'react'; import { getTimeAgo, groupSessionsByDate, @@ -31,6 +31,10 @@ export interface SessionSelectorProps { onSearchChange: (query: string) => void; /** Callback when a session is selected */ onSelectSession: (sessionId: string) => void; + /** Callback when a session is renamed */ + onRenameSession?: (sessionId: string, newTitle: string) => void; + /** Callback when a session is deleted */ + onDeleteSession?: (sessionId: string) => void; /** Callback when selector should close */ onClose: () => void; /** Whether there are more sessions to load */ @@ -71,11 +75,38 @@ export const SessionSelector: FC<SessionSelectorProps> = ({ searchQuery, onSearchChange, onSelectSession, + onRenameSession, + onDeleteSession, onClose, hasMore = false, isLoading = false, onLoadMore, }) => { + const [renamingSessionId, setRenamingSessionId] = useState<string | null>( + null, + ); + const [renameValue, setRenameValue] = useState(''); + const [originalRenameValue, setOriginalRenameValue] = useState(''); + const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); + const renameInputRef = useRef<HTMLInputElement>(null); + const isCancelingRenameRef = useRef(false); + + useEffect(() => { + if (renamingSessionId && renameInputRef.current) { + renameInputRef.current.focus(); + renameInputRef.current.select(); + } + }, [renamingSessionId]); + + const handleRenameSubmit = (sessionId: string) => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== originalRenameValue && onRenameSession) { + onRenameSession(sessionId, trimmed); + } + setRenamingSessionId(null); + setRenameValue(''); + setOriginalRenameValue(''); + }; if (!visible) { return null; } @@ -155,27 +186,127 @@ export const SessionSelector: FC<SessionSelectorProps> = ({ ''; const isActive = sessionId === currentSessionId; + if (renamingSessionId === sessionId) { + return ( + <div + key={sessionId} + className="session-item flex items-center py-1.5 px-2 rounded-md" + > + <input + ref={renameInputRef} + type="text" + maxLength={200} // SESSION_TITLE_MAX_LENGTH + className="flex-1 bg-[var(--vscode-input-background,var(--app-input-background))] text-[var(--vscode-input-foreground,var(--app-primary-foreground))] border-2 border-[var(--vscode-focusBorder)] rounded px-2 py-1 text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] outline-none min-w-0 shadow-[0_0_0_1px_var(--vscode-focusBorder)]" + value={renameValue} + onChange={(e) => setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleRenameSubmit(sessionId); + } else if (e.key === 'Escape') { + isCancelingRenameRef.current = true; + setRenamingSessionId(null); + setRenameValue(''); + setOriginalRenameValue(''); + } + }} + onBlur={() => { + if (isCancelingRenameRef.current) { + isCancelingRenameRef.current = false; + return; + } + handleRenameSubmit(sessionId); + }} + /> + </div> + ); + } + return ( - <button + <div key={sessionId} - type="button" - className={`session-item flex items-center justify-between py-1.5 px-2 bg-transparent border-none rounded-md cursor-pointer text-left w-full text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] text-[var(--app-primary-foreground)] transition-colors duration-100 hover:bg-[var(--app-list-hover-background)] ${ + className={`session-item group flex items-center justify-between py-1.5 px-2 rounded-md cursor-pointer transition-colors duration-100 hover:bg-[var(--app-list-hover-background)] ${ isActive ? 'active bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)] font-[600]' - : '' + : 'text-[var(--app-primary-foreground)]' }`} onClick={() => { onSelectSession(sessionId); onClose(); }} > - <span className="session-item-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap min-w-0"> + <span className="session-item-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap min-w-0 text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)]"> {title} </span> - <span className="session-item-time opacity-60 text-[0.9em] flex-shrink-0 ml-3"> - {getTimeAgo(lastUpdated)} + <span className="flex items-center gap-1 flex-shrink-0 ml-2"> + {(onRenameSession || onDeleteSession) && ( + <span + className={`items-center gap-0.5 ${confirmDeleteId === sessionId ? 'flex' : 'hidden group-hover:flex'}`} + > + {onRenameSession && ( + <button + type="button" + className="p-0.5 bg-transparent border-none cursor-pointer opacity-50 hover:opacity-100 text-[var(--app-primary-foreground)] rounded" + title="Rename" + onClick={(e) => { + e.stopPropagation(); + setRenamingSessionId(sessionId); + setRenameValue(title); + setOriginalRenameValue(title); + }} + > + <svg + width="14" + height="14" + viewBox="0 0 16 16" + fill="currentColor" + > + <path d="M13.23 1h-1.46L3.52 9.25l-.16.22L1 13.59 2.41 15l4.12-2.36.22-.16L15 4.23V2.77L13.23 1zM2.41 13.59l1.51-3 1.45 1.45-2.96 1.55zm3.83-2.06L4.47 9.76l8-8 1.77 1.77-8 8z" /> + </svg> + </button> + )} + {onDeleteSession && + !isActive && + (confirmDeleteId === sessionId ? ( + <button + type="button" + className="px-1.5 py-0.5 bg-[var(--vscode-inputValidation-errorBackground,#5a1d1d)] border border-[var(--vscode-inputValidation-errorBorder,#be1100)] cursor-pointer text-[var(--vscode-errorForeground,#f48771)] rounded text-[11px] leading-tight" + title="Click to confirm delete" + onClick={(e) => { + e.stopPropagation(); + setConfirmDeleteId(null); + onDeleteSession(sessionId); + }} + onBlur={() => setConfirmDeleteId(null)} + > + Delete? + </button> + ) : ( + <button + type="button" + className="p-0.5 bg-transparent border-none cursor-pointer opacity-50 hover:opacity-100 text-[var(--app-primary-foreground)] rounded" + title="Delete" + onClick={(e) => { + e.stopPropagation(); + setConfirmDeleteId(sessionId); + }} + > + <svg + width="14" + height="14" + viewBox="0 0 16 16" + fill="currentColor" + > + <path d="M10 3h3v1h-1v9l-1 1H5l-1-1V4H3V3h3V2a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1zM9 2H7v1h2V2zM5 4v9h6V4H5zm2 2h1v5H7V6zm3 0h-1v5h1V6z" /> + </svg> + </button> + ))} + </span> + )} + <span className="session-item-time opacity-60 text-[0.9em]"> + {getTimeAgo(lastUpdated)} + </span> </span> - </button> + </div> ); })} </div>