From c8832d54b7b6a269f89965b6198e291629cf3537 Mon Sep 17 00:00:00 2001 From: Barbara Kudiess Date: Wed, 11 Nov 2026 23:03:22 -0800 Subject: [PATCH 1/3] fix: Dashboard loses the selected chat on newer Gateways Use the released full-session-key query contract for chat handoff and remove web-routing release allowlists. Preserve search admission and credential safeguards, and route Systems to its matching web page. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/ARCHITECTURE.md | 2 +- docs/CONNECTION_ARCHITECTURE.md | 20 ++- .../Controls/SearchHost.xaml.cs | 3 - .../Presentation/HubSearch.cs | 4 +- .../Presentation/HubSearchCatalog.cs | 71 ++------ .../Presentation/HubSearchController.cs | 17 +- .../Services/HubCommandCatalog.cs | 9 +- .../Windows/WorkspaceWindow.xaml.cs | 11 +- .../Presentation/HubSearchCatalogTests.cs | 159 ++++++++++-------- .../Presentation/HubSearchControllerTests.cs | 31 +++- .../Presentation/HubSearchTests.cs | 1 + 11 files changed, 166 insertions(+), 162 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fe6e171b2c..885ae8a780 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -156,7 +156,7 @@ new attempt. This boundary does not change release signing or Windows policy. | Shared native command-catalog inputs | `HubCommandCatalog` adapts app state/localization and `HubPageRegistry` metadata into navigation search items. The registry supplies localized destination label keys and `FluentIconCatalog` glyphs for search, independently of legacy command wording/emoji; old titles remain search aliases. `HubSearchCatalog.Navigation` groups aliases by canonical destination, retaining their searchable titles and descriptions: notification preferences finds one General row, notification history remains separate, and About has its own destination. Removed Event Stream commands are not indexed. The legacy Channels command projects as a Dashboard destination even when the broader Dashboard route catalog is unavailable. MCP uses that local catalog and `HubSearch`, independent of the visible surface; message snippets are excluded | authoritative | | Unified icon-opened search | `HubSearch` ranks and deduplicates items; `HubSearchCatalog` projects Gateway data; window-owned `HubSearchController` fences async publication and activation. Same-context refresh preserves pending reads and remote results; Retry reloads them. The active `SearchHost` owner prevents a previous shell's Closed event from closing newer search. `SearchHost` preserves same-query scroll and surviving selected-row/input focus through metadata rebuilds; Show more transfers focus to the first newly revealed result before its disappearing button can restore an old row. It owns popup sizing, keyboard handling and one native list scroller containing only wrapped results. There is no search footer or visible status-message block; More/Retry sit beside the input and source status remains accessible through the input's UI Automation ItemStatus. `HubPresentationContractTests.SearchPopup_HasNoFooterOrVisibleStatusBlock_AndKeepsActionsBesideInput` guards the prohibited footer until the native `SearchHost` surface is replaced. Shells retain icons, flyout lifetime and per-open focus. Cross-surface requests coordinate both existing `FlyoutShowRequest` close owners and replay through the window's current-surface admission path; deferred destination reveal cannot take focus from a newer search generation. `PermissionSettingsWriterRoutingTests.MainAndSettingsSearch_ShareCatalogExecutorAndLabel_WithoutChangingTheVisibleSurface` guards these native owner boundaries until the shared shell search is replaced | authoritative | | Detached search RPC responses | `OpenClawGatewayClient` uses private `search:` request IDs and drops uncorrelated replies before legacy payload dispatch. Request cancellation frees the pending registration and stops queued writes; `WebSocketClientBase` drains a started frame under client-lifetime cancellation so a cancelled query cannot abort the shared socket. Loopback request and blocked real-stream send tests cover both boundaries | authoritative | -| Search destination section and agent provenance | `ConfigEditorModel` resolves sections from schema independently of saved values; `ConfigPage` renders missing sections with empty data. Its field reveal is bound to the requested section, consumed after one attempt, and cleared on manual navigation/unload; cancelling detail rendering also dismisses that operation's overlay without allowing a stale continuation to clear a newer one. `SchemaConfigEditor` applies field reveal after layout. Search-owned skills reads retain selected-agent cache provenance and open explicitly labeled Dashboard destinations. The independently proved `/skills` route on released `2026.11.1` does not enable its other unverified Dashboard routes. Retired Skills/Bindings native pages and global SkillsData are not restored | authoritative | +| Search destination section and agent provenance | `ConfigEditorModel` resolves sections from schema independently of saved values; `ConfigPage` renders missing sections with empty data. Its field reveal is bound to the requested section, consumed after one attempt, and cleared on manual navigation/unload; cancelling detail rendering also dismisses that operation's overlay without allowing a stale continuation to clear a newer one. `SchemaConfigEditor` applies field reveal after layout. Search-owned skills reads retain selected-agent cache provenance and open explicitly labeled Dashboard destinations. `HubSearchCatalog` uses established web routes without release allowlists, including `/skills`, `/systems` and full-key `/chat?session=` handoff. Systems destinations must not substitute the separate Devices settings page. RPC advertisement, permissions and connection/profile fencing remain authoritative for remote search reads; route availability is not capability negotiation. Retired Skills/Bindings native pages and global SkillsData are not restored | authoritative | | Settings-only command-catalog input adaptation | Closed in `SettingsShellView`; delegate to `HubCommandCatalog` so Workspace-first MCP searches use the same inputs | closed | | Gateway record test data | `OpenClaw.Connection.Tests.GatewayRecordBuilder` | authoritative | | Settings test data | `OpenClaw.TestSupport.SettingsDataBuilder` | authoritative | diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index bf15417c25..30e20822ca 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -254,7 +254,25 @@ Chat and Settings end with one shared `WorkspacePaneFooter`: account, Dashboard, and the notifications bell, always visible. Settings' compact rail stacks the same three as 40 DIP icons, with connection status as a dot on the avatar. Diagnostics and App settings live at the end of "This Computer". Dashboard and -Ctrl+Shift+D open the Gateway Dashboard home through `GatewayDashboardLauncher`. +Ctrl+Shift+D open the verified counterpart of the visible page or chat through +`GatewayDashboardLauncher`, falling back to Dashboard home when no verified +route is available. Chat handoff uses `/chat?session=` +without checking Gateway release numbers. The Gateway owns resolution of its +current session routes and configured main key. This query compatibility path +is present in both the +[older bootstrap](https://github.com/openclaw/openclaw/blob/3a9d69db306cd7f081e06254cb89c4bcc14a7107/ui/src/app/bootstrap-location.ts) +and [current released parser](https://github.com/openclaw/openclaw/blob/4ffdd2047d192dd65554de3c8c46c0e0197c6e2a/ui/src/app/released-session-query.ts). +Settings and search web destinations also use established routes, not Gateway +release allowlists. Their route contracts are recorded in the +[older route table](https://github.com/openclaw/openclaw/blob/3a9d69db306cd7f081e06254cb89c4bcc14a7107/ui/src/app-route-paths.ts) +and [current released route table](https://github.com/openclaw/openclaw/blob/4ffdd2047d192dd65554de3c8c46c0e0197c6e2a/ui/src/app-route-paths.ts). +Systems links use `/systems`, matching the native Systems page's Open in web +action, not the separate Devices settings page. The current released route table +defines `/systems`; the older table does not. Gateways without that page cannot +provide an equivalent Systems destination. +These navigation contracts are not capability negotiation. Search still gates +remote reads on advertised methods and operator permissions, fences stale +connection/profile results, and limits admin-only destinations to administrators. Each bell opens `NotificationFlyoutContent` using the existing `AppNotificationService`, without replacing chat or changing history. Its explicit Open notifications link and existing deep links retain the full page. diff --git a/src/OpenClaw.Tray.WinUI/Controls/SearchHost.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SearchHost.xaml.cs index 67bd691ce5..bcf4da6c0a 100644 --- a/src/OpenClaw.Tray.WinUI/Controls/SearchHost.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Controls/SearchHost.xaml.cs @@ -168,9 +168,6 @@ private void Render() var statuses = _controller.Statuses.Select(status => LocalizationHelper.Format($"Search_Status_{status.State}", L($"Search_Group_{status.Group}"))); var statusText = string.Join("\n", statuses); - if (_controller.HasDashboardCompatibilityWarning) - statusText = string.Join("\n", new[] { statusText, L("Search_Compatibility") } - .Where(text => text.Length > 0)); if (statusText.Length == 0 && items.Length == 0) statusText = L("Search_Empty"); SetInputStatus(statusText); diff --git a/src/OpenClaw.Tray.WinUI/Presentation/HubSearch.cs b/src/OpenClaw.Tray.WinUI/Presentation/HubSearch.cs index 8feb071904..e200572c1f 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/HubSearch.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/HubSearch.cs @@ -61,7 +61,7 @@ private static int Rank(HubSearchItem item, string query) } internal static IEnumerable Sessions( - IEnumerable source, bool serverMatch = false, bool dashboardSupported = false) + IEnumerable source, bool serverMatch = false) { var sessions = source.Where(session => !session.Archived && !SessionDisplayResolver.IsBackground(session)).ToArray(); @@ -71,7 +71,7 @@ internal static IEnumerable Sessions( SessionDisplayResolver.Resolve(session).AgentId, "\uE8BD", new(HubSearchTargetKind.Session, session.Key), $"{session.Label} {session.Subject} {session.AgentId} {session.Key}", - DashboardPath: dashboardSupported ? HubSearchCatalog.SessionPath(session.Key, session.IsMain) : null, + DashboardPath: HubSearchCatalog.SessionPath(session.Key), ServerMatch: serverMatch, UpdatedAt: session.UpdatedAt)).ToArray(); } diff --git a/src/OpenClaw.Tray.WinUI/Presentation/HubSearchCatalog.cs b/src/OpenClaw.Tray.WinUI/Presentation/HubSearchCatalog.cs index 59478efdf3..3953a9fbc0 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/HubSearchCatalog.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/HubSearchCatalog.cs @@ -1,6 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; -using OpenClaw.Shared; namespace OpenClawTray.Presentation; @@ -9,7 +7,7 @@ internal static class HubSearchCatalog internal static readonly (string Tag, string Path)[] Counterparts = [ ("connection", "/settings/connection"), ("sessions", "/sessions"), ("skills", "/skills"), - ("systems", "/settings/devices"), + ("systems", "/systems"), ("cron", "/automations"), ("config", "/settings/general"), ("usage", "/usage") ]; @@ -24,43 +22,12 @@ internal static readonly (string Id, string Path, bool Admin)[] DashboardRoutes ("Activity", "/activity", false), ("Portals", "/portals", false), ("Worktrees", "/worktrees", false) ]; - internal static bool SupportsDashboard(string? version) => - OpenClawReleaseVersion.TryParseStable(version, out var release) && - release.Year == 2026 && release.Month == 10 && release.Patch == 9; + // The released query contract lets the Gateway resolve its own session route and main key. + internal static string? SessionPath(string? key) => + string.IsNullOrWhiteSpace(key) ? null : $"/chat?session={Uri.EscapeDataString(key)}"; - internal static bool SupportsSkillsDashboard(string? version) => - SupportsDashboard(version) || - OpenClawReleaseVersion.TryParseStable(version, out var release) && - release.Year == 2026 && release.Month == 11 && release.Patch == 1; - - // Released session-url-contract exactKey grammar. Never derive a shortened session identity. - internal static string? SessionPath(string key, bool? isMain = null) - { - var parts = key.Split(':'); - if (parts.Length < 3 || parts[0] != "agent" || parts.Any(string.IsNullOrEmpty) || - !Regex.IsMatch(parts[1], "^[a-z0-9][a-z0-9_-]*$", RegexOptions.CultureInvariant | RegexOptions.NonBacktracking)) - return null; - var root = $"/chat/{Escape(parts[1])}"; - var rest = parts.Skip(2).ToArray(); - if (isMain == true || isMain is null && rest.Length == 1 && rest[0].Equals("main", StringComparison.OrdinalIgnoreCase)) - return root; - if (rest.Length == 1 && (rest[0].ToLowerInvariant() is "main" or "global" or "boot" or "sessions" || - Regex.IsMatch(rest[0], "^(?:.*-)?[0-9a-f]{8,32}$", RegexOptions.IgnoreCase | RegexOptions.NonBacktracking))) - return $"{root}/~key/{Escape(rest[0])}"; - return $"{root}/{string.Join("/", rest.Select(Escape))}"; - - static string Escape(string value) - { - if (value == ".") return "~dot"; - if (value == "..") return "~dotdot"; - var encoded = Uri.EscapeDataString(value).Replace(".", "%2E"); - return encoded.StartsWith('~') ? "~" + encoded : encoded; - } - } - - internal static string? Counterpart(string tag, bool supported) + internal static string? Counterpart(string tag) { - if (!supported) return null; if (tag.StartsWith("agent:", StringComparison.Ordinal)) { var parts = tag.Split(':'); @@ -72,11 +39,8 @@ static string Escape(string value) internal static string? ContextualDashboardPath( WorkspaceDestination destination, - string? currentSessionKey, - bool? isMain, - string? gatewayVersion) + string? currentSessionKey) { - var supported = SupportsDashboard(gatewayVersion); if (destination.Page == WorkspacePageId.Settings) { if (destination.CompanionTag is not { } tag) @@ -84,17 +48,16 @@ static string Escape(string value) if (tag.StartsWith("agent:", StringComparison.Ordinal) && tag.IndexOf(':', "agent:".Length) < 0) return null; - return Counterpart(tag, supported); + return Counterpart(tag); } - if (!supported || destination.Page != WorkspacePageId.Home) + if (destination.Page != WorkspacePageId.Home) return null; - var sessionKey = destination.SessionKey ?? currentSessionKey; - return string.IsNullOrWhiteSpace(sessionKey) ? null : SessionPath(sessionKey, isMain); + return SessionPath(destination.SessionKey ?? currentSessionKey); } internal static IEnumerable Navigation( - IEnumerable commands, bool connected, bool dashboardSupported, Func localize) + IEnumerable commands, bool connected, Func localize) { var destinations = commands.GroupBy(command => command.Action is { Kind: HubCommandActionKind.Navigate, Value: { } tag } @@ -117,7 +80,7 @@ internal static IEnumerable Navigation( yield return new($"page:{tag}", HubSearchGroup.Settings, localize(presentation.TitleKey), command.Subtitle, presentation.Icon, new(HubSearchTargetKind.Page, tag), string.Join(" ", destination.Select(alias => $"{alias.Action.Value} {alias.Title} {alias.Subtitle}")), - Counterpart(tag, dashboardSupported)); + connected ? Counterpart(tag) : null); } else if (command.Action.Kind == HubCommandActionKind.OpenDashboard && string.IsNullOrEmpty(command.Action.Value)) { @@ -129,7 +92,7 @@ internal static IEnumerable Navigation( } internal static IReadOnlyList ParseCatalog( - string method, JsonElement payload, string agentId, bool dashboardSupported, bool skillsDashboardSupported = false) + string method, JsonElement payload, string agentId) { var (property, group) = method switch { @@ -143,7 +106,6 @@ internal static IReadOnlyList ParseCatalog( rows.ValueKind != JsonValueKind.Array) throw new InvalidDataException("Invalid search catalog response."); var items = new List(); - var skillsSupported = dashboardSupported || skillsDashboardSupported; foreach (var row in rows.EnumerateArray()) { var id = Text(row, group == HubSearchGroup.Skills ? "skillKey" : "id"); @@ -155,11 +117,10 @@ internal static IReadOnlyList ParseCatalog( var target = group switch { HubSearchGroup.Automations => new HubSearchTarget(HubSearchTargetKind.Page, agent.Length == 0 ? "cron" : $"agent:{agent}:cron", id), - HubSearchGroup.Skills => new(HubSearchTargetKind.Dashboard, skillsSupported ? "/skills" : ""), + HubSearchGroup.Skills => new(HubSearchTargetKind.Dashboard, "/skills"), HubSearchGroup.Agents => new(HubSearchTargetKind.Page, $"agent:{id}"), _ => new(HubSearchTargetKind.Dashboard, "/settings/model-providers") }; - if (group == HubSearchGroup.Models && !dashboardSupported) continue; if (group == HubSearchGroup.Agents && row.TryGetProperty("identity", out var identity)) name = Text(identity, "name") ?? name; var dashboard = group switch @@ -173,12 +134,12 @@ internal static IReadOnlyList ParseCatalog( JsonSerializer.Serialize(new[] { group.ToString(), group == HubSearchGroup.Skills ? agentId : provider, id }), group, name, detail, "\uE8A5", target, $"{id} {Text(row, "name")} {Text(row, "source")} {Text(row, "workspace")} {Text(row, "alias")} {provider} {Tags(row)}", - (group == HubSearchGroup.Skills ? skillsSupported : dashboardSupported) ? dashboard : null)); + dashboard)); } return items; } - internal static IReadOnlyList Config(JsonElement payload, bool dashboardSupported, out bool partial) + internal static IReadOnlyList Config(JsonElement payload, out bool partial) { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty("schema", out var schema) || schema.ValueKind != JsonValueKind.Object) @@ -202,7 +163,7 @@ void Visit(JsonElement node, string path, int depth) Text(hint, "label") ?? Text(node, "title") ?? path, path, "\uE713", new(HubSearchTargetKind.Page, "config", path), $"{path} {Text(hint, "help")} {Text(node, "description")} {Tags(hint)}", - dashboardSupported ? "/settings/general" : null)); + "/settings/general")); if (node.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object) foreach (var property in properties.EnumerateObject()) Visit(property.Value, path.Length == 0 ? property.Name : $"{path}.{property.Name}", depth + 1); diff --git a/src/OpenClaw.Tray.WinUI/Presentation/HubSearchController.cs b/src/OpenClaw.Tray.WinUI/Presentation/HubSearchController.cs index 6f1a9aa767..3f7860cf1f 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/HubSearchController.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/HubSearchController.cs @@ -4,7 +4,7 @@ namespace OpenClawTray.Presentation; internal sealed record HubSearchContext( - IOperatorGatewayClient? Client, string AgentId, string? GatewayVersion, + IOperatorGatewayClient? Client, string AgentId, IReadOnlyList LocalItems, IReadOnlyList Sessions, int ProfileRevision = 0, bool IsActiveConnection = true) { @@ -38,8 +38,6 @@ internal sealed class HubSearchController( internal string Query { get; private set; } = ""; internal IReadOnlyList Results { get; private set; } = []; internal bool HasMoreResults { get; private set; } - internal bool HasDashboardCompatibilityWarning => _context is { Connected: true } && - !HubSearchCatalog.SupportsDashboard(_context.GatewayVersion); internal IReadOnlyList Statuses => _states.Select(pair => new HubSearchStatus(pair.Key, pair.Value)).ToArray(); internal event Action? Changed; @@ -234,9 +232,8 @@ Task Sessions(bool messages) => Run(messages ? "sessions.search" : "sessions.lis .DistinctBy(session => session.Key).ToArray(); if (result.Indexing || result.Truncated || result.ArchivedExcluded > 0) Post(generation, context, () => _states[messages ? HubSearchGroup.Messages : HubSearchGroup.Chats] = "Partial"); - var dashboardSupported = HubSearchCatalog.SupportsDashboard(context.GatewayVersion); - if (!messages) return HubSearch.Sessions(result.Sessions, serverMatch: true, dashboardSupported: dashboardSupported).ToArray(); - var byKey = HubSearch.Sessions(rows, dashboardSupported: dashboardSupported) + if (!messages) return HubSearch.Sessions(result.Sessions, serverMatch: true).ToArray(); + var byKey = HubSearch.Sessions(rows) .ToDictionary(item => item.Target.Value, StringComparer.Ordinal); return result.Hits.Where(hit => byKey.ContainsKey(hit.SessionKey)).Select(hit => { @@ -273,19 +270,15 @@ Task Catalog(string method, HubSearchGroup group, object? parameters, bool cache if (cache) Post(generation, context, () => { lock (_cache) _cache[method] = (DateTimeOffset.UtcNow, payload); }); } - var supported = HubSearchCatalog.SupportsDashboard(context.GatewayVersion); - if (group == HubSearchGroup.Models && !supported) - Post(generation, context, () => _states[group] = "Unavailable"); if (method == "config.schema") { - var items = HubSearchCatalog.Config(payload, supported, out var partial); + var items = HubSearchCatalog.Config(payload, out var partial); if (partial) Post(generation, context, () => _states[group] = "Partial"); return items; } if (payload.TryGetProperty("hasMore", out var more) && more.ValueKind == JsonValueKind.True) Post(generation, context, () => _states[group] = "Partial"); - return HubSearchCatalog.ParseCatalog(method, payload, context.AgentId, supported, - HubSearchCatalog.SupportsSkillsDashboard(context.GatewayVersion)); + return HubSearchCatalog.ParseCatalog(method, payload, context.AgentId); }); } diff --git a/src/OpenClaw.Tray.WinUI/Services/HubCommandCatalog.cs b/src/OpenClaw.Tray.WinUI/Services/HubCommandCatalog.cs index b3c78a7ba1..e3f29253a5 100644 --- a/src/OpenClaw.Tray.WinUI/Services/HubCommandCatalog.cs +++ b/src/OpenClaw.Tray.WinUI/Services/HubCommandCatalog.cs @@ -13,9 +13,8 @@ internal static IReadOnlyList BuildSearch(AppState? state, string var resources = HubPageRegistry.CommandResourceKeys.ToImmutableDictionary( key => key, LocalizationHelper.GetString, StringComparer.Ordinal); var connected = state?.Status == ConnectionStatus.Connected; - var supported = HubSearchCatalog.SupportsDashboard(state?.GatewaySelf?.ServerVersion); var commands = HubPageRegistry.BuildCommands(new(agentId, DiagnosticsGate.IsVisible, null, [], resources)); - var items = HubSearchCatalog.Navigation(commands, connected, supported, LocalizationHelper.GetString).ToList(); + var items = HubSearchCatalog.Navigation(commands, connected, LocalizationHelper.GetString).ToList(); foreach (var (id, label) in new[] { ("NodeMode", "PermissionsPage_NodeMode.Text"), @@ -30,12 +29,12 @@ internal static IReadOnlyList BuildSearch(AppState? state, string $"{id} {LocalizationHelper.GetString(id == "NodeMode" ? "PermissionsPage_WhenOnThisPC.Text" : label.Replace("_Label", "_Description"))}")); if (connected) { - items.AddRange(HubSearch.Sessions(state!.Sessions.Select(session => session.Clone()), dashboardSupported: supported)); + items.AddRange(HubSearch.Sessions(state!.Sessions.Select(session => session.Clone()))); if (state.AgentsList is { ValueKind: JsonValueKind.Object } agents && agents.TryGetProperty("agents", out var agentRows) && agentRows.ValueKind == JsonValueKind.Array) - items.AddRange(HubSearchCatalog.ParseCatalog("agents.list", agents, agentId, supported)); + items.AddRange(HubSearchCatalog.ParseCatalog("agents.list", agents, agentId)); } - if (supported) + if (connected) foreach (var route in HubSearchCatalog.DashboardRoutes) { if (route.Admin && !canAdmin) continue; diff --git a/src/OpenClaw.Tray.WinUI/Windows/WorkspaceWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/WorkspaceWindow.xaml.cs index 7859e86587..758c6f7539 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/WorkspaceWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/WorkspaceWindow.xaml.cs @@ -293,7 +293,7 @@ private HubSearchContext CaptureSearch() var agent = IsSettingsVisible ? _settings!.CurrentAgentId : SelectedAgentId ?? ""; var client = CurrentApp.GatewayClient; var items = HubCommandCatalog.BuildSearch(_state, agent, client?.GrantedOperatorScopes.Contains("operator.admin") == true); - return new(client, agent, _state.GatewaySelf?.ServerVersion, items, + return new(client, agent, items, _state.Sessions.Select(session => session.Clone()).ToArray(), _state.SelfProfileRevision, _state.Status == ConnectionStatus.Connected); } @@ -1164,15 +1164,8 @@ private void UpdateAvailableBadge() var sessionKey = destination.Page == WorkspacePageId.Home ? destination.SessionKey ?? _chat.CurrentSessionKey : null; - bool? isMain = sessionKey is null - ? null - : _state.Sessions.FirstOrDefault(session => session.Key == sessionKey)?.IsMain; - if (sessionKey is not null && isMain is null && - CurrentApp.GatewayClient?.MainSessionKey is { } mainSessionKey) - isMain = string.Equals(sessionKey, mainSessionKey, StringComparison.Ordinal); - return HubSearchCatalog.ContextualDashboardPath( - destination, sessionKey, isMain, _state.GatewaySelf?.ServerVersion); + destination, sessionKey); } private void OpenDashboard(string? path = null) => diff --git a/tests/OpenClaw.Tray.Tests/Presentation/HubSearchCatalogTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/HubSearchCatalogTests.cs index 9ded1e723c..c7c3cdb0ec 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/HubSearchCatalogTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/HubSearchCatalogTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Collections.Immutable; using System.Xml.Linq; +using OpenClaw.Connection; using OpenClawTray.Presentation; using OpenClawTray.Services; @@ -8,6 +9,19 @@ namespace OpenClaw.Tray.Tests.Presentation; public sealed class HubSearchCatalogTests { + [Theory] + [InlineData("config", "/settings/general")] + [InlineData("connection", "/settings/connection")] + [InlineData("systems", "/systems")] + [InlineData("skills", "/skills")] + [InlineData("cron", "/automations")] + [InlineData("usage", "/usage")] + public void SettingsHandoffUsesStableWebRoutes(string tag, string expected) + { + Assert.Equal(expected, HubSearchCatalog.ContextualDashboardPath( + WorkspaceDestination.Settings(tag), null)); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -20,7 +34,7 @@ public void NavigationUsesDestinationNamesAndNativeSymbolGlyphsWithoutChangingLe var commands = HubPageRegistry.BuildCommands(new("main", true, null, [], resources.ToImmutableDictionary())); Assert.Contains(commands, command => command.Title == "Go to Config"); - var items = HubSearchCatalog.Navigation(commands, connected, true, key => resources[key]).ToArray(); + var items = HubSearchCatalog.Navigation(commands, connected, key => resources[key]).ToArray(); Assert.All(items, item => { Assert.DoesNotContain("Go to", item.Title, StringComparison.OrdinalIgnoreCase); @@ -32,12 +46,14 @@ public void NavigationUsesDestinationNamesAndNativeSymbolGlyphsWithoutChangingLe Assert.Equal("Config", Assert.Single(items, item => item.Target.Value == "config").Title); var systems = Assert.Single(items, item => item.Target.Value == "systems"); Assert.Equal("Systems", systems.Title); - Assert.Equal("/settings/devices", systems.DashboardPath); + Assert.Equal("/systems", systems.DashboardPath); Assert.Equal("Automations", Assert.Single(items, item => item.Target.Value == "agent:main:cron").Title); } else { Assert.DoesNotContain(items, item => item.Target.Value is "config" or "systems" or "agent:main:cron"); + Assert.All(items.Where(item => item.Target.Kind == HubSearchTargetKind.Page), + item => Assert.Null(item.DashboardPath)); } Assert.Equal("Connection", Assert.Single(items, item => item.Target.Value == "connection").Title); var models = Assert.Single(items, item => item.Title == "Models"); @@ -57,18 +73,18 @@ public void NavigationUsesDestinationNamesAndNativeSymbolGlyphsWithoutChangingLe } [Theory] - [InlineData("Channels", true, true)] - [InlineData("Go to Channels", true, false)] - [InlineData("Channels", false, false)] + [InlineData("Channels", true)] + [InlineData("Go to Channels", true)] + [InlineData("Channels", false)] public void ChannelsSearchOpensDashboardInsteadOfRetiredNativePage( - string query, bool connected, bool dashboardSupported) + string query, bool connected) { var resources = XDocument.Load(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw")) .Root!.Elements("data").ToImmutableDictionary(element => (string)element.Attribute("name")!, element => element.Element("value")!.Value); var commands = HubPageRegistry.BuildCommands(new("main", false, null, [], resources)); - var items = HubSearchCatalog.Navigation(commands, connected, dashboardSupported, key => resources[key]); + var items = HubSearchCatalog.Navigation(commands, connected, key => resources[key]); var result = Assert.Single(HubSearch.Find(items, query), match => match.Item.Title == "Channels").Item; Assert.Equal(HubSearchGroup.Dashboard, result.Group); @@ -109,22 +125,23 @@ public void NavigationDestinationLabelsExistInEverySupportedLocale() public void InvalidSchemaDoesNotLookLikeASuccessfulEmptyIndex(string payload) { using var json = JsonDocument.Parse(payload); - Assert.Throws(() => HubSearchCatalog.Config(json.RootElement, true, out _)); + Assert.Throws(() => HubSearchCatalog.Config(json.RootElement, out _)); } [Theory] - [InlineData("agent:main:main", "/chat/main")] - [InlineData("agent:work:global", "/chat/work/~key/global")] - [InlineData("agent:work:12345678", "/chat/work/~key/12345678")] - [InlineData("agent:work:dm:user.one", "/chat/work/dm/user%2Eone")] - [InlineData("agent:work:~literal", "/chat/work/~~literal")] - [InlineData("agent:work:.", "/chat/work/~dot")] - [InlineData("agent:work:..", "/chat/work/~dotdot")] - [InlineData("agent:work:hello world", "/chat/work/hello%20world")] - [InlineData("agent:work:caf\u00e9", "/chat/work/caf%C3%A9")] - [InlineData("agent:work:", null)] - [InlineData("unknown", null)] - public void DashboardSessionLinksPreserveExactKeys(string key, string? expected) + [InlineData("agent:main:main", "/chat?session=agent%3Amain%3Amain")] + [InlineData("agent:work:global", "/chat?session=agent%3Awork%3Aglobal")] + [InlineData("agent:work:12345678", "/chat?session=agent%3Awork%3A12345678")] + [InlineData("agent:work:dm:user.one", "/chat?session=agent%3Awork%3Adm%3Auser.one")] + [InlineData("agent:work:~literal", "/chat?session=agent%3Awork%3A~literal")] + [InlineData("agent:work:.", "/chat?session=agent%3Awork%3A.")] + [InlineData("agent:work:..", "/chat?session=agent%3Awork%3A..")] + [InlineData("agent:work:hello world", "/chat?session=agent%3Awork%3Ahello%20world")] + [InlineData("agent:work:caf\u00e9", "/chat?session=agent%3Awork%3Acaf%C3%A9")] + [InlineData("global", "/chat?session=global")] + [InlineData("", null)] + [InlineData(null, null)] + public void DashboardSessionLinksPreserveExactKeys(string? key, string? expected) { Assert.Equal(expected, HubSearchCatalog.SessionPath(key)); } @@ -132,40 +149,71 @@ public void DashboardSessionLinksPreserveExactKeys(string key, string? expected) [Fact] public void DashboardMainSessionUsesAuthoritativeIdentityRatherThanAssumingTheMainSuffix() { - Assert.Equal("/chat/work", HubSearchCatalog.SessionPath("agent:work:custom-home", isMain: true)); - Assert.Equal("/chat/work/~key/main", HubSearchCatalog.SessionPath("agent:work:main", isMain: false)); + Assert.Equal("/chat?session=agent%3Awork%3Acustom-home", HubSearchCatalog.SessionPath("agent:work:custom-home")); + Assert.Equal("/chat?session=agent%3Awork%3Amain", HubSearchCatalog.SessionPath("agent:work:main")); } [Fact] public void ContextualDashboardPath_UsesCurrentChatSessionWhenHomeHistoryHasNoSession() { - Assert.Equal("/chat/work/~key/12345678", HubSearchCatalog.ContextualDashboardPath( - new(WorkspacePageId.Home), "agent:work:12345678", isMain: false, "2026.10.9")); + Assert.Equal("/chat?session=agent%3Awork%3A12345678", HubSearchCatalog.ContextualDashboardPath( + new(WorkspacePageId.Home), "agent:work:12345678")); + } + + [Theory] + [InlineData("agent:work:dashboard:12345678-1234-1234-1234-123456789abc", + "/chat?session=agent%3Awork%3Adashboard%3A12345678-1234-1234-1234-123456789abc")] + [InlineData("agent:work:main", "/chat?session=agent%3Awork%3Amain")] + [InlineData("agent:work:custom-home", "/chat?session=agent%3Awork%3Acustom-home")] + [InlineData("global", "/chat?session=global")] + [InlineData("agent:work:a b/c&d#e?f+%", + "/chat?session=agent%3Awork%3Aa%20b%2Fc%26d%23e%3Ff%2B%25")] + public async Task ContextualDashboardPath_ReleasedChatReachesBrowserWithExactSession( + string sessionKey, string expectedPath) + { + string? launched = null; + var launcher = new GatewayDashboardLauncher(() => true, + () => new("wss://gateway.example/control/", "shared secret", false, + CredentialResolver.SourceSharedGatewayToken), + url => { launched = url; return Task.FromResult(true); }, + () => throw new InvalidOperationException("Unexpected failure")); + var path = HubSearchCatalog.ContextualDashboardPath( + new(WorkspacePageId.Home, sessionKey), "agent:main:main"); + + Assert.True(await launcher.OpenAsync(path)); + Assert.Equal($"https://gateway.example/control{expectedPath}#token=shared%20secret", launched); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ContextualDashboardPath_MissingChatSelectionStillOpensHome(string? sessionKey) + { + Assert.Null(HubSearchCatalog.ContextualDashboardPath( + new(WorkspacePageId.Home), sessionKey)); } [Fact] public void ContextualDashboardPath_PrefersVisibleSettingsOverRetainedChat() { Assert.Equal("/usage", HubSearchCatalog.ContextualDashboardPath( - WorkspaceDestination.Settings("usage"), "agent:work:12345678", isMain: false, "2026.10.9")); + WorkspaceDestination.Settings("usage"), "agent:work:12345678")); } [Fact] public void ContextualDashboardPath_UsesExplicitSessionAndFallsBackWithoutVerifiedCounterpart() { - Assert.Equal("/chat/work/~key/main", HubSearchCatalog.ContextualDashboardPath( - new(WorkspacePageId.Home, "agent:work:main"), "agent:main:main", - isMain: false, "2026.10.9")); - Assert.Null(HubSearchCatalog.ContextualDashboardPath( - WorkspaceDestination.Settings("local-ai"), null, null, "2026.10.9")); + Assert.Equal("/chat?session=agent%3Awork%3Amain", HubSearchCatalog.ContextualDashboardPath( + new(WorkspacePageId.Home, "agent:work:main"), "agent:main:main")); Assert.Null(HubSearchCatalog.ContextualDashboardPath( - WorkspaceDestination.Settings("agent:research"), null, null, "2026.10.9")); + WorkspaceDestination.Settings("local-ai"), null)); Assert.Null(HubSearchCatalog.ContextualDashboardPath( - WorkspaceDestination.Settings("config"), null, null, "2026.11.1")); + WorkspaceDestination.Settings("agent:research"), null)); Assert.Null(HubSearchCatalog.ContextualDashboardPath( - new(WorkspacePageId.Home), null, null, "2026.10.9")); + new(WorkspacePageId.Home), null)); Assert.Null(HubSearchCatalog.ContextualDashboardPath( - new(WorkspacePageId.Notifications), "agent:work:12345678", isMain: false, "2026.10.9")); + new(WorkspacePageId.Notifications), "agent:work:12345678")); } [Fact] @@ -177,7 +225,7 @@ public void ConfigurationSearchExcludesSensitiveSubtreesAndNeverIndexesValues() "uiHints":{"secret":{"sensitive":true}}, "value":{"heartbeat":"private-current-value"}} """); - var item = Assert.Single(HubSearchCatalog.Config(json.RootElement, true, out var partial)); + var item = Assert.Single(HubSearchCatalog.Config(json.RootElement, out var partial)); Assert.False(partial); Assert.Equal("Heartbeat", item.Title); Assert.Equal("heartbeat", item.Target.Focus); @@ -188,59 +236,34 @@ public void ConfigurationSearchExcludesSensitiveSubtreesAndNeverIndexesValues() public void ScopedSkillsKeepAgentIdentityAndOpenTheDashboardRatherThanARetiredNativePage() { using var json = JsonDocument.Parse("""{"skills":[{"name":"Weather","skillKey":"weather"}]}"""); - var a = Assert.Single(HubSearchCatalog.ParseCatalog("skills.status", json.RootElement, "alpha", true)); - var b = Assert.Single(HubSearchCatalog.ParseCatalog("skills.status", json.RootElement, "beta", true)); + var a = Assert.Single(HubSearchCatalog.ParseCatalog("skills.status", json.RootElement, "alpha")); + var b = Assert.Single(HubSearchCatalog.ParseCatalog("skills.status", json.RootElement, "beta")); Assert.NotEqual(a.Id, b.Id); Assert.Equal(new(HubSearchTargetKind.Dashboard, "/skills"), a.Target); Assert.Equal(new(HubSearchTargetKind.Dashboard, "/skills"), b.Target); - var fallback = Assert.Single(HubSearchCatalog.ParseCatalog("skills.status", json.RootElement, "alpha", false)); - Assert.Equal(new(HubSearchTargetKind.Dashboard, ""), fallback.Target); } [Fact] - public void ReleasedSkillsRouteDoesNotEnableOtherUnverifiedDashboardRoutes() + public void SuccessfulCatalogReadsOfferTheirWebDestinations() { - const string release = "2026.11.1"; - Assert.False(HubSearchCatalog.SupportsDashboard(release)); - Assert.True(HubSearchCatalog.SupportsSkillsDashboard(release)); using var json = JsonDocument.Parse("""{"skills":[{"name":"Weather","skillKey":"weather"}],"models":[{"id":"one"}]}"""); - var skill = Assert.Single(HubSearchCatalog.ParseCatalog("skills.status", json.RootElement, "main", - HubSearchCatalog.SupportsDashboard(release), HubSearchCatalog.SupportsSkillsDashboard(release))); + var skill = Assert.Single(HubSearchCatalog.ParseCatalog("skills.status", json.RootElement, "main")); Assert.Equal(new(HubSearchTargetKind.Dashboard, "/skills"), skill.Target); Assert.Equal("/skills", skill.DashboardPath); - Assert.Empty(HubSearchCatalog.ParseCatalog("models.list", json.RootElement, "main", - HubSearchCatalog.SupportsDashboard(release), HubSearchCatalog.SupportsSkillsDashboard(release))); - Assert.Null(HubSearchCatalog.Counterpart("config", HubSearchCatalog.SupportsDashboard(release))); + var model = Assert.Single(HubSearchCatalog.ParseCatalog("models.list", json.RootElement, "main")); + Assert.Equal(new(HubSearchTargetKind.Dashboard, "/settings/model-providers"), model.Target); + Assert.Equal("/settings/model-providers", model.DashboardPath); } - [Theory] - [InlineData("2026.10.9", true)] - [InlineData("2026.11.1", true)] - [InlineData("2026.11.1-beta.1", false)] - [InlineData("2026.11.2", false)] - [InlineData(null, false)] - public void SkillsRoutingOnlyUsesSpecificallyVerifiedReleases(string? version, bool expected) => - Assert.Equal(expected, HubSearchCatalog.SupportsSkillsDashboard(version)); - [Fact] public void ModelsWithTheSameIdAcrossProvidersAreDistinctNavigationTargets() { using var json = JsonDocument.Parse("""{"models":[{"id":"one","provider":"a","name":""},{"id":"one","provider":"b","name":" "}]}"""); - var items = HubSearchCatalog.ParseCatalog("models.list", json.RootElement, "main", true); + var items = HubSearchCatalog.ParseCatalog("models.list", json.RootElement, "main"); Assert.Equal(2, items.Select(item => item.Id).Distinct().Count()); Assert.All(items, item => Assert.Equal("one", item.Title)); Assert.All(items, item => Assert.Equal( new(HubSearchTargetKind.Dashboard, "/settings/model-providers"), item.Target)); } - [Theory] - [InlineData("2026.10.9", true)] - [InlineData("2026.10.9-1", true)] - [InlineData("2026.10.9-beta.1", false)] - [InlineData("2026.9.1", false)] - [InlineData(null, false)] - public void UnverifiedDashboardVersionsDoNotProduceGuessedSpecificRoutes(string? version, bool expected) - { - Assert.Equal(expected, HubSearchCatalog.SupportsDashboard(version)); - } } diff --git a/tests/OpenClaw.Tray.Tests/Presentation/HubSearchControllerTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/HubSearchControllerTests.cs index 431f6ab6ad..9bd03943c6 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/HubSearchControllerTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/HubSearchControllerTests.cs @@ -55,14 +55,14 @@ internal Task ObservePost(Action complete) } [Fact] - public void ReleasedSkillsRouteFlowsThroughTheControllerWithoutEnablingTheWholeDashboardCatalog() + public void AdvertisedSkillsCatalogOffersItsWebRouteWithoutProbingUnadvertisedCatalogs() { var client = DispatchProxy.Create(); var proxy = (GatewayProxy)client; proxy.Methods = ["skills.status"]; using var json = JsonDocument.Parse("""{"skills":[{"name":"Weather","skillKey":"weather"}]}"""); proxy.Catalog = json.RootElement; - using var controller = new HubSearchController(() => new(client, "main", "2026.11.1", [], []), + using var controller = new HubSearchController(() => new(client, "main", [], []), new InlineDispatcher(), _ => { }, TimeSpan.Zero); controller.Open(); controller.SetQuery("Weather"); @@ -70,7 +70,26 @@ public void ReleasedSkillsRouteFlowsThroughTheControllerWithoutEnablingTheWholeD var skill = Assert.Single(controller.Results).Item; Assert.Equal(new(HubSearchTargetKind.Dashboard, "/skills"), skill.Target); Assert.Equal("/skills", skill.DashboardPath); - Assert.True(controller.HasDashboardCompatibilityWarning); + Assert.Contains(controller.Statuses, status => status.Group == HubSearchGroup.Models && status.State == "Unavailable"); + Assert.Contains(controller.Statuses, status => status.Group == HubSearchGroup.Settings && status.State == "Unavailable"); + } + + [Fact] + public void AdvertisedModelsRemainSearchableWithoutReleaseMetadata() + { + var client = DispatchProxy.Create(); + var proxy = (GatewayProxy)client; + proxy.Methods = ["models.list"]; + using var json = JsonDocument.Parse("""{"models":[{"id":"weather-model","provider":"local"}]}"""); + proxy.Catalog = json.RootElement; + using var controller = Create(client); + controller.Open(); + controller.SetQuery("weather-model"); + + var model = Assert.Single(controller.Results).Item; + Assert.Equal(new(HubSearchTargetKind.Dashboard, "/settings/model-providers"), model.Target); + Assert.DoesNotContain(controller.Statuses, + status => status.Group == HubSearchGroup.Models && status.State == "Unavailable"); } [Fact] @@ -161,7 +180,7 @@ public void RemoteSearchIsMarkedPendingDuringDebounceRatherThanLookingComplete() { var client = DispatchProxy.Create(); var proxy = (GatewayProxy)client; - using var controller = new HubSearchController(() => new(client, "main", "2026.10.9", [], []), + using var controller = new HubSearchController(() => new(client, "main", [], []), new InlineDispatcher(), _ => { }, TimeSpan.FromSeconds(1)); controller.Open(); controller.SetQuery("camera"); @@ -209,7 +228,7 @@ public async Task SessionActivityRefreshKeepsPendingReadsAndRemoteResults(bool c var dispatcher = new InlineDispatcher(); IReadOnlyList local = []; using var controller = new HubSearchController( - () => new(client, "main", "2026.10.9", local, []), dispatcher, _ => { }, TimeSpan.Zero); + () => new(client, "main", local, []), dispatcher, _ => { }, TimeSpan.Zero); controller.Open(); controller.SetQuery("lighthouse"); var pending = proxy.Queries["lighthouse"]; @@ -299,7 +318,7 @@ public async Task ExplicitRetryRestartsFailedReadsButContextChangesStillFenceOld private static HubSearchController Create(IOperatorGatewayClient client, Action? report = null, InlineDispatcher? dispatcher = null) => - new(() => new(client, "main", "2026.10.9", [], []), dispatcher ?? new InlineDispatcher(), report ?? (_ => { }), TimeSpan.Zero); + new(() => new(client, "main", [], []), dispatcher ?? new InlineDispatcher(), report ?? (_ => { }), TimeSpan.Zero); private static GatewaySearchResult Hit(string id) => new([new() { Key = $"agent:main:{id}", Label = "Uncached conversation" }], diff --git a/tests/OpenClaw.Tray.Tests/Presentation/HubSearchTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/HubSearchTests.cs index af85f66a79..339411fcab 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/HubSearchTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/HubSearchTests.cs @@ -74,6 +74,7 @@ public void SessionTargetsPreserveIdentityAndExcludeArchivedAndBackgroundRows() var item = Assert.Single(items); Assert.Equal("My chat", item.Title); Assert.Equal(new(HubSearchTargetKind.Session, "agent:main:one"), item.Target); + Assert.Equal("/chat?session=agent%3Amain%3Aone", item.DashboardPath); } private static HubSearchItem Item(string id, string title) => From e80146404b9205c3aa3540f64fba70024b2f1f24 Mon Sep 17 00:00:00 2001 From: "bakudies@microsoft.com" Date: Thu, 12 Nov 2026 00:18:09 -0800 Subject: [PATCH 2/3] fix: drop orphaned search compatibility copy and clarify Systems route doc Remove Search_Compatibility from all locales now that its last reader is gone, and state that /systems matches the native Open in web action only when the Gateway reports environments; older Gateways open their default view for routes they lack. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/CONNECTION_ARCHITECTURE.md | 9 +++++---- src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw | 1 - src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw | 1 - src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw | 1 - src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw | 1 - src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw | 1 - src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw | 1 - 7 files changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index 30e20822ca..baf1c579dd 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -266,10 +266,11 @@ Settings and search web destinations also use established routes, not Gateway release allowlists. Their route contracts are recorded in the [older route table](https://github.com/openclaw/openclaw/blob/3a9d69db306cd7f081e06254cb89c4bcc14a7107/ui/src/app-route-paths.ts) and [current released route table](https://github.com/openclaw/openclaw/blob/4ffdd2047d192dd65554de3c8c46c0e0197c6e2a/ui/src/app-route-paths.ts). -Systems links use `/systems`, matching the native Systems page's Open in web -action, not the separate Devices settings page. The current released route table -defines `/systems`; the older table does not. Gateways without that page cannot -provide an equivalent Systems destination. +Systems links use `/systems`, the same route the native Systems page's Open in +web action uses when the Gateway reports environments, not the separate Devices +settings page. The current released route table defines `/systems`; the older +table does not. Gateways without that page cannot provide an equivalent Systems +destination, and their web app opens its default view for any route it lacks. These navigation contracts are not capability negotiation. Search still gates remote reads on advertised methods and operator permissions, fences stale connection/profile results, and limits admin-only destinations to administrators. diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 2289fc967a..932c31522d 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -7711,7 +7711,6 @@ After enrolling, return to Settings > Windows Update and check for updates. I Portals Worktrees The requested item is no longer available here. - Some Dashboard destinations are unavailable for this Gateway version. {0}: choose an assistant first. Upload cleanup is blocked Review folder access and private-state permissions. Old uploads may remain until cleanup is restored. Do not retry completed uploads automatically. diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index cdff406b19..4a299ee381 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -7643,7 +7643,6 @@ Après votre inscription, revenez dans Paramètres > Windows Update et recher Portails Arbres de travail L'élément demandé n'est plus disponible ici. - Certaines destinations du tableau de bord ne sont pas disponibles pour cette version de la passerelle. {0} : choisissez d'abord un assistant. Le nettoyage des fichiers téléversés est bloqué Vérifiez l'accès aux dossiers et les autorisations des données privées. Les anciens fichiers peuvent subsister jusqu'au rétablissement du nettoyage. Ne relancez pas automatiquement les téléversements terminés. diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 9f66eea0bd..6d552c00c1 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -7644,7 +7644,6 @@ Ga na inschrijving terug naar Instellingen > Windows Update en controleer op Portalen Werkbomen Het gevraagde item is hier niet meer beschikbaar. - Sommige dashboardbestemmingen zijn niet beschikbaar voor deze gatewayversie. {0}: kies eerst een assistent. Uploads opruimen is geblokkeerd Controleer de maptoegang en machtigingen voor privégegevens. Oude uploads kunnen blijven staan totdat opruimen weer mogelijk is. Probeer voltooide uploads niet automatisch opnieuw. diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index 4b5349d6c4..59a525da29 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -7696,7 +7696,6 @@ Após a inscrição, volte para Configurações > Windows Update e verifique Portais Árvores de trabalho O item solicitado não está mais disponível aqui. - Alguns destinos do painel não estão disponíveis para esta versão do Gateway. {0}: escolha um assistente primeiro. A limpeza de uploads está bloqueada Revise o acesso às pastas e as permissões dos dados privados. Uploads antigos podem permanecer até que a limpeza seja restabelecida. Não repita automaticamente uploads concluídos. diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index a86c061deb..53854f294f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -7643,7 +7643,6 @@ 门户 工作树 请求的项目已不可用。 - 此网关版本不支持部分控制面板页面。 {0}:请先选择助手。 上传文件清理受阻 请检查文件夹访问权限和私有状态数据的权限。恢复清理前,旧上传文件可能会继续保留。请勿自动重试已完成的上传。 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index b6da7338ba..9457cd95a5 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -7643,7 +7643,6 @@ 入口網站 工作樹 要求的項目已無法使用。 - 此閘道版本不支援部分控制面板頁面。 {0}:請先選擇助理。 上傳檔案清理受阻 請檢查資料夾存取權和私人狀態資料的權限。恢復清理前,舊上傳檔案可能會繼續保留。請勿自動重試已完成的上傳。 From 5d97645888733f526056d92004f7638343b5add4 Mon Sep 17 00:00:00 2001 From: "bakudies@microsoft.com" Date: Thu, 12 Nov 2026 03:52:31 -0800 Subject: [PATCH 3/3] fix(tests): wait for SettingsSearch before invoking it The Settings chrome proof invoked SettingsSearch with an immediate UIA lookup and failed intermittently in Dark theme. Use the existing InvokeWhenEnabledAsync readiness helper, matching the pending #2048 change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs b/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs index d87df3a27e..1d9bcdc98a 100644 --- a/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs +++ b/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs @@ -417,7 +417,7 @@ async Task WaitForSearchInputAsync() return readyInput; } - Invoke(Find(root, "SettingsSearch")); + await InvokeWhenEnabledAsync(root, "SettingsSearch"); var input = await WaitForSearchInputAsync(); System.Windows.Forms.SendKeys.SendWait("Go to General"); await WaitUntilAsync(() => ((ValuePattern)input.GetCurrentPattern(ValuePattern.Pattern)).Current.Value == "Go to General");