import { Component, TemplateRef, signal, computed, effect, ChangeDetectionStrategy, AfterViewInit, OnDestroy, Type, ViewEncapsulation, ContentChild, input, output, ViewChild, } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CopilotSlot } from "../../slots/copilot-slot"; import { injectChatLabels } from "../../chat-config"; import { LucideAngularModule, ArrowUp } from "lucide-angular"; import { CopilotChatTextarea } from "./copilot-chat-textarea"; import { CopilotChatAudioRecorder } from "./copilot-chat-audio-recorder"; import { CopilotChatStartTranscribeButton, CopilotChatCancelTranscribeButton, CopilotChatFinishTranscribeButton, CopilotChatAddFileButton, } from "./copilot-chat-buttons"; import { CopilotChatToolbar } from "./copilot-chat-toolbar"; import { CopilotChatToolsMenu } from "./copilot-chat-tools-menu"; import type { CopilotChatInputMode, ToolsMenuItem, } from "./copilot-chat-input.types"; import { cn } from "../../utils"; import { injectChatState } from "../../chat-state"; /** * Context provided to slot templates */ export interface SendButtonContext { send: () => void; disabled: boolean; value: string; } export interface ToolbarContext { mode: CopilotChatInputMode; value: string; } @Component({ standalone: true, selector: "copilot-chat-input", host: { "data-copilotkit": "" }, imports: [ CommonModule, CopilotSlot, LucideAngularModule, CopilotChatTextarea, CopilotChatAudioRecorder, CopilotChatStartTranscribeButton, CopilotChatCancelTranscribeButton, CopilotChatFinishTranscribeButton, CopilotChatAddFileButton, CopilotChatToolbar, CopilotChatToolsMenu, ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: `
@if (computedMode() === "transcribe") { @if (audioRecorderTemplate || audioRecorderComponent()) { } @else { } } @else { @if (textAreaTemplate || textAreaComponent()) { } @else { } } @if (toolbarTemplate || toolbarComponent()) { } @else {
@if (addFileButtonTemplate || addFileButtonComponent()) { } @else { } @if (computedToolsMenu().length > 0) { @if (toolsButtonTemplate || toolsButtonComponent()) { } @else { } } @if (additionalToolbarItems()) { }
@if (computedMode() === "transcribe") { @if ( cancelTranscribeButtonTemplate || cancelTranscribeButtonComponent() ) { } @else { } @if ( finishTranscribeButtonTemplate || finishTranscribeButtonComponent() ) { } @else { } } @else { @if ( startTranscribeButtonTemplate || startTranscribeButtonComponent() ) { } @else { } @if (sendButtonTemplate || sendButtonComponent()) { } @else {
} }
}
`, styles: [ ` :host { display: block; width: 100%; } .shadow-\\[0_4px_4px_0_\\#0000000a\\2c_0_0_1px_0_\\#0000009e\\] { box-shadow: 0 4px 4px 0 #0000000a, 0 0 1px 0 #0000009e !important; } `, ], }) export class CopilotChatInput implements AfterViewInit, OnDestroy { @ViewChild(CopilotChatTextarea, { read: CopilotChatTextarea }) textAreaRef?: CopilotChatTextarea; @ViewChild(CopilotChatAudioRecorder) audioRecorderRef?: CopilotChatAudioRecorder; // Capture templates from content projection @ContentChild("sendButton", { read: TemplateRef }) sendButtonTemplate?: TemplateRef; @ContentChild("toolbar", { read: TemplateRef }) toolbarTemplate?: TemplateRef; @ContentChild("textArea", { read: TemplateRef }) textAreaTemplate?: TemplateRef; @ContentChild("audioRecorder", { read: TemplateRef }) audioRecorderTemplate?: TemplateRef; @ContentChild("startTranscribeButton", { read: TemplateRef }) startTranscribeButtonTemplate?: TemplateRef; @ContentChild("cancelTranscribeButton", { read: TemplateRef }) cancelTranscribeButtonTemplate?: TemplateRef; @ContentChild("finishTranscribeButton", { read: TemplateRef }) finishTranscribeButtonTemplate?: TemplateRef; @ContentChild("addFileButton", { read: TemplateRef }) addFileButtonTemplate?: TemplateRef; @ContentChild("toolsButton", { read: TemplateRef }) toolsButtonTemplate?: TemplateRef; // Class inputs for styling default components sendButtonClass = input(undefined); toolbarClass = input(undefined); textAreaClass = input(undefined); textAreaMaxRows = input(undefined); textAreaPlaceholder = input(undefined); audioRecorderClass = input(undefined); startTranscribeButtonClass = input(undefined); cancelTranscribeButtonClass = input(undefined); finishTranscribeButtonClass = input(undefined); addFileButtonClass = input(undefined); toolsButtonClass = input(undefined); // Component inputs for overrides sendButtonComponent = input | undefined>(undefined); toolbarComponent = input | undefined>(undefined); textAreaComponent = input | undefined>(undefined); audioRecorderComponent = input | undefined>(undefined); startTranscribeButtonComponent = input | undefined>(undefined); cancelTranscribeButtonComponent = input | undefined>(undefined); finishTranscribeButtonComponent = input | undefined>(undefined); addFileButtonComponent = input | undefined>(undefined); toolsButtonComponent = input | undefined>(undefined); // Regular inputs mode = input(undefined); toolsMenu = input<(ToolsMenuItem | "-")[] | undefined>(undefined); autoFocus = input(undefined); value = input(undefined); inputClass = input(undefined); // Note: Prefer host `class` for styling this component; // keep only `inputClass` to style the internal wrapper if needed. additionalToolbarItems = input | undefined>(undefined); // Output events submitMessage = output(); startTranscribe = output(); cancelTranscribe = output(); finishTranscribe = output(); addFile = output(); valueChange = output(); // Icons and default classes readonly ArrowUpIcon = ArrowUp; readonly defaultButtonClass = cn( // Base button styles "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium", "transition-all disabled:pointer-events-none disabled:opacity-50", "shrink-0 outline-none", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", // chatInputToolbarPrimary variant "cursor-pointer", "bg-black text-white", "dark:bg-white dark:text-black dark:focus-visible:outline-white", "rounded-full h-9 w-9", "transition-colors", "focus:outline-none", "hover:opacity-70 disabled:hover:opacity-100", "disabled:cursor-not-allowed disabled:bg-[#00000014] disabled:text-[rgb(13,13,13)]", "dark:disabled:bg-[#454545] dark:disabled:text-white", ); // Services readonly labels = injectChatLabels(); // readonly chatConfig = injectChatConfig(); readonly chatState = injectChatState(); // Signals modeSignal = signal("input"); toolsMenuSignal = signal<(ToolsMenuItem | "-")[]>([]); autoFocusSignal = signal(true); customClass = signal(undefined); // Default components // Note: CopilotChatTextarea uses attribute selector but is a component defaultAudioRecorder = CopilotChatAudioRecorder; defaultSendButton: any = null; // Will be set to avoid circular dependency CopilotChatToolbar = CopilotChatToolbar; CopilotChatAddFileButton = CopilotChatAddFileButton; CopilotChatToolsMenu = CopilotChatToolsMenu; CopilotChatCancelTranscribeButton = CopilotChatCancelTranscribeButton; CopilotChatFinishTranscribeButton = CopilotChatFinishTranscribeButton; CopilotChatStartTranscribeButton = CopilotChatStartTranscribeButton; // Computed values computedMode = computed(() => this.modeSignal()); computedToolsMenu = computed(() => this.toolsMenu() ?? []); computedAutoFocus = computed(() => this.autoFocus() ?? true); computedValue = computed(() => { const customValue = this.value() ?? ""; const configValue = this.chatState.inputValue(); return customValue || configValue || ""; }); computedClass = computed(() => { const baseClasses = cn( // Layout "flex w-full flex-col items-center justify-center", // Interaction "cursor-text", // Overflow and clipping "overflow-visible bg-clip-padding contain-inline-size", // Background "bg-white dark:bg-[#303030]", // Visual effects "shadow-[0_4px_4px_0_#0000000a,0_0_1px_0_#0000009e] rounded-[28px]", ); return cn(baseClasses, this.customClass()); }); // Context for slots (reactive via signals) sendButtonContext = computed(() => ({ send: () => this.send(), disabled: !this.computedValue().trim() || this.computedMode() === "processing", value: this.computedValue(), })); toolbarContext = computed(() => ({ mode: this.computedMode(), value: this.computedValue(), })); textAreaContext = computed(() => ({ value: this.computedValue(), autoFocus: this.computedAutoFocus(), disabled: this.computedMode() === "processing", maxRows: this.textAreaMaxRows(), placeholder: this.textAreaPlaceholder(), inputClass: this.textAreaClass(), onKeyDown: (event: KeyboardEvent) => this.handleKeyDown(event), onChange: (value: string) => this.handleValueChange(value), })); audioRecorderContext = computed(() => ({ inputShowControls: true, })); // Button contexts removed - now using outputs map for click handlers toolsContext = computed(() => ({ inputToolsMenu: this.computedToolsMenu(), inputDisabled: this.computedMode() === "transcribe", })); constructor() { // Effect to handle mode changes (no signal writes) effect(() => { const currentMode = this.computedMode(); if (currentMode === "transcribe" && this.audioRecorderRef) { this.audioRecorderRef.start().catch(console.error); } else if (this.audioRecorderRef?.getState() === "recording") { this.audioRecorderRef.stop().catch(console.error); } }); } // Output maps for slots addFileButtonOutputs = { clicked: () => this.handleAddFile() }; cancelTranscribeButtonOutputs = { clicked: () => this.handleCancelTranscribe(), }; finishTranscribeButtonOutputs = { clicked: () => this.handleFinishTranscribe(), }; startTranscribeButtonOutputs = { clicked: () => this.handleStartTranscribe(), }; // Support both `clicked` (idiomatic in our slots) and `click` (legacy) sendButtonOutputs = { clicked: () => this.send(), click: () => this.send() }; ngAfterViewInit(): void { // Auto-focus if needed if (this.computedAutoFocus() && this.textAreaRef) { setTimeout(() => { this.textAreaRef?.focus(); }); } } ngOnDestroy(): void { // Clean up any resources if (this.audioRecorderRef?.getState() === "recording") { this.audioRecorderRef?.stop().catch(console.error); } } handleKeyDown(event: KeyboardEvent): void { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); this.send(); } } handleValueChange(value: string): void { this.valueChange.emit(value); if (this.chatState) this.chatState.changeInput(value); } send(): void { const trimmed = this.computedValue().trim(); if (trimmed) { this.submitMessage.emit(trimmed); this.chatState.submitInput(trimmed); if (this.chatState) this.chatState.changeInput(""); if (this.textAreaRef) this.textAreaRef.setValue(""); // Refocus input if (this.textAreaRef) { setTimeout(() => { this.textAreaRef?.focus(); }); } } } handleStartTranscribe(): void { this.startTranscribe.emit(); this.modeSignal.set("transcribe"); } handleCancelTranscribe(): void { this.cancelTranscribe.emit(); this.modeSignal.set("input"); } handleFinishTranscribe(): void { this.finishTranscribe.emit(); this.modeSignal.set("input"); } handleAddFile(): void { this.addFile.emit(); } }