feat(session): add rename, delete, and auto-title generation for session (#3093)

* feat(session): add rename, delete, and auto-title generation for sessions

- Add /rename command with LLM auto-title generation when no args provided
- Add /delete command to remove sessions from the session picker
- Display session name tag embedded in input prompt top border
- Restore session name on /resume and --resume <title> CLI flag
- Support rename and delete via ACP extMethod for VSCode extension
- Add rename/delete UI to WebUI SessionSelector with two-click delete confirmation
- Fix parentUuid chain: custom_title records now correctly reference the
  previous record's UUID, preventing session history from appearing empty
  after rename
- Add SESSION_FILE_PATTERN validation to all SessionService methods that
  construct file paths from sessionId (defense-in-depth against path traversal)
- Fix fd leak in readCustomTitleFromFile with try/finally
- Fix --resume <title> exit code (exit 1 when no match found)
- Add project ownership checks to VSCode qwenSessionReader delete/rename

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(session): fix broken imports and missing mocks from rename/auto-title feature

- Fix renameCommand.ts import path to use barrel export instead of deep path
- Add setSessionName to mock CommandContext
- Add getSessionTitle to SessionService mock in useResumeCommand tests
- Update renameCommand tests for auto-generate title behavior
- Update InputPrompt snapshots

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(session): add head+tail dual-read, string-level extraction, and finalize mechanism

- Add sessionStorageUtils with extractLastJsonStringField() for fast
  string-level JSON field extraction without full parse
- Add readHeadAndTailSync() to read first and last 64KB of session files
- Replace readCustomTitleFromFile() with readSessionTitleFromFile() using
  head+tail dual-read (tail customTitle > head customTitle)
- Add finalize() to ChatRecordingService as single entry point for
  re-appending session metadata on any session departure
- Call finalize() on resume, session switch, and shutdown
- Export sessionStorageUtils from core package

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(session): show filtered picker when /resume <title> matches multiple sessions

Previously, multiple title matches opened the full session picker,
forcing the user to re-find their session. Now the matched sessions
are passed through as initialSessions to the picker, skipping the
full listSessions() load and showing only the relevant results.

Also clears sessionName on /clear so new sessions don't carry stale
title tags from the previous session.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ui): use stringWidth for CJK-safe border alignment in input prompt

topRightLabel.length counts UTF-16 code units, not terminal columns.
CJK characters take 2 columns but .length returns 1, causing the
border line to overflow. Use string-width for correct display width.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(session): address remaining PR #3093 review feedback

- Add SESSION_TITLE_MAX_LENGTH shared constant in core, replace
  hardcoded 200 in CLI/ACP/VSCode/WebUI
- Add title length validation to ACP renameSession endpoint
- Make recordCustomTitle return boolean; renameCommand checks it
  before updating UI to prevent silent data loss
- Add gitBranch to VSCode rename record for consistency with CLI
- Remove misleading "enforce kebab-case" comment
- Remove duplicate JSDoc on topRightLabel

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ui): add animated dots to session name generation loading indicator

The static "Generating session name…" text gave no visual feedback that
the operation was in progress. Cycle through ".", "..", "..." every
500ms so users can tell the LLM call is still running.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* feat(cli): add /tag as alias for /rename command

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(vscode): add loading overlay when switching to historical conversations

Adds isSwitchingSession state and sessionLoadComplete message to show
a loading transition while session history is being rehydrated via ACP.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(vscode): add 15s timeout fallback for session switching loading state

Prevents loading overlay from getting stuck indefinitely if
sessionLoadComplete message is never received.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(core): fix extractLastJsonStringField offset tracking and add lineContains filter

1. Track global character offset across both pattern variants so the
   truly last match wins (previously the second pattern scan could
   overwrite a later match from the first pattern).

2. Add optional lineContains parameter to scope matches to lines
   containing a marker (e.g. "custom_title"), preventing false matches
   from user content that happens to include a "customTitle" field.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* chore(cli): add i18n import to DialogManager

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>

* fix(vscode): align currentConversationId with webview on fallback restore

When session/load falls back to creating a fresh ACP session, backend
was tracking the new ACP id while the webview still viewed the archived
sessionId. That desync caused delete/rename/title-update to target the
wrong session during the fallback window, and prevented the post-first-
message sync path from firing because the two ids were pre-aligned.

Keep currentConversationId pointing at the archived sessionId until the
existing stream-end sync flips both sides to the live ACP id on the
first user message. Matches the pattern already used by the offline
branch.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(core): exhaustive scan in findSessionsByTitle to avoid mtime-boundary misses

listSessions() paginates with an mtime-only cursor and strict `<`
filter. When several session files share the same mtime across a page
boundary, the next page's filter drops them, so --resume <title> could
silently miss valid matches.

Scan all session files directly for title lookup, with filename as a
stable tie-breaker. Also check the (cheap) custom title before the
full hydration pass (first-record read, project filter, message count,
prompt extraction) so non-matching sessions skip the extra I/O.

listSessions() itself is left alone: its cursor crosses ACP/webview
package boundaries as a number and this edge case only affects UI
display order, not data loss.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(acp): plumb listSessions page size through _meta

VSCode companion passes `size` to acpConnection.listSessions, but the
ACP spec's ListSessionsRequest schema has no `size` field, so the SDK's
zod validator strips it before the agent handler sees it. The agent
then only forwarded `cursor` to SessionService.listSessions, silently
ignoring the caller's page-size intent.

Carry page size through `_meta.size` on both sides, matching the
pattern already used for other Qwen Code ACP extensions (e.g. the
filesystem service's `_meta.bom` / `_meta.encoding`). `_meta` is typed
as an open record in the ACP schema, so extra keys survive validation.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(webui): avoid unintended rename when canceling with Escape

The rename input auto-submits on blur, and pressing Escape also triggers
blur (via setRenamingSessionId(null) unmounting the input). Because state
updates are async, the blur handler's handleRenameSubmit could still read
the pre-Escape renameValue from its closure and call onRenameSession,
turning a cancel into an accidental rename.

Track cancellation via an isCancelingRenameRef flag: set it in the Escape
branch, and have onBlur short-circuit when the flag is true, then reset it.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(vscode-ide-companion): clear switch timeout on unmount

The 15s session-switch fallback timer was only cleared on the next call
to setIsSwitchingSession. If the webview is torn down mid-switch, the
timer stays alive and later fires setIsSwitchingSessionRaw(false) on an
unmounted hook. Add a useEffect cleanup to clear any pending timer on
unmount.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(cli): break handleResume/slashCommandActions circular dep

slashCommandActions (useMemo) depends on handleResume, but handleResume
was declared after useSlashCommandProcessor so it could call
setAwayRecapItem(null). useSlashCommandProcessor itself consumes
slashCommandActions, closing a three-way cycle that tsc catches as
TS2448 "used before declaration" once #3478's AppContainer changes land
in main and get auto-merged into open PRs.

Move handleResume above slashCommandActions and route the recap clear
through a ref that a later useEffect syncs with setAwayRecapItem.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(core): scan full file when title is not in tail window

Replace the head+tail dual-read with readLastJsonStringFieldSync: scan the
tail first and return on hit, otherwise stream the whole file and return
the last match. Closes the blind spot where a custom_title record landing
between the head and tail windows would be missed on large session files.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <noreply@qwen.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <noreply@qwen.ai>
Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
This commit is contained in:
qqqys 2026-04-22 11:48:01 +08:00 committed by GitHub
parent d1c8dff4d2
commit 0c423deedf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 3275 additions and 204 deletions

View file

@ -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<ListSessionsResponse> {
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<string, unknown>,
): Promise<Record<string, unknown>> {
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 ---

View file

@ -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<CliArgs> {
) {
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) {

View file

@ -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

View file

@ -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,

View file

@ -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: {

View file

@ -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<string | null>(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,

View file

@ -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',
});
});
});

View file

@ -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<SlashCommandActionReturn> => ({
type: 'dialog',
dialog: 'delete',
}),
};

View file

@ -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 <name>',
});
});
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 <name>',
});
});
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.',
});
});
});

View file

@ -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<string | null> {
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<SlashCommandActionReturn> => {
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>'),
};
}
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 }),
};
},
};

View file

@ -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".',
});
});
});

View file

@ -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<SlashCommandActionReturn> => ({
type: 'dialog',
dialog: 'resume',
}),
action: async (context, args): Promise<SlashCommandActionReturn> => {
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 }),
};
},
};

View file

@ -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<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;
setSessionName: (name: string | null) => void;
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
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 <id>). */
sessionId?: string;
/** Pre-filtered sessions for the picker (e.g., multiple title matches from /resume <title>). */
matchedSessions?: SessionListItem[];
dialog:
| 'help'
| 'arena_start'
@ -167,6 +179,7 @@ export interface OpenDialogActionReturn {
| 'permissions'
| 'approval-mode'
| 'resume'
| 'delete'
| 'extensions_manage'
| 'hooks'
| 'mcp';

View file

@ -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>
);

View file

@ -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')}
/>
);
}

View file

@ -1248,6 +1248,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
prefix={prefixNode}
borderColor={borderColor}
topRightLabel={uiState.sessionName || undefined}
isActive={!isEmbeddedShellFocused}
renderLine={renderLineWithHighlighting}
/>

View file

@ -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}>

View file

@ -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>,
{

View file

@ -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
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;

View file

@ -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;

View file

@ -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) */

View file

@ -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();

View file

@ -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,
};
}

View file

@ -51,6 +51,9 @@ vi.mock('@qwen-code/qwen-code-core', () => {
})
);
}
getSessionTitle(_sessionId: string) {
return undefined;
}
}
return {

View file

@ -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,

View file

@ -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) {

View file

@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
toggleVimEnabled: async () => false,
setGeminiMdFileCount: (_count) => {},
reloadCommands: () => {},
setSessionName: () => {},
extensionsUpdateState: new Map(),
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
addConfirmUpdateExtensionRequest: (_request) => {},

View file

@ -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) {

View file

@ -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';

View file

@ -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' });
});
});
});

View file

@ -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.
*/

View file

@ -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();
});
});
});

View file

@ -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`);

View file

@ -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', () => {
);
});
});
});
});

View file

@ -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;
}
}

View file

@ -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');
});
});
});

View file

@ -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)
}
}
}
}

View file

@ -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;

View file

@ -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');

View file

@ -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;
}
}
}

View file

@ -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 ? (

View file

@ -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>
);

View file

@ -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,

View file

@ -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)
*/

View file

@ -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,
};
};

View file

@ -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;

View file

@ -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>