Skip to content

Commit b09b210

Browse files
wolfibDevtools-frontend LUCI CQ
authored andcommitted
[Patch agent] Logic for when to show workspace selection dialog
- Persist selected workspace in a setting - Automatically select previously selected workspace - If no previously selected workspace -> don't automatically select any workspace - Track potential workspace deletion - Only show 'change' button if a workspace is selected - On 'apply' button if no workspace selected yet, show selection dialog and then immediately progress to applying changes Bug: 393266352 Change-Id: If20bd4eda982e5279319d6a9a08d949d31ac147a Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6348458 Reviewed-by: Ergün Erdoğmuş <[email protected]> Commit-Queue: Wolfgang Beyer <[email protected]>
1 parent 5e9bca5 commit b09b210

File tree

6 files changed

+158
-87
lines changed

6 files changed

+158
-87
lines changed

front_end/panels/ai_assistance/PatchWidget.test.ts

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// found in the LICENSE file.
44

55
import * as Common from '../../core/common/common.js';
6+
import type * as Persistence from '../../models/persistence/persistence.js';
67
import * as Workspace from '../../models/workspace/workspace.js';
78
import * as PanelCommon from '../../panels/common/common.js';
89
import {
@@ -15,6 +16,8 @@ import {
1516
import {updateHostConfig} from '../../testing/EnvironmentHelpers.js';
1617
import {describeWithMockConnection} from '../../testing/MockConnection.js';
1718

19+
import * as AiAssistance from './ai_assistance.js';
20+
1821
describeWithMockConnection('PatchWidget', () => {
1922
let showFreDialogStub: sinon.SinonStub<Parameters<typeof PanelCommon.FreDialog.show>, Promise<boolean>>;
2023
beforeEach(() => {
@@ -31,6 +34,7 @@ describeWithMockConnection('PatchWidget', () => {
3134
describe('applyToWorkspace', () => {
3235
beforeEach(() => {
3336
createTestFilesystem('file://test');
37+
Common.Settings.Settings.instance().createSetting('ai-assistance-patching-selected-project-id', 'file://test');
3438
updateHostConfig({
3539
devToolsFreestyler: {
3640
enabled: true,
@@ -79,44 +83,97 @@ Files:
7983
});
8084

8185
describe('workspace', () => {
82-
it('does not report a workspace project if disabled', async () => {
83-
createTestFilesystem('file://test');
86+
let project: Persistence.FileSystemWorkspaceBinding.FileSystem;
87+
88+
beforeEach(() => {
89+
project = createTestFilesystem('file://test').project;
8490
updateHostConfig({
8591
devToolsFreestyler: {
8692
enabled: true,
87-
patching: false,
93+
patching: true,
8894
},
8995
});
90-
const {view} = await createPatchWidget();
91-
assert.isUndefined(view.input.projectName);
9296
});
9397

94-
it('reports a current workspace project', async () => {
95-
createTestFilesystem('file://test');
98+
it('does not select a workspace project if patching is disabled', async () => {
9699
updateHostConfig({
97100
devToolsFreestyler: {
98101
enabled: true,
99-
patching: true,
102+
patching: false,
100103
},
101104
});
102105
const {view} = await createPatchWidget();
106+
assert.isUndefined(view.input.projectName);
107+
});
108+
109+
it('does not select a workspace project if setting does not exist', async () => {
110+
const {view} = await createPatchWidget();
111+
assert.isUndefined(view.input.projectName);
112+
});
113+
114+
it('selects a workspace project matching the setting', async () => {
115+
Common.Settings.Settings.instance().createSetting('ai-assistance-patching-selected-project-id', 'file://test');
116+
const {view} = await createPatchWidget();
103117
assert.strictEqual(view.input.projectName, 'test');
104118
});
105119

106-
it('reports an updated project', async () => {
107-
const {project} = createTestFilesystem('file://test');
108-
updateHostConfig({
109-
devToolsFreestyler: {
110-
enabled: true,
111-
patching: true,
112-
},
113-
});
120+
it('removes a selected workspace project upon workspace removal', async () => {
121+
Common.Settings.Settings.instance().createSetting('ai-assistance-patching-selected-project-id', 'file://test');
114122
const {view} = await createPatchWidget();
115123
assert.strictEqual(view.input.projectName, 'test');
116124

117125
Workspace.Workspace.WorkspaceImpl.instance().removeProject(project);
118-
createTestFilesystem('file://test2');
119-
assert.strictEqual((await view.nextInput).projectName, 'test2');
126+
const input = await view.nextInput;
127+
assert.isUndefined(input.projectName);
128+
});
129+
130+
it('selection is triggered by applyToWorkspace click if no workspace is (pre-)selected', async () => {
131+
let handler: (project: Workspace.Workspace.Project) => void = () => {};
132+
const showSelectWorkspaceDialogStub =
133+
sinon.stub(AiAssistance.SelectWorkspaceDialog, 'show').callsFake((handleProjectSelected, _project) => {
134+
handler = handleProjectSelected;
135+
});
136+
const {view, panel} = await createPatchWidget({aidaClient: mockAidaClient([[{explanation: 'suggested patch'}]])});
137+
panel.changeSummary = 'body { background-color: red; }';
138+
assert.isUndefined(view.input.projectName);
139+
140+
// Simulate clicking the "Apply to workspace" button
141+
view.input.onApplyToWorkspace();
142+
await new Promise(resolve => setTimeout(resolve, 0));
143+
assert.isTrue(showSelectWorkspaceDialogStub.calledOnce);
144+
145+
// Simulate selecting a workspace with the SelectWorkspaceDialog
146+
handler(project);
147+
const input = await view.nextInput;
148+
149+
// Assert that a patch has been generated and a project has been selected
150+
assert.strictEqual(input.patchSuggestion, 'suggested patch');
151+
assert.strictEqual(input.projectName, 'test');
152+
});
153+
154+
it('selection is triggered by the "change"-button if a workspace is already (pre-)selected', async () => {
155+
const {project: project2} = createTestFilesystem('file://test2');
156+
Common.Settings.Settings.instance().createSetting('ai-assistance-patching-selected-project-id', 'file://test');
157+
let handler: (project: Workspace.Workspace.Project) => void = () => {};
158+
const showSelectWorkspaceDialogStub =
159+
sinon.stub(AiAssistance.SelectWorkspaceDialog, 'show').callsFake((onProjectSelected, _project) => {
160+
handler = onProjectSelected;
161+
});
162+
const {view, panel} = await createPatchWidget();
163+
panel.changeSummary = 'body { background-color: red; }';
164+
assert.strictEqual(view.input.projectName, 'test');
165+
166+
// Simulate clicking the "Change" button
167+
assert.isTrue(showSelectWorkspaceDialogStub.notCalled);
168+
view.input.onChangeWorkspaceClick();
169+
assert.isTrue(showSelectWorkspaceDialogStub.calledOnce);
170+
171+
// Simulate selecting a different workspace with the SelectWorkspaceDialog
172+
handler(project2);
173+
const input = await view.nextInput;
174+
175+
// Assert that the project has been updated
176+
assert.strictEqual(input.projectName, 'test2');
120177
});
121178
});
122179
});

front_end/panels/ai_assistance/PatchWidget.ts

Lines changed: 57 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ export class PatchWidget extends UI.Widget.Widget {
139139
// Whether the user completed first run experience dialog or not.
140140
#aiPatchingFreCompletedSetting =
141141
Common.Settings.Settings.instance().createSetting('ai-assistance-patching-fre-completed', false);
142+
#projectIdSetting =
143+
Common.Settings.Settings.instance().createSetting('ai-assistance-patching-selected-project-id', '');
142144
#view: View;
143145
#viewOutput: ViewOutput = {};
144146
#aidaClient: Host.AidaClient.AidaClient;
@@ -156,6 +158,7 @@ export class PatchWidget extends UI.Widget.Widget {
156158
}) {
157159
super(false, false, element);
158160
this.#aidaClient = opts?.aidaClient ?? new Host.AidaClient.AidaClient();
161+
159162
// clang-format off
160163
this.#view = view ?? ((input, output, target) => {
161164
if (!input.changeSummary) {
@@ -257,17 +260,19 @@ export class PatchWidget extends UI.Widget.Widget {
257260

258261
return html`
259262
<div class="footer">
260-
<div class="change-workspace">
261-
<div class="selected-folder">
262-
<devtools-icon .name=${'folder'}></devtools-icon> <span title=${input.projectPath}>${input.projectName}</span>
263+
${input.projectName ? html`
264+
<div class="change-workspace">
265+
<div class="selected-folder">
266+
<devtools-icon .name=${'folder'}></devtools-icon> <span title=${input.projectPath}>${input.projectName}</span>
267+
</div>
268+
<devtools-button
269+
@click=${input.onChangeWorkspaceClick}
270+
.jslogContext=${'change-workspace'}
271+
.variant=${Buttons.Button.Variant.TEXT}>
272+
${lockedString(UIStringsNotTranslate.change)}
273+
</devtools-button>
263274
</div>
264-
<devtools-button
265-
@click=${input.onChangeWorkspaceClick}
266-
.jslogContext=${'change-workspace'}
267-
.variant=${Buttons.Button.Variant.TEXT}>
268-
${lockedString(UIStringsNotTranslate.change)}
269-
</devtools-button>
270-
</div>
275+
` : nothing}
271276
<div class="apply-to-workspace-container">
272277
${input.patchSuggestionLoading ? html`
273278
<div class="loading-text-container">
@@ -333,22 +338,6 @@ export class PatchWidget extends UI.Widget.Widget {
333338
this.requestUpdate();
334339
}
335340

336-
#onChangeWorkspaceClick(): void {
337-
const dialog = new UI.Dialog.Dialog('select-workspace');
338-
dialog.setMaxContentSize(new UI.Geometry.Size(384, 340));
339-
dialog.setSizeBehavior(UI.GlassPane.SizeBehavior.SET_EXACT_WIDTH_MAX_HEIGHT);
340-
dialog.setDimmed(true);
341-
342-
const handleProjectSelected = (project: Workspace.Workspace.Project): void => {
343-
this.#project = project;
344-
this.requestUpdate();
345-
};
346-
347-
new SelectWorkspaceDialog({dialog, handleProjectSelected, currentProject: this.#project})
348-
.show(dialog.contentElement);
349-
dialog.show();
350-
}
351-
352341
#onLearnMoreTooltipClick(): void {
353342
this.#viewOutput.tooltipRef?.value?.hidePopover();
354343
void UI.ViewManager.ViewManager.instance().showView('chrome-ai');
@@ -373,7 +362,7 @@ export class PatchWidget extends UI.Widget.Widget {
373362
},
374363
onDiscard: this.#onDiscard.bind(this),
375364
onSaveAll: this.#onSaveAll.bind(this),
376-
onChangeWorkspaceClick: this.#onChangeWorkspaceClick.bind(this),
365+
onChangeWorkspaceClick: this.#showSelectWorkspaceDialog.bind(this, {applyPatch: false}),
377366
},
378367
this.#viewOutput, this.contentElement);
379368
}
@@ -383,8 +372,7 @@ export class PatchWidget extends UI.Widget.Widget {
383372
this.#selectDefaultProject();
384373

385374
if (isAiAssistancePatchingEnabled()) {
386-
this.#workspace.addEventListener(Workspace.Workspace.Events.ProjectAdded, this.#onProjectAddedOrRemoved, this);
387-
this.#workspace.addEventListener(Workspace.Workspace.Events.ProjectRemoved, this.#onProjectAddedOrRemoved, this);
375+
this.#workspace.addEventListener(Workspace.Workspace.Events.ProjectRemoved, this.#onProjectRemoved, this);
388376

389377
// @ts-expect-error temporary global function for local testing.
390378
window.aiAssistanceTestPatchPrompt = async (changeSummary: string) => {
@@ -395,9 +383,7 @@ export class PatchWidget extends UI.Widget.Widget {
395383

396384
override willHide(): void {
397385
if (isAiAssistancePatchingEnabled()) {
398-
this.#workspace.removeEventListener(Workspace.Workspace.Events.ProjectAdded, this.#onProjectAddedOrRemoved, this);
399-
this.#workspace.removeEventListener(
400-
Workspace.Workspace.Events.ProjectRemoved, this.#onProjectAddedOrRemoved, this);
386+
this.#workspace.removeEventListener(Workspace.Workspace.Events.ProjectRemoved, this.#onProjectRemoved, this);
401387
}
402388
}
403389

@@ -443,41 +429,42 @@ export class PatchWidget extends UI.Widget.Widget {
443429
}
444430

445431
#selectDefaultProject(): void {
446-
if (isAiAssistancePatchingEnabled()) {
447-
// TODO: this is temporary code that should be replaced with
448-
// workflow selection flow. For now it picks the first Workspace
449-
// project that is not Snippets.
450-
const projects = this.#workspace.projectsForType(Workspace.Workspace.projectTypes.FileSystem);
432+
const project = this.#workspace.project(this.#projectIdSetting.get());
433+
if (project) {
434+
this.#project = project;
435+
} else {
451436
this.#project = undefined;
452-
for (const project of projects) {
453-
// This is for TypeScript to narrow the types. projectsForType()
454-
// probably only returns instances of
455-
// Persistence.FileSystemWorkspaceBinding.FileSystem.
456-
if (!(project instanceof Persistence.FileSystemWorkspaceBinding.FileSystem)) {
457-
continue;
458-
}
459-
if (project.fileSystem().type() !== Persistence.PlatformFileSystem.PlatformFileSystemType.WORKSPACE_PROJECT) {
460-
continue;
461-
}
462-
this.#project = project;
463-
this.requestUpdate();
464-
break;
465-
}
437+
this.#projectIdSetting.set('');
466438
}
439+
this.requestUpdate();
467440
}
468441

469-
#onProjectAddedOrRemoved(): void {
470-
this.#selectDefaultProject();
442+
#onProjectRemoved(): void {
443+
if (this.#project && !this.#workspace.project(this.#project.id())) {
444+
this.#projectIdSetting.set('');
445+
this.#project = undefined;
446+
this.requestUpdate();
447+
}
448+
}
449+
450+
#showSelectWorkspaceDialog(options: {applyPatch: boolean} = {applyPatch: false}): void {
451+
const onProjectSelected = (project: Workspace.Workspace.Project): void => {
452+
this.#project = project;
453+
this.#projectIdSetting.set(project.id());
454+
if (options.applyPatch) {
455+
void this.#applyPatchAndUpdateUI();
456+
} else {
457+
this.requestUpdate();
458+
}
459+
};
460+
461+
SelectWorkspaceDialog.show(onProjectSelected, this.#project);
471462
}
472463

473464
async #onApplyToWorkspace(): Promise<void> {
474465
if (!isAiAssistancePatchingEnabled()) {
475466
return;
476467
}
477-
const changeSummary = this.changeSummary;
478-
if (!changeSummary) {
479-
throw new Error('Change summary does not exist');
480-
}
481468

482469
// Show the FRE dialog if needed and only continue when
483470
// the user accepted the disclaimer.
@@ -486,6 +473,19 @@ export class PatchWidget extends UI.Widget.Widget {
486473
return;
487474
}
488475

476+
if (this.#project) {
477+
await this.#applyPatchAndUpdateUI();
478+
} else {
479+
this.#showSelectWorkspaceDialog({applyPatch: true});
480+
}
481+
}
482+
483+
async #applyPatchAndUpdateUI(): Promise<void> {
484+
const changeSummary = this.changeSummary;
485+
if (!changeSummary) {
486+
throw new Error('Change summary does not exist');
487+
}
488+
489489
this.#patchSuggestionLoading = true;
490490
this.requestUpdate();
491491
const {response, processedFiles} = await this.#applyPatch(changeSummary);

front_end/panels/ai_assistance/SelectWorkspaceDialog.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describeWithEnvironment('SelectWorkspaceDialog', () => {
1616
function createComponent(): {
1717
view: ViewFunctionStub<typeof AiAssistance.SelectWorkspaceDialog>,
1818
component: AiAssistance.SelectWorkspaceDialog,
19-
handleProjectSelected: sinon.SinonSpy<[Workspace.Workspace.Project], void>,
19+
onProjectSelected: sinon.SinonSpy<[Workspace.Workspace.Project], void>,
2020
hideDialogSpy: sinon.SinonSpy<[], void>,
2121
project: Persistence.FileSystemWorkspaceBinding.FileSystem,
2222
} {
@@ -26,37 +26,37 @@ describeWithEnvironment('SelectWorkspaceDialog', () => {
2626
const hideDialogSpy = sinon.spy(dialog, 'hide');
2727

2828
const view = createViewFunctionStub(AiAssistance.SelectWorkspaceDialog);
29-
const handleProjectSelected = sinon.spy() as sinon.SinonSpy<[Workspace.Workspace.Project], void>;
30-
const component = new AiAssistance.SelectWorkspaceDialog({dialog, handleProjectSelected}, view);
29+
const onProjectSelected = sinon.spy() as sinon.SinonSpy<[Workspace.Workspace.Project], void>;
30+
const component = new AiAssistance.SelectWorkspaceDialog({dialog, onProjectSelected}, view);
3131
component.markAsRoot();
3232
component.show(document.body);
3333
assert.strictEqual(view.callCount, 1);
3434
assert.strictEqual(view.input.selectedIndex, 0);
3535

36-
return {view, component, handleProjectSelected, hideDialogSpy, project};
36+
return {view, component, onProjectSelected, hideDialogSpy, project};
3737
}
3838

3939
it('selects a project', async () => {
40-
const {view, handleProjectSelected, hideDialogSpy, project} = createComponent();
40+
const {view, onProjectSelected, hideDialogSpy, project} = createComponent();
4141
view.input.onProjectSelected(1);
4242
const input = await view.nextInput;
4343
assert.strictEqual(view.callCount, 2);
4444
assert.strictEqual(input.selectedIndex, 1);
4545

4646
view.input.onSelectButtonClick();
47-
assert.isTrue(handleProjectSelected.calledOnceWith(project));
47+
assert.isTrue(onProjectSelected.calledOnceWith(project));
4848
assert.isTrue(hideDialogSpy.calledOnce);
4949
});
5050

5151
it('can be canceled', async () => {
52-
const {view, handleProjectSelected, hideDialogSpy} = createComponent();
52+
const {view, onProjectSelected, hideDialogSpy} = createComponent();
5353
view.input.onProjectSelected(1);
5454
const input = await view.nextInput;
5555
assert.strictEqual(view.callCount, 2);
5656
assert.strictEqual(input.selectedIndex, 1);
5757

5858
view.input.onCancelButtonClick();
59-
assert.isTrue(handleProjectSelected.notCalled);
59+
assert.isTrue(onProjectSelected.notCalled);
6060
assert.isTrue(hideDialogSpy.calledOnce);
6161
});
6262

0 commit comments

Comments
 (0)