| // 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 '../../ui/components/spinners/spinners.js'; | |
| import '../../ui/components/tooltips/tooltips.js'; | |
| import * as Common from '../../core/common/common.js'; | |
| import * as i18n from '../../core/i18n/i18n.js'; | |
| import * as CommentManager from '../../models/comment_manager/comment_manager.js'; | |
| import * as Buttons from '../../ui/components/buttons/buttons.js'; | |
| import * as UI from '../../ui/legacy/legacy.js'; | |
| import * as Lit from '../../ui/lit/lit.js'; | |
| import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; | |
| import * as CommonPanels from '../common/common.js'; | |
| import commentsPaneStyles from './commentsPane.css.js'; | |
| const {html, render, i18nTemplate, Directives: {classMap, ref, repeat}} = Lit; | |
| const {widget} = UI.Widget; | |
| const {computeCommentTitle, inputDisclaimer} = CommonPanels.CommentThreadWidget; | |
| type Title = CommonPanels.CommentThreadWidget.Title; | |
| const UIStrings = { | |
| /** | |
| * @description Tooltip text for clearing all comments. | |
| */ | |
| clearComments: 'Clear all comments', | |
| /** | |
| * @description Title of the empty state shown in the Comments panel when there are no queued comments. | |
| */ | |
| noQueuedComments: 'No queued comments', | |
| /** | |
| * @description Title of the empty state shown in the Comments panel when there are no comments at all. | |
| */ | |
| noComments: 'No comments', | |
| /** | |
| * @description Description in the empty state of the Comments panel. PH1 is the comment mode icon, the same icon as | |
| * the button that turns on comment mode. | |
| * @example {(comment icon)} PH1 | |
| */ | |
| emptyStateDescription: 'Use {PH1} to select an element and send tasks, questions, and context to your coding agent', | |
| /** | |
| * @description Accessible name of the comment mode icon shown inside the empty state description of the Comments panel. | |
| */ | |
| commentModeIcon: 'Comment mode', | |
| /** | |
| * @description Link in the empty state of the Comments panel that turns on comment mode so the user can add a comment. | |
| */ | |
| addComment: 'Add a comment', | |
| /** | |
| * @description Tooltip and aria-label for deleting a comment thread. | |
| */ | |
| deleteComment: 'Delete comment', | |
| /** | |
| * @description Button text for sending comments to the agent. | |
| */ | |
| sendToAgent: 'Send to agent', | |
| /** | |
| * @description Status text shown next to a spinner when a comment thread is being processed by the agent. | |
| */ | |
| working: 'Working', | |
| /** | |
| * @description Status text shown next to a checkmark when a comment thread has been sent but not yet picked up by | |
| * the agent. | |
| */ | |
| sentStatus: 'Sent', | |
| /** | |
| * @description Alt text for the checkmark icon shown on a comment thread that has been sent but not yet picked up | |
| * by the agent. | |
| */ | |
| sentCheckmark: 'Sent checkmark', | |
| /** | |
| * @description Button text shown on resolved comment threads to view the agent response. | |
| */ | |
| seeResponse: 'See response', | |
| /** | |
| * @description Tab label for queued comments in the comments drawer. | |
| */ | |
| queued: 'Queued', | |
| /** | |
| * @description Tab label for sent and resolved comments in the comments drawer. | |
| */ | |
| sent: 'Sent', | |
| /** | |
| * @description Label shown next to an element anchor in the comments drawer when the target DOM node has been removed or can no longer be found. | |
| */ | |
| removed: 'removed', | |
| /** | |
| * @description Text in the bar that visually separates the queued comments from the comments already sent to the agent in the comments drawer. | |
| */ | |
| sentComments: 'Sent comments', | |
| } as const; | |
| const str_ = i18n.i18n.registerUIStrings('panels/comments/CommentsPane.ts', UIStrings); | |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); | |
| export type Tab = 'queued'|'sent'; | |
| const TOGGLE_COMMENT_MODE_ACTION_ID = 'comments.toggle-comment-mode'; | |
| export interface ThreadViewData { | |
| thread: CommentManager.CommentManager.CommentThread; | |
| title: Title; | |
| commentText: string; | |
| } | |
| export interface ViewInput { | |
| activeTab: Tab; | |
| queuedThreads: ThreadViewData[]; | |
| sentThreads: ThreadViewData[]; | |
| onTabClick: (tab: Tab) => void; | |
| onScroll: () => void; | |
| onScrollEnd: () => void; | |
| onClearAll: () => void; | |
| onThreadClick: ( | |
| thread: CommentManager.CommentManager.CommentThread, | |
| fallbackPosition?: {left: number, top: number}, | |
| ) => void; | |
| onDeleteThread: (threadId: string) => void; | |
| onSendToAgent: () => void; | |
| onAddComment: () => void; | |
| } | |
| export interface ViewOutput { | |
| /** The element that scrolls the comments. Only used to restore its scroll position. */ | |
| scrollContainer?: HTMLElement; | |
| /** | |
| * Scrolls "Queued" to the very top and "Sent" to the sent section, if there is one. | |
| * Returns whether a smooth scroll started, which ends with a `scrollend` event. | |
| */ | |
| scrollToSection?: (tab: Tab, options: {animate: boolean}) => boolean; | |
| /** The section that has reached the top of the list. */ | |
| sectionAtTop?: () => Tab; | |
| } | |
| export type View = (input: ViewInput, output: ViewOutput, target: HTMLElement) => void; | |
| function renderThreadStatus( | |
| thread: CommentManager.CommentManager.CommentThread, | |
| onDeleteThread: (threadId: string) => void, | |
| ): Lit.LitTemplate { | |
| // clang-format off | |
| switch (thread.status) { | |
| case 'SENT_TO_AGENT': | |
| if (!thread.transmitted) { | |
| return html` | |
| <div class="thread-status-sent"> | |
| <devtools-icon | |
| class="check-icon" | |
| name="checkmark" | |
| aria-label=${i18nString(UIStrings.sentCheckmark)}> | |
| </devtools-icon> | |
| <span>${i18nString(UIStrings.sentStatus)}</span> | |
| </div> | |
| `; | |
| } | |
| return html` | |
| <div class="thread-status-working"> | |
| <devtools-spinner></devtools-spinner> | |
| <span>${i18nString(UIStrings.working)}</span> | |
| </div> | |
| `; | |
| case 'RESOLVED': | |
| return html` | |
| <devtools-button | |
| class="see-response-button" | |
| .data=${{ | |
| variant: Buttons.Button.Variant.TEXT, | |
| jslogContext: 'comments-see-response', | |
| } as Buttons.Button.ButtonData} | |
| @keydown=${(e: KeyboardEvent) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.stopPropagation(); | |
| } | |
| }} | |
| >${i18nString(UIStrings.seeResponse)}</devtools-button> | |
| `; | |
| case 'ACTIVE': | |
| return html` | |
| <devtools-button | |
| .data=${{ | |
| variant: Buttons.Button.Variant.ICON, | |
| iconName: 'bin', | |
| title: i18nString(UIStrings.deleteComment), | |
| jslogContext: 'delete', | |
| } as Buttons.Button.ButtonData} | |
| @click=${(e: Event) => { | |
| e.stopPropagation(); | |
| onDeleteThread(thread.id); | |
| }} | |
| @keydown=${(e: KeyboardEvent) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.stopPropagation(); | |
| } | |
| }} | |
| ></devtools-button> | |
| `; | |
| default: | |
| return Lit.nothing; | |
| } | |
| // clang-format on | |
| } | |
| export const DEFAULT_VIEW: View = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => { | |
| const isQueued = input.activeTab === 'queued'; | |
| const isSent = input.activeTab === 'sent'; | |
| const hasThreads = input.queuedThreads.length > 0 || input.sentThreads.length > 0; | |
| let sentSection: HTMLElement|undefined; | |
| output.scrollToSection = (tab, {animate}) => { | |
| const {scrollContainer} = output; | |
| const top = tab === 'queued' ? 0 : sentSection?.offsetTop; | |
| if (!scrollContainer || top === undefined || Math.abs(top - scrollContainer.scrollTop) < 1) { | |
| return false; | |
| } | |
| scrollContainer.scrollTo({top, behavior: animate ? 'smooth' : 'instant'}); | |
| return animate; | |
| }; | |
| // "Sent" once the sent section has scrolled up to the top edge of the list. | |
| output.sectionAtTop = () => { | |
| const {scrollContainer} = output; | |
| return scrollContainer && sentSection && sentSection.offsetTop - scrollContainer.scrollTop <= 1 ? 'sent' : 'queued'; | |
| }; | |
| const onThreadClick = (e: Event, thread: CommentManager.CommentManager.CommentThread): void => { | |
| const rowEl = e.currentTarget as HTMLElement; | |
| const rowRect = rowEl.getBoundingClientRect(); | |
| const badgeRect = rowEl.querySelector('.comment-pin-badge')?.getBoundingClientRect(); | |
| input.onThreadClick(thread, {left: badgeRect?.left ?? rowRect.left, top: rowRect.top}); | |
| }; | |
| // clang-format off | |
| const renderThreadList = (threads: ThreadViewData[], className: string): Lit.LitTemplate => html` | |
| <ul class="comments-list ${className}" role="list"> | |
| ${repeat( | |
| threads, | |
| item => item.thread.id, | |
| item => html` | |
| <li | |
| class="comment-thread-item ${item.thread.isOrphaned ? 'orphaned' : ''}" | |
| role="listitem" | |
| tabindex="0" | |
| @click=${(e: Event) => onThreadClick(e, item.thread)} | |
| @keydown=${(e: KeyboardEvent) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| onThreadClick(e, item.thread); | |
| } | |
| }} | |
| jslog=${VisualLogging.item('comment-thread').track({click: true})}> | |
| <div | |
| class=${classMap({ | |
| 'comment-pin-badge': true, | |
| resolved: item.thread.status === 'RESOLVED', | |
| })} | |
| aria-hidden="true"> | |
| </div> | |
| <span class="anchor-chip ${item.thread.isOrphaned ? 'orphaned' : ''}"> | |
| ${'node' in item.title ? | |
| widget(CommonPanels.DOMLinkifier.DOMNodeLink, { | |
| node: item.title.node, | |
| options: {preventKeyboardFocus: true, disabled: item.thread.isOrphaned}, | |
| }) : | |
| html`<span class="anchor-chip-text">${item.title.text}</span>`} | |
| ${item.thread.isOrphaned ? | |
| html`<span class="orphaned-label">(${i18nString(UIStrings.removed)})</span>` : | |
| Lit.nothing} | |
| </span> | |
| <div class="comment-text">${item.commentText}</div> | |
| ${renderThreadStatus(item.thread, input.onDeleteThread)} | |
| </li> | |
| `, | |
| )} | |
| </ul> | |
| `; | |
| const renderFooter = (): Lit.LitTemplate => html` | |
| <div class="comments-footer"> | |
| <devtools-icon | |
| class="info-icon" | |
| name="info" | |
| aria-label="Info" | |
| aria-details="comments-pane-info-tooltip" | |
| tabindex="0" | |
| ></devtools-icon> | |
| <devtools-tooltip | |
| id="comments-pane-info-tooltip" | |
| variant="rich" | |
| > | |
| <div class="info-tooltip-container"> | |
| ${inputDisclaimer()} | |
| </div> | |
| </devtools-tooltip> | |
| <button | |
| class="send-agent-button" | |
| @click=${input.onSendToAgent} | |
| jslog=${VisualLogging.action('comments-send-to-agent').track({click: true})}> | |
| ${i18nString(UIStrings.sendToAgent)} | |
| </button> | |
| </div> | |
| `; | |
| const renderEmptyState = (title: string): Lit.LitTemplate => html` | |
| <div class="comments-empty-state"> | |
| <div class="comments-empty-state-title">${title}</div> | |
| <div class="comments-empty-state-description"> | |
| ${i18nTemplate(str_, UIStrings.emptyStateDescription, { | |
| PH1: html`<devtools-icon | |
| class="comments-empty-state-icon" | |
| name="comment-mode" | |
| role="img" | |
| aria-label=${i18nString(UIStrings.commentModeIcon)} | |
| ></devtools-icon>`, | |
| })} | |
| </div> | |
| <devtools-button | |
| class="comments-empty-state-link" | |
| .data=${{ | |
| variant: Buttons.Button.Variant.TEXT, | |
| jslogContext: 'comments-add-comment', | |
| } as Buttons.Button.ButtonData} | |
| @click=${input.onAddComment} | |
| >${i18nString(UIStrings.addComment)}</devtools-button> | |
| </div> | |
| `; | |
| render(html` | |
| <style>${commentsPaneStyles}</style> | |
| <div class="comments-container" role="region" aria-label="Comments" jslog=${VisualLogging.panel('comments').track({resize: true})}> | |
| <div class="comments-toolbar" role="toolbar" jslog=${VisualLogging.toolbar('comments-drawer')}> | |
| <div class="toolbar-left"> | |
| <button | |
| class="tab-button ${isQueued ? 'active' : ''}" | |
| aria-current=${isQueued ? 'location' : 'false'} | |
| @click=${() => input.onTabClick('queued')} | |
| jslog=${VisualLogging.sectionHeader('queued').track({ click: true })}> | |
| ${i18nString(UIStrings.queued)} | |
| </button> | |
| <button | |
| class="tab-button ${isSent ? 'active' : ''}" | |
| aria-current=${isSent ? 'location' : 'false'} | |
| @click=${() => input.onTabClick('sent')} | |
| jslog=${VisualLogging.sectionHeader('sent').track({ click: true })}> | |
| ${i18nString(UIStrings.sent)} | |
| </button> | |
| </div> | |
| <button | |
| class="toolbar-button" | |
| title=${i18nString(UIStrings.clearComments)} | |
| aria-label=${i18nString(UIStrings.clearComments)} | |
| @click=${input.onClearAll} | |
| jslog=${VisualLogging.action('clear-comments').track({ click: true })}> | |
| <devtools-icon name="clear"></devtools-icon> | |
| </button> | |
| </div> | |
| ${hasThreads ? html` | |
| <div | |
| class="comments-scroll-container" | |
| ${ref(element => { | |
| output.scrollContainer = element as HTMLElement | undefined; | |
| })} | |
| @scroll=${input.onScroll} | |
| @scrollend=${input.onScrollEnd}> | |
| <div class="comments-queued-section"> | |
| ${input.queuedThreads.length > 0 ? html` | |
| ${renderThreadList(input.queuedThreads, 'comments-queued-list')} | |
| ${renderFooter()} | |
| ` : renderEmptyState(i18nString(UIStrings.noQueuedComments))} | |
| </div> | |
| ${input.sentThreads.length > 0 ? html` | |
| <div class="comments-sent-section" ${ref(element => { | |
| sentSection = element as HTMLElement | undefined; | |
| })}> | |
| <div class="comments-sent-separator" role="separator" aria-label=${i18nString(UIStrings.sentComments)}> | |
| ${i18nString(UIStrings.sentComments)} | |
| </div> | |
| ${renderThreadList(input.sentThreads, 'comments-sent-list')} | |
| </div> | |
| ` : Lit.nothing} | |
| </div> | |
| ` : renderEmptyState(i18nString(UIStrings.noComments))} | |
| </div> | |
| `, target); | |
| // clang-format on | |
| }; | |
| export class CommentsPane extends UI.Widget.Widget { | |
| static override readonly INJECT: readonly[typeof CommentManager.CommentManager.CommentManager, | |
| ] = | |
| [ | |
| CommentManager.CommentManager.CommentManager, | |
| ] as const; | |
| readonly #view: View; | |
| readonly #commentManager: CommentManager.CommentManager.CommentManager; | |
| readonly #cachedTitles = new WeakMap<CommentManager.CommentManager.CommentThread, Title>(); | |
| readonly #viewOutput: ViewOutput = {}; | |
| #activeTab: Tab = 'queued'; | |
| // Set while a tab click smoothly scrolls the list. Scroll events then don't | |
| // change the highlighted tab, so it doesn't flicker on the way. | |
| #scrollingToTab = false; | |
| // Whether the previous render had sent comments and nothing queued. We jump | |
| // to Sent only when this changes from false to true. Without it, every update | |
| // (e.g. the agent resolving a thread) would jump back to Sent even after the | |
| // user clicked Queued or scrolled up. | |
| #onlySentComments = false; | |
| constructor( | |
| element?: HTMLElement, | |
| [commentManager]: UI.Widget.WidgetDependencies<typeof CommentsPane> = | |
| [ | |
| new CommentManager.CommentManager.CommentManager(), | |
| ], | |
| view: View = DEFAULT_VIEW, | |
| ) { | |
| super(element); | |
| this.#view = view; | |
| this.#commentManager = commentManager; | |
| } | |
| #onThreadsChanged = (): void => { | |
| this.requestUpdate(); | |
| }; | |
| override wasShown(): void { | |
| super.wasShown(); | |
| this.#commentManager.addEventListener( | |
| CommentManager.CommentManager.Events.COMMENT_THREADS_CHANGED, | |
| this.#onThreadsChanged, | |
| this, | |
| ); | |
| // Re-evaluated on the next render, so that opening the drawer with only | |
| // sent comments shows them at the top. | |
| this.#onlySentComments = false; | |
| this.requestUpdate(); | |
| } | |
| /** | |
| * Lets `UI.Widget` store the list's scroll position when the drawer is hidden | |
| * and restore it when it is shown again. | |
| */ | |
| override elementsToRestoreScrollPositionsFor(): Element[] { | |
| const {scrollContainer} = this.#viewOutput; | |
| return scrollContainer ? [this.element, scrollContainer] : [this.element]; | |
| } | |
| override willHide(): void { | |
| this.#scrollingToTab = false; | |
| this.#commentManager.removeEventListener( | |
| CommentManager.CommentManager.Events.COMMENT_THREADS_CHANGED, | |
| this.#onThreadsChanged, | |
| this, | |
| ); | |
| super.willHide(); | |
| } | |
| #setActiveTab(tab: Tab): void { | |
| if (this.#activeTab === tab) { | |
| return; | |
| } | |
| this.#activeTab = tab; | |
| this.requestUpdate(); | |
| } | |
| #handleTabClick = (tab: Tab): void => { | |
| this.#scrollingToTab = this.#viewOutput.scrollToSection?.(tab, {animate: true}) ?? false; | |
| this.#setActiveTab(tab); | |
| }; | |
| #handleScroll = (): void => { | |
| if (!this.#scrollingToTab && this.#viewOutput.sectionAtTop) { | |
| this.#setActiveTab(this.#viewOutput.sectionAtTop()); | |
| } | |
| }; | |
| // Fires when a tab-click scroll finishes or the user interrupts it. | |
| #handleScrollEnd = (): void => { | |
| this.#scrollingToTab = false; | |
| this.#handleScroll(); | |
| }; | |
| #handleClearAll = (): void => { | |
| this.#commentManager.clear(); | |
| this.requestUpdate(); | |
| }; | |
| #handleThreadClick = ( | |
| thread: CommentManager.CommentManager.CommentThread, | |
| position?: {left: number, top: number}, | |
| ): void => { | |
| // Generated comments can only be revealed once resolved, to read the response. | |
| if (thread.isGeneratedComment && thread.status !== 'RESOLVED') { | |
| return; | |
| } | |
| void Common.Revealer.reveal(new CommentManager.CommentThread.RevealableThread(thread, position)); | |
| }; | |
| #handleDeleteThread = (threadId: string): void => { | |
| this.#commentManager.removeCommentThread(threadId); | |
| this.requestUpdate(); | |
| }; | |
| #handleSendToAgent = (): void => { | |
| this.#commentManager.sendQueuedThreads(); | |
| }; | |
| #handleAddComment = (): void => { | |
| const actionRegistry = UI.ActionRegistry.ActionRegistry.instance(); | |
| if (this.#commentManager.isCommentMode() || !actionRegistry.hasAction(TOGGLE_COMMENT_MODE_ACTION_ID)) { | |
| return; | |
| } | |
| void actionRegistry.getAction(TOGGLE_COMMENT_MODE_ACTION_ID).execute(); | |
| }; | |
| async #toViewData(thread: CommentManager.CommentManager.CommentThread): Promise<ThreadViewData> { | |
| let title = this.#cachedTitles.get(thread); | |
| if (!title) { | |
| title = await computeCommentTitle(thread.anchor); | |
| this.#cachedTitles.set(thread, title); | |
| } | |
| return { | |
| thread, | |
| title, | |
| commentText: thread.comments[0]?.text ?? '', | |
| }; | |
| } | |
| override async performUpdate(signal?: AbortSignal): Promise<void> { | |
| const allThreads = this.#commentManager.getCommentThreads(); | |
| const queued = allThreads.filter(thread => thread.status === 'ACTIVE'); | |
| const sent = allThreads.filter(thread => thread.status === 'SENT_TO_AGENT' || thread.status === 'RESOLVED'); | |
| const [queuedThreads, sentThreads] = await Promise.all([ | |
| Promise.all(queued.map(thread => this.#toViewData(thread))), | |
| Promise.all(sent.map(thread => this.#toViewData(thread))), | |
| ]); | |
| if (signal?.aborted) { | |
| return; | |
| } | |
| // When the list switches to only sent comments (on open, or e.g. after | |
| // sending the last queued comment), show the sent comments at the top. | |
| const onlySentComments = queuedThreads.length === 0 && sentThreads.length > 0; | |
| const showSentComments = onlySentComments && !this.#onlySentComments; | |
| this.#onlySentComments = onlySentComments; | |
| if (showSentComments) { | |
| this.#activeTab = 'sent'; | |
| } else if (sentThreads.length === 0) { | |
| this.#activeTab = 'queued'; | |
| } | |
| const viewInput: ViewInput = { | |
| activeTab: this.#activeTab, | |
| queuedThreads, | |
| sentThreads, | |
| onTabClick: this.#handleTabClick, | |
| onScroll: this.#handleScroll, | |
| onScrollEnd: this.#handleScrollEnd, | |
| onClearAll: this.#handleClearAll, | |
| onThreadClick: this.#handleThreadClick, | |
| onDeleteThread: this.#handleDeleteThread, | |
| onSendToAgent: this.#handleSendToAgent, | |
| onAddComment: this.#handleAddComment, | |
| }; | |
| this.#view(viewInput, this.#viewOutput, this.contentElement); | |
| if (showSentComments) { | |
| this.#viewOutput.scrollToSection?.('sent', {animate: false}); | |
| } | |
| } | |
| } |