Merge branch 'master' of https://github.com/shanselman/openclaw-windows-hub
This commit is contained in:
commit
95e2ee7b4a
@ -1522,12 +1522,27 @@ public class AgentEventInfo
|
||||
|
||||
public string FormattedTime => Timestamp.ToString("HH:mm:ss.fff");
|
||||
|
||||
public string StreamUpper => Stream.ToUpperInvariant();
|
||||
/// <summary>Resolved event kind — for "item" stream events, uses data.kind instead.</summary>
|
||||
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();
|
||||
|
||||
/// <summary>Color hex for stream badge (used by UI to create brush).</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Full assistant message text (no truncation), for expanded view.</summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether this event is an assistant stream (expanded view shows full text instead of JSON).</summary>
|
||||
public bool IsAssistantStream => ResolvedStream == "assistant";
|
||||
|
||||
/// <summary>Whether to show the raw DataJson section. Hidden for streams where SummaryLine is sufficient.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -86,6 +86,8 @@ public partial class App : Application
|
||||
private DevicePairingListInfo? _lastDevicePairList;
|
||||
private ModelsListInfo? _lastModelsList;
|
||||
private PresenceEntry[]? _lastPresence;
|
||||
private readonly List<AgentEventInfo> _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<SessionInfo>();
|
||||
_lastChannels = Array.Empty<ChannelHealth>();
|
||||
_lastNodes = Array.Empty<GatewayNodeInfo>();
|
||||
_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()
|
||||
|
||||
@ -136,14 +136,14 @@ public sealed class ConnectionPage : Component<OnboardingState>
|
||||
|
||||
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<OnboardingState>
|
||||
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<OnboardingState>
|
||||
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<OnboardingState>
|
||||
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<OnboardingState>
|
||||
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)
|
||||
|
||||
@ -48,10 +48,19 @@
|
||||
<ListView x:Name="EventsList" Grid.Row="2"
|
||||
SelectionMode="None"
|
||||
Visibility="Collapsed"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="EventsList_ItemClick"
|
||||
ContainerContentChanging="EventsList_ContainerContentChanging">
|
||||
<ListView.ItemContainerStyle>
|
||||
<Style TargetType="ListViewItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="Padding" Value="0,1"/>
|
||||
<Setter Property="Margin" Value="0"/>
|
||||
</Style>
|
||||
</ListView.ItemContainerStyle>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Padding="12,8" Margin="0,1" CornerRadius="4"
|
||||
<Grid Padding="16,10,16,12" Margin="0,2" CornerRadius="6"
|
||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
@ -59,31 +68,47 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Row 0: stream badge + timestamp + session -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
|
||||
<Border CornerRadius="4" Padding="6,2">
|
||||
<TextBlock Text="{Binding StreamUpper}" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="White"/>
|
||||
<!-- Row 0: badge + timestamp + chevron -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" CornerRadius="4" Padding="8,2,8,4" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StreamUpper}" FontSize="10" FontWeight="SemiBold"
|
||||
Foreground="White" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding FormattedTime}" VerticalAlignment="Center"
|
||||
FontSize="11" FontFamily="Consolas"
|
||||
<TextBlock Grid.Column="1" Text="{Binding FormattedTime}" VerticalAlignment="Center"
|
||||
FontSize="11" FontFamily="Consolas" Margin="8,0,0,0"
|
||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
|
||||
<TextBlock Text="{Binding RunId}" VerticalAlignment="Center"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
||||
MaxWidth="120" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<FontIcon Grid.Column="3" Glyph="" FontSize="10" VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Row 1: Summary line -->
|
||||
<TextBlock Grid.Row="1" Text="{Binding SummaryLine}"
|
||||
FontWeight="SemiBold" FontSize="12"
|
||||
Margin="0,4,0,0" TextTrimming="CharacterEllipsis"/>
|
||||
FontSize="13" TextWrapping="Wrap" MaxLines="3"
|
||||
Margin="0,6,0,0"/>
|
||||
|
||||
<!-- Row 2: Data JSON (collapsed by default, expandable) -->
|
||||
<TextBlock Grid.Row="2" Text="{Binding DataJson}" TextWrapping="Wrap"
|
||||
MaxLines="6" FontFamily="Consolas" FontSize="11"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,4,0,0" IsTextSelectionEnabled="True"/>
|
||||
<!-- Row 2: Expandable detail (hidden by default) -->
|
||||
<Grid Grid.Row="2" Visibility="Collapsed" Margin="0,8,0,0">
|
||||
<!-- Assistant: full text, same formatting as summary -->
|
||||
<TextBlock TextWrapping="Wrap" FontSize="13"
|
||||
IsTextSelectionEnabled="True" Visibility="Collapsed"/>
|
||||
|
||||
<!-- Non-assistant: RunId + JSON -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding RunId}"
|
||||
FontSize="11" FontFamily="Consolas"
|
||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
|
||||
<TextBlock Text="{Binding DataJson}" TextWrapping="Wrap"
|
||||
FontFamily="Consolas" FontSize="11"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
IsTextSelectionEnabled="True"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,7 +100,7 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<PasswordBox x:Uid="TokenPromptBox" x:Name="TokenPromptBox" Grid.Column="0"
|
||||
<TextBox x:Uid="TokenPromptBox" x:Name="TokenPromptBox" Grid.Column="0"
|
||||
PlaceholderText="Gateway token" Header="Token"/>
|
||||
<Button x:Uid="ConnectionPage_Button_105" Grid.Column="1" Content="Cancel"
|
||||
VerticalAlignment="Bottom" Click="OnCancelTokenPrompt"/>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -113,6 +113,7 @@
|
||||
<Button x:Uid="DebugPage_Button_113" Content="📁 Open Diagnostics Folder" Click="OnOpenDiagnosticsFolder" HorizontalAlignment="Stretch"/>
|
||||
<Button x:Uid="DebugPage_Button_114" Content="📋 Copy Support Context" Click="OnCopySupportContext"
|
||||
Style="{ThemeResource AccentButtonStyle}" HorizontalAlignment="Stretch"/>
|
||||
<Button Content="🔄 Relaunch First-Run Setup" Click="OnRelaunchOnboarding" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
|
||||
|
||||
@ -196,4 +196,9 @@ public sealed partial class DebugPage : Page
|
||||
timer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRelaunchOnboarding(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_hub?.OpenSetupAction?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<AgentEventInfo> _agentEvents = new();
|
||||
public System.Collections.Generic.IReadOnlyList<AgentEventInfo> LastAgentEvents => _agentEvents;
|
||||
|
||||
/// <summary>Called by App to also clear its own agent event cache when Clear is invoked.</summary>
|
||||
public Action? ClearAppAgentEventsCache { get; set; }
|
||||
|
||||
public void ClearAgentEvents()
|
||||
{
|
||||
DispatcherQueue?.TryEnqueue(() => _agentEvents.Clear());
|
||||
DispatcherQueue?.TryEnqueue(() =>
|
||||
{
|
||||
_agentEvents.Clear();
|
||||
ClearAppAgentEventsCache?.Invoke();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Seed the hub's agent event cache from App-level cache (deduplicates by RunId+Seq).</summary>
|
||||
public void SeedAgentEvents(IReadOnlyList<AgentEventInfo> 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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -546,7 +546,7 @@ public class LocalCommandRunnerIntegrationTests
|
||||
{
|
||||
Command = "Write-Output 'hello world'",
|
||||
Shell = "powershell",
|
||||
TimeoutMs = 10000
|
||||
TimeoutMs = 30000
|
||||
});
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user