feat(session): 实现会话保存和加载功能

- 在 AcpConnection 和 AcpSessionManager 中添加会话保存方法
- 在 QwenAgentManager 中实现通过 ACP 和直接保存会话的功能
- 在前端添加保存会话对话框和相关交互逻辑
- 新增 QwenSessionManager 用于直接操作文件系统保存和加载会话
This commit is contained in:
yiliang114
2025-11-21 23:51:48 +08:00
parent e2beecb9c4
commit ce07fb2b3f
13 changed files with 1379 additions and 59 deletions

View File

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

View File

@@ -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)
=========================== */

View File

@@ -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}` },
});
}
}
}

View File

@@ -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);
}

View File

@@ -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>
);
};

View File

@@ -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); }
}

View File

@@ -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>
);
};