wip(vscode-ide-companion): 实现 quick win 功能

- 将 WebView 调整到编辑器右侧
- 添加 ChatHeader 组件,实现会话下拉菜单
- 替换模态框为紧凑型下拉菜单
- 更新会话切换逻辑,显示当前标题
- 清理旧的会话选择器样式
基于 Claude Code v2.0.43 UI 分析实现。
This commit is contained in:
yiliang114
2025-11-19 00:16:45 +08:00
parent 729a3d0ab3
commit 732220e651
52 changed files with 16502 additions and 1420 deletions

View File

@@ -13,6 +13,7 @@ import {
type ToolCall as PermissionToolCall,
} from './components/PermissionRequest.js';
import { ToolCall, type ToolCallData } from './components/ToolCall.js';
import { EmptyState } from './components/EmptyState.js';
interface ToolCallUpdate {
type: 'tool_call' | 'tool_call_update';
@@ -54,6 +55,7 @@ export const App: React.FC = () => {
const [qwenSessions, setQwenSessions] = useState<
Array<Record<string, unknown>>
>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionSelector, setShowSessionSelector] = useState(false);
const [permissionRequest, setPermissionRequest] = useState<{
options: PermissionOption[];
@@ -63,6 +65,7 @@ export const App: React.FC = () => {
new Map(),
);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputFieldRef = useRef<HTMLDivElement>(null);
const handlePermissionRequest = React.useCallback(
(request: {
@@ -201,12 +204,26 @@ export const App: React.FC = () => {
handleToolCallUpdate(message.data);
break;
case 'qwenSessionList':
setQwenSessions(message.data.sessions || []);
case 'qwenSessionList': {
const sessions = message.data.sessions || [];
setQwenSessions(sessions);
// If no current session is selected and there are sessions, select the first one
if (!currentSessionId && sessions.length > 0) {
const firstSessionId =
(sessions[0].id as string) || (sessions[0].sessionId as string);
if (firstSessionId) {
setCurrentSessionId(firstSessionId);
}
}
break;
}
case 'qwenSessionSwitched':
setShowSessionSelector(false);
// Update current session ID
if (message.data.sessionId) {
setCurrentSessionId(message.data.sessionId as string);
}
// Load messages from the session
if (message.data.messages) {
setMessages(message.data.messages);
@@ -230,13 +247,23 @@ export const App: React.FC = () => {
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [currentStreamContent, handlePermissionRequest, handleToolCallUpdate]);
}, [
currentStreamContent,
currentSessionId,
handlePermissionRequest,
handleToolCallUpdate,
]);
useEffect(() => {
// Auto-scroll to bottom when messages change
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, currentStreamContent]);
// Load sessions on component mount
useEffect(() => {
vscode.postMessage({ type: 'getQwenSessions', data: {} });
}, [vscode]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -251,7 +278,11 @@ export const App: React.FC = () => {
data: { text: inputText },
});
// Clear input field
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
}
};
const handleLoadQwenSessions = () => {
@@ -262,25 +293,39 @@ export const App: React.FC = () => {
const handleNewQwenSession = () => {
vscode.postMessage({ type: 'newQwenSession', data: {} });
setShowSessionSelector(false);
setCurrentSessionId(null);
// Clear messages in UI
setMessages([]);
setCurrentStreamContent('');
};
const handleSwitchSession = (sessionId: string) => {
if (sessionId === currentSessionId) {
return;
}
vscode.postMessage({
type: 'switchQwenSession',
data: { sessionId },
});
setCurrentSessionId(sessionId);
setShowSessionSelector(false);
};
// Check if there are any messages or active content
const hasContent =
messages.length > 0 ||
isStreaming ||
toolCalls.size > 0 ||
permissionRequest !== null;
return (
<div className="chat-container">
{showSessionSelector && (
<div className="session-selector-overlay">
<div className="session-selector">
<div className="session-selector-header">
<h3>Qwen Sessions</h3>
<h3>Past Conversations</h3>
<button onClick={() => setShowSessionSelector(false)}></button>
</div>
<div className="session-selector-actions">
@@ -338,62 +383,196 @@ export const App: React.FC = () => {
)}
<div className="chat-header">
<button className="session-button" onClick={handleLoadQwenSessions}>
📋 Sessions
<button
className="header-conversations-button"
onClick={handleLoadQwenSessions}
title="Past conversations"
>
<span className="button-content">
<span className="button-text">Past Conversations</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className="dropdown-icon"
>
<path
fillRule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clipRule="evenodd"
></path>
</svg>
</span>
</button>
<div className="header-spacer"></div>
<button
className="new-session-header-button"
onClick={handleNewQwenSession}
title="New Session"
>
<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 d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"></path>
</svg>
</button>
</div>
<div className="messages-container">
{messages.map((msg, index) => (
<div key={index} className={`message ${msg.role}`}>
<div className="message-content">{msg.content}</div>
<div className="message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
))}
{!hasContent ? (
<EmptyState />
) : (
<>
{messages.map((msg, index) => (
<div key={index} className={`message ${msg.role}`}>
<div className="message-content">{msg.content}</div>
<div className="message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
))}
{/* Tool Calls */}
{Array.from(toolCalls.values()).map((toolCall) => (
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
))}
{/* Tool Calls */}
{Array.from(toolCalls.values()).map((toolCall) => (
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
))}
{/* Permission Request */}
{permissionRequest && (
<PermissionRequest
options={permissionRequest.options}
toolCall={permissionRequest.toolCall}
onResponse={handlePermissionResponse}
/>
{/* Permission Request */}
{permissionRequest && (
<PermissionRequest
options={permissionRequest.options}
toolCall={permissionRequest.toolCall}
onResponse={handlePermissionResponse}
/>
)}
{isStreaming && currentStreamContent && (
<div className="message assistant streaming">
<div className="message-content">{currentStreamContent}</div>
<div className="streaming-indicator"></div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
{isStreaming && currentStreamContent && (
<div className="message assistant streaming">
<div className="message-content">{currentStreamContent}</div>
<div className="streaming-indicator"></div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form className="input-form" onSubmit={handleSubmit}>
<input
type="text"
className="input-field"
placeholder="Type your message..."
value={inputText}
onChange={(e) => setInputText((e.target as HTMLInputElement).value)}
disabled={isStreaming}
/>
<button
type="submit"
className="send-button"
disabled={isStreaming || !inputText.trim()}
>
Send
</button>
</form>
<div className="input-form-container">
<div className="input-form-wrapper">
<form className="input-form" onSubmit={handleSubmit}>
<div className="input-banner"></div>
<div className="input-wrapper">
<div
ref={inputFieldRef}
contentEditable="plaintext-only"
className="input-field-editable"
role="textbox"
aria-label="Message input"
aria-multiline="true"
data-placeholder="Ask Claude to edit…"
onInput={(e) => {
const target = e.target as HTMLDivElement;
setInputText(target.textContent || '');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
suppressContentEditableWarning
/>
</div>
<div className="input-actions">
<button
type="button"
className="action-button edit-mode-button"
title="Claude will ask before each edit. Click to switch modes."
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
clipRule="evenodd"
></path>
</svg>
<span>Ask before edits</span>
</button>
<div className="action-divider"></div>
<button
type="button"
className="action-icon-button thinking-button"
title="Thinking off"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.00293 1.11523L8.35059 1.12402H8.35352C11.9915 1.30834 14.8848 4.31624 14.8848 8C14.8848 11.8025 11.8025 14.8848 8 14.8848C4.19752 14.8848 1.11523 11.8025 1.11523 8C1.11523 7.67691 1.37711 7.41504 1.7002 7.41504C2.02319 7.41514 2.28516 7.67698 2.28516 8C2.28516 11.1563 4.84369 13.7148 8 13.7148C11.1563 13.7148 13.7148 11.1563 13.7148 8C13.7148 4.94263 11.3141 2.4464 8.29492 2.29297V2.29199L7.99609 2.28516H7.9873V2.28418L7.89648 2.27539L7.88281 2.27441V2.27344C7.61596 2.21897 7.41513 1.98293 7.41504 1.7002C7.41504 1.37711 7.67691 1.11523 8 1.11523H8.00293ZM8 3.81543C8.32309 3.81543 8.58496 4.0773 8.58496 4.40039V7.6377L10.9619 8.82715C11.2505 8.97169 11.3678 9.32256 11.2236 9.61133C11.0972 9.86425 10.8117 9.98544 10.5488 9.91504L10.5352 9.91211V9.91016L10.4502 9.87891L10.4385 9.87402V9.87305L7.73828 8.52344C7.54007 8.42433 7.41504 8.22155 7.41504 8V4.40039C7.41504 4.0773 7.67691 3.81543 8 3.81543ZM2.44336 5.12695C2.77573 5.19517 3.02597 5.48929 3.02637 5.8418C3.02637 6.19456 2.7761 6.49022 2.44336 6.55859L2.2959 6.57324C1.89241 6.57324 1.56543 6.24529 1.56543 5.8418C1.56588 5.43853 1.89284 5.1123 2.2959 5.1123L2.44336 5.12695ZM3.46094 2.72949C3.86418 2.72984 4.19017 3.05712 4.19043 3.45996V3.46094C4.19009 3.86393 3.86392 4.19008 3.46094 4.19043H3.45996C3.05712 4.19017 2.72983 3.86419 2.72949 3.46094V3.45996C2.72976 3.05686 3.05686 2.72976 3.45996 2.72949H3.46094ZM5.98926 1.58008C6.32235 1.64818 6.57324 1.94276 6.57324 2.2959L6.55859 2.44336C6.49022 2.7761 6.19456 3.02637 5.8418 3.02637C5.43884 3.02591 5.11251 2.69895 5.1123 2.2959L5.12695 2.14844C5.19504 1.81591 5.48906 1.56583 5.8418 1.56543L5.98926 1.58008Z"
strokeWidth="0.27"
style={{
stroke: 'var(--app-secondary-foreground)',
fill: 'var(--app-secondary-foreground)',
}}
></path>
</svg>
</button>
<button
type="button"
className="action-icon-button command-button"
title="Show command menu (/)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12.528 3.047a.75.75 0 0 1 .449.961L8.433 16.504a.75.75 0 1 1-1.41-.512l4.544-12.496a.75.75 0 0 1 .961-.449Z"
clipRule="evenodd"
></path>
</svg>
</button>
<button
type="submit"
className="send-button-icon"
disabled={isStreaming || !inputText.trim()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
</form>
</div>
</div>
</div>
);
};