Skip to content

Commit 9162ea5

Browse files
hahhforestclaude
andcommitted
feat: 多会话支持 — /sessions、/switch、/session 命令
将 SessionManager 从扁平的 per-chatId 模型重构为 SessionGroup 分组模型, 允许用户在一个聊天中维护多个对话线程。 主要变更: - resetSession() 创建新会话而非清除旧会话,历史会话保留可切换 - 新增 /sessions 列出所有会话、/switch N 按编号切换、/session <prefix> 按 ID 前缀切换 - 虚拟 chatId ({chatId}::{N}) 确保 SessionRegistry 中不同会话的记录隔离 - 自动记录会话标题(首条消息预览) - 持久化格式向后兼容:自动将旧扁平格式迁移为分组格式 - 24 个测试覆盖所有新功能 设计参考 #239 统一命令规范。基于 upstream/main 全新重写(原 PR #174)。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a6ce914 commit 9162ea5

4 files changed

Lines changed: 518 additions & 86 deletions

File tree

src/bridge/command-handler.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export class CommandHandler {
6363
'`/model claude`, `/model kimi`, or `/model codex` - Switch engine (resets session)',
6464
'`/model <name>` - Set model for current engine',
6565
'`/memory` - Memory document commands',
66+
'`/sessions` - List all sessions in this chat',
67+
'`/switch [N]` - Switch to session N (or show list to pick)',
68+
'`/session <prefix>` - Switch by session ID prefix',
6669
'`/help` - Show this help message',
6770
'',
6871
'**Agent Commands** (pass through to the agent — Claude only):',
@@ -95,7 +98,8 @@ export class CommandHandler {
9598
} catch (err) {
9699
this.logger.warn({ err, chatId }, 'Failed to release persistent executor on /reset');
97100
}
98-
await this.sender.sendTextNotice(chatId, '✅ Session Reset', 'Conversation cleared. Working directory preserved.', 'green');
101+
await this.sender.sendTextNotice(chatId, '✅ Session Reset',
102+
'New session created. Previous sessions preserved — use `/sessions` to switch back.', 'green');
99103
return true;
100104

101105
case '/stop': {
@@ -162,6 +166,23 @@ export class CommandHandler {
162166
return true;
163167
}
164168

169+
case '/sessions': {
170+
await this.handleSessionsCommand(chatId);
171+
return true;
172+
}
173+
174+
case '/switch': {
175+
const args = text.slice('/switch'.length).trim();
176+
await this.handleSwitchCommand(chatId, args);
177+
return true;
178+
}
179+
180+
case '/session': {
181+
const args = text.slice('/session'.length).trim();
182+
await this.handleSessionPrefixCommand(chatId, args);
183+
return true;
184+
}
185+
165186
default:
166187
// Unrecognized /xxx commands — not handled here, pass through to Claude
167188
return false;
@@ -409,6 +430,63 @@ export class CommandHandler {
409430
);
410431
}
411432

433+
private async handleSessionsCommand(chatId: string): Promise<void> {
434+
const sessions = this.sessionManager.listSessions(chatId);
435+
if (sessions.length === 0) {
436+
await this.sender.sendTextNotice(chatId, '📋 Sessions', 'No sessions yet. Send a message to start one.');
437+
return;
438+
}
439+
const lines = sessions.map(s => {
440+
const marker = s.isActive ? ' ▶' : '';
441+
const sid = s.sessionId ? ` \`${s.sessionId.slice(0, 8)}\`` : '';
442+
const title = s.title ? ` — ${s.title}` : '';
443+
const time = new Date(s.lastUsed).toLocaleString();
444+
return `**${s.index + 1}.**${sid}${title} (${time})${marker}`;
445+
});
446+
lines.push('', 'Use `/switch N` to switch, or `/session <prefix>` to switch by session ID.');
447+
await this.sender.sendTextNotice(chatId, '📋 Sessions', lines.join('\n'));
448+
}
449+
450+
private async handleSwitchCommand(chatId: string, args: string): Promise<void> {
451+
if (!args) {
452+
await this.handleSessionsCommand(chatId);
453+
return;
454+
}
455+
const num = parseInt(args, 10);
456+
if (isNaN(num) || num < 1) {
457+
await this.sender.sendTextNotice(chatId, '❌ Invalid', 'Usage: `/switch N` where N is the session number from `/sessions`.', 'red');
458+
return;
459+
}
460+
const success = this.sessionManager.switchSession(chatId, num - 1);
461+
if (success) {
462+
const session = this.sessionManager.getSession(chatId);
463+
const sid = session.sessionId ? `\`${session.sessionId.slice(0, 8)}...\`` : '_new_';
464+
await this.sender.sendTextNotice(chatId, '✅ Switched', `Switched to session ${num} (${sid}).`, 'green');
465+
} else {
466+
const sessions = this.sessionManager.listSessions(chatId);
467+
await this.sender.sendTextNotice(chatId, '❌ Invalid',
468+
`Session ${num} does not exist. You have ${sessions.length} session(s). Use \`/sessions\` to list.`, 'red');
469+
}
470+
}
471+
472+
private async handleSessionPrefixCommand(chatId: string, prefix: string): Promise<void> {
473+
if (!prefix || prefix.length < 8) {
474+
await this.sender.sendTextNotice(chatId, '❌ Invalid',
475+
'Usage: `/session <prefix>` — provide at least 8 characters of the session ID.', 'red');
476+
return;
477+
}
478+
const idx = this.sessionManager.switchToSessionByPrefix(chatId, prefix);
479+
if (idx >= 0) {
480+
const session = this.sessionManager.getSession(chatId);
481+
const sid = session.sessionId ? `\`${session.sessionId.slice(0, 8)}...\`` : '_new_';
482+
await this.sender.sendTextNotice(chatId, '✅ Switched',
483+
`Switched to session ${idx + 1} (${sid}).`, 'green');
484+
} else {
485+
await this.sender.sendTextNotice(chatId, '❌ Not Found',
486+
`No session found matching prefix \`${prefix}\`. Use \`/sessions\` to list available sessions.`, 'red');
487+
}
488+
}
489+
412490
private defaultModelForEngine(engine: EngineName): string | undefined {
413491
switch (engine) {
414492
case 'claude':

src/bridge/message-bridge.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2325,10 +2325,13 @@ export class MessageBridge {
23252325
*/
23262326
/** Record session and messages in the cross-platform registry. */
23272327
private recordSession(chatId: string, prompt: string, responseText: string | undefined, claudeSessionId: string | undefined, costUsd: number | undefined, durationMs: number | undefined): void {
2328+
// Set session title from first user message
2329+
this.sessionManager.setTitle(chatId, prompt.slice(0, 50));
2330+
23282331
if (!this.sessionRegistry) return;
23292332
try {
23302333
this.sessionRegistry.createOrUpdate({
2331-
chatId,
2334+
chatId: this.sessionManager.getVirtualChatId(chatId),
23322335
botName: this.config.name,
23332336
claudeSessionId,
23342337
workingDirectory: this.sessionManager.getSession(chatId).workingDirectory,

0 commit comments

Comments
 (0)