diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index a2f1696f372..f5c0817dca7 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -66,6 +66,7 @@ "--vscode-chat-requestCodeBorder", "--vscode-chat-slashCommandBackground", "--vscode-chat-slashCommandForeground", + "--vscode-chat-thinkingShimmer", "--vscode-chatManagement-sashBorder", "--vscode-checkbox-background", "--vscode-checkbox-border", diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index c9e505b16d0..e499baa20a9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -108,6 +108,17 @@ interface ILazyThinkingItem { type ILazyItem = ILazyToolItem | ILazyThinkingItem; const THINKING_SCROLL_MAX_HEIGHT = 200; +const workingMessages = [ + localize('chat.thinking.working.1', 'Thinking...'), + localize('chat.thinking.working.2', 'Processing...'), + localize('chat.thinking.working.3', 'Analyzing...'), + localize('chat.thinking.working.4', 'Computing...'), + localize('chat.thinking.working.5', 'Loading...'), + localize('chat.thinking.working.6', 'Reasoning...'), + localize('chat.thinking.working.7', 'Evaluating...'), + localize('chat.thinking.working.8', 'Preparing...'), +]; + export class ChatThinkingContentPart extends ChatCollapsibleContentPart implements IChatContentPart { public readonly codeblocks: undefined; public readonly codeblocksPartId: undefined; @@ -134,6 +145,17 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private singleItemInfo: { element: HTMLElement; originalParent: HTMLElement; originalNextSibling: Node | null } | undefined; private lazyItems: ILazyItem[] = []; private hasExpandedOnce: boolean = false; + private workingSpinnerElement: HTMLElement | undefined; + private workingSpinnerLabel: HTMLElement | undefined; + private availableWorkingMessages: string[] = [...workingMessages]; + + private getRandomWorkingMessage(): string { + if (this.availableWorkingMessages.length === 0) { + this.availableWorkingMessages = [...workingMessages]; + } + const index = Math.floor(Math.random() * this.availableWorkingMessages.length); + return this.availableWorkingMessages.splice(index, 1)[0]; + } constructor( content: IChatThinkingPart, @@ -186,19 +208,20 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this.fixedScrollingMode) { node.classList.add('chat-thinking-fixed-mode'); this.currentTitle = this.defaultTitle; - if (this._collapseButton && !this.element.isComplete) { - this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); - } } // override for codicon chevron in the collapsible part this._register(autorun(r => { - this.expanded.read(r); - if (this._collapseButton && this.wrapper) { - if (this.wrapper.classList.contains('chat-thinking-streaming') && !this.element.isComplete) { - this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); - } else { + const isExpanded = this.expanded.read(r); + if (this._collapseButton) { + if (this.streamingCompleted || this.element.isComplete) { this._collapseButton.icon = Codicon.check; + } else if (!this.fixedScrollingMode) { + if (isExpanded) { + this._collapseButton.icon = Codicon.chevronDown; + } else { + this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + } } } })); @@ -215,10 +238,6 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this._onDidChangeHeight.fire(); })); - if (this._collapseButton && !this.streamingCompleted && !this.element.isComplete) { - this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); - } - const label = this.lastExtractedTitle ?? ''; if (!this.fixedScrollingMode && !this._isExpanded.get()) { this.setTitle(label); @@ -264,6 +283,17 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.renderMarkdown(this.currentThinkingValue); } + // Create the persistent working spinner element only if still streaming + if (!this.streamingCompleted && !this.element.isComplete) { + this.workingSpinnerElement = $('.chat-thinking-item.chat-thinking-spinner-item'); + const spinnerIcon = createThinkingIcon(ThemeIcon.modify(Codicon.loading, 'spin')); + this.workingSpinnerElement.appendChild(spinnerIcon); + this.workingSpinnerLabel = $('span.chat-thinking-spinner-label'); + this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(); + this.workingSpinnerElement.appendChild(this.workingSpinnerLabel); + this.wrapper.appendChild(this.workingSpinnerElement); + } + // wrap content in scrollable element for fixed scrolling mode if (this.fixedScrollingMode) { this.scrollableElement = this._register(new DomScrollableElement(this.wrapper, { @@ -433,6 +463,17 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.setDropdownClickable(!shouldDisable); } + private appendToWrapper(element: HTMLElement): void { + if (!this.wrapper) { + return; + } + if (this.workingSpinnerElement && this.workingSpinnerElement.parentNode === this.wrapper) { + this.wrapper.insertBefore(element, this.workingSpinnerElement); + } else { + this.wrapper.appendChild(element); + } + } + public resetId(): void { this.id = undefined; } @@ -497,6 +538,11 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen public markAsInactive(): void { this.isActive = false; + if (this.workingSpinnerElement) { + this.workingSpinnerElement.remove(); + this.workingSpinnerElement = undefined; + this.workingSpinnerLabel = undefined; + } } public finalizeTitleIfDefault(): void { @@ -506,6 +552,12 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } this.streamingCompleted = true; + if (this.workingSpinnerElement) { + this.workingSpinnerElement.remove(); + this.workingSpinnerElement = undefined; + this.workingSpinnerLabel = undefined; + } + if (this._collapseButton) { this._collapseButton.icon = Codicon.check; } @@ -761,6 +813,11 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.trackToolMetadata(toolInvocationId, toolInvocationOrMarkdown); this.appendedItemCount++; + // get random title + if (this.workingSpinnerLabel) { + this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(); + } + // If expanded or has been expanded once, render immediately if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { const result = factory(); @@ -967,12 +1024,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen itemWrapper.appendChild(iconElement); itemWrapper.appendChild(content); - // With lazy rendering, wrapper may not be created yet if content hasn't been expanded - if (!this.wrapper) { - return; - } - - this.wrapper.appendChild(itemWrapper); + this.appendToWrapper(itemWrapper); if (this.fixedScrollingMode && this.scrollableElement) { setTimeout(() => this.scrollToBottomIfEnabled(), 0); @@ -982,9 +1034,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private materializeLazyItem(item: ILazyItem): void { if (item.kind === 'thinking') { // Materialize thinking container - if (this.wrapper) { - this.wrapper.appendChild(item.textContainer); - } + this.appendToWrapper(item.textContainer); // Store reference to textContainer for updateThinking calls this.textContainer = item.textContainer; this.id = item.content.id; @@ -993,6 +1043,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return; } + if (this.workingSpinnerLabel) { + this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(); + } + // Handle tool items if (item.lazy.hasValue) { return; // Already materialized @@ -1017,9 +1071,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen // Use lazy rendering when collapsed to preserve order with tool items if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { // Render immediately when expanded - if (this.wrapper) { - this.wrapper.appendChild(this.textContainer); - } + this.appendToWrapper(this.textContainer); this.id = content.id; this.updateThinking(content); } else { @@ -1035,6 +1087,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen }; this.lazyItems.push(lazyThinking); } + + if (this.workingSpinnerLabel) { + this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(); + } } this.updateDropdownClickability(); } @@ -1072,6 +1128,11 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.markdownResult.dispose(); this.markdownResult = undefined; } + if (this.workingSpinnerElement) { + this.workingSpinnerElement.remove(); + this.workingSpinnerElement = undefined; + this.workingSpinnerLabel = undefined; + } super.dispose(); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index a2fb8b710b1..f37db571bfb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -17,6 +17,23 @@ margin: 0px; } + /* shimmer animation stuffs */ + .chat-thinking-spinner-item .chat-thinking-spinner-label { + background: linear-gradient( + 90deg, + var(--vscode-descriptionForeground) 0%, + var(--vscode-descriptionForeground) 30%, + var(--vscode-chat-thinkingShimmer) 50%, + var(--vscode-descriptionForeground) 70%, + var(--vscode-descriptionForeground) 100% + ); + background-size: 400% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: chat-thinking-shimmer 2s linear infinite; + } + &:not(.chat-used-context-collapsed) .chat-used-context-list.chat-thinking-collapsible { overflow: visible; max-height: none; @@ -56,8 +73,8 @@ overflow: hidden; padding: 10px 10px 10px 2px; - .codicon:not(.codicon-check) { - display: inline-flex; + .codicon:not(.codicon-check, .codicon-loading) { + display: inline-flex; } } @@ -124,7 +141,8 @@ /* chain of thought lines */ .chat-thinking-tool-wrapper, - .chat-thinking-item.markdown-content { + .chat-thinking-item.markdown-content, + .chat-thinking-spinner-item { position: relative; &::before { @@ -170,6 +188,15 @@ } } + + .chat-thinking-spinner-item { + padding: 6px 12px 6px 24px; + font-size: var(--vscode-chat-font-size-body-s); + + .chat-thinking-spinner-label { + color: var(--vscode-descriptionForeground); + } + } } .chat-thinking-item { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index fcad90fac07..498ea7f47d1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -861,6 +861,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind !== 'markdownContent' || part.content.value.trim().length > 0); + // don't show working progress when there is thinking content in partsToRender (about to be rendered) + if (partsToRender.some(part => part.kind === 'thinking')) { + return false; + } + // never show working progress when there is an active thinking piece const lastThinking = this.getLastThinkingPart(templateData.renderedParts); if (lastThinking) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 1e52e313a32..ede70d5c719 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -120,6 +120,15 @@ } } +@keyframes chat-thinking-shimmer { + 0% { + background-position: 120% 0; + } + 100% { + background-position: -120% 0; + } +} + .interactive-item-container .chat-animated-ellipsis::after { content: ''; white-space: nowrap; @@ -2247,6 +2256,23 @@ have to be updated for changes to the rules above, or to support more deeply nes display: inline; } } + + /* shimmer animation stuffs */ + &:has(.codicon-loading) .rendered-markdown.progress-step > p { + background: linear-gradient( + 90deg, + var(--vscode-descriptionForeground) 0%, + var(--vscode-descriptionForeground) 30%, + var(--vscode-chat-thinkingShimmer) 50%, + var(--vscode-descriptionForeground) 70%, + var(--vscode-descriptionForeground) 100% + ); + background-size: 400% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: chat-thinking-shimmer 2s linear infinite; + } } .interactive-item-container .chat-command-button { diff --git a/src/vs/workbench/contrib/chat/common/widget/chatColors.ts b/src/vs/workbench/contrib/chat/common/widget/chatColors.ts index bc5d77c816a..ad98943587c 100644 --- a/src/vs/workbench/contrib/chat/common/widget/chatColors.ts +++ b/src/vs/workbench/contrib/chat/common/widget/chatColors.ts @@ -73,3 +73,8 @@ export const chatLinesRemovedForeground = registerColor( 'chat.linesRemovedForeground', { dark: '#FC6A6A', light: '#BC2F32', hcDark: '#F48771', hcLight: '#B5200D' }, localize('chat.linesRemovedForeground', 'Foreground color of lines removed in chat code block pill.'), true); + +export const chatThinkingShimmer = registerColor( + 'chat.thinkingShimmer', + { dark: '#ffffff', light: '#000000', hcDark: '#ffffff', hcLight: '#000000' }, + localize('chat.thinkingShimmer', 'Shimmer highlight for thinking/working labels.'), true);