Skip to content

ADR-142: Introduce modal message service with FIFO queuing#99

Merged
jodavis merged 4 commits intodev/jodavis/ADR-128-spec-programmable-ir-commandsfrom
copilot/implement-adr-142-task
Feb 24, 2026
Merged

ADR-142: Introduce modal message service with FIFO queuing#99
jodavis merged 4 commits intodev/jodavis/ADR-128-spec-programmable-ir-commandsfrom
copilot/implement-adr-142-task

Conversation

Copy link
Contributor

Copilot AI commented Feb 24, 2026

Implements a dedicated modal message service to decouple message display from ConversationController, enabling both conversation and future programming messages to share a single, properly queued overlay UI.

New: IModalMessageService + ModalMessageService

  • Channel-based FIFO queue — single background reader processes one message at a time
  • keepAlive flag keeps the message visible after the body completes (used for confirmation prompts that need to remain while awaiting spoken response)
  • cancellationToken is stored per-request and forwarded to the body; OperationCanceledException produces a Canceled (not Faulted) task
await _modalMessageService.ShowMessageAsync(
    phrase,
    ct => _speechSynthesis.SayAsync(phrase, ct),
    keepAlive: isAsking && isLastPhrase);

New: ModalMessageView (MvvmObject)

Holds CurrentMessage : string?, consistent with the existing ConversationView MVVM pattern. Registered as a Singleton alongside IModalMessageService, with ModalMessageView injected into ModalMessageService via constructor.

New: ModalMessageUI.razor

Extracted into its own component (rendered from Remote.razor) since the modal is no longer specific to conversation. ConversationUI.razor is simplified to only handle conversation status and the listening border.

Removed: ConversationView.SpeakingMessage

The conversation-speaking-message CSS class is preserved; it is now rendered by ModalMessageUI.razor bound to ModalMessageView.CurrentMessage.

Tests

  • 12 synchronous unit tests in ModalMessageServiceTests (all [TestMethod] void) covering:
    • FIFO ordering and task-not-complete-while-body-runs
    • Keep-alive/message replacement
    • Markdown verbatim preservation
    • Fault recovery
    • PropertyChanged notification
    • Cancellation: token passed to body, message cleared, task in Canceled state, queue unblocked after cancellation
  • ConversationControllerTests updated: Expect_Synthesis_Say also wires the modal message mock so synthesis expectations implicitly verify the service is called with the correct phrase

🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

Copilot AI and others added 2 commits February 24, 2026 01:07
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
Copilot AI changed the title [WIP] Implement task ADR-142 from ProgrammableCommands ADR-142: Introduce modal message service with FIFO queuing Feb 24, 2026
Copilot AI requested a review from jodavis February 24, 2026 01:22
Copy link
Owner

@jodavis jodavis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Please review these comments and make the necessary changes.

}

@if (SpeakingMessage is not null)
@if (CurrentMessage is not null)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be moved to a different view component, since it's no longer specifically part of the Conversation UI.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in edebe56. Created ModalMessageUI.razor with the modal rendering, added it to Remote.razor, and removed it from ConversationUI.razor.

Comment on lines +24 to +26
.AddScoped<ModalMessageService>()
.AddScoped<IModalMessageService>(sp => sp.GetRequiredService<ModalMessageService>())
.AddScoped<Models.ModalMessageView>(sp => sp.GetRequiredService<ModalMessageService>().View);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.AddScoped<ModalMessageService>()
.AddScoped<IModalMessageService>(sp => sp.GetRequiredService<ModalMessageService>())
.AddScoped<Models.ModalMessageView>(sp => sp.GetRequiredService<ModalMessageService>().View);
.AddSingleton<IModalMessageService, ModalMessageService>()
.AddSingleton<Models.ModalMessageView>();

If ModalMessageService accepts the view as a constructor parameter, we can simplify the configuration of these services.

Also, these services should be Singletons, since (a) they don't have any configuration that can change, and (b) they are tied to the UI, so the UI could be holding onto the view.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in edebe56. ModalMessageService now accepts ModalMessageView via its constructor, and both are registered as Singletons:

.AddSingleton<Models.ModalMessageView>()
.AddSingleton<IModalMessageService, ModalMessageService>()

}

[TestMethod]
public async Task ShowMessageAsync_KeepAlive_ViewModelRaisesPropertyChangedAsync()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't testing keepAlive, but the method name suggests it is.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in edebe56 — renamed to ShowMessageAsync_ViewModelRaisesPropertyChanged.

Comment on lines +23 to +40
[TestMethod]
public async Task ShowMessageAsync_SingleMessage_SetsAndClearsCurrentMessageAsync()
{
// Arrange
using ModalMessageService sut = CreateSut();
string? messageWhileRunning = null;

// Act
await sut.ShowMessageAsync("Hello", async _ =>
{
messageWhileRunning = sut.View.CurrentMessage;
await Task.CompletedTask;
});

// Assert
messageWhileRunning.Should().Be("Hello", because: "message should be visible while body runs");
sut.View.CurrentMessage.Should().BeNull(because: "message should be cleared after body completes");
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a case that verifies the message stays set and the ShowMessageAsync task doesn't complete, until the body method completes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in edebe56 as ShowMessageAsync_WhileBodyRunning_TaskIsNotComplete. It uses a TaskCompletionSource to block the body, then asserts showTask.Should().NotBeComplete() and View.CurrentMessage.Should().Be("Hello") before unblocking.

secondMessageSeen.Should().Be("Second", because: "the queue should continue processing after a faulted body");
sut.View.CurrentMessage.Should().BeNull();
}

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a cases for cancellation -- verify the token is passed to the body message, the message value is cleared, the ShowMessageAsync method returns cancelled, and the next method call is unblocked. (These don't all have to be separate tests, but all the behaviors should be covered.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in edebe56 as three tests:

  • ShowMessageAsync_Cancellation_TokenIsPassedToBody — captures the token in the body and verifies IsCancellationRequested changes when cts.Cancel() is called
  • ShowMessageAsync_Cancellation_ClearsMessageAndReturnsCancelled — verifies View.CurrentMessage is null and the task is BeCanceled
  • ShowMessageAsync_Cancellation_SubsequentMessageStillShows — verifies the queue continues processing after a cancelled entry

Also updated ModalMessageService.ProcessQueueAsync to call TrySetCanceled (not TrySetException) when the body throws OperationCanceledException due to the request's cancellation token.

…tests, cancellation tests

Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Feb 24, 2026

@copilot Please review these comments and make the necessary changes.

All six comments addressed in edebe56. Summary of changes:

  • ModalMessageUI.razor (new) — extracted modal message rendering
  • Remote.razor — hosts <ModalMessageUI />
  • ConversationUI.razor — modal rendering removed
  • ModalMessageService — constructor-injected ModalMessageView, OperationCanceledExceptionTrySetCanceled
  • ConversationHostBuilderExtensions — Singleton registration via DI constructor injection
  • ModalMessageServiceTests — fully synchronous, all new test cases added

Copilot AI requested a review from jodavis February 24, 2026 18:52
@jodavis jodavis marked this pull request as ready for review February 24, 2026 22:30
@jodavis jodavis merged commit b6c437e into dev/jodavis/ADR-128-spec-programmable-ir-commands Feb 24, 2026
jodavis added a commit that referenced this pull request Feb 27, 2026
* Implement ADR-142: Modal message service with FIFO queuing
* Fix: propagate cancellationToken to body in ModalMessageService
* Address PR review: extract ModalMessageUI, Singleton DI, synchronous tests, cancellation tests
---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
jodavis added a commit that referenced this pull request Mar 11, 2026
* Implement ADR-142: Modal message service with FIFO queuing
* Fix: propagate cancellationToken to body in ModalMessageService
* Address PR review: extract ModalMessageUI, Singleton DI, synchronous tests, cancellation tests
---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants