Agents mobile: phone-aware dialog handler (#323741)

* Agents mobile (prototype): phone-aware dialog handler

Render IDialogService confirm/prompt as native bottom sheets on phone
layout, covering every modal in the Agents window in one place instead of
converting call sites individually.

The Agents window now registers a MobileDialogHandlerContribution (in place
of the standard web dialog handler) that drains the shared dialog model
through a MobileAwareDialogHandler. That handler delegates to the standard
BrowserDialogHandler off-phone and for text input / about, and on phone maps
the dialog model (message + detail + ordered buttons + optional checkbox)
onto a bottom sheet via the existing showMobileContentSheet primitive:
full-width stacked buttons at 44px, the safe action styled secondary, focus
moved into the sheet and restored on close.

Because confirmations from the sessions layer AND inherited chat / workbench
dialogs all flow through one handler, this covers them uniformly (e.g. delete
session, delete chat, discard changes, sign out) without per-call-site work.

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

* Agents mobile: drop the bespoke archive confirm sheet

The phone-aware dialog handler now renders every IDialogService.confirm as
a bottom sheet on phone, so the per-call-site mobileArchiveConfirmSheet is
redundant. Collapse ArchiveSectionAction back to a single dialogService.confirm
(matching the group "Mark All as Done" action) and delete the bespoke helper.

This also brings the section archive-all in line with the group archive-all:
both now flow through the handler and get the same sheet on phone, including
the "Do not ask me again" checkbox (safe to surface now that it renders in a
proper bottom sheet rather than the previously cramped desktop dialog).

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

* Address review: live checkbox state + dialog aria-describedby

- Track the chosen button and checkbox state across the whole interaction so
  a backdrop / Escape dismiss (or a checkbox toggle) returns the live values
  instead of a snapshot captured only on button click.
- Wire the message / detail to the sheet's role="dialog" element via
  aria-describedby so screen readers announce them when the sheet opens.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Osvaldo Ortega
2026-07-01 10:45:16 -07:00
committed by GitHub
parent bc1d4637cb
commit 38f7aedc6b
7 changed files with 353 additions and 118 deletions

View File

@@ -0,0 +1,100 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import Severity from '../../../../base/common/severity.js';
import { mnemonicButtonLabel } from '../../../../base/common/labels.js';
import { localize } from '../../../../nls.js';
import { AbstractDialogHandler, DialogType, IConfirmation, IConfirmationResult, IInput, IInputResult, IPrompt, IAsyncPromptResult } from '../../../../platform/dialogs/common/dialogs.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { BrowserDialogHandler } from '../../../../workbench/browser/parts/dialogs/dialogHandler.js';
import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js';
import { isPhoneLayout } from '../mobile/mobileLayout.js';
import { IMobileDialogSheetButton, showMobileDialogSheet } from './mobileDialogSheet.js';
/**
* Dialog handler for the Agents window that renders confirmations and prompts
* as native bottom sheets on phone layout, and otherwise delegates to the
* standard {@link BrowserDialogHandler}.
*
* Because every `IDialogService.confirm` / `prompt` call (sessions-layer or
* inherited from chat / workbench) flows through a single handler, routing it
* here gives every modal a phone-appropriate presentation without converting
* call sites one by one. Text input and the about dialog keep the desktop
* rendering for now.
*/
export class MobileAwareDialogHandler extends AbstractDialogHandler {
private readonly _desktop: BrowserDialogHandler;
constructor(
@IInstantiationService instantiationService: IInstantiationService,
@IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService,
) {
super();
this._desktop = instantiationService.createInstance(BrowserDialogHandler);
}
async confirm(confirmation: IConfirmation): Promise<IConfirmationResult> {
if (!isPhoneLayout(this._layoutService)) {
return this._desktop.confirm(confirmation);
}
const labels = this.getConfirmationButtons(confirmation); // [primary, cancel]
const cancelIndex = labels.length - 1;
const { button, checkboxChecked } = await showMobileDialogSheet(this._layoutService, {
title: this._titleFor(confirmation.type),
message: confirmation.message,
detail: confirmation.detail,
buttons: this._toSheetButtons(labels, cancelIndex),
defaultButtonIndex: cancelIndex,
checkbox: confirmation.checkbox,
});
return { confirmed: button === 0, checkboxChecked };
}
async prompt<T>(prompt: IPrompt<T>): Promise<IAsyncPromptResult<T>> {
if (!isPhoneLayout(this._layoutService)) {
return this._desktop.prompt(prompt);
}
const labels = this.getPromptButtons(prompt);
const cancelIndex = prompt.cancelButton ? labels.length - 1 : -1;
const { button, checkboxChecked } = await showMobileDialogSheet(this._layoutService, {
title: this._titleFor(prompt.type),
message: prompt.message,
detail: prompt.detail,
buttons: this._toSheetButtons(labels, cancelIndex),
defaultButtonIndex: cancelIndex >= 0 ? cancelIndex : 0,
checkbox: prompt.checkbox,
});
return this.getPromptResult(prompt, button, checkboxChecked);
}
// Text input and the about dialog keep the desktop rendering for now.
input(input: IInput): Promise<IInputResult> {
return this._desktop.input(input);
}
about(title: string, details: string, detailsToCopy: string): Promise<void> {
return this._desktop.about(title, details, detailsToCopy);
}
private _toSheetButtons(labels: string[], cancelIndex: number): IMobileDialogSheetButton[] {
return labels.map((label, index) => ({
label: mnemonicButtonLabel(label, true),
isCancel: index === cancelIndex,
}));
}
private _titleFor(type: Severity | DialogType | undefined): string {
switch (this.getDialogType(type)) {
case 'error': return localize('mobileDialog.error', "Error");
case 'warning': return localize('mobileDialog.warning', "Warning");
default: return localize('mobileDialog.confirm', "Confirm");
}
}
}

View File

@@ -0,0 +1,86 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../base/common/lifecycle.js';
import { Lazy } from '../../../../base/common/lazy.js';
import { IDialogHandler, IDialogResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../../workbench/common/contributions.js';
import { IDialogsModel, IDialogViewItem } from '../../../../workbench/common/dialogs.js';
import { createBrowserAboutDialogDetails } from '../../../../workbench/browser/parts/dialogs/dialog.js';
import { DialogService } from '../../../../workbench/services/dialogs/common/dialogService.js';
import { MobileAwareDialogHandler } from './mobileAwareDialogHandler.js';
/**
* Agents-window variant of the workbench `DialogHandlerContribution` that
* drains the shared {@link IDialogsModel} through a {@link MobileAwareDialogHandler},
* so every confirmation / prompt renders as a bottom sheet on phone layout.
*
* Registered in place of the standard web dialog-handler contribution (which
* the Agents window does not import), so only one handler drains the model.
*/
export class MobileDialogHandlerContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.mobileDialogHandler';
private readonly model: IDialogsModel;
private readonly impl: Lazy<IDialogHandler>;
private currentDialog: IDialogViewItem | undefined;
constructor(
@IDialogService private dialogService: IDialogService,
@IInstantiationService instantiationService: IInstantiationService,
@IProductService private productService: IProductService,
) {
super();
this.impl = new Lazy(() => instantiationService.createInstance(MobileAwareDialogHandler));
this.model = (this.dialogService as DialogService).model;
this._register(this.model.onWillShowDialog(() => {
if (!this.currentDialog) {
this.processDialogs();
}
}));
this.processDialogs();
}
private async processDialogs(): Promise<void> {
while (this.model.dialogs.length) {
this.currentDialog = this.model.dialogs[0];
let result: IDialogResult | Error | undefined = undefined;
try {
if (this.currentDialog.args.confirmArgs) {
const args = this.currentDialog.args.confirmArgs;
result = await this.impl.value.confirm(args.confirmation);
} else if (this.currentDialog.args.inputArgs) {
const args = this.currentDialog.args.inputArgs;
result = await this.impl.value.input(args.input);
} else if (this.currentDialog.args.promptArgs) {
const args = this.currentDialog.args.promptArgs;
result = await this.impl.value.prompt(args.prompt);
} else {
const aboutDialogDetails = createBrowserAboutDialogDetails(this.productService);
await this.impl.value.about(aboutDialogDetails.title, aboutDialogDetails.details, aboutDialogDetails.detailsToCopy);
}
} catch (error) {
result = error;
}
this.currentDialog.close(result);
this.currentDialog = undefined;
}
}
}
registerWorkbenchContribution2(
MobileDialogHandlerContribution.ID,
MobileDialogHandlerContribution,
WorkbenchPhase.BlockStartup // Block to allow for dialogs to show before restore finished
);

View File

@@ -0,0 +1,133 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { $, addDisposableListener, append, getActiveElement, isHTMLElement } from '../../../../base/browser/dom.js';
import { Button } from '../../../../base/browser/ui/button/button.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js';
import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js';
import { showMobileContentSheet } from '../mobile/mobilePickerSheet.js';
/** Monotonic seed for unique aria id wiring across concurrent sheets. */
let idSeed = 0;
/** A single action button rendered in the dialog sheet. */
export interface IMobileDialogSheetButton {
/** Display label (already stripped of mnemonics). */
readonly label: string;
/** When true the button is styled as the secondary / safe action. */
readonly isCancel: boolean;
}
export interface IMobileDialogSheetOptions {
/** Sheet header title (single line). Usually a short category label. */
readonly title: string;
/** Primary message describing the action (wraps). */
readonly message: string;
/** Optional muted detail line beneath the message (wraps). */
readonly detail?: string;
/** Action buttons, in the dialog's canonical order (index is returned). */
readonly buttons: readonly IMobileDialogSheetButton[];
/** Index of the button to focus on open (typically the safe action). */
readonly defaultButtonIndex: number;
/** Optional checkbox row (e.g. "Do not ask me again"). */
readonly checkbox?: { readonly label: string; readonly checked?: boolean };
}
export interface IMobileDialogSheetResult {
/** Index of the chosen button, or the cancel index when dismissed. */
readonly button: number;
readonly checkboxChecked?: boolean;
}
/**
* Render a generic confirmation / prompt as a native mobile bottom sheet.
*
* Maps the workbench dialog model (message + detail + ordered buttons +
* optional checkbox) onto {@link showMobileContentSheet}: full-width stacked
* buttons with 44px tap targets, the safe action styled secondary. Tapping a
* button resolves with its index; dismissing the sheet (backdrop / Escape)
* resolves with the cancel index (or -1 when the dialog has no cancel).
*
* Focus is moved into the sheet on open (defaulting to the safe action) and
* restored to the previously focused element on close, mirroring the desktop
* dialog's behavior.
*/
export async function showMobileDialogSheet(layoutService: IWorkbenchLayoutService, options: IMobileDialogSheetOptions): Promise<IMobileDialogSheetResult> {
const cancelIndex = options.buttons.findIndex(button => button.isCancel);
// Tracked across the whole interaction so both the dismiss path
// (backdrop / Escape) and a button tap return the live values, not a
// stale snapshot taken at click time.
let chosenButton = cancelIndex;
let checkboxChecked = options.checkbox?.checked;
const previouslyFocused = getActiveElement();
await showMobileContentSheet(
layoutService.mainContainer,
options.title,
(body, api) => {
const store = new DisposableStore();
const id = `mobile-dialog-${++idSeed}`;
const describedBy: string[] = [];
const message = append(body, $('.mobile-content-sheet-message'));
message.id = `${id}-message`;
message.textContent = options.message;
describedBy.push(message.id);
if (options.detail) {
const detail = append(body, $('.mobile-content-sheet-detail'));
detail.id = `${id}-detail`;
detail.textContent = options.detail;
describedBy.push(detail.id);
}
// Associate the message / detail with the sheet's dialog element so
// screen readers announce them when the sheet opens (the shell only
// sets an aria-label from the title).
body.closest('[role="dialog"]')?.setAttribute('aria-describedby', describedBy.join(' '));
if (options.checkbox) {
const row = append(body, $('label.mobile-content-sheet-checkbox'));
const checkbox = append(row, $('input')) as HTMLInputElement;
checkbox.type = 'checkbox';
checkbox.checked = !!options.checkbox.checked;
append(row, $('span')).textContent = options.checkbox.label;
store.add(addDisposableListener(checkbox, 'change', () => { checkboxChecked = checkbox.checked; }));
}
const actions = append(body, $('.mobile-content-sheet-actions'));
let buttonToFocus: Button | undefined;
options.buttons.forEach((descriptor, index) => {
const button = store.add(new Button(actions, { ...defaultButtonStyles, secondary: descriptor.isCancel }));
button.label = descriptor.label;
store.add(button.onDidClick(() => {
chosenButton = index;
api.close();
}));
if (index === options.defaultButtonIndex) {
buttonToFocus = button;
}
});
// Move focus into the modal sheet for keyboard / screen reader users.
buttonToFocus?.focus();
return store;
},
{ hideDoneButton: true },
);
if (isHTMLElement(previouslyFocused) && previouslyFocused.isConnected) {
previouslyFocused.focus();
}
return { button: chosenButton, checkboxChecked };
}

View File

@@ -284,6 +284,25 @@
padding: 0 12px;
}
/* Optional checkbox row (e.g. "Do not ask me again") for confirm-style
* content sheets. Comfortable tap target with the control and label inline. */
.mobile-content-sheet-checkbox {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px 0;
min-height: 44px;
font-size: 13px;
cursor: pointer;
}
.mobile-content-sheet-checkbox input {
width: 20px;
height: 20px;
margin: 0;
flex-shrink: 0;
}
/* iOS grouped-list section header. The HIG uses a regular-case
* footnote-sized label in secondary color — not the uppercased small
* caps that older Settings rows used. */

View File

@@ -1,89 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { $, append, getActiveElement, isHTMLElement } from '../../../../../base/browser/dom.js';
import { Button } from '../../../../../base/browser/ui/button/button.js';
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
import { localize } from '../../../../../nls.js';
import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js';
import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js';
import { showMobileContentSheet } from '../../../../browser/parts/mobile/mobilePickerSheet.js';
export interface IMobileArchiveConfirmOptions {
/** Sheet header title (single line). */
readonly title: string;
/** Bold message describing the scope of the action (wraps). */
readonly message: string;
/** Muted detail line beneath the message (wraps). */
readonly detail: string;
/** Label for the primary, destructive action button. */
readonly primaryLabel: string;
}
/**
* Phone-only confirmation for a destructive "Mark All as Done" bulk action.
* Presents a native bottom sheet (matching the other Agents-window mobile
* surfaces) with an explicit, full-width destructive action and Cancel,
* instead of squeezing the desktop modal dialog onto a phone.
*
* Resolves `true` only when the user taps the destructive action; any other
* dismissal (Cancel, backdrop tap, Escape) resolves `false`.
*
* Unlike the desktop dialog, the sheet intentionally omits the "Do not ask
* me again" opt-out: the persisted skip flag is shared across layouts, and
* letting a mis-tap permanently disable confirmation for a destructive bulk
* action is exactly the footgun this mobile treatment avoids.
*/
export async function confirmArchiveOnPhone(layoutService: IWorkbenchLayoutService, options: IMobileArchiveConfirmOptions): Promise<boolean> {
let confirmed = false;
// Remember what had focus so we can restore it when the sheet closes,
// mirroring `IDialogService.confirm`'s focus handling.
const previouslyFocused = getActiveElement();
await showMobileContentSheet(
layoutService.mainContainer,
options.title,
(body, api) => {
const store = new DisposableStore();
const message = append(body, $('.mobile-content-sheet-message'));
message.textContent = options.message;
const detail = append(body, $('.mobile-content-sheet-detail'));
detail.textContent = options.detail;
const actions = append(body, $('.mobile-content-sheet-actions'));
const archiveButton = store.add(new Button(actions, { ...defaultButtonStyles }));
archiveButton.label = options.primaryLabel;
store.add(archiveButton.onDidClick(() => {
confirmed = true;
api.close();
}));
const cancelButton = store.add(new Button(actions, { ...defaultButtonStyles, secondary: true }));
cancelButton.label = localize('mobileArchiveConfirm.cancel', "Cancel");
store.add(cancelButton.onDidClick(() => {
confirmed = false;
api.close();
}));
// Move focus into the modal sheet for keyboard / screen reader
// users, defaulting to the safe action so an accidental Enter
// cancels rather than performing the destructive action.
cancelButton.focus();
return store;
},
{ hideDoneButton: true },
);
if (isHTMLElement(previouslyFocused) && previouslyFocused.isConnected) {
previouslyFocused.focus();
}
return confirmed;
}

View File

@@ -33,9 +33,6 @@ import { ActiveSessionContextKeys } from '../../../changes/common/changes.js';
import { hasActiveSessionFailedCIChecks } from '../../../changes/browser/checksActions.js';
import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js';
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js';
import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js';
import { confirmArchiveOnPhone } from '../mobile/mobileArchiveConfirmSheet.js';
const CLOSE_SESSION_COMMAND_ID = 'sessionsViewPane.closeSession';
registerAction2(class CloseSessionAction extends Action2 {
@@ -515,37 +512,24 @@ registerAction2(class ArchiveSectionAction extends Action2 {
const sessionsManagementService = accessor.get(ISessionsManagementService);
const dialogService = accessor.get(IDialogService);
const storageService = accessor.get(IStorageService);
const layoutService = accessor.get(IWorkbenchLayoutService);
const skipConfirmation = storageService.getBoolean(ConfirmArchiveStorageKey, StorageScope.PROFILE, false);
if (!skipConfirmation) {
if (isPhoneLayout(layoutService)) {
const confirmed = await confirmArchiveOnPhone(layoutService, {
title: localize('archiveSectionSheet.title', "Mark All as Done"),
message: getArchiveSectionConfirmationMessage(context),
detail: localize('archiveSectionSessions.detail', "You can restore sessions later if needed from the sessions view."),
primaryLabel: localize('archiveSectionSessions.archive', "Mark All as Done"),
});
if (!confirmed) {
return;
const confirmed = await dialogService.confirm({
message: getArchiveSectionConfirmationMessage(context),
detail: localize('archiveSectionSessions.detail', "You can restore sessions later if needed from the sessions view."),
primaryButton: localize('archiveSectionSessions.archive', "Mark All as Done"),
checkbox: {
label: localize('doNotAskAgain', "Do not ask me again")
}
} else {
const confirmed = await dialogService.confirm({
message: getArchiveSectionConfirmationMessage(context),
detail: localize('archiveSectionSessions.detail', "You can restore sessions later if needed from the sessions view."),
primaryButton: localize('archiveSectionSessions.archive', "Mark All as Done"),
checkbox: {
label: localize('doNotAskAgain', "Do not ask me again")
}
});
});
if (!confirmed.confirmed) {
return;
}
if (!confirmed.confirmed) {
return;
}
if (confirmed.checkboxChecked) {
storageService.store(ConfirmArchiveStorageKey, true, StorageScope.PROFILE, StorageTarget.USER);
}
if (confirmed.checkboxChecked) {
storageService.store(ConfirmArchiveStorageKey, true, StorageScope.PROFILE, StorageTarget.USER);
}
}

View File

@@ -19,7 +19,9 @@ import './sessions.common.main.js';
//#region --- workbench parts
import '../workbench/browser/parts/dialogs/dialog.web.contribution.js';
// Agents window uses a phone-aware dialog handler (bottom sheets on phone)
// in place of the standard web dialog handler.
import './browser/parts/dialogs/mobileDialog.web.contribution.js';
//#endregion