diff --git a/.github/instructions/dashboard.instructions.md b/.github/instructions/dashboard.instructions.md new file mode 100644 index 0000000000..efcc9a0fcc --- /dev/null +++ b/.github/instructions/dashboard.instructions.md @@ -0,0 +1,8 @@ +--- +applyTo: '**' +--- + +- When finished, don't summarize what you have done. Just say "Done". + +- Do not provide extra flattery such as "Excellent idea" or "You're absolutely right". Just start working on the task + diff --git a/package.json b/package.json index 2ce1b33f37..d38162691a 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "onFileSystem:githubcommit", "onFileSystem:review", "onWebviewPanel:IssueOverview", - "onWebviewPanel:PullRequestOverview" + "onWebviewPanel:PullRequestOverview", + "onWebviewPanel:github-pull-request.projectTasksDashboard" ], "browser": "./dist/browser/extension", "l10n": "./dist/browser/extension", @@ -779,6 +780,19 @@ "type": "boolean", "default": false, "description": "%githubIssues.alwaysPromptForNewIssueRepo.description%" + }, + "githubPullRequests.projectTasksDashboard.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%githubPullRequests.projectTasksDashboard.enabled.markdownDescription%", + "tags": [ + "experimental" + ] + }, + "githubPullRequests.projectTasksDashboard.issueQuery": { + "type": "string", + "default": "state:open assignee:@me ", + "description": "%githubPullRequests.projectTasksDashboard.issueQuery.description%" } } }, @@ -1789,6 +1803,13 @@ "command": "pr.cancelCodingAgent", "title": "%command.pr.cancelCodingAgent.title%", "category": "%command.pull.request.category%" + }, + { + "command": "pr.projectTasksDashboard.open", + "title": "%command.pr.projectTasksDashboard.open.title%", + "category": "%command.pull.request.category%", + "icon": "$(dashboard)", + "when": "config.githubPullRequests.projectTasksDashboard.enabled" } ], "viewsWelcome": [ @@ -2952,11 +2973,6 @@ "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && github.copilot-chat.activated && config.githubPullRequests.experimental.chat", "group": "issues_1@1" }, - { - "command": "issue.assignToCodingAgent", - "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && config.githubPullRequests.codingAgent.enabled", - "group": "issues_1@2" - }, { "command": "issue.copyIssueNumber", "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", @@ -4264,6 +4280,7 @@ "webpack-cli": "4.2.0" }, "dependencies": { + "@monaco-editor/react": "^4.7.0", "@octokit/rest": "22.0.0", "@octokit/types": "14.1.0", "@vscode/codicons": "^0.0.36", @@ -4282,6 +4299,8 @@ "lru-cache": "6.0.0", "markdown-it": "^14.1.0", "marked": "^4.0.10", + "monaco-editor": "^0.53.0", + "monaco-editor-webpack-plugin": "^7.1.0", "react": "^16.12.0", "react-dom": "^16.12.0", "ssh-config": "4.1.1", diff --git a/package.nls.json b/package.nls.json index 8f3245def9..fa34b1ea8a 100644 --- a/package.nls.json +++ b/package.nls.json @@ -178,6 +178,11 @@ "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.projectTasksDashboard.open.title": "Open Dashboard", + "githubPullRequests.projectTasksDashboard.enabled.markdownDescription": "Enable the experimental project tasks dashboard feature. This adds a new dashboard for managing GitHub issues and tasks.", + "githubPullRequests.projectTasksDashboard.issueQuery.description": "The GitHub query to use for the project tasks dashboard", + "command.pr.createDashboard.title": "Create Dashboard", + "customEditor.github.tasks.displayName": "GitHub Tasks Editor", "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/@types/vscode.proposed.chatParticipantAdditions.d.ts b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts index 2cb4168b43..73c8c948cc 100644 --- a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts @@ -646,7 +646,13 @@ declare module 'vscode' { } export interface ChatRequest { - modeInstructions?: string; - modeInstructionsToolReferences?: readonly ChatLanguageModelToolReference[]; + readonly modeInstructions?: string; + readonly modeInstructions2?: ChatRequestModeInstructions; + } + + export interface ChatRequestModeInstructions { + readonly content: string; + readonly toolReferences?: readonly ChatLanguageModelToolReference[]; + readonly metadata?: Record; } } diff --git a/src/@types/vscode.proposed.chatParticipantPrivate.d.ts b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts index eb42d52c92..9c444026ad 100644 --- a/src/@types/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts @@ -187,6 +187,8 @@ declare module 'vscode' { isQuotaExceeded?: boolean; + isRateLimited?: boolean; + level?: ChatErrorLevel; code?: string; @@ -239,6 +241,7 @@ declare module 'vscode' { export class ExtendedLanguageModelToolResult extends LanguageModelToolResult { toolResultMessage?: string | MarkdownString; toolResultDetails?: Array; + toolMetadata?: unknown; } // #region Chat participant detection diff --git a/src/commands.ts b/src/commands.ts index a6946824aa..d7c9d034ec 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -32,6 +32,7 @@ import { PullRequestModel } from './github/pullRequestModel'; import { PullRequestOverviewPanel } from './github/pullRequestOverview'; import { chooseItem } from './github/quickPicks'; import { RepositoriesManager } from './github/repositoriesManager'; +import { TasksDashboardManager } from './github/tasksDashboard/tasksDashboardManager'; import { getIssuesUrl, getPullsUrl, isInCodespaces, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, vscodeDevPrLink } from './github/utils'; import { OverviewContext } from './github/views'; import { isNotificationTreeItem, NotificationTreeItem } from './notifications/notificationItem'; @@ -212,6 +213,7 @@ export function registerCommands( telemetry: ITelemetry, tree: PullRequestsTreeDataProvider, copilotRemoteAgentManager: CopilotRemoteAgentManager, + tasksDashboard: TasksDashboardManager, ) { const logId = 'RegisterCommands'; context.subscriptions.push( @@ -1053,6 +1055,12 @@ export function registerCommands( } )); + context.subscriptions.push( + vscode.commands.registerCommand('pr.projectTasksDashboard.open', async () => { + tasksDashboard.showOrCreateDashboard(); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.openDescriptionToTheSide', async (descriptionNode: RepositoryChangesNode) => { const folderManager = reposManager.getManagerForIssueModel(descriptionNode.pullRequestModel); diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index e17b47041f..177e0e0f66 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -92,4 +92,9 @@ export const COLOR_THEME = 'colorTheme'; export const CODING_AGENT = `${PR_SETTINGS_NAMESPACE}.codingAgent`; export const CODING_AGENT_ENABLED = 'enabled'; export const CODING_AGENT_AUTO_COMMIT_AND_PUSH = 'autoCommitAndPush'; -export const CODING_AGENT_PROMPT_FOR_CONFIRMATION = 'promptForConfirmation'; \ No newline at end of file +export const CODING_AGENT_PROMPT_FOR_CONFIRMATION = 'promptForConfirmation'; + +// Tasks Dashboard +export const TASKS_DASHBOARD = `${PR_SETTINGS_NAMESPACE}.projectTasksDashboard`; +export const TASKS_DASHBOARD_ENABLED = 'enabled'; +export const TASKS_DASHBOARD_ISSUE_QUERY = 'issueQuery'; \ No newline at end of file diff --git a/src/common/webview.ts b/src/common/webview.ts index 02b59a029a..0f87774f12 100644 --- a/src/common/webview.ts +++ b/src/common/webview.ts @@ -35,7 +35,7 @@ export class WebviewBase extends Disposable { protected _webview?: vscode.Webview; private _waitForReady: Promise; - private _onIsReady: vscode.EventEmitter = this._register(new vscode.EventEmitter()); + protected _onIsReady: vscode.EventEmitter = this._register(new vscode.EventEmitter()); protected readonly MESSAGE_UNHANDLED: string = 'message not handled'; diff --git a/src/extension.ts b/src/extension.ts index c22ba2f464..08e7f60556 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,6 +28,7 @@ import { CredentialStore } from './github/credentials'; import { FolderRepositoryManager } from './github/folderRepositoryManager'; import { OverviewRestorer } from './github/overviewRestorer'; import { RepositoriesManager } from './github/repositoriesManager'; +import { TasksDashboardManager } from './github/tasksDashboard/tasksDashboardManager'; import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitProviders/api'; import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider'; import { GitLensIntegration } from './integrations/gitlens/gitlensImpl'; @@ -229,7 +230,10 @@ async function init( context.subscriptions.push(new PRNotificationDecorationProvider(tree.notificationProvider)); - registerCommands(context, reposManager, reviewsManager, telemetry, tree, copilotRemoteAgentManager); + const tasksDashboard = new TasksDashboardManager(context, copilotRemoteAgentManager, reposManager, reviewsManager, telemetry); + context.subscriptions.push(tasksDashboard); + + registerCommands(context, reposManager, reviewsManager, telemetry, tree, copilotRemoteAgentManager, tasksDashboard); const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); diff --git a/src/github/tasksDashboard/taskChatHandler.ts b/src/github/tasksDashboard/taskChatHandler.ts new file mode 100644 index 0000000000..a75cc46235 --- /dev/null +++ b/src/github/tasksDashboard/taskChatHandler.ts @@ -0,0 +1,463 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { RepositoriesManager } from '../repositoriesManager'; +import { ISSUE_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../utils'; +import { TaskDashboardWebview } from './taskDashboardWebview'; +import { IssueData, TaskData, TaskManager } from './taskManager'; + +// Individual tool definitions +const LIST_ISSUES_TOOL = { + name: 'listIssues', + description: 'Lists open issues from the current repository', +} as const satisfies vscode.LanguageModelChatTool; + +const LIST_TASKS_TOOL = { + name: 'listTasks', + description: 'Lists existing tasks (both local and remote)', +} as const satisfies vscode.LanguageModelChatTool; + +const NEW_TASK_FROM_ISSUE_TOOL = { + name: 'newTaskFromIssue', + description: 'Creates a task from an existing issue', + inputSchema: { + type: 'object', + properties: { + issueNumber: { + type: 'number', + description: 'The GitHub issue number to work on' + }, + isLocal: { + type: 'boolean', + description: 'Whether to work on this issue locally (true) or remotely (false)' + } + }, + required: ['issueNumber', 'isLocal'] + } +} as const satisfies vscode.LanguageModelChatTool; + +const OPEN_EXISTING_TASK_TOOL = { + name: 'openExistingTask', + description: 'Opens/resumes an existing task', + inputSchema: { + type: 'object', + properties: { + taskId: { + type: 'string', + description: 'The unique identifier of the task to open/resume' + } + }, + required: ['taskId'] + } +} as const satisfies vscode.LanguageModelChatTool; + +const START_TASK_TOOL = { + name: 'startTask', + description: 'Starts a new task with the specified work mode (local or remote)', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The task description or query for what needs to be implemented' + }, + isLocal: { + type: 'boolean', + description: 'Whether to work locally (true) or remotely using GitHub Copilot agent (false)' + } + }, + required: ['query'] + } +} as const satisfies vscode.LanguageModelChatTool; + +const GENERAL_QUESTION_TOOL = { + name: 'generalQuestion', + description: 'Handles general programming questions', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The programming question or query to ask' + } + }, + required: ['query'] + } +} as const satisfies vscode.LanguageModelChatTool; + +/** + * Converts a basic JSON schema to a TypeScript type. + * + * TODO: only supports basic schemas. Doesn't support all JSON schema features. + */ +export type SchemaToType = T extends { type: 'string' } + ? string + : T extends { type: 'number' } + ? number + : T extends { type: 'boolean' } + ? boolean + : T extends { type: 'null' } + ? null + // Object + : T extends { type: 'object'; properties: infer P } + ? { [K in keyof P]: SchemaToType } + // Array + : T extends { type: 'array'; items: infer I } + ? Array> + // OneOf + : T extends { oneOf: infer I } + ? MapSchemaToType + // Fallthrough + : never; + +type MapSchemaToType = T extends [infer First, ...infer Rest] + ? SchemaToType | MapSchemaToType + : never; + + +const TOOL_DEFINITIONS: vscode.LanguageModelChatTool[] = [ + LIST_ISSUES_TOOL, + LIST_TASKS_TOOL, + NEW_TASK_FROM_ISSUE_TOOL, + OPEN_EXISTING_TASK_TOOL, + START_TASK_TOOL, + GENERAL_QUESTION_TOOL +]; + +export class TaskChatHandler { + private static readonly ID = 'TaskChatHandler'; + + constructor( + private readonly _taskManager: TaskManager, + private readonly _repositoriesManager: RepositoriesManager, + private readonly issueQuery: string, + private readonly _webview: TaskDashboardWebview, + ) { } + + public async handleChatSubmission(query: string): Promise { + query = query.trim(); + if (!query) { + return; + } + + // Use language model to determine intent and take appropriate action + return this.handleIntentDetermination(query); + } + + /** + * Uses Language Model with native tool support to determine user intent and take appropriate action + */ + private async handleIntentDetermination(query: string): Promise { + try { + // Get a language model for intent determination + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-5-mini' + }); + + if (!models || models.length === 0) { + throw new Error('No language model available for intent determination'); + } + + const model = models[0]; + + // Create the initial prompt for intent determination + const systemPrompt = `You are an AI assistant that helps users work on development tasks. +Your job is to determine user wants to do based on their prompt and dispatch the appropriate final tool call. +You may use the ${LIST_ISSUES_TOOL.name}() and ${LIST_TASKS_TOOL.name}() calls to gather information before making a decision. + +Important guidelines: +- IMPORTANT: If the user says @copilot, they ALWAYS want to work on a remote task. This could be an existing remote task or a new remote task. +- IMPORTANT: If the user says @local, they ALWAYS want to work on a local task. This could be an existing local task or a new local task. +- If the user mentions a specific issue number, use ${NEW_TASK_FROM_ISSUE_TOOL.name}() +- When starting new tasks, you may omit setting 'isLocal' to instead prompt the user on what type of start the want to use if this is not clear. +- Before starting work on an issue, make sure there is not already an task for it by calling ${LIST_ISSUES_TOOL.name}. +`; + + // Initialize messages and tool call loop + let messages = [ + vscode.LanguageModelChatMessage.Assistant(systemPrompt), + vscode.LanguageModelChatMessage.User(query) + ]; + + const runWithTools = async (): Promise => { + // Send the request with tools + const response = await model.sendRequest(messages, { + justification: 'Determining user intent for task management', + tools: TOOL_DEFINITIONS + }); + + // Stream text output and collect tool calls from the response + const toolCalls: vscode.LanguageModelToolCallPart[] = []; + let responseStr = ''; + + for await (const part of response.stream) { + if (part instanceof vscode.LanguageModelTextPart) { + responseStr += part.value; + } else if (part instanceof vscode.LanguageModelToolCallPart) { + toolCalls.push(part); + } + } + + Logger.debug(`LM response: ${responseStr}`, TaskChatHandler.ID); + + if (toolCalls.length > 0) { + // Handle each tool call + let shouldContinue = false; + for (const toolCall of toolCalls) { + const result = await this.handleToolCall(toolCall); + + // Check if this was an informational tool that should continue the loop + if (toolCall.name === LIST_ISSUES_TOOL.name || toolCall.name === LIST_TASKS_TOOL.name) { + // Add the tool call and result back to the conversation + messages.push(vscode.LanguageModelChatMessage.Assistant([new vscode.LanguageModelToolCallPart(toolCall.callId, toolCall.name, toolCall.input)])); + if (result) { + messages.push(vscode.LanguageModelChatMessage.User([new vscode.LanguageModelToolResultPart(toolCall.callId, [new vscode.LanguageModelTextPart(result)])])); + } + shouldContinue = true; + } else { + // This was a final action tool - stop here + Logger.debug(`Executed final action tool: ${toolCall.name}`, TaskChatHandler.ID); + return; + } + } + + // Continue the loop if we had informational tools + if (shouldContinue) { + return runWithTools(); + } + } + }; + + await runWithTools(); + + } catch (error) { + Logger.error(`Failed to determine intent: ${error}`, TaskChatHandler.ID); + throw error; + } + } + + /** + * Handles tool calls from the language model + */ + private async handleToolCall(toolCall: vscode.LanguageModelToolCallPart): Promise { + switch (toolCall.name) { + case LIST_ISSUES_TOOL.name: { + const issues = await this.getIssuesForLM(); + const issuesText = issues.length > 0 + ? `Open Issues:\n${issues.map(i => `- Issue #${i.number}: ${i.title}`).join('\n')}` + : 'No open issues found.'; + Logger.debug(`Found ${issues.length} issues`, TaskChatHandler.ID); + return issuesText; + } + case LIST_TASKS_TOOL.name: { + const tasks = await this.getTasksForLM(); + const tasksText = tasks.length > 0 + ? `Existing Tasks:\n${tasks.map(t => `- ${t.id}: ${t.title} (${t.isLocal === true ? 'local' : 'remote'}${t.branchName ? `, branch: ${t.branchName}` : ''})`).join('\n')}` + : 'No existing tasks found.'; + Logger.debug(`Found ${tasks.length} tasks`, TaskChatHandler.ID); + return tasksText; + } + case START_TASK_TOOL.name: { + const startParams = toolCall.input as SchemaToType; + if (startParams.isLocal === true) { + await this.handleLocalTaskSubmission(startParams.query); + Logger.debug(`Created new local task: ${startParams.query}`, TaskChatHandler.ID); + } else if (startParams.isLocal === false) { + await this.handleRemoteTaskSubmission(startParams.query); + Logger.debug(`Created new remote task: ${startParams.query}`, TaskChatHandler.ID); + } else { + await this.handleStartTask(startParams.query); + Logger.debug(`Created new task: ${startParams.query}`, TaskChatHandler.ID); + + } + return; + } + case NEW_TASK_FROM_ISSUE_TOOL.name: { + const issueParams = toolCall.input as SchemaToType; + const { issueNumber, isLocal } = issueParams; + + if (isLocal) { + await this._taskManager.handleLocalTaskForIssue(issueNumber, { issueNumber, owner: undefined, name: undefined }); + Logger.debug(`Created local task for issue #${issueNumber}`, TaskChatHandler.ID); + } else { + const issueQuery = `Work on issue #${issueNumber}`; + await this.handleRemoteTaskSubmission(issueQuery); + Logger.debug(`Created remote task for issue #${issueNumber}`, TaskChatHandler.ID); + } + return; + } + case OPEN_EXISTING_TASK_TOOL.name: { + const openParams = toolCall.input as SchemaToType; + await this.openExistingTask(openParams.taskId); + Logger.debug(`Opening existing task: ${openParams.taskId}`, TaskChatHandler.ID); + return; + } + case GENERAL_QUESTION_TOOL.name: { + const questionParams = toolCall.input as SchemaToType; + await this.handleGeneralQuestion(questionParams); + Logger.debug(`Opened general question in chat: ${questionParams.query}`, TaskChatHandler.ID); + return; + } + default: { + Logger.warn(`Unknown tool call: ${toolCall.name}`, TaskChatHandler.ID); + return; + } + } + } + + private async handleGeneralQuestion(questionParams: { readonly query: string; }) { + await vscode.commands.executeCommand('workbench.action.chat.open', { + query: questionParams.query, + mode: 'ask' + }); + } + + /** + * Gets issues formatted for Language Model context + */ + private async getIssuesForLM(): Promise { + return this._taskManager.getIssuesForQuery(this._repositoriesManager.folderManagers[0], this.issueQuery); + } + + /** + * Gets tasks formatted for Language Model context + */ + private async getTasksForLM(): Promise { + try { + return await this._taskManager.getAllTasks(); + } catch (error) { + Logger.error(`Failed to get tasks for LM: ${error}`, TaskChatHandler.ID); + return []; + } + } + + /** + * Opens an existing task by ID + */ + private async openExistingTask(taskId: string): Promise { + try { + // This is a placeholder - the actual implementation would depend on how tasks are structured + // It might involve switching to a branch, opening a pull request, or resuming a remote session + Logger.debug(`Opening existing task: ${taskId}`, TaskChatHandler.ID); + + const tasks = await this._taskManager.getAllTasks(); + const target = tasks.find(task => task.id === taskId); + if (target) { + if (target.isLocal) { + this._webview.switchToLocalTask(target.branchName!, target.pullRequest); + } else { + this._webview.switchToRemoteTask(target.id, target.pullRequest); + } + } + + + // TODO: Implement actual task opening logic based on task type + } catch (error) { + Logger.error(`Failed to open existing task ${taskId}: ${error}`, TaskChatHandler.ID); + } + } + + /** + * Handles starting a new task by asking user to choose between local or remote work + */ + private async handleStartTask(query: string): Promise { + try { + const workMode = await this.showWorkModeQuickPick(); + + if (workMode === 'local') { + await this.handleLocalTaskSubmission(query); + } else if (workMode === 'remote') { + await this.handleRemoteTaskSubmission(query); + } + // If workMode is undefined, user cancelled - do nothing + } catch (error) { + Logger.error(`Failed to start task: ${error}`, TaskChatHandler.ID); + } + } + + private async handleLocalTaskSubmission(query: string) { + const cleanQuery = query.replace(/@local\s*/, '').trim(); + const references = extractIssueReferences(cleanQuery); + + if (references.length > 0) { + const firstRef = references[0]; + const issueNumber = firstRef.issueNumber; + + try { + await this._taskManager.handleLocalTaskForIssue(issueNumber, firstRef); + } catch (error) { + Logger.error(`Failed to handle local task with issue support: ${error}`, TaskChatHandler.ID); + vscode.window.showErrorMessage('Failed to set up local task branch.'); + } + } else { + await this._taskManager.setupNewLocalWorkflow(cleanQuery); + } + } + + private async handleRemoteTaskSubmission(query: string) { + const cleanQuery = query.replace(/@copilot\s*/, '').trim(); + await this._taskManager.createRemoteBackgroundSession(cleanQuery); + } + + /** + * Shows a quick pick to let user choose between local and remote work + */ + private async showWorkModeQuickPick(): Promise<'local' | 'remote' | undefined> { + const quickPick = vscode.window.createQuickPick(); + quickPick.title = 'Choose how to work on this task'; + quickPick.placeholder = 'Select whether to work locally or remotely'; + quickPick.items = [ + { + label: '$(device-desktop) Work locally', + detail: 'Create a new branch and work in your local environment', + alwaysShow: true + }, + { + label: '$(cloud) Work remotely', + detail: 'Use GitHub Copilot remote agent to work in the cloud', + alwaysShow: true + } + ]; + + return new Promise<'local' | 'remote' | undefined>((resolve) => { + quickPick.onDidAccept(() => { + const selectedItem = quickPick.selectedItems[0]; + quickPick.hide(); + if (selectedItem) { + if (selectedItem.label.includes('locally')) { + resolve('local'); + } else if (selectedItem.label.includes('remotely')) { + resolve('remote'); + } + } + resolve(undefined); + }); + + quickPick.onDidHide(() => { + quickPick.dispose(); + resolve(undefined); + }); + + quickPick.show(); + }); + } +} + +/** + * Extracts issue references from text (e.g., #123, owner/repo#456) + */ +function extractIssueReferences(text: string): Array { + const out: ParsedIssue[] = []; + for (const match of text.matchAll(ISSUE_EXPRESSION)) { + const parsed = parseIssueExpressionOutput(match); + if (parsed) { + out.push(parsed); + } + } + return out; +} \ No newline at end of file diff --git a/src/github/tasksDashboard/taskDashboardWebview.ts b/src/github/tasksDashboard/taskDashboardWebview.ts new file mode 100644 index 0000000000..f27a518e99 --- /dev/null +++ b/src/github/tasksDashboard/taskDashboardWebview.ts @@ -0,0 +1,742 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as pathLib from 'path'; +import * as vscode from 'vscode'; +import type { Change } from '../../api/api'; +import { Status } from '../../api/api1'; +import Logger from '../../common/logger'; +import { ITelemetry } from '../../common/telemetry'; +import { toReviewUri } from '../../common/uri'; +import { getNonce, IRequestMessage, WebviewBase } from '../../common/webview'; +import { CreatePullRequestDataModel } from '../../view/createPullRequestDataModel'; +import { ReviewManager } from '../../view/reviewManager'; +import { ReviewsManager } from '../../view/reviewsManager'; +import { FolderRepositoryManager, ReposManagerState } from '../folderRepositoryManager'; +import { IssueOverviewPanel } from '../issueOverview'; +import { PullRequestModel } from '../pullRequestModel'; +import { PullRequestOverviewPanel } from '../pullRequestOverview'; +import { RepositoriesManager } from '../repositoriesManager'; +import { TaskChatHandler } from './taskChatHandler'; +import { IssueData, TaskData, TaskManager, TaskPr } from './taskManager'; + +export interface DashboardLoading { + readonly state: 'loading'; + readonly issueQuery: string; +} + +export interface DashboardReady { + readonly state: 'ready'; + readonly issueQuery: string; + readonly activeSessions: TaskData[]; + readonly milestoneIssues: IssueData[]; + readonly repository?: { + readonly owner: string; + readonly name: string; + }; + readonly currentBranch?: string; +} + + +export class TaskDashboardWebview extends WebviewBase { + private static readonly ID = 'DashboardWebviewProvider'; + + private readonly _chatHandler: TaskChatHandler; + + private _branchChangeTimeout: NodeJS.Timeout | undefined; + + private _issueQuery: string; + + constructor( + private readonly _context: vscode.ExtensionContext, + private readonly _repositoriesManager: RepositoriesManager, + private readonly _taskManager: TaskManager, + private readonly _reviewsManager: ReviewsManager, + private readonly _telemetry: ITelemetry, + private readonly _extensionUri: vscode.Uri, + panel: vscode.WebviewPanel, + issueQuery: string, + ) { + super(); + + this._webview = panel.webview; + this._issueQuery = issueQuery; + this._chatHandler = new TaskChatHandler(this._taskManager, this._repositoriesManager, issueQuery, this); + + this.registerBranchChangeListeners(); + this.registerRepositoryLoadListeners(); + + super.initialize(); + + this._webview.options = { + enableScripts: true, + localResourceRoots: [this._extensionUri] + }; + + this._webview.html = this.getHtmlForWebview(); + + // Initial data will be sent when webview sends 'ready' message + } + + public override dispose() { + super.dispose(); + + if (this._branchChangeTimeout) { + clearTimeout(this._branchChangeTimeout); + } + this._branchChangeTimeout = undefined; + } + + public async updateConfiguration(issueQuery: string): Promise { + this._issueQuery = issueQuery; + await this.updateDashboard(); + } + + private async updateDashboard(): Promise { + try { + // Wait for repositories to be loaded before fetching data + await this.waitForRepositoriesReady(); + + // Check if we actually have folder managers available before proceeding + if (!this._repositoriesManager.folderManagers || this._repositoriesManager.folderManagers.length === 0) { + // Don't send ready state if we don't have folder managers yet + return; + } + + const [activeSessions, milestoneIssues] = await Promise.all([ + this.getActiveSessions(), + this.getMilestoneIssues() + ]); + + // Get current repository info and branch + let repository: { owner: string; name: string } | undefined; + let currentBranch: string | undefined; + const targetRepos = this.getTargetRepositories(); + if (targetRepos.length > 0) { + const [owner, name] = targetRepos[0].split('/'); + if (owner && name) { + repository = { owner, name }; + } + } + + // Get current branch name + if (this._repositoriesManager.folderManagers.length > 0) { + const folderManager = this._repositoriesManager.folderManagers[0]; + currentBranch = folderManager.repository.state.HEAD?.name; + } + + const readyData: DashboardReady = { + state: 'ready', + issueQuery: this._issueQuery, + activeSessions: activeSessions, + milestoneIssues: milestoneIssues, + repository, + currentBranch + }; + this._postMessage({ + command: 'update-dashboard', + data: readyData + }); + } catch (error) { + Logger.error(`Failed to update dashboard: ${error}`, TaskDashboardWebview.ID); + } + } + + private async waitForRepositoriesReady(): Promise { + // If repositories are already loaded, return immediately + if (this._repositoriesManager.state === ReposManagerState.RepositoriesLoaded) { + return; + } + + // If we need authentication, we can't load repositories + if (this._repositoriesManager.state === ReposManagerState.NeedsAuthentication) { + + return; + } + + // Wait for repositories to be loaded + return new Promise((resolve) => { + const timeout = setTimeout(() => { + + resolve(); + }, 10000); // 10 second timeout + + const disposable = this._repositoriesManager.onDidChangeState(() => { + if (this._repositoriesManager.state === ReposManagerState.RepositoriesLoaded || + this._repositoriesManager.state === ReposManagerState.NeedsAuthentication) { + clearTimeout(timeout); + disposable.dispose(); + resolve(); + } + }); + }); + } + + + private async getActiveSessions(): Promise { + const targetRepos = this.getTargetRepositories(); + return await this._taskManager.getActiveSessions(targetRepos); + } + + public async switchToLocalTask(branchName: string, pullRequestInfo?: TaskPr): Promise { + + if (pullRequestInfo) { + const pullRequestModel = await this.toPrModelAndFolderManager(pullRequestInfo); + if (pullRequestModel) { + await this.checkoutPullRequestBranch(pullRequestModel.prModel); + } + } else { + // Switch to the branch first + await this._taskManager.switchToLocalTask(branchName); + + // Open the combined diff view for all changes in the branch + await this.openBranchDiffView(branchName); + } + + // Update dashboard to reflect current branch change + setTimeout(() => { + this.updateDashboard(); + }, 500); + } + + private async openBranchDiffView(branchName: string): Promise { + try { + // Find the folder manager that has this branch + const folderManager = this._repositoriesManager.folderManagers.find(fm => + fm.gitHubRepositories.length > 0 + ); + + if (!folderManager) { + vscode.window.showErrorMessage('No GitHub repository found in the current workspace.'); + return; + } + + const baseBranch = await this.getDefaultBranch(folderManager) || 'main'; + const changes = await this.getBranchChanges(folderManager, branchName, baseBranch); + + if (changes.length === 0) { + vscode.window.showInformationMessage(`No changes found in branch ${branchName}`); + return; + } + + // Get commit SHAs for both branches + const repository = folderManager.repository; + const baseCommit = await repository.getCommit('refs/heads/' + baseBranch); + // const branchCommit = await repository.getCommit('refs/heads/' + branchName); + + // Create URI pairs for the multi diff editor + const changeArgs: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = []; + for (const change of changes) { + const fileUri = change.uri; + + // Create review URIs for base and branch versions + const baseUri = toReviewUri( + fileUri, + pathLib.basename(fileUri.fsPath), + undefined, + baseCommit.hash, + false, + { base: true }, + folderManager.repository.rootUri + ); + + // Handle different change types + if (change.status === Status.INDEX_ADDED || change.status === Status.UNTRACKED) { + // Added files - show against empty + changeArgs.push([fileUri, undefined, fileUri]); + } else if (change.status === Status.INDEX_DELETED || change.status === Status.DELETED) { + // Deleted files - show old version against empty + changeArgs.push([fileUri, baseUri, undefined]); + } else { + // Modified, renamed, or other changes + changeArgs.push([fileUri, baseUri, fileUri]); + } + } + + return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in branch {0}', branchName), changeArgs); + } catch (error) { + Logger.error(`Failed to open branch diff view: ${error}`, TaskDashboardWebview.ID); + vscode.window.showErrorMessage(`Failed to open diff view for branch ${branchName}: ${error}`); + } + } + + private async getDefaultBranch(folderManager: FolderRepositoryManager): Promise { + const defaults = await folderManager.getPullRequestDefaults(); + return defaults.base; + } + + private async getBranchChanges(folderManager: FolderRepositoryManager, branchName: string, baseBranch: string): Promise { + try { + // Use the repository's git interface to get changed files + const repository = folderManager.repository; + + // Get the diff between base and target branch + const diff = await repository.diffBetween('refs/heads/' + baseBranch, 'refs/heads/' + branchName); + return diff; + } catch (error) { + Logger.debug(`Failed to get changed files via API: ${error}`, TaskDashboardWebview.ID); + } + + // Fallback: try to get changes using git status if on the branch + try { + const repository = folderManager.repository; + const changes = repository.state.workingTreeChanges.concat(repository.state.indexChanges); + return changes; + } catch (fallbackError) { + Logger.debug(`Fallback failed: ${fallbackError}`, TaskDashboardWebview.ID); + return []; + } + } + private async getMilestoneIssues(): Promise { + try { + const issuesMap = new Map(); + + // Check if we have any folder managers available + if (!this._repositoriesManager.folderManagers || this._repositoriesManager.folderManagers.length === 0) { + + return []; + } + + // Get target repositories (either explicitly configured or current workspace repos) + const targetRepos = this.getTargetRepositories(); + + // Process each target repository exactly once to avoid duplicates + for (const repoIdentifier of targetRepos) { + const [owner, repo] = repoIdentifier.split('/'); + + const folderManager = this._repositoriesManager.getManagerForRepository(owner, repo); + if (folderManager) { + const queryIssues = await this.getIssuesForQuery(folderManager, this._issueQuery); + + // Deduplicate issues by their unique identifier (repo + issue number) + for (const issue of queryIssues) { + const issueKey = `${owner}/${repo}#${issue.number}`; + if (!issuesMap.has(issueKey)) { + issuesMap.set(issueKey, issue); + } + } + } + } + + return Array.from(issuesMap.values()); + } catch (error) { + Logger.error(`Failed to get milestone issues: ${error}`, TaskDashboardWebview.ID); + return []; + } + } + + private getCurrentWorkspaceRepositories(): string[] { + if (!vscode.workspace.workspaceFolders) { + return []; + } + + // Get the primary repository from the first folder manager that has GitHub repositories + for (const folderManager of this._repositoriesManager.folderManagers) { + if (folderManager.gitHubRepositories.length > 0) { + // Return only the first repository to focus on current workspace + const repository = folderManager.gitHubRepositories[0]; + const repoIdentifier = `${repository.remote.owner}/${repository.remote.repositoryName}`; + return [repoIdentifier]; + } + } + + return []; + } + + private getTargetRepositories(): string[] { + return this.getCurrentWorkspaceRepositories(); + } + + private async getIssuesForQuery(folderManager: FolderRepositoryManager, query: string): Promise { + return this._taskManager.getIssuesForQuery(folderManager, query); + } + + private async switchToMainBranch(): Promise { + try { + // Find the first available folder manager with a repository + const folderManager = this._repositoriesManager.folderManagers.find(fm => + fm.gitHubRepositories.length > 0 + ); + if (!folderManager) { + vscode.window.showErrorMessage('No GitHub repository found in the current workspace.'); + return; + } + + const defaultBranch = await this.getDefaultBranch(folderManager) || 'main'; + await folderManager?.checkoutDefaultBranch(defaultBranch); + + // Update dashboard to reflect the branch change + setTimeout(() => { + this.updateDashboard(); + }, 500); + } catch (error) { + Logger.error(`Failed to switch to main branch: ${error}`, TaskDashboardWebview.ID); + vscode.window.showErrorMessage(`Failed to switch to main branch: ${error}`); + } + } + + private async createPullRequest(): Promise { + try { + // Find the first available folder manager with a repository + const folderManager = this._repositoriesManager.folderManagers.find(fm => + fm.gitHubRepositories.length > 0 + ); + + if (!folderManager) { + vscode.window.showErrorMessage('No GitHub repository found in the current workspace.'); + return; + } + + const repository = folderManager.repository; + const currentBranch = repository.state.HEAD?.name; + + if (!currentBranch) { + vscode.window.showErrorMessage('No current branch found.'); + return; + } + + // Check if there are any commits on this branch that aren't on main + const hasCommits = await this.hasCommitsOnBranch(repository, currentBranch); + + if (!hasCommits) { + // No commits yet, stage files, generate commit message, and open SCM view + try { + // Stage all changed files + const workingTreeChanges = repository.state.workingTreeChanges; + if (workingTreeChanges.length > 0) { + await repository.add(workingTreeChanges.map(change => change.uri.fsPath)); + Logger.debug(`Staged ${workingTreeChanges.length} files`, TaskDashboardWebview.ID); + } + + // Open SCM view first + await vscode.commands.executeCommand('workbench.view.scm'); + + // Generate commit message using Copilot + try { + await vscode.commands.executeCommand('github.copilot.git.generateCommitMessage'); + } catch (commitMsgError) { + Logger.debug(`Failed to generate commit message: ${commitMsgError}`, TaskDashboardWebview.ID); + // Don't fail the whole operation if commit message generation fails + } + + vscode.window.showInformationMessage('Files staged and commit message generated. Make your first commit before creating a pull request.'); + } catch (stagingError) { + Logger.error(`Failed to stage files: ${stagingError}`, TaskDashboardWebview.ID); + // Fall back to just opening SCM view + await vscode.commands.executeCommand('workbench.view.scm'); + vscode.window.showInformationMessage('Make your first commit before creating a pull request.'); + } + } else { + // Has commits, proceed with create pull request flow + const reviewManager = ReviewManager.getReviewManagerForFolderManager( + this._reviewsManager.reviewManagers, + folderManager, + ); + return reviewManager?.createPullRequest(); + } + } catch (error) { + Logger.error(`Failed to create pull request: ${error}`, TaskDashboardWebview.ID); + vscode.window.showErrorMessage(`Failed to create pull request: ${error}`); + } + } + + private async hasCommitsOnBranch(repository: any, branchName: string): Promise { + try { + // Find the folder manager that contains this repository + const folderManager = this._repositoriesManager.folderManagers.find(fm => + fm.repository === repository + ); + + if (!folderManager) { + Logger.debug(`Could not find folder manager for repository`, TaskDashboardWebview.ID); + return true; + } + + // Get the default branch (usually main or master) + const defaultBranch = await this.getDefaultBranch(folderManager) || 'main'; + + // Get the GitHub repository for this folder manager + const githubRepo = folderManager.gitHubRepositories[0]; + if (!githubRepo) { + Logger.debug(`No GitHub repository found in folder manager`, TaskDashboardWebview.ID); + return true; + } + + // Create a CreatePullRequestDataModel to check for changes + const dataModel = new CreatePullRequestDataModel( + folderManager, + githubRepo.remote.owner, + defaultBranch, + githubRepo.remote.owner, + branchName, + githubRepo.remote.repositoryName + ); + + // Check if there are any changes between the branch and the base + const commits = await dataModel.gitCommits(); + dataModel.dispose(); + + return commits.length > 0; + } catch (error) { + // If we can't determine commit status, assume there are commits and proceed + Logger.debug(`Could not check branch commits: ${error}`, TaskDashboardWebview.ID); + return true; + } + } + + protected override async _onDidReceiveMessage(message: IRequestMessage): Promise { + switch (message.command) { + case 'ready': { + this._onIsReady.fire(); + + // Send immediate initialize message with loading state + const loadingData: DashboardLoading = { + state: 'loading', + issueQuery: this._issueQuery + }; + + this._postMessage({ + command: 'initialize', + data: loadingData + }); + // Then update with full data + await this.updateDashboard(); + break; + } + case 'refresh-dashboard': + return this.updateDashboard(); + case 'submit-chat': { + // Send loading state to webview + this._postMessage({ + command: 'chat-submission-started' + }); + + try { + await this._chatHandler.handleChatSubmission(message.args?.query); + } finally { + // Send completion state to webview + this._postMessage({ + command: 'chat-submission-completed' + }); + } + return; + } + case 'open-session': + return this.openSession(message.args?.sessionId); + case 'open-issue': + return this.openIssue(message.args?.repoOwner, message.args?.repoName, message.args?.issueNumber); + case 'open-pull-request': + return this.openPullRequest(message.args?.pullRequest); + case 'switch-to-local-task': + return this.switchToLocalTask(message.args?.branchName); + case 'switch-to-remote-task': + return this.switchToRemoteTask(message.args?.sessionId, message.args?.pullRequest); + case 'open-external-url': + await vscode.env.openExternal(vscode.Uri.parse(message.args.url)); + return; + case 'switch-to-main': + return this.switchToMainBranch(); + case 'create-pull-request': + return this.createPullRequest(); + default: + return super._onDidReceiveMessage(message); + } + } + + private async checkoutPullRequestBranch(pullRequest: PullRequestModel): Promise { + const folderManager = this._repositoriesManager.getManagerForIssueModel(pullRequest); + if (folderManager && !pullRequest.equals(folderManager?.activePullRequest)) { + const reviewManager = ReviewManager.getReviewManagerForFolderManager(this._reviewsManager.reviewManagers, folderManager); + return reviewManager?.switch(pullRequest); + } + } + + public async switchToRemoteTask(sessionId: string, pullRequestInfo?: TaskPr): Promise { + try { + if (pullRequestInfo) { + // Show progress notification for the full review mode setup + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: `Entering review mode for PR #${pullRequestInfo.number}`, + cancellable: false + }, async (progress) => { + const pullRequestModel = await this.toPrModelAndFolderManager(pullRequestInfo); + if (pullRequestModel) { + progress.report({ message: 'Setting up workspace...', increment: 10 }); + + // First, find and checkout the PR branch to enter review mode + progress.report({ message: 'Switching to PR branch...', increment: 30 }); + await this.checkoutPullRequestBranch(pullRequestModel.prModel); + + // Small delay to ensure branch checkout and review mode activation completes + progress.report({ message: 'Activating review mode...', increment: 50 }); + await new Promise(resolve => setTimeout(resolve, 500)); + + // // Then open the pull request description (this is the "review mode" interface) + // progress.report({ message: 'Opening pull request...', increment: 75 }); + // await this.openPullRequest(pullRequest); + + // Finally open the chat session beside the PR description + progress.report({ message: 'Opening chat session...', increment: 90 }); + await vscode.window.showChatSession('copilot-swe-agent', sessionId, { + viewColumn: vscode.ViewColumn.Beside + }); + + progress.report({ message: 'Review mode ready!', increment: 100 }); + } + }); + + // Show success message + vscode.window.showInformationMessage( + `Review mode activated for PR #${pullRequestInfo.number}. You can now review changes and continue the chat session.` + ); + } else { + // No PR associated, just open the chat session + await vscode.window.showChatSession('copilot-swe-agent', sessionId, {}); + } + } catch (error) { + Logger.error(`Failed to open session with PR: ${error} `, TaskDashboardWebview.ID); + vscode.window.showErrorMessage(`Failed to enter review mode for pull request: ${error}`); + } + } + + private async openSession(sessionId: string): Promise { + try { + // Open the chat session + await vscode.window.showChatSession('copilot-swe-agent', sessionId, {}); + } catch (error) { + Logger.error(`Failed to open session: ${error} `, TaskDashboardWebview.ID); + vscode.window.showErrorMessage('Failed to open session.'); + } + } + + private async openIssue(repoOwner: string, repoName: string, issueNumber: number): Promise { + try { + // Try to find the issue in the current repositories + for (const folderManager of this._repositoriesManager.folderManagers) { + const issueModel = await folderManager.resolveIssue(repoOwner, repoName, issueNumber); + if (issueModel) { + return IssueOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, issueModel); + } + } + + // Fallback to opening externally if we can't find the issue locally + const issueUrl = `https://github.com/${repoOwner}/${repoName}/issues/${issueNumber}`; + await vscode.env.openExternal(vscode.Uri.parse(issueUrl)); + } catch (error) { + Logger.error(`Failed to open issue: ${error} `, TaskDashboardWebview.ID); + // Fallback to opening externally + try { + const issueUrl = `https://github.com/${repoOwner}/${repoName}/issues/${issueNumber}`; + await vscode.env.openExternal(vscode.Uri.parse(issueUrl)); + } catch (fallbackError) { + vscode.window.showErrorMessage('Failed to open issue.'); + } + } + } + + private async toPrModelAndFolderManager(pullRequest: TaskPr): Promise<{ prModel: PullRequestModel; folderManager: FolderRepositoryManager } | undefined> { + // 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 prModel = await folderManager.resolvePullRequest(owner, repo, pullRequest.number); + if (prModel) { + return { prModel, folderManager }; + } + } + } + return undefined; + } + + private async openPullRequest(pullRequestInfo: TaskPr): Promise { + const models = await this.toPrModelAndFolderManager(pullRequestInfo); + if (models) { + return PullRequestOverviewPanel.createOrShow(this._telemetry, this._extensionUri, models.folderManager, models.prModel); + } + + // Fallback to opening externally if we can't find the PR locally + await vscode.env.openExternal(vscode.Uri.parse(pullRequestInfo.url)); + } + + private registerBranchChangeListeners(): void { + // Listen for branch changes across all repositories + this._register(this._repositoriesManager.onDidChangeFolderRepositories((event) => { + if (event.added) { + this.registerFolderManagerBranchListeners(event.added); + } + })); + + // Register listeners for existing folder managers + for (const folderManager of this._repositoriesManager.folderManagers) { + this.registerFolderManagerBranchListeners(folderManager); + } + } + + private registerRepositoryLoadListeners(): void { + // Listen for repository state changes to update dashboard when repositories become available + this._register(this._repositoriesManager.onDidChangeState(() => { + // When repositories state changes, try to update the dashboard + if (this._repositoriesManager.state === ReposManagerState.RepositoriesLoaded) { + this.updateDashboard(); + } + })); + + // Listen for folder repository changes (when repositories are added to folder managers) + this._register(this._repositoriesManager.onDidChangeFolderRepositories((event) => { + if (event.added) { + // When new folder managers are added, they might have repositories we can use + this.updateDashboard(); + // Also register repository load listeners for the new folder manager + this._register(event.added.onDidLoadRepositories(() => { + this.updateDashboard(); + })); + } + })); + + // Also listen for when repositories are loaded within existing folder managers + for (const folderManager of this._repositoriesManager.folderManagers) { + this._register(folderManager.onDidLoadRepositories(() => { + this.updateDashboard(); + })); + } + } + + private registerFolderManagerBranchListeners(folderManager: FolderRepositoryManager): void { + // Listen for repository HEAD changes (branch changes) + this._register(folderManager.repository.state.onDidChange(() => { + // Debounce the update to avoid too frequent refreshes + if (this._branchChangeTimeout) { + clearTimeout(this._branchChangeTimeout); + } + this._branchChangeTimeout = setTimeout(() => { + this.updateDashboard(); + }, 300); // 300ms debounce + })); + } + + private getHtmlForWebview(): string { + const nonce = getNonce(); + const uri = vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview-dashboard.js'); + const codiconsUri = this._webview!.asWebviewUri(vscode.Uri.joinPath(this._context.extensionUri, 'node_modules', '@vscode/codicons', 'dist', 'codicon.css')); + + return ` + + + + + + + GitHub Dashboard + + +
+ + +`; + } +} \ No newline at end of file diff --git a/src/github/tasksDashboard/taskManager.ts b/src/github/tasksDashboard/taskManager.ts new file mode 100644 index 0000000000..153af9d7ba --- /dev/null +++ b/src/github/tasksDashboard/taskManager.ts @@ -0,0 +1,937 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ChatSessionWithPR } from '../copilotApi'; +import { CopilotRemoteAgentManager } from '../copilotRemoteAgent'; +import { FolderRepositoryManager } from '../folderRepositoryManager'; +import { IssueModel } from '../issueModel'; +import { RepositoriesManager } from '../repositoriesManager'; +import { ParsedIssue } from '../utils'; + + +export interface TaskPr { + readonly number: number; + readonly title: string; + readonly url: string; +} + +export interface TaskData { + readonly id: string; + readonly title: string; + readonly status: string; + readonly dateCreated: string; + readonly isCurrentBranch?: boolean; + readonly isTemporary?: boolean; + readonly isLocal?: boolean; + readonly branchName?: string; + readonly pullRequest?: TaskPr; +} + +export class TaskManager { + private static readonly ID = 'TaskManager'; + + constructor( + private readonly _repositoriesManager: RepositoriesManager, + private readonly _copilotRemoteAgentManager: CopilotRemoteAgentManager + ) { } + + /** + * Gets all active sessions (both local and remote) including temporary ones + */ + public async getActiveSessions(targetRepos: string[]): Promise { + try { + // Get both remote copilot sessions and local task branches + const [remoteSessions, localTasks] = await Promise.all([ + this.getRemoteSessions(targetRepos), + this.getLocalTasks() + ]); + + // Combine and deduplicate + const sessionMap = new Map(); + + // Add remote sessions + for (const session of remoteSessions) { + sessionMap.set(session.id, session); + } + + // Add local tasks + for (const task of localTasks) { + sessionMap.set(task.id, task); + } + + // Sort sessions so temporary ones appear first, then by date + const allSessions = Array.from(sessionMap.values()); + return allSessions.sort((a, b) => { + // Temporary sessions first + if (a.isTemporary && !b.isTemporary) return -1; + if (!a.isTemporary && b.isTemporary) return 1; + // Then current branch sessions + if (a.isCurrentBranch && !b.isCurrentBranch) return -1; + if (!a.isCurrentBranch && b.isCurrentBranch) return 1; + // Then sort by date (newest first) + return new Date(b.dateCreated).getTime() - new Date(a.dateCreated).getTime(); + }); + } catch (error) { + Logger.error(`Failed to get active sessions: ${error}`, TaskManager.ID); + return []; + } + } + + /** + * Gets all active sessions from all repositories (for global dashboard) + */ + public async getAllTasks(): Promise { + try { + // Get all repositories instead of filtering by target repos + const allRepos: string[] = []; + + // Collect all repo identifiers from all folder managers + for (const folderManager of this._repositoriesManager.folderManagers) { + for (const githubRepository of folderManager.gitHubRepositories) { + const repoId = `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`; + if (!allRepos.includes(repoId)) { + allRepos.push(repoId); + } + } + } + + // Get sessions from all repositories + const [remoteSessions, localTasks] = await Promise.all([ + this.getRemoteSessions(allRepos), + this.getLocalTasks() + ]); + + // Enhance remote sessions with repository information + const enhancedRemoteSessions = remoteSessions.map(session => ({ + ...session, + repository: this.extractRepositoryFromSession(session) + })); + + // Combine and deduplicate + const sessionMap = new Map(); + + // Add enhanced remote sessions + for (const session of enhancedRemoteSessions) { + sessionMap.set(session.id, session); + } + + // Add local tasks + for (const task of localTasks) { + sessionMap.set(task.id, task); + } + + // Sort sessions so temporary ones appear first, then by date + const allSessions = Array.from(sessionMap.values()); + return allSessions.sort((a, b) => { + // Temporary sessions first + if (a.isTemporary && !b.isTemporary) return -1; + if (!a.isTemporary && b.isTemporary) return 1; + // Then current branch sessions + if (a.isCurrentBranch && !b.isCurrentBranch) return -1; + if (!a.isCurrentBranch && b.isCurrentBranch) return 1; + // Then sort by date (newest first) + return new Date(b.dateCreated).getTime() - new Date(a.dateCreated).getTime(); + }); + } catch (error) { + Logger.error(`Failed to get all sessions: ${error}`, TaskManager.ID); + return []; + } + } + + private extractRepositoryFromSession(session: TaskData): string | undefined { + // Try to extract repository name from session title or other metadata + // This is a simple heuristic - in a real implementation, this might be stored with the session + const titleMatch = session.title.match(/(\w+\/\w+)/); + return titleMatch ? titleMatch[1] : undefined; + } + + private async findPullRequestForBranch(branchName: string): Promise<{ number: number; title: string; url: string } | undefined> { + try { + for (const folderManager of this._repositoriesManager.folderManagers) { + if (folderManager.gitHubRepositories.length === 0) { + continue; + } + + // Try each GitHub repository in this folder manager + for (const githubRepository of folderManager.gitHubRepositories) { + try { + // Use the getPullRequestForBranch method to find PRs for this branch + const pullRequest = await githubRepository.getPullRequestForBranch(branchName, githubRepository.remote.owner); + + if (pullRequest) { + Logger.debug(`Found PR #${pullRequest.number} for branch ${branchName}`, TaskManager.ID); + return { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.html_url + }; + } + } catch (error) { + Logger.debug(`Failed to find PR for branch ${branchName} in ${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}: ${error}`, TaskManager.ID); + // Continue to next repository + } + } + } + + Logger.debug(`No PR found for branch ${branchName}`, TaskManager.ID); + return undefined; + } catch (error) { + Logger.debug(`Failed to find PR for branch ${branchName}: ${error}`, TaskManager.ID); + return undefined; + } + } + + /** + * Gets local task branches (branches starting with "task/") + */ + public async getLocalTasks(): Promise { + try { + const localTasks: TaskData[] = []; + + // Check each folder manager for task branches + for (const folderManager of this._repositoriesManager.folderManagers) { + if (folderManager.gitHubRepositories.length === 0) { + continue; + } + + try { + if (folderManager.repository.getRefs) { + const refs = await folderManager.repository.getRefs({ pattern: 'refs/heads/' }); + + // Filter for task branches + const taskBranches = refs.filter(ref => + ref.name && + ref.name.startsWith('task/') + ); + + for (const branch of taskBranches) { + if (!branch.name) continue; + + // Get branch details + const currentBranchName = folderManager.repository.state.HEAD?.name; + const isCurrentBranch = currentBranchName === branch.name; + + // Get commit info for date + let dateCreated = new Date().toISOString(); + try { + // For now, use current date - we can enhance this later if needed + // const commit = await folderManager.repository.getBranch(branch.name); + // if (commit?.commit?.author?.date) { + // dateCreated = commit.commit.author.date.toISOString(); + // } + } catch { + // Use current date if we can't get commit info + } + + // Create a readable title from branch name + const taskName = branch.name + .replace(/^task\//, '') + .replace(/-/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + + // Check for associated pull request + let pullRequest: { number: number; title: string; url: string } | undefined = undefined; + try { + pullRequest = await this.findPullRequestForBranch(branch.name); + } catch (error) { + Logger.debug(`Failed to find PR for branch ${branch.name}: ${error}`, TaskManager.ID); + } + + localTasks.push({ + id: `local-${branch.name}`, + title: taskName, + status: '', // No status badge for local tasks + dateCreated, + isCurrentBranch, + isLocal: true, + branchName: branch.name, + pullRequest + }); + } + } + } catch (error) { + Logger.debug(`Failed to get refs for folder manager: ${error}`, TaskManager.ID); + } + } + + return localTasks; + } catch (error) { + Logger.error(`Failed to get local tasks: ${error}`, TaskManager.ID); + return []; + } + } + + /** + * Gets remote copilot sessions + */ + public async getRemoteSessions(targetRepos: string[]): 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); + let filteredSessions = sessions; + + // Filter sessions by repositories if specified + if (targetRepos.length > 0) { + filteredSessions = sessions.filter(session => { + // If session has a pull request, check if it belongs to one of the target repos + if (session.pullRequest?.html_url) { + const urlMatch = session.pullRequest.html_url.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/\d+/); + if (urlMatch) { + const [, owner, repo] = urlMatch; + const repoIdentifier = `${owner}/${repo}`; + return targetRepos.some(targetRepo => + targetRepo.toLowerCase() === repoIdentifier.toLowerCase() + ); + } + } + // If no pull request or URL doesn't match pattern, include it + // (this covers sessions that might not be tied to a specific repo) + return targetRepos.length === 0; + }); + } + + // Convert to SessionData format + const remoteSessions: TaskData[] = []; + for (const session of filteredSessions) { + const sessionData = this.convertSessionToData(session); + remoteSessions.push(sessionData); + } + + return remoteSessions; + } catch (error) { + Logger.error(`Failed to get remote sessions: ${error}`, TaskManager.ID); + return []; + } + } + + /** + * Switches to a local task branch + */ + public async switchToLocalTask(branchName: string): Promise { + if (!branchName) { + return; + } + + try { + // Find the folder manager that has this branch + const folderManager = this._repositoriesManager.folderManagers.find(fm => + fm.gitHubRepositories.length > 0 + ); + + if (!folderManager) { + vscode.window.showErrorMessage('No GitHub repository found in the current workspace.'); + return; + } + + // Switch to the branch + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: `Switching to branch: ${branchName}`, + cancellable: false + }, async () => { + await folderManager.repository.checkout(branchName); + }); + + // Show success message + vscode.window.showInformationMessage(`Switched to local task: ${branchName}`); + + } catch (error) { + Logger.error(`Failed to switch to local task: ${error}`, TaskManager.ID); + vscode.window.showErrorMessage(`Failed to switch to branch: ${error}`); + } + } + + /** + * Sets up local workflow: creates branch and opens chat with agent mode + */ + public async setupNewLocalWorkflow(query: string): Promise { + try { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: 'Setting up local workflow', + cancellable: false + }, async (progress) => { + progress.report({ message: 'Initializing...', increment: 10 }); + + // Get the first available folder manager with a repository + const folderManager = this._repositoriesManager.folderManagers.find(fm => + fm.gitHubRepositories.length > 0 + ); + + if (!folderManager) { + vscode.window.showErrorMessage('No GitHub repository found in the current workspace.'); + return; + } + + // Generate a logical branch name from the query + progress.report({ message: 'Generating branch name...', increment: 30 }); + const branchName = await this.generateBranchName(query); + + // Create the branch + progress.report({ message: `Creating branch: ${branchName}`, increment: 60 }); + await folderManager.repository.createBranch(branchName, true); + + // Create a fresh chat session and open with ask mode + progress.report({ message: 'Opening chat session...', increment: 90 }); + await vscode.commands.executeCommand('workbench.action.chat.newChat'); + + await new Promise(resolve => setTimeout(resolve, 500)); + + await vscode.commands.executeCommand('workbench.action.chat.open', { + query, + mode: 'agent' + }); + + progress.report({ message: 'Local workflow ready!', increment: 100 }); + }); + + // Show success message + vscode.window.showInformationMessage('Local workflow setup complete! Ready to work on your task.'); + + } catch (error) { + Logger.error(`Failed to setup local workflow: ${error}`, TaskManager.ID); + vscode.window.showErrorMessage(`Failed to setup local workflow: ${error}`); + } + } + + /** + * Handles local task for a specific issue - creates branch and opens chat + */ + public async handleLocalTaskForIssue(issueNumber: number, issueRef: ParsedIssue): Promise { + // Create branch name: task/issue-{number} + const branchName = `task/issue-${issueNumber}`; + + // Find the appropriate folder manager for this issue + let folderManager: FolderRepositoryManager | undefined; + let finalOwner: string; + let finalRepo: string; + + if (issueRef.owner && issueRef.name) { + // Full repository reference (owner/repo#123) + finalOwner = issueRef.owner; + finalRepo = issueRef.name; + + for (const manager of this._repositoriesManager.folderManagers) { + try { + const issueModel = await manager.resolveIssue(issueRef.owner, issueRef.name, issueNumber); + if (issueModel) { + folderManager = manager; + break; + } + } catch (error) { + // Continue looking in other repos + continue; + } + } + } else { + // Simple reference (#123) - use current repository + folderManager = this._repositoriesManager.folderManagers[0]; + if (folderManager && folderManager.gitHubRepositories.length > 0) { + const repo = folderManager.gitHubRepositories[0]; + finalOwner = repo.remote.owner; + finalRepo = repo.remote.repositoryName; + } else { + vscode.window.showErrorMessage('No repository context found for issue reference.'); + return; + } + } + + if (!folderManager) { + vscode.window.showErrorMessage('Repository not found in local workspace.'); + return; + } + + // Check if branch already exists + let branchExists = false; + try { + if (folderManager.repository.getRefs) { + const refs = await folderManager.repository.getRefs({ + contains: undefined, + count: undefined, + pattern: undefined, + sort: undefined + }); + const existingBranches = new Set( + refs + .filter(ref => ref.type === 1 && ref.name) // RefType.Head = 1 + .map(ref => ref.name!) + ); + branchExists = existingBranches.has(branchName); + } + } catch (error) { + Logger.debug(`Could not fetch branch refs: ${error}`, TaskManager.ID); + } + + if (branchExists) { + // Ask user if they want to switch to existing branch + const switchToBranch = vscode.l10n.t('Switch to Branch'); + const createNewBranch = vscode.l10n.t('Create New Branch'); + const cancel = vscode.l10n.t('Cancel'); + + const choice = await vscode.window.showInformationMessage( + vscode.l10n.t('Branch "{0}" already exists. What would you like to do?', branchName), + { modal: true }, + switchToBranch, + createNewBranch, + cancel + ); + + if (choice === switchToBranch) { + await folderManager.repository.checkout(branchName); + vscode.window.showInformationMessage(vscode.l10n.t('Switched to existing branch: {0}', branchName)); + } else if (choice === createNewBranch) { + // Generate a unique branch name + const timestamp = Date.now(); + const uniqueBranchName = `task/issue-${issueNumber}-${timestamp}`; + await folderManager.repository.createBranch(uniqueBranchName, true); + vscode.window.showInformationMessage(vscode.l10n.t('Created new branch: {0}', uniqueBranchName)); + } else { + return; // User cancelled + } + } else { + // Create new branch + await folderManager.repository.createBranch(branchName, true); + vscode.window.showInformationMessage(vscode.l10n.t('Created and switched to branch: {0}', branchName)); + } + + // Format the issue URL for the prompt + const githubUrl = `https://github.com/${finalOwner}/${finalRepo}/issues/${issueNumber}`; + const formattedPrompt = `Fix ${githubUrl}`; + + // Open agent chat with formatted prompt + await vscode.commands.executeCommand('workbench.action.chat.open', { query: formattedPrompt }); + } + + /** + * Creates a remote background session using the copilot remote agent + */ + public async createRemoteBackgroundSession(query: string): Promise { + try { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: 'Creating remote coding agent session', + cancellable: false + }, async (progress) => { + progress.report({ message: 'Preparing task...', increment: 20 }); + + // Extract the query without @copilot mention + const cleanQuery = query.replace(/@copilot\s*/, '').trim(); + + // Use the copilot remote agent manager to create a new session + progress.report({ message: 'Creating session with coding agent...', increment: 60 }); + const sessionResult = await this._copilotRemoteAgentManager.provideNewChatSessionItem({ + request: { + prompt: cleanQuery, + references: [], + participant: 'copilot-swe-agent' + } as any, // Type assertion as we're using this internal API + prompt: cleanQuery, + history: [], + metadata: { source: 'dashboard' } + }, new vscode.CancellationTokenSource().token); + + // Show confirmation that the task has been created + if (sessionResult && sessionResult.id) { + progress.report({ message: 'Session created successfully!', increment: 90 }); + + const sessionTitle = sessionResult.label || `Session ${sessionResult.id}`; + const viewAction = 'View Session'; + const result = await vscode.window.showInformationMessage( + `Created new coding agent task: ${sessionTitle}`, + viewAction + ); + + if (result === viewAction) { + // Open the session if user chooses to view it + await vscode.window.showChatSession('copilot-swe-agent', sessionResult.id, {}); + } + + progress.report({ message: 'Remote session ready!', increment: 100 }); + } else { + vscode.window.showErrorMessage('Failed to create coding agent session.'); + } + }); + + } catch (error) { + Logger.error(`Failed to create remote background session: ${error}`, TaskManager.ID); + vscode.window.showErrorMessage(`Failed to create coding agent session: ${error}`); + } + } + + /** + * Generates a logical branch name from a query using LM API with uniqueness checking + */ + public async generateBranchName(query: string): Promise { + try { + // Try to get a language model for branch name generation + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + + let baseName: string; + + if (models && models.length > 0) { + const model = models[0]; + + // Create a focused prompt for branch name generation + const namePrompt = `Generate a concise, descriptive git branch name for this task. The name should be: +- 3-6 words maximum +- Use kebab-case (lowercase with hyphens) +- Be descriptive but brief +- Follow conventional branch naming patterns +- No special characters except hyphens +- Always start with "task/" prefix + +Examples: +- "implement user authentication" → "task/user-authentication" +- "fix login bug" → "task/login-bug" +- "add search functionality" → "task/search-functionality" +- "refactor database code" → "task/database-code" +- "update styling" → "task/styling" + +Task: "${query}" + +Branch name:`; + + const messages = [vscode.LanguageModelChatMessage.User(namePrompt)]; + + const response = await model.sendRequest(messages, { + justification: 'Generating descriptive branch name for development task' + }); + + let result = ''; + for await (const chunk of response.text) { + result += chunk; + } + + // Clean up the LM response + baseName = result.trim() + .replace(/^["']|["']$/g, '') // Remove quotes + .replace(/[^\w\s/-]/g, '') // Remove special characters except hyphens and slashes + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen + .replace(/^-|-$/g, '') // Remove leading/trailing hyphens + .toLowerCase(); + + // Ensure it starts with task/ prefix + if (!baseName.startsWith('task/')) { + // Remove any existing prefix (feature/, fix/, etc.) and replace with task/ + baseName = baseName.replace(/^(feature|fix|refactor|update|bugfix|enhancement)\//, ''); + baseName = `task/${baseName}`; + } + + // Ensure it has a reasonable length + if (baseName.length > 50) { + baseName = baseName.substring(0, 50).replace(/-[^-]*$/, ''); // Cut at word boundary + } + } else { + // Fallback to simple name generation if LM is unavailable + baseName = this.generateFallbackBranchName(query); + } + + // Ensure uniqueness by checking existing branches + return await this.ensureUniqueBranchName(baseName); + + } catch (error) { + Logger.error(`Failed to generate branch name using LM: ${error}`, TaskManager.ID); + // Fallback to simple name generation + const fallbackName = this.generateFallbackBranchName(query); + return await this.ensureUniqueBranchName(fallbackName); + } + } + + /** + * Checks if a session is associated with the current branch + */ + public isSessionAssociatedWithCurrentBranch(session: ChatSessionWithPR): boolean { + if (!session.pullRequest) { + return false; + } + + // Parse the PR URL to get owner and repo + const urlMatch = session.pullRequest.html_url.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/); + if (!urlMatch) { + return false; + } + + const [, owner, repo] = urlMatch; + const prNumber = session.pullRequest.number; + + // Check if any folder manager has this PR checked out on current branch + for (const folderManager of this._repositoriesManager.folderManagers) { + // Check if this folder manager manages the target repo + if (this.folderManagerMatchesRepo(folderManager, owner, repo)) { + // Check if the current branch corresponds to this PR + const currentBranchName = folderManager.repository.state.HEAD?.name; + if (currentBranchName) { + // Try to find the PR model for this session + try { + // Use the active PR if it matches + if (folderManager.activePullRequest?.number === prNumber) { + return true; + } + // Also check if the branch name suggests it's a PR branch + // Common patterns: pr-123, pull/123, etc. + const prBranchPatterns = [ + new RegExp(`pr-${prNumber}$`, 'i'), + new RegExp(`pull/${prNumber}$`, 'i'), + new RegExp(`pr/${prNumber}$`, 'i') + ]; + for (const pattern of prBranchPatterns) { + if (pattern.test(currentBranchName)) { + return true; + } + } + } catch (error) { + // Ignore errors in checking PR association + } + } + } + } + + return false; + } + + // Private helper methods + + private convertSessionToData(session: ChatSessionWithPR): TaskData { + const isCurrentBranch = this.isSessionAssociatedWithCurrentBranch(session); + + // Map ChatSessionStatus enum to meaningful status strings + let status = ''; + if (session.status !== undefined) { + switch (session.status) { + case 0: // Failed + status = 'Failed'; + break; + case 1: // Completed + status = 'Completed'; + break; + case 2: // InProgress + status = 'In Progress'; + break; + default: + status = 'Unknown'; + } + } + + return { + id: session.id, + title: session.label, + status, + dateCreated: session.timing?.startTime ? new Date(session.timing.startTime).toISOString() : '', + isCurrentBranch, + pullRequest: session.pullRequest ? { + number: session.pullRequest.number, + title: session.pullRequest.title, + url: session.pullRequest.html_url + } : undefined + }; + } + + private generateFallbackBranchName(query: string): string { + // Clean up the query to create a branch-friendly name + const cleaned = query + .toLowerCase() + .replace(/[^\w\s-]/g, '') // Remove special characters except hyphens + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen + .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens + + // Truncate to reasonable length + const truncated = cleaned.length > 40 ? cleaned.substring(0, 40) : cleaned; + + return `task/${truncated}`; + } + + private async ensureUniqueBranchName(baseName: string): Promise { + try { + // Get the first available folder manager with a repository + const folderManager = this._repositoriesManager.folderManagers.find(fm => + fm.gitHubRepositories.length > 0 + ); + + if (!folderManager) { + // If no repository available, just add timestamp for uniqueness + const timestamp = Date.now().toString().slice(-6); + return `${baseName}-${timestamp}`; + } + + // Get existing branch names + let existingBranches = new Set(); + try { + if (folderManager.repository.getRefs) { + const refs = await folderManager.repository.getRefs({ + contains: undefined, + count: undefined, + pattern: undefined, + sort: undefined + }); + existingBranches = new Set( + refs + .filter(ref => ref.type === 1 && ref.name) // RefType.Head and has name + .map(ref => ref.name!) + ); + } + } catch (error) { + Logger.debug(`Could not fetch branch refs: ${error}`, TaskManager.ID); + // Continue with empty set - will use timestamp for uniqueness + } + + // Check if base name is unique + if (!existingBranches.has(baseName)) { + return baseName; + } + + // If not unique, try adding numeric suffixes + for (let i = 2; i <= 99; i++) { + const candidateName = `${baseName}-${i}`; + if (!existingBranches.has(candidateName)) { + return candidateName; + } + } + + // If still not unique after 99 attempts, add timestamp + const timestamp = Date.now().toString().slice(-6); + return `${baseName}-${timestamp}`; + + } catch (error) { + Logger.error(`Failed to check branch uniqueness: ${error}`, TaskManager.ID); + // Fallback to timestamp-based uniqueness + const timestamp = Date.now().toString().slice(-6); + return `${baseName}-${timestamp}`; + } + } + + private folderManagerMatchesRepo(folderManager: FolderRepositoryManager, owner: string, repo: string): boolean { + // Check if the folder manager manages a repository that matches the owner/repo + for (const repository of folderManager.gitHubRepositories) { + if (repository.remote.owner.toLowerCase() === owner.toLowerCase() && + repository.remote.repositoryName.toLowerCase() === repo.toLowerCase()) { + return true; + } + } + return false; + } + + public async findLocalTaskBranch(branchName: string): Promise { + try { + // Use the same logic as TaskManager to get all task branches + for (const folderManager of this._repositoriesManager.folderManagers) { + if (folderManager.repository.getRefs) { + const refs = await folderManager.repository.getRefs({ pattern: 'refs/heads/' }); + + // Debug: log all branches + Logger.debug(`All local branches: ${refs.map(r => r.name).join(', ')}`, TaskManager.ID); + + // Filter for task branches and look for our specific branch + const taskBranches = refs.filter(ref => + ref.name && + ref.name.startsWith('task/') + ); + + Logger.debug(`Task branches: ${taskBranches.map(r => r.name).join(', ')}`, TaskManager.ID); + Logger.debug(`Looking for branch: ${branchName}`, TaskManager.ID); + + const matchingBranch = taskBranches.find(ref => ref.name === branchName); + + if (matchingBranch) { + Logger.debug(`Found local task branch: ${branchName}`, TaskManager.ID); + return branchName; + } + } + } + Logger.debug(`Local task branch ${branchName} not found in any repository`, TaskManager.ID); + return undefined; + } catch (error) { + Logger.debug(`Failed to find local task branch ${branchName}: ${error}`, TaskManager.ID); + return undefined; + } + } + + public async getIssuesForQuery(folderManager: FolderRepositoryManager, query: string): Promise { + try { + // Get the primary repository for this folder manager to scope the search + let scopedQuery = query; + if (folderManager.gitHubRepositories.length > 0) { + const repo = folderManager.gitHubRepositories[0]; + const repoScope = `repo:${repo.remote.owner}/${repo.remote.repositoryName}`; + // Add repo scope to the query if it's not already present + if (!query.includes('repo:')) { + scopedQuery = `${repoScope} ${query}`; + } + } + + const searchResult = await folderManager.getIssues(scopedQuery); + if (!searchResult || !searchResult.items) { + return []; + } + + return Promise.all(searchResult.items.map(issue => this.convertIssueToData(issue))); + } catch (error) { + return []; + } + } + + private async convertIssueToData(issue: IssueModel): Promise { + const issueData: IssueData = { + 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 + }; + + // Check for local task branch + try { + const taskBranchName = `task/issue-${issue.number}`; + const localTaskBranch = await this.findLocalTaskBranch(taskBranchName); + if (localTaskBranch) { + issueData.localTaskBranch = localTaskBranch; + + // Check for associated pull request for this branch + const pullRequest = await issue.githubRepository.getPullRequestForBranch(localTaskBranch, issue.githubRepository.remote.owner); + if (pullRequest) { + issueData.pullRequest = { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.html_url, + }; + } + } + } catch (error) { + // If we can't check for branches, just continue without the local task info + Logger.debug(`Could not check for local task branch: ${error}`, TaskManager.ID); + } + + return issueData; + } +} + +export interface IssueData { + number: number; + title: string; + assignee?: string; + milestone?: string; + state: string; + url: string; + createdAt: string; + updatedAt: string; + localTaskBranch?: string; // Name of the local task branch if it exists + pullRequest?: { + number: number; + title: string; + url: string; + }; +} \ No newline at end of file diff --git a/src/github/tasksDashboard/tasksDashboardManager.ts b/src/github/tasksDashboard/tasksDashboardManager.ts new file mode 100644 index 0000000000..fcdbd21fbe --- /dev/null +++ b/src/github/tasksDashboard/tasksDashboardManager.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable, disposeAll } from '../../common/lifecycle'; +import { TASKS_DASHBOARD, TASKS_DASHBOARD_ENABLED, TASKS_DASHBOARD_ISSUE_QUERY } from '../../common/settingKeys'; +import { ITelemetry } from '../../common/telemetry'; +import { ReviewsManager } from '../../view/reviewsManager'; +import { CopilotRemoteAgentManager } from '../copilotRemoteAgent'; +import { RepositoriesManager } from '../repositoriesManager'; +import { TaskDashboardWebview } from './taskDashboardWebview'; +import { TaskManager } from './taskManager'; + +export class TasksDashboardManager extends Disposable implements vscode.WebviewPanelSerializer { + public static readonly viewType = 'github-pull-request.projectTasksDashboard'; + private static readonly viewTitle = vscode.l10n.t('Tasks Dashboard'); + + private _currentView: { + readonly webview: vscode.WebviewPanel; + readonly dashboardProvider: TaskDashboardWebview; + readonly disposables: vscode.Disposable[]; + } | undefined; + + private _statusBarItem?: vscode.StatusBarItem; + + private readonly _taskManager: TaskManager; + + constructor( + private readonly _context: vscode.ExtensionContext, + private readonly _copilotRemoteAgentManager: CopilotRemoteAgentManager, + private readonly _repositoriesManager: RepositoriesManager, + private readonly _reviewsManager: ReviewsManager, + private readonly _telemetry: ITelemetry, + ) { + super(); + + // Create status bar item for launching dashboard + this.updateStatusBarItem(); + + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${TASKS_DASHBOARD}.${TASKS_DASHBOARD_ENABLED}`)) { + this.updateStatusBarItem(); + } + })); + + // Register webview panel serializer for tasks dashboard + this._register(vscode.window.registerWebviewPanelSerializer(TasksDashboardManager.viewType, this)); + + this._taskManager = new TaskManager(this._repositoriesManager, this._copilotRemoteAgentManager); + } + + public override dispose() { + super.dispose(); + + this._currentView?.disposables.forEach(d => d.dispose()); + this._currentView = undefined; + + this._statusBarItem?.dispose(); + this._statusBarItem = undefined; + } + + private updateStatusBarItem(): void { + const dashboardEnabled = vscode.workspace.getConfiguration(TASKS_DASHBOARD).get(TASKS_DASHBOARD_ENABLED, false); + + if (dashboardEnabled && !this._statusBarItem) { + // Create status bar item if it doesn't exist and is now enabled + this._statusBarItem = this._register(vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100)); + this._statusBarItem.text = vscode.l10n.t('$(dashboard) Tasks'); + this._statusBarItem.tooltip = vscode.l10n.t('Open GitHub Tasks Dashboard'); + this._statusBarItem.command = 'pr.projectTasksDashboard.open'; + this._statusBarItem.show(); + } else if (!dashboardEnabled && this._statusBarItem) { + // Hide and dispose status bar item if it exists and is now disabled + this._statusBarItem.hide(); + this._statusBarItem.dispose(); + this._statusBarItem = undefined; + } + } + + public async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, _state: {}): Promise { + this.restoreDashboard(webviewPanel); + } + + private restoreDashboard(webviewPanel: vscode.WebviewPanel): void { + if (this._currentView) { + disposeAll(this._currentView.disposables); + this._currentView = undefined; + } + + webviewPanel.webview.options = { + enableScripts: true, + localResourceRoots: [this._context.extensionUri] + }; + + webviewPanel.iconPath = vscode.Uri.joinPath(this._context.extensionUri, 'resources/icons/github_logo.png'); + webviewPanel.title = TasksDashboardManager.viewTitle; + + const issueQuery = this.getIssueQuery(); + + const dashboardProvider = new TaskDashboardWebview( + this._context, + this._repositoriesManager, + this._taskManager, + this._reviewsManager, + this._telemetry, + this._context.extensionUri, + webviewPanel, + issueQuery, + ); + + const disposables: vscode.Disposable[] = []; + const currentViewEntry = { webview: webviewPanel, dashboardProvider, disposables }; + this._currentView = currentViewEntry; + + disposables.push(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${TASKS_DASHBOARD}.${TASKS_DASHBOARD_ISSUE_QUERY}`)) { + const newQuery = this.getIssueQuery(); + dashboardProvider.updateConfiguration(newQuery); + } + })); + + // Clean up when panel is disposed + disposables.push(webviewPanel.onDidDispose(() => { + if (this._currentView === currentViewEntry) { + disposeAll(disposables); + this._currentView = undefined; + } + })); + } + + public showOrCreateDashboard(): void { + // If we already have a panel, just reveal it + if (this._currentView) { + this._currentView.webview.reveal(vscode.ViewColumn.Active); + return; + } + + const newWebviewPanel = vscode.window.createWebviewPanel( + TasksDashboardManager.viewType, + TasksDashboardManager.viewTitle, + vscode.ViewColumn.Active, + { + enableScripts: true, + localResourceRoots: [this._context.extensionUri], + retainContextWhenHidden: true + } + ); + this.restoreDashboard(newWebviewPanel); + } + + private getIssueQuery(): string { + const config = vscode.workspace.getConfiguration(TASKS_DASHBOARD); + return config.get(TASKS_DASHBOARD_ISSUE_QUERY, this.getDefaultIssueQuery()); + } + + private getDefaultIssueQuery(): string { + return 'is:open assignee:@me'; + } +} \ No newline at end of file diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index cf62dad245..ed7dfb6659 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -160,8 +160,8 @@ export class IssueFeatureRegistrar extends Disposable { 'issue.assignToCodingAgent', (issueModel: any) => { /* __GDPR__ - "issue.assignToCodingAgent" : {} - */ + "issue.assignToCodingAgent" : {} + */ this.telemetry.sendTelemetryEvent('issue.assignToCodingAgent'); return this.assignToCodingAgent(issueModel); }, diff --git a/webpack.config.js b/webpack.config.js index 17f46cfe12..16c4b9be10 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -17,6 +17,7 @@ const ForkTsCheckerPlugin = require('fork-ts-checker-webpack-plugin'); const JSON5 = require('json5'); const TerserPlugin = require('terser-webpack-plugin'); const webpack = require('webpack'); +const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin'); async function resolveTSConfig(configFile) { const data = await new Promise((resolve, reject) => { @@ -56,6 +57,12 @@ async function getWebviewConfig(mode, env, entry) { new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }), + new MonacoWebpackPlugin({ + languages: [], + features: ['suggest', 'contextmenu'], + globalAPI: true, + publicPath: '' + }), new ForkTsCheckerPlugin({ async: false, formatter: 'basic', @@ -64,7 +71,6 @@ async function getWebviewConfig(mode, env, entry) { }, }), ]; - return { name: 'webviews', entry: entry, @@ -74,6 +80,7 @@ async function getWebviewConfig(mode, env, entry) { output: { filename: '[name].js', path: path.resolve(__dirname, 'dist'), + globalObject: 'self', }, optimization: { minimizer: [ @@ -367,6 +374,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..317c90e85b --- /dev/null +++ b/webviews/dashboardView/app.tsx @@ -0,0 +1,308 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import React, { useCallback, useEffect, useState } from 'react'; + +import { render } from 'react-dom'; +import { ChatInput } from './components/ChatInput'; +import { EmptyState } from './components/EmptyState'; +import { IssueItem } from './components/IssueItem'; +import { LoadingState } from './components/LoadingState'; +import { SessionItem } from './components/SessionItem'; +import { SortDropdown } from './components/SortDropdown'; +import { DashboardReady, DashboardState, IssueData, SessionData } from './types'; +import { extractMilestoneFromQuery, vscode } from './util'; + +export function main() { + render(, document.getElementById('app')); +} + +function Dashboard() { + const [dashboardState, setDashboardState] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [issueSort, setIssueSort] = useState<'date-oldest' | 'date-newest'>('date-oldest'); + const [hoveredIssue, setHoveredIssue] = useState(null); + const [chatInputValue, setChatInputValue] = useState(''); + const [focusTrigger, setFocusTrigger] = useState(0); + const [isChatSubmitting, setIsChatSubmitting] = useState(false); + + useEffect(() => { + // Listen for messages from the extension + const messageListener = (event: MessageEvent) => { + // Handle both direct messages and wrapped messages + const message = event.data?.res || event.data; + if (!message || !message.command) { + return; // Ignore messages without proper structure + } + switch (message.command) { + case 'initialize': + setDashboardState(message.data); + break; + case 'update-dashboard': + setDashboardState(message.data); + setRefreshing(false); + break; + case 'chat-submission-started': + setIsChatSubmitting(true); + break; + case 'chat-submission-completed': + setIsChatSubmitting(false); + // Clear the chat input when submission completes + setChatInputValue(''); + break; + } + }; + window.addEventListener('message', messageListener); + + vscode.postMessage({ command: 'ready' }); + + return () => { + window.removeEventListener('message', messageListener); + }; + }, []); + + const handleRefresh = useCallback(() => { + setRefreshing(true); + vscode.postMessage({ command: 'refresh-dashboard' }); + }, []); + + const handleSessionClick = useCallback((session: SessionData) => { + vscode.postMessage({ + command: 'switch-to-remote-task', + args: { + sessionId: session.id, + pullRequest: session.pullRequest + } + }); + }, []); + + const handleIssueClick = useCallback((issueUrl: string) => { + vscode.postMessage({ + command: 'open-issue', + args: { issueUrl } + }); + }, []); + + const handlePopulateLocalInput = useCallback((issue: any, event: React.MouseEvent) => { + event.stopPropagation(); // Prevent triggering the issue click + const command = `@local start work on #${issue.number}`; + setChatInputValue(command); + setFocusTrigger(prev => prev + 1); // Trigger focus + // Scroll to top to show the input box + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + const handlePopulateRemoteInput = useCallback((issue: any, event: React.MouseEvent) => { + event.stopPropagation(); // Prevent triggering the issue click + const command = `@copilot start work on #${issue.number}`; + setChatInputValue(command); + setFocusTrigger(prev => prev + 1); // Trigger focus + // Scroll to top to show the input box + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + const handlePullRequestClick = useCallback((pullRequest: { number: number; title: string; url: string }) => { + vscode.postMessage({ + command: 'open-pull-request', + args: { pullRequest } + }); + }, []); + + const handleIssueCountClick = useCallback(() => { + if (dashboardState?.state === 'ready') { + const readyState = dashboardState as DashboardReady; + const { owner, name } = readyState.repository || { owner: '', name: '' }; + + if (owner && name) { + const githubQuery = readyState.issueQuery; + + const githubUrl = `https://github.com/${owner}/${name}/issues?q=${encodeURIComponent(githubQuery)}`; + vscode.postMessage({ + command: 'open-external-url', + args: { url: githubUrl } + }); + } + } + }, [dashboardState]); + + const handleSwitchToLocalTask = useCallback((branchName: string, event: React.MouseEvent) => { + event.stopPropagation(); // Prevent triggering the issue click + vscode.postMessage({ + command: 'switch-to-local-task', + args: { branchName } + }); + }, []); + + const handleSwitchToMain = useCallback(() => { + vscode.postMessage({ + command: 'switch-to-main' + }); + }, []); + + // Sort issues based on selected option + const getSortedIssues = useCallback((issues: readonly IssueData[]) => { + if (!issues) return []; + + const sortedIssues = [...issues]; + + switch (issueSort) { + case 'date-oldest': + return sortedIssues.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()); + case 'date-newest': + return sortedIssues.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + default: + return sortedIssues; + } + }, [issueSort]); + + // Find associated session for an issue based on title matching or issue references + const findAssociatedSession = useCallback((issue: IssueData): SessionData | undefined => { + if (dashboardState?.state !== 'ready') return undefined; + + return dashboardState.activeSessions.find(session => { + // Skip local sessions + if (session.isLocal) return false; + + // Check if session title contains the issue number + const sessionTitle = session.title.toLowerCase(); + const issueNumber = `#${issue.number}`; + const issueTitle = issue.title.toLowerCase(); + + // Match by issue number reference or similar title + return sessionTitle.includes(issueNumber) || + sessionTitle.includes(issueTitle) || + issueTitle.includes(sessionTitle); + }); + }, [dashboardState]); + + // Derived state from discriminated union with proper type narrowing + const isReady = dashboardState?.state === 'ready'; + const readyState = isReady ? dashboardState as DashboardReady : null; + + const issueQuery = readyState?.issueQuery || ''; + const milestoneIssues = readyState?.milestoneIssues || []; + const activeSessions = isReady ? dashboardState.activeSessions : []; + const currentBranch = readyState?.currentBranch; return ( +
+
+

My Tasks

+
+ {readyState?.currentBranch && + readyState.currentBranch !== 'main' && + readyState.currentBranch !== 'master' && ( + + )} + +
+
+ +
+ {/* Input Area */} +
+

Start new task

+ +
+ + {/* Issues/Projects Area */} +
+
+

+ {issueQuery ? extractMilestoneFromQuery(issueQuery) : 'Issues'} +

+ {isReady && ( + + )} +
+ {isReady && ( +
+ {milestoneIssues.length || 0} issue{milestoneIssues.length !== 1 ? 's' : ''} +
+ )} +
+ {dashboardState?.state === 'loading' ? ( + + ) : isReady && !milestoneIssues.length ? ( + + ) : isReady ? ( + getSortedIssues(milestoneIssues).map((issue) => { + const associatedSession = findAssociatedSession(issue); + return ( + setHoveredIssue(issue)} + onHoverEnd={() => setHoveredIssue(null)} + currentBranch={currentBranch} + /> + ); + }) + ) : null} +
+
+ + {/* Tasks Area */} +
+
+

+ {isReady ? + `${activeSessions.length || 0} active task${activeSessions.length !== 1 ? 's' : ''}` : + 'Active tasks' + } +

+
+
+ {dashboardState?.state === 'loading' ? ( + + ) : isReady && !activeSessions.length ? ( + + ) : isReady ? ( + activeSessions.map((session, index) => ( + handleSessionClick(session)} + onPullRequestClick={handlePullRequestClick} + isHighlighted={hoveredIssue !== null && findAssociatedSession(hoveredIssue)?.id === session.id} + /> + )) + ) : null} +
+
+
+
+ ); +} diff --git a/webviews/dashboardView/components/ChatInput.tsx b/webviews/dashboardView/components/ChatInput.tsx new file mode 100644 index 0000000000..10383bba4e --- /dev/null +++ b/webviews/dashboardView/components/ChatInput.tsx @@ -0,0 +1,312 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* eslint-disable import/no-unresolved */ + +import Editor, { Monaco } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; +import React, { useCallback, useEffect, useState } from 'react'; +import { DashboardState } from '../types'; +import { vscode } from '../util'; +import { setupMonaco } from './monacoSupport'; + +export let suggestionDataSource: DashboardState | null = null; + +interface ChatInputProps { + data: DashboardState; + value: string; + onValueChange: (value: string) => void; + focusTrigger?: number; // Increment this to trigger focus + isSubmitting?: boolean; // Show progress spinner when true +} + +export const ChatInput: React.FC = ({ data, value, onValueChange, focusTrigger, isSubmitting = false }) => { + const [editor, setEditor] = useState(null); + const [showDropdown, setShowDropdown] = useState(false); + + // Focus the editor when focusTrigger changes + useEffect(() => { + if (focusTrigger !== undefined && editor) { + editor.focus(); + // Position cursor at the end + const model = editor.getModel(); + if (model) { + const position = model.getPositionAt(value.length); + editor.setPosition(position); + } + } + }, [focusTrigger, editor, value]); + + // Handle content changes from the editor + const handleEditorChange = useCallback((newValue: string | undefined) => { + onValueChange(newValue || ''); + }, [onValueChange]); + + const handleAgentClick = useCallback((agent: string) => { + let finalInput: string; + const currentInput = value.trim(); + + if (!currentInput) { + // Empty input - just set the agent + finalInput = agent; + } else { + // Check if input starts with an agent pattern + const agentMatch = currentInput.match(/^@(local|copilot)\s*/); + if (agentMatch) { + // Replace existing agent with the clicked one + finalInput = agent + currentInput.substring(agentMatch[0].length); + } else { + // No agent at start - prepend the clicked agent + finalInput = agent + currentInput; + } + } + + onValueChange(finalInput); + if (editor) { + editor.focus(); + // Position cursor at the end + const model = editor.getModel(); + if (model) { + const position = model.getPositionAt(finalInput.length); + editor.setPosition(position); + } + } + }, [value, editor, onValueChange]); + + const handleSendChat = useCallback(() => { + if (value.trim()) { + const trimmedInput = value.trim(); + + // Send all chat input to the provider for processing + vscode.postMessage({ + command: 'submit-chat', + args: { query: trimmedInput } + }); + + // Don't clear the input here - it will be cleared when submission completes + } + }, [value]); + + + + // Handle dropdown option for planning task with local agent + const handlePlanWithLocalAgent = useCallback(() => { + if (value.trim()) { + const trimmedInput = value.trim(); + // Remove @copilot prefix for planning with local agent and add @local prefix + const cleanQuery = trimmedInput.replace(/@copilot\s*/, '').trim(); + const localQuery = `@local ${cleanQuery}`; + + // Send command to submit chat with local agent prefix + vscode.postMessage({ + command: 'submit-chat', + args: { query: localQuery } + }); + + // Don't clear the input here - it will be cleared when submission completes + setShowDropdown(false); + } + }, [value]); + + // Handle clicking outside dropdown to close it + useEffect(() => { + const handleClickOutside = (event: Event) => { + const target = event.target as HTMLElement; + if (!target.closest('.send-button-container')) { + setShowDropdown(false); + } + }; + + if (showDropdown) { + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + } + }, [showDropdown]); + + // Setup editor instance when it mounts + const handleEditorDidMount = useCallback((editorInstance: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) => { + setEditor(editorInstance); + + // Auto-resize editor based on content + const updateHeight = () => { + const model = editorInstance.getModel(); + if (model) { + const lineCount = model.getLineCount(); + const lineHeight = editorInstance.getOption(monaco.editor.EditorOption.lineHeight); + const containerHeight = Math.min(Math.max(lineCount * lineHeight + 16, 60), window.innerHeight * 0.3); // 16px for padding, min 60px, max 30vh + + const container = editorInstance.getContainerDomNode(); + if (container) { + container.style.height = containerHeight + 'px'; + editorInstance.layout(); + } + } + }; + + // Update height on content change + editorInstance.onDidChangeModelContent(() => { + requestAnimationFrame(updateHeight); + }); + + // Initial height adjustment + requestAnimationFrame(updateHeight); + + // Handle keyboard shortcuts + editorInstance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { + handleSendChat(); + }); + + // Ensure paste command is available + editorInstance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => { + editorInstance.trigger('keyboard', 'editor.action.clipboardPasteAction', null); + }); + + // Focus the editor to ensure it can receive paste events + editorInstance.focus(); + }, [handleSendChat]); + + useEffect(() => { + suggestionDataSource = data; + }, [data]); + + return <> +
+
+ + {isCopilotCommand(value) ? ( +
+ + + {showDropdown && ( +
+ +
+ )} +
+ ) : ( + + )} +
+
+ +
+
+
+

+ Reference issues: Use #123 to start work on specific issues in this repo +

+

+ Choose your agent: Use handleAgentClick('@local ')} + title="Click to add @local to input" + >@local to work locally or handleAgentClick('@copilot ')} + title="Click to add @copilot to input" + >@copilot to use GitHub Copilot +

+
+
+
+ ; +}; +// Helper function to detect @copilot syntax +function isCopilotCommand(text: string): boolean { + return text.trim().startsWith('@copilot'); +} + +// Helper function to detect @local syntax +function isLocalCommand(text: string): boolean { + return text.trim().startsWith('@local'); +} + +setupMonaco(); diff --git a/webviews/dashboardView/components/EmptyState.tsx b/webviews/dashboardView/components/EmptyState.tsx new file mode 100644 index 0000000000..33997e5d36 --- /dev/null +++ b/webviews/dashboardView/components/EmptyState.tsx @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React from 'react'; + +interface EmptyStateProps { + message: string; +} + +export const EmptyState: React.FC = ({ message }) => { + return ( +
+ {message} +
+ ); +}; \ No newline at end of file diff --git a/webviews/dashboardView/components/IssueItem.tsx b/webviews/dashboardView/components/IssueItem.tsx new file mode 100644 index 0000000000..76b633745e --- /dev/null +++ b/webviews/dashboardView/components/IssueItem.tsx @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React from 'react'; +import { IssueData, SessionData } from '../types'; +import { formatDate, formatFullDateTime } from '../util'; + +interface IssueItemProps { + issue: IssueData; + onIssueClick: (issueUrl: string) => void; + onPopulateLocalInput: (issue: IssueData, event: React.MouseEvent) => void; + onPopulateRemoteInput: (issue: IssueData, event: React.MouseEvent) => void; + onSwitchToLocalTask?: (branchName: string, event: React.MouseEvent) => void; + associatedSession?: SessionData; + onSessionClick?: (session: SessionData) => void; + onPullRequestClick?: (pullRequest: { number: number; title: string; url: string }) => void; + onHover?: () => void; + onHoverEnd?: () => void; + currentBranch?: string; +} + +export const IssueItem: React.FC = ({ + issue, + onIssueClick, + onPopulateLocalInput, + onPopulateRemoteInput, + onSwitchToLocalTask, + associatedSession, + onSessionClick, + onPullRequestClick, + onHover, + onHoverEnd, + currentBranch, +}) => { + // Check if we're currently on the branch for this issue + const isOnIssueBranch = currentBranch && issue.localTaskBranch && currentBranch === issue.localTaskBranch; + return ( +
onIssueClick(issue.url)} + onMouseEnter={onHover} + onMouseLeave={onHoverEnd} + > +
+
+
+ #{issue.number}: {issue.title} +
+
+ {associatedSession ? ( +
+ {associatedSession.pullRequest ? ( + + ) : ( + + )} +
+ ) : isOnIssueBranch ? ( +
+ + + Active + +
+ ) : issue.localTaskBranch ? ( +
+ {issue.pullRequest ? ( + + ) : ( + + )} +
+ ) : ( +
+ + +
+ )} +
+
+ {issue.assignee && ( +
+ + {issue.assignee} +
+ )} + {issue.milestone && ( +
+ + {issue.milestone} +
+ )} +
+ Updated {formatDate(issue.updatedAt)} +
+
+
+ ); +}; diff --git a/webviews/dashboardView/components/LoadingState.tsx b/webviews/dashboardView/components/LoadingState.tsx new file mode 100644 index 0000000000..336e0adb43 --- /dev/null +++ b/webviews/dashboardView/components/LoadingState.tsx @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React from 'react'; + +interface LoadingStateProps { + message: string; +} + +export const LoadingState: React.FC = ({ message }) => { + return ( +
+ + {message} +
+ ); +}; \ No newline at end of file diff --git a/webviews/dashboardView/components/QuickActions.tsx b/webviews/dashboardView/components/QuickActions.tsx new file mode 100644 index 0000000000..9f0663d0dc --- /dev/null +++ b/webviews/dashboardView/components/QuickActions.tsx @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React from 'react'; +import { vscode } from '../util'; + +export const QuickActions: React.FC = () => { + const handleNewFile = () => { + vscode.postMessage({ command: 'new-file' }); + }; + + const handleOpenFolder = () => { + vscode.postMessage({ command: 'open-folder' }); + }; + + const handleCloneRepository = () => { + vscode.postMessage({ command: 'clone-repository' }); + }; + + const handleConnectTo = () => { + vscode.postMessage({ command: 'connect-to' }); + }; + + const handleGenerateWorkspace = () => { + vscode.postMessage({ command: 'generate-workspace' }); + }; + + return ( +
+
+ + New File... +
+
+ + Open... +
+
+ + Clone Git Repository... +
+
+ + Connect to... +
+
+ + Generate New Workspace... +
+
+ ); +}; \ No newline at end of file diff --git a/webviews/dashboardView/components/SessionItem.tsx b/webviews/dashboardView/components/SessionItem.tsx new file mode 100644 index 0000000000..c26f318b7b --- /dev/null +++ b/webviews/dashboardView/components/SessionItem.tsx @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React from 'react'; +import { SessionData } from '../types'; +import { formatDate, formatFullDateTime, vscode } from '../util'; + +interface SessionItemProps { + session: SessionData; + index: number; + onSessionClick: () => void; + onPullRequestClick: (pullRequest: { number: number; title: string; url: string }) => void; + isHighlighted?: boolean; +} + +const handleLocalTaskClick = (session: SessionData) => { + if (session.isLocal && session.id.startsWith('local-')) { + const branchName = session.id.replace('local-', ''); + vscode.postMessage({ + command: 'switch-to-local-task', + args: { branchName } + }); + } +}; + +export const SessionItem: React.FC = ({ + session, + index, + onSessionClick, + onPullRequestClick, + isHighlighted = false, +}) => { + return ( +
handleLocalTaskClick(session) : onSessionClick} + title={session.isTemporary ? + 'Task is being created...' : + session.isLocal ? + `Click to switch to local task branch${session.isCurrentBranch ? ' (Current Branch)' : ''}` : + session.pullRequest ? + `Click to open pull request #${session.pullRequest.number} and chat session${session.isCurrentBranch ? ' (Current Branch)' : ''}` : + `Click to open chat session${session.isCurrentBranch ? ' (Current Branch)' : ''}` + } + > +
+ {session.isCurrentBranch && ( + + + + )} + {!session.isTemporary && ( + + + + )} + {session.title} +
+
+ {(session.isTemporary || !session.isLocal) && ( +
+ {session.isTemporary ? ( + + + {session.status} + + ) : ( + + {(session.status === '2' || session.status?.toLowerCase() === 'in progress') && ( + + )} + {(session.status === '1' || session.status?.toLowerCase() === 'completed') && ( + + )} + {formatStatus(session.status, index)} + + )} + {formatDate(session.dateCreated)} +
+ )} + {session.isLocal && ( +
+ {formatDate(session.dateCreated)} +
+ )} +
+ {session.isLocal && session.branchName && ( +
+ + {session.branchName} +
+ )} + {session.pullRequest && ( +
+ +
+ )} + {session.isLocal && session.isCurrentBranch && !session.pullRequest && ( +
+ +
+ )} +
+
+
+ ); +}; + +const formatStatus = (status: string, index?: number) => { + // Show 'needs clarification' for the first active task + if (index === 0 && (status === '1' || status?.toLowerCase() === 'completed')) { + return 'Needs clarification'; + } + + switch (status?.toLowerCase()) { + case '0': + case 'failed': + return 'Failed'; + case '1': + case 'completed': + return 'Ready for review'; + case '2': + case 'in progress': + return 'In Progress'; + default: + return status || 'Unknown'; + } +}; + +const getStatusBadgeClass = (status: string) => { + switch (status?.toLowerCase()) { + case '1': + case 'completed': + return 'status-badge status-completed'; + case '2': + case 'in progress': + return 'status-badge status-in-progress'; + case '0': + case 'failed': + return 'status-badge status-failed'; + default: + return 'status-badge status-unknown'; + } +}; diff --git a/webviews/dashboardView/components/SortDropdown.tsx b/webviews/dashboardView/components/SortDropdown.tsx new file mode 100644 index 0000000000..7d97488090 --- /dev/null +++ b/webviews/dashboardView/components/SortDropdown.tsx @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React from 'react'; + +interface SortDropdownProps { + issueSort: 'date-oldest' | 'date-newest'; + onSortChange: (sortType: 'date-oldest' | 'date-newest') => void; +} + +export const SortDropdown: React.FC = ({ + issueSort, + onSortChange +}) => { + return ( +
+ +
+ ); +}; diff --git a/webviews/dashboardView/components/monacoSupport.ts b/webviews/dashboardView/components/monacoSupport.ts new file mode 100644 index 0000000000..2364632baa --- /dev/null +++ b/webviews/dashboardView/components/monacoSupport.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* eslint-disable import/no-unresolved */ + +import { loader } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; +// @ts-expect-error - Worker imports with ?worker suffix are handled by bundler +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import { DashboardReady } from '../types'; +import { suggestionDataSource } from './ChatInput'; + +/** + * Language id used for that chat input on the dashboard + */ +const inputLanguageId = 'taskInput'; + +export function setupMonaco() { + (self as any).MonacoEnvironment = { + getWorker(_: string, _label: string): Worker { + // Only support generic editor worker + return new editorWorker(); + }, + }; + + // Configure Monaco loader - use local monaco instance to avoid worker conflicts + loader.config({ monaco, }); + + // Register language for input + monaco.languages.register({ id: inputLanguageId }); + + // Define syntax highlighting rules + monaco.languages.setMonarchTokensProvider(inputLanguageId, { + tokenizer: { + root: [ + [/@(copilot|local)\b/, 'copilot-keyword'], + [/#\d+/, 'issue-reference'], + [/\w+\/\w+#\d+/, 'issue-reference'], + ] + } + }); + + // Define theme colors + monaco.editor.defineTheme('taskInputTheme', { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'copilot-keyword', foreground: '569cd6', fontStyle: 'bold' }, + { token: 'issue-reference', foreground: 'ffd700' }, + ], + colors: {} + }); + + // Setup autocomplete provider + monaco.languages.registerCompletionItemProvider(inputLanguageId, { + triggerCharacters: ['#', '@'], + provideCompletionItems: (model, position) => { + const textUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column + }); + + // Check if user is typing after # + const hashMatch = textUntilPosition.match(/#\d*$/); + if (hashMatch) { + const suggestions = (suggestionDataSource?.state === 'ready') + ? (suggestionDataSource as DashboardReady).milestoneIssues.map((issue: any): monaco.languages.CompletionItem => ({ + label: `#${issue.number}`, + kind: monaco.languages.CompletionItemKind.Reference, + insertText: `#${issue.number}`, + detail: issue.title, + documentation: `Issue #${issue.number}: ${issue.title}\nAssignee: ${issue.assignee || 'None'}\nMilestone: ${issue.milestone || 'None'}`, + range: { + startLineNumber: position.lineNumber, + startColumn: position.column - hashMatch[0].length, + endLineNumber: position.lineNumber, + endColumn: position.column + } + })) : []; + + return { suggestions }; + } + + // Provide @copilot and @local suggestions + if (textUntilPosition.match(/@\w*$/)) { + return { + suggestions: [{ + label: '@copilot', + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: 'copilot ', + detail: 'Start a new remote Copilot task', + documentation: 'Begin a task description that will be sent to Copilot to work remotely on GitHub', + range: { + startLineNumber: position.lineNumber, + startColumn: Math.max(1, position.column - (textUntilPosition.match(/@\w*$/)?.[0]?.length || 0)), + endLineNumber: position.lineNumber, + endColumn: position.column + } + }, { + label: '@local', + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: 'local ', + detail: 'Start a new local task', + documentation: 'Begin a task description that will create a new branch and work locally in your environment', + range: { + startLineNumber: position.lineNumber, + startColumn: Math.max(1, position.column - (textUntilPosition.match(/@\w*$/)?.[0]?.length || 0)), + endLineNumber: position.lineNumber, + endColumn: position.column + } + }] + }; + } + + return { suggestions: [] }; + } + }); +} \ No newline at end of file diff --git a/webviews/dashboardView/index.css b/webviews/dashboardView/index.css new file mode 100644 index 0000000000..e9d4edb6c4 --- /dev/null +++ b/webviews/dashboardView/index.css @@ -0,0 +1,1033 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.session-item.highlighted { + background-color: var(--vscode-list-focusBackground); +} + +html, body, .app { + min-height: 100%; +} + +body { + min-height: 400px; + padding: 16px; + padding-top: 12px; +} + +.dashboard-container { + max-width: 1400px; + margin: 0 auto; + gap: 8px; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1em; +} + +.dashboard-title { + font-size: 1.5rem; + font-weight: 400; + margin: 0; +} + +/* Header buttons container */ +.header-buttons { + display: flex; + align-items: center; + gap: 8px; +} + +/* Switch to main button */ +.switch-to-main-button { + background: transparent; + border: 1px solid var(--vscode-button-border); + color: var(--vscode-button-foreground); + cursor: pointer; + padding: 4px 8px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 11px; + transition: background-color 0.2s, border-color 0.2s; + white-space: nowrap; + height: 28px; +} + +.switch-to-main-button:hover { + background-color: var(--vscode-button-hoverBackground); + border-color: var(--vscode-button-hoverBackground); + color: var(--vscode-button-foreground); +} + +.switch-to-main-button .codicon { + font-size: 12px; +} + +.refresh-button { + background: transparent !important; + border: none !important; + color: var(--vscode-icon-foreground); + padding: 6px; + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; + width: 28px; + height: 28px; + box-shadow: none !important; + outline: none !important; +} + +.refresh-button:hover:not(:disabled) { + background-color: var(--vscode-toolbar-hoverBackground) !important; + color: var(--vscode-foreground); +} + +.refresh-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.codicon-modifier-spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Section loading styles */ +.section-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + color: var(--vscode-descriptionForeground); + font-size: 14px; +} + +/* Section count indicator */ +.section-count { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-bottom: 8px; +} + +/* Clickable count indicator */ +.section-count.clickable-count { + cursor: pointer; + transition: color 0.2s; + text-decoration: underline; + text-decoration-style: dotted; +} + +.section-count.clickable-count:hover { + color: var(--vscode-textLink-foreground); +} + +/* Section header with count and sort dropdown */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: -8px; + margin-bottom: 12px; + padding-right: 4px; +} + +.section-header .section-count { + margin: 0; + padding: 0; +} + +/* Sort dropdown */ +.sort-dropdown { + display: flex; + align-items: center; +} + +.sort-select { + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + color: var(--vscode-input-foreground); + font-size: 12px; + font-family: var(--vscode-font-family); + padding: 4px 8px; + border-radius: 2px; + cursor: pointer; + outline: none; + min-width: 140px; + appearance: none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23999' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 8px center; + background-repeat: no-repeat; + background-size: 12px; + padding-right: 28px; +} + +.sort-select:focus { + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 1px var(--vscode-focusBorder); +} + +.sort-select:hover { + background-color: var(--vscode-input-background); + border-color: var(--vscode-inputOption-hoverBackground); +} + +.sort-select option { + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + padding: 4px 8px; +} + +.dashboard-content { + width: 100%; + display: grid; + grid-template-columns: calc(50% - 8px) calc(50% - 8px); + grid-template-rows: auto 1fr; + grid-template-areas: + "input tasks" + "issues tasks"; + gap: 16px; +} + +.dashboard-column { + display: flex; + flex-direction: column; + min-width: 0; + gap: 8px; +} + +.input-area { + grid-area: input; + display: flex; + flex-direction: column; + gap: 4px; +} + +.issues-area { + grid-area: issues; + display: flex; + flex-direction: column; +} + +.tasks-area { + grid-area: tasks; + display: flex; + flex-direction: column; +} + +.area-header { + font-size: 13px; + font-weight: 600; + margin: 0; + color: var(--vscode-foreground); +} + +.area-header-container { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.filter-button { + background: transparent; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + padding: 4px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.filter-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.filter-button:active { + background-color: var(--vscode-toolbar-activeBackground); +} + +.filter-button .codicon { + font-size: 14px; +} + +.filter-dropdown { + position: relative; + display: inline-block; +} + +.filter-dropdown-menu { + position: absolute; + top: 100%; + right: 0; + background: var(--vscode-menu-background); + border: 1px solid var(--vscode-menu-border); + border-radius: 3px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + z-index: 1000; + min-width: 120px; + padding: 4px 0; +} + +.filter-dropdown-item { + display: flex; + align-items: center; + padding: 6px 12px; + cursor: pointer; + color: var(--vscode-menu-foreground); + transition: background-color 0.2s ease; +} + +.filter-dropdown-item:hover { + background-color: var(--vscode-menu-selectionBackground); + color: var(--vscode-menu-selectionForeground); +} + +.filter-dropdown-item .codicon { + margin-right: 8px; + font-size: 12px; +} + +.filter-dropdown-label { + font-size: 13px; +} + +.area-content { + flex: 1; + overflow-y: auto; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + margin-top: 4px; + padding: 0; +} + +.milestone-header { + cursor: help; + display: inline-block; + margin-top: 8px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.session-item, .issue-item { + padding: 12px; + border-bottom: 1px solid var(--vscode-panel-border); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 4px; +} + +.session-item:hover, .issue-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +/* Highlighting for linked issue-session pairs */ +.session-item.highlighted { + background-color: var(--vscode-list-hoverBackground); +} + +.session-item:last-child, .issue-item:last-child { + border-bottom: none; +} + +/* Current branch session styles - yellow border for active task */ +.session-item.current-branch { + border-left: 3px solid var(--vscode-testing-iconQueued); + background-color: rgba(255, 193, 7, 0.05); +} + +.session-item.current-branch:hover { + background-color: rgba(255, 193, 7, 0.1); +} + +.current-branch-indicator { + display: inline-flex; + align-items: center; + margin-right: 0.5em; + color: var(--vscode-gitDecoration-modifiedResourceForeground); + font-size: 14px; +} + +.current-branch-indicator .codicon { + font-size: 14px; +} + +.item-title { + font-weight: 500; + color: var(--vscode-foreground); + margin-bottom: 4px; + display: flex; + align-items: start; + justify-content: space-between; +} + +.remote-agent-button { + background: transparent; + border: none; + color: var(--vscode-button-foreground); + cursor: pointer; + padding: 4px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s, background-color 0.2s; + margin-left: 8px; + flex-shrink: 0; +} + +.issue-item:hover .remote-agent-button { + opacity: 1; +} + +.remote-agent-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.remote-agent-button .codicon { + font-size: 14px; +} + +.task-buttons { + display: flex; + align-items: center; + gap: 4px; + margin-left: 8px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.2s; +} + +.session-actions { + display: flex; + align-items: center; + gap: 4px; + margin-left: 8px; + flex-shrink: 0; + opacity: 1; +} + +.issue-item:hover .task-buttons { + opacity: 1; +} + +.session-link-button, +.session-start-button, +.local-task-button, +.coding-agent-task-button, +.switch-branch-button { + background: transparent; + border: 1px solid var(--vscode-button-border); + color: var(--vscode-button-foreground); + cursor: pointer; + padding: 3px 6px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + font-size: 11px; + transition: background-color 0.2s, border-color 0.2s; + white-space: nowrap; +} + +.session-start-button:hover, +.local-task-button:hover { + background-color: var(--vscode-button-hoverBackground); + border-color: var(--vscode-button-hoverBackground); + color: var(--vscode-button-foreground); +} + +.coding-agent-task-button:hover { + background-color: var(--vscode-button-hoverBackground); + border-color: var(--vscode-button-hoverBackground); + color: var(--vscode-button-foreground); +} + +.session-link-button { + background-color: var(--vscode-button-secondaryBackground); + border-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); +} + +.session-link-button:hover { + background-color: var(--vscode-button-secondaryHoverBackground); + border-color: var(--vscode-button-secondaryHoverBackground); + color: var(--vscode-button-secondaryForeground); +} + +.session-link-button .codicon, +.session-start-button .codicon, +.local-task-button .codicon, +.coding-agent-task-button .codicon { + font-size: 12px; +} + +.pr-link-button { + background: transparent; + border: 1px solid var(--vscode-button-secondaryBorder); + color: var(--vscode-button-secondaryForeground); + cursor: pointer; + padding: 4px 8px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 11px; + font-weight: 500; +} + +.pr-link-button:hover { + opacity: 0.8; +} + +.item-metadata { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.metadata-item { + display: flex; + align-items: center; + gap: 4px; +} + +.metadata-item-right { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.metadata-item .codicon { + margin-right: 2px; +} + +.metadata-item.status-and-date { + gap: 8px; +} + +.metadata-item.status-and-date .session-date { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.status-badge { + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 2px; +} + +.status-completed { + background-color: rgba(255, 255, 255, 0.05); + color: var(--vscode-foreground); + border: 1px solid var(--vscode-panel-border); +} + +.status-in-progress { + background-color: var(--vscode-testing-iconQueued); + color: var(--vscode-foreground); +} + +.status-failed { + background-color: var(--vscode-testing-iconFailed); + color: var(--vscode-foreground); +} + +.status-needs-clarification { + background-color: var(--vscode-editorWarning-foreground); + color: var(--vscode-editor-background); +} + +.status-creating { + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border: 1px solid var(--vscode-badge-background); +} + +.status-active { + background-color: var(--vscode-testing-iconQueued); + color: var(--vscode-editor-background); + border: 1px solid var(--vscode-testing-iconQueued); +} + +.status-available { + background-color: rgba(255, 255, 255, 0.1); + color: var(--vscode-foreground); + border: 1px solid var(--vscode-panel-border); +} + +.status-unknown { + background-color: rgba(255, 255, 255, 0.05); + color: var(--vscode-descriptionForeground); + border: 1px solid var(--vscode-panel-border); +} + +.temporary-session { + opacity: 0.8; + cursor: default; +} + +.temporary-session:hover { + background-color: var(--vscode-list-hoverBackground) !important; + cursor: default; +} + +/* Remove the old status-active-item styles as they're no longer used */ + +.task-type-indicator { + display: inline-flex; + align-items: center; + margin-right: 0.5em; + font-size: 12px; +} + +.task-type-indicator.local { + color: var(--vscode-gitDecoration-modifiedResourceForeground); +} + +.task-type-indicator.remote { + color: var(--vscode-charts-blue); +} + +.item-title-text { + flex: 1; +} + +.pull-request-link { + color: var(--vscode-textLink-foreground); + text-decoration: none; +} + +.pull-request-link:hover { + text-decoration: underline; +} + +.create-pr-button { + background: var(--vscode-button-secondaryBackground); + border: 1px solid var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + cursor: pointer; + padding: 3px 6px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + font-size: 11px; + transition: background-color 0.2s, border-color 0.2s; + white-space: nowrap; +} + +.create-pr-button:hover { + background-color: var(--vscode-button-secondaryHoverBackground); + border-color: var(--vscode-button-secondaryHoverBackground); + color: var(--vscode-button-secondaryForeground); +} + +.create-pr-button .codicon { + font-size: 12px; +} + +.active-badge { + background: rgba(255, 193, 7, 0.15); + color: var(--vscode-testing-iconQueued); + border: 1px solid var(--vscode-testing-iconQueued); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + display: inline-flex; + align-items: center; + cursor: default; + user-select: none; +} + +.branch-name { + color: var(--vscode-gitDecoration-modifiedResourceForeground); + font-family: var(--vscode-editor-font-family); + font-size: 11px; + font-weight: 500; +} + +.issue-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + margin-bottom: 4px; +} + +.chat-section { + margin-top: 4px; + max-height: 30vh; +} + +.chat-input-wrapper { + position: relative; + display: flex; + align-items: flex-end; +} + +.chat-input { + flex: 1; + min-height: 60px; + max-height: 120px; + padding: 8px 40px 8px 8px; /* Add right padding for the inline button */ + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + background-color: transparent; + color: var(--vscode-input-foreground); + font-family: var(--vscode-font-family); + resize: vertical; +} + +.chat-input:focus { + outline: none; + border-color: var(--vscode-focusBorder); +} + +.chat-input.copilot-command { + border-color: var(--vscode-editorInfo-border); + background-color: var(--vscode-editorInfo-background); +} + +.copilot-hint { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + padding: 4px 8px; + background-color: var(--vscode-editorInfo-background); + border: 1px solid var(--vscode-editorInfo-border); + border-radius: 3px; + font-size: 12px; + color: var(--vscode-editorInfo-foreground); +} + +.copilot-hint .codicon { + color: var(--vscode-editorInfo-foreground); +} + +.issue-references { + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +/* Monaco Editor styles */ +.monaco-input-wrapper { + position: relative; + display: flex; + align-items: flex-end; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + padding: 8px; + transition: border-color 0.2s; +} + +.monaco-input-wrapper:hover { + border-color: var(--vscode-input-border); +} + +.monaco-input-wrapper:focus-within { + border-color: #007acc; +} + +.monaco-editor-container { + flex: 1; + min-height: 60px; + max-height: 30vh; + padding-right: 100px; /* Space for send button with text */ +} + +/* Suppress Monaco Editor's internal border flashing */ +.monaco-input-wrapper .monaco-editor, +.monaco-input-wrapper .monaco-editor .monaco-editor-background, +.monaco-input-wrapper .monaco-editor .monaco-scrollable-element, +.monaco-input-wrapper .monaco-editor .view-lines { + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.monaco-input-wrapper .monaco-editor .monaco-editor-background { + background: transparent !important; +} + +.monaco-input-wrapper .send-button-inline { + position: absolute; + right: 8px; + bottom: 8px; + min-width: 28px; + height: 28px; + padding: 0 8px; + border: none !important; + border-radius: 0 !important; + background-color: transparent !important; + color: var(--vscode-button-foreground); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: none !important; + outline: none !important; + white-space: nowrap; + font-family: var(--vscode-font-family); +} + +.monaco-input-wrapper .send-button-inline:hover:not(:disabled) { + background-color: transparent !important; + border-radius: 0 !important; +} + +.monaco-input-wrapper .send-button-inline:disabled { + background-color: transparent; + color: var(--vscode-button-secondaryForeground); + cursor: not-allowed; + opacity: 0.6; +} + +/* Send button container for split button */ +.send-button-container { + position: absolute; + right: 14px; + bottom: 6px; + display: flex; + height: 28px; +} + +.monaco-input-wrapper .send-button-inline.split-left { + border-radius: 3px 0 0 3px !important; + border-right: 1px solid var(--vscode-button-separator) !important; + position: relative; + right: auto; + bottom: auto; + height: 100%; +} + +.monaco-input-wrapper .send-button-inline.split-right { + border-radius: 0 3px 3px 0 !important; + width: 24px !important; + min-width: 24px !important; + padding: 0 4px !important; + position: relative; + right: auto; + bottom: auto; + height: 100%; +} + +/* Dropdown menu */ +.dropdown-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background-color: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 1000; + min-width: 200px; +} + +.dropdown-item { + display: flex !important; + align-items: center !important; + width: 100% !important; + padding: 8px 12px !important; + border: none !important; + background: transparent !important; + color: var(--vscode-dropdown-foreground) !important; + cursor: pointer !important; + font-size: 13px !important; + text-align: left !important; + border-radius: 0 !important; + box-shadow: none !important; + outline: none !important; + white-space: nowrap !important; +} + +.dropdown-item:hover { + background-color: var(--vscode-list-hoverBackground) !important; +} + +.dropdown-item:first-child { + border-radius: 3px 3px 0 0 !important; +} + +.dropdown-item:last-child { + border-radius: 0 0 3px 3px !important; +} + +.dropdown-item .codicon { + margin-right: 8px !important; + font-size: 14px !important; +} + +.empty-state { + text-align: center; + color: var(--vscode-descriptionForeground); + padding: 24px; + font-style: italic; +} + +.quick-action-button { + display: flex; + align-items: center; + padding: 8px 12px; + margin-bottom: 4px; + border-radius: 2px; + cursor: pointer; + border: 1px solid var(--vscode-button-secondaryBorder); + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + transition: background-color 0.1s ease; + font-family: var(--vscode-font-family); + font-size: 13px; +} + +.quick-action-button:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} + +.quick-action-button .codicon { + margin-right: 8px; +} + +.loading-indicator { + grid-column: 1 / -1; + grid-row: 1 / -1; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + text-align: center; + color: var(--vscode-descriptionForeground); +} + +.error-message { + color: var(--vscode-errorForeground); + background-color: var(--vscode-inputValidation-errorBackground); + border: 1px solid var(--vscode-inputValidation-errorBorder); + padding: 8px; + border-radius: 4px; + margin: 8px 0; +} + +/* Instructions */ +.global-instructions { + margin-top: 8px; + font-size: 80%; +} + +.instructions-content p { + margin: 0; + color: var(--vscode-descriptionForeground); + line-height: 1.3; + padding-bottom: 0.6em; +} + +.instructions-content code { + background-color: var(--vscode-textCodeBlock-background); + padding: 2px 4px; + border-radius: 2px; + font-family: var(--vscode-editor-font-family); +} + +/* Input Area Separator */ +.input-separator { + height: 1px; + background-color: var(--vscode-widget-border); + margin: 12px 0; + opacity: 0.3; +} + +.quick-actions-grid { + display: grid; + grid-template-columns: 1fr; + gap: 4px; +} + +.quick-action-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + border-radius: 2px; + transition: background-color 0.2s ease; + font-size: 13px; +} + +.quick-action-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.quick-action-item .codicon { + font-size: 16px; +} + +.session-item:last-child, .issue-item:last-child { + border-bottom: none; +} + + + +@media (max-width: 600px) { + html, body, #app { + height: auto; + min-height: 100%; + } + + .dashboard-content { + grid-template-columns: 100%; + grid-template-rows: auto auto 1fr; + grid-template-areas: + "input" + "issues" + "tasks"; + overflow: visible; + } + + .input-area, + .issues-area, + .tasks-area { + overflow: visible; + } + +} diff --git a/webviews/dashboardView/index.ts b/webviews/dashboardView/index.ts new file mode 100644 index 0000000000..5b1a4bb0f1 --- /dev/null +++ b/webviews/dashboardView/index.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import '../common/common.css'; +import './index.css'; +import { main } from './app'; + +addEventListener('load', main); \ No newline at end of file diff --git a/webviews/dashboardView/tsconfig.json b/webviews/dashboardView/tsconfig.json new file mode 100644 index 0000000000..76bd8867c0 --- /dev/null +++ b/webviews/dashboardView/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react", + "lib": [ + "dom", + "dom.iterable", + "es2019" + ], + "tsBuildInfoFile": "../../tsconfig.webviews.tsbuildinfo" + } +} \ No newline at end of file diff --git a/webviews/dashboardView/types.ts b/webviews/dashboardView/types.ts new file mode 100644 index 0000000000..025d81ed9a --- /dev/null +++ b/webviews/dashboardView/types.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface SessionData { + readonly id: string; + readonly title: string; + readonly status: string; + readonly dateCreated: string; + readonly isCurrentBranch?: boolean; + readonly isTemporary?: boolean; + readonly isLocal?: boolean; + readonly branchName?: string; + readonly pullRequest?: { + readonly number: number; + readonly title: string; + readonly url: string; + }; +} + +export interface IssueData { + readonly number: number; + readonly title: string; + readonly assignee?: string; + readonly milestone?: string; + readonly state: string; + readonly url: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly localTaskBranch?: string; // Name of the local task branch if it exists + readonly pullRequest?: { + readonly number: number; + readonly title: string; + readonly url: string; + }; +} + +export type DashboardState = DashboardLoading | DashboardReady; + +export interface DashboardLoading { + readonly state: 'loading'; + readonly issueQuery: string; +} + +export interface DashboardReady { + readonly state: 'ready'; + readonly issueQuery: string; + readonly activeSessions: readonly SessionData[]; + readonly milestoneIssues: readonly IssueData[]; + readonly repository?: { + readonly owner: string; + readonly name: string; + }; + readonly currentBranch?: string; +} + + diff --git a/webviews/dashboardView/util.ts b/webviews/dashboardView/util.ts new file mode 100644 index 0000000000..25c7be6b72 --- /dev/null +++ b/webviews/dashboardView/util.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// eslint-disable-next-line rulesdir/no-any-except-union-method-signature +declare let acquireVsCodeApi: any; +export const vscode = acquireVsCodeApi(); + +export const formatDate = (dateString: string) => { + if (!dateString) { + return 'Unknown'; + } + + const date = new Date(dateString); + return date.toLocaleDateString(); +}; + +export const formatFullDateTime = (dateString: string) => { + if (!dateString) { + return 'Unknown'; + } + + const date = new Date(dateString); + return date.toLocaleString(); +}; + +export const extractMilestoneFromQuery = (query: string): string => { + if (!query) { + return 'Issues'; + } + + // Try to extract milestone from various formats: + // milestone:"name" or milestone:'name' or milestone:name + // Handle quoted milestones with spaces first + const quotedMatch = query.match(/milestone:["']([^"']+)["']/i); + if (quotedMatch && quotedMatch[1]) { + return quotedMatch[1]; + } + + // Handle unquoted milestones (no spaces) + const milestoneMatch = query.match(/milestone:([^\s]+)/i); + if (milestoneMatch && milestoneMatch[1]) { + return milestoneMatch[1]; + } + + // If no milestone found, return generic label + return 'Issues'; +}; diff --git a/yarn.lock b/yarn.lock index 961cf431f0..a7c8c36b8f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -453,6 +453,20 @@ resolved "https://registry.yarnpkg.com/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.7.tgz#ede48dd3f85af14ee369c805e5ed5b84222b9fe2" integrity sha512-SK3D3aVt+5vOOccKPnGaJWB5gQ8FuKfjboUJHedMP7gu54HqSCXX5iFXhktGD8nfJb0Go30eDvs/UDoTnR2kOA== +"@monaco-editor/loader@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.5.0.tgz#dcdbc7fe7e905690fb449bed1c251769f325c55d" + integrity sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw== + dependencies: + state-local "^1.0.6" + +"@monaco-editor/react@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.7.0.tgz#35a1ec01bfe729f38bfc025df7b7bac145602a60" + integrity sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA== + dependencies: + "@monaco-editor/loader" "^1.5.0" + "@nodelib/fs.scandir@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz#d4b3549a5db5de2683e0c1071ab4f140904bbf69" @@ -849,6 +863,11 @@ dependencies: "@types/node" "*" +"@types/trusted-types@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-1.0.6.tgz#569b8a08121d3203398290d602d84d73c8dcf5da" + integrity sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw== + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" @@ -4423,7 +4442,7 @@ loader-utils@^1.1.0: emojis-list "^3.0.0" json5 "^1.0.1" -loader-utils@^2.0.4: +loader-utils@^2.0.2, loader-utils@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== @@ -4835,6 +4854,20 @@ mocha@^9.0.1: yargs-parser "20.2.4" yargs-unparser "2.0.0" +monaco-editor-webpack-plugin@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.1.0.tgz#16f265c2b5dbb5fe08681b6b3b7d00d3c5b2ee97" + integrity sha512-ZjnGINHN963JQkFqjjcBtn1XBtUATDZBMgNQhDQwd78w2ukRhFXAPNgWuacaQiDZsUr4h1rWv5Mv6eriKuOSzA== + dependencies: + loader-utils "^2.0.2" + +monaco-editor@^0.53.0: + version "0.53.0" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.53.0.tgz#2f485492e0ee822be13b1b45e3092922963737ae" + integrity sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ== + dependencies: + "@types/trusted-types" "^1.0.6" + morgan@^1.6.1: version "1.10.0" resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" @@ -6119,6 +6152,11 @@ stack-chain@^1.3.7: resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-1.3.7.tgz#d192c9ff4ea6a22c94c4dd459171e3f00cea1285" integrity sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug== +state-local@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5" + integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w== + statuses@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"