feat(chat): add status widget and input part widget controller for en… (#280269)

* feat(chat): add status widget and input part widget controller for enhanced chat functionality

* update chat status widget to simplify entitlement checks to only show for internal users and improve button labeling
This commit is contained in:
Bhavya U
2025-12-01 16:06:17 -08:00
committed by GitHub
parent e320de7cd1
commit c45a02135b
8 changed files with 382 additions and 5 deletions

View File

@@ -89,6 +89,7 @@ import './agentSessions/agentSessionsView.js';
import { IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService } from './chat.js';
import { ChatAccessibilityService } from './chatAccessibilityService.js';
import './chatAttachmentModel.js';
import './chatStatusWidget.js';
import { ChatAttachmentResolveService, IChatAttachmentResolveService } from './chatAttachmentResolveService.js';
import { ChatMarkdownAnchorService, IChatMarkdownAnchorService } from './chatContentParts/chatMarkdownAnchorService.js';
import { ChatContextPickService, IChatContextPickService } from './chatContextPickService.js';
@@ -535,6 +536,16 @@ configurationRegistry.registerConfiguration({
default: true,
tags: ['experimental'],
},
['chat.statusWidget.enabled']: {
type: 'boolean',
description: nls.localize('chat.statusWidget.enabled.description', "Show the status widget in new chat sessions when quota is exceeded."),
default: false,
tags: ['experimental'],
included: false,
experiment: {
mode: 'auto'
}
},
[ChatConfiguration.AgentSessionsViewLocation]: {
type: 'string',
enum: ['disabled', 'view', 'single-view'], // TODO@bpasero remove this setting eventually

View File

@@ -97,6 +97,7 @@ import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmen
import { IDisposableReference } from './chatContentParts/chatCollections.js';
import { CollapsibleListPool, IChatCollapsibleListItem } from './chatContentParts/chatReferencesContentPart.js';
import { ChatTodoListWidget } from './chatContentParts/chatTodoListWidget.js';
import { ChatInputPartWidgetController } from './chatInputPartWidgets.js';
import { IChatContextService } from './chatContextService.js';
import { ChatDragAndDrop } from './chatDragAndDrop.js';
import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from './chatEditing/chatEditingActions.js';
@@ -234,6 +235,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
private chatEditingSessionWidgetContainer!: HTMLElement;
private chatInputTodoListWidgetContainer!: HTMLElement;
private chatInputWidgetsContainer!: HTMLElement;
private readonly _widgetController = this._register(new MutableDisposable<ChatInputPartWidgetController>());
private _inputPartHeight: number = 0;
get inputPartHeight() {
@@ -254,6 +257,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
return this.chatInputTodoListWidgetContainer.offsetHeight;
}
get inputWidgetsHeight() {
return this.chatInputWidgetsContainer?.offsetHeight ?? 0;
}
get attachmentsHeight() {
return this.attachmentsContainer.offsetHeight + (this.attachmentsContainer.checkVisibility() ? 6 : 0);
}
@@ -1312,6 +1319,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
if (this.options.renderStyle === 'compact') {
elements = dom.h('.interactive-input-part', [
dom.h('.interactive-input-and-edit-session', [
dom.h('.chat-input-widgets-container@chatInputWidgetsContainer'),
dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'),
dom.h('.chat-editing-session@chatEditingSessionWidgetContainer'),
dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [
@@ -1331,6 +1339,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
} else {
elements = dom.h('.interactive-input-part', [
dom.h('.interactive-input-followups@followupsContainer'),
dom.h('.chat-input-widgets-container@chatInputWidgetsContainer'),
dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'),
dom.h('.chat-editing-session@chatEditingSessionWidgetContainer'),
dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [
@@ -1362,6 +1371,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
const attachmentToolbarContainer = elements.attachmentToolbar;
this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer;
this.chatInputTodoListWidgetContainer = elements.chatInputTodoListWidgetContainer;
this.chatInputWidgetsContainer = elements.chatInputWidgetsContainer;
if (this.options.enableImplicitContext) {
this._implicitContext = this._register(
@@ -1374,6 +1384,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}));
}
this._widgetController.value = this.instantiationService.createInstance(ChatInputPartWidgetController, this.chatInputWidgetsContainer);
this._register(this._widgetController.value.onDidChangeHeight(() => this._onDidChangeHeight.fire()));
this.renderAttachedContext();
this._register(this._attachmentModel.onDidChange((e) => {
if (e.added.length > 0) {
@@ -2197,7 +2210,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
get contentHeight(): number {
const data = this.getLayoutData();
return data.followupsHeight + data.inputPartEditorHeight + data.inputPartVerticalPadding + data.inputEditorBorder + data.attachmentsHeight + data.toolbarsHeight + data.chatEditingStateHeight + data.todoListWidgetContainerHeight;
return data.followupsHeight + data.inputPartEditorHeight + data.inputPartVerticalPadding + data.inputEditorBorder + data.attachmentsHeight + data.toolbarsHeight + data.chatEditingStateHeight + data.todoListWidgetContainerHeight + data.inputWidgetsContainerHeight;
}
layout(height: number, width: number) {
@@ -2209,12 +2222,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
private previousInputEditorDimension: IDimension | undefined;
private _layout(height: number, width: number, allowRecurse = true): void {
const data = this.getLayoutData();
const inputEditorHeight = Math.min(data.inputPartEditorHeight, height - data.followupsHeight - data.attachmentsHeight - data.inputPartVerticalPadding - data.toolbarsHeight - data.chatEditingStateHeight - data.todoListWidgetContainerHeight);
const inputEditorHeight = Math.min(data.inputPartEditorHeight, height - data.followupsHeight - data.attachmentsHeight - data.inputPartVerticalPadding - data.toolbarsHeight - data.chatEditingStateHeight - data.todoListWidgetContainerHeight - data.inputWidgetsContainerHeight);
const followupsWidth = width - data.inputPartHorizontalPadding;
this.followupsContainer.style.width = `${followupsWidth}px`;
this._inputPartHeight = data.inputPartVerticalPadding + data.followupsHeight + inputEditorHeight + data.inputEditorBorder + data.attachmentsHeight + data.toolbarsHeight + data.chatEditingStateHeight + data.todoListWidgetContainerHeight;
this._inputPartHeight = data.inputPartVerticalPadding + data.followupsHeight + inputEditorHeight + data.inputEditorBorder + data.attachmentsHeight + data.toolbarsHeight + data.chatEditingStateHeight + data.todoListWidgetContainerHeight + data.inputWidgetsContainerHeight;
this._followupsHeight = data.followupsHeight;
this._editSessionWidgetHeight = data.chatEditingStateHeight;
@@ -2265,6 +2278,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
chatEditingStateHeight: this.chatEditingSessionWidgetContainer.offsetHeight,
sideToolbarWidth: inputSideToolbarWidth > 0 ? inputSideToolbarWidth + 4 /*gap*/ : 0,
todoListWidgetContainerHeight: this.chatInputTodoListWidgetContainer.offsetHeight,
inputWidgetsContainerHeight: this.inputWidgetsHeight,
};
}
}

View File

@@ -0,0 +1,144 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
import { ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { BrandedService, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
/**
* A widget that can be rendered on top of the chat input part.
*/
export interface IChatInputPartWidget extends IDisposable {
/**
* The DOM node of the widget.
*/
readonly domNode: HTMLElement;
/**
* Fired when the height of the widget changes.
*/
readonly onDidChangeHeight: Event<void>;
/**
* The current height of the widget in pixels.
*/
readonly height: number;
}
export interface IChatInputPartWidgetDescriptor<Services extends BrandedService[] = BrandedService[]> {
readonly id: string;
readonly when?: ContextKeyExpression;
readonly ctor: new (...services: Services) => IChatInputPartWidget;
}
/**
* Registry for chat input part widgets.
* Widgets register themselves and are instantiated by the controller based on context key conditions.
*/
export const ChatInputPartWidgetsRegistry = new class {
readonly widgets: IChatInputPartWidgetDescriptor[] = [];
register<Services extends BrandedService[]>(id: string, ctor: new (...services: Services) => IChatInputPartWidget, when?: ContextKeyExpression): void {
this.widgets.push({ id, ctor: ctor as IChatInputPartWidgetDescriptor['ctor'], when });
}
getWidgets(): readonly IChatInputPartWidgetDescriptor[] {
return this.widgets;
}
}();
interface IRenderedWidget {
readonly descriptor: IChatInputPartWidgetDescriptor;
readonly widget: IChatInputPartWidget;
readonly disposables: DisposableStore;
}
/**
* Controller that manages the rendering of widgets in the chat input part.
* Widgets are shown/hidden based on context key conditions.
*/
export class ChatInputPartWidgetController extends Disposable {
private readonly _onDidChangeHeight = this._register(new Emitter<void>());
readonly onDidChangeHeight: Event<void> = this._onDidChangeHeight.event;
private readonly renderedWidgets = new Map<string, IRenderedWidget>();
constructor(
private readonly container: HTMLElement,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
this.update();
this._register(this.contextKeyService.onDidChangeContext(e => {
const relevantKeys = new Set<string>();
for (const descriptor of ChatInputPartWidgetsRegistry.getWidgets()) {
if (descriptor.when) {
for (const key of descriptor.when.keys()) {
relevantKeys.add(key);
}
}
}
if (e.affectsSome(relevantKeys)) {
this.update();
}
}));
}
private update(): void {
const visibleIds = new Set<string>();
for (const descriptor of ChatInputPartWidgetsRegistry.getWidgets()) {
if (this.contextKeyService.contextMatchesRules(descriptor.when)) {
visibleIds.add(descriptor.id);
}
}
for (const [id, rendered] of this.renderedWidgets) {
if (!visibleIds.has(id)) {
rendered.widget.domNode.remove();
rendered.disposables.dispose();
this.renderedWidgets.delete(id);
}
}
for (const descriptor of ChatInputPartWidgetsRegistry.getWidgets()) {
if (!visibleIds.has(descriptor.id)) {
continue;
}
if (!this.renderedWidgets.has(descriptor.id)) {
const disposables = new DisposableStore();
const widget = this.instantiationService.createInstance(descriptor.ctor);
disposables.add(widget);
disposables.add(widget.onDidChangeHeight(() => this._onDidChangeHeight.fire()));
this.renderedWidgets.set(descriptor.id, { descriptor, widget, disposables });
this.container.appendChild(widget.domNode);
}
}
this._onDidChangeHeight.fire();
}
get height(): number {
let total = 0;
for (const rendered of this.renderedWidgets.values()) {
total += rendered.widget.height;
}
return total;
}
override dispose(): void {
for (const rendered of this.renderedWidgets.values()) {
rendered.disposables.dispose();
}
this.renderedWidgets.clear();
super.dispose();
}
}

View File

@@ -0,0 +1,138 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/chatStatusWidget.css';
import * as dom from '../../../../base/browser/dom.js';
import { Button } from '../../../../base/browser/ui/button/button.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { localize, localize2 } from '../../../../nls.js';
import { Categories } from '../../../../platform/action/common/actionCommonCategories.js';
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js';
import { ChatEntitlement, ChatEntitlementContextKeys, IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js';
import { ChatInputPartWidgetsRegistry, IChatInputPartWidget } from './chatInputPartWidgets.js';
import { ChatContextKeys } from '../common/chatContextKeys.js';
const $ = dom.$;
/**
* Widget that displays a status message with an optional action button.
* Only shown for free tier users when the setting is enabled (experiment controlled via onExP tag).
*/
export class ChatStatusWidget extends Disposable implements IChatInputPartWidget {
static readonly ID = 'chatStatusWidget';
readonly domNode: HTMLElement;
private readonly _onDidChangeHeight = this._register(new Emitter<void>());
readonly onDidChangeHeight: Event<void> = this._onDidChangeHeight.event;
private messageElement: HTMLElement | undefined;
private actionButton: Button | undefined;
private _isEnabled = false;
constructor(
@IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService,
@ICommandService private readonly commandService: ICommandService,
@IConfigurationService private readonly configurationService: IConfigurationService,
) {
super();
this.domNode = $('.chat-status-widget');
this.domNode.style.display = 'none';
this.initializeIfEnabled();
}
private initializeIfEnabled(): void {
const isEnabled = this.configurationService.getValue<boolean>('chat.statusWidget.enabled');
if (!isEnabled) {
return;
}
this._isEnabled = true;
if (!this.chatEntitlementService.isInternal) {
return;
}
this.createWidgetContent();
this.updateContent();
this.domNode.style.display = '';
this._register(this.chatEntitlementService.onDidChangeEntitlement(() => {
this.updateContent();
}));
this._onDidChangeHeight.fire();
}
get height(): number {
return this._isEnabled ? this.domNode.offsetHeight : 0;
}
private createWidgetContent(): void {
const contentContainer = $('.chat-status-content');
this.messageElement = $('.chat-status-message');
contentContainer.appendChild(this.messageElement);
const actionContainer = $('.chat-status-action');
this.actionButton = this._register(new Button(actionContainer, {
...defaultButtonStyles,
supportIcons: true
}));
this.actionButton.element.classList.add('chat-status-button');
this._register(this.actionButton.onDidClick(async () => {
const commandId = this.chatEntitlementService.entitlement === ChatEntitlement.Free
? 'workbench.action.chat.upgradePlan'
: 'workbench.action.chat.manageOverages';
await this.commandService.executeCommand(commandId);
}));
this.domNode.appendChild(contentContainer);
this.domNode.appendChild(actionContainer);
}
private updateContent(): void {
if (!this.messageElement || !this.actionButton) {
return;
}
this.messageElement.textContent = localize('chat.quotaExceeded.message', "Free tier chat message limit reached.");
this.actionButton.label = localize('chat.quotaExceeded.increaseLimit', "Increase Limit");
this._onDidChangeHeight.fire();
}
}
// TODO@bhavyaus remove this command after testing complete with team
registerAction2(class ToggleChatQuotaExceededAction extends Action2 {
constructor() {
super({
id: 'workbench.action.chat.toggleStatusWidget',
title: localize2('chat.toggleStatusWidget.label', "Toggle Chat Status Widget State"),
f1: true,
category: Categories.Developer,
precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatEntitlementContextKeys.Entitlement.internal),
});
}
run(accessor: ServicesAccessor): void {
const contextKeyService = accessor.get(IContextKeyService);
const currentValue = ChatEntitlementContextKeys.chatQuotaExceeded.getValue(contextKeyService) ?? false;
ChatEntitlementContextKeys.chatQuotaExceeded.bindTo(contextKeyService).set(!currentValue);
}
});
ChatInputPartWidgetsRegistry.register(
ChatStatusWidget.ID,
ChatStatusWidget,
ContextKeyExpr.and(ChatContextKeys.chatQuotaExceeded, ChatContextKeys.chatSessionIsEmpty)
);

View File

@@ -274,6 +274,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
};
private readonly _lockedToCodingAgentContextKey: IContextKey<boolean>;
private readonly _agentSupportsAttachmentsContextKey: IContextKey<boolean>;
private readonly _sessionIsEmptyContextKey: IContextKey<boolean>;
private _attachmentCapabilities: IChatAgentAttachmentCapabilities = supportsAllAttachments;
// Cache for prompt file descriptions to avoid async calls during rendering
@@ -382,6 +383,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
this._lockedToCodingAgentContextKey = ChatContextKeys.lockedToCodingAgent.bindTo(this.contextKeyService);
this._agentSupportsAttachmentsContextKey = ChatContextKeys.agentSupportsAttachments.bindTo(this.contextKeyService);
this._sessionIsEmptyContextKey = ChatContextKeys.chatSessionIsEmpty.bindTo(this.contextKeyService);
this.viewContext = viewContext ?? {};
@@ -1983,6 +1985,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
}));
const inputState = model.inputModel.state.get();
this.input.initForNewChatModel(inputState, model.getRequests().length === 0);
this._sessionIsEmptyContextKey.set(model.getRequests().length === 0);
this.refreshParsedInput();
this.viewModelDisposables.add(model.onDidChange((e) => {
@@ -1993,11 +1996,13 @@ export class ChatWidget extends Disposable implements IChatWidget {
}
if (e.kind === 'addRequest') {
this.inputPart.clearTodoListWidget(this.viewModel?.sessionResource, false);
this._sessionIsEmptyContextKey.set(false);
}
// Hide widget on request removal
if (e.kind === 'removeRequest') {
this.inputPart.clearTodoListWidget(this.viewModel?.sessionResource, true);
this.chatSuggestNextWidget.hide();
this._sessionIsEmptyContextKey.set((this.viewModel?.model.getRequests().length ?? 0) === 0);
}
// Show next steps widget when response completes (not when request starts)
if (e.kind === 'completedRequest') {

View File

@@ -759,8 +759,9 @@ have to be updated for changes to the rules above, or to support more deeply nes
}
.interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container,
.interactive-input-part:has(.chat-todo-list-widget-container > .chat-todo-list-widget.has-todos) .chat-input-container {
/* Remove top border radius when editing session or todo list is present */
.interactive-input-part:has(.chat-todo-list-widget-container > .chat-todo-list-widget.has-todos) .chat-input-container,
.interactive-input-part:has(.chat-input-widgets-container > .chat-status-widget:not([style*="display: none"])) .chat-input-container {
/* Remove top border radius when editing session, todo list, or status widget is present */
border-top-left-radius: 0;
border-top-right-radius: 0;
}
@@ -1088,6 +1089,12 @@ have to be updated for changes to the rules above, or to support more deeply nes
background-color: var(--vscode-toolbar-hoverBackground);
}
.interactive-session .interactive-input-part > .chat-input-widgets-container {
margin-bottom: -4px;
width: 100%;
position: relative;
}
/* Chat Todo List Widget Container - mirrors chat-editing-session styling */
.interactive-session .interactive-input-part > .chat-todo-list-widget-container {
margin-bottom: -4px;

View File

@@ -0,0 +1,57 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.interactive-session .interactive-input-part > .chat-input-widgets-container .chat-status-widget {
padding: 6px 3px 6px 3px;
box-sizing: border-box;
border: 1px solid var(--vscode-input-border, transparent);
background-color: var(--vscode-editor-background);
border-bottom: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.interactive-session .interactive-input-part > .chat-input-widgets-container .chat-status-widget .chat-status-content {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
padding-left: 8px;
}
.interactive-session .interactive-input-part > .chat-input-widgets-container .chat-status-widget .chat-status-message {
font-size: 11px;
line-height: 16px;
color: var(--vscode-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.interactive-session .interactive-input-part > .chat-input-widgets-container .chat-status-widget .chat-status-action {
flex-shrink: 0;
padding-right: 4px;
}
.interactive-session .interactive-input-part > .chat-input-widgets-container .chat-status-widget .chat-status-button {
font-size: 11px;
padding: 2px 8px;
min-width: unset;
height: 22px;
}
.interactive-session .interactive-input-part > .chat-input-widgets-container:has(.chat-status-widget:not([style*="display: none"])) + .chat-todo-list-widget-container .chat-todo-list-widget {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.interactive-session .interactive-input-part > .chat-input-widgets-container:has(.chat-status-widget:not([style*="display: none"])) + .chat-todo-list-widget-container:not(:has(.chat-todo-list-widget.has-todos)) + .chat-editing-session .chat-editing-session-container {
border-top-left-radius: 0;
border-top-right-radius: 0;
}

View File

@@ -61,6 +61,7 @@ export namespace ChatContextKeys {
export const location = new RawContextKey<ChatAgentLocation>('chatLocation', undefined);
export const inQuickChat = new RawContextKey<boolean>('quickChatHasFocus', false, { type: 'boolean', description: localize('inQuickChat', "True when the quick chat UI has focus, false otherwise.") });
export const hasFileAttachments = new RawContextKey<boolean>('chatHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") });
export const chatSessionIsEmpty = new RawContextKey<boolean>('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") });
export const remoteJobCreating = new RawContextKey<boolean>('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") });
export const hasRemoteCodingAgent = new RawContextKey<boolean>('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available"));