Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'age
content.push(localize('chatAgent.autoApprove', 'To automatically approve tool actions without manual confirmation, set {0} to {1} in your settings.', ChatConfiguration.GlobalAutoApprove, 'true'));
content.push(localize('chatAgent.acceptTool', 'To accept a tool action, use the Accept Tool Confirmation command{0}.', '<keybinding:workbench.action.chat.acceptTool>'));
content.push(localize('chatAgent.openEditedFilesSetting', 'By default, when edits are made to files, they will be opened. To change this behavior, set accessibility.openChatEditedFiles to false in your settings.'));
content.push(localize('chatAgent.focusTodosView', 'To toggle focus between the Agent TODOs view and the chat input, use Agent TODOs: Toggle Focus{0}.', '<keybinding:workbench.action.chat.focusTodosView>'));
}
content.push(localize('chatEditing.helpfulCommands', 'Some helpful commands include:'));
content.push(localize('workbench.action.chat.undoEdits', '- Undo Edits{0}.', '<keybinding:workbench.action.chat.undoEdits>'));
Expand Down
28 changes: 28 additions & 0 deletions src/vs/workbench/contrib/chat/browser/actions/chatActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { isAncestorOfActiveElement } from '../../../../../base/browser/dom.js';
import { alert } from '../../../../../base/browser/ui/aria/aria.js';
import { mainWindow } from '../../../../../base/browser/window.js';
import { toAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js';
import { coalesce } from '../../../../../base/common/arrays.js';
Expand Down Expand Up @@ -674,6 +675,33 @@ export function registerChatActions() {
}
});

registerAction2(class FocusTodosViewAction extends Action2 {
static readonly ID = 'workbench.action.chat.focusTodosView';

constructor() {
super({
id: FocusTodosViewAction.ID,
title: localize2('interactiveSession.focusTodosView.label', "Agent TODOs: Toggle Focus Between TODOs and Input"),
category: CHAT_CATEGORY,
f1: true,
precondition: ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent)),
keybinding: [{
weight: KeybindingWeight.WorkbenchContrib,
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyT,
}]
});
}

run(accessor: ServicesAccessor): void {
const widgetService = accessor.get(IChatWidgetService);
const widget = widgetService.lastFocusedWidget;

if (!widget || !widget.toggleTodosViewFocus()) {
alert(localize('chat.todoList.focusUnavailable', "No agent todos to focus right now."));
}
}
});

const nonEnterpriseCopilotUsers = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.notEquals(`config.${defaultChat.completionsAdvancedSetting}.authProvider`, defaultChat.provider.enterprise.id));
registerAction2(class extends Action2 {
constructor() {
Expand Down
10 changes: 10 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,16 @@ export interface IChatWidget {
*/
focusResponseItem(lastFocused?: boolean): void;
focusInput(): void;
/**
* Focuses the Todos view in the chat widget.
* @returns Whether the operation succeeded (i.e., the Todos view was focused).
*/
focusTodosView(): boolean;
/**
* Toggles focus between the Todos view and the previous focus target in the chat widget.
* @returns Whether the operation succeeded (i.e., the focus was toggled).
*/
toggleTodosViewFocus(): boolean;
hasInputFocus(): boolean;
getModeRequestOptions(): Partial<IChatSendRequestOptions>;
getCodeBlockInfoForEditor(uri: URI): IChatCodeBlockInfo | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,27 @@ export class ChatTodoListWidget extends Disposable {
}
}

public hasTodos(): boolean {
return this.domNode.classList.contains('has-todos') && !!this._todoList && this._todoList.length > 0;
}

public hasFocus(): boolean {
return dom.isAncestorOfActiveElement(this.todoListContainer);
}

public focus(): boolean {
if (!this.hasTodos()) {
return false;
}

if (!this._isExpanded) {
this.toggleExpanded();
}

this._todoList?.domFocus();
return this.hasFocus();
}

private updateTodoDisplay(): void {
if (!this._currentSessionResource) {
return;
Expand Down
12 changes: 12 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chatInputPart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
return this._inputEditor.hasWidgetFocus();
}

focusTodoList(): boolean {
return this._chatInputTodoListWidget.value?.focus() ?? false;
}

isTodoListFocused(): boolean {
return this._chatInputTodoListWidget.value?.hasFocus() ?? false;
}

hasVisibleTodos(): boolean {
return this._chatInputTodoListWidget.value?.hasTodos() ?? false;
}

/**
* Reset the input and update history.
* @param userQuery If provided, this will be added to the history. Followups and programmatic queries should not be passed.
Expand Down
21 changes: 21 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,27 @@ export class ChatWidget extends Disposable implements IChatWidget {
this._onDidFocus.fire();
}

focusTodosView(): boolean {
if (!this.input.hasVisibleTodos()) {
return false;
}

return this.input.focusTodoList();
}

toggleTodosViewFocus(): boolean {
if (!this.input.hasVisibleTodos()) {
return false;
}

if (this.input.isTodoListFocused()) {
this.focusInput();
return true;
}

return this.input.focusTodoList();
}

hasInputFocus(): boolean {
return this.input.hasFocus();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes
import { ChatTodoListWidget } from '../../browser/chatContentParts/chatTodoListWidget.js';
import { IChatTodo, IChatTodoListService } from '../../common/chatTodoListService.js';
import { mainWindow } from '../../../../../base/browser/window.js';
import { isAncestorOfActiveElement } from '../../../../../base/browser/dom.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';
Expand Down Expand Up @@ -201,4 +202,44 @@ suite('ChatTodoListWidget Accessibility', () => {
const todoListContainer = widget.domNode.querySelector('.todo-list-container');
assert.strictEqual(todoListContainer?.getAttribute('aria-labelledby'), 'todo-list-title');
});

test('focus expands and places focus on the todo list', () => {
widget.render(testSessionUri);

const expandoButton = widget.domNode.querySelector('.todo-list-expand .monaco-button');
assert.strictEqual(expandoButton?.getAttribute('aria-expanded'), 'false', 'Todo list should start collapsed');

const focused = widget.focus();
assert.strictEqual(focused, true, 'Focus should succeed when todos are present');
assert.strictEqual(expandoButton?.getAttribute('aria-expanded'), 'true', 'Focus should expand the todo list');

const todoListContainer = widget.domNode.querySelector('.todo-list-container') as HTMLElement;
assert.ok(todoListContainer, 'Todo list container should exist');
assert.ok(isAncestorOfActiveElement(todoListContainer), 'Todo list container should contain the active element after focusing');
});

test('hasTodos reports visibility state', () => {
widget.render(testSessionUri);
assert.strictEqual(widget.hasTodos(), true, 'Widget should report todos are present');

const emptyTodoListService: IChatTodoListService = {
_serviceBrand: undefined,
onDidUpdateTodos: Event.None,
getTodos: () => [],
setTodos: () => { }
};
const emptyConfigurationService = new TestConfigurationService({ 'chat.todoListTool.descriptionField': true });
const instantiationService = workbenchInstantiationService(undefined, store);
instantiationService.stub(IChatTodoListService, emptyTodoListService);
instantiationService.stub(IConfigurationService, emptyConfigurationService);
const emptyWidget = store.add(instantiationService.createInstance(ChatTodoListWidget));
mainWindow.document.body.appendChild(emptyWidget.domNode);

emptyWidget.render(testSessionUri);
assert.strictEqual(emptyWidget.hasTodos(), false, 'Widget should report no todos when the list is empty');

if (emptyWidget.domNode.parentNode) {
emptyWidget.domNode.parentNode.removeChild(emptyWidget.domNode);
}
});
});
Loading