Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libs/chat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/chat",
"version": "0.0.11",
"version": "0.0.12",
"exports": {
".": {
"types": "./index.d.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -72,6 +72,40 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
export class ChatPopupComponent {
readonly agent = input.required<Agent>();
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<string | null>('k');
/** Close the popup on Escape (default true). */
readonly closeOnEscape = input<boolean>(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); }
Expand Down
77 changes: 56 additions & 21 deletions libs/chat/src/lib/primitives/chat-input/chat-input.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,42 @@ 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"
aria-label="Type a message"
></textarea>
<div class="chat-input__controls">
<ng-content select="[chatInputTrailing]" />
<button
type="button"
class="chat-input__send"
[disabled]="!canSubmit()"
(click)="onSubmit()"
aria-label="Send message"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="19" x2="12" y2="5"/>
<polyline points="5 12 12 5 19 12"/>
</svg>
</button>
@if (isLoading() && canStop()) {
<button
type="button"
class="chat-input__send chat-input__send--stop"
(click)="onStop()"
aria-label="Stop generating"
title="Stop generating"
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2"/>
</svg>
</button>
} @else {
<button
type="button"
class="chat-input__send"
[disabled]="!canSubmit()"
(click)="onSubmit()"
aria-label="Send message"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="19" x2="12" y2="5"/>
<polyline points="5 12 12 5 19 12"/>
</svg>
</button>
}
</div>
</div>
<ng-content select="[chatInputFooter]" />
Expand All @@ -79,18 +94,25 @@ export class ChatInputComponent {
readonly agent = input.required<Agent>();
readonly submitOnEnter = input<boolean>(true);
readonly placeholder = input<string>('');
/** When true (default), shows a stop button while the agent is streaming. */
readonly showStopButton = input<boolean>(true);
readonly submitted = output<string>();
readonly stopped = output<void>();
readonly messageText = signal<string>('');
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<ElementRef<HTMLTextAreaElement>>('textareaEl');

focusTextarea(): void {
Expand All @@ -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<void> };
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();
}
}
6 changes: 6 additions & 0 deletions libs/chat/src/lib/styles/chat-input.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
`;
Loading