diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 93747fce7..fe0f2f603 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -230,27 +230,26 @@ Useful local scripts: ## Architecture Overview -### Native chat surface (FunctionalUI + OpenClaw.Chat) +### Native chat surface (Reactor + OpenClaw.Chat) The Hub Chat tab (`src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml`) and the tray ChatWindow popup (`src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml`) render their conversations with native WinUI 3 controls via the in-repo -`OpenClawTray.FunctionalUI` helper and `OpenClaw.Chat` model/reducer code. +Reactor components and `OpenClaw.Chat` model/reducer code. The standard WebView2-hosted gateway web client remains available as a settings-controlled fallback. **Layering:** ``` -src/OpenClaw.Tray.WinUI/Chat/ OpenClawChatTimeline · OpenClawComposer · OpenClawSessionHeader +src/OpenClaw.Tray.WinUI/Chat/ OpenClawReactorChatRoot · ReactorChatTimeline · ReactorChatComposer OpenClawChatDataProvider (adapts OpenClawGatewayClient → IChatDataProvider) - OpenClawChatRoot (FunctionalUI component composing the chat surface) - FunctionalChatHostExtensions (mounts FunctionalUI into a XAML ) - IChatGatewayBridge (testability seam over OpenClawGatewayClient) + ReactorChatHostExtensions (mounts Reactor into a XAML ) + IChatGatewayBridge (testability seam over OpenClawGatewayClient) ▲ depends on src/OpenClaw.Chat/ ChatThread · ChatTimelineState · IChatDataProvider · ChatTimelineReducer ▲ rendered by -src/OpenClawTray.FunctionalUI/ Component · RenderContext · FunctionalHostControl · WinUI elements +Reactor.WinUI Component · hooks · ReactorHostControl · WinUI elements ``` **Lifecycle:** @@ -259,12 +258,13 @@ src/OpenClawTray.FunctionalUI/ Component · RenderContext · FunctionalHostCon created in `InitializeGatewayClient` and disposed inside `UnsubscribeGatewayEvents`. Both the Hub Chat tab and the tray ChatWindow consume the same provider - opening either surface shows identical state. -- Each XAML host (`ChatPage`, `ChatWindow`) mounts its own `FunctionalHostControl` - with `ContentTarget` pointing at a ``. The +- `ReactorChatHostExtensions` mounts a dedicated `ReactorHostControl` for each + XAML surface (`ChatPage`, `ChatWindow`) as the child of its + ``. The surrounding chrome (NavigationView, popup header) stays XAML. - Provider events fire on the WebSocket-receive thread; the provider marshals `Changed` / `NotificationRequested` callbacks through a - dispatcher post delegate (`DispatcherQueue.AsPost()`), so FunctionalUI + dispatcher post delegate (`DispatcherQueue.AsPost()`), so Reactor components observe state on the UI thread. **Adding new chat behavior:** model new events in `OpenClaw.Chat`'s diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5f575a5cd..59e766128 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -74,8 +74,8 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | --- | --- | | `src/OpenClaw.Tray.WinUI/App.xaml.cs` | `IWindowManager`, `ITrayController`, `IActivationRouter`, `ISettingsChangeCoordinator`, `AppBootstrapper` | | `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs` | `ChatSendQueue`, `ChatBridgeEventPump`, `ChatHistoryLoader`, `ChatSnapshotProjector`, `AttachmentMetadataStore`; pure native tool projection stays in `NativeToolProjector` | -| `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/ReactorChatTimeline.cs` | `ChatBubbleRenderer`, `PermissionRequestCard`, `AttachmentBubbleRenderer`; tool rendering stays in `ToolCallCardRenderer` | +| `src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs` | `ComposerViewModel`, `SlashCommandPalette`, `AttachmentPreviewStrip`, `VoiceComposerController` | | `src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs` | `ConnectionPagePlan` (pure), `ConnectionPageViewModel`, 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/Services/NodeService.cs` | `McpServerHost`, `CanvasWindowManager`, `MediaCapabilityHost`, `RecordingConsentService`, `NodeCapabilityRegistry` | @@ -135,11 +135,11 @@ leading and trailing pipe. Columns, in order: | navigation-scope | authoritative | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs | page view-model activation/deactivation and disposal lifetime | NavigationScopeManager | HubWindow keeps frame navigation back-stack and rail selection | transient page view models are activated on navigation and deactivated then disposed on navigate-away | NavigationScopeManagerTests.NavigatingAway_DeactivatesAndDisposesPreviousViewModel | behavioral | - | | composition-root | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | presentation-layer service construction and wiring | AppServiceRegistration | App remains the composition root and owns non-DI service lifetimes | one validated root ServiceProvider; App-owned singletons registered as instances are never disposed by the container | AppServiceRegistrationTests.Dispose_DoesNotDisposeAppOwnedInstanceSingletons | behavioral | - | | node-summary-text | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | node-summary clipboard text formatting | NodeSummaryText | App keeps the clipboard side effect (building the DataPackage and setting clipboard content) | copied node-summary text is projected only by NodeSummaryText.Build (online/offline state, display-name fallback, short id, detail text, newline join) | NodeSummaryTextTests.Build_MultipleNodes_OneLinePerNodeJoinedByNewline | behavioral | - | -| reactor-chat-timeline | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | production chat message virtualization, row realization, and imperative scroll follow | ReactorChatTimeline through OpenClawReactorChatRoot and ReactorHostControl | OpenClawChatTimeline remains a legacy focused-test surface while its runtime route is migrated | the default chat route mounts one direct ReactorHostControl per XAML chat target; Reactor owns stable-key ItemsView and ItemContainer realization without a custom native list, collection reconciler, or scroll-layout mutation | review-only: user explicitly deferred new tests for this migration; required build and existing shared/tray suites still run | review-only | when Reactor timeline proof coverage replaces the legacy focused UI host coverage | +| reactor-chat-timeline | authoritative | removed legacy FunctionalUI chat timeline | production chat message virtualization, row realization, and imperative scroll follow | ReactorChatTimeline through OpenClawReactorChatRoot and ReactorHostControl | - | the default chat route mounts one direct ReactorHostControl per XAML chat target; Reactor owns stable-key ItemsView and ItemContainer realization without a custom native list, collection reconciler, or scroll-layout mutation | ChatTimelinePresentationTests.ReactorTimeline_UsesNonSelectableItemsViewContainersAndAnnotatedScrollBar | source-shape | when ReactorChatTimeline is replaced as the production virtualization owner | | chat-tool-activity-renderer | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs | production standalone tool-call and grouped activity presentation, summaries, disclosures, and detail rendering | ChatToolActivityPresentation + ToolCallCardRenderer | ReactorChatTimeline projects rows and delegates realization only | consecutive invocation grouping preserves source chronology; stable group identity comes from session, generation, and first tool entry; selectable output remains capped at 240px | ChatToolActivityPresentationTests.Project_GroupsOnlyConsecutiveSpansOfAtLeastTwoTools | behavioral | - | | chat-history-replay-projection | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | array-valued history content ordering projection | ChatHistoryReplayProjection | provider applies projected text and tool parts to the reducer | interleaved text, calls, and results replay in source order without clearing active tool correlation | OpenClawChatDataProviderTests.LoadHistoryAsync_InterleavedContentParts_PreserveChronologyAndCorrelation | behavioral | - | | reactor-tool-rendering-closed | closed | src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs | per-tool and grouped activity summary/detail rendering implementation | ToolCallCardRenderer | row projection, virtualization, hover state, assistant runs, and renderer delegation only | ReactorChatTimeline contains no tool detail renderer and delegates both standalone and grouped tool rows | ChatTimelinePresentationTests.ReactorTimeline_DelegatesToolAndActivityRenderingToFocusedOwner | source-shape | when ReactorChatTimeline is replaced as the production virtualization owner | -| functional-chat-default-mount | closed | src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs | mounting the FunctionalUI chat tree as the default ChatPage or ChatWindow surface | ReactorChatHostExtensions and OpenClawReactorChatRoot | legacy FunctionalUI chat files may remain for focused compatibility coverage only | ChatPage and ChatWindow mount the Reactor root directly into their existing ChatHost Borders; no FunctionalUI component mounts or nests Reactor on the default path | review-only: user explicitly deferred new tests for this migration; required build and existing shared/tray suites still run | review-only | when legacy FunctionalUI chat surfaces are removed | +| functional-chat-default-mount | closed | src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs | mounting the FunctionalUI chat tree as the default ChatPage or ChatWindow surface | ReactorChatHostExtensions and OpenClawReactorChatRoot | - | ChatPage and ChatWindow mount the Reactor root directly into their existing ChatHost Borders through ReactorChatHostExtensions | ChatToolCallsToggleContractTests.ProductionChatSurfaces_MountReactorRoot | source-shape | when ReactorChatHostExtensions is replaced as the authoritative production chat mount owner | | settings-store | authoritative | src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs | hand-rolled save/echo suppression flags for two-way settings binding | ISettingsStore | PermissionsPage and other surfaces may read SettingsManager directly until migrated | a save originating from Update does not echo Changed to the caller and external saves are republished on the UI thread | SettingsStoreTests.Update_DoesNotEchoChangedToSelf | behavioral | when all settings surfaces read and write through ISettingsStore | | settings-page-vm | authoritative | src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs | settings load, persist, echo-guard, and auto-save wiring | SettingsPageViewModel | code-behind keeps gateway-uninstall, gateway-info and uptime timer, saved-indicator visual, and app-info population | each settings control persists its field through the store preserving mutate-save-notify order and does not re-persist on external change | SettingsPageViewModelTests.ExternalChange_ReloadsWithoutRePersisting | behavioral | when the Settings page holds no settings persistence logic in code-behind | diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 00b4c5d6b..89c182512 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -1993,8 +1993,8 @@ private void ClearPendingRequests() // to preserve the chat approval banner on disconnect-mid-flight. The // OperationCanceledException thrown here is intentionally a connection // lifecycle signal, NOT a benign cancel. RunFireAndForget in the tray - // (OpenClawChatRoot) silently swallows OperationCanceledException — - // if a caller forwards the OCE up to RunFireAndForget instead of + // chat root silently absorbs OperationCanceledException. + // If a caller forwards the OCE up to RunFireAndForget instead of // catching it locally, the banner will be cleared with no UI feedback. // Today OpenClawChatDataProvider.RespondToPermissionAsync correctly // catches Exception ex; do not narrow that catch. diff --git a/src/OpenClaw.Tray.WinUI/App.xaml b/src/OpenClaw.Tray.WinUI/App.xaml index 97ec85d3d..c43747e1c 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml +++ b/src/OpenClaw.Tray.WinUI/App.xaml @@ -37,21 +37,7 @@ - - 1 False - - - - - - @@ -66,15 +52,7 @@ - - 1 False - - - - - - @@ -89,32 +67,10 @@ - - 2 True - - - - - - - - - - diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatMarkdownSanitizer.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatMarkdownSanitizer.cs index a33bdb1f4..8626bafb1 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatMarkdownSanitizer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatMarkdownSanitizer.cs @@ -36,7 +36,7 @@ internal static class ChatMarkdownSanitizer /// /// Flatten a parsed Markdown link's display text + destination URI /// into a single inert plain-text string. Used by the - /// OpenClawChatTimeline rendering path so + /// ReactorChatTimeline rendering path so /// that links the parser DOES emit (bare URLs, autolinks /// <https://…>) collapse to non-clickable text instead /// of -style runs. diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatTimelinePresentationContext.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatTimelinePresentationContext.cs new file mode 100644 index 000000000..134b3004c --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatTimelinePresentationContext.cs @@ -0,0 +1,25 @@ +using OpenClaw.Chat; + +namespace OpenClawTray.Chat; + +/// +/// Presentation inputs shared by the Reactor chat timeline and its focused card renderers. +/// +public sealed record ChatTimelinePresentationContext( + string? SessionId, + IReadOnlyList Entries, + bool HasMoreHistory, + Action? OnLoadMoreHistory, + IReadOnlyDictionary? EntryMetadata = null, + long TimelineGeneration = 0, + string UserSenderLabel = "OpenClaw Windows Tray", + string AssistantSenderLabel = "Field", + string? DefaultModel = null, + string? DefaultUsageSummary = null, + bool ShowThinkingIndicator = false, + bool ShowToolCalls = true, + int ToolCallsCollapseVersion = 0, + Func? OnReadAloud = null, + Action? OnStopSpeaking = null, + int ScrollToBottomToken = 0, + Action? OnPermissionResponse = null); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs deleted file mode 100644 index f438e9756..000000000 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs +++ /dev/null @@ -1,925 +0,0 @@ -using OpenClaw.Chat; -using OpenClaw.Shared; -using OpenClawTray.Helpers; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using OpenClawTray.FunctionalUI; -using OpenClawTray.FunctionalUI.Core; -using OpenClawTray.Services; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using static OpenClawTray.FunctionalUI.Factories; -using static OpenClawTray.FunctionalUI.Core.Theme; - -namespace OpenClawTray.Chat; - -/// - /// FunctionalUI root component used to render the OpenClaw chat surface (Header -/// + Timeline + InputBar + StatusBar). The surrounding XAML window/page owns -/// session navigation (via the existing NavigationView/SessionsPage) so -/// no Sidebar is rendered here. -/// -public sealed class OpenClawChatRoot : Component -{ - private static bool s_showToolCalls = true; - private static int s_toolCallsCollapseVersion; - private static event EventHandler? ToolCallsVisibilityChanged; - - /// - /// Sets whether tool-call / usage chips are shown in the chat timeline. This - /// is the single writer for the tool-call visibility state now that the - /// toggle lives in the Settings "Chat" section (previously a composer - /// toggle). Bumps the collapse version when hiding so already-expanded tool - /// chips collapse, updates the shared static, and notifies any mounted - /// so its timeline re-renders. - /// - public static void SetToolCallsVisible(bool visible) - { - if (!visible && s_showToolCalls) - s_toolCallsCollapseVersion++; - s_showToolCalls = visible; - ToolCallsVisibilityChanged?.Invoke(null, EventArgs.Empty); - } - - private readonly IChatDataProvider _provider; - private readonly string? _initialThreadId; - private readonly Func? _onReadAloud; - private readonly Action? _onStopSpeaking; - private readonly Func>? _onVoiceRequest; - private readonly Action? _onAttachClick; - private readonly Action? _onSettingsClick; - private readonly Action? _onSpeakerMuteChanged; - private readonly Func>? _confirmResetAsync; - private readonly bool _initialMuted; - private readonly bool _isCompact; - private Action>? _onFilesAttached; - private Action? _setVoiceTranscript; - private Action? _setVoiceAudioLevel; - private Action? _scrollToBottomToken; - private Action? _selectThread; - private string? _pendingSelectedThreadId; - /// - /// Programmatically start voice recording from outside the composer. - /// Set by the composer during render. - /// - public Action? TriggerVoiceRecording { get; set; } - - /// - /// Push mute state from outside (e.g. when another chat view toggles mute). - /// Set by render. - /// - public Action? SetSpeakerMuted { get; set; } - - /// - /// Callback invoked by the host window/page after a file is selected. - /// Appends pending attachments and triggers a re-render. - /// - public Action>? OnFilesAttached - { - get => _onFilesAttached; - set => _onFilesAttached = value; - } - - /// - /// Push streaming voice transcript text into the composer UI. - /// Set to null when recording stops to clear the display. - /// - public Action? SetVoiceTranscript - { - get => _setVoiceTranscript; - set => _setVoiceTranscript = value; - } - - /// - /// Push the current audio input level (0.0–1.0) into the composer UI. - /// - public Action? SetVoiceAudioLevel - { - get => _setVoiceAudioLevel; - set => _setVoiceAudioLevel = value; - } - - public OpenClawChatRoot( - IChatDataProvider provider, - string? initialThreadId = null, - Func? onReadAloud = null, - Action? onStopSpeaking = null, - Func>? onVoiceRequest = null, - Action? onAttachClick = null, - Action? onSettingsClick = null, - Action? onSpeakerMuteChanged = null, - Func>? confirmResetAsync = null, - bool initialMuted = false, - bool isCompact = false) - { - _provider = provider ?? throw new ArgumentNullException(nameof(provider)); - _initialThreadId = initialThreadId; - _onReadAloud = onReadAloud; - _onStopSpeaking = onStopSpeaking; - _onVoiceRequest = onVoiceRequest; - _onAttachClick = onAttachClick; - _onSettingsClick = onSettingsClick; - _onSpeakerMuteChanged = onSpeakerMuteChanged; - _confirmResetAsync = confirmResetAsync; - _initialMuted = initialMuted; - _isCompact = isCompact; - } - - public override Element Render() - { - var pendingAttachments = UseState>(Array.Empty(), threadSafe: true); - var pendingAttachmentsRef = UseRef>(pendingAttachments.Value); - pendingAttachmentsRef.Current = pendingAttachments.Value; - var speakerMuted = UseState(_initialMuted, threadSafe: true); - var voiceTranscript = UseState(null, threadSafe: true); - var voiceAudioLevel = UseState(0f, threadSafe: true); - var scrollToBottomToken = UseState(0, threadSafe: true); - var showToolCalls = UseState(s_showToolCalls, threadSafe: true); - var toolCallsCollapseVersion = UseState(s_toolCallsCollapseVersion, threadSafe: true); - var chatSurfaceHeight = UseState(null, threadSafe: true); - // Guards a duplicate suggestion-button click before the snapshot - // reflects the optimistic local user entry (which then ordinarily - // hides the zero-state buttons via the isEmptyConversation check). - // Cleared automatically when the next snapshot arrives. - var firstSendInFlight = UseState(false, threadSafe: true); - - void SetPendingAttachments(IReadOnlyList attachments) - { - pendingAttachmentsRef.Current = attachments; - pendingAttachments.Set(attachments); - } - - // Wire the attachment callback so the host window/page can append - // pending attachments after the file picker completes. - _onFilesAttached = attachments => - { - if (attachments.Count == 0) - return; - - SetPendingAttachments(pendingAttachmentsRef.Current.Concat(attachments).ToArray()); - }; - _setVoiceTranscript = voiceTranscript.Set; - _setVoiceAudioLevel = voiceAudioLevel.Set; - _scrollToBottomToken = () => scrollToBottomToken.Set(scrollToBottomToken.Value + 1); - SetSpeakerMuted = muted => speakerMuted.Set(muted); - var snapshotState = UseState(null, threadSafe: true); - var initialSelectedId = _initialThreadId ?? (_provider as OpenClawChatDataProvider)?.CachedLastChatState?.DefaultThreadId; - var selectedIdState = UseState(initialSelectedId, threadSafe: true); - // UseRef tracks the selected ID across renders so that closures captured - // inside UseEffect always read the latest value (UseState structs go stale). - var selectedIdRef = UseRef(initialSelectedId); - selectedIdRef.Current = selectedIdState.Value; - _selectThread = threadId => - { - _pendingSelectedThreadId = threadId; - selectedIdState.Set(threadId); - selectedIdRef.Current = threadId; - }; - - UseEffect((Func)(() => - { - EventHandler onToolCallsVisibilityChanged = (_, _) => - { - showToolCalls.Set(s_showToolCalls); - toolCallsCollapseVersion.Set(s_toolCallsCollapseVersion); - }; - - ToolCallsVisibilityChanged += onToolCallsVisibilityChanged; - return () => ToolCallsVisibilityChanged -= onToolCallsVisibilityChanged; - })); - - UseEffect((Func)(() => - { - var setSnapshot = snapshotState.Set; - var setSelected = selectedIdState.Set; - - EventHandler onChanged = (_, e) => - { - setSnapshot(e.Snapshot); - // The debounce must clear only when the new snapshot is evidence - // that the send round-trip has progressed for the compose key — - // either the optimistic user entry landed (Timelines has it) or - // an error event ended the turn. Clearing on every snapshot - // (presence, models, status, channel health …) would re-enable - // the suggestion buttons before the optimistic entry rendered - // and let a double-click duplicate-send. - if (e.Snapshot.ComposeTarget.SessionKey is { } ck && - e.Snapshot.Timelines.TryGetValue(ck, out var ctl) && - ctl.Entries.Any(x => x.Kind == ChatTimelineItemKind.User)) - { - firstSendInFlight.Set(false); - } - if (selectedIdRef.Current is null && e.Snapshot.DefaultThreadId is { } d) - { - setSelected(d); - selectedIdRef.Current = d; - } - }; - _provider.Changed += onChanged; - _ = LoadAsync(_provider, setSnapshot, () => selectedIdRef.Current, v => { setSelected(v); selectedIdRef.Current = v; }); - return () => _provider.Changed -= onChanged; - })); - - var snapshot = snapshotState.Value; - var selectedIdForMetadata = selectedIdState.Value ?? snapshot?.DefaultThreadId; - var entryMetaSnapshot = UseMemo?>(() => - { - if (selectedIdForMetadata is null) - return null; - - return _provider switch - { - OpenClawChatDataProvider nativeForMeta => nativeForMeta.GetEntryMetadata(selectedIdForMetadata), - _ => null - }; - }, selectedIdForMetadata ?? string.Empty, snapshot is null ? string.Empty : snapshot); - - Element BuildLoadingElement() - { - return Border( - VStack(8, - ProgressRing().Size(28, 28).HAlign(HorizontalAlignment.Center), - Caption(LocalizationHelper.GetString("Chat_Root_ConnectingToGateway")).Foreground(SecondaryText).HAlign(HorizontalAlignment.Center) - ).VAlign(VerticalAlignment.Center).HAlign(HorizontalAlignment.Center) - ).Background(new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent)); - } - - if (snapshot is null) - { - return BuildLoadingElement(); - } - - var selectedId = selectedIdState.Value ?? snapshot.DefaultThreadId; - var selectedThread = selectedId is { } id - ? Array.Find(snapshot.Threads, t => t.Id == id) - : null; - if (selectedThread is not null && - string.Equals(_pendingSelectedThreadId, selectedThread.Id, StringComparison.Ordinal)) - { - _pendingSelectedThreadId = null; - } - if (selectedThread is null - && selectedIdState.Value is { } staleSelectedId - && snapshot.DefaultThreadId is { } fallbackThreadId - && ChatLifecycleSelectionPolicy.ShouldFallback( - staleSelectedId, - _pendingSelectedThreadId, - fallbackThreadId)) - { - selectedId = fallbackThreadId; - selectedThread = Array.Find(snapshot.Threads, t => t.Id == fallbackThreadId); - selectedIdState.Set(fallbackThreadId); - selectedIdRef.Current = fallbackThreadId; - } - - // If no real session is selected yet but the provider exposes a ready - // compose target (gateway connected + handshake snapshot resolved), - // synthesize a transient compose-only ChatThread so the composer is - // visible from the welcome screen. The synthetic thread's Id is the - // canonical compose key — so when the gateway materializes the session - // and SessionsUpdated arrives, Threads contains a real entry with the - // same Id and `selectedThread` resolves to it on the next render - // without any re-keying or migration. - ChatThread? composeOnlyThread = null; - var pendingComposeKey = ChatLifecycleSelectionPolicy.RetainPendingForSelection( - _pendingSelectedThreadId, - selectedIdState.Value); - var composeKey = pendingComposeKey ?? - (snapshot.ComposeTarget.IsReady ? snapshot.ComposeTarget.SessionKey : null); - if (selectedThread is null && composeKey is not null) - { - // Use last-known state from the data provider so the composer shows - // the previous session title/model while reconnecting instead of - // generic "Main session"/"model" placeholders. - var lastState = (_provider as OpenClawChatDataProvider)?.CachedLastChatState; - composeOnlyThread = new ChatThread - { - Id = composeKey, - AgentId = snapshot.ComposeTarget.AgentId, - Title = _pendingSelectedThreadId is not null - ? LocalizationHelper.GetString("Chat_PendingNewSessionTitle") - : lastState?.ThreadTitle ?? "OpenClaw Windows Tray", - Model = lastState?.Model, - ModelProvider = lastState?.ModelProvider, - Status = ChatThreadStatus.Running, - Activity = ChatActivity.Idle, - }; - } - - // For everything below, `effectiveThread` is the thread the UI should - // render against. `selectedThread` stays null when nothing materialized - // exists yet so the zero-state still shows; `composeOnlyThread` exists - // so the composer can be wired up. - var effectiveThread = selectedThread ?? composeOnlyThread; - var connectedRaw = snapshot.ConnectionStatus; - var hostConnected = connectedRaw is not null - && connectedRaw.StartsWith("Connected", StringComparison.OrdinalIgnoreCase); - - // Lazy-load history the first time a real (materialized) thread is - // selected. Don't fire for the compose-only synthetic thread — it - // doesn't exist server-side yet, so chat.history would 404. A - // disconnected render must not replace a request canceled by the - // connection-generation boundary. - if (hostConnected && - selectedThread is not null && - _provider is OpenClawChatDataProvider native) - { - var threadId = selectedThread.Id; - RunFireAndForget(ct => native.LoadHistoryAsync(threadId, force: false, ct)); - } - - // Pull the timeline from the effective thread (so optimistic entries - // from a pre-materialization first send are visible immediately). - var timeline = effectiveThread is not null && snapshot.Timelines.TryGetValue(effectiveThread.Id, out var tl) - ? tl - : ChatTimelineState.Initial(); - var timelineGeneration = 0L; - if (effectiveThread is not null - && snapshot.TimelineGenerations is { } generations - && generations.TryGetValue(effectiveThread.Id, out var generation)) - { - timelineGeneration = generation; - } - var queuedMessages = Array.Empty(); - if (effectiveThread is not null - && snapshot.QueuedMessagesByThread is { } queuedByThread - && queuedByThread.TryGetValue(effectiveThread.Id, out var queuedForThread)) - { - queuedMessages = queuedForThread.ToArray(); - } - var hasPendingQueuedSend = queuedMessages.Any(message => - message.SendState is ChatQueuedMessageSendState.Queued or ChatQueuedMessageSendState.Sending); - - var entries = (IReadOnlyList)timeline.Entries; - var connState = (connectedRaw is not null && connectedRaw.StartsWith("Incompatible", StringComparison.OrdinalIgnoreCase)) - ? "incompatible-gateway" - : hostConnected ? "connected" - : (connectedRaw is not null && connectedRaw.StartsWith("Connecting", StringComparison.OrdinalIgnoreCase)) - ? "connecting" - : "disconnected"; - - // Header & divider intentionally hidden — the surrounding chrome - // (NavigationView page or tray popup TitleBar) already shows the - // session title; the in-chat header just duplicates it. - Element header = Empty(); - - // Per-entry metadata for the OpenClaw timeline footer (sender · time · model). - // Keep the same dictionary instance across composer-only renders so the - // timeline can skip re-rendering while the user types. - var entryMeta = effectiveThread is null ? null : entryMetaSnapshot; - var usageSummary = showToolCalls.Value - ? (ChatUsageFormatter.Format(entries, entryMeta) - ?? ChatUsageFormatter.Format(effectiveThread)) - : null; - - // The gateway's default agent identity is "Field" (matches the web UI footer), - // but for the WinUI tray we surface a generic "Assistant" label so the - // thinking indicator and sender chip read naturally to all users. - // TODO: wire to a real agent-name source (agents.list response or - // sessionDefaults.defaultAgentId from hello-ok) once available, then - // restore the per-agent name here. - const string assistantSenderLabel = "Assistant"; - - // Show inline "thinking" indicator only until this turn has an - // assistant bubble. Tool calls can arrive before the first assistant - // delta; those should nest under the thinking bubble instead of - // suppressing it. Once an assistant entry exists in the current turn, - // tool calls nest there and the thinking placeholder goes away. - var currentTurnHasAssistant = false; - for (var i = timeline.Entries.Count - 1; i >= 0; i--) - { - var kind = timeline.Entries[i].Kind; - if (kind == ChatTimelineItemKind.User) - break; - if (kind == ChatTimelineItemKind.Assistant) - { - currentTurnHasAssistant = true; - break; - } - } - var showThinking = timeline.TurnActive && !currentTurnHasAssistant; - - var pendingPermissionOverride = timeline.PendingPermission; - var turnActiveOverride = timeline.TurnActive; - - // Production zero-state: triggered when a thread is selected - // but has no messages yet (true "empty conversation"). We only - // surface the welcome zero-state once we're confident the - // conversation is genuinely empty — i.e. either the thread is - // the synthetic compose-only thread (fresh install, no real - // session yet) or `chat.history` has actually completed - // (timeline.HistoryLoaded). For a real session whose history is - // still being fetched, fall back to the reconnecting view so - // the welcome screen doesn't flicker on top of an as-yet - // unloaded timeline. See OpenClawChatDataProvider.HistoryLoaded - // — set to true only inside LoadHistoryAsync's rebuild. - // Note: `pendingPermissionOverride is null` is now redundant for - // live data — the reducer's ApplyPermissionRequest pushes a - // PermissionRequest timeline entry whenever PendingPermission is - // set, so `entries.Count > 0` already covers that case. - var isEmptyConversation = entries.Count == 0 - && !showThinking - && pendingPermissionOverride is null; - var isComposeOnlyThread = composeOnlyThread is not null - && ReferenceEquals(effectiveThread, composeOnlyThread); - var gatewayConnected = string.Equals(connState, "connected", StringComparison.Ordinal); - // Raw eligibility: would we *otherwise* render the welcome zero-state - // right now? We still need this signal to drive the settling effect - // below, but the actual decision to render welcome is gated on - // `welcomeSettledState` so a brief, race-driven eligibility window - // (e.g. an empty sessions.list briefly arriving before the populated - // one for a returning user) never flashes the suggestion buttons. - // - // Two distinct paths qualify for welcome: - // 1. Fresh install — the synthetic compose-only thread is selected - // AND the snapshot truly has no real threads yet. If real - // threads exist but the compose-only thread is *briefly* - // selected during a session-switch race, we explicitly do NOT - // qualify — that's the case that previously flashed welcome - // on returning users. - // 2. Returning user with an empty real session — a real thread is - // selected and its history has fully loaded (HistoryLoaded=true) - // but contains zero messages. - var hasRealThreads = snapshot.Threads.Length > 0; - var welcomeEligibleRaw = isEmptyConversation - && gatewayConnected - && ( - (isComposeOnlyThread && !hasRealThreads) - || (!isComposeOnlyThread && timeline.HistoryLoaded) - ); - - // Settling debounce: only promote to "authoritative" once the - // welcome-eligible signal has been stable for ~800ms. This protects - // against transient mid-handshake windows where threads briefly - // appear empty, ComposeTarget becomes ready, and the synthetic - // compose-only thread otherwise tricks the renderer into showing the - // suggestion buttons before the real session list lands. Fresh- - // install users still see the welcome screen — just ~800ms after - // connect — which is still well within perceived "loading" time. - // 800ms (up from 300ms) absorbs gateway sequences where an empty - // sessions.list precedes the populated one by several hundred ms. - var welcomeSettledState = UseState(false); - UseEffect((Func)(() => - { - if (!welcomeEligibleRaw) - { - if (welcomeSettledState.Value) welcomeSettledState.Set(false); - return () => { }; - } - // Schedule the promote-to-settled call once the eligibility - // window has been stable for the debounce interval. The hook - // dependency key includes every input that influences the - // welcome decision, so any change cancels the pending callback - // via the returned cleanup before re-arming on the next pass. - var dq = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); - var cancelled = false; - _ = Task.Run(async () => - { - try - { - await Task.Delay(800); - if (cancelled) return; - dq?.TryEnqueue(() => { if (!cancelled) welcomeSettledState.Set(true); }); - } - catch (OperationCanceledException) - { - // Cancellation is expected when the welcome eligibility signal changes. - } - catch (Exception ex) { OpenClawTray.Services.Logger.Debug($"ChatRoot: welcome settle race: {ex.Message}"); } - }); - return () => { cancelled = true; }; - }), - welcomeEligibleRaw, - effectiveThread?.Id ?? string.Empty, - isComposeOnlyThread, - timeline.HistoryLoaded, - hasRealThreads); - - var emptyConversationIsAuthoritative = welcomeEligibleRaw && welcomeSettledState.Value; - - var timelineProps = new OpenClawChatTimelineProps( - SessionId: effectiveThread?.Id, - Entries: entries, - HasMoreHistory: false, - OnLoadMoreHistory: null, - EntryMetadata: entryMeta, - TimelineGeneration: timelineGeneration, - UserSenderLabel: "OpenClaw Windows Tray", - AssistantSenderLabel: assistantSenderLabel, - DefaultModel: effectiveThread?.Model, - DefaultUsageSummary: usageSummary, - ShowThinkingIndicator: showThinking, - ShowToolCalls: showToolCalls.Value, - ToolCallsCollapseVersion: toolCallsCollapseVersion.Value, - OnReadAloud: _onReadAloud is not null - ? (text => _onReadAloud(text)) - : null, - OnStopSpeaking: _onStopSpeaking, - ScrollToBottomToken: scrollToBottomToken.Value, - OnPermissionResponse: effectiveThread?.Id is { } permissionThreadId - ? (rid, action) => OnPermission(permissionThreadId, rid, action) - : null); - - var bodyIsSkeleton = effectiveThread is null - || (isEmptyConversation && !emptyConversationIsAuthoritative); - Element body = Component(timelineProps); - - // Session list for the composer dropdown — grouped by the Gateway's - // agent presentation metadata. Background sessions stay hidden unless - // the user explicitly navigated to one, in which case it remains usable. - // Keep sessions with conversation activity regardless of lifecycle state. - // Empty gateway placeholders stay hidden unless explicitly selected. - var channelGroups = SessionVisibilityFilter.VisibleChatPickerThreads( - snapshot.Threads, - effectiveThread?.Id) - .Where(t => !string.IsNullOrEmpty(t.Title) - && t.IsVisibleInSessionPicker(effectiveThread?.Id)) - .GroupBy(t => string.IsNullOrWhiteSpace(t.AgentId) ? "other" : t.AgentId!) - // "main" first (sort key 0), then alphabetical - .OrderBy(g => g.Key.Equals("main", StringComparison.OrdinalIgnoreCase) ? 0 : 1) - .ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase) - .Select(g => new ChannelGroup( - AgentLabel: g.Key.Length > 0 ? char.ToUpperInvariant(g.Key[0]) + g.Key[1..] : "Unknown", - Sessions: g.Select(t => (Id: t.Id, Title: t.Title!, Model: t.Model, ModelProvider: t.ModelProvider)).ToArray())) - .ToArray(); - - // If the compose-only synthetic thread isn't represented in any group - // (e.g. fresh install: the gateway has no real sessions yet), inject a - // single-entry "Main" group so the composer's channel combo isn't blank. - // - // Keep routing ids opaque here too. The provider carries the Gateway's - // agent identity separately, including for compose-only threads. - if (effectiveThread is not null - && SessionVisibilityFilter.IsVisibleInChatPicker(effectiveThread, effectiveThread.Id) - && !ChannelGroupsContain(channelGroups, effectiveThread.Id)) - { - var agentId = string.IsNullOrWhiteSpace(effectiveThread.AgentId) ? "main" : effectiveThread.AgentId!; - var agentLabel = agentId.Length > 0 ? char.ToUpperInvariant(agentId[0]) + agentId[1..] : "Main"; - var syntheticGroup = new ChannelGroup( - AgentLabel: agentLabel, - Sessions: new[] { (Id: effectiveThread.Id!, Title: effectiveThread.Title ?? "OpenClaw Windows Tray", Model: effectiveThread.Model, ModelProvider: effectiveThread.ModelProvider) }); - - var augmented = new ChannelGroup[channelGroups.Length + 1]; - augmented[0] = syntheticGroup; - Array.Copy(channelGroups, 0, augmented, 1, channelGroups.Length); - channelGroups = augmented; - } - - Element composer = effectiveThread is { } composerThread - ? Component(new( - ConnectionState: connState, - TurnActive: turnActiveOverride, - ChannelLabel: composerThread.Title ?? "OpenClaw Windows Tray", - ChannelId: composerThread.Id!, - AvailableChannels: channelGroups, - AvailableModels: snapshot.AvailableModels, - CurrentModel: composerThread.Model, - CurrentModelProvider: composerThread.ModelProvider, - CurrentThinkingLevel: composerThread.ThinkingLevel, - MessageOptionsDisabled: turnActiveOverride || hasPendingQueuedSend, - ModelChoices: snapshot.ModelChoices, - OnSend: async (msg, attachments) => - { - var accepted = await OnSend( - composerThread.Id!, - composerThread.Title, - msg, - attachments); - if (accepted) - { - SetPendingAttachments(ChatComposerSubmissionPolicy.RemoveSubmittedAttachments( - pendingAttachmentsRef.Current, - attachments)); - } - return accepted; - }, - OnStop: () => OnStop(composerThread.Id!), - OnChannelChanged: id => - { - _pendingSelectedThreadId = ChatLifecycleSelectionPolicy.RetainPendingForSelection( - _pendingSelectedThreadId, - id); - selectedIdState.Set(id); - selectedIdRef.Current = id; - if (_provider is OpenClawChatDataProvider nativeProvider) - nativeProvider.RememberSelectedThread(id); - }, - OnModelChanged: model => ObserveFireAndForget(_provider.SetModelAsync(composerThread.Id!, model)), - OnModelCleared: () => ObserveFireAndForget(_provider.ClearModelAsync(composerThread.Id!)), - OnThinkingLevelChanged: level => RunFireAndForget(ct => _provider.SetThinkingLevelAsync(composerThread.Id!, level, ct)), - OnPermissionsChanged: allowAll => RunFireAndForget(ct => _provider.SetPermissionModeAsync(composerThread.Id!, allowAll, ct)), - OnVoiceRequest: _onVoiceRequest, - OnAttachClick: _onAttachClick, - PendingAttachments: pendingAttachments.Value, - QueuedMessages: queuedMessages, - OnQueuedMessageCancel: queuedMessageId => RunFireAndForget(ct => _provider.CancelQueuedMessageAsync(composerThread.Id!, queuedMessageId, ct)), - OnAttachmentRemoved: attachment => SetPendingAttachments(RemoveAttachment(pendingAttachmentsRef.Current, attachment)), - IsSpeakerMuted: speakerMuted.Value, - OnSpeakerToggle: () => - { - var newMuted = !speakerMuted.Value; - speakerMuted.Set(newMuted); - _onSpeakerMuteChanged?.Invoke(newMuted); - }, - OnSettingsClick: _onSettingsClick, - VoiceTranscript: voiceTranscript.Value, - VoiceAudioLevel: voiceAudioLevel.Value, - RegisterVoiceStarter: starter => TriggerVoiceRecording = starter, - OnAttachmentPasted: att => SetPendingAttachments(pendingAttachmentsRef.Current.Concat(new[] { att }).ToArray()), - IsCompact: _isCompact, - AvailableCommands: snapshot.AvailableCommands, - CommandsSupported: snapshot.CommandsSupported, - OnCommandsRequested: () => RunFireAndForget(ct => _provider.EnsureCommandCatalogAsync(ct)), - AvailableHeight: chatSurfaceHeight.Value)) - : (bodyIsSkeleton ? RenderSkeletonComposer() : Empty()); - - var divider = Empty(); - // Composer absorbs the old StatusBar. - void SetChatSurfaceHeight(double height) - { - if (double.IsNaN(height) || double.IsInfinity(height) || height <= 0) - return; - - chatSurfaceHeight.Set(Math.Round(height)); - } - - // Copilot-style scrim: instead of a hard divider line, the timeline - // dissolves into the composer dock via a vertical gradient that runs - // from transparent at the top to the solid theme-base fill at the - // bottom. The composer dock uses that same base fill, so the fade lands - // seamlessly. The color is resolved from the element's ActualTheme (not - // Application.Resources, which snapshots the default theme) so it flips - // live on a runtime light/dark switch. It never captures pointer input, - // so scrolling and the last message stay live. - static Brush BuildComposerFadeBrush(ElementTheme theme) - { - // Resolve the fade as a WHOLE brush per theme rather than reading a - // color off a walked brush. Reading .Color out of the visual tree - // re-resolves any {ThemeResource} against the ambient (light) app - // theme, which is why the fade previously read white on a dark page. - // ChatComposerFadeBrush is declared with literal colors per theme in - // App.xaml ThemeDictionaries (which the FunctionalUI walk can reach), - // so it flips correctly and stays aligned with the composer dock fill. - // A not-yet-loaded element can report ElementTheme.Default; coerce it - // to Dark so resolution never falls through to a missing app-root key - // (Loaded/ActualThemeChanged re-apply with the real theme). - var resolved = theme == ElementTheme.Light ? ElementTheme.Light : ElementTheme.Dark; - return Theme.ResolveBrush("ChatComposerFadeBrush", resolved); - } - - var composerFade = Border(Empty()) - .Set(f => - { - f.Height = 28; - f.VerticalAlignment = VerticalAlignment.Bottom; - f.IsHitTestVisible = false; - Theme.EnsureThemeCallback(f, () => f.Background = BuildComposerFadeBrush(f.ActualTheme)); - }); - - return Grid([GridSize.Star()], [GridSize.Auto, GridSize.Auto, GridSize.Star(), GridSize.Auto], - header.Grid(row: 0, column: 0), - divider.Grid(row: 1, column: 0), - body.Grid(row: 2, column: 0), - composerFade.Grid(row: 2, column: 0), - composer.Grid(row: 3, column: 0) - ).OnMount(root => - { - SetChatSurfaceHeight(root.ActualHeight); - root.SizeChanged += (_, e) => SetChatSurfaceHeight(e.NewSize.Height); - }); - } - - // Cheap allocation-free probe for "does any group contain a session with - // the given id?" — avoids the LINQ Any().Any() allocation in the render - // hot path. - private static bool ChannelGroupsContain(ChannelGroup[] groups, string id) - { - foreach (var g in groups) - { - foreach (var s in g.Sessions) - { - if (s.Id == id) return true; - } - } - return false; - } - - /// - /// Skeleton composer shown at the bottom of the chat surface while the - /// real composer is still gated. Renders a rounded input-field placeholder - /// and a circular send-button placeholder, both pulsing in sync with the - /// skeleton bubbles above. Keeps the overall chat surface visually intact - /// so the layout doesn't shift when the real composer lands. - /// - private static Element RenderSkeletonComposer() - { - var bubbleBrush = (Microsoft.UI.Xaml.Media.Brush)new Microsoft.UI.Xaml.Media.SolidColorBrush( - global::Windows.UI.Color.FromArgb(0x30, 0x80, 0x80, 0x80)); - - var inputField = Border() - .Background(bubbleBrush) - .Set(b => - { - b.CornerRadius = new CornerRadius(8); - b.Height = 56; - b.Margin = new Thickness(0, 0, 8, 0); - b.HorizontalAlignment = HorizontalAlignment.Stretch; - }) - .OnMount(MakeShimmer(0)); - - var sendButton = Border() - .Background(bubbleBrush) - .Set(b => - { - b.CornerRadius = new CornerRadius(20); - b.Width = 40; - b.Height = 40; - b.VerticalAlignment = VerticalAlignment.Center; - }) - .OnMount(MakeShimmer(160)); - - return Border( - Grid(new[] { GridSize.Star(), GridSize.Auto }, new[] { GridSize.Auto }, - inputField.Grid(row: 0, column: 0), - sendButton.Grid(row: 0, column: 1) - ) - ).Set(b => b.Padding = new Thickness(16, 8, 16, 16)); - } - - /// - /// Builds an OnMount action that attaches a Storyboard-driven opacity - /// pulse to the target element. staggers - /// the pulse phase so multiple bubbles wave in sequence rather than - /// blinking in unison. The animation auto-reverses and repeats forever; - /// the storyboard is dropped from scope once started but kept alive by - /// the visual tree via its target ref. - /// - private static Action MakeShimmer(double beginOffsetMs) - { - return fe => - { - try - { - var anim = new Microsoft.UI.Xaml.Media.Animation.DoubleAnimation - { - From = 1.0, - To = 0.45, - Duration = new Duration(TimeSpan.FromMilliseconds(900)), - AutoReverse = true, - RepeatBehavior = Microsoft.UI.Xaml.Media.Animation.RepeatBehavior.Forever, - EasingFunction = new Microsoft.UI.Xaml.Media.Animation.SineEase - { - EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseInOut, - }, - BeginTime = TimeSpan.FromMilliseconds(beginOffsetMs), - }; - Microsoft.UI.Xaml.Media.Animation.Storyboard.SetTarget(anim, fe); - Microsoft.UI.Xaml.Media.Animation.Storyboard.SetTargetProperty(anim, "Opacity"); - var sb = new Microsoft.UI.Xaml.Media.Animation.Storyboard(); - sb.Children.Add(anim); - sb.Begin(); - } - catch (Exception ex) - { - OpenClawTray.Services.Logger.Debug($"ChatRoot: skeleton storyboard animation failed (non-essential): {ex.Message}"); - } - }; - } - - private static Element PlaceholderEmptyThreadState(string connectionState) - { - var isConnected = string.Equals(connectionState, "connected", StringComparison.Ordinal); - var msg = isConnected - ? "Start a new OpenClaw chat from the composer below." - : LocalizationHelper.GetString("Chat_Root_ConnectingToGateway"); - - return Border( - VStack(8, - TextBlock("💬").FontSize(48).HAlign(HorizontalAlignment.Center), - Caption(msg).Foreground(SecondaryText).HAlign(HorizontalAlignment.Center) - ).VAlign(VerticalAlignment.Center).HAlign(HorizontalAlignment.Center) - ); - } - - private async Task OnSend( - string threadId, - string? displayName, - string message, - IReadOnlyList attachments) - { - _scrollToBottomToken?.Invoke(); - 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 && - _confirmResetAsync is not null && - !await _confirmResetAsync(threadId, displayName)) - { - return false; - } - - var result = await native.ExecuteLifecycleCommandAsync(threadId, command); - if (result.Succeeded && result.NewSessionKey is { } newSessionKey) - _selectThread?.Invoke(newSessionKey); - return result.Succeeded; - } - - try - { - if (attachments.Count > 0) - await _provider.SendMessageAsync(threadId, message, CancellationToken.None, attachments.ToArray()); - else - await _provider.SendMessageAsync(threadId, message); - } - catch (Exception ex) - { - System.Diagnostics.Trace.WriteLine($"[chat] send failed: {ex}"); - return false; - } - return true; - } - - private async Task SendSuggestionAsync(string threadId, string? displayName, string suggestion) - { - await OnSend(threadId, displayName, suggestion, Array.Empty()); - } - - 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.ToArray() : attachments; - } - - private void OnStop(string threadId) - { - RunFireAndForget(ct => _provider.StopResponseAsync(threadId, ct)); - } - - private void OnPermission(string threadId, string requestId, string action) - { - RunFireAndForget(ct => _provider.RespondToPermissionAsync(threadId, requestId, action, ct)); - } - - private static void RunFireAndForget(Func op) - { - _ = Task.Run(async () => - { - try { await op(CancellationToken.None); } - // slopwatch-ignore: SW003 Shutdown cancellation or disposal is expected and the caller already preserves the safe state. - catch (OperationCanceledException) { /* expected */ } - catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[chat] op failed: {ex}"); } - }); - } - - private static void ObserveFireAndForget(Task task) - { - _ = ObserveAsync(task); - - static async Task ObserveAsync(Task task) - { - try { await task; } - // slopwatch-ignore: SW003 Shutdown cancellation or disposal is expected and the caller already preserves the safe state. - catch (OperationCanceledException) { /* expected */ } - catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[chat] op failed: {ex}"); } - } - } - - private static async Task LoadAsync( - IChatDataProvider provider, - Action setSnapshot, - Func getSelected, - Action setSelected) - { - try - { - var snap = await provider.LoadAsync(); - setSnapshot(snap); - if (getSelected() is null && snap.DefaultThreadId is { } d) - setSelected(d); - } - catch (Exception ex) - { - System.Diagnostics.Trace.WriteLine($"[chat] LoadAsync failed: {ex}"); - } - } -} diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs deleted file mode 100644 index 3ec90caf1..000000000 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ /dev/null @@ -1,2877 +0,0 @@ -using OpenClaw.Chat; -using OpenClaw.Shared; -using OpenClawTray.Helpers; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.UI; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Documents; -using Microsoft.UI.Xaml.Media; -using OpenClawTray.FunctionalUI; -using OpenClawTray.FunctionalUI.Core; -using Windows.UI; -using static OpenClawTray.FunctionalUI.Factories; -using static OpenClawTray.FunctionalUI.Core.Theme; - -namespace OpenClawTray.Chat; - -/// -/// Extension of with OpenClaw-specific -/// per-entry metadata () and sender/model -/// labels used in the per-message footer rendering. Created by -/// OpenClawChatRoot. -/// -/// -/// Optional per-entry metadata snapshot keyed by ChatTimelineItem.Id. -/// Renderer falls back to defaults when an entry isn't present. -/// -/// Sender label shown below user bubbles. -/// Sender label shown below assistant cards. -/// Fallback model name when an entry's metadata doesn't carry one. -/// -/// When true, renders an italic "<agent> is thinking…" placeholder -/// inside an assistant bubble. Used by callers to bridge the gap between -/// turn-start and the first assistant delta arriving. -/// -/// When true, renders tool-call progress and usage footer summaries. -/// Bumps when expanded tool details should be reset. -public record OpenClawChatTimelineProps( - string? SessionId, - IReadOnlyList Entries, - bool HasMoreHistory, - Action? OnLoadMoreHistory, - IReadOnlyDictionary? EntryMetadata = null, - long TimelineGeneration = 0, - string UserSenderLabel = "OpenClaw Windows Tray", - string AssistantSenderLabel = "Field", - string? DefaultModel = null, - string? DefaultUsageSummary = null, - bool ShowThinkingIndicator = false, - bool ShowToolCalls = true, - int ToolCallsCollapseVersion = 0, - Func? OnReadAloud = null, - Action? OnStopSpeaking = null, - int ScrollToBottomToken = 0, - Action? OnPermissionResponse = null); - -/// -/// OpenClaw-skinned variant of from the vendored -/// chat sample. Reuses the same scroll/follow/load-more behavior but renames -/// the per-entry rendering to better match the web Control UI: -/// -/// -/// User messages: right-aligned pink bubble with avatar glyph and a -/// "<sender> · <time>" footer. -/// Assistant messages: left-aligned subtle card with ★ avatar glyph -/// and a "<agent> · <time> · <model>" footer. -/// Tool calls: prominent compact rounded card matching the web's -/// "Tool call exec" affordance, with a small footer for time. -/// Reasoning / status entries: muted styling as in upstream. -/// -/// -public class OpenClawChatTimeline : Component -{ - const double FollowThreshold = 60; - // Bounded settle used by QueueScrollToBottom to catch LATE virtualization extent - // corrections: after a discrete scroll-to-bottom, a row can realize below the fold a few - // frames later, growing the extent with NO ViewChanged/SizeChanged to drive a re-pin. A - // short self-terminating timer keeps chasing the true bottom until the extent is stable - // for a couple of ticks (or the hard cap elapses), then restores bottom anchoring. Re-pins - // are ChangeView-only (they never grow the extent) so this converges and cannot storm. - const int FollowToBottomSettleTickMs = 16; - const int FollowToBottomMaxSettleTicks = 24; - const int FollowToBottomSettleStableTicks = 2; - // While the settle timer is chasing the true bottom, an offset that lands below the bottom is - // either post-jump virtualization re-estimation (a BOUNDED band — keep chasing) or a genuine - // user scroll-up to read earlier history (MANY viewports away — abandon and don't fight). The - // abandon gap is the larger of an absolute floor and a viewport-relative band so it scales - // with window size while never dipping below the floor on short viewports. - const double FollowToBottomMinAbandonGap = 900; - const double FollowToBottomAbandonViewportFactor = 1.5; - // Follow-to-bottom during in-place streaming growth is handled by WinUI ScrollViewer scroll - // anchoring (sv.VerticalAnchorRatio = 1.0), NOT by a reactive post-layout re-pin. With the - // bottom row pinned as an anchor, the ScrollViewer keeps it glued to the viewport bottom - // BEFORE each frame is painted as the ItemsRepeater's extent estimate climbs during - // realization — so there is no intermediate short frame (no jitter) and no programmatic - // ChangeView fighting the user's own scrolling. Discrete events (new entry, session switch, - // initial load, ScrollToBottom token) use QueueScrollToBottom, which briefly turns anchoring - // OFF while it drives ChangeView to the true bottom then restores 1.0 — otherwise anchoring - // would re-pin the stale bottom row mid-growth and the view would land one row short. - // Anchoring alone covers the in-place growth case that fires no reliable SizeChanged. See - // issue #996 for the upstream (Reactor) port context. - - /// - /// Static scroll-offset store shared across all timeline instances so that - /// scroll position survives page navigation (which recreates the page and - /// component instances). Bounded to avoid unbounded memory growth. - /// - private static readonly Dictionary s_sessionOffsets = new(); - private const int MaxSessionOffsets = 50; - - // SECURITY (chat-rubber-duck HIGH 1 / MEDIUM 3): chat-bubble Markdown is - // rendered as sanitized inert text that: - // 1. Renders images as inert ``[Image: ]`` text (no Uri fetch) — - // blocks SSRF / tracking-pixel beacons triggered by a compromised - // gateway, malicious tool output, or a prompt-injected model. - // 2. Pre-strips inline link / image / ref-def syntax via - // so explicit - // ``[text](url)`` syntax never reaches the parser. - // 3. Renders raw HTML blocks as selectable plain text. - // Net effect: no click-to-navigate hyperlink or network-fetching - // image can be manufactured by untrusted Markdown inside a chat bubble. - private static Element SafeMarkdownText(string? text) - { - // Fast path: bubbles with no block-level markdown (the common case) - // keep the lightweight inline sanitizer to avoid the parser cost. - if (!Markdown.ChatMarkdownRenderer.ContainsBlockMarkdown(text)) - { - return TextBlock(string.Empty) - .Set(t => - { - t.TextWrapping = TextWrapping.Wrap; - t.IsTextSelectionEnabled = true; - ApplySafeMarkdownInlines(t, text); - }); - } - return Markdown.ChatMarkdownRenderer.Render(text) - ?? TextBlock(text ?? string.Empty) - .Set(t => { t.TextWrapping = TextWrapping.Wrap; t.IsTextSelectionEnabled = true; }); - } - - // Cache plain (non-markdown) text per TextBlock so we can reuse the - // assistant-bubble's Inlines-based render path for user prompts without - // re-clearing/rebuilding the run on every re-render. Going through - // Inlines (instead of the TextBlock.Text property) avoids a WinUI quirk - // where setting Text on a selection-enabled TextBlock during a parent - // re-render that fires immediately after the user finishes selecting can - // leave the glyph layer visually empty until the next focus change. - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable - s_plainCache = new(); - - // FontFamily instances are immutable, but `new FontFamily(...)` per - // render allocates a fresh CLR object whose reference does not equal - // the previous one. Reassigning a referentially-different FontFamily - // to a TextBlock invalidates its inline runs even when the source - // string is identical, which (in the tool-output panel) makes - // multi-line wrapped text vanish during a pointer-exit re-render. - // Caching sidesteps both the GC pressure and the invalidation. - // - // FontFamily is a DependencyObject with thread affinity, so a single - // process-wide singleton would crash with RPC_E_WRONG_THREAD if a - // second window on a different dispatcher ever tried to read it. - // Keying by DispatcherQueue mirrors the brush cache above: one - // shared instance per window, collected with its dispatcher. - // Off-dispatcher callers (tests, design-time) get a one-shot - // uncached instance — correct, just not reused. - private const string MonoFontFamilySource = "Cascadia Code, Cascadia Mono, Consolas"; - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< - Microsoft.UI.Dispatching.DispatcherQueue, FontFamily> s_monoFontByDispatcher = new(); - private const string ChatTextFontFamilySource = "Segoe UI Variable Text, Segoe UI"; - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< - Microsoft.UI.Dispatching.DispatcherQueue, FontFamily> s_chatTextFontByDispatcher = new(); - private static FontFamily s_monoFontFamily - { - get - { - var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); - if (dispatcher is null) - { - return new FontFamily(MonoFontFamilySource); - } - if (!s_monoFontByDispatcher.TryGetValue(dispatcher, out var family)) - { - family = new FontFamily(MonoFontFamilySource); - s_monoFontByDispatcher.Add(dispatcher, family); - } - return family; - } - } - private static FontFamily s_chatTextFontFamily - { - get - { - var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); - if (dispatcher is null) - { - return new FontFamily(ChatTextFontFamilySource); - } - if (!s_chatTextFontByDispatcher.TryGetValue(dispatcher, out var family)) - { - family = new FontFamily(ChatTextFontFamilySource); - s_chatTextFontByDispatcher.Add(dispatcher, family); - } - return family; - } - } - - private static void ApplyPlainSelectableInlines(TextBlock textBlock, string? text) - { - var normalized = text ?? string.Empty; - // ConfigureTextBlock may set Text="" and clear Inlines before this - // setter runs again, so only skip when the cached run is still present. - if (textBlock.Inlines.Count > 0 - && s_plainCache.TryGetValue(textBlock, out var cached) - && cached == normalized) - return; - s_plainCache.AddOrUpdate(textBlock, normalized); - textBlock.Inlines.Clear(); - if (normalized.Length > 0) - textBlock.Inlines.Add(new Run { Text = normalized }); - } - - // RichTextBlock analog of the user-bubble plain-text cache. Selection lives - // on the RichTextBlock, so re-applying identical text must NOT clear Blocks - // (that wipes the active selection). Skip the rebuild when the run is - // unchanged and a paragraph is still present. - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable - s_userParagraphCache = new(); - - private static void ApplyPlainSelectableParagraph(RichTextBlock richTextBlock, string? text) - { - var normalized = text ?? string.Empty; - if (richTextBlock.Blocks.Count > 0 - && s_userParagraphCache.TryGetValue(richTextBlock, out var cached) - && cached == normalized) - return; - s_userParagraphCache.AddOrUpdate(richTextBlock, normalized); - richTextBlock.Blocks.Clear(); - var paragraph = new Paragraph(); - if (normalized.Length > 0) - paragraph.Inlines.Add(new Run { Text = normalized }); - richTextBlock.Blocks.Add(paragraph); - } - - // Cache parsed markdown text per TextBlock to avoid re-clearing and - // rebuilding Inlines on every re-render when message content is stable. - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable - s_markdownCache = new(); - - private static void ApplySafeMarkdownInlines(TextBlock textBlock, string? text) - { - // Skip re-parsing only when text is unchanged AND inlines are still - // present. ConfigureTextBlock sets control.Text="" which clears - // Inlines, so we must re-apply even if the source text matches. - if (textBlock.Inlines.Count > 0 - && s_markdownCache.TryGetValue(textBlock, out var cached) - && cached == text) - return; - s_markdownCache.AddOrUpdate(textBlock, text ?? ""); - - textBlock.Inlines.Clear(); - - foreach (var segment in ChatMarkdownSanitizer.SanitizeAndSplitStrongEmphasis(text)) - { - if (segment.Text.Length == 0) - continue; - - if (segment.IsStrong) - { - var bold = new Bold(); - bold.Inlines.Add(new Run { Text = segment.Text }); - textBlock.Inlines.Add(bold); - } - else - { - textBlock.Inlines.Add(new Run { Text = segment.Text }); - } - } - } - - static string FormatToolLabel(ChatTimelineItem e) { - var text = e.Text ?? ""; - return e.ToolName switch - { - "bash" or "powershell" => $"$ {text}", - "read" or "view" => text, - "edit" or "create" => text, - "grep" => $"🔍 {text}", - "glob" => $"📂 {text}", - "web_fetch" => $"🌐 {text}", - "web_search" => $"🔎 {text}", - "task" => text, - "report_intent" => text, - _ => text == e.ToolName || string.IsNullOrEmpty(text) ? e.ToolName ?? "tool" : $"{e.ToolName}: {text}" - }; - } - - /// - /// Title-case a single token: "exec""Exec". Used by the - /// tool-chip inner header to mirror the web's Exec/Process - /// styling. Returns the empty string for null/empty input. - /// - static string CapitalizeFirst(string? s) - { - if (string.IsNullOrEmpty(s)) return string.Empty; - return char.ToUpperInvariant(s[0]) + (s.Length > 1 ? s[1..] : string.Empty); - } - - /// - /// If looks like a JSON object/array, pretty-print - /// it with 2-space indentation. Otherwise return the string verbatim. - /// Used so tool chips render gateway action blobs ({"action":"poll"…}) - /// the same way the web does, without affecting plain shell output. - /// - static string TryFormatJsonForDisplay(string text) - { - if (string.IsNullOrWhiteSpace(text)) return text; - var trimmed = text.TrimStart(); - if (trimmed.Length == 0) return text; - var first = trimmed[0]; - if (first != '{' && first != '[') return text; - try - { - using var doc = System.Text.Json.JsonDocument.Parse(trimmed); - return System.Text.Json.JsonSerializer.Serialize(doc.RootElement, - new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); - } - catch - { - return text; - } - } - - static bool ContainsEntryId(IReadOnlyList entries, string id) - { - for (var i = 0; i < entries.Count; i++) - { - if (entries[i].Id == id) - return true; - } - - return false; - } - - static double ClampOffset(double offset, double max) => - Math.Max(0, Math.Min(offset, max)); - - /// - /// Process-wide cache so we decode each cached image only once. Keyed by - /// byte-array reference so cache invalidates automatically when bytes - /// are replaced. BitmapImage instances are UI-thread-affine but read - /// access from other threads through ImageBrush is safe. - /// - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable _bitmapCache = new(); - - /// - /// Decodes into a , - /// caching the result so repeated renders of the same image don't re-run - /// the decoder. Returns null on any decode failure (renderer will - /// fall back to a filename chip). - /// - static Microsoft.UI.Xaml.Media.Imaging.BitmapImage? TryDecodeBitmap(byte[] bytes) - { - if (_bitmapCache.TryGetValue(bytes, out var existing)) - return existing; - try - { - var stream = new global::Windows.Storage.Streams.InMemoryRandomAccessStream(); - using (var writer = new global::Windows.Storage.Streams.DataWriter(stream)) - { - writer.WriteBytes(bytes); - writer.StoreAsync().AsTask().GetAwaiter().GetResult(); - writer.DetachStream(); - } - stream.Seek(0); - var bmp = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(); - bmp.SetSource(stream); - _bitmapCache.Add(bytes, bmp); - return bmp; - } - catch - { - return null; - } - } - - public override Element Render() - { - var bubbleRadius = new CornerRadius(16); - var bubblePadding = new Thickness(16, 12, 16, 12); - const double bubbleSideMargin = 8; - const bool showAsstBubbles = true; - var showToolCalls = Props.ShowToolCalls; - const double gutter = 64; - const bool showUserAvatar = false; - const bool showAssistAvatar = true; - const bool showTimestamps = true; - - var scrollViewRef = UseRef(null); - var isFollowingRef = UseRef(true); - var contentRef = UseRef(null); - var prevEntryCountRef = UseRef(0); - var prevSessionIdRef = UseRef(null); - var prevFirstEntryIdRef = UseRef(null); - var prevLastEntryIdRef = UseRef(null); - var lastVerticalOffsetRef = UseRef(0.0); - var lastScrollableHeightRef = UseRef(0.0); - var suppressAutoFollowRef = UseRef(false); - var sessionOffsetsRef = UseRef>(new()); - var prevScrollToBottomTokenRef = UseRef(0); - var scrollSettleTimerRef = UseRef(null); - // A reactive follow (SizeChanged) enqueues a pin on the dispatcher. Without a guard, - // the many SizeChanged notifications fired across an ItemsRepeater realization pass pile - // up dozens of enqueued pins; each re-pins to the bottom and CANCELS a user scroll issued - // in between (their ChangeView never gets a frame to apply), so the view can never leave - // the bottom — the "fighting the scrollbar" bug. This flag coalesces reactive follows to a - // single in-flight pin/settle at a time. Explicit scroll-to-bottom requests bypass it. - var scrollPinPendingRef = UseRef(false); - var hasMoreHistoryRef = UseRef(Props.HasMoreHistory); - var loadMoreHistoryRef = UseRef(Props.OnLoadMoreHistory); - var loadMoreRequestedForCountRef = UseRef(-1); - // Pending offset to restore after layout completes (SizeChanged). - // Set during initialLoad when ScrollableHeight is still 0. - var pendingRestoreOffsetRef = UseRef(null); - - // Per-entry expand state for tool chips. Tokens are - // "{entryId}:call" and "{entryId}:out" so call and output - // toggle independently. HashSet so the empty default is "all - // collapsed" — matches the web's default-collapsed look. - var expandedToolChips = UseState>(new HashSet(), threadSafe: true); - - // Track the last-seen collapse version so we clear expanded - // state when the user toggles tool calls off (collapsed view should - // start fresh when re-shown). - var collapseToolChipsVersion = Props.ToolCallsCollapseVersion; - var lastCollapseVersion = UseRef(collapseToolChipsVersion); - if (lastCollapseVersion.Current != collapseToolChipsVersion) - { - lastCollapseVersion.Current = collapseToolChipsVersion; - if (expandedToolChips.Value.Count > 0) - expandedToolChips.Set(new HashSet()); - } - - // When showToolCalls changes, pre-clear the native StackPanel so the - // reconciler (SyncChildren) only does inserts into an empty panel - // instead of expensive per-element RemoveAt calls that cascade - // Unloaded events through deep visual subtrees. - var prevShowToolCallsRef = UseRef(showToolCalls); - if (prevShowToolCallsRef.Current != showToolCalls) - { - prevShowToolCallsRef.Current = showToolCalls; - if (contentRef.Current is ItemsRepeater repeater) - repeater.ItemsSource = Array.Empty(); - else if (contentRef.Current is StackPanel stackPanel) - stackPanel.Children.Clear(); - } - - // Hover state — set of entry ids currently under the pointer. Used to - // reveal the trash / speak action icons beside user / assistant - // bubbles. Re-renders the whole timeline on hover transitions; that's - // fine for the entry counts we deal with (typically <100 visible). - var hoveredEntries = UseState>(new HashSet(), threadSafe: true); - - // Thinking-bubble dot animation. Cycles 0→1→2→3→0 every 400ms while the - // ShowThinkingIndicator prop is true; drives the trailing "." / ".." / - // "..." in the " is thinking" text so the bubble visibly pulses - // without needing a ProgressRing (which renders awkwardly at small - // sizes). DispatcherTimer fires on the UI thread so the reducer call - // is safe. UseReducer (not UseState) because the timer-tick closure - // re-reads on each fire — UseState.Value is a render-time snapshot, - // so a long-lived timer would forever advance from the same stale - // value. (Same reason as the AckAction reducer below.) - var (thinkingDotPhase, thinkingDotPhaseUpdate) = UseReducer(0, threadSafe: true); - UseEffect((Func)(() => - { - if (!Props.ShowThinkingIndicator) - return () => { }; - var timer = new Microsoft.UI.Xaml.DispatcherTimer - { - Interval = TimeSpan.FromMilliseconds(400) - }; - timer.Tick += (_, _) => thinkingDotPhaseUpdate(prev => (prev + 1) % 4); - timer.Start(); - return () => timer.Stop(); - }), Props.ShowThinkingIndicator); - - // Acknowledged actions — set of "entryId|actionKey" strings briefly - // marked after a click so the icon can swap to a checkmark for ~1.2s - // before reverting. Gives the user immediate "done" feedback for - // Copy / Read aloud / Delete without a toast. - // UseReducer (not UseState) so the updater always sees the LIVE - // hook value — UseState's `.Value` is a render-time snapshot, so - // a delayed continuation that reads it later sees a stale set and - // bails out, leaving the ack glyph stuck. - var (ackedActionsValue, ackUpdate) = UseReducer>(new HashSet(), threadSafe: true); - - // Track which entry is currently being read aloud so the button - // can toggle to stop playback on a second press. - var speakingEntryId = UseState(null, threadSafe: true); - - void AckAction(string entryId, string actionKey) => - AsyncEventHandlerGuard.Run( - () => AckActionAsync(entryId, actionKey), - new OpenClawTray.AppLogger(), - nameof(AckAction)); - - async Task AckActionAsync(string entryId, string actionKey) - { - var key = entryId + "|" + actionKey; - ackUpdate(prev => - { - if (prev.Contains(key)) return prev; - return new HashSet(prev) { key }; - }); - var dq = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); - await Task.Delay(700); - void Clear() => ackUpdate(prev => - { - if (!prev.Contains(key)) return prev; - var nxt = new HashSet(prev); - nxt.Remove(key); - return nxt; - }); - if (dq is null) Clear(); - else dq.TryEnqueue(Clear); - } - - hasMoreHistoryRef.Current = Props.HasMoreHistory; - loadMoreHistoryRef.Current = Props.OnLoadMoreHistory; - - var entryCount = Props.Entries.Count; - var firstEntryId = entryCount > 0 ? Props.Entries[0].Id : null; - var lastEntryId = entryCount > 0 ? Props.Entries[entryCount - 1].Id : null; - var previousSessionId = prevSessionIdRef.Current; - var previousEntryCount = prevEntryCountRef.Current; - var previousFirstEntryId = prevFirstEntryIdRef.Current; - var previousLastEntryId = prevLastEntryIdRef.Current; - var sessionChanged = Props.SessionId != previousSessionId; - var isFirstMount = sessionChanged && previousSessionId is null; - var initialLoad = isFirstMount - ? entryCount > 0 - : (!sessionChanged && previousEntryCount == 0 && entryCount > 0); - var prependedHistory = !sessionChanged - && previousEntryCount > 0 - && entryCount > previousEntryCount - && previousFirstEntryId is not null - && firstEntryId != previousFirstEntryId - && lastEntryId == previousLastEntryId - && ContainsEntryId(Props.Entries, previousFirstEntryId); - var appendedEntries = !sessionChanged - && entryCount > previousEntryCount - && !prependedHistory; - - void StoreSessionOffset(string? sessionId, double offset) - { - if (sessionId is { Length: > 0 }) - { - sessionOffsetsRef.Current[sessionId] = offset; - s_sessionOffsets[sessionId] = offset; - // Evict oldest entries when cache exceeds bound - if (s_sessionOffsets.Count > MaxSessionOffsets) - { - var first = s_sessionOffsets.Keys.First(); - s_sessionOffsets.Remove(first); - } - } - } - - void UpdateScrollMetrics(Microsoft.UI.Xaml.Controls.ScrollViewer sv) - { - if (sv.ViewportHeight <= 0) return; - - lastVerticalOffsetRef.Current = sv.VerticalOffset; - lastScrollableHeightRef.Current = sv.ScrollableHeight; - isFollowingRef.Current = sv.ScrollableHeight - sv.VerticalOffset <= FollowThreshold; - StoreSessionOffset(prevSessionIdRef.Current, sv.VerticalOffset); - } - - void QueueScrollToBottom( - Microsoft.UI.Xaml.Controls.ScrollViewer sv, - string? sessionId, - bool disableAnimation, - bool respectUserScrollPosition = false) - { - isFollowingRef.Current = true; - - // Bottom scroll anchoring (VerticalAnchorRatio = 1.0) keeps whatever row currently - // sits at the viewport bottom pinned there. During a DISCRETE scroll-to-bottom the - // extent is still growing — rows below the current anchor keep realizing — so if - // anchoring stays on the platform re-pins the STALE anchor row after each ChangeView - // and the view settles ~one row short of the true bottom and never converges (this is - // the LargeNative gap=240 and ThinkingAndStreaming 30%-stick regression; see PR #1014 - // / issue #996). Turn anchoring OFF while we drive the view to the real bottom, then - // restore 1.0 once the extent settles so subsequent IN-PLACE streaming growth of the - // newest row keeps following. This also stops anchoring and the SizeChanged-driven - // QueueScrollToBottom from fighting each other mid-stream (the residual streaming - // jitter noted on #996). - sv.VerticalAnchorRatio = double.NaN; - var anchoringRestored = false; - void RestoreAnchoring() - { - if (anchoringRestored) - return; - anchoringRestored = true; - sv.VerticalAnchorRatio = 1.0; - } - - void PinToBottom(bool passDisableAnimation) - { - sv.UpdateLayout(); - var bottom = sv.ScrollableHeight; - sv.ChangeView(null, bottom, null, passDisableAnimation); - lastVerticalOffsetRef.Current = bottom; - lastScrollableHeightRef.Current = sv.ScrollableHeight; - isFollowingRef.Current = true; - StoreSessionOffset(sessionId, bottom); - } - - // Only one settle timer runs at a time: a later discrete scroll-to-bottom (e.g. a - // token bump mid-stream) restarts the settle window instead of spawning parallel - // timers that would fight over ChangeView. Null the ref too (not just Stop) so a - // subsequently rejected enqueue can't leave a stopped-but-non-null timer that makes the - // follow gates believe a settle is still in flight and suppress follow forever. - scrollSettleTimerRef.Current?.Stop(); - scrollSettleTimerRef.Current = null; - - // A scroll-to-bottom is a FOLLOW intent, but it can be triggered by a layout growth - // (thinking indicator / new row) that fires while the user has ALREADY scrolled far up - // to read earlier history. Re-pinning then would yank them back down (the reported - // "fighting the scrollbar" bug). Distinguish the two by how far the live offset sits - // from the bottom: content-growth follow stays within a bounded band of the bottom, - // while a reader has scrolled MANY viewports away. The gap is measured on a FRESH - // layout (below), after any in-flight user ChangeView has been applied, so the decision - // never races a scroll the user just issued. - bool UserScrolledAway() - { - var abandonGap = Math.Max( - FollowToBottomMinAbandonGap, - sv.ViewportHeight * FollowToBottomAbandonViewportFactor); - return sv.ScrollableHeight - sv.VerticalOffset > abandonGap; - } - - // Coalesce reactive follows: mark a pin in flight so the SizeChanged storm does not - // pile up dozens of enqueued pins. Held across the enqueued callback's SYNCHRONOUS - // layout/pin work (during which our own UpdateLayout/ChangeView can re-enter - // SizeChanged) and released only in a terminal path: after the settle timer is started - // and owns the chase, on the abandon bail, or below if the enqueue is rejected. - scrollPinPendingRef.Current = true; - - if (!sv.DispatcherQueue.TryEnqueue(() => - { - // Stop any timer that a concurrently-enqueued QueueScrollToBottom may have created - // and left in the ref, so we never leak an orphaned timer that keeps pinning. - scrollSettleTimerRef.Current?.Stop(); - scrollSettleTimerRef.Current = null; - - // Flush any pending user ChangeView, then bail before pinning if this is a REACTIVE - // follow (layout growth) and the user has scrolled away — do NOT clobber their - // reading position with a bottom pin. Explicit scroll-to-bottom requests (session - // switch, token bump, user sent a message) pass respectUserScrollPosition = false - // and always pin, since they ARE the user asking to jump to the newest row. - sv.UpdateLayout(); - if ((respectUserScrollPosition && UserScrolledAway()) || suppressAutoFollowRef.Current) - { - // Abandoning the follow: we are NOT at the bottom, so DISABLE anchoring rather - // than restore it to 1.0 — pinning the bottom row here would let post-remount - // extent re-estimation drift the reader's held position. The coalescing guard is - // released as this pin is now resolved (no timer will run). - isFollowingRef.Current = false; - sv.VerticalAnchorRatio = double.NaN; - scrollPinPendingRef.Current = false; - return; - } - - // First pin immediately for responsiveness. - PinToBottom(disableAnimation); - - var ticks = 0; - var stableTicks = 0; - var timer = new Microsoft.UI.Xaml.DispatcherTimer - { - Interval = TimeSpan.FromMilliseconds(FollowToBottomSettleTickMs) - }; - scrollSettleTimerRef.Current = timer; - timer.Tick += (_, _) => - { - // Bail out if the ScrollViewer was swapped/detached (unmount) so we never - // keep pinning a dead visual or leave anchoring disabled. - if (scrollViewRef.Current != sv || sv.XamlRoot is null) - { - timer.Stop(); - scrollSettleTimerRef.Current = null; - RestoreAnchoring(); - return; - } - - ticks++; - - // Honor user-scroll intent detected by ViewChanged between ticks. - // When the user scrolls, their ChangeView fires ViewChanged which calls - // UpdateScrollMetrics → sets isFollowingRef=false (gap > FollowThreshold). - // Check BEFORE UpdateLayout so we never call PinToBottom after a user scroll. - if (!isFollowingRef.Current) - { - timer.Stop(); - scrollSettleTimerRef.Current = null; - sv.VerticalAnchorRatio = double.NaN; - return; - } - - sv.UpdateLayout(); - - // Re-check after layout: UpdateLayout can flush pending ViewChanged events - // (e.g. a user ChangeView that was queued but not yet dispatched). - if (!isFollowingRef.Current) - { - timer.Stop(); - scrollSettleTimerRef.Current = null; - sv.VerticalAnchorRatio = double.NaN; - return; - } - - // Yield to a real user scroll away from the bottom (bounded-band vs. many- - // viewports discriminator described on UserScrolledAway above). The settle - // timer ALWAYS yields — even for an explicit scroll-to-bottom, once the initial - // jump has landed we must not keep fighting a user who then drags up to read. - if (UserScrolledAway() || suppressAutoFollowRef.Current) - { - // Same abandon rule as the first pin: disable anchoring (do not restore - // 1.0) so the held reading position is not dragged by extent re-estimation. - isFollowingRef.Current = false; - timer.Stop(); - scrollSettleTimerRef.Current = null; - sv.VerticalAnchorRatio = double.NaN; - return; - } - - PinToBottom(passDisableAnimation: true); - - // Converge on being AT the bottom for a couple of ticks, then hand off to - // scroll anchoring (VerticalAnchorRatio = 1.0, restored below). We deliberately - // do NOT require the extent to be stable: WinUI's ItemsRepeater keeps re- - // estimating row heights as rows realize, so the extent wobbles for many frames - // even once we are visually pinned. Waiting for extent stability made this timer - // run its full hard cap (~384ms) re-pinning every tick, which clobbered a user - // scroll issued during that window (the offset never dropped because our own - // ChangeView superseded theirs every 16ms). Once we are at the bottom, restored - // anchoring keeps the bottom row glued as the extent estimate settles, so the - // timer's job is done — terminate quickly and stop fighting user input. - var atBottom = sv.ScrollableHeight - sv.VerticalOffset <= FollowThreshold; - stableTicks = atBottom ? stableTicks + 1 : 0; - - if (stableTicks >= FollowToBottomSettleStableTicks || ticks >= FollowToBottomMaxSettleTicks) - { - timer.Stop(); - scrollSettleTimerRef.Current = null; - RestoreAnchoring(); - } - }; - timer.Start(); - // The settle timer now owns the chase; release the coalescing guard. Further - // SizeChanged notifications are gated by scrollSettleTimerRef being non-null while - // it runs, and by scrollPinPendingRef only during the synchronous window above. - scrollPinPendingRef.Current = false; - })) - { - // Dispatcher rejected the enqueue (e.g. teardown): never leave anchoring disabled - // or the coalescing guard stuck on. - scrollPinPendingRef.Current = false; - RestoreAnchoring(); - } - } - - void QueuePreservePrependOffset(Microsoft.UI.Xaml.Controls.ScrollViewer sv, string? sessionId, double oldOffset, double oldScrollableHeight) - { - // Content is inserted ABOVE the current viewport ("load earlier history"). In a stock - // WinUI ItemsRepeater, bottom scroll anchoring (VerticalAnchorRatio = 1.0) would - // natively preserve the on-screen position by shifting VerticalOffset down by the - // inserted height. Our FunctionalUI reconciler, however, FULL-REMOUNTS every Entry on - // this render (the #996 limitation): the element the platform had chosen as the - // anchor is destroyed, so leaving anchoring at 1.0 just re-pins to the NEW bottom and - // yanks a scrolled-up reader down to the newest row (observed: offset 2528 -> 8531, - // gap 0). So for the prepend pass we DISABLE anchoring and manually re-seat the offset - // by the inserted height instead. Exact pixel preservation isn't achievable under the - // full remount, but this keeps the reader in the middle band — not reset to the top, - // not dragged to the bottom (see the KNOWN LIMITATION note on the prepend proof test). - suppressAutoFollowRef.Current = true; - sv.VerticalAnchorRatio = double.NaN; - - // A prior scroll-to-bottom settle timer would keep pinning to the bottom and defeat - // the preserved reading position — cancel it for this prepend. Clear the coalescing - // guard too so the anchoring gate/SizeChanged follow path isn't left believing a pin - // is still in flight. - scrollSettleTimerRef.Current?.Stop(); - scrollSettleTimerRef.Current = null; - scrollPinPendingRef.Current = false; - - void RestoreOffset() - { - // Re-seat by the ACTUAL inserted height measured after layout (ScrollableHeight - // delta), not a stale precomputed value, so estimated-extent wobble during row - // realization can't leave the reader clamped to the bottom. - sv.UpdateLayout(); - var delta = sv.ScrollableHeight - oldScrollableHeight; - var target = ClampOffset(oldOffset + Math.Max(0, delta), sv.ScrollableHeight); - sv.ChangeView(null, target, null, disableAnimation: true); - lastVerticalOffsetRef.Current = target; - lastScrollableHeightRef.Current = sv.ScrollableHeight; - isFollowingRef.Current = sv.ScrollableHeight - target <= FollowThreshold; - StoreSessionOffset(sessionId, target); - suppressAutoFollowRef.Current = false; - } - - if (!sv.DispatcherQueue.TryEnqueue(RestoreOffset)) - { - RestoreOffset(); - } - } - - // Load more button — outside the repeated items - var loadMoreButton = Props.HasMoreHistory - ? Button(LocalizationHelper.GetString("Chat_Timeline_LoadEarlier"), () => Props.OnLoadMoreHistory?.Invoke()) - .HAlign(HorizontalAlignment.Center) - .Set(b => { b.Padding = new Thickness(16, 8, 16, 8); b.CornerRadius = new CornerRadius(4); }) - .Resources(r => r - .Set("ButtonBackground", Ref("SubtleFillColorTransparentBrush")) - .Set("ButtonBackgroundPointerOver", Ref("SubtleFillColorSecondaryBrush")) - .Set("ButtonBackgroundPressed", Ref("SubtleFillColorTertiaryBrush")) - .Set("ButtonBorderBrush", Ref("SubtleFillColorTransparentBrush"))) - .Margin(0, 8, 0, 8) - : (Element)Empty(); - - static Element TimelineInset(Element child, double top = 2, double bottom = 2) => - Border(child).Padding(36, top, 24, bottom); - - // ── OpenClaw skin: bubbled user vs. left-aligned assistant card ── - - var userSender = Props.UserSenderLabel; - var assistantSender = Props.AssistantSenderLabel; - var defaultModel = Props.DefaultModel; - var meta = Props.EntryMetadata; - string? latestAssistantEntryId = null; - for (var i = Props.Entries.Count - 1; i >= 0; i--) - { - if (Props.Entries[i].Kind == ChatTimelineItemKind.Assistant) - { - latestAssistantEntryId = Props.Entries[i].Id; - break; - } - } - - // ── Web Control UI palette: "dash-light" theme (verified against the - // bundled assets/index-*.css — dash-light is what the user runs). - // Colors here mirror the CSS variables exactly so bubbles/avatars - // look identical to the web at http://localhost:18789/chat. - // ────────────────────────────────────────────────────────────── - // ── Kenny Hong palette (kenehong/native-chat-v2): Microsoft Fluent - // ``AccentFillColorDefaultBrush`` for the user bubble (white text on - // accent), ``SubtleFillColorSecondaryBrush`` for the assistant bubble - // and page background. All looked up from the theme so they react to - // light/dark mode and high-contrast settings without manual swaps. - // ───────────────────────────────────────────────────────────────── - Brush themeBrush(string key) => (Brush)Microsoft.UI.Xaml.Application.Current.Resources[key]; - // When the host window paints a non-Solid SystemBackdrop (Mica / MicaAlt / - // Acrylic), let it show through by using a transparent chat-page fill. - // Otherwise fall back to the subtle layer color so Solid mode still - // reads as a flat surface. - var chatPageBg = (Brush)new SolidColorBrush(Microsoft.UI.Colors.Transparent); - var assistantBubbleBg = themeBrush("SubtleFillColorSecondaryBrush"); - var assistantBubbleBdr = themeBrush("ControlStrokeColorDefaultBrush"); - // User bubble brushes vary with the configured tone. Accent → bold - // brand-color bubble with white text (classic iMessage feel). - // Secondary → ``AccentFillColorSecondaryBrush`` — the same accent - // color at a softer fill weight. Both modes pair with - // ``TextOnAccentFillColorPrimaryBrush``, which Fluent guarantees - // meets WCAG AA contrast against any accent-tinted fill in both - // light and dark themes (Microsoft's Fluent design token spec). - var userBubbleBg = themeBrush("AccentFillColorSecondaryBrush"); - var userBubbleBdr = themeBrush("AccentFillColorSecondaryBrush"); - var userBubbleFg = themeBrush("TextOnAccentFillColorPrimaryBrush"); - var avatarPanelBg = themeBrush("SubtleFillColorTertiaryBrush"); - var avatarBorder = themeBrush("ControlStrokeColorDefaultBrush"); - var assistantAvatarFg = themeBrush("TextFillColorSecondaryBrush"); - var userAvatarBg = themeBrush("AccentFillColorDefaultBrush"); - var userAvatarFg = themeBrush("TextOnAccentFillColorPrimaryBrush"); - // a11y: timestamps and "is thinking" caption sit directly on the - // window backdrop. On Mica/Acrylic the system tint is translucent, - // so Tertiary text can fall below WCAG AA. Bump to Secondary when - // the chat surface is transparent over a host backdrop. - // Timestamps / helper captions sit directly on the window backdrop (no - // bubble behind them), so a snapshot from Application.Resources renders - // the light-theme secondary color and vanishes in dark mode. Drive this - // brush from the built-in TextFillColorSecondary token for the timeline - // root's ActualTheme instead (wired below) so it stays legible after a - // runtime light/dark switch. - var chatStampFg = new SolidColorBrush( - Theme.ResolveColor("TextFillColorSecondary", ElementTheme.Default)); - var chatTextFg = themeBrush("TextFillColorPrimaryBrush"); - // Tool chips: very subtle background tint + light border so they - // read as a secondary surface distinct from the filled assistant - // bubble without looking like an empty outlined box. - // CardBackgroundFillColorDefaultBrush is the right semantic key — - // the bubble surface below is opaque (Mica/acrylic isn't being - // used directly), so the LayerOnAcrylic family would render - // incorrectly in dark/HC themes. - var toolCardBorderBrush = themeBrush("ControlStrokeColorDefaultBrush"); - - // Avatar: 36×36 circle (Kenny uses circular avatars). Same constructor - // as before but radius defaults to half the size for a perfect circle. - Element AvatarBox(string glyph, Brush bg, Brush border, Brush fg, double size = 36, double radius = 18) => - Border( - TextBlock(glyph) - .Set(t => - { - t.HorizontalAlignment = HorizontalAlignment.Center; - t.VerticalAlignment = VerticalAlignment.Center; - t.FontSize = 13; - t.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold; - t.Foreground = fg; - }) - ).Background(bg).Size(size, size).CornerRadius(radius) - .WithBorder(border, 1); - - // Assistant avatar: 36×36 circle showing the OpenClaw app icon (the - // same PNG used by the tray and chat-window title bar) so the agent - // identity is visually consistent across surfaces. - Element AssistantAvatar(double size = 36, double radius = 18) => - Border( - Image("ms-appx:///Assets/Square44x44Logo.targetsize-256_altform-unplated.png") - .Set(im => - { - im.Stretch = Stretch.UniformToFill; - im.HorizontalAlignment = HorizontalAlignment.Stretch; - im.VerticalAlignment = VerticalAlignment.Stretch; - }) - ).Background(avatarPanelBg).Size(size, size).CornerRadius(radius) - .WithBorder(avatarBorder, 1) - .Set(b => b.Padding = new Thickness(0)) - .AutomationName($"{assistantSender} avatar"); - - // Helper to format a timestamp as the web does: "h:mm tt" in local time. - static string FormatTime(DateTimeOffset? ts) => - ts is { } v ? v.ToLocalTime().ToString("h:mm tt") : ""; - - ChatEntryMetadata? MetaFor(string id) => - meta is not null && meta.TryGetValue(id, out var m) ? m : null; - - string RowKey(ChatTimelineItem entry) => - $"thread:{Props.SessionId ?? "none"}|generation:{Props.TimelineGeneration}|kind:{entry.Kind}|id:{entry.Id}"; - - string SyntheticRowKey(string id, ChatTimelineItemKind kind) => - $"thread:{Props.SessionId ?? "none"}|generation:{Props.TimelineGeneration}|kind:{kind}|synthetic:{id}"; - - // Hover-revealed action icon (copy / read aloud / trash). Opacity 0 - // and not hit-testable until the entry is hovered, then fades in - // and becomes clickable. Soft pill radius + Light weight glyph so - // it feels friendlier than the standard MDL2 button look. When the - // matching action is acknowledged (briefly after click) the glyph - // swaps to a checkmark for instant visual feedback. - Element HoverIcon(string entryId, string actionKey, string glyph, string ackGlyph, - string tip, Action onClick) - { - var visible = hoveredEntries.Value.Contains(entryId); - var acked = ackedActionsValue.Contains(entryId + "|" + actionKey); - var shownGlyph = acked ? ackGlyph : glyph; - var shownColor = acked ? themeBrush("SystemFillColorSuccessBrush") : chatStampFg; - return Button( - TextBlock(shownGlyph) - .Set(t => - { - t.FontFamily = FluentIconCatalog.SymbolThemeFontFamily; - t.FontSize = 14; - t.FontWeight = Microsoft.UI.Text.FontWeights.Light; - t.Foreground = shownColor; - }), - onClick - ).Set(b => - { - b.Padding = new Thickness(7, 5, 7, 5); - b.MinWidth = 30; b.MinHeight = 26; - b.CornerRadius = new CornerRadius(13); - // Hide together with hover — once the pointer leaves the - // bubble, the icon (whether ack'd or not) goes away too. - b.Opacity = visible ? 1.0 : 0.0; - b.IsHitTestVisible = visible; - }) - .Resources(r => r - .Set("ButtonBackground", new SolidColorBrush(Colors.Transparent)) - .Set("ButtonBackgroundPointerOver", themeBrush("SubtleFillColorSecondaryBrush")) - .Set("ButtonBackgroundPressed", themeBrush("SubtleFillColorTertiaryBrush")) - .Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent)) - .Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent)) - .Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent))) - .AutomationName(tip); - } - - // Wrap a row with hover handlers that flip the entry id in - // hoveredEntries on PointerEntered/Exited. Callers should wrap the - // row in a Border with a transparent background so the WHOLE - // bounding box (including the gap between bubble and footer) is - // hit-testable — otherwise moving the pointer down to a - // hover-revealed action button briefly exits the hover area and - // hides the icon before the click lands. - T WithHoverHandlers(T el, string entryId) where T : Element - { - return el - .OnPointerEntered((_, _) => - { - var current = hoveredEntries.Value; - if (current.Contains(entryId)) return; - var next = new HashSet(current) { entryId }; - hoveredEntries.Set(next); - }) - .OnPointerExited((_, _) => - { - var current = hoveredEntries.Value; - if (current.Contains(entryId)) - { - var next = new HashSet(current); - next.Remove(entryId); - hoveredEntries.Set(next); - } - // Drop any pending ack glyph for this entry so the next - // hover starts fresh with the original copy/speak/trash - // icon instead of a stale checkmark. - var prefix = entryId + "|"; - ackUpdate(prev => - { - if (!prev.Any(k => k.StartsWith(prefix, StringComparison.Ordinal))) - return prev; - return new HashSet(prev.Where(k => !k.StartsWith(prefix, StringComparison.Ordinal))); - }); - }); - } - - // Copy assistant message text to the system clipboard. Strips a - // light amount of markdown noise (fenced code backticks) so the - // clipboard payload reads naturally when pasted into prose. - static void CopyToClipboard(string text) - { - if (string.IsNullOrEmpty(text)) return; - try - { - ClipboardHelper.CopyText(text, flush: true); - } - catch (Exception ex) { OpenClawTray.Services.Logger.Debug($"ChatTimeline: clipboard copy contention: {ex.Message}"); } - } - - void ReadAloud(string entryId, string text) => - AsyncEventHandlerGuard.Run( - () => ReadAloudAsync(entryId, text), - new OpenClawTray.AppLogger(), - nameof(ReadAloud)); - - async Task ReadAloudAsync(string entryId, string text) - { - if (string.IsNullOrEmpty(text)) return; - - // Toggle: if this entry is currently being read, stop it. - if (speakingEntryId.Value == entryId) - { - speakingEntryId.Set(null); - Props.OnStopSpeaking?.Invoke(); - return; - } - - if (Props.OnReadAloud is not { } onReadAloud) return; - - speakingEntryId.Set(entryId); - try - { - await onReadAloud(StripMarkdownForSpeech(text)); - } - finally - { - // Clear speaking state when playback finishes or fails - speakingEntryId.Set(null); - } - } - - // Very light markdown stripper so the synthesizer doesn't read - // backticks, asterisks, link brackets, etc. Markdown rendering is - // already done visually; this only cleans the spoken transcript. - static string StripMarkdownForSpeech(string text) - { - if (string.IsNullOrEmpty(text)) return text; - var s = System.Text.RegularExpressions.Regex.Replace(text, @"```[\s\S]*?```", " code block "); - s = System.Text.RegularExpressions.Regex.Replace(s, @"`([^`]+)`", "$1"); - s = System.Text.RegularExpressions.Regex.Replace(s, @"!\[[^\]]*\]\([^)]*\)", " image "); - s = System.Text.RegularExpressions.Regex.Replace(s, @"\[([^\]]+)\]\([^)]*\)", "$1"); - s = System.Text.RegularExpressions.Regex.Replace(s, @"[*_#>]+", " "); - return s; - } - - Element BuildAssistantFooter(string sender, string time, string? model, - int? inputTokens, int? outputTokens, int? responseTokens, int? contextPct, - Brush stampFg, - string entryId, string entryText, - string? fallbackUsageSummary) - { - var entryUsageSummary = fallbackUsageSummary; - var showInlineUsage = Props.ShowToolCalls - && !string.IsNullOrWhiteSpace(entryUsageSummary); - - var parts = new List(); - void AddPill(string text) - { - if (string.IsNullOrEmpty(text)) return; - parts.Add(Caption(text).Foreground(stampFg) - .Set(t => t.FontSize = 11) - .VAlign(VerticalAlignment.Center)); - } - - // Hover actions — Copy + Read aloud. Placed at the END of the - // footer so the timestamp/sender stay anchored on the left and - // the empty space (when not hovered) trails off harmlessly to - // the right instead of leaving an awkward gap before the time. - AddPill(time); - if (showInlineUsage) - { - AddPill("·"); - AddPill(entryUsageSummary!); - } - - parts.Add(HoverIcon(entryId, "copy", "\uE8C8", "\uE73E", - LocalizationHelper.GetString("Chat_Assistant_Action_Copy"), - () => { CopyToClipboard(entryText); AckAction(entryId, "copy"); }).VAlign(VerticalAlignment.Center)); - - var isSpeaking = speakingEntryId.Value == entryId; - var speakGlyph = isSpeaking ? "\uE71A" : "\uE767"; // Stop vs Speaker - var speakTip = isSpeaking ? "Stop" : LocalizationHelper.GetString("Chat_Assistant_Action_ReadAloud"); - parts.Add(HoverIcon(entryId, "speak", speakGlyph, "\uE73E", - speakTip, - () => { ReadAloud(entryId, entryText); if (!isSpeaking) AckAction(entryId, "speak"); }).VAlign(VerticalAlignment.Center)); - - return (FlexRow(parts.ToArray()) with { ColumnGap = 8 }) - .HAlign(HorizontalAlignment.Left); - } - - // User-bubble footer mirrors the assistant footer UX so the same - // hover affordance shows up on both sides. Order is reversed for - // the user side: hover actions sit on the LEFT and the timestamp - // anchors the FAR RIGHT (closest to the bubble corner) — matches - // the user's reading direction when the bubble is right-aligned. - Element BuildUserFooter(string sender, string time, Brush stampFg, - string entryId, string entryText) - { - var parts = new List - { - HoverIcon(entryId, "copy", "\uE8C8", "\uE73E", - LocalizationHelper.GetString("Chat_Assistant_Action_Copy"), - () => { CopyToClipboard(entryText); AckAction(entryId, "copy"); }).VAlign(VerticalAlignment.Center), - // TODO: Restore this delete action once the chat provider can remove - // prompts from both the local timeline and gateway history. Leaving - // the no-op action visible is misleading because AckAction flashes - // success even though nothing is deleted. - // HoverIcon(entryId, "delete", "\uE74D", "\uE73E", - // LocalizationHelper.GetString("Chat_User_Action_Delete"), - // () => { /* TODO: wire to provider */ AckAction(entryId, "delete"); }).VAlign(VerticalAlignment.Center), - }; - - if (!string.IsNullOrEmpty(time)) - parts.Add(Caption(time).Foreground(stampFg) - .Set(t => t.FontSize = 11) - .VAlign(VerticalAlignment.Center)); - - return (FlexRow(parts.ToArray()) with { ColumnGap = 8 }) - .HAlign(HorizontalAlignment.Right); - } - - Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst) - { - // Detect attachment indicators injected by the send path. - // Format: "\u200B🖼️ filename.png" or "\u200B📎 file.md" on its own line. - // The zero-width space prefix prevents false positives from normal text. - // When present, split into message text + attachment cards. - var text = entry.Text ?? ""; - var lines = text.Split('\n'); - var messageLines = new List(); - var attachmentNames = new List<(string Icon, string Name, bool IsImage)>(); - - foreach (var line in lines) - { - var trimLine = line.Trim(); - if (trimLine.StartsWith("\u200B🖼️ ")) - attachmentNames.Add(("🖼️", trimLine.Substring(4).Trim(), true)); - else if (trimLine.StartsWith("\u200B📎 ")) - attachmentNames.Add(("📎", trimLine.Substring(3).Trim(), false)); - else - messageLines.Add(line); - } - - var messageText = string.Join('\n', messageLines).Trim(); - var hasMessage = !string.IsNullOrEmpty(messageText); - var hasAttachments = attachmentNames.Count > 0; - - // Build attachment elements. Images become real thumbnail previews - // by pulling the original bytes from OpenClawChatDataProvider's - // ImagePreviewCache (populated on Send). Non-image attachments - // remain as compact icon+name chips. Both are placed *inside* the - // same bubble as the message text so the user sees a single - // unified message — matching how Slack/iMessage/etc. show - // image-with-caption posts. - var attachmentElements = new List(); - if (hasAttachments) - { - foreach (var (_, name, isImage) in attachmentNames) - { - if (isImage && OpenClawChatDataProvider.ImagePreviewCache.TryGetValue(name, out var bytes)) - { - var bmp = TryDecodeBitmap(bytes); - if (bmp is not null) - { - const double maxW = 280; - const double maxH = 200; - var pw = bmp.PixelWidth > 0 ? bmp.PixelWidth : (int)maxW; - var ph = bmp.PixelHeight > 0 ? bmp.PixelHeight : (int)maxH; - var scale = Math.Min(Math.Min(maxW / pw, maxH / ph), 1.0); - var w = pw * scale; - var h = ph * scale; - - attachmentElements.Add( - Border(Empty()) - .CornerRadius(8) - .Set(b => - { - b.Width = w; - b.Height = h; - b.Background = new ImageBrush - { - ImageSource = bmp, - Stretch = Stretch.UniformToFill, - }; - b.HorizontalAlignment = HorizontalAlignment.Right; - })); - continue; - } - } - - // Fallback chip (file attachment or missing image bytes). - var fileGlyph = isImage ? "\uEB9F" : "\uE8A5"; // Photo / Page - attachmentElements.Add(Border( - Grid([GridSize.Auto, GridSize.Star()], [GridSize.Auto], - Border( - TextBlock(fileGlyph) - .Set(t => - { - t.FontFamily = FluentIconCatalog.SymbolThemeFontFamily; - t.FontSize = 16; - t.Foreground = userBubbleFg; - t.VerticalAlignment = VerticalAlignment.Center; - t.HorizontalAlignment = HorizontalAlignment.Center; - }) - ).Size(32, 32) - .CornerRadius(6) - .Background(new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF))) - .Grid(row: 0, column: 0), - TextBlock(name) - .Set(t => - { - t.TextWrapping = TextWrapping.NoWrap; - t.TextTrimming = TextTrimming.CharacterEllipsis; - t.FontSize = 13; - t.Foreground = userBubbleFg; - t.VerticalAlignment = VerticalAlignment.Center; - t.Margin = new Thickness(8, 0, 0, 0); - t.MaxWidth = 240; - }) - .Grid(row: 0, column: 1) - ) - ).Set(b => - { - b.CornerRadius = new CornerRadius(6); - b.Padding = new Thickness(8, 6, 12, 6); - b.BorderThickness = new Thickness(1); - b.BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0xFF, 0xFF)); - b.Background = new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xFF, 0xFF)); - })); - } - } - - // Build the unified bubble: attachments stacked at the top, text - // below them. A single Border with the user-bubble background + - // bubbleRadius wraps both so they read as one message. - var bubbleChildren = new List(); - foreach (var ae in attachmentElements) bubbleChildren.Add(ae); - var entryMeta = MetaFor(entry.Id); - if (hasMessage) - { - bubbleChildren.Add( - RichTextBlock() - .Set(t => - { - t.TextWrapping = TextWrapping.Wrap; - t.FontSize = 14; - t.Foreground = userBubbleFg; - t.IsTextSelectionEnabled = true; - t.FontFamily = s_chatTextFontFamily; - t.TextTrimming = TextTrimming.None; - t.MaxLines = 0; - t.LineHeight = 0; - t.CharacterSpacing = 0; - t.Width = double.NaN; - t.MinWidth = 0; - t.MaxWidth = double.PositiveInfinity; - t.Style = (Style)Application.Current.Resources["ChatUserBubbleSelectionStyle"]; - // Render the message as a single Paragraph (one Run) - // so the whole user message is one continuous - // selection scope — matching the assistant bubble's - // RichTextBlock append-block pattern. The plain-text - // run keeps the bubble inert (no markdown / links). - ApplyPlainSelectableParagraph(t, messageText); - })); - } - Element content; - if (bubbleChildren.Count > 0) - { - content = Border( - VStack(8, bubbleChildren.ToArray()) - ).Background(userBubbleBg) - .Set(b => - { - b.CornerRadius = bubbleRadius; - // When the bubble contains only an image, tighten the - // padding so the thumbnail nearly fills the bubble. - b.Padding = (hasAttachments && !hasMessage) - ? new Thickness(6, 6, 6, 6) - : bubblePadding; - b.VerticalAlignment = VerticalAlignment.Center; - }) - .HAlign(HorizontalAlignment.Right); - } - else - { - content = Empty(); - } - - // User avatars are hidden in the production tray chat, but keep - // the branch local so the row layout stays symmetric with the - // assistant path. - Element rightSlot = !showUserAvatar - ? Empty() - : (endsBurst - ? AvatarBox("🧑", userAvatarBg, userBubbleBdr, userAvatarFg).VAlign(VerticalAlignment.Center) - : Border(Empty()).Size(36, 36)); - - var bubbleRow = Grid( - [GridSize.Star(), GridSize.Auto], - [GridSize.Auto], - content.HAlign(HorizontalAlignment.Right).Grid(row: 0, column: 0), - rightSlot.Grid(row: 0, column: 1).Margin(showUserAvatar ? bubbleSideMargin : 0, 0, 0, 0) - ).HAlign(HorizontalAlignment.Stretch); - - Element footer = Empty(); - if (endsBurst && showTimestamps) - { - var timeStr = FormatTime(entryMeta?.Timestamp); - var rightInset = showUserAvatar ? (36 + bubbleSideMargin) : 0; - rightInset += (int)bubblePadding.Right; - footer = BuildUserFooter(userSender, timeStr, chatStampFg, entry.Id, entry.Text ?? "") - .Margin(0, 2, rightInset, 0); - } - - var topMargin = startsBurst ? 4.0 : 1.0; - var bottomMargin = endsBurst ? 4.0 : 1.0; - return WithHoverHandlers( - Border( - VStack(2, bubbleRow, footer) - .HAlign(HorizontalAlignment.Stretch) - ).Background(new SolidColorBrush(Colors.Transparent)) - .Margin(gutter, topMargin, 20, bottomMargin), - entry.Id); - } - - // Per-turn shared reference between the assistant bubble and any - // tool cards rendered below it. The tool card binds its Width to - // bubble.ActualWidth - toolIndent so the two cards' right edges - // (and left indent) stay exactly parallel as the bubble grows - // with content. Single-element Border[] used as a mutable slot - // since these are local functions (no nested class allowed). - Element RenderAssistantEntry( - ChatTimelineItem entry, - bool startsBurst, - bool endsBurst, - bool showAvatar, - Microsoft.UI.Xaml.Controls.Border[]? bubbleSlot = null, - Element? nestedTool = null, - Element? overrideBubbleContent = null, - bool suppressFooter = false, - bool forceVisible = false) - { - if (string.IsNullOrEmpty(entry.Text) && nestedTool is null && overrideBubbleContent is null) - return Empty(); - - // Hidden by user toggle — collapses entire assistant block. - if (!showAsstBubbles && !forceVisible) - return Empty(); - - // Avatar shown only on the FIRST entry of a contiguous agent-side - // run. Continuation entries reserve a 36×36 spacer so the bubble's - // left edge stays aligned with the first entry (matches the user - // burst path above and the tool burst path below). - Element leftSlot = !showAssistAvatar - ? Empty() - : (showAvatar - ? AssistantAvatar().VAlign(VerticalAlignment.Top) - : Border(Empty()).Size(36, 36)); - - // Assistant bubble — subtle gray with primary text. HAlign=Left - // keeps the bubble anchored next to the avatar/timestamp column. - // MaxWidth=720 caps the growth so long messages stop where the - // tool burst card's max right edge lands. - // When `nestedTool` is supplied, the tool burst (single chip OR - // collapsed multi-step summary) is rendered INSIDE the bubble's - // content area — directly below the assistant text with a small - // top gap — so it visually reads as a child of the bubble. - var assistantEntryMeta = MetaFor(entry.Id); - Element bubbleContent = overrideBubbleContent ?? SafeMarkdownText(entry.Text); - if (nestedTool != null) - { - // Top gap (markdown bottom → tool card top) needs to be a - // little larger than the bubble's bottom padding so the - // optical spacing matches the gap from the tool card to the - // bubble's bottom edge — Markdown text has very tight - // line-height with no trailing descender, so a literal-equal - // gap reads as visibly tighter on top. - var nestedTopGap = (int)Math.Round(bubblePadding.Bottom + 4); - bubbleContent = VStack(nestedTopGap, bubbleContent, nestedTool); - } - var card = Border( - bubbleContent - ).Background(assistantBubbleBg) - .Set(b => - { - b.CornerRadius = bubbleRadius; - b.Padding = bubblePadding; - b.MaxWidth = 720; - if (bubbleSlot != null) bubbleSlot[0] = b; - }); - - var bubbleRow = Grid( - [GridSize.Auto, GridSize.Star()], - [GridSize.Auto], - leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar ? bubbleSideMargin : 0, 0), - card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1) - ).HAlign(HorizontalAlignment.Stretch); - Element footer = Empty(); - if (endsBurst && showTimestamps && !suppressFooter) - { - var timeStr = FormatTime(assistantEntryMeta?.Timestamp); - var modelStr = assistantEntryMeta?.Model ?? defaultModel; - footer = BuildAssistantFooter(assistantSender, timeStr, modelStr, - assistantEntryMeta?.InputTokens, assistantEntryMeta?.OutputTokens, - assistantEntryMeta?.ResponseTokens, assistantEntryMeta?.ContextPercent, - chatStampFg, entry.Id, entry.Text ?? "", - entry.Id == latestAssistantEntryId ? Props.DefaultUsageSummary : null); - var leftInset = showAssistAvatar ? (36 + bubbleSideMargin) : 0; - leftInset += (int)bubblePadding.Left; - footer = footer.Margin(leftInset, 2, 0, 0); - } - - var topMargin = startsBurst ? 4.0 : 1.0; - var bottomMargin = endsBurst ? 4.0 : 1.0; - // AutomationName: when the bubble nests a tool burst inside it, - // UIA would treat the named container as a leaf and hide the - // nested tool card from screen readers. Drop the bubble-level - // name in the nested case — the markdown text inside is read - // out by UIA on its own, and Narrator can then traverse into - // the nested tool card as a sibling child. - var stack = VStack(2, bubbleRow, footer).HAlign(HorizontalAlignment.Stretch); - if (nestedTool == null) - stack = stack.AutomationName(entry.Text ?? ""); - return WithHoverHandlers( - Border(stack).Background(new SolidColorBrush(Colors.Transparent)) - .Margin(16, topMargin, gutter, bottomMargin), - entry.Id); - } - - // Tool burst: a *contiguous* run of ToolCall entries (one assistant - // turn may chain multiple tool invocations) is rendered as a SINGLE - // unified card with one row per tool. Each row collapses call+output - // into `▸ ⚡ · [Done]`; click expands the row - // to reveal the original args + raw output (the previous chip body). - // A single trailing `Tool ·