microsoft/vscode · #332799
POC: Chat session state frames
src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts6 + / 0 −
@@ -1001,6 +1001,12 @@ configurationRegistry.registerConfiguration({ default: true, markdownDescription: nls.localize('chat.progressBorder.enabled', "Show an animated gradient border around the chat input while the agent is working or thinking. Has no effect when reduced motion is enabled."), },+ [ChatConfiguration.SessionStateIndicatorEnabled]: {+ type: 'boolean',+ default: false,+ description: nls.localize('chat.experimental.sessionStateIndicator.enabled', "Enable state indicators around chat editor sessions."),+ tags: ['experimental'],+ }, [ChatConfiguration.NotifyWindowOnResponseReceived]: { type: 'string', enum: ['off', 'windowNotFocused', 'always'],src/vs/workbench/contrib/chat/browser/chat.ts3 + / 0 −
@@ -326,6 +326,9 @@ export interface IChatWidgetViewOptions { */ isSessionsWindow?: boolean; + /** Whether this host supports the experimental session state indicator. Defaults to false. */+ enableSessionStateIndicator?: boolean;+ /** Enables the transcript Find widget (`Ctrl/Cmd+F`) for this chat widget. Off by default. */ enableFind?: boolean; src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts75 + / 1 −
@@ -302,6 +302,24 @@ export const chatPersistentContentVisibleClass = 'chat-persistent-content-visibl /** Carries {@link IChatWidgetViewOptions.persistentContentHeight} to `chat.css`. */ export const chatPersistentContentHeightVariable = '--vscode-chat-persistent-content-height'; +/** Computes the visual session state and whether its latest completion remains unvisited. */+export function computeChatSessionStateIndicatorState(input: {+ readonly requestNeedsInput: boolean;+ readonly requestInProgress: boolean;+ readonly containsFocus: boolean;+ readonly requestWasActive: boolean;+ readonly hasUnvisitedCompletion: boolean;+}) {+ const state = input.requestNeedsInput ? 'needsInput' : input.requestInProgress ? 'inProgress' : 'idle';+ const requestActive = state !== 'idle';+ let hasUnvisitedCompletion = input.containsFocus ? false : input.hasUnvisitedCompletion;+ if (!requestActive && input.requestWasActive) {+ hasUnvisitedCompletion = !input.containsFocus;+ }++ return { state, requestActive, hasUnvisitedCompletion };+}+ export class ChatWidget extends Disposable implements IChatWidget { // eslint-disable-next-line @typescript-eslint/no-explicit-any@@ -468,6 +486,8 @@ export class ChatWidget extends Disposable implements IChatWidget { private readonly viewModelDisposables = this._register(new DisposableStore()); private _viewModel: ChatViewModel | undefined;+ private _requestActiveForStateIndicator = false;+ private _hasUnvisitedCompletion = false; private set viewModel(viewModel: ChatViewModel | undefined) { if (this._viewModel === viewModel) {@@ -478,6 +498,8 @@ export class ChatWidget extends Disposable implements IChatWidget { this.viewModelDisposables.clear(); this._viewModel = viewModel;+ this._requestActiveForStateIndicator = false;+ this._hasUnvisitedCompletion = false; if (viewModel) { this.viewModelDisposables.add(viewModel); this.logService.debug(`ChatWidget#setViewModel: have viewModel session=${viewModel.sessionResource.toString()} requests=${viewModel.model.getRequests().length}`);@@ -491,6 +513,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } this._onDidChangeViewModel.fire({ previousSessionResource, currentSessionResource: this._viewModel?.sessionResource });+ this.updateSessionStateIndicator(); } get viewModel() {@@ -672,6 +695,10 @@ export class ChatWidget extends Disposable implements IChatWidget { if (e.affectsConfiguration(ChatConfiguration.ProgressBorder)) { this.updateWorkingProgressBorder(); }+ if (e.affectsConfiguration(ChatConfiguration.SessionStateIndicatorEnabled)) {+ this.updateWorkingProgressBorder();+ this.updateSessionStateIndicator();+ } })); this._register(this.accessibilityService.onDidChangeReducedMotion(() => {@@ -947,13 +974,52 @@ export class ChatWidget extends Disposable implements IChatWidget { } const enabled = this.configurationService.getValue<boolean>(ChatConfiguration.ProgressBorder) === true && !this.accessibilityService.isMotionReduced()- && !isInlineChat(this);+ && !isInlineChat(this)+ && !this.isSessionStateIndicatorEnabled(); const inProgress = !!this.viewModel?.model.requestInProgress.get(); const working = enabled && inProgress; inputContainer.classList.toggle('working', working); setChatInputStackInputWorking(inputContainer, working); } + private isSessionStateIndicatorEnabled(): boolean {+ if (isInlineChat(this) || isQuickChat(this) || this.viewOptions.enableSessionStateIndicator !== true) {+ return false;+ }++ return this.configurationService.getValue<boolean>(ChatConfiguration.SessionStateIndicatorEnabled) === true;+ }++ /** Updates the whole-widget session state indicator. */+ private updateSessionStateIndicator(): void {+ if (!this.container) {+ return;+ }++ const enabled = this.isSessionStateIndicatorEnabled();+ const modelNeedsInput = !!this.viewModel?.model.requestNeedsInput.get();+ const indicatorState = computeChatSessionStateIndicatorState({+ requestNeedsInput: modelNeedsInput,+ requestInProgress: !!this.viewModel?.model.requestInProgress.get(),+ containsFocus: dom.isAncestorOfActiveElement(this.container),+ requestWasActive: this._requestActiveForStateIndicator,+ hasUnvisitedCompletion: this._hasUnvisitedCompletion,+ });+ this._requestActiveForStateIndicator = indicatorState.requestActive;+ this._hasUnvisitedCompletion = indicatorState.hasUnvisitedCompletion;++ const needsInput = enabled && indicatorState.state === 'needsInput';+ const inProgress = enabled && indicatorState.state === 'inProgress';+ const idle = enabled && !needsInput && !inProgress;+ const idleUnvisited = idle && this._hasUnvisitedCompletion;++ this.container.classList.toggle('chat-session-state-indicator', enabled);+ this.container.classList.toggle('chat-state-needs-input', needsInput);+ this.container.classList.toggle('chat-state-in-progress', inProgress);+ this.container.classList.toggle('chat-state-idle', idle);+ this.container.classList.toggle('chat-state-idle-unvisited', idleUnvisited);+ }+ get inputEditor(): ICodeEditor { return this.input.inputEditor; }@@ -1013,6 +1079,13 @@ export class ChatWidget extends Disposable implements IChatWidget { const renderInputToolbarBelowInput = this.viewOptions.renderInputToolbarBelowInput ?? false; this.container = dom.append(parent, $('.interactive-session'));+ const focusTracker = this._register(dom.trackFocus(this.container));+ this._register(focusTracker.onDidFocus(() => {+ if (this._hasUnvisitedCompletion) {+ this.updateSessionStateIndicator();+ }+ }));+ this.updateSessionStateIndicator(); if (this.viewOptions.persistentContentHeight) { // The class floats the persistent content; the variable tells the // surfaces the list now extends behind how far to keep clear.@@ -2706,6 +2779,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.requestInProgress.set(this.viewModel.model.requestInProgress.get()); this.hasActiveRequest.set(this.viewModel.model.hasActiveRequest.get()); this.updateWorkingProgressBorder();+ this.updateSessionStateIndicator(); // Update the editor's placeholder text when it changes in the view model if (events?.some(e => e?.kind === 'changePlaceholder')) {src/vs/workbench/contrib/chat/browser/widget/media/chat.css72 + / 0 −
@@ -26,6 +26,78 @@ --vscode-chat-font-size-body-xxl: 1.538em; } +@keyframes chat-session-working-glow {+ 0%,+ 100% {+ box-shadow: 0 0 var(--vscode-spacing-size20) color-mix(in srgb, var(--vscode-charts-green) 35%, transparent);+ }++ 50% {+ box-shadow: 0 0 var(--vscode-spacing-size60) color-mix(in srgb, var(--vscode-charts-green) 80%, transparent);+ }+}++.monaco-workbench .interactive-session.chat-session-state-indicator:is(.chat-state-idle-unvisited, .chat-state-in-progress, .chat-state-needs-input)::before {+ content: '';+ position: absolute;+ inset: 0;+ /* Paint above the Prompt Timeline sticky header (9) and rail (15). */+ z-index: 16;+ pointer-events: none;+ border: var(--vscode-strokeThickness) solid transparent;+ border-radius: var(--vscode-cornerRadius-medium);+ transition: border-color 350ms ease, box-shadow 350ms ease;+}++.monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-idle-unvisited::before {+ border-color: hsl(from var(--vscode-foreground) h 0% l / 0.7);+ border-style: dotted;+}++.monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-in-progress::before {+ border-color: var(--vscode-charts-green);+ border-style: solid;+ animation: chat-session-working-glow 1.4s ease-in-out infinite;+}++.monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-needs-input::before {+ border-color: var(--vscode-errorForeground);+ border-style: dashed;+ box-shadow: 0 0 var(--vscode-spacing-size80) color-mix(in srgb, var(--vscode-errorForeground) 60%, transparent);+}++.monaco-workbench.hc-black .interactive-session.chat-session-state-indicator::before,+.monaco-workbench.hc-light .interactive-session.chat-session-state-indicator::before {+ border-color: var(--vscode-contrastActiveBorder);+ box-shadow: none;+}++.monaco-workbench.hc-black .interactive-session.chat-session-state-indicator.chat-state-in-progress::before,+.monaco-workbench.hc-light .interactive-session.chat-session-state-indicator.chat-state-in-progress::before {+ border: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder);+ animation: none;+}++.monaco-reduce-motion .interactive-session.chat-session-state-indicator::before,+.monaco-workbench.monaco-reduce-motion .interactive-session.chat-session-state-indicator::before {+ transition: none;+}++.monaco-reduce-motion .interactive-session.chat-session-state-indicator.chat-state-in-progress::before,+.monaco-workbench.monaco-reduce-motion .interactive-session.chat-session-state-indicator.chat-state-in-progress::before {+ animation: none;+}++@media (prefers-reduced-motion: reduce) {+ .monaco-workbench .interactive-session.chat-session-state-indicator::before {+ transition: none;+ }++ .monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-in-progress::before {+ animation: none;+ }+}+ .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row > .monaco-tl-row > .monaco-tl-twistie, .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-tree-sticky-container .monaco-tree-sticky-row > .monaco-tl-row > .monaco-tl-twistie { /* Hide twisties from chat tree rows, but not from nested trees within a chat response */src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts1 + / 0 −
@@ -130,6 +130,7 @@ export class ChatEditor extends AbstractEditorWithViewState<IChatEditorViewState enableImplicitContext: true, enableWorkingSet: 'explicit', supportsChangingModes: true,+ enableSessionStateIndicator: true, }, { listForeground: editorForeground,src/vs/workbench/contrib/chat/common/constants.ts1 + / 0 −
@@ -87,6 +87,7 @@ export enum ChatConfiguration { ChatContextUsageEnabled = 'chat.contextUsage.enabled', Verbose = 'chat.verbose', ProgressBorder = 'chat.progressBorder.enabled',+ SessionStateIndicatorEnabled = 'chat.experimental.sessionStateIndicator.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', SubagentsAllowInvocationsFromSubagents = 'chat.subagents.allowInvocationsFromSubagents', SubagentsUseRichRendering = 'chat.subagents.useRichRendering',src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts39 + / 1 −
@@ -15,7 +15,7 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { SaveReason } from '../../../../../common/editor.js'; import { ISaveAllEditorsOptions, ISaveEditorsResult } from '../../../../../services/editor/common/editorService.js'; import { TestEditorService } from '../../../../../test/browser/workbenchTestServices.js';-import { acceptAndAwaitSentRequest, ChatWidget, getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight, saveAllBeforeChatSend, shouldShowChatTip, shouldShowChatWelcome, shouldUnlockChatPetQueueOrSteeringMessage, shouldUnlockChatPetRequestRevision } from '../../../browser/widget/chatWidget.js';+import { acceptAndAwaitSentRequest, ChatWidget, computeChatSessionStateIndicatorState, getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight, saveAllBeforeChatSend, shouldShowChatTip, shouldShowChatWelcome, shouldUnlockChatPetQueueOrSteeringMessage, shouldUnlockChatPetRequestRevision } from '../../../browser/widget/chatWidget.js'; import { IChatListItemTemplate } from '../../../browser/widget/chatListRenderer.js'; import { ChatRequestQueueKind, ChatSendResult, ChatSendResultSent, IChatSendRequestData } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../common/constants.js';@@ -147,6 +147,44 @@ suite('ChatWidget', () => { ], [true, false]); }); + test('tracks unvisited completions and needs-input precedence', () => {+ const active = computeChatSessionStateIndicatorState({+ requestNeedsInput: false,+ requestInProgress: true,+ containsFocus: false,+ requestWasActive: false,+ hasUnvisitedCompletion: false,+ });+ const completed = computeChatSessionStateIndicatorState({+ requestNeedsInput: false,+ requestInProgress: false,+ containsFocus: false,+ requestWasActive: active.requestActive,+ hasUnvisitedCompletion: active.hasUnvisitedCompletion,+ });+ const visited = computeChatSessionStateIndicatorState({+ requestNeedsInput: false,+ requestInProgress: false,+ containsFocus: true,+ requestWasActive: completed.requestActive,+ hasUnvisitedCompletion: completed.hasUnvisitedCompletion,+ });+ const needsInput = computeChatSessionStateIndicatorState({+ requestNeedsInput: true,+ requestInProgress: true,+ containsFocus: true,+ requestWasActive: visited.requestActive,+ hasUnvisitedCompletion: visited.hasUnvisitedCompletion,+ });++ assert.deepStrictEqual({ active, completed, visited, needsInput }, {+ active: { state: 'inProgress', requestActive: true, hasUnvisitedCompletion: false },+ completed: { state: 'idle', requestActive: false, hasUnvisitedCompletion: true },+ visited: { state: 'idle', requestActive: false, hasUnvisitedCompletion: false },+ needsInput: { state: 'needsInput', requestActive: true, hasUnvisitedCompletion: false },+ });+ });+ test('sticky request click survives synchronous template disposal during reveal', () => { const request = upcastPartial<IChatRequestViewModel>({ id: 'request',