chrome-devtools-frontend
Version:
Chrome DevTools UI
898 lines (832 loc) • 34.7 kB
text/typescript
// Copyright 2025 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable rulesdir/no-lit-render-outside-of-view */
import '../../ui/legacy/legacy.js';
import '../../ui/components/markdown_view/markdown_view.js';
import '../../ui/components/spinners/spinners.js';
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as Root from '../../core/root/root.js';
import * as AiAssistanceModel from '../../models/ai_assistance/ai_assistance.js';
import * as Persistence from '../../models/persistence/persistence.js';
import * as Workspace from '../../models/workspace/workspace.js';
import * as WorkspaceDiff from '../../models/workspace_diff/workspace_diff.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as UI from '../../ui/legacy/legacy.js';
import {Directives, html, type LitTemplate, nothing, render} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import * as ChangesPanel from '../changes/changes.js';
import * as PanelCommon from '../common/common.js';
import {SelectWorkspaceDialog} from './SelectWorkspaceDialog.js';
/*
* Strings that don't need to be translated at this time.
*/
const UIStringsNotTranslate = {
/**
*@description Text displayed for showing patch widget view.
*/
unsavedChanges: 'Unsaved changes',
/**
*@description Loading text displayed as a summary title when the patch suggestion is getting loaded
*/
applyingToWorkspace: 'Applying to workspace…',
/**
*@description Button text for staging changes to workspace.
*/
applyToWorkspace: 'Apply to workspace',
/**
*@description Button text to change the selected workspace
*/
change: 'Change',
/**
* @description Accessible title of the Change button to indicate that
* the button can be used to change the root folder.
*/
changeRootFolder: 'Change project root folder',
/**
*@description Button text to cancel applying to workspace
*/
cancel: 'Cancel',
/**
*@description Button text to discard the suggested changes and not save them to file system
*/
discard: 'Discard',
/**
*@description Button text to save all the suggested changes to file system
*/
saveAll: 'Save all',
/**
*@description Header text after the user saved the changes to the disk.
*/
savedToDisk: 'Saved to disk',
/**
*@description Disclaimer text shown for using code snippets with caution
*/
codeDisclaimer: 'Use code snippets with caution',
/**
*@description Tooltip text for the info icon beside the "Apply to workspace" button
*/
applyToWorkspaceTooltip: 'Source code from the selected folder is sent to Google to generate code suggestions.',
/**
*@description Tooltip text for the info icon beside the "Apply to workspace" button when enterprise logging is off
*/
applyToWorkspaceTooltipNoLogging:
'Source code from the selected folder is sent to Google to generate code suggestions. This data will not be used to improve Google’s AI models.',
/**
*@description The footer disclaimer that links to more information
* about the AI feature. Same text as in ChatView.
*/
learnMore: 'Learn about AI in DevTools',
/**
*@description Header text for the AI-powered code suggestions disclaimer dialog.
*/
freDisclaimerHeader: 'Get AI-powered code suggestions for your workspace',
/**
*@description First disclaimer item text for the fre dialog.
*/
freDisclaimerTextAiWontAlwaysGetItRight: 'This feature uses AI and won’t always get it right',
/**
*@description Second disclaimer item text for the fre dialog.
*/
freDisclaimerTextPrivacy: 'Source code from the selected folder is sent to Google to generate code suggestions',
/**
*@description Second disclaimer item text for the fre dialog when enterprise logging is off.
*/
freDisclaimerTextPrivacyNoLogging:
'Source code from the selected folder is sent to Google to generate code suggestions. This data will not be used to improve Google’s AI models.',
/**
*@description Third disclaimer item text for the fre dialog.
*/
freDisclaimerTextUseWithCaution: 'Use generated code snippets with caution',
/**
* @description Title of the link opening data that was used to
* produce a code suggestion.
*/
viewUploadedFiles: 'View data sent to Google',
/**
* @description Text indicating that a link opens in a new tab (for a11y).
*/
opensInNewTab: '(opens in a new tab)',
/**
* @description Generic error text for the case the changes were not applied to the workspace.
*/
genericErrorMessage: 'Changes couldn’t be applied to your workspace.',
} as const;
const lockedString = i18n.i18n.lockedString;
const CODE_SNIPPET_WARNING_URL = 'https://support.google.com/legal/answer/13505487';
export enum PatchSuggestionState {
/**
* The user did not attempt patching yet
*/
INITIAL = 'initial',
/**
* Applying to page tree is in progress
*/
LOADING = 'loading',
/**
* Applying to page tree succeeded
*/
SUCCESS = 'success',
/**
* Applying to page tree failed
*/
ERROR = 'error',
}
enum SelectedProjectType {
/**
* No project selected
*/
NONE = 'none',
/**
* The selected project is not an automatic workspace project
*/
REGULAR = 'regular',
/**
* The selected project is a disconnected automatic workspace project
*/
AUTOMATIC_DISCONNECTED = 'automaticDisconncted',
/**
* The selected project is a connected automatic workspace project
*/
AUTOMATIC_CONNECTED = 'automaticConnected',
}
export interface ViewInput {
workspaceDiff: WorkspaceDiff.WorkspaceDiff.WorkspaceDiffImpl;
patchSuggestionState: PatchSuggestionState;
changeSummary?: string;
sources?: string;
projectName: string;
projectPath: Platform.DevToolsPath.RawPathString;
projectType: SelectedProjectType;
savedToDisk?: boolean;
applyToWorkspaceTooltipText: Platform.UIString.LocalizedString;
onLearnMoreTooltipClick: () => void;
onApplyToWorkspace: () => void;
onCancel: () => void;
onDiscard: () => void;
onSaveAll: () => void;
onChangeWorkspaceClick?: () => void;
}
export interface ViewOutput {
tooltipRef?: Directives.Ref<HTMLElement>;
changeRef?: Directives.Ref<HTMLElement>;
summaryRef?: Directives.Ref<HTMLElement>;
}
type View = (input: ViewInput, output: ViewOutput, target: HTMLElement) => void;
export class PatchWidget extends UI.Widget.Widget {
changeSummary = '';
changeManager: AiAssistanceModel.ChangeManager|undefined;
// Whether the user completed first run experience dialog or not.
#aiPatchingFreCompletedSetting =
Common.Settings.Settings.instance().createSetting('ai-assistance-patching-fre-completed', false);
#projectIdSetting =
Common.Settings.Settings.instance().createSetting('ai-assistance-patching-selected-project-id', '');
#view: View;
#viewOutput: ViewOutput = {};
#aidaClient: Host.AidaClient.AidaClient;
#applyPatchAbortController?: AbortController;
#project?: Workspace.Workspace.Project;
#patchSources?: string;
#savedToDisk?: boolean;
#noLogging: boolean; // Whether the enterprise setting is `ALLOW_WITHOUT_LOGGING` or not.
#patchSuggestionState = PatchSuggestionState.INITIAL;
#workspaceDiff = WorkspaceDiff.WorkspaceDiff.workspaceDiff();
#workspace = Workspace.Workspace.WorkspaceImpl.instance();
#automaticFileSystem =
Persistence.AutomaticFileSystemManager.AutomaticFileSystemManager.instance().automaticFileSystem;
#applyToDisconnectedAutomaticWorkspace = false;
#popoverHelper: UI.PopoverHelper.PopoverHelper|null = null;
// `rpcId` from the `applyPatch` request
#rpcId: Host.AidaClient.RpcGlobalId|null = null;
constructor(element?: HTMLElement, view?: View, opts?: {
aidaClient: Host.AidaClient.AidaClient,
}) {
super(false, false, element);
this.#aidaClient = opts?.aidaClient ?? new Host.AidaClient.AidaClient();
this.#noLogging = Root.Runtime.hostConfig.aidaAvailability?.enterprisePolicyValue ===
Root.Runtime.GenAiEnterprisePolicyValue.ALLOW_WITHOUT_LOGGING;
// clang-format off
this.#view = view ?? ((input, output, target) => {
if (!input.changeSummary && input.patchSuggestionState === PatchSuggestionState.INITIAL) {
return;
}
output.tooltipRef = output.tooltipRef ?? Directives.createRef<HTMLElement>();
output.changeRef = output.changeRef ?? Directives.createRef<HTMLElement>();
output.summaryRef = output.summaryRef ?? Directives.createRef<HTMLElement>();
function renderSourcesLink(): LitTemplate {
if (!input.sources) {
return nothing;
}
return html`<x-link
class="link"
title="${UIStringsNotTranslate.viewUploadedFiles} ${UIStringsNotTranslate.opensInNewTab}"
href="data:text/plain;charset=utf-8,${encodeURIComponent(input.sources)}"
jslog=${VisualLogging.link('files-used-in-patching').track({click: true})}>
${UIStringsNotTranslate.viewUploadedFiles}
</x-link>`;
}
function renderHeader(): LitTemplate {
if (input.savedToDisk) {
return html`
<devtools-icon class="green-bright-icon summary-badge" .name=${'check-circle'}></devtools-icon>
<span class="header-text">
${lockedString(UIStringsNotTranslate.savedToDisk)}
</span>
`;
}
if (input.patchSuggestionState === PatchSuggestionState.SUCCESS) {
return html`
<devtools-icon class="on-tonal-icon summary-badge" .name=${'difference'}></devtools-icon>
<span class="header-text">
${lockedString(`File changes in ${input.projectName}`)}
</span>
<devtools-icon
class="arrow"
.name=${'chevron-down'}
></devtools-icon>
`;
}
return html`
<devtools-icon class="on-tonal-icon summary-badge" .name=${'pen-spark'}></devtools-icon>
<span class="header-text">
${lockedString(UIStringsNotTranslate.unsavedChanges)}
</span>
<devtools-icon
class="arrow"
.name=${'chevron-down'}
></devtools-icon>
`;
}
function renderContent(): LitTemplate {
if ((!input.changeSummary && input.patchSuggestionState === PatchSuggestionState.INITIAL) || input.savedToDisk) {
return nothing;
}
if (input.patchSuggestionState === PatchSuggestionState.SUCCESS) {
return html`<devtools-widget .widgetConfig=${UI.Widget.widgetConfig(ChangesPanel.CombinedDiffView.CombinedDiffView, {
workspaceDiff: input.workspaceDiff,
// Ignore user creates inspector-stylesheets
ignoredUrls: ['inspector://']
})}></devtools-widget>`;
}
return html`<devtools-code-block
.code=${input.changeSummary ?? ''}
.codeLang=${'css'}
.displayNotice=${true}
></devtools-code-block>
${input.patchSuggestionState === PatchSuggestionState.ERROR
? html`<div class="error-container">
<devtools-icon .name=${'cross-circle-filled'}></devtools-icon>${
lockedString(UIStringsNotTranslate.genericErrorMessage)
} ${renderSourcesLink()}
</div>`
: nothing
}`;
}
function renderFooter(): LitTemplate {
if (input.savedToDisk) {
return nothing;
}
if (input.patchSuggestionState === PatchSuggestionState.SUCCESS) {
return html`
<div class="footer">
<div class="left-side">
<x-link class="link disclaimer-link" href="https://support.google.com/legal/answer/13505487" jslog=${
VisualLogging.link('code-disclaimer').track({
click: true,
})}>
${lockedString(UIStringsNotTranslate.codeDisclaimer)}
</x-link>
${renderSourcesLink()}
</div>
<div class="save-or-discard-buttons">
<devtools-button
=${input.onDiscard}
.jslogContext=${'patch-widget.discard'}
.variant=${Buttons.Button.Variant.OUTLINED}>
${lockedString(UIStringsNotTranslate.discard)}
</devtools-button>
<devtools-button
=${input.onSaveAll}
.jslogContext=${'patch-widget.save-all'}
.variant=${Buttons.Button.Variant.PRIMARY}>
${lockedString(UIStringsNotTranslate.saveAll)}
</devtools-button>
</div>
</div>
`;
}
const iconName = input.projectType === SelectedProjectType.AUTOMATIC_DISCONNECTED ? 'folder-off' : input.projectType === SelectedProjectType.AUTOMATIC_CONNECTED ? 'folder-asterisk' : 'folder';
return html`
<div class="footer">
${input.projectName ? html`
<div class="change-workspace" jslog=${VisualLogging.section('patch-widget.workspace')}>
<devtools-icon .name=${iconName}></devtools-icon>
<span class="folder-name" title=${input.projectPath}>${input.projectName}</span>
${input.onChangeWorkspaceClick ? html`
<devtools-button
=${input.onChangeWorkspaceClick}
.jslogContext=${'change-workspace'}
.variant=${Buttons.Button.Variant.TEXT}
.title=${lockedString(UIStringsNotTranslate.changeRootFolder)}
.disabled=${input.patchSuggestionState === PatchSuggestionState.LOADING}
${Directives.ref(output.changeRef)}
>${lockedString(UIStringsNotTranslate.change)}</devtools-button>
` : nothing}
</div>
` : nothing}
<div class="apply-to-workspace-container" aria-live="polite">
${input.patchSuggestionState === PatchSuggestionState.LOADING ? html`
<div class="loading-text-container" jslog=${VisualLogging.section('patch-widget.apply-to-workspace-loading')}>
<devtools-spinner></devtools-spinner>
<span>
${lockedString(UIStringsNotTranslate.applyingToWorkspace)}
</span>
</div>
` : html`
<devtools-button
=${input.onApplyToWorkspace}
.jslogContext=${'patch-widget.apply-to-workspace'}
.variant=${Buttons.Button.Variant.OUTLINED}>
${lockedString(UIStringsNotTranslate.applyToWorkspace)}
</devtools-button>
`}
${input.patchSuggestionState === PatchSuggestionState.LOADING ? html`<devtools-button
=${input.onCancel}
.jslogContext=${'cancel'}
.variant=${Buttons.Button.Variant.OUTLINED}>
${lockedString(UIStringsNotTranslate.cancel)}
</devtools-button>` : nothing}
<devtools-button
aria-details="info-tooltip"
.jslogContext=${'patch-widget.info-tooltip-trigger'}
.iconName=${'info'}
.variant=${Buttons.Button.Variant.ICON}
.title=${input.applyToWorkspaceTooltipText}
></devtools-button>
</div>
</div>`;
}
// Use a simple div for the "Saved to disk" state as it's not expandable,
// otherwise use the interactive <details> element.
const template = input.savedToDisk
? html`
<div class="change-summary saved-to-disk" role="status" aria-live="polite">
<div class="header-container">
${renderHeader()}
</div>
</div>`
: html`
<details class="change-summary" jslog=${VisualLogging.section('patch-widget')}>
<summary class="header-container" ${Directives.ref(output.summaryRef)}>
${renderHeader()}
</summary>
${renderContent()}
${renderFooter()}
</details>
`;
render(template, target, {host: target});
});
// We're using PopoverHelper as a workaround instead of using <devtools-tooltip>. See the bug for more details.
// TODO: Update here when b/409965560 is fixed.
this.#popoverHelper = new UI.PopoverHelper.PopoverHelper(this.contentElement, event => {
// There are two ways this event is received for showing a popover case:
// * The latest element on the composed path is `<devtools-button>`
// * The 2nd element on the composed path is `<devtools-button>` (the last element is the `<button>` inside it.)
const hoveredNode = event.composedPath()[0];
const maybeDevToolsButton = event.composedPath()[2];
const popoverShownNode = hoveredNode instanceof HTMLElement && hoveredNode.getAttribute('aria-details') === 'info-tooltip' ? hoveredNode
: maybeDevToolsButton instanceof HTMLElement && maybeDevToolsButton.getAttribute('aria-details') === 'info-tooltip' ? maybeDevToolsButton
: null;
if (!popoverShownNode) {
return null;
}
return {
box: popoverShownNode.boxInWindow(),
show: async (popover: UI.GlassPane.GlassPane) => {
// clang-format off
render(html`
<style>
.info-tooltip-container {
max-width: var(--sys-size-28);
padding: var(--sys-size-4) var(--sys-size-5);
.tooltip-link {
display: block;
margin-top: var(--sys-size-4);
color: var(--sys-color-primary);
padding-left: 0;
}
}
</style>
<div class="info-tooltip-container">
${UIStringsNotTranslate.applyToWorkspaceTooltip}
<button
class="link tooltip-link"
role="link"
jslog=${VisualLogging.link('open-ai-settings').track({
click: true,
})}
=${this.#onLearnMoreTooltipClick}
>${lockedString(UIStringsNotTranslate.learnMore)}</button>
</div>`, popover.contentElement, {host: this});
// clang-forat on
return true;
},
};
}, 'patch-widget.info-tooltip');
this.#popoverHelper.setTimeout(0);
// clang-format on
this.requestUpdate();
}
#onLearnMoreTooltipClick(): void {
this.#viewOutput.tooltipRef?.value?.hidePopover();
void UI.ViewManager.ViewManager.instance().showView('chrome-ai');
}
#getDisplayedProject(): {projectName: string, projectPath: Platform.DevToolsPath.RawPathString} {
if (this.#project) {
return {
projectName: Common.ParsedURL.ParsedURL.encodedPathToRawPathString(
this.#project.displayName() as Platform.DevToolsPath.EncodedPathString),
projectPath: Common.ParsedURL.ParsedURL.urlToRawPathString(
this.#project.id() as Platform.DevToolsPath.UrlString, Host.Platform.isWin()),
};
}
if (this.#automaticFileSystem) {
return {
projectName: Common.ParsedURL.ParsedURL.extractName(this.#automaticFileSystem.root),
projectPath: this.#automaticFileSystem.root,
};
}
return {
projectName: '',
projectPath: Platform.DevToolsPath.EmptyRawPathString,
};
}
#shouldShowChangeButton(): boolean {
const automaticFileSystemProject =
this.#automaticFileSystem ? this.#workspace.projectForFileSystemRoot(this.#automaticFileSystem.root) : null;
const regularProjects = this.#workspace.projectsForType(Workspace.Workspace.projectTypes.FileSystem)
.filter(
project => project instanceof Persistence.FileSystemWorkspaceBinding.FileSystem &&
project.fileSystem().type() ===
Persistence.PlatformFileSystem.PlatformFileSystemType.WORKSPACE_PROJECT)
.filter(project => project !== automaticFileSystemProject);
return regularProjects.length > 0;
}
#getSelectedProjectType(projectPath: Platform.DevToolsPath.RawPathString): SelectedProjectType {
if (this.#automaticFileSystem && this.#automaticFileSystem.root === projectPath) {
return this.#project ? SelectedProjectType.AUTOMATIC_CONNECTED : SelectedProjectType.AUTOMATIC_DISCONNECTED;
}
return this.#project ? SelectedProjectType.NONE : SelectedProjectType.REGULAR;
}
override performUpdate(): void {
const {projectName, projectPath} = this.#getDisplayedProject();
this.#view(
{
workspaceDiff: this.#workspaceDiff,
changeSummary: this.changeSummary,
patchSuggestionState: this.#patchSuggestionState,
sources: this.#patchSources,
projectName,
projectPath,
projectType: this.#getSelectedProjectType(projectPath),
savedToDisk: this.#savedToDisk,
applyToWorkspaceTooltipText: this.#noLogging ?
lockedString(UIStringsNotTranslate.applyToWorkspaceTooltipNoLogging) :
lockedString(UIStringsNotTranslate.applyToWorkspaceTooltip),
onLearnMoreTooltipClick: this.#onLearnMoreTooltipClick.bind(this),
onApplyToWorkspace: this.#onApplyToWorkspace.bind(this),
onCancel: () => {
this.#applyPatchAbortController?.abort();
},
onDiscard: this.#onDiscard.bind(this),
onSaveAll: this.#onSaveAll.bind(this),
onChangeWorkspaceClick: this.#shouldShowChangeButton() ?
this.#showSelectWorkspaceDialog.bind(this, {applyPatch: false}) :
undefined,
},
this.#viewOutput, this.contentElement);
}
override wasShown(): void {
super.wasShown();
this.#selectDefaultProject();
if (isAiAssistancePatchingEnabled()) {
this.#workspace.addEventListener(Workspace.Workspace.Events.ProjectAdded, this.#onProjectAdded, this);
this.#workspace.addEventListener(Workspace.Workspace.Events.ProjectRemoved, this.#onProjectRemoved, this);
}
}
override willHide(): void {
this.#applyToDisconnectedAutomaticWorkspace = false;
if (isAiAssistancePatchingEnabled()) {
this.#workspace.removeEventListener(Workspace.Workspace.Events.ProjectAdded, this.#onProjectAdded, this);
this.#workspace.removeEventListener(Workspace.Workspace.Events.ProjectRemoved, this.#onProjectRemoved, this);
}
}
async #showFreDisclaimerIfNeeded(): Promise<boolean> {
const isAiPatchingFreCompleted = this.#aiPatchingFreCompletedSetting.get();
if (isAiPatchingFreCompleted) {
return true;
}
const result = await PanelCommon.FreDialog.show({
header: {iconName: 'smart-assistant', text: lockedString(UIStringsNotTranslate.freDisclaimerHeader)},
reminderItems: [
{
iconName: 'psychiatry',
content: lockedString(UIStringsNotTranslate.freDisclaimerTextAiWontAlwaysGetItRight),
},
{
iconName: 'google',
content: this.#noLogging ? lockedString(UIStringsNotTranslate.freDisclaimerTextPrivacyNoLogging) :
lockedString(UIStringsNotTranslate.freDisclaimerTextPrivacy),
},
{
iconName: 'warning',
// clang-format off
content: html`<x-link
href=${CODE_SNIPPET_WARNING_URL}
class="link devtools-link"
jslog=${VisualLogging.link('code-snippets-explainer.patch-widget').track({
click: true
})}
>${lockedString(UIStringsNotTranslate.freDisclaimerTextUseWithCaution)}</x-link>`,
// clang-format on
}
],
onLearnMoreClick: () => {
void UI.ViewManager.ViewManager.instance().showView('chrome-ai');
},
ariaLabel: lockedString(UIStringsNotTranslate.freDisclaimerHeader),
learnMoreButtonTitle: lockedString(UIStringsNotTranslate.learnMore),
});
if (result) {
this.#aiPatchingFreCompletedSetting.set(true);
}
return result;
}
#selectDefaultProject(): void {
const project = this.#automaticFileSystem ?
this.#workspace.projectForFileSystemRoot(this.#automaticFileSystem.root) :
this.#workspace.project(this.#projectIdSetting.get());
if (project) {
this.#project = project;
} else {
this.#project = undefined;
this.#projectIdSetting.set('');
}
this.requestUpdate();
}
#onProjectAdded(event: Common.EventTarget.EventTargetEvent<Workspace.Workspace.Project>): void {
const addedProject = event.data;
if (this.#applyToDisconnectedAutomaticWorkspace && this.#automaticFileSystem &&
addedProject === this.#workspace.projectForFileSystemRoot(this.#automaticFileSystem.root)) {
this.#applyToDisconnectedAutomaticWorkspace = false;
this.#project = addedProject;
void this.#applyPatchAndUpdateUI();
} else if (this.#project === undefined) {
this.#selectDefaultProject();
}
}
#onProjectRemoved(): void {
if (this.#project && !this.#workspace.project(this.#project.id())) {
this.#projectIdSetting.set('');
this.#project = undefined;
this.requestUpdate();
}
}
#showSelectWorkspaceDialog(options: {applyPatch: boolean} = {applyPatch: false}): void {
const onProjectSelected = (project: Workspace.Workspace.Project): void => {
this.#project = project;
this.#projectIdSetting.set(project.id());
if (options.applyPatch) {
void this.#applyPatchAndUpdateUI();
} else {
this.requestUpdate();
void this.updateComplete.then(() => {
this.contentElement?.querySelector('.apply-to-workspace-container devtools-button')
?.shadowRoot?.querySelector('button')
?.focus();
});
}
};
SelectWorkspaceDialog.show(onProjectSelected, this.#project);
}
async #onApplyToWorkspace(): Promise<void> {
if (!isAiAssistancePatchingEnabled()) {
return;
}
// Show the FRE dialog if needed and only continue when
// the user accepted the disclaimer.
const freDisclaimerCompleted = await this.#showFreDisclaimerIfNeeded();
if (!freDisclaimerCompleted) {
return;
}
if (this.#project) {
await this.#applyPatchAndUpdateUI();
} else if (this.#automaticFileSystem) {
this.#applyToDisconnectedAutomaticWorkspace = true;
await Persistence.AutomaticFileSystemManager.AutomaticFileSystemManager.instance().connectAutomaticFileSystem(
/* addIfMissing= */ true);
} else {
this.#showSelectWorkspaceDialog({applyPatch: true});
}
}
/**
* The modified files excluding inspector stylesheets
*/
get #modifiedFiles(): Workspace.UISourceCode.UISourceCode[] {
return this.#workspaceDiff.modifiedUISourceCodes().filter(modifiedUISourceCode => {
return !modifiedUISourceCode.url().startsWith('inspector://');
});
}
async #applyPatchAndUpdateUI(): Promise<void> {
const changeSummary = this.changeSummary;
if (!changeSummary) {
throw new Error('Change summary does not exist');
}
this.#patchSuggestionState = PatchSuggestionState.LOADING;
this.#rpcId = null;
this.requestUpdate();
const {response, processedFiles} = await this.#applyPatch(changeSummary);
if (response && 'rpcId' in response && response.rpcId) {
this.#rpcId = response.rpcId;
}
// Determines if applying the patch resulted in any actual file changes in the workspace.
// This is crucial because the agent might return an answer (e.g., an explanation)
// without making any code modifications (i.e., no `writeFile` calls).
// If no files were modified, we avoid transitioning to a success state,
// which would otherwise lead to an empty and potentially confusing diff view.
//
// Note: The `hasChanges` check below is based on `modifiedUISourceCodes()`, which reflects
// *all* current modifications in the workspace. It does not differentiate between
// changes made by this specific AI patch operation versus pre-existing changes
// made by the user. Consequently, if the AI patch itself makes no changes but the
// user already had other modified files, the widget will still transition to the
// success state (displaying all current workspace modifications).
const hasChanges = this.#modifiedFiles.length > 0;
if (response?.type === AiAssistanceModel.ResponseType.ANSWER && hasChanges) {
this.#patchSuggestionState = PatchSuggestionState.SUCCESS;
} else if (
response?.type === AiAssistanceModel.ResponseType.ERROR &&
response.error === AiAssistanceModel.ErrorType.ABORT) {
// If this is an abort error, we're returning back to the initial state.
this.#patchSuggestionState = PatchSuggestionState.INITIAL;
} else {
this.#patchSuggestionState = PatchSuggestionState.ERROR;
}
this.#patchSources = `Filenames in ${this.#project?.displayName()}.
Files:
${processedFiles.map(filename => `* ${filename}`).join('\n')}`;
this.requestUpdate();
if (this.#patchSuggestionState === PatchSuggestionState.SUCCESS) {
void this.updateComplete.then(() => {
this.#viewOutput.summaryRef?.value?.focus();
});
}
}
#onDiscard(): void {
for (const modifiedUISourceCode of this.#modifiedFiles) {
modifiedUISourceCode.resetWorkingCopy();
}
this.#patchSuggestionState = PatchSuggestionState.INITIAL;
this.#patchSources = undefined;
void this.changeManager?.popStashedChanges();
this.#submitRating(Host.AidaClient.Rating.NEGATIVE);
this.requestUpdate();
void this.updateComplete.then(() => {
this.#viewOutput.changeRef?.value?.focus();
});
}
#onSaveAll(): void {
for (const modifiedUISourceCode of this.#modifiedFiles) {
modifiedUISourceCode.commitWorkingCopy();
}
void this.changeManager?.stashChanges().then(() => {
this.changeManager?.dropStashedChanges();
});
this.#savedToDisk = true;
this.#submitRating(Host.AidaClient.Rating.POSITIVE);
this.requestUpdate();
}
#submitRating(rating: Host.AidaClient.Rating): void {
if (!this.#rpcId) {
return;
}
void this.#aidaClient.registerClientEvent({
corresponding_aida_rpc_global_id: this.#rpcId,
disable_user_content_logging: true,
do_conversation_client_event: {
user_feedback: {
sentiment: rating,
},
},
});
}
async #applyPatch(changeSummary: string): Promise<{
response: AiAssistanceModel.ResponseData | undefined,
processedFiles: string[],
}> {
if (!this.#project) {
throw new Error('Project does not exist');
}
this.#applyPatchAbortController = new AbortController();
const agent = new AiAssistanceModel.PatchAgent({
aidaClient: this.#aidaClient,
serverSideLoggingEnabled: false,
project: this.#project,
});
const {responses, processedFiles} =
await agent.applyChanges(changeSummary, {signal: this.#applyPatchAbortController.signal});
return {
response: responses.at(-1),
processedFiles,
};
}
}
export function isAiAssistancePatchingEnabled(): boolean {
return Boolean(Root.Runtime.hostConfig.devToolsFreestyler?.patching);
}
interface ExpectedChange {
path: string;
matches: string[];
doesNotMatch?: string[];
}
// @ts-expect-error temporary global function for local testing.
window.aiAssistanceTestPatchPrompt =
async (projectName: string, changeSummary: string, expectedChanges: ExpectedChange[]) => {
if (!isAiAssistancePatchingEnabled()) {
return;
}
const workspaceDiff = WorkspaceDiff.WorkspaceDiff.workspaceDiff();
const workspace = Workspace.Workspace.WorkspaceImpl.instance();
const project = workspace.projectsForType(Workspace.Workspace.projectTypes.FileSystem)
.filter(
project => project instanceof Persistence.FileSystemWorkspaceBinding.FileSystem &&
project.fileSystem().type() ===
Persistence.PlatformFileSystem.PlatformFileSystemType.WORKSPACE_PROJECT)
.find(project => project.displayName() === projectName);
if (!project) {
throw new Error('project not found');
}
const aidaClient = new Host.AidaClient.AidaClient();
const agent = new AiAssistanceModel.PatchAgent({
aidaClient,
serverSideLoggingEnabled: false,
project,
});
try {
const assertionFailures = [];
const {processedFiles, responses} = await agent.applyChanges(changeSummary);
if (responses.at(-1)?.type === AiAssistanceModel.ResponseType.ERROR) {
return {
error: 'failed to patch',
debugInfo: {
responses,
processedFiles,
},
};
}
for (const file of processedFiles) {
const change = expectedChanges.find(change => change.path === file);
if (!change) {
assertionFailures.push(`Patched ${file} that was not expected`);
break;
}
const agentProject = agent.agentProject;
const content = await agentProject.readFile(file);
if (!content) {
throw new Error(`${file} has no content`);
}
for (const m of change.matches) {
if (!content.match(new RegExp(m, 'gm'))) {
assertionFailures.push({
message: `Did not match ${m} in ${file}`,
file,
content,
});
}
}
for (const m of change.doesNotMatch || []) {
if (content.match(new RegExp(m, 'gm'))) {
assertionFailures.push({
message: `Unexpectedly matched ${m} in ${file}`,
file,
content,
});
}
}
}
return {
assertionFailures,
debugInfo: {
responses,
processedFiles,
},
};
} finally {
workspaceDiff.modifiedUISourceCodes().forEach(modifiedUISourceCode => {
modifiedUISourceCode.resetWorkingCopy();
});
}
};