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