Files
claude-task-master/apps/extension/src/services/webview-manager.ts
losolosol 83af314879 feat: added vscode start task button (#1201)
Co-authored-by: Carlos Montoya <carlos@Carloss-MacBook-Pro.local>
Co-authored-by: Carlos Montoya <los@losmontoya.com>
2025-09-12 05:35:57 +02:00

444 lines
12 KiB
TypeScript

/**
* Webview Manager - Simplified
* Manages webview panels and message handling
*/
import * as vscode from 'vscode';
import type { EventEmitter } from '../utils/event-emitter';
import type { ExtensionLogger } from '../utils/logger';
import type { ConfigService } from './config-service';
import type { TaskRepository } from './task-repository';
export class WebviewManager {
private panels = new Set<vscode.WebviewPanel>();
private configService?: ConfigService;
private mcpClient?: any;
private api?: any;
constructor(
private context: vscode.ExtensionContext,
private repository: TaskRepository,
private events: EventEmitter,
private logger: ExtensionLogger
) {}
setConfigService(configService: ConfigService): void {
this.configService = configService;
}
setMCPClient(mcpClient: any): void {
this.mcpClient = mcpClient;
}
setApi(api: any): void {
this.api = api;
}
async createOrShowPanel(): Promise<void> {
// Find existing panel
const existing = Array.from(this.panels).find(
(p) => p.title === 'TaskMaster Kanban'
);
if (existing) {
existing.reveal();
return;
}
// Create new panel
const panel = vscode.window.createWebviewPanel(
'taskrKanban',
'TaskMaster Kanban',
vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [
vscode.Uri.joinPath(this.context.extensionUri, 'dist')
]
}
);
// Set the icon for the webview tab
panel.iconPath = {
light: vscode.Uri.joinPath(
this.context.extensionUri,
'assets',
'icon-light.svg'
),
dark: vscode.Uri.joinPath(
this.context.extensionUri,
'assets',
'icon-dark.svg'
)
};
this.panels.add(panel);
panel.webview.html = this.getWebviewContent(panel.webview);
// Handle messages
panel.webview.onDidReceiveMessage(async (message) => {
await this.handleMessage(panel, message);
});
// Handle disposal
panel.onDidDispose(() => {
this.panels.delete(panel);
this.events.emit('webview:closed');
});
this.events.emit('webview:opened');
vscode.window.showInformationMessage('TaskMaster Kanban opened!');
}
broadcast(type: string, data: any): void {
this.panels.forEach((panel) => {
panel.webview.postMessage({ type, data });
});
}
getPanelCount(): number {
return this.panels.size;
}
dispose(): void {
this.panels.forEach((panel) => panel.dispose());
this.panels.clear();
}
private async handleMessage(
panel: vscode.WebviewPanel,
message: any
): Promise<void> {
// Validate message structure
if (!message || typeof message !== 'object') {
this.logger.error('Invalid message received:', message);
return;
}
const { type, data, requestId } = message;
this.logger.debug(`Webview message: ${type}`, message);
try {
let response: any;
switch (type) {
case 'ready':
// Webview is ready, send current connection status
const isConnected = this.mcpClient?.getStatus()?.isRunning || false;
panel.webview.postMessage({
type: 'connectionStatus',
data: {
isConnected: isConnected,
status: isConnected ? 'Connected' : 'Disconnected'
}
});
// No response needed for ready message
return;
case 'getTasks':
// Pass options to getAll including tag if specified
response = await this.repository.getAll({
tag: data?.tag,
withSubtasks: data?.withSubtasks ?? true
});
break;
case 'updateTaskStatus':
await this.repository.updateStatus(data.taskId, data.newStatus);
response = { success: true };
break;
case 'getConfig':
if (this.configService) {
response = await this.configService.getSafeConfig();
} else {
response = null;
}
break;
case 'readTaskFileData':
// For now, return the task data from repository
// In the future, this could read from actual task files
const task = await this.repository.getById(data.taskId);
if (task) {
response = {
details: task.details || '',
testStrategy: task.testStrategy || ''
};
} else {
response = {
details: '',
testStrategy: ''
};
}
break;
case 'updateTask':
// Handle task content updates with MCP
if (this.mcpClient) {
try {
const { taskId, updates, options = {} } = data;
// Use the update_task MCP tool
await this.mcpClient.callTool('update_task', {
id: String(taskId),
prompt: updates.description || '',
append: options.append || false,
research: options.research || false,
projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
});
response = { success: true };
} catch (error) {
this.logger.error('Failed to update task via MCP:', error);
throw error;
}
} else {
throw new Error('MCP client not initialized');
}
break;
case 'updateSubtask':
// Handle subtask content updates with MCP
if (this.mcpClient) {
try {
const { taskId, prompt, options = {} } = data;
// Use the update_subtask MCP tool
await this.mcpClient.callTool('update_subtask', {
id: String(taskId),
prompt: prompt,
research: options.research || false,
projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
});
response = { success: true };
} catch (error) {
this.logger.error('Failed to update subtask via MCP:', error);
throw error;
}
} else {
throw new Error('MCP client not initialized');
}
break;
case 'getComplexity':
// For backward compatibility - redirect to mcpRequest
this.logger.debug(
`getComplexity request for task ${data.taskId}, mcpClient available: ${!!this.mcpClient}`
);
if (this.mcpClient && data.taskId) {
try {
const complexityResult = await this.mcpClient.callTool(
'complexity_report',
{
projectRoot:
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
}
);
if (complexityResult?.report?.complexityAnalysis?.tasks) {
const task =
complexityResult.report.complexityAnalysis.tasks.find(
(t: any) => t.id === data.taskId
);
response = task ? { score: task.complexityScore } : {};
} else {
response = {};
}
} catch (error) {
this.logger.error('Failed to get complexity', error);
response = {};
}
} else {
this.logger.warn(
`Cannot get complexity: mcpClient=${!!this.mcpClient}, taskId=${data.taskId}`
);
response = {};
}
break;
case 'mcpRequest':
// Handle MCP tool calls
try {
// The tool and params come directly in the message
const tool = message.tool;
const params = message.params || {};
if (!this.mcpClient) {
throw new Error('MCP client not initialized');
}
if (!tool) {
throw new Error('Tool name not specified in mcpRequest');
}
// Add projectRoot if not provided
if (!params.projectRoot) {
params.projectRoot =
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
}
const result = await this.mcpClient.callTool(tool, params);
response = { data: result };
} catch (error) {
this.logger.error('MCP request failed:', error);
// Re-throw with cleaner error message
throw new Error(
error instanceof Error ? error.message : 'Unknown error'
);
}
break;
case 'getTags':
// Get available tags
if (this.mcpClient) {
try {
const result = await this.mcpClient.callTool('list_tags', {
projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath,
showMetadata: false
});
// The MCP response has a specific structure
// Based on the MCP SDK, the response is in result.content[0].text
let parsedData;
if (
result?.content &&
Array.isArray(result.content) &&
result.content[0]?.text
) {
try {
parsedData = JSON.parse(result.content[0].text);
} catch (e) {
this.logger.error('Failed to parse MCP response text:', e);
}
}
// Extract tags data from the parsed response
if (parsedData?.data) {
response = parsedData.data;
} else if (parsedData) {
response = parsedData;
} else if (result?.data) {
response = result.data;
} else {
response = { tags: [], currentTag: 'master' };
}
} catch (error) {
this.logger.error('Failed to get tags:', error);
response = { tags: [], currentTag: 'master' };
}
} else {
response = { tags: [], currentTag: 'master' };
}
break;
case 'switchTag':
// Switch to a different tag
if (this.mcpClient && data.tagName) {
try {
await this.mcpClient.callTool('use_tag', {
name: data.tagName,
projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
});
// Clear cache and fetch tasks for the new tag
await this.repository.refresh();
const tasks = await this.repository.getAll({ tag: data.tagName });
this.broadcast('tasksUpdated', { tasks, source: 'tag-switch' });
response = { success: true };
} catch (error) {
this.logger.error('Failed to switch tag:', error);
throw error;
}
} else {
throw new Error('Tag name not provided');
}
break;
case 'openExternal':
// Open external URL
if (message.url) {
vscode.env.openExternal(vscode.Uri.parse(message.url));
}
return;
case 'openTerminal':
// Open VS Code terminal for task execution
this.logger.info(`Opening terminal for task ${data.taskId}: ${data.taskTitle}`);
try {
const terminal = vscode.window.createTerminal({
name: `Task ${data.taskId}: ${data.taskTitle}`,
cwd: this.workspaceRoot
});
terminal.show();
this.logger.info('Terminal created and shown successfully');
response = { success: true };
} catch (error) {
this.logger.error('Failed to create terminal:', error);
response = { success: false, error: error.message };
}
break;
default:
throw new Error(`Unknown message type: ${type}`);
}
// Send response
if (requestId) {
panel.webview.postMessage({
type: 'response',
requestId,
success: true,
data: response
});
}
} catch (error) {
this.logger.error(`Error handling message ${type}`, error);
if (requestId) {
panel.webview.postMessage({
type: 'error',
requestId,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
}
private getWebviewContent(webview: vscode.Webview): string {
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'index.js')
);
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'index.css')
);
const nonce = this.getNonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}'; style-src ${webview.cspSource} 'unsafe-inline';">
<link href="${styleUri}" rel="stylesheet">
<title>TaskMaster Kanban</title>
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
private getNonce(): string {
let text = '';
const possible =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
}