From fe8e08d6eb867f0217cde1df49188ec6a74a788f Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:38:49 -0700 Subject: [PATCH 01/64] Prototyping --- package.json | 6 + package.nls.json | 1 + src/commands.ts | 13 + src/github/dashboardWebviewProvider.ts | 344 +++++++++++++++++++++++++ webpack.config.js | 1 + webviews/dashboardView/app.tsx | 284 ++++++++++++++++++++ webviews/dashboardView/index.css | 229 ++++++++++++++++ webviews/dashboardView/index.ts | 9 + 8 files changed, 887 insertions(+) create mode 100644 src/github/dashboardWebviewProvider.ts create mode 100644 webviews/dashboardView/app.tsx create mode 100644 webviews/dashboardView/index.css create mode 100644 webviews/dashboardView/index.ts diff --git a/package.json b/package.json index 93d145b4ce..28d40008f4 100644 --- a/package.json +++ b/package.json @@ -871,6 +871,12 @@ ] }, "commands": [ + { + "command": "pr.openDashboard", + "title": "%command.pr.openDashboard.title%", + "category": "%command.pull.request.category%", + "icon": "$(dashboard)" + }, { "command": "githubpr.remoteAgent", "title": "%command.githubpr.remoteAgent.title%", diff --git a/package.nls.json b/package.nls.json index 12089d7662..f5a1c8319e 100644 --- a/package.nls.json +++ b/package.nls.json @@ -175,6 +175,7 @@ "view.github.active.pull.request.name": "Review Pull Request", "view.github.active.pull.request.welcome.name": "Active Pull Request", "command.pull.request.category": "GitHub Pull Requests", + "command.pr.openDashboard.title": "Open Dashboard", "command.githubpr.remoteAgent.title": "Remote agent integration", "command.pr.create.title": "Create Pull Request", "command.pr.pick.title": "Checkout Pull Request", diff --git a/src/commands.ts b/src/commands.ts index 46e716d87a..4fcb989921 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1129,6 +1129,19 @@ export function registerCommands( } )); + context.subscriptions.push( + vscode.commands.registerCommand('pr.openDashboard', async () => { + /* __GDPR__ + "pr.openDashboard" : {} + */ + telemetry.sendTelemetryEvent('pr.openDashboard'); + + // Import here to avoid circular dependencies + const { DashboardWebviewProvider } = await import('./github/dashboardWebviewProvider'); + await DashboardWebviewProvider.createOrShow(context, reposManager, copilotRemoteAgentManager, telemetry, context.extensionUri); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.openDescriptionToTheSide', async (descriptionNode: RepositoryChangesNode) => { const folderManager = reposManager.getManagerForIssueModel(descriptionNode.pullRequestModel); diff --git a/src/github/dashboardWebviewProvider.ts b/src/github/dashboardWebviewProvider.ts new file mode 100644 index 0000000000..c2a41cb96e --- /dev/null +++ b/src/github/dashboardWebviewProvider.ts @@ -0,0 +1,344 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; +import { getNonce, IRequestMessage, WebviewBase } from '../common/webview'; +import { ChatSessionWithPR } from './copilotApi'; +import { CopilotRemoteAgentManager } from './copilotRemoteAgent'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { IssueModel } from './issueModel'; +import { RepositoriesManager } from './repositoriesManager'; + +export interface DashboardData { + activeSessions: SessionData[]; + milestoneIssues: IssueData[]; +} + +export interface SessionData { + id: string; + title: string; + status: string; + dateCreated: string; + pullRequest?: { + number: number; + title: string; + url: string; + }; +} + +export interface IssueData { + number: number; + title: string; + assignee?: string; + milestone?: string; + state: string; + url: string; + createdAt: string; + updatedAt: string; +} + +export class DashboardWebviewProvider extends WebviewBase { + public static readonly viewType = 'github.dashboard'; + private static readonly ID = 'DashboardWebviewProvider'; + public static currentPanel?: DashboardWebviewProvider; + + protected readonly _panel: vscode.WebviewPanel; + + constructor( + private readonly _context: vscode.ExtensionContext, + private readonly _repositoriesManager: RepositoriesManager, + private readonly _copilotRemoteAgentManager: CopilotRemoteAgentManager, + private readonly _telemetry: ITelemetry, + extensionUri: vscode.Uri, + panel: vscode.WebviewPanel + ) { + super(); + this._panel = panel; + this._webview = panel.webview; + super.initialize(); + + // Set webview options + this._webview.options = { + enableScripts: true, + localResourceRoots: [extensionUri] + }; + + // Set webview HTML + this._webview.html = this.getHtmlForWebview(); + + // Listen for panel disposal + this._register(this._panel.onDidDispose(() => { + DashboardWebviewProvider.currentPanel = undefined; + })); + + // Send initial data + this.updateDashboard(); + } + + public static async createOrShow( + context: vscode.ExtensionContext, + reposManager: RepositoriesManager, + copilotRemoteAgentManager: CopilotRemoteAgentManager, + telemetry: ITelemetry, + extensionUri: vscode.Uri + ): Promise { + const column = vscode.window.activeTextEditor?.viewColumn || vscode.ViewColumn.One; + + // If we already have a panel, show it + if (DashboardWebviewProvider.currentPanel) { + DashboardWebviewProvider.currentPanel._panel.reveal(column); + return; + } + + // Create a new panel + const panel = vscode.window.createWebviewPanel( + DashboardWebviewProvider.viewType, + 'My Tasks', + column, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [extensionUri] + } + ); + + // Set the icon + panel.iconPath = { + light: vscode.Uri.joinPath(extensionUri, 'resources', 'icons', 'github_logo.png'), + dark: vscode.Uri.joinPath(extensionUri, 'resources', 'icons', 'github_logo.png') + }; + + DashboardWebviewProvider.currentPanel = new DashboardWebviewProvider( + context, + reposManager, + copilotRemoteAgentManager, + telemetry, + extensionUri, + panel + ); + } + + public static refresh(): void { + if (DashboardWebviewProvider.currentPanel) { + DashboardWebviewProvider.currentPanel.updateDashboard(); + } + } + + private async updateDashboard(): Promise { + try { + const data = await this.getDashboardData(); + this._postMessage({ + command: 'update-dashboard', + data: data + }); + } catch (error) { + Logger.error(`Failed to update dashboard: ${error}`, DashboardWebviewProvider.ID); + } + } + + private async getDashboardData(): Promise { + const [activeSessions, milestoneIssues] = await Promise.all([ + this.getActiveSessions(), + this.getMilestoneIssues() + ]); + + return { + activeSessions, + milestoneIssues + }; + } + + private async getActiveSessions(): Promise { + try { + // Create a cancellation token for the request + const source = new vscode.CancellationTokenSource(); + const token = source.token; + + const sessions = await this._copilotRemoteAgentManager.provideChatSessions(token); + return sessions.map(session => this.convertSessionToData(session)); + } catch (error) { + Logger.error(`Failed to get active sessions: ${error}`, DashboardWebviewProvider.ID); + return []; + } + } + + private convertSessionToData(session: ChatSessionWithPR): SessionData { + return { + id: session.id, + title: session.label, + status: session.status ? session.status.toString() : 'Unknown', + dateCreated: session.timing?.startTime ? new Date(session.timing.startTime).toISOString() : '', + pullRequest: session.pullRequest ? { + number: session.pullRequest.number, + title: session.pullRequest.title, + url: session.pullRequest.html_url + } : undefined + }; + } + + private async getMilestoneIssues(): Promise { + try { + const issues: IssueData[] = []; + + for (const folderManager of this._repositoriesManager.folderManagers) { + const milestoneIssues = await this.getIssuesForMilestone(folderManager, 'September 2025'); + issues.push(...milestoneIssues); + } + + return issues; + } catch (error) { + Logger.error(`Failed to get milestone issues: ${error}`, DashboardWebviewProvider.ID); + return []; + } + } + + private async getIssuesForMilestone(folderManager: FolderRepositoryManager, milestoneTitle: string): Promise { + try { + // Build query for open issues in the specific milestone + const query = `is:open milestone:"${milestoneTitle}" assignee:@me`; + const searchResult = await folderManager.getIssues(query); + + if (!searchResult || !searchResult.items) { + return []; + } + + return searchResult.items.map(issue => this.convertIssueToData(issue)); + } catch (error) { + Logger.debug(`Failed to get issues for milestone ${milestoneTitle}: ${error}`, DashboardWebviewProvider.ID); + return []; + } + } + + private convertIssueToData(issue: IssueModel): IssueData { + return { + number: issue.number, + title: issue.title, + assignee: issue.assignees?.[0]?.login, + milestone: issue.milestone?.title, + state: issue.state, + url: issue.html_url, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt + }; + } + + protected override async _onDidReceiveMessage(message: IRequestMessage): Promise { + switch (message.command) { + case 'refresh-dashboard': + await this.updateDashboard(); + break; + case 'open-chat': + await this.openChatWithQuery(message.args?.query); + break; + case 'open-session': + await this.openSession(message.args?.sessionId); + break; + case 'open-issue': + await this.openIssue(message.args?.issueUrl); + break; + case 'open-pull-request': + await this.openPullRequest(message.args?.pullRequest); + break; + default: + await super._onDidReceiveMessage(message); + break; + } + } + + private async openChatWithQuery(query: string): Promise { + if (!query) { + return; + } + + try { + await vscode.commands.executeCommand('workbench.action.chat.open', { query }); + } catch (error) { + Logger.error(`Failed to open chat with query: ${error}`, DashboardWebviewProvider.ID); + vscode.window.showErrorMessage('Failed to open chat. Make sure the Chat extension is available.'); + } + } + + private async openSession(sessionId: string): Promise { + if (!sessionId) { + return; + } + + try { + // Open the chat session + await vscode.window.showChatSession('copilot-swe-agent', sessionId, {}); + } catch (error) { + Logger.error(`Failed to open session: ${error}`, DashboardWebviewProvider.ID); + vscode.window.showErrorMessage('Failed to open session.'); + } + } + + private async openIssue(issueUrl: string): Promise { + if (!issueUrl) { + return; + } + + try { + await vscode.env.openExternal(vscode.Uri.parse(issueUrl)); + } catch (error) { + Logger.error(`Failed to open issue: ${error}`, DashboardWebviewProvider.ID); + vscode.window.showErrorMessage('Failed to open issue.'); + } + } + + private async openPullRequest(pullRequest: { number: number; title: string; url: string }): Promise { + if (!pullRequest) { + return; + } + + try { + // Try to find the pull request in the current repositories + for (const folderManager of this._repositoriesManager.folderManagers) { + // Parse the URL to get owner and repo + const urlMatch = pullRequest.url.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/); + if (urlMatch) { + const [, owner, repo] = urlMatch; + const pullRequestModel = await folderManager.resolvePullRequest(owner, repo, pullRequest.number); + if (pullRequestModel) { + // Use the extension's command to open the pull request + await vscode.commands.executeCommand('pr.openDescription', pullRequestModel); + return; + } + } + } + + // Fallback to opening externally if we can't find the PR locally + await vscode.env.openExternal(vscode.Uri.parse(pullRequest.url)); + } catch (error) { + Logger.error(`Failed to open pull request: ${error}`, DashboardWebviewProvider.ID); + // Fallback to opening externally + try { + await vscode.env.openExternal(vscode.Uri.parse(pullRequest.url)); + } catch (fallbackError) { + vscode.window.showErrorMessage('Failed to open pull request.'); + } + } + } + + private getHtmlForWebview(): string { + const nonce = getNonce(); + const uri = vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview-dashboard.js'); + + return ` + + + + + + GitHub Dashboard + + +
+ + +`; + } +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 14a1ccfd42..1a061d6008 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -383,6 +383,7 @@ module.exports = 'webview-pr-description': './webviews/editorWebview/index.ts', 'webview-open-pr-view': './webviews/activityBarView/index.ts', 'webview-create-pr-view-new': './webviews/createPullRequestViewNew/index.ts', + 'webview-dashboard': './webviews/dashboardView/index.ts', }), ]); }; diff --git a/webviews/dashboardView/app.tsx b/webviews/dashboardView/app.tsx new file mode 100644 index 0000000000..61e9cf1a3c --- /dev/null +++ b/webviews/dashboardView/app.tsx @@ -0,0 +1,284 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useEffect, useState } from 'react'; +import { render } from 'react-dom'; + +interface SessionData { + id: string; + title: string; + status: string; + dateCreated: string; + pullRequest?: { + number: number; + title: string; + url: string; + }; +} + +interface IssueData { + number: number; + title: string; + assignee?: string; + milestone?: string; + state: string; + url: string; + createdAt: string; + updatedAt: string; +} + +interface DashboardData { + activeSessions: SessionData[]; + milestoneIssues: IssueData[]; +} + +// eslint-disable-next-line rulesdir/no-any-except-union-method-signature +declare let acquireVsCodeApi: any; +const vscode = acquireVsCodeApi(); + +export function main() { + render(, document.getElementById('app')); +} + +function Dashboard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [chatInput, setChatInput] = useState(''); + + useEffect(() => { + // Listen for messages from the extension + const messageListener = (event: MessageEvent) => { + const message = event.data.res; + switch (message.command) { + case 'update-dashboard': + setData(message.data); + setLoading(false); + break; + } + }; + + window.addEventListener('message', messageListener); + + // Request initial data + vscode.postMessage({ command: 'ready' }); + + vscode.postMessage({ command: 'refresh-dashboard' }); + + return () => { + window.removeEventListener('message', messageListener); + }; + }, []); + + const handleRefresh = () => { + setLoading(true); + vscode.postMessage({ command: 'refresh-dashboard' }); + }; + + const handleSendChat = () => { + if (chatInput.trim()) { + vscode.postMessage({ + command: 'open-chat', + args: { query: chatInput.trim() } + }); + setChatInput(''); + } + }; + + const handleSessionClick = (sessionId: string) => { + vscode.postMessage({ + command: 'open-session', + args: { sessionId } + }); + }; + + const handleIssueClick = (issueUrl: string) => { + vscode.postMessage({ + command: 'open-issue', + args: { issueUrl } + }); + }; + + const handlePullRequestClick = (pullRequest: { number: number; title: string; url: string }) => { + vscode.postMessage({ + command: 'open-pull-request', + args: { pullRequest } + }); + }; + + const formatDate = (dateString: string) => { + if (!dateString) return 'Unknown'; + const date = new Date(dateString); + return date.toLocaleDateString(); + }; + + const getStatusBadgeClass = (status: string) => { + switch (status?.toLowerCase()) { + case 'completed': + case '1': + return 'status-badge status-completed'; + case 'in-progress': + case 'inprogress': + case '2': + return 'status-badge status-in-progress'; + case 'failed': + case '0': + return 'status-badge status-failed'; + default: + return 'status-badge status-in-progress'; + } + }; + + const formatStatus = (status: string) => { + switch (status?.toLowerCase()) { + case '0': + return 'Failed'; + case '1': + return 'Completed'; + case '2': + return 'In Progress'; + default: + return status || 'Unknown'; + } + }; + + if (loading) { + return ( +
+
Loading dashboard...
+
+ ); + } + + return ( +
+
+

My Tasks

+ +
+ +
+ {/* Left Column: Start new task */} +
+

Start new task

+ + {/* Chat Input Section */} +
+
+