Skip to content

Commit fe8e08d

Browse files
committed
Prototyping
1 parent 46dd6a8 commit fe8e08d

8 files changed

Lines changed: 887 additions & 0 deletions

File tree

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,12 @@
871871
]
872872
},
873873
"commands": [
874+
{
875+
"command": "pr.openDashboard",
876+
"title": "%command.pr.openDashboard.title%",
877+
"category": "%command.pull.request.category%",
878+
"icon": "$(dashboard)"
879+
},
874880
{
875881
"command": "githubpr.remoteAgent",
876882
"title": "%command.githubpr.remoteAgent.title%",

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@
175175
"view.github.active.pull.request.name": "Review Pull Request",
176176
"view.github.active.pull.request.welcome.name": "Active Pull Request",
177177
"command.pull.request.category": "GitHub Pull Requests",
178+
"command.pr.openDashboard.title": "Open Dashboard",
178179
"command.githubpr.remoteAgent.title": "Remote agent integration",
179180
"command.pr.create.title": "Create Pull Request",
180181
"command.pr.pick.title": "Checkout Pull Request",

src/commands.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,19 @@ export function registerCommands(
11291129
}
11301130
));
11311131

1132+
context.subscriptions.push(
1133+
vscode.commands.registerCommand('pr.openDashboard', async () => {
1134+
/* __GDPR__
1135+
"pr.openDashboard" : {}
1136+
*/
1137+
telemetry.sendTelemetryEvent('pr.openDashboard');
1138+
1139+
// Import here to avoid circular dependencies
1140+
const { DashboardWebviewProvider } = await import('./github/dashboardWebviewProvider');
1141+
await DashboardWebviewProvider.createOrShow(context, reposManager, copilotRemoteAgentManager, telemetry, context.extensionUri);
1142+
})
1143+
);
1144+
11321145
context.subscriptions.push(
11331146
vscode.commands.registerCommand('pr.openDescriptionToTheSide', async (descriptionNode: RepositoryChangesNode) => {
11341147
const folderManager = reposManager.getManagerForIssueModel(descriptionNode.pullRequestModel);
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
import Logger from '../common/logger';
8+
import { ITelemetry } from '../common/telemetry';
9+
import { getNonce, IRequestMessage, WebviewBase } from '../common/webview';
10+
import { ChatSessionWithPR } from './copilotApi';
11+
import { CopilotRemoteAgentManager } from './copilotRemoteAgent';
12+
import { FolderRepositoryManager } from './folderRepositoryManager';
13+
import { IssueModel } from './issueModel';
14+
import { RepositoriesManager } from './repositoriesManager';
15+
16+
export interface DashboardData {
17+
activeSessions: SessionData[];
18+
milestoneIssues: IssueData[];
19+
}
20+
21+
export interface SessionData {
22+
id: string;
23+
title: string;
24+
status: string;
25+
dateCreated: string;
26+
pullRequest?: {
27+
number: number;
28+
title: string;
29+
url: string;
30+
};
31+
}
32+
33+
export interface IssueData {
34+
number: number;
35+
title: string;
36+
assignee?: string;
37+
milestone?: string;
38+
state: string;
39+
url: string;
40+
createdAt: string;
41+
updatedAt: string;
42+
}
43+
44+
export class DashboardWebviewProvider extends WebviewBase {
45+
public static readonly viewType = 'github.dashboard';
46+
private static readonly ID = 'DashboardWebviewProvider';
47+
public static currentPanel?: DashboardWebviewProvider;
48+
49+
protected readonly _panel: vscode.WebviewPanel;
50+
51+
constructor(
52+
private readonly _context: vscode.ExtensionContext,
53+
private readonly _repositoriesManager: RepositoriesManager,
54+
private readonly _copilotRemoteAgentManager: CopilotRemoteAgentManager,
55+
private readonly _telemetry: ITelemetry,
56+
extensionUri: vscode.Uri,
57+
panel: vscode.WebviewPanel
58+
) {
59+
super();
60+
this._panel = panel;
61+
this._webview = panel.webview;
62+
super.initialize();
63+
64+
// Set webview options
65+
this._webview.options = {
66+
enableScripts: true,
67+
localResourceRoots: [extensionUri]
68+
};
69+
70+
// Set webview HTML
71+
this._webview.html = this.getHtmlForWebview();
72+
73+
// Listen for panel disposal
74+
this._register(this._panel.onDidDispose(() => {
75+
DashboardWebviewProvider.currentPanel = undefined;
76+
}));
77+
78+
// Send initial data
79+
this.updateDashboard();
80+
}
81+
82+
public static async createOrShow(
83+
context: vscode.ExtensionContext,
84+
reposManager: RepositoriesManager,
85+
copilotRemoteAgentManager: CopilotRemoteAgentManager,
86+
telemetry: ITelemetry,
87+
extensionUri: vscode.Uri
88+
): Promise<void> {
89+
const column = vscode.window.activeTextEditor?.viewColumn || vscode.ViewColumn.One;
90+
91+
// If we already have a panel, show it
92+
if (DashboardWebviewProvider.currentPanel) {
93+
DashboardWebviewProvider.currentPanel._panel.reveal(column);
94+
return;
95+
}
96+
97+
// Create a new panel
98+
const panel = vscode.window.createWebviewPanel(
99+
DashboardWebviewProvider.viewType,
100+
'My Tasks',
101+
column,
102+
{
103+
enableScripts: true,
104+
retainContextWhenHidden: true,
105+
localResourceRoots: [extensionUri]
106+
}
107+
);
108+
109+
// Set the icon
110+
panel.iconPath = {
111+
light: vscode.Uri.joinPath(extensionUri, 'resources', 'icons', 'github_logo.png'),
112+
dark: vscode.Uri.joinPath(extensionUri, 'resources', 'icons', 'github_logo.png')
113+
};
114+
115+
DashboardWebviewProvider.currentPanel = new DashboardWebviewProvider(
116+
context,
117+
reposManager,
118+
copilotRemoteAgentManager,
119+
telemetry,
120+
extensionUri,
121+
panel
122+
);
123+
}
124+
125+
public static refresh(): void {
126+
if (DashboardWebviewProvider.currentPanel) {
127+
DashboardWebviewProvider.currentPanel.updateDashboard();
128+
}
129+
}
130+
131+
private async updateDashboard(): Promise<void> {
132+
try {
133+
const data = await this.getDashboardData();
134+
this._postMessage({
135+
command: 'update-dashboard',
136+
data: data
137+
});
138+
} catch (error) {
139+
Logger.error(`Failed to update dashboard: ${error}`, DashboardWebviewProvider.ID);
140+
}
141+
}
142+
143+
private async getDashboardData(): Promise<DashboardData> {
144+
const [activeSessions, milestoneIssues] = await Promise.all([
145+
this.getActiveSessions(),
146+
this.getMilestoneIssues()
147+
]);
148+
149+
return {
150+
activeSessions,
151+
milestoneIssues
152+
};
153+
}
154+
155+
private async getActiveSessions(): Promise<SessionData[]> {
156+
try {
157+
// Create a cancellation token for the request
158+
const source = new vscode.CancellationTokenSource();
159+
const token = source.token;
160+
161+
const sessions = await this._copilotRemoteAgentManager.provideChatSessions(token);
162+
return sessions.map(session => this.convertSessionToData(session));
163+
} catch (error) {
164+
Logger.error(`Failed to get active sessions: ${error}`, DashboardWebviewProvider.ID);
165+
return [];
166+
}
167+
}
168+
169+
private convertSessionToData(session: ChatSessionWithPR): SessionData {
170+
return {
171+
id: session.id,
172+
title: session.label,
173+
status: session.status ? session.status.toString() : 'Unknown',
174+
dateCreated: session.timing?.startTime ? new Date(session.timing.startTime).toISOString() : '',
175+
pullRequest: session.pullRequest ? {
176+
number: session.pullRequest.number,
177+
title: session.pullRequest.title,
178+
url: session.pullRequest.html_url
179+
} : undefined
180+
};
181+
}
182+
183+
private async getMilestoneIssues(): Promise<IssueData[]> {
184+
try {
185+
const issues: IssueData[] = [];
186+
187+
for (const folderManager of this._repositoriesManager.folderManagers) {
188+
const milestoneIssues = await this.getIssuesForMilestone(folderManager, 'September 2025');
189+
issues.push(...milestoneIssues);
190+
}
191+
192+
return issues;
193+
} catch (error) {
194+
Logger.error(`Failed to get milestone issues: ${error}`, DashboardWebviewProvider.ID);
195+
return [];
196+
}
197+
}
198+
199+
private async getIssuesForMilestone(folderManager: FolderRepositoryManager, milestoneTitle: string): Promise<IssueData[]> {
200+
try {
201+
// Build query for open issues in the specific milestone
202+
const query = `is:open milestone:"${milestoneTitle}" assignee:@me`;
203+
const searchResult = await folderManager.getIssues(query);
204+
205+
if (!searchResult || !searchResult.items) {
206+
return [];
207+
}
208+
209+
return searchResult.items.map(issue => this.convertIssueToData(issue));
210+
} catch (error) {
211+
Logger.debug(`Failed to get issues for milestone ${milestoneTitle}: ${error}`, DashboardWebviewProvider.ID);
212+
return [];
213+
}
214+
}
215+
216+
private convertIssueToData(issue: IssueModel): IssueData {
217+
return {
218+
number: issue.number,
219+
title: issue.title,
220+
assignee: issue.assignees?.[0]?.login,
221+
milestone: issue.milestone?.title,
222+
state: issue.state,
223+
url: issue.html_url,
224+
createdAt: issue.createdAt,
225+
updatedAt: issue.updatedAt
226+
};
227+
}
228+
229+
protected override async _onDidReceiveMessage(message: IRequestMessage<any>): Promise<void> {
230+
switch (message.command) {
231+
case 'refresh-dashboard':
232+
await this.updateDashboard();
233+
break;
234+
case 'open-chat':
235+
await this.openChatWithQuery(message.args?.query);
236+
break;
237+
case 'open-session':
238+
await this.openSession(message.args?.sessionId);
239+
break;
240+
case 'open-issue':
241+
await this.openIssue(message.args?.issueUrl);
242+
break;
243+
case 'open-pull-request':
244+
await this.openPullRequest(message.args?.pullRequest);
245+
break;
246+
default:
247+
await super._onDidReceiveMessage(message);
248+
break;
249+
}
250+
}
251+
252+
private async openChatWithQuery(query: string): Promise<void> {
253+
if (!query) {
254+
return;
255+
}
256+
257+
try {
258+
await vscode.commands.executeCommand('workbench.action.chat.open', { query });
259+
} catch (error) {
260+
Logger.error(`Failed to open chat with query: ${error}`, DashboardWebviewProvider.ID);
261+
vscode.window.showErrorMessage('Failed to open chat. Make sure the Chat extension is available.');
262+
}
263+
}
264+
265+
private async openSession(sessionId: string): Promise<void> {
266+
if (!sessionId) {
267+
return;
268+
}
269+
270+
try {
271+
// Open the chat session
272+
await vscode.window.showChatSession('copilot-swe-agent', sessionId, {});
273+
} catch (error) {
274+
Logger.error(`Failed to open session: ${error}`, DashboardWebviewProvider.ID);
275+
vscode.window.showErrorMessage('Failed to open session.');
276+
}
277+
}
278+
279+
private async openIssue(issueUrl: string): Promise<void> {
280+
if (!issueUrl) {
281+
return;
282+
}
283+
284+
try {
285+
await vscode.env.openExternal(vscode.Uri.parse(issueUrl));
286+
} catch (error) {
287+
Logger.error(`Failed to open issue: ${error}`, DashboardWebviewProvider.ID);
288+
vscode.window.showErrorMessage('Failed to open issue.');
289+
}
290+
}
291+
292+
private async openPullRequest(pullRequest: { number: number; title: string; url: string }): Promise<void> {
293+
if (!pullRequest) {
294+
return;
295+
}
296+
297+
try {
298+
// Try to find the pull request in the current repositories
299+
for (const folderManager of this._repositoriesManager.folderManagers) {
300+
// Parse the URL to get owner and repo
301+
const urlMatch = pullRequest.url.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/);
302+
if (urlMatch) {
303+
const [, owner, repo] = urlMatch;
304+
const pullRequestModel = await folderManager.resolvePullRequest(owner, repo, pullRequest.number);
305+
if (pullRequestModel) {
306+
// Use the extension's command to open the pull request
307+
await vscode.commands.executeCommand('pr.openDescription', pullRequestModel);
308+
return;
309+
}
310+
}
311+
}
312+
313+
// Fallback to opening externally if we can't find the PR locally
314+
await vscode.env.openExternal(vscode.Uri.parse(pullRequest.url));
315+
} catch (error) {
316+
Logger.error(`Failed to open pull request: ${error}`, DashboardWebviewProvider.ID);
317+
// Fallback to opening externally
318+
try {
319+
await vscode.env.openExternal(vscode.Uri.parse(pullRequest.url));
320+
} catch (fallbackError) {
321+
vscode.window.showErrorMessage('Failed to open pull request.');
322+
}
323+
}
324+
}
325+
326+
private getHtmlForWebview(): string {
327+
const nonce = getNonce();
328+
const uri = vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview-dashboard.js');
329+
330+
return `<!DOCTYPE html>
331+
<html lang="en">
332+
<head>
333+
<meta charset="UTF-8">
334+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src vscode-resource: https:; script-src 'nonce-${nonce}'; style-src vscode-resource: 'unsafe-inline' http: https: data:;">
335+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
336+
<title>GitHub Dashboard</title>
337+
</head>
338+
<body>
339+
<div id="app"></div>
340+
<script nonce="${nonce}" src="${this._webview!.asWebviewUri(uri).toString()}"></script>
341+
</body>
342+
</html>`;
343+
}
344+
}

webpack.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ module.exports =
383383
'webview-pr-description': './webviews/editorWebview/index.ts',
384384
'webview-open-pr-view': './webviews/activityBarView/index.ts',
385385
'webview-create-pr-view-new': './webviews/createPullRequestViewNew/index.ts',
386+
'webview-dashboard': './webviews/dashboardView/index.ts',
386387
}),
387388
]);
388389
};

0 commit comments

Comments
 (0)