Path: blob/main/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionHoverWidget.ts
5257 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as dom from '../../../../../base/browser/dom.js';6import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js';7import { RunOnceScheduler } from '../../../../../base/common/async.js';8import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';9import { Codicon } from '../../../../../base/common/codicons.js';10import { fromNow, getDurationString } from '../../../../../base/common/date.js';11import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js';12import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js';13import { ThemeIcon } from '../../../../../base/common/themables.js';14import { localize } from '../../../../../nls.js';15import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';16import { IChatService } from '../../common/chatService/chatService.js';17import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js';18import { IChatModel } from '../../common/model/chatModel.js';19import { ChatViewModel } from '../../common/model/chatViewModel.js';20import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js';21import { IChatWidgetService } from '../chat.js';22import { ChatListWidget } from '../widget/chatListWidget.js';23import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName } from './agentSessions.js';24import { AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession } from './agentSessionsModel.js';25import './media/agentSessionHoverWidget.css';2627const HEADER_HEIGHT = 60;28const CHAT_LIST_HEIGHT = 240;29const CHAT_HOVER_WIDTH = 500;3031export class AgentSessionHoverWidget extends Disposable {3233readonly domNode: HTMLElement;34private modelRef?: Promise<IChatModel | undefined>;35private listWidget?: ChatListWidget;36private readonly contentElement: HTMLElement;37private readonly loadingElement: HTMLElement;38private readonly renderScheduler: RunOnceScheduler;39private hasRendered = false;40private readonly cts: CancellationTokenSource;4142constructor(43public readonly session: IAgentSession,44@IChatService private readonly chatService: IChatService,45@IInstantiationService private readonly instantiationService: IInstantiationService,46@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,47) {48super();4950this.domNode = dom.$('.agent-session-hover.interactive-session');51this.domNode.style.width = `${CHAT_HOVER_WIDTH}px`;52this.domNode.style.height = `${HEADER_HEIGHT + CHAT_LIST_HEIGHT}px`;53this.domNode.style.overflow = 'hidden';5455this.cts = new CancellationTokenSource();56this._register(toDisposable(() => this.cts.cancel()));5758// Build header immediately59this.buildHeader();6061// Create content container with loading state62this.contentElement = dom.append(this.domNode, dom.$('.agent-session-hover-content'));63this.loadingElement = dom.append(this.contentElement, dom.$('.agent-session-hover-loading'));64dom.append(this.loadingElement, renderIcon(ThemeIcon.modify(Codicon.loading, 'spin')));6566// Delay rendering by 200ms to avoid expensive rendering for brief hovers67this.renderScheduler = this._register(new RunOnceScheduler(() => this.render(), 200));68}6970onRendered() {71this.modelRef ??= this.loadModel();7273if (!this.hasRendered) {74this.hasRendered = true;75this.renderScheduler.schedule();76} else {77this.listWidget?.layout(CHAT_LIST_HEIGHT, CHAT_HOVER_WIDTH);78}79}8081private async loadModel() {82const modelRef = await this.chatService.loadSessionForResource(this.session.resource, ChatAgentLocation.Chat, this.cts.token);83if (this._store.isDisposed) {84modelRef?.dispose();85return;86}8788if (!modelRef) {89// Show fallback tooltip text90this.loadingElement.remove();91const tooltip = this.buildFallbackTooltip(this.session);92this.domNode.textContent = typeof tooltip === 'string' ? tooltip : tooltip.value;93return;94}9596this._register(modelRef);97return modelRef.object;98}99100private async render() {101this.modelRef ??= this.loadModel();102const model = await this.modelRef;103if (!model || this._store.isDisposed) {104return;105}106107// Remove loading state108this.loadingElement.remove();109110// Create view model - only show last request+response pair111const codeBlockCollection = this._register(this.instantiationService.createInstance(CodeBlockModelCollection, 'agentSessionHover'));112const viewModel = this._register(this.instantiationService.createInstance(113ChatViewModel,114model,115codeBlockCollection,116{ maxVisibleItems: 2 }117));118119// Create the chat list widget120const container = dom.append(this.contentElement, dom.$('.interactive-list'));121const listWidget = this._register(this.instantiationService.createInstance(122ChatListWidget,123container,124{125rendererOptions: {126renderStyle: 'compact',127noHeader: true,128editable: false,129},130currentChatMode: () => ChatModeKind.Ask,131}132));133listWidget.layout(CHAT_LIST_HEIGHT, CHAT_HOVER_WIDTH);134listWidget.setScrollLock(true);135listWidget.setViewModel(viewModel);136listWidget.refresh();137138const viewModelScheudler = this._register(new RunOnceScheduler(() => listWidget.refresh(), 500));139this._register(viewModel.onDidChange(() => {140if (!viewModelScheudler.isScheduled()) {141viewModelScheudler.schedule();142}143}));144145// Handle followup clicks - open the session and accept input146this._register(listWidget.onDidClickFollowup(async (followup) => {147const widget = await this.chatWidgetService.openSession(model.sessionResource);148if (widget) {149widget.acceptInput(followup.message);150}151}));152}153154private buildHeader(): void {155const session = this.session;156const header = dom.append(this.domNode, dom.$('.agent-session-hover-header'));157158// Title row159const titleRow = dom.append(header, dom.$('.agent-session-hover-title'));160dom.append(titleRow, dom.$('span', undefined, session.label));161162// Details row: Provider icon + Duration/Time • Diff • Status (if not completed)163const detailsRow = dom.append(header, dom.$('.agent-session-hover-details'));164165// Provider icon + name + Duration or start time166const providerType = getAgentSessionProvider(session.providerType);167const provider = providerType ?? AgentSessionProviders.Local;168const providerIcon = getAgentSessionProviderIcon(provider);169dom.append(detailsRow, renderIcon(providerIcon));170dom.append(detailsRow, dom.$('span', undefined, getAgentSessionProviderName(provider)));171dom.append(detailsRow, dom.$('span.separator', undefined, '•'));172173if (session.timing.lastRequestEnded && session.timing.lastRequestStarted) {174const duration = this.toDuration(session.timing.lastRequestStarted, session.timing.lastRequestEnded, true);175if (duration) {176dom.append(detailsRow, dom.$('span', undefined, duration));177}178} else {179const startTime = session.timing.lastRequestStarted ?? session.timing.created;180dom.append(detailsRow, dom.$('span', undefined, fromNow(startTime, true, true)));181}182183// Diff information184const diff = getAgentChangesSummary(session.changes);185if (diff && hasValidDiff(session.changes)) {186dom.append(detailsRow, dom.$('span.separator', undefined, '•'));187const diffContainer = dom.append(detailsRow, dom.$('.agent-session-hover-diff'));188if (diff.files > 0) {189dom.append(diffContainer, dom.$('span', undefined, diff.files === 1 ? localize('tooltip.file', "1 file") : localize('tooltip.files', "{0} files", diff.files)));190}191if (diff.insertions > 0) {192dom.append(diffContainer, dom.$('span.insertions', undefined, `+${diff.insertions}`));193}194if (diff.deletions > 0) {195dom.append(diffContainer, dom.$('span.deletions', undefined, `-${diff.deletions}`));196}197}198199// Status (only show if not completed)200if (session.status !== AgentSessionStatus.Completed) {201dom.append(detailsRow, dom.$('span.separator', undefined, '•'));202dom.append(detailsRow, dom.$('span', undefined, this.toStatusLabel(session.status)));203}204205// Archived indicator206if (session.isArchived()) {207dom.append(detailsRow, dom.$('span.separator', undefined, '•'));208dom.append(detailsRow, renderIcon(Codicon.archive));209dom.append(detailsRow, dom.$('span', undefined, localize('tooltip.archived', "Archived")));210}211}212213private buildFallbackTooltip(session: IAgentSession): IMarkdownString {214const lines: string[] = [];215216// Title217lines.push(`**${session.label}**`);218219// Tooltip (from provider)220if (session.tooltip) {221const tooltip = typeof session.tooltip === 'string' ? session.tooltip : session.tooltip.value;222lines.push(tooltip);223} else {224225// Description226if (session.description) {227const description = typeof session.description === 'string' ? session.description : session.description.value;228lines.push(description);229}230231// Badge232if (session.badge) {233const badge = typeof session.badge === 'string' ? session.badge : session.badge.value;234lines.push(badge);235}236}237238// Details line: Provider icon + Duration/Time • Diff • Status (if not completed)239const details: string[] = [];240241// Provider icon + name + Duration or start time242const providerType = getAgentSessionProvider(session.providerType);243const provider = providerType ?? AgentSessionProviders.Local;244const providerIcon = getAgentSessionProviderIcon(provider);245const providerName = getAgentSessionProviderName(provider);246let timeLabel: string;247if (session.timing.lastRequestEnded && session.timing.lastRequestStarted) {248const duration = this.toDuration(session.timing.lastRequestStarted, session.timing.lastRequestEnded, true);249timeLabel = duration ?? fromNow(session.timing.lastRequestStarted, true, true);250} else {251const startTime = session.timing.lastRequestStarted ?? session.timing.created;252timeLabel = fromNow(startTime, true, true);253}254details.push(`$(${providerIcon.id}) ${providerName} • ${timeLabel}`);255256// Diff information257const diff = getAgentChangesSummary(session.changes);258if (diff && hasValidDiff(session.changes)) {259const diffParts: string[] = [];260if (diff.files > 0) {261diffParts.push(diff.files === 1 ? localize('tooltip.file', "1 file") : localize('tooltip.files', "{0} files", diff.files));262}263if (diff.insertions > 0) {264diffParts.push(`+${diff.insertions}`);265}266if (diff.deletions > 0) {267diffParts.push(`-${diff.deletions}`);268}269if (diffParts.length > 0) {270details.push(diffParts.join(' '));271}272}273274// Status (only show if not completed)275if (session.status !== AgentSessionStatus.Completed) {276details.push(this.toStatusLabel(session.status));277}278279lines.push(details.join(' • '));280281// Archived status282if (session.isArchived()) {283lines.push(`$(archive) ${localize('tooltip.archived', "Archived")}`);284}285286return new MarkdownString(lines.join('\n\n'), { supportThemeIcons: true });287}288289private toDuration(startTime: number, endTime: number, useFullTimeWords: boolean): string | undefined {290const elapsed = Math.round((endTime - startTime) / 1000) * 1000;291if (elapsed < 1000) {292return undefined;293}294295return getDurationString(elapsed, useFullTimeWords);296}297298private toStatusLabel(status: AgentSessionStatus): string {299let statusLabel: string;300switch (status) {301case AgentSessionStatus.NeedsInput:302statusLabel = localize('agentSessionNeedsInput', "Needs Input");303break;304case AgentSessionStatus.InProgress:305statusLabel = localize('agentSessionInProgress', "In Progress");306break;307case AgentSessionStatus.Failed:308statusLabel = localize('agentSessionFailed', "Failed");309break;310default:311statusLabel = localize('agentSessionCompleted', "Completed");312}313314return statusLabel;315}316}317318319