mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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:
parent
d1c8dff4d2
commit
0c423deedf
47 changed files with 3275 additions and 204 deletions
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
32
packages/cli/src/ui/commands/deleteCommand.test.ts
Normal file
32
packages/cli/src/ui/commands/deleteCommand.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/ui/commands/deleteCommand.ts
Normal file
21
packages/cli/src/ui/commands/deleteCommand.ts
Normal 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',
|
||||
}),
|
||||
};
|
||||
162
packages/cli/src/ui/commands/renameCommand.test.ts
Normal file
162
packages/cli/src/ui/commands/renameCommand.test.ts
Normal 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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
185
packages/cli/src/ui/commands/renameCommand.ts
Normal file
185
packages/cli/src/ui/commands/renameCommand.ts
Normal 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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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".',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1248,6 +1248,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}
|
||||
prefix={prefixNode}
|
||||
borderColor={borderColor}
|
||||
topRightLabel={uiState.sessionName || undefined}
|
||||
isActive={!isEmbeddedShellFocused}
|
||||
renderLine={renderLineWithHighlighting}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
100
packages/cli/src/ui/hooks/useDeleteCommand.ts
Normal file
100
packages/cli/src/ui/hooks/useDeleteCommand.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -51,6 +51,9 @@ vi.mock('@qwen-code/qwen-code-core', () => {
|
|||
})
|
||||
);
|
||||
}
|
||||
getSessionTitle(_sessionId: string) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
|||
toggleVimEnabled: async () => false,
|
||||
setGeminiMdFileCount: (_count) => {},
|
||||
reloadCommands: () => {},
|
||||
setSessionName: () => {},
|
||||
extensionsUpdateState: new Map(),
|
||||
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
506
packages/core/src/services/sessionService.rename.test.ts
Normal file
506
packages/core/src/services/sessionService.rename.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
283
packages/core/src/utils/sessionStorageUtils.test.ts
Normal file
283
packages/core/src/utils/sessionStorageUtils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
227
packages/core/src/utils/sessionStorageUtils.ts
Normal file
227
packages/core/src/utils/sessionStorageUtils.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue