diff --git a/libs/chat/package.json b/libs/chat/package.json index b23d6deb7..0153f1439 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.11", + "version": "0.0.12", "exports": { ".": { "types": "./index.d.ts", diff --git a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts index 3a1612274..e1db3272f 100644 --- a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts +++ b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts @@ -1,6 +1,6 @@ // libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, input, model } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, model, DestroyRef, inject, DOCUMENT, effect } from '@angular/core'; import type { Agent } from '../../agent'; import { ChatComponent } from '../chat/chat.component'; import { ChatLauncherButtonComponent } from '../../primitives/chat-launcher-button/chat-launcher-button.component'; @@ -72,6 +72,40 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; export class ChatPopupComponent { readonly agent = input.required(); readonly open = model(false); + /** + * Keyboard shortcut (single key) that toggles the popup with cmd (mac) + * or ctrl (other). Set to `null` to disable. Default: 'k' — matches the + * widely-used cmd/ctrl+K convention. + */ + readonly shortcut = input('k'); + /** Close the popup on Escape (default true). */ + readonly closeOnEscape = input(true); + + private readonly destroyRef = inject(DestroyRef); + private readonly document = inject(DOCUMENT); + + constructor() { + effect(() => { + // Re-bind whenever shortcut/closeOnEscape change. + const shortcut = this.shortcut(); + const closeOnEscape = this.closeOnEscape(); + const win = this.document.defaultView; + if (!win) return; + const isMac = /Mac|iPhone|iPad/i.test(win.navigator.platform || win.navigator.userAgent); + const handler = (e: KeyboardEvent): void => { + if (shortcut && e.key.toLowerCase() === shortcut.toLowerCase() && (isMac ? e.metaKey : e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + return; + } + if (closeOnEscape && this.open() && e.key === 'Escape') { + this.closeWindow(); + } + }; + win.addEventListener('keydown', handler); + this.destroyRef.onDestroy(() => win.removeEventListener('keydown', handler)); + }); + } toggle(): void { this.open.update((v) => !v); } openWindow(): void { this.open.set(true); } diff --git a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts index a1e2e7539..712189111 100644 --- a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts +++ b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts @@ -48,8 +48,9 @@ export function submitMessage( (ngModelChange)="messageText.set($event)" name="messageText" [placeholder]="placeholder()" - [disabled]="isDisabled()" (keydown.enter)="onKeydown($any($event))" + (compositionstart)="composing.set(true)" + (compositionend)="composing.set(false)" (focus)="focused.set(true)" (blur)="focused.set(false)" rows="1" @@ -57,18 +58,32 @@ export function submitMessage( >
- + @if (isLoading() && canStop()) { + + } @else { + + }
@@ -79,18 +94,25 @@ export class ChatInputComponent { readonly agent = input.required(); readonly submitOnEnter = input(true); readonly placeholder = input(''); + /** When true (default), shows a stop button while the agent is streaming. */ + readonly showStopButton = input(true); readonly submitted = output(); + readonly stopped = output(); readonly messageText = signal(''); - readonly isDisabled = computed(() => this.agent().isLoading()); + readonly isLoading = computed(() => this.agent().isLoading()); + /** True while an IME composition (CJK input, accent, autocorrect) is active. */ + protected readonly composing = signal(false); readonly focused = signal(false); - - + /** Submit is allowed only when not loading and there's non-whitespace text. */ readonly canSubmit = computed(() => { - if (this.isDisabled()) return false; + if (this.isLoading()) return false; return this.messageText().trim().length > 0; }); + /** The stop button only appears when the consumer opted in AND we're loading. */ + readonly canStop = computed(() => this.showStopButton()); + private readonly textareaEl = viewChild>('textareaEl'); focusTextarea(): void { @@ -106,10 +128,23 @@ export class ChatInputComponent { } } - onKeydown(event: KeyboardEvent): void { - if (this.submitOnEnter() && !event.shiftKey) { - event.preventDefault(); - this.onSubmit(); + /** Abort the current streaming response (if the adapter supports it). */ + onStop(): void { + const a = this.agent() as unknown as { stop?: () => void | Promise }; + if (typeof a.stop === 'function') { + void a.stop(); } + this.stopped.emit(); + } + + onKeydown(event: KeyboardEvent): void { + if (!this.submitOnEnter() || event.shiftKey) return; + // Don't submit while an IME composition is in progress (CJK input, + // dead-key accents, autocorrect popups). The composition's terminating + // Enter must reach the textarea so the candidate is committed; submitting + // here would discard the user's in-progress character. + if (this.composing() || event.isComposing || event.keyCode === 229) return; + event.preventDefault(); + this.onSubmit(); } } diff --git a/libs/chat/src/lib/styles/chat-input.styles.ts b/libs/chat/src/lib/styles/chat-input.styles.ts index be00822ef..8a89a5efa 100644 --- a/libs/chat/src/lib/styles/chat-input.styles.ts +++ b/libs/chat/src/lib/styles/chat-input.styles.ts @@ -68,5 +68,11 @@ export const CHAT_INPUT_STYLES = ` cursor: not-allowed; opacity: 0.7; } + .chat-input__send--stop { + /* Slightly subdued vs the active send so the user doesn't fear the button. */ + background: var(--ngaf-chat-text); + color: var(--ngaf-chat-bg); + } + .chat-input__send--stop:hover { transform: scale(1.05); } .chat-input__send svg { width: 16px; height: 16px; } `;