diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1de10e72c..810840343 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -110,7 +110,9 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | `src/OpenClaw.Tray.WinUI/Services/TrayMenuRenderer.cs` | semantic composition → `TrayMenuPresenter`; connection toggle projection → `ConnectionTogglePresenter`; keep WinUI control construction and callback application in the renderer | | `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs` | Keep as the `IChatDataProvider` facade; atomic runtime coordination → `ChatConversationState`, lock-internal state mechanics → its queue/reset/history/presentation/lifecycle/approval substates, queue decisions → `ChatSendQueuePolicy`, history IO → `ChatHistoryLoader`, mapping → `ChatEventMapper`, native tool projection → `NativeToolProjector`, snapshots → `ChatSnapshotProjector`, metadata → `ChatMetadataStore`, persistence → `ChatStatePersistence` | | `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs` | `ReactorChatTimeline` (production `ItemsView` / `ItemContainer`), `ChatBubbleRenderer`, `ToolCallCardRenderer`, `PermissionRequestCard`, `AttachmentBubbleRenderer` | -| `src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs` | `ComposerViewModel`, `SlashCommandPalette`, `AttachmentPreviewStrip`, `VoiceComposerController` | +| `src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs` | `ComposerViewModel`, `SlashCommandPalette`, `AttachmentPreviewStrip`, `VoiceComposerController` (legacy FunctionalUI surface; the production path is `ReactorChatComposer.cs` below) | +| `src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs` | Keep as the provider-subscription/selection/timeline-composition root only; composer state → `ChatComposerViewModel`, composer workflow → `ChatComposerController`, composer view → `ReactorChatComposer.cs` | +| `src/OpenClaw.Tray.WinUI/Chat/ReactorChatComposer.cs` | Declarative view only; workflow/state changes go in `ChatComposerViewModel`/`ChatComposerController`, not new Reactor `UseState`/refs here | | `src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs` | `ConnectionPagePlan` (pure), `ConnectionPageViewModel`, `GatewayDirectConnectService`, gateway row models | | `src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs` | settings read/persist → `SettingsPageViewModel` + `ISettingsStore`; keep gateway-uninstall, uptime timer, saved-indicator, and app-info in the view | | `src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs` | state/commands → `PermissionsPageViewModel`; runtime projection → `PermissionsPageRuntimeSource`; persistence → `ISettingsStore` and `IExecApprovalsPresentationStore`; keep exact WinUI rendering, clipboard/privacy actions, and save-hint timer in the view | @@ -227,6 +229,10 @@ leading and trailing pipe. Columns, in order: | gateway-manager-bootstrap-owner-closed | closed | src/OpenClaw.Connection/GatewayConnectionManager.cs | bootstrap timing flags, durable-token clear helper, post-bootstrap scheduling, and operator mismatch recovery | BootstrapTokenLifecycle | public setup/shared-token façade and save-failure rollback; one-shot shared-token validation; operator event forwarding; typed lifecycle/reconnect/v2 ports | stale token events cannot restore timing flags, clear a newer record, or schedule an untyped reconnect callback | ConnectionDomainOwnerClosureTests.GatewayConnectionManager_DoesNotReintroduceBootstrapTimingOwnership | source-shape | when GatewayConnectionManager no longer composes BootstrapTokenLifecycle directly | | device-pair-approval-coordinator | authoritative | src/OpenClaw.Connection/GatewayConnectionManager.cs | device role-upgrade approval, confirmation, dedupe, and one-in-flight plus one-queued bounded reconnect | DevicePairApprovalCoordinator | manager pairing-event forwarding and generation-bound operator gateway lease source | post-approval node reconnect is bounded to two attempts per request and reacquires the current operator gateway | DevicePairApprovalCoordinatorTests.PostApproveReconnect_IsBounded | behavioral | - | | gateway-manager-device-pair-owner-closed | closed | src/OpenClaw.Connection/GatewayConnectionManager.cs | device-pair approve RPC, success dedupe, reconnect attempts, and queued retry state | DevicePairApprovalCoordinator | pairing-event forwarding, node snapshot application, and generation-bound operator gateway lease source | manager cannot regain device-pair workflow fields or approve/reconnect methods | ConnectionDomainOwnerClosureTests.GatewayConnectionManager_DoesNotReintroduceDevicePairWorkflowOwnership | source-shape | when GatewayConnectionManager no longer composes DevicePairApprovalCoordinator directly | +| chat-composer-view-model | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs (nested ReactorChatComposer) | draft text/revision, pending attachment identities/presentation, slash UI state, composer busy flags, selector/queue projections, and derived enablement in the root/nested composer Reactor hooks | ChatComposerViewModel | ReactorChatComposer (view) reads projected values and applies immutable ChatComposerInputs from the root; ChatComposerViewModel never subscribes to IChatDataProvider | every observable mutation is dispatched through IUiDispatcher, ChatComposerInputs is applied only when its revision strictly increases, and no mutation is accepted after Dispose | ChatComposerViewModelTests.ApplyInputs_RejectsOutOfOrderRevision | behavioral | - | +| chat-composer-controller | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs (root SendAsync/OnStop and composer callback closures) | send/stop/reset-confirmation/queue-cancel/model-set-clear/thinking/catalog/attachment-ingress-remove/paste-image/voice workflow and cancellation | ChatComposerController over IChatComposerRuntimePort and ChatComposerHostActions | root session selection (SelectThread) and view event forwarding; D1 provider remains authoritative for send admission and queue mechanics | draft revision and attachment reference identities are snapshotted at operation start and cleared only when the accepted result still matches; operations are fenced by a generation bumped on Dispose so late completions cannot mutate a disposed/superseded controller | ChatComposerControllerTests.SendAsync_EditDuringDelayedSend_DoesNotClearTheEditedDraft | behavioral | - | +| chat-composer-host-lifetime | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatHostExtensions.cs | ad hoc per-render HostCallbacks assignment and no explicit composer session lifetime | IChatComposerFactory + ChatComposerSession, owned/disposed exactly once by MountedReactorChat | ChatPage and ChatWindow each receive a separate session over the same provider; the factory is a stateless singleton with no constructor-started work | disposing a MountedReactorChat disposes its ChatComposerSession (controller then view model) exactly once, and repeated Dispose calls are a no-op | ChatComposerSessionTests.Dispose_DisposesViewModelAndControllerExactlyOnce | behavioral | - | +| reactor-chat-root-composer-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs | composer draft/attachment/slash/voice/send mutable state and direct composer send/model/thinking/catalog/queue-cancel provider calls | ChatComposerViewModel + ChatComposerController | provider subscription, initial load, immutable snapshot, selected/materialized/compose-only thread selection, timeline/generation/metadata projection, permission-card forwarding, checkpoint routing, #1089 scroll/follow tokens, root composition, and construction of one immutable ChatComposerInputs projection per render | the root holds no composer UseState/refs and calls no composer provider API directly; it only builds ChatComposerInputs and forwards it plus a bound SelectThread handoff to the composer session | ChatRootComposerClosureTests.Root_DoesNotReintroduceComposerMutableState | source-shape | when OpenClawReactorChatRoot is replaced by a different root/composer boundary | ## Deferred test builders diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatComposerController.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerController.cs new file mode 100644 index 000000000..b77a1e3ff --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerController.cs @@ -0,0 +1,597 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Presentation; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClawTray.Chat; + +/// +/// Focused workflow orchestrator for the composer. It owns send/lifecycle/stop/reset +/// confirmation/queue cancel/model set-clear/thinking/catalog/attachment ingress- +/// remove/paste-image/voice operation cancellation and IDs, executed over the narrow +/// and +/// ports. It reuses , +/// (through the view model), and +/// // +/// exactly as the pre-D2 root did. +/// +/// +/// Never writes a named control, builds XAML, owns a popup instance, or calls +/// Application.Current. D1 remains authoritative for send admission, queue +/// mechanics, reset/history state, and permission identity; this controller only +/// converts operation outcomes into typed results applied to the view model. +/// +internal sealed partial class ChatComposerController : IDisposable +{ + private readonly ChatComposerViewModel _vm; + private readonly IChatComposerRuntimePort _port; + private readonly ChatComposerHostActions _hostActions; + private readonly object _operationGate = new(); + + /// Canceled and disposed exactly once, in . Never + /// read directly after construction — see , which is + /// the only thing every port call actually uses. + private readonly CancellationTokenSource _lifetimeCts = new(); + + /// The single captured once, in the + /// constructor, and reused for every send/stop/queue-cancel/model/thinking/ + /// catalog port call. 's getter + /// throws once the source is disposed; + /// a pre-captured token remains usable (and, post-dispose, correctly + /// canceled) for cancellation checks/registration, so capturing it once here + /// avoids ever re-reading the getter after construction. + private readonly CancellationToken _lifetimeToken; + + private Action? _selectedSessionHandoff; + private CancellationTokenSource? _voiceCancellation; + private CancellationTokenSource? _pasteCancellation; +#pragma warning disable CS0169 // Consumed only by the WinRT paste partial (ChatComposerControllerClipboard.cs), + // which is not linked into the pure net10.0 OpenClaw.Tray.Tests project. + private int _pasteOperation; +#pragma warning restore CS0169 + private int _voiceOperation; + private int _voiceStopOperation; + private int _sendOperation; + private int _catalogOperation; + private int _generation; + + /// Controller-owned single-flight send gate, independent of the + /// rendered/projected value. + /// is dispatched through + /// , which — when the host dispatcher + /// does not currently have thread access — only *enqueues* the mutation rather + /// than applying it immediately; IsSending can therefore still read + /// for a window after a send has already started. Using + /// it as the single-flight guard would let a second concurrent + /// call slip through and invoke the provider twice for + /// one user action. This field is the actual gate (acquired/released only via + /// , never read/written as a plain bool), and + /// IsSending remains purely a derived, render-only output. + private int _sendGate; + private volatile bool _disposed; + +#if OPENCLAW_TRAY_TESTS + /// Test-only synchronization seam invoked in + /// after its entry disposed check, before any port call. + internal Func? TestOnlyAfterEntryBeforePortInvocation; + + /// Test-only synchronization seam invoked at the start of + /// , before its eager synchronous port call. + internal Action? TestOnlyBeforeFireAndForgetSynchronousInvocation; + + /// Test-only synchronization seam invoked after voice's cheap entry + /// checks and before operation registration. + internal Action? TestOnlyBeforeVoiceRegistration; + + /// Test-only observation seam invoked after a registered voice + /// operation releases its cancellation source. + internal Action? TestOnlyVoiceOperationCleanedUp; + + /// Test-only contention probe that must acquire the same operation + /// gate used by registration and disposal. + internal void TestOnlyProbeOperationGate() + { + lock (_operationGate) { } + } +#endif + + public ChatComposerController( + ChatComposerViewModel viewModel, + IChatComposerRuntimePort port, + ChatComposerHostActions hostActions) + { + _vm = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + _port = port ?? throw new ArgumentNullException(nameof(port)); + _hostActions = hostActions ?? throw new ArgumentNullException(nameof(hostActions)); + _lifetimeToken = _lifetimeCts.Token; + } + /// Binds the root's session-selection handoff exactly once. Safe to call + /// on every render: it is a no-op once bound, since the underlying closure is + /// stable for the lifetime of the mounted root. No-ops after disposal. + public void BindSelectionHandoff(Action handoff) + { + if (_disposed) + return; + + _selectedSessionHandoff ??= handoff; + } + + /// Exposed for disposal characterization tests. + internal bool IsDisposed => _disposed; + + /// Handles a session-picker selection. Reuses the same handoff delegate + /// the lifecycle "/new" flow uses to select a freshly created session. No-ops + /// after disposal. + public void SelectChannel(string threadId) + { + if (_disposed) + return; + + _selectedSessionHandoff?.Invoke(threadId); + } + + /// Full composer send workflow: local admission first, snapshot of draft + /// revision/attachment identities/compose target at operation start, delegate once + /// to , then clear only the accepted, still-matching + /// draft/attachments. In-flight edits and attachment additions survive. + /// + /// Single-flight is enforced by (an + /// -guarded field), not by + /// : that VM property is only + /// dispatched through , which may + /// merely enqueue (not yet apply) the "sending" flag when the host dispatcher + /// does not currently have thread access, leaving a window where a second + /// concurrent call would otherwise observe stale "not sending" state and send + /// twice. + /// + public async Task SendAsync() + { + if (_disposed) + return false; + if (_vm.Inputs is not { } inputs) + return false; + + var thread = inputs.CurrentThread; + var draft = _vm.Draft; + var attachments = _vm.PendingAttachments; + var message = draft.Trim(); + if ((message.Length == 0 && attachments.Count == 0) + || _vm.SlashDisplay.IsLoading + || inputs.ConnectionState != "connected") + { + return false; + } + + if (Interlocked.CompareExchange(ref _sendGate, 1, 0) != 0) + return false; + + try + { + var submittedRevision = _vm.DraftRevision; + var generationAtStart = _generation; + var sendOperation = ++_sendOperation; + _vm.SetSending(true); + try + { + var accepted = await SendCoreAsync(thread.Id, thread.Title, message, attachments).ConfigureAwait(true); + if (_disposed || generationAtStart != _generation || sendOperation != _sendOperation) + return accepted; + + if (accepted) + { + if (ChatComposerSubmissionPolicy.ShouldClearInput(submittedRevision, _vm.DraftRevision)) + _vm.ClearDraft(); + _vm.RemoveSubmittedAttachments(attachments); + } + + return accepted; + } + finally + { + if (!_disposed && generationAtStart == _generation && sendOperation == _sendOperation) + _vm.SetSending(false); + } + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception ex) + { + System.Diagnostics.Trace.WriteLine($"[chat] composer send failed: {ex}"); + return false; + } + finally + { + // Released unconditionally — including on a throw from the snapshot/ + // SetSending(true) statements above or from anywhere in the inner try + // — so a controller that is still alive (or whose send merely got + // fenced by the disposed/generation check above) can always accept + // its next send once this one has fully unwound. + Interlocked.Exchange(ref _sendGate, 0); + } + } + + /// Pure send/lifecycle workflow with no composer draft/attachment state. + /// Used by the composer send workflow above and directly by the root's welcome- + /// screen quick-start suggestion, exactly as the pre-D2 root's private + /// SendAsync helper served both call sites. Fences every resume point + /// (after the compact enqueue, the reset-confirmation dialog, and the lifecycle + /// execute/send calls) against a dispose/generation change that happened while + /// awaiting, so a controller disposed mid-confirmation cannot still execute the + /// destructive command or hand a stale session key to a torn-down host. No-ops + /// (returns false without calling the port) if already disposed at entry. + public async Task SendCoreAsync( + string threadId, + string? displayName, + string message, + IReadOnlyList attachments) + { + if (_disposed) + return false; + +#if OPENCLAW_TRAY_TESTS + if (TestOnlyAfterEntryBeforePortInvocation is { } hook) + await hook().ConfigureAwait(true); +#endif + + var generationAtStart = _generation; + bool StillLive() => !_disposed && generationAtStart == _generation; + + if (_port.SupportsNativeLifecycle + && ChatLifecycleCommandParser.TryParse(message, attachments.Count > 0, out var command)) + { + if (ChatLifecycleCommandExecutionPolicy.ShouldQueue(command)) + { + var queued = await _port.EnqueueCompactCommandAsync(threadId).ConfigureAwait(true); + return StillLive() && queued; + } + + if (command == ChatLifecycleCommandKind.Reset && _hostActions.ConfirmResetAsync is not null) + { + var confirmed = await _hostActions.ConfirmResetAsync(threadId, displayName).ConfigureAwait(true); + if (!StillLive() || !confirmed) + return false; + } + + var result = await _port.ExecuteLifecycleCommandAsync(threadId, command).ConfigureAwait(true); + if (!StillLive()) + return false; + if (result.Succeeded && result.NewSessionKey is { } sessionKey) + _selectedSessionHandoff?.Invoke(sessionKey); + return result.Succeeded; + } + + var accepted = await _port.SendMessageAsync(threadId, message, attachments, _lifetimeToken).ConfigureAwait(true); + return StillLive() && accepted; + } + + public void Stop() + { + if (_disposed) + return; + if (_vm.Inputs?.CurrentThread.Id is not { } threadId) + return; + + FireAndForget(_ => _port.StopResponseAsync(threadId, _lifetimeToken)); + } + + public void CancelQueuedMessage(string queuedMessageId) + { + if (_disposed) + return; + if (_vm.Inputs?.CurrentThread.Id is not { } threadId) + return; + + FireAndForget(_ => _port.CancelQueuedMessageAsync(threadId, queuedMessageId, _lifetimeToken)); + } + + public void SetModel(string model) + { + if (_disposed) + return; + if (_vm.Inputs?.CurrentThread.Id is not { } threadId) + return; + + FireAndForget(_ => _port.SetModelAsync(threadId, model, _lifetimeToken)); + } + + public void ClearModel() + { + if (_disposed) + return; + if (_vm.Inputs?.CurrentThread.Id is not { } threadId) + return; + + FireAndForget(_ => _port.ClearModelAsync(threadId, _lifetimeToken)); + } + + public void SetThinkingLevel(string level) + { + if (_disposed) + return; + if (_vm.Inputs?.CurrentThread.Id is not { } threadId) + return; + + FireAndForget(_ => _port.SetThinkingLevelAsync(threadId, level, _lifetimeToken)); + } + + /// Requests a command-catalog refresh. Assigns a monotonic operation ID + /// and threads the shared lifetime token so the outstanding request is actually + /// canceled on dispose. Refreshed results flow back only through the root's + /// provider-subscribed snapshot/ApplyInputs path (already monotonic-guarded), + /// so this call itself owns no VM mutation to fence beyond disposal/cancellation. + public void RequestCommandCatalog() + { + if (_disposed) + return; + + ++_catalogOperation; + FireAndForget(_ => _port.EnsureCommandCatalogAsync(_lifetimeToken)); + } + + public void AddAttachment(ChatAttachment attachment) + { + if (_disposed) + return; + + _vm.AddAttachments(new[] { attachment }); + } + + /// Ingests attachments that originate outside the declarative tree (the + /// host file picker). Bound once at session creation, not reassigned per render. + /// No-ops after disposal. + public void AddAttachments(IReadOnlyList attachments) + { + if (_disposed) + return; + + _vm.AddAttachments(attachments); + } + + public void RemoveAttachment(ChatAttachment attachment) + { + if (_disposed) + return; + + _vm.RemoveAttachment(attachment); + } + + public void ToggleSpeakerMuted() + { + if (_disposed) + return; + + var next = !_vm.IsSpeakerMuted; + _vm.SetSpeakerMuted(next); + _hostActions.SpeakerMuteChanged?.Invoke(next); + } + + /// Starts a voice-capture operation. Cancels any prior in-flight capture, + /// assigns a new monotonic operation ID, and fences the eventual completion so a + /// stale capture (superseded, unmounted, or disposed) cannot mutate the view model. + /// No-ops after disposal. + public void StartVoiceRecording() + { + if (_disposed || _hostActions.VoiceCaptureRequest is not { } request || _vm.IsRecording) + return; + +#if OPENCLAW_TRAY_TESTS + TestOnlyBeforeVoiceRegistration?.Invoke(); +#endif + + CancellationTokenSource cancellation; + CancellationTokenSource? superseded; + Task requestTask; + int operation; + int generationAtStart; + + lock (_operationGate) + { + if (_disposed || _vm.IsRecording) + return; + + cancellation = new CancellationTokenSource(); + superseded = _voiceCancellation; + _voiceCancellation = cancellation; + operation = ++_voiceOperation; + _voiceStopOperation = 0; + generationAtStart = _generation; + _vm.SetRecording(true); + + try + { + requestTask = request( + cancellation.Token, + () => SetVoiceRecordingStartedIfCurrent(cancellation, operation, generationAtStart)) + ?? Task.FromException(new InvalidOperationException("Voice capture returned no task.")); + } + catch (Exception ex) + { + requestTask = Task.FromException(ex); + } + } + + // The external request is synchronously initiated while registration is + // linearized above; all awaiting and cancellation happen after releasing + // the operation gate. + TryCancel(superseded); + _ = ReceiveVoiceAsync(requestTask, cancellation, operation, generationAtStart); + } + + /// Requests cancellation of the in-flight voice capture. The capture's + /// own completion path decides whether a partial transcript survives. No-ops + /// after disposal (Dispose already cancels any in-flight capture). + public void StopVoiceRecording() + { + if (_disposed) + return; + + CancellationTokenSource? cancellation; + lock (_operationGate) + { + if (_disposed) + return; + + _voiceStopOperation = _voiceOperation; + cancellation = _voiceCancellation; + } + + TryCancel(cancellation); + } + + private void SetVoiceRecordingStartedIfCurrent( + CancellationTokenSource cancellation, + int operation, + int generationAtStart) + { + lock (_operationGate) + { + if (!_disposed + && generationAtStart == _generation + && operation == _voiceOperation + && ReferenceEquals(_voiceCancellation, cancellation)) + { + _vm.SetRecording(true); + } + } + } + + private async Task ReceiveVoiceAsync( + Task requestTask, + CancellationTokenSource cancellation, + int operation, + int generationAtStart) + { + try + { + var transcript = await requestTask.ConfigureAwait(true); + lock (_operationGate) + { + var stoppedByUser = _voiceStopOperation == operation; + if (!_disposed + && generationAtStart == _generation + && operation == _voiceOperation + && ReferenceEquals(_voiceCancellation, cancellation) + && (!cancellation.IsCancellationRequested || stoppedByUser) + && !string.IsNullOrWhiteSpace(transcript)) + { + _vm.AppendVoiceTranscript(transcript); + } + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + OpenClawTray.Services.Logger.Debug($"Reactor chat composer voice request failed: {ex.Message}"); + } + finally + { + lock (_operationGate) + { + if (ReferenceEquals(_voiceCancellation, cancellation)) + _voiceCancellation = null; + if (!_disposed + && generationAtStart == _generation + && _voiceOperation == operation) + { + _vm.SetRecording(false); + } + } + + cancellation.Dispose(); +#if OPENCLAW_TRAY_TESTS + TestOnlyVoiceOperationCleanedUp?.Invoke(); +#endif + } + } + + private static void TryCancel(CancellationTokenSource? cancellation) + { + if (cancellation is null) + return; + + try + { + cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + // The operation completion owns disposal and may win this race. + } + } + + /// Invokes synchronously (on the caller's + /// thread) to obtain its Task, then observes completion/errors without awaiting. + /// Invoking synchronously — rather than deferring the call itself into + /// Task.Run — preserves call order when the UI issues several of these in + /// quick succession (for example two rapid model picks), matching the pre-D2 + /// root's ObserveFireAndForget(props.Provider.SetModelAsync(...)) pattern + /// where the provider call itself was already evaluated eagerly at the call site. + private void FireAndForget(Func operation) + { +#if OPENCLAW_TRAY_TESTS + TestOnlyBeforeFireAndForgetSynchronousInvocation?.Invoke(); +#endif + + Task task; + try + { + task = operation(CancellationToken.None); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + System.Diagnostics.Trace.WriteLine($"[chat] operation failed: {ex}"); + return; + } + + _ = ObserveAsync(task); + + static async Task ObserveAsync(Task pending) + { + try { await pending.ConfigureAwait(true); } + catch (OperationCanceledException) { } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[chat] operation failed: {ex}"); } + } + } + + /// Marks the controller disposed, cancels every outstanding operation + /// (voice, paste, and the shared lifetime token used by stop/queue-cancel/model/ + /// thinking/catalog/send), and bumps the generation so any already-running + /// completion is fenced out. Idempotent: repeated calls are a no-op. + public void Dispose() + { + if (_disposed) + return; + + CancellationTokenSource? voiceCancellation; + CancellationTokenSource? pasteCancellation; + lock (_operationGate) + { + if (_disposed) + return; + + _disposed = true; + _generation++; + voiceCancellation = _voiceCancellation; + _voiceCancellation = null; + pasteCancellation = _pasteCancellation; + _pasteCancellation = null; + } + + // Cancellation invokes registered callbacks synchronously. Run it only + // after releasing the registration gate so callbacks may safely re-enter + // the controller or wait for operation cleanup. The winning Dispose call + // still returns only after every cancellation request has been issued. + _lifetimeCts.Cancel(); + _lifetimeCts.Dispose(); + TryCancel(voiceCancellation); + TryCancel(pasteCancellation); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatComposerControllerClipboard.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerControllerClipboard.cs new file mode 100644 index 000000000..610074fb0 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerControllerClipboard.cs @@ -0,0 +1,169 @@ +using OpenClaw.Shared; +using System; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClawTray.Chat; + +/// +/// WinRT clipboard-image decode partial for . +/// Split into its own file (rather than living in ChatComposerController.cs) +/// so the WinRT-free half of the controller can be linked into the pure net10.0 +/// OpenClaw.Tray.Tests project for direct unit testing; this file is compiled +/// only as part of the full WinUI build. +/// +internal sealed partial class ChatComposerController +{ + /// Test-only asynchronous synchronization seam awaited immediately + /// before the clipboard decode begins. Always (a no-op) + /// in production; exists so a test can force this method to suspend and yield + /// back to its caller before any WinRT clipboard/decode work starts, so a + /// caller-driven call + /// deterministically wins the "dispose before decode completes" race rather + /// than depending on the real decode pipeline happening to suspend before it + /// (rarely, for a trivially small bitmap) completes synchronously. Assigned + /// only from OpenClaw.Tray.UITests via InternalsVisibleTo, so the + /// WinUI project's own compilation never sees an assignment, hence the + /// explicit suppression below. +#pragma warning disable CS0649 // Assigned only by OpenClaw.Tray.UITests via InternalsVisibleTo. + internal Func? TestOnlyBeforeDecodeAsync; + + /// Test-only observation seam invoked immediately before the WinRT + /// clipboard bitmap request is made. + internal Action? TestOnlyClipboardGetBitmapInitiated; + + /// Test-only synchronization seam awaited after decode completes and + /// before the attachment result is considered for application. + internal Func? TestOnlyAfterDecodeAsync; +#pragma warning restore CS0649 + + /// Decodes a clipboard bitmap into a PNG attachment, mirroring the pre-D2 + /// view's paste handler exactly: bitmap-only, PNG re-encode, size gate, and no + /// draft loss on failure/rejection. Assigns a monotonic paste operation ID and a + /// dedicated : starting a new paste cancels + /// and supersedes any prior in-flight paste (mirroring voice capture), and the + /// eventual decode result is only applied to the view model if this paste is + /// still the current one, the controller is not disposed, and the generation has + /// not advanced — so a late/superseded/post-dispose decode cannot add a stale + /// attachment. + public async Task PasteImageAsync( + global::Windows.ApplicationModel.DataTransfer.DataPackageView clipboardContent) + { + if (_disposed) + return; + + CancellationTokenSource cancellation; + CancellationTokenSource? superseded; + int operation; + int generationAtStart; + + lock (_operationGate) + { + if (_disposed) + return; + + cancellation = new CancellationTokenSource(); + superseded = _pasteCancellation; + _pasteCancellation = cancellation; + operation = ++_pasteOperation; + generationAtStart = _generation; + } + + TryCancel(superseded); + + try + { + if (TestOnlyBeforeDecodeAsync is { } hook) + await hook().ConfigureAwait(true); + + Task bitmapTask; + lock (_operationGate) + { + if (_disposed + || generationAtStart != _generation + || operation != _pasteOperation + || !ReferenceEquals(_pasteCancellation, cancellation)) + { + return; + } + + bitmapTask = clipboardContent.GetBitmapAsync().AsTask(cancellation.Token); + TestOnlyClipboardGetBitmapInitiated?.Invoke(); + } + + // GetBitmapAsync is synchronously initiated while registration is + // linearized above; decode and every await run after releasing the gate. + var attachment = await TryReadImageFromClipboardAsync(bitmapTask, cancellation.Token) + .ConfigureAwait(true); + if (TestOnlyAfterDecodeAsync is { } afterDecode) + await afterDecode().ConfigureAwait(true); + + lock (_operationGate) + { + if (attachment is not null + && !_disposed + && generationAtStart == _generation + && operation == _pasteOperation + && ReferenceEquals(_pasteCancellation, cancellation)) + { + _vm.AddAttachments(new[] { attachment }); + } + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + OpenClawTray.Services.Logger.Debug( + $"Reactor chat composer: clipboard image paste failed: {ex.Message}"); + } + finally + { + lock (_operationGate) + { + if (ReferenceEquals(_pasteCancellation, cancellation)) + _pasteCancellation = null; + } + + cancellation.Dispose(); + } + } + + private async Task TryReadImageFromClipboardAsync( + Task bitmapTask, + CancellationToken cancellationToken) + { + var streamRef = await bitmapTask.ConfigureAwait(true); + using var input = await streamRef.OpenReadAsync().AsTask(cancellationToken).ConfigureAwait(true); + var decoder = await global::Windows.Graphics.Imaging.BitmapDecoder.CreateAsync(input) + .AsTask(cancellationToken).ConfigureAwait(true); + using var bitmap = await decoder.GetSoftwareBitmapAsync().AsTask(cancellationToken).ConfigureAwait(true); + using var output = new global::Windows.Storage.Streams.InMemoryRandomAccessStream(); + var encoder = await global::Windows.Graphics.Imaging.BitmapEncoder.CreateAsync( + global::Windows.Graphics.Imaging.BitmapEncoder.PngEncoderId, + output).AsTask(cancellationToken).ConfigureAwait(true); + encoder.SetSoftwareBitmap(bitmap); + await encoder.FlushAsync().AsTask(cancellationToken).ConfigureAwait(true); + + var size = (long)output.Size; + if (size > ChatAttachment.MaxSizeBytes) + return null; + + output.Seek(0); + var bytes = new byte[size]; + using (var reader = new global::Windows.Storage.Streams.DataReader(output.GetInputStreamAt(0))) + { + await reader.LoadAsync((uint)size).AsTask(cancellationToken).ConfigureAwait(true); + reader.ReadBytes(bytes); + } + + return new ChatAttachment + { + Type = "image", + MimeType = "image/png", + FileName = $"pasted-image-{DateTime.Now:yyyyMMdd-HHmmss}.png", + Content = Convert.ToBase64String(bytes), + SizeBytes = size, + }; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatComposerFactory.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerFactory.cs new file mode 100644 index 000000000..3987a496e --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerFactory.cs @@ -0,0 +1,28 @@ +using OpenClaw.Chat; +using OpenClawTray.Presentation; +using System; + +namespace OpenClawTray.Chat; + +/// +/// Production . Stateless apart from the injected, +/// App-owned singleton; starts no background work. +/// +internal sealed class ChatComposerFactory(IUiDispatcher dispatcher) : IChatComposerFactory +{ + private readonly IUiDispatcher _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + public ChatComposerSession Create( + IChatDataProvider provider, + ChatComposerHostActions hostActions, + bool initialSpeakerMuted) + { + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(hostActions); + + var port = new ChatComposerRuntimePort(provider); + var viewModel = new ChatComposerViewModel(_dispatcher, initialSpeakerMuted); + var controller = new ChatComposerController(viewModel, port, hostActions); + return new ChatComposerSession(viewModel, controller, hostActions); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatComposerHostActions.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerHostActions.cs new file mode 100644 index 000000000..b49ed8847 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerHostActions.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClawTray.Chat; + +/// +/// Host/view capabilities the composer controller forwards to, bound once when the +/// session is created by . +/// These are delegate references to existing host behavior (dialog confirmation, +/// file picker, voice capture, settings navigation, speaker persistence) — not +/// named-control setters and not a second mutable state source. Internal: not part +/// of the pre-existing public host API, so it stays internal rather than growing +/// the public surface merely because it is a primary-constructor record. +/// +/// +/// is intentionally not part of this immutable +/// record: it depends on the root's per-mount selection state, which does not exist +/// until renders for the first time. The root +/// binds it once via . +/// +internal sealed record ChatComposerHostActions( + Func>? ConfirmResetAsync, + Action? AttachmentPickerRequest, + Func>? VoiceCaptureRequest, + Action? SettingsNavigation, + Action? SpeakerMuteChanged); diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatComposerInputs.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerInputs.cs new file mode 100644 index 000000000..de574091d --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerInputs.cs @@ -0,0 +1,32 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using System; +using System.Collections.Generic; + +namespace OpenClawTray.Chat; + +/// +/// Immutable per-render projection that pushes +/// into the composer session after it resolves the provider snapshot, selection, and +/// effective thread. This is render-only truth: +/// never mutates it and never subscribes to the provider directly. +/// +/// +/// is a strictly increasing counter assigned by the root on +/// every push. rejects any input whose +/// revision is not greater than the currently-applied one, so an out-of-order dispatch +/// (for example a delayed UI-thread callback racing a newer render) cannot regress the +/// composer's view of session/model/thinking/queue/connection state. +/// +internal sealed record ChatComposerInputs( + long Revision, + string ConnectionState, + bool TurnActive, + ChatThread CurrentThread, + IReadOnlyList AvailableChannels, + string[] AvailableModels, + IReadOnlyList? ModelChoices, + bool MessageOptionsDisabled, + IReadOnlyList QueuedMessages, + IReadOnlyList? AvailableCommands, + bool CommandsSupported); diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatComposerRuntimePort.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerRuntimePort.cs new file mode 100644 index 000000000..58f741882 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerRuntimePort.cs @@ -0,0 +1,98 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClawTray.Chat; + +/// +/// Production adapter. It holds only the +/// current reference and forwards each call +/// verbatim; it never caches a decision, subscribes to Changed, or retries +/// on its own. Exceptions from the underlying provider are swallowed and traced the +/// same way the pre-D2 root/composer closures did, so behavior is unchanged. +/// +internal sealed class ChatComposerRuntimePort(IChatDataProvider provider) : IChatComposerRuntimePort +{ + public bool SupportsNativeLifecycle => provider is OpenClawChatDataProvider; + + public async Task SendMessageAsync( + string threadId, + string message, + IReadOnlyList attachments, + CancellationToken cancellationToken) + { + try + { + await provider.SendMessageAsync(threadId, message, cancellationToken, attachments).ConfigureAwait(true); + return true; + } + catch (Exception ex) + { + System.Diagnostics.Trace.WriteLine($"[chat] send failed: {ex}"); + return false; + } + } + + public Task EnqueueCompactCommandAsync(string threadId) => + provider is OpenClawChatDataProvider native + ? native.EnqueueCompactCommandAsync(threadId) + : Task.FromResult(false); + + public Task ExecuteLifecycleCommandAsync( + string threadId, + ChatLifecycleCommandKind command) => + provider is OpenClawChatDataProvider native + ? native.ExecuteLifecycleCommandAsync(threadId, command) + : Task.FromResult(new ChatLifecycleCommandResult( + command, + Succeeded: false, + Error: "This gateway does not support lifecycle commands.")); + + public async Task StopResponseAsync(string threadId, CancellationToken cancellationToken) + { + try { await provider.StopResponseAsync(threadId, cancellationToken).ConfigureAwait(true); } + catch (OperationCanceledException) { } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[chat] operation failed: {ex}"); } + } + + public async Task CancelQueuedMessageAsync( + string threadId, + string queuedMessageId, + CancellationToken cancellationToken) + { + try { await provider.CancelQueuedMessageAsync(threadId, queuedMessageId, cancellationToken).ConfigureAwait(true); } + catch (OperationCanceledException) { } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[chat] operation failed: {ex}"); } + } + + public async Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken) + { + try { await provider.SetModelAsync(threadId, model, cancellationToken).ConfigureAwait(true); } + catch (OperationCanceledException) { } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[chat] operation failed: {ex}"); } + } + + public async Task ClearModelAsync(string threadId, CancellationToken cancellationToken) + { + try { await provider.ClearModelAsync(threadId, cancellationToken).ConfigureAwait(true); } + catch (OperationCanceledException) { } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[chat] operation failed: {ex}"); } + } + + public async Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken) + { + try { await provider.SetThinkingLevelAsync(threadId, thinkingLevel, cancellationToken).ConfigureAwait(true); } + catch (OperationCanceledException) { } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[chat] operation failed: {ex}"); } + } + + public async Task EnsureCommandCatalogAsync(CancellationToken cancellationToken) + { + try { await provider.EnsureCommandCatalogAsync(cancellationToken).ConfigureAwait(true); } + catch (OperationCanceledException) { } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[chat] operation failed: {ex}"); } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatComposerSession.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerSession.cs new file mode 100644 index 000000000..2ec990342 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerSession.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading; + +namespace OpenClawTray.Chat; + +/// +/// One transient host-mount bundle: a , a +/// , and their shared +/// . Created by the stateless +/// and owned/disposed exactly once by +/// . and +/// each hold a separate session over the same provider, so draft, attachment, focus, +/// popup, and voice state stay host-local while provider/runtime state is shared. +/// Public only because it is a property type on the pre-existing public +/// and a constructor parameter of the +/// pre-existing public ; every other member (and the +/// constructor itself) stays internal — only is a public +/// member the pre-existing host API needs to call. +/// +public sealed class ChatComposerSession : IDisposable +{ + private int _disposed; + + internal ChatComposerSession( + ChatComposerViewModel viewModel, + ChatComposerController controller, + ChatComposerHostActions hostActions) + { + ViewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + Controller = controller ?? throw new ArgumentNullException(nameof(controller)); + HostActions = hostActions ?? throw new ArgumentNullException(nameof(hostActions)); + } + + internal ChatComposerViewModel ViewModel { get; } + + internal ChatComposerController Controller { get; } + + internal ChatComposerHostActions HostActions { get; } + + /// Applies the root's latest immutable projection to the view model. + internal void ApplyInputs(ChatComposerInputs inputs) => ViewModel.ApplyInputs(inputs); + + /// Disposes the controller then the view model exactly once. Safe to + /// call multiple times; repeated calls are a no-op. + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + Controller.Dispose(); + ViewModel.Dispose(); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatComposerViewModel.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerViewModel.cs new file mode 100644 index 000000000..b1a90e52f --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerViewModel.cs @@ -0,0 +1,469 @@ +using OpenClaw.Shared; +using OpenClawTray.Presentation; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace OpenClawTray.Chat; + +/// +/// WinUI/XAML-free, disposable view model that owns only the composer's ephemeral, +/// host-local observable state: draft text and revision, pending attachment +/// identities, send/voice busy flags, slash UI state (via the existing pure +/// ), transient dismissal/catalog-awaiting +/// presentation, and the latest immutable projection. +/// +/// +/// This type never subscribes to and never owns a +/// file, manager, cache, or runtime generation. Every mutation is dispatched through +/// so is always raised on the +/// UI thread, even when a background completion (voice transcript, send result) drives +/// the mutation. is render-only truth: +/// rejects any projection whose is not +/// greater than the currently-applied one. +/// +internal sealed class ChatComposerViewModel : INotifyPropertyChanged, IDisposable +{ + private readonly IUiDispatcher _dispatcher; + private readonly object _queueLock = new(); + + /// Linearizes "apply one dequeued mutation and raise PropertyChanged" + /// against . Held only around the apply/notify step, never + /// across the dequeue (so and this lock are never + /// nested from the drain side) and never across the whole drain loop (so a + /// reentrant subscriber that enqueues another + /// mutation cannot deadlock: only ever needs + /// ). acquires this lock before + /// marking the view model disposed, so an apply that has already started is + /// guaranteed to finish (and raise its notification) before + /// can return, while an apply that has not yet started observes disposal and + /// drops its mutation with no state, revision, or notification change. + private readonly object _applyLock = new(); + + private readonly Queue _pendingMutations = new(); + private bool _draining; + + /// Disposal flag read under three different synchronization domains: + /// with held ('s authoritative + /// check, 's empty-queue check), with + /// held ('s linearization + /// recheck before applying), and with neither lock held at all + /// ('s unlocked fast-path check, ). + /// A plain write under one lock is not guaranteed visible to + /// a reader synchronized on a different lock or on no lock — a monitor's + /// acquire/release barrier only orders operations for threads that enter that + /// same monitor. gives every read/write acquire/ + /// release semantics regardless of which (if any) lock is held, so the flip in + /// is guaranteed visible to every reader above without + /// widening or nesting the existing two-lock design. + private volatile bool _disposed; + + /// Test-only synchronization seam invoked immediately after a mutation + /// is dequeued and before is acquired to apply it. + /// Always (a no-op) in production; exists solely so a + /// test can deterministically land inside the linearization gap between + /// "dequeued" and "applied-or-dropped" and prove which outcome wins against a + /// concurrent , without relying on unpredictable OS thread + /// scheduling. Assigned only from OpenClaw.Tray.Tests, which links this + /// file directly into its own compilation via a <Compile Include> + /// source link rather than referencing the built WinUI assembly — so the WinUI + /// project's own compilation never sees an assignment, hence the explicit + /// suppression below. +#pragma warning disable CS0649 // Assigned only by a source-linked test compile item, not by this project. + internal Action? TestOnlyAfterDequeueBeforeApplyLock; +#pragma warning restore CS0649 + + private string _draft = string.Empty; + private long _draftRevision; + private IReadOnlyList _pendingAttachments = Array.Empty(); + private bool _isSending; + private bool _isRecording; + private bool _isSpeakerMuted; + private string? _voiceTranscript; + private float _voiceAudioLevel; + private ReactorSlashMenuState _slashMenuState = ReactorSlashMenuState.Closed; + private int? _dismissedSlashInputRevision; + private bool _awaitingCatalog; + private ChatComposerInputs? _inputs; + + public ChatComposerViewModel(IUiDispatcher dispatcher, bool initialSpeakerMuted) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _isSpeakerMuted = initialSpeakerMuted; + SlashDisplay = ReactorSlashDisplayState.Inactive; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + /// Bumped on every accepted mutation. The Reactor view uses this as its + /// single re-render invalidation token; it is an adapter detail, not a second + /// copy of composer state. + public int RenderRevision { get; private set; } + + public string Draft => _draft; + public long DraftRevision => _draftRevision; + public IReadOnlyList PendingAttachments => _pendingAttachments; + public bool IsSending => _isSending; + public bool IsRecording => _isRecording; + public bool IsSpeakerMuted => _isSpeakerMuted; + public string? VoiceTranscript => _voiceTranscript; + public float VoiceAudioLevel => _voiceAudioLevel; + public ReactorSlashMenuState SlashMenuState => _slashMenuState; + public ReactorSlashDisplayState SlashDisplay { get; private set; } + public ChatComposerInputs? Inputs => _inputs; + + /// Exposed for disposal characterization tests. + internal bool IsDisposed => _disposed; + + + public bool CanSend => + _inputs is { } inputs + && inputs.ConnectionState == "connected" + && !_isSending + && !SlashDisplay.IsLoading + && (_draft.Trim().Length > 0 || _pendingAttachments.Count > 0); + + /// Applies the latest immutable projection from the root. Rejects any + /// projection whose revision does not strictly advance the applied one, so an + /// out-of-order dispatcher application cannot regress session/model/thinking/ + /// queue/connection state. + public void ApplyInputs(ChatComposerInputs inputs) + { + ArgumentNullException.ThrowIfNull(inputs); + Mutate(() => + { + if (_inputs is { } current && inputs.Revision <= current.Revision) + return; + + _inputs = inputs; + RecomputeSlashDisplay(); + }); + } + + public void SetDraft(string value) + { + Mutate(() => + { + _draftRevision++; + _dismissedSlashInputRevision = null; + _draft = value; + _slashMenuState = ReactorSlashCommandController.ReconcileState( + _draft, + _inputs?.AvailableCommands, + _slashMenuState); + RecomputeSlashDisplay(); + }); + } + + public void ClearDraft() + { + Mutate(() => + { + _draftRevision++; + _draft = string.Empty; + _slashMenuState = ReactorSlashCommandController.ReconcileState( + _draft, + _inputs?.AvailableCommands, + _slashMenuState); + RecomputeSlashDisplay(); + }); + } + + public void AppendVoiceTranscript(string transcript) + { + var draft = _draft.TrimEnd(); + SetDraft(draft.Length == 0 ? transcript : $"{draft} {transcript}"); + } + + public void CommitSlashText(string value, ReactorSlashMenuState nextState) + { + Mutate(() => + { + _draftRevision++; + _draft = value; + _slashMenuState = nextState; + RecomputeSlashDisplay(); + }); + } + + public void MoveSlashSelection(int delta) + { + Mutate(() => + { + _slashMenuState = ReactorSlashCommandController.MoveSelection(_slashMenuState, SlashDisplay, delta); + RecomputeSlashDisplay(); + }); + } + + public void DismissSlashMenu() + { + Mutate(() => + { + _dismissedSlashInputRevision = (int)_draftRevision; + _slashMenuState = ReactorSlashMenuState.Closed; + RecomputeSlashDisplay(); + }); + } + + /// Commits the currently-selected slash item, if any. Mirrors the + /// pre-D2 CommitSlashText call after ReactorSlashCommandController.CommitSelection. + public ReactorSlashCommitResult CommitSelectedSlashItem() + { + var commit = ReactorSlashCommandController.CommitSelection(SlashDisplay); + if (commit.Accepted) + CommitSlashText(commit.Text, commit.NextState); + return commit; + } + + /// Returns true exactly once per catalog-awaiting transition, matching + /// the pre-D2 awaitingCatalog ref semantics. + public bool ShouldRequestCatalogOnOpen() + { + var shouldRequest = ReactorSlashCommandController.ShouldRequestCatalogOnOpen(_awaitingCatalog, SlashDisplay); + _awaitingCatalog = SlashDisplay.ShouldRequestCatalog; + return shouldRequest; + } + + public void ReconcileAfterCatalogRefresh() + { + if (!ReactorSlashCommandController.ShouldReconcileAfterCatalogRefresh( + (int)_draftRevision, + _dismissedSlashInputRevision)) + { + return; + } + + Mutate(() => + { + _slashMenuState = ReactorSlashCommandController.ReconcileState( + _draft, + _inputs?.AvailableCommands, + _slashMenuState); + RecomputeSlashDisplay(); + }); + } + + public void AddAttachments(IReadOnlyList attachments) + { + if (attachments.Count == 0) + return; + + Mutate(() => _pendingAttachments = _pendingAttachments.Concat(attachments).ToArray()); + } + + public void RemoveAttachment(ChatAttachment attachment) + { + Mutate(() => + { + var next = new List(_pendingAttachments.Count); + var removed = false; + foreach (var current in _pendingAttachments) + { + if (!removed && ReferenceEquals(current, attachment)) + { + removed = true; + continue; + } + + next.Add(current); + } + + if (removed) + _pendingAttachments = next; + }); + } + + /// Removes only the attachments an accepted send actually submitted, by + /// reference identity, so attachments added while the send was in flight survive. + public void RemoveSubmittedAttachments(IReadOnlyList submitted) => + Mutate(() => _pendingAttachments = ChatComposerSubmissionPolicy.RemoveSubmittedAttachments( + _pendingAttachments, + submitted)); + + public void SetSending(bool value) => Mutate(() => _isSending = value); + + public void SetRecording(bool value) => Mutate(() => + { + _isRecording = value; + RecomputeSlashDisplay(); + }); + + public void SetVoiceTranscript(string? value) => Mutate(() => _voiceTranscript = value); + + public void SetVoiceAudioLevel(float value) => Mutate(() => _voiceAudioLevel = value); + + public void SetSpeakerMuted(bool value) => Mutate(() => _isSpeakerMuted = value); + + private void RecomputeSlashDisplay() + { + var commandModeEnabled = _inputs?.ConnectionState == "connected" && !_isRecording; + SlashDisplay = ReactorSlashCommandController.Evaluate( + _draft, + _slashMenuState, + commandModeEnabled, + _inputs?.CommandsSupported ?? false, + _inputs?.AvailableCommands); + } + + /// Enqueues onto a single internal FIFO and + /// ensures exactly one drain is scheduled/running. This — not a per-call + /// TryEnqueue fast path — is what guarantees queued provider/host inputs, + /// completions, and user edits apply in the order they were enqueued: a mutation + /// that arrives while on the UI thread still drains any already-queued + /// background-originated work first, before (and in the same drain pass as) its + /// own change. Rejected (no-op) after so a late background + /// completion cannot mutate a disposed view model or notify a detached view. + private void Mutate(Action change) + { + if (_disposed) + return; + + bool shouldScheduleDrain; + lock (_queueLock) + { + if (_disposed) + return; + + _pendingMutations.Enqueue(change); + shouldScheduleDrain = !_draining; + if (shouldScheduleDrain) + _draining = true; + } + + if (!shouldScheduleDrain) + return; + + if (_dispatcher.HasThreadAccess) + { + DrainQueue(); + return; + } + + if (_dispatcher.TryEnqueue(DrainQueue)) + return; + + // The dispatcher refused the drain (for example, it is shutting down). + // Drop the queued work safely rather than leaving it stuck forever or + // applying it out of order later, and diagnose per the repo's existing + // "dispatcher rejected the work item" convention (see + // ReactorChatHostExtensions.AsPost) instead of failing silently. + lock (_queueLock) + { + _pendingMutations.Clear(); + _draining = false; + } + + System.Diagnostics.Debug.WriteLine( + "Dropped chat composer UI update because DispatcherQueue rejected the drain."); + } + + /// Drains the FIFO to empty, applying each queued mutation and raising + /// exactly one per applied item, always on the UI + /// thread. Runs until the queue is observed empty under the lock, so mutations + /// enqueued while a drain is already in progress are picked up by that same + /// drain rather than needing a second scheduled pass. + /// + /// Dequeues under only, then releases it before + /// acquiring to apply — so is + /// never held across the mutation action or the + /// callback. is rechecked for disposal immediately + /// after acquiring it: this is the linearization point. If + /// wins the race (acquires first and marks the view + /// model disposed), the dequeued item is dropped here with no state, revision, + /// or notification change. If this drain wins (acquires + /// first), the apply and notification are guaranteed to complete — and any + /// concurrent call is guaranteed to block until they do — + /// before can return. + /// + private void DrainQueue() + { + while (true) + { + Action next; + lock (_queueLock) + { + if (_disposed) + { + _pendingMutations.Clear(); + _draining = false; + return; + } + + if (_pendingMutations.Count == 0) + { + _draining = false; + return; + } + + next = _pendingMutations.Dequeue(); + } + + TestOnlyAfterDequeueBeforeApplyLock?.Invoke(); + + lock (_applyLock) + { + // Linearization point: if Dispose already ran (or is running and + // got here first), drop this item outright — no state mutation, no + // revision bump, no notification. A reentrant PropertyChanged + // subscriber that calls Dispose() synchronously from inside this + // same apply is fine too: Monitor locks are reentrant for the + // owning thread, so Dispose's own lock(_applyLock) block for the + // disposed-flag flip still runs (and clears the queue) before this + // apply's try block below observes _disposed and can act on it for + // any FUTURE loop iteration; this iteration's own apply, having + // already begun, still completes below exactly once. + if (_disposed) + continue; + + try + { + next(); + RenderRevision++; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(null)); + } + catch (Exception ex) + { + // A single misbehaving mutation action or PropertyChanged + // subscriber must never wedge _draining=true forever (which + // would silently and permanently freeze every future composer + // UI update) or strand the remaining queued work behind it. Log + // and keep draining the rest of the queue, matching the "no + // silent failure" handling already used for a rejected drain + // (in Mutate) and for FireAndForget operations in + // ChatComposerController. + System.Diagnostics.Debug.WriteLine($"Chat composer UI update mutation failed: {ex}"); + } + } + } + } + + /// Marks the view model disposed and stops all future notification. + /// Idempotent: repeated calls are a no-op. Acquires + /// first (blocking until any apply currently in flight — one that had already + /// started before this call — completes and raises its notification), then + /// separately clears any still-pending queued work under + /// . The two lock acquisitions are sequential, never + /// nested, so there is no lock-order cycle with (which + /// only ever holds one of the two locks at a time). + public void Dispose() + { + if (_disposed) + return; + + lock (_applyLock) + { + if (_disposed) + return; + + _disposed = true; + PropertyChanged = null; + } + + lock (_queueLock) + { + _pendingMutations.Clear(); + _draining = false; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/IChatComposerFactory.cs b/src/OpenClaw.Tray.WinUI/Chat/IChatComposerFactory.cs new file mode 100644 index 000000000..dcd13fd5d --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/IChatComposerFactory.cs @@ -0,0 +1,25 @@ +using OpenClaw.Chat; + +namespace OpenClawTray.Chat; + +/// +/// Stateless factory for a per-host-mount . +/// Registered as a singleton in the existing DI root; the factory itself starts no +/// background work and holds only the app-owned IUiDispatcher singleton. +/// Internal: only (same +/// assembly) resolves and calls it. It is not part of the pre-existing public host +/// API (, +/// , ), +/// so it stays internal rather than growing the public surface. +/// +internal interface IChatComposerFactory +{ + /// Creates one session bundling a view model, controller, and runtime + /// port over . Call once per host mount; the caller + /// () owns disposal + /// via the returned session. + ChatComposerSession Create( + IChatDataProvider provider, + ChatComposerHostActions hostActions, + bool initialSpeakerMuted); +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/IChatComposerRuntimePort.cs b/src/OpenClaw.Tray.WinUI/Chat/IChatComposerRuntimePort.cs new file mode 100644 index 000000000..f0913b53c --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/IChatComposerRuntimePort.cs @@ -0,0 +1,41 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClawTray.Chat; + +/// +/// Narrow adapter over the current and the native +/// lifecycle bridge. It holds only a provider reference: no cache, subscription, +/// collection, generation, or persistence. Provider queue/runtime decisions remain +/// authoritative; this port never makes an admission or retry decision itself. +/// +internal interface IChatComposerRuntimePort +{ + /// True when the underlying provider supports native lifecycle commands. + bool SupportsNativeLifecycle { get; } + + Task SendMessageAsync( + string threadId, + string message, + IReadOnlyList attachments, + CancellationToken cancellationToken); + + Task EnqueueCompactCommandAsync(string threadId); + + Task ExecuteLifecycleCommandAsync(string threadId, ChatLifecycleCommandKind command); + + Task StopResponseAsync(string threadId, CancellationToken cancellationToken); + + Task CancelQueuedMessageAsync(string threadId, string queuedMessageId, CancellationToken cancellationToken); + + Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken); + + Task ClearModelAsync(string threadId, CancellationToken cancellationToken); + + Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken); + + Task EnsureCommandCatalogAsync(CancellationToken cancellationToken); +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs index 5dd95d4fe..196ab3b61 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs @@ -23,17 +23,11 @@ namespace OpenClawTray.Chat; public sealed record OpenClawReactorChatRootProps( IChatDataProvider Provider, - ReactorChatHostCallbacks HostCallbacks, + ChatComposerSession ComposerSession, string? InitialThreadId = null, Func? OnReadAloud = null, Action? OnStopSpeaking = null, - Func>? OnVoiceRequest = null, - Action? OnAttachClick = null, - Action? OnSettingsClick = null, Action? OnOpenCheckpoints = null, - Action? OnSpeakerMuteChanged = null, - Func>? ConfirmResetAsync = null, - bool InitialMuted = false, bool IsCompact = false); /// @@ -69,35 +63,12 @@ public override Element Render() var (selectedId, setSelectedId) = UseState(initialSelection, threadSafe: true); var selectedIdRef = UseRef(initialSelection); selectedIdRef.Current = selectedId; - var (pendingAttachments, setPendingAttachments) = - UseState>(Array.Empty(), threadSafe: true); - var pendingAttachmentsRef = UseRef>(pendingAttachments); - pendingAttachmentsRef.Current = pendingAttachments; - var (speakerMuted, setSpeakerMuted) = UseState(props.InitialMuted, threadSafe: true); - var (voiceTranscript, setVoiceTranscript) = UseState(null, threadSafe: true); - var (voiceAudioLevel, setVoiceAudioLevel) = UseState(0f, threadSafe: true); var (scrollToBottomToken, setScrollToBottomToken) = UseState(0, threadSafe: true); var (showToolCalls, setShowToolCalls) = UseState(s_showToolCalls, threadSafe: true); var (toolCallsCollapseVersion, setToolCallsCollapseVersion) = UseState(s_toolCallsCollapseVersion, threadSafe: true); var (firstSendInFlight, setFirstSendInFlight) = UseState(false, threadSafe: true); - - void UpdatePendingAttachments(IReadOnlyList attachments) - { - pendingAttachmentsRef.Current = attachments; - setPendingAttachments(attachments); - } - - props.HostCallbacks.AttachFiles = attachments => - { - if (attachments.Count > 0) - UpdatePendingAttachments(pendingAttachmentsRef.Current.Concat(attachments).ToArray()); - }; - props.HostCallbacks.SetVoiceTranscript = setVoiceTranscript; - props.HostCallbacks.SetVoiceAudioLevel = setVoiceAudioLevel; - props.HostCallbacks.SetSpeakerMuted = setSpeakerMuted; - - UseEffect((Func)(() => () => props.HostCallbacks.Clear()), props.HostCallbacks); + var inputsRevisionRef = UseRef(0L); UseEffect((Func)(() => { @@ -295,6 +266,11 @@ void SelectThread(string threadId) native.RememberSelectedThread(threadId); } + // Bound once (idempotent) so the composer controller can hand a freshly + // created "/new" session, or a session-picker selection, back to the root's + // selection state without the controller depending on Reactor state directly. + props.ComposerSession.Controller.BindSelectionHandoff(SelectThread); + Action? onSuggestionPicked = null; if (mode == ReactorChatTimelineMode.Empty && effectiveThread is { } suggestionThread) { @@ -304,14 +280,12 @@ void SelectThread(string threadId) return; setFirstSendInFlight(true); - ObserveFireAndForget(SendAsync( + setScrollToBottomToken(scrollToBottomToken + 1); + ObserveFireAndForget(props.ComposerSession.Controller.SendCoreAsync( suggestionThread.Id, suggestionThread.Title, suggestion, - Array.Empty(), - setScrollToBottomToken, - scrollToBottomToken, - SelectThread)); + Array.Empty())); }; } @@ -322,58 +296,31 @@ void SelectThread(string threadId) firstSendInFlight, OnOpenCheckpoints: props.OnOpenCheckpoints, HistoryRevision: historyRevision)); - var composerElement = effectiveThread is null - ? Empty() - : Component(new( - connectionState, - timeline.TurnActive, - effectiveThread, - VisibleChannels(snapshot.Threads, effectiveThread), - snapshot.AvailableModels, - snapshot.ModelChoices, - timeline.TurnActive || hasPendingQueuedSend, - pendingAttachments, - queuedMessages, - async (message, attachments) => - { - var accepted = await SendAsync( - effectiveThread.Id, - effectiveThread.Title, - message, - attachments, - setScrollToBottomToken, - scrollToBottomToken, - SelectThread); - if (accepted) - UpdatePendingAttachments(RemoveSubmittedAttachments(pendingAttachmentsRef.Current, attachments)); - return accepted; - }, - () => OnStop(effectiveThread.Id), - SelectThread, - model => ObserveFireAndForget(props.Provider.SetModelAsync(effectiveThread.Id, model)), - () => ObserveFireAndForget(props.Provider.ClearModelAsync(effectiveThread.Id)), - level => RunFireAndForget(ct => props.Provider.SetThinkingLevelAsync(effectiveThread.Id, level, ct)), - allowAll => RunFireAndForget(ct => props.Provider.SetPermissionModeAsync(effectiveThread.Id, allowAll, ct)), - props.OnVoiceRequest, - props.OnAttachClick, - speakerMuted, - () => - { - var next = !speakerMuted; - setSpeakerMuted(next); - props.OnSpeakerMuteChanged?.Invoke(next); - }, - props.OnSettingsClick, - voiceTranscript, - voiceAudioLevel, - starter => props.HostCallbacks.TriggerVoiceRecording = starter, - attachment => UpdatePendingAttachments(pendingAttachmentsRef.Current.Concat(new[] { attachment }).ToArray()), - attachment => UpdatePendingAttachments(RemoveAttachment(pendingAttachmentsRef.Current, attachment)), - queuedMessageId => RunFireAndForget(ct => props.Provider.CancelQueuedMessageAsync(effectiveThread.Id, queuedMessageId, ct)), - snapshot.AvailableCommands, - snapshot.CommandsSupported, - () => RunFireAndForget(ct => props.Provider.EnsureCommandCatalogAsync(ct)), + + Element composerElement; + if (effectiveThread is null) + { + composerElement = Empty(); + } + else + { + props.ComposerSession.ApplyInputs(new ChatComposerInputs( + Revision: ++inputsRevisionRef.Current, + ConnectionState: connectionState, + TurnActive: timeline.TurnActive, + CurrentThread: effectiveThread, + AvailableChannels: VisibleChannels(snapshot.Threads, effectiveThread), + AvailableModels: snapshot.AvailableModels, + ModelChoices: snapshot.ModelChoices, + MessageOptionsDisabled: timeline.TurnActive || hasPendingQueuedSend, + QueuedMessages: queuedMessages, + AvailableCommands: snapshot.AvailableCommands, + CommandsSupported: snapshot.CommandsSupported)); + composerElement = Component(new( + props.ComposerSession, + () => setScrollToBottomToken(scrollToBottomToken + 1), props.IsCompact)); + } return Grid( [GridSize.Star()], @@ -426,78 +373,9 @@ private static IReadOnlyList VisibleChannels(ChatThread[] threads, C return visible; } - private async Task SendAsync( - string threadId, - string? displayName, - string message, - IReadOnlyList attachments, - Action setScrollToBottomToken, - int scrollToBottomToken, - Action onLifecycleSessionCreated) - { - setScrollToBottomToken(scrollToBottomToken + 1); - var provider = Props.Provider; - if (provider is OpenClawChatDataProvider native - && ChatLifecycleCommandParser.TryParse(message, attachments.Count > 0, out var command)) - { - if (ChatLifecycleCommandExecutionPolicy.ShouldQueue(command)) - return await native.EnqueueCompactCommandAsync(threadId); - - if (command == ChatLifecycleCommandKind.Reset - && Props.ConfirmResetAsync is not null - && !await Props.ConfirmResetAsync(threadId, displayName)) - { - return false; - } - - var result = await native.ExecuteLifecycleCommandAsync(threadId, command); - if (result.Succeeded && result.NewSessionKey is { } sessionKey) - onLifecycleSessionCreated(sessionKey); - return result.Succeeded; - } - - try - { - await provider.SendMessageAsync(threadId, message, CancellationToken.None, attachments); - return true; - } - catch (Exception ex) - { - System.Diagnostics.Trace.WriteLine($"[chat] send failed: {ex}"); - return false; - } - } - - private void OnStop(string threadId) => - RunFireAndForget(ct => Props.Provider.StopResponseAsync(threadId, ct)); - private void OnPermission(string threadId, string requestId, string action) => RunFireAndForget(ct => Props.Provider.RespondToPermissionAsync(threadId, requestId, action, ct)); - private static IReadOnlyList RemoveAttachment( - IReadOnlyList attachments, - ChatAttachment attachment) - { - var next = new List(attachments.Count); - var removed = false; - foreach (var current in attachments) - { - if (!removed && ReferenceEquals(current, attachment)) - { - removed = true; - continue; - } - - next.Add(current); - } - return removed ? next : attachments; - } - - private static IReadOnlyList RemoveSubmittedAttachments( - IReadOnlyList attachments, - IReadOnlyList submitted) => - attachments.Where(attachment => !submitted.Contains(attachment)).ToArray(); - private static string ToConnectionState(string? value) => value?.StartsWith("Incompatible", StringComparison.OrdinalIgnoreCase) == true ? "incompatible-gateway" @@ -548,1477 +426,3 @@ private static async Task LoadAsync( } } } - -public sealed record ReactorChatComposerProps( - string ConnectionState, - bool TurnActive, - ChatThread CurrentThread, - IReadOnlyList AvailableChannels, - string[] AvailableModels, - IReadOnlyList? ModelChoices, - bool MessageOptionsDisabled, - IReadOnlyList PendingAttachments, - IReadOnlyList QueuedMessages, - Func, Task> OnSend, - Action OnStop, - Action OnChannelChanged, - Action OnModelChanged, - Action OnModelCleared, - Action OnThinkingLevelChanged, - Action OnPermissionsChanged, - Func>? OnVoiceRequest, - Action? OnAttachClick, - bool IsSpeakerMuted, - Action OnSpeakerToggle, - Action? OnSettingsClick, - string? VoiceTranscript, - float VoiceAudioLevel, - Action RegisterVoiceStarter, - Action OnAttachmentPasted, - Action OnAttachmentRemoved, - Action OnQueuedMessageCancel, - IReadOnlyList? AvailableCommands, - bool CommandsSupported, - Action? OnCommandsRequested, - bool IsCompact); - -public sealed class ReactorChatComposer : Component -{ - private static readonly string[] ThinkingLevels = ["off", "minimal", "low", "medium", "high"]; - - public override Element Render() - { - var props = Props; - var colorScheme = UseColorScheme(); - var (text, setText) = UseState(string.Empty, threadSafe: true); - var (isSending, setIsSending) = UseState(false, threadSafe: true); - var (isRecording, setIsRecording) = UseState(false, threadSafe: true); - var (slashMenuState, setSlashMenuState) = UseState(ReactorSlashMenuState.Closed, threadSafe: true); - var inputRevision = UseRef(0); - var sendInFlight = UseRef(false); - var voiceCancellation = UseRef(null); - var voiceOperation = UseRef(0); - var voiceStopOperation = UseRef(0); - var onAttachmentPasted = UseRef>(props.OnAttachmentPasted); - onAttachmentPasted.Current = props.OnAttachmentPasted; - var pasteHandler = UseRef(async (_, args) => - { - if (GetBitmapClipboardContent() is not { } clipboardContent) - return; - - // Paste is a synchronous routed event. Suppress the default text paste - // before awaiting bitmap extraction so a multi-format clipboard cannot - // insert text alongside the image attachment. - args.Handled = true; - await PasteImageFromClipboardAsync(clipboardContent, onAttachmentPasted.Current); - }); - var inputText = UseRef(text); - var inputControl = UseRef(null); - var slashPopup = UseRef(null); - var slashPopupContentRef = UseRef<(string Key, FrameworkElement? Content)>((string.Empty, null)); - var awaitingCatalog = UseRef(false); - var dismissedSlashInputRevision = UseRef(null); - var mounted = UseRef(true); - inputText.Current = text; - var slashDisplay = ReactorSlashCommandController.Evaluate( - text, - slashMenuState, - props.ConnectionState == "connected" && !isRecording, - props.CommandsSupported, - props.AvailableCommands); - UseEffect((Func)(() => () => - { - mounted.Current = false; - voiceCancellation.Current?.Cancel(); - voiceCancellation.Current?.Dispose(); - voiceCancellation.Current = null; - voiceOperation.Current++; - CloseSlashPopup(slashPopup); - }), Array.Empty()); - UseEffect((Func)(() => - { - if (ReactorSlashCommandController.ShouldRequestCatalogOnOpen(awaitingCatalog.Current, slashDisplay)) - props.OnCommandsRequested?.Invoke(); - awaitingCatalog.Current = slashDisplay.ShouldRequestCatalog; - return static () => { }; - }), slashDisplay.ShouldRequestCatalog); - UseEffect((Func)(() => - { - if (ReactorSlashCommandController.ShouldReconcileAfterCatalogRefresh( - inputRevision.Current, - dismissedSlashInputRevision.Current)) - { - setSlashMenuState(ReactorSlashCommandController.ReconcileState( - inputText.Current, - props.AvailableCommands, - slashMenuState)); - } - return static () => { }; - }), props.AvailableCommands); - - void StartVoiceRecording() - { - if (props.OnVoiceRequest is null || isRecording) - return; - - var cancellation = new CancellationTokenSource(); - voiceCancellation.Current?.Cancel(); - voiceCancellation.Current?.Dispose(); - voiceCancellation.Current = cancellation; - var operation = ++voiceOperation.Current; - voiceStopOperation.Current = 0; - setIsRecording(true); - _ = ReceiveVoiceAsync( - props.OnVoiceRequest, - cancellation, - operation, - voiceOperation, - voiceStopOperation, - voiceCancellation, - mounted, - AppendVoiceTranscript, - setIsRecording); - } - - props.RegisterVoiceStarter(StartVoiceRecording); - - void SetText(string value) - { - inputRevision.Current++; - dismissedSlashInputRevision.Current = null; - inputText.Current = value; - setText(value); - setSlashMenuState(ReactorSlashCommandController.ReconcileState( - value, - props.AvailableCommands, - slashMenuState)); - } - - void AppendVoiceTranscript(string transcript) - { - var draft = inputText.Current.TrimEnd(); - SetText(draft.Length == 0 ? transcript : $"{draft} {transcript}"); - } - - void Send() - { - var message = text.Trim(); - if ((message.Length == 0 && props.PendingAttachments.Count == 0) - || sendInFlight.Current - || slashDisplay.IsLoading - || props.ConnectionState != "connected") - return; - - sendInFlight.Current = true; - setIsSending(true); - _ = SendAsync( - props.OnSend, - message, - props.PendingAttachments, - inputRevision.Current, - inputRevision, - sendInFlight, - SetText, - setIsSending); - } - - var modelChoices = props.ModelChoices is { Count: > 0 } - ? props.ModelChoices - : props.AvailableModels - .Where(model => !string.IsNullOrWhiteSpace(model)) - .Select(model => new ChatModelChoice(model, model)) - .ToArray(); - var selectableModels = modelChoices.Where(model => model.IsSelectable).ToArray(); - var modelNames = new[] { Localized("Chat_Composer_Reasoning_Default", "Default") } - .Concat(selectableModels.Select(ChatModelLabels.BuildMenuLabel)) - .ToArray(); - var modelIndex = string.IsNullOrWhiteSpace(props.CurrentThread.Model) - ? 0 - : Math.Max(0, Array.FindIndex( - selectableModels, - model => model.MatchesModel(props.CurrentThread.Model, props.CurrentThread.ModelProvider)) + 1); - var thinkingIndex = Math.Max(0, Array.IndexOf( - ThinkingLevels, - props.CurrentThread.ThinkingLevel ?? "medium")); - var actionLabel = props.TurnActive - ? Localized("Chat_Composer_Tooltip_Stop", "Stop") - : Localized("Chat_Composer_Tooltip_Send", "Send"); - var controlCornerRadius = new CornerRadius(4); - - Element IconButton( - string glyph, - string automationName, - Action onClick, - bool enabled = true, - string? automationId = null) - { - return Button( - TextBlock(glyph).Set(textBlock => - { - textBlock.FontFamily = FluentIconCatalog.SymbolThemeFontFamily; - textBlock.FontSize = 16; - Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( - textBlock, - Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw); - }), - onClick) - .AutomationName(automationName) - .Foreground(Theme.SecondaryText) - .Resources(resources => resources - .Set("ButtonBackground", Theme.Ref("SubtleFillColorTransparentBrush")) - .Set("ButtonBackgroundPointerOver", Theme.SubtleFill) - .Set("ButtonBackgroundPressed", Theme.Ref("SubtleFillColorTertiaryBrush")) - .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush")) - .Set("ButtonBorderBrushPointerOver", Theme.Ref("SubtleFillColorTransparentBrush")) - .Set("ButtonBorderBrushPressed", Theme.Ref("SubtleFillColorTransparentBrush"))) - .Set(button => - { - button.Width = 32; - button.Height = 32; - button.MinWidth = 32; - button.MinHeight = 32; - button.Padding = new Thickness(0); - button.CornerRadius = controlCornerRadius; - button.IsEnabled = enabled; - button.BorderThickness = new Thickness(0); - if (!string.IsNullOrWhiteSpace(automationId)) - { - Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId( - button, - automationId); - } - ComposerAutomationVisibility.Prepare(button); - ToolTipService.SetToolTip(button, automationName); - }) - .OnUnmount(control => ComposerAutomationVisibility.Detach( - (FrameworkElement)control)); - } - - Element PickerButton( - string label, - string automationName, - string automationId, - bool enabled, - double maxLabelWidth) - { - return Button( - HStack( - 4, - TextBlock(label).Set(textBlock => - { - textBlock.FontSize = 13; - textBlock.MaxWidth = maxLabelWidth; - textBlock.TextTrimming = TextTrimming.CharacterEllipsis; - textBlock.TextWrapping = TextWrapping.NoWrap; - }), - TextBlock("\uE70D").Set(textBlock => - { - textBlock.FontFamily = FluentIconCatalog.SymbolThemeFontFamily; - textBlock.FontSize = 10; - textBlock.Margin = new Thickness(2, 4, 0, 0); - Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( - textBlock, - Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw); - })), - () => { }) - .AutomationName(automationName) - .Foreground(Theme.SecondaryText) - .Resources(resources => resources - .Set("ButtonBackground", Theme.Ref("SubtleFillColorTransparentBrush")) - .Set("ButtonBackgroundPointerOver", Theme.SubtleFill) - .Set("ButtonBackgroundPressed", Theme.Ref("SubtleFillColorTertiaryBrush")) - .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush")) - .Set("ButtonBorderBrushPointerOver", Theme.Ref("SubtleFillColorTransparentBrush")) - .Set("ButtonBorderBrushPressed", Theme.Ref("SubtleFillColorTransparentBrush"))) - .Set(button => - { - button.Height = 32; - button.MinHeight = 32; - button.MinWidth = 0; - button.Padding = new Thickness(8, 0, 8, 0); - button.CornerRadius = controlCornerRadius; - button.IsEnabled = enabled; - button.BorderThickness = new Thickness(0); - Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId( - button, - automationId); - ComposerAutomationVisibility.Prepare(button); - }) - .OnUnmount(control => ComposerAutomationVisibility.Detach( - (FrameworkElement)control)); - } - - var attachmentRows = props.PendingAttachments - .Select(attachment => - (Element)HStack( - 6, - TextBlock(attachment.FileName).FontSize(12), - Button("×", () => props.OnAttachmentRemoved(attachment)) - .SubtleButton() - .AutomationName("Remove attachment"))) - .ToArray(); - var audioLevel = Math.Clamp(props.VoiceAudioLevel, 0f, 1f); - var voiceFeedbackText = string.IsNullOrWhiteSpace(props.VoiceTranscript) - ? Localized("Chat_Voice_ListeningPrompt", "Listening…") - : props.VoiceTranscript; - var waveformBars = Enumerable.Range(0, 8) - .Select(index => - (Element)Border(Empty()) - .Width(2) - .Height(2 + (audioLevel * (index % 3 == 1 ? 10 : 7))) - .CornerRadius(1) - .VAlign(VerticalAlignment.Center) - .Background(Theme.SecondaryText)) - .ToArray(); - Element voiceFeedback = !isRecording - ? Empty() - : Border( - HStack( - 6, - Border(Empty()) - .Width(6) - .Height(6) - .CornerRadius(3) - .Background(Theme.SecondaryText), - TextBlock(voiceFeedbackText) - .FontSize(11) - .Foreground(Theme.SecondaryText), - HStack(1, waveformBars))) - .Padding(8, 4) - .HAlign(HorizontalAlignment.Left); - var queuedRows = props.QueuedMessages - .Select((message, index) => - { - var failed = message.SendState == ChatQueuedMessageSendState.Failed; - var actionKey = failed - ? "Chat_Composer_QueuedMessageRemoveFailed" - : "Chat_Composer_QueuedMessageCancel"; - var actionAutomationKey = failed - ? "Chat_Composer_QueuedMessageRemoveFailedAutomationFormat" - : "Chat_Composer_QueuedMessageCancelAutomationFormat"; - var rowAutomationKey = failed - ? "Chat_Composer_QueuedMessageFailedAutomationFormat" - : "Chat_Composer_QueuedMessageAutomationFormat"; - var action = message.SendState == ChatQueuedMessageSendState.Sending - ? Empty() - : Button(Localized(actionKey, failed ? "Remove failed message" : "Cancel"), - () => props.OnQueuedMessageCancel(message.Id)) - .SubtleButton() - .AutomationId($"{(failed ? "ChatQueuedMessageRemoveFailed" : "ChatQueuedMessageCancel")}_{message.Id}") - .AutomationName(string.Format( - CultureInfo.CurrentCulture, - Localized(actionAutomationKey, "{0}: {1}"), - index + 1, - message.Text)); - var state = failed - ? (Element)TextBlock(Localized("Chat_Composer_QueuedMessageFailed", "Failed")) - .FontSize(12) - : Empty(); - var error = failed && !string.IsNullOrWhiteSpace(message.ErrorText) - ? (Element)TextBlock(message.ErrorText!).FontSize(12) - : Empty(); - return (Element)HStack( - 6, - VStack( - 4, - state, - TextBlock(message.Text).FontSize(12).MaxWidth(260), - error) - .HAlign(HorizontalAlignment.Left), - action) - .AutomationName(string.Format( - CultureInfo.CurrentCulture, - Localized(rowAutomationKey, "{0}"), - message.Text)); - }) - .ToArray(); - var queuedCountText = string.Format( - CultureInfo.CurrentCulture, - Localized("Chat_Composer_QueuedCountFormat", "{0} queued messages"), - queuedRows.Length); - Element queuedPanel = queuedRows.Length == 0 - ? Empty() - : Border( - VStack( - 8, - TextBlock(queuedCountText) - .FontSize(13) - .Set(textBlock => textBlock.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold), - ScrollView(VStack(4, queuedRows)) - .MaxHeight(props.IsCompact ? 144 : 220) - .Set(scrollView => - { - scrollView.VerticalScrollBarVisibility = ScrollingScrollBarVisibility.Auto; - scrollView.HorizontalScrollBarVisibility = ScrollingScrollBarVisibility.Hidden; - scrollView.HorizontalScrollMode = ScrollingScrollMode.Disabled; - scrollView.HorizontalContentAlignment = HorizontalAlignment.Stretch; - }))) - .Set(border => Microsoft.UI.Xaml.Automation.AutomationProperties.SetLiveSetting( - border, - Microsoft.UI.Xaml.Automation.Peers.AutomationLiveSetting.Polite)) - .AutomationName(queuedCountText); - - void DismissSlashMenu() - { - dismissedSlashInputRevision.Current = inputRevision.Current; - setSlashMenuState(ReactorSlashMenuState.Closed); - } - - void CommitSlashText(string value, ReactorSlashMenuState nextState) - { - inputRevision.Current++; - inputText.Current = value; - setText(value); - setSlashMenuState(nextState); - inputControl.Current?.DispatcherQueue?.TryEnqueue(() => - { - if (inputControl.Current is not { } textBox) - return; - - textBox.Focus(FocusState.Programmatic); - var caret = textBox.Text?.Length ?? 0; - textBox.SelectionStart = caret; - textBox.SelectionLength = 0; - }); - } - - var slashPopupVisible = slashDisplay.IsVisible - && (slashDisplay.IsLoading - || (slashDisplay.IsArgsMode && slashDisplay.ArgCommand is not null) - || slashDisplay.Commands.Count > 0); - var popupCatalogKey = props.AvailableCommands is null - ? "missing" - : RuntimeHelpers.GetHashCode(props.AvailableCommands).ToString(CultureInfo.InvariantCulture); - var popupArgumentCommandKey = slashDisplay.ArgCommand?.Name - ?? slashDisplay.ArgCommand?.DisplayName() - ?? string.Empty; - var popupStateKey = string.Join( - "|", - slashPopupVisible, - slashDisplay.IsLoading, - slashDisplay.IsArgsMode, - popupArgumentCommandKey, - slashDisplay.Query, - slashDisplay.SelectedIndex, - slashDisplay.SelectableCount, - popupCatalogKey, - colorScheme); - FrameworkElement? slashPopupContent; - if (!slashPopupVisible) - { - slashPopupContentRef.Current = (string.Empty, null); - slashPopupContent = null; - } - else if (slashPopupContentRef.Current.Key == popupStateKey) - { - slashPopupContent = slashPopupContentRef.Current.Content; - } - else if (slashDisplay.IsLoading) - { - slashPopupContent = CreateSlashPopupHost(BuildSlashHintPopup( - Localized("Chat_Composer_Slash_Loading", "Loading commands..."))); - slashPopupContentRef.Current = (popupStateKey, slashPopupContent); - } - else if (slashDisplay.IsArgsMode && slashDisplay.ArgCommand is { } argCommand) - { - slashPopupContent = CreateSlashPopupHost(BuildSlashArgPopup( - argCommand, - slashDisplay.ArgChoices, - slashDisplay.SelectedIndex, - choice => CommitSlashText( - argCommand.BuildArgInsertionText(choice.Value), - ReactorSlashMenuState.Closed))); - slashPopupContentRef.Current = (popupStateKey, slashPopupContent); - } - else - { - slashPopupContent = CreateSlashPopupHost(BuildSlashPopup( - slashDisplay.Groups, - slashDisplay.SelectedIndex, - slashDisplay.Query, - colorScheme, - command => - { - CommitSlashText( - command.FirstArgChoices().Count > 0 ? command.DisplayName() + " " : command.BuildInsertionText(), - command.FirstArgChoices().Count > 0 - ? new ReactorSlashMenuState(true, string.Empty, 0, true) - : ReactorSlashMenuState.Closed); - })); - slashPopupContentRef.Current = (popupStateKey, slashPopupContent); - } - - var input = TextBox( - text, - SetText, - PlaceholderFor(props.ConnectionState)) - .AutomationId("ChatComposerInput") - .AutomationName(PlaceholderFor(props.ConnectionState)) - .OnKeyDown((sender, args) => - { - if (slashDisplay.IsVisible) - { - switch (args.Key) - { - case global::Windows.System.VirtualKey.Down when slashDisplay.HasSelection: - args.Handled = true; - setSlashMenuState(ReactorSlashCommandController.MoveSelection( - slashMenuState, - slashDisplay, - 1)); - return; - - case global::Windows.System.VirtualKey.Up when slashDisplay.HasSelection: - args.Handled = true; - setSlashMenuState(ReactorSlashCommandController.MoveSelection( - slashMenuState, - slashDisplay, - -1)); - return; - - case global::Windows.System.VirtualKey.Enter: - case global::Windows.System.VirtualKey.Tab: - if (slashDisplay.HasSelection) - { - args.Handled = true; - var commit = ReactorSlashCommandController.CommitSelection(slashDisplay); - if (commit.Accepted) - CommitSlashText(commit.Text, commit.NextState); - return; - } - - if (slashDisplay.IsLoading) - { - args.Handled = true; - if (args.Key == global::Windows.System.VirtualKey.Tab) - DismissSlashMenu(); - return; - } - break; - - case global::Windows.System.VirtualKey.Escape: - args.Handled = true; - DismissSlashMenu(); - return; - } - - if (slashDisplay.IsLoading - && (args.Key == global::Windows.System.VirtualKey.Up - || args.Key == global::Windows.System.VirtualKey.Down)) - { - args.Handled = true; - return; - } - } - - if (args.Key != global::Windows.System.VirtualKey.Enter) - return; - - args.Handled = true; - var shift = Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread( - global::Windows.System.VirtualKey.Shift); - if (shift.HasFlag(global::Windows.UI.Core.CoreVirtualKeyStates.Down) - && sender is Microsoft.UI.Xaml.Controls.TextBox textBox) - { - var current = textBox.Text ?? string.Empty; - var start = Math.Clamp(textBox.SelectionStart, 0, current.Length); - var end = Math.Clamp(start + textBox.SelectionLength, start, current.Length); - SetText(current[..start] + "\n" + current[end..]); - textBox.SelectionStart = start + 1; - textBox.SelectionLength = 0; - return; - } - - Send(); - }) - .TextWrapping(TextWrapping.Wrap) - .Set(control => - { - inputControl.Current = control; - var transparent = new SolidColorBrush(Microsoft.UI.Colors.Transparent); - control.MinHeight = 56; - control.MaxHeight = 200; - control.FontSize = 14; - control.Padding = new Thickness(8); - control.IsEnabled = props.ConnectionState == "connected"; - control.AcceptsReturn = false; - control.BorderThickness = new Thickness(0); - control.BorderBrush = transparent; - control.Background = transparent; - control.Resources["TextControlBorderThemeThickness"] = new Thickness(0); - control.Resources["TextControlBorderThemeThicknessFocused"] = new Thickness(0); - control.Resources["TextControlBackground"] = transparent; - control.Resources["TextControlBackgroundFocused"] = transparent; - control.Resources["TextControlBackgroundPointerOver"] = transparent; - control.Resources["TextControlBorderBrush"] = transparent; - control.Resources["TextControlBorderBrushFocused"] = transparent; - control.Resources["TextControlBorderBrushPointerOver"] = transparent; - ComposerAutomationVisibility.Prepare(control); - }) - .OnMount(control => - { - var textBox = (TextBox)control; - textBox.Paste += pasteHandler.Current; - textBox.ContextFlyout = CreateComposerContextFlyout( - textBox, - () => onAttachmentPasted.Current); - }) - .OnUnmount(control => - { - var textBox = (TextBox)control; - textBox.Paste -= pasteHandler.Current; - textBox.ContextFlyout = null; - ComposerAutomationVisibility.Detach(textBox); - }); - UseEffect((Func)(() => - { - if (inputControl.Current is { } anchor) - DriveSlashPopup(slashPopup, anchor, slashPopupContent, slashPopupVisible); - else - CloseSlashPopup(slashPopup); - return static () => { }; - }), popupStateKey); - - var sessionPicker = MenuFlyout( - PickerButton( - props.CurrentThread.Title, - $"{Localized("Chat_Composer_Accessibility_Session", "Session")}: {props.CurrentThread.Title}", - "ChatComposerSessionPicker", - !props.MessageOptionsDisabled && props.AvailableChannels.Count > 1, - props.IsCompact ? 56 : 160), - props.AvailableChannels - .Select(thread => RadioMenuItem( - thread.Title, - "chat-sessions", - string.Equals(thread.Id, props.CurrentThread.Id, StringComparison.Ordinal), - () => props.OnChannelChanged(thread.Id))) - .ToArray()); - - var modelPickerLabel = modelIndex == 0 - ? Localized("Chat_Composer_Reasoning_Default", "Default") - : selectableModels[modelIndex - 1].DisplayName; - var modelPicker = MenuFlyout( - PickerButton( - modelPickerLabel, - $"{Localized("Chat_Composer_Accessibility_Model", "Model")}: {modelPickerLabel}", - "ChatComposerModelPicker", - !props.MessageOptionsDisabled, - props.IsCompact ? 68 : 180), - modelNames - .Select((modelName, index) => RadioMenuItem( - modelName, - "chat-models", - index == modelIndex, - () => - { - if (index == 0) - props.OnModelCleared(); - else if (index <= selectableModels.Length) - props.OnModelChanged(selectableModels[index - 1].SelectionId); - })) - .ToArray()); - - var reasoningPicker = MenuFlyout( - PickerButton( - ThinkingLevels[thinkingIndex], - $"{Localized("Chat_Composer_Accessibility_Reasoning", "Reasoning")}: {ThinkingLevels[thinkingIndex]}", - "ChatComposerReasoningPicker", - !props.MessageOptionsDisabled, - props.IsCompact ? 54 : 96), - ThinkingLevels - .Select((level, index) => RadioMenuItem( - level, - "chat-thinking-level", - index == thinkingIndex, - () => props.OnThinkingLevelChanged(level))) - .ToArray()); - - var attachButton = IconButton( - "\uE723", - Localized("Chat_Composer_Tooltip_Attach", "Attach"), - () => props.OnAttachClick?.Invoke(), - props.OnAttachClick is not null, - "ChatComposerAttach"); - var voiceButton = IconButton( - isRecording - ? "\uE15B" - : "\uE720", - isRecording - ? Localized("Chat_Composer_Tooltip_Stop", "Stop") - : Localized("Chat_Composer_Tooltip_Voice", "Voice"), - () => - { - if (isRecording) - { - voiceStopOperation.Current = voiceOperation.Current; - voiceCancellation.Current?.Cancel(); - } - else - StartVoiceRecording(); - }, - props.OnVoiceRequest is not null, - "ChatComposerVoice"); - var speakerButton = IconButton( - props.IsSpeakerMuted ? "\uE74F" : "\uE767", - props.IsSpeakerMuted ? "Unmute" : "Mute", - props.OnSpeakerToggle, - automationId: "ChatComposerSpeakerToggle"); - Element settingsButton = props.IsCompact || props.OnSettingsClick is null - ? Empty() - : IconButton( - "\uE713", - Localized("Chat_Composer_Tooltip_Settings", "Settings"), - props.OnSettingsClick, - automationId: "ChatComposerSettings"); - - Element primaryAction = props.TurnActive - ? IconButton( - "\uE71A", - actionLabel, - props.OnStop, - automationId: "ChatComposerPrimaryAction") - : Button( - TextBlock("\uE724").Set(textBlock => - { - textBlock.FontFamily = FluentIconCatalog.SymbolThemeFontFamily; - textBlock.FontSize = 16; - Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( - textBlock, - Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw); - }), - Send) - .AccentButton() - .AutomationName(actionLabel) - .Set(button => - { - button.Width = 32; - button.Height = 32; - button.MinWidth = 32; - button.MinHeight = 32; - button.Padding = new Thickness(0); - button.CornerRadius = controlCornerRadius; - Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId( - button, - "ChatComposerPrimaryAction"); - button.IsEnabled = props.ConnectionState == "connected" - && !isSending - && !slashDisplay.IsLoading - && (!string.IsNullOrWhiteSpace(text) || props.PendingAttachments.Count > 0); - ComposerAutomationVisibility.Prepare(button); - ToolTipService.SetToolTip(button, actionLabel); - }) - .OnUnmount(control => ComposerAutomationVisibility.Detach( - (FrameworkElement)control)); - - var leftToolbar = HStack(8, attachButton, sessionPicker, modelPicker, reasoningPicker) - .HAlign(HorizontalAlignment.Left) - .VAlign(VerticalAlignment.Center); - var rightToolbar = HStack(8, voiceButton, speakerButton, settingsButton, primaryAction) - .HAlign(HorizontalAlignment.Right) - .VAlign(VerticalAlignment.Center); - var toolbar = Grid( - [GridSize.Star(), GridSize.Auto], - [GridSize.Auto], - leftToolbar.Grid(row: 0, column: 0), - rightToolbar.Grid(row: 0, column: 1)); - - var composerChildren = new List(); - if (isRecording) - composerChildren.Add(voiceFeedback); - if (attachmentRows.Length > 0) - composerChildren.Add(VStack(4, attachmentRows)); - if (queuedRows.Length > 0) - composerChildren.Add(queuedPanel); - composerChildren.Add(input); - composerChildren.Add(toolbar); - - return Border( - VStack(8, composerChildren.ToArray()) - .Padding(8, 2, 8, 8)) - .BorderThickness(1) - .CornerRadius(8) - .Margin(12) - .Background(Theme.ControlFill) - .BorderBrush(Theme.ControlStroke) - .HAlign(HorizontalAlignment.Stretch); - } - - private static void CloseSlashPopup(Ref popupRef) - { - if (popupRef.Current is not { } popup) - return; - - popup.IsOpen = false; - if (popup.Child is ReactorHostControl host) - host.Dispose(); - popup.Child = null; - popup.PlacementTarget = null; - } - - private static ReactorHostControl CreateSlashPopupHost(Element content) - { - var host = new ReactorHostControl(); - host.Mount(_ => content); - return host; - } - - private static void DriveSlashPopup( - Ref popupRef, - TextBox anchor, - FrameworkElement? content, - bool visible) - { - var popup = popupRef.Current; - if (popup is null) - { - popup = new Microsoft.UI.Xaml.Controls.Primitives.Popup - { - IsLightDismissEnabled = false, - ShouldConstrainToRootBounds = true, - }; - popupRef.Current = popup; - } - - if (!visible || content is null || anchor.XamlRoot is null) - { - CloseSlashPopup(popupRef); - return; - } - - content.Width = Math.Max(280, anchor.ActualWidth > 0 ? anchor.ActualWidth : 360); - popup.XamlRoot = anchor.XamlRoot; - popup.PlacementTarget = anchor; - popup.DesiredPlacement = Microsoft.UI.Xaml.Controls.Primitives.PopupPlacementMode.Top; - if (popup.Child is ReactorHostControl previousHost - && !ReferenceEquals(previousHost, content)) - previousHost.Dispose(); - popup.Child = content; - popup.IsOpen = true; - } - - private static Element BuildSlashHintPopup(string text) - { - return SlashShell( - TextBlock(text) - .FontSize(12) - .Foreground(Theme.SecondaryText) - .Margin(8, 6, 8, 6)); - } - - private static Element BuildSlashPopup( - IReadOnlyList groups, - int selectedIndex, - string query, - ColorScheme colorScheme, - Action onPick) - { - var rows = new List(); - var index = 0; - foreach (var group in groups) - { - rows.Add(SlashCategoryHeader(CommandCategories.Label(group.Category))); - foreach (var command in group.Commands) - { - rows.Add(SlashRow(command, index == selectedIndex, query, colorScheme, onPick)); - index++; - } - } - - return SlashShell( - ScrollView(VStack(0, rows.ToArray())) - .MaxHeight(280) - .Set(scrollViewer => - { - scrollViewer.VerticalScrollBarVisibility = ScrollingScrollBarVisibility.Auto; - scrollViewer.HorizontalScrollBarVisibility = ScrollingScrollBarVisibility.Hidden; - })); - } - - private static Element SlashCategoryHeader(string text) - { - return TextBlock((text ?? string.Empty).ToUpperInvariant()) - .FontSize(11) - .SemiBold() - .CharacterSpacing(60) - .Foreground(Theme.TertiaryText) - .Margin(8, 8, 8, 2); - } - - private static Element BuildSlashArgPopup( - GatewayCommand command, - IReadOnlyList choices, - int selectedIndex, - Action onPick) - { - var argDescription = command.Args?.FirstOrDefault()?.Description; - var headerText = !string.IsNullOrWhiteSpace(argDescription) - ? $"{command.DisplayName()} {argDescription}" - : !string.IsNullOrWhiteSpace(command.Description) - ? $"{command.DisplayName()} {command.Description}" - : command.DisplayName(); - var rows = new List - { - TextBlock(headerText) - .FontSize(11) - .SemiBold() - .TextTrimming(TextTrimming.CharacterEllipsis) - .MaxLines(1) - .Foreground(Theme.TertiaryText) - .Margin(8, 6, 8, 2), - }; - for (var index = 0; index < choices.Count; index++) - rows.Add(SlashArgRow(command, choices[index], index == selectedIndex, onPick)); - - return SlashShell( - ScrollView(VStack(0, rows.ToArray())) - .MaxHeight(280) - .Set(scrollViewer => - { - scrollViewer.VerticalScrollBarVisibility = ScrollingScrollBarVisibility.Auto; - scrollViewer.HorizontalScrollBarVisibility = ScrollingScrollBarVisibility.Hidden; - })); - } - - private static Element SlashArgRow( - GatewayCommand command, - GatewayCommandArgChoice choice, - bool selected, - Action onPick) - { - var label = string.IsNullOrWhiteSpace(choice.Label) ? choice.Value : choice.Label; - var background = selected ? Theme.SubtleFill : Theme.Ref("SubtleFillColorTransparentBrush"); - return Button( - HStack( - 8, - TextBlock(label) - .FontSize(13) - .SemiBold() - .VAlign(VerticalAlignment.Center) - .Foreground(Theme.PrimaryText), - TextBlock($"{command.DisplayName()} {choice.Value}") - .FontSize(12) - .VAlign(VerticalAlignment.Center) - .TextTrimming(TextTrimming.CharacterEllipsis) - .MaxLines(1) - .Foreground(Theme.SecondaryText)), - () => onPick(choice)) - .Padding(8, 7, 8, 7) - .HAlign(HorizontalAlignment.Stretch) - .CornerRadius(6) - .AutomationName($"Choose {label} for {command.DisplayName()}") - .Resources(resources => resources - .Set("ButtonBackground", background) - .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush"))) - .Set(button => - { - button.HorizontalContentAlignment = HorizontalAlignment.Left; - button.BorderThickness = new Thickness(0); - }) - .OnMount(element => - { - if (selected) - element.StartBringIntoView(new BringIntoViewOptions { AnimationDesired = false }); - }); - } - - private static Element SlashShell(Element child) - { - return Border(child) - .Padding(4) - .CornerRadius(8) - .Background(Theme.Ref("AcrylicBackgroundFillColorDefaultBrush")) - .WithBorder(Theme.Ref("SurfaceStrokeColorFlyoutBrush"), 1) - .Translation(0, 0, 32) - .Set(border => border.Shadow = new ThemeShadow()); - } - - private static Element SlashRow( - GatewayCommand command, - bool selected, - string query, - ColorScheme colorScheme, - Action onPick) - { - var cells = new List - { - TextBlock(SlashGlyph(command)) - .FontFamily(FluentIconCatalog.SymbolThemeFontFamily) - .FontSize(14) - .VAlign(VerticalAlignment.Center) - .Foreground(Theme.SecondaryText) - .AccessibilityView(Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw) - .Grid(row: 0, column: 0), - TextBlock(command.DisplayName()) - .FontSize(13) - .SemiBold() - .VAlign(VerticalAlignment.Center) - .Foreground(Theme.PrimaryText) - .Set(textBlock => ApplyQueryHighlight(textBlock, query, colorScheme)) - .Grid(row: 0, column: 1), - }; - var args = command.ArgTemplate(); - if (!string.IsNullOrWhiteSpace(args)) - { - cells.Add( - TextBlock(args) - .FontSize(12) - .FontFamily("Consolas") - .VAlign(VerticalAlignment.Center) - .Foreground(Theme.SecondaryText) - .Grid(row: 0, column: 2)); - } - - if (!string.IsNullOrWhiteSpace(command.Description)) - { - cells.Add( - TextBlock(command.Description!) - .FontSize(12) - .VAlign(VerticalAlignment.Center) - .HAlign(HorizontalAlignment.Right) - .TextAlignment(TextAlignment.Right) - .TextTrimming(TextTrimming.CharacterEllipsis) - .MaxLines(1) - .Foreground(Theme.SecondaryText) - .Set(textBlock => ApplyQueryHighlight(textBlock, query, colorScheme)) - .Grid(row: 0, column: 3)); - } - - var options = command.OptionCount(); - if (options > 0) - { - cells.Add(SlashBadge($"{options} options").Grid(row: 0, column: 4)); - } - - var background = selected ? Theme.SubtleFill : Theme.Ref("SubtleFillColorTransparentBrush"); - return Button( - Grid( - [GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Star(), GridSize.Auto], - [GridSize.Auto], - cells.ToArray()) - .Set(grid => grid.ColumnSpacing = 8) - .VAlign(VerticalAlignment.Center), - () => onPick(command)) - .Padding(8, 7, 8, 7) - .HAlign(HorizontalAlignment.Stretch) - .CornerRadius(6) - .AutomationName($"Insert {command.DisplayName()}") - .Resources(resources => resources - .Set("ButtonBackground", background) - .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush"))) - .Set(button => - { - button.HorizontalContentAlignment = HorizontalAlignment.Stretch; - button.BorderThickness = new Thickness(0); - }) - .OnMount(element => - { - if (selected) - element.StartBringIntoView(new BringIntoViewOptions { AnimationDesired = false }); - }); - } - - private static Element SlashBadge(string text) - { - return Border( - TextBlock(text) - .FontSize(10) - .SemiBold() - .Foreground(Theme.Ref("TextOnAccentFillColorPrimaryBrush"))) - .Padding(6, 1, 6, 1) - .CornerRadius(4) - .VAlign(VerticalAlignment.Center) - .Background(Theme.AccentSecondary); - } - - private static void ApplyQueryHighlight(TextBlock textBlock, string? query, ColorScheme colorScheme) - { - textBlock.TextHighlighters.Clear(); - var text = textBlock.Text ?? string.Empty; - var normalized = (query ?? string.Empty).Trim().TrimStart('/').Trim(); - if (normalized.Length == 0 || text.Length < normalized.Length || colorScheme == ColorScheme.HighContrast) - return; - - var isDark = colorScheme == ColorScheme.Dark; - if (ThemeRef.Resolve("AccentFillColorDefaultBrush", isDark) is not SolidColorBrush accent - || ThemeRef.Resolve("TextFillColorPrimaryBrush", isDark) is not Brush foreground) - return; - - var accentColor = accent.Color; - var highlighter = new Microsoft.UI.Xaml.Documents.TextHighlighter - { - Background = new SolidColorBrush(global::Windows.UI.Color.FromArgb(31, accentColor.R, accentColor.G, accentColor.B)), - Foreground = foreground, - }; - - for (var index = 0; index <= text.Length - normalized.Length;) - { - var found = text.IndexOf(normalized, index, StringComparison.OrdinalIgnoreCase); - if (found < 0) - break; - highlighter.Ranges.Add(new Microsoft.UI.Xaml.Documents.TextRange - { - StartIndex = found, - Length = normalized.Length, - }); - index = found + normalized.Length; - } - - if (highlighter.Ranges.Count > 0) - textBlock.TextHighlighters.Add(highlighter); - } - - private static string SlashGlyph(GatewayCommand command) - { - var name = (command.NativeName ?? command.DisplayName()).Trim().TrimStart('/').ToLowerInvariant() - .Replace(':', '_') - .Replace('.', '_') - .Replace('-', '_'); - return name switch - { - "help" or "commands" => "\uE82D", - "status" or "usage" => "\uE9D9", - "export" or "export_session" => "\uE896", - "skill" or "fast" => "\uE945", - "model" or "models" or "think" => "\uE713", - "new" => "\uE710", - "reset" or "redirect" => "\uE72C", - "compact" => "\uE9F3", - "stop" => "\uE71A", - "clear" => "\uE74D", - "agents" => "\uE7F4", - "subagents" => "\uE8B7", - "steer" => "\uE724", - "tts" => "\uE767", - _ => "\uE756", - }; - } - - private static async Task SendAsync( - Func, Task> send, - string message, - IReadOnlyList attachments, - int submittedRevision, - Ref inputRevision, - Ref sendInFlight, - Action setText, - Action setIsSending) - { - try - { - if (await send(message, attachments) - && ChatComposerSubmissionPolicy.ShouldClearInput( - submittedRevision, - inputRevision.Current)) - setText(string.Empty); - } - catch (Exception ex) - { - System.Diagnostics.Trace.WriteLine($"[chat] composer send failed: {ex}"); - } - finally - { - sendInFlight.Current = false; - setIsSending(false); - } - } - - private static async Task ReceiveVoiceAsync( - Func> request, - CancellationTokenSource cancellation, - int operation, - Ref voiceOperation, - Ref voiceStopOperation, - Ref voiceCancellation, - Ref mounted, - Action setText, - Action setIsRecording) - { - try - { - var transcript = await request(cancellation.Token, () => setIsRecording(true)); - var stoppedByUser = voiceStopOperation.Current == operation; - if (mounted.Current - && (!cancellation.IsCancellationRequested || stoppedByUser) - && !string.IsNullOrWhiteSpace(transcript)) - setText(transcript); - } - catch (OperationCanceledException) { } - catch (Exception ex) - { - OpenClawTray.Services.Logger.Debug($"Reactor chat composer voice request failed: {ex.Message}"); - } - finally - { - if (ReferenceEquals(voiceCancellation.Current, cancellation)) - voiceCancellation.Current = null; - cancellation.Dispose(); - if (voiceOperation.Current == operation) - setIsRecording(false); - } - } - - private static async Task TryReadImageFromClipboardAsync( - global::Windows.ApplicationModel.DataTransfer.DataPackageView content) - { - var streamRef = await content.GetBitmapAsync(); - using var input = await streamRef.OpenReadAsync(); - var decoder = await global::Windows.Graphics.Imaging.BitmapDecoder.CreateAsync(input); - using var bitmap = await decoder.GetSoftwareBitmapAsync(); - using var output = new global::Windows.Storage.Streams.InMemoryRandomAccessStream(); - var encoder = await global::Windows.Graphics.Imaging.BitmapEncoder.CreateAsync( - global::Windows.Graphics.Imaging.BitmapEncoder.PngEncoderId, - output); - encoder.SetSoftwareBitmap(bitmap); - await encoder.FlushAsync(); - - var size = (long)output.Size; - if (size > ChatAttachment.MaxSizeBytes) - return null; - - output.Seek(0); - var bytes = new byte[size]; - using (var reader = new global::Windows.Storage.Streams.DataReader(output.GetInputStreamAt(0))) - { - await reader.LoadAsync((uint)size); - reader.ReadBytes(bytes); - } - - return new ChatAttachment - { - Type = "image", - MimeType = "image/png", - FileName = $"pasted-image-{DateTime.Now:yyyyMMdd-HHmmss}.png", - Content = Convert.ToBase64String(bytes), - SizeBytes = size, - }; - } - - private static global::Windows.ApplicationModel.DataTransfer.DataPackageView? GetBitmapClipboardContent() - { - try - { - var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent(); - return content is not null - && content.Contains( - global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Bitmap) - ? content - : null; - } - catch (System.Runtime.InteropServices.COMException ex) - { - OpenClawTray.Services.Logger.Debug( - $"Reactor chat composer: clipboard access failed: {ex.Message}"); - return null; - } - } - - private static MenuFlyout CreateComposerContextFlyout( - TextBox textBox, - Func> getOnAttachmentPasted) - { - var undoItem = CreateStandardMenuItem( - Microsoft.UI.Xaml.Input.StandardUICommandKind.Undo, - textBox.Undo); - var redoItem = CreateStandardMenuItem( - Microsoft.UI.Xaml.Input.StandardUICommandKind.Redo, - textBox.Redo); - var cutItem = CreateStandardMenuItem( - Microsoft.UI.Xaml.Input.StandardUICommandKind.Cut, - textBox.CutSelectionToClipboard); - var copyItem = CreateStandardMenuItem( - Microsoft.UI.Xaml.Input.StandardUICommandKind.Copy, - textBox.CopySelectionToClipboard); - var pasteItem = CreateStandardMenuItem( - Microsoft.UI.Xaml.Input.StandardUICommandKind.Paste, - () => - { - if (GetBitmapClipboardContent() is { } clipboardContent) - _ = PasteImageFromClipboardAsync(clipboardContent, getOnAttachmentPasted()); - else - PasteTextFromClipboard(textBox); - }); - var selectAllItem = CreateStandardMenuItem( - Microsoft.UI.Xaml.Input.StandardUICommandKind.SelectAll, - textBox.SelectAll); - Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId( - pasteItem, - "ChatComposerPasteMenuItem"); - - var editSeparator = new MenuFlyoutSeparator(); - var selectAllSeparator = new MenuFlyoutSeparator(); - var menu = new MenuFlyout(); - menu.Items.Add(undoItem); - menu.Items.Add(redoItem); - menu.Items.Add(editSeparator); - menu.Items.Add(cutItem); - menu.Items.Add(copyItem); - menu.Items.Add(pasteItem); - menu.Items.Add(selectAllSeparator); - menu.Items.Add(selectAllItem); - menu.Opening += (_, _) => - { - var state = ChatComposerContextMenuState.Project( - textBox.CanUndo, - textBox.CanRedo, - textBox.SelectionLength > 0, - ClipboardContainsPasteContent(), - !string.IsNullOrEmpty(textBox.Text)); - undoItem.Visibility = ToVisibility(state.ShowUndo); - redoItem.Visibility = ToVisibility(state.ShowRedo); - cutItem.Visibility = ToVisibility(state.ShowCut); - copyItem.Visibility = ToVisibility(state.ShowCopy); - pasteItem.Visibility = ToVisibility(state.ShowPaste); - selectAllItem.Visibility = ToVisibility(state.ShowSelectAll); - editSeparator.Visibility = ToVisibility(state.ShowEditSeparator); - selectAllSeparator.Visibility = ToVisibility(state.ShowSelectAllSeparator); - }; - return menu; - } - - private static Visibility ToVisibility(bool visible) => - visible ? Visibility.Visible : Visibility.Collapsed; - - private static MenuFlyoutItem CreateStandardMenuItem( - Microsoft.UI.Xaml.Input.StandardUICommandKind kind, - Action execute) - { - var command = new Microsoft.UI.Xaml.Input.StandardUICommand(kind); - command.CanExecuteRequested += (_, args) => args.CanExecute = true; - command.ExecuteRequested += (_, _) => execute(); - return new MenuFlyoutItem - { - Command = command, - Visibility = Visibility.Collapsed, - }; - } - - private static bool ClipboardContainsPasteContent() - { - try - { - var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent(); - return content is not null - && (content.Contains( - global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Bitmap) - || content.Contains( - global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text)); - } - catch (System.Runtime.InteropServices.COMException ex) - { - OpenClawTray.Services.Logger.Debug( - $"Reactor chat composer: clipboard access failed: {ex.Message}"); - return false; - } - } - - private static void PasteTextFromClipboard(TextBox textBox) - { - try - { - textBox.PasteFromClipboard(); - } - catch (System.Runtime.InteropServices.COMException ex) - { - OpenClawTray.Services.Logger.Debug( - $"Reactor chat composer: clipboard text paste failed: {ex.Message}"); - } - } - - private static async Task PasteImageFromClipboardAsync( - global::Windows.ApplicationModel.DataTransfer.DataPackageView clipboardContent, - Action onAttachmentPasted) - { - try - { - var attachment = await TryReadImageFromClipboardAsync(clipboardContent); - if (attachment is not null) - onAttachmentPasted(attachment); - } - catch (Exception ex) - { - OpenClawTray.Services.Logger.Debug( - $"Reactor chat composer: clipboard image paste failed: {ex.Message}"); - } - } - - private static string PlaceholderFor(string connectionState) => connectionState switch - { - "connected" => Localized("Chat_Composer_Placeholder_Connected", "Message Assistant (Enter to send)"), - "connecting" => Localized("Chat_Composer_Placeholder_Connecting", "Connecting…"), - "incompatible-gateway" => Localized( - "Chat_Composer_Placeholder_IncompatibleGateway", - "Gateway update required: incompatible version"), - _ => Localized("Chat_Composer_Placeholder_NotConnected", "Not connected"), - }; - - private static string Localized(string key, string fallback) - { - var value = LocalizationHelper.GetString(key); - return string.IsNullOrWhiteSpace(value) || string.Equals(value, key, StringComparison.Ordinal) - ? fallback - : value; - } -} - -internal static class ComposerAutomationVisibility -{ - public static void Prepare(FrameworkElement control) - { - Detach(control); - if (HasUsableLayout(control)) - { - ApplyReadyState(control); - return; - } - - control.IsHitTestVisible = false; - Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( - control, - Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw); - control.Loaded += OnLoaded; - control.SizeChanged += OnSizeChanged; - } - - public static void Detach(FrameworkElement control) - { - control.Loaded -= OnLoaded; - control.SizeChanged -= OnSizeChanged; - } - - private static void OnLoaded(object sender, RoutedEventArgs args) => - TryEnableHitTesting(sender); - - private static void OnSizeChanged(object sender, SizeChangedEventArgs args) => - TryEnableHitTesting(sender); - - private static void TryEnableHitTesting(object sender) - { - if (sender is not FrameworkElement control || !HasUsableLayout(control)) - return; - - ApplyReadyState(control); - } - - private static void ApplyReadyState(FrameworkElement control) - { - Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( - control, - Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Control); - control.IsHitTestVisible = true; - var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer - .FromElement(control) - ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer - .CreatePeerForElement(control); - peer?.RaisePropertyChangedEvent( - Microsoft.UI.Xaml.Automation.AutomationElementIdentifiers.IsOffscreenProperty, - true, - false); - Detach(control); - } - - private static bool HasUsableLayout(FrameworkElement control) => - control.IsLoaded - && control.Visibility == Visibility.Visible - && control.ActualWidth > 0 - && control.ActualHeight > 0; -} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatComposer.cs new file mode 100644 index 000000000..9569ac8ed --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatComposer.cs @@ -0,0 +1,1281 @@ +using Microsoft.UI; +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Hosting; +using Microsoft.UI.Reactor.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Helpers; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using static Microsoft.UI.Reactor.Factories; + +namespace OpenClawTray.Chat; + +/// +/// View-only props for the composer. carries the host-mount +/// and ; +/// lets the root bump its own #1089 scroll-follow +/// token before a send is attempted, exactly as the pre-D2 root did inline. +/// +internal sealed record ReactorChatComposerViewProps( + ChatComposerSession Session, + Action OnSendRequested, + bool IsCompact); + +/// +/// Declarative Reactor view for the composer. It owns control construction, popup/ +/// control references, caret and focus application, keyboard forwarding, automation +/// properties, and theme/high-contrast resource application. It holds no draft/send/ +/// voice/slash workflow state, calls no provider API directly, and performs no +/// lifecycle parsing or attachment security decisions: all of that lives in +/// and , +/// which it reads and calls through . +/// +internal sealed class ReactorChatComposer : Component +{ + private static readonly string[] ThinkingLevels = ["off", "minimal", "low", "medium", "high"]; + + public override Element Render() + { + var props = Props; + var vm = props.Session.ViewModel; + var controller = props.Session.Controller; + var colorScheme = UseColorScheme(); + + // The Reactor view subscribes to the view model exactly once per mount and + // unsubscribes on unmount. This render-invalidation counter is an adapter + // detail: it is not a second copy of composer state, only a re-render token. + var (renderRevision, setRenderRevision) = UseState(vm.RenderRevision, threadSafe: true); + var inputControl = UseRef(null); + var slashPopup = UseRef(null); + var slashPopupContentRef = UseRef<(string Key, FrameworkElement? Content)>((string.Empty, null)); + var controllerRef = UseRef(controller); + controllerRef.Current = controller; + var pasteHandler = UseRef(async (_, args) => + { + if (GetBitmapClipboardContent() is not { } clipboardContent) + return; + + // Paste is a synchronous routed event. Suppress the default text paste + // before awaiting bitmap extraction so a multi-format clipboard cannot + // insert text alongside the image attachment. + args.Handled = true; + await controllerRef.Current.PasteImageAsync(clipboardContent); + }); + + UseEffect((Func)(() => + { + void OnChanged(object? sender, PropertyChangedEventArgs args) => setRenderRevision(vm.RenderRevision); + vm.PropertyChanged += OnChanged; + return () => + { + vm.PropertyChanged -= OnChanged; + CloseSlashPopup(slashPopup); + }; + }), Array.Empty()); + + if (vm.Inputs is not { } inputs) + return Empty(); + + var text = vm.Draft; + var isSending = vm.IsSending; + var isRecording = vm.IsRecording; + var slashDisplay = vm.SlashDisplay; + + void FocusAndPlaceCaretAtEnd() + { + inputControl.Current?.DispatcherQueue?.TryEnqueue(() => + { + if (inputControl.Current is not { } textBox) + return; + + textBox.Focus(FocusState.Programmatic); + var caret = textBox.Text?.Length ?? 0; + textBox.SelectionStart = caret; + textBox.SelectionLength = 0; + }); + } + + void CommitSlash(string value, ReactorSlashMenuState nextState) + { + vm.CommitSlashText(value, nextState); + FocusAndPlaceCaretAtEnd(); + } + + UseEffect((Func)(() => + { + if (vm.ShouldRequestCatalogOnOpen()) + controller.RequestCommandCatalog(); + return static () => { }; + }), slashDisplay.ShouldRequestCatalog); + UseEffect((Func)(() => + { + vm.ReconcileAfterCatalogRefresh(); + return static () => { }; + }), inputs.AvailableCommands); + + void Send() + { + if (!vm.CanSend) + return; + + props.OnSendRequested(); + _ = controller.SendAsync(); + } + + var modelChoices = inputs.ModelChoices is { Count: > 0 } + ? inputs.ModelChoices + : inputs.AvailableModels + .Where(model => !string.IsNullOrWhiteSpace(model)) + .Select(model => new ChatModelChoice(model, model)) + .ToArray(); + var selectableModels = modelChoices.Where(model => model.IsSelectable).ToArray(); + var modelNames = new[] { Localized("Chat_Composer_Reasoning_Default", "Default") } + .Concat(selectableModels.Select(ChatModelLabels.BuildMenuLabel)) + .ToArray(); + var modelIndex = string.IsNullOrWhiteSpace(inputs.CurrentThread.Model) + ? 0 + : Math.Max(0, Array.FindIndex( + selectableModels, + model => model.MatchesModel(inputs.CurrentThread.Model, inputs.CurrentThread.ModelProvider)) + 1); + var thinkingIndex = Math.Max(0, Array.IndexOf( + ThinkingLevels, + inputs.CurrentThread.ThinkingLevel ?? "medium")); + var actionLabel = inputs.TurnActive + ? Localized("Chat_Composer_Tooltip_Stop", "Stop") + : Localized("Chat_Composer_Tooltip_Send", "Send"); + var controlCornerRadius = new CornerRadius(4); + + Element IconButton( + string glyph, + string automationName, + Action onClick, + bool enabled = true, + string? automationId = null) + { + return Button( + TextBlock(glyph).Set(textBlock => + { + textBlock.FontFamily = FluentIconCatalog.SymbolThemeFontFamily; + textBlock.FontSize = 16; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( + textBlock, + Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw); + }), + onClick) + .AutomationName(automationName) + .Foreground(Theme.SecondaryText) + .Resources(resources => resources + .Set("ButtonBackground", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBackgroundPointerOver", Theme.SubtleFill) + .Set("ButtonBackgroundPressed", Theme.Ref("SubtleFillColorTertiaryBrush")) + .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBorderBrushPointerOver", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBorderBrushPressed", Theme.Ref("SubtleFillColorTransparentBrush"))) + .Set(button => + { + button.Width = 32; + button.Height = 32; + button.MinWidth = 32; + button.MinHeight = 32; + button.Padding = new Thickness(0); + button.CornerRadius = controlCornerRadius; + button.IsEnabled = enabled; + button.BorderThickness = new Thickness(0); + if (!string.IsNullOrWhiteSpace(automationId)) + { + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId( + button, + automationId); + } + ComposerAutomationVisibility.Prepare(button); + ToolTipService.SetToolTip(button, automationName); + }) + .OnUnmount(control => ComposerAutomationVisibility.Detach( + (FrameworkElement)control)); + } + + Element PickerButton( + string label, + string automationName, + string automationId, + bool enabled, + double maxLabelWidth) + { + return Button( + HStack( + 4, + TextBlock(label).Set(textBlock => + { + textBlock.FontSize = 13; + textBlock.MaxWidth = maxLabelWidth; + textBlock.TextTrimming = TextTrimming.CharacterEllipsis; + textBlock.TextWrapping = TextWrapping.NoWrap; + }), + TextBlock("\uE70D").Set(textBlock => + { + textBlock.FontFamily = FluentIconCatalog.SymbolThemeFontFamily; + textBlock.FontSize = 10; + textBlock.Margin = new Thickness(2, 4, 0, 0); + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( + textBlock, + Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw); + })), + () => { }) + .AutomationName(automationName) + .Foreground(Theme.SecondaryText) + .Resources(resources => resources + .Set("ButtonBackground", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBackgroundPointerOver", Theme.SubtleFill) + .Set("ButtonBackgroundPressed", Theme.Ref("SubtleFillColorTertiaryBrush")) + .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBorderBrushPointerOver", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBorderBrushPressed", Theme.Ref("SubtleFillColorTransparentBrush"))) + .Set(button => + { + button.Height = 32; + button.MinHeight = 32; + button.MinWidth = 0; + button.Padding = new Thickness(8, 0, 8, 0); + button.CornerRadius = controlCornerRadius; + button.IsEnabled = enabled; + button.BorderThickness = new Thickness(0); + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId( + button, + automationId); + ComposerAutomationVisibility.Prepare(button); + }) + .OnUnmount(control => ComposerAutomationVisibility.Detach( + (FrameworkElement)control)); + } + + var attachmentRows = vm.PendingAttachments + .Select(attachment => + (Element)HStack( + 6, + TextBlock(attachment.FileName).FontSize(12), + Button("×", () => controller.RemoveAttachment(attachment)) + .SubtleButton() + .AutomationName("Remove attachment"))) + .ToArray(); + var audioLevel = Math.Clamp(vm.VoiceAudioLevel, 0f, 1f); + var voiceFeedbackText = string.IsNullOrWhiteSpace(vm.VoiceTranscript) + ? Localized("Chat_Voice_ListeningPrompt", "Listening…") + : vm.VoiceTranscript; + var waveformBars = Enumerable.Range(0, 8) + .Select(index => + (Element)Border(Empty()) + .Width(2) + .Height(2 + (audioLevel * (index % 3 == 1 ? 10 : 7))) + .CornerRadius(1) + .VAlign(VerticalAlignment.Center) + .Background(Theme.SecondaryText)) + .ToArray(); + Element voiceFeedback = !isRecording + ? Empty() + : Border( + HStack( + 6, + Border(Empty()) + .Width(6) + .Height(6) + .CornerRadius(3) + .Background(Theme.SecondaryText), + TextBlock(voiceFeedbackText) + .FontSize(11) + .Foreground(Theme.SecondaryText), + HStack(1, waveformBars))) + .Padding(8, 4) + .HAlign(HorizontalAlignment.Left); + var queuedRows = inputs.QueuedMessages + .Select((message, index) => + { + var failed = message.SendState == ChatQueuedMessageSendState.Failed; + var actionKey = failed + ? "Chat_Composer_QueuedMessageRemoveFailed" + : "Chat_Composer_QueuedMessageCancel"; + var actionAutomationKey = failed + ? "Chat_Composer_QueuedMessageRemoveFailedAutomationFormat" + : "Chat_Composer_QueuedMessageCancelAutomationFormat"; + var rowAutomationKey = failed + ? "Chat_Composer_QueuedMessageFailedAutomationFormat" + : "Chat_Composer_QueuedMessageAutomationFormat"; + var action = message.SendState == ChatQueuedMessageSendState.Sending + ? Empty() + : Button(Localized(actionKey, failed ? "Remove failed message" : "Cancel"), + () => controller.CancelQueuedMessage(message.Id)) + .SubtleButton() + .AutomationId($"{(failed ? "ChatQueuedMessageRemoveFailed" : "ChatQueuedMessageCancel")}_{message.Id}") + .AutomationName(string.Format( + CultureInfo.CurrentCulture, + Localized(actionAutomationKey, "{0}: {1}"), + index + 1, + message.Text)); + var state = failed + ? (Element)TextBlock(Localized("Chat_Composer_QueuedMessageFailed", "Failed")) + .FontSize(12) + : Empty(); + var error = failed && !string.IsNullOrWhiteSpace(message.ErrorText) + ? (Element)TextBlock(message.ErrorText!).FontSize(12) + : Empty(); + return (Element)HStack( + 6, + VStack( + 4, + state, + TextBlock(message.Text).FontSize(12).MaxWidth(260), + error) + .HAlign(HorizontalAlignment.Left), + action) + .AutomationName(string.Format( + CultureInfo.CurrentCulture, + Localized(rowAutomationKey, "{0}"), + message.Text)); + }) + .ToArray(); + var queuedCountText = string.Format( + CultureInfo.CurrentCulture, + Localized("Chat_Composer_QueuedCountFormat", "{0} queued messages"), + queuedRows.Length); + Element queuedPanel = queuedRows.Length == 0 + ? Empty() + : Border( + VStack( + 8, + TextBlock(queuedCountText) + .FontSize(13) + .Set(textBlock => textBlock.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold), + ScrollView(VStack(4, queuedRows)) + .MaxHeight(props.IsCompact ? 144 : 220) + .Set(scrollView => + { + scrollView.VerticalScrollBarVisibility = ScrollingScrollBarVisibility.Auto; + scrollView.HorizontalScrollBarVisibility = ScrollingScrollBarVisibility.Hidden; + scrollView.HorizontalScrollMode = ScrollingScrollMode.Disabled; + scrollView.HorizontalContentAlignment = HorizontalAlignment.Stretch; + }))) + .Set(border => Microsoft.UI.Xaml.Automation.AutomationProperties.SetLiveSetting( + border, + Microsoft.UI.Xaml.Automation.Peers.AutomationLiveSetting.Polite)) + .AutomationName(queuedCountText); + + var slashPopupVisible = slashDisplay.IsVisible + && (slashDisplay.IsLoading + || (slashDisplay.IsArgsMode && slashDisplay.ArgCommand is not null) + || slashDisplay.Commands.Count > 0); + var popupCatalogKey = inputs.AvailableCommands is null + ? "missing" + : RuntimeHelpers.GetHashCode(inputs.AvailableCommands).ToString(CultureInfo.InvariantCulture); + var popupArgumentCommandKey = slashDisplay.ArgCommand?.Name + ?? slashDisplay.ArgCommand?.DisplayName() + ?? string.Empty; + var popupStateKey = string.Join( + "|", + slashPopupVisible, + slashDisplay.IsLoading, + slashDisplay.IsArgsMode, + popupArgumentCommandKey, + slashDisplay.Query, + slashDisplay.SelectedIndex, + slashDisplay.SelectableCount, + popupCatalogKey, + colorScheme); + FrameworkElement? slashPopupContent; + if (!slashPopupVisible) + { + slashPopupContentRef.Current = (string.Empty, null); + slashPopupContent = null; + } + else if (slashPopupContentRef.Current.Key == popupStateKey) + { + slashPopupContent = slashPopupContentRef.Current.Content; + } + else if (slashDisplay.IsLoading) + { + slashPopupContent = CreateSlashPopupHost(BuildSlashHintPopup( + Localized("Chat_Composer_Slash_Loading", "Loading commands..."))); + slashPopupContentRef.Current = (popupStateKey, slashPopupContent); + } + else if (slashDisplay.IsArgsMode && slashDisplay.ArgCommand is { } argCommand) + { + slashPopupContent = CreateSlashPopupHost(BuildSlashArgPopup( + argCommand, + slashDisplay.ArgChoices, + slashDisplay.SelectedIndex, + choice => CommitSlash( + argCommand.BuildArgInsertionText(choice.Value), + ReactorSlashMenuState.Closed))); + slashPopupContentRef.Current = (popupStateKey, slashPopupContent); + } + else + { + slashPopupContent = CreateSlashPopupHost(BuildSlashPopup( + slashDisplay.Groups, + slashDisplay.SelectedIndex, + slashDisplay.Query, + colorScheme, + command => + { + CommitSlash( + command.FirstArgChoices().Count > 0 ? command.DisplayName() + " " : command.BuildInsertionText(), + command.FirstArgChoices().Count > 0 + ? new ReactorSlashMenuState(true, string.Empty, 0, true) + : ReactorSlashMenuState.Closed); + })); + slashPopupContentRef.Current = (popupStateKey, slashPopupContent); + } + + var input = TextBox( + text, + vm.SetDraft, + PlaceholderFor(inputs.ConnectionState)) + .AutomationId("ChatComposerInput") + .AutomationName(PlaceholderFor(inputs.ConnectionState)) + .OnKeyDown((sender, args) => + { + if (slashDisplay.IsVisible) + { + switch (args.Key) + { + case global::Windows.System.VirtualKey.Down when slashDisplay.HasSelection: + args.Handled = true; + vm.MoveSlashSelection(1); + return; + + case global::Windows.System.VirtualKey.Up when slashDisplay.HasSelection: + args.Handled = true; + vm.MoveSlashSelection(-1); + return; + + case global::Windows.System.VirtualKey.Enter: + case global::Windows.System.VirtualKey.Tab: + if (slashDisplay.HasSelection) + { + args.Handled = true; + var commit = vm.CommitSelectedSlashItem(); + if (commit.Accepted) + FocusAndPlaceCaretAtEnd(); + return; + } + + if (slashDisplay.IsLoading) + { + args.Handled = true; + if (args.Key == global::Windows.System.VirtualKey.Tab) + vm.DismissSlashMenu(); + return; + } + break; + + case global::Windows.System.VirtualKey.Escape: + args.Handled = true; + vm.DismissSlashMenu(); + return; + } + + if (slashDisplay.IsLoading + && (args.Key == global::Windows.System.VirtualKey.Up + || args.Key == global::Windows.System.VirtualKey.Down)) + { + args.Handled = true; + return; + } + } + + if (args.Key != global::Windows.System.VirtualKey.Enter) + return; + + args.Handled = true; + var shift = Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread( + global::Windows.System.VirtualKey.Shift); + if (shift.HasFlag(global::Windows.UI.Core.CoreVirtualKeyStates.Down) + && sender is Microsoft.UI.Xaml.Controls.TextBox textBox) + { + var current = textBox.Text ?? string.Empty; + var start = Math.Clamp(textBox.SelectionStart, 0, current.Length); + var end = Math.Clamp(start + textBox.SelectionLength, start, current.Length); + vm.SetDraft(current[..start] + "\n" + current[end..]); + textBox.SelectionStart = start + 1; + textBox.SelectionLength = 0; + return; + } + + Send(); + }) + .TextWrapping(TextWrapping.Wrap) + .Set(control => + { + inputControl.Current = control; + var transparent = new SolidColorBrush(Microsoft.UI.Colors.Transparent); + control.MinHeight = 56; + control.MaxHeight = 200; + control.FontSize = 14; + control.Padding = new Thickness(8); + control.IsEnabled = inputs.ConnectionState == "connected"; + control.AcceptsReturn = false; + control.BorderThickness = new Thickness(0); + control.BorderBrush = transparent; + control.Background = transparent; + control.Resources["TextControlBorderThemeThickness"] = new Thickness(0); + control.Resources["TextControlBorderThemeThicknessFocused"] = new Thickness(0); + control.Resources["TextControlBackground"] = transparent; + control.Resources["TextControlBackgroundFocused"] = transparent; + control.Resources["TextControlBackgroundPointerOver"] = transparent; + control.Resources["TextControlBorderBrush"] = transparent; + control.Resources["TextControlBorderBrushFocused"] = transparent; + control.Resources["TextControlBorderBrushPointerOver"] = transparent; + ComposerAutomationVisibility.Prepare(control); + }) + .OnMount(control => + { + var textBox = (TextBox)control; + textBox.Paste += pasteHandler.Current; + textBox.ContextFlyout = CreateComposerContextFlyout( + textBox, + () => controllerRef.Current); + }) + .OnUnmount(control => + { + var textBox = (TextBox)control; + textBox.Paste -= pasteHandler.Current; + textBox.ContextFlyout = null; + ComposerAutomationVisibility.Detach(textBox); + }); + UseEffect((Func)(() => + { + if (inputControl.Current is { } anchor) + DriveSlashPopup(slashPopup, anchor, slashPopupContent, slashPopupVisible); + else + CloseSlashPopup(slashPopup); + return static () => { }; + }), popupStateKey); + + var sessionPicker = MenuFlyout( + PickerButton( + inputs.CurrentThread.Title, + $"{Localized("Chat_Composer_Accessibility_Session", "Session")}: {inputs.CurrentThread.Title}", + "ChatComposerSessionPicker", + !inputs.MessageOptionsDisabled && inputs.AvailableChannels.Count > 1, + props.IsCompact ? 56 : 160), + inputs.AvailableChannels + .Select(thread => RadioMenuItem( + thread.Title, + "chat-sessions", + string.Equals(thread.Id, inputs.CurrentThread.Id, StringComparison.Ordinal), + () => controller.SelectChannel(thread.Id))) + .ToArray()); + + var modelPickerLabel = modelIndex == 0 + ? Localized("Chat_Composer_Reasoning_Default", "Default") + : selectableModels[modelIndex - 1].DisplayName; + var modelPicker = MenuFlyout( + PickerButton( + modelPickerLabel, + $"{Localized("Chat_Composer_Accessibility_Model", "Model")}: {modelPickerLabel}", + "ChatComposerModelPicker", + !inputs.MessageOptionsDisabled, + props.IsCompact ? 68 : 180), + modelNames + .Select((modelName, index) => RadioMenuItem( + modelName, + "chat-models", + index == modelIndex, + () => + { + if (index == 0) + controller.ClearModel(); + else if (index <= selectableModels.Length) + controller.SetModel(selectableModels[index - 1].SelectionId); + })) + .ToArray()); + + var reasoningPicker = MenuFlyout( + PickerButton( + ThinkingLevels[thinkingIndex], + $"{Localized("Chat_Composer_Accessibility_Reasoning", "Reasoning")}: {ThinkingLevels[thinkingIndex]}", + "ChatComposerReasoningPicker", + !inputs.MessageOptionsDisabled, + props.IsCompact ? 54 : 96), + ThinkingLevels + .Select((level, index) => RadioMenuItem( + level, + "chat-thinking-level", + index == thinkingIndex, + () => controller.SetThinkingLevel(level))) + .ToArray()); + + var attachButton = IconButton( + "\uE723", + Localized("Chat_Composer_Tooltip_Attach", "Attach"), + () => props.Session.HostActions.AttachmentPickerRequest?.Invoke(), + props.Session.HostActions.AttachmentPickerRequest is not null, + "ChatComposerAttach"); + var voiceButton = IconButton( + isRecording + ? "\uE15B" + : "\uE720", + isRecording + ? Localized("Chat_Composer_Tooltip_Stop", "Stop") + : Localized("Chat_Composer_Tooltip_Voice", "Voice"), + () => + { + if (isRecording) + controller.StopVoiceRecording(); + else + controller.StartVoiceRecording(); + }, + props.Session.HostActions.VoiceCaptureRequest is not null, + "ChatComposerVoice"); + var speakerButton = IconButton( + vm.IsSpeakerMuted ? "\uE74F" : "\uE767", + vm.IsSpeakerMuted ? "Unmute" : "Mute", + controller.ToggleSpeakerMuted, + automationId: "ChatComposerSpeakerToggle"); + Element settingsButton = props.IsCompact || props.Session.HostActions.SettingsNavigation is null + ? Empty() + : IconButton( + "\uE713", + Localized("Chat_Composer_Tooltip_Settings", "Settings"), + props.Session.HostActions.SettingsNavigation, + automationId: "ChatComposerSettings"); + + Element primaryAction = inputs.TurnActive + ? IconButton( + "\uE71A", + actionLabel, + controller.Stop, + automationId: "ChatComposerPrimaryAction") + : Button( + TextBlock("\uE724").Set(textBlock => + { + textBlock.FontFamily = FluentIconCatalog.SymbolThemeFontFamily; + textBlock.FontSize = 16; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( + textBlock, + Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw); + }), + Send) + .AccentButton() + .AutomationName(actionLabel) + .Set(button => + { + button.Width = 32; + button.Height = 32; + button.MinWidth = 32; + button.MinHeight = 32; + button.Padding = new Thickness(0); + button.CornerRadius = controlCornerRadius; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId( + button, + "ChatComposerPrimaryAction"); + button.IsEnabled = vm.CanSend; + ComposerAutomationVisibility.Prepare(button); + ToolTipService.SetToolTip(button, actionLabel); + }) + .OnUnmount(control => ComposerAutomationVisibility.Detach( + (FrameworkElement)control)); + + var leftToolbar = HStack(8, attachButton, sessionPicker, modelPicker, reasoningPicker) + .HAlign(HorizontalAlignment.Left) + .VAlign(VerticalAlignment.Center); + var rightToolbar = HStack(8, voiceButton, speakerButton, settingsButton, primaryAction) + .HAlign(HorizontalAlignment.Right) + .VAlign(VerticalAlignment.Center); + var toolbar = Grid( + [GridSize.Star(), GridSize.Auto], + [GridSize.Auto], + leftToolbar.Grid(row: 0, column: 0), + rightToolbar.Grid(row: 0, column: 1)); + + var composerChildren = new List(); + if (isRecording) + composerChildren.Add(voiceFeedback); + if (attachmentRows.Length > 0) + composerChildren.Add(VStack(4, attachmentRows)); + if (queuedRows.Length > 0) + composerChildren.Add(queuedPanel); + composerChildren.Add(input); + composerChildren.Add(toolbar); + + return Border( + VStack(8, composerChildren.ToArray()) + .Padding(8, 2, 8, 8)) + .BorderThickness(1) + .CornerRadius(8) + .Margin(12) + .Background(Theme.ControlFill) + .BorderBrush(Theme.ControlStroke) + .HAlign(HorizontalAlignment.Stretch); + } + + private static void CloseSlashPopup(Ref popupRef) + { + if (popupRef.Current is not { } popup) + return; + + popup.IsOpen = false; + if (popup.Child is ReactorHostControl host) + host.Dispose(); + popup.Child = null; + popup.PlacementTarget = null; + } + + private static ReactorHostControl CreateSlashPopupHost(Element content) + { + var host = new ReactorHostControl(); + host.Mount(_ => content); + return host; + } + + private static void DriveSlashPopup( + Ref popupRef, + TextBox anchor, + FrameworkElement? content, + bool visible) + { + var popup = popupRef.Current; + if (popup is null) + { + popup = new Microsoft.UI.Xaml.Controls.Primitives.Popup + { + IsLightDismissEnabled = false, + ShouldConstrainToRootBounds = true, + }; + popupRef.Current = popup; + } + + if (!visible || content is null || anchor.XamlRoot is null) + { + CloseSlashPopup(popupRef); + return; + } + + content.Width = Math.Max(280, anchor.ActualWidth > 0 ? anchor.ActualWidth : 360); + popup.XamlRoot = anchor.XamlRoot; + popup.PlacementTarget = anchor; + popup.DesiredPlacement = Microsoft.UI.Xaml.Controls.Primitives.PopupPlacementMode.Top; + if (popup.Child is ReactorHostControl previousHost + && !ReferenceEquals(previousHost, content)) + previousHost.Dispose(); + popup.Child = content; + popup.IsOpen = true; + } + + private static Element BuildSlashHintPopup(string text) + { + return SlashShell( + TextBlock(text) + .FontSize(12) + .Foreground(Theme.SecondaryText) + .Margin(8, 6, 8, 6)); + } + + private static Element BuildSlashPopup( + IReadOnlyList groups, + int selectedIndex, + string query, + ColorScheme colorScheme, + Action onPick) + { + var rows = new List(); + var index = 0; + foreach (var group in groups) + { + rows.Add(SlashCategoryHeader(CommandCategories.Label(group.Category))); + foreach (var command in group.Commands) + { + rows.Add(SlashRow(command, index == selectedIndex, query, colorScheme, onPick)); + index++; + } + } + + return SlashShell( + ScrollView(VStack(0, rows.ToArray())) + .MaxHeight(280) + .Set(scrollViewer => + { + scrollViewer.VerticalScrollBarVisibility = ScrollingScrollBarVisibility.Auto; + scrollViewer.HorizontalScrollBarVisibility = ScrollingScrollBarVisibility.Hidden; + })); + } + + private static Element SlashCategoryHeader(string text) + { + return TextBlock((text ?? string.Empty).ToUpperInvariant()) + .FontSize(11) + .SemiBold() + .CharacterSpacing(60) + .Foreground(Theme.TertiaryText) + .Margin(8, 8, 8, 2); + } + + private static Element BuildSlashArgPopup( + GatewayCommand command, + IReadOnlyList choices, + int selectedIndex, + Action onPick) + { + var argDescription = command.Args?.FirstOrDefault()?.Description; + var headerText = !string.IsNullOrWhiteSpace(argDescription) + ? $"{command.DisplayName()} {argDescription}" + : !string.IsNullOrWhiteSpace(command.Description) + ? $"{command.DisplayName()} {command.Description}" + : command.DisplayName(); + var rows = new List + { + TextBlock(headerText) + .FontSize(11) + .SemiBold() + .TextTrimming(TextTrimming.CharacterEllipsis) + .MaxLines(1) + .Foreground(Theme.TertiaryText) + .Margin(8, 6, 8, 2), + }; + for (var index = 0; index < choices.Count; index++) + rows.Add(SlashArgRow(command, choices[index], index == selectedIndex, onPick)); + + return SlashShell( + ScrollView(VStack(0, rows.ToArray())) + .MaxHeight(280) + .Set(scrollViewer => + { + scrollViewer.VerticalScrollBarVisibility = ScrollingScrollBarVisibility.Auto; + scrollViewer.HorizontalScrollBarVisibility = ScrollingScrollBarVisibility.Hidden; + })); + } + + private static Element SlashArgRow( + GatewayCommand command, + GatewayCommandArgChoice choice, + bool selected, + Action onPick) + { + var label = string.IsNullOrWhiteSpace(choice.Label) ? choice.Value : choice.Label; + var background = selected ? Theme.SubtleFill : Theme.Ref("SubtleFillColorTransparentBrush"); + return Button( + HStack( + 8, + TextBlock(label) + .FontSize(13) + .SemiBold() + .VAlign(VerticalAlignment.Center) + .Foreground(Theme.PrimaryText), + TextBlock($"{command.DisplayName()} {choice.Value}") + .FontSize(12) + .VAlign(VerticalAlignment.Center) + .TextTrimming(TextTrimming.CharacterEllipsis) + .MaxLines(1) + .Foreground(Theme.SecondaryText)), + () => onPick(choice)) + .Padding(8, 7, 8, 7) + .HAlign(HorizontalAlignment.Stretch) + .CornerRadius(6) + .AutomationName($"Choose {label} for {command.DisplayName()}") + .Resources(resources => resources + .Set("ButtonBackground", background) + .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush"))) + .Set(button => + { + button.HorizontalContentAlignment = HorizontalAlignment.Left; + button.BorderThickness = new Thickness(0); + }) + .OnMount(element => + { + if (selected) + element.StartBringIntoView(new BringIntoViewOptions { AnimationDesired = false }); + }); + } + + private static Element SlashShell(Element child) + { + return Border(child) + .Padding(4) + .CornerRadius(8) + .Background(Theme.Ref("AcrylicBackgroundFillColorDefaultBrush")) + .WithBorder(Theme.Ref("SurfaceStrokeColorFlyoutBrush"), 1) + .Translation(0, 0, 32) + .Set(border => border.Shadow = new ThemeShadow()); + } + + private static Element SlashRow( + GatewayCommand command, + bool selected, + string query, + ColorScheme colorScheme, + Action onPick) + { + var cells = new List + { + TextBlock(SlashGlyph(command)) + .FontFamily(FluentIconCatalog.SymbolThemeFontFamily) + .FontSize(14) + .VAlign(VerticalAlignment.Center) + .Foreground(Theme.SecondaryText) + .AccessibilityView(Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw) + .Grid(row: 0, column: 0), + TextBlock(command.DisplayName()) + .FontSize(13) + .SemiBold() + .VAlign(VerticalAlignment.Center) + .Foreground(Theme.PrimaryText) + .Set(textBlock => ApplyQueryHighlight(textBlock, query, colorScheme)) + .Grid(row: 0, column: 1), + }; + var args = command.ArgTemplate(); + if (!string.IsNullOrWhiteSpace(args)) + { + cells.Add( + TextBlock(args) + .FontSize(12) + .FontFamily("Consolas") + .VAlign(VerticalAlignment.Center) + .Foreground(Theme.SecondaryText) + .Grid(row: 0, column: 2)); + } + + if (!string.IsNullOrWhiteSpace(command.Description)) + { + cells.Add( + TextBlock(command.Description!) + .FontSize(12) + .VAlign(VerticalAlignment.Center) + .HAlign(HorizontalAlignment.Right) + .TextAlignment(TextAlignment.Right) + .TextTrimming(TextTrimming.CharacterEllipsis) + .MaxLines(1) + .Foreground(Theme.SecondaryText) + .Set(textBlock => ApplyQueryHighlight(textBlock, query, colorScheme)) + .Grid(row: 0, column: 3)); + } + + var options = command.OptionCount(); + if (options > 0) + { + cells.Add(SlashBadge($"{options} options").Grid(row: 0, column: 4)); + } + + var background = selected ? Theme.SubtleFill : Theme.Ref("SubtleFillColorTransparentBrush"); + return Button( + Grid( + [GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Star(), GridSize.Auto], + [GridSize.Auto], + cells.ToArray()) + .Set(grid => grid.ColumnSpacing = 8) + .VAlign(VerticalAlignment.Center), + () => onPick(command)) + .Padding(8, 7, 8, 7) + .HAlign(HorizontalAlignment.Stretch) + .CornerRadius(6) + .AutomationName($"Insert {command.DisplayName()}") + .Resources(resources => resources + .Set("ButtonBackground", background) + .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush"))) + .Set(button => + { + button.HorizontalContentAlignment = HorizontalAlignment.Stretch; + button.BorderThickness = new Thickness(0); + }) + .OnMount(element => + { + if (selected) + element.StartBringIntoView(new BringIntoViewOptions { AnimationDesired = false }); + }); + } + + private static Element SlashBadge(string text) + { + return Border( + TextBlock(text) + .FontSize(10) + .SemiBold() + .Foreground(Theme.Ref("TextOnAccentFillColorPrimaryBrush"))) + .Padding(6, 1, 6, 1) + .CornerRadius(4) + .VAlign(VerticalAlignment.Center) + .Background(Theme.AccentSecondary); + } + + private static void ApplyQueryHighlight(TextBlock textBlock, string? query, ColorScheme colorScheme) + { + textBlock.TextHighlighters.Clear(); + var text = textBlock.Text ?? string.Empty; + var normalized = (query ?? string.Empty).Trim().TrimStart('/').Trim(); + if (normalized.Length == 0 || text.Length < normalized.Length || colorScheme == ColorScheme.HighContrast) + return; + + var isDark = colorScheme == ColorScheme.Dark; + if (ThemeRef.Resolve("AccentFillColorDefaultBrush", isDark) is not SolidColorBrush accent + || ThemeRef.Resolve("TextFillColorPrimaryBrush", isDark) is not Brush foreground) + return; + + var accentColor = accent.Color; + var highlighter = new Microsoft.UI.Xaml.Documents.TextHighlighter + { + Background = new SolidColorBrush(global::Windows.UI.Color.FromArgb(31, accentColor.R, accentColor.G, accentColor.B)), + Foreground = foreground, + }; + + for (var index = 0; index <= text.Length - normalized.Length;) + { + var found = text.IndexOf(normalized, index, StringComparison.OrdinalIgnoreCase); + if (found < 0) + break; + highlighter.Ranges.Add(new Microsoft.UI.Xaml.Documents.TextRange + { + StartIndex = found, + Length = normalized.Length, + }); + index = found + normalized.Length; + } + + if (highlighter.Ranges.Count > 0) + textBlock.TextHighlighters.Add(highlighter); + } + + private static string SlashGlyph(GatewayCommand command) + { + var name = (command.NativeName ?? command.DisplayName()).Trim().TrimStart('/').ToLowerInvariant() + .Replace(':', '_') + .Replace('.', '_') + .Replace('-', '_'); + return name switch + { + "help" or "commands" => "\uE82D", + "status" or "usage" => "\uE9D9", + "export" or "export_session" => "\uE896", + "skill" or "fast" => "\uE945", + "model" or "models" or "think" => "\uE713", + "new" => "\uE710", + "reset" or "redirect" => "\uE72C", + "compact" => "\uE9F3", + "stop" => "\uE71A", + "clear" => "\uE74D", + "agents" => "\uE7F4", + "subagents" => "\uE8B7", + "steer" => "\uE724", + "tts" => "\uE767", + _ => "\uE756", + }; + } + + private static global::Windows.ApplicationModel.DataTransfer.DataPackageView? GetBitmapClipboardContent() + { + try + { + var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent(); + return content is not null + && content.Contains( + global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Bitmap) + ? content + : null; + } + catch (System.Runtime.InteropServices.COMException ex) + { + OpenClawTray.Services.Logger.Debug( + $"Reactor chat composer: clipboard access failed: {ex.Message}"); + return null; + } + } + + private static MenuFlyout CreateComposerContextFlyout( + TextBox textBox, + Func getController) + { + var undoItem = CreateStandardMenuItem( + Microsoft.UI.Xaml.Input.StandardUICommandKind.Undo, + textBox.Undo); + var redoItem = CreateStandardMenuItem( + Microsoft.UI.Xaml.Input.StandardUICommandKind.Redo, + textBox.Redo); + var cutItem = CreateStandardMenuItem( + Microsoft.UI.Xaml.Input.StandardUICommandKind.Cut, + textBox.CutSelectionToClipboard); + var copyItem = CreateStandardMenuItem( + Microsoft.UI.Xaml.Input.StandardUICommandKind.Copy, + textBox.CopySelectionToClipboard); + var pasteItem = CreateStandardMenuItem( + Microsoft.UI.Xaml.Input.StandardUICommandKind.Paste, + () => + { + if (GetBitmapClipboardContent() is { } clipboardContent) + _ = getController().PasteImageAsync(clipboardContent); + else + PasteTextFromClipboard(textBox); + }); + var selectAllItem = CreateStandardMenuItem( + Microsoft.UI.Xaml.Input.StandardUICommandKind.SelectAll, + textBox.SelectAll); + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId( + pasteItem, + "ChatComposerPasteMenuItem"); + + var editSeparator = new MenuFlyoutSeparator(); + var selectAllSeparator = new MenuFlyoutSeparator(); + var menu = new MenuFlyout(); + menu.Items.Add(undoItem); + menu.Items.Add(redoItem); + menu.Items.Add(editSeparator); + menu.Items.Add(cutItem); + menu.Items.Add(copyItem); + menu.Items.Add(pasteItem); + menu.Items.Add(selectAllSeparator); + menu.Items.Add(selectAllItem); + menu.Opening += (_, _) => + { + var state = ChatComposerContextMenuState.Project( + textBox.CanUndo, + textBox.CanRedo, + textBox.SelectionLength > 0, + ClipboardContainsPasteContent(), + !string.IsNullOrEmpty(textBox.Text)); + undoItem.Visibility = ToVisibility(state.ShowUndo); + redoItem.Visibility = ToVisibility(state.ShowRedo); + cutItem.Visibility = ToVisibility(state.ShowCut); + copyItem.Visibility = ToVisibility(state.ShowCopy); + pasteItem.Visibility = ToVisibility(state.ShowPaste); + selectAllItem.Visibility = ToVisibility(state.ShowSelectAll); + editSeparator.Visibility = ToVisibility(state.ShowEditSeparator); + selectAllSeparator.Visibility = ToVisibility(state.ShowSelectAllSeparator); + }; + return menu; + } + + private static Visibility ToVisibility(bool visible) => + visible ? Visibility.Visible : Visibility.Collapsed; + + private static MenuFlyoutItem CreateStandardMenuItem( + Microsoft.UI.Xaml.Input.StandardUICommandKind kind, + Action execute) + { + var command = new Microsoft.UI.Xaml.Input.StandardUICommand(kind); + command.CanExecuteRequested += (_, args) => args.CanExecute = true; + command.ExecuteRequested += (_, _) => execute(); + return new MenuFlyoutItem + { + Command = command, + Visibility = Visibility.Collapsed, + }; + } + + private static bool ClipboardContainsPasteContent() + { + try + { + var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent(); + return content is not null + && (content.Contains( + global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Bitmap) + || content.Contains( + global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text)); + } + catch (System.Runtime.InteropServices.COMException ex) + { + OpenClawTray.Services.Logger.Debug( + $"Reactor chat composer: clipboard access failed: {ex.Message}"); + return false; + } + } + + private static void PasteTextFromClipboard(TextBox textBox) + { + try + { + textBox.PasteFromClipboard(); + } + catch (System.Runtime.InteropServices.COMException ex) + { + OpenClawTray.Services.Logger.Debug( + $"Reactor chat composer: clipboard text paste failed: {ex.Message}"); + } + } + + private static string PlaceholderFor(string connectionState) => connectionState switch + { + "connected" => Localized("Chat_Composer_Placeholder_Connected", "Message Assistant (Enter to send)"), + "connecting" => Localized("Chat_Composer_Placeholder_Connecting", "Connecting…"), + "incompatible-gateway" => Localized( + "Chat_Composer_Placeholder_IncompatibleGateway", + "Gateway update required: incompatible version"), + _ => Localized("Chat_Composer_Placeholder_NotConnected", "Not connected"), + }; + + private static string Localized(string key, string fallback) + { + var value = LocalizationHelper.GetString(key); + return string.IsNullOrWhiteSpace(value) || string.Equals(value, key, StringComparison.Ordinal) + ? fallback + : value; + } +} + +internal static class ComposerAutomationVisibility +{ + public static void Prepare(FrameworkElement control) + { + Detach(control); + if (HasUsableLayout(control)) + { + ApplyReadyState(control); + return; + } + + control.IsHitTestVisible = false; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( + control, + Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw); + control.Loaded += OnLoaded; + control.SizeChanged += OnSizeChanged; + } + + public static void Detach(FrameworkElement control) + { + control.Loaded -= OnLoaded; + control.SizeChanged -= OnSizeChanged; + } + + private static void OnLoaded(object sender, RoutedEventArgs args) => + TryEnableHitTesting(sender); + + private static void OnSizeChanged(object sender, SizeChangedEventArgs args) => + TryEnableHitTesting(sender); + + private static void TryEnableHitTesting(object sender) + { + if (sender is not FrameworkElement control || !HasUsableLayout(control)) + return; + + ApplyReadyState(control); + } + + private static void ApplyReadyState(FrameworkElement control) + { + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( + control, + Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Control); + control.IsHitTestVisible = true; + var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer + .FromElement(control) + ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer + .CreatePeerForElement(control); + peer?.RaisePropertyChangedEvent( + Microsoft.UI.Xaml.Automation.AutomationElementIdentifiers.IsOffscreenProperty, + true, + false); + Detach(control); + } + + private static bool HasUsableLayout(FrameworkElement control) => + control.IsLoaded + && control.Visibility == Visibility.Visible + && control.ActualWidth > 0 + && control.ActualHeight > 0; +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatHostExtensions.cs b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatHostExtensions.cs index 07f19b97c..4d55e0554 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatHostExtensions.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatHostExtensions.cs @@ -26,23 +26,26 @@ public static Action AsPost(this DispatcherQueue dispatcher) => System.Diagnostics.Debug.WriteLine("Dropped chat UI update because DispatcherQueue rejected the work item."); }; - public static MountedReactorChat MountReactorChat( - this Window window, + /// Builds the reset-confirmation dialog closure (centralized here so + /// and do not each + /// duplicate it) and creates one from the + /// resolved . Callers pass the returned session + /// into . and + /// stay internal — this helper, not a + /// public factory parameter on MountReactorChat, is their only call site + /// outside this file. + internal static ChatComposerSession CreateComposerSession( Border target, + IChatComposerFactory composerFactory, IChatDataProvider provider, - string? initialThreadId = null, - Func? onReadAloud = null, - Action? onStopSpeaking = null, - Func>? onVoiceRequest = null, - Action? onAttachClick = null, - Action? onSettingsClick = null, - Action? onOpenCheckpoints = null, - Action? onSpeakerMuteChanged = null, - bool initialMuted = false, - bool isCompact = false) + Func>? onVoiceRequest, + Action? onAttachClick, + Action? onSettingsClick, + Action? onSpeakerMuteChanged, + bool initialMuted) { - ArgumentNullException.ThrowIfNull(window); ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(composerFactory); ArgumentNullException.ThrowIfNull(provider); async Task ConfirmResetAsync(string sessionKey, string? displayName) @@ -72,38 +75,72 @@ async Task ConfirmResetAsync(string sessionKey, string? displayName) return await dialog.ShowAsync() == ContentDialogResult.Primary; } - var callbacks = new ReactorChatHostCallbacks(); + var hostActions = new ChatComposerHostActions( + ConfirmResetAsync, + onAttachClick, + onVoiceRequest, + onSettingsClick, + onSpeakerMuteChanged); + return composerFactory.Create(provider, hostActions, initialMuted); + } + + public static MountedReactorChat MountReactorChat( + this Window window, + Border target, + IChatDataProvider provider, + ChatComposerSession composerSession, + string? initialThreadId = null, + Func? onReadAloud = null, + Action? onStopSpeaking = null, + Action? onOpenCheckpoints = null, + bool isCompact = false) + { + ArgumentNullException.ThrowIfNull(window); + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(composerSession); + + // External attachment/voice/mute ingress binds directly to the session, once, + // instead of being reassigned by the Reactor tree on every render. + var callbacks = new ReactorChatHostCallbacks + { + AttachFiles = attachments => composerSession.Controller.AddAttachments(attachments), + SetVoiceTranscript = text => composerSession.ViewModel.SetVoiceTranscript(text), + SetVoiceAudioLevel = level => composerSession.ViewModel.SetVoiceAudioLevel(level), + TriggerVoiceRecording = () => composerSession.Controller.StartVoiceRecording(), + SetSpeakerMuted = muted => composerSession.ViewModel.SetSpeakerMuted(muted), + }; + var props = new OpenClawReactorChatRootProps( provider, - callbacks, + composerSession, initialThreadId, onReadAloud, onStopSpeaking, - onVoiceRequest, - onAttachClick, - onSettingsClick, onOpenCheckpoints, - onSpeakerMuteChanged, - ConfirmResetAsync, - initialMuted, isCompact); var host = new ReactorHostControl(); host.Mount(_ => Component(props)); target.Child = host; VisualTestCapture.ScheduleSignalCapture(target); - return new MountedReactorChat(target, host, callbacks); + return new MountedReactorChat(target, host, callbacks, composerSession); } } /// /// Imperative host handle used by the page and compact window for attachment -/// and voice input that originates outside the declarative chat tree. +/// and voice input that originates outside the declarative chat tree. Owns the one +/// created for this mount and disposes it exactly +/// once, alongside the Reactor host. /// public sealed class MountedReactorChat( Border target, ReactorHostControl host, - ReactorChatHostCallbacks callbacks) : IDisposable + ReactorChatHostCallbacks callbacks, + ChatComposerSession session) : IDisposable { + private int _disposed; + public void AttachFile(ChatAttachment attachment) => AttachFiles(new[] { attachment }); public void AttachFiles(IReadOnlyList attachments) => @@ -123,8 +160,17 @@ public void TriggerVoiceRecording() => public void SetSpeakerMuted(bool muted) => callbacks.SetSpeakerMuted?.Invoke(muted); + /// First-wins/idempotent: only the first call performs teardown + /// (session disposal, callback clearing, host disposal, target detach); every + /// later call — concurrent or sequential — is a no-op. This matters because + /// is not itself guaranteed idempotent, + /// so a repeated external Dispose() call must never reach it twice. public void Dispose() { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + session.Dispose(); callbacks.Clear(); host.Dispose(); if (ReferenceEquals(target.Child, host)) diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs index a47756423..bb41e2539 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.Web.WebView2.Core; +using Microsoft.Extensions.DependencyInjection; using OpenClaw.Chat; using OpenClaw.Shared; using OpenClaw.Shared.Capabilities; @@ -239,6 +240,16 @@ private void ShowReactorSurface() var app = App.Current as App; var provider = ResolveChatProvider(app); + // A missing IChatComposerFactory registration is a composition bug, not a + // "disconnected" state: once the DI container exists, require it so the + // failure surfaces loudly through the existing app-level unhandled-exception + // handler/crash log rather than being silently treated as "no provider yet". + // Only an as-yet-uninitialized container (a normal startup race, same timing + // window during which the provider itself is also legitimately absent) is + // still tolerated here. + var composerFactory = app?.Services is { } services + ? services.GetRequiredService() + : null; Func? readAloud = app is null ? null : ReadChatTextAloudAsync; // Consume a pending session-key hand-off from SessionsPage or a @@ -274,7 +285,7 @@ private void ShowReactorSurface() DisposeReactorHost(); - if (provider is null) + if (provider is null || composerFactory is null) { // If we already have a mounted chat, keep it visible rather than // flashing the disconnected placeholder. The ChatProviderChanged @@ -290,18 +301,23 @@ private void ShowReactorSurface() PlaceholderPanel.Visibility = Visibility.Collapsed; ChatHost.Visibility = Visibility.Visible; - _reactorHost = CurrentApp.ActiveHubWindow!.MountReactorChat( + var composerSession = ReactorChatHostExtensions.CreateComposerSession( ChatHost, + composerFactory, provider, - initialThreadId: threadIdToMount, - onReadAloud: readAloud, - onStopSpeaking: () => app?.StopChatSpeaking(), onVoiceRequest: VoiceTranscribeAsync, onAttachClick: OnAttachClicked, onSettingsClick: () => _hub?.NavigateTo("voice"), - onOpenCheckpoints: OpenSessionCheckpoints, onSpeakerMuteChanged: muted => _ = OnSpeakerMuteChangedAsync(muted), initialMuted: ShouldStartSpeakerMuted(CurrentApp.Settings)); + _reactorHost = CurrentApp.ActiveHubWindow!.MountReactorChat( + ChatHost, + provider, + composerSession, + initialThreadId: threadIdToMount, + onReadAloud: readAloud, + onStopSpeaking: () => app?.StopChatSpeaking(), + onOpenCheckpoints: OpenSessionCheckpoints); _mountedProvider = provider; _mountedThreadId = threadIdToMount; UpdateNativeChatSurfaceActive(); diff --git a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs index 07db0d61d..0df20d95d 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs @@ -1,5 +1,6 @@ using OpenClaw.Shared.ExecApprovals; using Microsoft.Extensions.DependencyInjection; +using OpenClawTray.Chat; using OpenClawTray.Services; namespace OpenClawTray.Presentation; @@ -43,6 +44,10 @@ public static IServiceCollection AddOpenClawTrayCore(this IServiceCollection ser // Container-owned navigation lifetime manager (disposed with the root provider). services.AddSingleton(); + // Stateless per-host-mount composer session factory. Depends only on the + // App-owned dispatcher instance above; starts no background work. + services.AddSingleton(); + // Transient page view models resolved per navigation scope. services.AddTransient(); services.AddTransient(); diff --git a/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs index da4b3d0b0..d35473121 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs @@ -1,4 +1,5 @@ using OpenClaw.Chat; +using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Composition.SystemBackdrops; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -405,6 +406,16 @@ private void TryMountReactorChat() { var app = App.Current as App; var provider = app?.ChatProvider; + // A missing IChatComposerFactory registration is a composition bug, not a + // "disconnected" state: once the DI container exists, require it so the + // failure surfaces loudly through the existing app-level unhandled-exception + // handler/crash log rather than being silently treated as "no provider yet". + // Only an as-yet-uninitialized container (a normal startup race, same timing + // window during which the provider itself is also legitimately absent) is + // still tolerated here. + var composerFactory = app?.Services is { } services + ? services.GetRequiredService() + : null; Func? readAloud = app is null ? null : ReadChatTextAloudAsync; if (_reactorHost is not null && ReferenceEquals(_mountedProvider, provider)) @@ -417,7 +428,7 @@ private void TryMountReactorChat() DisposeReactorHost(); - if (provider is null) + if (provider is null || composerFactory is null) { PlaceholderPanel.Visibility = Visibility.Visible; ChatHost.Visibility = Visibility.Collapsed; @@ -428,17 +439,22 @@ private void TryMountReactorChat() PlaceholderPanel.Visibility = Visibility.Collapsed; ChatHost.Visibility = Visibility.Visible; var appInstance = App.Current as App; - _reactorHost = ((Window)this).MountReactorChat( + var composerSession = ReactorChatHostExtensions.CreateComposerSession( ChatHost, + composerFactory, provider, - onReadAloud: readAloud, - onStopSpeaking: () => appInstance?.StopChatSpeaking(), onVoiceRequest: VoiceTranscribeAsync, onAttachClick: OnAttachClicked, onSettingsClick: () => appInstance?.ShowHub("voice"), - onOpenCheckpoints: OpenSessionCheckpoints, onSpeakerMuteChanged: muted => _ = OnSpeakerMuteChangedAsync(muted), - initialMuted: ShouldStartSpeakerMuted(appInstance?.Settings), + initialMuted: ShouldStartSpeakerMuted(appInstance?.Settings)); + _reactorHost = ((Window)this).MountReactorChat( + ChatHost, + provider, + composerSession, + onReadAloud: readAloud, + onStopSpeaking: () => appInstance?.StopChatSpeaking(), + onOpenCheckpoints: OpenSessionCheckpoints, isCompact: true); _mountedProvider = provider; UpdateNativeChatSurfaceActive(); diff --git a/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs b/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs new file mode 100644 index 000000000..a5833ba73 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs @@ -0,0 +1,1154 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClaw.Tray.Tests.Presentation; +using OpenClawTray.Chat; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Tray.Tests; + +/// +/// Characterization tests for : local admission, +/// exact provider delegation, delayed-send draft/attachment survival, lifecycle +/// command routing (including attachments bypassing lifecycle parsing), reset +/// confirmation gating, queue/model/thinking/catalog delegation, and exactly-once +/// disposal with fenced late completions. +/// +public sealed class ChatComposerControllerTests +{ + private static ChatThread MakeThread(string id = "session-1") => + new() + { + Id = id, + Title = "Test Session", + Status = ChatThreadStatus.Running, + Activity = ChatActivity.Idle, + }; + + private static ChatComposerInputs MakeInputs(long revision = 1, ChatThread? thread = null, string connectionState = "connected") => + new( + revision, + connectionState, + false, + thread ?? MakeThread(), + System.Array.Empty(), + System.Array.Empty(), + null, + false, + System.Array.Empty(), + null, + false); + + private static (ChatComposerViewModel Vm, ChatComposerController Controller, FakeChatComposerRuntimePort Port, ChatComposerHostActions HostActions) + MakeController(ChatComposerHostActions? hostActions = null, RecordingUiDispatcher? dispatcher = null) + { + var vm = new ChatComposerViewModel(dispatcher ?? new RecordingUiDispatcher(), initialSpeakerMuted: false); + vm.ApplyInputs(MakeInputs()); + var port = new FakeChatComposerRuntimePort(); + var actions = hostActions ?? new ChatComposerHostActions(null, null, null, null, null); + var controller = new ChatComposerController(vm, port, actions); + return (vm, controller, port, actions); + } + + [Fact] + public async Task SendAsync_EmptySubmitIsBlocked() + { + var (_, controller, port, _) = MakeController(); + + var accepted = await controller.SendAsync(); + + Assert.False(accepted); + Assert.Equal(0, port.SendMessageCallCount); + } + + [Fact] + public async Task SendAsync_DisconnectedIsBlocked() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + vm.ApplyInputs(MakeInputs(connectionState: "disconnected")); + vm.SetDraft("hello"); + var port = new FakeChatComposerRuntimePort(); + var controller = new ChatComposerController(vm, port, new ChatComposerHostActions(null, null, null, null, null)); + + var accepted = await controller.SendAsync(); + + Assert.False(accepted); + Assert.Equal(0, port.SendMessageCallCount); + } + + [Fact] + public async Task SendAsync_OrdinaryMessage_CallsProviderExactlyOnceAndClearsDraft() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("hello world"); + + var accepted = await controller.SendAsync(); + + Assert.True(accepted); + Assert.Equal(1, port.SendMessageCallCount); + Assert.Equal(("session-1", "hello world", (IReadOnlyList)System.Array.Empty()), port.LastSendMessageCall); + Assert.Equal(string.Empty, vm.Draft); + } + + [Fact] + public async Task SendAsync_AttachmentOnlySubmission_SendsEmptyMessageAndClearsAttachment() + { + var (vm, controller, port, _) = MakeController(); + var attachment = new ChatAttachment { FileName = "a.png" }; + vm.AddAttachments(new[] { attachment }); + + var accepted = await controller.SendAsync(); + + Assert.True(accepted); + Assert.Equal(1, port.SendMessageCallCount); + Assert.Empty(vm.PendingAttachments); + } + + [Fact] + public async Task SendAsync_EditDuringDelayedSend_DoesNotClearTheEditedDraft() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("original"); + port.SendMessageGate = new TaskCompletionSource(); + + var sendTask = controller.SendAsync(); + Assert.Equal(1, port.SendMessageCallCount); + + // User keeps typing while the send is still in flight. + vm.SetDraft("original edited further"); + + port.SendMessageGate.SetResult(true); + var accepted = await sendTask; + + Assert.True(accepted); + Assert.Equal("original edited further", vm.Draft); + } + + [Fact] + public async Task SendAsync_AttachmentAddedWhileInFlight_SurvivesAcceptedSend() + { + var (vm, controller, port, _) = MakeController(); + var submitted = new ChatAttachment { FileName = "submitted.png" }; + vm.AddAttachments(new[] { submitted }); + port.SendMessageGate = new TaskCompletionSource(); + + var sendTask = controller.SendAsync(); + var addedLater = new ChatAttachment { FileName = "added-later.png" }; + controller.AddAttachment(addedLater); + + port.SendMessageGate.SetResult(true); + await sendTask; + + Assert.Single(vm.PendingAttachments); + Assert.Same(addedLater, vm.PendingAttachments[0]); + } + + [Fact] + public async Task SendAsync_RejectedSend_PreservesDraftAndAttachments() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("keep me"); + var attachment = new ChatAttachment { FileName = "keep.png" }; + vm.AddAttachments(new[] { attachment }); + port.SendMessageGate = new TaskCompletionSource(); + port.SendMessageGate.SetResult(false); + + var accepted = await controller.SendAsync(); + + Assert.False(accepted); + Assert.Equal("keep me", vm.Draft); + Assert.Single(vm.PendingAttachments); + } + + [Fact] + public async Task SendAsync_OneOperationAtATime_SecondCallWhileSendingIsBlocked() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("first"); + port.SendMessageGate = new TaskCompletionSource(); + + var firstSend = controller.SendAsync(); + var secondAccepted = await controller.SendAsync(); + + Assert.False(secondAccepted); + Assert.Equal(1, port.SendMessageCallCount); + + port.SendMessageGate.SetResult(true); + await firstSend; + } + + [Fact] + public async Task SendAsync_TwiceBeforeVmDrain_WithHeldDispatcher_OnlyOnePortInvocationThenAllowedAfterCompletion() + { + // Reproduces the exact gap the controller-owned interlocked send gate + // closes: with a dispatcher that does not currently have thread access + // and holds enqueued work (simulating an async-dispatched UI thread), + // ChatComposerViewModel.SetSending(true)'s Mutate() call is only + // *queued*, not yet applied — so IsSending cannot be relied upon to + // block a second concurrent SendAsync() call issued before that queued + // mutation drains. The controller must gate single-flight send with its + // own state (_sendGate), independent of the VM's rendered/projected + // IsSending value. + var dispatcher = new RecordingUiDispatcher { HasThreadAccess = false, RunEnqueuedImmediately = false }; + var (vm, controller, port, _) = MakeController(dispatcher: dispatcher); + vm.SetDraft("first"); + dispatcher.FlushPending(); // apply the draft so SendAsync's synchronous admission checks see it. + port.SendMessageGate = new TaskCompletionSource(); + + var firstSend = controller.SendAsync(); + + // The VM's SetSending(true) mutation is only queued on the held + // dispatcher right now, not yet applied — proving this is exactly the + // scenario where rendered VM state cannot be trusted as the + // single-flight guard. + Assert.False(vm.IsSending); + + var secondAccepted = await controller.SendAsync(); + + Assert.False(secondAccepted); + Assert.Equal(1, port.SendMessageCallCount); + + port.SendMessageGate.SetResult(true); + var firstAccepted = await firstSend; + dispatcher.FlushPending(); + + Assert.True(firstAccepted); + + // After the first send has fully completed (the gate is released in + // SendAsync's finally block), a further send must be allowed again. + port.SendMessageGate = new TaskCompletionSource(); + vm.SetDraft("second"); + dispatcher.FlushPending(); + + var thirdSend = controller.SendAsync(); + port.SendMessageGate.SetResult(true); + var thirdAccepted = await thirdSend; + + Assert.True(thirdAccepted); + Assert.Equal(2, port.SendMessageCallCount); + } + + [Fact] + public void Disposed_FieldIsDeclaredVolatile() + { + // The operation-registration gate protects final admission checks, while + // public entry points deliberately retain cheap lock-free rejects. Those + // reads rely on volatile visibility. + var field = typeof(ChatComposerController).GetField( + "_disposed", + BindingFlags.NonPublic | BindingFlags.Instance); + + Assert.NotNull(field); + Assert.Contains(typeof(IsVolatile), field!.GetRequiredCustomModifiers()); + } + + [Fact] + public async Task SendAsync_NewCommand_HandsCanonicalSessionKeyToBoundSelection() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("/new"); + port.ExecuteLifecycleGate = new TaskCompletionSource(); + port.ExecuteLifecycleGate.SetResult(new ChatLifecycleCommandResult( + ChatLifecycleCommandKind.New, + Succeeded: true, + NewSessionKey: "new-session-key")); + string? handedOff = null; + controller.BindSelectionHandoff(key => handedOff = key); + + var accepted = await controller.SendAsync(); + + Assert.True(accepted); + Assert.Equal(1, port.ExecuteLifecycleCallCount); + Assert.Equal(ChatLifecycleCommandKind.New, port.LastLifecycleCall!.Value.Command); + Assert.Equal("new-session-key", handedOff); + Assert.Equal(0, port.SendMessageCallCount); + } + + [Fact] + public async Task SendAsync_Compact_UsesQueuePathNotLifecycleExecute() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("/compact"); + + var accepted = await controller.SendAsync(); + + Assert.True(accepted); + Assert.Equal(1, port.EnqueueCompactCallCount); + Assert.Equal(0, port.ExecuteLifecycleCallCount); + Assert.Equal(0, port.SendMessageCallCount); + } + + [Fact] + public async Task SendAsync_Compact_DisposedWhileEnqueueInFlight_DoesNotReportAccepted() + { + // /compact must recheck disposal/generation after its await too, exactly + // like the ordinary-send and lifecycle-execute paths: an enqueue that + // resolves true after the controller was disposed must not be reported (or + // treated) as an accepted send outcome. + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("/compact"); + port.EnqueueCompactGate = new TaskCompletionSource(); + + var sendTask = controller.SendAsync(); + Assert.Equal(1, port.EnqueueCompactCallCount); + controller.Dispose(); + port.EnqueueCompactGate.SetResult(true); + var accepted = await sendTask; + + Assert.False(accepted); + } + + [Fact] + public async Task AllPublicEntryPoints_NoOpAfterDispose_WithZeroProviderHostOrViewModelCalls() + { + var voiceRequestCalls = 0; + var confirmResetCalls = 0; + var speakerMuteCalls = 0; + var actions = new ChatComposerHostActions( + ConfirmResetAsync: (_, _) => { confirmResetCalls++; return Task.FromResult(true); }, + AttachmentPickerRequest: null, + VoiceCaptureRequest: (_, _) => { voiceRequestCalls++; return Task.FromResult("x"); }, + SettingsNavigation: null, + SpeakerMuteChanged: _ => speakerMuteCalls++); + var (vm, controller, port, _) = MakeController(actions); + var handoffCalls = 0; + controller.BindSelectionHandoff(_ => handoffCalls++); + + controller.Dispose(); + Assert.True(controller.IsDisposed); + + // Re-bind after dispose must also no-op (BindSelectionHandoff itself checks + // disposed before touching the field). + var rebindHandoffCalls = 0; + controller.BindSelectionHandoff(_ => rebindHandoffCalls++); + + controller.SelectChannel("some-thread"); + controller.Stop(); + controller.CancelQueuedMessage("q1"); + controller.SetModel("model-x"); + controller.ClearModel(); + controller.SetThinkingLevel("high"); + controller.RequestCommandCatalog(); + controller.AddAttachment(new ChatAttachment { FileName = "a.png" }); + controller.AddAttachments(new[] { new ChatAttachment { FileName = "b.png" } }); + controller.RemoveAttachment(new ChatAttachment { FileName = "c.png" }); + controller.ToggleSpeakerMuted(); + controller.StartVoiceRecording(); + controller.StopVoiceRecording(); + var sendAccepted = await controller.SendAsync(); + var sendCoreAccepted = await controller + .SendCoreAsync("thread", "Title", "hello", Array.Empty()); + + // Zero calls reached the port, the host actions, or mutated the view model. + Assert.Equal(0, port.SendMessageCallCount); + Assert.Equal(0, port.EnqueueCompactCallCount); + Assert.Equal(0, port.ExecuteLifecycleCallCount); + Assert.Equal(0, port.StopCallCount); + Assert.Equal(0, port.CancelQueuedCallCount); + Assert.Equal(0, port.SetModelCallCount); + Assert.Equal(0, port.ClearModelCallCount); + Assert.Equal(0, port.SetThinkingLevelCallCount); + Assert.Equal(0, port.EnsureCommandCatalogCallCount); + Assert.Equal(0, voiceRequestCalls); + Assert.Equal(0, confirmResetCalls); + Assert.Equal(0, speakerMuteCalls); + Assert.Equal(0, handoffCalls); + Assert.Equal(0, rebindHandoffCalls); + Assert.Empty(vm.PendingAttachments); + Assert.False(vm.IsSpeakerMuted); + Assert.False(vm.IsRecording); + Assert.False(sendAccepted); + Assert.False(sendCoreAccepted); + } + + [Fact] + public async Task SendAsync_Reset_ConfirmationDeclined_DoesNotExecute() + { + var actions = new ChatComposerHostActions( + ConfirmResetAsync: (_, _) => Task.FromResult(false), + null, null, null, null); + var (vm, controller, port, _) = MakeController(actions); + vm.SetDraft("/reset"); + + var accepted = await controller.SendAsync(); + + Assert.False(accepted); + Assert.Equal(0, port.ExecuteLifecycleCallCount); + } + + [Fact] + public async Task SendAsync_Reset_ConfirmationAccepted_Executes() + { + var actions = new ChatComposerHostActions( + ConfirmResetAsync: (_, _) => Task.FromResult(true), + null, null, null, null); + var (vm, controller, port, _) = MakeController(actions); + vm.SetDraft("/reset"); + port.ExecuteLifecycleGate = new TaskCompletionSource(); + port.ExecuteLifecycleGate.SetResult(new ChatLifecycleCommandResult(ChatLifecycleCommandKind.Reset, Succeeded: true)); + + var accepted = await controller.SendAsync(); + + Assert.True(accepted); + Assert.Equal(ChatLifecycleCommandKind.Reset, port.LastLifecycleCall!.Value.Command); + } + + [Fact] + public async Task SendAsync_Reset_ConfirmationFault_ReturnsFalseAndRestoresSendingState() + { + var actions = new ChatComposerHostActions( + ConfirmResetAsync: (_, _) => Task.FromException( + new InvalidOperationException("dialog failed")), + null, null, null, null); + var (vm, controller, port, _) = MakeController(actions); + vm.SetDraft("/reset"); + + var accepted = await controller.SendAsync(); + + Assert.False(accepted); + Assert.False(vm.IsSending); + Assert.Equal("/reset", vm.Draft); + Assert.Equal(0, port.ExecuteLifecycleCallCount); + } + + [Fact] + public async Task SendAsync_LifecycleFault_ReturnsFalseAndRestoresSendingState() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("/new"); + port.ExecuteLifecycleGate = new TaskCompletionSource(); + port.ExecuteLifecycleGate.SetException(new InvalidOperationException("lifecycle failed")); + + var accepted = await controller.SendAsync(); + + Assert.False(accepted); + Assert.False(vm.IsSending); + Assert.Equal("/new", vm.Draft); + Assert.Equal(1, port.ExecuteLifecycleCallCount); + } + + [Fact] + public async Task SendAsync_Reset_DisposedWhileConfirmationDialogOpen_DoesNotExecute() + { + // Guards against a controller disposed (e.g. provider replaced/host torn + // down) while the user is still looking at the reset confirmation dialog: + // the confirmation resuming with "true" must not still execute the + // destructive reset command against a disposed controller/port. + var confirmGate = new TaskCompletionSource(); + var actions = new ChatComposerHostActions( + ConfirmResetAsync: (_, _) => confirmGate.Task, + null, null, null, null); + var (vm, controller, port, _) = MakeController(actions); + vm.SetDraft("/reset"); + + var sendTask = controller.SendAsync(); + controller.Dispose(); + confirmGate.SetResult(true); + var accepted = await sendTask; + + Assert.False(accepted); + Assert.Equal(0, port.ExecuteLifecycleCallCount); + } + + [Fact] + public async Task SendAsync_New_DisposedBeforeLifecycleExecuteCompletes_DoesNotInvokeSelectionHandoff() + { + // Guards against a "/new" whose ExecuteLifecycleCommandAsync resolves after + // dispose: the stale session-key handoff must not fire into a torn-down root. + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("/new"); + port.ExecuteLifecycleGate = new TaskCompletionSource(); + string? handedOff = null; + controller.BindSelectionHandoff(key => handedOff = key); + + var sendTask = controller.SendAsync(); + controller.Dispose(); + port.ExecuteLifecycleGate.SetResult(new ChatLifecycleCommandResult( + ChatLifecycleCommandKind.New, + Succeeded: true, + NewSessionKey: "new-session-key")); + var accepted = await sendTask; + + Assert.False(accepted); + Assert.Null(handedOff); + } + + [Fact] + public async Task SendAsync_AttachmentsBypassLifecycleParsing() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("/new"); + vm.AddAttachments(new[] { new ChatAttachment { FileName = "a.png" } }); + + var accepted = await controller.SendAsync(); + + Assert.True(accepted); + Assert.Equal(0, port.ExecuteLifecycleCallCount); + Assert.Equal(1, port.SendMessageCallCount); + Assert.Equal("/new", port.LastSendMessageCall!.Value.Message); + } + + [Fact] + public void Stop_DelegatesExactlyOnceWithCurrentThreadId() + { + var (_, controller, port, _) = MakeController(); + + controller.Stop(); + + // FireAndForget invokes the port call synchronously (only the awaiting of + // its result is deferred), so the call count is observable immediately — + // this also preserves call order for rapid successive invocations. + Assert.Equal(1, port.StopCallCount); + Assert.Equal("session-1", port.LastStopThreadId); + } + + [Fact] + public void CancelQueuedMessage_DelegatesExactId() + { + var (_, controller, port, _) = MakeController(); + + controller.CancelQueuedMessage("queued-42"); + + Assert.Equal(1, port.CancelQueuedCallCount); + Assert.Equal(("session-1", "queued-42"), port.LastCancelQueuedCall); + } + + [Fact] + public void SetModel_DelegatesConcreteModel() + { + var (_, controller, port, _) = MakeController(); + + controller.SetModel("gpt-5"); + + Assert.Equal(1, port.SetModelCallCount); + Assert.Equal(0, port.ClearModelCallCount); + Assert.Equal(("session-1", "gpt-5"), port.LastSetModelCall); + } + + [Fact] + public void ClearModel_CallsExplicitClearNotSet() + { + var (_, controller, port, _) = MakeController(); + + controller.ClearModel(); + + Assert.Equal(1, port.ClearModelCallCount); + Assert.Equal(0, port.SetModelCallCount); + } + + [Fact] + public void SetModel_RapidSuccessiveCalls_InvokeThePortSynchronouslyInCallOrder() + { + // The port call itself must happen synchronously at the call site (only + // awaiting its completion is deferred), so two rapid model picks reach the + // provider in the order the user made them, not in whatever order a + // thread-pool-deferred invocation happens to schedule them. + var (_, controller, port, _) = MakeController(); + + controller.SetModel("first-pick"); + controller.SetModel("second-pick"); + + Assert.Equal(new[] { "first-pick", "second-pick" }, port.SetModelCallOrder); + } + + [Fact] + public void SetThinkingLevel_DelegatesExactLevel() + { + var (_, controller, port, _) = MakeController(); + + controller.SetThinkingLevel("high"); + + Assert.Equal(1, port.SetThinkingLevelCallCount); + Assert.Equal(("session-1", "high"), port.LastSetThinkingLevelCall); + } + + [Fact] + public void RequestCommandCatalog_DelegatesToPort() + { + var (_, controller, port, _) = MakeController(); + + controller.RequestCommandCatalog(); + + Assert.Equal(1, port.EnsureCommandCatalogCallCount); + } + + [Fact] + public async Task StartVoiceRecording_AppendsTranscriptOnCompletion() + { + var voiceGate = new TaskCompletionSource(); + var actions = new ChatComposerHostActions( + null, + null, + VoiceCaptureRequest: (_, _) => voiceGate.Task, + null, + null); + var (vm, controller, _, _) = MakeController(actions); + + controller.StartVoiceRecording(); + Assert.True(vm.IsRecording); + + voiceGate.SetResult("hello from voice"); + await Task.Delay(20); + + Assert.False(vm.IsRecording); + Assert.Equal("hello from voice", vm.Draft); + } + + [Fact] + public async Task Dispose_CancelsVoiceAndFencesLateCompletionFromMutatingViewModel() + { + var voiceGate = new TaskCompletionSource(); + var actions = new ChatComposerHostActions( + null, + null, + VoiceCaptureRequest: (ct, _) => voiceGate.Task, + null, + null); + var (vm, controller, _, _) = MakeController(actions); + controller.StartVoiceRecording(); + + controller.Dispose(); + Assert.True(controller.IsDisposed); + + // Late completion arrives after dispose; it must not mutate the view model. + voiceGate.SetResult("late transcript"); + await Task.Delay(20); + + Assert.Equal(string.Empty, vm.Draft); + } + + [Fact] + public void StartVoiceRecording_DisposeWinsBeforeRegistration_DoesNotInvokeHostOrMutateViewModel() + { + var requestCalls = 0; + var actions = new ChatComposerHostActions( + null, + null, + VoiceCaptureRequest: (_, _) => + { + Interlocked.Increment(ref requestCalls); + return Task.FromResult("unexpected"); + }, + null, + null); + var (vm, controller, _, _) = MakeController(actions); + using var hookReached = new ManualResetEventSlim(); + using var releaseHook = new ManualResetEventSlim(); + Exception? observed = null; + controller.TestOnlyBeforeVoiceRegistration = () => + { + hookReached.Set(); + Assert.True(releaseHook.Wait(TimeSpan.FromSeconds(5))); + }; + + var startThread = new Thread(() => + { + try { controller.StartVoiceRecording(); } + catch (Exception ex) { observed = ex; } + }); + startThread.Start(); + Assert.True(hookReached.Wait(TimeSpan.FromSeconds(5))); + + controller.Dispose(); + releaseHook.Set(); + + Assert.True(startThread.Join(TimeSpan.FromSeconds(5))); + Assert.Null(observed); + Assert.Equal(0, requestCalls); + Assert.False(vm.IsRecording); + Assert.Equal(string.Empty, vm.Draft); + } + + [Fact] + public void StartVoiceRecording_RegistrationWins_InitiatesBeforeDisposeReturnsAndFencesLateCallbacks() + { + var dispatcher = new RecordingUiDispatcher + { + HasThreadAccess = false, + RunEnqueuedImmediately = false, + }; + var requestCalls = 0; + CancellationToken requestToken = default; + Action? recordingStarted = null; + var voiceResult = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var requestEntered = new ManualResetEventSlim(); + using var releaseRequest = new ManualResetEventSlim(); + using var disposeStarted = new ManualResetEventSlim(); + using var cleanupReached = new ManualResetEventSlim(); + var actions = new ChatComposerHostActions( + null, + null, + VoiceCaptureRequest: (token, started) => + { + Interlocked.Increment(ref requestCalls); + requestToken = token; + recordingStarted = started; + requestEntered.Set(); + Assert.True(releaseRequest.Wait(TimeSpan.FromSeconds(5))); + return voiceResult.Task; + }, + null, + null); + var (vm, controller, _, _) = MakeController(actions, dispatcher); + dispatcher.FlushPending(); + controller.TestOnlyVoiceOperationCleanedUp = () => cleanupReached.Set(); + Exception? startException = null; + Exception? disposeException = null; + + var startThread = new Thread(() => + { + try { controller.StartVoiceRecording(); } + catch (Exception ex) { startException = ex; } + }); + startThread.Start(); + Assert.True(requestEntered.Wait(TimeSpan.FromSeconds(5))); + + var disposeThread = new Thread(() => + { + disposeStarted.Set(); + try { controller.Dispose(); } + catch (Exception ex) { disposeException = ex; } + }); + disposeThread.Start(); + Assert.True(disposeStarted.Wait(TimeSpan.FromSeconds(5))); + Assert.True( + SpinWait.SpinUntil( + () => (disposeThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)), + "Dispose did not block on the held voice-registration gate."); + Assert.True(disposeThread.IsAlive); + + releaseRequest.Set(); + Assert.True(startThread.Join(TimeSpan.FromSeconds(5))); + Assert.True(disposeThread.Join(TimeSpan.FromSeconds(5))); + Assert.Null(startException); + Assert.Null(disposeException); + Assert.Equal(1, requestCalls); + Assert.True(requestToken.IsCancellationRequested); + + var enqueuedAtDispose = dispatcher.EnqueuedCount; + recordingStarted?.Invoke(); + voiceResult.SetResult("late transcript"); + Assert.True(cleanupReached.Wait(TimeSpan.FromSeconds(5))); + + Assert.Equal(enqueuedAtDispose, dispatcher.EnqueuedCount); + Assert.Equal(string.Empty, vm.Draft); + } + + [Fact] + public void StartVoiceRecording_SynchronousHostException_IsObservedAndCleansUpOnce() + { + var actions = new ChatComposerHostActions( + null, + null, + VoiceCaptureRequest: (_, _) => throw new InvalidOperationException("synchronous failure"), + null, + null); + var (vm, controller, _, _) = MakeController(actions); + var cleanupCalls = 0; + controller.TestOnlyVoiceOperationCleanedUp = () => cleanupCalls++; + + var exception = Record.Exception(controller.StartVoiceRecording); + + Assert.Null(exception); + Assert.False(vm.IsRecording); + Assert.Equal(1, cleanupCalls); + } + + [Fact] + public async Task VoiceStartStopDispose_RaceStress_NeverThrowsAndDisposeRemainsIdempotent() + { + for (var iteration = 0; iteration < 100; iteration++) + { + var requestCalls = 0; + var voiceResult = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupReached = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var actions = new ChatComposerHostActions( + null, + null, + VoiceCaptureRequest: (_, _) => + { + Interlocked.Increment(ref requestCalls); + return voiceResult.Task; + }, + null, + null); + var (_, controller, _, _) = MakeController(actions); + controller.TestOnlyVoiceOperationCleanedUp = () => cleanupReached.TrySetResult(); + + await Task.WhenAll( + Task.Run(controller.StartVoiceRecording), + Task.Run(controller.StopVoiceRecording), + Task.Run(controller.Dispose), + Task.Run(controller.Dispose)); + + voiceResult.TrySetResult("late"); + if (requestCalls != 0) + await cleanupReached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var exception = Record.Exception(controller.Dispose); + Assert.Null(exception); + Assert.InRange(requestCalls, 0, 1); + } + } + + [Fact] + public async Task SendAsync_AfterDispose_ReturnsFalseWithoutCallingPort() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("hello"); + controller.Dispose(); + + var accepted = await controller.SendAsync(); + + Assert.False(accepted); + Assert.Equal(0, port.SendMessageCallCount); + } + + [Fact] + public void Dispose_IsIdempotent() + { + var (_, controller, _, _) = MakeController(); + + controller.Dispose(); + var exception = Record.Exception(controller.Dispose); + + Assert.Null(exception); + } + + [Fact] + public async Task Dispose_LifetimeCancellationCallbackCanWaitForReentrantControllerWork() + { + var voiceRequestCalls = 0; + var actions = new ChatComposerHostActions( + null, + null, + VoiceCaptureRequest: (_, _) => + { + Interlocked.Increment(ref voiceRequestCalls); + return Task.FromResult(null); + }, + null, + null); + var (_, controller, port, _) = MakeController(actions); + controller.Stop(); + Assert.NotNull(port.LastStopToken); + + Task? callbackWork = null; + var callbackInvoked = false; + var callbackWorkCompleted = false; + var callbackObservedDisposed = false; + using var registration = port.LastStopToken!.Value.Register(() => + { + callbackInvoked = true; + callbackWork = Task.Run(() => + { + controller.TestOnlyProbeOperationGate(); + callbackObservedDisposed = controller.IsDisposed; + controller.StartVoiceRecording(); + }); + callbackWorkCompleted = callbackWork.Wait(TimeSpan.FromSeconds(2)); + }); + + var disposeTask = Task.Run(controller.Dispose); + var exception = await Record.ExceptionAsync( + async () => await disposeTask.WaitAsync(TimeSpan.FromSeconds(5))); + if (callbackWork is not null) + await callbackWork.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Null(exception); + Assert.True(callbackInvoked); + Assert.True(callbackWorkCompleted); + Assert.True(callbackObservedDisposed); + Assert.True(controller.IsDisposed); + Assert.Equal(0, voiceRequestCalls); + } + + [Fact] + public async Task Dispose_VoiceCancellationCallbackCanWaitForReentrantControllerWork() + { + var voiceRequestCalls = 0; + CancellationToken voiceToken = default; + var voiceResult = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupReached = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var actions = new ChatComposerHostActions( + null, + null, + VoiceCaptureRequest: (token, _) => + { + Interlocked.Increment(ref voiceRequestCalls); + voiceToken = token; + return voiceResult.Task; + }, + null, + null); + var (vm, controller, _, _) = MakeController(actions); + controller.TestOnlyVoiceOperationCleanedUp = () => cleanupReached.TrySetResult(); + controller.StartVoiceRecording(); + Assert.True(voiceToken.CanBeCanceled); + + Task? callbackWork = null; + var callbackInvoked = false; + var callbackWorkCompleted = false; + using var registration = voiceToken.Register(() => + { + callbackInvoked = true; + callbackWork = Task.Run(async () => + { + voiceResult.TrySetResult("late transcript"); + await cleanupReached.Task; + }); + callbackWorkCompleted = callbackWork.Wait(TimeSpan.FromSeconds(2)); + }); + + var disposeTask = Task.Run(controller.Dispose); + var exception = await Record.ExceptionAsync( + async () => await disposeTask.WaitAsync(TimeSpan.FromSeconds(5))); + if (callbackWork is not null) + await callbackWork.WaitAsync(TimeSpan.FromSeconds(5)); + await cleanupReached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Null(exception); + Assert.True(callbackInvoked); + Assert.True(callbackWorkCompleted); + Assert.Equal(1, voiceRequestCalls); + Assert.Equal(string.Empty, vm.Draft); + } + + [Fact] + public async Task SendAsync_DisposedBetweenEntryAndPortInvocation_ReturnsFalseWithoutUnobservedExceptionAndOnlyTheAlreadyAdmittedPortCall() + { + // ClawSweeper-found P2 regression proof: controller methods used to read + // `_lifetimeCts.Token` (the property getter) after their own disposed + // check, so a concurrent Dispose() landing between that check and the + // token read could make the getter throw ObjectDisposedException — most + // visibly here, in the path SendAsync -> SendCoreAsync -> + // _port.SendMessageAsync(..., token). That throw was unhandled and would + // fault the Task this method returns; if the caller does not await/observe + // it (a typical UI fire-and-forget button handler), that is an unobserved + // task exception. The fix captures one CancellationToken value in the + // constructor and never calls the source's Token property getter again. + // + // This test uses TestOnlyAfterEntryBeforePortInvocation to deterministically + // land a concurrent Dispose() exactly in the gap the bug occupied — after + // SendCoreAsync's entry disposed check, before any port call — then proves + // the resulting Task completes with `false` (not canceled/faulted from the + // caller's perspective) and no exception is ever thrown, while the send + // that had already been admitted still reaches the port exactly once (its + // token now safely reflecting cancellation) rather than being silently + // dropped or duplicated. + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("hello"); + var resumeAfterDispose = new TaskCompletionSource(); + controller.TestOnlyAfterEntryBeforePortInvocation = () => resumeAfterDispose.Task; + + var sendTask = controller.SendAsync(); + + controller.Dispose(); + resumeAfterDispose.SetResult(); + + Exception? observed = null; + bool accepted = false; + try + { + accepted = await sendTask; + } + catch (Exception ex) + { + observed = ex; + } + + Assert.Null(observed); + Assert.False(accepted); + Assert.Equal(1, port.SendMessageCallCount); + Assert.NotNull(port.LastSendMessageToken); + Assert.True(port.LastSendMessageToken!.Value.IsCancellationRequested); + } + + [Fact] + public async Task SendCoreAsync_DirectCall_DisposedBetweenEntryAndPortInvocation_ReturnsFalseWithoutUnobservedException() + { + // Sibling proof for the OTHER SendCoreAsync call site (the root's + // welcome-screen quick-start suggestion calls SendCoreAsync directly, + // without going through SendAsync's draft/attachment/send-gate wrapping). + var (_, controller, port, _) = MakeController(); + var resumeAfterDispose = new TaskCompletionSource(); + controller.TestOnlyAfterEntryBeforePortInvocation = () => resumeAfterDispose.Task; + + var task = controller.SendCoreAsync("session-1", "Test Session", "hello", Array.Empty()); + + controller.Dispose(); + resumeAfterDispose.SetResult(); + + Exception? observed = null; + bool accepted = false; + try + { + accepted = await task; + } + catch (Exception ex) + { + observed = ex; + } + + Assert.Null(observed); + Assert.False(accepted); + Assert.Equal(1, port.SendMessageCallCount); + } + + [Fact] + public void Stop_DisposedWhileCallInFlight_CtsCancellationReachesTheInFlightFakePortWithoutUnobservedException() + { + // Proves the captured _lifetimeToken really is the same live token the + // fake port received: canceling it via Dispose() must be observable on + // the exact CancellationToken value already handed to the in-flight call, + // and completing that already-in-flight call afterward must not throw or + // fault (no re-read of a disposed CancellationTokenSource.Token anywhere). + var (vm, controller, port, _) = MakeController(); + port.StopGate = new TaskCompletionSource(); + + controller.Stop(); + + Assert.Equal(1, port.StopCallCount); + Assert.NotNull(port.LastStopToken); + Assert.False(port.LastStopToken!.Value.IsCancellationRequested); + + controller.Dispose(); + + Assert.True(port.LastStopToken!.Value.IsCancellationRequested); + + var exception = Record.Exception(() => port.StopGate.SetResult()); + Assert.Null(exception); + } + + [Fact] + public void SetModel_DisposedWhileCallInFlight_CtsCancellationReachesTheInFlightFakePortWithoutUnobservedException() + { + var (_, controller, port, _) = MakeController(); + port.SetModelGate = new TaskCompletionSource(); + + controller.SetModel("gpt-5.6"); + + Assert.Equal(1, port.SetModelCallCount); + Assert.NotNull(port.LastSetModelToken); + Assert.False(port.LastSetModelToken!.Value.IsCancellationRequested); + + controller.Dispose(); + + Assert.True(port.LastSetModelToken!.Value.IsCancellationRequested); + + var exception = Record.Exception(() => port.SetModelGate.SetResult()); + Assert.Null(exception); + } + + [Fact] + public void SetModel_DisposedBetweenEntryAndFireAndForgetInvocation_NoUnobservedExceptionAndCancellationReachesThePort() + { + // Deterministic sibling proof for the fire-and-forget family (Stop, + // CancelQueuedMessage, SetModel, ClearModel, SetThinkingLevel, + // RequestCommandCatalog all share this exact shape): the race window + // between the method's own disposed check and its FireAndForget-wrapped + // synchronous port call is only a few CPU instructions wide, so real OS + // thread scheduling cannot reliably land inside it (confirmed: 200 + // real-thread racing iterations across the other four sibling methods + // never reproduced a failure). TestOnlyBeforeFireAndForgetSynchronousInvocation + // lets this test force a concurrent Dispose() into exactly that gap instead. + var (_, controller, port, _) = MakeController(); + port.SetModelGate = new TaskCompletionSource(); + var hookReached = new ManualResetEventSlim(false); + var resumeHook = new ManualResetEventSlim(false); + controller.TestOnlyBeforeFireAndForgetSynchronousInvocation = () => + { + hookReached.Set(); + Assert.True(resumeHook.Wait(TimeSpan.FromSeconds(5)), "Test did not release the blocked hook in time."); + }; + + var callerThread = new Thread(() => controller.SetModel("gpt-5.6")); + callerThread.Start(); + + Assert.True(hookReached.Wait(TimeSpan.FromSeconds(5)), "FireAndForget hook was not reached in time."); + + // Dispose concurrently while SetModel is blocked before its synchronous + // port call — this is exactly the gap where the pre-fix code would have + // re-read the (now-disposed) CancellationTokenSource.Token and thrown. + controller.Dispose(); + resumeHook.Set(); + + Assert.True(callerThread.Join(TimeSpan.FromSeconds(5))); + + Assert.Equal(1, port.SetModelCallCount); + Assert.NotNull(port.LastSetModelToken); + Assert.True( + port.LastSetModelToken!.Value.IsCancellationRequested, + "The already-in-flight call's token should reflect the concurrent disposal's cancellation."); + + var exception = Record.Exception(() => port.SetModelGate.SetResult()); + Assert.Null(exception); + } + + [Fact] + public void RemainingFireAndForgetOperations_ConcurrentWithDispose_NeverThrowOrFault() + { + // Sibling race coverage for the remaining fire-and-forget operations that + // also used to re-read _lifetimeCts.Token at their call site: + // CancelQueuedMessage, ClearModel, SetThinkingLevel, RequestCommandCatalog. + // Each is raced against a concurrent Dispose() on a real second thread, + // many times, with a fresh controller per iteration, asserting neither + // thread ever observes an exception. + var operations = new Action[] + { + c => c.CancelQueuedMessage("queued-1"), + c => c.ClearModel(), + c => c.SetThinkingLevel("high"), + c => c.RequestCommandCatalog(), + }; + + foreach (var operation in operations) + { + for (var iteration = 0; iteration < 200; iteration++) + { + var (_, controller, _, _) = MakeController(); + Exception? observed = null; + + var callerThread = new Thread(() => + { + try + { + operation(controller); + } + catch (Exception ex) + { + observed = ex; + } + }); + var disposeThread = new Thread(() => + { + try + { + controller.Dispose(); + } + catch (Exception ex) + { + observed ??= ex; + } + }); + + callerThread.Start(); + disposeThread.Start(); + Assert.True(callerThread.Join(TimeSpan.FromSeconds(5))); + Assert.True(disposeThread.Join(TimeSpan.FromSeconds(5))); + + Assert.Null(observed); + } + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/ChatComposerFactoryDiFailFastTests.cs b/tests/OpenClaw.Tray.Tests/ChatComposerFactoryDiFailFastTests.cs new file mode 100644 index 000000000..fb916852e --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ChatComposerFactoryDiFailFastTests.cs @@ -0,0 +1,61 @@ +using System.IO; + +namespace OpenClaw.Tray.Tests; + +/// +/// Source-shape guard: a missing IChatComposerFactory DI registration must +/// surface as a composition failure (via GetRequiredService, letting the +/// existing app-level unhandled-exception/crash-log handler catch it), not be +/// silently treated as the "disconnected, no provider yet" placeholder state. Only +/// an as-yet-uninitialized App.Services container (a normal startup race, +/// during which the provider is also legitimately absent) still falls back to the +/// placeholder. See docs/ARCHITECTURE.md and AGENTS.md for the composition-root +/// conventions this guards (matching the existing +/// sp.GetRequiredService<NavigationScopeManager>() pattern already used +/// by App.xaml.cs). +/// +public sealed class ChatComposerFactoryDiFailFastTests +{ + [Theory] + [InlineData("Pages", "ChatPage.xaml.cs")] + [InlineData("Windows", "ChatWindow.xaml.cs")] + public void HostComposition_RequiresComposerFactory_ViaGetRequiredService(string folder, string fileName) + { + var source = ReadSource(folder, fileName); + + // Fails fast once the container exists: GetRequiredService, not GetService. + Assert.Contains("services.GetRequiredService()", source); + Assert.DoesNotContain("GetService()", source); + + // The null-coalescing fallback is reached only when the container itself + // has not been built yet (app?.Services is null) — the same timing window + // during which the chat provider is also legitimately absent — not when a + // real registration lookup failed (that throws instead of returning null). + Assert.Contains("app?.Services is { } services", source); + Assert.Contains("? services.GetRequiredService()", source); + Assert.Contains(": null;", source); + } + + [Theory] + [InlineData("Pages", "ChatPage.xaml.cs")] + [InlineData("Windows", "ChatWindow.xaml.cs")] + public void HostComposition_StillTreatsUninitializedContainerAsNoProviderPlaceholder( + string folder, + string fileName) + { + var source = ReadSource(folder, fileName); + + // The placeholder-panel branch still exists and is still gated on both the + // provider and the (now fail-fast) composerFactory being null — i.e. the + // only remaining null case for composerFactory (an uninitialized + // container) is intentionally still folded into the same "no provider yet" + // placeholder path, not a distinct silent branch. + Assert.Contains("if (provider is null || composerFactory is null)", source); + } + + private static string ReadSource(string folder, string fileName) + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + return File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", folder, fileName)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ChatComposerSessionTests.cs b/tests/OpenClaw.Tray.Tests/ChatComposerSessionTests.cs new file mode 100644 index 000000000..d27bb398d --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ChatComposerSessionTests.cs @@ -0,0 +1,59 @@ +using OpenClaw.Tray.Tests.Presentation; +using OpenClawTray.Chat; + +namespace OpenClaw.Tray.Tests; + +/// +/// Characterization tests for and +/// : exactly-once disposal cascading to both the +/// view model and controller, and that the factory itself is stateless (starts no +/// background work and produces an independent session per call). +/// +public sealed class ChatComposerSessionTests +{ + [Fact] + public void Dispose_DisposesViewModelAndControllerExactlyOnce() + { + var dispatcher = new RecordingUiDispatcher(); + var factory = new ChatComposerFactory(dispatcher); + var provider = new FakeChatDataProviderForComposerTests(); + var hostActions = new ChatComposerHostActions(null, null, null, null, null); + var session = factory.Create(provider, hostActions, initialSpeakerMuted: false); + + session.Dispose(); + var exception = Record.Exception(session.Dispose); + + Assert.Null(exception); + } + + [Fact] + public void Create_ProducesAnIndependentSessionPerCall() + { + var dispatcher = new RecordingUiDispatcher(); + var factory = new ChatComposerFactory(dispatcher); + var provider = new FakeChatDataProviderForComposerTests(); + var hostActions = new ChatComposerHostActions(null, null, null, null, null); + + var first = factory.Create(provider, hostActions, initialSpeakerMuted: false); + var second = factory.Create(provider, hostActions, initialSpeakerMuted: false); + + Assert.NotSame(first, second); + + first.Dispose(); + second.Dispose(); + } + + [Fact] + public void HostActions_AreExposedUnchangedFromCreation() + { + var dispatcher = new RecordingUiDispatcher(); + var factory = new ChatComposerFactory(dispatcher); + var provider = new FakeChatDataProviderForComposerTests(); + var hostActions = new ChatComposerHostActions(null, () => { }, null, null, null); + + var session = factory.Create(provider, hostActions, initialSpeakerMuted: false); + + Assert.Same(hostActions, session.HostActions); + session.Dispose(); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ChatComposerViewModelTests.cs b/tests/OpenClaw.Tray.Tests/ChatComposerViewModelTests.cs new file mode 100644 index 000000000..b5f3ab184 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ChatComposerViewModelTests.cs @@ -0,0 +1,653 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClaw.Tray.Tests.Presentation; +using OpenClawTray.Chat; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Tray.Tests; + +/// +/// Characterization tests for : draft/attachment +/// ownership, the monotonic-revision guard, derived +/// , UI-thread dispatch of every mutation, +/// and exactly-once disposal with no late notification. +/// +public sealed class ChatComposerViewModelTests +{ + private static ChatThread MakeThread(string id = "session-1", string? model = null, string? thinking = null) => + new() + { + Id = id, + Title = "Test Session", + Status = ChatThreadStatus.Running, + Activity = ChatActivity.Idle, + Model = model, + ThinkingLevel = thinking, + }; + + private static ChatComposerInputs MakeInputs( + long revision = 1, + string connectionState = "connected", + bool turnActive = false, + ChatThread? thread = null) => + new( + revision, + connectionState, + turnActive, + thread ?? MakeThread(), + System.Array.Empty(), + System.Array.Empty(), + null, + false, + System.Array.Empty(), + null, + false); + + [Fact] + public void SetDraft_UpdatesDraftAndBumpsRevision() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + + vm.SetDraft("hello"); + + Assert.Equal("hello", vm.Draft); + Assert.Equal(1, vm.DraftRevision); + } + + [Fact] + public void ClearDraft_ResetsDraftAndStillBumpsRevision() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + vm.SetDraft("hello"); + + vm.ClearDraft(); + + Assert.Equal(string.Empty, vm.Draft); + Assert.Equal(2, vm.DraftRevision); + } + + [Fact] + public void RemoveAttachment_UsesReferenceEqualityNotValueEquality() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + var original = new ChatAttachment { FileName = "file.txt" }; + var duplicate = new ChatAttachment { FileName = "file.txt" }; + vm.AddAttachments(new[] { original, duplicate }); + + vm.RemoveAttachment(original); + + Assert.Single(vm.PendingAttachments); + Assert.Same(duplicate, vm.PendingAttachments[0]); + } + + [Fact] + public void RemoveSubmittedAttachments_PreservesAttachmentsAddedWhileSending() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + var submitted = new ChatAttachment { FileName = "submitted.txt" }; + vm.AddAttachments(new[] { submitted }); + vm.AddAttachments(new[] { new ChatAttachment { FileName = "added-later.txt" } }); + + vm.RemoveSubmittedAttachments(new[] { submitted }); + + Assert.Single(vm.PendingAttachments); + Assert.Equal("added-later.txt", vm.PendingAttachments[0].FileName); + } + + [Fact] + public void ApplyInputs_RejectsOutOfOrderRevision() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + var newer = MakeInputs(revision: 5, thread: MakeThread("newer")); + var stale = MakeInputs(revision: 3, thread: MakeThread("stale")); + + vm.ApplyInputs(newer); + vm.ApplyInputs(stale); + + Assert.Equal("newer", vm.Inputs!.CurrentThread.Id); + } + + [Fact] + public void ApplyInputs_AcceptsStrictlyIncreasingRevision() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + vm.ApplyInputs(MakeInputs(revision: 1, thread: MakeThread("first"))); + vm.ApplyInputs(MakeInputs(revision: 2, thread: MakeThread("second"))); + + Assert.Equal("second", vm.Inputs!.CurrentThread.Id); + } + + [Fact] + public void CanSend_FalseWhenDraftAndAttachmentsAreEmpty() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + vm.ApplyInputs(MakeInputs()); + + Assert.False(vm.CanSend); + } + + [Fact] + public void CanSend_TrueWithNonEmptyDraftWhileConnectedAndIdle() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + vm.ApplyInputs(MakeInputs()); + vm.SetDraft("hello"); + + Assert.True(vm.CanSend); + } + + [Fact] + public void CanSend_FalseWhileSending() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + vm.ApplyInputs(MakeInputs()); + vm.SetDraft("hello"); + vm.SetSending(true); + + Assert.False(vm.CanSend); + } + + [Fact] + public void CanSend_FalseWhenDisconnected() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + vm.ApplyInputs(MakeInputs(connectionState: "disconnected")); + vm.SetDraft("hello"); + + Assert.False(vm.CanSend); + } + + [Fact] + public void CanSend_TrueForAttachmentOnlySubmission() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + vm.ApplyInputs(MakeInputs()); + + Assert.False(vm.CanSend); + vm.AddAttachments(new[] { new ChatAttachment { FileName = "a.png" } }); + Assert.True(vm.CanSend); + } + + [Fact] + public void Mutations_AreDispatchedThroughIUiDispatcher_WhenOffUiThread() + { + var dispatcher = new RecordingUiDispatcher { HasThreadAccess = false, RunEnqueuedImmediately = false }; + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + + vm.SetDraft("queued"); + + Assert.Equal(string.Empty, vm.Draft); + Assert.Equal(1, dispatcher.EnqueuedCount); + + dispatcher.FlushPending(); + + Assert.Equal("queued", vm.Draft); + } + + [Fact] + public void PropertyChanged_IsRaisedOnEveryAcceptedMutation() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + var raisedCount = 0; + vm.PropertyChanged += (_, _) => raisedCount++; + + vm.SetDraft("a"); + vm.SetDraft("ab"); + + Assert.Equal(2, raisedCount); + } + + [Fact] + public void Dispose_RejectsLateMutationAndStopsNotifying() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + var raisedCount = 0; + vm.PropertyChanged += (_, _) => raisedCount++; + vm.SetDraft("before"); + + vm.Dispose(); + vm.SetDraft("after"); + + Assert.Equal("before", vm.Draft); + Assert.Equal(1, raisedCount); + Assert.True(vm.IsDisposed); + } + + [Fact] + public void Dispose_IsIdempotent() + { + var vm = new ChatComposerViewModel(new RecordingUiDispatcher(), initialSpeakerMuted: false); + + vm.Dispose(); + var exception = Record.Exception(vm.Dispose); + + Assert.Null(exception); + } + + [Fact] + public void Mutations_QueuedWhileDrainPending_StillApplyInEnqueueOrder() + { + // Serialization contract: a mutation that arrives while on the UI thread + // must not jump ahead of an already-queued, not-yet-drained background + // mutation. Both are enqueued onto one internal FIFO and applied by the + // same drain pass, in enqueue order. + var dispatcher = new RecordingUiDispatcher { HasThreadAccess = false, RunEnqueuedImmediately = false }; + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + + // Background completion enqueues first; the drain is scheduled but held + // back (RunEnqueuedImmediately is false) until FlushPending() runs it. + vm.SetDraft("from-background"); + Assert.Equal(1, dispatcher.EnqueuedCount); + + // A "UI thread" mutation arrives before that scheduled drain has run. + dispatcher.HasThreadAccess = true; + vm.SetDraft("from-ui-thread-while-pending"); + + // It must have deferred to the already-scheduled drain rather than racing + // ahead: nothing has applied yet, and no second drain was scheduled. + Assert.Equal(string.Empty, vm.Draft); + Assert.Equal(1, dispatcher.EnqueuedCount); + + dispatcher.FlushPending(); + + // The single drain applies both mutations in enqueue order; the later one + // is the final value, exactly as if applied back-to-back in arrival order. + Assert.Equal("from-ui-thread-while-pending", vm.Draft); + } + + [Fact] + public void Mutations_MultipleBackgroundCompletionsQueueBeforeDispatcherRuns_ApplyInOrder() + { + var dispatcher = new RecordingUiDispatcher { HasThreadAccess = false, RunEnqueuedImmediately = false }; + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + + vm.SetVoiceTranscript("first"); + vm.SetVoiceTranscript("second"); + vm.SetVoiceTranscript("third"); + + // All three mutations joined the one scheduled drain; only one drain was + // ever scheduled with the dispatcher. + Assert.Equal(1, dispatcher.EnqueuedCount); + Assert.Null(vm.VoiceTranscript); + + dispatcher.FlushPending(); + + Assert.Equal("third", vm.VoiceTranscript); + } + + [Fact] + public void Dispose_WithQueuedWork_DrainsWithoutApplyingOrNotifying() + { + var dispatcher = new RecordingUiDispatcher { HasThreadAccess = false, RunEnqueuedImmediately = false }; + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + var raisedCount = 0; + vm.PropertyChanged += (_, _) => raisedCount++; + var revisionBeforeDispose = vm.RenderRevision; + + vm.SetDraft("queued-before-dispose"); // enqueued; the scheduled drain has not run yet + vm.Dispose(); + + // The already-scheduled drain callback still fires (the dispatcher itself + // has no idea the view model was disposed), but DrainQueue must observe + // disposal and drop the queue rather than applying or notifying. + dispatcher.FlushPending(); + + Assert.True(vm.IsDisposed); + Assert.Equal(string.Empty, vm.Draft); + Assert.Equal(0, raisedCount); + Assert.Equal(revisionBeforeDispose, vm.RenderRevision); + } + + [Fact] + public void Mutate_DispatcherRejectsDrain_DropsQueueSafelyAndDoesNotThrow() + { + var dispatcher = new RecordingUiDispatcher { HasThreadAccess = false, RejectEnqueue = true }; + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + var raisedCount = 0; + vm.PropertyChanged += (_, _) => raisedCount++; + + var exception = Record.Exception(() => vm.SetDraft("rejected")); + + // A rejected drain must fail safe: no exception, no partial/queued state + // left behind, no notification for the dropped mutation, and the view + // model remains usable (not marked disposed) for a future accepted call. + Assert.Null(exception); + Assert.Equal(string.Empty, vm.Draft); + Assert.Equal(0, raisedCount); + Assert.False(vm.IsDisposed); + + // Recovery: once the dispatcher accepts work again, new mutations apply + // normally (the drain-in-progress flag was correctly reset, not left stuck). + dispatcher.RejectEnqueue = false; + dispatcher.RunEnqueuedImmediately = false; + vm.SetDraft("accepted-after-recovery"); + dispatcher.FlushPending(); + Assert.Equal("accepted-after-recovery", vm.Draft); + } + + [Fact] + public void DrainQueue_ThrowingPropertyChangedSubscriber_DoesNotWedgeFutureDrains() + { + // A single misbehaving PropertyChanged subscriber (or mutation action) + // must not leave the internal _draining flag stuck true forever, which + // would silently and permanently freeze every future composer UI update. + var dispatcher = new RecordingUiDispatcher { HasThreadAccess = false, RunEnqueuedImmediately = false }; + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + var throwOnce = true; + vm.PropertyChanged += (_, _) => + { + if (throwOnce) + { + throwOnce = false; + throw new InvalidOperationException("simulated subscriber bug"); + } + }; + + vm.SetDraft("first"); + var exception = Record.Exception(dispatcher.FlushPending); + + // The drain itself must not propagate/crash on the bad subscriber, and the + // mutation that triggered it still applied (the throw happens only in the + // notification step, after the state change already ran). + Assert.Null(exception); + Assert.Equal("first", vm.Draft); + + // Recovery: a later mutation must still be able to schedule and complete a + // fresh drain — proving _draining was correctly reset despite the exception. + vm.SetDraft("second"); + dispatcher.FlushPending(); + Assert.Equal("second", vm.Draft); + } + + [Fact] + public void ConcurrentDispose_WaitsForAnAlreadyInFlightApplyToCompleteBeforeReturning() + { + // Proves the "apply wins" linearization outcome under REAL concurrency (not + // same-thread reentrancy): once an apply has begun (acquired the internal + // apply/lifetime lock), a concurrent Dispose() call on another thread must + // block until that apply — including raising its PropertyChanged + // notification — has fully completed before Dispose() can return. + var dispatcher = new RecordingUiDispatcher(); + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + var applyStarted = new ManualResetEventSlim(false); + var releaseApply = new ManualResetEventSlim(false); + + vm.PropertyChanged += (_, _) => + { + if (vm.Draft != "in-flight") + return; + + applyStarted.Set(); + Assert.True(releaseApply.Wait(TimeSpan.FromSeconds(5)), "Test did not release the blocked apply in time."); + }; + + // HasThreadAccess defaults to true, so Mutate() would drain synchronously + // on whichever thread calls it — run it on a dedicated thread so the test + // thread stays free to drive Dispose() concurrently. + var applyThread = new Thread(() => vm.SetDraft("in-flight")); + applyThread.Start(); + Assert.True(applyStarted.Wait(TimeSpan.FromSeconds(5)), "Apply did not start in time."); + + var disposeThreadStarted = new ManualResetEventSlim(false); + var disposeReturned = new ManualResetEventSlim(false); + var disposeThread = new Thread(() => + { + disposeThreadStarted.Set(); + vm.Dispose(); + disposeReturned.Set(); + }); + disposeThread.Start(); + + // Prove the dispose thread has actually started running (not merely + // scheduled) before asserting it hasn't returned yet — otherwise a + // starved/not-yet-scheduled thread could make the negative assertion below + // pass for the wrong reason even against the old, unsynchronized Dispose(). + Assert.True(disposeThreadStarted.Wait(TimeSpan.FromSeconds(5)), "Dispose thread did not start in time."); + + // Dispose must NOT be able to return while the apply is still blocked mid- + // notification: give it a short window to (incorrectly) race ahead, then + // prove it has not. + Assert.False( + disposeReturned.Wait(TimeSpan.FromMilliseconds(300)), + "Dispose() returned before the in-flight apply/notification completed — linearization broken."); + + releaseApply.Set(); + + Assert.True(disposeReturned.Wait(TimeSpan.FromSeconds(5)), "Dispose() did not complete after the apply finished."); + Assert.True(applyThread.Join(TimeSpan.FromSeconds(5))); + Assert.True(disposeThread.Join(TimeSpan.FromSeconds(5))); + + // The in-flight mutation's state change was applied — it had already + // started before Dispose() was called, so it completed rather than being + // dropped. + Assert.Equal("in-flight", vm.Draft); + Assert.True(vm.IsDisposed); + } + + [Fact] + public void ReentrantDisposeDuringInFlightNotification_DropsQueuedMutationWithNoFurtherStateOrNotificationChange() + { + // Proves a reentrant-disposal variant of the "disposal wins" outcome: a + // mutation that is still only queued (never yet dequeued/applied) when + // Dispose() takes effect must never be applied — no state change, no + // revision bump, no notification — here constructed deterministically by + // enqueuing it and then calling Dispose() reentrantly from inside another + // mutation's own in-flight PropertyChanged notification. Monitor locks are + // reentrant for the owning thread, so Dispose()'s disposed-flag flip and + // queue-clear still run, synchronously, before this method returns. + // + // This does NOT exercise the narrower "already dequeued but not yet + // applied, racing a concurrent Dispose on another thread" gap the apply + // lock closes — see + // DequeuedMutationRacingConcurrentDispose_DropsMutationWhenDisposalWinsTheApplyLockRace + // for a genuinely concurrent, deterministic proof of that specific window. + var dispatcher = new RecordingUiDispatcher(); + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + var propertyChangedCount = 0; + var revisionAfterFirst = -1; + + vm.PropertyChanged += (_, _) => + { + propertyChangedCount++; + if (vm.Draft != "first") + return; + + revisionAfterFirst = vm.RenderRevision; + + // While "first" is still being applied/notified (the apply lock is + // held, reentrantly available only to this same thread), enqueue a + // second mutation and then dispose. Dispose must drop "second" before + // the drain can ever reach it again. + vm.SetDraft("second"); + vm.Dispose(); + }; + + vm.SetDraft("first"); + + // "first" already completed (its apply had already started) — the apply- + // wins outcome for the mutation already in flight. + Assert.Equal("first", vm.Draft); + Assert.True(vm.IsDisposed); + + // "second" was enqueued after disposal had already begun (from inside + // "first"'s own notification) and must never be applied or notified: draft, + // revision, and notification count are all unchanged from immediately + // after "first" applied. + Assert.Equal("first", vm.Draft); + Assert.Equal(revisionAfterFirst, vm.RenderRevision); + Assert.Equal(1, propertyChangedCount); + } + + [Fact] + public void DequeuedMutationRacingConcurrentDispose_DropsMutationWhenDisposalWinsTheApplyLockRace() + { + // Proves the exact linearization gap the apply lock closes: a mutation + // that has already been DEQUEUED (removed from the pending queue, so a + // queue-clear alone cannot stop it) but has not yet acquired the apply + // lock, racing a concurrent Dispose() on another thread for that lock. If + // disposal wins the race, the dequeued mutation must be dropped — no + // state change, no revision bump, no notification — even though it was + // already out of the queue when Dispose() ran. + // + // The test-only hook is required because this gap is a handful of CPU + // instructions between releasing _queueLock and acquiring _applyLock: real + // OS thread scheduling cannot deterministically land inside it, so this + // uses TestOnlyAfterDequeueBeforeApplyLock to pause the drain thread + // exactly there while Dispose() races in from a second thread. + var dispatcher = new RecordingUiDispatcher(); + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + var initialDraft = vm.Draft; + var initialRevision = vm.RenderRevision; + var propertyChangedCount = 0; + vm.PropertyChanged += (_, _) => propertyChangedCount++; + + var dequeueReached = new ManualResetEventSlim(false); + var disposeCompleted = new ManualResetEventSlim(false); + vm.TestOnlyAfterDequeueBeforeApplyLock = () => + { + // Only pause for the mutation under test — avoid recursing if this + // hook were ever invoked again for a later item. + vm.TestOnlyAfterDequeueBeforeApplyLock = null; + dequeueReached.Set(); + Assert.True(disposeCompleted.Wait(TimeSpan.FromSeconds(5)), "Dispose did not complete in time."); + }; + + var drainThread = new Thread(() => vm.SetDraft("second")); + drainThread.Start(); + + Assert.True(dequeueReached.Wait(TimeSpan.FromSeconds(5)), "Drain did not reach the dequeue point in time."); + + // The mutation is now dequeued but paused before the apply lock. Dispose + // concurrently from a second thread — it must be free to acquire the + // apply lock immediately (the drain thread is blocked in the test hook, + // not holding any lock) and win the race. + vm.Dispose(); + disposeCompleted.Set(); + + Assert.True(drainThread.Join(TimeSpan.FromSeconds(5))); + + Assert.Equal(initialDraft, vm.Draft); + Assert.Equal(initialRevision, vm.RenderRevision); + Assert.Equal(0, propertyChangedCount); + Assert.True(vm.IsDisposed); + } + + [Fact] + public async Task ReentrantPropertyChangedSubscriber_EnqueuesAnotherMutation_NoDeadlockAndLaterMutationDrains() + { + // A PropertyChanged subscriber that reentrantly calls back into the view + // model (enqueuing another mutation) must not deadlock — Mutate() only + // ever needs the queue lock, which the apply lock holder never reacquires + // reentrantly for this — and the reentrantly-enqueued mutation must still + // drain and apply within the same overall drain pass. + var dispatcher = new RecordingUiDispatcher(); + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + var reentered = false; + + vm.PropertyChanged += (_, _) => + { + if (!reentered && vm.Draft == "first") + { + reentered = true; + vm.SetDraft("second"); + } + }; + + var driverTask = Task.Run(() => vm.SetDraft("first")); + var completed = await Task.WhenAny(driverTask, Task.Delay(TimeSpan.FromSeconds(5))) == driverTask; + + Assert.True(completed, "Reentrant PropertyChanged subscriber caused a deadlock."); + Assert.True(reentered); + // The later, reentrantly-enqueued mutation drained and applied within the + // same pass — it was not dropped or left stranded in the queue. + Assert.Equal("second", vm.Draft); + } + + [Fact] + public void StressDisposeAcrossThreads_OffThreadMutationsNeverApplyOrNotifyAfterDisposalWithoutRelyingOnBlockingEventSynchronization() + { + // Regression coverage for cross-lock/no-lock visibility of the disposal + // flag: _disposed is written under _applyLock (in Dispose) but read under + // _queueLock (Mutate's authoritative check), under _applyLock (DrainQueue's + // linearization recheck), and under no lock at all (Mutate's unlocked + // fast-path check, and IsDisposed). A Monitor's acquire/release memory + // barrier only orders memory for threads that actually enter *that same* + // monitor — a plain (non-volatile) bool write in Dispose is not guaranteed + // to ever become visible to a thread that only ever reads it via a + // different lock or no lock at all. _disposed is now `volatile` + // specifically to close that gap. + // + // This test deliberately avoids ManualResetEventSlim or any other + // blocking/kernel synchronization primitive to publish "Dispose already + // returned" to the racing thread — those carry their own full memory + // fences and would mask a missing `volatile` regardless of the real fix. + // The only cross-thread signal it uses is a plain Volatile.Read/Write on a + // local int (not a lock, not a wait handle, not the field under test) + // driving a tight, lock-free polling loop that hammers real Mutate() calls + // across many iterations, so a genuine visibility failure has a realistic + // chance to manifest as a mutation applying/notifying after Dispose() has + // already returned on another thread. + const int iterations = 1000; + + for (var iteration = 0; iteration < iterations; iteration++) + { + var dispatcher = new RecordingUiDispatcher(); + var vm = new ChatComposerViewModel(dispatcher, initialSpeakerMuted: false); + var disposeReturned = 0; // 0/1, touched only via Volatile.Read/Write. + var violationObserved = false; + + vm.PropertyChanged += (_, _) => + { + if (Volatile.Read(ref disposeReturned) != 0) + violationObserved = true; + }; + + var mutatorThread = new Thread(() => + { + var spins = 0; + while (Volatile.Read(ref disposeReturned) == 0 && spins < 200_000) + { + vm.SetDraft("stress-" + spins); + spins++; + } + }); + + mutatorThread.Start(); + vm.Dispose(); + Volatile.Write(ref disposeReturned, 1); + + Assert.True( + mutatorThread.Join(TimeSpan.FromSeconds(5)), + $"Mutator thread did not finish (iteration {iteration})."); + Assert.False( + violationObserved, + $"A mutation applied/notified after Dispose() had already returned on another thread (iteration {iteration})."); + Assert.True(vm.IsDisposed); + } + } + + [Fact] + public void Disposed_FieldIsDeclaredVolatile() + { + // The stress test above proves the *externally observable* disposal + // linearization holds, but that property is also (separately) guaranteed + // by the _applyLock monitor for every code path that actually goes + // through it — so a passing stress test alone does not discriminate + // whether `_disposed` is actually declared `volatile`. This structural + // guard closes that gap directly: it fails if a future edit ever removes + // the `volatile` keyword, which is the only thing that also protects the + // *unlocked* fast-path read in Mutate() and the unlocked IsDisposed + // property from cross-thread staleness. + var field = typeof(ChatComposerViewModel).GetField( + "_disposed", + BindingFlags.NonPublic | BindingFlags.Instance); + + Assert.NotNull(field); + Assert.Contains(typeof(IsVolatile), field!.GetRequiredCustomModifiers()); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ChatRootComposerClosureTests.cs b/tests/OpenClaw.Tray.Tests/ChatRootComposerClosureTests.cs new file mode 100644 index 000000000..be322bbe4 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ChatRootComposerClosureTests.cs @@ -0,0 +1,54 @@ +using System.IO; + +namespace OpenClaw.Tray.Tests; + +/// +/// Narrow source-shape guard for the D2 reactor-chat-root-composer-closed +/// ledger row. must not +/// regain composer draft/attachment/slash/voice/send mutable state or direct +/// composer provider workflow calls; that ownership now lives in +/// and +/// . See +/// docs/ARCHITECTURE.md for the retirement condition. +/// +public sealed class ChatRootComposerClosureTests +{ + [Fact] + public void Root_DoesNotReintroduceComposerMutableState() + { + var root = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Chat", + "OpenClawReactorChatRoot.cs")); + + // Composer draft/attachment/slash/voice/send state must not come back as + // Reactor UseState/refs on the root. + Assert.DoesNotContain("pendingAttachments", root); + Assert.DoesNotContain("speakerMuted", root); + Assert.DoesNotContain("voiceTranscript", root); + Assert.DoesNotContain("voiceAudioLevel", root); + Assert.DoesNotContain("slashMenuState", root); + Assert.DoesNotContain("ReactorSlashMenuState", root); + Assert.DoesNotContain("sendInFlight", root); + Assert.DoesNotContain("voiceCancellation", root); + Assert.DoesNotContain("voiceOperation", root); + + // The root must not call composer send/model/thinking/catalog provider + // APIs directly; those now go through ChatComposerController. + Assert.DoesNotContain("props.Provider.SendMessageAsync", root); + Assert.DoesNotContain("props.Provider.SetModelAsync", root); + Assert.DoesNotContain("props.Provider.ClearModelAsync", root); + Assert.DoesNotContain("props.Provider.SetThinkingLevelAsync", root); + Assert.DoesNotContain("props.Provider.EnsureCommandCatalogAsync", root); + Assert.DoesNotContain("props.Provider.CancelQueuedMessageAsync", root); + Assert.DoesNotContain("ChatLifecycleCommandParser", root); + + // Allowed residue: the root still owns provider subscription, selection, + // timeline projection, and constructs the composer's immutable inputs. + Assert.Contains("nativeProvider.LoadHistoryAsync", root); + Assert.Contains("props.ComposerSession.ApplyInputs(new ChatComposerInputs(", root); + Assert.Contains("props.ComposerSession.Controller.BindSelectionHandoff(SelectThread)", root); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs index ea140645b..4e3699ee9 100644 --- a/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs @@ -223,86 +223,83 @@ public void ReactorTimeline_RequeuesOnlyForCompletedHistoryReplacement() [Fact] public void ReactorComposer_OffsetsPickerChevronRightAndUp() { - var root = File.ReadAllText(Path.Combine( + var composer = File.ReadAllText(Path.Combine( TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Chat", - "OpenClawReactorChatRoot.cs")); + "ReactorChatComposer.cs")); - Assert.Contains("textBlock.Margin = new Thickness(2, 4, 0, 0)", root); + Assert.Contains("textBlock.Margin = new Thickness(2, 4, 0, 0)", composer); } [Fact] public void ReactorComposer_GatesClickableControlsUntilLayoutIsUsable() { - var root = File.ReadAllText(Path.Combine( + var composer = File.ReadAllText(Path.Combine( TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Chat", - "OpenClawReactorChatRoot.cs")); - - Assert.Contains("internal static class ComposerAutomationVisibility", root); - Assert.Contains("control.IsHitTestVisible = false;", root); - Assert.Contains("control.IsLoaded", root); - Assert.Contains("control.ActualWidth > 0", root); - Assert.Contains("control.ActualHeight > 0", root); - Assert.Contains("AccessibilityView.Raw", root); - Assert.Contains("AccessibilityView.Control", root); + "ReactorChatComposer.cs")); + + Assert.Contains("internal static class ComposerAutomationVisibility", composer); + Assert.Contains("control.IsHitTestVisible = false;", composer); + Assert.Contains("control.IsLoaded", composer); + Assert.Contains("control.ActualWidth > 0", composer); + Assert.Contains("control.ActualHeight > 0", composer); + Assert.Contains("AccessibilityView.Raw", composer); + Assert.Contains("AccessibilityView.Control", composer); Assert.True( - root.Split("AccessibilityView.Raw", StringSplitOptions.None).Length - 1 >= 4); - Assert.Contains(".AutomationId(\"ChatComposerInput\")", root); - Assert.Contains("AutomationProperties.SetAutomationId(", root); - Assert.Contains("RaisePropertyChangedEvent(", root); - Assert.Contains("AutomationElementIdentifiers.IsOffscreenProperty", root); + composer.Split("AccessibilityView.Raw", StringSplitOptions.None).Length - 1 >= 4); + Assert.Contains(".AutomationId(\"ChatComposerInput\")", composer); + Assert.Contains("AutomationProperties.SetAutomationId(", composer); + Assert.Contains("RaisePropertyChangedEvent(", composer); + Assert.Contains("AutomationElementIdentifiers.IsOffscreenProperty", composer); Assert.Equal( 4, - root.Split( + composer.Split( "ComposerAutomationVisibility.Prepare(", StringSplitOptions.None).Length - 1); - Assert.Contains("\"ChatComposerAttach\"", root); - Assert.Contains("\"ChatComposerSpeakerToggle\"", root); - Assert.Contains("\"ChatComposerSessionPicker\"", root); - Assert.Contains("\"ChatComposerModelPicker\"", root); - Assert.Contains("\"ChatComposerReasoningPicker\"", root); - Assert.Contains("\"ChatComposerVoice\"", root); - Assert.Contains("\"ChatComposerSettings\"", root); - Assert.Contains("\"ChatComposerPrimaryAction\"", root); + Assert.Contains("\"ChatComposerAttach\"", composer); + Assert.Contains("\"ChatComposerSpeakerToggle\"", composer); + Assert.Contains("\"ChatComposerSessionPicker\"", composer); + Assert.Contains("\"ChatComposerModelPicker\"", composer); + Assert.Contains("\"ChatComposerReasoningPicker\"", composer); + Assert.Contains("\"ChatComposerVoice\"", composer); + Assert.Contains("\"ChatComposerSettings\"", composer); + Assert.Contains("\"ChatComposerPrimaryAction\"", composer); } [Fact] public void ReactorComposer_BoundsAndAnnouncesQueuedMessages() { - var root = File.ReadAllText(Path.Combine( + var composer = File.ReadAllText(Path.Combine( TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Chat", - "OpenClawReactorChatRoot.cs")); + "ReactorChatComposer.cs")); - Assert.Contains("ScrollView(VStack(4, queuedRows))", root); - Assert.Contains(".MaxHeight(props.IsCompact ? 144 : 220)", root); - Assert.Contains("AutomationLiveSetting.Polite", root); + Assert.Contains("ScrollView(VStack(4, queuedRows))", composer); + Assert.Contains(".MaxHeight(props.IsCompact ? 144 : 220)", composer); + Assert.Contains("AutomationLiveSetting.Polite", composer); } [Fact] public void ReactorComposer_ReattachesStableImagePasteHandlerAfterRemount() { - var root = File.ReadAllText(Path.Combine( + var composer = File.ReadAllText(Path.Combine( TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Chat", - "OpenClawReactorChatRoot.cs")); - var composer = root[root.IndexOf( - "public sealed class ReactorChatComposer", - StringComparison.Ordinal)..]; + "ReactorChatComposer.cs")); const string callbackRef = - "var onAttachmentPasted = UseRef>(props.OnAttachmentPasted);"; + "var controllerRef = UseRef(controller);"; const string callbackAssignment = - "onAttachmentPasted.Current = props.OnAttachmentPasted;"; + "controllerRef.Current = controller;"; const string handlerRef = "var pasteHandler = UseRef(async (_, args) =>"; const string mount = @@ -330,24 +327,21 @@ public void ReactorComposer_ReattachesStableImagePasteHandlerAfterRemount() "Windows.ApplicationModel.DataTransfer.Clipboard.GetContent()", pasteHandlerBody); Assert.Contains( - "await PasteImageFromClipboardAsync(clipboardContent, onAttachmentPasted.Current)", + "await controllerRef.Current.PasteImageAsync(clipboardContent);", composer); - Assert.Contains("onAttachmentPasted(attachment);", composer); + Assert.DoesNotContain("TryReadImageFromClipboardAsync", composer); Assert.DoesNotContain("pasteHooked", composer); } [Fact] public void ReactorComposer_UsesBitmapOnlyContextMenuThatReentersStablePastePath() { - var root = File.ReadAllText(Path.Combine( + var composer = File.ReadAllText(Path.Combine( TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Chat", - "OpenClawReactorChatRoot.cs")); - var composer = root[root.IndexOf( - "public sealed class ReactorChatComposer", - StringComparison.Ordinal)..]; + "ReactorChatComposer.cs")); Assert.Contains("textBox.ContextFlyout = CreateComposerContextFlyout(", composer); Assert.Contains("textBox.ContextFlyout = null;", composer); @@ -383,12 +377,12 @@ public void ReactorComposer_UsesBitmapOnlyContextMenuThatReentersStablePastePath Assert.DoesNotContain("TryReadImageFromClipboardAsync", menuFactory); Assert.Contains("GetBitmapClipboardContent()", menuFactory); Assert.Contains( - "_ = PasteImageFromClipboardAsync(clipboardContent, getOnAttachmentPasted())", + "_ = getController().PasteImageAsync(clipboardContent);", menuFactory); Assert.Equal( 1, composer.Split( - "await PasteImageFromClipboardAsync(clipboardContent, onAttachmentPasted.Current)", + "await controllerRef.Current.PasteImageAsync(clipboardContent);", StringSplitOptions.None).Length - 1); Assert.Contains("PasteTextFromClipboard(textBox);", menuFactory); Assert.Contains("private static void PasteTextFromClipboard(TextBox textBox)", composer); @@ -459,15 +453,12 @@ public void ChatComposerContextMenuState_ProjectsNativeCommandVisibility() [Fact] public void ReactorComposer_UsesReactorThemeResourcesWithoutManualThemeObservation() { - var root = File.ReadAllText(Path.Combine( + var composer = File.ReadAllText(Path.Combine( TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Chat", - "OpenClawReactorChatRoot.cs")); - var composer = root[root.IndexOf( - "public sealed class ReactorChatComposer", - StringComparison.Ordinal)..]; + "ReactorChatComposer.cs")); Assert.Contains("UseColorScheme()", composer); Assert.Contains(".Background(Theme.ControlFill)", composer); diff --git a/tests/OpenClaw.Tray.Tests/FakeChatComposerRuntimePort.cs b/tests/OpenClaw.Tray.Tests/FakeChatComposerRuntimePort.cs new file mode 100644 index 000000000..9c10fd881 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/FakeChatComposerRuntimePort.cs @@ -0,0 +1,155 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Chat; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Tray.Tests; + +/// +/// Fake that records every call so +/// characterization tests can assert exact delegation counts/arguments without a +/// real or gateway bridge. Every method is +/// independently gated by a completed-by-default +/// so tests can hold a call in flight (delayed send, in-flight attachment additions). +/// +internal sealed class FakeChatComposerRuntimePort : IChatComposerRuntimePort +{ + public bool SupportsNativeLifecycle { get; set; } = true; + + public int SendMessageCallCount { get; private set; } + public (string ThreadId, string Message, IReadOnlyList Attachments)? LastSendMessageCall { get; private set; } + public CancellationToken? LastSendMessageToken { get; private set; } + public TaskCompletionSource SendMessageGate { get; set; } = Completed(true); + + public int EnqueueCompactCallCount { get; private set; } + public string? LastCompactThreadId { get; private set; } + public TaskCompletionSource EnqueueCompactGate { get; set; } = Completed(true); + + public int ExecuteLifecycleCallCount { get; private set; } + public (string ThreadId, ChatLifecycleCommandKind Command)? LastLifecycleCall { get; private set; } + public TaskCompletionSource ExecuteLifecycleGate { get; set; } = + CompletedResult(new ChatLifecycleCommandResult(ChatLifecycleCommandKind.New, Succeeded: true)); + + public int StopCallCount { get; private set; } + public string? LastStopThreadId { get; private set; } + public CancellationToken? LastStopToken { get; private set; } + public TaskCompletionSource StopGate { get; set; } = CompletedVoid(); + + public int CancelQueuedCallCount { get; private set; } + public (string ThreadId, string MessageId)? LastCancelQueuedCall { get; private set; } + public CancellationToken? LastCancelQueuedToken { get; private set; } + + public int SetModelCallCount { get; private set; } + public (string ThreadId, string Model)? LastSetModelCall { get; private set; } + public List SetModelCallOrder { get; } = new(); + public CancellationToken? LastSetModelToken { get; private set; } + public TaskCompletionSource SetModelGate { get; set; } = CompletedVoid(); + + public int ClearModelCallCount { get; private set; } + public string? LastClearModelThreadId { get; private set; } + public CancellationToken? LastClearModelToken { get; private set; } + + public int SetThinkingLevelCallCount { get; private set; } + public (string ThreadId, string Level)? LastSetThinkingLevelCall { get; private set; } + public CancellationToken? LastSetThinkingLevelToken { get; private set; } + + public int EnsureCommandCatalogCallCount { get; private set; } + public CancellationToken? LastEnsureCommandCatalogToken { get; private set; } + + public Task SendMessageAsync( + string threadId, + string message, + IReadOnlyList attachments, + CancellationToken cancellationToken) + { + SendMessageCallCount++; + LastSendMessageCall = (threadId, message, attachments); + LastSendMessageToken = cancellationToken; + return SendMessageGate.Task; + } + + public Task EnqueueCompactCommandAsync(string threadId) + { + EnqueueCompactCallCount++; + LastCompactThreadId = threadId; + return EnqueueCompactGate.Task; + } + + public Task ExecuteLifecycleCommandAsync(string threadId, ChatLifecycleCommandKind command) + { + ExecuteLifecycleCallCount++; + LastLifecycleCall = (threadId, command); + return ExecuteLifecycleGate.Task; + } + + public Task StopResponseAsync(string threadId, CancellationToken cancellationToken) + { + StopCallCount++; + LastStopThreadId = threadId; + LastStopToken = cancellationToken; + return StopGate.Task; + } + + public Task CancelQueuedMessageAsync(string threadId, string queuedMessageId, CancellationToken cancellationToken) + { + CancelQueuedCallCount++; + LastCancelQueuedCall = (threadId, queuedMessageId); + LastCancelQueuedToken = cancellationToken; + return Task.CompletedTask; + } + + public Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken) + { + SetModelCallCount++; + LastSetModelCall = (threadId, model); + SetModelCallOrder.Add(model); + LastSetModelToken = cancellationToken; + return SetModelGate.Task; + } + + public Task ClearModelAsync(string threadId, CancellationToken cancellationToken) + { + ClearModelCallCount++; + LastClearModelThreadId = threadId; + LastClearModelToken = cancellationToken; + return Task.CompletedTask; + } + + public Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken) + { + SetThinkingLevelCallCount++; + LastSetThinkingLevelCall = (threadId, thinkingLevel); + LastSetThinkingLevelToken = cancellationToken; + return Task.CompletedTask; + } + + public Task EnsureCommandCatalogAsync(CancellationToken cancellationToken) + { + EnsureCommandCatalogCallCount++; + LastEnsureCommandCatalogToken = cancellationToken; + return Task.CompletedTask; + } + + private static TaskCompletionSource Completed(bool result) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + tcs.SetResult(result); + return tcs; + } + + private static TaskCompletionSource CompletedResult(ChatLifecycleCommandResult result) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + tcs.SetResult(result); + return tcs; + } + + private static TaskCompletionSource CompletedVoid() + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + tcs.SetResult(); + return tcs; + } +} diff --git a/tests/OpenClaw.Tray.Tests/FakeChatDataProviderForComposerTests.cs b/tests/OpenClaw.Tray.Tests/FakeChatDataProviderForComposerTests.cs new file mode 100644 index 000000000..5a713330b --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/FakeChatDataProviderForComposerTests.cs @@ -0,0 +1,49 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Tray.Tests; + +/// +/// Minimal fake for / +/// tests that only need a valid provider reference, +/// not real chat behavior. All members are no-ops. +/// +internal sealed class FakeChatDataProviderForComposerTests : IChatDataProvider +{ + public string DisplayName => "fake"; + +#pragma warning disable CS0067 // Never raised: this fake only needs a valid provider reference. + public event EventHandler? Changed; + public event EventHandler? NotificationRequested; +#pragma warning restore CS0067 + + public Task LoadAsync(CancellationToken cancellationToken = default) => + throw new NotSupportedException("Not used by session/factory tests."); + + public Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task StopResponseAsync(string threadId, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task RespondToPermissionAsync(string threadId, string requestId, string action, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 0b065434a..cacaa3045 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -67,6 +67,15 @@ + + + + + + + + + diff --git a/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs index 5794a9f64..f83e5f57e 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using OpenClaw.Shared; using OpenClaw.Shared.ExecApprovals; +using OpenClawTray.Chat; using OpenClawTray.Presentation; using OpenClawTray.Services; @@ -72,6 +73,61 @@ public void AppOwnedSingletons_ResolveToTheProvidedInstances() } } + [Fact] + public void ChatComposerFactory_IsRegisteredAsAStatelessSingleton() + { + var provider = BuildProvider(out _, out _, out _, out _, out _, out var temp); + using (provider) + using (temp) + { + var first = provider.GetRequiredService(); + var second = provider.GetRequiredService(); + + Assert.Same(first, second); + Assert.IsType(first); + } + } + + [Fact] + public void ChatComposerFactory_MissingRegistration_GetRequiredServiceThrowsInsteadOfReturningNull() + { + // Proves the fail-fast property the host-composition call sites rely on: + // if IChatComposerFactory were ever NOT registered (a genuine composition + // bug), GetRequiredService throws rather than silently yielding null, which + // is what lets ChatPage/ChatWindow surface the failure through the app's + // existing unhandled-exception/crash-log path instead of conflating it with + // the "disconnected, no provider yet" placeholder. + var temp = new TempDir(); + using (temp) + { + var dispatcher = new RecordingUiDispatcher(); + var commands = new FakeAppCommands(); + var settings = new SettingsManager(temp.Path); + var execApprovalsStore = new ExecApprovalsStore(temp.Path, NullLogger.Instance); + var runtimeHost = new FakePermissionsPageRuntimeHost(); + var services = new ServiceCollection(); + services.AddOpenClawTrayCore(new AppServiceContext( + dispatcher, + commands, + settings, + execApprovalsStore, + runtimeHost)); + + // Simulate the registration being absent by building a container that + // only removes this one registration, keeping everything else intact. + IServiceCollection withoutFactory = new ServiceCollection(); + foreach (var descriptor in services) + { + if (descriptor.ServiceType != typeof(IChatComposerFactory)) + withoutFactory.Add(descriptor); + } + + using var provider = withoutFactory.BuildServiceProvider(); + + Assert.Throws(() => provider.GetRequiredService()); + } + } + [Fact] public void PageViewModels_AreTransient_AndReceiveInjectedServices() { diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs b/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs index 397f6fcdf..b11f0055a 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs @@ -21,8 +21,16 @@ internal sealed class RecordingUiDispatcher : IUiDispatcher, IDisposable /// When false, enqueued actions are held until . public bool RunEnqueuedImmediately { get; set; } = true; + /// When true, refuses the work item (returns + /// false without recording/running it), simulating a dispatcher that is + /// shutting down. + public bool RejectEnqueue { get; set; } + public bool TryEnqueue(Action action) { + if (RejectEnqueue) + return false; + EnqueuedCount++; if (RunEnqueuedImmediately) { diff --git a/tests/OpenClaw.Tray.Tests/ReactorSlashCommandSourceContractTests.cs b/tests/OpenClaw.Tray.Tests/ReactorSlashCommandSourceContractTests.cs index 92a64361b..3de597b90 100644 --- a/tests/OpenClaw.Tray.Tests/ReactorSlashCommandSourceContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/ReactorSlashCommandSourceContractTests.cs @@ -3,56 +3,78 @@ namespace OpenClaw.Tray.Tests; +/// +/// Source-contract tests for the composer's D2 owners: ChatComposerController +/// (send/lifecycle delegation and command-catalog request), ChatComposerViewModel +/// (slash evaluation/reconciliation), and ReactorChatComposer (the view-only +/// popup cache). Updated in D2 to point at the new owner/view seam; see +/// docs/ARCHITECTURE.md for the chat-composer-* ledger rows. +/// public class ReactorSlashCommandSourceContractTests { [Fact] - public void ReactorComposer_WiresSnapshotCommandCatalogAndLazyRequest() + public void ReactorRoot_WiresSnapshotCommandCatalogIntoComposerInputs() { - var source = ReadReactorRootSource(); + var root = ReadSource("OpenClawReactorChatRoot.cs"); - Assert.Contains("snapshot.AvailableCommands", source); - Assert.Contains("snapshot.CommandsSupported", source); - Assert.Contains("() => RunFireAndForget(ct => props.Provider.EnsureCommandCatalogAsync(ct))", source); - Assert.Contains("ReactorSlashCommandController.ShouldRequestCatalogOnOpen", source); + Assert.Contains("AvailableCommands: snapshot.AvailableCommands", root); + Assert.Contains("CommandsSupported: snapshot.CommandsSupported", root); } [Fact] - public void ReactorRoot_SendAsync_RetainsLifecycleDispatcherPath() + public void ChatComposerController_RequestsCatalogThroughTheRuntimePort() { - var source = ReadReactorRootSource(); + var controller = ReadSource("ChatComposerController.cs"); + + Assert.Contains("++_catalogOperation;", controller); + Assert.Contains("FireAndForget(_ => _port.EnsureCommandCatalogAsync(_lifetimeToken));", controller); + } + + [Fact] + public void ChatComposerViewModel_UsesShouldRequestCatalogOnOpen() + { + var viewModel = ReadSource("ChatComposerViewModel.cs"); + + Assert.Contains("ReactorSlashCommandController.ShouldRequestCatalogOnOpen(_awaitingCatalog, SlashDisplay)", viewModel); + } + + [Fact] + public void ChatComposerController_SendCoreAsync_RetainsLifecycleDispatcherPath() + { + var controller = ReadSource("ChatComposerController.cs"); AssertInOrder( - source, + controller, "ChatLifecycleCommandParser.TryParse(message, attachments.Count > 0, out var command)", "ChatLifecycleCommandExecutionPolicy.ShouldQueue(command)", - "native.ExecuteLifecycleCommandAsync(threadId, command)", - "provider.SendMessageAsync(threadId, message, CancellationToken.None, attachments)"); + "_port.ExecuteLifecycleCommandAsync(threadId, command)", + "_port.SendMessageAsync(threadId, message, attachments, _lifetimeToken)"); } [Fact] - public void ReactorComposer_EvaluatesTheStoredSlashStateWithoutReopeningDismissedText() + public void ChatComposerViewModel_EvaluatesTheStoredSlashStateWithoutReopeningDismissedText() { - var source = ReadReactorRootSource(); - var evaluationStart = source.IndexOf( - "var slashDisplay = ReactorSlashCommandController.Evaluate(", + var viewModel = ReadSource("ChatComposerViewModel.cs"); + var evaluationStart = viewModel.IndexOf( + "SlashDisplay = ReactorSlashCommandController.Evaluate(", StringComparison.Ordinal); Assert.True(evaluationStart >= 0); Assert.True( - source.IndexOf("slashMenuState,", evaluationStart, StringComparison.Ordinal) >= 0); - Assert.DoesNotContain("resolvedSlashMenuState", source); + viewModel.IndexOf("_slashMenuState,", evaluationStart, StringComparison.Ordinal) >= 0); + Assert.DoesNotContain("resolvedSlashMenuState", viewModel); } [Fact] public void ReactorComposer_CachesStablePopupContentBeforeApplyingTheme() { - var source = ReadReactorRootSource(); + var composer = ReadSource("ReactorChatComposer.cs"); - Assert.Contains("var slashPopupContentRef = UseRef", source); - Assert.Contains("slashPopupContentRef.Current.Key == popupStateKey", source); + Assert.Contains("var slashPopupContentRef = UseRef", composer); + Assert.Contains("slashPopupContentRef.Current.Key == popupStateKey", composer); } - private static string ReadReactorRootSource() + private static string ReadSource(string fileName) { var root = TestRepositoryPaths.GetRepositoryRoot(); return File.ReadAllText(Path.Combine( @@ -60,7 +82,7 @@ private static string ReadReactorRootSource() "src", "OpenClaw.Tray.WinUI", "Chat", - "OpenClawReactorChatRoot.cs")); + fileName)); } private static void AssertInOrder(string source, params string[] fragments) diff --git a/tests/OpenClaw.Tray.UITests/ChatComposerControllerPasteFencingProofTests.cs b/tests/OpenClaw.Tray.UITests/ChatComposerControllerPasteFencingProofTests.cs new file mode 100644 index 000000000..ab9a6522b --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/ChatComposerControllerPasteFencingProofTests.cs @@ -0,0 +1,314 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Chat; +using OpenClawTray.Presentation.Adapters; +using Windows.ApplicationModel.DataTransfer; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +namespace OpenClaw.Tray.UITests; + +/// +/// Real-WinRT proof for the paste-image operation fence added to +/// : a monotonic paste operation ID plus a +/// dedicated that a new paste +/// cancels/supersedes, and disposal cancels/fences so a late decode can never add a +/// stale attachment. Runs on the shared because the +/// clipboard bitmap decode pipeline requires a live WinRT apartment. +/// +[Collection(UICollection.Name)] +public sealed class ChatComposerControllerPasteFencingProofTests +{ + private readonly UIThreadFixture _ui; + + public ChatComposerControllerPasteFencingProofTests(UIThreadFixture ui) => _ui = ui; + + private sealed class NoopChatDataProvider : IChatDataProvider + { + public string DisplayName => "noop"; +#pragma warning disable CS0067 + public event EventHandler? Changed; + public event EventHandler? NotificationRequested; +#pragma warning restore CS0067 + public Task LoadAsync(CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + public Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public Task StopResponseAsync(string threadId, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public Task RespondToPermissionAsync(string threadId, string requestId, string action, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private static (ChatComposerViewModelHandle Vm, ChatComposerController Controller) MakeController( + UIThreadFixture ui) + { + var dispatcher = new WinUIDispatcher(ui.Dispatcher); + var factory = new ChatComposerFactory(dispatcher); + var provider = new NoopChatDataProvider(); + var hostActions = new ChatComposerHostActions(null, null, null, null, null); + var session = factory.Create(provider, hostActions, initialSpeakerMuted: false); + return (new ChatComposerViewModelHandle(session), session.Controller); + } + + /// Thin internal-visibility accessor so this file does not need to + /// expose new public surface on just for + /// tests: it reads the session's internal ViewModel via the same + /// InternalsVisibleTo grant the rest of this proof relies on. + private sealed class ChatComposerViewModelHandle + { + private readonly ChatComposerSession _session; + public ChatComposerViewModelHandle(ChatComposerSession session) => _session = session; + public int PendingAttachmentCount => _session.ViewModel.PendingAttachments.Count; + public string? LastAttachmentFileName => + _session.ViewModel.PendingAttachments.Count == 0 + ? null + : _session.ViewModel.PendingAttachments[^1].FileName; + public void Dispose() => _session.Dispose(); + } + + private static async Task CreateClipboardBitmapAsync(byte r, byte g, byte b) + { + // A minimal 2x2 BGRA8 bitmap is enough to exercise the real decode/encode + // pipeline without the cost of a large test image. + var pixels = new byte[2 * 2 * 4]; + for (var i = 0; i < pixels.Length; i += 4) + { + pixels[i] = b; + pixels[i + 1] = g; + pixels[i + 2] = r; + pixels[i + 3] = 255; + } + + using var softwareBitmap = SoftwareBitmap.CreateCopyFromBuffer( + CryptographicBufferFromBytes(pixels), + BitmapPixelFormat.Bgra8, + 2, + 2, + BitmapAlphaMode.Premultiplied); + + // Deliberately not disposed: RandomAccessStreamReference.CreateFromStream + // wraps this stream by reference rather than copying it, so the backing + // data must stay alive for as long as the clipboard (and the paste decode + // pipeline reading from it) may still reference it. It is a small + // in-memory buffer in a short-lived test process, so the leak is fine. + var stream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream); + encoder.SetSoftwareBitmap(softwareBitmap); + await encoder.FlushAsync(); + stream.Seek(0); + + var dataPackage = new DataPackage(); + dataPackage.SetBitmap(RandomAccessStreamReference.CreateFromStream(stream)); + return dataPackage.GetView(); + } + + private static IBuffer CryptographicBufferFromBytes(byte[] bytes) + { + using var writer = new DataWriter(); + writer.WriteBytes(bytes); + return writer.DetachBuffer(); + } + + [Fact] + public async Task PasteImageAsync_DisposeWinsDuringPreDecodeHook_NeverStartsGetBitmapOrAddsAttachment() + { + await _ui.RunOnUIAsync(async () => + { + var (vmHandle, controller) = MakeController(_ui); + var clip = await CreateClipboardBitmapAsync(0, 0, 255); + + // Force PasteImageAsync to suspend and yield back to this caller before + // any WinRT clipboard/decode work starts, so Dispose() below + // deterministically wins the "dispose before decode completes" race — + // this does not depend on the real decode pipeline happening to + // suspend before it (rarely, for a trivially small bitmap like this + // one) completes entirely synchronously. + var resumeDecode = new TaskCompletionSource(); + controller.TestOnlyBeforeDecodeAsync = () => resumeDecode.Task; + var getBitmapCalls = 0; + controller.TestOnlyClipboardGetBitmapInitiated = () => getBitmapCalls++; + + var pasteTask = controller.PasteImageAsync(clip); + + // Dispose while the decode has not even been allowed to start yet. + controller.Dispose(); + resumeDecode.SetResult(); + var exception = await Record.ExceptionAsync(() => pasteTask); + + Assert.Null(exception); + Assert.Equal(0, getBitmapCalls); + Assert.Equal(0, vmHandle.PendingAttachmentCount); + }); + } + + [Fact] + public async Task PasteImageAsync_DecodeInitiationWins_DisposeFencesDecodedResult() + { + await _ui.RunOnUIAsync(async () => + { + var (vmHandle, controller) = MakeController(_ui); + var clip = await CreateClipboardBitmapAsync(0, 255, 0); + var getBitmapCalls = 0; + var decoded = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseDecoded = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + controller.TestOnlyClipboardGetBitmapInitiated = () => getBitmapCalls++; + controller.TestOnlyAfterDecodeAsync = async () => + { + decoded.TrySetResult(); + await releaseDecoded.Task; + }; + + var pasteTask = controller.PasteImageAsync(clip); + await decoded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + controller.Dispose(); + releaseDecoded.SetResult(); + var exception = await Record.ExceptionAsync(() => pasteTask); + + Assert.Null(exception); + Assert.Equal(1, getBitmapCalls); + Assert.Equal(0, vmHandle.PendingAttachmentCount); + }); + } + + [Fact] + public async Task PasteImageAsync_GetBitmapInitiationWins_BlocksDisposeUntilHostCallStarts() + { + await _ui.RunOnUIAsync(async () => + { + var (vmHandle, controller) = MakeController(_ui); + var clip = await CreateClipboardBitmapAsync(0, 255, 255); + using var disposeStarted = new ManualResetEventSlim(); + Thread? disposeThread = null; + Exception? disposeException = null; + var getBitmapCalls = 0; + var disposeBlockedOnGate = false; + var disposeWasAliveWhileGateHeld = false; + controller.TestOnlyClipboardGetBitmapInitiated = () => + { + getBitmapCalls++; + disposeThread = new Thread(() => + { + disposeStarted.Set(); + try { controller.Dispose(); } + catch (Exception ex) { disposeException = ex; } + }); + disposeThread.Start(); + if (disposeStarted.Wait(TimeSpan.FromSeconds(5))) + { + disposeBlockedOnGate = SpinWait.SpinUntil( + () => (disposeThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)); + disposeWasAliveWhileGateHeld = disposeThread.IsAlive; + } + }; + + var pasteTask = controller.PasteImageAsync(clip); + + Assert.NotNull(disposeThread); + Assert.True(disposeThread!.Join(TimeSpan.FromSeconds(5))); + var exception = await Record.ExceptionAsync(() => pasteTask); + + Assert.Null(disposeException); + Assert.Null(exception); + Assert.True(disposeBlockedOnGate, "Dispose did not block on the held paste-initiation gate."); + Assert.True(disposeWasAliveWhileGateHeld); + Assert.Equal(1, getBitmapCalls); + Assert.Equal(0, vmHandle.PendingAttachmentCount); + }); + } + + [Fact] + public async Task PasteImageAsync_OrdinaryPaste_AddsExactlyOneAttachment() + { + await _ui.RunOnUIAsync(async () => + { + var (vmHandle, controller) = MakeController(_ui); + var clip = await CreateClipboardBitmapAsync(64, 128, 192); + var getBitmapCalls = 0; + controller.TestOnlyClipboardGetBitmapInitiated = () => getBitmapCalls++; + + await controller.PasteImageAsync(clip); + await _ui.YieldToRenderAsync(); + + Assert.Equal(1, getBitmapCalls); + Assert.Equal(1, vmHandle.PendingAttachmentCount); + Assert.StartsWith("pasted-image-", vmHandle.LastAttachmentFileName); + vmHandle.Dispose(); + }); + } + + [Fact] + public async Task PasteImageAsync_NewPasteSupersedesPreDecodePaste_OnlyLatestAdds() + { + await _ui.RunOnUIAsync(async () => + { + var (vmHandle, controller) = MakeController(_ui); + var firstClip = await CreateClipboardBitmapAsync(255, 0, 255); + var secondClip = await CreateClipboardBitmapAsync(255, 255, 0); + var releaseFirst = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var hookCalls = 0; + var getBitmapCalls = 0; + controller.TestOnlyBeforeDecodeAsync = () => + Interlocked.Increment(ref hookCalls) == 1 + ? releaseFirst.Task + : Task.CompletedTask; + controller.TestOnlyClipboardGetBitmapInitiated = () => getBitmapCalls++; + + var firstPaste = controller.PasteImageAsync(firstClip); + var secondPaste = controller.PasteImageAsync(secondClip); + await secondPaste; + releaseFirst.SetResult(); + await firstPaste; + await _ui.YieldToRenderAsync(); + + Assert.Equal(1, getBitmapCalls); + Assert.Equal(1, vmHandle.PendingAttachmentCount); + vmHandle.Dispose(); + }); + } + + [Fact] + public async Task PasteImageDispose_RaceStress_NeverStartsDecodeAfterDisposeOrThrows() + { + await _ui.RunOnUIAsync(async () => + { + var clip = await CreateClipboardBitmapAsync(255, 0, 0); + for (var iteration = 0; iteration < 50; iteration++) + { + var (vmHandle, controller) = MakeController(_ui); + var resumeDecode = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var getBitmapCalls = 0; + controller.TestOnlyBeforeDecodeAsync = () => resumeDecode.Task; + controller.TestOnlyClipboardGetBitmapInitiated = () => getBitmapCalls++; + + var pasteTask = controller.PasteImageAsync(clip); + controller.Dispose(); + controller.Dispose(); + resumeDecode.SetResult(); + var exception = await Record.ExceptionAsync(() => pasteTask); + + Assert.Null(exception); + Assert.Equal(0, getBitmapCalls); + Assert.Equal(0, vmHandle.PendingAttachmentCount); + } + }); + } +} diff --git a/tests/OpenClaw.Tray.UITests/MountedReactorChatDisposalProofTests.cs b/tests/OpenClaw.Tray.UITests/MountedReactorChatDisposalProofTests.cs new file mode 100644 index 000000000..1fc461ee5 --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/MountedReactorChatDisposalProofTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.UI.Reactor.Hosting; +using Microsoft.UI.Xaml.Controls; +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Chat; +using OpenClawTray.Presentation; +using OpenClawTray.Presentation.Adapters; +using static Microsoft.UI.Reactor.Factories; + +namespace OpenClaw.Tray.UITests; + +/// +/// Real-WinUI proof that is first-wins and +/// idempotent: session/callback/host/target teardown happens exactly once even when +/// Dispose() is called repeatedly. Runs on the shared +/// because / require a live WinUI +/// dispatcher and XamlRoot. +/// +[Collection(UICollection.Name)] +public sealed class MountedReactorChatDisposalProofTests +{ + private readonly UIThreadFixture _ui; + + public MountedReactorChatDisposalProofTests(UIThreadFixture ui) => _ui = ui; + + private sealed class NoopChatDataProvider : IChatDataProvider + { + public string DisplayName => "noop"; +#pragma warning disable CS0067 + public event EventHandler? Changed; + public event EventHandler? NotificationRequested; +#pragma warning restore CS0067 + public Task LoadAsync(CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + public Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public Task StopResponseAsync(string threadId, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public Task RespondToPermissionAsync(string threadId, string requestId, string action, CancellationToken cancellationToken = default) => + Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + [Fact] + public async Task Dispose_RepeatedCalls_TearDownExactlyOnce() + { + await _ui.RunOnUIAsync(() => + { + var dispatcher = new WinUIDispatcher(_ui.Dispatcher); + var factory = new ChatComposerFactory(dispatcher); + var provider = new NoopChatDataProvider(); + var hostActions = new ChatComposerHostActions(null, null, null, null, null); + var session = factory.Create(provider, hostActions, initialSpeakerMuted: false); + + var target = new Border(); + _ui.Container.Children.Add(target); + var host = new ReactorHostControl(); + host.Mount(_ => Empty()); + target.Child = host; + + var callbacks = new ReactorChatHostCallbacks + { + AttachFiles = _ => { }, + }; + var mounted = new MountedReactorChat(target, host, callbacks, session); + + // First call performs real teardown. + mounted.Dispose(); + Assert.Null(target.Child); + Assert.Null(callbacks.AttachFiles); + + // Second (and third) calls must be pure no-ops: no exception, no + // observable change, and — critically — no second call into + // ReactorHostControl.Dispose(), which is not itself guaranteed + // idempotent by the Reactor library. + var secondCallException = Record.Exception(mounted.Dispose); + var thirdCallException = Record.Exception(mounted.Dispose); + + Assert.Null(secondCallException); + Assert.Null(thirdCallException); + Assert.Null(target.Child); + Assert.Null(callbacks.AttachFiles); + + _ui.Container.Children.Remove(target); + }); + } +}