Skip to content

Commit 9b261e7

Browse files
jackfranklinDevtools-frontend LUCI CQ
authored andcommitted
GreenDev: "floaty" in DevTools prototype
This is the prototype of the "DevTools Floaty" window idea that will enable users to use a "point and click" approach to add additional context to the active AI Agent in DevTools. **Important**: all of this functionality is behind the GreenDev flag. We have **no intention** of pushing this feature live in this state. This is code landing to user test in Canary that will not ship without an additional project to make this code fully production worthy. That is why this CL has no tests, for example. **What** This CL is made up of a few parts: 1. The `Floaty` window. This window appears either via the icon in the AI Assistance panel, or by using the `f` key on the keyboard. 2. The `onFloatyClick` function, which can be called when the user has clicked on something that we want to add as context. If the floaty is in inspect mode, it will add this item to the context. 3. The `extraContext` in the AI Assistance chat view, which shows all the additional context items the user has added. 4. The `facts` added in AI Agent, which are added to the prompt that is sent to the LLM so it is aware that the user has explicitly added more items to the context. Bug: 461428712 Change-Id: Ic21e18d266a994d4eb593d3b7ef4726b5d214cb0 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/7198584 Commit-Queue: Jack Franklin <jacktfranklin@chromium.org> Reviewed-by: Finnur Thorarinsson <finnur@chromium.org>
1 parent fda58af commit 9b261e7

20 files changed

Lines changed: 812 additions & 16 deletions

File tree

config/gni/devtools_grd_files.gni

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2494,6 +2494,7 @@ grd_files_unbundled_sources = [
24942494
"front_end/ui/legacy/EmptyWidget.js",
24952495
"front_end/ui/legacy/FilterBar.js",
24962496
"front_end/ui/legacy/FilterSuggestionBuilder.js",
2497+
"front_end/ui/legacy/Floaty.js",
24972498
"front_end/ui/legacy/ForwardedInputEventHandler.js",
24982499
"front_end/ui/legacy/Fragment.js",
24992500
"front_end/ui/legacy/GlassPane.js",
@@ -2641,6 +2642,7 @@ grd_files_unbundled_sources = [
26412642
"front_end/ui/legacy/dropTarget.css.js",
26422643
"front_end/ui/legacy/emptyWidget.css.js",
26432644
"front_end/ui/legacy/filter.css.js",
2645+
"front_end/ui/legacy/floaty.css.js",
26442646
"front_end/ui/legacy/glassPane.css.js",
26452647
"front_end/ui/legacy/infobar.css.js",
26462648
"front_end/ui/legacy/inspectorCommon.css.js",

front_end/entrypoints/main/MainImpl.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -809,8 +809,8 @@ export class MainMenuItem implements UI.Toolbar.Provider {
809809
readonly #item: UI.Toolbar.ToolbarMenuButton;
810810
constructor() {
811811
this.#item = new UI.Toolbar.ToolbarMenuButton(
812-
this.#handleContextMenu.bind(this), /* isIconDropdown */ true, /* useSoftMenu */ true, 'main-menu',
813-
'dots-vertical');
812+
this.#handleContextMenu.bind(this), /* isIconDropdown */ true,
813+
/* useSoftMenu */ true, 'main-menu', 'dots-vertical');
814814
this.#item.element.classList.add('main-menu');
815815
this.#item.setTitle(i18nString(UIStrings.customizeAndControlDevtools));
816816
}

front_end/models/ai_assistance/AiConversation.ts

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
import * as Host from '../../core/host/host.js';
66
import * as Root from '../../core/root/root.js';
7+
import * as SDK from '../../core/sdk/sdk.js';
8+
import * as Trace from '../../models/trace/trace.js';
9+
import * as NetworkTimeCalculator from '../network_time_calculator/network_time_calculator.js';
710

811
import {
912
type AiAgent,
@@ -19,6 +22,10 @@ import {PerformanceAgent} from './agents/PerformanceAgent.js';
1922
import {StylingAgent} from './agents/StylingAgent.js';
2023
import {AiHistoryStorage, ConversationType, type SerializedConversation} from './AiHistoryStorage.js';
2124
import type {ChangeManager} from './ChangeManager.js';
25+
import {NetworkRequestFormatter} from './data_formatters/NetworkRequestFormatter.js';
26+
import {PerformanceInsightFormatter} from './data_formatters/PerformanceInsightFormatter.js';
27+
import {micros} from './data_formatters/UnitFormatters.js';
28+
import {AgentFocus} from './performance/AIContext.js';
2229

2330
export const NOT_FOUND_IMAGE_DATA = '';
2431
const MAX_TITLE_LENGTH = 80;
@@ -240,15 +247,91 @@ export class AiConversation {
240247
return agent;
241248
}
242249

250+
#factsCache = new Map<ExtraContext, Host.AidaClient.RequestFact>();
251+
252+
async #createFactsForExtraContext(contexts: ExtraContext[]): Promise<void> {
253+
for (const context of contexts) {
254+
const cached = this.#factsCache.get(context);
255+
if (cached) {
256+
this.#agent.addFact(cached);
257+
continue;
258+
}
259+
260+
if (context instanceof SDK.DOMModel.DOMNode) {
261+
const desc = await StylingAgent.describeElement(context);
262+
263+
const fact = {
264+
text: `Relevant HTML element:\n${desc}`,
265+
metadata: {
266+
source: 'devtools-floaty',
267+
score: 1,
268+
}
269+
};
270+
this.#factsCache.set(context, fact);
271+
this.#agent.addFact(fact);
272+
} else if (context instanceof SDK.NetworkRequest.NetworkRequest) {
273+
const calculator = new NetworkTimeCalculator.NetworkTransferTimeCalculator();
274+
calculator.updateBoundaries(context);
275+
const formatter = new NetworkRequestFormatter(context, calculator);
276+
const desc = await formatter.formatNetworkRequest();
277+
278+
const fact = {
279+
text: `Relevant network request:\n${desc}`,
280+
metadata: {
281+
source: 'devtools-floaty',
282+
score: 1,
283+
}
284+
};
285+
this.#factsCache.set(context, fact);
286+
this.#agent.addFact(fact);
287+
} else if ('insight' in context) {
288+
const focus = AgentFocus.fromInsight(context.trace, context.insight);
289+
const formatter = new PerformanceInsightFormatter(
290+
focus,
291+
context.insight,
292+
);
293+
294+
const text = `Relevant Performance Insight:\n${formatter.formatInsight()}`;
295+
const fact = {
296+
text,
297+
metadata: {
298+
source: 'devtools-floaty',
299+
score: 1,
300+
}
301+
};
302+
this.#factsCache.set(context, fact);
303+
this.#agent.addFact(fact);
304+
} else {
305+
// Must be a trace event
306+
const time = Trace.Types.Timing.Micro(
307+
context.event.ts - context.traceStartTime,
308+
);
309+
310+
const desc = `Trace event: ${context.event.name}
311+
Time: ${micros(time)}`;
312+
313+
const fact = {
314+
text: `Relevant trace event:\n${desc}`,
315+
metadata: {
316+
source: 'devtools-floaty',
317+
score: 1,
318+
}
319+
};
320+
this.#factsCache.set(context, fact);
321+
this.#agent.addFact(fact);
322+
}
323+
}
324+
}
325+
243326
async *
244327
run(
245328
initialQuery: string,
246-
options: {
247-
selected: ConversationContext<unknown>|null,
248-
signal?: AbortSignal,
249-
},
329+
options: {selected: ConversationContext<unknown>|null, signal?: AbortSignal, extraContext?: ExtraContext[]},
250330
multimodalInput?: MultimodalInput,
251331
): AsyncGenerator<ResponseData, void, void> {
332+
if (options.extraContext) {
333+
await this.#createFactsForExtraContext(options.extraContext);
334+
}
252335
for await (const data of this.#agent.run(initialQuery, options, multimodalInput)) {
253336
// We don't want to save partial responses to the conversation history.
254337
// TODO(crbug.com/463325400): We should save interleaved answers to the history as well.
@@ -267,3 +350,10 @@ export class AiConversation {
267350
function isAiAssistanceServerSideLoggingEnabled(): boolean {
268351
return !Root.Runtime.hostConfig.aidaAvailability?.disallowLogging;
269352
}
353+
354+
// TODO: this is the same as the type in UI.Floaty but we cannot use UI
355+
// here. This is fine for prototyping but if we take this further we can
356+
// rearchitect.
357+
type ExtraContext = SDK.DOMModel.DOMNode|SDK.NetworkRequest.NetworkRequest|
358+
{event: Trace.Types.Events.Event, traceStartTime: Trace.Types.Timing.Micro}|
359+
{insight: Trace.Insights.Types.InsightModel, trace: Trace.TraceModel.ParsedTrace};

front_end/models/logs/BUILD.gn

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ devtools_entrypoint("bundle") {
3737
"../../panels/issues/*",
3838
"../../panels/network/*",
3939
"../../ui/components/request_link_icon/*",
40+
"../../ui/legacy/*",
4041
"../ai_assistance/*",
4142
"../extensions/*",
4243
]

front_end/models/trace/BUILD.gn

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ devtools_entrypoint("bundle") {
4949

5050
visibility = [
5151
":*",
52+
"../../../mcp/*",
5253
"../../../test/interactions/*",
5354
"../../core/sdk:unittests",
5455
"../../legacy_test_runner/*",
@@ -60,12 +61,11 @@ devtools_entrypoint("bundle") {
6061
"../../services/tracing/*",
6162
"../../testing/*",
6263
"../../ui/components/docs/*",
63-
"../../ui/legacy/components/utils/*",
64+
"../../ui/legacy/*",
6465
"../ai_assistance/*",
6566
"../live-metrics/*",
6667
"../trace_source_maps_resolver/*",
6768
"./*",
68-
"../../../mcp/*",
6969
]
7070

7171
visibility += devtools_models_visibility

front_end/models/trace/types/TraceEvents.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ export interface Event {
8585
tdur?: Micro;
8686
dur?: Micro;
8787
}
88+
export function objectIsEvent(obj: object): obj is Event {
89+
return 'cat' in obj && 'name' in obj && 'ts' in obj;
90+
}
8891

8992
export interface Args {
9093
data?: ArgsData;

front_end/panels/ai_assistance/AiAssistancePanel.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ export class AiAssistancePanel extends UI.Panel.Panel {
505505
#isTextInputEmpty = true;
506506
#timelinePanelInstance: TimelinePanel.TimelinePanel.TimelinePanel|null = null;
507507
#runAbortController = new AbortController();
508+
#additionalContextItemsFromFloaty: UI.Floaty.FloatyContextSelection[] = [];
508509

509510
constructor(private view: View = defaultView, {aidaClient, aidaAvailability, syncInfo}: {
510511
aidaClient: Host.AidaClient.AidaClient,
@@ -549,6 +550,7 @@ export class AiAssistancePanel extends UI.Panel.Panel {
549550
return {
550551
state: ViewState.CHAT_VIEW,
551552
props: {
553+
additionalFloatyContext: this.#additionalContextItemsFromFloaty,
552554
blockedByCrossOrigin: this.#blockedByCrossOrigin,
553555
isLoading: this.#isLoading,
554556
messages: this.#messages,
@@ -649,6 +651,15 @@ export class AiAssistancePanel extends UI.Panel.Panel {
649651
}
650652
}
651653

654+
#bindFloatyListener(): void {
655+
const additionalContexts = UI.Context.Context.instance().flavor(UI.Floaty.FloatyFlavor);
656+
if (!additionalContexts) {
657+
return;
658+
}
659+
this.#additionalContextItemsFromFloaty = additionalContexts.selectedContexts;
660+
this.requestUpdate();
661+
}
662+
652663
#getDefaultConversationType(): AiAssistanceModel.AiHistoryStorage.ConversationType|undefined {
653664
const {hostConfig} = Root.Runtime;
654665
const viewManager = UI.ViewManager.ViewManager.instance();
@@ -765,6 +776,11 @@ export class AiAssistancePanel extends UI.Panel.Panel {
765776
this.#selectDefaultAgentIfNeeded();
766777

767778
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistancePanelOpened);
779+
780+
if (Root.Runtime.hostConfig.devToolsGreenDevUi?.enabled) {
781+
UI.Context.Context.instance().addFlavorChangeListener(UI.Floaty.FloatyFlavor, this.#bindFloatyListener, this);
782+
this.#bindFloatyListener();
783+
}
768784
}
769785

770786
override willHide(): void {
@@ -1409,6 +1425,7 @@ export class AiAssistancePanel extends UI.Panel.Panel {
14091425
text, {
14101426
signal,
14111427
selected: context,
1428+
extraContext: this.#additionalContextItemsFromFloaty,
14121429
},
14131430
multimodalInput),
14141431
);

front_end/panels/ai_assistance/components/ChatView.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describeWithEnvironment('ChatView', () => {
4040
disclaimerText: i18n.i18n.lockedString('disclaimer text'),
4141
isTextInputEmpty: true,
4242
markdownRenderer: new AiAssistancePanel.MarkdownRendererWithCodeBlock(),
43+
additionalFloatyContext: [],
4344
...options,
4445
};
4546
}

front_end/panels/ai_assistance/components/ChatView.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import '../../../ui/components/spinners/spinners.js';
88

99
import * as Host from '../../../core/host/host.js';
1010
import * as i18n from '../../../core/i18n/i18n.js';
11-
import type * as Platform from '../../../core/platform/platform.js';
11+
import * as Platform from '../../../core/platform/platform.js';
12+
import * as Root from '../../../core/root/root.js';
1213
import * as SDK from '../../../core/sdk/sdk.js';
1314
import * as AiAssistanceModel from '../../../models/ai_assistance/ai_assistance.js';
15+
import * as Trace from '../../../models/trace/trace.js';
1416
import * as Workspace from '../../../models/workspace/workspace.js';
1517
import * as PanelsCommon from '../../../panels/common/common.js';
1618
import * as PanelUtils from '../../../panels/utils/utils.js';
@@ -265,6 +267,7 @@ export interface Props {
265267
isTextInputEmpty: boolean;
266268
uploadImageInputEnabled?: boolean;
267269
markdownRenderer: MarkdownLitRenderer;
270+
additionalFloatyContext: UI.Floaty.FloatyContextSelection[];
268271
}
269272

270273
export class ChatView extends HTMLElement {
@@ -517,7 +520,8 @@ export class ChatView extends HTMLElement {
517520
onTakeScreenshot: this.#props.onTakeScreenshot,
518521
onRemoveImageInput: this.#props.onRemoveImageInput,
519522
onTextInputChange: this.#props.onTextInputChange,
520-
onImageUpload: this.#handleImageUpload
523+
onImageUpload: this.#handleImageUpload,
524+
additionalFloatyContext: this.#props.additionalFloatyContext,
521525
});
522526
};
523527

@@ -1347,6 +1351,7 @@ function renderChatInput({
13471351
isTextInputEmpty,
13481352
uploadImageInputEnabled,
13491353
disclaimerText,
1354+
additionalFloatyContext,
13501355
onContextClick,
13511356
onInspectElementClick,
13521357
onSubmit,
@@ -1365,6 +1370,7 @@ function renderChatInput({
13651370
selectedContext: AiAssistanceModel.AiAgent.ConversationContext<unknown>|null,
13661371
inspectElementToggled: boolean,
13671372
isTextInputEmpty: boolean,
1373+
additionalFloatyContext: UI.Floaty.FloatyContextSelection[],
13681374
disclaimerText: string,
13691375
onContextClick: () => void,
13701376
onInspectElementClick: () => void,
@@ -1389,6 +1395,7 @@ function renderChatInput({
13891395

13901396
// clang-format off
13911397
return html` <form class="input-form" @submit=${onSubmit}>
1398+
${renderFloatyExtraContext(additionalFloatyContext)}
13921399
<div class=${chatInputContainerCls}>
13931400
${renderImageInput({
13941401
multimodalInputEnabled,
@@ -1463,6 +1470,75 @@ function renderChatInput({
14631470
// clang-format on
14641471
}
14651472

1473+
function renderFloatyExtraContext(contexts: UI.Floaty.FloatyContextSelection[]): Lit.LitTemplate {
1474+
if (!Root.Runtime.hostConfig.devToolsGreenDevUi?.enabled) {
1475+
return Lit.nothing;
1476+
}
1477+
1478+
// clang-format off
1479+
return html`
1480+
<ul class="floaty">
1481+
${contexts.map(c => {
1482+
function onDelete(e: MouseEvent): void {
1483+
e.preventDefault();
1484+
UI.Floaty.onFloatyContextDelete(c);
1485+
}
1486+
1487+
return html`<li>
1488+
<span class="context-item">
1489+
${renderFloatyContext(c)}
1490+
</span>
1491+
<devtools-button
1492+
class="floaty-delete-button"
1493+
@click=${onDelete}
1494+
.data=${{
1495+
variant: Buttons.Button.Variant.ICON,
1496+
iconName: 'cross',
1497+
title: 'Delete',
1498+
size: Buttons.Button.Size.SMALL,
1499+
} as Buttons.Button.ButtonData}
1500+
></devtools-button>
1501+
</li>`;
1502+
})}
1503+
<li class="open-floaty">
1504+
<devtools-button
1505+
class="floaty-add-button"
1506+
@click=${UI.Floaty.onFloatyOpen}
1507+
.data=${{
1508+
variant: Buttons.Button.Variant.ICON,
1509+
iconName: 'select-element',
1510+
title: 'Open context picker',
1511+
size: Buttons.Button.Size.SMALL,
1512+
} as Buttons.Button.ButtonData}
1513+
></devtools-button>
1514+
</li>
1515+
</ul>
1516+
`;
1517+
// clang-format on
1518+
}
1519+
1520+
function renderFloatyContext(context: UI.Floaty.FloatyContextSelection): Lit.TemplateResult {
1521+
if (context instanceof SDK.NetworkRequest.NetworkRequest) {
1522+
return html`${context.url()}`;
1523+
}
1524+
1525+
if (context instanceof SDK.DOMModel.DOMNode) {
1526+
return html`<devtools-widget .widgetConfig=${
1527+
UI.Widget.widgetConfig(PanelsCommon.DOMLinkifier.DOMNodeLink, {node: context})}>`;
1528+
}
1529+
1530+
if ('insight' in context) {
1531+
return html`${context.insight.title}`;
1532+
}
1533+
1534+
if ('event' in context && 'traceStartTime' in context) {
1535+
const time = Trace.Types.Timing.Micro(context.event.ts - context.traceStartTime);
1536+
return html`${context.event.name} @ ${i18n.TimeUtilities.formatMicroSecondsAsMillisFixed(time)}`;
1537+
}
1538+
1539+
Platform.assertNever(context, 'Unsupported context');
1540+
}
1541+
14661542
function renderMainContents({
14671543
messages,
14681544
isLoading,

0 commit comments

Comments
 (0)