blob: 25e15a20c49ca2482e3cc3feed3a2b3a53d9b0d3 [file] [log] [blame]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@material/web/button/text-button';
import '@material/web/icon/icon';
import '../shared/gr-icon/gr-icon';
import {css, html, LitElement, nothing} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {when} from 'lit/directives/when.js';
import {ContextItemType} from '../../api/ai-code-review';
import {chatModelToken, Turn} from '../../models/chat/chat-model';
import {resolve} from '../../models/dependency';
import {subscribe} from '../lit/subscription-controller';
/**
* A component to display a dropdown with references used by the model.
*/
@customElement('references-dropdown')
export class ReferencesDropdown extends LitElement {
private readonly getChatModel = resolve(this, chatModelToken);
@property({type: Number}) turnIndex = 0;
@state() private turns: readonly Turn[] = [];
@state() private contextItemTypes: readonly ContextItemType[] = [];
@state() private showReferences = false;
@state() private listWarnings = false;
static override styles = [
css`
:host {
display: block;
}
.references-dropdown-container {
display: flex;
flex-direction: row;
align-items: center;
/* Match the vertical line in the mockup */
border-left: 1px solid var(--border-color);
padding-left: var(--spacing-m);
}
.references-dropdown-content {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
padding: var(--spacing-s) 0 0 var(--spacing-m);
/* Match the vertical line in the mockup */
border-left: 1px solid var(--border-color);
}
.references-dropdown-button {
--md-text-button-label-text-color: var(--link-color);
--md-text-button-icon-color: var(--link-color);
--md-text-button-label-text-font: var(--font-family);
--md-text-button-label-text-weight: 500;
--md-text-button-label-text-size: var(--font-size-normal);
--md-text-button-hover-label-text-color: var(--link-color);
--md-text-button-hover-icon-color: var(--link-color);
--md-text-button-focus-label-text-color: var(--link-color);
--md-text-button-focus-icon-color: var(--link-color);
--md-text-button-pressed-label-text-color: var(--link-color);
--md-text-button-pressed-icon-color: var(--link-color);
margin-left: calc(-1 * var(--spacing-m));
}
.button-outer-wrapper {
display: inline-block;
max-width: 100%;
}
.reference-button {
height: 32px;
background-color: transparent;
border-radius: 8px;
max-width: inherit;
/* For <a> and <button> to look like buttons. */
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
text-decoration: none;
border: none;
font: inherit;
cursor: pointer;
}
.reference-button.pill-link {
background-color: var(--background-color-tertiary);
padding: 0 12px;
border-radius: 8px;
height: 32px;
}
.reference-button.cl-references-button,
.reference-button.list-warnings-button,
.reference-button.selection-button {
background-color: transparent;
}
.reference-button[disabled] {
cursor: default;
}
.reference-wrapper {
display: flex;
align-items: center;
vertical-align: middle;
gap: var(--spacing-s);
color: var(--primary-text-color);
padding-top: 2px;
font-weight: var(--font-weight-medium);
font-size: var(--font-size-normal);
line-height: var(--line-height-normal);
}
.reference-wrapper .display-text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
flex: 0 1 auto;
}
.reference-wrapper .additional-text {
flex-shrink: 0;
}
.reference-wrapper .reference-icon {
flex: 0 0 auto;
height: 20px;
width: 20px;
}
.reference-wrapper .reference-icon.selection-icon {
max-width: 100%;
max-height: 100%;
color: var(--file-chip-avatar-color);
margin-right: 3px;
margin-left: -3px;
margin-top: -5px;
transform: scale(0.85);
}
.reference-button .expand-icon {
transform: scale(0.5);
}
.cl-references,
.warnings-list {
margin: var(--spacing-s) 0;
padding-left: 20px;
list-style-type: disc;
}
.cl-references li,
.warnings-list li {
color: var(--primary-text-color);
overflow-wrap: break-word;
font-size: var(--font-size-normal);
line-height: var(--line-height-normal);
}
.warning-icon {
color: var(--warning-icon, purple);
}
.warning-icon.reference-icon {
transform: scale(0.8);
margin-left: -2px;
margin-top: -6px;
padding-right: 2px;
color: var(--warning-icon, purple);
}
`,
];
constructor() {
super();
subscribe(
this,
() => this.getChatModel().turns$,
x => (this.turns = x ?? [])
);
subscribe(
this,
() => this.getChatModel().contextItemTypes$,
x => (this.contextItemTypes = x ?? [])
);
}
private get turn(): Turn | undefined {
if (!this.turns || this.turnIndex >= this.turns.length) {
return undefined;
}
return this.turns[this.turnIndex];
}
private get dynamicReferences() {
return this.turn?.geminiMessage.references ?? [];
}
get validDynamicReferences() {
return this.dynamicReferences.filter(reference => !reference.errorMsg);
}
get dynamicReferencesWithErrors() {
return this.dynamicReferences.filter(reference => !!reference.errorMsg);
}
get totalReferencesCount() {
return this.validDynamicReferences.length;
}
get hasErrors() {
return this.dynamicReferencesWithErrors.length !== 0;
}
private toggleShowReferences() {
this.showReferences = !this.showReferences;
}
private toggleListWarnings() {
this.listWarnings = !this.listWarnings;
}
private renderReferenceIcon(type: string) {
const contextItemType = this.contextItemTypes.find(
contextItemType => contextItemType.id === type
);
const icon = contextItemType?.icon ?? '';
if (!icon) return nothing;
return html`<gr-icon class="reference-icon" .icon=${icon}></gr-icon>`;
}
override render() {
if (this.totalReferencesCount === 0) return nothing;
return html`
<div class="references-dropdown-container">
<md-text-button
@click=${this.toggleShowReferences}
class="references-dropdown-button"
>
<md-icon slot="icon"
>${this.showReferences ? 'expand_less' : 'expand_more'}</md-icon
>
Context used (${this.totalReferencesCount})
</md-text-button>
${when(
this.hasErrors,
() => html`
<md-icon
class="warning-icon"
title="There were errors loading some references."
>warning_amber</md-icon
>
`
)}
</div>
${when(this.showReferences, () => this.renderDropdownContent())}
`;
}
private renderDropdownContent() {
return html`
<div class="references-dropdown-content">
${this.validDynamicReferences.map(
reference =>
html`<div class="button-outer-wrapper">
<a
class="reference-button pill-link"
.href=${reference.externalUrl}
target="_blank"
.title=${reference.tooltip ?? ''}
>
<div class="reference-wrapper">
${this.renderReferenceIcon(reference.type)}
<span class="display-text">${reference.displayText}</span>
${when(
reference.secondaryText,
() => html`<span class="additional-text"
>- ${reference.secondaryText}</span
>`
)}
</div>
</a>
</div>`
)}
${when(
this.hasErrors,
() => html`
<button
class="reference-button list-warnings-button"
@click=${this.toggleListWarnings}
>
<div class="reference-wrapper">
<md-icon class="reference-icon warning-icon"
>warning_amber</md-icon
>
<span class="display-text">Warnings</span>
<md-icon class="expand-icon"
>${this.listWarnings ? 'expand_less' : 'expand_more'}</md-icon
>
</div>
</button>
`
)}
${when(this.hasErrors && this.listWarnings, () =>
this.renderWarningsList()
)}
</div>
`;
}
private renderWarningsList() {
return html`
<ul class="warnings-list">
${this.dynamicReferencesWithErrors.map(
reference => html`
<li>
Failed to load ${reference.displayText}: ${reference.errorMsg}
</li>
`
)}
</ul>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'references-dropdown': ReferencesDropdown;
}
}