blob: bbfd0526b1eec7a50240e1df4f0fb70498feeb9e [file] [log] [blame] [edit]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@material/web/chips/assist-chip.js';
import '@material/web/chips/chip-set.js';
import './context-chip';
import './context-input-chip';
import {css, html, LitElement} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {when} from 'lit/directives/when.js';
import {
ContextItem,
ContextItemType,
ModelInfo,
} from '../../api/ai-code-review';
import {chatModelToken, Turn} from '../../models/chat/chat-model';
import {
contextItemEquals,
searchForContextLinks,
} from '../../models/chat/context-item-util';
import {resolve} from '../../models/dependency';
import {debounce, DelayedTask} from '../../utils/async-util';
import {fire} from '../../utils/event-util';
import {subscribe} from '../lit/subscription-controller';
const MAX_VISIBLE_CONTEXT_ITEMS_COLLAPSED = 3;
const MAX_VISIBLE_SUGGESTED_CONTEXT_ITEMS_COLLAPSED = 1;
const MAX_VISIBLE_INPUT_LINES = 5;
interface PromptBoxContextItem extends ContextItem {
isProvisional?: boolean;
}
@customElement('prompt-box')
export class PromptBox extends LitElement {
@query('#promptInput') promptInput?: HTMLTextAreaElement;
@property({type: Boolean})
isDisabled = false;
@property({type: String})
disabledMessage = 'Review Agent is disabled.';
@state() hasModelLoadingError = false;
@state() selectedModel?: ModelInfo;
@state() userInput = '';
@state() previousMessageIndex = -1;
@state() turns: readonly Turn[] = [];
@state() errorMessage?: string;
@state() contextItems: readonly PromptBoxContextItem[] = [];
@state() dynamicContextItemsSuggestions: PromptBoxContextItem[] = [];
@state() showAllContextItems = false;
@state() contextItemTypes: readonly ContextItemType[] = [];
// TODO(milutin): Find out if we need this.
// @ts-ignore
private turnBasisForUserInput?: number;
private lineHeight = 0;
private maxInputHeight = 0;
private updateDynamicContextItemsTask?: DelayedTask;
private readonly getChatModel = resolve(this, chatModelToken);
constructor() {
super();
subscribe(
this,
() => this.getChatModel().modelsLoadingError$,
x => (this.hasModelLoadingError = !!x)
);
subscribe(
this,
() => this.getChatModel().turns$,
x => {
const newLength = x?.length ?? 0;
if (this.turns.length !== newLength) {
this.previousMessageIndex = newLength;
}
this.turns = x ?? [];
}
);
subscribe(
this,
() => this.getChatModel().errorMessage$,
x => (this.errorMessage = x)
);
subscribe(
this,
() => this.getChatModel().selectedModel$,
x => (this.selectedModel = x)
);
subscribe(
this,
() => this.getChatModel().contextItemTypes$,
x => (this.contextItemTypes = x)
);
subscribe(
this,
() => this.getChatModel().userContextItems$,
x => (this.contextItems = x)
);
}
protected get suggestedContextItems(): ContextItem[] {
return this.dynamicContextItemsSuggestions.filter(item =>
this.contextItems.every(
existingItem => !contextItemEquals(item, existingItem)
)
);
}
private get nextTurnIndex() {
return this.turns.length;
}
private get lastTurn() {
if (this.turns.length === 0) {
return undefined;
}
return this.turns[this.turns.length - 1];
}
get chatInputDisabledText() {
if (this.hasModelLoadingError) {
return 'Failed to load models. Please reload the page.';
}
if (!this.selectedModel) {
return 'Loading models...';
}
const lastTurn = this.lastTurn;
const geminiMessage = lastTurn?.geminiMessage;
const isBackgroundRequest = lastTurn?.userMessage.isBackgroundRequest;
if (
!!this.errorMessage ||
(geminiMessage !== undefined &&
!geminiMessage.responseComplete &&
!isBackgroundRequest)
) {
return 'Thinking ...';
}
if (this.isDisabled) {
return this.disabledMessage;
}
return '';
}
get chatInputDisabled() {
return !!this.chatInputDisabledText;
}
private get previousUserMessages() {
return this.turns.map(turn => turn.userMessage.content);
}
private get promptHasInput() {
return !!this.userInput;
}
private get selectedPreviousMessage() {
const previousUserMessages = this.previousUserMessages;
return this.previousMessageIndex >= 0 &&
this.previousMessageIndex < previousUserMessages.length
? previousUserMessages[this.previousMessageIndex]
: '';
}
/**
* Calculates the number of context items (both regular and suggested)
* that are not visible when the context chip set is collapsed.
*/
get numExcessContextItems() {
const regularContextItems = this.contextItems;
const suggestedContextItems = this.suggestedContextItems;
return (
Math.max(
0,
regularContextItems.length - MAX_VISIBLE_CONTEXT_ITEMS_COLLAPSED
) +
Math.max(
0,
suggestedContextItems.length -
MAX_VISIBLE_SUGGESTED_CONTEXT_ITEMS_COLLAPSED
)
);
}
get shouldShowContextToggle() {
return this.numExcessContextItems > 0;
}
get contextToggleTooltip() {
return this.showAllContextItems
? 'Collapse'
: `${this.numExcessContextItems} additional items or suggestions`;
}
get contextToggleText() {
return this.showAllContextItems ? 'â–²' : `+${this.numExcessContextItems}`;
}
static override styles = css`
:host {
background-color: var(--background-color-tertiary);
border-radius: 8px;
display: flex;
flex-direction: column;
/* For high contrast mode. */
outline: 1px solid transparent;
padding: var(--spacing-l) var(--spacing-l) var(--spacing-m)
var(--spacing-xl);
transition: box-shadow 280ms cubic-bezier(0.4, 0, 0.2, 1);
}
:host(:focus-within) {
background: var(
--search-box-focus-bg-color,
var(--background-color-primary)
);
box-shadow: 0px 1px 2px 0px var(--elevation-color),
0px 2px 6px 2px var(--elevation-color);
}
.tab-chip-set md-assist-chip.tab-chip {
--md-assist-chip-container-height: 16px;
--md-assist-chip-label-text-size: 10px;
--md-assist-chip-label-text-color: var(--primary-fg-color);
--md-assist-chip-label-line-height: 16px;
background-color: var(--tab-chip-bg-color);
border-radius: 4px;
margin: 0;
padding: 0;
}
gr-icon,
md-icon {
font-size: 20px;
line-height: 24px;
}
.prompt-box-inner-container {
display: flex;
height: auto;
min-height: 32px;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-bottom: 8px;
}
.prompt-input-container {
flex-grow: 1;
height: inherit;
margin-right: var(--spacing-xs);
position: relative;
}
.prompt-input {
background: transparent;
color: var(--primary-text-color);
font: inherit;
font-size: 16px; /* $font-size-large */
border: none;
outline: none;
resize: none;
padding: 0;
margin: 0;
height: auto;
width: 100%;
max-width: 100%;
}
.prompt-input::placeholder {
color: var(--deemphasized-text-color);
}
.context-chip-set {
gap: 4px;
}
.context-chip-set md-assist-chip.context-toggle-chip {
--md-assist-chip-container-height: 20px;
--md-assist-chip-label-text-size: 12px;
--md-assist-chip-label-text-weight: 500;
--md-assist-chip-label-line-height: 16px;
--md-assist-chip-label-text-color: var(--primary-default, purple);
margin: 0;
margin-left: auto;
border-color: var(--border-color);
background-color: var(--elevation-2);
border-radius: 8px;
display: flex;
padding: 0 4px;
}
`;
override render() {
return html`
<div class="prompt-box-inner-container">
<div class="prompt-input-container">
<textarea
id="promptInput"
rows="1"
class="prompt-input"
name="search"
role="searchbox"
autocomplete="off"
spellcheck="false"
aria-label="Ask Gemini"
?disabled=${this.chatInputDisabled}
.placeholder=${this.placeHolderText()}
.value=${this.userInput}
@keydown=${this.onKeyDown}
@input=${this.onInput}
@scroll=${this.onScroll}
></textarea>
</div>
${this.renderTabChip()}
</div>
${this.renderAddContext()}
`;
}
private renderTabChip() {
return html` ${when(
this.tabChipVisible(),
() => html`
<md-chip-set class="tab-chip-set">
<md-assist-chip class="tab-chip" label="Tab"></md-assist-chip>
</md-chip-set>
`
)}`;
}
private renderAddContext() {
return html`
<md-chip-set class="context-chip-set">
<context-input-chip
@context-item-added=${(e: CustomEvent<ContextItem>) =>
this.onContextItemAdded(e.detail)}
></context-input-chip>
${(this.showAllContextItems
? this.contextItems
: this.contextItems.slice(0, MAX_VISIBLE_CONTEXT_ITEMS_COLLAPSED)
).map(
contextItem => html`
<context-chip
class="external-context"
.text=${contextItem.title}
.contextItem=${contextItem}
.tooltip=${contextItem.tooltip}
@remove-context-chip=${() => this.removeContextItem(contextItem)}
></context-chip>
`
)}
${(this.showAllContextItems
? this.suggestedContextItems
: this.suggestedContextItems.slice(
0,
MAX_VISIBLE_SUGGESTED_CONTEXT_ITEMS_COLLAPSED
)
).map(
contextItem => html`
<context-chip
class="suggestion-context"
.contextItem=${contextItem}
.text=${contextItem.title}
.isSuggestion=${true}
.tooltip=${`Add this ${contextItem.title} as context`}
@accept-context-item-suggestion=${() =>
this.acceptContextItemSuggestion(contextItem)}
></context-chip>
`
)}
${when(
this.shouldShowContextToggle,
() => html`
<md-assist-chip
class="context-toggle-chip"
@click=${this.toggleShowAllContext}
.title=${this.contextToggleTooltip}
.label=${this.contextToggleText}
>
</md-assist-chip>
`
)}
</md-chip-set>
`;
}
override firstUpdated() {
this.lineHeight = this.promptInput?.offsetHeight ?? 0;
this.maxInputHeight = this.lineHeight * MAX_VISIBLE_INPUT_LINES;
}
override updated(changedProperties: Map<string | number | symbol, unknown>) {
if (changedProperties.has('userInput')) {
if (!this.userInput) {
this.turnBasisForUserInput = undefined;
this.resetInputHeight();
} else {
this.adjustInputHeight();
}
}
if (changedProperties.has('chatInputDisabled')) {
if (!this.chatInputDisabled) {
this.refocusPromptInput();
}
}
}
private onScroll() {
this.scrollTop = this.promptInput?.scrollTop ?? 0;
this.adjustInputHeight();
}
private onInput(e: Event) {
this.userInput = (e.target as HTMLTextAreaElement).value;
this.adjustInputHeight();
this.updateDynamicContextItemsDebounced();
fire(this, 'user-input-change', {value: this.userInput});
}
private placeHolderText() {
if (this.chatInputDisabledText) {
return this.chatInputDisabledText;
}
const selectedPreviousMessage = this.selectedPreviousMessage;
if (selectedPreviousMessage) {
return selectedPreviousMessage;
}
if (this.isTextInputFocused()) {
return '';
}
return 'Enter a prompt here...';
}
private onContextItemAdded(contextItem: ContextItem) {
this.addContextItem(contextItem);
}
private addContextItem(contextItem: ContextItem) {
this.getChatModel().addContextItem(contextItem);
}
private removeContextItem(contextItem: ContextItem) {
this.getChatModel().removeContextItem(contextItem);
}
private acceptContextItemSuggestion(contextItem: ContextItem) {
this.addContextItem(contextItem);
this.updateDynamicContextItems();
}
private tabChipVisible() {
return (
!this.chatInputDisabled &&
!this.promptHasInput &&
!!this.selectedPreviousMessage
);
}
private onKeyDown(event: KeyboardEvent) {
if (
event.ctrlKey ||
event.altKey ||
event.metaKey ||
(event.shiftKey && event.key === 'Enter')
) {
return;
}
switch (event.key) {
case 'Enter':
this.onEnter(event);
break;
case 'ArrowUp':
case 'ArrowDown':
this.handleArrowKey(event);
break;
case 'Tab':
this.onTab(event);
break;
default:
break;
}
}
private handleArrowKey(event: KeyboardEvent) {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
const previousMessageIndex = this.previousMessageIndex;
const previousUserMessages = this.previousUserMessages;
const index =
event.key === 'ArrowUp'
? previousMessageIndex - 1
: previousMessageIndex + 1;
if (index < 0 || index > previousUserMessages.length) return;
this.doKeyboardAction(event, () => {
this.previousMessageIndex = index;
});
}
private onTab(event: Event) {
const previousMessageIndex = this.previousMessageIndex;
const previousUserMessages = this.previousUserMessages;
if (
previousMessageIndex < 0 ||
previousMessageIndex >= previousUserMessages.length
) {
return;
}
this.doKeyboardAction(event, () => {
this.userInput = previousUserMessages[previousMessageIndex];
this.turnBasisForUserInput = previousMessageIndex;
});
}
/** Shared handler for up/down/tab keyboard actions. */
private doKeyboardAction(event: Event, action: () => void) {
if (this.promptHasInput) return;
event.preventDefault();
event.stopPropagation();
action();
this.adjustInputHeight();
}
private onEnter(event: Event) {
const userInput = this.userInput;
if (!userInput.trim()) return;
event.preventDefault();
event.stopPropagation();
this.dynamicContextItemsSuggestions = [];
const lastTurn = this.lastTurn;
const isLastTurnBackgroundRequest =
lastTurn?.userMessage.isBackgroundRequest;
const isLastTurnComplete = lastTurn?.geminiMessage.responseComplete;
if (isLastTurnBackgroundRequest && !isLastTurnComplete) {
// We don't want to block the user from typing and hitting enter in the
// prompt box if the current background request is not yet complete.
// Instead, we start a new conversation in this case.
this.getChatModel().startNewChatWithUserInput(
userInput,
undefined,
undefined,
true
);
} else {
this.getChatModel().chat(
userInput,
lastTurn?.userMessage.actionId,
this.nextTurnIndex
// TODO(milutin): Find out if we need this.
// this.turnBasisForUserInput
);
}
// Reset height after sending
this.resetInputHeight();
}
private toggleShowAllContext() {
this.showAllContextItems = !this.showAllContextItems;
}
private isTextInputFocused() {
return document.activeElement === this.promptInput;
}
private adjustInputHeight() {
if (!this.promptInput) {
return;
}
// Reset height to auto to correctly calculate scrollHeight for shrinking
this.promptInput.style.height = 'auto';
const scrollHeight = this.promptInput.scrollHeight;
this.promptInput.style.height = `${Math.min(
scrollHeight,
this.maxInputHeight
)}px`;
}
private resetInputHeight() {
if (this.promptInput) {
this.promptInput.style.height = `${this.lineHeight}px`;
}
}
private refocusPromptInput() {
setTimeout(() => {
this.promptInput?.focus();
});
}
private updateDynamicContextItemsDebounced() {
this.updateDynamicContextItemsTask = debounce(
this.updateDynamicContextItemsTask,
() => {
this.updateDynamicContextItems();
},
200
);
}
private updateDynamicContextItems() {
const suggestedContextItems = searchForContextLinks(
this.userInput,
this.contextItemTypes
).map(item => {
return {
...item,
isProvisional: true,
};
});
this.dynamicContextItemsSuggestions = suggestedContextItems;
// include all suggested context items as materialized context items
// by default
suggestedContextItems.forEach(contextItem => {
if (
!this.contextItems.some(item => contextItemEquals(item, contextItem))
) {
this.addContextItem(contextItem);
}
});
// remove materialized context items from the store if they previously came
// from a suggestion, if they no longer exist in the prompt input
this.contextItems.forEach(contextItem => {
if (
!suggestedContextItems.some(item =>
contextItemEquals(item, contextItem)
) &&
contextItem.isProvisional
) {
this.removeContextItem(contextItem);
}
});
}
}
export interface ContextItemAddedEvent extends CustomEvent<ContextItem> {
type: 'context-item-added';
}
export interface UserInputChangedEvent extends CustomEvent<{value: string}> {
type: 'user-input-change';
}
declare global {
interface HTMLElementEventMap {
'context-item-added': ContextItemAddedEvent;
'user-input-change': UserInputChangedEvent;
}
interface HTMLElementTagNameMap {
'prompt-box': PromptBox;
}
}