chrome-devtools-frontend
Version:
Chrome DevTools UI
179 lines (158 loc) • 6.18 kB
text/typescript
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as SDK from '../../../core/sdk/sdk.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as Marked from '../../../third_party/marked/marked.js';
import type * as MarkdownView from '../../../ui/components/markdown_view/MarkdownView.js';
import * as Lit from '../../../ui/lit/lit.js';
import * as PanelsCommon from '../../common/common.js';
import {MarkdownRendererWithCodeBlock} from './MarkdownRendererWithCodeBlock.js';
const {html} = Lit.StaticHtml;
const {until} = Lit.Directives;
export class StylingAgentMarkdownRenderer extends MarkdownRendererWithCodeBlock {
constructor(
private mainFrameId = '',
) {
super();
}
#renderTableFromJson(data: Array<Record<string, string>>): Lit.LitTemplate|null {
if (!Array.isArray(data) || data.length === 0 || typeof data[0] !== 'object' || data[0] === null) {
return null;
}
const headers = Object.keys(data[0]);
const requiredKeys = ['Problem', 'Element', 'NodeId', 'Details'];
if (!requiredKeys.every(key => headers.includes(key))) {
return null; // Not the expected JSON structure
}
const problemIndex = headers.indexOf('Problem');
if (problemIndex > -1) {
const problemHeader = headers.splice(problemIndex, 1);
headers.unshift(...problemHeader);
}
return html`
<table style="width: 100%;">
<thead>
<tr>
${headers.map(header => html`<th style="text-align: left;">${header === 'NodeId' ? '' : header}</th>`)}
</tr>
</thead>
<tbody>
${data.flatMap(row => {
return html`
<tr>
${headers.map(header => {
if (header === 'NodeId') {
return html`<td>${this.#renderLinkifiedText(row[header])}</td>`;
}
if (header === 'Details') {
// eslint-disable-next-line @devtools/no-a-tags-in-lit
return html`<td><a href="#" @click=${this.#toggleDetailsRow}>Details</a></td>`;
}
return html`<td>${row[header]}</td>`;
})}
</tr>
<tr class="details-row" style="display: none;">
<td colspan=${headers.length} style="background-color: #f0f0f0; padding: 1em;">
<devtools-markdown-view .data=${{
tokens: Marked.Marked.lexer(row['Details']),
renderer: new StylingAgentMarkdownRenderer(this.mainFrameId),
} as MarkdownView.MarkdownViewData}></devtools-markdown-view>
</td>
</tr>
`;
})}
</tbody>
</table>
<br><div>To investigate these problems, please click one of the provided links (above), to set as context, and ask me further questions about the problem.</div>
`;
}
override templateForToken(token: Marked.Marked.MarkedToken): Lit.LitTemplate|null {
if (token.type === 'code') {
try {
const data = JSON.parse(token.text);
const table = this.#renderTableFromJson(data);
if (table) {
return table;
}
} catch {
// Not a JSON object, fallback to default rendering.
}
}
if (token.type === 'link' && token.href.startsWith('#')) {
let nodeId = undefined;
if (token.href.startsWith('#node-')) {
nodeId = Number(token.href.replace('#node-', '')) as Protocol.DOM.BackendNodeId;
} else if (token.href.startsWith('#')) {
// So often does it ignore requests to prepend nodes with node-, frustratingly.
nodeId = Number(token.href.replace('#', '')) as Protocol.DOM.BackendNodeId;
}
if (nodeId) {
return html`<span>${
until(this.#linkifyNode(nodeId, token.text).then(node => node || token.text), token.text)}</span>`;
}
}
return super.templateForToken(token);
}
#toggleDetailsRow(e: Event): void {
e.preventDefault();
const link = e.target as HTMLAnchorElement;
const currentRow = link.closest('tr');
if (!currentRow) {
return;
}
const detailsRow = currentRow.nextElementSibling as HTMLTableRowElement | null;
if (detailsRow?.classList.contains('details-row')) {
if (detailsRow.style.display === 'none') {
detailsRow.style.display = 'table-row';
link.textContent = 'Hide';
} else {
detailsRow.style.display = 'none';
link.textContent = 'Details';
}
}
}
#renderLinkifiedText(text: string): Lit.LitTemplate {
if (text.indexOf(',') === -1) {
const nodeId = Number(text) as Protocol.DOM.BackendNodeId;
if (isNaN(nodeId)) {
// Not a number, return as is.
return html`${text}`;
}
return this.#renderSingleLink(nodeId);
}
// Check for comma separated list.
const nodeIdsStr = text.split(',').map(s => s.trim()).filter(Boolean);
return html`${nodeIdsStr.map(idStr => {
const nodeId = Number(idStr) as Protocol.DOM.BackendNodeId;
if (isNaN(nodeId)) {
return html`<div>${idStr}</div>`;
}
return html`<div>${this.#renderSingleLink(nodeId)}</div>`;
})}`;
}
#renderSingleLink(nodeId: Protocol.DOM.BackendNodeId): Lit.LitTemplate {
const label = `link`;
return html`<span>${until(this.#linkifyNode(nodeId, label).then(node => node || label), label)}</span>`;
}
async #linkifyNode(backendNodeId: Protocol.DOM.BackendNodeId, label: string): Promise<Lit.LitTemplate|undefined> {
if (backendNodeId === undefined) {
return;
}
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
const domModel = target?.model(SDK.DOMModel.DOMModel);
if (!domModel) {
return undefined;
}
const domNodesMap = await domModel.pushNodesByBackendIdsToFrontend(new Set([backendNodeId]));
const node = domNodesMap?.get(backendNodeId);
if (!node) {
return;
}
if (node.frameId() !== this.mainFrameId) {
return;
}
const linkedNode = PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(node, {textContent: label});
return linkedNode;
}
}