From ce07fb2b3f0bca4de6c16e78483a769603a602eb Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 21 Nov 2025 23:51:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(session):=20=E5=AE=9E=E7=8E=B0=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E4=BF=9D=E5=AD=98=E5=92=8C=E5=8A=A0=E8=BD=BD=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 AcpConnection 和 AcpSessionManager 中添加会话保存方法 - 在 QwenAgentManager 中实现通过 ACP 和直接保存会话的功能 - 在前端添加保存会话对话框和相关交互逻辑 - 新增 QwenSessionManager 用于直接操作文件系统保存和加载会话 --- .../src/acp/acpConnection.ts | 16 ++ .../src/acp/acpSessionManager.ts | 34 +++ .../vscode-ide-companion/src/acp/schema.ts | 4 +- .../src/agents/qwenAgentManager.ts | 134 ++++++++++ .../src/services/qwenSessionManager.ts | 199 +++++++++++++++ .../src/services/qwenSessionReader.ts | 2 +- .../vscode-ide-companion/src/webview/App.tsx | 190 ++++++++++----- .../src/webview/ClaudeCodeStyles.css | 13 + .../src/webview/MessageHandler.ts | 134 ++++++++++ .../webview/components/SaveSessionDialog.css | 167 +++++++++++++ .../webview/components/SaveSessionDialog.tsx | 124 ++++++++++ .../src/webview/components/SessionManager.css | 193 +++++++++++++++ .../src/webview/components/SessionManager.tsx | 228 ++++++++++++++++++ 13 files changed, 1379 insertions(+), 59 deletions(-) create mode 100644 packages/vscode-ide-companion/src/services/qwenSessionManager.ts create mode 100644 packages/vscode-ide-companion/src/webview/components/SaveSessionDialog.css create mode 100644 packages/vscode-ide-companion/src/webview/components/SaveSessionDialog.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/SessionManager.css create mode 100644 packages/vscode-ide-companion/src/webview/components/SessionManager.tsx diff --git a/packages/vscode-ide-companion/src/acp/acpConnection.ts b/packages/vscode-ide-companion/src/acp/acpConnection.ts index 204ccf7a..c8035cd8 100644 --- a/packages/vscode-ide-companion/src/acp/acpConnection.ts +++ b/packages/vscode-ide-companion/src/acp/acpConnection.ts @@ -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 { + return this.sessionManager.saveSession( + tag, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + /** * 断开连接 */ diff --git a/packages/vscode-ide-companion/src/acp/acpSessionManager.ts b/packages/vscode-ide-companion/src/acp/acpSessionManager.ts index f3ed296d..9e9b92c5 100644 --- a/packages/vscode-ide-companion/src/acp/acpSessionManager.ts +++ b/packages/vscode-ide-companion/src/acp/acpSessionManager.ts @@ -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>, + nextRequestId: { value: number }, + ): Promise { + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + + console.log('[ACP] Saving session with tag:', tag); + const response = await this.sendRequest( + AGENT_METHODS.session_save, + { + sessionId: this.sessionId, + tag, + }, + child, + pendingRequests, + nextRequestId, + ); + console.log('[ACP] Session save response:', response); + return response; + } + /** * 重置会话管理器状态 */ diff --git a/packages/vscode-ide-companion/src/acp/schema.ts b/packages/vscode-ide-companion/src/acp/schema.ts index 6b18d02b..c3b92cb1 100644 --- a/packages/vscode-ide-companion/src/acp/schema.ts +++ b/packages/vscode-ide-companion/src/acp/schema.ts @@ -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; /** diff --git a/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts b/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts index 9c6b9cf1..c852e2d3 100644 --- a/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts @@ -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 { + 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; + } + } + /** * 创建新会话 * diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts new file mode 100644 index 00000000..746adde3 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index b9c7e84e..ba8083e2 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -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'; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 17366e9e..506a6f16 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -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(null); const [isComposing, setIsComposing] = useState(false); + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [savedSessionTags, setSavedSessionTags] = useState([]); // 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 = () => {
+ + + +
+
+
+ + { + setTag(e.target.value); + if (error) setError(''); + }} + placeholder="e.g., project-planning, bug-fix, research" + className={error ? 'error' : ''} + /> + {error &&
{error}
} +
+ Give this conversation a meaningful name so you can find it + later +
+
+
+ +
+ + +
+
+ + + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/SessionManager.css b/packages/vscode-ide-companion/src/webview/components/SessionManager.css new file mode 100644 index 00000000..0cbf9ea2 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/SessionManager.css @@ -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); } +} \ No newline at end of file diff --git a/packages/vscode-ide-companion/src/webview/components/SessionManager.tsx b/packages/vscode-ide-companion/src/webview/components/SessionManager.tsx new file mode 100644 index 00000000..dcb790fd --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/SessionManager.tsx @@ -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 = ({ + currentSessionId, + onSwitchSession, + onSaveSession, + onResumeSession, +}) => { + const vscode = useVSCode(); + const [sessions, setSessions] = useState([]); + 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 ( +
+
+

Saved Conversations

+ +
+ +
+ +
+ +
+ + + + + setSearchQuery(e.target.value)} + /> +
+ +
+ {isLoading ? ( +
+
+ Loading conversations... +
+ ) : filteredSessions.length === 0 ? ( +
+ {searchQuery + ? 'No matching conversations' + : 'No saved conversations yet'} +
+ ) : ( + filteredSessions.map((session) => ( +
+
+
{session.name}
+
+ + {new Date(session.lastUpdated).toLocaleDateString()} + + + {session.messageCount}{' '} + {session.messageCount === 1 ? 'message' : 'messages'} + +
+
+
+ + +
+
+ )) + )} +
+
+ ); +};