UNPKG

chrome-devtools-frontend

Version:
898 lines (832 loc) 34.7 kB
// 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 @click=${input.onDiscard} .jslogContext=${'patch-widget.discard'} .variant=${Buttons.Button.Variant.OUTLINED}> ${lockedString(UIStringsNotTranslate.discard)} </devtools-button> <devtools-button @click=${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 @click=${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 @click=${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 @click=${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, })} @click=${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(); }); } };