Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionHoverWidget.ts
5257 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as dom from '../../../../../base/browser/dom.js';
7
import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js';
8
import { RunOnceScheduler } from '../../../../../base/common/async.js';
9
import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';
10
import { Codicon } from '../../../../../base/common/codicons.js';
11
import { fromNow, getDurationString } from '../../../../../base/common/date.js';
12
import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js';
13
import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js';
14
import { ThemeIcon } from '../../../../../base/common/themables.js';
15
import { localize } from '../../../../../nls.js';
16
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
17
import { IChatService } from '../../common/chatService/chatService.js';
18
import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js';
19
import { IChatModel } from '../../common/model/chatModel.js';
20
import { ChatViewModel } from '../../common/model/chatViewModel.js';
21
import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js';
22
import { IChatWidgetService } from '../chat.js';
23
import { ChatListWidget } from '../widget/chatListWidget.js';
24
import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName } from './agentSessions.js';
25
import { AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession } from './agentSessionsModel.js';
26
import './media/agentSessionHoverWidget.css';
27
28
const HEADER_HEIGHT = 60;
29
const CHAT_LIST_HEIGHT = 240;
30
const CHAT_HOVER_WIDTH = 500;
31
32
export class AgentSessionHoverWidget extends Disposable {
33
34
readonly domNode: HTMLElement;
35
private modelRef?: Promise<IChatModel | undefined>;
36
private listWidget?: ChatListWidget;
37
private readonly contentElement: HTMLElement;
38
private readonly loadingElement: HTMLElement;
39
private readonly renderScheduler: RunOnceScheduler;
40
private hasRendered = false;
41
private readonly cts: CancellationTokenSource;
42
43
constructor(
44
public readonly session: IAgentSession,
45
@IChatService private readonly chatService: IChatService,
46
@IInstantiationService private readonly instantiationService: IInstantiationService,
47
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
48
) {
49
super();
50
51
this.domNode = dom.$('.agent-session-hover.interactive-session');
52
this.domNode.style.width = `${CHAT_HOVER_WIDTH}px`;
53
this.domNode.style.height = `${HEADER_HEIGHT + CHAT_LIST_HEIGHT}px`;
54
this.domNode.style.overflow = 'hidden';
55
56
this.cts = new CancellationTokenSource();
57
this._register(toDisposable(() => this.cts.cancel()));
58
59
// Build header immediately
60
this.buildHeader();
61
62
// Create content container with loading state
63
this.contentElement = dom.append(this.domNode, dom.$('.agent-session-hover-content'));
64
this.loadingElement = dom.append(this.contentElement, dom.$('.agent-session-hover-loading'));
65
dom.append(this.loadingElement, renderIcon(ThemeIcon.modify(Codicon.loading, 'spin')));
66
67
// Delay rendering by 200ms to avoid expensive rendering for brief hovers
68
this.renderScheduler = this._register(new RunOnceScheduler(() => this.render(), 200));
69
}
70
71
onRendered() {
72
this.modelRef ??= this.loadModel();
73
74
if (!this.hasRendered) {
75
this.hasRendered = true;
76
this.renderScheduler.schedule();
77
} else {
78
this.listWidget?.layout(CHAT_LIST_HEIGHT, CHAT_HOVER_WIDTH);
79
}
80
}
81
82
private async loadModel() {
83
const modelRef = await this.chatService.loadSessionForResource(this.session.resource, ChatAgentLocation.Chat, this.cts.token);
84
if (this._store.isDisposed) {
85
modelRef?.dispose();
86
return;
87
}
88
89
if (!modelRef) {
90
// Show fallback tooltip text
91
this.loadingElement.remove();
92
const tooltip = this.buildFallbackTooltip(this.session);
93
this.domNode.textContent = typeof tooltip === 'string' ? tooltip : tooltip.value;
94
return;
95
}
96
97
this._register(modelRef);
98
return modelRef.object;
99
}
100
101
private async render() {
102
this.modelRef ??= this.loadModel();
103
const model = await this.modelRef;
104
if (!model || this._store.isDisposed) {
105
return;
106
}
107
108
// Remove loading state
109
this.loadingElement.remove();
110
111
// Create view model - only show last request+response pair
112
const codeBlockCollection = this._register(this.instantiationService.createInstance(CodeBlockModelCollection, 'agentSessionHover'));
113
const viewModel = this._register(this.instantiationService.createInstance(
114
ChatViewModel,
115
model,
116
codeBlockCollection,
117
{ maxVisibleItems: 2 }
118
));
119
120
// Create the chat list widget
121
const container = dom.append(this.contentElement, dom.$('.interactive-list'));
122
const listWidget = this._register(this.instantiationService.createInstance(
123
ChatListWidget,
124
container,
125
{
126
rendererOptions: {
127
renderStyle: 'compact',
128
noHeader: true,
129
editable: false,
130
},
131
currentChatMode: () => ChatModeKind.Ask,
132
}
133
));
134
listWidget.layout(CHAT_LIST_HEIGHT, CHAT_HOVER_WIDTH);
135
listWidget.setScrollLock(true);
136
listWidget.setViewModel(viewModel);
137
listWidget.refresh();
138
139
const viewModelScheudler = this._register(new RunOnceScheduler(() => listWidget.refresh(), 500));
140
this._register(viewModel.onDidChange(() => {
141
if (!viewModelScheudler.isScheduled()) {
142
viewModelScheudler.schedule();
143
}
144
}));
145
146
// Handle followup clicks - open the session and accept input
147
this._register(listWidget.onDidClickFollowup(async (followup) => {
148
const widget = await this.chatWidgetService.openSession(model.sessionResource);
149
if (widget) {
150
widget.acceptInput(followup.message);
151
}
152
}));
153
}
154
155
private buildHeader(): void {
156
const session = this.session;
157
const header = dom.append(this.domNode, dom.$('.agent-session-hover-header'));
158
159
// Title row
160
const titleRow = dom.append(header, dom.$('.agent-session-hover-title'));
161
dom.append(titleRow, dom.$('span', undefined, session.label));
162
163
// Details row: Provider icon + Duration/Time • Diff • Status (if not completed)
164
const detailsRow = dom.append(header, dom.$('.agent-session-hover-details'));
165
166
// Provider icon + name + Duration or start time
167
const providerType = getAgentSessionProvider(session.providerType);
168
const provider = providerType ?? AgentSessionProviders.Local;
169
const providerIcon = getAgentSessionProviderIcon(provider);
170
dom.append(detailsRow, renderIcon(providerIcon));
171
dom.append(detailsRow, dom.$('span', undefined, getAgentSessionProviderName(provider)));
172
dom.append(detailsRow, dom.$('span.separator', undefined, '•'));
173
174
if (session.timing.lastRequestEnded && session.timing.lastRequestStarted) {
175
const duration = this.toDuration(session.timing.lastRequestStarted, session.timing.lastRequestEnded, true);
176
if (duration) {
177
dom.append(detailsRow, dom.$('span', undefined, duration));
178
}
179
} else {
180
const startTime = session.timing.lastRequestStarted ?? session.timing.created;
181
dom.append(detailsRow, dom.$('span', undefined, fromNow(startTime, true, true)));
182
}
183
184
// Diff information
185
const diff = getAgentChangesSummary(session.changes);
186
if (diff && hasValidDiff(session.changes)) {
187
dom.append(detailsRow, dom.$('span.separator', undefined, '•'));
188
const diffContainer = dom.append(detailsRow, dom.$('.agent-session-hover-diff'));
189
if (diff.files > 0) {
190
dom.append(diffContainer, dom.$('span', undefined, diff.files === 1 ? localize('tooltip.file', "1 file") : localize('tooltip.files', "{0} files", diff.files)));
191
}
192
if (diff.insertions > 0) {
193
dom.append(diffContainer, dom.$('span.insertions', undefined, `+${diff.insertions}`));
194
}
195
if (diff.deletions > 0) {
196
dom.append(diffContainer, dom.$('span.deletions', undefined, `-${diff.deletions}`));
197
}
198
}
199
200
// Status (only show if not completed)
201
if (session.status !== AgentSessionStatus.Completed) {
202
dom.append(detailsRow, dom.$('span.separator', undefined, '•'));
203
dom.append(detailsRow, dom.$('span', undefined, this.toStatusLabel(session.status)));
204
}
205
206
// Archived indicator
207
if (session.isArchived()) {
208
dom.append(detailsRow, dom.$('span.separator', undefined, '•'));
209
dom.append(detailsRow, renderIcon(Codicon.archive));
210
dom.append(detailsRow, dom.$('span', undefined, localize('tooltip.archived', "Archived")));
211
}
212
}
213
214
private buildFallbackTooltip(session: IAgentSession): IMarkdownString {
215
const lines: string[] = [];
216
217
// Title
218
lines.push(`**${session.label}**`);
219
220
// Tooltip (from provider)
221
if (session.tooltip) {
222
const tooltip = typeof session.tooltip === 'string' ? session.tooltip : session.tooltip.value;
223
lines.push(tooltip);
224
} else {
225
226
// Description
227
if (session.description) {
228
const description = typeof session.description === 'string' ? session.description : session.description.value;
229
lines.push(description);
230
}
231
232
// Badge
233
if (session.badge) {
234
const badge = typeof session.badge === 'string' ? session.badge : session.badge.value;
235
lines.push(badge);
236
}
237
}
238
239
// Details line: Provider icon + Duration/Time • Diff • Status (if not completed)
240
const details: string[] = [];
241
242
// Provider icon + name + Duration or start time
243
const providerType = getAgentSessionProvider(session.providerType);
244
const provider = providerType ?? AgentSessionProviders.Local;
245
const providerIcon = getAgentSessionProviderIcon(provider);
246
const providerName = getAgentSessionProviderName(provider);
247
let timeLabel: string;
248
if (session.timing.lastRequestEnded && session.timing.lastRequestStarted) {
249
const duration = this.toDuration(session.timing.lastRequestStarted, session.timing.lastRequestEnded, true);
250
timeLabel = duration ?? fromNow(session.timing.lastRequestStarted, true, true);
251
} else {
252
const startTime = session.timing.lastRequestStarted ?? session.timing.created;
253
timeLabel = fromNow(startTime, true, true);
254
}
255
details.push(`$(${providerIcon.id}) ${providerName} • ${timeLabel}`);
256
257
// Diff information
258
const diff = getAgentChangesSummary(session.changes);
259
if (diff && hasValidDiff(session.changes)) {
260
const diffParts: string[] = [];
261
if (diff.files > 0) {
262
diffParts.push(diff.files === 1 ? localize('tooltip.file', "1 file") : localize('tooltip.files', "{0} files", diff.files));
263
}
264
if (diff.insertions > 0) {
265
diffParts.push(`+${diff.insertions}`);
266
}
267
if (diff.deletions > 0) {
268
diffParts.push(`-${diff.deletions}`);
269
}
270
if (diffParts.length > 0) {
271
details.push(diffParts.join(' '));
272
}
273
}
274
275
// Status (only show if not completed)
276
if (session.status !== AgentSessionStatus.Completed) {
277
details.push(this.toStatusLabel(session.status));
278
}
279
280
lines.push(details.join(' • '));
281
282
// Archived status
283
if (session.isArchived()) {
284
lines.push(`$(archive) ${localize('tooltip.archived', "Archived")}`);
285
}
286
287
return new MarkdownString(lines.join('\n\n'), { supportThemeIcons: true });
288
}
289
290
private toDuration(startTime: number, endTime: number, useFullTimeWords: boolean): string | undefined {
291
const elapsed = Math.round((endTime - startTime) / 1000) * 1000;
292
if (elapsed < 1000) {
293
return undefined;
294
}
295
296
return getDurationString(elapsed, useFullTimeWords);
297
}
298
299
private toStatusLabel(status: AgentSessionStatus): string {
300
let statusLabel: string;
301
switch (status) {
302
case AgentSessionStatus.NeedsInput:
303
statusLabel = localize('agentSessionNeedsInput', "Needs Input");
304
break;
305
case AgentSessionStatus.InProgress:
306
statusLabel = localize('agentSessionInProgress', "In Progress");
307
break;
308
case AgentSessionStatus.Failed:
309
statusLabel = localize('agentSessionFailed', "Failed");
310
break;
311
default:
312
statusLabel = localize('agentSessionCompleted', "Completed");
313
}
314
315
return statusLabel;
316
}
317
}
318
319