animations for reasoning and tool calls inside reasoning (#290216)

* animations for reasoning and tool calls inside reasoning

* add shimmer

* address some comments

* Update src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* remove extra keyframes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Justin Chen
2026-01-26 08:54:28 -08:00
committed by GitHub
parent e3657ad9e3
commit dabbb57ecb
6 changed files with 152 additions and 27 deletions

View File

@@ -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",

View File

@@ -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();
}
}

View File

@@ -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 {

View File

@@ -861,6 +861,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
// Show if no content, only "used references", ends with a complete tool call, or ends with complete text edits and there is no incomplete tool call (edits are still being applied some time after they are all generated)
const lastPart = findLast(partsToRender, part => 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) {

View File

@@ -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 {

View File

@@ -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);