Skip to content

Commit 6bd91a1

Browse files
wolfibDevtools-frontend LUCI CQ
authored andcommitted
[Patch agent] Show infobar warning for AI-edited files in editor
If the working copy of a UISourceCode contains AI-generated changes, show an infobar warning when this file is opened in an editor panel. If the changes are discarded or saved to disk, the warning is removed. Screenshot: https://i.imgur.com/4OqWghA.png Bug: 399560250 Change-Id: If757dd49bf5b61d85319cbb3ae90fbabc0e042b4 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6355191 Reviewed-by: Simon Zünd <[email protected]> Commit-Queue: Wolfgang Beyer <[email protected]>
1 parent da8bc2c commit 6bd91a1

File tree

11 files changed

+174
-43
lines changed

11 files changed

+174
-43
lines changed

config/gni/devtools_grd_files.gni

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,6 +1796,7 @@ grd_files_debug_sources = [
17961796
"front_end/panels/snippets/ScriptSnippetFileSystem.js",
17971797
"front_end/panels/snippets/SnippetsQuickOpen.js",
17981798
"front_end/panels/sources/AddSourceMapURLDialog.js",
1799+
"front_end/panels/sources/AiWarningInfobarPlugin.js",
17991800
"front_end/panels/sources/BreakpointEditDialog.js",
18001801
"front_end/panels/sources/CSSPlugin.js",
18011802
"front_end/panels/sources/CallStackSidebarPane.js",

front_end/models/workspace/UISourceCode.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export class UISourceCode extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
7272
private contentEncodedInternal?: boolean;
7373
private isKnownThirdPartyInternal: boolean;
7474
private isUnconditionallyIgnoreListedInternal: boolean;
75+
#containsAiChanges = false;
7576

7677
constructor(project: Project, url: Platform.DevToolsPath.UrlString, contentType: Common.ResourceType.ResourceType) {
7778
super();
@@ -378,6 +379,7 @@ export class UISourceCode extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
378379
private innerResetWorkingCopy(): void {
379380
this.workingCopyInternal = null;
380381
this.workingCopyGetter = null;
382+
this.setContainsAiChanges(false);
381383
}
382384

383385
setWorkingCopy(newWorkingCopy: string): void {
@@ -386,6 +388,14 @@ export class UISourceCode extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
386388
this.workingCopyChanged();
387389
}
388390

391+
setContainsAiChanges(containsAiChanges: boolean): void {
392+
this.#containsAiChanges = containsAiChanges;
393+
}
394+
395+
containsAiChanges(): boolean {
396+
return this.#containsAiChanges;
397+
}
398+
389399
setContent(content: string, isBase64: boolean): void {
390400
this.contentEncodedInternal = isBase64;
391401
if (this.projectInternal.canSetFileContent()) {

front_end/panels/ai_assistance/AgentProject.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export class AgentProject {
101101
}
102102
this.#linesChanged += linesChanged;
103103
uiSourceCode.setWorkingCopy(content);
104+
uiSourceCode.setContainsAiChanges(true);
104105
}
105106

106107
/**
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import * as i18n from '../../core/i18n/i18n.js';
6+
import * as Workspace from '../../models/workspace/workspace.js';
7+
import type * as TextEditor from '../../ui/components/text_editor/text_editor.js';
8+
import * as SourceFrame from '../../ui/legacy/components/source_frame/source_frame.js';
9+
import * as UI from '../../ui/legacy/legacy.js';
10+
11+
import {Plugin} from './Plugin.js';
12+
13+
const UIStrings = {
14+
/**
15+
*@description Infobar text announcing that the file contents have been changed by AI
16+
*/
17+
aiContentWarning: 'This file contains AI-generated content',
18+
} as const;
19+
const str_ = i18n.i18n.registerUIStrings('panels/sources/AiWarningInfobarPlugin.ts', UIStrings);
20+
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
21+
22+
export class AiWarningInfobarPlugin extends Plugin {
23+
#editor: TextEditor.TextEditor.TextEditor|undefined = undefined;
24+
#aiWarningInfobar: UI.Infobar.Infobar|null = null;
25+
26+
constructor(uiSourceCode: Workspace.UISourceCode.UISourceCode) {
27+
super(uiSourceCode);
28+
this.uiSourceCode.addEventListener(
29+
Workspace.UISourceCode.Events.WorkingCopyCommitted, this.#onWorkingCopyCommitted, this);
30+
}
31+
32+
override dispose(): void {
33+
this.#aiWarningInfobar?.dispose();
34+
this.#aiWarningInfobar = null;
35+
this.uiSourceCode.removeEventListener(
36+
Workspace.UISourceCode.Events.WorkingCopyCommitted, this.#onWorkingCopyCommitted, this);
37+
super.dispose();
38+
}
39+
40+
static override accepts(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
41+
return uiSourceCode.contentType().hasScripts() || uiSourceCode.contentType().hasStyleSheets();
42+
}
43+
44+
override editorInitialized(editor: TextEditor.TextEditor.TextEditor): void {
45+
this.#editor = editor;
46+
if (this.uiSourceCode.containsAiChanges()) {
47+
this.#showAiWarningInfobar();
48+
}
49+
}
50+
51+
#onWorkingCopyCommitted(): void {
52+
if (!this.uiSourceCode.containsAiChanges()) {
53+
this.#aiWarningInfobar?.dispose();
54+
this.#aiWarningInfobar = null;
55+
}
56+
}
57+
58+
#showAiWarningInfobar(): void {
59+
const infobar = new UI.Infobar.Infobar(
60+
UI.Infobar.Type.WARNING, i18nString(UIStrings.aiContentWarning), undefined, undefined,
61+
'contains-ai-content-warning');
62+
this.#aiWarningInfobar = infobar;
63+
infobar.setCloseCallback(() => this.removeInfobar(this.#aiWarningInfobar));
64+
this.attachInfobar(this.#aiWarningInfobar);
65+
}
66+
67+
attachInfobar(bar: UI.Infobar.Infobar): void {
68+
if (this.#editor) {
69+
this.#editor.dispatch({effects: SourceFrame.SourceFrame.addInfobar.of(bar)});
70+
}
71+
}
72+
73+
removeInfobar(bar: UI.Infobar.Infobar|null): void {
74+
if (this.#editor && bar) {
75+
this.#editor.dispatch({effects: SourceFrame.SourceFrame.removeInfobar.of(bar)});
76+
}
77+
}
78+
}

front_end/panels/sources/BUILD.gn

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ generate_css("css_files") {
2828
devtools_module("sources") {
2929
sources = [
3030
"AddSourceMapURLDialog.ts",
31+
"AiWarningInfobarPlugin.ts",
3132
"BreakpointEditDialog.ts",
3233
"CSSPlugin.ts",
3334
"CallStackSidebarPane.ts",

front_end/panels/sources/DebuggerPlugin.ts

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,6 @@ export class DebuggerPlugin extends Plugin {
311311
click: (view, block, event) => this.handleGutterClick(view.state.doc.lineAt(block.from), event as MouseEvent),
312312
},
313313
}),
314-
infobarState,
315314
breakpointMarkers,
316315
TextEditor.ExecutionPositionHighlighter.positionHighlighter('cm-executionLine', 'cm-executionToken'),
317316
CodeMirror.Prec.lowest(continueToMarkers.field),
@@ -454,13 +453,13 @@ export class DebuggerPlugin extends Plugin {
454453

455454
attachInfobar(bar: UI.Infobar.Infobar): void {
456455
if (this.editor) {
457-
this.editor.dispatch({effects: addInfobar.of(bar)});
456+
this.editor.dispatch({effects: SourceFrame.SourceFrame.addInfobar.of(bar)});
458457
}
459458
}
460459

461460
removeInfobar(bar: UI.Infobar.Infobar|null): void {
462461
if (this.editor && bar) {
463-
this.editor.dispatch({effects: removeInfobar.of(bar)});
462+
this.editor.dispatch({effects: SourceFrame.SourceFrame.removeInfobar.of(bar)});
464463
}
465464
}
466465

@@ -1794,31 +1793,6 @@ export class BreakpointLocationRevealer implements
17941793
}
17951794
}
17961795

1797-
// Infobar panel state, used to show additional panels below the editor.
1798-
1799-
const addInfobar = CodeMirror.StateEffect.define<UI.Infobar.Infobar>();
1800-
const removeInfobar = CodeMirror.StateEffect.define<UI.Infobar.Infobar>();
1801-
1802-
const infobarState = CodeMirror.StateField.define<UI.Infobar.Infobar[]>({
1803-
create(): UI.Infobar.Infobar[] {
1804-
return [];
1805-
},
1806-
update(current, tr): UI.Infobar.Infobar[] {
1807-
for (const effect of tr.effects) {
1808-
if (effect.is(addInfobar)) {
1809-
current = current.concat(effect.value);
1810-
} else if (effect.is(removeInfobar)) {
1811-
current = current.filter(b => b !== effect.value);
1812-
}
1813-
}
1814-
return current;
1815-
},
1816-
provide: (field): CodeMirror.Extension => CodeMirror.showPanel.computeN(
1817-
[field],
1818-
(state): Array<() => CodeMirror.Panel> =>
1819-
state.field(field).map((bar): (() => CodeMirror.Panel) => (): CodeMirror.Panel => ({dom: bar.element}))),
1820-
});
1821-
18221796
// Enumerate non-breakable lines (lines without a known corresponding
18231797
// position in the UISource).
18241798
async function computeNonBreakableLines(

front_end/panels/sources/SourcesView.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,41 @@ describeWithEnvironment('SourcesView', () => {
9999
assert.instanceOf(sourcesView.getSourceView(uiSourceCode), SourcesComponents.HeadersView.HeadersView);
100100
});
101101

102+
it('shows and hides an infobar which warns about AI-generated changes', async () => {
103+
const attachSpy = sinon.spy(Sources.AiWarningInfobarPlugin.AiWarningInfobarPlugin.prototype, 'attachInfobar');
104+
const removeSpy = sinon.spy(Sources.AiWarningInfobarPlugin.AiWarningInfobarPlugin.prototype, 'removeInfobar');
105+
106+
const sourcesView = new Sources.SourcesView.SourcesView();
107+
const {uiSourceCode} = createFileSystemUISourceCode({
108+
url: urlString`file:///path/to/project/example.ts`,
109+
mimeType: 'text/typescript',
110+
content: 'export class Foo {}',
111+
});
112+
113+
// Mock an AI-generated edit
114+
uiSourceCode.setWorkingCopy('export class Bar {}');
115+
uiSourceCode.setContainsAiChanges(true);
116+
117+
const contentLoadedPromise = new Promise(res => window.addEventListener('source-file-loaded', res));
118+
const widget = sourcesView.viewForFile(uiSourceCode);
119+
assert.instanceOf(widget, Sources.UISourceCodeFrame.UISourceCodeFrame);
120+
const uiSourceCodeFrame = widget;
121+
122+
// Only load the AiWarningInfobarPlugin
123+
sinon.stub(Sources.UISourceCodeFrame.UISourceCodeFrame, 'sourceFramePlugins').returns([
124+
Sources.AiWarningInfobarPlugin.AiWarningInfobarPlugin
125+
]);
126+
uiSourceCodeFrame.wasShown();
127+
128+
await contentLoadedPromise;
129+
130+
assert.isTrue(attachSpy.called);
131+
assert.isTrue(removeSpy.notCalled);
132+
133+
uiSourceCode.commitWorkingCopy();
134+
assert.isTrue(removeSpy.called);
135+
});
136+
102137
describe('viewForFile', () => {
103138
it('records the correct media type in the DevTools.SourcesPanelFileOpened metric', async () => {
104139
const sourcesView = new Sources.SourcesView.SourcesView();

front_end/panels/sources/UISourceCodeFrame.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import * as IssueCounter from '../../ui/components/issue_counter/issue_counter.j
4141
import * as SourceFrame from '../../ui/legacy/components/source_frame/source_frame.js';
4242
import * as UI from '../../ui/legacy/legacy.js';
4343

44+
import {AiWarningInfobarPlugin} from './AiWarningInfobarPlugin.js';
4445
import {CoveragePlugin} from './CoveragePlugin.js';
4546
import {CSSPlugin} from './CSSPlugin.js';
4647
import {DebuggerPlugin} from './DebuggerPlugin.js';
@@ -50,20 +51,6 @@ import {ResourceOriginPlugin} from './ResourceOriginPlugin.js';
5051
import {SnippetsPlugin} from './SnippetsPlugin.js';
5152
import {SourcesPanel} from './SourcesPanel.js';
5253

53-
function sourceFramePlugins(): Array<typeof Plugin> {
54-
// The order of these plugins matters for toolbar items and editor
55-
// extension precedence
56-
return [
57-
CSSPlugin,
58-
DebuggerPlugin,
59-
SnippetsPlugin,
60-
ResourceOriginPlugin,
61-
CoveragePlugin,
62-
MemoryProfilePlugin,
63-
PerformanceProfilePlugin,
64-
];
65-
}
66-
6754
export class UISourceCodeFrame extends
6855
Common.ObjectWrapper.eventMixin<EventTypes, typeof SourceFrame.SourceFrame.SourceFrameImpl>(
6956
SourceFrame.SourceFrame.SourceFrameImpl) {
@@ -342,11 +329,26 @@ export class UISourceCodeFrame extends
342329
this.updateLanguageMode('').then(() => this.reloadPlugins(), console.error);
343330
}
344331

332+
static sourceFramePlugins(): Array<typeof Plugin> {
333+
// The order of these plugins matters for toolbar items and editor
334+
// extension precedence
335+
return [
336+
CSSPlugin,
337+
DebuggerPlugin,
338+
SnippetsPlugin,
339+
ResourceOriginPlugin,
340+
CoveragePlugin,
341+
MemoryProfilePlugin,
342+
PerformanceProfilePlugin,
343+
AiWarningInfobarPlugin,
344+
];
345+
}
346+
345347
private loadPlugins(): void {
346348
const binding = Persistence.Persistence.PersistenceImpl.instance().binding(this.uiSourceCodeInternal);
347349
const pluginUISourceCode = binding ? binding.network : this.uiSourceCodeInternal;
348350

349-
for (const pluginType of sourceFramePlugins()) {
351+
for (const pluginType of UISourceCodeFrame.sourceFramePlugins()) {
350352
if (pluginType.accepts(pluginUISourceCode)) {
351353
this.plugins.push(new pluginType(pluginUISourceCode, this));
352354
}

front_end/panels/sources/sources.ts

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

55
import * as AddSourceMapURLDialog from './AddSourceMapURLDialog.js';
6+
import * as AiWarningInfobarPlugin from './AiWarningInfobarPlugin.js';
67
import * as BreakpointEditDialog from './BreakpointEditDialog.js';
78
import * as CallStackSidebarPane from './CallStackSidebarPane.js';
89
import * as CategorizedBreakpointL10n from './CategorizedBreakpointL10n.js';
@@ -34,6 +35,7 @@ import * as WatchExpressionsSidebarPane from './WatchExpressionsSidebarPane.js';
3435

3536
export {
3637
AddSourceMapURLDialog,
38+
AiWarningInfobarPlugin,
3739
BreakpointEditDialog,
3840
CallStackSidebarPane,
3941
CategorizedBreakpointL10n,

front_end/ui/legacy/components/source_frame/SourceFrame.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ export class SourceFrameImpl extends Common.ObjectWrapper.eventMixin<EventTypes,
312312
activeDark: 'var(--sys-color-divider-prominent)',
313313
},
314314
}),
315+
infobarState,
315316
];
316317
}
317318

@@ -1247,3 +1248,28 @@ const sourceFrameTheme = CodeMirror.EditorView.theme({
12471248
*/
12481249
export type RevealPosition = number|{lineNumber: number, columnNumber?: number}|
12491250
{from: {lineNumber: number, columnNumber: number}, to: {lineNumber: number, columnNumber: number}};
1251+
1252+
// Infobar panel state, used to show additional panels below the editor.
1253+
1254+
export const addInfobar = CodeMirror.StateEffect.define<UI.Infobar.Infobar>();
1255+
export const removeInfobar = CodeMirror.StateEffect.define<UI.Infobar.Infobar>();
1256+
1257+
const infobarState = CodeMirror.StateField.define<UI.Infobar.Infobar[]>({
1258+
create(): UI.Infobar.Infobar[] {
1259+
return [];
1260+
},
1261+
update(current, tr): UI.Infobar.Infobar[] {
1262+
for (const effect of tr.effects) {
1263+
if (effect.is(addInfobar)) {
1264+
current = current.concat(effect.value);
1265+
} else if (effect.is(removeInfobar)) {
1266+
current = current.filter(b => b !== effect.value);
1267+
}
1268+
}
1269+
return current;
1270+
},
1271+
provide: (field): CodeMirror.Extension => CodeMirror.showPanel.computeN(
1272+
[field],
1273+
(state): Array<() => CodeMirror.Panel> =>
1274+
state.field(field).map((bar): (() => CodeMirror.Panel) => (): CodeMirror.Panel => ({dom: bar.element}))),
1275+
});

0 commit comments

Comments
 (0)