diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index db9f9e1ec..60f2859cc 100644 --- a/src/OpenClaw.Chat/ChatModels.cs +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -123,7 +123,30 @@ public record ChatDataSnapshot( IReadOnlyDictionary Timelines, string? DefaultThreadId, string? ConnectionStatus, - string[] AvailableModels); + string[] AvailableModels, + ChatComposeTarget ComposeTarget); + +/// +/// Describes where the UI may send the next chat message. Distinct from +/// because, in protocols like the +/// OpenClaw gateway, there is always a canonical "main" target whose session +/// row may not have been materialized yet (zero sessions on fresh install). +/// The UI uses this to decide whether the composer should be enabled even +/// when is empty. +/// +/// +/// The provider-resolved canonical session key for the default send target, +/// or null when not yet known (e.g. handshake incomplete). +/// +/// +/// True when the provider is connected, has resolved a canonical send target, +/// and accepts calls keyed by +/// . +/// +public sealed record ChatComposeTarget(string? SessionKey, bool IsReady) +{ + public static ChatComposeTarget NotReady { get; } = new(null, false); +} public sealed class ChatDataChangedEventArgs(ChatDataSnapshot snapshot) : EventArgs { @@ -157,7 +180,10 @@ public interface IChatDataProvider : IAsyncDisposable event EventHandler? NotificationRequested; Task LoadAsync(CancellationToken cancellationToken = default); - Task CreateThreadAsync(string? initialMessage = null, CancellationToken cancellationToken = default); + // Note: there is intentionally no CreateThreadAsync. The gateway protocol + // has no "create new session" RPC; the canonical send target is exposed via + // ChatDataSnapshot.ComposeTarget and the first SendMessageAsync against it + // implicitly materializes the session on the server. Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken = default); Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken, IReadOnlyList? attachments) => SendMessageAsync(threadId, message, cancellationToken); diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index 23dbe4539..cee42838c 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -239,6 +239,22 @@ private async Task ConnectCoreAsync(string? gatewayId = null) _gatewayNeedsV2Signature = true; }; + // Operator-side auto-approve: the node-side WindowsNodeClient + // only sees its own pairing state. But when the gateway broadcasts + // a node.pair.requested event, the OPERATOR client gets a fresh + // NodePairListUpdated push. If our own node deviceId shows up in + // that pending list and we have approval scopes, approve it + // immediately — otherwise the user is stuck looking at a connected + // node with empty capabilities until they manually approve. + // This complements (not replaces) the node-side flow on line ~810 + // which still handles the case where the node knows it's pending + // before any operator event lands. + lifecycle.DataClient.NodePairListUpdated += (s, info) => + { + if (Interlocked.Read(ref _generation) != gen) return; + _ = TryOperatorAutoApproveOwnNodePairAsync(info, gen); + }; + // If we already know this gateway needs v2, tell the client upfront if (_gatewayNeedsV2Signature) lifecycle.DataClient.UseV2Signature = true; @@ -841,17 +857,23 @@ private async void OnNodePairingStatusChanged(object? sender, PairingStatusEvent _transitionSemaphore.Release(); } - // Auto-approve node pairing if operator has admin/pairing scope + // Auto-approve node pairing if operator has admin/pairing scope. + // _autoApproveInFlight is a CAS guard scoped to JUST the approve RPC — + // we release it before the reconnect delay so unrelated approvals + // (different requestIds) aren't starved while we wait for the gateway + // and node-reconnect handshake to settle (which can take 5–30s on + // first connect via WSL cold-start). if (e.Status == PairingStatus.Pending && !string.IsNullOrWhiteSpace(e.RequestId) && e.RequestId != _lastAutoApprovedRequestId) { - // Atomic guard: only one approval in-flight at a time. - // If another approval is already running, skip this one entirely. if (Interlocked.CompareExchange(ref _autoApproveInFlight, e.RequestId, null) != null) { return; } + var approvalGeneration = Interlocked.Read(ref _generation); + bool attemptedApprove = false; + bool approved = false; try { var operatorClient = _activeLifecycle?.DataClient; @@ -865,18 +887,10 @@ private async void OnNodePairingStatusChanged(object? sender, PairingStatusEvent _diagnostics.Record("node", $"Auto-approving node pairing (requestId={e.RequestId})"); try { - var approved = await operatorClient.NodePairApproveAsync(e.RequestId); - if (approved) - { - _lastAutoApprovedRequestId = e.RequestId; - _diagnostics.Record("node", "Node pairing auto-approved — reconnecting node"); - await Task.Delay(1000); // brief delay for gateway to process - await StartNodeConnectionAsync(); - } - else - { + attemptedApprove = true; + approved = await operatorClient.NodePairApproveAsync(e.RequestId); + if (!approved) _diagnostics.Record("node", "Node auto-approval failed"); - } } catch (Exception ex) { @@ -888,8 +902,117 @@ private async void OnNodePairingStatusChanged(object? sender, PairingStatusEvent } finally { + // Only dedupe after an actual approve attempt. If the operator + // client was disconnected or lacked scope, the operator-side + // NodePairListUpdated path must still be able to approve this + // same requestId once the operator is ready. + if (attemptedApprove && Interlocked.Read(ref _generation) == approvalGeneration) + _lastAutoApprovedRequestId = e.RequestId; + Interlocked.Exchange(ref _autoApproveInFlight, null); + } + + // Post-approve reconnect happens OUTSIDE the CAS guard so it + // doesn't block unrelated approvals. + if (approved) + { + _diagnostics.Record("node", "Node pairing auto-approved — reconnecting node"); + await Task.Delay(1000); // brief delay for gateway to process + if (Interlocked.Read(ref _generation) == approvalGeneration) + await StartNodeConnectionAsync(); + } + } + } + + /// + /// Operator-side auto-approve. When the gateway pushes + /// and there is a + /// pending entry for our OWN node's deviceId, approve it. The node-side + /// auto-approve at handles the + /// case where the node already knows it is pending; this method handles + /// the case where the node is device-paired (its WindowsNodeClient sees + /// itself as Paired) but its node-sub-pairing hasn't been approved yet — + /// the only signal for that case is the operator-side broadcast. + /// + private async Task TryOperatorAutoApproveOwnNodePairAsync(PairingListInfo? info, long gen) + { + if (info?.Pending == null || info.Pending.Count == 0) return; + + var ownNodeId = _nodeConnector?.NodeDeviceId; + if (string.IsNullOrWhiteSpace(ownNodeId)) return; + + var operatorClient = _activeLifecycle?.DataClient; + if (operatorClient?.IsConnectedToGateway != true) return; + if (!OperatorScopeHelper.CanApproveDevices(operatorClient.GrantedOperatorScopes)) return; + + // Track whether ANY approve succeeded so we know to schedule one + // reconnect at the end (rather than reconnecting per-entry, which + // would race with itself). + string? lastApprovedRequestId = null; + + foreach (var req in info.Pending) + { + if (Interlocked.Read(ref _generation) != gen) return; + if (string.IsNullOrWhiteSpace(req.RequestId)) continue; + if (req.RequestId == _lastAutoApprovedRequestId) continue; + if (!string.Equals(req.NodeId, ownNodeId, StringComparison.OrdinalIgnoreCase)) continue; + + // CAS guard scoped to JUST the approve RPC. Release before the + // post-approve reconnect so unrelated approvals are not starved + // (e.g. another own-node pending with a different requestId in + // the same or next snapshot). + if (Interlocked.CompareExchange(ref _autoApproveInFlight, req.RequestId, null) != null) + continue; + + bool approved = false; + try + { + _diagnostics.Record("node", $"Operator-side auto-approving own node pairing (requestId={req.RequestId})"); + try + { + approved = await operatorClient.NodePairApproveAsync(req.RequestId); + if (!approved) + _diagnostics.Record("node", "Operator-side node pair approval rejected by gateway"); + } + catch (Exception ex) + { + _logger.Warn($"[ConnMgr] Operator-side node auto-approve failed: {ex.Message}"); + _diagnostics.Record("node", $"Operator-side auto-approve error: {ex.Message}"); + } + } + finally + { + // Always record the requestId — both on success (prevent + // re-approving the same id after the gateway re-broadcasts) + // and on failure (prevent a spin loop on a rejected id). + // Re-check generation: if a reconnect happened during the + // await above, DisposeActiveClient already cleared + // _lastAutoApprovedRequestId for the new generation; we must + // not overwrite that null with a stale id from the old gen. + if (Interlocked.Read(ref _generation) == gen) + _lastAutoApprovedRequestId = req.RequestId; Interlocked.Exchange(ref _autoApproveInFlight, null); } + + if (approved) + { + lastApprovedRequestId = req.RequestId; + // Continue scanning so a second own-pending in the same + // snapshot (e.g. stale requestId from prior session) also + // gets attempted — broken only by an explicit failure to + // approve, which we still try the next entry for. + } + // On failure/rejection, fall through to next own-pending entry + // rather than break — the gateway may not re-broadcast if the + // approve frame round-tripped and was rejected mid-flight. + } + + // Single reconnect after the snapshot is fully processed. + if (lastApprovedRequestId is not null) + { + _diagnostics.Record("node", $"Operator-side approved {lastApprovedRequestId} — reconnecting node so caps propagate"); + await Task.Delay(1000); + if (Interlocked.Read(ref _generation) == gen) + await StartNodeConnectionAsync(); } } diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index ce2418ea1..2d59f0389 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -41,6 +41,10 @@ public interface IOperatorGatewayClient string? OperatorDeviceId { get; } IReadOnlyList GrantedOperatorScopes { get; } bool IsConnectedToGateway { get; } + /// Canonical main session key resolved from hello-ok; null until handshake. + string? MainSessionKey { get; } + /// True once the hello-ok handshake has been processed. + bool HasHandshakeSnapshot { get; } // ─── Connection events (from WebSocketClientBase) ─── event EventHandler? StatusChanged; diff --git a/src/OpenClaw.Shared/OpenClaw.Shared.csproj b/src/OpenClaw.Shared/OpenClaw.Shared.csproj index 82b057ea2..dc5e54fc0 100644 --- a/src/OpenClaw.Shared/OpenClaw.Shared.csproj +++ b/src/OpenClaw.Shared/OpenClaw.Shared.csproj @@ -9,6 +9,7 @@ + diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 0a72b0489..6de001159 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -44,7 +44,31 @@ public class OpenClawGatewayClient : WebSocketClientBase, IOperatorGatewayClient private readonly object _sessionsLock = new(); private readonly DeviceIdentity _deviceIdentity; private readonly string _currentGatewayUrl; - private string _mainSessionKey = "main"; + private string? _mainSessionKey; + private bool _hasHandshakeSnapshot; + + /// + /// The gateway's canonical main session key as published in the hello-ok + /// snapshot (preferring the canonical sessionDefaults.mainSessionKey + /// over the legacy alias mainKey). null until handshake + /// completes or after a disconnect. Callers should pass this exact value + /// (or null) to ; never + /// substitute a literal like "main", which can drift from the + /// canonical key the gateway echoes back in chat events. + /// + /// + /// Read via because the field is + /// written from the gateway WebSocket thread and read from the UI thread + /// (through ). + /// + public string? MainSessionKey => Volatile.Read(ref _mainSessionKey); + + /// + /// True once the hello-ok handshake has been processed (i.e. session + /// defaults are known). Reset to false on disconnect. Surfaces to the UI + /// as . + /// + public bool HasHandshakeSnapshot => Volatile.Read(ref _hasHandshakeSnapshot); private string? _operatorDeviceId; private string[] _grantedOperatorScopes = Array.Empty(); private string _connectAuthToken; @@ -141,6 +165,13 @@ protected override bool ShouldAutoReconnect() protected override void OnDisconnected() { ClearPendingRequests(); + // Invalidate the handshake snapshot — the next hello-ok must + // re-establish the canonical session key, scopes, etc. Without this, + // a reconnect-after-server-restart could leave the tray sending to a + // stale canonical key that the new server doesn't recognize, and + // HasHandshakeSnapshot would lie about the offline state to callers. + Volatile.Write(ref _mainSessionKey, null); + Volatile.Write(ref _hasHandshakeSnapshot, false); } protected override void OnDisposing() @@ -176,6 +207,14 @@ protected override void OnDisposing() public event EventHandler? AgentsListUpdated; public event EventHandler? AgentFilesListUpdated; public event EventHandler? AgentFileContentUpdated; + + // ─── Test-only event raisers ─── + // Exposed via [InternalsVisibleTo("OpenClaw.Connection.Tests")] so unit + // tests can drive event-handler code paths without a live WebSocket. + // Avoids the previous reflection-on-private-backing-field approach which + // silently breaks the moment the events grow explicit add/remove blocks. + internal void RaiseNodePairListUpdatedForTests(PairingListInfo info) + => NodePairListUpdated?.Invoke(this, info); /// /// Raised when the gateway broadcasts a "chat" event (assistant or user /// message echo). Use this to drive a chat-UI timeline; the existing @@ -273,9 +312,8 @@ public async Task SendChatMessageForRunAsync(string message, str if (string.IsNullOrWhiteSpace(message) && !hasAttachments) throw new ArgumentException("Message or attachment is required", nameof(message)); - var effectiveSessionKey = string.IsNullOrWhiteSpace(sessionKey) - ? _mainSessionKey - : sessionKey.Trim(); + var effectiveSessionKey = ResolveEffectiveSessionKey( + sessionKey, Volatile.Read(ref _mainSessionKey), "chat.send"); var requestId = Guid.NewGuid().ToString(); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -333,9 +371,8 @@ Task IOperatorGatewayClient.SendChatMessageForRunAsync(string me /// public async Task RequestChatHistoryAsync(string? sessionKey = null, int timeoutMs = 15000) { - var effectiveSessionKey = string.IsNullOrWhiteSpace(sessionKey) - ? _mainSessionKey - : sessionKey.Trim(); + var effectiveSessionKey = ResolveEffectiveSessionKey( + sessionKey, Volatile.Read(ref _mainSessionKey), "chat.history"); var payload = await SendWizardRequestAsync( "chat.history", @@ -354,9 +391,8 @@ public async Task SendChatAbortAsync(string runId, string? sessionKey = null, in { if (string.IsNullOrWhiteSpace(runId)) throw new ArgumentException("runId is required", nameof(runId)); - var effectiveSessionKey = string.IsNullOrWhiteSpace(sessionKey) - ? _mainSessionKey - : sessionKey.Trim(); + var effectiveSessionKey = ResolveEffectiveSessionKey( + sessionKey, Volatile.Read(ref _mainSessionKey), "chat.abort"); await SendWizardRequestAsync("chat.abort", new { runId, sessionKey = effectiveSessionKey }, timeoutMs); } @@ -1498,8 +1534,13 @@ private void HandleResponse(JsonElement root) ResetReconnectAttempts(); _operatorDeviceId = TryGetHandshakeDeviceId(payload); _grantedOperatorScopes = TryGetHandshakeScopes(payload); - _mainSessionKey = TryGetHandshakeMainSessionKey(payload) ?? "main"; - _logger.Info($"[HANDSHAKE] deviceId={_operatorDeviceId}, scopes=[{string.Join(", ", _grantedOperatorScopes)}], mainSession={_mainSessionKey}"); + // Write the key first, then publish the readiness flag. Pair with + // Volatile.Read on the public getters so a reader observing + // HasHandshakeSnapshot==true is guaranteed to see the populated + // MainSessionKey (release/acquire ordering). + Volatile.Write(ref _mainSessionKey, TryGetHandshakeMainSessionKey(payload)); + Volatile.Write(ref _hasHandshakeSnapshot, true); + _logger.Info($"[HANDSHAKE] deviceId={_operatorDeviceId}, scopes=[{string.Join(", ", _grantedOperatorScopes)}], mainSession={_mainSessionKey ?? "(unset)"}"); PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload)); if (_bootstrapPairAsNode) { @@ -1537,7 +1578,7 @@ private void HandleResponse(JsonElement root) { _logger.Info($"Granted operator scopes: {string.Join(", ", _grantedOperatorScopes)}"); } - _logger.Info($"Main session key: {_mainSessionKey}"); + _logger.Info($"Main session key: {_mainSessionKey ?? "(unset)"}"); // Extract presence from snapshot TryParsePresence(payload); @@ -2055,6 +2096,31 @@ private static string[] ReadStringArray(JsonElement array) return buffer[..count]; } + /// + /// Resolves the effective sessionKey for a chat-related RPC, + /// preferring a non-empty caller-supplied value over the handshake + /// . Throws + /// if neither is usable — + /// callers MUST NOT fall back to a literal like "main", which + /// can drift from the canonical key the gateway echoes back. + /// + /// + /// Extracted as an internal static so unit tests can exercise the + /// pre-handshake throw without needing a live WebSocket — see + /// OpenClawGatewayClientSessionKeyTests. + /// + internal static string ResolveEffectiveSessionKey( + string? callerSessionKey, string? resolvedMainSessionKey, string operationName) + { + var effective = string.IsNullOrWhiteSpace(callerSessionKey) + ? resolvedMainSessionKey + : callerSessionKey.Trim(); + if (string.IsNullOrWhiteSpace(effective)) + throw new InvalidOperationException( + $"{operationName} requires a sessionKey, but the gateway handshake has not resolved one yet."); + return effective; + } + private static string? TryGetHandshakeMainSessionKey(JsonElement payload) { if (!payload.TryGetProperty("snapshot", out var snapshot) || snapshot.ValueKind != JsonValueKind.Object) @@ -2067,13 +2133,29 @@ private static string[] ReadStringArray(JsonElement array) return null; } - if (!sessionDefaults.TryGetProperty("mainKey", out var mainKey) || mainKey.ValueKind != JsonValueKind.String) + // Prefer the canonical "mainSessionKey" (e.g. "agent:main:main") over + // the legacy alias "mainKey" (e.g. "main"). The gateway accepts both + // for chat.send routing, but the chat/session events it emits back + // are keyed by the canonical form. Using the alias here would cause + // the tray's local timeline (keyed by the alias) to diverge from the + // gateway's echo (keyed by canonical), stranding optimistic state. + if (sessionDefaults.TryGetProperty("mainSessionKey", out var canonical) && + canonical.ValueKind == JsonValueKind.String) { - return null; + var canonicalValue = canonical.GetString(); + if (!string.IsNullOrWhiteSpace(canonicalValue)) + return canonicalValue; } - var value = mainKey.GetString(); - return string.IsNullOrWhiteSpace(value) ? null : value; + if (sessionDefaults.TryGetProperty("mainKey", out var mainKey) && + mainKey.ValueKind == JsonValueKind.String) + { + var value = mainKey.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + + return null; } private static string? TryGetHandshakeDeviceToken(JsonElement payload) @@ -2365,11 +2447,17 @@ private void HandleAgentEvent(JsonElement root) } catch { } - // sessionKey is inside payload, not root - var sessionKey = "unknown"; + // sessionKey is inside payload, not root. We deliberately do NOT + // substitute a fallback like "unknown" or "main" — empty must + // propagate so the provider can drop the event and surface the + // protocol gap, rather than silently routing into a synthetic bucket. + var sessionKey = ""; if (payload.TryGetProperty("sessionKey", out var sk)) - sessionKey = sk.GetString() ?? "unknown"; - var isMain = sessionKey == "main" || sessionKey.Contains(":main:"); + sessionKey = sk.GetString() ?? ""; + if (string.IsNullOrEmpty(sessionKey)) + _logger.Warn("[GatewayClient] Agent event missing sessionKey; will be dropped downstream."); + var isMain = !string.IsNullOrEmpty(sessionKey) + && (sessionKey == "main" || sessionKey.Contains(":main:")); // Emit raw agent event (cloned for thread safety) try @@ -2515,10 +2603,15 @@ private void HandleChatEvent(JsonElement root) if (!root.TryGetProperty("payload", out var payload)) return; EmitRawChatEvent(payload); - // Extract sessionKey for the timeline-driving event. - var sessionKey = "main"; + // Extract sessionKey for the timeline-driving event. As with agent + // events, do NOT substitute a fallback like "main" — empty must + // propagate so the provider's empty-key drop policy can surface the + // protocol gap instead of silently routing into a synthetic bucket. + var sessionKey = ""; if (payload.TryGetProperty("sessionKey", out var skProp)) - sessionKey = skProp.GetString() ?? "main"; + sessionKey = skProp.GetString() ?? ""; + if (string.IsNullOrEmpty(sessionKey)) + _logger.Warn("[GatewayClient] Chat event missing sessionKey; will be dropped downstream."); // Best-effort usage extraction — gateway emits this only on terminal // (state="final") events in practice; we still read it defensively diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs index 5898e01bd..c97bd4d4f 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs @@ -87,7 +87,8 @@ private ChatDataSnapshot BuildSnapshot() Timelines: timelines, DefaultThreadId: ThreadId, ConnectionStatus: "connected", - AvailableModels: Models); + AvailableModels: Models, + ComposeTarget: new ChatComposeTarget(ThreadId, true)); } private void RaiseChanged() => @@ -96,9 +97,6 @@ private void RaiseChanged() => public Task LoadAsync(CancellationToken cancellationToken = default) => Task.FromResult(BuildSnapshot()); - public Task CreateThreadAsync(string? initialMessage = null, CancellationToken cancellationToken = default) - => Task.FromResult(new ChatThread { Id = ThreadId, Title = "Exploration preview" }); - public Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken = default) { var entries = new List(_timeline.Entries) diff --git a/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs b/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs index 6e523bcf2..2f0207a48 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs @@ -11,6 +11,10 @@ public interface IChatGatewayBridge : IDisposable { bool IsConnected { get; } ConnectionStatus CurrentStatus { get; } + /// Canonical main session key resolved by the gateway handshake; null until ready. + string? MainSessionKey { get; } + /// True once the gateway handshake has resolved session defaults. + bool HasHandshakeSnapshot { get; } SessionInfo[] GetSessionList(); ModelsListInfo? GetCurrentModelsList(); @@ -75,6 +79,8 @@ public GatewayClientChatBridge(OpenClawGatewayClient client) public bool IsConnected => _client.IsConnectedToGateway; public ConnectionStatus CurrentStatus => _currentStatus; + public string? MainSessionKey => _client.MainSessionKey; + public bool HasHandshakeSnapshot => _client.HasHandshakeSnapshot; public SessionInfo[] GetSessionList() => _client.GetSessionList(); public ModelsListInfo? GetCurrentModelsList() => _currentModels; diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 24f32bc00..71a707a30 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -134,28 +134,6 @@ public Task LoadAsync(CancellationToken cancellationToken = de } } - public Task CreateThreadAsync(string? initialMessage = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - // The gateway has no "create new chat session" RPC today; the operator - // is always wired to the gateway's main session. Return that thread - // and (optionally) send the initial message into it. - ChatThread thread; - lock (_gate) - { - EnsureTimelinesForSessionsLocked(); - thread = ResolveMainOrFirstThreadLocked() - ?? new ChatThread { Id = "main", Title = "Main session", Status = ChatThreadStatus.Running }; - } - - if (!string.IsNullOrWhiteSpace(initialMessage)) - { - return SendMessageAsync(thread.Id, initialMessage!, cancellationToken) - .ContinueWith(_ => thread, cancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default); - } - return Task.FromResult(thread); - } - // Explicit interface implementation (no attachments). Task IChatDataProvider.SendMessageAsync(string threadId, string message, CancellationToken cancellationToken) => SendMessageAsync(threadId, message, cancellationToken, attachments: null); @@ -854,9 +832,21 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) { if (message is null) return; + // The gateway must include a canonical sessionKey on every chat event. + // If it doesn't, that's a protocol bug — drop the event rather than + // routing it to a literal "main" bucket that can't possibly match the + // optimistic timeline keyed by the canonical key. Surfacing the drop + // here makes future protocol gaps visible instead of silently merging + // into a synthetic key. + if (string.IsNullOrEmpty(message.SessionKey)) + { + Logger.Warn($"[ChatProvider] Dropping chat message with empty sessionKey (role={message.Role})"); + return; + } + // Suppress chat messages for threads that were aborted by the user. // Chat messages don't carry a runId, so we use thread-level suppression. - var msgThreadId = string.IsNullOrEmpty(message.SessionKey) ? "main" : message.SessionKey; + var msgThreadId = message.SessionKey; lock (_gate) { if (_abortedThreads.Contains(msgThreadId)) @@ -880,7 +870,7 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) if (LooksLikeSystemControlNote(message.Text)) { if (string.IsNullOrEmpty(message.Text)) return; - var sysThread = string.IsNullOrEmpty(message.SessionKey) ? "main" : message.SessionKey; + var sysThread = message.SessionKey; ChatEntryMetadata? sysMeta; lock (_gate) { sysMeta = BuildLiveMetaLocked(sysThread, message.Ts); } ApplyEventAndPublish(sysThread, @@ -896,7 +886,7 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) if (roleLower == "toolresult" || roleLower == "tool_result") { if (string.IsNullOrEmpty(message.Text)) return; - var trThread = string.IsNullOrEmpty(message.SessionKey) ? "main" : message.SessionKey; + var trThread = message.SessionKey; ChatEntryMetadata? trMeta; lock (_gate) { trMeta = BuildLiveMetaLocked(trThread, message.Ts); } var capped = TruncateForChatEntry(message.Text); @@ -911,7 +901,7 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) if (string.IsNullOrEmpty(message.Text)) return; - var threadId = string.IsNullOrEmpty(message.SessionKey) ? "main" : message.SessionKey; + var threadId = message.SessionKey; ChatEntryMetadata? meta; lock (_gate) { @@ -953,7 +943,15 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) private void OnAgentEventReceived(object? sender, AgentEventInfo evt) { if (evt is null) return; - var threadId = string.IsNullOrEmpty(evt.SessionKey) ? "main" : evt.SessionKey; + // As with chat events, every agent event must carry a canonical + // sessionKey. Drop the event rather than routing to "main" if missing — + // see the rationale in OnChatMessageReceived. + if (string.IsNullOrEmpty(evt.SessionKey)) + { + Logger.Warn($"[ChatProvider] Dropping agent event with empty sessionKey (stream={evt.Stream})"); + return; + } + var threadId = evt.SessionKey; // Always update run tracking first (state maintenance must not be skipped). UpdateActiveRunId(evt, threadId); @@ -1762,23 +1760,51 @@ private void EnsureTimelinesForSessionsLocked() } } - private ChatThread? ResolveMainOrFirstThreadLocked() - { - if (_sessions.Length == 0) return null; - var main = Array.Find(_sessions, s => s.IsMain); - return ToThread(main ?? _sessions[0]); - } - private ChatDataSnapshot BuildSnapshotLocked() { - var threads = new ChatThread[_sessions.Length]; + // Build threads from the gateway's authoritative session list. + // No synthesis based on local timeline keys — the UI's compose target + // is exposed separately via ChatComposeTarget so the renderer can show + // a usable composer even before the first session materializes server- + // side (e.g. fresh install with zero sessions). + var threadList = new List(_sessions.Length + 1); for (int i = 0; i < _sessions.Length; i++) - threads[i] = ToThread(_sessions[i]); + threadList.Add(ToThread(_sessions[i])); + + var composeKey = _bridge.MainSessionKey; + var composeReady = _bridge.HasHandshakeSnapshot + && !string.IsNullOrWhiteSpace(composeKey) + && _status == ConnectionStatus.Connected; + + // If the compose target hasn't materialized as a real session yet but + // already has an optimistic timeline (because the user sent a message + // before the gateway echoed back sessions.list), surface a synthetic + // thread record so the UI can render the optimistic bubble without + // falling back into the "no thread selected" zero state. The synthetic + // thread's Id is the canonical compose key, so when SessionsUpdated + // eventually arrives with the same key it replaces the synthetic in + // place — no migration, no re-keying. + if (composeReady + && composeKey is { } ck + && _timelines.TryGetValue(ck, out var pendingTl) + && pendingTl.Entries.Count > 0 + && !_sessions.Any(s => string.Equals(s.Key, ck, StringComparison.Ordinal))) + { + threadList.Add(new ChatThread + { + Id = ck, + Title = "Main session", + Status = ChatThreadStatus.Running, + Activity = ChatActivity.Idle, + }); + } + + var threads = threadList.ToArray(); // Snapshot a defensive copy of the timeline dict. var timelinesCopy = new Dictionary(_timelines); - var defaultThreadId = ResolveDefaultThreadIdLocked(threads); + var defaultThreadId = ResolveDefaultThreadIdLocked(); var connectionLabel = _status switch { @@ -1789,32 +1815,47 @@ private ChatDataSnapshot BuildSnapshotLocked() _ => _status.ToString() }; + var composeTarget = composeReady + ? new ChatComposeTarget(composeKey, true) + : ChatComposeTarget.NotReady; + return new ChatDataSnapshot( Threads: threads, Timelines: timelinesCopy, DefaultThreadId: defaultThreadId, ConnectionStatus: connectionLabel, - AvailableModels: _availableModels); + AvailableModels: _availableModels, + ComposeTarget: composeTarget); } - private static string? ResolveDefaultThreadIdLocked(ChatThread[] threads) + private string? ResolveDefaultThreadIdLocked() { - if (threads.Length == 0) return null; - for (int i = 0; i < threads.Length; i++) + // Prefer the gateway's canonical main session (IsMain on SessionInfo) + // so we never have to guess from a literal like "main". Only fall back + // to the compose target (pre-materialization) or the first available + // session when no main is present. + for (int i = 0; i < _sessions.Length; i++) { - // ChatThread doesn't carry the IsMain flag explicitly, so detect it - // by a heuristic: the upstream Title we set for main sessions. - if (string.Equals(threads[i].Id, "main", StringComparison.OrdinalIgnoreCase)) - return threads[i].Id; + var s = _sessions[i]; + if (s.IsMain && !string.IsNullOrEmpty(s.Key)) + return s.Key; } - return threads[0].Id; + if (_bridge.HasHandshakeSnapshot + && _bridge.MainSessionKey is { } mk + && !string.IsNullOrWhiteSpace(mk)) + return mk; + if (_sessions.Length > 0 && !string.IsNullOrEmpty(_sessions[0].Key)) + return _sessions[0].Key; + return null; } private static ChatThread ToThread(SessionInfo s) { return new ChatThread { - Id = string.IsNullOrEmpty(s.Key) ? "main" : s.Key, + // SessionInfo.Key is the canonical gateway session key; we trust + // it as-is rather than substituting a literal like "main". + Id = s.Key ?? string.Empty, Title = !string.IsNullOrWhiteSpace(s.DisplayName) ? s.DisplayName! : (s.IsMain ? "Main session" : s.ShortKey), diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs index 7af8099e9..5a66152f0 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs @@ -104,6 +104,11 @@ public override Element Render() var speakerMuted = UseState(_initialMuted, threadSafe: true); var voiceTranscript = UseState(null, threadSafe: true); var voiceAudioLevel = UseState(0f, 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); // Wire the OnFileAttached callback so the host window/page can set the // pending attachment after the file picker completes. @@ -147,6 +152,19 @@ public override Element Render() 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); @@ -199,14 +217,46 @@ Element BuildLoadingElement() ? Array.Find(snapshot.Threads, t => t.Id == id) : null; - // Lazy-load history the first time a thread is selected. + // 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; + if (selectedThread is null + && snapshot.ComposeTarget.IsReady + && snapshot.ComposeTarget.SessionKey is { } composeKey) + { + composeOnlyThread = new ChatThread + { + Id = composeKey, + Title = "Main session", + 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; + + // 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. if (selectedThread is not null && _provider is OpenClawChatDataProvider native) { var threadId = selectedThread.Id; RunFireAndForget(ct => native.LoadHistoryAsync(threadId, force: false, ct)); } - var timeline = selectedThread is not null && snapshot.Timelines.TryGetValue(selectedThread.Id, out var tl) + // 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(); @@ -227,7 +277,7 @@ Element BuildLoadingElement() // 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 = selectedThread is null ? null : entryMetaSnapshot; + var entryMeta = effectiveThread is null ? null : entryMetaSnapshot; // 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 @@ -313,21 +363,25 @@ Element BuildLoadingElement() && !showThinking && pendingPermissionOverride is null; - Element body = bodyOverride ?? (selectedThread is null || isEmptyConversation + Element body = bodyOverride ?? (effectiveThread is null || isEmptyConversation ? RenderZeroState(suggestion => { - if (selectedThread is { } t) + if (firstSendInFlight.Value) return; // debounce double-click + if (effectiveThread is { } t) + { + firstSendInFlight.Set(true); OnSend(t.Id, suggestion, null); - }) + } + }, suggestionsDisabled: firstSendInFlight.Value) : Component(new( - SessionId: selectedThread.Id, + SessionId: effectiveThread.Id, Entries: entries, HasMoreHistory: false, OnLoadMoreHistory: null, EntryMetadata: entryMeta, UserSenderLabel: "OpenClaw Windows Tray", AssistantSenderLabel: assistantSenderLabel, - DefaultModel: selectedThread.Model, + DefaultModel: effectiveThread.Model, ShowThinkingIndicator: showThinking, OnReadAloud: _onReadAloud is not null ? (text => _onReadAloud(text)) @@ -344,23 +398,23 @@ Element BuildLoadingElement() .Distinct(StringComparer.Ordinal) .ToArray(); - Element composer = (selectedThread is not null && !suppressComposer) + Element composer = (effectiveThread is not null && !suppressComposer) ? Component(new( ConnectionState: connState, TurnActive: turnActiveOverride, PendingPermission: pendingPermissionOverride, - ChannelLabel: selectedThread.Title ?? "main", + ChannelLabel: effectiveThread.Title ?? "Main session", AvailableChannels: channelTitles, AvailableModels: snapshot.AvailableModels, - CurrentModel: selectedThread.Model, - CurrentThinkingLevel: selectedThread.ThinkingLevel, + CurrentModel: effectiveThread.Model, + CurrentThinkingLevel: effectiveThread.ThinkingLevel, OnSend: (msg, att) => { pendingAttachment.Set(null); - OnSend(selectedThread.Id, msg, att); + OnSend(effectiveThread.Id, msg, att); }, - OnStop: () => OnStop(selectedThread.Id), - OnPermissionResponse: (rid, allow) => OnPermission(selectedThread.Id, rid, allow), + OnStop: () => OnStop(effectiveThread.Id), + OnPermissionResponse: (rid, allow) => OnPermission(effectiveThread.Id, rid, allow), OnChannelChanged: title => { var match = Array.Find(snapshot.Threads, t => t.Title == title); @@ -370,9 +424,9 @@ Element BuildLoadingElement() selectedIdRef.Current = match.Id; } }, - OnModelChanged: model => RunFireAndForget(ct => _provider.SetModelAsync(selectedThread.Id, model, ct)), - OnThinkingLevelChanged: level => RunFireAndForget(ct => _provider.SetThinkingLevelAsync(selectedThread.Id, level, ct)), - OnPermissionsChanged: allowAll => RunFireAndForget(ct => _provider.SetPermissionModeAsync(selectedThread.Id, allowAll, ct)), + OnModelChanged: model => RunFireAndForget(ct => _provider.SetModelAsync(effectiveThread.Id, model, ct)), + OnThinkingLevelChanged: level => RunFireAndForget(ct => _provider.SetThinkingLevelAsync(effectiveThread.Id, level, ct)), + OnPermissionsChanged: allowAll => RunFireAndForget(ct => _provider.SetPermissionModeAsync(effectiveThread.Id, allowAll, ct)), OnVoiceRequest: _onVoiceRequest, OnAttachClick: _onAttachClick, PendingAttachment: pendingAttachment.Value, @@ -422,16 +476,16 @@ private static ChatThread SynthesizePreviewThread(ChatDataSnapshot snapshot) /// is responsible for routing the suggestion text into a send (typically /// via the active thread's OnSend handler). /// - private static Element RenderZeroState(Action onSuggestionPicked) + private static Element RenderZeroState(Action onSuggestionPicked, bool suggestionsDisabled = false) { var welcomeTitle = LocalizedOrDefault("Chat_ZeroState_WelcomeTitle", "Welcome to OpenClaw"); var welcomeSubtitle = LocalizedOrDefault("Chat_ZeroState_WelcomeSubtitle", "How can I help you today?"); var suggestions = new[] { - "Summarize the last commit on this branch", - "Explain what this repo does", - "Show me how to add a new chat exploration option", + "Say hi 👋", + "What can you do?", + "Give me a quick tour of OpenClaw", }; Element SuggestionButton(string text) => @@ -442,6 +496,7 @@ Element SuggestionButton(string text) => b.HorizontalContentAlignment = HorizontalAlignment.Left; b.Padding = new Thickness(12, 10, 12, 10); b.CornerRadius = new CornerRadius(8); + b.IsEnabled = !suggestionsDisabled; }); return Border( diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index d95bd53f3..572ea3078 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -128,6 +128,29 @@ public void Initialize() // active row as "disconnected" for one frame before the snapshot pass // flips it to "Connected". RefreshFromSnapshot(_connectionManager?.CurrentSnapshot ?? GatewayConnectionSnapshot.Idle); + + // Eagerly refresh pending-pairing lists so the banner reflects truth + // the moment the user navigates here (rather than waiting for the + // gateway to push the next node.pair.requested broadcast). Both calls + // are tracked + idempotent on the gateway side. + if (CurrentApp.GatewayClient is { IsConnectedToGateway: true } client) + { + _ = Task.Run(async () => + { + try { await client.RequestNodePairListAsync(); } + catch (Exception ex) { Services.Logger.Warn($"[ConnectionPage] Eager node-pair refresh failed: {ex.Message}"); } + try { await client.RequestDevicePairListAsync(); } + catch (Exception ex) { Services.Logger.Warn($"[ConnectionPage] Eager device-pair refresh failed: {ex.Message}"); } + }); + } + + // Push any already-resolved pending lists into the page immediately + // — the AppState may have been populated on a prior visit and the + // PropertyChanged subscriber only fires on future changes. + if (_appState?.NodePairList is { } existingNode) + UpdatePairingRequests(existingNode); + if (_appState?.DevicePairList is { } existingDevice) + UpdateDevicePairingRequests(existingDevice); } private void OnPageUnloaded(object sender, RoutedEventArgs e) diff --git a/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml index 1e3719624..bc0064657 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml @@ -70,6 +70,45 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}"/> + + + + + + + + +