blob: eb399b82cefb052c28ba8fd72993ce922654d322 [file] [log] [blame]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@material/web/progress/circular-progress.js';
import '@material/web/button/filled-button.js';
import '@material/web/button/text-button.js';
import '../shared/gr-icon/gr-icon';
import '../shared/gr-button/gr-button';
import '../shared/gr-formatted-text/gr-formatted-text';
import './citations-box';
import './references-dropdown';
import './message-actions';
import {css, html, LitElement} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {when} from 'lit/directives/when.js';
import {changeModelToken} from '../../models/change/change-model';
import {
filesModelToken,
NormalizedFileInfo,
} from '../../models/change/files-model';
import {
chatModelToken,
CreateCommentPart,
GeminiMessage as GeminiMessageModel,
ResponsePartType,
Turn,
} from '../../models/chat/chat-model';
import {commentsModelToken} from '../../models/comments/comments-model';
import {resolve} from '../../models/dependency';
import {NumericChangeId, PatchSetNumber} from '../../types/common';
import {compareComments, createNew} from '../../utils/comment-util';
import {assert} from '../../utils/common-util';
import {subscribe} from '../lit/subscription-controller';
@customElement('gemini-message')
export class GeminiMessage extends LitElement {
@property({type: Number}) turnIndex = 0;
@property({type: Boolean}) isLatest = false;
/**
* A background request is a request that is not part of an active ongoing
* chat conversation, but just kicked off from the splash page.
*/
@property({type: Boolean}) isBackgroundRequest = false;
@state() turns: readonly Turn[] = [];
@state() fileEntities: {[path: string]: NormalizedFileInfo} = {};
@state() currentClNumber?: NumericChangeId;
@state() showErrorDetails = false;
@state() latestPatchNum?: PatchSetNumber;
private readonly getChatModel = resolve(this, chatModelToken);
private readonly getCommentsModel = resolve(this, commentsModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getFilesModel = resolve(this, filesModelToken);
static override styles = [
css`
:host {
display: block;
padding-top: var(--spacing-s);
padding-bottom: var(--spacing-s);
}
.material-icon {
vertical-align: middle;
}
.suggested-comment {
padding: 10px;
background-color: var(--background-color-tertiary);
border: 1px solid var(--border-color);
border-radius: 5px;
margin-bottom: 10px;
overflow-x: auto;
scrollbar-width: thin;
}
.thinking-indicator {
display: flex;
align-items: center;
}
.gemini-icon {
color: #4285f4;
}
.thinking-spinner {
--md-circular-progress-size: 24px;
margin-left: 10px;
}
.server-error {
display: flex;
align-items: center;
gap: var(--spacing-s);
font-weight: 500;
color: var(--error-foreground);
margin-bottom: var(--spacing-s);
}
.error-icon {
color: var(--error-foreground);
}
.error-message {
margin-bottom: var(--spacing-m);
color: var(--deemphasized-text-color);
}
.error-details {
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-s);
font-family: var(--monospace-font-family);
font-size: var(--font-size-small);
white-space: pre-wrap;
background-color: var(--background-color-tertiary);
padding: var(--spacing-s);
border-radius: var(--border-radius);
}
.error-actions {
display: flex;
gap: var(--spacing-m);
margin-top: var(--spacing-s);
}
.user-info {
margin-bottom: var(--spacing-m);
}
.text-response {
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-xl);
}
references-dropdown {
margin-bottom: var(--spacing-l);
}
.text-content {
overflow-x: auto;
scrollbar-width: thin;
}
.comment-path,
.comment-line {
display: flex;
align-items: center;
gap: var(--spacing-s);
margin-bottom: var(--spacing-xs);
color: var(--link-color);
text-decoration: none;
}
.comment-path gr-icon,
.comment-line gr-icon {
font-size: 16px;
}
.suggested-comment-message {
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-m);
}
`,
];
constructor() {
super();
subscribe(
this,
() => this.getChatModel().turns$,
x => (this.turns = x ?? [])
);
subscribe(
this,
() => this.getFilesModel().files$,
x => {
const fileEntities: {[path: string]: NormalizedFileInfo} = {};
for (const file of x) {
fileEntities[file.__path] = file;
}
this.fileEntities = fileEntities;
}
);
subscribe(
this,
() => this.getChangeModel().changeNum$,
x => (this.currentClNumber = x)
);
subscribe(
this,
() => this.getChangeModel().latestPatchNum$,
x => (this.latestPatchNum = x)
);
}
private async onAddAsComment(part: CreateCommentPart) {
const draft = {
...part.comment,
...createNew(part.comment.message, true),
};
if (!draft.patch_set) {
draft.patch_set = this.latestPatchNum;
}
// TODO(milutin): Remove this once Gemini or backend fixes the issue.
if (draft.range && draft.range.end_line < draft.range.start_line) {
draft.range.end_line = draft.range.start_line;
}
await this.getCommentsModel().saveDraft(draft);
this.getCommentsModel().reloadAllComments();
}
private onRetry() {
this.getChatModel().regenerateMessage(this.turnId());
}
private toggleShowErrorDetails() {
this.showErrorDetails = !this.showErrorDetails;
}
override render() {
if (this.turnIndex >= this.turns.length) return;
const message = this.message();
if (!message) return;
const responseParts = message.responseParts;
const textParts = responseParts.filter(
part => part.type === ResponsePartType.TEXT
);
return html`
${when(
!this.isBackgroundRequest,
() => html`
<div class="user-info">
<gr-icon
class="gemini-icon"
icon="robot_2"
.title=${message.timestamp
? new Date(message.timestamp).toLocaleString()
: ''}
></gr-icon>
</div>
`
)}
${when(
message.errorMessage,
() => html`
<div class="server-error text-content">
<gr-icon icon="error" class="error-icon"></gr-icon>
Server error
</div>
<div class="error-message">
We were unable to fulfill your request.
${when(
this.showErrorDetails,
() => html`<p class="error-details">${message.errorMessage}</p>`
)}
<div class="error-actions">
<gr-button @click=${() => this.onRetry()} link>Retry</gr-button>
<gr-button @click=${() => this.toggleShowErrorDetails()} link>
${this.showErrorDetails ? 'Hide details' : 'Show details'}
</gr-button>
</div>
</div>
`
)}
${when(!message.errorMessage && responseParts.length === 0, () =>
when(
message.responseComplete,
() => html`<p class="text-content">
The server did not return any response.
</p>`,
() => html`<div class="thinking-indicator">
<p class="text-content">Thinking ...</p>
${when(
!this.isBackgroundRequest,
() => html`
<md-circular-progress
class="thinking-spinner"
indeterminate
size="small"
></md-circular-progress>
`
)}
</div>`
)
)}
${when(
!message.errorMessage && responseParts.length > 0,
() => html`
${textParts.map(
responsePart => html`
<p class="text-content text-response">
<gr-formatted-text
.markdown=${true}
.content=${responsePart.content}
></gr-formatted-text>
</p>
`
)}
${when(!this.isBackgroundRequest, () =>
this.sortedComments().map(
comment => html`
${when(
comment.comment.path,
() => html`
<div class="comment-path">
<gr-icon icon="description"></gr-icon>
${comment.comment.path}
</div>
`
)}
${when(
comment.comment.line,
() => html`
<div class="comment-line">
<gr-icon icon="code"></gr-icon>
${comment.comment.line}
</div>
`
)}
<div class="suggested-comment">
<p class="suggested-comment-message">
<gr-formatted-text
.markdown=${true}
.content=${comment.comment.message}
></gr-formatted-text>
</p>
<gr-button
primary
class="add-as-comment-button"
@click=${() => this.onAddAsComment(comment)}
>Add as Comment
</gr-button>
</div>
`
)
)}
${when(
message.responseComplete && !this.isBackgroundRequest,
() => html`
<citations-box .turnIndex=${this.turnIndex}></citations-box>
<references-dropdown
.turnIndex=${this.turnIndex}
></references-dropdown>
<message-actions
.turnId=${this.turnId()}
.isLatest=${this.isLatest}
></message-actions>
`
)}
`
)}
`;
}
private message(): GeminiMessageModel {
assert(this.turnIndex < this.turns.length, 'turnIndex out of bounds');
return this.turns[this.turnIndex].geminiMessage;
}
private sortedComments() {
return this.message()
.responseParts.filter(
part => part.type === ResponsePartType.CREATE_COMMENT
)
.sort((p1, p2) => {
const c1 = {...createNew(p1.comment.message), ...p1.comment};
const c2 = {...createNew(p2.comment.message), ...p2.comment};
return compareComments(c1, c2);
});
}
private turnId() {
return {
turnIndex: this.turnIndex,
regenerationIndex: this.message().regenerationIndex,
};
}
}
declare global {
interface HTMLElementTagNameMap {
'gemini-message': GeminiMessage;
}
}