Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { SingleSlotTtlCache, TtlCache } from '../common/ttlCache';
import { isUntitledSessionId } from '../common/utils';
import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';
import { CONTINUE_TRUNCATION, extractTitle, getAuthorDisplayName, getRepoId, isActiveTaskState, SessionIdForPr, SessionIdForTask, toOpenPullRequestWebviewUri, truncatePrompt } from '../vscode/copilotCodingAgentUtils';
import { CloudAgentBackend, PullArtifactRef, TaskCloudAgentBackend, TaskContent } from '../vscode/cloudAgentBackend';
import { CloudAgentBackend, CloudSessionData, PullArtifactRef, TaskCloudAgentBackend, TaskContent } from '../vscode/cloudAgentBackend';
import { CopilotCloudGitOperationsManager } from './copilotCloudGitOperationsManager';
import { ChatSessionContentBuilder, SessionResponseLogChunk } from './copilotCloudSessionContentBuilder';
import { StreamBaseline, TaskTurnStreamer } from './taskTurnStreamer';
Expand Down Expand Up @@ -141,6 +141,18 @@ export function normalizeInitialSessionOptions(initialOptions: unknown, logServi
return [];
}

export function getCloudSessionItemMetadata(repo: CloudSessionData['repo'], diffRefs: CloudSessionData['diffRefs']): { readonly owner: string; readonly name: string; readonly host?: string; readonly branch?: string } | undefined {
if (!repo) {
return undefined;
}
return {
owner: repo.owner,
name: repo.name,
...(repo.host ? { host: repo.host } : {}),
...(diffRefs?.headRef ? { branch: diffRefs.headRef } : {}),
};
}

export function parseSessionLogChunksSafely(rawText: string, logService: ILogService, parser: (value: string) => SessionResponseLogChunk[]): SessionResponseLogChunk[] {
try {
return parser(rawText);
Expand Down Expand Up @@ -1327,12 +1339,13 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
const changes = entry.diffRefs
? await this._prFileChangesService.getComparisonChangedFiles(entry.diffRefs)
: undefined;
const metadata = getCloudSessionItemMetadata(entry.repo, entry.diffRefs);
return {
resource: vscode.Uri.from({ scheme: CopilotCloudSessionsProvider.TYPE, path: '/' + SessionIdForTask.getId(taskId) }),
label: entry.latestSession.name || taskId,
status,
...(changes?.length ? { changes } : {}),
...(entry.repo ? { metadata: { owner: entry.repo.owner, name: entry.repo.name } } : {}),
...(metadata ? { metadata } : {}),
...(createdAt ? {
timing: {
created: createdAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,15 @@ function taskToSessionInfo(task: AgentTask): SessionInfo {
* Also used by the provider to derive `{owner, repo}` for the "Create pull request" toolbar
* action on PR-less tasks.
*/
export function parseRepoFromTaskUrl(htmlUrl: string | undefined): { owner: string; name: string } | undefined {
export function parseRepoFromTaskUrl(htmlUrl: string | undefined): { owner: string; name: string; host: string } | undefined {
if (!htmlUrl) {
return undefined;
}
try {
const { pathname } = new URL(htmlUrl);
const { hostname, pathname } = new URL(htmlUrl);
const match = pathname.match(/^\/([^/]+)\/([^/]+)\//);
if (match) {
return { owner: match[1], name: match[2] };
return { owner: match[1], name: match[2], host: hostname };
}
} catch {
// not a parseable URL
Expand Down Expand Up @@ -281,7 +281,7 @@ export class TaskApiBackend implements TaskCloudAgentBackend {

async fetchSessionList(repoIds: GithubRepoId[] | undefined, isAgentWorkspace: boolean, _refresh: boolean): Promise<CloudSessionData[]> {
const listOpts: ListTasksOptions = { per_page: 100 };
const tasksWithRepo: { task: AgentTask; repo: { owner: string; name: string } | undefined }[] = [];
const tasksWithRepo: { task: AgentTask; repo: CloudSessionData['repo'] }[] = [];

// In the agents window we surface all of the user's sessions rather than scoping to the
// active workspace's repositories, so always use the global user-scoped list there.
Expand All @@ -308,11 +308,11 @@ export class TaskApiBackend implements TaskCloudAgentBackend {
repoIds.map(async repo => {
try {
const r = await this._taskApiClient.listTasksForRepo(repo.org, repo.repo, repoListOpts);
return { repo: { owner: repo.org, name: repo.repo }, response: r };
return { repo: { owner: repo.org, name: repo.repo, host: repo.host }, response: r };
} catch (e: unknown) {
this._logService.warn(`Failed to fetch tasks for ${repo.org}/${repo.repo}: ${e}`);
this._instrumentation.operationFailed('fetchSessionList', e);
return { repo: { owner: repo.org, name: repo.repo }, response: { tasks: [] as readonly AgentTask[] } satisfies AgentTaskListResponse };
return { repo: { owner: repo.org, name: repo.repo, host: repo.host }, response: { tasks: [] as readonly AgentTask[] } satisfies AgentTaskListResponse };
}
}),
);
Expand All @@ -329,7 +329,7 @@ export class TaskApiBackend implements TaskCloudAgentBackend {
// id lookup is cached (by promise) per repo id so a page of same-repo tasks — resolved
// concurrently via `Promise.all` — shares a single in-flight call.
const repoByIdCache = new Map<number, Promise<{ owner: string; name: string } | undefined>>();
const resolveRepo = (task: AgentTask, listRepo: { owner: string; name: string } | undefined): Promise<{ owner: string; name: string } | undefined> => {
const resolveRepo = (task: AgentTask, listRepo: CloudSessionData['repo']): Promise<CloudSessionData['repo']> => {
const known = listRepo ?? parseRepoFromTaskUrl(task.html_url);
if (known) {
return Promise.resolve(known);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { mock } from '../../../../util/common/test/simpleMock';
import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes';
import { ITaskApiClient, ListTaskEventsOptions, ListTasksOptions } from '../../common/taskApiTypes';
import { ChatSessionContentBuilder, extractTaskErrorDetail, formatTaskStoppedMessage } from '../copilotCloudSessionContentBuilder';
import { normalizeInitialSessionOptions, parseSessionLogChunksSafely, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider';
import { getCloudSessionItemMetadata, normalizeInitialSessionOptions, parseSessionLogChunksSafely, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider';
import { TaskApiBackend, parseRepoFromTaskUrl, isCloudCodingAgentTask } from '../taskApiBackend';
import { isActiveTaskState, isFailedTaskState } from '../../vscode/copilotCodingAgentUtils';
import { NullCloudBackendInstrumentation } from '../cloudBackendTelemetry';
Expand Down Expand Up @@ -129,6 +129,18 @@ describe('copilotCloudSessionsProvider helpers', () => {
expect(result).toEqual([]);
expect(logService.error).toHaveBeenCalledWith(expect.any(SyntaxError), expect.stringContaining('Failed to parse streamed log content'));
});

it('includes the task branch in PR-less cloud session metadata', () => {
expect(getCloudSessionItemMetadata(
{ owner: 'microsoft', name: 'vscode', host: 'github.com' },
{ owner: 'microsoft', repo: 'vscode', baseRef: 'main', headRef: 'copilot/task-branch' },
)).toEqual({
owner: 'microsoft',
name: 'vscode',
host: 'github.com',
branch: 'copilot/task-branch',
});
});
});

describe('ChatSessionContentBuilder', () => {
Expand Down Expand Up @@ -720,7 +732,7 @@ describe('isActiveTaskState / isFailedTaskState', () => {

describe('parseRepoFromTaskUrl', () => {
it('extracts owner and name from a task html_url', () => {
expect(parseRepoFromTaskUrl('https://github.com/octocat/hello-world/agents/tasks/abc')).toEqual({ owner: 'octocat', name: 'hello-world' });
expect(parseRepoFromTaskUrl('https://github.example.com/octocat/hello-world/agents/tasks/abc')).toEqual({ owner: 'octocat', name: 'hello-world', host: 'github.example.com' });
});

it('returns undefined for an unparseable URL', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export interface CloudSessionData {
* attach `owner`/`name` metadata to PR-less task cards (otherwise they group under
* "Unknown"/"Other" until a PR resolves) and passes it to `resolvePullArtifact` for PR lookup.
*/
readonly repo?: { readonly owner: string; readonly name: string };
readonly repo?: { readonly owner: string; readonly name: string; readonly host?: string };
/**
* Branch comparison refs for a settled, PR-less task that pushed a branch. When present,
* the provider fetches the changed files (`base...head`) so the session's changed-files
Expand Down
23 changes: 23 additions & 0 deletions src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@

import { IReaderWithStore } from '../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { IGitHubInfo } from '../../../services/sessions/common/session.js';
import { computePullRequestIcon, GitHubCIOverallStatus, GitHubPullRequestState, IGitHubPullRequest, IPullRequestIconStatus } from '../common/types.js';
import { IGitHubService } from './githubService.js';
import { IPullRequestIconCache } from './pullRequestIconCache.js';

/**
* Reads the live {@link IPullRequestIconStatus} for a pull request from the shared
Expand Down Expand Up @@ -37,3 +39,24 @@ export function computeLivePullRequestIcon(reader: IReaderWithStore, gitHubServi
const status = computePullRequestIconStatus(reader, gitHubService, owner, repo, livePR);
return computePullRequestIcon(livePR.isDraft ? 'draft' : livePR.state, status);
}

/**
* Computes a session PR icon from the shared live model, with persistent and provider-reported fallbacks while it loads.
*/
export function computeSessionPullRequestIcon(reader: IReaderWithStore, gitHubService: IGitHubService, iconCache: IPullRequestIconCache, gitHubInfo: IGitHubInfo): ThemeIcon | undefined {
const pullRequest = gitHubInfo.pullRequest;
if (!pullRequest) {
return undefined;
}

const prLink = pullRequest.uri.toString();
const prModelRef = reader.store.add(gitHubService.createPullRequestModelReference(gitHubInfo.owner, gitHubInfo.repo, pullRequest.number));
const livePullRequest = prModelRef.object.pullRequest.read(reader);
if (!livePullRequest) {
return iconCache.get(prLink) ?? pullRequest.icon ?? computePullRequestIcon(GitHubPullRequestState.Open);
}

const icon = computeLivePullRequestIcon(reader, gitHubService, gitHubInfo.owner, gitHubInfo.repo, livePullRequest);
iconCache.set(prLink, icon);
return icon;
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ import { ChatInteractivity, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effective
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';
import { computeLivePullRequestIcon } from '../../../github/browser/pullRequestIconStatus.js';
import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js';
import { computeSessionPullRequestIcon } from '../../../github/browser/pullRequestIconStatus.js';
import { IPullRequestIconCache } from '../../../github/browser/pullRequestIconCache.js';
import { mapProtocolStatus } from './agentHostDiffs.js';
import { createChangesets } from './agentHostSessionChangesets.js';
Expand Down Expand Up @@ -610,35 +609,11 @@ export class AgentHostSessionAdapter extends Disposable implements ISession {
return baseGitHubInfo;
}

const prLink = baseGitHubInfo.pullRequest.uri.toString();
const prModelRef = reader.store.add(this._gitHubService.createPullRequestModelReference(
baseGitHubInfo.owner, baseGitHubInfo.repo, baseGitHubInfo.pullRequest.number));
const livePR = prModelRef.object.pullRequest.read(reader);

if (!livePR) {
// The live model hasn't been fetched yet (e.g. right after a PR is first
// detected, or right after startup). Show the last known icon from the
// persistent cache, falling back to a neutral open-PR icon, so the row
// surfaces a PR icon immediately instead of the read/unread dot while the
// first fetch is in flight. The agent-host git state carries no PR state,
// so the live model refines it (merged/closed/draft/failing checks) within
// a poll cycle.
const icon = this._pullRequestIconCache.get(prLink) ?? computePullRequestIcon(GitHubPullRequestState.Open);
return {
...baseGitHubInfo,
pullRequest: { ...baseGitHubInfo.pullRequest, icon }
};
}

const icon = computeLivePullRequestIcon(reader, this._gitHubService, baseGitHubInfo.owner, baseGitHubInfo.repo, livePR);
// Remember the freshly computed icon so the next startup can show it instantly.
// The cache de-duplicates unchanged icons, so this is a no-op when nothing changed.
this._pullRequestIconCache.set(prLink, icon);
return {
...baseGitHubInfo,
pullRequest: {
...baseGitHubInfo.pullRequest,
icon
icon: computeSessionPullRequestIcon(reader, this._gitHubService, this._pullRequestIconCache, baseGitHubInfo)
}
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ Adapts an existing `IAgentSession` from the chat layer into the `ISession` facad
- Constructs with initial values from the agent session's metadata and timing
- `update(session)` performs a batched observable transaction to update all reactive properties
- Extracts workspace info, changes, description, and GitHub info from session metadata
- Treats a cloud provider's `pullRequestUrl` as authoritative for owner, repository, and PR number (including URL-only metadata), and falls back to resolving the PR from `owner`/`name`/`branch` when the URL is absent
- PR-less task cards must carry `diffRefs.headRef` into their session metadata as `branch`; repository-only metadata cannot drive the branch fallback
- Uses the shared GitHub PR model, background polling contribution, and persistent icon cache for github.com; provider-reported GitHub Enterprise links retain their provider state icon but skip the public `api.github.com` polling path
- Maps `ChatSessionStatus` → `SessionStatus`
- Handles both CLI and Cloud session metadata formats for repository resolution

Expand Down
Loading
Loading