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();
}
}