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
5 changes: 5 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ These are the canonical homes. Do not reintroduce private copies elsewhere.
| UI-thread marshaling for presentation code | `IUiDispatcher` | authoritative |
| Page view-model activation/deactivation + disposal lifetime | `NavigationScopeManager` | authoritative |
| Presentation-layer DI composition root | `AppServiceRegistration` (root `ServiceProvider`, owned by `App`) | authoritative |
| Settings snapshot read + batched save + non-echoing change notification | `ISettingsStore` | authoritative |
| Settings page load/persist view logic | `SettingsPageViewModel` | authoritative |
| Capability UI metadata | `NodeCapabilityUiCatalog` (planned) | planned |
| Capability registration/gating | `NodeCapabilityRegistrationPolicy` (planned) | planned |
| Local MCP exposure policy | `McpCapabilityPolicy` (planned) | planned |
Expand All @@ -72,6 +74,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere.
| `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs` | `TimelineScrollController`, `ChatBubbleRenderer`, `ToolCallCardRenderer`, `PermissionRequestCard`, `AttachmentBubbleRenderer` |
| `src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs` | `ComposerViewModel`, `SlashCommandPalette`, `AttachmentPreviewStrip`, `VoiceComposerController` |
| `src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs` | `ConnectionPagePlan` (pure), `ConnectionPageViewModel`, gateway row models |
| `src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs` | settings read/persist → `SettingsPageViewModel` + `ISettingsStore`; keep gateway-uninstall, uptime timer, saved-indicator, and app-info in the view |
| `src/OpenClaw.Tray.WinUI/Services/NodeService.cs` | `McpServerHost`, `CanvasWindowManager`, `MediaCapabilityHost`, `RecordingConsentService`, `NodeCapabilityRegistry` |
| `src/OpenClaw.Shared/OpenClawGatewayClient.cs` | `PendingRequestRegistry`, `ConnectEnvelopeBuilder`, `GatewayMessageRouter`, per-domain API facades |
| `src/OpenClaw.Shared/Models.cs` | per-domain model files + `*Mapper` classes |
Expand Down Expand Up @@ -124,6 +127,8 @@ leading and trailing pipe. Columns, in order:
| navigation-scope | authoritative | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs | page view-model activation/deactivation and disposal lifetime | NavigationScopeManager | HubWindow keeps frame navigation back-stack and rail selection | transient page view models are activated on navigation and deactivated then disposed on navigate-away | NavigationScopeManagerTests.NavigatingAway_DeactivatesAndDisposesPreviousViewModel | behavioral | - |
| composition-root | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | presentation-layer service construction and wiring | AppServiceRegistration | App remains the composition root and owns non-DI service lifetimes | one validated root ServiceProvider; App-owned singletons registered as instances are never disposed by the container | AppServiceRegistrationTests.Dispose_DoesNotDisposeAppOwnedInstanceSingletons | behavioral | - |
| node-summary-text | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | node-summary clipboard text formatting | NodeSummaryText | App keeps the clipboard side effect (building the DataPackage and setting clipboard content) | copied node-summary text is projected only by NodeSummaryText.Build (online/offline state, display-name fallback, short id, detail text, newline join) | NodeSummaryTextTests.Build_MultipleNodes_OneLinePerNodeJoinedByNewline | behavioral | - |
| settings-store | authoritative | src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs | hand-rolled save/echo suppression flags for two-way settings binding | ISettingsStore | PermissionsPage and other surfaces may read SettingsManager directly until migrated | a save originating from Update does not echo Changed to the caller and external saves are republished on the UI thread | SettingsStoreTests.Update_DoesNotEchoChangedToSelf | behavioral | when all settings surfaces read and write through ISettingsStore |
| settings-page-vm | authoritative | src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs | settings load, persist, echo-guard, and auto-save wiring | SettingsPageViewModel | code-behind keeps gateway-uninstall, gateway-info and uptime timer, saved-indicator visual, and app-info population | each settings control persists its field through the store preserving mutate-save-notify order and does not re-persist on external change | SettingsPageViewModelTests.ExternalChange_ReloadsWithoutRePersisting | behavioral | when the Settings page holds no settings persistence logic in code-behind |
<!-- LEDGER:END -->

## Deferred test builders
Expand Down
67 changes: 63 additions & 4 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,22 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands
private ServiceProvider? _services;

/// <summary>
/// Page type → view-model type map used by the navigation activation hook.
/// Currently empty: no page resolves a view model from DI yet, so the activation
/// hook is a runtime no-op. Entries are added here as pages adopt view models.
/// Page type → view-model type map used by the navigation activation hook. The Settings
/// page resolves its view model from DI and binds it as the page DataContext; pages absent
/// from the map take the no-op activation path.
/// </summary>
private static readonly IReadOnlyDictionary<Type, Type> PageViewModelMap =
new Dictionary<Type, Type>();
new Dictionary<Type, Type>
{
[typeof(Pages.SettingsPage)] = typeof(SettingsPageViewModel),
};

/// <summary>The root service provider, or null before startup / after shutdown.</summary>
internal IServiceProvider? Services => _services;

/// <summary>The settings facade, or null before startup / after shutdown.</summary>
internal ISettingsStore? SettingsStore => _services?.GetService<ISettingsStore>();

/// <summary>Resolves the page activator used by <c>HubWindow</c>'s navigation hook.</summary>
internal IPageActivator? PageActivator => _services?.GetService<IPageActivator>();
/// <summary>
Expand Down Expand Up @@ -4208,6 +4214,33 @@ private async Task ToggleAutoStartAsync()
await AutoStartManager.SetAutoStartAsync(_settings.AutoStart);
}

/// <summary>
/// Persists the auto-start setting and applies the Windows OS registration in the original
/// order (save, then await the OS write, then notify). Returns true only when the OS write
/// and notify complete, so the caller shows its saved confirmation only on success. The save
/// is marked as a store self-write so it does not echo an external-change reload.
/// </summary>
public async Task<bool> ApplyAutoStart(bool autoStart)
{
if (_settings == null) return false;
try
{
_settings.AutoStart = autoStart;
using (SettingsStore?.BeginSelfWrite())
{
_settings.Save();
}
await AutoStartManager.SetAutoStartAsync(autoStart);
OnSettingsSaved(this, EventArgs.Empty);
return true;
}
catch (Exception ex)
{
Logger.Error($"ApplyAutoStart failed: {ex.Message}");
return false;
}
}

private void OpenLogFile()
{
try
Expand Down Expand Up @@ -4491,6 +4524,12 @@ public Task SpeakChatTextAsync(string text) =>
/// <summary>Raised when speaker mute state changes from any source (composer, settings, etc.).</summary>
public event Action<bool>? SpeakerMuteChanged;

/// <summary>
/// Sets speaker mute from any surface (chat window, chat page, voice settings) and persists it.
/// This public path is NOT store-self-write-suppressed, so an open Settings page still reflects
/// a mute toggled elsewhere. The Settings-page-originated call goes through the explicit
/// <see cref="IAppCommands.SetChatSpeakerMuted"/> below, which suppresses its own echo.
/// </summary>
public void SetChatSpeakerMuted(bool muted)
{
if (_chatCoordinator is { } c) c.IsMuted = muted;
Expand All @@ -4504,6 +4543,26 @@ public void SetChatSpeakerMuted(bool muted)
SpeakerMuteChanged?.Invoke(muted);
}

/// <summary>
/// Settings-page-originated mute: wraps the shared write in a store self-write so it does not
/// echo an external-change reload back to the Settings view model that triggered it.
/// </summary>
void IAppCommands.SetChatSpeakerMuted(bool muted)
{
using (SettingsStore?.BeginSelfWrite())
{
SetChatSpeakerMuted(muted);
}
}

/// <summary>
/// Pushes tool-call visibility into the live chat timeline. Forwards to the shared
/// static writer so a WinUI-free settings view model can drive it through IAppCommands
/// without referencing the chat UI directly.
/// </summary>
public void SetChatToolCallsVisible(bool visible) =>
OpenClawTray.Chat.OpenClawChatRoot.SetToolCallsVisible(visible);

private static void SendDeepLinkToRunningInstance(string uri)
{
try
Expand Down
29 changes: 21 additions & 8 deletions src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
TextWrapping="Wrap"/>
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="AutoStartToggle" MinWidth="0" OnContent="" OffContent=""
IsOn="{Binding AutoStart, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageAutoStart"
AutomationProperties.Name="Start with Windows"/>
</Grid>
Expand All @@ -74,6 +75,8 @@
TextWrapping="Wrap"/>
</StackPanel>
<ComboBox Grid.Column="1" x:Name="AppThemeComboBox" MinWidth="160"
SelectedValuePath="Tag"
SelectedValue="{Binding AppTheme, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageAppTheme"
AutomationProperties.Name="App theme">
<ComboBoxItem x:Uid="SettingsPage_AppTheme_System" Content="System" Tag="System"/>
Expand All @@ -100,6 +103,7 @@
TextWrapping="Wrap"/>
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="GlobalHotkeyToggle" MinWidth="0" OnContent="" OffContent=""
IsOn="{Binding GlobalHotkeyEnabled, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageHotkey"
AutomationProperties.Name="Global hotkey"/>
</Grid>
Expand All @@ -122,6 +126,7 @@
TextWrapping="Wrap"/>
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="UseLegacyWebChatToggle" MinWidth="0" OnContent="" OffContent=""
IsOn="{Binding UseLegacyWebChat, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageUseLegacyWebChat"
AutomationProperties.Name="Use gateway chat interface"/>
</Grid>
Expand Down Expand Up @@ -149,6 +154,7 @@
TextWrapping="Wrap"/>
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="ReadResponsesAloudToggle" MinWidth="0" OnContent="" OffContent=""
IsOn="{Binding ReadResponsesAloud, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageReadAloud"
AutomationProperties.LabeledBy="{Binding ElementName=ReadResponsesAloudLabel}"/>
</Grid>
Expand All @@ -172,6 +178,7 @@
TextWrapping="Wrap"/>
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="ShowToolCallsToggle" MinWidth="0" OnContent="" OffContent=""
IsOn="{Binding ShowChatToolCalls, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageShowToolCalls"
AutomationProperties.LabeledBy="{Binding ElementName=ShowToolCallsLabel}"/>
</Grid>
Expand All @@ -198,6 +205,7 @@
TextWrapping="Wrap"/>
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="NotificationsToggle" MinWidth="0" OnContent="" OffContent=""
IsOn="{Binding ShowNotifications, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageNotifications"
AutomationProperties.Name="Show notifications"/>
</Grid>
Expand All @@ -221,6 +229,8 @@
</StackPanel>
<ComboBox Grid.Column="1" x:Uid="SettingsPage_NotificationSoundComboBox"
x:Name="NotificationSoundComboBox" MinWidth="140"
SelectedValuePath="Tag"
SelectedValue="{Binding NotificationSound, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageSound">
<ComboBoxItem x:Uid="SettingsPage_ComboBoxItem_48" Content="Default" Tag="Default"/>
<ComboBoxItem x:Uid="SettingsPage_ComboBoxItem_49" Content="None" Tag="None"/>
Expand Down Expand Up @@ -248,28 +258,28 @@
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<CheckBox Grid.Row="0" Grid.Column="0" x:Uid="NotifyHealthCb" x:Name="NotifyHealthCb"
Content="Health alerts"
Content="Health alerts" IsChecked="{Binding NotifyHealth, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageNotifyHealth"/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Uid="NotifyUrgentCb" x:Name="NotifyUrgentCb"
Content="Urgent messages"
Content="Urgent messages" IsChecked="{Binding NotifyUrgent, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageNotifyUrgent"/>
<CheckBox Grid.Row="1" Grid.Column="0" x:Uid="NotifyReminderCb" x:Name="NotifyReminderCb"
Content="Reminders"
Content="Reminders" IsChecked="{Binding NotifyReminder, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageNotifyReminder"/>
<CheckBox Grid.Row="1" Grid.Column="1" x:Uid="NotifyEmailCb" x:Name="NotifyEmailCb"
Content="Email summaries"
Content="Email summaries" IsChecked="{Binding NotifyEmail, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageNotifyEmail"/>
<CheckBox Grid.Row="2" Grid.Column="0" x:Uid="NotifyCalendarCb" x:Name="NotifyCalendarCb"
Content="Calendar events"
Content="Calendar events" IsChecked="{Binding NotifyCalendar, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageNotifyCalendar"/>
<CheckBox Grid.Row="2" Grid.Column="1" x:Uid="NotifyBuildCb" x:Name="NotifyBuildCb"
Content="Build notifications"
Content="Build notifications" IsChecked="{Binding NotifyBuild, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageNotifyBuild"/>
<CheckBox Grid.Row="3" Grid.Column="0" x:Uid="NotifyStockCb" x:Name="NotifyStockCb"
Content="Stock alerts"
Content="Stock alerts" IsChecked="{Binding NotifyStock, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageNotifyStock"/>
<CheckBox Grid.Row="3" Grid.Column="1" x:Uid="NotifyInfoCb" x:Name="NotifyInfoCb"
Content="Info messages"
Content="Info messages" IsChecked="{Binding NotifyInfo, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageNotifyInfo"/>
</Grid>
<Button x:Uid="TestNotificationButton" x:Name="TestNotificationButton"
Expand Down Expand Up @@ -304,6 +314,7 @@
TextWrapping="Wrap"/>
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="ScreenRecordingToggle" MinWidth="0" OnContent="" OffContent=""
IsOn="{Binding ScreenRecordingConsentGiven, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageScreenRecording"
AutomationProperties.Name="Allow screen recording"/>
</Grid>
Expand All @@ -326,6 +337,7 @@
TextWrapping="Wrap"/>
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="CameraRecordingToggle" MinWidth="0" OnContent="" OffContent=""
IsOn="{Binding CameraRecordingConsentGiven, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageCameraRecording"
AutomationProperties.Name="Allow camera recording"/>
</Grid>
Expand Down Expand Up @@ -453,6 +465,7 @@
MinWidth="0"
OnContent=""
OffContent=""
IsOn="{Binding ShowDiagnostics, Mode=TwoWay}"
AutomationProperties.AutomationId="SettingsPageShowDiagnostics"
AutomationProperties.Name="Enable Diagnostics"/>
</Grid>
Expand Down
Loading
Loading