UI cleanups

This commit is contained in:
Javier Calvarro Nelson
2025-12-11 13:13:53 +01:00
Unverified
parent c34a72c3bf
commit 0c93762cb6
15 changed files with 666 additions and 15 deletions
@@ -14,6 +14,7 @@ public sealed partial class AgentBoundaryContext<TState> : IAgentBoundaryContext
private readonly List<ChatMessage> _pendingMessages = [];
private readonly List<Action> _messageChangeSubscribers = [];
private readonly List<Action> _responseUpdateSubscribers = [];
private readonly List<Action> _runStatusSubscribers = [];
private readonly List<AITool> _tools = [];
private readonly Dictionary<string, TaskCompletionSource<object>> _pendingResponses = [];
@@ -29,6 +30,11 @@ public sealed partial class AgentBoundaryContext<TState> : IAgentBoundaryContext
public ChatResponseUpdate? CurrentUpdate { get; private set; }
/// <summary>
/// Gets whether the agent is currently processing a turn.
/// </summary>
public bool IsProcessing { get; private set; }
public AgentBoundaryContext(AIAgent agent, AgentThread thread)
{
this._agent = agent;
@@ -102,6 +108,12 @@ public sealed partial class AgentBoundaryContext<TState> : IAgentBoundaryContext
this._pendingMessages.Clear();
this._pendingMessages.AddRange(userMessages);
// Mark as processing
this.IsProcessing = true;
// Notify subscribers that run status changed (processing started)
this.TriggerRunStatusChanged();
// User messages added, notify subscribers.
this.TriggerMessageChanges();
@@ -159,6 +171,10 @@ public sealed partial class AgentBoundaryContext<TState> : IAgentBoundaryContext
this._pendingMessages.Clear();
this.CurrentMessage = null;
this.CurrentUpdate = null;
this.IsProcessing = false;
// Notify subscribers that run status changed (processing ended)
this.TriggerRunStatusChanged();
// Notify subscribers
this.TriggerChatResponseUpdate();
@@ -192,6 +208,20 @@ public sealed partial class AgentBoundaryContext<TState> : IAgentBoundaryContext
{
return new ResponseUpdateSubscription(this._responseUpdateSubscribers, onChatResponse);
}
public RunStatusSubscription SubscribeToRunStatusChanges(Action onRunStatusChanged)
{
return new RunStatusSubscription(this._runStatusSubscribers, onRunStatusChanged);
}
private void TriggerRunStatusChanged()
{
// Iterate backwards to avoid issues if subscribers are removed during iteration
for (var i = this._runStatusSubscribers.Count - 1; i >= 0; i--)
{
this._runStatusSubscribers[i]();
}
}
}
public interface IAgentBoundaryContext
@@ -210,11 +240,23 @@ public interface IAgentBoundaryContext
ChatResponseUpdate? CurrentUpdate { get; }
/// <summary>
/// Gets whether the agent is currently processing a turn.
/// </summary>
bool IsProcessing { get; }
// Triggered any time there is a change on a message.
MessageSubscription SubscribeToMessageChanges(Action onNewMessage);
ResponseUpdateSubscription SubscribeToResponseUpdates(Action onChatResponse);
/// <summary>
/// Subscribes to run status changes (when processing starts or ends).
/// </summary>
/// <param name="onRunStatusChanged">The callback to invoke when run status changes.</param>
/// <returns>A subscription that can be disposed to unsubscribe.</returns>
RunStatusSubscription SubscribeToRunStatusChanges(Action onRunStatusChanged);
CancellationToken CancellationToken { get; }
/// <summary>
@@ -326,3 +368,44 @@ public readonly struct ResponseUpdateSubscription : IDisposable, IEquatable<Resp
return !(left == right);
}
}
public readonly struct RunStatusSubscription : IDisposable, IEquatable<RunStatusSubscription>
{
private readonly List<Action> _subscribers;
private readonly Action _subscription;
public RunStatusSubscription(List<Action> subscribers, Action subscription)
{
this._subscribers = subscribers;
this._subscription = subscription;
this._subscribers.Add(this._subscription);
}
public void Dispose() => this._subscribers.Remove(this._subscription);
public override bool Equals(object? obj)
{
return obj is RunStatusSubscription subscription && this.Equals(subscription);
}
public bool Equals(RunStatusSubscription other)
{
return EqualityComparer<List<Action>>.Default.Equals(this._subscribers, other._subscribers) &&
EqualityComparer<Action>.Default.Equals(this._subscription, other._subscription);
}
public override int GetHashCode()
{
return HashCode.Combine(this._subscribers, this._subscription);
}
public static bool operator ==(RunStatusSubscription left, RunStatusSubscription right)
{
return left.Equals(right);
}
public static bool operator !=(RunStatusSubscription left, RunStatusSubscription right)
{
return !(left == right);
}
}
@@ -4,11 +4,12 @@ using Microsoft.Extensions.AI;
namespace Microsoft.AspNetCore.Components.AI;
public partial class AgentInput : IComponent
public sealed partial class AgentInput : IComponent, IDisposable
{
private RenderHandle _renderHandle;
private AgentBoundaryContext<object?>? _context;
private string? _inputText;
private RunStatusSubscription? _subscription;
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
@@ -22,13 +23,34 @@ public partial class AgentInput : IComponent
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this._context = this.AgentContext;
// Unsubscribe from previous context if it changed
if (this._context != this.AgentContext)
{
this._subscription?.Dispose();
this._context = this.AgentContext;
// Subscribe to run status changes
if (this._context != null)
{
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
}
}
this.Render();
return Task.CompletedTask;
}
private void OnRunStatusChanged()
{
this.Render();
}
private void Render()
{
var isProcessing = this._context?.IsProcessing ?? false;
var isDisabled = string.IsNullOrWhiteSpace(this._inputText) || isProcessing;
this._renderHandle.Render(builder =>
{
builder.OpenElement(0, "div");
@@ -39,13 +61,14 @@ public partial class AgentInput : IComponent
builder.AddAttribute(4, "oninput", EventCallback.Factory.Create<ChangeEventArgs>(this, e => { this._inputText = e.Value?.ToString(); this.Render(); }));
builder.AddAttribute(5, "placeholder", this.Placeholder);
builder.AddAttribute(6, "rows", "1");
builder.AddAttribute(7, "disabled", isProcessing);
builder.CloseElement();
builder.OpenElement(7, "button");
builder.AddAttribute(8, "class", "send-button");
builder.AddAttribute(9, "onclick", EventCallback.Factory.Create(this, this.SendAsync));
builder.AddAttribute(10, "disabled", string.IsNullOrWhiteSpace(this._inputText));
builder.AddMarkupContent(11, """<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>""");
builder.OpenElement(8, "button");
builder.AddAttribute(9, "class", "send-button");
builder.AddAttribute(10, "onclick", EventCallback.Factory.Create(this, this.SendAsync));
builder.AddAttribute(11, "disabled", isDisabled);
builder.AddMarkupContent(12, """<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>""");
builder.CloseElement(); // close button
builder.CloseElement(); // close div
@@ -54,7 +77,7 @@ public partial class AgentInput : IComponent
private async Task SendAsync()
{
if (!string.IsNullOrWhiteSpace(this._inputText) && this._context != null)
if (!string.IsNullOrWhiteSpace(this._inputText) && this._context is { IsProcessing: false })
{
var text = this._inputText;
this._inputText = ""; // Clear input immediately
@@ -63,4 +86,9 @@ public partial class AgentInput : IComponent
await this._context.SendAsync(new ChatMessage(ChatRole.User, text));
}
}
public void Dispose()
{
this._subscription?.Dispose();
}
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.AspNetCore.Components.AI;
/// <summary>
/// A component that displays a loading indicator (three animated dots) when the agent is processing.
/// </summary>
public sealed partial class AgentLoadingIndicator : IComponent, IDisposable
{
private RenderHandle _renderHandle;
private AgentBoundaryContext<object?>? _context;
private RunStatusSubscription? _subscription;
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
public void Attach(RenderHandle renderHandle)
{
this._renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
// Unsubscribe from previous context if it changed
if (this._context != this.AgentContext)
{
this._subscription?.Dispose();
this._context = this.AgentContext;
// Subscribe to run status changes
if (this._context != null)
{
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
}
}
this.Render();
return Task.CompletedTask;
}
private void OnRunStatusChanged()
{
this.Render();
}
private void Render()
{
var isProcessing = this._context?.IsProcessing ?? false;
this._renderHandle.Render(builder =>
{
if (!isProcessing)
{
return;
}
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "agent-loading-indicator");
builder.OpenElement(2, "div");
builder.AddAttribute(3, "class", "agent-loading-dots");
builder.AddMarkupContent(4, "<span></span><span></span><span></span>");
builder.CloseElement();
builder.CloseElement();
});
}
public void Dispose()
{
this._subscription?.Dispose();
}
}
@@ -60,11 +60,12 @@ public readonly struct Suggestion : IEquatable<Suggestion>
public static bool operator !=(Suggestion left, Suggestion right) => !left.Equals(right);
}
public partial class AgentSuggestions : IComponent
public sealed partial class AgentSuggestions : IComponent, IDisposable
{
private RenderHandle _renderHandle;
private AgentBoundaryContext<object?>? _context;
private IReadOnlyList<Suggestion>? _suggestions;
private RunStatusSubscription? _subscription;
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
@@ -78,14 +79,34 @@ public partial class AgentSuggestions : IComponent
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
this._context = this.AgentContext;
// Unsubscribe from previous context if it changed
if (this._context != this.AgentContext)
{
this._subscription?.Dispose();
this._context = this.AgentContext;
// Subscribe to run status changes
if (this._context != null)
{
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
}
}
this._suggestions = this.Suggestions;
this.Render();
return Task.CompletedTask;
}
private void OnRunStatusChanged()
{
this.Render();
}
private void Render()
{
var isProcessing = this._context?.IsProcessing ?? false;
this._renderHandle.Render(builder =>
{
if (this._suggestions is null || this._suggestions.Count == 0)
@@ -103,7 +124,8 @@ public partial class AgentSuggestions : IComponent
builder.SetKey(suggestion.Text);
builder.AddAttribute(3, "class", "suggestion-button");
builder.AddAttribute(4, "onclick", EventCallback.Factory.Create(this, () => this.SelectSuggestionAsync(suggestion)));
builder.AddContent(5, suggestion.Text);
builder.AddAttribute(5, "disabled", isProcessing);
builder.AddContent(6, suggestion.Text);
builder.CloseElement();
}
@@ -113,9 +135,14 @@ public partial class AgentSuggestions : IComponent
private async Task SelectSuggestionAsync(Suggestion suggestion)
{
if (this._context != null)
if (this._context is { IsProcessing: false })
{
await this._context.SendAsync(suggestion.Message);
}
}
public void Dispose()
{
this._subscription?.Dispose();
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.Extensions.AI;
@@ -0,0 +1,309 @@
/* Copyright (c) Microsoft. All rights reserved. */
/* ==========================================================================
AG-UI Components - Shared Styles
These styles provide consistent appearance for AI components across demos.
========================================================================== */
/* ==========================================================================
Agent Input Component
========================================================================== */
.agent-input {
display: flex;
align-items: flex-end;
gap: 12px;
width: 100%;
}
.agent-input textarea {
flex: 1;
padding: 12px 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
resize: none;
min-height: 44px;
max-height: 200px;
line-height: 1.5;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.agent-input textarea:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.agent-input textarea::placeholder {
color: #9ca3af;
}
.agent-input textarea:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
.agent-input .send-button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 8px;
background-color: #2563eb;
color: white;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
flex-shrink: 0;
}
.agent-input .send-button:hover:not(:disabled) {
background-color: #1d4ed8;
}
.agent-input .send-button:disabled {
background-color: #d1d5db;
color: #9ca3af;
cursor: not-allowed;
}
.agent-input .send-button svg {
display: block;
}
/* ==========================================================================
Agent Suggestions Component
========================================================================== */
.agent-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-start;
margin-bottom: 0.75rem;
}
.suggestion-button {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 1rem;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
.suggestion-button:hover:not(:disabled) {
background: #f0f0f0;
border-color: #0078d4;
color: #0078d4;
}
.suggestion-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ==========================================================================
Loading Indicator - Triple Dot Animation
========================================================================== */
.agent-loading-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: #6b7280;
font-size: 14px;
}
.agent-loading-dots {
display: flex;
gap: 4px;
align-items: center;
}
.agent-loading-dots span {
width: 6px;
height: 6px;
background-color: #2563eb;
border-radius: 50%;
animation: agent-dot-bounce 1.4s ease-in-out infinite both;
}
.agent-loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.agent-loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
.agent-loading-dots span:nth-child(3) {
animation-delay: 0s;
}
@keyframes agent-dot-bounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* Inline loading indicator for use within messages */
.agent-loading-inline {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: #f3f4f6;
border-radius: 12px;
}
.agent-loading-inline span {
width: 5px;
height: 5px;
background-color: #6b7280;
border-radius: 50%;
animation: agent-dot-bounce 1.4s ease-in-out infinite both;
}
.agent-loading-inline span:nth-child(1) {
animation-delay: -0.32s;
}
.agent-loading-inline span:nth-child(2) {
animation-delay: -0.16s;
}
.agent-loading-inline span:nth-child(3) {
animation-delay: 0s;
}
/* ==========================================================================
Processing State Overlay
========================================================================== */
.agent-input-container {
position: relative;
}
.agent-input-container.processing .agent-input textarea {
background-color: #f9fafb;
}
/* ==========================================================================
Message Styles
========================================================================== */
.message-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
flex-direction: column;
gap: 4px;
}
.message-user {
align-items: flex-end;
}
.message-assistant {
align-items: flex-start;
}
.message-content {
max-width: 80%;
padding: 12px 16px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
}
.message-user .message-content {
background-color: #2563eb;
color: white;
border-bottom-right-radius: 4px;
}
.message-assistant .message-content {
background-color: #f3f4f6;
color: #1f2937;
border-bottom-left-radius: 4px;
}
/* ==========================================================================
Common Chat Layout Styles
========================================================================== */
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
max-width: 1200px;
margin: 0 auto;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid #e5e5e5;
}
.chat-title {
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
}
.new-chat-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d1d1;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
color: #424242;
transition: all 0.2s;
}
.new-chat-button:hover {
background: #f5f5f5;
border-color: #b3b3b3;
}
.button-icon {
font-size: 1.2rem;
line-height: 1;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
flex-direction: column;
}
.chat-input-container {
padding: 1.5rem 2rem 2rem;
border-top: 1px solid #e5e5e5;
background: white;
}
@@ -21,6 +21,7 @@
<AgentBoundary Agent="@agent">
<div class="chat-content">
<Messages />
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
@@ -30,6 +30,7 @@
<TextTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
@@ -23,6 +23,7 @@
<WeatherCallTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
@@ -26,6 +26,7 @@
<TextTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
@@ -54,6 +54,7 @@
<TextTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<AgentState TState="DocumentState"
@@ -216,11 +216,11 @@
}
/* Agent Suggestions styling overrides */
::deep .suggestions-container {
::deep .agent-suggestions {
margin-bottom: 12px;
}
::deep .suggestion-chip {
::deep .agent-suggestions .suggestion-button {
background: #f3f4f6;
border: 1px solid #e5e7eb;
padding: 8px 14px;
@@ -231,7 +231,12 @@
transition: all 0.15s ease;
}
::deep .suggestion-chip:hover {
::deep .agent-suggestions .suggestion-button:hover:not(:disabled) {
background: #e5e7eb;
border-color: #d1d5db;
}
::deep .agent-suggestions .suggestion-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -116,6 +116,7 @@
<TextTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
<AgentInput Placeholder="Ask about your recipe..." />
@@ -30,6 +30,7 @@
<TextTemplate />
</ContentTemplates>
</Messages>
<AgentLoadingIndicator />
</div>
<div class="chat-input-container">
@@ -148,3 +148,122 @@ h1:focus {
.agent-input .send-button svg {
display: block;
}
.agent-input textarea:disabled {
background-color: #f3f4f6;
color: #9ca3af;
cursor: not-allowed;
}
/* Loading dots animation */
.agent-loading-dots {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
gap: 4px;
}
.agent-loading-dots span {
width: 8px;
height: 8px;
background-color: #2563eb;
border-radius: 50%;
animation: agent-dot-bounce 1.4s ease-in-out infinite both;
}
.agent-loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.agent-loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
.agent-loading-dots span:nth-child(3) {
animation-delay: 0s;
}
@keyframes agent-dot-bounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* Loading indicator in message list */
.agent-loading-indicator {
display: flex;
align-items: flex-start;
padding: 12px 16px;
}
.agent-loading-indicator .agent-loading-dots {
background-color: #f3f4f6;
border-radius: 16px;
padding: 12px 16px;
width: auto;
height: auto;
}
.agent-loading-indicator .agent-loading-dots span {
width: 6px;
height: 6px;
background-color: #6b7280;
}
/* AgentSuggestions component styles */
.agent-suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 16px;
}
.agent-suggestions .suggestion-button {
padding: 8px 16px;
border: 1px solid #d1d5db;
border-radius: 20px;
background-color: #f9fafb;
color: #374151;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.agent-suggestions .suggestion-button:hover:not(:disabled) {
border-color: #2563eb;
background-color: #eff6ff;
color: #2563eb;
}
.agent-suggestions .suggestion-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Chat layout common styles */
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
max-width: 800px;
margin: 0 auto;
background-color: #ffffff;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.chat-input-area {
border-top: 1px solid #e5e7eb;
background-color: #fff;
}