diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index 3b445d90672..bb0d1a0b70a 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -26,6 +26,14 @@ Then read the relevant spec for the area you are changing (see table below). If ## Common Pitfalls +- **Paired experiment treatments must resolve atomically**: when a prompt and its editable placeholder are separate treatment values, use them only when both are non-empty; otherwise use both defaults so copy from different variants is never mixed. The prompt may omit the placeholder token entirely, in which case it is used literally and placeholder highlighting is simply absent. + +- **Onboarding variations share structural steps and vary only their run step**: keep one scenario for workspace selection, then resolve the experiment/developer variation when the run step executes. Personalized GitHub prompts use existing authentication silently, stay within a bounded cancellable lookup, verify that the selected draft workspace is still current, and fall back to the default prompt without surfacing an error. + +- **Agent-host onboarding readiness comes from advertised session types, not provider registration**: an agent-host provider exists before its root state connects, while its `sessionTypes` stay empty. Gate tours that need a usable host on a context key derived from any `local-agent-host`/`agenthost-*` provider exposing a session type, and update it from `ISessionsManagementService.onDidChangeSessionTypes`. + +- **Diagnostic log text is not a unit-test contract**: add consistently prefixed, actionable logs, but do not add tests that assert log messages or levels. Validate the underlying behavior and keep diagnostics free to evolve. + - **Shared visual-module gates must not activate broader layout contracts**: the Agents window may opt into shared editor-tab styles through a tab-specific root class, but must not apply the broad `style-override` class unless it also loads every matching Modern UI layout module. Keep shared tab runtime metrics aware of both gates, and preserve structural behavior such as a sticky add-tab action when removing a Sessions-owned stylesheet. Since the Agents workbench is always modern, chat-tab presentation belongs in the owning `chatCompositeBar.css`, scoped through `.session-chat-tabs-bar` and chat-specific classes while consuming shared state tokens; do not add Sessions selectors to or rewrite the shared editor stylesheet. - **Minimum-size activation across the Sessions/Editor split must be symmetric and layout-aware**: when either part is at minimum width, pointer or keyboard activation expands it by shrinking its sibling to minimum width. In single-pane layout, the Editor grid node's effective minimum includes the visible docked Auxiliary Bar width; using `editorPartView.minimumWidth` alone collapses Details. diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index f39217b6418..828b7dcbecc 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -85,6 +85,7 @@ export const SessionWorkspacePickerGroupContext = new RawContextKey('ses export const SessionWorkspacePickerVisibleContext = new RawContextKey('sessionWorkspacePickerVisible', false, localize('sessionWorkspacePickerVisible', "Whether the new-session view's workspace picker is rendered (as opposed to being replaced by the no-agent-host empty state)")); export const SessionHarnessPickerVisibleContext = new RawContextKey('sessionHarnessPickerVisible', false, localize('sessionHarnessPickerVisible', "Whether the new-session view's harness (session type) picker is visible — it is hidden when at most one harness can serve the selected workspace")); export const SessionIsolationPickerVisibleContext = new RawContextKey('sessionIsolationPickerVisible', false, localize('sessionIsolationPickerVisible', "Whether the new-session view's isolation picker is visible — it is shown only when the isolation option is enabled and the workspace has a git repository")); +export const AgentHostSessionTypesAvailableContext = new RawContextKey('agentHostSessionTypesAvailable', false, localize('agentHostSessionTypesAvailable', "Whether at least one connected agent-host provider has advertised session types")); //#endregion diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubRecentUserWorkFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubRecentUserWorkFetcher.ts new file mode 100644 index 00000000000..6387592281c --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubRecentUserWorkFetcher.ts @@ -0,0 +1,247 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { GitHubApiClient } from '../githubApiClient.js'; + +export interface IGitHubRecentIssue { + readonly number: number; + readonly title: string; + readonly url: string; + readonly updatedAt: string; +} + +export interface IGitHubRecentPullRequest { + readonly number: number; + readonly title: string; + readonly url: string; + readonly updatedAt: string; + readonly statusCheckRollupState: string | undefined; + readonly latestCommitAt: string | undefined; + readonly reviewThreads?: readonly IGitHubRecentPullRequestReviewThread[]; +} + +export interface IGitHubRecentPullRequestReviewThread { + readonly isResolved: boolean; + readonly latestCommentAt: string | undefined; +} + +export interface IGitHubRecentUserWork { + readonly issues: readonly IGitHubRecentIssue[]; + readonly pullRequests: readonly IGitHubRecentPullRequest[]; +} + +interface IGitHubRecentIssuesResponse { + readonly search: { + readonly nodes: readonly ({ + readonly __typename: 'Issue'; + readonly number: number; + readonly title: string; + readonly url: string; + readonly updatedAt: string; + } | null)[] | null; + }; +} + +interface IGitHubIssueLinkageResponse { + readonly repository: Record | null; +} + +interface IGitHubRecentPullRequestsResponse { + readonly search: { + readonly nodes: readonly ({ + readonly __typename: 'PullRequest'; + readonly number: number; + readonly title: string; + readonly url: string; + readonly updatedAt: string; + readonly commits: { + readonly nodes: readonly ({ + readonly commit: { + readonly committedDate: string; + readonly statusCheckRollup: { readonly state: string } | null; + }; + } | null)[] | null; + }; + } | null)[] | null; + }; +} + +interface IGitHubPullRequestReviewThreadsResponse { + readonly repository: { + readonly pullRequest: { + readonly reviewThreads: { + readonly nodes: readonly ({ + readonly isResolved: boolean; + readonly comments: { + readonly nodes: readonly ({ + readonly createdAt: string; + } | null)[] | null; + }; + } | null)[] | null; + }; + } | null; + } | null; +} + +const RECENT_ISSUES_QUERY = ` + query RecentAssignedIssues($query: String!) { + search(query: $query, type: ISSUE, first: 5) { + nodes { + ... on Issue { + __typename + number + title + url + updatedAt + } + } + } + } +`; + +const RECENT_PULL_REQUESTS_QUERY = ` + query RecentAuthoredPullRequests($query: String!) { + search(query: $query, type: ISSUE, first: 5) { + nodes { + ... on PullRequest { + __typename + number + title + url + updatedAt + commits(last: 1) { + nodes { + commit { + committedDate + statusCheckRollup { + state + } + } + } + } + } + } + } + } +`; + +const PULL_REQUEST_REVIEW_THREADS_QUERY = ` + query PullRequestReviewThreads($owner: String!, $repo: String!, $pullRequestNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pullRequestNumber) { + reviewThreads(first: 100) { + nodes { + isResolved + comments(last: 1) { + nodes { + createdAt + } + } + } + } + } + } + } +`; + +export class GitHubRecentUserWorkFetcher { + constructor(private readonly _apiClient: GitHubApiClient) { } + + async getRecentAssignedIssues(owner: string, repo: string, token: CancellationToken): Promise { + const data = await this._apiClient.graphql( + RECENT_ISSUES_QUERY, + 'githubApi.getRecentAssignedIssues', + { query: `repo:${owner}/${repo} is:issue is:open assignee:@me sort:updated-desc` }, + { token, createAuthenticationSession: false }, + ); + + return (data.search.nodes ?? []) + .filter(isDefined) + .map(issue => ({ number: issue.number, title: issue.title, url: issue.url, updatedAt: issue.updatedAt })); + } + + async getRecentAuthoredPullRequests(owner: string, repo: string, token: CancellationToken): Promise { + const data = await this._apiClient.graphql( + RECENT_PULL_REQUESTS_QUERY, + 'githubApi.getRecentAuthoredPullRequests', + { query: `repo:${owner}/${repo} is:pr is:open author:@me sort:updated-desc` }, + { token, createAuthenticationSession: false }, + ); + + return (data.search.nodes ?? []) + .filter(isDefined) + .map(pullRequest => { + const latestCommit = pullRequest.commits.nodes?.find(isDefined)?.commit; + return { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + updatedAt: pullRequest.updatedAt, + statusCheckRollupState: latestCommit?.statusCheckRollup?.state, + latestCommitAt: latestCommit?.committedDate, + }; + }); + } + + async getPullRequestReviewThreads(owner: string, repo: string, pullRequestNumber: number, token: CancellationToken): Promise { + const data = await this._apiClient.graphql( + PULL_REQUEST_REVIEW_THREADS_QUERY, + 'githubApi.getPullRequestReviewThreads', + { owner, repo, pullRequestNumber }, + { token, createAuthenticationSession: false }, + ); + + return (data.repository?.pullRequest?.reviewThreads.nodes ?? []) + .filter(isDefined) + .map(thread => ({ + isResolved: thread.isResolved, + latestCommentAt: thread.comments.nodes?.find(isDefined)?.createdAt, + })); + } + + async getIssuesWithLinkedPullRequests(owner: string, repo: string, issueNumbers: readonly number[], token: CancellationToken): Promise> { + if (issueNumbers.length === 0) { + return new Set(); + } + const issueVariables = issueNumbers.map((_, index) => `$issue${index}: Int!`).join(', '); + const issueSelections = issueNumbers.map((_, index) => ` + issue${index}: issue(number: $issue${index}) { + closedByPullRequestsReferences(first: 1, includeClosedPrs: true) { + totalCount + } + } + `).join(''); + const query = ` + query IssueLinkage($owner: String!, $repo: String!, ${issueVariables}) { + repository(owner: $owner, name: $repo) { + ${issueSelections} + } + } + `; + const variables: Record = { owner, repo }; + issueNumbers.forEach((issueNumber, index) => variables[`issue${index}`] = issueNumber); + const data = await this._apiClient.graphql( + query, + 'githubApi.getIssuesWithLinkedPullRequests', + variables, + { token, createAuthenticationSession: false }, + ); + + const linkedIssueNumbers = new Set(); + issueNumbers.forEach((issueNumber, index) => { + if ((data.repository?.[`issue${index}`]?.closedByPullRequestsReferences?.totalCount ?? 0) > 0) { + linkedIssueNumbers.add(issueNumber); + } + }); + return linkedIssueNumbers; + } +} + +function isDefined(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/src/vs/sessions/contrib/github/browser/githubApiClient.ts b/src/vs/sessions/contrib/github/browser/githubApiClient.ts index 9ab125a1613..41a21c46214 100644 --- a/src/vs/sessions/contrib/github/browser/githubApiClient.ts +++ b/src/vs/sessions/contrib/github/browser/githubApiClient.ts @@ -17,6 +17,8 @@ const GITHUB_GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`; export interface IGitHubApiRequestOptions { readonly data?: unknown; readonly etag?: string; + readonly token?: CancellationToken; + readonly createAuthenticationSession?: boolean; } export interface IGitHubApiResponse { @@ -45,6 +47,13 @@ export class GitHubApiError extends Error { } } +export class GitHubAuthenticationError extends Error { + constructor() { + super('No GitHub authentication sessions available'); + this.name = 'GitHubAuthenticationError'; + } +} + /** * Low-level GitHub REST API client. Handles authentication, * request construction, and error classification. @@ -66,14 +75,14 @@ export class GitHubApiClient extends Disposable { return this._request(method, `${GITHUB_API_BASE}${path}`, path, 'application/vnd.github.v3+json', callSite, options); } - async graphql(query: string, callSite: string, variables?: Record): Promise { + async graphql(query: string, callSite: string, variables?: Record, options?: Pick): Promise { const response = await this._request>( 'POST', GITHUB_GRAPHQL_ENDPOINT, '/graphql', 'application/vnd.github+json', callSite, - { data: { query, variables } } + { ...options, data: { query, variables } } ); if (response.data?.errors?.length) { @@ -92,7 +101,7 @@ export class GitHubApiClient extends Disposable { } private async _request(method: string, url: string, pathForLogging: string, accept: string, callSite: string, options?: IGitHubApiRequestOptions): Promise> { - const token = await this._getAuthToken(); + const token = await this._getAuthToken(options?.createAuthenticationSession !== false); this._logService.trace(`${LOG_PREFIX} ${method} ${pathForLogging}`); this._logService.trace(`${TRACE_PREFIX} [GitHubApiClient] -> ${method} ${pathForLogging} (callSite ${callSite}${options?.etag !== undefined ? `, ifNoneMatch ${options.etag}` : ''})`); @@ -111,7 +120,7 @@ export class GitHubApiClient extends Disposable { // Bypass the renderer HTTP cache so conditional polling reaches GitHub (see PR_ICON_POLLING.md). disableCache: true, callSite - }, CancellationToken.None); + }, options?.token ?? CancellationToken.None); const rateLimitRemaining = parseRateLimitHeader(response.res.headers?.['x-ratelimit-remaining']); if (rateLimitRemaining !== undefined && rateLimitRemaining < 100) { @@ -151,13 +160,13 @@ export class GitHubApiClient extends Disposable { return { data, statusCode, etag: responseETag }; } - private async _getAuthToken(): Promise { + private async _getAuthToken(createIfNone: boolean): Promise { let sessions = await this._authenticationService.getSessions('github', [], { silent: true }); - if (!sessions || sessions.length === 0) { + if ((!sessions || sessions.length === 0) && createIfNone) { sessions = await this._authenticationService.getSessions('github', [], { createIfNone: true }); } if (!sessions || sessions.length === 0) { - throw new Error('No GitHub authentication sessions available'); + throw new GitHubAuthenticationError(); } // Prefer a session with 'repo' scope, but fall back to the first available session diff --git a/src/vs/sessions/contrib/github/browser/githubService.ts b/src/vs/sessions/contrib/github/browser/githubService.ts index 9c772f87631..38baa57fd28 100644 --- a/src/vs/sessions/contrib/github/browser/githubService.ts +++ b/src/vs/sessions/contrib/github/browser/githubService.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -14,6 +15,7 @@ import { GitHubPullRequestReviewThreadsModel, GitHubPullRequestReviewThreadsMode import { GitHubPullRequestCIModel, GitHubPullRequestCIModelReferenceCollection } from './models/githubPullRequestCIModel.js'; import { GitHubIssueModel, GitHubIssueModelReferenceCollection } from './models/githubIssueModel.js'; import { GitHubChangesFetcher } from './fetchers/githubChangesFetcher.js'; +import { GitHubRecentUserWorkFetcher, IGitHubRecentIssue, IGitHubRecentPullRequest, IGitHubRecentPullRequestReviewThread } from './fetchers/githubRecentUserWorkFetcher.js'; import { getPullRequestKey } from '../common/utils.js'; import { derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; import { structuralEquals } from '../../../../base/common/equals.js'; @@ -73,6 +75,11 @@ export interface IGitHubService { * not cached, so a later retry can succeed once a PR is created. */ findPullRequestNumberByHeadBranch(owner: string, repo: string, branch: string): Promise; + + getRecentAssignedIssues(owner: string, repo: string, token: CancellationToken): Promise; + getRecentAuthoredPullRequests(owner: string, repo: string, token: CancellationToken): Promise; + getPullRequestReviewThreads(owner: string, repo: string, pullRequestNumber: number, token: CancellationToken): Promise; + getIssuesWithLinkedPullRequests(owner: string, repo: string, issueNumbers: readonly number[], token: CancellationToken): Promise>; } export const IGitHubService = createDecorator('sessionsGitHubService'); @@ -86,6 +93,7 @@ export class GitHubService extends Disposable implements IGitHubService { readonly activeSessionPullRequestReviewThreadsObs: IObservable; private readonly _changesFetcher: GitHubChangesFetcher; + private readonly _recentUserWorkFetcher: GitHubRecentUserWorkFetcher; private readonly _repositoryReferences: GitHubRepositoryModelReferenceCollection; private readonly _pullRequestReferences: GitHubPullRequestModelReferenceCollection; private readonly _pullRequestReviewThreadsReferences: GitHubPullRequestReviewThreadsModelReferenceCollection; @@ -113,6 +121,7 @@ export class GitHubService extends Disposable implements IGitHubService { this._apiClient = apiClient; this._changesFetcher = new GitHubChangesFetcher(apiClient); + this._recentUserWorkFetcher = new GitHubRecentUserWorkFetcher(apiClient); this._repositoryReferences = instantiationService.createInstance(GitHubRepositoryModelReferenceCollection, apiClient); this._pullRequestReferences = instantiationService.createInstance(GitHubPullRequestModelReferenceCollection, apiClient); @@ -209,6 +218,22 @@ export class GitHubService extends Disposable implements IGitHubService { return this._issueReferences.acquire(`${owner}/${repo}/issues/${issueNumber}`, owner, repo, issueNumber); } + getRecentAssignedIssues(owner: string, repo: string, token: CancellationToken): Promise { + return this._recentUserWorkFetcher.getRecentAssignedIssues(owner, repo, token); + } + + getRecentAuthoredPullRequests(owner: string, repo: string, token: CancellationToken): Promise { + return this._recentUserWorkFetcher.getRecentAuthoredPullRequests(owner, repo, token); + } + + getPullRequestReviewThreads(owner: string, repo: string, pullRequestNumber: number, token: CancellationToken): Promise { + return this._recentUserWorkFetcher.getPullRequestReviewThreads(owner, repo, pullRequestNumber, token); + } + + getIssuesWithLinkedPullRequests(owner: string, repo: string, issueNumbers: readonly number[], token: CancellationToken): Promise> { + return this._recentUserWorkFetcher.getIssuesWithLinkedPullRequests(owner, repo, issueNumbers, token); + } + getChangedFiles(owner: string, repo: string, base: string, head: string): Promise { return this._changesFetcher.getChangedFiles(owner, repo, base, head); } diff --git a/src/vs/sessions/contrib/github/common/utils.ts b/src/vs/sessions/contrib/github/common/utils.ts index 3fc3524214f..af03a7932e5 100644 --- a/src/vs/sessions/contrib/github/common/utils.ts +++ b/src/vs/sessions/contrib/github/common/utils.ts @@ -5,6 +5,7 @@ import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../../services/sessions/common/session.js'; import { IGitHubChangedFile } from './types.js'; export interface IPullRequestContentUriParams { @@ -28,3 +29,21 @@ export function toPRContentUri(fileName: string, params: IPullRequestContentUriP export function getPullRequestKey(owner: string, repo: string, prNumber: number): string { return `${owner}/${repo}/${prNumber}`; } + +export function getGitHubRepositoryFromUri(uri: URI): { readonly owner: string; readonly repo: string } | undefined { + if (uri.scheme !== GITHUB_REMOTE_FILE_SCHEME) { + return undefined; + } + const segments = uri.path.split('/').filter(Boolean); + if (segments.length < 2) { + return undefined; + } + try { + return { + owner: decodeURIComponent(segments[0]), + repo: decodeURIComponent(segments[1]), + }; + } catch { + return undefined; + } +} diff --git a/src/vs/sessions/contrib/github/test/browser/githubApiClient.test.ts b/src/vs/sessions/contrib/github/test/browser/githubApiClient.test.ts index 810177ebe95..c50da1fc619 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubApiClient.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubApiClient.test.ts @@ -12,8 +12,8 @@ import { IRequestContext, IRequestOptions } from '../../../../../base/parts/requ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IRequestCompleteEvent, IRequestService } from '../../../../../platform/request/common/request.js'; -import { AuthenticationSession, IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; -import { GitHubApiClient } from '../../browser/githubApiClient.js'; +import { AuthenticationSession, IAuthenticationGetSessionsOptions, IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; +import { GitHubApiClient, GitHubAuthenticationError } from '../../browser/githubApiClient.js'; /** * Captures the options passed to {@link IRequestService.request} and returns a @@ -40,14 +40,17 @@ class FakeRequestService extends Disposable implements Partial class FakeAuthenticationService implements Partial { readonly _serviceBrand: undefined; + readonly getSessionsOptions: (IAuthenticationGetSessionsOptions | undefined)[] = []; + sessions: readonly AuthenticationSession[] = [{ + id: 'session-1', + accessToken: 'token-123', + account: { id: 'account-1', label: 'octocat' }, + scopes: ['repo'], + }]; - async getSessions(): Promise { - return [{ - id: 'session-1', - accessToken: 'token-123', - account: { id: 'account-1', label: 'octocat' }, - scopes: ['repo'], - }]; + async getSessions(...args: Parameters): Promise { + this.getSessionsOptions.push(args[2]); + return this.sessions; } } @@ -55,13 +58,15 @@ suite('GitHubApiClient', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let requestService: FakeRequestService; + let authenticationService: FakeAuthenticationService; let client: GitHubApiClient; setup(() => { requestService = store.add(new FakeRequestService()); + authenticationService = new FakeAuthenticationService(); client = store.add(new GitHubApiClient( requestService as unknown as IRequestService, - new FakeAuthenticationService() as unknown as IAuthenticationService, + authenticationService as unknown as IAuthenticationService, new NullLogService(), )); }); @@ -88,4 +93,15 @@ suite('GitHubApiClient', () => { { statusCode: 304, data: undefined, etag: '"etag-2"' }, ); }); + + test('does not create an authentication session for a silent request', async () => { + authenticationService.sessions = []; + + await assert.rejects( + client.graphql('query Test { viewer { login } }', 'test', undefined, { createAuthenticationSession: false }), + GitHubAuthenticationError, + ); + + assert.deepStrictEqual(authenticationService.getSessionsOptions, [{ silent: true }]); + }); }); diff --git a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts index 8043bd8d014..a4e98b9fdcd 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { GitHubPRFetcher, computeMergeability } from '../../browser/fetchers/githubPRFetcher.js'; import { GitHubPRCIFetcher, computeOverallCIStatus } from '../../browser/fetchers/githubPRCIFetcher.js'; +import { GitHubRecentUserWorkFetcher } from '../../browser/fetchers/githubRecentUserWorkFetcher.js'; import { GitHubRepositoryFetcher } from '../../browser/fetchers/githubRepositoryFetcher.js'; import { GitHubApiClient, GitHubApiError, IGitHubApiRequestOptions } from '../../browser/githubApiClient.js'; import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, GitHubPullRequestState, IGitHubPullRequestReview, IGitHubPullRequest, MergeBlockerKind } from '../../common/types.js'; @@ -17,7 +19,7 @@ class MockApiClient { private _nextResponse: unknown; private _nextError: Error | undefined; readonly requestCalls: { method: string; path: string; body?: unknown }[] = []; - readonly graphqlCalls: { query: string; variables?: Record }[] = []; + readonly graphqlCalls: { query: string; variables?: Record; options?: Pick }[] = []; setNextResponse(data: unknown): void { this._nextResponse = data; @@ -37,8 +39,8 @@ class MockApiClient { return { data: this._nextResponse as T, statusCode: 200 }; } - async graphql(query: string, _callSite: string, variables?: Record): Promise { - this.graphqlCalls.push({ query, variables }); + async graphql(query: string, _callSite: string, variables?: Record, options?: Pick): Promise { + this.graphqlCalls.push({ query, variables, options }); if (this._nextError) { throw this._nextError; } @@ -46,6 +48,113 @@ class MockApiClient { } } +suite('GitHubRecentUserWorkFetcher', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('queries assigned issue summaries independently', async () => { + const mockApi = new MockApiClient(); + mockApi.setNextResponse({ + search: { + nodes: [ + null, + { __typename: 'Issue', number: 1, title: 'First issue', url: 'https://github.com/o/r/issues/1', updatedAt: '2026-08-07T10:00:00Z' }, + { __typename: 'Issue', number: 2, title: 'Second issue', url: 'https://github.com/o/r/issues/2', updatedAt: '2026-08-07T11:00:00Z' }, + ], + } + }); + const fetcher = new GitHubRecentUserWorkFetcher(mockApi as unknown as GitHubApiClient); + + assert.deepStrictEqual({ + issues: await fetcher.getRecentAssignedIssues('o', 'r', CancellationToken.None), + variables: mockApi.graphqlCalls[0].variables, + createAuthenticationSession: mockApi.graphqlCalls[0].options?.createAuthenticationSession, + }, { + issues: [ + { number: 1, title: 'First issue', url: 'https://github.com/o/r/issues/1', updatedAt: '2026-08-07T10:00:00Z' }, + { number: 2, title: 'Second issue', url: 'https://github.com/o/r/issues/2', updatedAt: '2026-08-07T11:00:00Z' }, + ], + variables: { query: 'repo:o/r is:issue is:open assignee:@me sort:updated-desc' }, + createAuthenticationSession: false, + }); + }); + + test('queries pull request summaries without review threads', async () => { + const mockApi = new MockApiClient(); + mockApi.setNextResponse({ + search: { + nodes: [{ + __typename: 'PullRequest', + number: 3, + title: 'Fix CI', + url: 'https://github.com/o/r/pull/3', + updatedAt: '2026-08-07T12:00:00Z', + commits: { nodes: [{ commit: { committedDate: '2026-08-07T09:00:00Z', statusCheckRollup: { state: 'FAILURE' } } }] }, + }], + }, + }); + const fetcher = new GitHubRecentUserWorkFetcher(mockApi as unknown as GitHubApiClient); + + assert.deepStrictEqual({ + pullRequests: await fetcher.getRecentAuthoredPullRequests('o', 'r', CancellationToken.None), + variables: mockApi.graphqlCalls[0].variables, + }, { + pullRequests: [{ + number: 3, + title: 'Fix CI', + url: 'https://github.com/o/r/pull/3', + updatedAt: '2026-08-07T12:00:00Z', + statusCheckRollupState: 'FAILURE', + latestCommitAt: '2026-08-07T09:00:00Z', + }], + variables: { query: 'repo:o/r is:pr is:open author:@me sort:updated-desc' }, + }); + }); + + test('queries review threads for one pull request independently', async () => { + const mockApi = new MockApiClient(); + mockApi.setNextResponse({ + repository: { + pullRequest: { + reviewThreads: { + nodes: [{ + isResolved: false, + comments: { nodes: [{ createdAt: '2026-08-07T10:00:00Z' }] }, + }], + }, + }, + }, + }); + const fetcher = new GitHubRecentUserWorkFetcher(mockApi as unknown as GitHubApiClient); + + assert.deepStrictEqual({ + reviewThreads: await fetcher.getPullRequestReviewThreads('o', 'r', 3, CancellationToken.None), + variables: mockApi.graphqlCalls[0].variables, + }, { + reviewThreads: [{ isResolved: false, latestCommentAt: '2026-08-07T10:00:00Z' }], + variables: { owner: 'o', repo: 'r', pullRequestNumber: 3 }, + }); + }); + + test('checks pull request linkage for issue summaries in one request', async () => { + const mockApi = new MockApiClient(); + mockApi.setNextResponse({ + repository: { + issue0: { closedByPullRequestsReferences: { totalCount: 1 } }, + issue1: { closedByPullRequestsReferences: { totalCount: 0 } }, + }, + }); + const fetcher = new GitHubRecentUserWorkFetcher(mockApi as unknown as GitHubApiClient); + + assert.deepStrictEqual({ + linkedIssues: [...await fetcher.getIssuesWithLinkedPullRequests('o', 'r', [1, 2], CancellationToken.None)], + variables: mockApi.graphqlCalls[0].variables, + }, { + linkedIssues: [1], + variables: { owner: 'o', repo: 'r', issue0: 1, issue1: 2 }, + }); + }); +}); + suite('GitHubRepositoryFetcher', () => { const store = new DisposableStore(); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/agentHostReadinessContext.ts b/src/vs/sessions/contrib/onboardingTours/browser/agentHostReadinessContext.ts new file mode 100644 index 00000000000..ac6f715159d --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/browser/agentHostReadinessContext.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; +import { AgentHostSessionTypesAvailableContext } from '../../../common/contextkeys.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +export class AgentHostReadinessContextContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'sessions.contrib.onboardingTours.agentHostReadinessContext'; + + private readonly _agentHostSessionTypesAvailable: IContextKey; + + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + this._agentHostSessionTypesAvailable = AgentHostSessionTypesAvailableContext.bindTo(contextKeyService); + this._register(toDisposable(() => this._agentHostSessionTypesAvailable.reset())); + this._register(this._sessionsManagementService.onDidChangeSessionTypes(() => this._update())); + this._update(); + } + + private _update(): void { + this._agentHostSessionTypesAvailable.set(this._sessionsManagementService.getAllProviderSessionTypes() + .some(({ providerId }) => isAgentHostProviderId(providerId))); + } +} + +registerWorkbenchContribution2(AgentHostReadinessContextContribution.ID, AgentHostReadinessContextContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/gitHubRepositoryResolver.ts b/src/vs/sessions/contrib/onboardingTours/browser/gitHubRepositoryResolver.ts new file mode 100644 index 00000000000..78933f0c554 --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/browser/gitHubRepositoryResolver.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Schemas } from '../../../../base/common/network.js'; +import { dirname, isEqual, joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../platform/files/common/files.js'; +import { getGitHubRepositoryFromRemoteUrl, IGitHubRemoteInfo } from '../../../../workbench/contrib/git/common/utils.js'; + +const MAX_PARENT_LOOKUPS = 50; + +export async function resolveGitHubRepositoryFromGitConfig(fileService: IFileService, workspaceUri: URI): Promise { + const configUri = await findGitConfig(fileService, workspaceUri); + if (!configUri) { + return undefined; + } + const content = await readFileIfExists(fileService, configUri); + return content ? parseGitHubRepositoryFromGitConfig(content) : undefined; +} + +export function parseGitHubRepositoryFromGitConfig(content: string): IGitHubRemoteInfo | undefined { + const remotes: { readonly name: string; readonly url: string }[] = []; + let remoteName: string | undefined; + for (const line of content.split(/\r?\n/)) { + const section = /^\s*\[\s*remote\s+"([^"]+)"\s*\]\s*$/i.exec(line); + if (section) { + remoteName = section[1]; + continue; + } + if (/^\s*\[/.test(line)) { + remoteName = undefined; + continue; + } + const url = remoteName ? /^\s*url\s*=\s*(.+?)\s*$/i.exec(line)?.[1] : undefined; + if (url && remoteName) { + remotes.push({ name: remoteName, url: stripQuotes(url) }); + } + } + + remotes.sort((a, b) => Number(b.name === 'origin') - Number(a.name === 'origin')); + for (const remote of remotes) { + const repository = getGitHubRepositoryFromRemoteUrl(remote.url); + if (repository) { + return repository; + } + } + return undefined; +} + +async function findGitConfig(fileService: IFileService, workspaceUri: URI): Promise { + let current = workspaceUri; + for (let i = 0; i < MAX_PARENT_LOOKUPS; i++) { + const dotGit = joinPath(current, '.git'); + const stat = await statIfExists(fileService, dotGit); + if (stat) { + if (stat.isDirectory) { + return joinPath(dotGit, 'config'); + } + const dotGitContent = await readFileIfExists(fileService, dotGit); + const gitDirPath = dotGitContent ? /^\s*gitdir:\s*(.+?)\s*$/im.exec(dotGitContent)?.[1] : undefined; + if (gitDirPath) { + const gitDir = resolveGitPath(current, gitDirPath); + const commonDirPath = await readFileIfExists(fileService, joinPath(gitDir, 'commondir')); + const configRoot = commonDirPath ? resolveGitPath(gitDir, commonDirPath.trim()) : gitDir; + return joinPath(configRoot, 'config'); + } + } + + const parent = dirname(current); + if (isEqual(parent, current)) { + break; + } + current = parent; + } + return undefined; +} + +function resolveGitPath(base: URI, value: string): URI { + const path = value.trim(); + if (/^[a-zA-Z]:[\\/]/.test(path)) { + return URI.file(path); + } + const normalizedPath = path.replace(/\\/g, '/'); + if (normalizedPath.startsWith('/')) { + return base.scheme === Schemas.file ? URI.file(path) : base.with({ path: normalizedPath }); + } + return joinPath(base, normalizedPath); +} + +async function statIfExists(fileService: IFileService, resource: URI) { + try { + return await fileService.stat(resource); + } catch (error) { + if (toFileOperationResult(error as Error) === FileOperationResult.FILE_NOT_FOUND) { + return undefined; + } + throw error; + } +} + +async function readFileIfExists(fileService: IFileService, resource: URI): Promise { + try { + return (await fileService.readFile(resource)).value.toString(); + } catch (error) { + if (toFileOperationResult(error as Error) === FileOperationResult.FILE_NOT_FOUND) { + return undefined; + } + throw error; + } +} + +function stripQuotes(value: string): string { + return value.length >= 2 && value.startsWith('"') && value.endsWith('"') + ? value.slice(1, -1) + : value; +} diff --git a/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3Prompt.ts b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3Prompt.ts new file mode 100644 index 00000000000..db61601c42c --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3Prompt.ts @@ -0,0 +1,668 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceTimeout } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; +import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { format } from '../../../../base/common/strings.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; +import { getGitHubRemoteInfo, IGitHubRemoteInfo } from '../../../../workbench/contrib/git/common/utils.js'; +import { getOnboardingDeveloperModeVariation, isOnboardingDeveloperModeEnabled, OnboardingDeveloperModeVariations, ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG } from '../../../../workbench/contrib/onboarding/common/onboardingScenarioService.js'; +import { IWorkbenchAssignmentService } from '../../../../workbench/services/assignment/common/assignmentService.js'; +import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { INewSessionComposerService } from '../../chat/browser/newSessionComposerService.js'; +import { getGitHubRepositoryFromUri } from '../../github/common/utils.js'; +import { GitHubAuthenticationError } from '../../github/browser/githubApiClient.js'; +import { IGitHubRecentIssue, IGitHubRecentPullRequest, IGitHubRecentUserWork } from '../../github/browser/fetchers/githubRecentUserWorkFetcher.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { resolveGitHubRepositoryFromGitConfig } from './gitHubRepositoryResolver.js'; +import { NEW_SESSION_VIEW_V3_GITHUB_PROMPT_VARIATION, NEW_SESSION_VIEW_V3_PROMPT_VARIATION, NEW_SESSION_VIEW_V3_TOUR_ID, NEW_SESSION_VIEW_V3_VARIATION_TREATMENT } from './tours/newSessionViewV3Tour.js'; + +const PROMPT_TYPING_DURATION_MS = 2_500; +const DEFAULT_GITHUB_LOOKUP_TIMEOUTS = { + totalMs: 6_000, + summaryMs: 2_500, + linkageMs: 1_500, + reviewMs: 2_500, +}; +const LOG_PREFIX = '[NewSessionViewV3Prompt]'; +const PROMPT_TEMPLATE_TREATMENT = 'onb.newSessionViewV3.promptTemplate'; +const PLACEHOLDER_TREATMENT = 'onb.newSessionViewV3.placeholder'; +const DEFAULT_TASK_PLACEHOLDER = localize('sessions.onboarding.newSessionViewV3.prompt.taskPlaceholder', "[describe the coding task]"); +const DEFAULT_PROMPT_TEMPLATE = localize('sessions.onboarding.newSessionViewV3.prompt.text', "Help me complete {0} in this project. First, inspect the relevant files and explain your approach briefly. Then implement the solution using existing project conventions, avoid unrelated changes, and run the most relevant tests or checks. If anything is unclear, make a reasonable assumption and state it. When finished, summarize what changed and mention any remaining issues."); + +export type NewSessionViewV3ConfiguredVariation = 'prompt' | 'githubPrompt' | 'unknown'; +export type NewSessionViewV3EffectiveStrategy = 'prompt' | 'githubCiFailure' | 'githubReviewComments' | 'githubIssue'; +export type NewSessionViewV3FallbackReason = 'none' | 'unsupportedVariation' | 'noRepository' | 'noAuthentication' | 'timeout' | 'requestFailed' | 'noCandidate'; + +interface INewSessionViewV3PromptPlan { + readonly prompt: string; + readonly taskPlaceholder: string; + readonly effectiveStrategy: NewSessionViewV3EffectiveStrategy; + readonly fallbackReason: NewSessionViewV3FallbackReason; +} + +interface INewSessionViewV3GitHubCandidate { + readonly title: string; + readonly url: string; + readonly strategy: Exclude; +} + +interface INewSessionViewV3RepositoryContext { + readonly session: IActiveSession; + readonly workspaceUri: string; + readonly folderUri: string; + readonly repository: IGitHubRemoteInfo; +} + +type AgentHostRepositoryResolution = + | { readonly kind: 'pending' } + | { readonly kind: 'sessionChanged' } + | { readonly kind: 'noGitHubRemote' } + | { readonly kind: 'resolved'; readonly context: INewSessionViewV3RepositoryContext }; + +type GitHubPromptResult = + | { readonly kind: 'candidate'; readonly candidate: INewSessionViewV3GitHubCandidate } + | { readonly kind: 'fallback'; readonly reason: Extract }; + +type GitHubLookupFailureReason = 'noAuthentication' | 'timeout' | 'requestFailed' | 'cancelled'; + +type GitHubLookupOutcome = + | { readonly kind: 'success'; readonly value: T } + | { readonly kind: 'failure'; readonly reason: GitHubLookupFailureReason }; + +interface IGitHubReviewLookupResult { + readonly candidate: INewSessionViewV3GitHubCandidate | undefined; + readonly failures: readonly GitHubLookupFailureReason[]; +} + +interface IGitHubLookupTimeouts { + readonly totalMs: number; + readonly summaryMs: number; + readonly linkageMs: number; + readonly reviewMs: number; +} + +export class NewSessionViewV3PromptRunner { + private readonly _gitHubLookupTimeouts: IGitHubLookupTimeouts; + + constructor( + private readonly _assignmentService: IWorkbenchAssignmentService, + private readonly _configurationService: IConfigurationService, + private readonly _sessionsService: ISessionsService, + private readonly _newSessionComposerService: INewSessionComposerService, + private readonly _gitService: IGitService, + private readonly _fileService: IFileService, + private readonly _gitHubService: IGitHubService, + private readonly _telemetryService: ITelemetryService, + private readonly _logService: ILogService, + gitHubLookupTimeouts: Partial = {}, + ) { + this._gitHubLookupTimeouts = { ...DEFAULT_GITHUB_LOOKUP_TIMEOUTS, ...gitHubLookupTimeouts }; + } + + async run(token: CancellationToken): Promise { + this._logService.info(`${LOG_PREFIX} Starting V3 prompt resolution.`); + const configuredVariation = await this._resolveConfiguredVariation(); + if (token.isCancellationRequested) { + this._logService.trace(`${LOG_PREFIX} Prompt resolution was cancelled after resolving the configured variation.`); + return false; + } + + const plan = configuredVariation === 'githubPrompt' + ? await this._resolveGitHubPromptWithFallback(token) + : await this._resolvePrompt(configuredVariation === 'unknown' ? 'unsupportedVariation' : 'none'); + if (token.isCancellationRequested) { + this._logService.trace(`${LOG_PREFIX} Prompt resolution was cancelled before prompt insertion.`); + return false; + } + + this._logService.info(`${LOG_PREFIX} Resolved effective strategy '${plan.effectiveStrategy}' with fallback reason '${plan.fallbackReason}'.`); + const shown = await this._animatePrompt(plan.prompt, plan.taskPlaceholder, token); + this._logService.info(`${LOG_PREFIX} Prompt insertion completed with shown=${shown}.`); + this._reportStrategy(configuredVariation, plan, shown); + return shown; + } + + private async _resolveConfiguredVariation(): Promise { + const developerModeEnabled = isOnboardingDeveloperModeEnabled(this._configurationService, NEW_SESSION_VIEW_V3_TOUR_ID); + const developerVariations = this._configurationService.getValue(ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG); + const configuredDeveloperVariation = typeof developerVariations === 'object' && developerVariations !== null + ? developerVariations[NEW_SESSION_VIEW_V3_TOUR_ID] + : undefined; + const developerVariation = getOnboardingDeveloperModeVariation(this._configurationService, NEW_SESSION_VIEW_V3_TOUR_ID); + if (configuredDeveloperVariation && !developerModeEnabled) { + this._logService.warn(`${LOG_PREFIX} Ignoring developer variation '${configuredDeveloperVariation}' because developer mode is not enabled for '${NEW_SESSION_VIEW_V3_TOUR_ID}'.`); + } + if (developerVariation) { + this._logService.info(`${LOG_PREFIX} Using developer variation '${developerVariation}'.`); + return this._normalizeVariation(developerVariation, 'developer setting'); + } + + this._logService.trace(`${LOG_PREFIX} No active developer variation; resolving treatment '${NEW_SESSION_VIEW_V3_VARIATION_TREATMENT}'.`); + const treatmentVariation = await this._assignmentService.getTreatment(NEW_SESSION_VIEW_V3_VARIATION_TREATMENT); + this._logService.info(`${LOG_PREFIX} Treatment variation resolved to '${treatmentVariation || NEW_SESSION_VIEW_V3_PROMPT_VARIATION}'.`); + return this._normalizeVariation(treatmentVariation, 'treatment'); + } + + private _normalizeVariation(variation: string | undefined, source: string): NewSessionViewV3ConfiguredVariation { + if (variation === undefined || variation === '' || variation === NEW_SESSION_VIEW_V3_PROMPT_VARIATION) { + return 'prompt'; + } + if (variation === NEW_SESSION_VIEW_V3_GITHUB_PROMPT_VARIATION) { + return 'githubPrompt'; + } + this._logService.warn(`${LOG_PREFIX} Unsupported variation '${variation}' from ${source}; using '${NEW_SESSION_VIEW_V3_PROMPT_VARIATION}'.`); + return 'unknown'; + } + + private async _resolveGitHubPromptWithFallback(token: CancellationToken): Promise { + this._logService.info(`${LOG_PREFIX} Starting GitHub prompt lookup with a ${this._gitHubLookupTimeouts.totalMs}ms total timeout.`); + const operationCts = new CancellationTokenSource(token); + let timedOut = false; + try { + const result = await raceTimeout( + this._resolveGitHubPrompt(operationCts.token), + this._gitHubLookupTimeouts.totalMs, + () => { + timedOut = true; + this._logService.warn(`${LOG_PREFIX} GitHub prompt lookup timed out after ${this._gitHubLookupTimeouts.totalMs}ms; using the prompt variation.`); + operationCts.cancel(); + }, + ); + if (timedOut) { + return this._resolvePrompt('timeout'); + } + if (!result) { + return this._resolvePrompt('timeout'); + } + if (result.kind === 'fallback') { + this._logService.warn(`${LOG_PREFIX} GitHub prompt lookup requested fallback '${result.reason}'; using the prompt variation.`); + return this._resolvePrompt(result.reason); + } + this._logService.info(`${LOG_PREFIX} Selected GitHub candidate strategy '${result.candidate.strategy}'.`); + return this._createGitHubPrompt(result.candidate); + } catch (error) { + if (isCancellationError(error) && timedOut) { + return this._resolvePrompt('timeout'); + } + if (isCancellationError(error) && token.isCancellationRequested) { + this._logService.trace(`${LOG_PREFIX} GitHub prompt lookup was cancelled by the onboarding flow.`); + return this._resolvePrompt('requestFailed'); + } + if (error instanceof GitHubAuthenticationError) { + this._logService.warn(`${LOG_PREFIX} No existing GitHub authentication session is available; using the prompt variation without requesting sign-in.`); + return this._resolvePrompt('noAuthentication'); + } + this._logService.error(`${LOG_PREFIX} GitHub prompt lookup failed; using the prompt variation.`, error); + return this._resolvePrompt('requestFailed'); + } finally { + operationCts.dispose(); + } + } + + private async _resolveGitHubPrompt(token: CancellationToken): Promise { + while (!token.isCancellationRequested) { + const context = await this._resolveGitHubRepository(token); + if (!context) { + this._logService.warn(`${LOG_PREFIX} Could not resolve a GitHub repository for the selected workspace.`); + return { kind: 'fallback', reason: 'noRepository' }; + } + const lookupCts = new CancellationTokenSource(token); + const owner = context.repository.owner; + const repo = context.repository.repo; + this._logService.info(`${LOG_PREFIX} Starting independent GitHub lookups for '${owner}/${repo}'.`); + const issuesLookup = this._resolveIssueCandidates(owner, repo, lookupCts.token); + try { + const pullRequestsLookup = await this._runGitHubLookup( + 'authored pull request summaries', + this._gitHubLookupTimeouts.summaryMs, + lookupCts.token, + lookupToken => this._gitHubService.getRecentAuthoredPullRequests(owner, repo, lookupToken), + ); + if (!this._isCurrentRepositoryContext(context)) { + this._logService.info(`${LOG_PREFIX} The selected workspace changed during the GitHub lookup; retrying for the current workspace.`); + continue; + } + + const failures: GitHubLookupFailureReason[] = []; + if (pullRequestsLookup.kind === 'success') { + const pullRequests = [...pullRequestsLookup.value].sort(compareUpdatedAtDescending); + const failingPullRequest = pullRequests.find(isFailingPullRequest); + this._logService.info(`${LOG_PREFIX} Pull request summary lookup returned ${pullRequests.length} open authored pull request(s), including ${pullRequests.filter(isFailingPullRequest).length} with failing CI.`); + if (failingPullRequest) { + return { kind: 'candidate', candidate: toCandidate(failingPullRequest, 'githubCiFailure') }; + } + + const reviewLookup = await this._resolveReviewCandidate(owner, repo, pullRequests, lookupCts.token); + failures.push(...reviewLookup.failures); + if (!this._isCurrentRepositoryContext(context)) { + this._logService.info(`${LOG_PREFIX} The selected workspace changed during review lookup; retrying for the current workspace.`); + continue; + } + if (reviewLookup.candidate) { + return { kind: 'candidate', candidate: reviewLookup.candidate }; + } + } else { + failures.push(pullRequestsLookup.reason); + } + + const issues = await issuesLookup; + if (!this._isCurrentRepositoryContext(context)) { + this._logService.info(`${LOG_PREFIX} The selected workspace changed during issue lookup; retrying for the current workspace.`); + continue; + } + if (issues.kind === 'success') { + this._logService.info(`${LOG_PREFIX} Issue lookup returned ${issues.value.length} unlinked open issue(s) assigned to the user.`); + const issue = [...issues.value].sort(compareUpdatedAtDescending)[0]; + if (issue) { + return { kind: 'candidate', candidate: { title: issue.title, url: issue.url, strategy: 'githubIssue' } }; + } + } else { + failures.push(issues.reason); + } + + this._logService.warn(`${LOG_PREFIX} No eligible GitHub candidate was available from the lookups that completed in time.`); + return { kind: 'fallback', reason: getLookupFallbackReason(failures) }; + } finally { + lookupCts.dispose(true); + } + } + this._logService.trace(`${LOG_PREFIX} GitHub prompt lookup stopped because it was cancelled.`); + return { kind: 'fallback', reason: 'noRepository' }; + } + + private async _resolveIssueCandidates(owner: string, repo: string, token: CancellationToken): Promise> { + const issues = await this._runGitHubLookup( + 'assigned issue summaries', + this._gitHubLookupTimeouts.summaryMs, + token, + lookupToken => this._gitHubService.getRecentAssignedIssues(owner, repo, lookupToken), + ); + if (issues.kind === 'failure' || issues.value.length === 0) { + return issues; + } + + const linkedIssues = await this._runGitHubLookup( + 'issue pull request linkage', + this._gitHubLookupTimeouts.linkageMs, + token, + lookupToken => this._gitHubService.getIssuesWithLinkedPullRequests(owner, repo, issues.value.map(issue => issue.number), lookupToken), + ); + if (linkedIssues.kind === 'success') { + const unlinkedIssues = issues.value.filter(issue => !linkedIssues.value.has(issue.number)); + this._logService.info(`${LOG_PREFIX} Issue linkage lookup excluded ${issues.value.length - unlinkedIssues.length} issue(s) with related pull requests.`); + return { kind: 'success', value: unlinkedIssues }; + } + if (linkedIssues.reason === 'cancelled' && token.isCancellationRequested) { + return linkedIssues; + } + + this._logService.warn(`${LOG_PREFIX} Issue linkage was unavailable (${linkedIssues.reason}); treating all assigned issues as having no related pull request.`); + return issues; + } + + private async _resolveReviewCandidate(owner: string, repo: string, pullRequests: readonly IGitHubRecentPullRequest[], token: CancellationToken): Promise { + const eligiblePullRequests = pullRequests.filter(pullRequest => !!pullRequest.latestCommitAt); + if (eligiblePullRequests.length === 0) { + this._logService.info(`${LOG_PREFIX} No pull requests have a latest commit timestamp, so review-thread lookup is unnecessary.`); + return { candidate: undefined, failures: [] }; + } + + this._logService.info(`${LOG_PREFIX} Starting ${eligiblePullRequests.length} independent review-thread lookup(s).`); + const results = await Promise.all(eligiblePullRequests.map(async pullRequest => ({ + pullRequest, + outcome: await this._runGitHubLookup( + `review threads for pull request #${pullRequest.number}`, + this._gitHubLookupTimeouts.reviewMs, + token, + lookupToken => this._gitHubService.getPullRequestReviewThreads(owner, repo, pullRequest.number, lookupToken), + ), + }))); + const completedPullRequests: IGitHubRecentPullRequest[] = []; + const failures: GitHubLookupFailureReason[] = []; + for (const result of results) { + if (result.outcome.kind === 'success') { + completedPullRequests.push({ ...result.pullRequest, reviewThreads: result.outcome.value }); + } else { + failures.push(result.outcome.reason); + } + } + + const reviewPullRequest = completedPullRequests.sort(compareUpdatedAtDescending).find(hasUnaddressedReviewComments); + this._logService.info(`${LOG_PREFIX} Review-thread lookups completed for ${completedPullRequests.length} of ${eligiblePullRequests.length} pull request(s); ${reviewPullRequest ? 'an eligible pull request was found' : 'no eligible pull request was found'}.`); + return { + candidate: reviewPullRequest ? toCandidate(reviewPullRequest, 'githubReviewComments') : undefined, + failures, + }; + } + + private async _runGitHubLookup( + label: string, + timeoutMs: number, + token: CancellationToken, + lookup: (token: CancellationToken) => Promise, + ): Promise> { + const lookupCts = new CancellationTokenSource(token); + const startTime = Date.now(); + let timedOut = false; + this._logService.trace(`${LOG_PREFIX} Starting ${label} lookup with a ${timeoutMs}ms timeout.`); + try { + const value = await raceTimeout( + lookup(lookupCts.token), + timeoutMs, + () => { + timedOut = true; + this._logService.warn(`${LOG_PREFIX} ${capitalize(label)} lookup timed out after ${timeoutMs}ms.`); + lookupCts.cancel(); + }, + ); + if (timedOut || value === undefined) { + return { kind: 'failure', reason: 'timeout' }; + } + this._logService.info(`${LOG_PREFIX} ${capitalize(label)} lookup completed in ${Date.now() - startTime}ms.`); + return { kind: 'success', value }; + } catch (error) { + if (timedOut) { + return { kind: 'failure', reason: 'timeout' }; + } + if (error instanceof GitHubAuthenticationError) { + this._logService.warn(`${LOG_PREFIX} ${capitalize(label)} lookup could not run because no existing GitHub authentication session is available.`); + return { kind: 'failure', reason: 'noAuthentication' }; + } + if (isCancellationError(error) && token.isCancellationRequested) { + this._logService.trace(`${LOG_PREFIX} ${capitalize(label)} lookup was cancelled.`); + return { kind: 'failure', reason: 'cancelled' }; + } + this._logService.error(`${LOG_PREFIX} ${capitalize(label)} lookup failed after ${Date.now() - startTime}ms.`, error); + return { kind: 'failure', reason: 'requestFailed' }; + } finally { + lookupCts.dispose(); + } + } + + private async _resolveGitHubRepository(token: CancellationToken): Promise { + while (!token.isCancellationRequested) { + const activeSession = this._sessionsService.activeSession.get(); + if (!activeSession) { + this._logService.trace(`${LOG_PREFIX} No active draft session is available for repository resolution.`); + return undefined; + } + if (activeSession.isCreated.get()) { + this._logService.trace(`${LOG_PREFIX} The active session is already created, so the V3 new-session prompt cannot resolve its repository.`); + return undefined; + } + const workspace = activeSession.workspace.get(); + const folder = workspace?.folders[0]; + this._logWorkspaceSnapshot(activeSession); + if (!workspace || !folder) { + this._logService.trace(`${LOG_PREFIX} The active draft has no primary workspace folder.`); + return undefined; + } + const gitHubInfo = folder.gitRepository?.gitHubInfo.get(); + if (gitHubInfo) { + this._logService.info(`${LOG_PREFIX} Resolved GitHub repository '${gitHubInfo.owner}/${gitHubInfo.repo}' from session metadata.`); + return this._createRepositoryContext(activeSession, workspace.uri.toString(), folder.workingDirectory.toString(), { owner: gitHubInfo.owner, repo: gitHubInfo.repo }); + } + const repositoryFromUri = getGitHubRepositoryFromUri(folder.root) + ?? getGitHubRepositoryFromUri(folder.workingDirectory) + ?? (folder.gitRepository ? getGitHubRepositoryFromUri(folder.gitRepository.uri) : undefined); + if (repositoryFromUri) { + this._logService.info(`${LOG_PREFIX} Resolved GitHub repository '${repositoryFromUri.owner}/${repositoryFromUri.repo}' from the workspace URI.`); + return this._createRepositoryContext(activeSession, workspace.uri.toString(), folder.workingDirectory.toString(), repositoryFromUri); + } + + try { + const repositoryFromConfig = await resolveGitHubRepositoryFromGitConfig(this._fileService, folder.workingDirectory); + if (repositoryFromConfig) { + this._logService.info(`${LOG_PREFIX} Resolved GitHub repository '${repositoryFromConfig.owner}/${repositoryFromConfig.repo}' directly from .git/config.`); + return this._createRepositoryContext(activeSession, workspace.uri.toString(), folder.workingDirectory.toString(), repositoryFromConfig); + } + this._logService.trace(`${LOG_PREFIX} No supported github.com remote was found directly in .git/config.`); + } catch (error) { + this._logService.warn(`${LOG_PREFIX} Reading Git repository metadata directly from the selected workspace failed.`, error); + } + + if (isAgentHostProviderId(activeSession.providerId)) { + this._logService.info(`${LOG_PREFIX} Waiting for Agent Host git metadata for the active draft.`); + const result = await this._waitForAgentHostRepository(activeSession, token); + if (result.kind === 'sessionChanged') { + this._logService.info(`${LOG_PREFIX} The active draft changed while waiting for Agent Host git metadata; retrying.`); + continue; + } + if (result.kind === 'noGitHubRemote') { + this._logService.info(`${LOG_PREFIX} Agent Host git metadata reports that the selected workspace has no github.com remote.`); + return undefined; + } + if (result.kind === 'resolved') { + this._logService.info(`${LOG_PREFIX} Resolved GitHub repository '${result.context.repository.owner}/${result.context.repository.repo}' from asynchronously published Agent Host metadata.`); + return result.context; + } + } + + this._logService.trace(`${LOG_PREFIX} Session metadata, workspace URIs, and .git/config did not identify GitHub; inspecting Git extension remotes.`); + const repository = await this._gitService.openRepository(folder.workingDirectory); + if (!repository) { + this._logService.trace(`${LOG_PREFIX} The selected workspace folder could not be opened through the Git extension.`); + return undefined; + } + const repositoryFromRemote = getGitHubRemoteInfo(repository.state.get()); + if (!repositoryFromRemote) { + this._logService.trace(`${LOG_PREFIX} The selected Git repository has no supported github.com remote.`); + return undefined; + } + this._logService.info(`${LOG_PREFIX} Resolved GitHub repository '${repositoryFromRemote.owner}/${repositoryFromRemote.repo}' from Git extension remotes.`); + return this._createRepositoryContext(activeSession, workspace.uri.toString(), folder.workingDirectory.toString(), repositoryFromRemote); + } + return undefined; + } + + private _waitForAgentHostRepository(activeSession: IActiveSession, token: CancellationToken): Promise { + return new Promise((resolve, reject) => { + const disposables = new DisposableStore(); + const reaction = disposables.add(new MutableDisposable()); + const finish = (result: AgentHostRepositoryResolution) => { + disposables.dispose(); + resolve(result); + }; + reaction.value = autorun(reader => { + if (this._sessionsService.activeSession.read(reader) !== activeSession || activeSession.isCreated.read(reader)) { + finish({ kind: 'sessionChanged' }); + return; + } + const workspace = activeSession.workspace.read(reader); + const folder = workspace?.folders[0]; + const gitRepository = folder?.gitRepository; + const gitHubInfo = gitRepository?.gitHubInfo.read(reader); + if (workspace && folder && gitHubInfo) { + finish({ + kind: 'resolved', + context: this._createRepositoryContext(activeSession, workspace.uri.toString(), folder.workingDirectory.toString(), { owner: gitHubInfo.owner, repo: gitHubInfo.repo }), + }); + return; + } + if (gitRepository?.hasGitHubRemote === false) { + finish({ kind: 'noGitHubRemote' }); + } + }); + disposables.add(token.onCancellationRequested(() => { + disposables.dispose(); + reject(new CancellationError()); + })); + if (token.isCancellationRequested) { + disposables.dispose(); + reject(new CancellationError()); + } + }); + } + + private _logWorkspaceSnapshot(activeSession: IActiveSession): void { + const workspace = activeSession.workspace.get(); + const folder = workspace?.folders[0]; + const gitRepository = folder?.gitRepository; + const gitHubInfo = gitRepository?.gitHubInfo.get(); + this._logService.info(`${LOG_PREFIX} Workspace snapshot: provider='${activeSession.providerId}', sessionType='${activeSession.sessionType}', workspace='${workspace?.uri.toString() ?? 'none'}', root='${folder?.root.toString() ?? 'none'}', workingDirectory='${folder?.workingDirectory.toString() ?? 'none'}', gitRepository='${gitRepository?.uri.toString() ?? 'none'}', hasGitHubRemote=${String(gitRepository?.hasGitHubRemote)}, gitHubRepository='${gitHubInfo ? `${gitHubInfo.owner}/${gitHubInfo.repo}` : 'none'}'.`); + } + + private _createRepositoryContext(session: IActiveSession, workspaceUri: string, folderUri: string, repository: IGitHubRemoteInfo): INewSessionViewV3RepositoryContext { + return { + session, + workspaceUri, + folderUri, + repository, + }; + } + + private _isCurrentRepositoryContext(context: INewSessionViewV3RepositoryContext): boolean { + const activeSession = this._sessionsService.activeSession.get(); + const workspace = activeSession?.workspace.get(); + return activeSession === context.session + && workspace?.uri.toString() === context.workspaceUri + && workspace.folders[0]?.workingDirectory.toString() === context.folderUri; + } + + private async _resolvePrompt(fallbackReason: NewSessionViewV3FallbackReason): Promise { + const [promptTemplateTreatment, placeholderTreatment] = await Promise.all([ + this._assignmentService.getTreatment(PROMPT_TEMPLATE_TREATMENT), + this._assignmentService.getTreatment(PLACEHOLDER_TREATMENT), + ]); + const hasTreatment = typeof promptTemplateTreatment === 'string' && !!promptTemplateTreatment.trim() + && typeof placeholderTreatment === 'string' && !!placeholderTreatment.trim(); + const promptTemplate = hasTreatment ? promptTemplateTreatment : DEFAULT_PROMPT_TEMPLATE; + const taskPlaceholder = hasTreatment ? placeholderTreatment : DEFAULT_TASK_PLACEHOLDER; + if (hasTreatment) { + this._logService.info(`${LOG_PREFIX} Using prompt template and placeholder from paired treatments.`); + } else { + this._logService.info(`${LOG_PREFIX} Prompt treatments were not both set to non-empty strings; using the default prompt template and placeholder.`); + } + + return { + prompt: format(promptTemplate, taskPlaceholder), + taskPlaceholder, + effectiveStrategy: 'prompt', + fallbackReason, + }; + } + + private _createGitHubPrompt(candidate: INewSessionViewV3GitHubCandidate): INewSessionViewV3PromptPlan { + const prompt = candidate.strategy === 'githubCiFailure' + ? localize('sessions.onboarding.newSessionViewV3.githubPrompt.ciFailure', "The following pull request has failing CI checks: \"{0}\" ({1}). Investigate the failures and resolve them.", candidate.title, candidate.url) + : candidate.strategy === 'githubReviewComments' + ? localize('sessions.onboarding.newSessionViewV3.githubPrompt.reviewComments', "The following pull request has unresolved review comments that have not been addressed by a newer commit: \"{0}\" ({1}). Address the review comments and update the pull request.", candidate.title, candidate.url) + : localize('sessions.onboarding.newSessionViewV3.githubPrompt.issue', "Tackle the following issue and create a pull request for it: \"{0}\" ({1}).", candidate.title, candidate.url); + return { + prompt, + taskPlaceholder: '', + effectiveStrategy: candidate.strategy, + fallbackReason: 'none', + }; + } + + private _animatePrompt(prompt: string, taskPlaceholder: string, token: CancellationToken): Promise | boolean { + const activeSession = this._sessionsService.activeSession.get(); + if (activeSession?.isCreated.get()) { + this._logService.warn(`${LOG_PREFIX} Skipping prompt insertion because the active session was created before animation started.`); + return false; + } + const composer = this._newSessionComposerService.activeComposer.get(); + if (!composer) { + this._logService.warn(`${LOG_PREFIX} Skipping prompt insertion because no active new-session composer is available.`); + return false; + } + this._logService.trace(`${LOG_PREFIX} Animating the resolved prompt in the active new-session composer.`); + return composer.animatePrompt(prompt, PROMPT_TYPING_DURATION_MS, taskPlaceholder, token); + } + + private _reportStrategy(configuredVariation: NewSessionViewV3ConfiguredVariation, plan: INewSessionViewV3PromptPlan, shown: boolean): void { + type OnboardingPromptStrategyEvent = { + scenarioId: string; + configuredVariation: string; + effectiveStrategy: string; + fallbackReason: string; + shown: boolean; + }; + type OnboardingPromptStrategyClassification = { + owner: 'benibenj'; + comment: 'Reports which prompt strategy an onboarding tour selected without collecting prompt or repository content.'; + scenarioId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the onboarding scenario that ran.' }; + configuredVariation: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The configured prompt variation, reduced to a known category.' }; + effectiveStrategy: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The effective prompt strategy selected for the tour.' }; + fallbackReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The categorical reason a configured strategy fell back to the default prompt.' }; + shown: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the selected prompt was inserted into the chat input.' }; + }; + this._telemetryService.publicLog2('onboarding.promptStrategy', { + scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + configuredVariation, + effectiveStrategy: plan.effectiveStrategy, + fallbackReason: plan.fallbackReason, + shown, + }); + } +} + +export function selectNewSessionViewV3GitHubCandidate(recentWork: IGitHubRecentUserWork): INewSessionViewV3GitHubCandidate | undefined { + const pullRequests = [...recentWork.pullRequests].sort(compareUpdatedAtDescending); + const failingPullRequest = pullRequests.find(isFailingPullRequest); + if (failingPullRequest) { + return toCandidate(failingPullRequest, 'githubCiFailure'); + } + + const reviewPullRequest = pullRequests.find(hasUnaddressedReviewComments); + if (reviewPullRequest) { + return toCandidate(reviewPullRequest, 'githubReviewComments'); + } + + const issue = [...recentWork.issues].sort(compareUpdatedAtDescending)[0]; + return issue ? { title: issue.title, url: issue.url, strategy: 'githubIssue' } : undefined; +} + +function isFailingPullRequest(pullRequest: IGitHubRecentPullRequest): boolean { + return pullRequest.statusCheckRollupState === 'FAILURE' || pullRequest.statusCheckRollupState === 'ERROR'; +} + +function hasUnaddressedReviewComments(pullRequest: IGitHubRecentPullRequest): boolean { + const latestCommitAt = pullRequest.latestCommitAt ? Date.parse(pullRequest.latestCommitAt) : NaN; + if (!Number.isFinite(latestCommitAt)) { + return false; + } + return (pullRequest.reviewThreads ?? []).some(thread => { + const latestCommentAt = thread.latestCommentAt ? Date.parse(thread.latestCommentAt) : NaN; + return !thread.isResolved && Number.isFinite(latestCommentAt) && latestCommentAt > latestCommitAt; + }); +} + +function getLookupFallbackReason(failures: readonly GitHubLookupFailureReason[]): Extract { + if (failures.includes('noAuthentication')) { + return 'noAuthentication'; + } + if (failures.includes('timeout')) { + return 'timeout'; + } + if (failures.includes('requestFailed')) { + return 'requestFailed'; + } + return 'noCandidate'; +} + +function compareUpdatedAtDescending(a: { readonly updatedAt: string }, b: { readonly updatedAt: string }): number { + return Date.parse(b.updatedAt) - Date.parse(a.updatedAt); +} + +function toCandidate(pullRequest: IGitHubRecentPullRequest, strategy: 'githubCiFailure' | 'githubReviewComments'): INewSessionViewV3GitHubCandidate { + return { title: pullRequest.title, url: pullRequest.url, strategy }; +} + +function capitalize(value: string): string { + return value.length > 0 ? value[0].toUpperCase() + value.slice(1) : value; +} diff --git a/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3TourContribution.ts b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3TourContribution.ts index 942e74b36fc..45e0a5f6e58 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3TourContribution.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3TourContribution.ts @@ -3,17 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { onboardingScenarioRegistry } from '../../../../workbench/contrib/onboarding/common/onboardingRegistry.js'; import { IOnboardingScenarioService } from '../../../../workbench/contrib/onboarding/common/onboardingScenarioService.js'; +import { IWorkbenchAssignmentService } from '../../../../workbench/services/assignment/common/assignmentService.js'; import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { INewSessionComposerService } from '../../chat/browser/newSessionComposerService.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { NewSessionViewV3PromptRunner } from './newSessionViewV3Prompt.js'; import { NewSessionViewTourTrigger } from './newSessionViewTourTrigger.js'; import { createNewSessionViewV3Tour, NEW_SESSION_VIEW_V3_TOUR_ID } from './tours/newSessionViewV3Tour.js'; @@ -28,6 +34,12 @@ class NewSessionViewV3TourContribution extends Disposable implements IWorkbenchC @IContextKeyService contextKeyService: IContextKeyService, @IChatEntitlementService chatEntitlementService: IChatEntitlementService, @INewSessionComposerService private readonly _newSessionComposerService: INewSessionComposerService, + @IWorkbenchAssignmentService assignmentService: IWorkbenchAssignmentService, + @IGitService gitService: IGitService, + @IFileService fileService: IFileService, + @IGitHubService gitHubService: IGitHubService, + @ITelemetryService telemetryService: ITelemetryService, + @ILogService logService: ILogService, ) { super(); @@ -40,19 +52,22 @@ class NewSessionViewV3TourContribution extends Disposable implements IWorkbenchC contextKeyService, chatEntitlementService, )); + const promptRunner = new NewSessionViewV3PromptRunner( + assignmentService, + configurationService, + this._sessionsService, + this._newSessionComposerService, + gitService, + fileService, + gitHubService, + telemetryService, + logService, + ); this._register(onboardingScenarioRegistry.register(createNewSessionViewV3Tour( trigger.signal, - (prompt, durationMs, taskPlaceholder, token) => this._animatePrompt(prompt, durationMs, taskPlaceholder, token), + token => promptRunner.run(token), ))); } - - private async _animatePrompt(prompt: string, durationMs: number, taskPlaceholder: string, token: CancellationToken): Promise { - const activeSession = this._sessionsService.activeSession.get(); - if (activeSession?.isCreated.get()) { - return false; - } - return this._newSessionComposerService.activeComposer.get()?.animatePrompt(prompt, durationMs, taskPlaceholder, token) ?? false; - } } registerWorkbenchContribution2(NewSessionViewV3TourContribution.ID, NewSessionViewV3TourContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts b/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts index 5eb2cd0f972..389380b114e 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts @@ -8,6 +8,7 @@ // effect. The onboarding engine and the spotlight presentation live in // `vs/workbench/contrib/onboarding` and are booted from the workbench // contribution imported in the entry point. +import './agentHostReadinessContext.js'; import './newSessionTourContribution.js'; import './newSessionViewV2TourContribution.js'; import './newSessionViewV3TourContribution.js'; diff --git a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTourShared.ts b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTourShared.ts index 52c4f0fc9ee..1ebbe9227ef 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTourShared.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTourShared.ts @@ -9,12 +9,13 @@ import { EditorPartModalVisibleContext } from '../../../../../workbench/common/c import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ISpotlightStep } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightTypes.js'; import { ChatEntitlementContextKeys } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; -import { IsNewChatSessionContext, SessionHasWorkspaceContext, SessionWorkspacePickerVisibleContext } from '../../../../common/contextkeys.js'; +import { AgentHostSessionTypesAvailableContext, IsNewChatSessionContext, SessionHasWorkspaceContext, SessionWorkspacePickerVisibleContext } from '../../../../common/contextkeys.js'; export function createNewSessionViewRecentTourWhen(): ContextKeyExpression | undefined { return ContextKeyExpr.and( ChatContextKeys.enabled, IsNewChatSessionContext, + AgentHostSessionTypesAvailableContext, ChatEntitlementContextKeys.Entitlement.signedOut.toNegated(), EditorPartModalVisibleContext.toNegated(), ); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV3Tour.ts b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV3Tour.ts index a0238ea854c..d0c7a86579a 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV3Tour.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV3Tour.ts @@ -5,7 +5,6 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IObservable } from '../../../../../base/common/observable.js'; -import { localize } from '../../../../../nls.js'; import { RUN_ONBOARDING_STEP_KIND, IRunOnboardingStepPayload } from '../../../../../workbench/contrib/onboarding/browser/sequence/runOnboardingStep.js'; import { SPOTLIGHT_PRESENTATION_KIND } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightTypes.js'; import { IOnboardingScenario } from '../../../../../workbench/contrib/onboarding/common/onboardingScenario.js'; @@ -14,23 +13,24 @@ import { NEW_SESSION_ONBOARDING_SEEN_KEY } from './newSessionTour.js'; import { createNewSessionViewRecentTourWhen, createNewSessionViewWorkspaceStep } from './newSessionViewTourShared.js'; export const NEW_SESSION_VIEW_V3_TOUR_ID = 'sessions.onboarding.newSessionViewV3'; +export const NEW_SESSION_VIEW_V3_PROMPT_VARIATION = 'prompt'; +export const NEW_SESSION_VIEW_V3_GITHUB_PROMPT_VARIATION = 'githubPrompt'; +export const NEW_SESSION_VIEW_V3_VARIATION_TREATMENT = 'onb.newSessionViewV3.variation'; +export const NEW_SESSION_VIEW_V3_VARIATIONS = [NEW_SESSION_VIEW_V3_PROMPT_VARIATION, NEW_SESSION_VIEW_V3_GITHUB_PROMPT_VARIATION] as const; const NEW_SESSION_VIEW_V3_EXPERIMENT = { behaviorFlag: 'onb.newSessionViewV3.show', assignmentContextIdFlag: 'onb.newSessionViewV3.id', } as const; -const PROMPT_TYPING_DURATION_MS = 2_500; -const NEW_SESSION_VIEW_V3_TASK_PLACEHOLDER = localize('sessions.onboarding.newSessionViewV3.prompt.taskPlaceholder', "[describe the coding task]"); -const NEW_SESSION_VIEW_V3_PROMPT = localize('sessions.onboarding.newSessionViewV3.prompt.text', "Help me complete {0} in this project. First, inspect the relevant files and explain your approach briefly. Then implement the solution using existing project conventions, avoid unrelated changes, and run the most relevant tests or checks. If anything is unclear, make a reasonable assumption and state it. When finished, summarize what changed and mention any remaining issues.", NEW_SESSION_VIEW_V3_TASK_PLACEHOLDER); - export function createNewSessionViewV3Tour( signal: IObservable, - runPromptStep: (prompt: string, durationMs: number, taskPlaceholder: string, token: CancellationToken) => Promise | boolean, + runPromptStep: (token: CancellationToken) => Promise | boolean, ): IOnboardingScenario { return { id: NEW_SESSION_VIEW_V3_TOUR_ID, seenKey: NEW_SESSION_ONBOARDING_SEEN_KEY, + developerModeVariations: NEW_SESSION_VIEW_V3_VARIATIONS, when: createNewSessionViewRecentTourWhen(), trigger: { kind: 'observable', signal }, priority: 120, @@ -48,7 +48,7 @@ export function createNewSessionViewV3Tour( id: 'insertPrompt', kind: RUN_ONBOARDING_STEP_KIND, payload: { - run: async token => ({ shown: await runPromptStep(NEW_SESSION_VIEW_V3_PROMPT, PROMPT_TYPING_DURATION_MS, NEW_SESSION_VIEW_V3_TASK_PLACEHOLDER, token) }), + run: async token => ({ shown: await runPromptStep(token) }), } satisfies IRunOnboardingStepPayload, }, ], diff --git a/src/vs/sessions/contrib/onboardingTours/test/browser/agentHostReadinessContext.test.ts b/src/vs/sessions/contrib/onboardingTours/test/browser/agentHostReadinessContext.test.ts new file mode 100644 index 00000000000..dd30041611d --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/test/browser/agentHostReadinessContext.test.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. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { AgentHostSessionTypesAvailableContext } from '../../../../common/contextkeys.js'; +import { SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; +import { IProviderSessionType, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { AgentHostReadinessContextContribution } from '../../browser/agentHostReadinessContext.js'; + +suite('AgentHostReadinessContextContribution', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('tracks whether any agent-host provider advertises session types', () => { + const onDidChangeSessionTypes = disposables.add(new Emitter()); + let sessionTypes: IProviderSessionType[] = []; + const sessionsManagementService = new class extends mock() { + override readonly onDidChangeSessionTypes = onDidChangeSessionTypes.event; + override getAllProviderSessionTypes(): IProviderSessionType[] { return sessionTypes; } + }(); + const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); + disposables.add(new AgentHostReadinessContextContribution(sessionsManagementService, contextKeyService)); + const states = [contextKeyService.getContextKeyValue(AgentHostSessionTypesAvailableContext.key)]; + + sessionTypes = [providerSessionType('copilot-chat')]; + onDidChangeSessionTypes.fire(); + states.push(contextKeyService.getContextKeyValue(AgentHostSessionTypesAvailableContext.key)); + + sessionTypes = [providerSessionType('agenthost-remote')]; + onDidChangeSessionTypes.fire(); + states.push(contextKeyService.getContextKeyValue(AgentHostSessionTypesAvailableContext.key)); + + sessionTypes = []; + onDidChangeSessionTypes.fire(); + states.push(contextKeyService.getContextKeyValue(AgentHostSessionTypesAvailableContext.key)); + + assert.deepStrictEqual(states, [false, false, true, false]); + }); +}); + +function providerSessionType(providerId: string): IProviderSessionType { + return { + providerId, + sessionType: { + id: 'copilotcli', + label: 'Copilot', + icon: Codicon.vm, + authRequirement: SessionTypeAuthRequirement.GitHub, + }, + }; +} diff --git a/src/vs/sessions/contrib/onboardingTours/test/browser/gitHubRepositoryResolver.test.ts b/src/vs/sessions/contrib/onboardingTours/test/browser/gitHubRepositoryResolver.test.ts new file mode 100644 index 00000000000..99c5b1529ba --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/test/browser/gitHubRepositoryResolver.test.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { getGitHubRepositoryFromRemoteUrl } from '../../../../../workbench/contrib/git/common/utils.js'; +import { parseGitHubRepositoryFromGitConfig, resolveGitHubRepositoryFromGitConfig } from '../../browser/gitHubRepositoryResolver.js'; + +const ROOT = URI.from({ scheme: 'vscode-tests', path: '/workspace' }); + +suite('GitHubRepositoryResolver', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('prefers the origin GitHub remote from git config', () => { + assert.deepStrictEqual(parseGitHubRepositoryFromGitConfig(` + [remote "upstream"] + url = https://github.com/upstream/project.git + [remote "origin"] + url = git@github.com:owner/project.git + `), { + owner: 'owner', + repo: 'project', + }); + }); + + test('does not normalize HTTP hosts that merely end in github.com', () => { + assert.deepStrictEqual({ + lookalike: getGitHubRepositoryFromRemoteUrl('https://evil-github.com/owner/project.git'), + sshAlias: getGitHubRepositoryFromRemoteUrl('ssh://work-github.com/owner/project.git'), + }, { + lookalike: undefined, + sshAlias: { owner: 'owner', repo: 'project' }, + }); + }); + + test('finds git config above a nested selected workspace folder', async () => { + const fileService = disposables.add(new FileService(new NullLogService())); + const provider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(ROOT.scheme, provider)); + await fileService.createFolder(joinPath(ROOT, '.git')); + await fileService.createFolder(joinPath(ROOT, 'src', 'feature')); + await fileService.writeFile(joinPath(ROOT, '.git', 'config'), VSBuffer.fromString(` + [remote "origin"] + url = https://github.com/microsoft/vscode.git + `)); + + assert.deepStrictEqual(await resolveGitHubRepositoryFromGitConfig(fileService, joinPath(ROOT, 'src', 'feature')), { + owner: 'microsoft', + repo: 'vscode', + }); + }); +}); diff --git a/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts index 3ce14cd5c19..9569f0d5af4 100644 --- a/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts +++ b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts @@ -6,8 +6,9 @@ import assert from 'assert'; import { observableValue } from '../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IsNewChatSessionContext, SessionHasWorkspaceContext } from '../../../../common/contextkeys.js'; +import { AgentHostSessionTypesAvailableContext, IsNewChatSessionContext, SessionHasWorkspaceContext } from '../../../../common/contextkeys.js'; import { createNewSessionViewV2Tour, NEW_SESSION_VIEW_V2_TOUR_ID } from '../../browser/tours/newSessionViewV2Tour.js'; +import { createNewSessionViewV3Tour } from '../../browser/tours/newSessionViewV3Tour.js'; import { NEW_SESSION_ONBOARDING_SEEN_KEY } from '../../browser/tours/newSessionTour.js'; import { createNewSessionViewTour } from '../../browser/tours/newSessionViewTour.js'; @@ -82,6 +83,16 @@ suite('NewSessionViewV2Tour', () => { ); }); + test('waits for an agent-host provider before running V2 or V3', () => { + const trigger = observableValue(disposables, false); + const scenarios = [createNewSessionViewV2Tour(trigger), createNewSessionViewV3Tour(trigger, () => true)]; + + assert.deepStrictEqual( + scenarios.map(scenario => scenario.when?.keys().includes(AgentHostSessionTypesAvailableContext.key)), + [true, true], + ); + }); + test('keeps picker targets interactive in both view tours', () => { const trigger = observableValue(disposables, false); const scenarios = [createNewSessionViewTour(trigger), createNewSessionViewV2Tour(trigger)]; diff --git a/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Prompt.test.ts b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Prompt.test.ts new file mode 100644 index 00000000000..d6f6f800b32 --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Prompt.test.ts @@ -0,0 +1,520 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { FileOperationError, FileOperationResult, IFileService } from '../../../../../platform/files/common/files.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IGitRepository, IGitService } from '../../../../../workbench/contrib/git/common/gitService.js'; +import { ONBOARDING_DEVELOPER_MODE_CONFIG, ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG } from '../../../../../workbench/contrib/onboarding/common/onboardingScenarioService.js'; +import { IWorkbenchAssignmentService } from '../../../../../workbench/services/assignment/common/assignmentService.js'; +import { NullWorkbenchAssignmentService } from '../../../../../workbench/services/assignment/test/common/nullAssignmentService.js'; +import { GITHUB_REMOTE_FILE_SCHEME, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { INewSessionComposerService } from '../../../chat/browser/newSessionComposerService.js'; +import { GitHubAuthenticationError } from '../../../github/browser/githubApiClient.js'; +import { IGitHubRecentUserWork } from '../../../github/browser/fetchers/githubRecentUserWorkFetcher.js'; +import { IGitHubService } from '../../../github/browser/githubService.js'; +import { NewSessionViewV3PromptRunner, selectNewSessionViewV3GitHubCandidate } from '../../browser/newSessionViewV3Prompt.js'; +import { NEW_SESSION_VIEW_V3_TOUR_ID } from '../../browser/tours/newSessionViewV3Tour.js'; + +class TestAssignmentService extends NullWorkbenchAssignmentService { + constructor(private readonly _treatments: Partial>) { + super(); + } + + override async getTreatment(name: string): Promise { + return this._treatments[name] as T | undefined; + } +} + +class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { readonly name: string; readonly data: object | undefined }[] = []; + + override publicLog2(name?: string, data?: object): void { + if (name) { + this.events.push({ name, data }); + } + } +} + +class MissingFileService extends mock() { + override stat(_resource: URI): ReturnType { + return Promise.reject(new FileOperationError('Not found', FileOperationResult.FILE_NOT_FOUND)); + } +} + +type TestGitHubRequest = + | { readonly kind: 'issues'; readonly owner: string; readonly repo: string } + | { readonly kind: 'pullRequests'; readonly owner: string; readonly repo: string } + | { readonly kind: 'reviews'; readonly owner: string; readonly repo: string; readonly pullRequestNumber: number } + | { readonly kind: 'issueLinkage'; readonly owner: string; readonly repo: string; readonly issueNumbers: readonly number[] }; + +suite('NewSessionViewV3Prompt', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('selects the newest candidate in priority order', () => { + const reviewPullRequest = pullRequest('Review', '2026-08-07T12:00:00Z', undefined, '2026-08-07T09:00:00Z', '2026-08-07T10:00:00Z'); + const recentFailure = pullRequest('Recent failure', '2026-08-07T11:00:00Z', 'FAILURE'); + const olderFailure = pullRequest('Older failure', '2026-08-07T10:00:00Z', 'ERROR'); + const recentIssue = issue('Recent issue', '2026-08-07T13:00:00Z'); + const olderIssue = issue('Older issue', '2026-08-07T08:00:00Z'); + + assert.deepStrictEqual({ + ci: selectNewSessionViewV3GitHubCandidate({ pullRequests: [olderFailure, reviewPullRequest, recentFailure], issues: [recentIssue] }), + review: selectNewSessionViewV3GitHubCandidate({ pullRequests: [reviewPullRequest], issues: [recentIssue] }), + issue: selectNewSessionViewV3GitHubCandidate({ pullRequests: [], issues: [olderIssue, recentIssue] }), + none: selectNewSessionViewV3GitHubCandidate({ pullRequests: [pullRequest('Addressed', '2026-08-07T14:00:00Z', undefined, '2026-08-07T11:00:00Z', '2026-08-07T10:00:00Z')], issues: [] }), + }, { + ci: { title: 'Recent failure', url: 'https://github.com/o/r/pull/Recent%20failure', strategy: 'githubCiFailure' }, + review: { title: 'Review', url: 'https://github.com/o/r/pull/Review', strategy: 'githubReviewComments' }, + issue: { title: 'Recent issue', url: 'https://github.com/o/r/issues/Recent%20issue', strategy: 'githubIssue' }, + none: undefined, + }); + }); + + test('uses prompt treatments only as a complete pair and permits literal prompts', async () => { + const complete = await runPrompt({ + 'onb.newSessionViewV3.promptTemplate': 'Inspect this project and suggest the next task.', + 'onb.newSessionViewV3.placeholder': '[custom task]', + }); + const incomplete = await runPrompt({ + 'onb.newSessionViewV3.promptTemplate': 'Please complete {0}.', + }); + + assert.deepStrictEqual({ + complete: complete.animation, + incomplete: incomplete.animation, + }, { + complete: { prompt: 'Inspect this project and suggest the next task.', durationMs: 2_500, placeholder: '[custom task]' }, + incomplete: { + prompt: 'Help me complete [describe the coding task] in this project. First, inspect the relevant files and explain your approach briefly. Then implement the solution using existing project conventions, avoid unrelated changes, and run the most relevant tests or checks. If anything is unclear, make a reasonable assumption and state it. When finished, summarize what changed and mention any remaining issues.', + durationMs: 2_500, + placeholder: '[describe the coding task]', + }, + }); + }); + + test('developer override selects a GitHub CI prompt and reports telemetry', async () => { + const result = await runPrompt({ + 'onb.newSessionViewV3.variation': 'prompt', + }, { + [ONBOARDING_DEVELOPER_MODE_CONFIG]: { [NEW_SESSION_VIEW_V3_TOUR_ID]: true }, + [ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG]: { [NEW_SESSION_VIEW_V3_TOUR_ID]: 'githubPrompt' }, + }, { + pullRequests: [pullRequest('Fix CI', '2026-08-07T12:00:00Z', 'FAILURE')], + issues: [], + }); + + assert.deepStrictEqual({ + animation: result.animation, + telemetry: result.telemetry, + }, { + animation: { + prompt: 'The following pull request has failing CI checks: "Fix CI" (https://github.com/o/r/pull/Fix%20CI). Investigate the failures and resolve them.', + durationMs: 2_500, + placeholder: '', + }, + telemetry: [{ + name: 'onboarding.promptStrategy', + data: { + scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + configuredVariation: 'githubPrompt', + effectiveStrategy: 'githubCiFailure', + fallbackReason: 'none', + shown: true, + }, + }], + }); + }); + + test('falls back to the prompt variation when silent GitHub authentication is unavailable', async () => { + const result = await runPrompt({ + 'onb.newSessionViewV3.variation': 'githubPrompt', + }, {}, new GitHubAuthenticationError()); + + assert.deepStrictEqual({ + animation: result.animation, + telemetry: result.telemetry, + }, { + animation: { + prompt: 'Help me complete [describe the coding task] in this project. First, inspect the relevant files and explain your approach briefly. Then implement the solution using existing project conventions, avoid unrelated changes, and run the most relevant tests or checks. If anything is unclear, make a reasonable assumption and state it. When finished, summarize what changed and mention any remaining issues.', + durationMs: 2_500, + placeholder: '[describe the coding task]', + }, + telemetry: [{ + name: 'onboarding.promptStrategy', + data: { + scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + configuredVariation: 'githubPrompt', + effectiveStrategy: 'prompt', + fallbackReason: 'noAuthentication', + shown: true, + }, + }], + }); + }); + + test('uses an issue when the pull request summary lookup times out', async () => { + const result = await runPrompt( + { 'onb.newSessionViewV3.variation': 'githubPrompt' }, + {}, + { pullRequests: [], issues: [issue('Ready issue', '2026-08-07T13:00:00Z')] }, + { pullRequestLookupNeverResolves: true }, + ); + + assert.deepStrictEqual({ + animation: result.animation, + telemetry: result.telemetry, + }, { + animation: { + prompt: 'Tackle the following issue and create a pull request for it: "Ready issue" (https://github.com/o/r/issues/Ready%20issue).', + durationMs: 2_500, + placeholder: '', + }, + telemetry: [{ + name: 'onboarding.promptStrategy', + data: { + scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + configuredVariation: 'githubPrompt', + effectiveStrategy: 'githubIssue', + fallbackReason: 'none', + shown: true, + }, + }], + }); + }); + + test('uses an assigned issue when its pull request linkage lookup times out', async () => { + const result = await runPrompt( + { 'onb.newSessionViewV3.variation': 'githubPrompt' }, + {}, + { pullRequests: [], issues: [issue('Unknown linkage', '2026-08-07T13:00:00Z')] }, + { issueLinkageLookupNeverResolves: true }, + ); + + assert.deepStrictEqual(result.animation, { + prompt: 'Tackle the following issue and create a pull request for it: "Unknown linkage" (https://github.com/o/r/issues/Unknown%20linkage).', + durationMs: 2_500, + placeholder: '', + }); + }); + + test('resolves the repository from a cloud GitHub workspace URI', async () => { + const result = await runPrompt( + { 'onb.newSessionViewV3.variation': 'githubPrompt' }, + {}, + { pullRequests: [], issues: [issue('Cloud issue', '2026-08-07T13:00:00Z')] }, + { + workspaceUri: URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/cloud/repository/HEAD' }), + includeGitHubInfo: false, + }, + ); + + assert.deepStrictEqual({ + animation: result.animation, + gitHubRequests: result.gitHubRequests, + }, { + animation: { + prompt: 'Tackle the following issue and create a pull request for it: "Cloud issue" (https://github.com/o/r/issues/Cloud%20issue).', + durationMs: 2_500, + placeholder: '', + }, + gitHubRequests: [ + { kind: 'issues', owner: 'cloud', repo: 'repository' }, + { kind: 'pullRequests', owner: 'cloud', repo: 'repository' }, + { kind: 'issueLinkage', owner: 'cloud', repo: 'repository', issueNumbers: [1] }, + ], + }); + }); + + test('resolves the repository from a local GitHub remote', async () => { + const result = await runPrompt( + { 'onb.newSessionViewV3.variation': 'githubPrompt' }, + {}, + { pullRequests: [], issues: [issue('Local issue', '2026-08-07T13:00:00Z')] }, + { + includeGitHubInfo: false, + gitRemoteUrl: 'git@github.com:local/repository.git', + }, + ); + + assert.deepStrictEqual(result.gitHubRequests, [ + { kind: 'issues', owner: 'local', repo: 'repository' }, + { kind: 'pullRequests', owner: 'local', repo: 'repository' }, + { kind: 'issueLinkage', owner: 'local', repo: 'repository', issueNumbers: [1] }, + ]); + }); + + test('waits for Agent Host git metadata instead of requiring the Git extension', async () => { + const workspace = observableValue('workspace', createWorkspace(URI.file('C:\\repo'), 'r', false)); + const activeSession = new class extends mock() { + override readonly providerId = 'local-agent-host'; + override readonly sessionType = 'copilotcli'; + override readonly isCreated = constObservable(false); + override readonly workspace = workspace; + }(); + let gitServiceCalled = false; + let prompt: string | undefined; + const runner = new NewSessionViewV3PromptRunner( + new TestAssignmentService({ 'onb.newSessionViewV3.variation': 'githubPrompt' }), + new TestConfigurationService(), + new class extends mock() { + override readonly activeSession = constObservable(activeSession); + }(), + new class extends mock() { + override readonly activeComposer = constObservable({ + animatePrompt: async (text: string) => { + prompt = text; + return true; + }, + }); + }(), + new class extends mock() { + override async openRepository(): Promise { + gitServiceCalled = true; + return undefined; + } + }(), + new MissingFileService(), + new class extends mock() { + override async getRecentAssignedIssues() { + return [issue('Metadata issue', '2026-08-07T14:00:00Z')]; + } + override async getRecentAuthoredPullRequests() { return []; } + override async getPullRequestReviewThreads() { return []; } + override async getIssuesWithLinkedPullRequests() { return new Set(); } + }(), + new TestTelemetryService(), + new NullLogService(), + { totalMs: 1_000, summaryMs: 100, linkageMs: 100, reviewMs: 100 }, + ); + + const run = runner.run(CancellationToken.None); + await timeout(0); + workspace.set(createWorkspace(URI.file('C:\\repo'), 'r', true), undefined); + await run; + + assert.deepStrictEqual({ + gitServiceCalled, + prompt, + }, { + gitServiceCalled: false, + prompt: 'Tackle the following issue and create a pull request for it: "Metadata issue" (https://github.com/o/r/issues/Metadata%20issue).', + }); + }); + + test('discards a result when the selected workspace changes during the request', async () => { + const firstWorkspace = createWorkspace(URI.file('C:\\first'), 'first'); + const secondWorkspace = createWorkspace(URI.file('C:\\second'), 'second'); + const firstSession = createSession(firstWorkspace); + const secondSession = createSession(secondWorkspace); + const activeSession = observableValue('activeSession', firstSession); + const requests: { owner: string; repo: string }[] = []; + let prompt: string | undefined; + const runner = new NewSessionViewV3PromptRunner( + new TestAssignmentService({ 'onb.newSessionViewV3.variation': 'githubPrompt' }), + new TestConfigurationService(), + new class extends mock() { + override readonly activeSession = activeSession; + }(), + new class extends mock() { + override readonly activeComposer = constObservable({ + animatePrompt: async (text: string) => { + prompt = text; + return true; + }, + }); + }(), + new class extends mock() { }(), + new MissingFileService(), + new class extends mock() { + override async getRecentAssignedIssues(_owner: string, repo: string) { + return [issue(repo === 'first' ? 'Stale issue' : 'Current issue', '2026-08-07T14:00:00Z')]; + } + override async getRecentAuthoredPullRequests(owner: string, repo: string) { + requests.push({ owner, repo }); + if (requests.length === 1) { + activeSession.set(secondSession, undefined); + } + return []; + } + override async getPullRequestReviewThreads() { + return []; + } + override async getIssuesWithLinkedPullRequests() { + return new Set(); + } + }(), + new TestTelemetryService(), + new NullLogService(), + ); + + await runner.run(CancellationToken.None); + + assert.deepStrictEqual({ + requests, + prompt, + }, { + requests: [{ owner: 'o', repo: 'first' }, { owner: 'o', repo: 'second' }], + prompt: 'Tackle the following issue and create a pull request for it: "Current issue" (https://github.com/o/r/issues/Current%20issue).', + }); + }); +}); + +async function runPrompt( + treatments: Partial>, + configuration: Record = {}, + gitHubResult: IGitHubRecentUserWork | Error = { pullRequests: [], issues: [] }, + options: { readonly workspaceUri?: URI; readonly includeGitHubInfo?: boolean; readonly gitRemoteUrl?: string; readonly pullRequestLookupNeverResolves?: boolean; readonly issueLinkageLookupNeverResolves?: boolean } = {}, +): Promise<{ + readonly animation: { readonly prompt: string; readonly durationMs: number; readonly placeholder: string } | undefined; + readonly telemetry: readonly { readonly name: string; readonly data: object | undefined }[]; + readonly gitHubRequests: readonly TestGitHubRequest[]; +}> { + let animation: { prompt: string; durationMs: number; placeholder: string } | undefined; + const workspaceUri = options.workspaceUri ?? URI.file('C:\\repo'); + const workspace = createWorkspace(workspaceUri, 'r', options.includeGitHubInfo !== false); + const activeSession = createSession(workspace); + const sessionsService = new class extends mock() { + override readonly activeSession = constObservable(activeSession); + }(); + const composerService = new class extends mock() { + override readonly activeComposer = constObservable({ + animatePrompt: async (prompt: string, durationMs: number, placeholder: string) => { + animation = { prompt, durationMs, placeholder }; + return true; + }, + }); + }(); + const gitHubService = new class extends mock() { + readonly requests: TestGitHubRequest[] = []; + override async getRecentAssignedIssues(owner: string, repo: string) { + this.requests.push({ kind: 'issues', owner, repo }); + if (gitHubResult instanceof Error) { + throw gitHubResult; + } + return gitHubResult.issues; + } + override async getRecentAuthoredPullRequests(owner: string, repo: string) { + this.requests.push({ kind: 'pullRequests', owner, repo }); + if (options.pullRequestLookupNeverResolves) { + return new Promise(() => { }); + } + if (gitHubResult instanceof Error) { + throw gitHubResult; + } + return gitHubResult.pullRequests; + } + override async getPullRequestReviewThreads(owner: string, repo: string, pullRequestNumber: number) { + this.requests.push({ kind: 'reviews', owner, repo, pullRequestNumber }); + if (gitHubResult instanceof Error) { + throw gitHubResult; + } + return gitHubResult.pullRequests.find(pullRequest => pullRequest.number === pullRequestNumber)?.reviewThreads ?? []; + } + override async getIssuesWithLinkedPullRequests(owner: string, repo: string, issueNumbers: readonly number[]) { + this.requests.push({ kind: 'issueLinkage', owner, repo, issueNumbers }); + if (options.issueLinkageLookupNeverResolves) { + return new Promise(() => { }); + } + return new Set(); + } + }(); + const telemetryService = new TestTelemetryService(); + const gitService = new class extends mock() { + override async openRepository(): Promise { + if (!options.gitRemoteUrl) { + return undefined; + } + return new class extends mock() { + override readonly rootUri = workspaceUri; + override readonly state = constObservable({ + remotes: [{ name: 'origin', fetchUrl: options.gitRemoteUrl, isReadOnly: false }], + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + untrackedChanges: [], + }); + }(); + } + }(); + const runner = new NewSessionViewV3PromptRunner( + new TestAssignmentService(treatments) as IWorkbenchAssignmentService, + new TestConfigurationService(configuration), + sessionsService, + composerService, + gitService, + new MissingFileService(), + gitHubService, + telemetryService, + new NullLogService(), + { totalMs: 100, summaryMs: 20, linkageMs: 20, reviewMs: 20 }, + ); + + await runner.run(CancellationToken.None); + return { animation, telemetry: telemetryService.events, gitHubRequests: gitHubService.requests }; +} + +function pullRequest(title: string, updatedAt: string, statusCheckRollupState?: string, latestCommitAt?: string, latestCommentAt?: string, number = 1) { + return { + number, + title, + url: `https://github.com/o/r/pull/${encodeURIComponent(title)}`, + updatedAt, + statusCheckRollupState, + latestCommitAt, + reviewThreads: latestCommentAt ? [{ isResolved: false, latestCommentAt }] : [], + }; +} + +function issue(title: string, updatedAt: string, number = 1) { + return { + number, + title, + url: `https://github.com/o/r/issues/${encodeURIComponent(title)}`, + updatedAt, + }; +} + +function createWorkspace(uri: URI, repo: string, includeGitHubInfo = true): ISessionWorkspace { + return { + uri, + label: repo, + icon: Codicon.repo, + folders: [{ + root: uri, + workingDirectory: uri, + name: repo, + description: undefined, + gitRepository: { + uri, + workTreeUri: undefined, + baseBranchName: undefined, + gitHubInfo: constObservable(includeGitHubInfo ? { owner: 'o', repo } : undefined), + }, + }], + requiresWorkspaceTrust: true, + isVirtualWorkspace: uri.scheme === GITHUB_REMOTE_FILE_SCHEME, + }; +} + +function createSession(workspace: ISessionWorkspace): IActiveSession { + return new class extends mock() { + override readonly providerId = 'test'; + override readonly sessionType = 'test'; + override readonly isCreated = constObservable(false); + override readonly workspace = constObservable(workspace); + }(); +} diff --git a/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Tour.test.ts b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Tour.test.ts index af3e676a1e7..501fcc17155 100644 --- a/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Tour.test.ts +++ b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Tour.test.ts @@ -15,12 +15,10 @@ import { NEW_SESSION_ONBOARDING_SEEN_KEY } from '../../browser/tours/newSessionT suite('NewSessionViewV3Tour', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('reuses V2 criteria and workspace step, then runs the cancellable task template step', async () => { + test('reuses V2 criteria and workspace step, then runs the variation step', async () => { const trigger = observableValue(disposables, false); - const inserted: { readonly prompt: string; readonly durationMs: number; readonly taskPlaceholder: string }[] = []; let receivedToken: CancellationToken | undefined; - const scenario = createNewSessionViewV3Tour(trigger, (prompt, durationMs, taskPlaceholder, token) => { - inserted.push({ prompt, durationMs, taskPlaceholder }); + const scenario = createNewSessionViewV3Tour(trigger, token => { receivedToken = token; return true; }); @@ -49,12 +47,12 @@ suite('NewSessionViewV3Tour', () => { seenKey: scenario.seenKey, priority: scenario.priority, experiment: scenario.experiment, + developerModeVariations: scenario.developerModeVariations, criteriaMatchV2: scenario.when?.serialize() === v2Scenario.when?.serialize(), presentationKind: scenario.presentation.kind, steps: steps.map(step => ({ id: step.id, kind: step.kind })), workspaceStep: summarizeWorkspaceStep(workspaceStep), v2WorkspaceStep: summarizeWorkspaceStep(v2WorkspaceStep), - inserted, receivedTokenIsForwarded: receivedToken === CancellationToken.None, runResult, }, { @@ -65,6 +63,7 @@ suite('NewSessionViewV3Tour', () => { behaviorFlag: 'onb.newSessionViewV3.show', assignmentContextIdFlag: 'onb.newSessionViewV3.id', }, + developerModeVariations: ['prompt', 'githubPrompt'], criteriaMatchV2: true, presentationKind: 'sequence', steps: [ @@ -95,11 +94,6 @@ suite('NewSessionViewV3Tour', () => { allowTargetInteraction: true, advanceWhen: 'sessionHasWorkspace', }, - inserted: [{ - prompt: 'Help me complete [describe the coding task] in this project. First, inspect the relevant files and explain your approach briefly. Then implement the solution using existing project conventions, avoid unrelated changes, and run the most relevant tests or checks. If anything is unclear, make a reasonable assumption and state it. When finished, summarize what changed and mention any remaining issues.', - durationMs: 2_500, - taskPlaceholder: '[describe the coding task]', - }], receivedTokenIsForwarded: true, runResult: { shown: true }, }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 5b9018aa525..ad749efbd09 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -47,7 +47,7 @@ import { getRegisteredLanguageModels, resolveConfiguredModel, resolveModelIdenti import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { ChatInteractivity, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, SessionStatus, SessionTypeAuthRequirement, toSessionId } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionStatus, SessionTypeAuthRequirement, toSessionId } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot } from '../../../../services/sessions/common/sessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; @@ -248,6 +248,33 @@ function toGitHubPullRequestRefs(pullRequestUrls: readonly string[] | undefined) return refs.length > 0 ? refs : undefined; } +function toGitHubInfo(meta: SessionMeta | undefined): IGitHubInfo | undefined { + const state = readSessionGitHubState(meta); + const gitState = readSessionGitState(meta); + const pullRequests = toGitHubPullRequestRefs(state?.pullRequestUrls); + const pullRequest = pullRequests?.[0]; + const repository = state?.owner && state.repo + ? { owner: state.owner, repo: state.repo } + : gitState?.githubOwner && gitState.githubRepo + ? { owner: gitState.githubOwner, repo: gitState.githubRepo } + : pullRequest; + + if (!repository) { + return undefined; + } + + return { + owner: repository.owner, + repo: repository.repo, + pullRequests, + pullRequest: pullRequest ? { + number: pullRequest.number, + uri: pullRequest.uri, + } : undefined, + issues: toGitHubIssueRefs(state?.issueUrls), + }; +} + // ============================================================================ // AgentHostSessionAdapter — shared adapter for local and remote sessions // ============================================================================ @@ -711,31 +738,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const baseGitHubInfoObs = derivedOpts({ equalsFn: isGitHubInfoEqual }, reader => { - const meta = this._metaObs.read(reader); - const state = readSessionGitHubState(meta); - if (!state) { - return undefined; - } - - const pullRequests = toGitHubPullRequestRefs(state.pullRequestUrls); - const pullRequest = pullRequests?.[0]; - const owner = state.owner ?? pullRequest?.owner; - const repo = state.repo ?? pullRequest?.repo; - - if (!owner || !repo) { - return undefined; - } - - return { - owner, - repo, - pullRequests, - pullRequest: pullRequest ? { - number: pullRequest.number, - uri: pullRequest.uri, - } : undefined, - issues: toGitHubIssueRefs(state.issueUrls), - }; + return toGitHubInfo(this._metaObs.read(reader)); }); const gitHubInfoWithIcon = derived(this, reader => { @@ -1520,6 +1523,7 @@ class NewSession extends Disposable { private readonly _title: ISettableObservable; private readonly _modelId: ISettableObservable; private readonly _mode: ISettableObservable<{ readonly id: string; readonly kind: string } | undefined>; + private readonly _workspace: ISettableObservable; private readonly _changesets = observableValue(this, undefined); private readonly _worktreePending = observableValue(this, false); private readonly _isActiveSessionObs: IObservable; @@ -1616,7 +1620,7 @@ class NewSession extends Disposable { this._title = observableValue(this, ''); const title = this._title; const updatedAt = observableValue(this, new Date()); - const workspaceObs = observableValue(this, ctx.workspace); + this._workspace = observableValue(this, ctx.workspace); const changes = observableValueOpts({ owner: this, equalsFn: sessionFileChangesEqual }, []); const checkpoints = observableValue(this, undefined); this._selectedModelId = undefined; @@ -1653,7 +1657,7 @@ class NewSession extends Disposable { sessionType: ctx.sessionType.id, icon: ctx.icon, createdAt, - workspace: workspaceObs, + workspace: this._workspace, isQuickChat: constObservable(this._kind.isQuickChat), worktreePending: this._worktreePending, title, @@ -1714,6 +1718,51 @@ class NewSession extends Disposable { setLoading(loading: boolean): void { this._loading.set(loading, undefined); } setTitle(title: string): void { this._title.set(title, undefined); } + applySessionMeta(meta: SessionMeta | undefined): boolean { + const workspace = this._workspace.get(); + const primaryFolder = workspace?.folders[0]; + if (!workspace || !primaryFolder) { + return false; + } + + const gitState = readSessionGitState(meta); + const gitHubInfo = toGitHubInfo(meta); + if (!gitState && !gitHubInfo) { + return false; + } + + const currentRepository = primaryFolder.gitRepository ?? { + uri: primaryFolder.root, + workTreeUri: undefined, + baseBranchName: undefined, + gitHubInfo: constObservable(undefined), + }; + const nextGitHubInfo = gitHubInfo + ?? (gitState?.hasGitHubRemote === false ? undefined : currentRepository.gitHubInfo.get()); + const nextWorkspace: ISessionWorkspace = { + ...workspace, + folders: [{ + ...primaryFolder, + gitRepository: { + ...currentRepository, + branchName: gitState?.branchName ?? currentRepository.branchName, + baseBranchName: gitState?.baseBranchName ?? currentRepository.baseBranchName, + hasGitHubRemote: gitState?.hasGitHubRemote ?? currentRepository.hasGitHubRemote, + upstreamBranchName: gitState?.upstreamBranchName ?? currentRepository.upstreamBranchName, + incomingChanges: gitState?.incomingChanges ?? currentRepository.incomingChanges, + outgoingChanges: gitState?.outgoingChanges ?? currentRepository.outgoingChanges, + uncommittedChanges: gitState?.uncommittedChanges ?? currentRepository.uncommittedChanges, + gitHubInfo: constObservable(nextGitHubInfo), + }, + }, ...workspace.folders.slice(1)], + }; + if (sessionWorkspaceEqual(workspace, nextWorkspace)) { + return false; + } + this._workspace.set(nextWorkspace, undefined); + return true; + } + // -- Config -------------------------------------------------------------- getConfig(): ResolveSessionConfigResult | undefined { return this._config; } @@ -4345,15 +4394,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** * NewSession variant of {@link _applySessionStateUpdate}: writes the - * customizations subset (the only one the agent picker reads) and - * fires `_onDidChangeCustomAgents` when it changes. Skips - * {@link _seedRunningConfigFromState} (NewSession owns its own config - * via `NewSession._config`) and {@link _applySessionMetaFromState} - * (which only applies to cached running sessions). + * customizations subset and applies git/GitHub metadata to the draft + * workspace. Skips {@link _seedRunningConfigFromState} because NewSession + * owns its own config via `NewSession._config`. */ private _handleNewSessionStateUpdate(sessionId: string, state: SessionState): void { const previous = this._lastSessionStates.get(sessionId); this._lastSessionStates.set(sessionId, state); + this._newSessions.get(sessionId)?.applySessionMeta(state._meta); if (!previous || customizationsChanged(previous, state)) { this._onDidChangeCustomAgents.fire(); this._onDidChangeCustomizations.fire(); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 0d228b04a67..373d6f9b301 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -2221,12 +2221,59 @@ suite('LocalAgentHostSessionsProvider', () => { children: [{ type: CustomizationType.Agent, id: 'agent://only', uri: 'agent://only', name: 'only' }], }], }); + assert.deepStrictEqual(provider.getCustomAgents(session.sessionId), [ { type: CustomizationType.Agent, id: 'agent://only', uri: 'agent://only', name: 'only' }, ]); assert.ok(fired > after, 'expected onDidChangeCustomAgents to fire again on a second update'); }); + test('NewSession publishes Agent Host git metadata before the first message', async () => { + const provider = createProvider(disposables, agentHost); + const sessionTypeId = provider.sessionTypes[0].id; + const session = provider.createNewSession(URI.parse('file:///home/user/proj'), sessionTypeId); + await timeout(0); + const rawId = session.resource.path.substring(1); + + agentHost.setSessionState(rawId, sessionTypeId, { + provider: sessionTypeId, + title: '', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + customizations: [], + _meta: { + github: { + owner: 'partial-owner', + }, + git: { + hasGitHubRemote: true, + githubOwner: 'microsoft', + githubRepo: 'vscode', + branchName: 'main', + }, + }, + }); + + const gitRepository = session.workspace.get()?.folders[0]?.gitRepository; + assert.deepStrictEqual({ + hasGitHubRemote: gitRepository?.hasGitHubRemote, + branchName: gitRepository?.branchName, + gitHubInfo: gitRepository?.gitHubInfo.get(), + }, { + hasGitHubRemote: true, + branchName: 'main', + gitHubInfo: { + owner: 'microsoft', + repo: 'vscode', + pullRequests: undefined, + pullRequest: undefined, + issues: undefined, + }, + }); + }); + test('NewSession releases observed changeset subscriptions when inactive', async () => { const activeSession = observableValue('test.activeSession', undefined); const provider = createProvider(disposables, agentHost, undefined, { activeSession }); diff --git a/src/vs/workbench/contrib/git/common/utils.ts b/src/vs/workbench/contrib/git/common/utils.ts index eeb0e75eac5..08b944c2368 100644 --- a/src/vs/workbench/contrib/git/common/utils.ts +++ b/src/vs/workbench/contrib/git/common/utils.ts @@ -7,6 +7,11 @@ import { equalsIgnoreCase } from '../../../../base/common/strings.js'; import { URI } from '../../../../base/common/uri.js'; import { GitRemote, GitRepositoryState } from './gitService.js'; +export interface IGitHubRemoteInfo { + readonly owner: string; + readonly repo: string; +} + export function hasGitHubRemotes(repositoryState: GitRepositoryState): boolean { const hosts = ['github.com', 'ghe.com']; const remotes = getOrderedRemotes(repositoryState!) @@ -29,6 +34,34 @@ export function hasGitHubRemotes(repositoryState: GitRepositoryState): boolean { return false; } +export function getGitHubRemoteInfo(repositoryState: GitRepositoryState): IGitHubRemoteInfo | undefined { + for (const remote of getOrderedRemotes(repositoryState)) { + if (remote.fetchUrl) { + const repository = getGitHubRepositoryFromRemoteUrl(remote.fetchUrl); + if (repository) { + return repository; + } + } + } + + return undefined; +} + +export function getGitHubRepositoryFromRemoteUrl(remoteUrl: string): IGitHubRemoteInfo | undefined { + const remote = parseRemoteUrl(remoteUrl); + if (!remote) { + return undefined; + } + const host = equalsIgnoreCase(remote.scheme, 'ssh') ? remote.host : remote.rawHost; + if (!equalsIgnoreCase(host, 'github.com') && !equalsIgnoreCase(host, 'www.github.com')) { + return undefined; + } + const segments = remote.path.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/i, '').split('/'); + return segments.length === 2 && segments[0] && segments[1] + ? { owner: segments[0], repo: segments[1] } + : undefined; +} + function getOrderedRemotes(repositoryState: GitRepositoryState): readonly GitRemote[] { if (repositoryState.remotes.length < 2) { return repositoryState.remotes; @@ -64,7 +97,7 @@ function getOrderedRemotes(repositoryState: GitRepositoryState): readonly GitRem return Array.from(remotes.values()); } -function parseRemoteUrl(fetchUrl: string): { host: string; rawHost: string; path: string } | undefined { +function parseRemoteUrl(fetchUrl: string): { scheme: string; host: string; rawHost: string; path: string } | undefined { fetchUrl = fetchUrl.trim(); try { // Normalize git shorthand syntax (git@github.com:user/repo.git) into an explicit ssh:// url @@ -102,7 +135,7 @@ function parseRemoteUrl(fetchUrl: string): { host: string; rawHost: string; path .replace(/^[\w\-]+-/, '') // Remove common ssh syntax: abc-github.com .replace(/-[\w\-]+$/, '');// Remove common ssh syntax: github.com-abc - return { host: normalizedHost, rawHost, path: path }; + return { scheme: repoUrl.scheme, host: normalizedHost, rawHost, path: path }; } catch (err) { return undefined; } diff --git a/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts b/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts index 6db964e133b..6fe7aebbbf8 100644 --- a/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts +++ b/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts @@ -17,7 +17,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { onboardingPresentationRegistry } from '../common/onboardingPresentation.js'; import { onboardingScenarioRegistry } from '../common/onboardingRegistry.js'; import { onboardingSequenceStepPresentationRegistry } from '../common/onboardingSequence.js'; -import { IOnboardingScenarioService, ONBOARDING_DEVELOPER_MODE_CONFIG, ONBOARDING_ENABLED_CONFIG } from '../common/onboardingScenarioService.js'; +import { IOnboardingScenarioService, ONBOARDING_DEVELOPER_MODE_CONFIG, ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG, ONBOARDING_ENABLED_CONFIG } from '../common/onboardingScenarioService.js'; import { OnboardingScenarioService } from './onboardingService.js'; import { RunOnboardingStepPresentation } from './sequence/runOnboardingStep.js'; import { OnboardingSequencePresentation } from './sequence/sequencePresentation.js'; @@ -36,9 +36,19 @@ const configurationRegistry = Registry.as(ConfigurationE function buildDeveloperModeConfigurationNode(): IConfigurationNode { const properties: IStringDictionary = {}; const defaultValue: IStringDictionary = {}; - for (const id of onboardingScenarioRegistry.getScenarios().map(scenario => scenario.id).sort()) { - properties[id] = { type: 'boolean', default: false }; - defaultValue[id] = false; + const variationProperties: IStringDictionary = {}; + const variationDefaultValue: IStringDictionary = {}; + for (const scenario of [...onboardingScenarioRegistry.getScenarios()].sort((a, b) => a.id.localeCompare(b.id))) { + properties[scenario.id] = { type: 'boolean', default: false }; + defaultValue[scenario.id] = false; + if (scenario.developerModeVariations?.length) { + variationProperties[scenario.id] = { + type: 'string', + default: '', + enum: ['', ...scenario.developerModeVariations], + }; + variationDefaultValue[scenario.id] = ''; + } } return { ...workbenchConfigurationNodeBase, @@ -50,6 +60,14 @@ function buildDeveloperModeConfigurationNode(): IConfigurationNode { additionalProperties: { type: 'boolean' }, tags: ['experimental'], description: localize('onboarding.developerMode', "Map of onboarding scenario/tour id to whether developer mode is enabled for it. When enabled for a scenario, that onboarding tour ignores usage-based eligibility checks (such as how many sessions you have started), previously persisted shown state, and any linked experiment (so it is shown even if the experiment is not running or you are in the control group). It does not override the {0} setting. The tour is still shown at most once per window session, so reload the window to show it again.", `\`#${ONBOARDING_ENABLED_CONFIG}#\``) + }, + [ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG]: { + type: 'object', + default: variationDefaultValue, + properties: variationProperties, + additionalProperties: { type: 'string' }, + tags: ['experimental'], + description: localize('onboarding.developerModeVariations', "Map of onboarding scenario/tour id to the variation used while developer mode is enabled for that scenario. An empty value uses the experiment-selected or default variation.") } } }; diff --git a/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts b/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts index da907bce381..1e32a1486e4 100644 --- a/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts +++ b/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts @@ -124,6 +124,9 @@ export interface IOnboardingScenario { */ readonly experiment?: IOnboardingExperiment; + /** Variations exposed through the onboarding developer-mode variation override. */ + readonly developerModeVariations?: readonly string[]; + /** When `true`, the scenario runs on every eligible session instead of once per user. */ readonly repeatable?: boolean; } diff --git a/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts b/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts index 7f2f0c2f748..b75802962c3 100644 --- a/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts +++ b/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts @@ -38,12 +38,18 @@ export const ONBOARDING_ENABLED_CONFIG = 'onboarding.enabled'; */ export const ONBOARDING_DEVELOPER_MODE_CONFIG = 'onboarding.developerMode'; +/** Developer override for a scenario's experiment-selected variation. */ +export const ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG = 'onboarding.developerModeVariations'; + /** * The shape of the {@link ONBOARDING_DEVELOPER_MODE_CONFIG} setting: a map of * scenario/tour id to whether developer mode is enabled for that scenario. */ export type OnboardingDeveloperMode = { readonly [scenarioId: string]: boolean }; +/** The shape of the {@link ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG} setting. */ +export type OnboardingDeveloperModeVariations = { readonly [scenarioId: string]: string }; + /** * Whether onboarding developer mode is enabled for the given scenario/tour id. * Reads {@link ONBOARDING_DEVELOPER_MODE_CONFIG} and returns `true` only when the @@ -54,6 +60,15 @@ export function isOnboardingDeveloperModeEnabled(configurationService: IConfigur return typeof value === 'object' && value !== null && value[scenarioId] === true; } +export function getOnboardingDeveloperModeVariation(configurationService: IConfigurationService, scenarioId: string): string | undefined { + if (!isOnboardingDeveloperModeEnabled(configurationService, scenarioId)) { + return undefined; + } + const value = configurationService.getValue(ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG); + const variation = typeof value === 'object' && value !== null ? value[scenarioId] : undefined; + return typeof variation === 'string' && variation.length > 0 ? variation : undefined; +} + /** * The presentation-agnostic onboarding engine. It decides *when* and *whether* * a scenario runs (eligibility, scheduling, once-per-user persistence) and diff --git a/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts b/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts index fc3103a5cbd..ee0f4706e27 100644 --- a/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts +++ b/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts @@ -24,7 +24,7 @@ import { OnboardingScenarioService } from '../../browser/onboardingService.js'; import { IOnboardingPresentation, IOnboardingRunContext, onboardingPresentationRegistry } from '../../common/onboardingPresentation.js'; import { onboardingScenarioRegistry } from '../../common/onboardingRegistry.js'; import { IOnboardingRunResult, IOnboardingScenario, OnboardingDismissReason, OnboardingOutcome } from '../../common/onboardingScenario.js'; -import { ONBOARDING_DEVELOPER_MODE_CONFIG, ONBOARDING_ENABLED_CONFIG } from '../../common/onboardingScenarioService.js'; +import { getOnboardingDeveloperModeVariation, ONBOARDING_DEVELOPER_MODE_CONFIG, ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG, ONBOARDING_ENABLED_CONFIG } from '../../common/onboardingScenarioService.js'; function completedResult(outcome: OnboardingOutcome = OnboardingOutcome.Completed): IOnboardingRunResult { const dismissReason = outcome === OnboardingOutcome.Skipped ? OnboardingDismissReason.SkipButton @@ -117,6 +117,25 @@ suite('OnboardingScenarioService', () => { Memento.clear(StorageScope.APPLICATION); }); + test('developer variation only overrides while developer mode is enabled', () => { + const disabled = new TestConfigurationService({ + [ONBOARDING_DEVELOPER_MODE_CONFIG]: { tour: false }, + [ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG]: { tour: 'githubPrompt' }, + }); + const enabled = new TestConfigurationService({ + [ONBOARDING_DEVELOPER_MODE_CONFIG]: { tour: true }, + [ONBOARDING_DEVELOPER_MODE_VARIATIONS_CONFIG]: { tour: 'githubPrompt' }, + }); + + assert.deepStrictEqual({ + disabled: getOnboardingDeveloperModeVariation(disabled, 'tour'), + enabled: getOnboardingDeveloperModeVariation(enabled, 'tour'), + }, { + disabled: undefined, + enabled: 'githubPrompt', + }); + }); + let idSeed = 0; function uniqueKind(): string { return `test-presentation-${idSeed++}`; }