diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs
index 6c0ceec..496e879 100644
--- a/src/OpenClaw.Shared/Models.cs
+++ b/src/OpenClaw.Shared/Models.cs
@@ -1522,12 +1522,27 @@ public class AgentEventInfo
public string FormattedTime => Timestamp.ToString("HH:mm:ss.fff");
- public string StreamUpper => Stream.ToUpperInvariant();
+ /// Resolved event kind — for "item" stream events, uses data.kind instead.
+ public string ResolvedStream
+ {
+ get
+ {
+ var s = Stream.ToLowerInvariant();
+ if (s == "item" && Data.ValueKind == JsonValueKind.Object &&
+ Data.TryGetProperty("kind", out var k))
+ {
+ return k.GetString()?.ToLowerInvariant() ?? s;
+ }
+ return s;
+ }
+ }
+
+ public string StreamUpper => ResolvedStream.ToUpperInvariant();
/// Color hex for stream badge (used by UI to create brush).
- public string BadgeColorHex => Stream.ToLowerInvariant() switch
+ public string BadgeColorHex => ResolvedStream switch
{
- "tool" => "#FFDC781E", // Orange
+ "tool" => "#FFB45D3A", // Burnt sienna
"assistant" => "#FF28A050", // Green
"error" => "#FFC83232", // Red
"lifecycle" => "#FF3C78C8", // Blue
@@ -1546,17 +1561,23 @@ public class AgentEventInfo
if (!string.IsNullOrEmpty(Summary)) return Summary;
try
{
- var s = Stream.ToLowerInvariant();
+ var s = ResolvedStream;
if (s == "tool" && Data.ValueKind == JsonValueKind.Object)
{
var name = Data.TryGetProperty("name", out var n) ? n.GetString() : null;
+ var title = Data.TryGetProperty("title", out var ti) ? ti.GetString() : null;
var phase = Data.TryGetProperty("phase", out var p) ? p.GetString() : null;
- if (name != null) return phase != null ? $"🔧 {name} ({phase})" : $"🔧 {name}";
+ var status = Data.TryGetProperty("status", out var st) ? st.GetString() : null;
+ // Prefer title (richer) over just name
+ if (title != null)
+ return phase != null ? $"🔧 {title} ({phase})" : $"🔧 {title}";
+ if (name != null)
+ return phase != null ? $"🔧 {name} ({phase})" : $"🔧 {name}";
}
if (s == "assistant" && Data.ValueKind == JsonValueKind.Object)
{
var text = Data.TryGetProperty("text", out var t) ? t.GetString() : null;
- if (text != null) return text.Length > 120 ? text[..120] + "…" : text;
+ if (text != null) return text.Length > 300 ? text[..300] + "…" : text;
}
if (s == "error" && Data.ValueKind == JsonValueKind.Object)
{
@@ -1566,8 +1587,11 @@ public class AgentEventInfo
}
if (s == "lifecycle" && Data.ValueKind == JsonValueKind.Object)
{
- var state = Data.TryGetProperty("state", out var st) ? st.GetString() : null;
- if (state != null) return $"⚡ {state}";
+ var state = Data.TryGetProperty("state", out var st) ? st.GetString()
+ : Data.TryGetProperty("livenessState", out var ls) ? ls.GetString() : null;
+ var phase = Data.TryGetProperty("phase", out var ph) ? ph.GetString() : null;
+ if (state != null)
+ return phase != null ? $"⚡ {state} ({phase})" : $"⚡ {state}";
}
}
catch { }
@@ -1577,18 +1601,51 @@ public class AgentEventInfo
public bool HasSummary => !string.IsNullOrEmpty(SummaryLine);
+ /// Full assistant message text (no truncation), for expanded view.
+ public string? FullAssistantText
+ {
+ get
+ {
+ if (ResolvedStream != "assistant" || Data.ValueKind != JsonValueKind.Object) return null;
+ try { return Data.TryGetProperty("text", out var t) ? t.GetString() : null; }
+ catch { return null; }
+ }
+ }
+
+ /// Whether this event is an assistant stream (expanded view shows full text instead of JSON).
+ public bool IsAssistantStream => ResolvedStream == "assistant";
+
+ /// Whether to show the raw DataJson section. Hidden for streams where SummaryLine is sufficient.
+ public bool ShowDataJson
+ {
+ get
+ {
+ var s = ResolvedStream;
+ if (s is "assistant" or "error" or "lifecycle") return false;
+ return true;
+ }
+ }
+
+ // UI-only state for expand/collapse (not serialized)
+ [System.Text.Json.Serialization.JsonIgnore]
+ public bool IsExpanded { get; set; }
+
+ private string? _cachedDataJson;
+
public string DataJson
{
get
{
+ if (_cachedDataJson != null) return _cachedDataJson;
try
{
- return JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true });
+ _cachedDataJson = JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true });
}
catch
{
- return Data.ToString() ?? "{}";
+ _cachedDataJson = Data.ToString() ?? "{}";
}
+ return _cachedDataJson;
}
}
}
diff --git a/src/OpenClaw.Shared/WebSocketClientBase.cs b/src/OpenClaw.Shared/WebSocketClientBase.cs
index aecde0a..97a5813 100644
--- a/src/OpenClaw.Shared/WebSocketClientBase.cs
+++ b/src/OpenClaw.Shared/WebSocketClientBase.cs
@@ -251,6 +251,10 @@ public abstract class WebSocketClientBase : IDisposable
while (!_disposed && !_cts.Token.IsCancellationRequested && ShouldAutoReconnect())
{
var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
+ // Add 0-25% jitter to prevent thundering herd when multiple clients
+ // (operator + node) reconnect on the same schedule
+ var jitter = Random.Shared.Next(0, delay / 4);
+ delay += jitter;
_reconnectAttempts++;
_logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})");
RaiseStatusChanged(ConnectionStatus.Connecting);
diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs
index a9d4acd..c88b824 100644
--- a/src/OpenClaw.Shared/WindowsNodeClient.cs
+++ b/src/OpenClaw.Shared/WindowsNodeClient.cs
@@ -30,6 +30,10 @@ public class WindowsNodeClient : WebSocketClientBase
private bool _isPaired;
// Bridges the gap between an approval event and the next hello-ok when the gateway omits auth.deviceToken.
private bool _pairingApprovedAwaitingReconnect;
+ // Persists across disconnect/error so ShouldAutoReconnect can block reconnect
+ // even after OnDisconnected clears _isPendingApproval.
+ private volatile bool _pairingBlocked;
+ private volatile bool _rateLimited;
private readonly string _gatewayToken;
private readonly string? _bootstrapToken;
@@ -277,6 +281,7 @@ public class WindowsNodeClient : WebSocketClientBase
_isPendingApproval = true;
_isPaired = false;
+ _pairingBlocked = true;
_pairingApprovedAwaitingReconnect = false;
_logger.Info($"[NODE] Pairing requested for this device via {eventType}");
@@ -310,6 +315,7 @@ public class WindowsNodeClient : WebSocketClientBase
{
_isPendingApproval = false;
_isPaired = true;
+ _pairingBlocked = false; // Allow reconnect after approval
_pairingApprovedAwaitingReconnect = true;
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
@@ -603,6 +609,7 @@ public class WindowsNodeClient : WebSocketClientBase
PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload));
var reconnectingAfterApproval = _pairingApprovedAwaitingReconnect;
_isConnected = true;
+ _rateLimited = false; // Clear transient rate-limit on successful connect
ResetReconnectAttempts();
// Extract node ID if returned
@@ -654,6 +661,7 @@ public class WindowsNodeClient : WebSocketClientBase
{
_isPendingApproval = true;
_isPaired = false;
+ _pairingBlocked = true;
_logger.Info("Not yet paired - check 'openclaw devices list' for pending approval");
_logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}");
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
@@ -717,6 +725,7 @@ public class WindowsNodeClient : WebSocketClientBase
_isPendingApproval = true;
_isPaired = false;
+ _pairingBlocked = true;
_pairingApprovedAwaitingReconnect = false;
var detail = !string.IsNullOrWhiteSpace(pairingRequestId)
@@ -731,6 +740,18 @@ public class WindowsNodeClient : WebSocketClientBase
return;
}
+ // Rate-limit / terminal auth errors — stop reconnecting
+ if (error.Contains("too many failed", StringComparison.OrdinalIgnoreCase) ||
+ error.Contains("rate limit", StringComparison.OrdinalIgnoreCase) ||
+ error.Contains("origin not allowed", StringComparison.OrdinalIgnoreCase) ||
+ error.Contains("token mismatch", StringComparison.OrdinalIgnoreCase))
+ {
+ _rateLimited = true;
+ _logger.Warn($"[NODE] Terminal auth error; stopping reconnect. Error: {error}");
+ RaiseStatusChanged(ConnectionStatus.Error);
+ return;
+ }
+
_logger.Error($"Node registration failed: {error} (code: {errorCode})");
RaiseStatusChanged(ConnectionStatus.Error);
}
@@ -997,6 +1018,20 @@ public class WindowsNodeClient : WebSocketClientBase
GatewaySelfUpdated?.Invoke(this, info);
}
+ protected override bool ShouldAutoReconnect()
+ {
+ // Don't reconnect while awaiting pairing approval — each reconnect
+ // generates a new pairing request on the gateway, causing a storm.
+ // _pairingBlocked survives OnDisconnected (which clears _isPendingApproval).
+ if (_pairingBlocked)
+ return false;
+
+ if (_rateLimited)
+ return false;
+
+ return true;
+ }
+
protected override void OnDisconnected()
{
_isConnected = false;
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index 0b3968d..3593081 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -86,6 +86,8 @@ public partial class App : Application
private DevicePairingListInfo? _lastDevicePairList;
private ModelsListInfo? _lastModelsList;
private PresenceEntry[]? _lastPresence;
+ private readonly List _agentEventsCache = new();
+ private const int MaxAppAgentEvents = 400;
private UpdateCommandCenterInfo _lastUpdateInfo = BuildInitialUpdateInfo();
private DateTime _lastCheckTime = DateTime.Now;
private DateTime _lastUsageActivityLogUtc = DateTime.MinValue;
@@ -883,6 +885,7 @@ public partial class App : Application
_lastNodePairList = null;
_lastDevicePairList = null;
_lastModelsList = null;
+ _agentEventsCache.Clear();
UpdateTrayIcon();
_hubWindow?.UpdateStatus(_currentStatus);
}
@@ -1491,19 +1494,30 @@ public partial class App : Application
return;
}
- if (string.IsNullOrWhiteSpace(_settings.Token))
+ // Need either a regular token or a bootstrap token to connect
+ var effectiveToken = _settings.Token;
+ if (string.IsNullOrWhiteSpace(effectiveToken))
{
- Logger.Info("Gateway token not configured — skipping operator client initialization");
- return;
+ if (useBootstrapHandoffAuth && !string.IsNullOrWhiteSpace(_settings.BootstrapToken))
+ {
+ // Bootstrap-only flow (setup code / QR): use bootstrap token for initial pairing
+ effectiveToken = _settings.BootstrapToken;
+ }
+ else
+ {
+ Logger.Info("Gateway token not configured — skipping operator client initialization");
+ return;
+ }
}
// Unsubscribe from old client if exists
UnsubscribeGatewayEvents();
+ _gatewayClient?.Dispose();
_lastGatewaySelf = null;
_gatewayClient = new OpenClawGatewayClient(
gatewayUrl,
- _settings.Token,
+ effectiveToken,
new AppLogger(),
useBootstrapHandoffAuth);
_gatewayClient.SetUserRules(_settings.UserRules.Count > 0 ? _settings.UserRules : null);
@@ -1944,6 +1958,19 @@ public partial class App : Application
if (_hubWindow != null && !_hubWindow.IsClosed)
_hubWindow.LastAuthError = null;
}
+
+ // Clear stale data when disconnected so tray menu doesn't show old sessions/nodes
+ if (status == ConnectionStatus.Disconnected || status == ConnectionStatus.Error)
+ {
+ _lastSessions = Array.Empty();
+ _lastChannels = Array.Empty();
+ _lastNodes = Array.Empty();
+ _lastNodePairList = null;
+ _lastDevicePairList = null;
+ _lastModelsList = null;
+ _lastGatewaySelf = null;
+ }
+
UpdateTrayIcon();
_dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow);
@@ -2252,7 +2279,13 @@ public partial class App : Application
private void OnAgentEventReceived(object? sender, AgentEventInfo evt)
{
- _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateAgentEvent(evt));
+ _dispatcherQueue?.TryEnqueue(() =>
+ {
+ _agentEventsCache.Insert(0, evt);
+ if (_agentEventsCache.Count > MaxAppAgentEvents)
+ _agentEventsCache.RemoveRange(MaxAppAgentEvents, _agentEventsCache.Count - MaxAppAgentEvents);
+ _hubWindow?.UpdateAgentEvent(evt);
+ });
}
private void OnNodePairListUpdated(object? sender, PairingListInfo data)
@@ -2497,6 +2530,7 @@ public partial class App : Application
_hubWindow.OpenDashboardAction = OpenDashboard;
_hubWindow.CheckForUpdatesAction = () => _ = CheckForUpdatesUserInitiatedAsync();
_hubWindow.QuickSendAction = () => ShowQuickSend();
+ _hubWindow.OpenSetupAction = () => _ = ShowOnboardingAsync();
_hubWindow.ConnectAction = () =>
{
InitializeGatewayClient();
@@ -2518,6 +2552,7 @@ public partial class App : Application
{
ReconnectGateway();
};
+ _hubWindow.ClearAppAgentEventsCache = () => _agentEventsCache.Clear();
if (_nodeService != null)
{
_hubWindow.NodeIsConnected = _nodeService.IsConnected;
@@ -2574,6 +2609,7 @@ public partial class App : Application
if (_lastPresence != null) _hubWindow.UpdatePresence(_lastPresence);
if (_lastGatewaySelf != null) _hubWindow.UpdateGatewaySelf(_lastGatewaySelf);
if (_lastAgentsList.HasValue) _hubWindow.UpdateAgentsList(_lastAgentsList.Value);
+ if (_agentEventsCache.Count > 0) _hubWindow.SeedAgentEvents(_agentEventsCache);
}
private void ShowSettings()
diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs
index 4355a8f..1d59a76 100644
--- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs
+++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs
@@ -136,14 +136,14 @@ public sealed class ConnectionPage : Component
void OnSetupCodeChanged(string code)
{
- setSetupCode(code);
if (string.IsNullOrWhiteSpace(code)) return;
var result = SetupCodeDecoder.Decode(code);
if (!result.Success)
{
- // Not a valid setup code — user might be still typing
+ // Not a valid setup code — user might be still typing.
+ // Don't call setSetupCode here to avoid re-render that steals focus.
if (code.Length > 2048)
Logger.Warn("[Connection] Setup code rejected: exceeds 2048 character limit");
else
@@ -151,6 +151,8 @@ public sealed class ConnectionPage : Component
return;
}
+ // Valid setup code decoded — now update state (will re-render)
+ setSetupCode(code);
if (result.Url != null)
{
setUrl(result.Url);
@@ -159,7 +161,8 @@ public sealed class ConnectionPage : Component
if (result.Token != null)
{
setToken(result.Token);
- Props.Settings.Token = result.Token;
+ // Bootstrap token goes to BootstrapToken only — it's single-use for pairing.
+ // Don't save as Settings.Token (causes reconnect storms on restart).
Props.Settings.BootstrapToken = result.Token;
}
setStatusMsg($"✅ {LocalizationHelper.GetString("Onboarding_Connection_StatusDecoded")}");
@@ -205,7 +208,13 @@ public sealed class ConnectionPage : Component
async void TestConnection()
{
Props.Settings.GatewayUrl = url;
- Props.Settings.Token = token;
+ // Only save to Settings.Token if the user entered a manual token,
+ // not a decoded bootstrap token (which belongs in BootstrapToken only).
+ if (string.IsNullOrWhiteSpace(Props.Settings.BootstrapToken) ||
+ !string.Equals(token, Props.Settings.BootstrapToken, StringComparison.Ordinal))
+ {
+ Props.Settings.Token = token;
+ }
// When SSH mode, start the managed tunnel before health-checking the local URL.
if (mode == ConnectionMode.Ssh)
@@ -473,40 +482,14 @@ public sealed class ConnectionPage : Component
catch { /* clipboard unavailable — ignore */ }
}
- // Setup code row: TextField + Paste + QR buttons (Grid keeps the field expanding)
+ // Setup code row: TextField + Paste + QR buttons
cardChildren.Add(
Grid(["1*", "Auto", "Auto"], ["Auto"],
TextField(setupCode, OnSetupCodeChanged,
placeholder: LocalizationHelper.GetString("Onboarding_Connection_SetupCodePlaceholder"),
header: LocalizationHelper.GetString("Onboarding_Connection_SetupCode"))
- .OnGotFocus((sender, _) =>
- {
- if (sender is Microsoft.UI.Xaml.Controls.TextBox tb && string.IsNullOrEmpty(tb.Text))
- {
- try
- {
- var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent();
- if (content.Contains(global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text))
- {
- var task = content.GetTextAsync();
- task.Completed = (op, status) =>
- {
- if (status == global::Windows.Foundation.AsyncStatus.Completed)
- {
- var text = op.GetResults();
- tb.DispatcherQueue.TryEnqueue(() =>
- {
- tb.Text = text;
- OnSetupCodeChanged(text);
- });
- }
- };
- }
- }
- catch { }
- }
- })
- .Grid(row: 0, column: 0),
+ .Grid(row: 0, column: 0)
+ .Set(tb => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSetupCode")),
Button(LocalizationHelper.GetString("Onboarding_Connection_PasteSetup"), PasteSetupCode)
.VAlign(VerticalAlignment.Bottom)
.Margin(6, 0, 0, 0)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
index 1c90ccf..2af0c32 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
@@ -48,10 +48,19 @@
+
+
+
-
@@ -59,31 +68,47 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+ FontSize="13" TextWrapping="Wrap" MaxLines="3"
+ Margin="0,6,0,0"/>
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs
index 7fc3640..11088f4 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs
@@ -60,6 +60,19 @@ public sealed partial class AgentEventsPage : Page
public void AddEvent(AgentEventInfo evt)
{
+ // Deduplicate by RunId + Seq
+ if (_allEvents.Any(e => e.RunId == evt.RunId && e.Seq == evt.Seq))
+ return;
+
+ // For assistant events, replace earlier streaming chunks with the latest one
+ // (each chunk contains the full accumulated text, so only the latest matters)
+ if (evt.Stream.Equals("assistant", StringComparison.OrdinalIgnoreCase))
+ {
+ _allEvents.RemoveAll(e =>
+ e.Stream.Equals("assistant", StringComparison.OrdinalIgnoreCase) &&
+ e.RunId == evt.RunId && e.Seq < evt.Seq);
+ }
+
_allEvents.Insert(0, evt);
if (_allEvents.Count > MaxEvents)
_allEvents.RemoveRange(MaxEvents, _allEvents.Count - MaxEvents);
@@ -100,9 +113,9 @@ public sealed partial class AgentEventsPage : Page
filtered = filtered.Where(e => e.SessionKey != null &&
e.SessionKey.StartsWith($"agent:{_agentIdFilter}:", StringComparison.OrdinalIgnoreCase));
- // Filter by stream type
+ // Filter by stream type (use ResolvedStream so "item" events with kind:"tool" match the Tool filter)
if (_activeFilter != "all")
- filtered = filtered.Where(e => e.Stream.Equals(_activeFilter, StringComparison.OrdinalIgnoreCase));
+ filtered = filtered.Where(e => e.ResolvedStream.Equals(_activeFilter, StringComparison.OrdinalIgnoreCase));
var list = filtered.ToList();
EventsList.ItemsSource = list;
@@ -121,30 +134,96 @@ public sealed partial class AgentEventsPage : Page
private void EventsList_ContainerContentChanging(ListViewBase sender, ContainerContentChangingEventArgs args)
{
- if (args.Item is AgentEventInfo evt && args.ItemContainer?.ContentTemplateRoot is Grid grid)
+ if (args.Item is not AgentEventInfo evt || args.ItemContainer?.ContentTemplateRoot is not Grid grid)
+ return;
+
+ // Row 0: header Grid with badge (col 0), timestamp (col 1), chevron (col 3)
+ if (grid.Children[0] is Grid headerGrid && headerGrid.Children[0] is Border badge)
{
- // Find the first Border in the first StackPanel (the badge)
- if (grid.Children[0] is StackPanel headerPanel && headerPanel.Children[0] is Border badge)
+ var hex = evt.BadgeColorHex;
+ try
{
- var hex = evt.BadgeColorHex;
- try
- {
- var a = Convert.ToByte(hex[1..3], 16);
- var r = Convert.ToByte(hex[3..5], 16);
- var g = Convert.ToByte(hex[5..7], 16);
- var b = Convert.ToByte(hex[7..9], 16);
- badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(
- Microsoft.UI.ColorHelper.FromArgb(a, r, g, b));
- }
- catch
- {
- badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Gray);
- }
+ var r = Convert.ToByte(hex[3..5], 16);
+ var g = Convert.ToByte(hex[5..7], 16);
+ var b = Convert.ToByte(hex[7..9], 16);
+ var color = Microsoft.UI.ColorHelper.FromArgb(255, r, g, b);
+ badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ Microsoft.UI.ColorHelper.FromArgb(40, r, g, b));
+ if (badge.Child is TextBlock badgeText)
+ badgeText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(color);
}
- // Hide summary row if empty
- if (grid.Children.Count > 1 && grid.Children[1] is TextBlock summaryBlock)
+ catch
{
- summaryBlock.Visibility = evt.HasSummary ? Visibility.Visible : Visibility.Collapsed;
+ badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ Microsoft.UI.ColorHelper.FromArgb(40, 100, 100, 100));
+ }
+
+ // Update chevron glyph based on model state
+ if (headerGrid.Children.Count > 2 && headerGrid.Children[2] is FontIcon chevron)
+ chevron.Glyph = evt.IsExpanded ? "\uE70E" : "\uE70D";
+ }
+
+ // Row 1: summary
+ if (grid.Children.Count > 1 && grid.Children[1] is TextBlock summaryBlock)
+ {
+ summaryBlock.Visibility = evt.HasSummary ? Visibility.Visible : Visibility.Collapsed;
+ if (evt.IsAssistantStream)
+ {
+ // Swap between truncated summary and full text
+ summaryBlock.Text = evt.IsExpanded ? (evt.FullAssistantText ?? evt.SummaryLine) : evt.SummaryLine;
+ summaryBlock.MaxLines = evt.IsExpanded ? 0 : 3;
+ }
+ else
+ {
+ summaryBlock.Text = evt.SummaryLine;
+ summaryBlock.MaxLines = evt.IsExpanded ? 0 : 3;
+ }
+ }
+
+ // Row 2: detail panel — only for streams that still need raw JSON
+ if (grid.Children.Count > 2 && grid.Children[2] is Grid detailGrid)
+ {
+ if (!evt.ShowDataJson)
+ {
+ // Assistant/error/lifecycle events show enough context in the summary row.
+ detailGrid.Visibility = Visibility.Collapsed;
+ }
+ else
+ {
+ detailGrid.Visibility = evt.IsExpanded ? Visibility.Visible : Visibility.Collapsed;
+ }
+ }
+ }
+
+ private void EventsList_ItemClick(object sender, ItemClickEventArgs e)
+ {
+ if (e.ClickedItem is not AgentEventInfo evt) return;
+ evt.IsExpanded = !evt.IsExpanded;
+
+ // Update the visual container
+ if (sender is ListView listView)
+ {
+ var container = listView.ContainerFromItem(e.ClickedItem) as ListViewItem;
+ if (container?.ContentTemplateRoot is Grid grid)
+ {
+ // Update chevron
+ if (grid.Children[0] is Grid headerGrid
+ && headerGrid.Children.Count > 2 && headerGrid.Children[2] is FontIcon chevron)
+ chevron.Glyph = evt.IsExpanded ? "\uE70E" : "\uE70D";
+
+ // Update summary text and MaxLines
+ if (grid.Children.Count > 1 && grid.Children[1] is TextBlock summaryBlock)
+ {
+ summaryBlock.Text = evt.IsAssistantStream && evt.IsExpanded
+ ? (evt.FullAssistantText ?? evt.SummaryLine)
+ : evt.SummaryLine;
+ summaryBlock.MaxLines = evt.IsExpanded ? 0 : 3;
+ }
+
+ // Toggle detail panel only for streams where raw JSON is still useful.
+ if (grid.Children.Count > 2 && grid.Children[2] is Grid detailGrid)
+ detailGrid.Visibility = (evt.IsExpanded && evt.ShowDataJson)
+ ? Visibility.Visible : Visibility.Collapsed;
}
}
}
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml
index 44c610f..99ab53e 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml
@@ -100,7 +100,7 @@
-
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
index 88d8222..2529cd1 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
@@ -473,14 +473,14 @@ public sealed partial class ConnectionPage : Page
_pendingGatewayUrl = gw.ConnectionUrl;
_pendingGatewayId = gw.Id;
TokenPromptText.Text = $"Connect to gateway at {gw.Host}:{gw.Port}";
- TokenPromptBox.Password = _hub.Settings.Token ?? "";
+ TokenPromptBox.Text = _hub.Settings.Token ?? "";
TokenPromptPanel.Visibility = Visibility.Visible;
TokenPromptBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
}
private void OnConnectWithToken(object sender, RoutedEventArgs e)
{
- var token = TokenPromptBox.Password?.Trim();
+ var token = TokenPromptBox.Text?.Trim();
if (string.IsNullOrEmpty(token) || _hub?.Settings == null || string.IsNullOrEmpty(_pendingGatewayUrl))
return;
@@ -535,10 +535,9 @@ public sealed partial class ConnectionPage : Page
settings.GatewayUrl = result.Url;
if (!string.IsNullOrEmpty(result.Token))
{
+ // Bootstrap token goes to BootstrapToken only — it's single-use for pairing.
+ // Don't save it as Settings.Token, which would cause reconnect storms on restart.
settings.BootstrapToken = result.Token;
- // Also set as the operator token so InitializeGatewayClient can connect
- if (string.IsNullOrWhiteSpace(settings.Token))
- settings.Token = result.Token;
}
settings.Save();
diff --git a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml
index 054f5c3..54313b3 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml
@@ -113,6 +113,7 @@
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs
index 3bf9f88..1938aed 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs
@@ -196,4 +196,9 @@ public sealed partial class DebugPage : Page
timer.Start();
}
}
+
+ private void OnRelaunchOnboarding(object sender, RoutedEventArgs e)
+ {
+ _hub?.OpenSetupAction?.Invoke();
+ }
}
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
index b62997d..2c12061 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
@@ -30,6 +30,7 @@ public sealed partial class HubWindow : WindowEx
public Action? ConnectAction { get; set; }
public Action? DisconnectAction { get; set; }
public Action? ReconnectAction { get; set; }
+ public Action? OpenSetupAction { get; set; }
// Node service state (set by App.xaml.cs in ShowHub)
public bool NodeIsConnected { get; set; }
@@ -399,9 +400,34 @@ public sealed partial class HubWindow : WindowEx
private readonly System.Collections.Generic.List _agentEvents = new();
public System.Collections.Generic.IReadOnlyList LastAgentEvents => _agentEvents;
+ /// Called by App to also clear its own agent event cache when Clear is invoked.
+ public Action? ClearAppAgentEventsCache { get; set; }
+
public void ClearAgentEvents()
{
- DispatcherQueue?.TryEnqueue(() => _agentEvents.Clear());
+ DispatcherQueue?.TryEnqueue(() =>
+ {
+ _agentEvents.Clear();
+ ClearAppAgentEventsCache?.Invoke();
+ });
+ }
+
+ /// Seed the hub's agent event cache from App-level cache (deduplicates by RunId+Seq).
+ public void SeedAgentEvents(IReadOnlyList appCache)
+ {
+ DispatcherQueue?.TryEnqueue(() =>
+ {
+ var existingKeys = new System.Collections.Generic.HashSet<(string, int)>(
+ _agentEvents.Select(e => (e.RunId, e.Seq)));
+ foreach (var evt in appCache)
+ {
+ if (!existingKeys.Contains((evt.RunId, evt.Seq)))
+ {
+ _agentEvents.Add(evt);
+ if (_agentEvents.Count >= MaxAgentEvents) break;
+ }
+ }
+ });
}
public void UpdateAgentEvent(AgentEventInfo evt)
diff --git a/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs b/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs
index 28223e5..3337dfc 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs
@@ -51,7 +51,7 @@ public sealed class SetupWizardWindow : WindowEx
// Step 0: Setup code + manual entry
private readonly TextBox _setupCodeBox;
private readonly TextBox _gatewayUrlBox;
- private readonly PasswordBox _tokenBox;
+ private readonly TextBox _tokenBox;
private readonly TextBlock _testStatusLabel;
private readonly Button _testButton;
private readonly StackPanel _manualEntryPanel;
@@ -196,17 +196,17 @@ public sealed class SetupWizardWindow : WindowEx
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
Foreground = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"]
});
- _tokenBox = new PasswordBox
+ _tokenBox = new TextBox
{
Header = LocalizationHelper.GetString("Setup_TokenHeader"),
PlaceholderText = LocalizationHelper.GetString("Setup_TokenPlaceholder"),
- Password = _draftToken
+ Text = _draftToken
};
AutomationProperties.SetAutomationId(_tokenBox, "TokenBox");
- _tokenBox.PasswordChanged += (s, e) => _connectionTested = false;
- _tokenBox.PasswordChanged += (s, e) =>
+ _tokenBox.TextChanged += (s, e) => _connectionTested = false;
+ _tokenBox.TextChanged += (s, e) =>
{
- _draftToken = _tokenBox.Password;
+ _draftToken = _tokenBox.Text;
UpdatePairingStatusText();
};
_manualEntryPanel.Children.Add(_tokenBox);
@@ -718,7 +718,7 @@ public sealed class SetupWizardWindow : WindowEx
private async void OnTestConnection(object sender, RoutedEventArgs e)
{
_draftGatewayUrl = _gatewayUrlBox.Text.Trim();
- _draftToken = _tokenBox.Password;
+ _draftToken = _tokenBox.Text;
UpdatePairingStatusText();
if (!GatewayUrlHelper.IsValidGatewayUrl(_draftGatewayUrl))
diff --git a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs
index 66d1678..0300f8d 100644
--- a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs
+++ b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs
@@ -546,7 +546,7 @@ public class LocalCommandRunnerIntegrationTests
{
Command = "Write-Output 'hello world'",
Shell = "powershell",
- TimeoutMs = 10000
+ TimeoutMs = 30000
});
Assert.Equal(0, result.ExitCode);
diff --git a/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs b/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs
index a434baf..cc24793 100644
--- a/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs
+++ b/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs
@@ -251,7 +251,7 @@ public class WebSocketClientBaseTests
Assert.Contains(ConnectionStatus.Error, statuses);
Assert.True(statuses.Count(s => s == ConnectionStatus.Connecting) >= 2);
- Assert.Contains(_logger.Logs, line => line.Contains("reconnecting in 1000ms", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(_logger.Logs, line => line.Contains("reconnecting in 1", StringComparison.OrdinalIgnoreCase) && line.Contains("ms (attempt 1)", StringComparison.OrdinalIgnoreCase));
client.Dispose();
}