feat(session): add rename, delete, and auto-title generation for session#3093
feat(session): add rename, delete, and auto-title generation for session#3093qqqys wants to merge 9 commits intoQwenLM:mainfrom
Conversation
…ions - 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>
📋 Review SummaryThis PR introduces session rename, delete, and auto-title generation features across CLI, VSCode extension, and WebUI. The implementation is well-architected with proper security hardening, efficient file I/O using tail-read strategies, and comprehensive test coverage. Overall, this is a solid implementation with thoughtful design decisions around data persistence and parent chain integrity. 🔍 General FeedbackPositive aspects:
Architectural decisions:
Recurring patterns:
🎯 Specific Feedback🔴 Critical
🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
| @@ -113,6 +117,8 @@ 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. */ | |||
There was a problem hiding this comment.
[P0 · 正确性] TAIL_READ_SIZE = 64KB 会导致长会话丢失自定义标题
readCustomTitleFromFile 只读文件最后 64KB。如果用户 /rename 后继续大量对话,custom_title 记录会被推到距文件末尾 >64KB 的位置,tail-read 就找不到它了。
64KB ≈ 600-700 条典型 chat record,长对话很容易超过。影响范围:
getSessionTitle()返回undefined→ CLI 标签消失listSessions()的customTitle丢失 → picker 不显示自定义名称findSessionsByTitle()依赖listSessions→--resume <title>查找失败
建议方案:
- 将 custom_title 同时写入独立 sidecar 文件(如
<sessionId>.title),读标题时优先查 sidecar - 或者
readCustomTitleFromFile未找到时 fallback 到全文扫描 - 或者改变追加策略:每次 rename 同时在文件末尾追加新记录(已经是这样),但读取时增大 buffer / 全文扫
| }), | ||
| getSessionId: vi.fn().mockReturnValue('test-session-id'), | ||
| getSessionService: vi.fn().mockReturnValue({ | ||
| renameSession: vi.fn().mockResolvedValue(true), |
There was a problem hiding this comment.
[P1 · 测试] 断言值与代码实际行为不匹配,测试应该会 fail
测试期望空输入返回 'Please provide a name. Usage: /rename <name>'。但实际代码路径:
name = ''.trim()→''if (!name)→ 进入自动生成分支generateSessionTitle(config)→ mock 没有getGeminiClient/getContentGenerator→ catch 后返回null- 最终返回
'Could not generate a title. Usage: /rename <name>'
期望值应改为 'Could not generate a title. Usage: /rename <name>'。
下面 line 107 的 whitespace-only test 也有相同问题。
另外建议补充一个正向的 auto-generate 测试(mock LLM 返回生成标题的场景)。
| const title = params['title'] as string; | ||
| if (!sessionId || !SESSION_ID_RE.test(sessionId)) { | ||
| throw RequestError.invalidParams( | ||
| undefined, |
There was a problem hiding this comment.
[P1 · 安全] ACP renameSession 缺少 title 长度校验
CLI (renameCommand.ts) 限制了 MAX_TITLE_LENGTH = 200,SessionMessageHandler.ts 也做了 200 字符检查。但此处只验证了 title 非空和类型是 string,没有长度上限。
恶意 ACP 客户端可发送超长 title → 完整写入 JSONL → readCustomTitleFromFile 的 64KB 读取只拿到 title 的一部分 → JSON.parse 失败 → 标题丢失。
建议加长度检查:
if (title.length > 200) {
throw RequestError.invalidParams(undefined, 'Title too long (max 200 chars)');
}| } catch { | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
[P1 · 代码质量] console.log 残留在生产代码中
console.log('[QwenSessionReader] Renaming session:', sessionId, title);
会在 VSCode 输出面板打印用户的 session title。应删除或改为 console.debug。
| */ | ||
| private readLastRecordUuid(filePath: string): string | null { | ||
| try { | ||
| const TAIL_SIZE = 64 * 1024; |
There was a problem hiding this comment.
[P2 · 代码质量] readLastRecordUuid 在两个包中完全重复
sessionService.ts 和 qwenSessionReader.ts 的 readLastRecordUuid 逻辑完全一致(64KB tail-read + 反向遍历 + JSON.parse)。readCustomTitleFromFile 的 buffer 读取模式也类似。
建议抽取到 packages/core 的共享工具函数,如 readTailLines(filePath, maxBytes): string[]。
packages/cli/src/gemini.tsx
Outdated
| writeStderrLine( | ||
| `Multiple sessions found with title "${argv.resume}". Please select one:`, | ||
| ); | ||
| resolvedSessionId = await showResumeSessionPicker(); |
There was a problem hiding this comment.
[P2 · UX] 多个 title 匹配时 picker 显示所有 session
当 matches.length > 1 时,提示 Multiple sessions found with title "xxx",然后 showResumeSessionPicker() 显示全部 session。用户看到上百个 session 却不知道哪些匹配。
建议传入匹配列表做预过滤,或高亮匹配项。
| // Close dialog immediately. | ||
| closeDeleteDialog(); | ||
|
|
||
| // Prevent deleting the current session. |
There was a problem hiding this comment.
[P2 · UX] CLI /delete 没有确认步骤,删除不可恢复
WebUI/VSCode 有两步确认(点 delete → 再点 "Delete?"),但 CLI 的流程是 SessionPicker → 选中 → 直接 removeSession(fs.unlinkSync)。
用户可能误选 session 就被永久删除了。建议:
- 选中后 addItem 显示确认提示,要求二次输入确认
- 或者在
handleDelete前加一个 "Are you sure?" dialog
| const [renamingSessionId, setRenamingSessionId] = useState<string | null>( | ||
| null, | ||
| ); | ||
| const [renameValue, setRenameValue] = useState(''); |
There was a problem hiding this comment.
[P2 · 一致性] 标题长度限制 200 在多处硬编码,没有共享常量
maxLength={200} 这个值分散在 4 个地方:
renameCommand.ts:MAX_TITLE_LENGTH = 200SessionMessageHandler.ts:trimmedTitle.length > 200SessionSelector.tsx:maxLength={200}- ACP 端:缺失(见另一条评论)
建议在 packages/core 定义共享常量 SESSION_TITLE_MAX_LENGTH,各处引用。
| return true; | ||
| } catch (error) { | ||
| console.error('[QwenSessionReader] Failed to delete session:', error); | ||
| return false; |
There was a problem hiding this comment.
[P2 · 一致性] VSCode rename record 缺少 gitBranch,且 version: 'vscode' 不是语义化版本
CLI 通过 ChatRecordingService.recordCustomTitle() 创建的 record 包含 gitBranch 和真实版本号。但 VSCode 路径创建的 record:
- 没有
gitBranch version: 'vscode'不是版本号
sessionService.ts:renameSession 也缺少 gitBranch。record 格式不一致可能给未来迁移/分析带来困惑。
| gitBranch: firstRecord.gitBranch, | ||
| filePath, | ||
| messageCount, | ||
| customTitle: this.readCustomTitleFromFile(filePath), |
There was a problem hiding this comment.
[P2 · 性能] listSessions 为每个 session 同步调用 readCustomTitleFromFile
listSessions 遍历时为每个文件执行同步 I/O(openSync + readSync + closeSync)。100 个 session = 100 次额外同步磁盘读取,可能在 session picker 等交互场景造成 UI 卡顿。
建议考虑:
- 将标题读取与现有的
readLines调用合并(已经在读文件了) - 或改为 async I/O
- 或按需 lazy-load(仅对 viewport 内的 session 读取标题)
| ); | ||
|
|
||
| const columns = process.stdout.columns || 80; | ||
| // Build the top border line: ─────── label ── |
There was a problem hiding this comment.
[P2 · Correctness] topRightLabel.length is wrong for non-ASCII characters — border will be misaligned
labelWidth = topRightLabel.length + 4 uses JavaScript's .length which counts UTF-16 code units, not terminal display columns. CJK characters (e.g. Chinese session names) occupy 2 terminal columns but .length returns 1, so the dash count will be too high and the border line will overflow/wrap.
Example: a label "修复登录" has .length === 4 but takes 8 terminal columns.
Since /rename accepts arbitrary user input (not just kebab-case), this is a real display bug for international users.
Fix: use a string-width library (e.g. string-width which is already commonly available in Ink-based projects) to calculate the actual display width:
import stringWidth from 'string-width';
const labelWidth = topRightLabel ? stringWidth(topRightLabel) + 4 : 0;| }), | ||
| }; | ||
| } | ||
|
|
There was a problem hiding this comment.
[P2 · Robustness] recordCustomTitle silently swallows errors, but setSessionName is called unconditionally — UI/data inconsistency
recordCustomTitle() has an internal try-catch that swallows write errors (just logs to debug). But context.ui.setSessionName(name) on line 151 runs regardless of whether the file write succeeded. This means:
- Disk full / permission error → file write fails silently
- UI tag shows the new name → user thinks rename worked
- On next resume, the title is gone (never persisted)
Two options to fix:
- Make
recordCustomTitlereturn a boolean indicating success, and only callsetSessionNameon success - Or change
recordCustomTitleto rethrow, and wrap the call here in a try-catch
The current code is a silent data-loss scenario.
| <SessionPicker | ||
| sessionService={config.getSessionService()} | ||
| currentBranch={uiState.branchName} | ||
| onSelect={uiActions.handleDelete} |
There was a problem hiding this comment.
[P3 · UX] Delete picker shows the current active session as selectable
The SessionPicker shows all sessions including the current one. If the user selects the current session, handleDelete rejects it with an info message "Cannot delete the current active session." — but this happens after the picker closes.
Better UX: pass the current session ID to SessionPicker and visually disable or hide it in the delete view, so users don't waste a click on an un-deletable item. The resume picker doesn't have this problem because resuming the current session is a valid (albeit no-op) action.
| @@ -69,6 +69,9 @@ export interface BaseTextInputProps { | |||
| prefix?: React.ReactNode; | |||
| /** Border color for the input box. */ | |||
| borderColor?: string; | |||
There was a problem hiding this comment.
[P3 · Nit] Duplicate JSDoc comment
Two consecutive doc-comments for topRightLabel:
/** Label overlaid on the top border (right-aligned). */
/** Label rendered on the top border line (right-aligned). Plain string for width calculation. */
Keep only the second one.
…itle 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>
| toggleVimEnabled, | ||
| setGeminiMdFileCount, | ||
| reloadCommands, | ||
| setSessionName: setSessionName ?? (() => {}), |
There was a problem hiding this comment.
[P1 · 正确性] 新 session 后旧的 title tag 不会被清掉
现在 sessionName 被挂进 ui 上下文了,但 ui.clear() 这里只清了 history/screen,没有一起 setSessionName(null)。
/clear 和 /new 会先 startNewSession(),然后走 context.ui.clear(),所以新会话仍然会显示上一条会话的自定义标题,直到再次 /rename 或 /resume。
建议把 sessionName 也纳入 clear/reset 路径,一起在这里清空,避免新会话带着旧标签。
|
|
||
| if (matches.length > 1) { | ||
| // Multiple matches — show picker to let user choose | ||
| return { type: 'dialog', dialog: 'resume' }; |
There was a problem hiding this comment.
[P2 · 交互一致性] 多个同名 session 时,这里打开的是“全量 picker”,不是“匹配结果 picker”
findSessionsByTitle(arg) 已经把匹配集算出来了,但这里直接回退到通用 resume dialog,实际会展示所有 session。
这样用户输入 /resume my-title 且命中多个结果时,还得重新在全量列表里找一遍,甚至可能误选到不相关的 session。PR 描述里写的是“multiple sessions match, the interactive picker is shown”,按这个语义更像是应该只在匹配结果里二次选择。
建议把匹配集传给 picker,或者单独做一个 choose from matches 分支;gemini.tsx 的 --resume <title> 路径也有同样问题,最好共用一套实现。
yiliang114
left a comment
There was a problem hiding this comment.
整体方向是对的,session rename/delete/auto-title 这套功能也补得比较完整。
不过我这边又看到一个阻塞性的状态问题:新 session 之后旧的 title tag 还会残留在输入框上;另外 multiple title matches 的 resume 交互也还需要再收一下。加上前面已有的几个阻塞点,这轮我先挂 request changes,具体看 inline comments。
…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>
…ple 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>
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>
- 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>
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>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
TLDR
Add session rename, delete, and auto-title generation across CLI, VSCode extension, and WebUI. Users can now
/renamesessions (with optional LLM-generated titles),/deletesessions, and resume sessions by custom title via--resume <title>. The session name is displayed as a tag in the CLI input prompt and persists across resume.Screenshots / Video Demo
Dive Deeper
Architecture
Custom titles are stored as append-only system records in the session JSONL file:
{"uuid":"...","parentUuid":"<previous-record-uuid>","type":"system","subtype":"custom_title","systemPayload":{"customTitle":"my-feature"}}Key design decisions:
parentUuidmust correctly chain to the previous record — without this,reconstructHistory()(which walks from the tail record upward) would sever the chain and the session would appear empty on next loadreadCustomTitleFromFile), not full file scan/renamecommand usesChatRecordingService.recordCustomTitle()which inherits correctparentUuidfromlastRecordUuid. The ACP/VSCode path usesSessionService.renameSession()which explicitly reads the last record's UUID before writingSecurity hardening
SessionServicemethods that construct file paths fromsessionIdnow validate againstSESSION_FILE_PATTERN(defense-in-depth against path traversal)qwenSessionReaderdelete/rename verify project ownership viaprojectHashreadCustomTitleFromFileusestry/finallyto prevent fd leaksAuto-title generation
When
/renameis called without arguments, it extracts the last ~1000 chars of conversation history and sends a single request to the current model asking for a kebab-case title (e.g.,fix-login-bug). A pending indicator ("Generating session name…") is shown during the request.Resume by title
--resume <title>performs a case-insensitive exact match against custom titles. If multiple sessions match, the interactive picker is shown. If no match and the input isn't a valid UUID, exits with code 1.Reviewer Test Plan
CLI
/rename— should auto-generate a kebab-case title and show it as a tag in the input border/rename my-custom-name— should set the title and show tag/resume— the renamed session should show its custom title in the picker/delete— select a non-current session, confirm deletionqwen --resume my-custom-name— should resume by titleqwen --resume nonexistent— should exit with code 1VSCode Extension
WebUI
maxLength={200}Edge cases
/renamewith empty input (just Enter) — should trigger auto-generate, not error/renamewith very long name (>200 chars) — should show errorTesting Matrix
Linked issues / bugs
resolves: #2619 #2999 #3032 #3078 #3234