| // 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}, | |
| ) { | |
| } | |
| } |