From 88e8b95bf2566c9856c702003ef51c75782a8e36 Mon Sep 17 00:00:00 2001 From: David Bond Date: Tue, 6 Oct 2026 00:26:51 +0100 Subject: [PATCH] MS-26473 PDChat: input toolbar with model picker and separate read-aloud Every chat control now sits in one thin toolbar above the text box, which takes the full width: the agent and model pickers, notification sounds (moved from the header), read-aloud, the microphone and Send. The window controls stay in the header. - IChatService.Models and SelectedModelId, offered like agents (two or more). - IChatService.IsReadAloudEnabled, off by default and independent of both the microphone and notification sounds, so all four sound combinations exist. Read-aloud works without the microphone; the microphone pauses only while an answer is to be spoken. - The microphone is an icon with an accessible name, no text. - Narrow panels hide the picker labels (container query) and keep one row. - The demo offers two models, and its second agent is no longer named like a voice option. Co-Authored-By: Claude Opus 5.5 --- .../Components/PDChatTests.Agents.cs | 4 +- .../Components/PDChatTests.Fakes.cs | 1 + .../Components/PDChatTests.Models.cs | 75 ++++++++++ .../Components/PDChatTests.Service.cs | 6 +- .../Components/PDChatTests.Voice.cs | 137 ++++++++++++++++-- .../Components/PDChatTests.cs | 4 + .../Components/PDMessagesTests.ChatInput.cs | 15 +- .../Interfaces/ChatServiceDefaultState.cs | 4 + .../Interfaces/IChatService.cs | 26 ++++ .../Models/PDChatModelOption.cs | 10 ++ PanoramicData.Blazor/PDChat.Agents.cs | 25 ---- PanoramicData.Blazor/PDChat.Toolbar.cs | 39 +++++ PanoramicData.Blazor/PDChat.Voice.cs | 99 ++++++++++--- PanoramicData.Blazor/PDChat.razor | 47 ++++-- PanoramicData.Blazor/PDChat.razor.css | 56 ++++++- PanoramicData.Blazor/PDMessages.razor | 16 +- PanoramicData.Blazor/PDMessages.razor.cs | 3 +- PanoramicData.Blazor/PDMessages.razor.css | 28 +++- .../Services/DumbChatService.Voice.cs | 16 +- 19 files changed, 501 insertions(+), 110 deletions(-) create mode 100644 PanoramicData.Blazor.Test/Components/PDChatTests.Models.cs create mode 100644 PanoramicData.Blazor/Models/PDChatModelOption.cs delete mode 100644 PanoramicData.Blazor/PDChat.Agents.cs create mode 100644 PanoramicData.Blazor/PDChat.Toolbar.cs diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Agents.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Agents.cs index 095de2328..2b8b210f6 100644 --- a/PanoramicData.Blazor.Test/Components/PDChatTests.Agents.cs +++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Agents.cs @@ -24,7 +24,7 @@ public void The_agent_picker_is_hidden_with_fewer_than_two_agents(int count) var component = RenderChat(new FakeChatService { Agents = [.. new[] { _merlin, _alice }.Take(count)] }); component.FindAll(".pdchat-agent-picker").Should().BeEmpty(); - component.FindAll(".chat-input-accessories").Should().BeEmpty("there is nothing else to show there"); + component.Find(".chat-input-accessories").QuerySelectorAll("select").Should().BeEmpty(); } /// Verifies that no picker is shown when the service does not list agents at all. @@ -38,7 +38,7 @@ public void The_agent_picker_is_shown_in_the_input_area_with_two_agents() { var component = RenderChat(new FakeChatService { Agents = [_merlin, _alice] }); - var select = component.Find(".chat-input-container .chat-input-accessories .pdchat-agent-picker select"); + var select = component.Find(".chat-input-accessories .pdchat-agent-picker select"); select.GetAttribute("title").Should().Be("Who you are talking to"); var options = select.QuerySelectorAll("option"); options.Select(option => option.TextContent).Should().Equal("Merlin", "Alice"); diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Fakes.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Fakes.cs index 319da5a7a..3df5c1037 100644 --- a/PanoramicData.Blazor.Test/Components/PDChatTests.Fakes.cs +++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Fakes.cs @@ -56,6 +56,7 @@ private sealed partial class FakeChatService : IChatService public PDChatButtonPosition ToastAnchor { get; set; } = PDChatButtonPosition.BottomRight; public PDChatVoiceEndpoints? VoiceEndpoints { get; set; } public IReadOnlyList? Agents { get; set; } + public IReadOnlyList? Models { get; set; } public IReadOnlyList Messages => Store; public bool SupportsConversations { get; init; } diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Models.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Models.cs new file mode 100644 index 000000000..ac6b08681 --- /dev/null +++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Models.cs @@ -0,0 +1,75 @@ +using AwesomeAssertions; +using Bunit; +using Microsoft.AspNetCore.Components; +using PanoramicData.Blazor.Interfaces; +using PanoramicData.Blazor.Models; + +namespace PanoramicData.Blazor.Test; + +/// +/// The model picker: offered in the input toolbar only when the service lists two or more models, and choosing one sets +/// . +/// +public partial class PDChatTests +{ + private static readonly PDChatModelOption _quick = new("quick", "Quick", "Small and fast"); + private static readonly PDChatModelOption _thorough = new("thorough", "Thorough"); + + /// Verifies that no model picker is shown with no models, or with only one to choose. + [Theory] + [InlineData(0)] + [InlineData(1)] + public void The_model_picker_is_hidden_with_fewer_than_two_models(int count) + => RenderChat(new FakeChatService { Models = [.. new[] { _quick, _thorough }.Take(count)] }) + .FindAll(".pdchat-model-picker").Should().BeEmpty(); + + /// Verifies that two or more models are offered in the toolbar, labelled, the first selected by default. + [Fact] + public void The_model_picker_is_shown_in_the_toolbar_with_two_models() + { + var picker = RenderChat(new FakeChatService { Models = [_quick, _thorough] }).Find(".chat-input-accessories .pdchat-model-picker"); + + picker.QuerySelector(".pdchat-toolbar-label")!.TextContent.Should().Be("Model"); + var options = picker.QuerySelectorAll("option"); + options.Select(option => option.TextContent).Should().Equal("Quick", "Thorough"); + options[0].GetAttribute("title").Should().Be("Small and fast"); + options[0].HasAttribute("selected").Should().BeTrue(); + } + + /// Verifies that choosing a model sets the service's selected model, and an unknown one is ignored. + [Fact] + public async Task Choosing_a_model_sets_the_selected_model() + { + var service = new FakeChatService { Models = [_quick, _thorough] }; + var component = RenderChat(service); + + await component.Find(".pdchat-model-picker select").ChangeAsync(new ChangeEventArgs { Value = "mallory" }); + ((IChatService)service).SelectedModelId.Should().BeNull(); + + await component.Find(".pdchat-model-picker select").ChangeAsync(new ChangeEventArgs { Value = "thorough" }); + ((IChatService)service).SelectedModelId.Should().Be("thorough"); + component.Find(".pdchat-model-picker option[value=thorough]").HasAttribute("selected").Should().BeTrue(); + } + + /// Verifies that the agent and model pickers are separate, labelled choices in the same toolbar. + [Fact] + public void The_agent_and_model_pickers_are_both_labelled() + { + var toolbar = RenderChat(new FakeChatService { Agents = [_merlin, _alice], Models = [_quick, _thorough] }) + .Find(".chat-input-accessories"); + + toolbar.QuerySelectorAll(".pdchat-toolbar-label").Select(label => label.TextContent).Should().Equal("Agent", "Model"); + } + + /// Verifies that the demo service offers models, agents and simulated voice. + [Fact] + public void The_demo_service_offers_the_whole_toolbar() + { + using var service = new PanoramicData.Blazor.Services.DumbChatService(); + IChatService chat = service; + + chat.Models.Should().HaveCountGreaterThan(1); + chat.Agents.Should().HaveCountGreaterThan(1); + chat.VoiceEndpoints.Should().Be(PDChatVoiceEndpoints.Simulated); + } +} diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Service.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Service.cs index bce1bfa56..6851f21d0 100644 --- a/PanoramicData.Blazor.Test/Components/PDChatTests.Service.cs +++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Service.cs @@ -21,10 +21,10 @@ public async Task Mute_toggles_the_service_and_raises_the_event() var service = new FakeChatService(); var component = RenderChat(service, p => p.Add(x => x.OnMuteToggled, () => toggles++)); - await HeaderButton(component, "Mute").ClickAsync(new MouseEventArgs()); + await InputToolbarButton(component, "Notification sounds: on").ClickAsync(new MouseEventArgs()); service.IsMuted.Should().BeTrue(); - HeaderButton(component, "Unmute").TextContent.Should().Contain("πŸ”‡"); + InputToolbarButton(component, "Notification sounds: off").TextContent.Should().Contain("πŸ”•"); toggles.Should().Be(1); } @@ -37,7 +37,7 @@ public async Task A_mute_change_from_the_service_is_reflected() await component.InvokeAsync(() => service.AnnounceMute(true)); - component.WaitForAssertion(() => HeaderButton(component, "Unmute").Should().NotBeNull(), Patience); + component.WaitForAssertion(() => InputToolbarButton(component, "Notification sounds: off").Should().NotBeNull(), Patience); } /// Verifies that live-status and configuration announcements re-render the chat. diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Voice.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Voice.cs index 220e58702..00e09b0c5 100644 --- a/PanoramicData.Blazor.Test/Components/PDChatTests.Voice.cs +++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Voice.cs @@ -31,7 +31,7 @@ public void Voice_Mode_is_offered_but_off_by_default() var component = RenderChat(VoiceService()); var toggle = component.Find(".pdchat-voice-toggle"); - toggle.TextContent.Should().Contain("Voice"); + toggle.GetAttribute("aria-label").Should().Be("Voice: speak instead of typing"); toggle.GetAttribute("title").Should().Be("Voice: speak instead of typing"); toggle.GetAttribute("aria-pressed").Should().Be("false"); component.FindAll(".pdchat-voice-status").Should().BeEmpty(); @@ -45,7 +45,7 @@ public void The_Voice_control_is_in_the_input_area() var component = RenderChat(VoiceService()); component.FindAll(".pdchat-header .pdchat-voice-toggle").Should().BeEmpty(); - component.FindAll(".chat-input-container .chat-input-accessories .pdchat-voice-toggle").Should().ContainSingle(); + component.FindAll(".chat-input-accessories .pdchat-voice-toggle").Should().ContainSingle(); } /// @@ -176,7 +176,7 @@ public async Task Dictation_is_never_auto_sent_while_the_text_box_has_focus() service.Sent.Should().BeEmpty(); component.Find(".pdchat-voice-status").TextContent.Trim().Should().Be("Listening. You are editing, so press Send when ready."); - await component.Find(".chat-input-container > button").ClickAsync(new MouseEventArgs()); + await component.Find(".chat-input-accessories .chat-send").ClickAsync(new MouseEventArgs()); service.Sent.Should().ContainSingle().Which.Message.Should().Be("Is it done?"); module.Invocations["pause"].Should().ContainSingle().Which.Arguments[0].Should().Be(true); @@ -317,10 +317,128 @@ public async Task A_refused_microphone_is_explained() component.Find(".pdchat-voice-status").TextContent.Should().Contain("microphone could not be opened"); } - private static FakeChatService VoiceService(TimeSpan? autoSendDelay = null) + /// Verifies that answers are not read aloud by default: dictation is sent and the microphone keeps listening. + [Fact] + public async Task Without_read_aloud_dictation_is_sent_and_nothing_is_spoken() + { + var module = SetUpVoiceModule(); + var service = VoiceService(readAloud: false); + var component = await RenderListeningAsync(service); + component.Find(".pdchat-read-aloud-toggle").GetAttribute("aria-pressed").Should().Be("false"); + + await AskAsync(component, service, "Is it done?"); + await component.InvokeAsync(() => service.Receive(Message("Yes."))); + + module.Invocations["speak"].Should().BeEmpty(); + module.Invocations["pause"].Should().BeEmpty("the microphone only pauses while an answer is to be spoken"); + component.Instance.VoiceState.Should().Be(PDChatVoiceState.Listening); + } + + /// Verifies that read-aloud works without the microphone: the answer to a typed message is spoken. + [Fact] + public async Task With_read_aloud_and_no_microphone_a_typed_question_is_answered_aloud() + { + var module = SetUpVoiceModule(); + var service = VoiceService(readAloud: false); + var component = RenderChat(service); + await component.Find(".pdchat-read-aloud-toggle").ClickAsync(new()); + ((IChatService)service).IsReadAloudEnabled.Should().BeTrue(); + + await component.Find("textarea").InputAsync(new Microsoft.AspNetCore.Components.ChangeEventArgs { Value = "Is it done?" }); + await component.Find(".chat-input-accessories .chat-send").ClickAsync(new MouseEventArgs()); + await component.InvokeAsync(() => service.Receive(Message("Yes."))); + + module.Invocations["speak"].Should().ContainSingle().Which.Arguments[1].Should().Be("Yes."); + module.Invocations["start"].Should().BeEmpty("reading aloud never opens the microphone"); + component.Instance.VoiceState.Should().Be(PDChatVoiceState.Off); + } + + /// Verifies that turning read-aloud off mid-answer stops speaking and the microphone listens again. + [Fact] + public async Task Turning_read_aloud_off_mid_answer_stops_it_and_listening_resumes() + { + var module = SetUpVoiceModule(); + var service = VoiceService(); + var component = await RenderListeningAsync(service); + await AskAsync(component, service, "Is it done?"); + await component.InvokeAsync(() => service.Receive(Message("Yes."))); + component.Instance.VoiceState.Should().Be(PDChatVoiceState.Speaking); + + await component.Find(".pdchat-read-aloud-toggle").ClickAsync(new()); + + module.Invocations["stopSpeaking"].Should().ContainSingle(); + module.Invocations["pause"].Select(call => call.Arguments[0]).Should().Equal(true, false); + component.Instance.VoiceState.Should().Be(PDChatVoiceState.Listening); + } + + /// Verifies that read-aloud and notification sounds are separate choices, so all four combinations exist. + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Read_aloud_and_notification_sounds_are_independent(bool muted, bool readAloud) + { + var service = VoiceService(readAloud: false); + var component = RenderChat(service); + + if (muted) + { + await component.Find(".pdchat-mute-toggle").ClickAsync(new()); + } + + if (readAloud) + { + await component.Find(".pdchat-read-aloud-toggle").ClickAsync(new()); + } + + service.IsMuted.Should().Be(muted); + ((IChatService)service).IsReadAloudEnabled.Should().Be(readAloud); + component.Find(".pdchat-mute-toggle").TextContent.Should().Be(muted ? "πŸ”•" : "πŸ””"); + component.Find(".pdchat-read-aloud-toggle").GetAttribute("aria-pressed").Should().Be(readAloud ? "true" : "false"); + } + + /// Verifies that the microphone control is an icon alone, named for screen readers by its label. + [Fact] + public void The_microphone_control_is_an_icon_only() + { + var toggle = RenderChat(VoiceService()).Find(".pdchat-voice-toggle"); + + toggle.TextContent.Trim().Should().Be("πŸŽ™οΈ"); + toggle.GetAttribute("aria-label").Should().Be("Voice: speak instead of typing"); + } + + /// Verifies that the toolbar sits above the input, leaving the text box and Send the whole row. + [Fact] + public void The_toolbar_sits_above_a_full_width_input_row() + { + var component = RenderChat(VoiceService()); + + var toolbar = component.Find(".chat-input-accessories"); + toolbar.NextElementSibling!.ClassList.Should().Contain("chat-input-container"); + component.Find(".chat-input-container").Children.Select(child => child.LocalName).Should().Equal("textarea"); + toolbar.LastElementChild!.ClassList.Should().Contain("chat-send"); + component.FindAll(".pdchat-header .pdchat-mute-toggle, .pdchat-header-btn[title^='Notification']").Should().BeEmpty(); + toolbar.QuerySelector(".pdchat-mute-toggle").Should().NotBeNull(); + } + + /// Verifies that notification sounds can still be switched where typing is not permitted. + [Fact] + public void Notification_sounds_stay_reachable_where_typing_is_not_permitted() + { + var service = VoiceService(); + ((IChatService)service).IsInputPermitted = false; + var component = RenderChat(service); + + component.FindAll(".chat-input-accessories .pdchat-mute-toggle").Should().ContainSingle(); + component.FindAll(".pdchat-voice-toggle, .pdchat-read-aloud-toggle").Should().BeEmpty(); + } + + private static FakeChatService VoiceService(TimeSpan? autoSendDelay = null, bool readAloud = true) { var service = new FakeChatService { VoiceEndpoints = _voiceEndpoints }; ((IChatService)service).VoiceAutoSendDelay = autoSendDelay ?? TimeSpan.Zero; + ((IChatService)service).IsReadAloudEnabled = readAloud; return service; } @@ -365,17 +483,6 @@ public async Task Simulated_Voice_Mode_uses_its_own_module() component.Instance.VoiceState.Should().Be(PDChatVoiceState.Listening); } - /// Verifies that the demo service shows Voice Mode and the agent picker with no speech service behind it. - [Fact] - public void The_demo_service_offers_simulated_Voice_Mode_and_agents() - { - using var service = new PanoramicData.Blazor.Services.DumbChatService(); - IChatService chat = service; - - chat.VoiceEndpoints.Should().Be(PDChatVoiceEndpoints.Simulated); - chat.Agents.Should().HaveCountGreaterThan(1); - } - private BunitJSModuleInterop SetUpVoiceModule() { var module = JSInterop.SetupModule(VoiceModulePath); diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.cs index 8df526899..9c96dbc2f 100644 --- a/PanoramicData.Blazor.Test/Components/PDChatTests.cs +++ b/PanoramicData.Blazor.Test/Components/PDChatTests.cs @@ -89,6 +89,10 @@ private static IElement HeaderButton(IRenderedComponent where TComponent : IComponent => component.Find($".pdchat-header-btn[title='{title}']"); + private static IElement InputToolbarButton(IRenderedComponent component, string title) + where TComponent : IComponent + => component.Find($".chat-input-accessories .pdchat-toolbar-btn[title='{title}']"); + private static ChatMessage Message(string text, MessageType type = MessageType.Normal) => new() { Id = Guid.NewGuid(), diff --git a/PanoramicData.Blazor.Test/Components/PDMessagesTests.ChatInput.cs b/PanoramicData.Blazor.Test/Components/PDMessagesTests.ChatInput.cs index 98e180a49..e55b54ab8 100644 --- a/PanoramicData.Blazor.Test/Components/PDMessagesTests.ChatInput.cs +++ b/PanoramicData.Blazor.Test/Components/PDMessagesTests.ChatInput.cs @@ -68,19 +68,22 @@ public async Task SendAsync_is_the_same_as_pressing_send() events.Should().Equal("input:spoken", "input:spoken", "send"); } - /// Verifies that host controls are shown between the text box and Send, and not at all when absent. + /// Verifies that host controls and Send share a toolbar above a full-width text box. [Fact] - public void Input_accessories_sit_beside_send() + public void Input_accessories_sit_in_a_toolbar_above_the_input() { - RenderLive([]).FindAll(".chat-input-accessories").Should().BeEmpty(); + RenderLive([]).Find(".chat-input-accessories").Children.Select(child => child.ClassName).Should().Equal("btn btn-sm btn-secondary chat-send"); var component = Render(parameters => parameters .Add(p => p.IsLive, true) .Add(p => p.InputAccessories, builder => builder.AddMarkupContent(0, ""))); - var row = component.Find(".chat-input-container"); - row.Children.Select(child => child.LocalName).Should().Equal("textarea", "div", "button"); - row.QuerySelector(".chat-input-accessories .extra").Should().NotBeNull(); + var toolbar = component.Find(".chat-input-accessories"); + toolbar.GetAttribute("role").Should().Be("toolbar"); + toolbar.QuerySelector(".extra").Should().NotBeNull(); + toolbar.NextElementSibling!.ClassList.Should().Contain("chat-input-container"); + component.Find(".chat-input-container").Children.Select(child => child.LocalName).Should().Equal("textarea"); + toolbar.LastElementChild!.ClassList.Should().Contain("chat-send"); } /// Verifies that the text box takes focus when shown, unless that is turned off. diff --git a/PanoramicData.Blazor/Interfaces/ChatServiceDefaultState.cs b/PanoramicData.Blazor/Interfaces/ChatServiceDefaultState.cs index 70da9c6e5..e2544d62f 100644 --- a/PanoramicData.Blazor/Interfaces/ChatServiceDefaultState.cs +++ b/PanoramicData.Blazor/Interfaces/ChatServiceDefaultState.cs @@ -50,6 +50,10 @@ internal sealed class ChatServiceDefaultState public string? SelectedAgentId { get; set; } + public string? SelectedModelId { get; set; } + + public bool IsReadAloudEnabled { get; set; } + /// /// Gets or sets the handlers subscribed to the default . /// They are kept so that subscribing and unsubscribing behave as they would on any event, but a service that diff --git a/PanoramicData.Blazor/Interfaces/IChatService.cs b/PanoramicData.Blazor/Interfaces/IChatService.cs index cbaa41c20..80fe70c0e 100644 --- a/PanoramicData.Blazor/Interfaces/IChatService.cs +++ b/PanoramicData.Blazor/Interfaces/IChatService.cs @@ -121,6 +121,32 @@ TimeSpan VoiceAutoSendDelay set => ChatServiceDefaultState.For(this).SelectedAgentId = value; } + /// + /// Gets or sets whether the answer to each message the user sends is read aloud. False by default. Independent of + /// both the microphone and , which governs notification sounds. Needs . + /// + bool IsReadAloudEnabled + { + get => ChatServiceDefaultState.For(this).IsReadAloudEnabled; + set => ChatServiceDefaultState.For(this).IsReadAloudEnabled = value; + } + + /// + /// Gets the models the user may choose to answer, or null (the default) when the host offers no choice. + /// A picker is shown in the input toolbar only when there are two or more. + /// + IReadOnlyList? Models => null; + + /// + /// Gets or sets the of the model that answers, or null (the default) for the + /// host's default. The chat sets this when the user picks a model; a host that needs to react implements the setter. + /// + string? SelectedModelId + { + get => ChatServiceDefaultState.For(this).SelectedModelId; + set => ChatServiceDefaultState.For(this).SelectedModelId = value; + } + /// /// Gets or sets whether the chat should auto-restore when new messages arrive. /// diff --git a/PanoramicData.Blazor/Models/PDChatModelOption.cs b/PanoramicData.Blazor/Models/PDChatModelOption.cs new file mode 100644 index 000000000..63827b8e7 --- /dev/null +++ b/PanoramicData.Blazor/Models/PDChatModelOption.cs @@ -0,0 +1,10 @@ +namespace PanoramicData.Blazor.Models; + +/// +/// One of the models a host offers in 's input toolbar, for the user to choose +/// which model answers. +/// +/// The identifier the host recognises, stored in . +/// The name shown in the picker. +/// An optional description, shown as the option's tooltip. +public sealed record PDChatModelOption(string Id, string Name, string? Description = null); diff --git a/PanoramicData.Blazor/PDChat.Agents.cs b/PanoramicData.Blazor/PDChat.Agents.cs deleted file mode 100644 index bb40aeafb..000000000 --- a/PanoramicData.Blazor/PDChat.Agents.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace PanoramicData.Blazor; - -/// -/// PDChat: the agent picker in the input area, offered when the chat service lists two or more -/// . -/// -public partial class PDChat -{ - private bool IsAgentPickerOffered => ChatService.Agents is { Count: > 1 }; - - // Null or unknown means the host's default, which the picker shows as its first agent. - private PDChatAgentOption? SelectedAgent => ChatService.Agents?.FirstOrDefault(agent => agent.Id == ChatService.SelectedAgentId) - ?? (ChatService.Agents is { Count: > 0 } agents ? agents[0] : null); - - private RenderFragment? InputAccessories => IsVoiceModeOffered || IsAgentPickerOffered ? InputAccessoriesContent : null; - - private void OnAgentSelected(ChangeEventArgs args) - { - var id = args.Value?.ToString(); - if (ChatService.Agents?.Any(agent => agent.Id == id) == true) - { - ChatService.SelectedAgentId = id; - } - } -} diff --git a/PanoramicData.Blazor/PDChat.Toolbar.cs b/PanoramicData.Blazor/PDChat.Toolbar.cs new file mode 100644 index 000000000..9e9610869 --- /dev/null +++ b/PanoramicData.Blazor/PDChat.Toolbar.cs @@ -0,0 +1,39 @@ +namespace PanoramicData.Blazor; + +/// +/// PDChat: the thin toolbar above the text box, holding the agent and model pickers (each offered only when the chat +/// service lists two or more), notifications and Voice Mode. +/// +public partial class PDChat +{ + private bool IsAgentPickerOffered => ChatService.Agents is { Count: > 1 }; + + private bool IsModelPickerOffered => ChatService.Models is { Count: > 1 }; + + // Null or unknown means the host's default, which each picker shows as its first option. + private PDChatAgentOption? SelectedAgent => ChatService.Agents?.FirstOrDefault(agent => agent.Id == ChatService.SelectedAgentId) + ?? (ChatService.Agents is { Count: > 0 } agents ? agents[0] : null); + + private PDChatModelOption? SelectedModel => ChatService.Models?.FirstOrDefault(model => model.Id == ChatService.SelectedModelId) + ?? (ChatService.Models is { Count: > 0 } models ? models[0] : null); + + private string MuteButtonTitle => _isMuted ? "Notification sounds: off" : "Notification sounds: on"; + + private void OnAgentSelected(ChangeEventArgs args) + { + var id = args.Value?.ToString(); + if (ChatService.Agents?.Any(agent => agent.Id == id) == true) + { + ChatService.SelectedAgentId = id; + } + } + + private void OnModelSelected(ChangeEventArgs args) + { + var id = args.Value?.ToString(); + if (ChatService.Models?.Any(model => model.Id == id) == true) + { + ChatService.SelectedModelId = id; + } + } +} diff --git a/PanoramicData.Blazor/PDChat.Voice.cs b/PanoramicData.Blazor/PDChat.Voice.cs index 106411db7..3fbb10146 100644 --- a/PanoramicData.Blazor/PDChat.Voice.cs +++ b/PanoramicData.Blazor/PDChat.Voice.cs @@ -22,14 +22,14 @@ public enum PDChatVoiceState } /// -/// Voice Mode for : the user dictates into the text box, and hears the answer. Off by default, and -/// offered only when the chat service supplies . +/// Voice for : two independent choices, both off by default and offered only when the chat service +/// supplies . Dictation (the microphone) types into the text box; read-aloud +/// () speaks the answer to each message the user sends. /// /// /// Recognised words are appended to the text box, where they can be edited. When the speaker pauses and the box does /// not have focus, the text is sent after as if Send had been pressed; -/// while the user is editing, they press Send themselves. The first finished reply to a message sent while Voice Mode -/// is listening is read aloud. +/// while the user is editing, they press Send themselves. While an answer is read aloud the microphone sends nothing. /// public partial class PDChat { @@ -54,10 +54,16 @@ public partial class PDChat private bool IsVoiceModeOn => VoiceState != PDChatVoiceState.Off; + private bool IsReadAloudOn => IsVoiceModeOffered && ChatService.IsReadAloudEnabled; + private string VoiceButtonTitle => IsVoiceModeOn ? "Voice: stop listening" : "Voice: speak instead of typing"; + private string ReadAloudButtonTitle => IsReadAloudOn + ? "Read answers aloud: on" + : "Read answers aloud: off"; + private string VoiceStatusCssClass => $"pdchat-voice-status pdchat-voice-{VoiceState.ToString().ToLowerInvariant()}" + (VoiceError is null ? string.Empty : " pdchat-voice-error"); private string VoiceStatusText => VoiceError ?? VoiceState switch @@ -67,7 +73,7 @@ public partial class PDChat ? "Listening. You are editing, so press Send when ready." : "Listening. Ask your question, then pause.", PDChatVoiceState.Thinking => "Thinking…", - PDChatVoiceState.Speaking => "Speaking. Turn Voice off to stop.", + PDChatVoiceState.Speaking => "Reading the answer aloud.", _ => string.Empty, }; @@ -88,9 +94,8 @@ private async Task ToggleVoiceModeAsync() VoiceState = PDChatVoiceState.Starting; try { - _voiceModule ??= await JSRuntime.InvokeAsync("import", endpoints.ModulePath ?? _voiceModulePath); - _voiceReference ??= DotNetObjectReference.Create(this); - await _voiceModule.InvokeVoidAsync("start", endpoints.ListenUrl, _voiceReference); + var module = await GetVoiceModuleAsync(endpoints); + await module.InvokeVoidAsync("start", endpoints.ListenUrl, _voiceReference); VoiceState = PDChatVoiceState.Listening; } catch (JSException) @@ -100,6 +105,41 @@ private async Task ToggleVoiceModeAsync() } } + // Loaded on first use, so the page asks for nothing until the user turns a voice control on. + private async Task GetVoiceModuleAsync(PDChatVoiceEndpoints endpoints) + { + _voiceModule ??= await JSRuntime.InvokeAsync("import", endpoints.ModulePath ?? _voiceModulePath); + _voiceReference ??= DotNetObjectReference.Create(this); + return _voiceModule; + } + + private async Task ToggleReadAloudAsync() + { + ChatService.IsReadAloudEnabled = !ChatService.IsReadAloudEnabled; + if (ChatService.IsReadAloudEnabled) + { + return; + } + + // Turned off mid-answer: stop talking, and give the microphone back if it was waiting. + _isAwaitingSpokenAnswer = false; + if (_voiceModule is not null) + { + await _voiceModule.InvokeVoidAsync("stopSpeaking"); + } + + if (VoiceState is PDChatVoiceState.Thinking or PDChatVoiceState.Speaking) + { + await ResumeListeningAsync(); + } + } + + private async Task ResumeListeningAsync() + { + VoiceState = PDChatVoiceState.Listening; + await (_voiceModule?.InvokeVoidAsync("pause", false) ?? ValueTask.CompletedTask); + } + private async Task StopVoiceModeAsync() { CancelAutoSend(); @@ -232,52 +272,65 @@ await InvokeAsync(async () => } /// - /// Marks a message about to be sent while listening as a spoken exchange: the microphone pauses and the reply is read aloud. + /// Called as a message is about to be sent. The dictation turn is over; with read-aloud on, the answer is awaited, + /// and a listening microphone pauses until it has been spoken. /// private async Task BeginSpokenExchangeAsync() { - if (VoiceState != PDChatVoiceState.Listening) + CancelAutoSend(); + _hasDictatedSinceTurn = false; + _isAwaitingSpokenAnswer = IsReadAloudOn; + if (!_isAwaitingSpokenAnswer || VoiceState != PDChatVoiceState.Listening) { return; } - CancelAutoSend(); - _hasDictatedSinceTurn = false; VoiceState = PDChatVoiceState.Thinking; - _isAwaitingSpokenAnswer = true; // Half duplex: while Merlin thinks and speaks, the microphone sends nothing, so it never hears itself. await (_voiceModule?.InvokeVoidAsync("pause", true) ?? ValueTask.CompletedTask); } - /// Reads the first finished reply to a spoken question aloud, then listens again. + /// Reads the first finished reply to a sent message aloud, when read-aloud is on. private async Task SpeakAnswerIfAwaitedAsync(ChatMessage message) { if (!_isAwaitingSpokenAnswer || message.Sender.IsUser || message.Type == MessageType.Typing - || ChatService.VoiceEndpoints is not { } endpoints || _voiceModule is null) + || ChatService.VoiceEndpoints is not { } endpoints) { return; } _isAwaitingSpokenAnswer = false; - VoiceState = PDChatVoiceState.Speaking; - await InvokeAsync(StateHasChanged); + if (VoiceState == PDChatVoiceState.Thinking) + { + VoiceState = PDChatVoiceState.Speaking; + await InvokeAsync(StateHasChanged); + } - // The module calls OnVoiceSpoken when the last of the audio has played. - await _voiceModule.InvokeVoidAsync("speak", endpoints.SpeakUrl, ToSpeakableText(message), _voiceReference); + try + { + var module = await GetVoiceModuleAsync(endpoints); + + // The module calls OnVoiceSpoken when the last of the audio has played. + await module.InvokeVoidAsync("speak", endpoints.SpeakUrl, ToSpeakableText(message), _voiceReference); + } + catch (JSException) + { + VoiceError = "The answer could not be read aloud."; + await OnVoiceSpoken(); + } } - /// Called by the voice module when an answer has finished playing; listening resumes. + /// Called by the voice module when an answer has finished playing; a paused microphone listens again. [JSInvokable] public async Task OnVoiceSpoken() { - if (VoiceState != PDChatVoiceState.Speaking) + if (VoiceState is not (PDChatVoiceState.Speaking or PDChatVoiceState.Thinking)) { return; } - VoiceState = PDChatVoiceState.Listening; - await (_voiceModule?.InvokeVoidAsync("pause", false) ?? ValueTask.CompletedTask); + await ResumeListeningAsync(); await InvokeAsync(StateHasChanged); } diff --git a/PanoramicData.Blazor/PDChat.razor b/PanoramicData.Blazor/PDChat.razor index 55244cee2..d9fd3674c 100644 --- a/PanoramicData.Blazor/PDChat.razor +++ b/PanoramicData.Blazor/PDChat.razor @@ -84,9 +84,6 @@ πŸ—‘οΈ } - @* Show dock to side button for corner modes *@ @if (ChatService.DockMode is PDChatDockMode.BottomRight or PDChatDockMode.TopRight or PDChatDockMode.BottomLeft or PDChatDockMode.TopLeft) @@ -291,34 +288,56 @@ @code { - private RenderFragment InputAccessoriesContent => @ + private RenderFragment InputAccessories => @ @if (IsAgentPickerOffered) { -
+
+ + } + @if (IsModelPickerOffered) + { + } + + @if (IsVoiceModeOffered) { + + aria-label="@VoiceButtonTitle" + aria-pressed="@(IsVoiceModeOn ? "true" : "false")">πŸŽ™οΈ }
; } \ No newline at end of file diff --git a/PanoramicData.Blazor/PDChat.razor.css b/PanoramicData.Blazor/PDChat.razor.css index 79c330574..187b0f43b 100644 --- a/PanoramicData.Blazor/PDChat.razor.css +++ b/PanoramicData.Blazor/PDChat.razor.css @@ -543,26 +543,66 @@ body:has(.pdchat-container.dock-fullscreen) { outline-offset: 1px; } -/* Input-area controls: the Voice switch carries a word as well as an icon, so it says what it does. */ -.pdchat-voice-toggle { - white-space: nowrap; +/* Input toolbar: icon-only toggles (each titled) and compact pickers. */ +.pdchat-toolbar-btn { + padding: 1px 3px; + line-height: 1.4; + font-size: 0.85rem; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + opacity: 0.6; +} + +.pdchat-toolbar-btn:hover, +.pdchat-toolbar-btn:focus-visible { + opacity: 1; + border-color: var(--pd-chat-border, #dee2e6); +} + +.pdchat-mute-toggle[aria-pressed="true"], +.pdchat-toolbar-btn.active { + opacity: 1; } .pdchat-voice-toggle.active { - color: var(--pd-chat-bg, #fff); background: #dc3545; border-color: #dc3545; } -.pdchat-agent-picker { +.pdchat-toolbar-spacer { + flex: 1; +} + +.pdchat-toolbar-picker { display: flex; + flex-shrink: 0; align-items: center; gap: 4px; - min-width: 0; + margin: 0; } -.pdchat-agent-picker select { - min-width: 0; +.pdchat-toolbar-label { + color: var(--pd-chat-muted-color, #6c757d); +} + +/* A corner-docked chat is too narrow for the words; each select keeps its tooltip and accessible name. */ +@container (max-width: 420px) { + .pdchat-toolbar-label { + display: none; + } +} + +.pdchat-toolbar-picker select { + field-sizing: content; + max-width: 10rem; + padding: 0 4px; + font-size: inherit; + color: var(--pd-chat-color, #212529); + background: var(--pd-chat-bg, #fff); + border: 1px solid var(--pd-chat-border, #ccc); + border-radius: 4px; } .pdchat-agent-icon { diff --git a/PanoramicData.Blazor/PDMessages.razor b/PanoramicData.Blazor/PDMessages.razor index 030ca2291..72f99e8e9 100644 --- a/PanoramicData.Blazor/PDMessages.razor +++ b/PanoramicData.Blazor/PDMessages.razor @@ -17,6 +17,17 @@ } } + @if (InputAccessories is not null || IsInputPermitted) + { + @* Every control sits in this thin bar, so the text box below has the whole width. *@ + + } @if (IsInputPermitted) {
@@ -30,11 +41,6 @@ placeholder="Type a message..." rows="3" disabled="@(!IsLive)"> - @if (InputAccessories is not null) - { -
@InputAccessories
- } -
} else if (!string.IsNullOrWhiteSpace(InputDisabledMessage)) diff --git a/PanoramicData.Blazor/PDMessages.razor.cs b/PanoramicData.Blazor/PDMessages.razor.cs index 3450cb2bf..9880d6ee0 100644 --- a/PanoramicData.Blazor/PDMessages.razor.cs +++ b/PanoramicData.Blazor/PDMessages.razor.cs @@ -100,7 +100,8 @@ public partial class PDMessages : IChatInput internal ElementReference InputRef { get; set; } /// - /// Gets or sets optional controls shown in the input row beside the Send button, such as a voice or agent control. + /// Gets or sets optional controls shown in the thin toolbar above the text box, before Send. The toolbar is shown + /// even where input is not permitted, so a control such as notification sounds stays reachable. /// [Parameter] public RenderFragment? InputAccessories { get; set; } diff --git a/PanoramicData.Blazor/PDMessages.razor.css b/PanoramicData.Blazor/PDMessages.razor.css index eb5d65e56..74da111ea 100644 --- a/PanoramicData.Blazor/PDMessages.razor.css +++ b/PanoramicData.Blazor/PDMessages.razor.css @@ -113,15 +113,35 @@ flex-shrink: 0; } -/* Host-supplied controls (voice, agent) stacked beside Send. */ +/* Host-supplied controls in a thin toolbar above the input, which keeps the full width. */ .chat-input-accessories { + container-type: inline-size; display: flex; - flex-direction: column; - align-items: stretch; + flex-wrap: wrap; + align-items: center; gap: 4px; flex-shrink: 0; min-width: 0; - max-width: 40%; + margin-top: auto; + padding: 2px 6px; + font-size: 0.8rem; + background: var(--pd-chat-secondary-bg, #f5f7fa); + border-top: 1px solid var(--pd-chat-border, #dee2e6); +} + +/* Send ends the bar, after any host controls. */ +.chat-input-accessories .chat-send { + margin-left: auto; + padding: 0 8px; + flex-shrink: 0; +} + +.chat-input-accessories + .chat-input-container, +.chat-input-accessories + .chat-input-disabled, +.pdchat-window.fullscreen .chat-input-accessories + .chat-input-container { + margin-top: 0; + padding-top: 0; + border-top: none; } /* Message shown in place of the input when input is not permitted (IsInputPermitted = false). diff --git a/PanoramicData.Blazor/Services/DumbChatService.Voice.cs b/PanoramicData.Blazor/Services/DumbChatService.Voice.cs index 50116c496..90d34e787 100644 --- a/PanoramicData.Blazor/Services/DumbChatService.Voice.cs +++ b/PanoramicData.Blazor/Services/DumbChatService.Voice.cs @@ -1,17 +1,25 @@ namespace PanoramicData.Blazor.Services; /// -/// The Voice Mode and agent picker of , so a demo shows both without a speech service. +/// The voice controls and the agent and model pickers of , so a demo shows the whole input +/// toolbar without a speech service. /// public partial class DumbChatService { - /// Gets or sets Voice Mode's endpoints; simulated by default, and null hides Voice Mode. + /// Gets or sets the voice endpoints; simulated by default, and null hides the voice controls. public PDChatVoiceEndpoints? VoiceEndpoints { get; set; } = PDChatVoiceEndpoints.Simulated; - /// Gets the agents offered in the input area. + /// Gets the agents offered in the input toolbar. public IReadOnlyList? Agents { get; } = [ new("dumbbot", "DumbBot", "Answers by keyword, and not very well."), - new("parrot", "Parrot", "Exactly as helpful as DumbBot, with more feathers."), + new("pedant", "Pedant", "Exactly as helpful as DumbBot, but would like to correct your grammar first."), + ]; + + /// Gets the models offered in the input toolbar. The demo answers the same whichever is chosen. + public IReadOnlyList? Models { get; } = + [ + new("dumb-mini", "Dumb Mini", "Small and quick."), + new("dumb-max", "Dumb Max", "Large and thorough. Equally dumb."), ]; }