diff --git a/src/@types/vscode.proposed.chatParticipantPrivate.d.ts b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts index 5e8b337772..66ae4a6310 100644 --- a/src/@types/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts @@ -183,6 +183,8 @@ declare module 'vscode' { isQuotaExceeded?: boolean; level?: ChatErrorLevel; + + code?: string; } export namespace chat { diff --git a/src/@types/vscode.proposed.chatSessionsProvider.d.ts b/src/@types/vscode.proposed.chatSessionsProvider.d.ts index 592fe2ed63..43f43bcfbe 100644 --- a/src/@types/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/@types/vscode.proposed.chatSessionsProvider.d.ts @@ -94,6 +94,16 @@ declare module 'vscode' { * The tooltip text when you hover over this item. */ tooltip?: string | MarkdownString; + + /** + * A badge to show additional information about the session. + */ + badge?: string; + + /** + * The tooltip text when you hover over the badge. + */ + badgeTooltip?: string; } export interface ChatSession { diff --git a/src/github/copilotRemoteAgent.ts b/src/github/copilotRemoteAgent.ts index c545523ad5..69bafc1da1 100644 --- a/src/github/copilotRemoteAgent.ts +++ b/src/github/copilotRemoteAgent.ts @@ -56,6 +56,11 @@ export class CopilotRemoteAgentManager extends Disposable { readonly onDidCreatePullRequest = this._onDidCreatePullRequest.event; private readonly _onDidChangeChatSessions = this._register(new vscode.EventEmitter()); readonly onDidChangeChatSessions = this._onDidChangeChatSessions.event; + + // Track sessions with new completions for badge notification + private readonly _unviewedCompletedSessions = new Set(); + private readonly _onDidChangeSessionBadges = this._register(new vscode.EventEmitter()); + readonly onDidChangeSessionBadges = this._onDidChangeSessionBadges.event; private readonly gitOperationsManager: GitOperationsManager; @@ -94,6 +99,9 @@ export class CopilotRemoteAgentManager extends Disposable { // Set initial context this.updateAssignabilityContext(); + + // Initialize badge state management + this.loadViewedSessions(); } private _copilotApiPromise: Promise | undefined; @@ -395,6 +403,8 @@ export class CopilotRemoteAgentManager extends Disposable { autoPushAndCommit, ); + this.refreshChatSessions(); + if (result.state !== 'success') { /* __GDPR__ "remoteAgent.command.result" : { @@ -666,6 +676,105 @@ export class CopilotRemoteAgentManager extends Disposable { return this._stateModel.getCounts(); } + // Badge state management methods + markSessionAsViewed(sessionId: string): void { + if (this._unviewedCompletedSessions.has(sessionId)) { + this._unviewedCompletedSessions.delete(sessionId); + this.addSessionToViewedList(sessionId); + this._onDidChangeSessionBadges.fire(); + } + } + + private async addSessionToViewedList(sessionId: string): Promise { + try { + const currentViewed = this.context.globalState.get('copilot.viewedSessions', []); + const timestamp = Date.now().toString(); + const sessionEntry = `${sessionId}|${timestamp}`; + + if (!currentViewed.some(entry => entry.startsWith(`${sessionId}|`))) { + const updatedViewed = [...currentViewed, sessionEntry]; + await this.context.globalState.update('copilot.viewedSessions', updatedViewed); + } + } catch (error) { + Logger.error(`Failed to add session to viewed list: ${error}`, CopilotRemoteAgentManager.ID); + } + } + + hasUnviewedCompletion(sessionId: string): boolean { + return this._unviewedCompletedSessions.has(sessionId); + } + + getUnviewedSessionCount(): number { + return this._unviewedCompletedSessions.size; + } + + private async loadViewedSessions(): Promise { + try { + const viewedSessions = this.context.globalState.get('copilot.viewedSessions', []); + // Clean up old entries (older than 30 days) + const deleteDate = Date.now() - (30 * 24 * 60 * 60 * 1000); + const validSessions = viewedSessions.filter(entry => { + try { + const [, timestamp] = entry.split('|'); + const entryTime = parseInt(timestamp || '0', 10); + return entryTime > deleteDate; + } catch { + return false; + } + }); + + // Save cleaned up sessions + if (validSessions.length !== viewedSessions.length) { + await this.context.globalState.update('copilot.viewedSessions', validSessions); + } + + // Initialize unviewed sessions by checking current sessions against viewed ones + await this.updateUnviewedSessionsFromCurrentState(); + } catch (error) { + Logger.error(`Failed to load viewed sessions: ${error}`, CopilotRemoteAgentManager.ID); + } + } + + private async updateUnviewedSessionsFromCurrentState(): Promise { + try { + const viewedSessions = this.context.globalState.get('copilot.viewedSessions', []); + const viewedSessionIds = new Set(viewedSessions.map(entry => entry.split('|')[0])); + + // Get current coding agent sessions + const capi = await this.copilotApi; + if (!capi) { + return; + } + + await this.waitRepoManagerInitialization(); + const codingAgentPRs = await capi.getAllCodingAgentPRs(this.repositoriesManager); + + // Check which completed sessions haven't been viewed + const updatedUnviewed = new Set(); + for (const session of codingAgentPRs) { + const timeline = await session.getCopilotTimelineEvents(session); + const status = copilotEventToSessionStatus(mostRecentCopilotEvent(timeline)); + const sessionId = session.number.toString(); + + if (status === vscode.ChatSessionStatus.Completed && !viewedSessionIds.has(sessionId)) { + updatedUnviewed.add(sessionId); + } + } + + // Update unviewed sessions if changed + const hasChanges = updatedUnviewed.size !== this._unviewedCompletedSessions.size || + ![...updatedUnviewed].every(id => this._unviewedCompletedSessions.has(id)); + + if (hasChanges) { + this._unviewedCompletedSessions.clear(); + updatedUnviewed.forEach(id => this._unviewedCompletedSessions.add(id)); + this._onDidChangeSessionBadges.fire(); + } + } catch (error) { + Logger.error(`Failed to update unviewed sessions: ${error}`, CopilotRemoteAgentManager.ID); + } + } + public async provideNewChatSessionItem(options: { prompt?: string; history: ReadonlyArray; metadata?: any; }, _token: vscode.CancellationToken): Promise { const { prompt } = options; if (!prompt) { @@ -720,14 +829,23 @@ export class CopilotRemoteAgentManager extends Disposable { const timeline = await session.getCopilotTimelineEvents(session); const status = copilotEventToSessionStatus(mostRecentCopilotEvent(timeline)); const tooltip = await issueMarkdown(session, this.context, this.repositoriesManager); + const sessionId = session.number.toString(); + + // Badge logic: show badge for completed sessions that haven't been viewed + const hasNewCompletions = status === vscode.ChatSessionStatus.Completed && + this.hasUnviewedCompletion(sessionId); + return { - id: `${session.number}`, + id: sessionId, label: session.title || `Session ${session.number}`, iconPath: this.getIconForSession(status), description: `${dateFromNow(session.createdAt)}`, pullRequest: session, tooltip, status, + // Badge properties for notification + badge: hasNewCompletions ? '●' : undefined, + badgeTooltip: hasNewCompletions ? vscode.l10n.t('Session completed - click to view results') : undefined }; })); } catch (error) { @@ -749,6 +867,9 @@ export class CopilotRemoteAgentManager extends Disposable { return this.createEmptySession(); } + // Mark this session as viewed when user opens it + this.markSessionAsViewed(id); + await this.waitRepoManagerInitialization(); const pullRequest = await this.findPullRequestById(pullRequestNumber, true); @@ -1268,6 +1389,8 @@ export class CopilotRemoteAgentManager extends Disposable { public refreshChatSessions(): void { this._onDidChangeChatSessions.fire(); + // Update badge state when sessions are refreshed + this.updateUnviewedSessionsFromCurrentState(); } public async cancelMostRecentChatSession(pullRequest: PullRequestModel): Promise { diff --git a/src/test/github/copilotRemoteAgent.test.ts b/src/test/github/copilotRemoteAgent.test.ts index d09c1081a7..38389713e4 100644 --- a/src/test/github/copilotRemoteAgent.test.ts +++ b/src/test/github/copilotRemoteAgent.test.ts @@ -388,4 +388,35 @@ describe('CopilotRemoteAgentManager', function () { assert(endTime - startTime < 100); }); }); + + describe('badge notification functionality', function () { + it('should expose onDidChangeSessionBadges event', function () { + assert.strictEqual(typeof manager.onDidChangeSessionBadges, 'function'); + }); + + it('should track unviewed completed sessions', function () { + // Initially no unviewed sessions + assert.strictEqual(manager.getUnviewedSessionCount(), 0); + assert.strictEqual(manager.hasUnviewedCompletion('123'), false); + }); + + it('should mark sessions as viewed', function () { + // Mark a session as viewed (this should not throw even if session doesn't exist) + manager.markSessionAsViewed('123'); + assert.strictEqual(manager.hasUnviewedCompletion('123'), false); + }); + + it('should handle badge state changes', function () { + let eventFired = false; + const disposable = manager.onDidChangeSessionBadges(() => { + eventFired = true; + }); + + // Marking a non-existent session as viewed should not fire event + manager.markSessionAsViewed('non-existent'); + assert.strictEqual(eventFired, false); + + disposable.dispose(); + }); + }); });