Skip to content

Commit bc518f7

Browse files
karkarlCopilot
andauthored
refactor(tray): extract Hub and tray presentation models (#1082)
* refactor(tray): extract Hub and tray presentation models Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tray): preserve current session projection after rebase Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 529161b9-67ce-4fde-98b4-c96d8991faff --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Karen Lai <7976322+karkarl@users.noreply.github.com> Copilot-Session: 529161b9-67ce-4fde-98b4-c96d8991faff
1 parent f4e1cf3 commit bc518f7

26 files changed

Lines changed: 3549 additions & 2021 deletions

docs/ARCHITECTURE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ These are the canonical homes. Do not reintroduce private copies elsewhere.
7070
| Managed-local automatic repair eligibility and orchestration | `ManagedLocalGatewayAutoRepairMonitor` + `ManagedLocalGatewayRepairCoordinator` | authoritative |
7171
| Permissions page state, settings commands, and exec-approvals presentation | `PermissionsPageViewModel` | authoritative |
7272
| Permissions runtime status projection | `PermissionsPageRuntimeSource` | authoritative |
73+
| Hub navigation tags, page mapping, command catalog/search, and gateway-page classification | `HubPageRegistry` | authoritative |
74+
| Hub notification banner severity and action projection | `AppNotificationInfoBarPresenter` | authoritative |
75+
| Tray-menu semantic composition and connection-toggle state | `TrayMenuPresenter` + `ConnectionTogglePresenter` | authoritative |
7376
| Capability UI metadata | `NodeCapabilityUiCatalog` (planned) | planned |
7477
| Capability registration/gating | `NodeCapabilityRegistrationPolicy` (planned) | planned |
7578
| Local MCP exposure policy | `McpCapabilityPolicy` (planned) | planned |
@@ -81,6 +84,8 @@ These are the canonical homes. Do not reintroduce private copies elsewhere.
8184
| If you are editing… | Do not grow it. Extract toward… |
8285
| --- | --- |
8386
| `src/OpenClaw.Tray.WinUI/App.xaml.cs` | `IWindowManager`, `ITrayController`, `IActivationRouter`, `ISettingsChangeCoordinator`, `AppBootstrapper` |
87+
| `src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs` | navigation/catalog policy → `HubPageRegistry`; notification banner projection → `AppNotificationInfoBarPresenter`; keep Frame, NavigationView, back-stack mutation, control application, and route side effects in the view |
88+
| `src/OpenClaw.Tray.WinUI/Services/TrayMenuRenderer.cs` | semantic composition → `TrayMenuPresenter`; connection toggle projection → `ConnectionTogglePresenter`; keep WinUI control construction and callback application in the renderer |
8489
| `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs` | `ChatSendQueue`, `ChatBridgeEventPump`, `ChatHistoryLoader`, `ChatSnapshotProjector`, `AttachmentMetadataStore`; pure native tool projection stays in `NativeToolProjector` |
8590
| `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs` | `ReactorChatTimeline` (production `ItemsView` / `ItemContainer`), `ChatBubbleRenderer`, `ToolCallCardRenderer`, `PermissionRequestCard`, `AttachmentBubbleRenderer` |
8691
| `src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs` | `ComposerViewModel`, `SlashCommandPalette`, `AttachmentPreviewStrip`, `VoiceComposerController` |
@@ -165,6 +170,11 @@ leading and trailing pipe. Columns, in order:
165170
| cmd-payload-tokenization | authoritative | src/OpenClaw.Shared/ExecApprovals/ExecReusableCommandBinder.cs | parsing a cmd payload into tokens and rewriting its executable token | CmdPayloadTokenizer | ExecReusableCommandBinder.TryTokenizeStaticCmdPayload remains as a delegating wrapper for existing callers and tests | a payload rewrite is built from parsed token spans and is accepted only after re-parsing proves the argument list is unchanged except for the pinned executable | ExecReusableCommandBinderTests.PinnedCarrier_DoesNotRewriteArgumentsThatRepeatTheExecutableText | behavioral | - |
166171
| exec-carrier-cwd-ambiguity-check | closed | src/OpenClaw.Shared/ExecApprovals/ExecReusableCommandBinder.cs | deciding whether a carrier payload may be durably approved when the working directory could shadow it | CanonicalCmdCarrier.TryBuildPinnedCarrier (payload executable pinning) | - | the approval-time working-directory check is deleted, not merely bypassed: ExecCommandResolver exposes no HasCurrentDirectoryCandidate, a trusted carrier's payload executable is pinned to its resolved absolute path so cmd has nothing to search for, and a post-approval shadow cannot win | ExecReusableCommandBinderTests.PinnedCarrier_IgnoresShadowInsertedAfterApproval | behavioral | - |
167172
| exec-legacy-host-quarantine | authoritative | src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs | deciding whether a provenance-less path-only allowlist entry authorizes an interpreter or code host | ExecAllowlistMatcher.MatchInternal via ExecCommandToken.IsLegacyQuarantinedHost | argument binding remains the security boundary for every rule this node generates | an allowlist entry with no source and no argPattern is inert when its resolved target is a command host the previous model refused, is never deleted or migrated, and is superseded only by an explicit allow-always sibling carrying source and argPattern | ExecAllowlistArgBindingTests.LegacyPathOnlyEntryForACommandHost_IsInert | behavioral | - |
173+
| hub-page-registry | authoritative | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs and GatewayNavVisibilityDebouncePolicy | navigation aliases, page mapping, command metadata and search, and gateway-page classification | HubPageRegistry | HubWindow keeps Frame and NavigationView application, back-stack mutation, command cache lifetime, and semantic action execution; GatewayNavVisibilityDebouncePolicy keeps disconnect timing | every current direct, legacy, and agent-scoped tag resolves identically; command order, titles, actions, search caps, and gateway prune set remain stable | HubPageRegistryTests.BuildCommands_PreservesBaseOrderActionsIconsAndResourceKeys | behavioral | - |
174+
| hub-page-registry-closed | closed | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs and GatewayNavVisibilityDebouncePolicy | private tag/page switches, command catalogs or search predicates, and gateway-page tag lists | HubPageRegistry | view-only navigation application and debounce timing listed in hub-page-registry | HubWindow and the debounce policy do not regain catalog or page-classification copies | HubPresentationContractTests.HubPageRegistry_OwnsMappingsCommandsAndGatewayClassification | source-shape | when HubWindow is replaced by a different shell and GatewayNavVisibilityDebouncePolicy is retired |
175+
| app-notification-infobar-presentation | authoritative | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs | banner severity filtering, selected-banner fallback, notification action versus Show more, and action enabled state | AppNotificationInfoBarPresenter | HubWindow keeps notification subscription, bell reconciliation, WinUI control assignment, navigation, and dismissal side effects | Warning and Error banners retain priority and hiding semantics while action projection stays WinUI-free | AppNotificationInfoBarPresenterTests.Present_ActionableNotificationWinsOverShowMore | behavioral | - |
176+
| tray-menu-presentation | authoritative | TrayMenuStateBuilder and src/OpenClaw.Tray.WinUI/App.xaml.cs | tray row and flyout presence, ordering, text, formatting, icon identity, action, checked and enabled state, accelerator, accessibility names, and connection-toggle projection | TrayMenuPresenter + ConnectionTogglePresenter | App captures immutable input and owns callbacks, persistence, reconnect and live-control synchronization; TrayMenuRenderer builds WinUI controls; TrayMenuWindow owns popup mechanics | equal immutable snapshots project equal complete menus; connected and disconnected compositions, all nine permission toggles, and transient connection states preserve behavior | TrayMenuPresenterTests.Connected_ProjectsExactTopLevelAndNestedOrder | behavioral | - |
177+
| tray-menu-state-builder-closed | closed | TrayMenuStateBuilder and src/OpenClaw.Tray.WinUI/App.xaml.cs | snapshot interpretation, semantic menu construction, and duplicated connection-toggle decisions | TrayMenuPresenter + ConnectionTogglePresenter | mechanical rendering, immutable snapshot capture, action dispatch, persistence callbacks, weak control references, and Win32 popup behavior | presentation owners stay WinUI/App/concrete-settings free and the renderer does not interpret runtime snapshots | TrayMenuPresentationContractTests.PresentationFiles_AreWinUiAppAndConcreteSettingsFree | source-shape | when the tray menu no longer uses WinUI rendering |
168178
<!-- LEDGER:END -->
169179

170180
## Deferred test builders

src/OpenClaw.Tray.WinUI/App.xaml.cs

Lines changed: 132 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,10 +1286,9 @@ private void OnTrayMenuItemClicked(object? sender, string action)
12861286
case "exit": ExitApplication(); break;
12871287
case "about": ShowHub("about"); break;
12881288
default:
1289-
if (action.StartsWith("perm-toggle|", StringComparison.Ordinal)
1290-
&& _permToggleActions.TryGetValue(action, out var permAction))
1289+
if (action.StartsWith("perm-toggle|", StringComparison.Ordinal))
12911290
{
1292-
permAction();
1291+
ToggleTrayPermission(action);
12931292
}
12941293
else if (action.StartsWith("session-reset|", StringComparison.Ordinal))
12951294
_ = ExecuteSessionActionAsync("reset", action["session-reset|".Length..]);
@@ -1581,37 +1580,116 @@ private bool TryPersistPermissionSetting(
15811580
}
15821581
}
15831582

1583+
private void ToggleTrayPermission(string action)
1584+
{
1585+
if (_settings is null)
1586+
return;
1587+
1588+
switch (action)
1589+
{
1590+
case "perm-toggle|Windows node":
1591+
PersistTrayPermission(
1592+
nameof(SettingsManager.EnableNodeMode),
1593+
!_settings.EnableNodeMode,
1594+
(edit, value) => edit.EnableNodeMode = value,
1595+
(settings, value) => settings.EnableNodeMode = value);
1596+
break;
1597+
case "perm-toggle|System tools":
1598+
PersistTrayPermission(
1599+
nameof(SettingsManager.NodeSystemRunEnabled),
1600+
!_settings.NodeSystemRunEnabled,
1601+
(edit, value) => edit.NodeSystemRunEnabled = value,
1602+
(settings, value) => settings.NodeSystemRunEnabled = value);
1603+
break;
1604+
case "perm-toggle|Browser control":
1605+
PersistTrayPermission(
1606+
nameof(SettingsManager.NodeBrowserProxyEnabled),
1607+
!_settings.NodeBrowserProxyEnabled,
1608+
(edit, value) => edit.NodeBrowserProxyEnabled = value,
1609+
(settings, value) => settings.NodeBrowserProxyEnabled = value);
1610+
break;
1611+
case "perm-toggle|Camera":
1612+
PersistTrayPermission(
1613+
nameof(SettingsManager.NodeCameraEnabled),
1614+
!_settings.NodeCameraEnabled,
1615+
(edit, value) => edit.NodeCameraEnabled = value,
1616+
(settings, value) => settings.NodeCameraEnabled = value);
1617+
break;
1618+
case "perm-toggle|Canvas":
1619+
PersistTrayPermission(
1620+
nameof(SettingsManager.NodeCanvasEnabled),
1621+
!_settings.NodeCanvasEnabled,
1622+
(edit, value) => edit.NodeCanvasEnabled = value,
1623+
(settings, value) => settings.NodeCanvasEnabled = value);
1624+
break;
1625+
case "perm-toggle|Screen capture":
1626+
PersistTrayPermission(
1627+
nameof(SettingsManager.NodeScreenEnabled),
1628+
!_settings.NodeScreenEnabled,
1629+
(edit, value) => edit.NodeScreenEnabled = value,
1630+
(settings, value) => settings.NodeScreenEnabled = value);
1631+
break;
1632+
case "perm-toggle|Location":
1633+
PersistTrayPermission(
1634+
nameof(SettingsManager.NodeLocationEnabled),
1635+
!_settings.NodeLocationEnabled,
1636+
(edit, value) => edit.NodeLocationEnabled = value,
1637+
(settings, value) => settings.NodeLocationEnabled = value);
1638+
break;
1639+
case "perm-toggle|Voice (TTS)":
1640+
PersistTrayPermission(
1641+
nameof(SettingsManager.NodeTtsEnabled),
1642+
!_settings.NodeTtsEnabled,
1643+
(edit, value) => edit.NodeTtsEnabled = value,
1644+
(settings, value) => settings.NodeTtsEnabled = value);
1645+
break;
1646+
case "perm-toggle|Speech-to-text (STT)":
1647+
PersistTrayPermission(
1648+
nameof(SettingsManager.NodeSttEnabled),
1649+
!_settings.NodeSttEnabled,
1650+
(edit, value) => edit.NodeSttEnabled = value,
1651+
(settings, value) => settings.NodeSttEnabled = value);
1652+
break;
1653+
}
1654+
}
1655+
1656+
private void PersistTrayPermission(
1657+
string settingName,
1658+
bool value,
1659+
Action<ISettingsEditor, bool> edit,
1660+
Action<SettingsManager, bool> fallbackEdit)
1661+
{
1662+
if (TryPersistPermissionSetting(
1663+
ref _trayPermissionWriteOrigin,
1664+
$"tray permissions flyout ({settingName})",
1665+
settings => edit(settings, value),
1666+
settings => fallbackEdit(settings, value),
1667+
out _))
1668+
{
1669+
ReconnectWithSyncedBrowserProxyForward();
1670+
}
1671+
}
1672+
15841673
private void BuildTrayMenuPopup(TrayMenuWindow menu)
15851674
{
15861675
// Preview data must be applied before snapshot capture so the injected
15871676
// values are visible to the builder without coupling it to App state.
15881677
ApplyTrayMenuPreviewDataIfRequested();
15891678
var snapshot = CaptureTrayMenuSnapshot();
1679+
var presentation = new TrayMenuPresenter(snapshot).Present();
15901680
var callbacks = new TrayMenuCallbacks(
15911681
DispatchAction: action => OnTrayMenuItemClicked(null, action),
1592-
UpdatePermissionAndReconnect: (settingName, edit, fallbackEdit) =>
1593-
{
1594-
if (TryPersistPermissionSetting(
1595-
ref _trayPermissionWriteOrigin,
1596-
$"tray permissions flyout ({settingName})",
1597-
edit,
1598-
fallbackEdit,
1599-
out _))
1600-
{
1601-
ReconnectWithSyncedBrowserProxyForward();
1602-
}
1603-
},
16041682
TrackConnectionToggle: toggle => _connectionToggleRef = new WeakReference<ToggleSwitch>(toggle),
16051683
IsConnectionToggleSuspended: () => _suspendConnectionToggleEvent);
1606-
var builder = new TrayMenuStateBuilder(snapshot, _permToggleActions, callbacks);
1684+
var renderer = new TrayMenuRenderer(presentation, callbacks);
16071685

16081686
// Render the whole menu inside a single update batch so layout
16091687
// measures only once instead of once-per-row. Pair with EndUpdate
16101688
// in finally so an exception mid-build doesn't wedge layout.
16111689
menu.BeginUpdate();
16121690
try
16131691
{
1614-
builder.Build(menu);
1692+
renderer.Render(menu);
16151693
}
16161694
finally
16171695
{
@@ -1651,20 +1729,44 @@ private TrayMenuSnapshot CaptureTrayMenuSnapshot()
16511729
OverallState = _connectionManager?.CurrentSnapshot.OverallState,
16521730
AuthFailureMessage = _appState?.AuthFailureMessage,
16531731
GatewayUrl = _gatewayRegistry?.GetActive()?.Url ?? _settings?.GetEffectiveGatewayUrl(),
1654-
GatewaySelf = _appState?.GatewaySelf,
1655-
Presence = _appState?.Presence,
1732+
GatewaySelf = TrayGatewaySelfSnapshot.From(_appState?.GatewaySelf),
1733+
Presence =
1734+
[
1735+
.. (_appState?.Presence ?? Array.Empty<PresenceEntry>())
1736+
.Select(TrayPresenceSnapshot.From),
1737+
],
16561738
EnableNodeMode = _settings?.EnableNodeMode == true && _nodeService != null,
16571739
NodeIsPaired = _nodeService?.IsPaired ?? false,
16581740
NodeIsPendingApproval = _nodeService?.IsPendingApproval ?? false,
16591741
NodeIsConnected = _nodeService?.IsConnected ?? false,
1660-
NodePairList = _appState?.NodePairList,
1661-
DevicePairList = _appState?.DevicePairList,
1662-
Nodes = _appState?.Nodes ?? Array.Empty<GatewayNodeInfo>(),
1663-
Sessions = _appState?.Sessions ?? Array.Empty<SessionInfo>(),
1664-
Usage = _appState?.Usage,
1665-
UsageStatus = _appState?.UsageStatus,
1666-
UsageCost = _appState?.UsageCost,
1667-
Settings = _settings,
1742+
NodePendingPairCount = _appState?.NodePairList?.Pending.Count ?? 0,
1743+
DevicePendingPairCount = _appState?.DevicePairList?.Pending.Count ?? 0,
1744+
Nodes =
1745+
[
1746+
.. (_appState?.Nodes ?? Array.Empty<GatewayNodeInfo>())
1747+
.Select(TrayNodeSnapshot.From),
1748+
],
1749+
Sessions =
1750+
[
1751+
.. (_appState?.Sessions ?? Array.Empty<SessionInfo>())
1752+
.Select(TraySessionSnapshot.From),
1753+
],
1754+
Usage = TrayUsageSnapshot.From(_appState?.Usage),
1755+
UsageStatus = TrayUsageStatusSnapshot.From(_appState?.UsageStatus),
1756+
UsageCost = TrayUsageCostSnapshot.From(_appState?.UsageCost),
1757+
Settings = _settings is null
1758+
? null
1759+
: new TrayMenuSettingsSnapshot(
1760+
_settings.EnableNodeMode,
1761+
_settings.EnableMcpServer,
1762+
_settings.NodeSystemRunEnabled,
1763+
_settings.NodeBrowserProxyEnabled,
1764+
_settings.NodeCameraEnabled,
1765+
_settings.NodeCanvasEnabled,
1766+
_settings.NodeScreenEnabled,
1767+
_settings.NodeLocationEnabled,
1768+
_settings.NodeTtsEnabled,
1769+
_settings.NodeSttEnabled),
16681770
SetupMenuLabel = setupMenuLabel,
16691771
ShowSetupMenuEntry = !hasSetupManagedLocalWslGateway,
16701772
LastUpdated = _appState?.LastCheckTime,
@@ -1764,8 +1866,6 @@ private void ApplyTrayMenuPreviewDataIfRequested()
17641866
}
17651867

17661868

1767-
private readonly Dictionary<string, Action> _permToggleActions = new(StringComparer.Ordinal);
1768-
17691869
#region Gateway Client
17701870

17711871
private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false)
@@ -3318,22 +3418,12 @@ private void SyncConnectionToggle(ConnectionStatus status, OverallConnectionStat
33183418
return;
33193419
}
33203420

3321-
var shouldBeOn = ConnectionStatusPresenter.IsLiveOrPending(overallState, status);
3322-
var canToggle = overallState switch
3323-
{
3324-
OverallConnectionState.Connecting or OverallConnectionState.Disconnecting => false,
3325-
null => status is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error,
3326-
_ => true
3327-
};
3328-
var statusText = ConnectionStatusPresenter.PlainText(overallState, status);
3421+
var presentation = ConnectionTogglePresenter.Present(status, overallState);
33293422
_suspendConnectionToggleEvent = true;
33303423
try
33313424
{
3332-
TrayMenuWindow.SetMenuToggleSwitchState(toggle, shouldBeOn, canToggle);
3333-
ToolTipService.SetToolTip(toggle,
3334-
shouldBeOn ? $"{statusText} - toggle off to disconnect"
3335-
: status == ConnectionStatus.Connecting ? "Connecting..."
3336-
: $"{statusText} - toggle on to connect");
3425+
TrayMenuWindow.SetMenuToggleSwitchState(toggle, presentation.IsOn, presentation.IsEnabled);
3426+
ToolTipService.SetToolTip(toggle, presentation.ToolTip);
33373427
}
33383428
finally
33393429
{

0 commit comments

Comments
 (0)