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
20 changes: 15 additions & 5 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands
internal VoiceService? VoiceService => _nodeService?.VoiceService ?? _standaloneVoiceService;
/// <summary>The full device ID of the local node service (if running).</summary>
internal string? NodeFullDeviceId => _nodeService?.FullDeviceId;
/// <summary>Live node service instance used by settings surfaces for MCP status.</summary>
internal NodeService? ActiveNodeService => _nodeService;

/// <summary>
/// Session key that the chat surface should select on its next mount.
Expand Down Expand Up @@ -644,7 +646,7 @@ _dispatcherQueue is null
credentialResolver, clientFactory, _gatewayRegistry, appLogger,
identityStore: new DeviceIdentityFileStore(appLogger),
nodeConnector: nodeConnector,
isNodeEnabled: ShouldInitializeNodeService,
isNodeEnabled: IsGatewayNodeEnabled,
diagnostics: diagnostics,
tunnelManager: _sshTunnelService);
_connectionManager.OperatorClientChanged += OnOperatorClientChanged;
Expand Down Expand Up @@ -1541,7 +1543,7 @@ record = SyncGatewayBrowserProxyForward(record);
if (credential == null)
{
var nodeCredential = ResolveStartupNodeCredential(record, resolver, identityDir);
if (nodeCredential != null && ShouldInitializeNodeService())
if (nodeCredential != null && IsGatewayNodeEnabled())
{
Logger.Info(
$"Connecting node-only gateway during {context}: {record.Url} ({nodeCredential.Source})");
Expand All @@ -1562,6 +1564,8 @@ record = SyncGatewayBrowserProxyForward(record);
ObserveBackgroundFault(
_connectionManager.ConnectAsync(record.Id),
$"[App] Startup gateway connect failed during {context}");
if (!IsGatewayNodeEnabled())
TryStartLocalMcpOnlyNode();
return true;
}

Expand Down Expand Up @@ -1891,6 +1895,12 @@ private bool ShouldInitializeNodeService()
return _settings?.EnableNodeMode == true || _settings?.EnableMcpServer == true;
}

/// <summary>True when this PC should connect as a gateway node.</summary>
private bool IsGatewayNodeEnabled()
{
return _settings?.EnableNodeMode == true;
}

/// <summary>
/// Ensures a WSL keepalive process is running for the local gateway distro
/// so the WSL2 VM stays up even after the tray exits.
Expand Down Expand Up @@ -2672,8 +2682,8 @@ private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus s
if (status == ConnectionStatus.Connected)
{
_ = RunHealthCheckAsync();
// For local gateways, the NodeConnector is suppressed because NodeService
// owns the identity. Connect the NodeService directly after operator connects.
// Gateway-node mode connects the NodeService after operator auth; MCP-only
// mode keeps serving local tools and must not escalate into node pairing.
_ = TryConnectLocalNodeServiceAsync();
}
}
Expand All @@ -2686,7 +2696,7 @@ private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus s
/// </summary>
private async Task TryConnectLocalNodeServiceAsync()
{
if (_connectionManager == null)
if (_connectionManager == null || !IsGatewayNodeEnabled())
return;

Logger.Info("[App] Auto-connecting local NodeService via EnsureNodeConnectedAsync");
Expand Down
131 changes: 84 additions & 47 deletions src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -321,20 +321,22 @@ private void ApplyPlan(ConnectionPagePlan plan)
bool isRecovery = plan.Mode == ConnectionPageMode.Recovery;
bool isAdding = plan.Mode == ConnectionPageMode.AddGateway;

// Operator + Node cards only when we actually have an active operator
// connection AND we're not in a focused sub-view (Welcome / Recovery /
// AddGateway). Recovery's help block carries the action; the role
// cards would just compete with it.
// Operator + Node cards are normally tied to an active operator session.
// Local MCP-only mode has no operator session, but still needs the Node
// card so users can see that MCP is serving local tools.
bool hasOperatorSession = _lastSnapshot.OverallState is
OverallConnectionState.Connected
or OverallConnectionState.Ready
or OverallConnectionState.Degraded
or OverallConnectionState.Connecting
or OverallConnectionState.PairingRequired
or OverallConnectionState.Disconnecting;
bool showRoles = hasOperatorSession && !isWelcome && !isAdding && !isRecovery;
var hasStandaloneNodeCard = plan.NodeCard != NodeCardState.Hidden && !hasOperatorSession;
bool showRoles = (hasOperatorSession || hasStandaloneNodeCard) && !isAdding && !isRecovery;
CockpitPanel.Visibility = showRoles ? Visibility.Visible : Visibility.Collapsed;
OperatorSection.Visibility = showRoles ? Visibility.Visible : Visibility.Collapsed;
OperatorSection.Visibility = showRoles && plan.OperatorCard != OperatorCardState.Hidden
? Visibility.Visible
: Visibility.Collapsed;

// Bottom section: exactly one of these is visible
// • SavedGatewaysCard — Cockpit / Recovery (always present when registry has items)
Expand Down Expand Up @@ -814,6 +816,14 @@ or NodeCardState.OnNodeRateLimited
Helpers.FluentIconCatalog.StatusOk,
"SystemFillColorSuccessBrush",
capCount == 1 ? LocalizationHelper.GetString("ConnectionPage_NodeActiveOneCapability") : string.Format(LocalizationHelper.GetString("ConnectionPage_NodeActiveCapabilities"), capCount)),
NodeCardState.OnNodeConnecting => (
Helpers.FluentIconCatalog.Sync,
"SystemFillColorCautionBrush",
LocalizationHelper.GetString("ConnectionPage_NodeStarting")),
NodeCardState.OffMcpOnly => (
Helpers.FluentIconCatalog.Terminal,
"SystemFillColorAttentionBrush",
LocalizationHelper.GetString("ConnectionPage_NodeMcpOnly")),
NodeCardState.OnPermissionsIncomplete => (
Helpers.FluentIconCatalog.StatusWarn,
"SystemFillColorCautionBrush",
Expand Down Expand Up @@ -858,47 +868,72 @@ or NodeCardState.OnNodeRateLimited
? ResolveBrush("SystemFillColorCriticalBrush")
: ResolveBrush("TextFillColorPrimaryBrush");

// The gateway's node-list contract owns this boundary. Pending
// declarations are visible for approval context but never counted or
// labeled as approved/effective.
bool showSurfaces = settings != null && plan.NodeCard != NodeCardState.Off
&& plan.NodeCard != NodeCardState.Hidden;
NodeCapabilityText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
NodeCommandText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
NodePermissionText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
if (showSurfaces)
{
NodeCapabilityText.Text = BuildNodeSurfaceListString(
"ConnectionPage_NodeEffectiveCapabilities",
plan.NodeEffectiveCapabilities);
NodeCommandText.Text = BuildNodeSurfaceListString(
"ConnectionPage_NodeEffectiveCommands",
plan.NodeEffectiveCommands);
NodePermissionText.Text = BuildNodePermissionListString(
"ConnectionPage_NodeEffectivePermissions",
plan.NodeEffectivePermissions);
}

var showPendingDeclarations = showSurfaces &&
(plan.NodeApprovalState is GatewayNodeApprovalState.PendingApproval or
GatewayNodeApprovalState.PendingReapproval ||
plan.NodePendingDeclaredCapabilities.Count > 0 ||
plan.NodePendingDeclaredCommands.Count > 0 ||
plan.NodePendingDeclaredPermissions.Count > 0);
NodePendingDeclarationsPanel.Visibility = showPendingDeclarations
? Visibility.Visible
: Visibility.Collapsed;
if (showPendingDeclarations)
if (plan.NodeCard == NodeCardState.OffMcpOnly)
{
NodeCapabilityText.Visibility = Visibility.Visible;
NodeCapabilityText.Text = LocalizationHelper.Format(
"ConnectionPage_NodeMcpOnlyReachable", NodeService.McpServerUrl);
NodeCommandText.Visibility = Visibility.Collapsed;
NodePermissionText.Visibility = Visibility.Collapsed;
NodePendingDeclarationsPanel.Visibility = Visibility.Collapsed;

var mcpError = CurrentApp.ActiveNodeService?.McpStartupError;
if (!string.IsNullOrEmpty(mcpError))
{
NodeStatusIcon.Glyph = Helpers.FluentIconCatalog.StatusErr;
NodeStatusIcon.Foreground = ResolveBrush("SystemFillColorCriticalBrush");
NodeStatusText.Text = LocalizationHelper.GetString("ConnectionPage_NodeMcpError");
NodeStatusText.Foreground = ResolveBrush("SystemFillColorCriticalBrush");
NodeCapabilityText.Visibility = Visibility.Collapsed;
NodeBodyText.Text = mcpError;
NodeBodyText.Foreground = ResolveBrush("SystemFillColorCriticalBrush");
NodeBodyText.Visibility = Visibility.Visible;
}
}
else
{
NodePendingCapabilityText.Text = BuildNodeSurfaceListString(
"ConnectionPage_NodePendingDeclaredCapabilities",
plan.NodePendingDeclaredCapabilities);
NodePendingCommandText.Text = BuildNodeSurfaceListString(
"ConnectionPage_NodePendingDeclaredCommands",
plan.NodePendingDeclaredCommands);
NodePendingPermissionText.Text = BuildNodePermissionListString(
"ConnectionPage_NodePendingDeclaredPermissions",
plan.NodePendingDeclaredPermissions);
// Pending declarations are visible for approval context but never
// counted as the active node contract.
bool showSurfaces = settings != null && plan.NodeCard != NodeCardState.Off
&& plan.NodeCard != NodeCardState.Hidden
&& plan.NodeCard != NodeCardState.OnNodeConnecting;
NodeCapabilityText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
NodeCommandText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
NodePermissionText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
if (showSurfaces)
{
NodeCapabilityText.Text = BuildNodeSurfaceListString(
"ConnectionPage_NodeEffectiveCapabilities",
plan.NodeEffectiveCapabilities);
NodeCommandText.Text = BuildNodeSurfaceListString(
"ConnectionPage_NodeEffectiveCommands",
plan.NodeEffectiveCommands);
NodePermissionText.Text = BuildNodePermissionListString(
"ConnectionPage_NodeEffectivePermissions",
plan.NodeEffectivePermissions);
}

var showPendingDeclarations = showSurfaces &&
(plan.NodeApprovalState is GatewayNodeApprovalState.PendingApproval or
GatewayNodeApprovalState.PendingReapproval ||
plan.NodePendingDeclaredCapabilities.Count > 0 ||
plan.NodePendingDeclaredCommands.Count > 0 ||
plan.NodePendingDeclaredPermissions.Count > 0);
NodePendingDeclarationsPanel.Visibility = showPendingDeclarations
? Visibility.Visible
: Visibility.Collapsed;
if (showPendingDeclarations)
{
NodePendingCapabilityText.Text = BuildNodeSurfaceListString(
"ConnectionPage_NodePendingDeclaredCapabilities",
plan.NodePendingDeclaredCapabilities);
NodePendingCommandText.Text = BuildNodeSurfaceListString(
"ConnectionPage_NodePendingDeclaredCommands",
plan.NodePendingDeclaredCommands);
NodePendingPermissionText.Text = BuildNodePermissionListString(
"ConnectionPage_NodePendingDeclaredPermissions",
plan.NodePendingDeclaredPermissions);
}
}

// Sync toggle from current settings (suppress event)
Expand Down Expand Up @@ -1102,7 +1137,9 @@ private List<Border> BuildCapabilityChips(IReadOnlyList<string>? capabilities, N
{
var chips = new List<Border>();
if (capabilities == null || capabilities.Count == 0) return chips;
if (state == NodeCardState.Off || state == NodeCardState.Hidden) return chips;
if (state == NodeCardState.Off || state == NodeCardState.Hidden
|| state == NodeCardState.OffMcpOnly || state == NodeCardState.OnNodeConnecting)
return chips;

void Add(string label, bool enabled, bool warn = false, bool error = false)
{
Expand Down
36 changes: 29 additions & 7 deletions src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ internal enum NodeCardState
{
Hidden,
Off,
/// <summary>Gateway node is off, local MCP server is enabled.</summary>
OffMcpOnly,
OnHealthy,
/// <summary>Node role is connecting / starting up (not yet ready).</summary>
OnNodeConnecting,
OnPermissionsIncomplete,
OnNodeApprovalRequired,
OnNodeReapprovalRequired,
Expand Down Expand Up @@ -220,7 +224,7 @@ private static ConnectionPagePlan BuildDerived(
// ─── Derived layout ───
return snap.OverallState switch
{
OverallConnectionState.Idle => BuildIdle(savedGatewayCount, activeRecord),
OverallConnectionState.Idle => BuildIdle(savedGatewayCount, activeRecord, settings),

OverallConnectionState.Connecting => BuildCockpitConnecting(snap, activeRecord, displayName),

Expand Down Expand Up @@ -249,16 +253,20 @@ private static ConnectionPagePlan BuildDerived(
ActiveGatewayHasSshTunnel = activeRecord?.SshTunnel != null,
},

_ => BuildIdle(savedGatewayCount, activeRecord),
_ => BuildIdle(savedGatewayCount, activeRecord, settings),
};
}

// ───────────────────────────────────────────────────────────────────
// Mode builders
// ───────────────────────────────────────────────────────────────────

private static ConnectionPagePlan BuildIdle(int savedCount, GatewayRecord? activeRecord)
private static ConnectionPagePlan BuildIdle(
int savedCount,
GatewayRecord? activeRecord,
SettingsManager? settings)
{
var idleNodeCard = BuildIdleNodeCardState(settings);
if (savedCount == 0)
{
return new ConnectionPagePlan
Expand All @@ -268,18 +276,20 @@ private static ConnectionPagePlan BuildIdle(int savedCount, GatewayRecord? activ
StripAccent = ConnectionAccent.Neutral,
StripHeadline = "No gateway yet",
StripSub = "Add a gateway to get started.",
NodeCard = idleNodeCard,
};
}

// Saved gateways exist but none active — drop straight into Cockpit
// (Operator/Node panels hide themselves because OperatorCardState=Hidden).
// (role panels hide themselves unless local MCP-only status is visible).
return new ConnectionPagePlan
{
Mode = ConnectionPageMode.Cockpit,
StripGlyph = OpenClawTray.Helpers.FluentIconCatalog.System,
StripAccent = ConnectionAccent.Neutral,
StripHeadline = "Not connected",
StripSub = "Pick a gateway below, or add a new one.",
NodeCard = idleNodeCard,
RelevantGatewayId = activeRecord?.Id,
};
}
Expand Down Expand Up @@ -617,7 +627,8 @@ GatewayNodeApprovalState.PendingApproval or
var nodeCardAllowsTrustOverride = plan.NodeCard is
NodeCardState.OnHealthy or
NodeCardState.OnPermissionsIncomplete or
NodeCardState.OnNodePairingRequired ||
NodeCardState.OnNodePairingRequired or
NodeCardState.OnNodeConnecting ||
nodeConnectingAllowsTrustOverride;
// Authoritative node-list trust can override any non-device-pair card.
// Snapshot fallback is narrower: Unknown stays on discovery-only pairing UI.
Expand Down Expand Up @@ -685,14 +696,16 @@ NodeCardState.OnPermissionsIncomplete or
private static NodeCardState BuildNodeCardState(GatewayConnectionSnapshot snap, SettingsManager? settings)
{
if (settings == null) return NodeCardState.Hidden;
if (!settings.EnableNodeMode) return NodeCardState.Off;

// Operator must be connected for the node card to be meaningful.
if (!settings.EnableNodeMode)
return settings.EnableMcpServer ? NodeCardState.OffMcpOnly : NodeCardState.Off;

if (snap.OperatorState != RoleConnectionState.Connected)
return NodeCardState.Off;

return snap.NodeState switch
{
RoleConnectionState.Connecting => NodeCardState.OnNodeConnecting,
RoleConnectionState.PairingRequired => NodeCardState.OnNodePairingRequired,
RoleConnectionState.PairingRejected => NodeCardState.OnNodeRejected,
RoleConnectionState.RateLimited => NodeCardState.OnNodeRateLimited,
Expand All @@ -702,6 +715,15 @@ _ when CountEnabledCapabilities(settings) == 0 => NodeCardState.OnPermissionsInc
};
}

private static NodeCardState BuildIdleNodeCardState(SettingsManager? settings)
{
if (settings == null) return NodeCardState.Hidden;

return !settings.EnableNodeMode && settings.EnableMcpServer
? NodeCardState.OffMcpOnly
: NodeCardState.Hidden;
}

private static string? BuildNodeApproveCommand(GatewayConnectionSnapshot snap)
{
if (snap.NodeState != RoleConnectionState.PairingRequired) return null;
Expand Down
2 changes: 1 addition & 1 deletion src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@
<TextBlock x:Uid="PermissionsPage_McpHeader" Text="Local MCP Server"
Style="{StaticResource BodyStrongTextBlockStyle}"/>
<TextBlock x:Uid="PermissionsPage_McpDescription"
Text="Expose capabilities over HTTP for CLI tools and local integrations."
Text="Serves capabilities to local MCP clients (CLI tools, integrations) on this PC over HTTP."
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"/>
Expand Down
Loading
Loading