blob: f129503dab5b38cc86501c53388bb13039f41be3 [file]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {fire} from '../../../utils/event-util';
import {fontStyles} from '../../../styles/gr-font-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {modalStyles} from '../../../styles/gr-modal-styles';
import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {GrButton} from '../../shared/gr-button/gr-button';
import {getAppContext} from '../../../services/app-context';
import {fireError} from '../../../utils/event-util';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {changeModelToken} from '../../../models/change/change-model';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {CommentThread} from '../../../types/common';
import {isUnresolved} from '../../../utils/comment-util';
import {ParsedChangeInfo} from '../../../types/types';
import {PatchSetNum} from '../../../types/common';
import {HELP_ME_REVIEW_PROMPT, IMPROVE_COMMIT_MESSAGE} from './prompts';
import {when} from 'lit/directives/when.js';
import {copyToClipboard} from '../../../utils/common-util';
import {Interaction} from '../../../constants/reporting';
import '@material/web/select/outlined-select';
import '@material/web/select/select-option';
import {materialStyles} from '../../../styles/gr-material-styles';
import '@material/web/radio/radio';
const PROMPT_TEMPLATES = {
HELP_REVIEW: {
id: 'help_review',
label: 'Help me with review',
prompt: HELP_ME_REVIEW_PROMPT,
},
IMPROVE_COMMIT_MESSAGE: {
id: 'improve_commit_message',
label: 'Improve commit message',
prompt: IMPROVE_COMMIT_MESSAGE,
},
PATCH_ONLY: {
id: 'patch_only',
label: 'Just patch content',
prompt: '{{patch}}',
},
RESOLVE_COMMENTS: {
id: 'resolve_comments',
label: 'Unresolved Comments',
prompt: '{{comments}}',
},
};
const CONTEXT_OPTIONS = [
{label: '3 lines (default)', value: 3},
{label: '10 lines', value: 10},
{label: '25 lines', value: 25},
{label: '50 lines', value: 50},
{label: '100 lines', value: 100},
];
type PromptTemplateId = keyof typeof PROMPT_TEMPLATES;
@customElement('gr-ai-prompt-dialog')
export class GrAiPromptDialog extends LitElement {
/**
* Fired when the user presses the close button.
*
* @event close
*/
@query('#closeButton') protected closeButton?: GrButton;
@state() change?: ParsedChangeInfo;
@state() patchNum?: PatchSetNum;
@state() patchContent?: string;
@state() loading = false;
@property({type: String}) selectedTemplate: PromptTemplateId = 'HELP_REVIEW';
@property({type: Number}) private context = 3;
// private but used in tests
@property({type: Array}) threads: CommentThread[] = [];
@state() private promptContent = '';
@state() private promptSize = '';
@state() private opened = false;
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getCommentsModel = resolve(this, commentsModelToken);
private readonly restApiService = getAppContext().restApiService;
private readonly reporting = getAppContext().reportingService;
constructor() {
super();
subscribe(
this,
() => this.getChangeModel().change$,
x => (this.change = x)
);
subscribe(
this,
() => this.getChangeModel().patchNum$,
x => (this.patchNum = x)
);
subscribe(
this,
() => this.getCommentsModel().threads$,
x => (this.threads = x)
);
}
static override get styles() {
return [
fontStyles,
sharedStyles,
modalStyles,
materialStyles,
css`
:host {
display: block;
padding: var(--spacing-m) 0;
}
section {
display: flex;
padding: var(--spacing-m) var(--spacing-xl);
}
.flexContainer {
display: flex;
justify-content: space-between;
padding-top: var(--spacing-m);
}
.footer {
justify-content: flex-end;
}
.info-text {
font-size: var(--font-size-small);
color: var(--deactivated-text-color);
max-width: 420px;
}
.closeButtonContainer {
align-items: flex-end;
display: flex;
flex: 0;
justify-content: flex-end;
}
.content {
width: 100%;
}
.options-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-m);
}
.template-options {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
}
.template-option {
display: flex;
align-items: center;
gap: var(--spacing-m);
margin-bottom: 0.5em;
}
textarea {
width: 550px;
height: 300px;
font-family: var(--monospace-font-family);
font-size: var(--font-size-mono);
line-height: var(--line-height-mono);
padding: var(--spacing-s);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
resize: vertical;
}
.toolbar {
display: flex;
gap: var(--spacing-s);
margin-top: var(--spacing-s);
justify-content: space-between;
align-items: center;
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing-l);
}
.size {
color: var(--deactivated-text-color);
}
.context-selector {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
`,
];
}
override render() {
return html`
<section>
<h3 class="heading-3">Copy AI Prompt (experimental)</h3>
</section>
${when(
this.loading,
() => html` <div class="loading">Loading patch ...</div>`,
() => html` <section class="flexContainer">
<div class="content">
${when(
this.getNumParents() === 1,
() => html`<div class="options-bar">
<div class="template-selector">
<div class="template-options">
${Object.entries(PROMPT_TEMPLATES).map(
([key, template]) => html`
<label class="template-option">
<md-radio
name="template"
.value=${key}
?checked=${this.selectedTemplate === key}
@change=${(e: Event) => {
const input = e.target as HTMLInputElement;
this.selectedTemplate =
input.value as PromptTemplateId;
}}
>
</md-radio>
${template.label}
</label>
`
)}
</div>
</div>
<div class="context-selector">
<md-outlined-select
label="Context"
value=${this.context.toString()}
@change=${(e: Event) => {
const select = e.target as HTMLSelectElement;
this.context = Number(select.value);
}}
>
${CONTEXT_OPTIONS.map(
option =>
html`<md-select-option
.value=${option.value.toString()}
>
<div slot="headline">${option.label}</div>
</md-select-option>`
)}
</md-outlined-select>
</div>
</div>
<textarea
.value=${this.promptContent}
readonly
placeholder="Patch content will appear here..."
></textarea>
<div class="toolbar">
<div class="info-text">
You can paste this prompt in an AI Model if your project
code can be shared with AI. We recommend a thinking model.
You can also use it for an AI Agent as context (a reference
to a git change).
</div>
<div class="actions">
<div class="size">${this.promptSize}</div>
<gr-button @click=${this.handleCopyPatch}>
<gr-icon icon="content_copy" small></gr-icon>
Copy Prompt
</gr-button>
</div>
</div>`,
() => html`
<div class="info-text">
This change has multiple parents. Currently, the "Create AI
Review Prompt" feature does not support multiple parents.
</div>
`
)}
</div>
</section>`
)}
<section class="footer">
<span class="closeButtonContainer">
<gr-button
id="closeButton"
link
@click=${(e: Event) => {
this.handleCloseTap(e);
}}
>Close</gr-button
>
</span>
</section>
`;
}
override firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
if (!this.getAttribute('role')) this.setAttribute('role', 'dialog');
}
override willUpdate(changedProperties: PropertyValues) {
if (!this.opened) return;
if (
changedProperties.has('patchContent') ||
changedProperties.has('selectedTemplate') ||
changedProperties.has('threads')
) {
this.updatePromptContent();
}
if (changedProperties.has('context')) {
this.loadPatchContent();
}
}
open() {
this.opened = true;
if (this.getNumParents() === 1) {
this.loadPatchContent();
}
}
private getNumParents() {
if (
!this.change ||
!this.change.current_revision ||
!this.change.revisions
) {
return 0;
}
const revision = this.change.revisions[this.change.current_revision];
return revision?.commit?.parents.length ?? 0;
}
private async loadPatchContent() {
if (!this.change || !this.patchNum) return;
this.loading = true;
const content = await this.restApiService.getPatchContent(
this.change._number,
this.patchNum,
this.context,
() => fireError(this, 'Failed to get patch content')
);
this.loading = false;
if (!content) return;
this.patchContent = content;
}
private getUnresolvedCommentsFormatted(): string {
const unresolvedThreads = this.threads.filter(isUnresolved);
if (unresolvedThreads.length === 0) return 'No unresolved comments.';
return unresolvedThreads
.map(thread => {
const comments = thread.comments.map(
c => `${c.author?.name ?? 'Unknown'}:\n${c.message}`
);
let loc = '';
if (thread.line) {
loc = `Line ${thread.line}`;
} else if (thread.range) {
loc = `Lines ${thread.range.start_line}-${thread.range.end_line}`;
} else {
loc = 'File level';
}
return `* File: ${thread.path} (${loc})
${comments.join('\n\n')}`;
})
.join('\n\n');
}
private updatePromptContent() {
if (!this.patchContent) {
this.promptContent = '';
this.promptSize = '';
return;
}
const template = PROMPT_TEMPLATES[this.selectedTemplate];
// Security compliance: Removing 2nd line of patchContent as it contains personal data.
const lines = this.patchContent.split('\n');
if (lines.length > 1 && lines[1].toLowerCase().startsWith('from:')) {
lines.splice(1, 1);
}
const sanitizedPatchContent = lines.join('\n');
this.promptContent = template.prompt.replace(
'{{patch}}',
() => sanitizedPatchContent
);
if (this.selectedTemplate === 'RESOLVE_COMMENTS') {
this.promptContent = this.promptContent.replace('{{comments}}', () =>
this.getUnresolvedCommentsFormatted()
);
}
// Inserts a space before each capital letter to handle CamelCase
const textWithSpaces = this.promptContent.replace(/([A-Z])/g, ' $1');
// Splits by whitespace, symbols, and now slashes
const size = textWithSpaces
.split(/["\s.(){}[\]\\/-]+/)
.filter(Boolean).length;
if (size === 0) {
this.promptSize = '';
} else if (size < 1000) {
this.promptSize = `${size} words`;
} else {
this.promptSize = `${(size / 1000).toFixed(1)}k words`;
}
}
private async handleCopyPatch(e: Event) {
e.preventDefault();
e.stopPropagation();
if (!this.promptContent) return;
this.reporting.reportInteraction(Interaction.COPY_AI_PROMPT, {
template: this.selectedTemplate,
context: this.context,
});
await copyToClipboard(this.promptContent, 'AI Prompt');
}
private handleCloseTap(e: Event) {
e.preventDefault();
e.stopPropagation();
this.opened = false;
this.patchContent = undefined;
this.promptContent = '';
this.promptSize = '';
fire(this, 'close', {});
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-ai-prompt-dialog': GrAiPromptDialog;
}
}