mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(session): 实现会话保存和加载功能
- 在 AcpConnection 和 AcpSessionManager 中添加会话保存方法 - 在 QwenAgentManager 中实现通过 ACP 和直接保存会话的功能 - 在前端添加保存会话对话框和相关交互逻辑 - 新增 QwenSessionManager 用于直接操作文件系统保存和加载会话
This commit is contained in:
@@ -41,6 +41,7 @@ import { AcpSessionManager } from './acpSessionManager.js';
|
||||
* ✅ session/prompt - Send user message to agent
|
||||
* ✅ session/cancel - Cancel current generation
|
||||
* ✅ session/load - Load previous session
|
||||
* ✅ session/save - Save current session
|
||||
*
|
||||
* Custom Methods (Not in standard ACP):
|
||||
* ⚠️ session/list - List available sessions (custom extension)
|
||||
@@ -348,6 +349,21 @@ export class AcpConnection {
|
||||
await this.sessionManager.cancelSession(this.child);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前会话
|
||||
*
|
||||
* @param tag - 保存标签
|
||||
* @returns 保存响应
|
||||
*/
|
||||
async saveSession(tag: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.saveSession(
|
||||
tag,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
|
||||
@@ -349,6 +349,40 @@ export class AcpSessionManager {
|
||||
console.log('[ACP] Cancel notification sent');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前会话
|
||||
*
|
||||
* @param tag - 保存标签
|
||||
* @param child - 子进程实例
|
||||
* @param pendingRequests - 待处理请求映射表
|
||||
* @param nextRequestId - 请求ID计数器
|
||||
* @returns 保存响应
|
||||
*/
|
||||
async saveSession(
|
||||
tag: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
if (!this.sessionId) {
|
||||
throw new Error('No active ACP session');
|
||||
}
|
||||
|
||||
console.log('[ACP] Saving session with tag:', tag);
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_save,
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
tag,
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log('[ACP] Session save response:', response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置会话管理器状态
|
||||
*/
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
* ✅ initialize - Protocol initialization
|
||||
* ✅ authenticate - User authentication
|
||||
* ✅ session/new - Create new session
|
||||
* ❌ session/load - Load existing session (not implemented in CLI)
|
||||
* ✅ session/load - Load existing session
|
||||
* ✅ session/prompt - Send user message to agent
|
||||
* ✅ session/cancel - Cancel current generation
|
||||
* ✅ session/save - Save current session
|
||||
*/
|
||||
export const AGENT_METHODS = {
|
||||
authenticate: 'authenticate',
|
||||
@@ -29,6 +30,7 @@ export const AGENT_METHODS = {
|
||||
session_load: 'session/load',
|
||||
session_new: 'session/new',
|
||||
session_prompt: 'session/prompt',
|
||||
session_save: 'session/save',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
QwenSessionReader,
|
||||
type QwenSession,
|
||||
} from '../services/qwenSessionReader.js';
|
||||
import { QwenSessionManager } from '../services/qwenSessionManager.js';
|
||||
import type { AuthStateManager } from '../auth/authStateManager.js';
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
} from './qwenTypes.js';
|
||||
import { QwenConnectionHandler } from './qwenConnectionHandler.js';
|
||||
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export type { ChatMessage, PlanEntry, ToolCallUpdateData };
|
||||
|
||||
@@ -33,6 +35,7 @@ export type { ChatMessage, PlanEntry, ToolCallUpdateData };
|
||||
export class QwenAgentManager {
|
||||
private connection: AcpConnection;
|
||||
private sessionReader: QwenSessionReader;
|
||||
private sessionManager: QwenSessionManager;
|
||||
private connectionHandler: QwenConnectionHandler;
|
||||
private sessionUpdateHandler: QwenSessionUpdateHandler;
|
||||
private currentWorkingDir: string = process.cwd();
|
||||
@@ -43,6 +46,7 @@ export class QwenAgentManager {
|
||||
constructor() {
|
||||
this.connection = new AcpConnection();
|
||||
this.sessionReader = new QwenSessionReader();
|
||||
this.sessionManager = new QwenSessionManager();
|
||||
this.connectionHandler = new QwenConnectionHandler();
|
||||
this.sessionUpdateHandler = new QwenSessionUpdateHandler({});
|
||||
|
||||
@@ -158,6 +162,100 @@ export class QwenAgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ACP session/save 方法保存会话
|
||||
*
|
||||
* @param sessionId - 会话ID
|
||||
* @param tag - 保存标签
|
||||
* @returns 保存响应
|
||||
*/
|
||||
async saveSessionViaAcp(
|
||||
sessionId: string,
|
||||
tag: string,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
console.log(
|
||||
'[QwenAgentManager] Saving session via ACP:',
|
||||
sessionId,
|
||||
'with tag:',
|
||||
tag,
|
||||
);
|
||||
const response = await this.connection.saveSession(tag);
|
||||
console.log('[QwenAgentManager] Session save response:', response);
|
||||
// Extract message from response result or error
|
||||
let message = '';
|
||||
if (response?.result) {
|
||||
if (typeof response.result === 'string') {
|
||||
message = response.result;
|
||||
} else if (
|
||||
typeof response.result === 'object' &&
|
||||
response.result !== null
|
||||
) {
|
||||
// Try to get message from result object
|
||||
message =
|
||||
(response.result as { message?: string }).message ||
|
||||
JSON.stringify(response.result);
|
||||
} else {
|
||||
message = String(response.result);
|
||||
}
|
||||
} else if (response?.error) {
|
||||
message = response.error.message;
|
||||
}
|
||||
|
||||
return { success: true, message };
|
||||
} catch (error) {
|
||||
console.error('[QwenAgentManager] Session save via ACP failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接保存会话到文件系统(不依赖 ACP)
|
||||
*
|
||||
* @param messages - 当前会话消息
|
||||
* @param sessionName - 会话名称
|
||||
* @returns 保存结果
|
||||
*/
|
||||
async saveSessionDirect(
|
||||
messages: ChatMessage[],
|
||||
sessionName: string,
|
||||
): Promise<{ success: boolean; sessionId?: string; message?: string }> {
|
||||
try {
|
||||
console.log('[QwenAgentManager] Saving session directly:', sessionName);
|
||||
|
||||
// 转换消息格式
|
||||
const qwenMessages = messages.map((msg) => ({
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date(msg.timestamp).toISOString(),
|
||||
type: msg.role === 'user' ? ('user' as const) : ('qwen' as const),
|
||||
content: msg.content,
|
||||
}));
|
||||
|
||||
// 保存会话
|
||||
const sessionId = await this.sessionManager.saveSession(
|
||||
qwenMessages,
|
||||
sessionName,
|
||||
this.currentWorkingDir,
|
||||
);
|
||||
|
||||
console.log('[QwenAgentManager] Session saved directly:', sessionId);
|
||||
return {
|
||||
success: true,
|
||||
sessionId,
|
||||
message: `会话已保存: ${sessionName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[QwenAgentManager] Session save directly failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试通过 ACP session/load 方法加载会话
|
||||
* 这是一个测试方法,用于验证 CLI 是否支持 session/load
|
||||
@@ -180,6 +278,42 @@ export class QwenAgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接从文件系统加载会话(不依赖 ACP)
|
||||
*
|
||||
* @param sessionId - 会话ID
|
||||
* @returns 加载的会话消息或null
|
||||
*/
|
||||
async loadSessionDirect(sessionId: string): Promise<ChatMessage[] | null> {
|
||||
try {
|
||||
console.log('[QwenAgentManager] Loading session directly:', sessionId);
|
||||
|
||||
// 加载会话
|
||||
const session = await this.sessionManager.loadSession(
|
||||
sessionId,
|
||||
this.currentWorkingDir,
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
console.log('[QwenAgentManager] Session not found:', sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 转换消息格式
|
||||
const messages: ChatMessage[] = session.messages.map((msg) => ({
|
||||
role: msg.type === 'user' ? 'user' : 'assistant',
|
||||
content: msg.content,
|
||||
timestamp: new Date(msg.timestamp).getTime(),
|
||||
}));
|
||||
|
||||
console.log('[QwenAgentManager] Session loaded directly:', sessionId);
|
||||
return messages;
|
||||
} catch (error) {
|
||||
console.error('[QwenAgentManager] Session load directly failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
*
|
||||
|
||||
199
packages/vscode-ide-companion/src/services/qwenSessionManager.ts
Normal file
199
packages/vscode-ide-companion/src/services/qwenSessionManager.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
import type { QwenSession, QwenMessage } from './qwenSessionReader.js';
|
||||
|
||||
/**
|
||||
* Qwen Session Manager
|
||||
*
|
||||
* This service provides direct filesystem access to save and load sessions
|
||||
* without relying on the CLI's ACP session/save method.
|
||||
*/
|
||||
export class QwenSessionManager {
|
||||
private qwenDir: string;
|
||||
|
||||
constructor() {
|
||||
this.qwenDir = path.join(os.homedir(), '.qwen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate project hash (same as CLI)
|
||||
* Qwen CLI uses SHA256 hash of the project path
|
||||
*/
|
||||
private getProjectHash(workingDir: string): string {
|
||||
return crypto.createHash('sha256').update(workingDir).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session directory for a project
|
||||
*/
|
||||
private getSessionDir(workingDir: string): string {
|
||||
const projectHash = this.getProjectHash(workingDir);
|
||||
return path.join(this.qwenDir, 'tmp', projectHash, 'chats');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new session ID
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current conversation as a named session (checkpoint-like functionality)
|
||||
*
|
||||
* @param messages - Current conversation messages
|
||||
* @param sessionName - Name/tag for the saved session
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Session ID of the saved session
|
||||
*/
|
||||
async saveSession(
|
||||
messages: QwenMessage[],
|
||||
sessionName: string,
|
||||
workingDir: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Create session directory if it doesn't exist
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate session ID and filename
|
||||
const sessionId = this.generateSessionId();
|
||||
const filename = `session-${sessionId}.json`;
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
|
||||
// Create session object
|
||||
const session: QwenSession = {
|
||||
sessionId,
|
||||
projectHash: this.getProjectHash(workingDir),
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messages,
|
||||
};
|
||||
|
||||
// Save session to file
|
||||
fs.writeFileSync(filePath, JSON.stringify(session, null, 2), 'utf-8');
|
||||
|
||||
console.log(`[QwenSessionManager] Session saved: ${filePath}`);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to save session:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a saved session by name
|
||||
*
|
||||
* @param sessionName - Name/tag of the session to load
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Loaded session or null if not found
|
||||
*/
|
||||
async loadSession(
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
): Promise<QwenSession | null> {
|
||||
try {
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
const filename = `session-${sessionId}.json`;
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`[QwenSessionManager] Session file not found: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const session = JSON.parse(content) as QwenSession;
|
||||
|
||||
console.log(`[QwenSessionManager] Session loaded: ${filePath}`);
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to load session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved sessions
|
||||
*
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Array of session objects
|
||||
*/
|
||||
async listSessions(workingDir: string): Promise<QwenSession[]> {
|
||||
try {
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(sessionDir)
|
||||
.filter(
|
||||
(file) => file.startsWith('session-') && file.endsWith('.json'),
|
||||
);
|
||||
|
||||
const sessions: QwenSession[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(sessionDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const session = JSON.parse(content) as QwenSession;
|
||||
sessions.push(session);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[QwenSessionManager] Failed to read session file ${file}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last updated time (newest first)
|
||||
sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(),
|
||||
);
|
||||
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to list sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a saved session
|
||||
*
|
||||
* @param sessionId - ID of the session to delete
|
||||
* @param workingDir - Current working directory
|
||||
* @returns True if deleted successfully, false otherwise
|
||||
*/
|
||||
async deleteSession(sessionId: string, workingDir: string): Promise<boolean> {
|
||||
try {
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
const filename = `session-${sessionId}.json`;
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`[QwenSessionManager] Session deleted: ${filePath}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to delete session:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
interface QwenMessage {
|
||||
export interface QwenMessage {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: 'user' | 'qwen';
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type CompletionItem,
|
||||
} from './components/CompletionMenu.js';
|
||||
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
|
||||
import { SaveSessionDialog } from './components/SaveSessionDialog.js';
|
||||
|
||||
interface ToolCallUpdate {
|
||||
type: 'tool_call' | 'tool_call_update';
|
||||
@@ -227,6 +228,8 @@ export const App: React.FC = () => {
|
||||
const [thinkingEnabled, setThinkingEnabled] = useState(false);
|
||||
const [activeFileName, setActiveFileName] = useState<string | null>(null);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [savedSessionTags, setSavedSessionTags] = useState<string[]>([]);
|
||||
|
||||
// Workspace files cache
|
||||
const [workspaceFiles, setWorkspaceFiles] = useState<
|
||||
@@ -539,66 +542,100 @@ export const App: React.FC = () => {
|
||||
}, [handleAttachContextClick]);
|
||||
|
||||
// Handle removing context attachment
|
||||
const handleToolCallUpdate = React.useCallback((update: ToolCallUpdate) => {
|
||||
setToolCalls((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existing = newMap.get(update.toolCallId);
|
||||
const handleToolCallUpdate = React.useCallback(
|
||||
(update: ToolCallUpdate) => {
|
||||
setToolCalls((prevToolCalls) => {
|
||||
const newMap = new Map(prevToolCalls);
|
||||
const existing = newMap.get(update.toolCallId);
|
||||
|
||||
// Helper function to safely convert title to string
|
||||
const safeTitle = (title: unknown): string => {
|
||||
if (typeof title === 'string') {
|
||||
return title;
|
||||
// Helper function to safely convert title to string
|
||||
const safeTitle = (title: unknown): string => {
|
||||
if (typeof title === 'string') {
|
||||
return title;
|
||||
}
|
||||
if (title && typeof title === 'object') {
|
||||
return JSON.stringify(title);
|
||||
}
|
||||
return 'Tool Call';
|
||||
};
|
||||
|
||||
if (update.type === 'tool_call') {
|
||||
// New tool call - cast content to proper type
|
||||
const content = update.content?.map((item) => ({
|
||||
type: item.type as 'content' | 'diff',
|
||||
content: item.content,
|
||||
path: item.path,
|
||||
oldText: item.oldText,
|
||||
newText: item.newText,
|
||||
}));
|
||||
|
||||
newMap.set(update.toolCallId, {
|
||||
toolCallId: update.toolCallId,
|
||||
kind: update.kind || 'other',
|
||||
title: safeTitle(update.title),
|
||||
status: update.status || 'pending',
|
||||
rawInput: update.rawInput as string | object | undefined,
|
||||
content,
|
||||
locations: update.locations,
|
||||
});
|
||||
} else if (update.type === 'tool_call_update' && existing) {
|
||||
// Update existing tool call
|
||||
const updatedContent = update.content
|
||||
? update.content.map((item) => ({
|
||||
type: item.type as 'content' | 'diff',
|
||||
content: item.content,
|
||||
path: item.path,
|
||||
oldText: item.oldText,
|
||||
newText: item.newText,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
newMap.set(update.toolCallId, {
|
||||
...existing,
|
||||
...(update.kind && { kind: update.kind }),
|
||||
...(update.title && { title: safeTitle(update.title) }),
|
||||
...(update.status && { status: update.status }),
|
||||
...(updatedContent && { content: updatedContent }),
|
||||
...(update.locations && { locations: update.locations }),
|
||||
});
|
||||
}
|
||||
if (title && typeof title === 'object') {
|
||||
return JSON.stringify(title);
|
||||
|
||||
return newMap;
|
||||
});
|
||||
},
|
||||
[setToolCalls],
|
||||
);
|
||||
|
||||
const handleSaveSession = useCallback(
|
||||
(tag: string) => {
|
||||
// Send save session request to extension
|
||||
vscode.postMessage({
|
||||
type: 'saveSession',
|
||||
data: { tag },
|
||||
});
|
||||
setShowSaveDialog(false);
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
|
||||
// Handle save session response
|
||||
const handleSaveSessionResponse = useCallback(
|
||||
(response: { success: boolean; message?: string }) => {
|
||||
if (response.success) {
|
||||
// Add the new tag to saved session tags
|
||||
if (response.message) {
|
||||
const tagMatch = response.message.match(/tag: (.+)$/);
|
||||
if (tagMatch) {
|
||||
setSavedSessionTags((prev) => [...prev, tagMatch[1]]);
|
||||
}
|
||||
}
|
||||
return 'Tool Call';
|
||||
};
|
||||
|
||||
if (update.type === 'tool_call') {
|
||||
// New tool call - cast content to proper type
|
||||
const content = update.content?.map((item) => ({
|
||||
type: item.type as 'content' | 'diff',
|
||||
content: item.content,
|
||||
path: item.path,
|
||||
oldText: item.oldText,
|
||||
newText: item.newText,
|
||||
}));
|
||||
|
||||
newMap.set(update.toolCallId, {
|
||||
toolCallId: update.toolCallId,
|
||||
kind: update.kind || 'other',
|
||||
title: safeTitle(update.title),
|
||||
status: update.status || 'pending',
|
||||
rawInput: update.rawInput as string | object | undefined,
|
||||
content,
|
||||
locations: update.locations,
|
||||
});
|
||||
} else if (update.type === 'tool_call_update' && existing) {
|
||||
// Update existing tool call
|
||||
const updatedContent = update.content
|
||||
? update.content.map((item) => ({
|
||||
type: item.type as 'content' | 'diff',
|
||||
content: item.content,
|
||||
path: item.path,
|
||||
oldText: item.oldText,
|
||||
newText: item.newText,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
newMap.set(update.toolCallId, {
|
||||
...existing,
|
||||
...(update.kind && { kind: update.kind }),
|
||||
...(update.title && { title: safeTitle(update.title) }),
|
||||
...(update.status && { status: update.status }),
|
||||
...(updatedContent && { content: updatedContent }),
|
||||
...(update.locations && { locations: update.locations }),
|
||||
});
|
||||
} else {
|
||||
// Handle error - could show a toast or error message
|
||||
console.error('Failed to save session:', response.message);
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[setSavedSessionTags],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for messages from extension
|
||||
@@ -828,6 +865,12 @@ export const App: React.FC = () => {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'saveSessionResponse': {
|
||||
// Handle save session response
|
||||
handleSaveSessionResponse(message.data);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -835,7 +878,12 @@ export const App: React.FC = () => {
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [currentSessionId, handlePermissionRequest, handleToolCallUpdate]);
|
||||
}, [
|
||||
currentSessionId,
|
||||
handlePermissionRequest,
|
||||
handleToolCallUpdate,
|
||||
handleSaveSessionResponse,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-scroll to bottom when messages change
|
||||
@@ -1230,6 +1278,26 @@ export const App: React.FC = () => {
|
||||
</span>
|
||||
</button>
|
||||
<div className="header-spacer"></div>
|
||||
<button
|
||||
className="save-session-header-button"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
title="Save Conversation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
className="icon-svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.25 2A2.25 2.25 0 0 0 2 4.25v11.5A2.25 2.25 0 0 0 4.25 18h11.5A2.25 2.25 0 0 0 18 15.75V8.25a.75.75 0 0 1 .217-.517l.083-.083a.75.75 0 0 1 1.061 0l2.239 2.239A.75.75 0 0 1 22 10.5v5.25a4.75 4.75 0 0 1-4.75 4.75H4.75A4.75 4.75 0 0 1 0 15.75V4.25A4.75 4.75 0 0 1 4.75 0h5a.75.75 0 0 1 0 1.5h-5ZM9.017 6.5a1.5 1.5 0 0 1 2.072.58l.43.862a1 1 0 0 0 .895.558h3.272a1.5 1.5 0 0 1 1.5 1.5v6.75a1.5 1.5 0 0 1-1.5 1.5h-7.5a1.5 1.5 0 0 1-1.5-1.5v-6.75a1.5 1.5 0 0 1 1.5-1.5h1.25a1 1 0 0 0 .895-.558l.43-.862a1.5 1.5 0 0 1 .511-.732ZM11.78 8.47a.75.75 0 0 0-1.06-1.06L8.75 9.379 7.78 8.41a.75.75 0 0 0-1.06 1.06l1.5 1.5a.75.75 0 0 0 1.06 0l2.5-2.5Z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="new-session-header-button"
|
||||
onClick={handleNewQwenSession}
|
||||
@@ -1596,6 +1664,14 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Session Dialog */}
|
||||
<SaveSessionDialog
|
||||
isOpen={showSaveDialog}
|
||||
onClose={() => setShowSaveDialog(false)}
|
||||
onSave={handleSaveSession}
|
||||
existingTags={savedSessionTags}
|
||||
/>
|
||||
|
||||
{/* Permission Drawer - Cursor style */}
|
||||
{permissionRequest && (
|
||||
<PermissionDrawer
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
* Path: /Users/jinjing/Downloads/Anthropic.claude-code-2.0.43/extension/webview/index.css
|
||||
*/
|
||||
|
||||
/* Import component styles */
|
||||
@import './components/SaveSessionDialog.css';
|
||||
@import './components/SessionManager.css';
|
||||
@import './components/MessageContent.css';
|
||||
@import './components/EmptyState.css';
|
||||
@import './components/CompletionMenu.css';
|
||||
@import './components/ContextPills.css';
|
||||
@import './components/PermissionDrawer.css';
|
||||
@import './components/PlanDisplay.css';
|
||||
@import './components/Timeline.css';
|
||||
@import './components/shared/FileLink.css';
|
||||
@import './components/toolcalls/shared/DiffDisplay.css';
|
||||
|
||||
/* ===========================
|
||||
Header Styles (from Claude Code .he)
|
||||
=========================== */
|
||||
|
||||
@@ -25,6 +25,8 @@ export class MessageHandler {
|
||||
type: string;
|
||||
data: { optionId: string };
|
||||
}) => void;
|
||||
// 当前消息列表
|
||||
private messages: ChatMessage[] = [];
|
||||
|
||||
constructor(
|
||||
private agentManager: QwenAgentManager,
|
||||
@@ -197,6 +199,14 @@ export class MessageHandler {
|
||||
await this.handleGetWorkspaceFiles(data?.query as string);
|
||||
break;
|
||||
|
||||
case 'saveSession':
|
||||
await this.handleSaveSession(data?.tag as string);
|
||||
break;
|
||||
|
||||
case 'resumeSession':
|
||||
await this.handleResumeSession(data?.sessionId as string);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[MessageHandler] Unknown message type:', message.type);
|
||||
break;
|
||||
@@ -788,4 +798,128 @@ export class MessageHandler {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理保存会话请求
|
||||
* 首先尝试通过 ACP 协议保存,如果失败则直接保存到文件系统
|
||||
*/
|
||||
private async handleSaveSession(tag: string): Promise<void> {
|
||||
try {
|
||||
console.log('[MessageHandler] Saving session with tag:', tag);
|
||||
|
||||
if (!this.currentConversationId) {
|
||||
throw new Error('No active conversation to save');
|
||||
}
|
||||
|
||||
// 从 conversationStore 获取当前会话消息
|
||||
const conversation = await this.conversationStore.getConversation(
|
||||
this.currentConversationId,
|
||||
);
|
||||
const messages = conversation?.messages || [];
|
||||
|
||||
// 首先尝试通过 ACP 保存
|
||||
try {
|
||||
const response = await this.agentManager.saveSessionViaAcp(
|
||||
this.currentConversationId,
|
||||
tag,
|
||||
);
|
||||
|
||||
console.log('[MessageHandler] Session saved via ACP:', response);
|
||||
|
||||
// Send response back to WebView
|
||||
this.sendToWebView({
|
||||
type: 'saveSessionResponse',
|
||||
data: response,
|
||||
});
|
||||
} catch (acpError) {
|
||||
console.warn(
|
||||
'[MessageHandler] ACP save failed, falling back to direct save:',
|
||||
acpError,
|
||||
);
|
||||
|
||||
// ACP 保存失败,尝试直接保存到文件系统
|
||||
const response = await this.agentManager.saveSessionDirect(
|
||||
messages,
|
||||
tag,
|
||||
);
|
||||
|
||||
console.log('[MessageHandler] Session saved directly:', response);
|
||||
|
||||
// Send response back to WebView
|
||||
this.sendToWebView({
|
||||
type: 'saveSessionResponse',
|
||||
data: response,
|
||||
});
|
||||
}
|
||||
|
||||
// Also refresh the session list
|
||||
await this.handleGetQwenSessions();
|
||||
} catch (error) {
|
||||
console.error('[MessageHandler] Failed to save session:', error);
|
||||
this.sendToWebView({
|
||||
type: 'saveSessionResponse',
|
||||
data: {
|
||||
success: false,
|
||||
message: `Failed to save session: ${error}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理恢复会话请求
|
||||
* 首先尝试通过 ACP 协议加载,如果失败则直接从文件系统加载
|
||||
*/
|
||||
private async handleResumeSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
console.log('[MessageHandler] Resuming session:', sessionId);
|
||||
|
||||
// 首先尝试通过 ACP 加载
|
||||
try {
|
||||
await this.agentManager.loadSessionViaAcp(sessionId);
|
||||
|
||||
// Set current conversation ID
|
||||
this.currentConversationId = sessionId;
|
||||
|
||||
// Get session messages for display
|
||||
const messages = await this.agentManager.getSessionMessages(sessionId);
|
||||
|
||||
// Send response back to WebView
|
||||
this.sendToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId, messages },
|
||||
});
|
||||
} catch (acpError) {
|
||||
console.warn(
|
||||
'[MessageHandler] ACP load failed, falling back to direct load:',
|
||||
acpError,
|
||||
);
|
||||
|
||||
// ACP 加载失败,尝试直接从文件系统加载
|
||||
const messages = await this.agentManager.loadSessionDirect(sessionId);
|
||||
|
||||
if (messages) {
|
||||
// Set current conversation ID
|
||||
this.currentConversationId = sessionId;
|
||||
|
||||
// Send response back to WebView
|
||||
this.sendToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId, messages },
|
||||
});
|
||||
} else {
|
||||
throw new Error('会话加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
// Also refresh the session list
|
||||
await this.handleGetQwenSessions();
|
||||
} catch (error) {
|
||||
console.error('[MessageHandler] Failed to resume session:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to resume session: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/* Save Session Dialog Styles */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
background: var(--app-menu-background);
|
||||
border: 1px solid var(--app-menu-border);
|
||||
border-radius: var(--corner-radius-small);
|
||||
min-width: 400px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
font-size: var(--vscode-chat-font-size, 13px);
|
||||
font-family: var(--vscode-chat-font-family);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--app-menu-border);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--app-primary-foreground);
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog-close:hover {
|
||||
background: var(--app-ghost-button-hover-background);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--app-primary-foreground);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--app-primary-border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font-size: var(--vscode-chat-font-size, 13px);
|
||||
font-family: var(--vscode-chat-font-family);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.form-group input.error {
|
||||
border-color: var(--vscode-inputValidation-errorBorder);
|
||||
}
|
||||
|
||||
.form-help {
|
||||
margin-top: 6px;
|
||||
font-size: 0.9em;
|
||||
color: var(--app-secondary-foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 6px;
|
||||
font-size: 0.9em;
|
||||
color: var(--vscode-inputValidation-errorForeground);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--app-menu-border);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: var(--vscode-chat-font-size, 13px);
|
||||
font-family: var(--vscode-chat-font-family);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
|
||||
.secondary-button:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
/* Save Session Header Button */
|
||||
.save-session-header-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.save-session-header-button:hover {
|
||||
background: var(--app-ghost-button-hover-background);
|
||||
}
|
||||
|
||||
.save-session-header-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--app-primary-foreground);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface SaveSessionDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (tag: string) => void;
|
||||
existingTags?: string[];
|
||||
}
|
||||
|
||||
export const SaveSessionDialog: React.FC<SaveSessionDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
existingTags = [],
|
||||
}) => {
|
||||
const [tag, setTag] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
// Focus the input when dialog opens
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when dialog closes
|
||||
setTag('');
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!tag.trim()) {
|
||||
setError('Please enter a name for this conversation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if tag already exists
|
||||
if (existingTags.includes(tag.trim())) {
|
||||
setError(
|
||||
'A conversation with this name already exists. Please choose a different name.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(tag.trim());
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay" onClick={onClose}>
|
||||
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>Save Conversation</h3>
|
||||
<button className="dialog-close" onClick={onClose} aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M12 4L4 12M4 4L12 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="dialog-body">
|
||||
<div className="form-group">
|
||||
<label htmlFor="session-tag">Conversation Name</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="session-tag"
|
||||
type="text"
|
||||
value={tag}
|
||||
onChange={(e) => {
|
||||
setTag(e.target.value);
|
||||
if (error) setError('');
|
||||
}}
|
||||
placeholder="e.g., project-planning, bug-fix, research"
|
||||
className={error ? 'error' : ''}
|
||||
/>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<div className="form-help">
|
||||
Give this conversation a meaningful name so you can find it
|
||||
later
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dialog-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="primary-button">
|
||||
Save Conversation
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
/* Session Manager Styles */
|
||||
.session-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-size: var(--vscode-chat-font-size, 13px);
|
||||
font-family: var(--vscode-chat-font-family);
|
||||
}
|
||||
|
||||
.session-manager-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--app-primary-border-color);
|
||||
}
|
||||
|
||||
.session-manager-header h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--app-primary-foreground);
|
||||
}
|
||||
|
||||
.session-manager-actions {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--app-primary-border-color);
|
||||
}
|
||||
|
||||
.session-manager-actions .secondary-button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
font-size: var(--vscode-chat-font-size, 13px);
|
||||
font-family: var(--vscode-chat-font-family);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-manager-actions .secondary-button:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
.session-search {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--app-primary-border-color);
|
||||
}
|
||||
|
||||
.session-search svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
color: var(--app-primary-foreground);
|
||||
}
|
||||
|
||||
.session-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--app-primary-foreground);
|
||||
font-size: var(--vscode-chat-font-size, 13px);
|
||||
font-family: var(--vscode-chat-font-family);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.session-search input::placeholder {
|
||||
color: var(--app-input-placeholder-foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.session-list-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
gap: 8px;
|
||||
color: var(--app-secondary-foreground);
|
||||
}
|
||||
|
||||
.session-list-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--app-secondary-foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
color: var(--app-primary-foreground);
|
||||
transition: background 0.1s ease;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
background: var(--app-list-hover-background);
|
||||
}
|
||||
|
||||
.session-item.active {
|
||||
background: var(--app-list-active-background);
|
||||
color: var(--app-list-active-foreground);
|
||||
}
|
||||
|
||||
.session-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-item-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.9em;
|
||||
color: var(--app-secondary-foreground);
|
||||
}
|
||||
|
||||
.session-item-date,
|
||||
.session-item-count {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-item-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.session-item-actions .icon-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.session-item-actions .icon-button:hover {
|
||||
background: var(--app-ghost-button-hover-background);
|
||||
}
|
||||
|
||||
.session-item-actions .icon-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--app-primary-foreground);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useVSCode } from '../hooks/useVSCode.js';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
name: string;
|
||||
lastUpdated: string;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
interface SessionManagerProps {
|
||||
currentSessionId: string | null;
|
||||
onSwitchSession: (sessionId: string) => void;
|
||||
onSaveSession: () => void;
|
||||
onResumeSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const SessionManager: React.FC<SessionManagerProps> = ({
|
||||
currentSessionId,
|
||||
onSwitchSession,
|
||||
onSaveSession,
|
||||
onResumeSession,
|
||||
}) => {
|
||||
const vscode = useVSCode();
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Load sessions when component mounts
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
const loadSessions = React.useCallback(() => {
|
||||
setIsLoading(true);
|
||||
vscode.postMessage({
|
||||
type: 'listSavedSessions',
|
||||
data: {},
|
||||
});
|
||||
}, [vscode]);
|
||||
|
||||
// Listen for session list updates
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
|
||||
if (message.type === 'savedSessionsList') {
|
||||
setIsLoading(false);
|
||||
setSessions(message.data.sessions || []);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
const filteredSessions = sessions.filter((session) =>
|
||||
session.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleSaveCurrent = () => {
|
||||
onSaveSession();
|
||||
};
|
||||
|
||||
const handleResumeSession = (sessionId: string) => {
|
||||
onResumeSession(sessionId);
|
||||
};
|
||||
|
||||
const handleSwitchSession = (sessionId: string) => {
|
||||
onSwitchSession(sessionId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="session-manager">
|
||||
<div className="session-manager-header">
|
||||
<h3>Saved Conversations</h3>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={loadSessions}
|
||||
disabled={isLoading}
|
||||
title="Refresh sessions"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M13.3333 8C13.3333 10.9455 10.9455 13.3333 8 13.3333C5.05451 13.3333 2.66663 10.9455 2.66663 8C2.66663 5.05451 5.05451 2.66663 8 2.66663"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.6666 8L13.3333 8M13.3333 8L13.3333 5.33333M13.3333 8L10.6666 10.6667"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="session-manager-actions">
|
||||
<button className="secondary-button" onClick={handleSaveCurrent}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M2.66663 2.66663H10.6666L13.3333 5.33329V13.3333H2.66663V2.66663Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 10.6666V8M8 8V5.33329M8 8H10.6666M8 8H5.33329"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Save Current
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="session-search">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M7.33329 12.6666C10.2788 12.6666 12.6666 10.2788 12.6666 7.33329C12.6666 4.38777 10.2788 2 7.33329 2C4.38777 2 2 4.38777 2 7.33329C2 10.2788 4.38777 12.6666 7.33329 12.6666Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.9999 14L11.0999 11.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="session-list">
|
||||
{isLoading ? (
|
||||
<div className="session-list-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<span>Loading conversations...</span>
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="session-list-empty">
|
||||
{searchQuery
|
||||
? 'No matching conversations'
|
||||
: 'No saved conversations yet'}
|
||||
</div>
|
||||
) : (
|
||||
filteredSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`session-item ${session.id === currentSessionId ? 'active' : ''}`}
|
||||
>
|
||||
<div className="session-item-info">
|
||||
<div className="session-item-name">{session.name}</div>
|
||||
<div className="session-item-meta">
|
||||
<span className="session-item-date">
|
||||
{new Date(session.lastUpdated).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="session-item-count">
|
||||
{session.messageCount}{' '}
|
||||
{session.messageCount === 1 ? 'message' : 'messages'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="session-item-actions">
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => handleResumeSession(session.id)}
|
||||
title="Resume this conversation"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M5.33337 4L10.6667 8L5.33337 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => handleSwitchSession(session.id)}
|
||||
title="Switch to this conversation"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M10.6666 4L13.3333 6.66667L10.6666 9.33333"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.66663 6.66667H13.3333"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user