Skip to content

Commit 6e78932

Browse files
committed
feat(mpp-vscode): add React Webview UI for chat interface
Phase 6 - React Webview UI: - Create webview React project with Vite build - Add MessageList component with Markdown rendering - Add ChatInput component with auto-resize textarea - Add useVSCode hook for extension communication - Integrate VSCode theme variables for consistent styling - Support streaming response with animated indicators - Add fallback inline HTML when React bundle not available - Update ChatViewProvider to load React bundle with CSP Components: - App.tsx - Main chat application - MessageList.tsx - Message display with Markdown support - ChatInput.tsx - Input with keyboard shortcuts - useVSCode.ts - VSCode API communication hook Testing: - Add ChatViewProvider tests - All 63 tests passing Refs #31
1 parent 7d6a9ce commit 6e78932

File tree

17 files changed

+1115
-117
lines changed

17 files changed

+1115
-117
lines changed

mpp-vscode/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,13 @@ mpp-vscode/
8787
- [x] DevIns 语言支持
8888
- [x] 语法高亮 (TextMate grammar)
8989
- [x] 自动补全 (/, @, $ 触发)
90+
- [x] React Webview UI
91+
- [x] React + Vite 构建
92+
- [x] Markdown 渲染 (react-markdown + remark-gfm)
93+
- [x] VSCode 主题集成
94+
- [x] 流式响应动画
9095
- [ ] 代码索引集成
9196
- [ ] 领域词典支持
92-
- [ ] React Webview UI (替换内嵌 HTML)
9397

9498
## 参考项目
9599

mpp-vscode/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,10 @@
124124
},
125125
"scripts": {
126126
"vscode:prepublish": "npm run build",
127-
"build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node",
128-
"watch": "npm run build -- --watch",
127+
"build": "npm run build:extension && npm run build:webview",
128+
"build:extension": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node",
129+
"build:webview": "cd webview && npm install && npm run build",
130+
"watch": "npm run build:extension -- --watch",
129131
"package": "vsce package",
130132
"lint": "eslint src --ext ts",
131133
"test": "vitest run",

mpp-vscode/src/providers/chat-view.ts

Lines changed: 85 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import * as vscode from 'vscode';
6+
import * as path from 'path';
67
import { LLMService, ModelConfig } from '../bridge/mpp-core';
78

89
/**
@@ -27,10 +28,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
2728

2829
webviewView.webview.options = {
2930
enableScripts: true,
30-
localResourceRoots: [this.context.extensionUri]
31+
localResourceRoots: [
32+
vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'webview')
33+
]
3134
};
3235

33-
webviewView.webview.html = this.getHtmlContent();
36+
webviewView.webview.html = this.getHtmlContent(webviewView.webview);
3437

3538
// Handle messages from webview
3639
webviewView.webview.onDidReceiveMessage(async (message) => {
@@ -133,131 +136,99 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
133136
this.postMessage({ type: 'historyCleared' });
134137
}
135138

136-
private getHtmlContent(): string {
139+
private getHtmlContent(webview: vscode.Webview): string {
140+
// Check if React build exists
141+
const webviewPath = vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'webview');
142+
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'assets', 'index.js'));
143+
const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'assets', 'index.css'));
144+
145+
// Use nonce for security
146+
const nonce = this.getNonce();
147+
148+
// Try to use React build, fallback to inline HTML
137149
return `<!DOCTYPE html>
138150
<html lang="en">
139151
<head>
140152
<meta charset="UTF-8">
141153
<meta name="viewport" content="width=device-width, initial-scale=1.0">
154+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
155+
<link rel="stylesheet" href="${styleUri}">
142156
<title>AutoDev Chat</title>
143-
<style>
144-
* { box-sizing: border-box; margin: 0; padding: 0; }
145-
body {
146-
font-family: var(--vscode-font-family);
147-
font-size: var(--vscode-font-size);
148-
color: var(--vscode-foreground);
149-
background: var(--vscode-editor-background);
150-
height: 100vh;
151-
display: flex;
152-
flex-direction: column;
153-
}
154-
#messages {
155-
flex: 1;
156-
overflow-y: auto;
157-
padding: 12px;
158-
}
159-
.message {
160-
margin-bottom: 12px;
161-
padding: 8px 12px;
162-
border-radius: 6px;
163-
max-width: 90%;
164-
}
165-
.user { background: var(--vscode-button-background); margin-left: auto; }
166-
.assistant { background: var(--vscode-editor-inactiveSelectionBackground); }
167-
.error { background: var(--vscode-inputValidation-errorBackground); color: var(--vscode-inputValidation-errorForeground); }
168-
#input-container {
169-
padding: 12px;
170-
border-top: 1px solid var(--vscode-panel-border);
171-
display: flex;
172-
gap: 8px;
173-
}
174-
#input {
175-
flex: 1;
176-
padding: 8px;
177-
border: 1px solid var(--vscode-input-border);
178-
background: var(--vscode-input-background);
179-
color: var(--vscode-input-foreground);
180-
border-radius: 4px;
181-
resize: none;
182-
}
183-
button {
184-
padding: 8px 16px;
185-
background: var(--vscode-button-background);
186-
color: var(--vscode-button-foreground);
187-
border: none;
188-
border-radius: 4px;
189-
cursor: pointer;
190-
}
191-
button:hover { background: var(--vscode-button-hoverBackground); }
192-
.typing { opacity: 0.7; font-style: italic; }
193-
</style>
194157
</head>
195158
<body>
196-
<div id="messages"></div>
197-
<div id="input-container">
198-
<textarea id="input" rows="2" placeholder="Ask AutoDev..."></textarea>
199-
<button id="send">Send</button>
200-
</div>
201-
<script>
202-
const vscode = acquireVsCodeApi();
203-
const messagesEl = document.getElementById('messages');
204-
const inputEl = document.getElementById('input');
205-
const sendBtn = document.getElementById('send');
206-
let currentResponse = null;
207-
208-
function addMessage(role, content) {
209-
const div = document.createElement('div');
210-
div.className = 'message ' + role;
211-
div.textContent = content;
212-
messagesEl.appendChild(div);
213-
messagesEl.scrollTop = messagesEl.scrollHeight;
214-
return div;
215-
}
216-
217-
sendBtn.onclick = () => {
218-
const content = inputEl.value.trim();
219-
if (content) {
220-
vscode.postMessage({ type: 'sendMessage', content });
221-
inputEl.value = '';
222-
}
223-
};
224-
225-
inputEl.onkeydown = (e) => {
226-
if (e.key === 'Enter' && !e.shiftKey) {
227-
e.preventDefault();
228-
sendBtn.click();
159+
<div id="root"></div>
160+
<script nonce="${nonce}" src="${scriptUri}"></script>
161+
<script nonce="${nonce}">
162+
// Fallback if React bundle not loaded
163+
if (!document.getElementById('root').hasChildNodes()) {
164+
document.getElementById('root').innerHTML = \`
165+
<div style="height: 100vh; display: flex; flex-direction: column; font-family: var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editor-background);">
166+
<div id="messages" style="flex: 1; overflow-y: auto; padding: 12px;"></div>
167+
<div style="padding: 12px; border-top: 1px solid var(--vscode-panel-border); display: flex; gap: 8px;">
168+
<textarea id="input" rows="2" placeholder="Ask AutoDev..." style="flex: 1; padding: 8px; border: 1px solid var(--vscode-input-border); background: var(--vscode-input-background); color: var(--vscode-input-foreground); border-radius: 4px; resize: none;"></textarea>
169+
<button id="send" style="padding: 8px 16px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; border-radius: 4px; cursor: pointer;">Send</button>
170+
</div>
171+
</div>
172+
\`;
173+
174+
const vscode = acquireVsCodeApi();
175+
const messagesEl = document.getElementById('messages');
176+
const inputEl = document.getElementById('input');
177+
const sendBtn = document.getElementById('send');
178+
let currentResponse = null;
179+
180+
function addMessage(role, content) {
181+
const div = document.createElement('div');
182+
div.className = 'message ' + role;
183+
div.style.cssText = 'margin-bottom: 12px; padding: 8px 12px; border-radius: 6px; max-width: 90%;';
184+
if (role === 'user') div.style.cssText += 'background: var(--vscode-button-background); margin-left: auto;';
185+
else if (role === 'assistant') div.style.cssText += 'background: var(--vscode-editor-inactiveSelectionBackground);';
186+
else div.style.cssText += 'background: var(--vscode-inputValidation-errorBackground); color: var(--vscode-inputValidation-errorForeground);';
187+
div.textContent = content;
188+
messagesEl.appendChild(div);
189+
messagesEl.scrollTop = messagesEl.scrollHeight;
190+
return div;
229191
}
230-
};
231192
232-
window.addEventListener('message', (e) => {
233-
const msg = e.data;
234-
switch (msg.type) {
235-
case 'userMessage':
236-
addMessage('user', msg.content);
237-
break;
238-
case 'startResponse':
239-
currentResponse = addMessage('assistant', '');
240-
currentResponse.classList.add('typing');
241-
break;
242-
case 'responseChunk':
243-
if (currentResponse) currentResponse.textContent += msg.content;
244-
messagesEl.scrollTop = messagesEl.scrollHeight;
245-
break;
246-
case 'endResponse':
247-
if (currentResponse) currentResponse.classList.remove('typing');
248-
currentResponse = null;
249-
break;
250-
case 'error':
251-
addMessage('error', msg.content);
252-
break;
253-
case 'historyCleared':
254-
messagesEl.innerHTML = '';
255-
break;
256-
}
257-
});
193+
sendBtn.onclick = () => {
194+
const content = inputEl.value.trim();
195+
if (content) {
196+
vscode.postMessage({ type: 'sendMessage', content });
197+
inputEl.value = '';
198+
}
199+
};
200+
201+
inputEl.onkeydown = (e) => {
202+
if (e.key === 'Enter' && !e.shiftKey) {
203+
e.preventDefault();
204+
sendBtn.click();
205+
}
206+
};
207+
208+
window.addEventListener('message', (e) => {
209+
const msg = e.data;
210+
switch (msg.type) {
211+
case 'userMessage': addMessage('user', msg.content); break;
212+
case 'startResponse': currentResponse = addMessage('assistant', ''); break;
213+
case 'responseChunk': if (currentResponse) { currentResponse.textContent += msg.content; messagesEl.scrollTop = messagesEl.scrollHeight; } break;
214+
case 'endResponse': currentResponse = null; break;
215+
case 'error': addMessage('error', msg.content); break;
216+
case 'historyCleared': messagesEl.innerHTML = ''; break;
217+
}
218+
});
219+
}
258220
</script>
259221
</body>
260222
</html>`;
261223
}
224+
225+
private getNonce(): string {
226+
let text = '';
227+
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
228+
for (let i = 0; i < 32; i++) {
229+
text += possible.charAt(Math.floor(Math.random() * possible.length));
230+
}
231+
return text;
232+
}
262233
}
263234

0 commit comments

Comments
 (0)