From 8965969a98aebd2727c078a0cacd060d7102a760 Mon Sep 17 00:00:00 2001 From: Roland Banks Date: Wed, 11 Nov 2026 16:26:52 +0700 Subject: [PATCH] PDChat: the voice picker shows the running mode, a Mic off badge, and an optional Alt shortcut that cycles the modes MS-28499: the picker set each option's selected attribute, which the browser stops following once the user has chosen, so after Mic off the ear button resumed listening while the picker still read Mic off. It now sets the select's value from the voice state. MS-28498: while the microphone is off the voice button keeps its mode icon and carries a badge, PDChatIcons.MicOff (fas fa-microphone-slash by default; empty shows none). MS-28518: new VoiceModeShortcutKey parameter (a KeyboardEvent.code, null by default). Alt with that key, while focus is in the chat, cycles Mic off, HAL, Dictate and the wake phrase mode; the picker's tooltip names it. The demo sets KeyM. Co-Authored-By: Claude Opus 5.5 --- .../Pages/PDChatDocumentation.razor | 7 + .../Shared/MainLayout.razor | 1 + .../PDChatTests.VoicePickerAndShortcut.cs | 210 ++++++++++++++++++ PanoramicData.Blazor/Models/PDChatIcons.cs | 3 + PanoramicData.Blazor/PDChat.Voice.cs | 39 ++++ PanoramicData.Blazor/PDChat.razor | 17 +- PanoramicData.Blazor/PDChat.razor.cs | 8 + PanoramicData.Blazor/PDChat.razor.css | 3 +- tests/pdchat-voice-controls.spec.ts | 83 +++++++ 9 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 PanoramicData.Blazor.Test/Components/PDChatTests.VoicePickerAndShortcut.cs create mode 100644 tests/pdchat-voice-controls.spec.ts diff --git a/PanoramicData.Blazor.Demo/Pages/PDChatDocumentation.razor b/PanoramicData.Blazor.Demo/Pages/PDChatDocumentation.razor index 4acc17da2..994af6246 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDChatDocumentation.razor +++ b/PanoramicData.Blazor.Demo/Pages/PDChatDocumentation.razor @@ -11,6 +11,7 @@ MessagesList<ChatMessage>Messages to display OnSendEventCallback<string>Fired when user sends message IconsPDChatIconsThe CSS classes of the header and toolbar icons. Font Awesome by default. See Icons and theming. + VoiceModeShortcutKeystring?A key code such as "KeyM": Alt with that key, while focus is in the chat, cycles Voice Mode through Mic off and its modes. null (the default) offers no shortcut. See Input toolbar, voice and wake phrase. @@ -159,6 +160,12 @@ The microphone is one split control: the button starts and stops listening, and the select beside it picks the mode. The button shows the mode as its icon (the HAL eye, a microphone, or an ear) and the state through its colour and motion: listening, editing, thinking, speaking, dormant or paused. Its title says both in words. + While the microphone is off, a small badge (Icons.MicOff, a microphone with a slash) marks the + button; set it to an empty string to show none. The select shows Mic off while the microphone is off and the + running mode otherwise. + Set the VoiceModeShortcutKey parameter to a key code such as "KeyM" to let Alt with + that key, while focus is in the chat, cycle the select through Mic off and the modes; the select's tooltip + then names it. Without it there is no shortcut. In Hal and Wake, clicking in the text box switches to manual: dictation keeps streaming into the box, Send submits, and the next pause sends again. A message's SpokenMessage, when set, is read aloud in place of the message, which is displayed unchanged; an diff --git a/PanoramicData.Blazor.Demo/Shared/MainLayout.razor b/PanoramicData.Blazor.Demo/Shared/MainLayout.razor index 0dcc80e14..e10693bb2 100644 --- a/PanoramicData.Blazor.Demo/Shared/MainLayout.razor +++ b/PanoramicData.Blazor.Demo/Shared/MainLayout.razor @@ -41,6 +41,7 @@ UserIconSelector="GetUserIcon" PriorityIconSelector="GetPriorityIcon" SoundSelector="GetSoundUrl" + VoiceModeShortcutKey="KeyM" User="User" /> diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.VoicePickerAndShortcut.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.VoicePickerAndShortcut.cs new file mode 100644 index 000000000..05545d58b --- /dev/null +++ b/PanoramicData.Blazor.Test/Components/PDChatTests.VoicePickerAndShortcut.cs @@ -0,0 +1,210 @@ +using AwesomeAssertions; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using PanoramicData.Blazor.Interfaces; +using PanoramicData.Blazor.Models; + +namespace PanoramicData.Blazor.Test; + +/// +/// The voice controls' state: the picker shows what the microphone is doing, the voice button marks Mic off, and a host's +/// shortcut (here Alt+M) cycles the choices. +/// +public partial class PDChatTests +{ + private static readonly KeyboardEventArgs _altM = new() { Code = "KeyM", Key = "m", AltKey = true }; + + /// Verifies that the picker reads Mic off after a load, whatever mode was last chosen, because nothing listens yet. + [Theory] + [InlineData(PDChatVoiceMode.Hal)] + [InlineData(PDChatVoiceMode.Dictate)] + [InlineData(PDChatVoiceMode.Wake)] + public void The_picker_reads_Mic_off_after_a_load(PDChatVoiceMode lastMode) + { + var service = WakeService(); + ((IChatService)service).VoiceMode = lastMode; + + PickerValue(RenderChat(service)).Should().Be("MicOff"); + } + + /// Verifies that once the ear button resumes listening after Mic off, the picker reads the mode, not Mic off. + [Theory] + [InlineData(PDChatVoiceMode.Hal)] + [InlineData(PDChatVoiceMode.Dictate)] + [InlineData(PDChatVoiceMode.Wake)] + public async Task The_picker_reads_the_mode_when_the_ear_button_resumes_listening(PDChatVoiceMode mode) + { + SetUpVoiceModule(); + var component = RenderChat(WakeService()); + await component.Find(".pdchat-voice-mode-picker select").ChangeAsync(new ChangeEventArgs { Value = mode.ToString() }); + await component.Find(".pdchat-voice-mode-picker select").ChangeAsync(new ChangeEventArgs { Value = "MicOff" }); + PickerValue(component).Should().Be("MicOff"); + + await component.Find(".pdchat-voice-toggle").ClickAsync(new()); + + PickerValue(component).Should().Be(mode.ToString()); + } + + /// Verifies that the voice button shows a Mic off badge while the microphone is off, in every mode. + [Theory] + [InlineData(PDChatVoiceMode.Hal)] + [InlineData(PDChatVoiceMode.Dictate)] + [InlineData(PDChatVoiceMode.Wake)] + public void The_voice_button_shows_a_Mic_off_badge_while_the_microphone_is_off(PDChatVoiceMode mode) + { + var service = WakeService(); + ((IChatService)service).VoiceMode = mode; + + var toggle = RenderChat(service).Find(".pdchat-voice-toggle"); + + IconClass(toggle.QuerySelector(".pdchat-voice-off-badge")!).Should().Be("fas fa-microphone-slash"); + } + + /// Verifies that the voice button carries no Mic off badge, and keeps its mode's glyph, while listening. + [Theory] + [InlineData(PDChatVoiceMode.Hal, ".pdchat-hal-eye")] + [InlineData(PDChatVoiceMode.Dictate, "i.fa-microphone")] + [InlineData(PDChatVoiceMode.Wake, "i.fa-assistive-listening-systems")] + public async Task The_voice_button_has_no_Mic_off_badge_while_listening(PDChatVoiceMode mode, string glyph) + { + SetUpVoiceModule(); + var component = await RenderInModeAsync(WakeService(), mode); + + var toggle = component.Find(".pdchat-voice-toggle"); + + toggle.QuerySelector(".pdchat-voice-off-badge").Should().BeNull(); + toggle.QuerySelector(glyph).Should().NotBeNull(); + } + + /// Verifies that a host's Mic off icon replaces the default badge icon. + [Fact] + public void A_host_s_Mic_off_icon_replaces_the_default() + { + var component = RenderChat(VoiceService(), p => p.Add(x => x.Icons, new PDChatIcons { MicOff = "bi bi-mic-mute" })); + + IconClass(component.Find(".pdchat-voice-off-badge")).Should().Be("bi bi-mic-mute"); + } + + /// Verifies that a host hides the Mic off badge by giving it no icon. + [Fact] + public void An_empty_Mic_off_icon_shows_no_badge() + => RenderChat(VoiceService(), p => p.Add(x => x.Icons, new PDChatIcons { MicOff = string.Empty })) + .FindAll(".pdchat-voice-off-badge").Should().BeEmpty(); + + /// Verifies that Alt+M in the text box cycles Mic off, HAL, Dictate and back to Mic off. + [Fact] + public async Task Alt_M_cycles_Mic_off_HAL_and_Dictate() + { + var module = SetUpVoiceModule(); + var component = RenderWithShortcut(VoiceService()); + + var seen = new List(); + for (var press = 0; press < 3; press++) + { + await component.Find("textarea").KeyDownAsync(_altM); + seen.Add(PickerValue(component)); + } + + seen.Should().Equal("Hal", "Dictate", "MicOff"); + component.Instance.VoiceState.Should().Be(PDChatVoiceState.Off); + module.Invocations["stop"].Should().ContainSingle(); + } + + /// Verifies that Alt+M includes the wake phrase mode when the host offers it. + [Fact] + public async Task Alt_M_includes_the_wake_phrase_mode_when_offered() + { + SetUpVoiceModule(); + var component = RenderWithShortcut(WakeService()); + + var seen = new List(); + for (var press = 0; press < 4; press++) + { + await component.Find("textarea").KeyDownAsync(_altM); + seen.Add(PickerValue(component)); + } + + seen.Should().Equal("Hal", "Dictate", "Wake", "MicOff"); + } + + /// Verifies that only Alt with the host's key cycles, so AltGr (Ctrl and Alt) and other keys type as usual. + [Theory] + [InlineData("KeyM", false, false, false, false)] + [InlineData("KeyM", true, true, false, false)] + [InlineData("KeyM", true, false, true, false)] + [InlineData("KeyM", true, false, false, true)] + [InlineData("KeyN", true, false, false, false)] + public async Task Other_keys_do_not_cycle(string code, bool alt, bool ctrl, bool shift, bool meta) + { + var module = SetUpVoiceModule(); + var component = RenderWithShortcut(VoiceService()); + + await component.Find("textarea").KeyDownAsync(new KeyboardEventArgs { Code = code, AltKey = alt, CtrlKey = ctrl, ShiftKey = shift, MetaKey = meta }); + + component.Instance.VoiceState.Should().Be(PDChatVoiceState.Off); + module.Invocations.Should().BeEmpty(); + } + + /// Verifies that the shortcut is off unless the host names its key, so a host's own Alt shortcuts are left alone. + [Fact] + public async Task Alt_M_does_nothing_unless_the_host_names_the_key() + { + var module = SetUpVoiceModule(); + var component = RenderChat(VoiceService()); + + await component.Find("textarea").KeyDownAsync(_altM); + + component.Instance.VoiceState.Should().Be(PDChatVoiceState.Off); + module.Invocations.Should().BeEmpty(); + } + + /// Verifies that cycling leaves read aloud as the user set it. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Alt_M_leaves_read_aloud_as_set(bool readAloud) + { + SetUpVoiceModule(); + var service = VoiceService(readAloud: readAloud); + var component = RenderWithShortcut(service); + + for (var press = 0; press < 3; press++) + { + await component.Find("textarea").KeyDownAsync(_altM); + } + + ((IChatService)service).IsReadAloudEnabled.Should().Be(readAloud); + } + + /// Verifies that Alt+M does nothing while the chat is minimised, where the voice controls are not shown. + [Fact] + public async Task Alt_M_does_nothing_while_the_chat_is_minimised() + { + var module = SetUpVoiceModule(); + var service = VoiceService(); + service.DockMode = PDChatDockMode.Minimized; + var component = RenderWithShortcut(service); + + await component.Find(".pdchat-toggle-collapsed").KeyDownAsync(_altM); + + component.Instance.VoiceState.Should().Be(PDChatVoiceState.Off); + module.Invocations.Should().BeEmpty(); + } + + /// Verifies that the picker's tooltip names the shortcut when the host has one, and is unchanged when not. + [Fact] + public void The_picker_s_tooltip_names_the_shortcut_when_there_is_one() + { + RenderWithShortcut(VoiceService()).Find(".pdchat-voice-mode-picker select").GetAttribute("title") + .Should().Be("How Voice Mode listens. Alt+M cycles the choices."); + RenderChat(VoiceService()).Find(".pdchat-voice-mode-picker select").GetAttribute("title") + .Should().Be("How Voice Mode listens"); + } + + private IRenderedComponent RenderWithShortcut(FakeChatService service) + => RenderChat(service, p => p.Add(x => x.VoiceModeShortcutKey, "KeyM")); + + private static string? PickerValue(IRenderedComponent component) + => component.Find(".pdchat-voice-mode-picker select").GetAttribute("value"); +} diff --git a/PanoramicData.Blazor/Models/PDChatIcons.cs b/PanoramicData.Blazor/Models/PDChatIcons.cs index 7684f980a..acff94a48 100644 --- a/PanoramicData.Blazor/Models/PDChatIcons.cs +++ b/PanoramicData.Blazor/Models/PDChatIcons.cs @@ -40,6 +40,9 @@ public sealed record PDChatIcons /// Gets the badge on the voice button while dictation is paused. public string VoicePaused { get; init; } = "fas fa-pause"; + /// Gets the badge on the voice button while the microphone is off. Empty shows no badge. + public string MicOff { get; init; } = "fas fa-microphone-slash"; + /// Gets the attach images button's icon. public string Attach { get; init; } = "fas fa-paperclip"; diff --git a/PanoramicData.Blazor/PDChat.Voice.cs b/PanoramicData.Blazor/PDChat.Voice.cs index 1c2814e90..faa191311 100644 --- a/PanoramicData.Blazor/PDChat.Voice.cs +++ b/PanoramicData.Blazor/PDChat.Voice.cs @@ -185,6 +185,45 @@ private async Task StartVoiceModeAsync() private const string MicOffValue = "MicOff"; + // Set as the select's value: per-option selected attributes stop moving the selection once the user has chosen. + private string VoicePickerValue => IsVoiceModeOn ? VoiceMode.ToString() : MicOffValue; + + private string VoicePickerTitle => VoiceModeShortcutKey is { Length: > 0 } key + ? $"How Voice Mode listens. Alt+{ShortcutKeyName(key)} cycles the choices." + : "How Voice Mode listens"; + + // KeyboardEvent.code names the physical key, so "KeyM" is shown as M and "Digit5" as 5. + private static string ShortcutKeyName(string code) + => code.StartsWith("Key", StringComparison.Ordinal) ? code[3..] + : code.StartsWith("Digit", StringComparison.Ordinal) ? code[5..] + : code; + + private async Task OnChatKeyDownAsync(KeyboardEventArgs args) + { + if (!IsVoiceModeShortcut(args) || !IsVoiceModeOffered || ChatService.DockMode == PDChatDockMode.Minimized) + { + return; + } + + await OnVoiceModeSelectedAsync(new ChangeEventArgs { Value = NextVoiceChoice() }); + } + + // Alt alone: Ctrl with Alt is AltGr on many layouts, which types characters. + private bool IsVoiceModeShortcut(KeyboardEventArgs args) + => VoiceModeShortcutKey is { Length: > 0 } key && args.Code == key + && args.AltKey && !args.CtrlKey && !args.ShiftKey && !args.MetaKey; + + private string NextVoiceChoice() + { + List choices = [MicOffValue, nameof(PDChatVoiceMode.Hal), nameof(PDChatVoiceMode.Dictate)]; + if (IsWakeModeOffered) + { + choices.Add(nameof(PDChatVoiceMode.Wake)); + } + + return choices[(choices.IndexOf(VoicePickerValue) + 1) % choices.Count]; + } + private async Task OnVoiceModeSelectedAsync(ChangeEventArgs args) { if (args.Value?.ToString() == MicOffValue) diff --git a/PanoramicData.Blazor/PDChat.razor b/PanoramicData.Blazor/PDChat.razor index 49b2ccaaa..a035e3163 100644 --- a/PanoramicData.Blazor/PDChat.razor +++ b/PanoramicData.Blazor/PDChat.razor @@ -3,7 +3,8 @@ @* Issue #106: one cascade for the whole component, rather than threading a callback through PDMessages three times - once per dock mode - and PDMessage besides. *@ -
+
@switch (ChatService.DockMode) { case PDChatDockMode.Minimized: @@ -390,13 +391,13 @@ aria-label="@VoiceButtonTitle" aria-pressed="@(IsVoiceModeOn ? "true" : "false")">@VoiceButtonGlyph @@ -425,5 +426,9 @@ } } + @if (VoiceState == PDChatVoiceState.Off && !string.IsNullOrEmpty(Icons.MicOff)) + { + + } ; } \ No newline at end of file diff --git a/PanoramicData.Blazor/PDChat.razor.cs b/PanoramicData.Blazor/PDChat.razor.cs index 95ab2f882..92f749c0e 100644 --- a/PanoramicData.Blazor/PDChat.razor.cs +++ b/PanoramicData.Blazor/PDChat.razor.cs @@ -86,6 +86,14 @@ public partial class PDChat : JSModuleComponentBase [Parameter] public PDChatIcons Icons { get; set; } = new(); + /// + /// Gets or sets the key that, pressed with Alt while focus is in the chat, cycles Voice Mode through Mic off and its + /// modes. A KeyboardEvent.code such as "KeyM", so it is the same physical key on every layout. Null, the default, + /// offers no shortcut. + /// + [Parameter] + public string? VoiceModeShortcutKey { get; set; } + /// /// A function to select a user icon for a given message. /// diff --git a/PanoramicData.Blazor/PDChat.razor.css b/PanoramicData.Blazor/PDChat.razor.css index 3ed26c2a1..3639d5866 100644 --- a/PanoramicData.Blazor/PDChat.razor.css +++ b/PanoramicData.Blazor/PDChat.razor.css @@ -770,7 +770,8 @@ body:has(.pdchat-container.dock-fullscreen) { } } -.pdchat-voice-paused-badge { +.pdchat-voice-paused-badge, +.pdchat-voice-off-badge { position: absolute; right: 3px; bottom: 3px; diff --git a/tests/pdchat-voice-controls.spec.ts b/tests/pdchat-voice-controls.spec.ts new file mode 100644 index 000000000..389ad4a6a --- /dev/null +++ b/tests/pdchat-voice-controls.spec.ts @@ -0,0 +1,83 @@ +import { test, expect as baseExpect, type Page } from "@playwright/test"; + +// A cold server is slow to start Blazor. +const expect = baseExpect.configure({ timeout: 15000 }); + +// The demo chat service is one object shared by every page load, so these tests take turns. +test.describe.configure({ mode: "serial" }); + +// A fake microphone, allowed without a prompt, so the simulated Voice Mode can start. +test.use({ + launchOptions: { + args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"], + }, +}); + +async function openChat(page: Page) { + await page.goto("/pdchat"); + const toggle = page.locator(".pdchat-toggle-collapsed").first(); + const textarea = page.locator(".chat-input-container textarea"); + // The button is prerendered before Blazor is interactive, so a first click can be lost. + await expect(async () => { + if (!(await textarea.isVisible())) { + await toggle.click({ timeout: 2000 }); + } + + await expect(textarea).toBeVisible({ timeout: 5000 }); + }).toPass({ timeout: 30000 }); +} + +const picker = (page: Page) => page.locator(".pdchat-voice-mode-picker select"); +const earButton = (page: Page) => page.locator(".pdchat-voice-toggle"); + +test.describe("PDChat: voice controls", () => { + test.afterEach(async ({ page }) => { + // Leave the shared demo with the microphone off. + if ((await picker(page).count()) > 0 && (await picker(page).inputValue()) !== "MicOff") { + await picker(page).selectOption("MicOff"); + } + }); + + test("the picker shows the mode when the ear button resumes listening after Mic off, and Mic off after a reload", async ({ + page, + }) => { + await openChat(page); + await picker(page).selectOption("Hal"); + await expect(earButton(page)).toHaveAttribute("aria-pressed", "true"); + await picker(page).selectOption("MicOff"); + await expect(earButton(page)).toHaveAttribute("aria-pressed", "false"); + + await earButton(page).click(); + + await expect(earButton(page)).toHaveAttribute("aria-pressed", "true"); + await expect(picker(page)).toHaveValue("Hal"); + + await openChat(page); + await expect(picker(page)).toHaveValue("MicOff"); + }); + + test("Alt+M in the chat cycles the voice choices, and does nothing outside the chat", async ({ page }) => { + await openChat(page); + await expect(picker(page)).toHaveValue("MicOff"); + + await page.locator(".chat-input-container textarea").focus(); + await page.keyboard.press("Alt+KeyM"); + await expect(picker(page)).toHaveValue("Hal"); + await page.keyboard.press("Alt+KeyM"); + await expect(picker(page)).toHaveValue("Dictate"); + + await page.locator('input[placeholder="Enter chat title"]').focus(); + await page.keyboard.press("Alt+KeyM"); + await expect(picker(page)).toHaveValue("Dictate"); + }); + + test("the ear button carries a Mic off badge only while the microphone is off", async ({ page }) => { + await openChat(page); + await expect(earButton(page).locator(".pdchat-voice-off-badge")).toBeVisible(); + + await picker(page).selectOption("Hal"); + + await expect(earButton(page)).toHaveAttribute("aria-pressed", "true"); + await expect(earButton(page).locator(".pdchat-voice-off-badge")).toHaveCount(0); + }); +});