microsoft/vscode · #332799

POC: Chat session state frames

bryanchen-d · merged Sep 1, 20267 files · 197 + / 2
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',