Skip to content

Commit 5b788a8

Browse files
bhavyausCopilot
andauthored
chat todo list: disable clear while request in progress; update styles and tests (microsoft#273643)
* chat todo list: disable clear while request in progress; update styles and tests * Update src/vs/workbench/contrib/chat/browser/chatContentParts/chatTodoListWidget.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 3fff78b commit 5b788a8

File tree

3 files changed

+81
-51
lines changed

3 files changed

+81
-51
lines changed

src/vs/workbench/contrib/chat/browser/chatContentParts/chatTodoListWidget.ts

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import { Emitter, Event } from '../../../../../base/common/event.js';
1212
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
1313
import { localize } from '../../../../../nls.js';
1414
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
15+
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
1516
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
1617
import { WorkbenchList } from '../../../../../platform/list/browser/listService.js';
1718
import { IChatTodoListService, IChatTodo } from '../../common/chatTodoListService.js';
19+
import { ChatContextKeys } from '../../common/chatContextKeys.js';
1820
import { TodoListToolDescriptionFieldSettingId } from '../../common/tools/manageTodoListTool.js';
1921

2022
class TodoListDelegate implements IListVirtualDelegate<IChatTodo> {
@@ -125,7 +127,7 @@ export class ChatTodoListWidget extends Disposable {
125127

126128
private _isExpanded: boolean = false;
127129
private _userManuallyExpanded: boolean = false;
128-
private expandoElement!: HTMLElement;
130+
private expandoButton!: Button;
129131
private todoListContainer!: HTMLElement;
130132
private clearButtonContainer!: HTMLElement;
131133
private clearButton!: Button;
@@ -135,11 +137,19 @@ export class ChatTodoListWidget extends Disposable {
135137
constructor(
136138
@IChatTodoListService private readonly chatTodoListService: IChatTodoListService,
137139
@IConfigurationService private readonly configurationService: IConfigurationService,
138-
@IInstantiationService private readonly instantiationService: IInstantiationService
140+
@IInstantiationService private readonly instantiationService: IInstantiationService,
141+
@IContextKeyService private readonly contextKeyService: IContextKeyService
139142
) {
140143
super();
141144

142145
this.domNode = this.createChatTodoWidget();
146+
147+
// Listen to context key changes to update clear button state when request state changes
148+
this._register(this.contextKeyService.onDidChangeContext(e => {
149+
if (e.affectsSome(new Set([ChatContextKeys.requestInProgress.key]))) {
150+
this.updateClearButtonState();
151+
}
152+
}));
143153
}
144154

145155
public get height(): number {
@@ -150,11 +160,12 @@ export class ChatTodoListWidget extends Disposable {
150160
const container = dom.$('.chat-todo-list-widget');
151161
container.style.display = 'none';
152162

153-
this.expandoElement = dom.$('.todo-list-expand');
154-
this.expandoElement.setAttribute('role', 'button');
155-
this.expandoElement.setAttribute('aria-expanded', 'true');
156-
this.expandoElement.setAttribute('tabindex', '0');
157-
this.expandoElement.setAttribute('aria-controls', 'todo-list-container');
163+
const expandoContainer = dom.$('.todo-list-expand');
164+
this.expandoButton = this._register(new Button(expandoContainer, {
165+
supportIcons: true
166+
}));
167+
this.expandoButton.element.setAttribute('aria-expanded', String(this._isExpanded));
168+
this.expandoButton.element.setAttribute('aria-controls', 'todo-list-container');
158169

159170
// Create title section to group icon and title
160171
const titleSection = dom.$('.todo-list-title-section');
@@ -174,37 +185,28 @@ export class ChatTodoListWidget extends Disposable {
174185
titleSection.appendChild(expandIcon);
175186
titleSection.appendChild(titleElement);
176187

177-
this.expandoElement.appendChild(titleSection);
178-
this.expandoElement.appendChild(this.clearButtonContainer);
188+
this.expandoButton.element.appendChild(titleSection);
189+
this.expandoButton.element.appendChild(this.clearButtonContainer);
179190

180191
this.todoListContainer = dom.$('.todo-list-container');
181192
this.todoListContainer.style.display = this._isExpanded ? 'block' : 'none';
182193
this.todoListContainer.id = 'todo-list-container';
183194
this.todoListContainer.setAttribute('role', 'list');
184195
this.todoListContainer.setAttribute('aria-labelledby', 'todo-list-title');
185196

186-
container.appendChild(this.expandoElement);
197+
container.appendChild(expandoContainer);
187198
container.appendChild(this.todoListContainer);
188199

189-
this._register(dom.addDisposableListener(this.expandoElement, 'click', () => {
200+
this._register(this.expandoButton.onDidClick(() => {
190201
this.toggleExpanded();
191202
}));
192203

193-
this._register(dom.addDisposableListener(this.expandoElement, 'keydown', (e) => {
194-
if (e.key === 'Enter' || e.key === ' ') {
195-
e.preventDefault();
196-
this.toggleExpanded();
197-
}
198-
}));
199-
200204
return container;
201205
}
202206

203207
private createClearButton(): void {
204208
this.clearButton = new Button(this.clearButtonContainer, {
205209
supportIcons: true,
206-
title: localize('chat.todoList.clearButton', 'Clear all todos'),
207-
ariaLabel: localize('chat.todoList.clearButton.ariaLabel', 'Clear all todos')
208210
});
209211
this.clearButton.element.tabIndex = 0;
210212
this.clearButton.icon = Codicon.clearAll;
@@ -262,7 +264,7 @@ export class ChatTodoListWidget extends Disposable {
262264
}
263265

264266
private renderTodoList(todoList: IChatTodo[]): void {
265-
const titleElement = this.expandoElement.querySelector('.todo-list-title') as HTMLElement;
267+
const titleElement = this.expandoButton.element.querySelector('.todo-list-title') as HTMLElement;
266268
if (titleElement) {
267269
this.updateTitleElement(titleElement, todoList);
268270
}
@@ -307,13 +309,16 @@ export class ChatTodoListWidget extends Disposable {
307309
const hasInProgressTask = todoList.some(todo => todo.status === 'in-progress');
308310
const hasCompletedTask = todoList.some(todo => todo.status === 'completed');
309311

312+
// Update clear button state based on request progress
313+
this.updateClearButtonState();
314+
310315
// Only auto-collapse if there are in-progress or completed tasks AND user hasn't manually expanded
311316
if ((hasInProgressTask || hasCompletedTask) && this._isExpanded && !this._userManuallyExpanded) {
312317
this._isExpanded = false;
313-
this.expandoElement.setAttribute('aria-expanded', 'false');
318+
this.expandoButton.element.setAttribute('aria-expanded', 'false');
314319
this.todoListContainer.style.display = 'none';
315320

316-
const expandIcon = this.expandoElement.querySelector('.expand-icon') as HTMLElement;
321+
const expandIcon = this.expandoButton.element.querySelector('.expand-icon') as HTMLElement;
317322
if (expandIcon) {
318323
expandIcon.classList.remove('codicon-chevron-down');
319324
expandIcon.classList.add('codicon-chevron-right');
@@ -328,7 +333,7 @@ export class ChatTodoListWidget extends Disposable {
328333
this._isExpanded = !this._isExpanded;
329334
this._userManuallyExpanded = true;
330335

331-
const expandIcon = this.expandoElement.querySelector('.expand-icon') as HTMLElement;
336+
const expandIcon = this.expandoButton.element.querySelector('.expand-icon') as HTMLElement;
332337
if (expandIcon) {
333338
expandIcon.classList.toggle('codicon-chevron-down', this._isExpanded);
334339
expandIcon.classList.toggle('codicon-chevron-right', !this._isExpanded);
@@ -338,7 +343,7 @@ export class ChatTodoListWidget extends Disposable {
338343

339344
if (this._currentSessionId) {
340345
const todoList = this.chatTodoListService.getTodos(this._currentSessionId);
341-
const titleElement = this.expandoElement.querySelector('.todo-list-title') as HTMLElement;
346+
const titleElement = this.expandoButton.element.querySelector('.todo-list-title') as HTMLElement;
342347
if (titleElement) {
343348
this.updateTitleElement(titleElement, todoList);
344349
}
@@ -357,6 +362,26 @@ export class ChatTodoListWidget extends Disposable {
357362
this._onDidChangeHeight.fire();
358363
}
359364

365+
private updateClearButtonState(): void {
366+
if (!this._currentSessionId) {
367+
return;
368+
}
369+
370+
const todoList = this.chatTodoListService.getTodos(this._currentSessionId);
371+
const hasInProgressTask = todoList.some(todo => todo.status === 'in-progress');
372+
const isRequestInProgress = ChatContextKeys.requestInProgress.getValue(this.contextKeyService) ?? false;
373+
const shouldDisable = isRequestInProgress && hasInProgressTask;
374+
375+
this.clearButton.enabled = !shouldDisable;
376+
377+
// Update tooltip based on state
378+
if (shouldDisable) {
379+
this.clearButton.setTitle(localize('chat.todoList.clearButton.disabled', 'Cannot clear todos while a task is in progress'));
380+
} else {
381+
this.clearButton.setTitle(localize('chat.todoList.clearButton', 'Clear all todos'));
382+
}
383+
}
384+
360385
private updateTitleElement(titleElement: HTMLElement, todoList: IChatTodo[]): void {
361386
titleElement.textContent = '';
362387

@@ -371,8 +396,8 @@ export class ChatTodoListWidget extends Disposable {
371396
const expandButtonLabel = this._isExpanded
372397
? localize('chat.todoList.collapseButton', 'Collapse Todos')
373398
: localize('chat.todoList.expandButton', 'Expand Todos');
374-
this.expandoElement.setAttribute('aria-label', expandButtonLabel);
375-
this.expandoElement.setAttribute('aria-expanded', this._isExpanded ? 'true' : 'false');
399+
this.expandoButton.element.setAttribute('aria-label', expandButtonLabel);
400+
this.expandoButton.element.setAttribute('aria-expanded', this._isExpanded ? 'true' : 'false');
376401

377402
if (this._isExpanded) {
378403
const titleText = dom.$('span');

src/vs/workbench/contrib/chat/browser/media/chat.css

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1106,15 +1106,24 @@ have to be updated for changes to the rules above, or to support more deeply nes
11061106
}
11071107

11081108
.interactive-session .interactive-input-part > .chat-todo-list-widget-container .chat-todo-list-widget .todo-list-expand {
1109+
width: 100%;
1110+
}
1111+
1112+
.interactive-session .interactive-input-part > .chat-todo-list-widget-container .chat-todo-list-widget .todo-list-expand .monaco-button {
11091113
display: flex;
11101114
align-items: center;
11111115
gap: 4px;
11121116
cursor: pointer;
11131117
justify-content: space-between;
11141118
width: 100%;
1119+
background-color: transparent;
1120+
border-color: transparent;
1121+
color: var(--vscode-foreground);
1122+
padding: 0;
1123+
min-width: unset;
11151124
}
11161125

1117-
.interactive-session .interactive-input-part > .chat-todo-list-widget-container .chat-todo-list-widget .todo-list-expand:focus:not(:focus-visible) {
1126+
.interactive-session .interactive-input-part > .chat-todo-list-widget-container .chat-todo-list-widget .todo-list-expand .monaco-button:focus:not(:focus-visible) {
11181127
outline: none;
11191128
}
11201129

src/vs/workbench/contrib/chat/test/browser/chatTodoListWidget.test.ts

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@ import { ChatTodoListWidget } from '../../browser/chatContentParts/chatTodoListW
1010
import { IChatTodo, IChatTodoListService } from '../../common/chatTodoListService.js';
1111
import { mainWindow } from '../../../../../base/browser/window.js';
1212
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
13+
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
1314
import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';
1415

1516
suite('ChatTodoListWidget Accessibility', () => {
1617
const store = ensureNoDisposablesAreLeakedInTestSuite();
1718

1819
let widget: ChatTodoListWidget;
19-
let mockTodoListService: IChatTodoListService;
20-
let mockConfigurationService: IConfigurationService;
2120

2221
const sampleTodos: IChatTodo[] = [
2322
{ id: 1, title: 'First task', status: 'not-started' },
@@ -27,22 +26,20 @@ suite('ChatTodoListWidget Accessibility', () => {
2726

2827
setup(() => {
2928
// Mock the todo list service
30-
mockTodoListService = {
29+
const mockTodoListService: IChatTodoListService = {
3130
_serviceBrand: undefined,
3231
onDidUpdateTodos: Event.None,
3332
getTodos: (sessionId: string) => sampleTodos,
3433
setTodos: (sessionId: string, todos: IChatTodo[]) => { }
3534
};
3635

3736
// Mock the configuration service
38-
// eslint-disable-next-line local/code-no-any-casts
39-
mockConfigurationService = {
40-
_serviceBrand: undefined,
41-
getValue: (key: string) => key === 'chat.todoListTool.descriptionField' ? true : undefined
42-
} as any;
37+
const mockConfigurationService = new TestConfigurationService({ 'chat.todoListTool.descriptionField': true });
4338

4439
const instantiationService = workbenchInstantiationService(undefined, store);
45-
widget = store.add(new ChatTodoListWidget(mockTodoListService, mockConfigurationService, instantiationService));
40+
instantiationService.stub(IChatTodoListService, mockTodoListService);
41+
instantiationService.stub(IConfigurationService, mockConfigurationService);
42+
widget = store.add(instantiationService.createInstance(ChatTodoListWidget));
4643
mainWindow.document.body.appendChild(widget.domNode);
4744
});
4845

@@ -106,16 +103,17 @@ suite('ChatTodoListWidget Accessibility', () => {
106103
test('expand button has proper accessibility attributes', () => {
107104
widget.render('test-session');
108105

109-
// The expandoElement has the accessibility attributes
110-
const expandoElement = widget.domNode.querySelector('.todo-list-expand');
111-
assert.ok(expandoElement, 'Should have expando element');
112-
assert.strictEqual(expandoElement?.getAttribute('role'), 'button');
113-
assert.strictEqual(expandoElement?.getAttribute('tabindex'), '0');
114-
assert.strictEqual(expandoElement?.getAttribute('aria-expanded'), 'false'); // Should be collapsed due to in-progress task
115-
assert.strictEqual(expandoElement?.getAttribute('aria-controls'), 'todo-list-container');
106+
// The expandoButton is now a Monaco Button, so we need to check its element
107+
const expandoContainer = widget.domNode.querySelector('.todo-list-expand');
108+
assert.ok(expandoContainer, 'Should have expando container');
109+
110+
const expandoButton = expandoContainer?.querySelector('.monaco-button');
111+
assert.ok(expandoButton, 'Should have Monaco button');
112+
assert.strictEqual(expandoButton?.getAttribute('aria-expanded'), 'false'); // Should be collapsed due to in-progress task
113+
assert.strictEqual(expandoButton?.getAttribute('aria-controls'), 'todo-list-container');
116114

117115
// The title element should have progress information
118-
const titleElement = expandoElement?.querySelector('.todo-list-title');
116+
const titleElement = expandoButton?.querySelector('.todo-list-title');
119117
assert.ok(titleElement, 'Should have title element');
120118
const titleText = titleElement?.textContent;
121119
// When collapsed, title shows progress and current task: " (2/3) - Second task"
@@ -159,14 +157,12 @@ suite('ChatTodoListWidget Accessibility', () => {
159157
setTodos: (sessionId: string, todos: IChatTodo[]) => { }
160158
};
161159

162-
// eslint-disable-next-line local/code-no-any-casts
163-
const emptyConfigurationService: IConfigurationService = {
164-
_serviceBrand: undefined,
165-
getValue: (key: string) => key === 'chat.todoListTool.descriptionField' ? true : undefined
166-
} as any;
160+
const emptyConfigurationService = new TestConfigurationService({ 'chat.todoListTool.descriptionField': true });
167161

168162
const instantiationService = workbenchInstantiationService(undefined, store);
169-
const emptyWidget = store.add(new ChatTodoListWidget(emptyTodoListService, emptyConfigurationService, instantiationService));
163+
instantiationService.stub(IChatTodoListService, emptyTodoListService);
164+
instantiationService.stub(IConfigurationService, emptyConfigurationService);
165+
const emptyWidget = store.add(instantiationService.createInstance(ChatTodoListWidget));
170166
mainWindow.document.body.appendChild(emptyWidget.domNode);
171167

172168
emptyWidget.render('test-session');

0 commit comments

Comments
 (0)