File: Components\MessageInput.cs
Web Access
Project: src\aspnetcore\src\Components\AI\src\Microsoft.AspNetCore.Components.AI.csproj (Microsoft.AspNetCore.Components.AI)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.AI;
 
namespace Microsoft.AspNetCore.Components.AI;
 
/// <summary>
/// Text input that sends messages to the cascaded <see cref="AgentContext"/> and disables
/// itself while a response streams.
/// </summary>
public class MessageInput : IComponent, IDisposable
{
    private RenderHandle _renderHandle;
    private AgentContext _agentContext = default!;
    private string? _placeholder;
    private RenderFragment? _leadingActions;
    private RenderFragment? _trailingActions;
    private string _text = "";
    private bool _isDisabled;
    private IDisposable? _statusSub;
 
    /// <summary>
    /// Gets or sets the conversation this input sends messages to.
    /// </summary>
    [CascadingParameter]
    public AgentContext AgentContext { get; set; } = default!;
 
    /// <summary>
    /// Gets or sets the placeholder text of the input.
    /// </summary>
    [Parameter]
    public string? Placeholder { get; set; }
 
    /// <summary>
    /// Gets or sets the content rendered before the text area.
    /// </summary>
    [Parameter]
    public RenderFragment? LeadingActions { get; set; }
 
    /// <summary>
    /// Gets or sets the content rendered after the text area. Replaces the default send button.
    /// </summary>
    [Parameter]
    public RenderFragment? TrailingActions { get; set; }
 
    void IComponent.Attach(RenderHandle renderHandle)
    {
        _renderHandle = renderHandle;
    }
 
    Task IComponent.SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);
        _agentContext = AgentContext
            ?? throw new InvalidOperationException(
                "MessageInput must be inside an AgentBoundary.");
        _placeholder = Placeholder;
        _leadingActions = LeadingActions;
        _trailingActions = TrailingActions;
 
        // Register once. The AgentContext cascade is fixed for the lifetime of the component,
        // so re-registering on every parameter set would accumulate handlers and retain the
        // component on each parent re-render.
        _statusSub ??= _agentContext.RegisterOnStatusChanged(status =>
        {
            _isDisabled = status is ConversationStatus.Streaming or ConversationStatus.AwaitingInput;
            Render();
        });
 
        Render();
        return Task.CompletedTask;
    }
 
    private void Render()
    {
        _renderHandle.Render(builder =>
        {
            builder.OpenElement(0, "div");
            builder.AddAttribute(1, "class", "sc-ai-input");
 
            if (_leadingActions is not null)
            {
                builder.AddContent(2, _leadingActions);
            }
 
            builder.OpenElement(3, "div");
            builder.AddAttribute(4, "class", "sc-ai-input__body");
 
            builder.OpenElement(10, "textarea");
            builder.AddAttribute(11, "class", "sc-ai-input__textarea");
            builder.AddAttribute(12, "placeholder", _placeholder ?? "Type a message...");
            builder.AddAttribute(13, "disabled", _isDisabled);
            builder.AddAttribute(14, "value", _text);
            builder.AddAttribute(15, "aria-label", _placeholder ?? "Type a message...");
            builder.AddAttribute(16, "oninput",
                EventCallback.Factory.Create<ChangeEventArgs>(
                    this, e =>
                    {
                        _text = e.Value?.ToString() ?? "";
                        // Re-render so the component's rendered value tracks the DOM value.
                        // Without this, clearing _text on submit produces no diff (the last
                        // rendered value was already empty) and the textarea is never cleared.
                        Render();
                    }));
            builder.SetUpdatesAttributeName("value");
            builder.AddAttribute(17, "onkeydown",
                EventCallback.Factory.Create<KeyboardEventArgs>(this, OnKeyDown));
            builder.CloseElement(); // textarea
 
            builder.CloseElement(); // input body
 
            if (_trailingActions is not null)
            {
                builder.AddContent(50, _trailingActions);
            }
            else
            {
                builder.OpenElement(50, "button");
                builder.AddAttribute(51, "type", "button");
                builder.AddAttribute(52, "class", "sc-ai-input__send");
                builder.AddAttribute(53, "disabled", _isDisabled);
                builder.AddAttribute(54, "aria-label", "Send message");
                builder.AddAttribute(55, "onclick",
                    EventCallback.Factory.Create(this, SubmitAsync));
 
                // Send icon SVG
                builder.AddMarkupContent(56,
                    "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 2 11 13\"/><path d=\"M22 2 15 22 11 13 2 9z\"/></svg>");
 
                builder.CloseElement();
            }
 
            builder.CloseElement(); // div
        });
    }
 
    private async Task OnKeyDown(KeyboardEventArgs e)
    {
        if (e.Key == "Enter" && !e.ShiftKey && !_isDisabled)
        {
            await SubmitAsync();
        }
    }
 
    private async Task SubmitAsync()
    {
        if (_isDisabled || string.IsNullOrWhiteSpace(_text))
        {
            return;
        }
 
        var text = _text;
        _text = "";
        Render();
 
        await _agentContext.SendMessageAsync(new ChatMessage(ChatRole.User, text));
    }
 
    /// <summary>
    /// Removes the status subscription this input registered on the conversation.
    /// </summary>
    public void Dispose()
    {
        _statusSub?.Dispose();
        GC.SuppressFinalize(this);
    }
}