blob: 7fd3468d7ed583e0ce5e29c4ae7d88642912aa95 [file] [log] [blame]
/**
* @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, 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 {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';
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}}',
},
};
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;
@state() selectedTemplate: PromptTemplateId = 'HELP_REVIEW';
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly restApiService = getAppContext().restApiService;
constructor() {
super();
subscribe(
this,
() => this.getChangeModel().change$,
x => (this.change = x)
);
subscribe(
this,
() => this.getChangeModel().patchNum$,
x => (this.patchNum = x)
);
}
static override get styles() {
return [
fontStyles,
sharedStyles,
modalStyles,
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;
}
.closeButtonContainer {
align-items: flex-end;
display: flex;
flex: 0;
justify-content: flex-end;
}
.content {
width: 100%;
}
.template-selector {
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-s);
}
textarea {
width: 500px;
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);
}
`,
];
}
override render() {
return html`
<section>
<h3 class="heading-3">Create AI Prompt (experimental)</h3>
</section>
${when(
this.loading,
() => html` <div class="loading">Loading patch ...</div>`,
() => html` <section class="flexContainer">
<div class="content">
<div class="template-selector">
<div class="template-options">
${Object.entries(PROMPT_TEMPLATES).map(
([key, template]) => html`
<label class="template-option">
<input
type="radio"
name="template"
.value=${key}
?checked=${this.selectedTemplate === key}
@change=${(e: Event) => {
const input = e.target as HTMLInputElement;
this.selectedTemplate =
input.value as PromptTemplateId;
}}
/>
${template.label}
</label>
`
)}
</div>
</div>
<textarea
.value=${this.getPromptContent()}
readonly
placeholder="Patch content will appear here..."
></textarea>
<div class="toolbar">
<gr-button @click=${this.handleCopyPatch}>
<gr-icon icon="content_copy" small></gr-icon>
Copy Prompt
</gr-button>
</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 (changedProperties.has('change') || changedProperties.has('patchNum')) {
this.loadPatchContent();
}
}
private async loadPatchContent() {
if (!this.change || !this.patchNum) return;
this.loading = true;
const content = await this.restApiService.getPatchContent(
this.change._number,
this.patchNum
);
this.loading = false;
if (!content) {
fireError(this, 'Failed to get patch content');
return;
}
this.patchContent = content;
}
private getPromptContent(): string {
if (!this.patchContent) return '';
const template = PROMPT_TEMPLATES[this.selectedTemplate];
return template.prompt.replace('{{patch}}', this.patchContent);
}
private async handleCopyPatch(e: Event) {
e.preventDefault();
e.stopPropagation();
if (!this.patchContent) return;
await copyToClipboard(this.getPromptContent(), 'AI Prompt');
}
private handleCloseTap(e: Event) {
e.preventDefault();
e.stopPropagation();
fire(this, 'close', {});
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-ai-prompt-dialog': GrAiPromptDialog;
}
}