import {
Component,
ContentChild,
TemplateRef,
Type,
ViewChild,
ElementRef,
ChangeDetectionStrategy,
ChangeDetectorRef,
ViewEncapsulation,
computed,
signal,
OnInit,
OnChanges,
OnDestroy,
AfterViewInit,
input,
output,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { CopilotSlot } from "../../slots/copilot-slot";
import { CopilotChatViewScrollView } from "./copilot-chat-view-scroll-view";
import { CopilotChatViewScrollToBottomButton } from "./copilot-chat-view-scroll-to-bottom-button";
import { CopilotChatViewFeather } from "./copilot-chat-view-feather";
import { CopilotChatViewInputContainer } from "./copilot-chat-view-input-container";
import { CopilotChatViewDisclaimer } from "./copilot-chat-view-disclaimer";
import { Message } from "@ag-ui/client";
import { cn } from "../../utils";
import { ResizeObserverService } from "../../resize-observer";
import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers";
import { Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
/**
* CopilotChatView component - Angular port of the React component.
* A complete chat interface with message feed and input components.
*
* @example
* ```html
*
*
* ```
*/
@Component({
standalone: true,
selector: "copilot-chat-view",
imports: [CommonModule, CopilotSlot, CopilotChatViewScrollView],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
providers: [ResizeObserverService, CopilotChatViewHandlers],
template: `
@if (customLayoutTemplate) {
} @else {
}
`,
})
export class CopilotChatView
implements OnInit, OnChanges, AfterViewInit, OnDestroy
{
// Core inputs matching React props
messages = input([]);
autoScroll = input(true);
showCursor = input(false);
// MessageView slot inputs
messageViewComponent = input | undefined>(undefined);
messageViewTemplate = input | undefined>(undefined);
messageViewClass = input(undefined);
// ScrollView slot inputs
scrollViewComponent = input | undefined>(undefined);
scrollViewTemplate = input | undefined>(undefined);
scrollViewClass = input(undefined);
// ScrollToBottomButton slot inputs
scrollToBottomButtonComponent = input | undefined>(undefined);
scrollToBottomButtonTemplate = input | undefined>(undefined);
scrollToBottomButtonClass = input(undefined);
// Input slot inputs
inputComponent = input | undefined>(undefined);
inputTemplate = input | undefined>(undefined);
// InputContainer slot inputs
inputContainerComponent = input | undefined>(undefined);
inputContainerTemplate = input | undefined>(undefined);
inputContainerClass = input(undefined);
// Feather slot inputs
featherComponent = input | undefined>(undefined);
featherTemplate = input | undefined>(undefined);
featherClass = input(undefined);
// Disclaimer slot inputs
disclaimerComponent = input | undefined>(undefined);
disclaimerTemplate = input | undefined>(undefined);
disclaimerClass = input(undefined);
disclaimerText = input(undefined);
// Custom layout template (render prop pattern)
@ContentChild("customLayout") customLayoutTemplate?: TemplateRef;
// Named template slots for deep customization
@ContentChild("sendButton") sendButtonTemplate?: TemplateRef;
@ContentChild("toolbar") toolbarTemplate?: TemplateRef;
@ContentChild("textArea") textAreaTemplate?: TemplateRef;
@ContentChild("audioRecorder") audioRecorderTemplate?: TemplateRef;
@ContentChild("assistantMessageMarkdownRenderer")
assistantMessageMarkdownRendererTemplate?: TemplateRef;
@ContentChild("thumbsUpButton") thumbsUpButtonTemplate?: TemplateRef;
@ContentChild("thumbsDownButton") thumbsDownButtonTemplate?: TemplateRef;
@ContentChild("readAloudButton") readAloudButtonTemplate?: TemplateRef;
@ContentChild("regenerateButton") regenerateButtonTemplate?: TemplateRef;
// Output events for assistant message actions (bubbled from child components)
assistantMessageThumbsUp = output<{ message: Message }>();
assistantMessageThumbsDown = output<{ message: Message }>();
assistantMessageReadAloud = output<{ message: Message }>();
assistantMessageRegenerate = output<{ message: Message }>();
// Output events for user message actions (if applicable)
userMessageCopy = output<{ message: Message }>();
userMessageEdit = output<{ message: Message }>();
// ViewChild references
@ViewChild("inputContainerSlotRef", { read: ElementRef })
inputContainerSlotRef?: ElementRef;
// Default components for slots
protected readonly defaultScrollViewComponent = CopilotChatViewScrollView;
protected readonly defaultScrollToBottomButtonComponent =
CopilotChatViewScrollToBottomButton;
protected readonly defaultInputContainerComponent =
CopilotChatViewInputContainer;
protected readonly defaultFeatherComponent = CopilotChatViewFeather;
protected readonly defaultDisclaimerComponent = CopilotChatViewDisclaimer;
// Signals for reactive state
protected messagesValue = computed(() => this.messages());
protected autoScrollSignal = computed(() => this.autoScroll());
protected showCursorSignal = computed(() => this.showCursor());
protected disclaimerTextSignal = computed(() => this.disclaimerText());
protected disclaimerClassSignal = computed(() => this.disclaimerClass());
protected inputContainerHeight = signal(0);
protected isResizing = signal(false);
protected contentPaddingBottom = computed(
() => this.inputContainerHeight() + 32,
);
// Computed signals
protected computedClass = computed(() => cn("relative h-full"));
// Slot resolution computed signals
protected messageViewSlot = computed(
() => this.messageViewTemplate() || this.messageViewComponent(),
);
protected scrollViewSlot = computed(
() => this.scrollViewTemplate() || this.scrollViewComponent(),
);
protected scrollToBottomButtonSlot = computed(
() =>
this.scrollToBottomButtonTemplate() ||
this.scrollToBottomButtonComponent(),
);
protected inputSlot = computed(
() => this.inputTemplate() || this.inputComponent(),
);
protected inputContainerSlot = computed(
() => this.inputContainerTemplate() || this.inputContainerComponent(),
);
protected featherSlot = computed(
() => this.featherTemplate() || this.featherComponent(),
);
protected disclaimerSlot = computed(
() => this.disclaimerTemplate() || this.disclaimerComponent(),
);
// Context objects for slots
protected scrollViewContext = computed(() => ({
autoScroll: this.autoScrollSignal(),
scrollToBottomButton: this.scrollToBottomButtonSlot(),
scrollToBottomButtonClass: this.scrollToBottomButtonClass(),
inputContainerHeight: this.inputContainerHeight(),
isResizing: this.isResizing(),
messages: this.messagesValue(),
messageView: this.messageViewSlot(),
messageViewClass: this.messageViewClass(),
}));
// Removed scrollViewPropsComputed - no longer needed
protected inputContainerContext = computed(() => ({
input: this.inputSlot(),
disclaimer: this.disclaimerSlot(),
disclaimerText: this.disclaimerTextSignal(),
disclaimerClass: this.disclaimerClassSignal(),
inputContainerClass: this.inputContainerClass(),
}));
// Removed inputContainerPropsComputed - no longer needed
// Layout context for custom templates (render prop pattern)
protected layoutContext = computed(() => ({
messageView: this.messageViewSlot(),
input: this.inputSlot(),
scrollView: this.scrollViewSlot(),
scrollToBottomButton: this.scrollToBottomButtonSlot(),
feather: this.featherSlot(),
inputContainer: this.inputContainerSlot(),
disclaimer: this.disclaimerSlot(),
}));
private destroy$ = new Subject();
private resizeTimeoutRef?: number;
constructor(
private resizeObserverService: ResizeObserverService,
private cdr: ChangeDetectorRef,
private handlers: CopilotChatViewHandlers,
) {
// Clear any pending resize timeout when toggling isResizing, without signal writes here
}
ngOnInit(): void {
// Initialize handler availability in the view-scoped service
// OutputEmitterRef doesn't expose 'observed'; default to true to enable UI affordances
this.handlers.hasAssistantThumbsUpHandler.set(true);
this.handlers.hasAssistantThumbsDownHandler.set(true);
this.handlers.hasAssistantReadAloudHandler.set(true);
this.handlers.hasAssistantRegenerateHandler.set(true);
this.handlers.hasUserCopyHandler.set(true);
this.handlers.hasUserEditHandler.set(true);
}
ngOnChanges(): void {
// Keep handler availability in sync (assume available)
this.handlers.hasAssistantThumbsUpHandler.set(true);
this.handlers.hasAssistantThumbsDownHandler.set(true);
this.handlers.hasAssistantReadAloudHandler.set(true);
this.handlers.hasAssistantRegenerateHandler.set(true);
this.handlers.hasUserCopyHandler.set(true);
this.handlers.hasUserEditHandler.set(true);
}
ngAfterViewInit(): void {
// Don't set a default height - measure it dynamically
// Set up input container height monitoring
const measureAndObserve = () => {
if (
!this.inputContainerSlotRef ||
!this.inputContainerSlotRef.nativeElement
) {
return false;
}
// The slot ref points to the copilot-slot element
// We need to find the actual input container component inside it
const slotElement = this.inputContainerSlotRef.nativeElement;
const componentElement = slotElement.querySelector(
"copilot-chat-view-input-container",
);
if (!componentElement) {
return false;
}
// Look for the absolute positioned div that contains the input
let innerDiv = componentElement.querySelector(
"div.absolute",
) as HTMLElement;
// If not found by class, try first child
if (!innerDiv) {
innerDiv = componentElement.firstElementChild as HTMLElement;
}
if (!innerDiv) {
return false;
}
// Measure the actual height
const measuredHeight = innerDiv.offsetHeight;
if (measuredHeight === 0) {
return false;
}
// Success! Set the initial height
this.inputContainerHeight.set(measuredHeight);
this.cdr.detectChanges();
// Create an ElementRef wrapper for ResizeObserver
const innerDivRef = new ElementRef(innerDiv);
// Set up ResizeObserver to track changes
this.resizeObserverService
.observeElement(innerDivRef, 0, 250)
.pipe(takeUntil(this.destroy$))
.subscribe((state) => {
const newHeight = state.height;
if (newHeight !== this.inputContainerHeight() && newHeight > 0) {
this.inputContainerHeight.set(newHeight);
this.isResizing.set(true);
this.cdr.detectChanges();
// Clear existing timeout
if (this.resizeTimeoutRef) {
clearTimeout(this.resizeTimeoutRef);
}
// Set isResizing to false after a short delay
this.resizeTimeoutRef = window.setTimeout(() => {
this.isResizing.set(false);
this.resizeTimeoutRef = undefined;
this.cdr.detectChanges();
}, 250);
}
});
return true;
};
// Try to measure immediately
if (!measureAndObserve()) {
// If failed, retry with increasing delays
let attempts = 0;
const maxAttempts = 10;
const retry = () => {
attempts++;
if (measureAndObserve()) {
// Successfully measured
} else if (attempts < maxAttempts) {
// Exponential backoff: 50ms, 100ms, 200ms, 400ms, etc.
const delay = 50 * Math.pow(2, Math.min(attempts - 1, 4));
setTimeout(retry, delay);
} else {
// Failed to measure after max attempts
}
};
// Start retry with first delay
setTimeout(retry, 50);
}
}
ngOnDestroy(): void {
if (this.resizeTimeoutRef) {
clearTimeout(this.resizeTimeoutRef);
}
this.destroy$.next();
this.destroy$.complete();
}
}