Improve copy-to-clipboard UX in Ask.razor

Refactored the copy button to use per-message state, displaying a checkmark icon and "Copied!" tooltip when a message is copied. Simplified state management, removed unused references, and updated the Reset method to clear copied state. Enhanced Ask.razor.css to minimize button chrome and improve interactive states for a cleaner UI.
This commit is contained in:
Marco Minerva
2026-07-28 15:42:07 +02:00
parent 54609d1718
commit 20bf98b131
2 changed files with 46 additions and 21 deletions
@@ -77,10 +77,10 @@
<Icon Class="d-flex" Name="IconName.CashCoin"></Icon> <Icon Class="d-flex" Name="IconName.CashCoin"></Icon>
</Tooltip> </Tooltip>
</div> </div>
<div class="text-end bg-transparent"> <div class="text-end bg-transparent copy-button">
<Tooltip Title="@toolTipText" Color="TooltipColor.Dark" Placement="TooltipPlacement.Bottom"> <Tooltip Title="@(copiedMessage == message ? "Copied!" : "Copy to clipboard")" Color="TooltipColor.Dark" Placement="TooltipPlacement.Bottom">
<Button Type="ButtonType.Button" Outline="false" @onclick="@(async () => await CopyToClipboardAsync(message.Text))"> <Button Type="ButtonType.Button" Outline="false" @onclick="@(async () => await CopyToClipboardAsync(message))">
@if (showCopyConfirmation) @if (copiedMessage == message)
{ {
<Icon Name="IconName.Check" Class="text-success" /> <Icon Name="IconName.Check" Class="text-success" />
} }
@@ -113,11 +113,11 @@
</span> </span>
<input @ref="askInput" type="text" @bind="@question" @bind:event="oninput" placeholder="Ask a question about your documents..." class="form-control border-0" maxlength="2000" @onkeydown="HandleKeyDown" /> <input @ref="askInput" type="text" @bind="@question" @bind:event="oninput" placeholder="Ask a question about your documents..." class="form-control border-0" maxlength="2000" @onkeydown="HandleKeyDown" />
<div class="input-group-text bg-transparent border-0"> <div class="input-group-text bg-transparent border-0">
<Button Type="ButtonType.Submit" @ref="askButton" Color="ButtonColor.Primary" Disabled="@(isAsking || string.IsNullOrWhiteSpace(question))" @onclick="AskQuestion"> <Button Type="ButtonType.Submit" Color="ButtonColor.Primary" Disabled="@(isAsking || string.IsNullOrWhiteSpace(question))" @onclick="AskQuestion">
<Icon Name="IconName.Send" /> <Icon Name="IconName.Send" />
</Button> </Button>
<Tooltip Title="Start a new conversation" Color="TooltipColor.Secondary" Placement="TooltipPlacement.Bottom"> <Tooltip Title="Start a new conversation" Color="TooltipColor.Secondary" Placement="TooltipPlacement.Bottom">
<Button Type="ButtonType.Reset" @ref="resetButton" Class="ms-2" Color="ButtonColor.Secondary" Disabled="@isAsking" @onclick="Reset"> <Button Type="ButtonType.Reset" Class="ms-2" Color="ButtonColor.Secondary" Disabled="@isAsking" @onclick="Reset">
<Icon CustomIconName="bi bi-x-lg" /> <Icon CustomIconName="bi bi-x-lg" />
</Button> </Button>
</Tooltip> </Tooltip>
@@ -128,8 +128,6 @@
@code @code
{ {
private Button askButton = default!;
private Button resetButton = default!;
private ElementReference askInput = default!; private ElementReference askInput = default!;
private ElementReference chat = default!; private ElementReference chat = default!;
@@ -137,10 +135,9 @@
private string? question; private string? question;
private Guid conversationId = Guid.NewGuid(); private Guid conversationId = Guid.NewGuid();
private bool isAsking = false; private bool isAsking;
private bool showCopyConfirmation = false; private Message? copiedMessage;
private string toolTipText = "Copy to clipboard";
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
@@ -222,9 +219,10 @@
} }
finally finally
{ {
await EnsureMessageIsVisibleAsync();
isAsking = false; isAsking = false;
await InvokeAsync(StateHasChanged);
await EnsureMessageIsVisibleAsync();
} }
} }
@@ -232,25 +230,29 @@
{ {
question = null; question = null;
conversationId = Guid.NewGuid(); conversationId = Guid.NewGuid();
copiedMessage = null;
messages.Clear(); messages.Clear();
} }
private async Task CopyToClipboardAsync(string text) private async Task CopyToClipboardAsync(Message message)
{ {
if (text is null) if (string.IsNullOrEmpty(message.Text))
{
return; return;
}
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text); await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", message.Text);
showCopyConfirmation = true; copiedMessage = message;
toolTipText = "Copied!";
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
await Task.Delay(3000); // Shows the checkmark for 3 seconds await Task.Delay(3000); // Shows the checkmark for 3 seconds
toolTipText = "Copy to clipboard"; if (copiedMessage == message)
showCopyConfirmation = false; {
await InvokeAsync(StateHasChanged); copiedMessage = null;
await InvokeAsync(StateHasChanged);
}
} }
private static string FormatTokenUsage(TokenUsageResponse? tokenUsageResponse) private static string FormatTokenUsage(TokenUsageResponse? tokenUsageResponse)
@@ -119,6 +119,29 @@ input[type="checkbox"] + label {
z-index: 10; z-index: 10;
} }
/* The copy button only shows an icon, so the default button chrome is removed and just the pointer cursor is kept. */
.copy-button ::deep .btn {
background-color: transparent;
border-color: transparent;
box-shadow: none;
color: var(--bs-secondary-color);
}
.copy-button ::deep .btn:hover {
background-color: transparent;
border-color: transparent;
color: var(--bs-body-color);
}
.copy-button ::deep .btn:focus,
.copy-button ::deep .btn:focus-visible,
.copy-button ::deep .btn:active {
background-color: transparent;
border-color: transparent;
box-shadow: none;
outline: none;
}
.btn-clipboard { .btn-clipboard {
line-height: 1; line-height: 1;
color: var(--bs-body-color); color: var(--bs-body-color);