Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
21 changes: 20 additions & 1 deletion docs/CONNECTION_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,26 @@ 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=<encoded-full-session-key>`
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`, 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.
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.
Expand Down
3 changes: 0 additions & 3 deletions src/OpenClaw.Tray.WinUI/Controls/SearchHost.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/OpenClaw.Tray.WinUI/Presentation/HubSearch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ private static int Rank(HubSearchItem item, string query)
}

internal static IEnumerable<HubSearchItem> Sessions(
IEnumerable<SessionInfo> source, bool serverMatch = false, bool dashboardSupported = false)
IEnumerable<SessionInfo> source, bool serverMatch = false)
{
var sessions = source.Where(session => !session.Archived &&
!SessionDisplayResolver.IsBackground(session)).ToArray();
Expand All @@ -71,7 +71,7 @@ internal static IEnumerable<HubSearchItem> 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();
}

Expand Down
71 changes: 16 additions & 55 deletions src/OpenClaw.Tray.WinUI/Presentation/HubSearchCatalog.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using OpenClaw.Shared;

namespace OpenClawTray.Presentation;

Expand All @@ -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")
];

Expand All @@ -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(':');
Expand All @@ -72,29 +39,25 @@ 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)
return null;
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<HubSearchItem> Navigation(
IEnumerable<HubCommand> commands, bool connected, bool dashboardSupported, Func<string, string> localize)
IEnumerable<HubCommand> commands, bool connected, Func<string, string> localize)
{
var destinations = commands.GroupBy(command =>
command.Action is { Kind: HubCommandActionKind.Navigate, Value: { } tag }
Expand All @@ -117,7 +80,7 @@ internal static IEnumerable<HubSearchItem> 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))
{
Expand All @@ -129,7 +92,7 @@ internal static IEnumerable<HubSearchItem> Navigation(
}

internal static IReadOnlyList<HubSearchItem> ParseCatalog(
string method, JsonElement payload, string agentId, bool dashboardSupported, bool skillsDashboardSupported = false)
string method, JsonElement payload, string agentId)
{
var (property, group) = method switch
{
Expand All @@ -143,7 +106,6 @@ internal static IReadOnlyList<HubSearchItem> ParseCatalog(
rows.ValueKind != JsonValueKind.Array)
throw new InvalidDataException("Invalid search catalog response.");
var items = new List<HubSearchItem>();
var skillsSupported = dashboardSupported || skillsDashboardSupported;
foreach (var row in rows.EnumerateArray())
{
var id = Text(row, group == HubSearchGroup.Skills ? "skillKey" : "id");
Expand All @@ -155,11 +117,10 @@ internal static IReadOnlyList<HubSearchItem> 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
Expand All @@ -173,12 +134,12 @@ internal static IReadOnlyList<HubSearchItem> 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<HubSearchItem> Config(JsonElement payload, bool dashboardSupported, out bool partial)
internal static IReadOnlyList<HubSearchItem> Config(JsonElement payload, out bool partial)
{
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty("schema", out var schema) ||
schema.ValueKind != JsonValueKind.Object)
Expand All @@ -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);
Expand Down
Loading
Loading