blob: 939be44ddaaffcaa39594c9cf06ad6d809990d7e [file]
// 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 Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
export interface EditorAnchorSignature {
/** 1-based line number for CodeMirror text editor anchors */
lineNumber: number;
/** File path associated with the editor */
filePath?: string;
}
export interface DOMNodeAnchorSignature {
/** Backend NodeId for DOM nodes (`data-backend-node-id`) */
backendNodeId: number;
/** Target ID associated with the DOM node (`data-target-id`) */
targetId: string;
}
export interface TimelineAnchorSignature {
/** Identifier of the trace to scope comments to a specific recording */
traceId: string;
/**
* Serializable key from Trace.EventsSerializer (e.g. 'r-123', 'p-1-2-3-4', 's-5'), or
* 'fc-<chartLocation>-<entryIndex>' for flame chart entries that EventsSerializer cannot key.
*/
traceEventKey: string;
/** Primary event title or category name */
entryName: string;
/** Event start timestamp in microseconds */
startTimeMicro: number;
/** Chart location */
chartLocation: 'main'|'network';
/** Event duration in microseconds (omitted for instant markers) */
durationMicro?: number;
}
export interface CommentAnchorSignature {
/** Visual logging tree path, e.g. "Panel: elements > Pane: styles > TreeOutline > TreeItem: color" */
vePath: string;
/** Normalized text content of the target node */
textSignature?: string;
/** Text content of the parent container VE node for sibling disambiguation */
parentTextSignature?: string;
/** 0-indexed position among siblings sharing the same visual logging path */
siblingIndex?: number;
/** Optional backend RequestId for Network panel elements (`data-network-request-id`) */
networkRequestId?: string;
/** Optional DOM node identifiers (`data-backend-node-id`, `data-target-id`) */
node?: DOMNodeAnchorSignature;
/** Optional editor anchor coordinates for CodeMirror text editors */
editor?: EditorAnchorSignature;
/** Optional timeline flamechart anchor coordinates for Performance panel trace entries */
timeline?: TimelineAnchorSignature;
/**
* Optional formatted lines that give the AI agent context about the
* anchored target beyond the VE path and text signature, for example the
* timing and warnings of a Performance panel trace entry. CD4ABridge
* appends each line to the comment text that it sends to the agent.
*
* These lines are only context for the agent. They are not used to
* re-match the anchor to its target; the other fields of this signature
* do that.
*
* Currently only the Performance panel sets this field. Before adding
* context here, consider whether it belongs in a new field on this
* signature instead. This field is only for text that is already
* formatted for the agent, not for data that other code needs to read.
*/
agentContext?: string[];
}
export interface Comment {
author: 'DEVELOPER'|'AGENT';
text: string;
timestamp: number;
}
export type CommentThreadStatus = 'DRAFT'|'ACTIVE'|'SENT_TO_AGENT'|'RESOLVED';
export const enum Events {
CHANGED = 'Changed',
}
export interface EventTypes {
[Events.CHANGED]: void;
}
export interface CommentThreadOptions {
anchor: CommentAnchorSignature;
comments?: Comment[];
/** True for comments generated from the change tracker. */
isGeneratedComment?: boolean;
}
export class CommentThread extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
readonly id: string = crypto.randomUUID();
readonly anchor: CommentAnchorSignature;
/** True for comments generated from the change tracker. */
readonly isGeneratedComment: boolean;
#isOrphaned = false;
comments: Comment[];
status: CommentThreadStatus = 'DRAFT';
#transmitted = false;
/**
* False until the developer has opened the thread since the agent last
* responded. Only meaningful for RESOLVED threads.
*/
responseSeen: boolean = false;
constructor(options: CommentThreadOptions) {
super();
this.anchor = options.anchor;
this.comments = options.comments ?? [];
this.isGeneratedComment = Boolean(options.isGeneratedComment);
}
get isOrphaned(): boolean {
return this.#isOrphaned;
}
setOrphaned(isOrphaned: boolean): void {
if (this.#isOrphaned === isOrphaned) {
return;
}
this.#isOrphaned = isOrphaned;
this.dispatchEventToListeners(Events.CHANGED);
}
/**
* True once the thread has been taken by the agent (e.g. via CD4ABridge).
*/
get transmitted(): boolean {
return this.#transmitted;
}
/**
* Marks the thread as taken by the agent.
*/
markTransmitted(): void {
if (this.#transmitted) {
return;
}
this.#transmitted = true;
this.dispatchEventToListeners(Events.CHANGED);
}
/**
* Returns whether it was changed.
*/
#saveText(text?: string, author: 'DEVELOPER'|'AGENT' = 'DEVELOPER'): boolean {
if (text && text.trim().length > 0) {
this.comments.push({
author,
text: text.trim(),
timestamp: Date.now(),
});
return true;
}
return false;
}
/**
* Records UMA for a developer-authored comment when it leaves the DRAFT state.
* Returns true if this is the first user comment. Returns false otherwise.
*/
#recordCommentCreated(queued: boolean = false): void {
if (this.isGeneratedComment) {
return;
}
const text = this.comments.find(c => c.author === 'DEVELOPER')?.text.trim();
if (!text) {
return;
}
Host.userMetrics.commentCreated(1);
Host.userMetrics.commentSize(text.length);
if (queued) {
Host.userMetrics.commentQueued(1);
}
return;
}
save(text?: string, author: 'DEVELOPER'|'AGENT' = 'DEVELOPER'): void {
let changed = this.#saveText(text, author);
if (this.status === 'DRAFT') {
this.#recordCommentCreated(true);
this.status = 'ACTIVE';
changed = true;
}
if (changed) {
this.dispatchEventToListeners(Events.CHANGED);
}
}
editComment(text: string): void {
const trimmed = text.trim();
if (this.status !== 'ACTIVE' || !trimmed) {
return;
}
const comment = this.comments.find(c => c.author === 'DEVELOPER');
if (!comment || comment.text === trimmed) {
return;
}
comment.text = trimmed;
this.dispatchEventToListeners(Events.CHANGED);
}
sendToAgent(text?: string, author: 'DEVELOPER'|'AGENT' = 'DEVELOPER'): void {
let changed = this.#saveText(text, author);
if (this.status === 'DRAFT') {
this.#recordCommentCreated();
}
if (this.status === 'DRAFT' || this.status === 'ACTIVE') {
this.status = 'SENT_TO_AGENT';
changed = true;
}
if (changed) {
this.dispatchEventToListeners(Events.CHANGED);
}
}
resolve(replyText?: string): void {
const reply = replyText?.trim();
if (reply) {
this.comments.push({
author: 'AGENT',
text: reply,
timestamp: Date.now(),
});
}
// A new response, or a thread that is newly resolved, has not been seen yet.
if (this.status !== 'RESOLVED' || reply) {
this.responseSeen = false;
}
this.status = 'RESOLVED';
this.dispatchEventToListeners(Events.CHANGED);
}
/**
* Marks the agent response of a RESOLVED thread as seen by the developer.
*/
markResponseSeen(): void {
if (this.status !== 'RESOLVED' || this.responseSeen) {
return;
}
this.responseSeen = true;
this.dispatchEventToListeners(Events.CHANGED);
}
}
export class RevealableThread {
constructor(
readonly thread: CommentThread,
readonly position?: {left: number, top: number},
) {
}
}