Skip to content

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

Merged
qqqys merged 26 commits into
QwenLM:mainfrom
qqqys:feat/rename_session
Apr 22, 2026
Merged

feat(session): add rename, delete, and auto-title generation for session#3093
qqqys merged 26 commits into
QwenLM:mainfrom
qqqys:feat/rename_session

Conversation

@qqqys

@qqqys qqqys commented Apr 10, 2026

Copy link
Copy Markdown
Collaborator

TLDR

Add session rename, delete, and auto-title generation across CLI, VSCode extension, and WebUI. Users can now /rename sessions (with optional LLM-generated titles), /delete sessions, 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

cli
ide

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:

  • parentUuid must 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 load
  • Title is read via efficient 64KB tail-read (readCustomTitleFromFile), not full file scan
  • The CLI's /rename command uses ChatRecordingService.recordCustomTitle() which inherits correct parentUuid from lastRecordUuid. The ACP/VSCode path uses SessionService.renameSession() which explicitly reads the last record's UUID before writing

Security hardening

  • All SessionService methods that construct file paths from sessionId now validate against SESSION_FILE_PATTERN (defense-in-depth against path traversal)
  • VSCode qwenSessionReader delete/rename verify project ownership via projectHash
  • readCustomTitleFromFile uses try/finally to prevent fd leaks

Auto-title generation

When /rename is 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

  1. Start a new session, have a conversation, then run /rename — should auto-generate a kebab-case title and show it as a tag in the input border
  2. Run /rename my-custom-name — should set the title and show tag
  3. Run /resume — the renamed session should show its custom title in the picker
  4. Select the renamed session — tag should appear, conversation history should load correctly
  5. Run /delete — select a non-current session, confirm deletion
  6. Exit and run qwen --resume my-custom-name — should resume by title
  7. Run qwen --resume nonexistent — should exit with code 1

VSCode Extension

  1. Open session picker, hover over a session — rename/delete icons should appear
  2. Click rename icon, type new name, press Enter — title should update in the list
  3. Switch away from the renamed session and back — history should load correctly (not empty)
  4. Click delete icon → "Delete?" appears → click again to confirm → session removed from list
  5. Cannot delete the currently active session (error message shown)

WebUI

  1. Same rename/delete flow as VSCode
  2. Rename input has maxLength={200}
  3. Blurring rename input without changes does NOT send a rename request
  4. Delete confirmation button stays visible even when mouse leaves the row (while in confirm state)

Edge cases

  • /rename with empty input (just Enter) — should trigger auto-generate, not error
  • /rename with very long name (>200 chars) — should show error
  • Rename a session, compact it, resume — title should survive compaction
  • Delete a session while another tab has it open — tab keeps its local state

Testing Matrix

🍏 🪟 🐧
npm run
npx
Docker
Podman - -
Seatbelt - -

Linked issues / bugs

resolves: #2619 #2999 #3032 #3078 #3234

…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>
@qqqys qqqys requested review from tanzhenxin and yiliang114 April 10, 2026 07:45
@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This 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 Feedback

Positive aspects:

  • Excellent security hardening with SESSION_FILE_PATTERN validation on all SessionService methods
  • Smart use of append-only system records for custom titles with proper parentUuid chaining
  • Efficient 64KB tail-read strategy (readCustomTitleFromFile) instead of full file scans
  • Comprehensive test coverage across CLI commands and core services
  • Proper fd leak prevention with try/finally blocks in file operations
  • Good cross-platform support (CLI, VSCode, WebUI) with consistent behavior

Architectural decisions:

  • Storing custom titles as system records in the JSONL file is elegant and ensures persistence
  • The dual-path approach (ChatRecordingService for recording sessions, SessionService fallback) handles edge cases well
  • Case-insensitive title matching for --resume is user-friendly

Recurring patterns:

  • Consistent validation patterns across all three platforms (CLI, VSCode, WebUI)
  • Proper error handling with user-friendly messages throughout

🎯 Specific Feedback

🔴 Critical

  • File: packages/cli/src/gemini.tsx:353-361 - The logic flow for handling --resume has a subtle issue. When argv.resume !== '' and !cliConfig.isValidSessionId(argv.resume) and matches.length === 0, the code falls through to the final else block which calls process.exit(1). However, this exit happens BEFORE loadCliConfig is called, which means any cleanup or initialization that would happen in the normal flow is skipped. Consider whether this early exit could leave resources in an inconsistent state.

  • File: packages/core/src/services/sessionService.ts:672-680 - The renameSession method reads the first record to verify project ownership, but if jsonl.readLines throws an error other than ENOENT, it's re-thrown. This could expose internal errors to callers. Consider catching and converting to a false return for all non-critical errors to maintain consistent error handling with removeSession.

🟡 High

  • File: packages/cli/src/ui/commands/renameCommand.ts:72-76 - The generateSessionTitle function extracts conversation text but has a potential issue: history could be empty or contain no text parts, resulting in an empty string being sent to the LLM. While the function returns null in this case, it would be better to check earlier and avoid the API call entirely. Also, the system instruction says "Reply with ONLY the kebab-case name" but there's no validation that the response is actually kebab-case.

  • File: packages/vscode-ide-companion/src/webview/components/SessionSelector.tsx:233-238 - The rename input's onBlur handler calls handleRenameSubmit, which will submit even if the user just wants to cancel. The Escape key handler sets renamingSessionId(null) but doesn't restore the original value in the UI. This could lead to accidental renames with partial edits.

  • File: packages/core/src/services/sessionService.ts:648-656 - The readLastRecordUuid function doesn't validate SESSION_FILE_PATTERN before reading the file. While callers should validate, adding this check would provide defense-in-depth consistent with other methods like renameSession and removeSession.

🟢 Medium

  • File: packages/cli/src/ui/components/BaseTextInput.tsx:247-253 - The label width calculation assumes the label won't exceed available columns. If topRightLabel is very long (close to or exceeding columns), dashCount could become negative or zero, resulting in visual glitches. Consider adding Math.max(0, ...) for the dash count calculation.

  • File: packages/cli/src/ui/commands/renameCommand.ts:40-43 - The extractConversationText function walks backwards through history with texts.length < 6, but the comment says "~1000 chars". These two constraints could conflict - 6 messages could be much more or less than 1000 chars. Consider making the character limit the primary constraint instead of message count.

  • File: packages/core/src/services/sessionService.ts:707-715 - The findSessionsByTitle method paginates through ALL sessions to find matches. For users with many sessions, this could be slow. Consider adding an optional limit parameter or early termination after finding N matches (since multiple matches just trigger the picker anyway).

  • File: packages/vscode-ide-companion/src/services/qwenSessionReader.ts:423-426 - The renameSession method uses crypto.randomUUID() but doesn't import it at the top of the file (uses namespace import * as crypto). While this works, consider using the same randomUUID import pattern as sessionService.ts for consistency.

🔵 Low

  • File: packages/cli/src/ui/commands/renameCommand.ts:1 - Missing type import comment. The file imports Content from @google/genai but this import appears unused (the function uses config.getGeminiClient().getHistory(true) which should already return the proper type).

  • File: packages/cli/src/config/config.ts:574 - The comment // --resume accepts either a session UUID or a custom title is helpful, but consider adding a brief note about the validation that happens in gemini.tsx before this point for future maintainers.

  • File: packages/cli/src/ui/components/SessionPicker.tsx:130 - The title prop defaults to "Resume Session" via nullish coalescing, but the delete dialog also uses this component with title={t('Delete Session')}. Consider extracting the default to a constant or making it a required prop for clarity.

  • File: packages/core/src/services/chatRecordingService.ts:60 - The new custom_title subtype is added to the union type, but there's no JSDoc comment explaining when this subtype is used (unlike other subtypes which have more context). Consider adding a brief comment.

  • File: packages/cli/src/ui/hooks/useDeleteCommand.ts:44-52 - The check sessionId === config.getSessionId() prevents deleting the current session, but this comparison might fail if one is a UUID and the other has additional suffix (e.g., -agent-{suffix} mentioned in the isValidSessionId comment). Consider normalizing both IDs before comparison.

✅ Highlights

  • Security hardening - All SessionService methods that construct file paths now validate against SESSION_FILE_PATTERN, providing excellent defense-in-depth against path traversal attacks

  • Parent UUID chaining - The careful handling of parentUuid in renameSession (reading the last record's UUID before appending the title record) shows deep understanding of the history reconstruction algorithm

  • Efficient I/O - The 64KB tail-read strategy in readCustomTitleFromFile and readLastRecordUuid demonstrates performance-conscious design, avoiding full file scans for common operations

  • FD leak prevention - Consistent use of try/finally blocks to ensure fs.closeSync is called even if read operations fail

  • Test coverage - Comprehensive test files for both CLI commands (renameCommand.test.ts, deleteCommand.test.ts) and core services (sessionService.rename.test.ts, chatRecordingService.customTitle.test.ts) covering edge cases like empty input, multiple matches, and file errors

  • Cross-platform consistency - The same patterns and validations are implemented across CLI, VSCode, and WebUI, ensuring consistent user experience

* (32-36 hex characters, optionally with hyphens).
*/
const SESSION_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.jsonl$/;
/** Maximum number of lines to scan when looking for the first prompt text. */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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> 查找失败

建议方案:

  1. 将 custom_title 同时写入独立 sidecar 文件(如 <sessionId>.title),读标题时优先查 sidecar
  2. 或者 readCustomTitleFromFile 未找到时 fallback 到全文扫描
  3. 或者改变追加策略:每次 rename 同时在文件末尾追加新记录(已经是这样),但读取时增大 buffer / 全文扫

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。重构为 readHeadAndTailSync() 同时读取文件头尾各 64KB,readSessionTitleFromFile 先查 tail 再 fallback 到 head。另外 ChatRecordingService.finalize() 在 session 退出/切换时会重新 append title 到文件末尾,保证 title 始终在 tail 窗口内。

}),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getSessionService: vi.fn().mockReturnValue({
renameSession: vi.fn().mockResolvedValue(true),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 · 测试] 断言值与代码实际行为不匹配,测试应该会 fail

测试期望空输入返回 'Please provide a name. Usage: /rename <name>'。但实际代码路径:

  1. name = ''.trim()''
  2. if (!name) → 进入自动生成分支
  3. generateSessionTitle(config) → mock 没有 getGeminiClient/getContentGenerator → catch 后返回 null
  4. 最终返回 '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 返回生成标题的场景)。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。测试断言已更新,与实际代码行为一致。

const title = params['title'] as string;
if (!sessionId || !SESSION_ID_RE.test(sessionId)) {
throw RequestError.invalidParams(
undefined,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 · 安全] ACP renameSession 缺少 title 长度校验

CLI (renameCommand.ts) 限制了 MAX_TITLE_LENGTH = 200SessionMessageHandler.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)');
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。acpAgent.ts 中 renameSession 现在检查 title.length > SESSION_TITLE_MAX_LENGTH,使用 core 导出的共享常量。

} catch {
return null;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 · 代码质量] console.log 残留在生产代码中

console.log('[QwenSessionReader] Renaming session:', sessionId, title);

会在 VSCode 输出面板打印用户的 session title。应删除或改为 console.debug

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复,已移除。

*/
private readLastRecordUuid(filePath: string): string | null {
try {
const TAIL_SIZE = 64 * 1024;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 · 代码质量] readLastRecordUuid 在两个包中完全重复

sessionService.tsqwenSessionReader.tsreadLastRecordUuid 逻辑完全一致(64KB tail-read + 反向遍历 + JSON.parse)。readCustomTitleFromFile 的 buffer 读取模式也类似。

建议抽取到 packages/core 的共享工具函数,如 readTailLines(filePath, maxBytes): string[]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

暂不修改。vscode-ide-companion 和 core 包之间目前没有直接依赖关系(ide companion 通过 ACP 协议与 CLI 通信),抽共享工具会引入跨包依赖。后续如果统一包结构时再一起处理。

Comment thread packages/cli/src/gemini.tsx Outdated
writeStderrLine(
`Multiple sessions found with title "${argv.resume}". Please select one:`,
);
resolvedSessionId = await showResumeSessionPicker();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 · UX] 多个 title 匹配时 picker 显示所有 session

matches.length > 1 时,提示 Multiple sessions found with title "xxx",然后 showResumeSessionPicker() 显示全部 session。用户看到上百个 session 却不知道哪些匹配。

建议传入匹配列表做预过滤,或高亮匹配项。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。gemini.tsxresumeCommand.ts 现在都将 matches 传给 picker,只显示匹配的 session。

// Close dialog immediately.
closeDeleteDialog();

// Prevent deleting the current session.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 · UX] CLI /delete 没有确认步骤,删除不可恢复

WebUI/VSCode 有两步确认(点 delete → 再点 "Delete?"),但 CLI 的流程是 SessionPicker → 选中 → 直接 removeSessionfs.unlinkSync)。

用户可能误选 session 就被永久删除了。建议:

  1. 选中后 addItem 显示确认提示,要求二次输入确认
  2. 或者在 handleDelete 前加一个 "Are you sure?" dialog

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

暂不加。CLI 的 /delete 本身已经需要用户在 picker 中主动选择 session,这个交互已经是一个 explicit action。和 WebUI/VSCode 不同,CLI 没有误触风险(没有鼠标悬浮触发)。后续如果有用户反馈再考虑加确认。

const [renamingSessionId, setRenamingSessionId] = useState<string | null>(
null,
);
const [renameValue, setRenameValue] = useState('');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 · 一致性] 标题长度限制 200 在多处硬编码,没有共享常量

maxLength={200} 这个值分散在 4 个地方:

  • renameCommand.ts: MAX_TITLE_LENGTH = 200
  • SessionMessageHandler.ts: trimmedTitle.length > 200
  • SessionSelector.tsx: maxLength={200}
  • ACP 端:缺失(见另一条评论)

建议在 packages/core 定义共享常量 SESSION_TITLE_MAX_LENGTH,各处引用。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core 层已抽出 SESSION_TITLE_MAX_LENGTH 常量,renameCommand.tsacpAgent.ts 已引用。WebUI SessionSelector.tsx 和 VSCode SessionMessageHandler.ts 因为不直接依赖 core 包,用注释标注了对应常量。跨包引入常量代价较大,保持现状。

return true;
} catch (error) {
console.error('[QwenSessionReader] Failed to delete session:', error);
return false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 · 一致性] VSCode rename record 缺少 gitBranch,且 version: 'vscode' 不是语义化版本

CLI 通过 ChatRecordingService.recordCustomTitle() 创建的 record 包含 gitBranch 和真实版本号。但 VSCode 路径创建的 record:

  • 没有 gitBranch
  • version: 'vscode' 不是版本号

sessionService.ts:renameSession 也缺少 gitBranch。record 格式不一致可能给未来迁移/分析带来困惑。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。renameSession 现在包含 gitBranch: getGitBranch(cwd)version: 'vscode' 保留,这是标识记录来源,不是 semver。

gitBranch: firstRecord.gitBranch,
filePath,
messageCount,
customTitle: this.readCustomTitleFromFile(filePath),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 · 性能] listSessions 为每个 session 同步调用 readCustomTitleFromFile

listSessions 遍历时为每个文件执行同步 I/O(openSync + readSync + closeSync)。100 个 session = 100 次额外同步磁盘读取,可能在 session picker 等交互场景造成 UI 卡顿。

建议考虑:

  1. 将标题读取与现有的 readLines 调用合并(已经在读文件了)
  2. 或改为 async I/O
  3. 或按需 lazy-load(仅对 viewport 内的 session 读取标题)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已知问题,暂不改。readHeadAndTailSync 对单个文件的 I/O 耗时极短(两次 64KB read),默认分页 20 条 session 的场景下不会造成可感知的阻塞。异步化需要改造整个 listSessions 调用链,收益不大,后续有性能问题再优化。

);

const columns = process.stdout.columns || 80;
// Build the top border line: ─────── label ──

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。改用 stringWidth(topRightLabel) 替代 .length,正确处理 CJK 和 emoji 等宽字符。

}),
};
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. Make recordCustomTitle return a boolean indicating success, and only call setSessionName on success
  2. Or change recordCustomTitle to rethrow, and wrap the call here in a try-catch

The current code is a silent data-loss scenario.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。recordCustomTitle() 返回 false 时提前返回错误信息,不再执行 setSessionName

<SessionPicker
sessionService={config.getSessionService()}
currentBranch={uiState.branchName}
onSelect={uiActions.handleDelete}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。useDeleteCommand 会检查 sessionId === config.getSessionId(),选中当前 session 时显示提示而非删除。

@@ -69,6 +69,9 @@ export interface BaseTextInputProps {
prefix?: React.ReactNode;
/** Border color for the input box. */
borderColor?: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复,重复的 JSDoc 已移除。

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

lnxsun commented Apr 11, 2026

Copy link
Copy Markdown

我的#3105 被毙了,期待qqqys你的成果

toggleVimEnabled,
setGeminiMdFileCount,
reloadCommands,
setSessionName: setSessionName ?? (() => {}),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 · 正确性] 新 session 后旧的 title tag 不会被清掉

现在 sessionName 被挂进 ui 上下文了,但 ui.clear() 这里只清了 history/screen,没有一起 setSessionName(null)

/clear/new 会先 startNewSession(),然后走 context.ui.clear(),所以新会话仍然会显示上一条会话的自定义标题,直到再次 /rename/resume

建议把 sessionName 也纳入 clear/reset 路径,一起在这里清空,避免新会话带着旧标签。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。/clear handler 中增加了 setSessionName?.(null) 调用。


if (matches.length > 1) {
// Multiple matches — show picker to let user choose
return { type: 'dialog', dialog: 'resume' };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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> 路径也有同样问题,最好共用一套实现。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。resumeCommand.ts 现在返回 matchedSessions: matches,picker 只展示匹配结果而非全量 session 列表。

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

整体方向是对的,session rename/delete/auto-title 这套功能也补得比较完整。

不过我这边又看到一个阻塞性的状态问题:新 session 之后旧的 title tag 还会残留在输入框上;另外 multiple title matches 的 resume 交互也还需要再收一下。加上前面已有的几个阻塞点,这轮我先挂 request changes,具体看 inline comments。

qqqys and others added 5 commits April 12, 2026 15:22
…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>
qqqys and others added 4 commits April 15, 2026 19:51
…sations

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>
…tate

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

Co-Authored-By: Qwen-Coder <noreply@qwen.com>
…eContains 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>
qqqys and others added 3 commits April 21, 2026 10:33
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 QwenLM#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>
@qqqys qqqys force-pushed the feat/rename_session branch from 4cac407 to 29fd0b8 Compare April 21, 2026 08:18
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>
@qqqys qqqys requested a review from wenshao April 21, 2026 08:56
yiliang114
yiliang114 previously approved these changes Apr 21, 2026

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!


case 'renameQwenSession':
await this.handleRenameQwenSession(
(data?.sessionId as string) || '',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This PR introduces a compile-blocking TS4111 pattern in several changed VSCode/WebUI session files: properties from Record<string, unknown> / index-signature objects are accessed with dot notation (data.sessionId, data.title, session.id, etc.). Under this repo's TypeScript settings, these must use bracket notation or a stricter declared type.

Suggested change
(data?.sessionId as string) || '',
case 'renameQwenSession':
await this.handleRenameQwenSession(
(data?.['sessionId'] as string) || '',
(data?.['title'] as string) || '',
);
break;

— gpt-5.4 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wenshao 感谢review,这条 [Critical] 不成立,gpt-5.4 漏看了 package 级 tsconfig:

noPropertyAccessFromIndexSignature: true 只写在根 tsconfig.json 里,但 packages/vscode-ide-companion/tsconfig.jsonpackages/webui/tsconfig.json 既没extends 根 config,自己也没开这个 flag —— 这两个包的 strict 集没有 TS4111。

实测在 HEAD (c36d08dfb) 上:

  • npm run typecheck(覆盖 cli / core / sdk / webui 4 个 workspace)exit 0
  • cd packages/vscode-ide-companion && npm run check-types(即 tsc --noEmit)exit 0

如果后续要把根 tsconfig 的 strict 集对齐到这两个包,那是单独一个清理 PR 的事,不是本 PR 的 blocker。建议 dismiss 这条。

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@qqqys qqqys merged commit 0c423de into QwenLM:main Apr 22, 2026
13 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Qwen Code Roadmap Apr 22, 2026
wenshao added a commit that referenced this pull request Apr 23, 2026
…#3540)

* feat(session): auto-title sessions via fast model, add /rename --auto

The /rename work in #3093 generates kebab-case titles only when the user
explicitly runs `/rename` with no args; until they do, the session picker
shows the first user prompt (often truncated or misleading). This change
adds a sentence-case auto-title that fires once per session after the
first assistant turn, using the configured fast model.

New service: `packages/core/src/services/sessionTitle.ts` —
`tryGenerateSessionTitle(config, signal)` returns a discriminated outcome
(`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can
either handle failures generically or map reasons to actionable messages.
Prompt shape: 3-7 words, sentence case, good/bad examples including a
CJK row, JSON schema enforced via `baseLlmClient.generateJson`.
`maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight
rate limits.

Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after
`recordAssistantTurn`. Fire-and-forget promise, guarded by:

- `currentCustomTitle` — don't overwrite any existing title.
- `autoTitleController` doubles as in-flight flag; a second turn while
  the first is still pending is a no-op.
- `autoTitleAttempts` cap of 3 — the first assistant turn may be a
  pure tool-call with no user-visible text; retry for a handful of
  turns until a title lands. Cap bounds total waste.
- `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto-
  titles; spending fast-model tokens on a one-shot session is waste.
- `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out.
- `config.getFastModel()` falsy — skip entirely rather than falling
  back to the main model; auto-titling on main-model tokens is too
  expensive to be silent.

Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' |
'manual'` field. Absent on pre-change records (treated as `undefined`
→ manual, safe default so a user's pre-upgrade `/rename` is never
silently reclassified). `SessionPicker` renders `titleSource === 'auto'`
titles in dim (secondary) color; manual stays full contrast. On resume,
the persisted source is rehydrated into `currentTitleSource` — without
this, finalize's re-append would rewrite an auto title as manual on
every resume cycle.

Cross-process manual-rename guard: when two CLI tabs target the same
JSONL, in-memory state can diverge. Before writing an auto record, the
IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a
`/rename` from another process landed as manual, bail and sync local
state — never clobber a deliberately-chosen manual title with a model
guess. Cost is one 64KB tail read per successful generation.

`finalize()` aborts the in-flight controller before re-appending the
title record. Session switch / shutdown doesn't have to wait on a slow
fast-model call.

New user-facing command: `/rename --auto` regenerates via the same
generator — explicit user trigger, overwrites whatever's there (manual
or auto) because the user asked. Errors route through
`autoFailureMessage(reason)` so `empty_history`, `model_error`,
`aborted`, etc. each get actionable guidance rather than a generic
"could not generate". `/rename -- --literal-name` is the sentinel for
titles that start with `--`; unknown `--flag` tokens error with a hint
pointing at the sentinel. Existing `/rename <name>` and bare `/rename`
(kebab-case via existing path) are unchanged, except the kebab path now
prefers fast model when available and runs its output through
`stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the
sentence-case path).

New shared util: `packages/core/src/utils/terminalSafe.ts` —
`stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI
(\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A
model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise
execute on every SessionPicker render; both sentence-case and kebab
paths now route titles through the helper before they reach the JSONL
or the UI.

Tail-read extractor: `extractLastJsonStringFields(text, primaryKey,
otherKeys, lineContains)` reads multiple fields from the same matching
line in a single pass. Two separate tail scans could return a mismatched
pair (primary from a newer record, secondary from an older one with only
the primary set); the new helper guarantees the pair is atomic. Validates
a proper closing quote on the primary value so a crash-truncated trailing
record can't win the latest-match race. `readLastJsonStringFieldsSync`
is its file-reading wrapper — same tail-window fast path and full-file
fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB`
cap so a corrupt multi-GB session file can't freeze the picker. Session
reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows
where the constant isn't exposed) — defense in depth against a symlink
planted in `~/.qwen/projects/<proj>/chats/`.

Character handling: `flattenToTail` on the LLM prompt drops a dangling
low surrogate after `slice(-1000)` — otherwise a CJK supplementary char
or emoji cut mid-pair produces invalid UTF-16 that some providers 400.
`sanitizeTitle` applies the same surrogate scrub after max-length trim,
and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so
a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char
strip. `lineContains` in the title reader is tightened from the loose
substring `'custom_title'` to `'"subtype":"custom_title"'` so user text
containing the literal `custom_title` can't shadow a real record.

Tests: 46 new unit tests across
- `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call
  filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets.
- `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix,
  in-flight guard, abort propagation on finalize, manual/auto/legacy
  resume symmetry, cross-process race, env opt-out, retry-after-
  transient.
- `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle
  boundary, truncated trailing record, lineContains, multi-field atom.
- `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel,
  unknown-flag hint, positional rejection, manual/SessionService
  fallbacks.

* docs(session): design doc for auto session titles

Matches the session-recap design doc shape (Overview / Triggers /
Architecture / Prompt Design / History Filtering / Persistence /
Concurrency / Configuration / Observability / Out of Scope) and adds a
Security Hardening section unique to the title path — titles render
directly in the picker and persist in user-readable JSONL, so
LLM-returned control sequences are an attack surface the recap path
doesn't have.

Captures decisions a code-only reader has to reverse-engineer:

- Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop).
- Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call).
- Why the auto trigger does NOT fall back to the main model but
  session-recap does (auto-title fires on every turn; silently charging
  main-model tokens is a bill surprise).
- Why `titleSource: undefined` stays unwritten on legacy records (no
  rewrite risks silently reclassifying user intent).
- Why the cross-process re-read sits between the LLM await and the
  append (manual wins at both in-process and on-disk layers).
- Why `finalize()`'s abort tolerates a controller swap (in-flight
  identity check).
- Why JSON-schema function calling instead of tag extraction (avoid
  reasoning preamble bleed; cross-provider reliability).

Placed at docs/design/session-title/ alongside session-recap,
compact-mode, fork-subagent, and other per-feature design docs. No
sidebar index update required — the design folder is unindexed.

* test(rename): pin model choice in bare /rename kebab path

Addresses reviewer feedback: the bare `/rename` model selection
(`config.getFastModel() ?? config.getModel()`) had no test pinning
it either way. Previous tests mocked `getHistory: []`, which exits
the function before the model is ever chosen, so a silent regression
to either direction (always-main or always-fast) would pass CI.

Two explicit cases now:
- fastModel set → `generateContent` called with `model: 'qwen-turbo'`.
- fastModel unset → `generateContent` called with `model: 'main-model'`.

The tests intentionally mock a non-empty history so the kebab path
reaches the generateContent call site instead of bailing on empty input.
chiga0 pushed a commit that referenced this pull request Apr 24, 2026
…ion (#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>
chiga0 pushed a commit that referenced this pull request Apr 24, 2026
…#3540)

* feat(session): auto-title sessions via fast model, add /rename --auto

The /rename work in #3093 generates kebab-case titles only when the user
explicitly runs `/rename` with no args; until they do, the session picker
shows the first user prompt (often truncated or misleading). This change
adds a sentence-case auto-title that fires once per session after the
first assistant turn, using the configured fast model.

New service: `packages/core/src/services/sessionTitle.ts` —
`tryGenerateSessionTitle(config, signal)` returns a discriminated outcome
(`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can
either handle failures generically or map reasons to actionable messages.
Prompt shape: 3-7 words, sentence case, good/bad examples including a
CJK row, JSON schema enforced via `baseLlmClient.generateJson`.
`maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight
rate limits.

Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after
`recordAssistantTurn`. Fire-and-forget promise, guarded by:

- `currentCustomTitle` — don't overwrite any existing title.
- `autoTitleController` doubles as in-flight flag; a second turn while
  the first is still pending is a no-op.
- `autoTitleAttempts` cap of 3 — the first assistant turn may be a
  pure tool-call with no user-visible text; retry for a handful of
  turns until a title lands. Cap bounds total waste.
- `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto-
  titles; spending fast-model tokens on a one-shot session is waste.
- `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out.
- `config.getFastModel()` falsy — skip entirely rather than falling
  back to the main model; auto-titling on main-model tokens is too
  expensive to be silent.

Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' |
'manual'` field. Absent on pre-change records (treated as `undefined`
→ manual, safe default so a user's pre-upgrade `/rename` is never
silently reclassified). `SessionPicker` renders `titleSource === 'auto'`
titles in dim (secondary) color; manual stays full contrast. On resume,
the persisted source is rehydrated into `currentTitleSource` — without
this, finalize's re-append would rewrite an auto title as manual on
every resume cycle.

Cross-process manual-rename guard: when two CLI tabs target the same
JSONL, in-memory state can diverge. Before writing an auto record, the
IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a
`/rename` from another process landed as manual, bail and sync local
state — never clobber a deliberately-chosen manual title with a model
guess. Cost is one 64KB tail read per successful generation.

`finalize()` aborts the in-flight controller before re-appending the
title record. Session switch / shutdown doesn't have to wait on a slow
fast-model call.

New user-facing command: `/rename --auto` regenerates via the same
generator — explicit user trigger, overwrites whatever's there (manual
or auto) because the user asked. Errors route through
`autoFailureMessage(reason)` so `empty_history`, `model_error`,
`aborted`, etc. each get actionable guidance rather than a generic
"could not generate". `/rename -- --literal-name` is the sentinel for
titles that start with `--`; unknown `--flag` tokens error with a hint
pointing at the sentinel. Existing `/rename <name>` and bare `/rename`
(kebab-case via existing path) are unchanged, except the kebab path now
prefers fast model when available and runs its output through
`stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the
sentence-case path).

New shared util: `packages/core/src/utils/terminalSafe.ts` —
`stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI
(\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A
model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise
execute on every SessionPicker render; both sentence-case and kebab
paths now route titles through the helper before they reach the JSONL
or the UI.

Tail-read extractor: `extractLastJsonStringFields(text, primaryKey,
otherKeys, lineContains)` reads multiple fields from the same matching
line in a single pass. Two separate tail scans could return a mismatched
pair (primary from a newer record, secondary from an older one with only
the primary set); the new helper guarantees the pair is atomic. Validates
a proper closing quote on the primary value so a crash-truncated trailing
record can't win the latest-match race. `readLastJsonStringFieldsSync`
is its file-reading wrapper — same tail-window fast path and full-file
fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB`
cap so a corrupt multi-GB session file can't freeze the picker. Session
reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows
where the constant isn't exposed) — defense in depth against a symlink
planted in `~/.qwen/projects/<proj>/chats/`.

Character handling: `flattenToTail` on the LLM prompt drops a dangling
low surrogate after `slice(-1000)` — otherwise a CJK supplementary char
or emoji cut mid-pair produces invalid UTF-16 that some providers 400.
`sanitizeTitle` applies the same surrogate scrub after max-length trim,
and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so
a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char
strip. `lineContains` in the title reader is tightened from the loose
substring `'custom_title'` to `'"subtype":"custom_title"'` so user text
containing the literal `custom_title` can't shadow a real record.

Tests: 46 new unit tests across
- `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call
  filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets.
- `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix,
  in-flight guard, abort propagation on finalize, manual/auto/legacy
  resume symmetry, cross-process race, env opt-out, retry-after-
  transient.
- `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle
  boundary, truncated trailing record, lineContains, multi-field atom.
- `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel,
  unknown-flag hint, positional rejection, manual/SessionService
  fallbacks.

* docs(session): design doc for auto session titles

Matches the session-recap design doc shape (Overview / Triggers /
Architecture / Prompt Design / History Filtering / Persistence /
Concurrency / Configuration / Observability / Out of Scope) and adds a
Security Hardening section unique to the title path — titles render
directly in the picker and persist in user-readable JSONL, so
LLM-returned control sequences are an attack surface the recap path
doesn't have.

Captures decisions a code-only reader has to reverse-engineer:

- Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop).
- Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call).
- Why the auto trigger does NOT fall back to the main model but
  session-recap does (auto-title fires on every turn; silently charging
  main-model tokens is a bill surprise).
- Why `titleSource: undefined` stays unwritten on legacy records (no
  rewrite risks silently reclassifying user intent).
- Why the cross-process re-read sits between the LLM await and the
  append (manual wins at both in-process and on-disk layers).
- Why `finalize()`'s abort tolerates a controller swap (in-flight
  identity check).
- Why JSON-schema function calling instead of tag extraction (avoid
  reasoning preamble bleed; cross-provider reliability).

Placed at docs/design/session-title/ alongside session-recap,
compact-mode, fork-subagent, and other per-feature design docs. No
sidebar index update required — the design folder is unindexed.

* test(rename): pin model choice in bare /rename kebab path

Addresses reviewer feedback: the bare `/rename` model selection
(`config.getFastModel() ?? config.getModel()`) had no test pinning
it either way. Previous tests mocked `getHistory: []`, which exits
the function before the model is ever chosen, so a silent regression
to either direction (always-main or always-fast) would pass CI.

Two explicit cases now:
- fastModel set → `generateContent` called with `model: 'qwen-turbo'`.
- fastModel unset → `generateContent` called with `model: 'main-model'`.

The tests intentionally mock a non-empty history so the kebab path
reaches the generateContent call site instead of bailing on empty input.
mabry1985 pushed a commit to protoLabsAI/protoCLI that referenced this pull request May 3, 2026
Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session
renaming/deletion + custom-title support. Skips the auto-title-via-LLM
piece (depends on un-ported gateway shape) and the vscode-ide-companion
files (deleted in our fork).

What's in:

- /rename: prompt for a custom session title; persisted via
  ChatRecordingService.recordCustomTitle and surfaced in the picker.
- /delete: opens a SessionPicker that calls
  SessionService.removeSession on selection.
- SessionListItem.customTitle field + readSessionTitleFromFile tail
  scanner on session file load.
- SessionService.renameSession / getSessionTitle /
  findSessionsByTitle.
- ACP extMethod handlers for renameSession + deleteSession.
- SessionStart restores session-name tag from the persisted custom
  title via useInitializationEffects.
- --resume now accepts UUID or title (validation moved to runtime).

Conflict resolution notes:

- Kept HEAD's bg-agent useEffect block; the upstream init useEffect
  was already extracted into useInitializationEffects, so the
  customTitle restore goes there with an optional setSessionName arg.
- Kept HEAD's rewind dialog; added the delete dialog as a sibling.
- Kept HEAD's voice/recap state; added sessionName/setSessionName to
  UIState. Dropped upstream's streamingResponseLengthRef +
  isReceivingContent (token-display PR QwenLM#3329, un-ported).
- Dropped upstream MemoryDialog import (auto-memory un-ported); kept
  the i18n t import for the Delete dialog title.

Tests: 29 new tests pass (rename/delete commands, customTitle
recording, sessionService rename/find). Resume tests still pass.

Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a
generateSessionTitle path through ContentGenerator that needs
adaptation to our gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mabry1985 pushed a commit to protoLabsAI/protoCLI that referenced this pull request May 3, 2026
Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session
renaming/deletion + custom-title support. Skips the auto-title-via-LLM
piece (depends on un-ported gateway shape) and the vscode-ide-companion
files (deleted in our fork).

What's in:

- /rename: prompt for a custom session title; persisted via
  ChatRecordingService.recordCustomTitle and surfaced in the picker.
- /delete: opens a SessionPicker that calls
  SessionService.removeSession on selection.
- SessionListItem.customTitle field + readSessionTitleFromFile tail
  scanner on session file load.
- SessionService.renameSession / getSessionTitle /
  findSessionsByTitle.
- ACP extMethod handlers for renameSession + deleteSession.
- SessionStart restores session-name tag from the persisted custom
  title via useInitializationEffects.
- --resume now accepts UUID or title (validation moved to runtime).

Conflict resolution notes:

- Kept HEAD's bg-agent useEffect block; the upstream init useEffect
  was already extracted into useInitializationEffects, so the
  customTitle restore goes there with an optional setSessionName arg.
- Kept HEAD's rewind dialog; added the delete dialog as a sibling.
- Kept HEAD's voice/recap state; added sessionName/setSessionName to
  UIState. Dropped upstream's streamingResponseLengthRef +
  isReceivingContent (token-display PR QwenLM#3329, un-ported).
- Dropped upstream MemoryDialog import (auto-memory un-ported); kept
  the i18n t import for the Delete dialog title.

Tests: 29 new tests pass (rename/delete commands, customTitle
recording, sessionService rename/find). Resume tests still pass.

Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a
generateSessionTitle path through ContentGenerator that needs
adaptation to our gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mabry1985 added a commit to protoLabsAI/protoCLI that referenced this pull request May 3, 2026
…) (#218)

* feat(session): port /rename and /delete with custom titles (QwenLM#3093)

Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session
renaming/deletion + custom-title support. Skips the auto-title-via-LLM
piece (depends on un-ported gateway shape) and the vscode-ide-companion
files (deleted in our fork).

What's in:

- /rename: prompt for a custom session title; persisted via
  ChatRecordingService.recordCustomTitle and surfaced in the picker.
- /delete: opens a SessionPicker that calls
  SessionService.removeSession on selection.
- SessionListItem.customTitle field + readSessionTitleFromFile tail
  scanner on session file load.
- SessionService.renameSession / getSessionTitle /
  findSessionsByTitle.
- ACP extMethod handlers for renameSession + deleteSession.
- SessionStart restores session-name tag from the persisted custom
  title via useInitializationEffects.
- --resume now accepts UUID or title (validation moved to runtime).

Conflict resolution notes:

- Kept HEAD's bg-agent useEffect block; the upstream init useEffect
  was already extracted into useInitializationEffects, so the
  customTitle restore goes there with an optional setSessionName arg.
- Kept HEAD's rewind dialog; added the delete dialog as a sibling.
- Kept HEAD's voice/recap state; added sessionName/setSessionName to
  UIState. Dropped upstream's streamingResponseLengthRef +
  isReceivingContent (token-display PR QwenLM#3329, un-ported).
- Dropped upstream MemoryDialog import (auto-memory un-ported); kept
  the i18n t import for the Delete dialog title.

Tests: 29 new tests pass (rename/delete commands, customTitle
recording, sessionService rename/find). Resume tests still pass.

Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a
generateSessionTitle path through ContentGenerator that needs
adaptation to our gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: nudge PR conflict recomputation

---------

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
…ion (QwenLM#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 QwenLM#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 QwenLM#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>
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
…QwenLM#3540)

* feat(session): auto-title sessions via fast model, add /rename --auto

The /rename work in QwenLM#3093 generates kebab-case titles only when the user
explicitly runs `/rename` with no args; until they do, the session picker
shows the first user prompt (often truncated or misleading). This change
adds a sentence-case auto-title that fires once per session after the
first assistant turn, using the configured fast model.

New service: `packages/core/src/services/sessionTitle.ts` —
`tryGenerateSessionTitle(config, signal)` returns a discriminated outcome
(`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can
either handle failures generically or map reasons to actionable messages.
Prompt shape: 3-7 words, sentence case, good/bad examples including a
CJK row, JSON schema enforced via `baseLlmClient.generateJson`.
`maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight
rate limits.

Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after
`recordAssistantTurn`. Fire-and-forget promise, guarded by:

- `currentCustomTitle` — don't overwrite any existing title.
- `autoTitleController` doubles as in-flight flag; a second turn while
  the first is still pending is a no-op.
- `autoTitleAttempts` cap of 3 — the first assistant turn may be a
  pure tool-call with no user-visible text; retry for a handful of
  turns until a title lands. Cap bounds total waste.
- `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto-
  titles; spending fast-model tokens on a one-shot session is waste.
- `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out.
- `config.getFastModel()` falsy — skip entirely rather than falling
  back to the main model; auto-titling on main-model tokens is too
  expensive to be silent.

Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' |
'manual'` field. Absent on pre-change records (treated as `undefined`
→ manual, safe default so a user's pre-upgrade `/rename` is never
silently reclassified). `SessionPicker` renders `titleSource === 'auto'`
titles in dim (secondary) color; manual stays full contrast. On resume,
the persisted source is rehydrated into `currentTitleSource` — without
this, finalize's re-append would rewrite an auto title as manual on
every resume cycle.

Cross-process manual-rename guard: when two CLI tabs target the same
JSONL, in-memory state can diverge. Before writing an auto record, the
IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a
`/rename` from another process landed as manual, bail and sync local
state — never clobber a deliberately-chosen manual title with a model
guess. Cost is one 64KB tail read per successful generation.

`finalize()` aborts the in-flight controller before re-appending the
title record. Session switch / shutdown doesn't have to wait on a slow
fast-model call.

New user-facing command: `/rename --auto` regenerates via the same
generator — explicit user trigger, overwrites whatever's there (manual
or auto) because the user asked. Errors route through
`autoFailureMessage(reason)` so `empty_history`, `model_error`,
`aborted`, etc. each get actionable guidance rather than a generic
"could not generate". `/rename -- --literal-name` is the sentinel for
titles that start with `--`; unknown `--flag` tokens error with a hint
pointing at the sentinel. Existing `/rename <name>` and bare `/rename`
(kebab-case via existing path) are unchanged, except the kebab path now
prefers fast model when available and runs its output through
`stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the
sentence-case path).

New shared util: `packages/core/src/utils/terminalSafe.ts` —
`stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI
(\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A
model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise
execute on every SessionPicker render; both sentence-case and kebab
paths now route titles through the helper before they reach the JSONL
or the UI.

Tail-read extractor: `extractLastJsonStringFields(text, primaryKey,
otherKeys, lineContains)` reads multiple fields from the same matching
line in a single pass. Two separate tail scans could return a mismatched
pair (primary from a newer record, secondary from an older one with only
the primary set); the new helper guarantees the pair is atomic. Validates
a proper closing quote on the primary value so a crash-truncated trailing
record can't win the latest-match race. `readLastJsonStringFieldsSync`
is its file-reading wrapper — same tail-window fast path and full-file
fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB`
cap so a corrupt multi-GB session file can't freeze the picker. Session
reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows
where the constant isn't exposed) — defense in depth against a symlink
planted in `~/.qwen/projects/<proj>/chats/`.

Character handling: `flattenToTail` on the LLM prompt drops a dangling
low surrogate after `slice(-1000)` — otherwise a CJK supplementary char
or emoji cut mid-pair produces invalid UTF-16 that some providers 400.
`sanitizeTitle` applies the same surrogate scrub after max-length trim,
and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so
a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char
strip. `lineContains` in the title reader is tightened from the loose
substring `'custom_title'` to `'"subtype":"custom_title"'` so user text
containing the literal `custom_title` can't shadow a real record.

Tests: 46 new unit tests across
- `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call
  filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets.
- `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix,
  in-flight guard, abort propagation on finalize, manual/auto/legacy
  resume symmetry, cross-process race, env opt-out, retry-after-
  transient.
- `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle
  boundary, truncated trailing record, lineContains, multi-field atom.
- `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel,
  unknown-flag hint, positional rejection, manual/SessionService
  fallbacks.

* docs(session): design doc for auto session titles

Matches the session-recap design doc shape (Overview / Triggers /
Architecture / Prompt Design / History Filtering / Persistence /
Concurrency / Configuration / Observability / Out of Scope) and adds a
Security Hardening section unique to the title path — titles render
directly in the picker and persist in user-readable JSONL, so
LLM-returned control sequences are an attack surface the recap path
doesn't have.

Captures decisions a code-only reader has to reverse-engineer:

- Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop).
- Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call).
- Why the auto trigger does NOT fall back to the main model but
  session-recap does (auto-title fires on every turn; silently charging
  main-model tokens is a bill surprise).
- Why `titleSource: undefined` stays unwritten on legacy records (no
  rewrite risks silently reclassifying user intent).
- Why the cross-process re-read sits between the LLM await and the
  append (manual wins at both in-process and on-disk layers).
- Why `finalize()`'s abort tolerates a controller swap (in-flight
  identity check).
- Why JSON-schema function calling instead of tag extraction (avoid
  reasoning preamble bleed; cross-provider reliability).

Placed at docs/design/session-title/ alongside session-recap,
compact-mode, fork-subagent, and other per-feature design docs. No
sidebar index update required — the design folder is unindexed.

* test(rename): pin model choice in bare /rename kebab path

Addresses reviewer feedback: the bare `/rename` model selection
(`config.getFastModel() ?? config.getModel()`) had no test pinning
it either way. Previous tests mocked `getHistory: []`, which exits
the function before the model is ever chosen, so a silent regression
to either direction (always-main or always-fast) would pass CI.

Two explicit cases now:
- fastModel set → `generateContent` called with `model: 'qwen-turbo'`.
- fastModel unset → `generateContent` called with `model: 'main-model'`.

The tests intentionally mock a non-empty history so the kebab path
reaches the generateContent call site instead of bailing on empty input.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[VS Code Chat] Allow name change for session

7 participants