From f9c82d59b5a2fe403889e52abd329b88dbd85ac5 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 17:37:13 -0500 Subject: [PATCH 01/28] docs: add design spec for floating documentation window and Hugging Face Hub fixes --- ...6-09-18-floating-docs-and-hf-hub-design.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-18-floating-docs-and-hf-hub-design.md diff --git a/docs/superpowers/specs/2026-09-18-floating-docs-and-hf-hub-design.md b/docs/superpowers/specs/2026-09-18-floating-docs-and-hf-hub-design.md new file mode 100644 index 0000000..bff8b18 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-floating-docs-and-hf-hub-design.md @@ -0,0 +1,64 @@ +# Floating Documentation Window & Hugging Face Hub Overhaul Design Specification + +## 1. Overview & Objective + +This design addresses two key user experience improvements in **Local LLM Server Manager**: +1. **Floating / Standalone Documentation Window**: Allow users to keep procedural guides visible in a floating, movable window or overlay while simultaneously operating other tabs (Workflows, Models, Settings) without switching back and forth, including smart step-level tab navigation. +2. **Hugging Face Hub Overhaul**: Fix the initial empty state by automatically loading curated trending/starter models on open, implement clear, high-contrast active/inactive visual styling for modality and hardware filter toggles, and verify filter tag mappings. + +--- + +## 2. Component 1: Floating In-App Documentation System + +### 2.1 Problem +Currently, in-app documentation is confined to a full-screen tab (`📖 Documentation`). When following instructions to generate video, download models, or configure settings, the user must constantly toggle between the documentation tab and the target workspace tab. + +### 2.2 Architecture & Hybrid Layout +* **Desktop Native Window (`DocumentationWindow.axaml`)**: + * Can be popped out into a standalone OS window that can be dragged to a secondary monitor. + * Includes a **Pin / Always-on-Top** toggle (`Topmost="True"`). + * Uses the application's dark theme palette and compact dimensions (420x620). +* **In-App Draggable Floating HUD (`DocumentationFloatingOverlay.axaml`)**: + * Lives inside `MainView.axaml` on an overlay layer (`ZIndex="100"`). + * Can be dragged anywhere across the main window canvas via pointer drag on its titlebar. + * Supports a **Minimize to Pill** mode (collapses to a compact 36px floating badge showing current step title and quick Next/Prev buttons). + * Works identically across both Desktop and WebAssembly (WASM). +* **Smart Step Navigation**: + * `DocStep` is extended with an optional `TargetTab` property (`Models = 0`, `Workflows = 1`, `HardwareFit = 2`, `Copilot = 3`, `Documentation = 4`, `Settings = 5`). + * Each step card in the floating guide features a **"Jump to Tab"** button (`🚀`). Clicking it updates `MainViewModel.SelectedTabIndex` so the target workspace immediately appears behind the guide. + +--- + +## 3. Component 2: Hugging Face Hub Overhaul + +### 3.1 Initial Model Loading +* **Problem**: Opening the Hugging Face Hub currently shows an empty list with a "No models found" message because search is never invoked on startup. +* **Solution**: + * `HuggingFaceSearchViewModel` triggers an initial automated query on first load if results are empty. + * The initial query fetches top curated trending and high-utility starter models (`Qwen/Qwen2.5-Coder-7B-Instruct-GGUF`, `meta-llama/Llama-3.2-3B-Instruct-GGUF`, `deepseek-ai/DeepSeek-R1-Distill-Qwen-7B-GGUF`, `Wan-AI/Wan2.1-T2V-14B`, `hexgrad/Kokoro-82M`). + * Live Can I Run It hardware fit badges are computed immediately against the user's GPU VRAM and system RAM. + +### 3.2 Toggle Button Styling & Visual Clarity +* **Problem**: Modality buttons (`Text`, `Image`, `Audio`, `Video`, `3D`) and Hardware Fit buttons (`🟢 Full VRAM`, `🟡 Partial Offload`, etc.) are plain flat buttons with zero visual indicator showing whether they are currently active/included or disabled. +* **Solution**: + * Implement distinct visual states: + * **Active Modality State**: Solid vibrant background (`#2563EB` for inputs, `#8B5CF6` for outputs), bright white text, bold font, subtle glow border, and an active checkmark (`✓`). + * **Inactive Modality State**: Dark muted background (`#1E293B`), dim text (`#64748B`), 50% opacity, standard border. + * **Active Hardware Fit State**: Full color badge with solid pill background and border. + * **Inactive Hardware Fit State**: 35% opacity, desaturated muted background, and a strikethrough or dim icon. + +### 3.3 Filter Logic Verification +* Ensure `ToggleInputModality`, `ToggleOutputModality`, and `ToggleFitVerdict` immediately trigger search and local filtering without getting out of sync. +* Ensure pipeline tags (`image-to-video`, `text-to-video`, `text-to-image`, `text-generation`, `automatic-speech-recognition`, `text-to-speech`) map accurately to Hugging Face API filters. + +--- + +## 4. Verification & Testing + +* **Unit Tests**: + * `DocumentationViewModelTests`: Verify step navigation, pop-out state toggle, and `TargetTab` properties. + * `HuggingFaceSearchViewModelTests`: Verify initial model loading, modality toggle synchronization, and hardware filter states. +* **Tooling Checks**: + * `npm run lint`: 0 ESLint errors. + * `npx tsc --noEmit`: 0 TypeScript errors. + * `dotnet test`: 100% pass across all unit and integration tests. From 298b7c39c9b37497acd87b293253ea668a8c389b Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 17:37:28 -0500 Subject: [PATCH 02/28] docs: add implementation plan for floating documentation and Hugging Face Hub overhaul --- .../2026-09-18-floating-docs-and-hf-hub.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-floating-docs-and-hf-hub.md diff --git a/docs/superpowers/plans/2026-09-18-floating-docs-and-hf-hub.md b/docs/superpowers/plans/2026-09-18-floating-docs-and-hf-hub.md new file mode 100644 index 0000000..dd5f083 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-floating-docs-and-hf-hub.md @@ -0,0 +1,148 @@ +# Floating Documentation & Hugging Face Hub Overhaul Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or direct execution to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Provide a floating, movable in-app documentation overlay/window with smart tab navigation, and overhaul the Hugging Face Hub with default model loading and clear active toggle styling. + +**Architecture:** Extend `DocumentationViewModel` with overlay states and `TargetTab` step navigation. Create `DocumentationFloatingOverlay.axaml` on the top canvas layer of `MainView.axaml` and a native `DocumentationWindow.axaml` for desktop pop-out. Update `HuggingFaceSearchViewModel` to load starter models on startup, and update `HuggingFaceTabControl.axaml` with high-contrast active/inactive toggle styling. + +**Tech Stack:** C# (.NET 10), Avalonia UI 12.x, CommunityToolkit.Mvvm, xUnit. + +## Global Constraints + +* Must maintain 100% test pass rate across `LocalLLMServerManager.Tests`. +* Must pass `npm run lint` and `npx tsc --noEmit` with zero errors. +* Must support both Desktop and WebAssembly without platform crashes. +* Avoid using "ASD-STE100" in user-facing labels. + +--- + +### Task 1: Extend DocumentationViewModel with Floating States & Step Target Tabs + +**Files:** +- Modify: `LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs` +- Test: `LocalLLMServerManager.Tests/DocumentationViewModelTests.cs` + +**Interfaces:** +- Produces: `DocStep.TargetTab`, `IsFloatingOverlayOpen`, `IsMinimizedToPill`, `OverlayX`, `OverlayY`, `CurrentStepIndex`, `JumpToStepTabCommand`. + +- [ ] **Step 1: Update DocStep record to include TargetTab** + ```csharp + public record DocStep( + int StepNumber, + string Title, + string Action, + string ExpectedResult, + int? TargetTab = null + ); + ``` + +- [ ] **Step 2: Add floating state properties and commands to DocumentationViewModel** + Add observable properties: + - `bool IsFloatingOverlayOpen = false` + - `bool IsMinimizedToPill = false` + - `double OverlayX = 20` + - `double OverlayY = 60` + - `int CurrentStepIndex = 0` + Add commands: + - `ToggleFloatingOverlayCommand()` + - `ToggleMinimizeToPillCommand()` + - `NextStepCommand()` + - `PreviousStepCommand()` + - `JumpToStepTab(int? targetTab)` with an `Action? OnNavigateToTabRequested` callback. + +- [ ] **Step 3: Map TargetTab on procedural steps** + Map `TargetTab: 1` (Workflows) on generation steps, `TargetTab: 0` (Models) on model steps, `TargetTab: 2` (Can I Run It) on compatibility steps, and `TargetTab: 5` (Settings) on setup steps. + +- [ ] **Step 4: Update DocumentationViewModelTests and verify pass** + Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~DocumentationViewModelTests"` + +- [ ] **Step 5: Commit Task 1** + ```bash + git add LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs LocalLLMServerManager.Tests/DocumentationViewModelTests.cs + git commit -m "feat(docs): add floating overlay states and step target tabs to DocumentationViewModel" + ``` + +--- + +### Task 2: Create Floating Documentation Overlay & Pop-Out Controls + +**Files:** +- Create: `LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml` +- Create: `LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml.cs` +- Modify: `LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml` +- Modify: `LocalLLMServerManager.Shared/Views/MainView.axaml` +- Modify: `LocalLLMServerManager.Shared/Views/MainView.axaml.cs` + +**Interfaces:** +- Consumes: `DocumentationViewModel` +- Produces: Movable floating guide overlay and pop-out trigger button. + +- [ ] **Step 1: Create DocumentationFloatingOverlay.axaml** + Implement floating card with: + - Header drag region (`PointerPressed`, `PointerMoved`, `PointerReleased` handlers updating `OverlayX` and `OverlayY`). + - Minimize/Expand button (`IsMinimizedToPill`). + - Close button (`ToggleFloatingOverlayCommand`). + - Step counter (`Step X of Y`) with `<` Previous and `>` Next buttons. + - Active step description and action text. + - `🚀 Jump to Tab` button invoking `JumpToStepTabCommand`. + +- [ ] **Step 2: Add Pop-Out button to DocumentationTabControl.axaml** + Add `[🗗 Pop Out Floating Guide]` button in the top right of the documentation header that executes `ToggleFloatingOverlayCommand`. + +- [ ] **Step 3: Integrate floating overlay layer in MainView.axaml** + Wrap `MainView.axaml` content in a `Panel` or top-level `Grid` with `DocumentationFloatingOverlay` positioned via `Margin` or `Canvas` bound to `Documentation`. Wire up `OnNavigateToTabRequested` in `MainViewModel` to set `SelectedTabIndex`. + +- [ ] **Step 4: Verify build and test** + Run `dotnet build LocalLLMServerManager.slnx` and verify zero compilation errors. + +- [ ] **Step 5: Commit Task 2** + ```bash + git add LocalLLMServerManager.Shared/Views/ + git commit -m "feat(ui): implement floating documentation overlay with draggable controls and step navigation" + ``` + +--- + +### Task 3: Overhaul Hugging Face Hub (Default Loading, Active Toggle Styling, Filter Logic) + +**Files:** +- Modify: `LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs` +- Modify: `LocalLLMServerManager.Shared/Views/Controls/HuggingFaceTabControl.axaml` +- Modify: `LocalLLMServerManager.Tests/HuggingFaceSearchViewModelTests.cs` + +**Interfaces:** +- Produces: Initial model loading on launch, distinct visual active/inactive states for modality buttons and hardware filters. + +- [ ] **Step 1: Implement default starter model loading in HuggingFaceSearchViewModel** + Add an `InitializeDefaultModelsAsync()` method or auto-trigger in constructor/navigation that queries curated high-utility models (`Qwen/Qwen2.5-Coder-7B-Instruct-GGUF`, `meta-llama/Llama-3.2-3B-Instruct-GGUF`, `deepseek-ai/DeepSeek-R1-Distill-Qwen-7B-GGUF`, `Wan-AI/Wan2.1-T2V-14B`, `hexgrad/Kokoro-82M`) so the hub is populated immediately. + +- [ ] **Step 2: Update HuggingFaceTabControl.axaml with high-contrast active toggle styles** + - For input buttons (`Text`, `Image`, `Audio`, `Video`): Bind background and foreground to active states (`IsInputTextActive`, etc.). Active buttons show vibrant blue `#2563EB` with `✓` checkmark; inactive show muted `#1E293B`. + - For output buttons (`Text`, `Image`, `Audio`, `Video`, `3D`): Active buttons show vibrant purple `#8B5CF6` with `✓` checkmark; inactive show muted `#1E293B`. + - For hardware filters: Active chips show full color (`#10B981`, `#F59E0B`, `#F97316`, `#EF4444`) with solid border; inactive chips show dim `Opacity="0.35"`. + +- [ ] **Step 3: Verify and update HuggingFaceSearchViewModelTests** + Ensure tests verify initial loading and filter toggle states. Run `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~HuggingFaceSearchViewModelTests"`. + +- [ ] **Step 4: Commit Task 3** + ```bash + git add LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs LocalLLMServerManager.Shared/Views/Controls/HuggingFaceTabControl.axaml LocalLLMServerManager.Tests/HuggingFaceSearchViewModelTests.cs + git commit -m "feat(huggingface): add default model loading and clear active toggle styling" + ``` + +--- + +### Task 4: Verification, Quality Gates & Pull Request + +**Files:** +- All modified files. + +- [ ] **Step 1: Run complete test suite** + Run `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj -c Release --nologo` +- [ ] **Step 2: Run linter and TypeScript checks** + Run `npm run lint` and `npx tsc --noEmit` +- [ ] **Step 3: Run VitePress docs build** + Run `npm run docs:build` +- [ ] **Step 4: Commit and push feature branch** + Push `feature/fixes-and-adjustments` to origin and open a PR. From 0cd8c461e099adfee3c531ff98fa2ec34afa7362 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 17:40:02 -0500 Subject: [PATCH 03/28] feat(docs): implement hybrid floating documentation overlay and native pop-out window with smart tab navigation --- .../ViewModels/DocumentationViewModel.cs | 180 +++++++++++++----- .../ViewModels/MainViewModel.cs | 1 + .../DocumentationFloatingOverlay.axaml | 167 ++++++++++++++++ .../DocumentationFloatingOverlay.axaml.cs | 76 ++++++++ .../Controls/DocumentationTabControl.axaml | 36 +++- .../Views/MainView.axaml | 8 + .../DocumentationViewModelTests.cs | 44 +++++ Views/DocumentationWindow.axaml | 38 ++++ Views/DocumentationWindow.axaml.cs | 36 ++++ Views/MainWindow.axaml.cs | 19 +- 10 files changed, 554 insertions(+), 51 deletions(-) create mode 100644 LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml create mode 100644 LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml.cs create mode 100644 Views/DocumentationWindow.axaml create mode 100644 Views/DocumentationWindow.axaml.cs diff --git a/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs index e15a51d..01a35a8 100644 --- a/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs @@ -11,7 +11,8 @@ public record DocStep( int StepNumber, string Title, string Action, - string ExpectedResult + string ExpectedResult, + int? TargetTab = null ); public record DocSection( @@ -32,12 +33,103 @@ public partial class DocumentationViewModel : ObservableObject [ObservableProperty] private DocSection? _selectedSection; + [ObservableProperty] + private bool _isFloatingOverlayOpen = false; + + [ObservableProperty] + private bool _isMinimizedToPill = false; + + [ObservableProperty] + private double _overlayX = 30; + + [ObservableProperty] + private double _overlayY = 70; + + [ObservableProperty] + private int _currentStepIndex = 0; + + public DocStep? CurrentStep => SelectedSection?.Steps != null && CurrentStepIndex >= 0 && CurrentStepIndex < SelectedSection.Steps.Count + ? SelectedSection.Steps[CurrentStepIndex] + : null; + + public Action? OnNavigateToTabRequested { get; set; } + public Action? OnPopOutNativeWindowRequested { get; set; } + + [RelayCommand] + public void PopOutNativeWindow() + { + if (OnPopOutNativeWindowRequested != null) + { + OnPopOutNativeWindowRequested.Invoke(); + } + else + { + ToggleFloatingOverlay(); + } + } + public DocumentationViewModel() { InitializeSections(); SelectedSection = Sections.FirstOrDefault(); } + partial void OnSelectedSectionChanged(DocSection? value) + { + CurrentStepIndex = 0; + OnPropertyChanged(nameof(CurrentStep)); + } + + partial void OnCurrentStepIndexChanged(int value) + { + OnPropertyChanged(nameof(CurrentStep)); + } + + [RelayCommand] + public void ToggleFloatingOverlay() + { + IsFloatingOverlayOpen = !IsFloatingOverlayOpen; + if (IsFloatingOverlayOpen) + { + CurrentStepIndex = 0; + IsMinimizedToPill = false; + } + } + + [RelayCommand] + public void ToggleMinimizeToPill() + { + IsMinimizedToPill = !IsMinimizedToPill; + } + + [RelayCommand] + public void NextStep() + { + if (SelectedSection != null && CurrentStepIndex < SelectedSection.Steps.Count - 1) + { + CurrentStepIndex++; + } + } + + [RelayCommand] + public void PreviousStep() + { + if (CurrentStepIndex > 0) + { + CurrentStepIndex--; + } + } + + [RelayCommand] + public void JumpToStepTab(int? targetTab) + { + int tab = targetTab ?? CurrentStep?.TargetTab ?? -1; + if (tab >= 0) + { + OnNavigateToTabRequested?.Invoke(tab); + } + } + [RelayCommand] public void SelectSection(string sectionId) { @@ -61,12 +153,12 @@ private void InitializeSections() Prerequisite: "Start the Forge or ComfyUI engine in the Workflows tab.", Steps: new List { - new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens."), - new(2, "Select Image Studio", "Click the '🎨 Image' button in the studio selector.", "The Image generation controls display."), - new(3, "Choose Engine", "Select 'Stable Diffusion / Forge' or 'ComfyUI'.", "The engine status shows Online."), - new(4, "Select Style Preset", "Click a preset chip such as 'Standard Square' or 'Landscape Wallpaper'.", "Resolution and sampling parameters load automatically."), - new(5, "Enter Text Prompt", "Type your descriptive prompt into the Prompt box. Add unwanted features into the Negative Prompt box.", "Prompt text is ready for generation."), - new(6, "Click Generate Image", "Click the '🎨 Generate Image' button.", "The stage tracker shows generation progress and the final image appears in the gallery.") + new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens.", TargetTab: 1), + new(2, "Select Image Studio", "Click the '🎨 Image' button in the studio selector.", "The Image generation controls display.", TargetTab: 1), + new(3, "Choose Engine", "Select 'Stable Diffusion / Forge' or 'ComfyUI'.", "The engine status shows Online.", TargetTab: 1), + new(4, "Select Style Preset", "Click a preset chip such as 'Standard Square' or 'Landscape Wallpaper'.", "Resolution and sampling parameters load automatically.", TargetTab: 1), + new(5, "Enter Text Prompt", "Type your descriptive prompt into the Prompt box. Add unwanted features into the Negative Prompt box.", "Prompt text is ready for generation.", TargetTab: 1), + new(6, "Click Generate Image", "Click the '🎨 Generate Image' button.", "The stage tracker shows generation progress and the final image appears in the gallery.", TargetTab: 1) }, Notes: new List { @@ -88,12 +180,12 @@ private void InitializeSections() Prerequisite: "Verify that Ollama status shows Online in the top telemetry header.", Steps: new List { - new(1, "Open Models Tab", "Click 'Models' in the top navigation bar.", "The Models management view opens."), - new(2, "Download or Select Model", "Select 'Downloaded & Manage'. Choose a model from your installed list, or pull a new model from the library.", "The model is loaded and ready for inference."), - new(3, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar, then select '💬 Text'.", "The Text LLM generation studio displays."), - new(4, "Configure Context Window", "Adjust target context tokens if necessary. Review the estimated KV cache memory text.", "Target memory stays within available GPU VRAM limits."), - new(5, "Enter Prompt", "Type your question or instructions into the text area.", "The prompt is ready to submit."), - new(6, "Click Generate Text", "Click the '💬 Generate Text' button.", "The model generates the response in real time.") + new(1, "Open Models Tab", "Click 'Models' in the top navigation bar.", "The Models management view opens.", TargetTab: 0), + new(2, "Download or Select Model", "Select 'Downloaded & Manage'. Choose a model from your installed list, or pull a new model from the library.", "The model is loaded and ready for inference.", TargetTab: 0), + new(3, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar, then select '💬 Text'.", "The Text LLM generation studio displays.", TargetTab: 1), + new(4, "Configure Context Window", "Adjust target context tokens if necessary. Review the estimated KV cache memory text.", "Target memory stays within available GPU VRAM limits.", TargetTab: 1), + new(5, "Enter Prompt", "Type your question or instructions into the text area.", "The prompt is ready to submit.", TargetTab: 1), + new(6, "Click Generate Text", "Click the '💬 Generate Text' button.", "The model generates the response in real time.", TargetTab: 1) }, Notes: new List { @@ -110,12 +202,12 @@ private void InitializeSections() Prerequisite: "Verify that at least 8 GB of VRAM is available and ComfyUI is running.", Steps: new List { - new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens."), - new(2, "Select Video Studio", "Click the '🎬 Video' button in the studio selector.", "The Video generation studio displays."), - new(3, "Select Video Preset", "Click a preset chip (for example, 'Quick 480p Preview' or 'Cinematic HD 720p').", "Workflow model, resolution, and frame counts populate automatically."), - new(4, "Enter Prompt", "Type your scene description into the Prompt box.", "The scene description is validated."), - new(5, "Click Generate Video", "Click the '🎬 Generate Video' button.", "The 4-stage pipeline tracker shows live progress, VRAM load, and denoising steps."), - new(6, "Preview and Export", "When generation finishes, the clip loads in the interactive player. Click 'Export' to save the MP4 file.", "The MP4 video file is saved to your disk.") + new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens.", TargetTab: 1), + new(2, "Select Video Studio", "Click the '🎬 Video' button in the studio selector.", "The Video generation studio displays.", TargetTab: 1), + new(3, "Select Video Preset", "Click a preset chip (for example, 'Quick 480p Preview' or 'Cinematic HD 720p').", "Workflow model, resolution, and frame counts populate automatically.", TargetTab: 1), + new(4, "Enter Prompt", "Type your scene description into the Prompt box.", "The scene description is validated.", TargetTab: 1), + new(5, "Click Generate Video", "Click the '🎬 Generate Video' button.", "The 4-stage pipeline tracker shows live progress, VRAM load, and denoising steps.", TargetTab: 1), + new(6, "Preview and Export", "When generation finishes, the clip loads in the interactive player. Click 'Export' to save the MP4 file.", "The MP4 video file is saved to your disk.", TargetTab: 1) }, Notes: new List { @@ -136,12 +228,12 @@ private void InitializeSections() Prerequisite: "Ensure the Audio Feature Pack is installed in the Settings tab.", Steps: new List { - new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens."), - new(2, "Select Audio Studio", "Click the '🎵 Audio' button in the studio selector.", "The Audio generation studio displays."), - new(3, "Choose Workflow", "Select 'Kokoro TTS' for speech narration or 'Stable Audio Open' for sound effects.", "Controls update with duration, seed, and voice parameters."), - new(4, "Enter Audio Prompt", "Type the speech text or sound effect description into the prompt box.", "The input text is ready for synthesis."), - new(5, "Queue Audio Generation", "Click 'Queue Audio Generation Workflow'.", "The audio stage tracker monitors generation and the waveform visualizer activates."), - new(6, "Listen and Play", "Click the Play button in the player bar to listen to the synthesized audio.", "Audio plays through your default speaker output.") + new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens.", TargetTab: 1), + new(2, "Select Audio Studio", "Click the '🎵 Audio' button in the studio selector.", "The Audio generation studio displays.", TargetTab: 1), + new(3, "Choose Workflow", "Select 'Kokoro TTS' for speech narration or 'Stable Audio Open' for sound effects.", "Controls update with duration, seed, and voice parameters.", TargetTab: 1), + new(4, "Enter Audio Prompt", "Type the speech text or sound effect description into the prompt box.", "The input text is ready for synthesis.", TargetTab: 1), + new(5, "Queue Audio Generation", "Click 'Queue Audio Generation Workflow'.", "The audio stage tracker monitors generation and the waveform visualizer activates.", TargetTab: 1), + new(6, "Listen and Play", "Click the Play button in the player bar to listen to the synthesized audio.", "Audio plays through your default speaker output.", TargetTab: 1) } )); @@ -154,12 +246,12 @@ private void InitializeSections() Prerequisite: "Ensure ComfyUI 3D pack is enabled with at least 12 GB VRAM free.", Steps: new List { - new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens."), - new(2, "Select 3D Mesh Studio", "Click the '📦 3D' button in the studio selector.", "The 3D studio controls display."), - new(3, "Select Mode", "Choose 'Text-to-3D' or 'Image-to-3D'.", "Input parameters load for the selected mode."), - new(4, "Enter Prompt or Image", "Type the object description or supply an input image.", "Input data is staged for 3D reconstruction."), - new(5, "Click Generate 3D Mesh", "Click the '📦 Generate 3D Mesh' button.", "The system runs sparse-structure and texture generation passes."), - new(6, "Preview and Download", "Inspect the rendered model and click 'Download GLB'.", "The 3D GLB file is ready for import into Blender or 3D engines.") + new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens.", TargetTab: 1), + new(2, "Select 3D Mesh Studio", "Click the '📦 3D' button in the studio selector.", "The 3D studio controls display.", TargetTab: 1), + new(3, "Select Mode", "Choose 'Text-to-3D' or 'Image-to-3D'.", "Input parameters load for the selected mode.", TargetTab: 1), + new(4, "Enter Prompt or Image", "Type the object description or supply an input image.", "Input data is staged for 3D reconstruction.", TargetTab: 1), + new(5, "Click Generate 3D Mesh", "Click the '📦 Generate 3D Mesh' button.", "The system runs sparse-structure and texture generation passes.", TargetTab: 1), + new(6, "Preview and Download", "Inspect the rendered model and click 'Download GLB'.", "The 3D GLB file is ready for import into Blender or 3D engines.", TargetTab: 1) } )); @@ -172,11 +264,11 @@ private void InitializeSections() Prerequisite: "Ensure an active internet connection is available.", Steps: new List { - new(1, "Open Models Tab", "Click 'Models' in the top navigation bar.", "The Models hub opens."), - new(2, "Select Hub", "Select '🤗 Hugging Face Hub' for GGUF/multimodal models, or '🎨 CivitAI Hub' for SD checkpoints and LoRAs.", "The corresponding search view displays."), - new(3, "Search or Click Suggestion", "Type keywords into the search box or click a suggestion chip (such as 'SDXL' or 'Photorealistic').", "The loading indicator displays while searching."), - new(4, "Verify Hardware Fit", "Inspect the green, yellow, or red compatibility badge on the model card.", "The badge confirms whether the model fits into your GPU VRAM."), - new(5, "Click Download", "Click 'Download' on the desired model card.", "A download toast notification confirms the operation and progress begins.") + new(1, "Open Models Tab", "Click 'Models' in the top navigation bar.", "The Models hub opens.", TargetTab: 0), + new(2, "Select Hub", "Select '🤗 Hugging Face Hub' for GGUF/multimodal models, or '🎨 CivitAI Hub' for SD checkpoints and LoRAs.", "The corresponding search view displays.", TargetTab: 0), + new(3, "Search or Click Suggestion", "Type keywords into the search box or click a suggestion chip (such as 'SDXL' or 'Photorealistic').", "The loading indicator displays while searching.", TargetTab: 0), + new(4, "Verify Hardware Fit", "Inspect the green, yellow, or red compatibility badge on the model card.", "The badge confirms whether the model fits into your GPU VRAM.", TargetTab: 0), + new(5, "Click Download", "Click 'Download' on the desired model card.", "A download toast notification confirms the operation and progress begins.", TargetTab: 0) } )); @@ -189,10 +281,10 @@ private void InitializeSections() Prerequisite: "Check that GPU memory telemetry is visible in the top header.", Steps: new List { - new(1, "Open Can I Run It Tab", "Click '⚡ Can I Run It' in the top navigation bar.", "The hardware fit calculator view opens."), - new(2, "Select Modality", "Choose LLM, Image, Video, Audio, or 3D.", "Parameters for the chosen modality appear."), - new(3, "Enter Model Size", "Enter the model name, size in GB, or select a known preset.", "The system computes VRAM and system RAM requirements."), - new(4, "Read Verdict Banner", "Review the verdict: Full VRAM (Fastest), Partial Offload (Usable), CPU Only (Slow), or Out of Memory (Incompatible).", "You know if the model will run smoothly without system crashes.") + new(1, "Open Can I Run It Tab", "Click '⚡ Can I Run It' in the top navigation bar.", "The hardware fit calculator view opens.", TargetTab: 2), + new(2, "Select Modality", "Choose LLM, Image, Video, Audio, or 3D.", "Parameters for the chosen modality appear.", TargetTab: 2), + new(3, "Enter Model Size", "Enter the model name, size in GB, or select a known preset.", "The system computes VRAM and system RAM requirements.", TargetTab: 2), + new(4, "Read Verdict Banner", "Review the verdict: Full VRAM (Fastest), Partial Offload (Usable), CPU Only (Slow), or Out of Memory (Incompatible).", "You know if the model will run smoothly without system crashes.", TargetTab: 2) } )); @@ -205,10 +297,10 @@ private void InitializeSections() Prerequisite: "Check the service status pills in the top header.", Steps: new List { - new(1, "Check Service Badges", "Inspect the Ollama, Forge, and ComfyUI status pills in the header.", "Green indicates Online. Red indicates Offline. Yellow indicates Busy."), - new(2, "Start an Offline Engine", "If an engine shows Offline, open the Workflows tab and click 'Toggle Engine' or click 'Refresh' in the header.", "The engine starts and the pill turns green."), - new(3, "Clear GPU Memory", "If a model fails with Out Of Memory, click 'Refresh' or unload unused models in the Models tab.", "GPU VRAM is freed for the next generation."), - new(4, "Verify Tool Discovery", "Open Settings and review the Tool Discovery section if an engine fails to launch.", "Paths to Python, Git, and engines are validated.") + new(1, "Check Service Badges", "Inspect the Ollama, Forge, and ComfyUI status pills in the header.", "Green indicates Online. Red indicates Offline. Yellow indicates Busy.", TargetTab: 5), + new(2, "Start an Offline Engine", "If an engine shows Offline, open the Workflows tab and click 'Toggle Engine' or click 'Refresh' in the header.", "The engine starts and the pill turns green.", TargetTab: 1), + new(3, "Clear GPU Memory", "If a model fails with Out Of Memory, click 'Refresh' or unload unused models in the Models tab.", "GPU VRAM is freed for the next generation.", TargetTab: 0), + new(4, "Verify Tool Discovery", "Open Settings and review the Tool Discovery section if an engine fails to launch.", "Paths to Python, Git, and engines are validated.", TargetTab: 5) } )); } diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs index 90dc1db..240b7fb 100644 --- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs @@ -159,6 +159,7 @@ public MainViewModel( Settings = new SettingsViewModel(PresetService) { ApiBase = ApiBase }; Audio = new AudioStudioViewModel(PresetService, _canIRunItService) { ApiBase = ApiBase }; Assistant = new AiAssistantViewModel(assistantService, settingsService, promptService, httpClient ?? Http); + Documentation.OnNavigateToTabRequested = tab => SelectedTabIndex = tab; LoadStudioPresets(); RecalculateVideoHardwareFit(); diff --git a/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml b/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml new file mode 100644 index 0000000..465b83c --- /dev/null +++ b/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml.cs b/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml.cs new file mode 100644 index 0000000..ee8d6c1 --- /dev/null +++ b/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml.cs @@ -0,0 +1,76 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using LocalLLMServerManager.Shared.ViewModels; + +namespace LocalLLMServerManager.Shared.Views.Controls; + +public partial class DocumentationFloatingOverlay : UserControl +{ + private Point _dragStartPoint; + private bool _isDragging = false; + + public DocumentationFloatingOverlay() + { + InitializeComponent(); + + var pillHandle = this.FindControl("PillDragHandle"); + if (pillHandle != null) + { + pillHandle.PointerPressed += OnDragHandlePointerPressed; + pillHandle.PointerMoved += OnDragHandlePointerMoved; + pillHandle.PointerReleased += OnDragHandlePointerReleased; + } + + var cardHandle = this.FindControl("CardDragHandle"); + if (cardHandle != null) + { + cardHandle.PointerPressed += OnDragHandlePointerPressed; + cardHandle.PointerMoved += OnDragHandlePointerMoved; + cardHandle.PointerReleased += OnDragHandlePointerReleased; + } + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void OnDragHandlePointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + _isDragging = true; + _dragStartPoint = e.GetPosition(this.Parent as Visual); + e.Pointer.Capture(sender as IInputElement); + e.Handled = true; + } + } + + private void OnDragHandlePointerMoved(object? sender, PointerEventArgs e) + { + if (_isDragging && DataContext is DocumentationViewModel vm && this.Parent is Visual parentVisual) + { + var currentPos = e.GetPosition(parentVisual); + var deltaX = currentPos.X - _dragStartPoint.X; + var deltaY = currentPos.Y - _dragStartPoint.Y; + + vm.OverlayX = Math.Max(10, vm.OverlayX + deltaX); + vm.OverlayY = Math.Max(10, vm.OverlayY + deltaY); + + _dragStartPoint = currentPos; + e.Handled = true; + } + } + + private void OnDragHandlePointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (_isDragging) + { + _isDragging = false; + e.Pointer.Capture(null); + e.Handled = true; + } + } +} diff --git a/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml index 090aa1b..8ef22c6 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml @@ -7,13 +7,26 @@ - - + + - + + + @@ -52,9 +65,20 @@ - - - + + + + + + diff --git a/LocalLLMServerManager.Shared/Views/MainView.axaml b/LocalLLMServerManager.Shared/Views/MainView.axaml index 86cd513..8cb7101 100644 --- a/LocalLLMServerManager.Shared/Views/MainView.axaml +++ b/LocalLLMServerManager.Shared/Views/MainView.axaml @@ -58,6 +58,14 @@ + + + + + diff --git a/LocalLLMServerManager.Tests/DocumentationViewModelTests.cs b/LocalLLMServerManager.Tests/DocumentationViewModelTests.cs index aa8d7d8..22e1167 100644 --- a/LocalLLMServerManager.Tests/DocumentationViewModelTests.cs +++ b/LocalLLMServerManager.Tests/DocumentationViewModelTests.cs @@ -58,4 +58,48 @@ public void SelectSection_SwitchesActiveDocument() // Should retain previous selection if target not found Assert.Equal("can-i-run-it", vm.SelectedSection.Id); } + + [Fact] + public void FloatingOverlay_TogglesAndNavigatesSteps() + { + var vm = new DocumentationViewModel(); + Assert.False(vm.IsFloatingOverlayOpen); + + vm.ToggleFloatingOverlay(); + Assert.True(vm.IsFloatingOverlayOpen); + Assert.False(vm.IsMinimizedToPill); + Assert.Equal(0, vm.CurrentStepIndex); + Assert.NotNull(vm.CurrentStep); + + vm.NextStep(); + Assert.Equal(1, vm.CurrentStepIndex); + + vm.PreviousStep(); + Assert.Equal(0, vm.CurrentStepIndex); + + vm.ToggleMinimizeToPill(); + Assert.True(vm.IsMinimizedToPill); + + vm.ToggleMinimizeToPill(); + Assert.False(vm.IsMinimizedToPill); + + vm.ToggleFloatingOverlay(); + Assert.False(vm.IsFloatingOverlayOpen); + } + + [Fact] + public void JumpToStepTab_InvokesCallbackWithCorrectTargetTab() + { + var vm = new DocumentationViewModel(); + vm.SelectSection("image-generation"); + + int receivedTab = -1; + vm.OnNavigateToTabRequested = tab => receivedTab = tab; + + Assert.NotNull(vm.CurrentStep); + Assert.Equal(1, vm.CurrentStep.TargetTab); + + vm.JumpToStepTab(null); + Assert.Equal(1, receivedTab); + } } diff --git a/Views/DocumentationWindow.axaml b/Views/DocumentationWindow.axaml new file mode 100644 index 0000000..a07061d --- /dev/null +++ b/Views/DocumentationWindow.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/DocumentationWindow.axaml.cs b/Views/DocumentationWindow.axaml.cs new file mode 100644 index 0000000..4884fc5 --- /dev/null +++ b/Views/DocumentationWindow.axaml.cs @@ -0,0 +1,36 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using LocalLLMServerManager.Shared.ViewModels; + +namespace LocalLLMServerManager.Views; + +public partial class DocumentationWindow : Window +{ + public DocumentationWindow() + { + InitializeComponent(); + } + + public DocumentationWindow(DocumentationViewModel viewModel) : this() + { + DataContext = viewModel; + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void OnPinClicked(object? sender, RoutedEventArgs e) + { + Topmost = !Topmost; + var pinText = this.FindControl("PinText"); + if (pinText != null) + { + pinText.Text = Topmost ? "📌 Pinned (Topmost)" : "📌 Pin to Top"; + pinText.Foreground = Topmost ? Brushes.LimeGreen : Brushes.Gray; + } + } +} diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs index e3c4608..9b5aa78 100644 --- a/Views/MainWindow.axaml.cs +++ b/Views/MainWindow.axaml.cs @@ -9,10 +9,27 @@ namespace LocalLLMServerManager.Views; public partial class MainWindow : Window { + private DocumentationWindow? _docWindow; + public MainWindow() { InitializeComponent(); - DataContext = new MainViewModel(); + var mainVm = new MainViewModel(); + DataContext = mainVm; + + mainVm.Documentation.OnPopOutNativeWindowRequested = () => + { + if (_docWindow == null || !_docWindow.IsVisible) + { + _docWindow = new DocumentationWindow(mainVm.Documentation); + _docWindow.Closed += (s, e) => _docWindow = null; + _docWindow.Show(); + } + else + { + _docWindow.Activate(); + } + }; PropertyChanged += (sender, e) => { From b963c69fd8ea54b20df8163f3ec8569724b8fa63 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 17:53:12 -0500 Subject: [PATCH 04/28] feat(hub): overhaul filter states, starter models, and active button styling across hubs - Pre-populate curated starter models and default downloads on initial launch for Hugging Face and CivitAI hubs so hubs are never empty. - Fix pipeline tag matching and VLM modality categorization for Hugging Face search. - Connect CivitAI starter models to hardware and model type filters (FilteredStarterModels) with explicit Checkpoint / LoRA / All Type toggles. - Add high-contrast active state indicators with checkmarks across Hugging Face, CivitAI, Ollama Models, Can I Run It, and Settings Studio Presets. - Update and add comprehensive unit test suites in HuggingFaceSearchViewModelTests, CivitaiSearchViewModelTests, and SearchServicesTests. --- Endpoints/ModelProxyEndpoints.cs | 12 +- .../Services/HuggingFaceSearchService.cs | 5 +- .../ViewModels/CivitaiSearchViewModel.cs | 78 ++++-- .../ViewModels/HuggingFaceSearchViewModel.cs | 104 +++++++- .../ViewModels/MainViewModel.cs | 2 + .../ViewModels/SettingsViewModel.cs | 13 +- .../Views/Controls/CanIRunItView.axaml | 67 ++++- .../Views/Controls/CivitaiTabControl.axaml | 175 ++++++++++--- .../Controls/HuggingFaceTabControl.axaml | 245 ++++++++++++++++-- .../Controls/OllamaModelsTabControl.axaml | 56 +++- .../Views/Controls/SettingsTabControl.axaml | 55 +++- .../CivitaiSearchViewModelTests.cs | 54 ++++ .../HuggingFaceSearchViewModelTests.cs | 98 +++++++ .../SearchServicesTests.cs | 1 + 14 files changed, 859 insertions(+), 106 deletions(-) diff --git a/Endpoints/ModelProxyEndpoints.cs b/Endpoints/ModelProxyEndpoints.cs index 639feae..e0375e1 100644 --- a/Endpoints/ModelProxyEndpoints.cs +++ b/Endpoints/ModelProxyEndpoints.cs @@ -187,24 +187,24 @@ async Task HandleDeleteModel(HttpContext context, IHttpClientFactory cl var tag = !string.IsNullOrWhiteSpace(pipeline_tag) ? pipeline_tag : pipeline_tags; string requestUrl; + var searchParam = !string.IsNullOrWhiteSpace(query) ? $"search={Uri.EscapeDataString(query)}&" : ""; if (!string.IsNullOrWhiteSpace(tag)) { if (tag.Equals("gguf", StringComparison.OrdinalIgnoreCase)) { - var qParam = string.IsNullOrWhiteSpace(query) ? "llama" : query; - requestUrl = $"https://huggingface.co/api/models?search={Uri.EscapeDataString(qParam)}&filter=gguf&sort=downloads&direction=-1&limit=25"; + var qFallback = string.IsNullOrWhiteSpace(query) ? "search=llama&" : searchParam; + requestUrl = $"https://huggingface.co/api/models?{qFallback}filter=gguf&sort=downloads&direction=-1&limit=25"; } else { - var qParam = Uri.EscapeDataString(query); var tagParam = Uri.EscapeDataString(tag); - requestUrl = $"https://huggingface.co/api/models?search={qParam}&pipeline_tag={tagParam}&sort=downloads&direction=-1&limit=25"; + requestUrl = $"https://huggingface.co/api/models?{searchParam}pipeline_tag={tagParam}&sort=downloads&direction=-1&limit=25"; } } else { - var qParam = string.IsNullOrWhiteSpace(query) ? "llama" : query; - requestUrl = $"https://huggingface.co/api/models?search={Uri.EscapeDataString(qParam)}&filter=gguf&sort=downloads&direction=-1&limit=25"; + var qFallback = string.IsNullOrWhiteSpace(query) ? "search=llama&" : searchParam; + requestUrl = $"https://huggingface.co/api/models?{qFallback}filter=gguf&sort=downloads&direction=-1&limit=25"; } using var req = new HttpRequestMessage(HttpMethod.Get, requestUrl); diff --git a/LocalLLMServerManager.Shared/Services/HuggingFaceSearchService.cs b/LocalLLMServerManager.Shared/Services/HuggingFaceSearchService.cs index f6cf39d..df7761e 100644 --- a/LocalLLMServerManager.Shared/Services/HuggingFaceSearchService.cs +++ b/LocalLLMServerManager.Shared/Services/HuggingFaceSearchService.cs @@ -78,8 +78,9 @@ public async Task> SearchRepositoriesAsync(string apiB public async Task> SearchModelsAsync(string query, string? pipelineTag = null, System.Threading.CancellationToken ct = default) { using var http = new HttpClient(); - var tagParam = string.IsNullOrWhiteSpace(pipelineTag) ? "" : $"&pipeline_tag={Uri.EscapeDataString(pipelineTag)}"; - var requestUrl = $"https://huggingface.co/api/models?search={Uri.EscapeDataString(query)}{tagParam}&sort=downloads&direction=-1&limit=20"; + var searchParam = string.IsNullOrWhiteSpace(query) ? "" : $"search={Uri.EscapeDataString(query)}&"; + var tagParam = string.IsNullOrWhiteSpace(pipelineTag) ? "" : $"pipeline_tag={Uri.EscapeDataString(pipelineTag)}&"; + var requestUrl = $"https://huggingface.co/api/models?{searchParam}{tagParam}sort=downloads&direction=-1&limit=20"; var result = new List(); try { diff --git a/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs index 2c10116..90769f0 100644 --- a/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs @@ -17,11 +17,23 @@ public partial class CivitaiSearchViewModel : ObservableObject private readonly ITelemetryService? _telemetryService; [ObservableProperty] private string _civitaiSearchQuery = ""; - [ObservableProperty] private string _selectedCivitaiType = "Checkpoint"; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsTypeAllActive))] + [NotifyPropertyChangedFor(nameof(IsTypeCheckpointActive))] + [NotifyPropertyChangedFor(nameof(IsTypeLoraActive))] + private string _selectedCivitaiType = "Checkpoint"; + + public bool IsTypeAllActive => string.IsNullOrWhiteSpace(SelectedCivitaiType) || SelectedCivitaiType.Equals("All", StringComparison.OrdinalIgnoreCase); + public bool IsTypeCheckpointActive => SelectedCivitaiType.Equals("Checkpoint", StringComparison.OrdinalIgnoreCase); + public bool IsTypeLoraActive => SelectedCivitaiType.Equals("LORA", StringComparison.OrdinalIgnoreCase); + + [ObservableProperty] private string? _activeStarterChip = null; + [ObservableProperty] private bool _isLoading = false; public ObservableCollection CivitaiResults { get; } = new(); public ObservableCollection FilteredCivitaiResults { get; } = new(); public ObservableCollection StarterModels { get; } = new(); + public ObservableCollection FilteredStarterModels { get; } = new(); public static readonly List DefaultStarterModels = new() { @@ -71,6 +83,7 @@ private void InitStarterModels() var badge = _canIRunItService.EvaluateQuickFit(s.Name, s.SizeBytes > 0 ? s.SizeBytes : null, "Image", (long)TotalVramMb, (long)TotalRamMb); StarterModels.Add(s with { FitBadge = badge }); } + ApplyFilter(); } public void UpdateHardwareTelemetry(double totalVramMb, double totalRamMb) @@ -103,28 +116,39 @@ public void ApplyFilter() FilteredCivitaiResults.Clear(); foreach (var r in CivitaiResults) { - if (r.FitBadge == null) + if (MatchesVerdictFilter(r.FitBadge)) { FilteredCivitaiResults.Add(r); - continue; } + } - bool matches = r.FitBadge.FitVerdict switch - { - FitVerdict.FullVram => IsFullVramActive, - FitVerdict.PartialOffload => IsPartialOffloadActive, - FitVerdict.CpuOnly => IsCpuOnlyActive, - FitVerdict.OutOfMemory => IsOomActive, - _ => true - }; - - if (matches) + FilteredStarterModels.Clear(); + foreach (var s in StarterModels) + { + bool typeMatches = string.IsNullOrWhiteSpace(SelectedCivitaiType) || + SelectedCivitaiType.Equals("All", StringComparison.OrdinalIgnoreCase) || + s.Type.Equals(SelectedCivitaiType, StringComparison.OrdinalIgnoreCase); + + if (typeMatches && MatchesVerdictFilter(s.FitBadge)) { - FilteredCivitaiResults.Add(r); + FilteredStarterModels.Add(s); } } } + private bool MatchesVerdictFilter(QuickFitBadge? fitBadge) + { + if (fitBadge == null) return true; + return fitBadge.FitVerdict switch + { + FitVerdict.FullVram => IsFullVramActive, + FitVerdict.PartialOffload => IsPartialOffloadActive, + FitVerdict.CpuOnly => IsCpuOnlyActive, + FitVerdict.OutOfMemory => IsOomActive, + _ => true + }; + } + [RelayCommand] public void ToggleFitVerdict(string verdict) { @@ -153,9 +177,19 @@ public void ToggleFitVerdict(string verdict) partial void OnIsCpuOnlyActiveChanged(bool value) => ApplyFilter(); partial void OnIsOomActiveChanged(bool value) => ApplyFilter(); + [RelayCommand] + public void SelectCivitaiType(string? type) + { + SelectedCivitaiType = string.IsNullOrWhiteSpace(type) ? "All" : type; + ActiveStarterChip = null; + ApplyFilter(); + _ = SearchCivitaiAsync(); + } + [RelayCommand] public void ApplyStarterChip(string? chip) { + ActiveStarterChip = chip; var clean = (chip ?? "").Replace("🌟", "").Replace("📸", "").Replace("🎨", "").Replace("🎭", "").Replace("⚡", "").Replace("🔮", "").Trim(); if (clean.Contains("LoRA", StringComparison.OrdinalIgnoreCase)) { @@ -188,6 +222,19 @@ public void InspectModel(CivitaiModelItem? item) } } + public async Task LoadDefaultModelsAsync(string apiBase, HttpClient http) + { + if (CivitaiResults.Count > 0) return; + try + { + await SearchCivitaiAsync(apiBase, http); + } + catch + { + // Starter models are retained + } + } + [RelayCommand] public async Task SearchCivitaiAsync() { @@ -199,7 +246,8 @@ public async Task SearchCivitaiAsync(string apiBase, HttpClient http) IsLoading = true; try { - var results = await _civitaiSearchService.SearchModelsAsync(apiBase, CivitaiSearchQuery, SelectedCivitaiType, "Most Downloaded", http); + string typeParam = SelectedCivitaiType.Equals("All", StringComparison.OrdinalIgnoreCase) ? "" : SelectedCivitaiType; + var results = await _civitaiSearchService.SearchModelsAsync(apiBase, CivitaiSearchQuery, typeParam, "Most Downloaded", http); CivitaiResults.Clear(); foreach (var r in results) { diff --git a/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs index 3386cd1..1deda8b 100644 --- a/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs @@ -35,6 +35,25 @@ public partial class HuggingFaceSearchViewModel : ObservableObject public ObservableCollection SelectedInputModalities { get; } = new() { "Text" }; public ObservableCollection SelectedOutputModalities { get; } = new() { "Text" }; + [ObservableProperty] private string? _activePreset = "LLM"; + + public bool IsPresetMultimodal => string.Equals(ActivePreset, "Multimodal", StringComparison.OrdinalIgnoreCase); + public bool IsPresetLlm => string.Equals(ActivePreset, "LLM", StringComparison.OrdinalIgnoreCase); + public bool IsPresetImage => string.Equals(ActivePreset, "Image", StringComparison.OrdinalIgnoreCase); + public bool IsPresetVideo => string.Equals(ActivePreset, "Video", StringComparison.OrdinalIgnoreCase); + public bool IsPresetAudio => string.Equals(ActivePreset, "Audio", StringComparison.OrdinalIgnoreCase); + public bool IsPreset3D => string.Equals(ActivePreset, "3D", StringComparison.OrdinalIgnoreCase); + + partial void OnActivePresetChanged(string? value) + { + OnPropertyChanged(nameof(IsPresetMultimodal)); + OnPropertyChanged(nameof(IsPresetLlm)); + OnPropertyChanged(nameof(IsPresetImage)); + OnPropertyChanged(nameof(IsPresetVideo)); + OnPropertyChanged(nameof(IsPresetAudio)); + OnPropertyChanged(nameof(IsPreset3D)); + } + [ObservableProperty] private bool _isInputTextActive = true; [ObservableProperty] private bool _isInputImageActive = false; [ObservableProperty] private bool _isInputAudioActive = false; @@ -71,6 +90,7 @@ public HuggingFaceSearchViewModel( _canIRunItService = canIRunItService ?? new CanIRunItService(); _telemetryService = telemetryService; HuggingFaceResults.CollectionChanged += (s, e) => ApplyFilter(); + LoadCuratedStarterModels(); } public void UpdateHardwareTelemetry(double totalVramMb, double totalRamMb) @@ -169,6 +189,8 @@ public void ToggleInputModality(string modality) else if (m.Equals("Video", StringComparison.OrdinalIgnoreCase)) IsInputVideoActive = !IsInputVideoActive; + ActivePreset = null; + SelectedPipelineTag = null; SyncInputModalitiesList(); _ = SearchHuggingFaceAsync(); } @@ -188,6 +210,8 @@ public void ToggleOutputModality(string modality) else if (m.Equals("3D", StringComparison.OrdinalIgnoreCase) || m.Equals("ThreeD", StringComparison.OrdinalIgnoreCase)) IsOutputThreeDActive = !IsOutputThreeDActive; + ActivePreset = null; + SelectedPipelineTag = null; SyncOutputModalitiesList(); _ = SearchHuggingFaceAsync(); } @@ -214,9 +238,11 @@ public void SyncOutputModalitiesList() [RelayCommand] public void ApplyPreset(string preset) { + SelectedPipelineTag = null; var p = (preset ?? "").Trim().ToLowerInvariant(); if (p.Contains("multimodal") || p.Contains("vlm") || p.Contains("vision")) { + ActivePreset = "Multimodal"; IsInputTextActive = true; IsInputImageActive = true; IsInputAudioActive = false; @@ -230,6 +256,7 @@ public void ApplyPreset(string preset) } else if (p.Contains("llm") || p.Contains("text")) { + ActivePreset = "LLM"; IsInputTextActive = true; IsInputImageActive = false; IsInputAudioActive = false; @@ -243,6 +270,7 @@ public void ApplyPreset(string preset) } else if (p.Contains("image") || p.Contains("diffusion")) { + ActivePreset = "Image"; IsInputTextActive = true; IsInputImageActive = false; IsInputAudioActive = false; @@ -256,6 +284,7 @@ public void ApplyPreset(string preset) } else if (p.Contains("video")) { + ActivePreset = "Video"; IsInputTextActive = true; IsInputImageActive = true; IsInputAudioActive = false; @@ -269,6 +298,7 @@ public void ApplyPreset(string preset) } else if (p.Contains("audio") || p.Contains("speech") || p.Contains("tts")) { + ActivePreset = "Audio"; IsInputTextActive = true; IsInputImageActive = false; IsInputAudioActive = true; @@ -282,6 +312,7 @@ public void ApplyPreset(string preset) } else if (p.Contains("3d")) { + ActivePreset = "3D"; IsInputTextActive = true; IsInputImageActive = true; IsInputAudioActive = false; @@ -316,7 +347,11 @@ public static List ResolvePipelineTags(IEnumerable inputs, IEnum { tags.Add("image-to-text"); } - else if (inSet.Contains("Text") && outSet.Contains("Text")) + else if (inSet.Contains("Video") && outSet.Contains("Text")) + { + tags.Add("video-text-to-text"); + } + else if (inSet.Contains("Text") && outSet.Contains("Text") && !inSet.Contains("Audio") && !outSet.Contains("Audio") && !outSet.Contains("Image") && !outSet.Contains("Video") && !outSet.Contains("3D")) { tags.Add("text-generation"); } @@ -334,6 +369,7 @@ public static List ResolvePipelineTags(IEnumerable inputs, IEnum if (outSet.Contains("Video")) { if (inSet.Contains("Image")) tags.Add("image-to-video"); + if (inSet.Contains("Video")) tags.Add("video-to-video"); tags.Add("text-to-video"); } @@ -341,6 +377,7 @@ public static List ResolvePipelineTags(IEnumerable inputs, IEnum { tags.Add("text-to-speech"); tags.Add("text-to-audio"); + if (inSet.Contains("Audio")) tags.Add("audio-to-audio"); } if (inSet.Contains("Audio") && outSet.Contains("Text")) @@ -362,7 +399,11 @@ public static string DetermineModality(string modelName, string? pipelineTag) string tag = (pipelineTag ?? "").ToLowerInvariant(); string name = (modelName ?? "").ToLowerInvariant(); - if (tag.Contains("video") || name.Contains("wan") || name.Contains("ltx") || name.Contains("hunyuanvideo")) + // Check multimodal VLM first so image-to-text / image-text-to-text isn't misclassified as image diffusion + if (tag.Contains("image-text-to-text") || tag.Contains("image-to-text") || tag.Contains("visual-question-answering") || tag.Contains("vlm") || name.Contains("vl-") || name.Contains("-vl")) + return "LLM"; + + if (tag.Contains("video") || name.Contains("wan") || name.Contains("ltx") || name.Contains("hunyuanvideo") || name.Contains("cogvideox")) return "Video"; if (tag.Contains("audio") || tag.Contains("speech") || tag.Contains("tts") || name.Contains("kokoro") || name.Contains("whisper") || name.Contains("xtts")) return "Audio"; @@ -374,6 +415,55 @@ public static string DetermineModality(string modelName, string? pipelineTag) return "LLM"; } + public static List GetCuratedStarterModels() + { + return new List + { + new HuggingFaceRepoItem("Qwen/Qwen2.5-Coder-7B-Instruct-GGUF", "Qwen", 4820, "1,250,000 downloads", "text-generation"), + new HuggingFaceRepoItem("meta-llama/Llama-3.2-3B-Instruct-GGUF", "meta-llama", 3100, "980,000 downloads", "text-generation"), + new HuggingFaceRepoItem("deepseek-ai/DeepSeek-R1-Distill-Qwen-7B-GGUF", "deepseek-ai", 5200, "1,450,000 downloads", "text-generation"), + new HuggingFaceRepoItem("Wan-AI/Wan2.1-T2V-14B", "Wan-AI", 2800, "420,000 downloads", "text-to-video"), + new HuggingFaceRepoItem("hexgrad/Kokoro-82M", "hexgrad", 3900, "680,000 downloads", "text-to-audio"), + new HuggingFaceRepoItem("black-forest-labs/FLUX.1-schnell", "black-forest-labs", 6200, "1,850,000 downloads", "text-to-image") + }; + } + + public void LoadCuratedStarterModels() + { + if (HuggingFaceResults.Count > 0) return; + + foreach (var r in GetCuratedStarterModels()) + { + string modality = DetermineModality(r.Id, r.PipelineTag); + var badge = _canIRunItService.EvaluateQuickFit(r.Id, null, modality, (long)TotalVramMb, (long)TotalRamMb); + HuggingFaceResults.Add(r with { FitBadge = badge }); + } + ApplyFilter(); + } + + public async Task LoadDefaultModelsAsync(string apiBase, HttpClient http) + { + try + { + var results = await _hfSearchService.SearchRepositoriesAsync(apiBase, "", "text-generation", http); + if (results != null && results.Count > 0) + { + HuggingFaceResults.Clear(); + foreach (var r in results) + { + string modality = DetermineModality(r.Id, r.PipelineTag); + var badge = _canIRunItService.EvaluateQuickFit(r.Id, null, modality, (long)TotalVramMb, (long)TotalRamMb); + HuggingFaceResults.Add(r with { FitBadge = badge }); + } + ApplyFilter(); + } + } + catch + { + // Curated starter models are already loaded, retain them + } + } + [RelayCommand] public void NavigateToCanIRunIt(string? modelName) { @@ -446,10 +536,20 @@ public async Task SearchHuggingFaceAsync(string apiBase, HttpClient http) var badge = _canIRunItService.EvaluateQuickFit(r.Id, null, modality, (long)TotalVramMb, (long)TotalRamMb); HuggingFaceResults.Add(r with { FitBadge = badge }); } + + if (results.Count == 0 && string.IsNullOrWhiteSpace(HfSearchQuery)) + { + LoadCuratedStarterModels(); + } + ApplyFilter(); } catch { + if (HuggingFaceResults.Count == 0 && string.IsNullOrWhiteSpace(HfSearchQuery)) + { + LoadCuratedStarterModels(); + } ToastService.Instance.Show("Failed to query Hugging Face Hub.", ToastType.Error); } finally diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs index 240b7fb..c40377b 100644 --- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs @@ -167,6 +167,8 @@ public MainViewModel( _ = RefreshStatusAsync(); _ = Ollama.LoadInstalledModelsAsync(ApiBase, Http); + _ = HuggingFace.LoadDefaultModelsAsync(ApiBase, Http); + _ = Civitai.LoadDefaultModelsAsync(ApiBase, Http); _ = Audio.LoadAudioWorkflowsAsync(ApiBase, Http); _ = Audio.LoadAudioFilesAsync(ApiBase, Http); _ = LoadSettingsAsync(); diff --git a/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs index eaf9d67..7161d8a 100644 --- a/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs @@ -23,7 +23,18 @@ public partial class SettingsViewModel : ObservableObject public ObservableCollection AllPresets { get; } = new(); public ObservableCollection FilteredPresets { get; } = new(); - [ObservableProperty] private string _selectedPresetModalityFilter = "All"; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsFilterAllActive))] + [NotifyPropertyChangedFor(nameof(IsFilterVideoActive))] + [NotifyPropertyChangedFor(nameof(IsFilterImageActive))] + [NotifyPropertyChangedFor(nameof(IsFilterAudioActive))] + private string _selectedPresetModalityFilter = "All"; + + public bool IsFilterAllActive => string.IsNullOrWhiteSpace(SelectedPresetModalityFilter) || SelectedPresetModalityFilter.Equals("All", StringComparison.OrdinalIgnoreCase); + public bool IsFilterVideoActive => SelectedPresetModalityFilter.Equals("Video", StringComparison.OrdinalIgnoreCase); + public bool IsFilterImageActive => SelectedPresetModalityFilter.Equals("Image", StringComparison.OrdinalIgnoreCase); + public bool IsFilterAudioActive => SelectedPresetModalityFilter.Equals("Audio", StringComparison.OrdinalIgnoreCase); + [ObservableProperty] private string _presetsJson = ""; [ObservableProperty] private string _forgeModelsPath = ""; diff --git a/LocalLLMServerManager.Shared/Views/Controls/CanIRunItView.axaml b/LocalLLMServerManager.Shared/Views/Controls/CanIRunItView.axaml index f968a1a..9a044f3 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/CanIRunItView.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/CanIRunItView.axaml @@ -7,6 +7,33 @@ x:Class="LocalLLMServerManager.Shared.Views.Controls.CanIRunItView" x:DataType="vm:CanIRunItViewModel"> + + + + + + + @@ -56,16 +83,36 @@ - + + + + diff --git a/LocalLLMServerManager.Shared/Views/Controls/CivitaiTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/CivitaiTabControl.axaml index d930d6b..8f6c47b 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/CivitaiTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/CivitaiTabControl.axaml @@ -4,7 +4,73 @@ x:Class="LocalLLMServerManager.Shared.Views.Controls.CivitaiTabControl" x:DataType="vm:CivitaiSearchViewModel"> - + + + + + + + + + + + + + + + + + + + @@ -28,39 +94,84 @@ - - - - - + + + + + + + + + - - - + + + + + + + + - + - + @@ -73,7 +184,7 @@ - + diff --git a/LocalLLMServerManager.Shared/Views/Controls/HuggingFaceTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/HuggingFaceTabControl.axaml index bf751b6..9080f0c 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/HuggingFaceTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/HuggingFaceTabControl.axaml @@ -4,6 +4,108 @@ x:Class="LocalLLMServerManager.Shared.Views.Controls.HuggingFaceTabControl" x:DataType="vm:HuggingFaceSearchViewModel"> + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -32,52 +134,143 @@ - + + + + + - + - - + + + - - + + + + - - - - diff --git a/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml index 0554f9a..ecb211c 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml @@ -4,6 +4,30 @@ x:Class="LocalLLMServerManager.Shared.Views.Controls.OllamaModelsTabControl" x:DataType="vm:OllamaLibraryViewModel"> + + + + + + + + @@ -20,17 +44,33 @@ - - - - diff --git a/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml index a14c9fc..7846d83 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml @@ -5,6 +5,33 @@ x:Class="LocalLLMServerManager.Shared.Views.Controls.SettingsTabControl" x:DataType="vm:SettingsViewModel"> + + + + + + + @@ -116,10 +143,30 @@ - + + + diff --git a/LocalLLMServerManager.Tests/CivitaiSearchViewModelTests.cs b/LocalLLMServerManager.Tests/CivitaiSearchViewModelTests.cs index 9a47082..a62315f 100644 --- a/LocalLLMServerManager.Tests/CivitaiSearchViewModelTests.cs +++ b/LocalLLMServerManager.Tests/CivitaiSearchViewModelTests.cs @@ -175,4 +175,58 @@ public async Task SearchCivitaiAsync_TogglesIsLoadingProperly() Assert.False(vm.IsLoading); Assert.Single(vm.CivitaiResults); } + + [Fact] + public void ApplyFilter_FiltersStarterModels_ByVerdictAndType() + { + var mockCivitai = new Mock(); + var vm = new CivitaiSearchViewModel(mockCivitai.Object); + + // Initial state: default starter models loaded (DefaultStarterModels has Checkpoints and LORA) + Assert.NotEmpty(vm.FilteredStarterModels); + // Default selected type is Checkpoint + Assert.All(vm.FilteredStarterModels, s => Assert.Equal("Checkpoint", s.Type)); + + // When switching to LORA type + vm.SelectCivitaiType("LORA"); + Assert.NotEmpty(vm.FilteredStarterModels); + Assert.All(vm.FilteredStarterModels, s => Assert.Equal("LORA", s.Type)); + + // When switching to All + vm.SelectCivitaiType("All"); + Assert.Equal(vm.StarterModels.Count, vm.FilteredStarterModels.Count); + + // When toggling off all verdicts + vm.IsFullVramActive = false; + vm.IsPartialOffloadActive = false; + vm.IsCpuOnlyActive = false; + vm.IsOomActive = false; + Assert.Empty(vm.FilteredStarterModels); + } + + [Fact] + public void SelectCivitaiType_UpdatesActiveBooleansAndFilter() + { + var mockCivitai = new Mock(); + var vm = new CivitaiSearchViewModel(mockCivitai.Object); + + vm.SelectCivitaiType("LORA"); + Assert.True(vm.IsTypeLoraActive); + Assert.False(vm.IsTypeCheckpointActive); + Assert.False(vm.IsTypeAllActive); + + vm.SelectCivitaiType("Checkpoint"); + Assert.False(vm.IsTypeLoraActive); + Assert.True(vm.IsTypeCheckpointActive); + Assert.False(vm.IsTypeAllActive); + + vm.SelectCivitaiType("All"); + Assert.False(vm.IsTypeLoraActive); + Assert.False(vm.IsTypeCheckpointActive); + Assert.True(vm.IsTypeAllActive); + + vm.SelectCivitaiType(null); + Assert.True(vm.IsTypeAllActive); + } } + diff --git a/LocalLLMServerManager.Tests/HuggingFaceSearchViewModelTests.cs b/LocalLLMServerManager.Tests/HuggingFaceSearchViewModelTests.cs index aad9354..d1c8215 100644 --- a/LocalLLMServerManager.Tests/HuggingFaceSearchViewModelTests.cs +++ b/LocalLLMServerManager.Tests/HuggingFaceSearchViewModelTests.cs @@ -284,4 +284,102 @@ public async Task OpenHfModalAsync_TogglesIsModalLoadingProperly() Assert.False(vm.IsModalLoading); Assert.Single(vm.ModalHfFiles); } + + [Fact] + public void Constructor_LoadsCuratedStarterModels_ByDefault() + { + var mockHf = new Mock(); + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + + Assert.NotEmpty(vm.HuggingFaceResults); + Assert.NotEmpty(vm.FilteredHuggingFaceResults); + Assert.True(vm.HuggingFaceResults.Count >= 5); + foreach (var item in vm.HuggingFaceResults) + { + Assert.NotNull(item.FitBadge); + } + } + + [Fact] + public void ApplyPreset_SetsActivePreset_AndTogglesClearSelectedPipelineTag() + { + var mockHf = new Mock(); + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + + vm.SelectedPipelineTag = "custom-tag"; + vm.ApplyPreset("LLM"); + + Assert.Null(vm.SelectedPipelineTag); + Assert.Equal("LLM", vm.ActivePreset); + Assert.True(vm.IsPresetLlm); + Assert.False(vm.IsPresetMultimodal); + + vm.ApplyPreset("Multimodal"); + Assert.Equal("Multimodal", vm.ActivePreset); + Assert.True(vm.IsPresetMultimodal); + Assert.False(vm.IsPresetLlm); + + vm.ApplyPreset("Image"); + Assert.Equal("Image", vm.ActivePreset); + Assert.True(vm.IsPresetImage); + + vm.ApplyPreset("Video"); + Assert.Equal("Video", vm.ActivePreset); + Assert.True(vm.IsPresetVideo); + + vm.ApplyPreset("Audio"); + Assert.Equal("Audio", vm.ActivePreset); + Assert.True(vm.IsPresetAudio); + + vm.ApplyPreset("3D"); + Assert.Equal("3D", vm.ActivePreset); + Assert.True(vm.IsPreset3D); + + // Manually toggling an input or output clears ActivePreset + vm.ToggleInputModality("Image"); + Assert.Null(vm.ActivePreset); + Assert.False(vm.IsPreset3D); + } + + [Fact] + public void ResolvePipelineTags_ForAudio_DoesNotIncludeTextGeneration() + { + var tags = HuggingFaceSearchViewModel.ResolvePipelineTags( + new[] { "Text", "Audio" }, + new[] { "Text", "Audio" }); + + Assert.DoesNotContain("text-generation", tags); + Assert.Contains("text-to-speech", tags); + Assert.Contains("text-to-audio", tags); + Assert.Contains("automatic-speech-recognition", tags); + Assert.Contains("audio-to-audio", tags); + } + + [Theory] + [InlineData("Qwen/Qwen2-VL-7B-Instruct", "image-text-to-text", "LLM")] + [InlineData("meta-llama/Llama-3.2-11B-Vision-Instruct", "image-to-text", "LLM")] + [InlineData("google/docvqa-donut", "visual-question-answering", "LLM")] + public void DetermineModality_VlmModels_ClassifiedAsLlmNotImage(string name, string tag, string expected) + { + var result = HuggingFaceSearchViewModel.DetermineModality(name, tag); + Assert.Equal(expected, result); + } + + [Fact] + public async Task LoadDefaultModelsAsync_LoadsFromService_OrRetainsCuratedStarterModels() + { + var mockHf = new Mock(); + mockHf.Setup(s => s.SearchRepositoriesAsync("http://localhost", "", "text-generation", It.IsAny())) + .ReturnsAsync(new List + { + new HuggingFaceRepoItem("custom/top-model", "custom", 1000, "50k", "text-generation", null) + }); + + var vm = new HuggingFaceSearchViewModel(mockHf.Object); + using var client = new HttpClient(); + await vm.LoadDefaultModelsAsync("http://localhost", client); + + Assert.Single(vm.HuggingFaceResults); + Assert.Equal("custom/top-model", vm.HuggingFaceResults[0].Id); + } } diff --git a/LocalLLMServerManager.Tests/SearchServicesTests.cs b/LocalLLMServerManager.Tests/SearchServicesTests.cs index 481eca5..be84240 100644 --- a/LocalLLMServerManager.Tests/SearchServicesTests.cs +++ b/LocalLLMServerManager.Tests/SearchServicesTests.cs @@ -233,6 +233,7 @@ public void HuggingFaceSearchViewModel_ApplyPreset_UpdatesActiveModalities() public void HuggingFaceSearchViewModel_FilterByFitVerdict_FiltersResultsCorrectly() { var vm = new LocalLLMServerManager.Shared.ViewModels.HuggingFaceSearchViewModel(new Mock().Object); + vm.HuggingFaceResults.Clear(); vm.HuggingFaceResults.Add(new LocalLLMServerManager.Shared.ViewModels.HuggingFaceRepoItem("repo1", "author", 10, "100 downloads", "text-generation", new LocalLLMServerManager.Shared.Models.QuickFitBadge("🟢 Full VRAM", "#10B981", "", LocalLLMServerManager.Shared.Models.FitVerdict.FullVram))); vm.HuggingFaceResults.Add(new LocalLLMServerManager.Shared.ViewModels.HuggingFaceRepoItem("repo2", "author", 20, "200 downloads", "text-generation", From c6e3bf5afd6ed17337ef5fa366459bd67fedd117 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 20:22:40 -0500 Subject: [PATCH 05/28] docs(spec): add design spec for collaborative UI bug hunting via AvaloniaMcp --- ...-18-collaborative-debug-workflow-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-18-collaborative-debug-workflow-design.md diff --git a/docs/superpowers/specs/2026-09-18-collaborative-debug-workflow-design.md b/docs/superpowers/specs/2026-09-18-collaborative-debug-workflow-design.md new file mode 100644 index 0000000..0b4c4a0 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-collaborative-debug-workflow-design.md @@ -0,0 +1,156 @@ +# Design Spec: Collaborative UI Bug Hunting via AvaloniaMcp & DevTools + +**Date:** 2026-09-18 +**Status:** Approved +**Target Branch:** `feature/fixes-and-adjustments` + +--- + +## 1. Overview & Purpose + +This design enables a collaborative, live debugging environment between the developer and Antigravity (AI coding assistant) while running `LocalLLMServerManager` in `Debug` mode. + +The developer explores the desktop Avalonia UI, hunts for bugs, and reports anomalies or user experience friction. Concurrently, Antigravity attaches directly to the running application process via the open-source **`AvaloniaMcp`** Model Context Protocol (MCP) server, inspecting the live visual and logical trees, reading ViewModel data contexts, intercepting broken XAML bindings, capturing UI screenshots, and programmatically triggering commands to reproduce and resolve bugs in real time. + +--- + +## 2. Architecture & Components + +``` ++-------------------------------------------------------------------------+ +| USER WORKSPACE | +| - Browses Desktop Avalonia UI in Debug mode | +| - Inspects visual layout & controls with F12 (Avalonia.Diagnostics) | +| - Reports issues, edge cases, and UI friction in conversation | ++------------------------------------+------------------------------------+ + | + v ++-------------------------------------------------------------------------+ +| AVALONIA RUNTIME PROCESS (PID: {pid}) | +| | +| - AppBuilder.Configure() | +| .UsePlatformDetect() | +| .UseMcpDiagnostics() <-- AvaloniaMcp Named Pipe Server | +| .LogToTrace() | +| | +| - Named Pipe Endpoint: `avalonia-mcp-{pid}` | +| - Process Discovery File: `%TEMP%/avalonia-mcp/{pid}.json` | ++------------------------------------+------------------------------------+ + | Named Pipe (JSON-RPC) + v ++-------------------------------------------------------------------------+ +| AVALONIA MCP SERVER (`avaloniamcp`) | +| | +| - Global .NET Tool (`dotnet avalonia-mcp`) | +| - Connects to `avalonia-mcp-{pid}` via stdio transport | +| - Exposes 15 MCP Tools to AI Assistant | ++------------------------------------+------------------------------------+ + | MCP Protocol (stdio) + v ++-------------------------------------------------------------------------+ +| ANTIGRAVITY AI ASSISTANT | +| - Discovers running UI app instance | +| - Queries visual/logical trees, DataContexts, & broken bindings | +| - Reads & edits control properties dynamically | +| - Captures screenshots of buggy controls or layouts | +| - Diagnoses root causes and applies code fixes directly | ++-------------------------------------------------------------------------+ +``` + +### Key Components + +1. **`AvaloniaMcp.Diagnostics` (NuGet Package v0.4.0)**: + - Added conditionally to `LocalLLMServerManager.csproj` for `'$(Configuration)' == 'Debug'`. + - Free, open-source MIT library designed specifically for `.NET 10` and `Avalonia 11.2+`. + - Starts a lightweight named-pipe server in the app on launch (`UseMcpDiagnostics()`) with zero runtime overhead in production. + +2. **`avaloniamcp` (.NET Global Tool v0.4.0)**: + - Installed globally via `dotnet tool install -g avaloniamcp`. + - Connects to the running Avalonia named-pipe process and translates MCP tool invocations into UI-thread-dispatched inspection calls. + +3. **`Avalonia.Diagnostics` (NuGet Package v11.3.22 / 12.x)**: + - Added conditionally to `LocalLLMServerManager.csproj` for `'$(Configuration)' == 'Debug'`. + - Enables the built-in F12 DevTools inspection window for human developer interaction. + +4. **Antigravity MCP Configuration**: + - Registered as `avalonia_mcp` server using command `dotnet` and arguments `["avalonia-mcp"]`. + +--- + +## 3. Tool Reference (AvaloniaMcp) + +The `avaloniamcp` server exposes 15 specialized tools to Antigravity: + +| Category | Tool | Parameters | Functionality | +|---|---|---|---| +| **Inspection** | `list_windows` | None | Lists all open windows, titles, bounds, and states. | +| | `get_visual_tree` | `maxDepth?` | Full visual element hierarchy with types, names, layout bounds, and visibility. | +| | `get_logical_tree` | `maxDepth?` | High-level logical tree representing developer XAML intent. | +| | `find_control` | `name?`, `typeName?`, `text?` | Fast lookup of UI elements by `#Name`, `Type`, or text content. | +| | `get_control_properties` | `controlId` | Dumps all Avalonia properties, values, types, and inheritance sources. | +| **Data & Bindings** | `get_data_context` | `controlId?` | Serializes the bound ViewModel properties and values to JSON. | +| | `get_binding_errors` | None | Pinpoints all broken bindings with error messages and timestamps. | +| **Visual & Styles** | `take_screenshot` | `controlId?` | Captures the entire window or a specific control as a base64 PNG. | +| | `get_applied_styles` | `controlId` | Inspects active style classes, pseudo-classes (`:pointerover`), and setters. | +| | `get_resources` | `controlId?` | Inspects resource dictionaries (colors, templates, brushes, converters). | +| | `get_focused_element` | None | Returns the currently focused control and tab index. | +| **Interaction** | `click_control` | `controlId` | Simulates click events and triggers bound `ICommand` handlers. | +| | `input_text` | `controlId`, `text` | Types text into `TextBox` or editable controls. | +| | `set_property` | `controlId`, `propertyName`, `value` | Mutates control properties at runtime to test layout fixes live. | +| **Discovery** | `discover_apps` | None | Discovers running Avalonia apps and their PIDs. | + +--- + +## 4. Collaborative Bug-Hunting Workflow + +```mermaid +sequenceDiagram + autonumber + actor User as Developer + participant App as Avalonia App (Debug) + participant MCP as avaloniamcp Server + participant Agent as Antigravity AI + + Agent->>App: Launch via `dotnet run -c Debug` + App->>MCP: Establish named pipe (avalonia-mcp-{pid}) + User->>App: Interacts with UI (browsing, clicking, filtering) + User->>Agent: Reports issue: "CivitAI starter models didn't filter by LoRA" + Agent->>MCP: Call `get_binding_errors` & `get_data_context` + MCP->>App: Query UI thread dispatch + App-->>MCP: Returns DataContext state & binding status + MCP-->>Agent: JSON snapshot of ViewModel properties + Agent->>Agent: Analyzes discrepancy in ViewModel / XAML + Agent->>User: Explains root cause & proposes code fix + Agent->>App: Applies code patch & verifies tests + User->>App: Validates fix live in application +``` + +### Collaborative Steps +1. **Launch**: App is compiled and launched in `Debug` configuration. The desktop window appears on the user's desktop, and `avaloniamcp` automatically binds to the process. +2. **Explore**: User browses features (Hugging Face hub, CivitAI filters, Ollama library, floating documentation overlay, Can I Run It, settings). +3. **Report**: When an unexpected UI behavior occurs, the user describes the symptom or presses F12 to inspect the element name. +4. **Inspect**: Antigravity uses `get_binding_errors`, `get_data_context`, and `get_visual_tree` to see the exact runtime values and state without guesswork. +5. **Fix & Verify**: Antigravity writes the fix, runs `dotnet test`, `npm run lint`, and `npx tsc --noEmit`, and the user confirms the resolution. + +--- + +## 5. Security & Isolation Considerations + +- `AvaloniaMcp.Diagnostics` and `Avalonia.Diagnostics` are strictly conditioned on `'$(Configuration)' == 'Debug'`. +- Production/Release builds do not include the diagnostic bridge, avoiding any attack surface, binary bloat, or unintended named pipes. +- Named pipes only bind to local loopback/OS IPC (`%TEMP%/avalonia-mcp/`), accessible only by the current logged-in user. + +--- + +## 6. Verification Plan + +1. **Package Verification**: + - `LocalLLMServerManager.csproj` references `AvaloniaMcp.Diagnostics` (v0.4.0) under `Condition="'$(Configuration)' == 'Debug'"`. + - `Program.cs` includes `#if DEBUG` `.UseMcpDiagnostics()` `#endif`. +2. **Tool Verification**: + - `dotnet tool install -g avaloniamcp` executes cleanly. + - Running `dotnet avalonia-mcp cli discover_apps` detects the running process. + - `get_binding_errors` and `get_data_context` return valid JSON. +3. **Build & Test Verification**: + - Full test suite (`dotnet test`) passes with 0 errors. + - Release build verification (`dotnet build -c Release`) ensures diagnostic packages are stripped from Release builds. From d3d91adb41ae41cb9eec0c6b19ac61a13153433f Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 20:23:30 -0500 Subject: [PATCH 06/28] docs(plan): add implementation plan for collaborative UI debugging via AvaloniaMcp --- ...2026-09-18-collaborative-debug-mcp-plan.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-collaborative-debug-mcp-plan.md diff --git a/docs/superpowers/plans/2026-09-18-collaborative-debug-mcp-plan.md b/docs/superpowers/plans/2026-09-18-collaborative-debug-mcp-plan.md new file mode 100644 index 0000000..2a0fe4e --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-collaborative-debug-mcp-plan.md @@ -0,0 +1,119 @@ +# Collaborative UI Debugging with AvaloniaMcp Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable live, collaborative debugging between the developer and AI assistant using `AvaloniaMcp` (MCP server for Avalonia) and F12 DevTools in `Debug` builds. + +**Architecture:** Integrate `AvaloniaMcp.Diagnostics` and `Avalonia.Diagnostics` conditionally for Debug builds. Wire a dedicated `UiDiagnosticLogger` to capture Avalonia binding errors and trace logs. Add user-facing documentation in VitePress and automated unit tests. + +**Tech Stack:** C# 13, .NET 10, Avalonia 12.1.2, AvaloniaMcp.Diagnostics 0.4.0, Avalonia.Diagnostics 11.3.22, xUnit, VitePress. + +## Global Constraints +- Do NOT create releases, git tags, or bump version numbers. +- Do NOT use the acronym "ASD-STE100" in any documentation, code, or UI copy. +- Always run `npm run lint` and `npx tsc --noEmit` after code changes. +- Ensure all packages are conditionally included only in `Debug` configuration so `Release` builds remain unaffected. + +--- + +### Task 1: Package References & Startup Initialization + +**Files:** +- Modify: `LocalLLMServerManager.csproj` +- Modify: `Program.cs` +- Modify: `Views/MainWindow.axaml.cs` + +**Interfaces:** +- Produces: Application startup in `Debug` mode launches AvaloniaMcp named pipe server (`UseMcpDiagnostics()`) and enables F12 DevTools (`this.AttachDevTools()`). + +- [ ] **Step 1: Update `LocalLLMServerManager.csproj`** +Add conditional PackageReferences for `AvaloniaMcp.Diagnostics` (0.4.0) and `Avalonia.Diagnostics` (11.3.22) when `'$(Configuration)' == 'Debug'`. + +- [ ] **Step 2: Update `Program.cs`** +Add `#if DEBUG using AvaloniaMcp.Diagnostics; #endif` and wire `.UseMcpDiagnostics()` into `BuildAvaloniaApp()` under `#if DEBUG`. + +- [ ] **Step 3: Update `Views/MainWindow.axaml.cs`** +Add `#if DEBUG this.AttachDevTools(); #endif` in `MainWindow` constructor. + +- [ ] **Step 4: Verify build in both Debug and Release** +Run `dotnet build -c Debug` and `dotnet build -c Release` to confirm successful compilation. + +- [ ] **Step 5: Commit** +`git commit -m "feat(debug): integrate AvaloniaMcp diagnostics and F12 DevTools for debug builds"` + +--- + +### Task 2: UI Diagnostic Trace Interceptor & Unit Tests + +**Files:** +- Create: `LocalLLMServerManager.Shared/Services/UiDiagnosticLogger.cs` +- Create: `LocalLLMServerManager.Tests/UiDiagnosticLoggerTests.cs` + +**Interfaces:** +- Produces: `IUiDiagnosticLogger` / `UiDiagnosticLogger` with methods: + - `void RecordBindingError(string message, string? source = null)` + - `IReadOnlyList GetRecentErrors()` + - `void Clear()` + - `string ExportLogSummary()` + +- [ ] **Step 1: Write failing test in `UiDiagnosticLoggerTests.cs`** +Test that `UiDiagnosticLogger` records binding errors, limits max history to prevent memory leaks, and formats timestamps accurately. + +- [ ] **Step 2: Run test to verify it fails** +Run `dotnet test LocalLLMServerManager.Tests --filter "FullyQualifiedName~UiDiagnosticLoggerTests"`. + +- [ ] **Step 3: Implement `UiDiagnosticLogger.cs`** +Implement thread-safe diagnostic logger. + +- [ ] **Step 4: Run test to verify it passes** +Run `dotnet test LocalLLMServerManager.Tests --filter "FullyQualifiedName~UiDiagnosticLoggerTests"`. + +- [ ] **Step 5: Commit** +`git commit -m "feat(diagnostics): add UiDiagnosticLogger and unit test coverage"` + +--- + +### Task 3: Documentation for Collaborative Debugging + +**Files:** +- Create: `docs/guide/collaborative-debugging.md` +- Modify: `docs/.vitepress/config.ts` + +**Interfaces:** +- Produces: Clear developer guide explaining: + - How to start the app in Debug mode. + - How `avaloniamcp` runs and connects via named pipes. + - The 15 available MCP tools for AI assistants. + - How human developers use F12 DevTools. + - Best practices for reporting and fixing UI issues. + +- [ ] **Step 1: Create `docs/guide/collaborative-debugging.md`** +Write clear, accessible guide without any "ASD-STE100" terminology. + +- [ ] **Step 2: Update `docs/.vitepress/config.ts` sidebar** +Add link under Developer Guide / Advanced Features. + +- [ ] **Step 3: Build docs site** +Run `npm run docs:build` to confirm 0 errors. + +- [ ] **Step 4: Commit** +`git commit -m "docs: add comprehensive collaborative debugging guide for AvaloniaMcp"` + +--- + +### Task 4: Global Tool Verification, Quality Gates & PR + +**Files:** +- All modified files + +- [ ] **Step 1: Install `avaloniamcp` global tool** +Run `dotnet tool install -g avaloniamcp` (or update if already installed). + +- [ ] **Step 2: Run full verification suite** +Run `dotnet test -c Release --nologo`. +Run `npm run lint`. +Run `npx tsc --noEmit`. +Run `npm run docs:build`. + +- [ ] **Step 3: Push branch and create Pull Request** +Push feature branch and open PR via `gh pr create`. From bcd52d33f2d079b2ae837d8199cb5110e92b33a1 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 20:22:40 -0500 Subject: [PATCH 07/28] docs(spec): add design spec for collaborative UI bug hunting via AvaloniaMcp --- ...-18-collaborative-debug-workflow-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-18-collaborative-debug-workflow-design.md diff --git a/docs/superpowers/specs/2026-09-18-collaborative-debug-workflow-design.md b/docs/superpowers/specs/2026-09-18-collaborative-debug-workflow-design.md new file mode 100644 index 0000000..0b4c4a0 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-collaborative-debug-workflow-design.md @@ -0,0 +1,156 @@ +# Design Spec: Collaborative UI Bug Hunting via AvaloniaMcp & DevTools + +**Date:** 2026-09-18 +**Status:** Approved +**Target Branch:** `feature/fixes-and-adjustments` + +--- + +## 1. Overview & Purpose + +This design enables a collaborative, live debugging environment between the developer and Antigravity (AI coding assistant) while running `LocalLLMServerManager` in `Debug` mode. + +The developer explores the desktop Avalonia UI, hunts for bugs, and reports anomalies or user experience friction. Concurrently, Antigravity attaches directly to the running application process via the open-source **`AvaloniaMcp`** Model Context Protocol (MCP) server, inspecting the live visual and logical trees, reading ViewModel data contexts, intercepting broken XAML bindings, capturing UI screenshots, and programmatically triggering commands to reproduce and resolve bugs in real time. + +--- + +## 2. Architecture & Components + +``` ++-------------------------------------------------------------------------+ +| USER WORKSPACE | +| - Browses Desktop Avalonia UI in Debug mode | +| - Inspects visual layout & controls with F12 (Avalonia.Diagnostics) | +| - Reports issues, edge cases, and UI friction in conversation | ++------------------------------------+------------------------------------+ + | + v ++-------------------------------------------------------------------------+ +| AVALONIA RUNTIME PROCESS (PID: {pid}) | +| | +| - AppBuilder.Configure() | +| .UsePlatformDetect() | +| .UseMcpDiagnostics() <-- AvaloniaMcp Named Pipe Server | +| .LogToTrace() | +| | +| - Named Pipe Endpoint: `avalonia-mcp-{pid}` | +| - Process Discovery File: `%TEMP%/avalonia-mcp/{pid}.json` | ++------------------------------------+------------------------------------+ + | Named Pipe (JSON-RPC) + v ++-------------------------------------------------------------------------+ +| AVALONIA MCP SERVER (`avaloniamcp`) | +| | +| - Global .NET Tool (`dotnet avalonia-mcp`) | +| - Connects to `avalonia-mcp-{pid}` via stdio transport | +| - Exposes 15 MCP Tools to AI Assistant | ++------------------------------------+------------------------------------+ + | MCP Protocol (stdio) + v ++-------------------------------------------------------------------------+ +| ANTIGRAVITY AI ASSISTANT | +| - Discovers running UI app instance | +| - Queries visual/logical trees, DataContexts, & broken bindings | +| - Reads & edits control properties dynamically | +| - Captures screenshots of buggy controls or layouts | +| - Diagnoses root causes and applies code fixes directly | ++-------------------------------------------------------------------------+ +``` + +### Key Components + +1. **`AvaloniaMcp.Diagnostics` (NuGet Package v0.4.0)**: + - Added conditionally to `LocalLLMServerManager.csproj` for `'$(Configuration)' == 'Debug'`. + - Free, open-source MIT library designed specifically for `.NET 10` and `Avalonia 11.2+`. + - Starts a lightweight named-pipe server in the app on launch (`UseMcpDiagnostics()`) with zero runtime overhead in production. + +2. **`avaloniamcp` (.NET Global Tool v0.4.0)**: + - Installed globally via `dotnet tool install -g avaloniamcp`. + - Connects to the running Avalonia named-pipe process and translates MCP tool invocations into UI-thread-dispatched inspection calls. + +3. **`Avalonia.Diagnostics` (NuGet Package v11.3.22 / 12.x)**: + - Added conditionally to `LocalLLMServerManager.csproj` for `'$(Configuration)' == 'Debug'`. + - Enables the built-in F12 DevTools inspection window for human developer interaction. + +4. **Antigravity MCP Configuration**: + - Registered as `avalonia_mcp` server using command `dotnet` and arguments `["avalonia-mcp"]`. + +--- + +## 3. Tool Reference (AvaloniaMcp) + +The `avaloniamcp` server exposes 15 specialized tools to Antigravity: + +| Category | Tool | Parameters | Functionality | +|---|---|---|---| +| **Inspection** | `list_windows` | None | Lists all open windows, titles, bounds, and states. | +| | `get_visual_tree` | `maxDepth?` | Full visual element hierarchy with types, names, layout bounds, and visibility. | +| | `get_logical_tree` | `maxDepth?` | High-level logical tree representing developer XAML intent. | +| | `find_control` | `name?`, `typeName?`, `text?` | Fast lookup of UI elements by `#Name`, `Type`, or text content. | +| | `get_control_properties` | `controlId` | Dumps all Avalonia properties, values, types, and inheritance sources. | +| **Data & Bindings** | `get_data_context` | `controlId?` | Serializes the bound ViewModel properties and values to JSON. | +| | `get_binding_errors` | None | Pinpoints all broken bindings with error messages and timestamps. | +| **Visual & Styles** | `take_screenshot` | `controlId?` | Captures the entire window or a specific control as a base64 PNG. | +| | `get_applied_styles` | `controlId` | Inspects active style classes, pseudo-classes (`:pointerover`), and setters. | +| | `get_resources` | `controlId?` | Inspects resource dictionaries (colors, templates, brushes, converters). | +| | `get_focused_element` | None | Returns the currently focused control and tab index. | +| **Interaction** | `click_control` | `controlId` | Simulates click events and triggers bound `ICommand` handlers. | +| | `input_text` | `controlId`, `text` | Types text into `TextBox` or editable controls. | +| | `set_property` | `controlId`, `propertyName`, `value` | Mutates control properties at runtime to test layout fixes live. | +| **Discovery** | `discover_apps` | None | Discovers running Avalonia apps and their PIDs. | + +--- + +## 4. Collaborative Bug-Hunting Workflow + +```mermaid +sequenceDiagram + autonumber + actor User as Developer + participant App as Avalonia App (Debug) + participant MCP as avaloniamcp Server + participant Agent as Antigravity AI + + Agent->>App: Launch via `dotnet run -c Debug` + App->>MCP: Establish named pipe (avalonia-mcp-{pid}) + User->>App: Interacts with UI (browsing, clicking, filtering) + User->>Agent: Reports issue: "CivitAI starter models didn't filter by LoRA" + Agent->>MCP: Call `get_binding_errors` & `get_data_context` + MCP->>App: Query UI thread dispatch + App-->>MCP: Returns DataContext state & binding status + MCP-->>Agent: JSON snapshot of ViewModel properties + Agent->>Agent: Analyzes discrepancy in ViewModel / XAML + Agent->>User: Explains root cause & proposes code fix + Agent->>App: Applies code patch & verifies tests + User->>App: Validates fix live in application +``` + +### Collaborative Steps +1. **Launch**: App is compiled and launched in `Debug` configuration. The desktop window appears on the user's desktop, and `avaloniamcp` automatically binds to the process. +2. **Explore**: User browses features (Hugging Face hub, CivitAI filters, Ollama library, floating documentation overlay, Can I Run It, settings). +3. **Report**: When an unexpected UI behavior occurs, the user describes the symptom or presses F12 to inspect the element name. +4. **Inspect**: Antigravity uses `get_binding_errors`, `get_data_context`, and `get_visual_tree` to see the exact runtime values and state without guesswork. +5. **Fix & Verify**: Antigravity writes the fix, runs `dotnet test`, `npm run lint`, and `npx tsc --noEmit`, and the user confirms the resolution. + +--- + +## 5. Security & Isolation Considerations + +- `AvaloniaMcp.Diagnostics` and `Avalonia.Diagnostics` are strictly conditioned on `'$(Configuration)' == 'Debug'`. +- Production/Release builds do not include the diagnostic bridge, avoiding any attack surface, binary bloat, or unintended named pipes. +- Named pipes only bind to local loopback/OS IPC (`%TEMP%/avalonia-mcp/`), accessible only by the current logged-in user. + +--- + +## 6. Verification Plan + +1. **Package Verification**: + - `LocalLLMServerManager.csproj` references `AvaloniaMcp.Diagnostics` (v0.4.0) under `Condition="'$(Configuration)' == 'Debug'"`. + - `Program.cs` includes `#if DEBUG` `.UseMcpDiagnostics()` `#endif`. +2. **Tool Verification**: + - `dotnet tool install -g avaloniamcp` executes cleanly. + - Running `dotnet avalonia-mcp cli discover_apps` detects the running process. + - `get_binding_errors` and `get_data_context` return valid JSON. +3. **Build & Test Verification**: + - Full test suite (`dotnet test`) passes with 0 errors. + - Release build verification (`dotnet build -c Release`) ensures diagnostic packages are stripped from Release builds. From 13dd6dc669ac09dc5ec4e383ea56e9577e887c72 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 20:23:30 -0500 Subject: [PATCH 08/28] docs(plan): add implementation plan for collaborative UI debugging via AvaloniaMcp --- ...2026-09-18-collaborative-debug-mcp-plan.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-collaborative-debug-mcp-plan.md diff --git a/docs/superpowers/plans/2026-09-18-collaborative-debug-mcp-plan.md b/docs/superpowers/plans/2026-09-18-collaborative-debug-mcp-plan.md new file mode 100644 index 0000000..2a0fe4e --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-collaborative-debug-mcp-plan.md @@ -0,0 +1,119 @@ +# Collaborative UI Debugging with AvaloniaMcp Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable live, collaborative debugging between the developer and AI assistant using `AvaloniaMcp` (MCP server for Avalonia) and F12 DevTools in `Debug` builds. + +**Architecture:** Integrate `AvaloniaMcp.Diagnostics` and `Avalonia.Diagnostics` conditionally for Debug builds. Wire a dedicated `UiDiagnosticLogger` to capture Avalonia binding errors and trace logs. Add user-facing documentation in VitePress and automated unit tests. + +**Tech Stack:** C# 13, .NET 10, Avalonia 12.1.2, AvaloniaMcp.Diagnostics 0.4.0, Avalonia.Diagnostics 11.3.22, xUnit, VitePress. + +## Global Constraints +- Do NOT create releases, git tags, or bump version numbers. +- Do NOT use the acronym "ASD-STE100" in any documentation, code, or UI copy. +- Always run `npm run lint` and `npx tsc --noEmit` after code changes. +- Ensure all packages are conditionally included only in `Debug` configuration so `Release` builds remain unaffected. + +--- + +### Task 1: Package References & Startup Initialization + +**Files:** +- Modify: `LocalLLMServerManager.csproj` +- Modify: `Program.cs` +- Modify: `Views/MainWindow.axaml.cs` + +**Interfaces:** +- Produces: Application startup in `Debug` mode launches AvaloniaMcp named pipe server (`UseMcpDiagnostics()`) and enables F12 DevTools (`this.AttachDevTools()`). + +- [ ] **Step 1: Update `LocalLLMServerManager.csproj`** +Add conditional PackageReferences for `AvaloniaMcp.Diagnostics` (0.4.0) and `Avalonia.Diagnostics` (11.3.22) when `'$(Configuration)' == 'Debug'`. + +- [ ] **Step 2: Update `Program.cs`** +Add `#if DEBUG using AvaloniaMcp.Diagnostics; #endif` and wire `.UseMcpDiagnostics()` into `BuildAvaloniaApp()` under `#if DEBUG`. + +- [ ] **Step 3: Update `Views/MainWindow.axaml.cs`** +Add `#if DEBUG this.AttachDevTools(); #endif` in `MainWindow` constructor. + +- [ ] **Step 4: Verify build in both Debug and Release** +Run `dotnet build -c Debug` and `dotnet build -c Release` to confirm successful compilation. + +- [ ] **Step 5: Commit** +`git commit -m "feat(debug): integrate AvaloniaMcp diagnostics and F12 DevTools for debug builds"` + +--- + +### Task 2: UI Diagnostic Trace Interceptor & Unit Tests + +**Files:** +- Create: `LocalLLMServerManager.Shared/Services/UiDiagnosticLogger.cs` +- Create: `LocalLLMServerManager.Tests/UiDiagnosticLoggerTests.cs` + +**Interfaces:** +- Produces: `IUiDiagnosticLogger` / `UiDiagnosticLogger` with methods: + - `void RecordBindingError(string message, string? source = null)` + - `IReadOnlyList GetRecentErrors()` + - `void Clear()` + - `string ExportLogSummary()` + +- [ ] **Step 1: Write failing test in `UiDiagnosticLoggerTests.cs`** +Test that `UiDiagnosticLogger` records binding errors, limits max history to prevent memory leaks, and formats timestamps accurately. + +- [ ] **Step 2: Run test to verify it fails** +Run `dotnet test LocalLLMServerManager.Tests --filter "FullyQualifiedName~UiDiagnosticLoggerTests"`. + +- [ ] **Step 3: Implement `UiDiagnosticLogger.cs`** +Implement thread-safe diagnostic logger. + +- [ ] **Step 4: Run test to verify it passes** +Run `dotnet test LocalLLMServerManager.Tests --filter "FullyQualifiedName~UiDiagnosticLoggerTests"`. + +- [ ] **Step 5: Commit** +`git commit -m "feat(diagnostics): add UiDiagnosticLogger and unit test coverage"` + +--- + +### Task 3: Documentation for Collaborative Debugging + +**Files:** +- Create: `docs/guide/collaborative-debugging.md` +- Modify: `docs/.vitepress/config.ts` + +**Interfaces:** +- Produces: Clear developer guide explaining: + - How to start the app in Debug mode. + - How `avaloniamcp` runs and connects via named pipes. + - The 15 available MCP tools for AI assistants. + - How human developers use F12 DevTools. + - Best practices for reporting and fixing UI issues. + +- [ ] **Step 1: Create `docs/guide/collaborative-debugging.md`** +Write clear, accessible guide without any "ASD-STE100" terminology. + +- [ ] **Step 2: Update `docs/.vitepress/config.ts` sidebar** +Add link under Developer Guide / Advanced Features. + +- [ ] **Step 3: Build docs site** +Run `npm run docs:build` to confirm 0 errors. + +- [ ] **Step 4: Commit** +`git commit -m "docs: add comprehensive collaborative debugging guide for AvaloniaMcp"` + +--- + +### Task 4: Global Tool Verification, Quality Gates & PR + +**Files:** +- All modified files + +- [ ] **Step 1: Install `avaloniamcp` global tool** +Run `dotnet tool install -g avaloniamcp` (or update if already installed). + +- [ ] **Step 2: Run full verification suite** +Run `dotnet test -c Release --nologo`. +Run `npm run lint`. +Run `npx tsc --noEmit`. +Run `npm run docs:build`. + +- [ ] **Step 3: Push branch and create Pull Request** +Push feature branch and open PR via `gh pr create`. From 1c07632aac4ec525089005e7a513e357123ceb29 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 20:29:11 -0500 Subject: [PATCH 09/28] feat(debug): integrate AvaloniaMcp diagnostics, F12 DevTools, and UiDiagnosticLogger - Add conditional Debug package references for AvaloniaMcp.Diagnostics (0.4.0) and Avalonia.Diagnostics (11.3.22) - Wire .UseMcpDiagnostics() in Program.cs and this.AttachDevTools() in MainWindow.axaml.cs under #if DEBUG - Implement thread-safe UiDiagnosticLogger and add 6 xUnit tests in UiDiagnosticLoggerTests.cs - Add comprehensive collaborative debugging developer guide in docs/guide/collaborative-debugging.md and link in VitePress sidebar --- .../Services/UiDiagnosticLogger.cs | 132 ++++++ .../UiDiagnosticLoggerTests.cs | 158 ++++++++ LocalLLMServerManager.csproj | 5 + Program.cs | 11 +- Views/MainWindow.axaml.cs | 3 + docs/.vitepress/config.mts | 16 + docs/guide/collaborative-debugging.md | 375 ++++++++++++++++++ 7 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 LocalLLMServerManager.Shared/Services/UiDiagnosticLogger.cs create mode 100644 LocalLLMServerManager.Tests/UiDiagnosticLoggerTests.cs create mode 100644 docs/guide/collaborative-debugging.md diff --git a/LocalLLMServerManager.Shared/Services/UiDiagnosticLogger.cs b/LocalLLMServerManager.Shared/Services/UiDiagnosticLogger.cs new file mode 100644 index 0000000..9c8d6e1 --- /dev/null +++ b/LocalLLMServerManager.Shared/Services/UiDiagnosticLogger.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace LocalLLMServerManager.Shared.Services; + +/// +/// Represents a captured UI binding or diagnostic error entry. +/// +public record BindingDiagnosticEntry(DateTime Timestamp, string Message, string? Source = null); + +/// +/// Interface for thread-safe UI diagnostic error logging. +/// +public interface IUiDiagnosticLogger +{ + /// + /// Records a new UI binding error. + /// + void RecordBindingError(string message, string? source = null); + + /// + /// Retrieves the most recent binding diagnostic entries up to . + /// + IReadOnlyList GetRecentErrors(int maxCount = 50); + + /// + /// Clears all recorded diagnostic entries. + /// + void Clear(); + + /// + /// Exports a formatted diagnostic summary string. + /// + string ExportLogSummary(); +} + +/// +/// Thread-safe bounded diagnostic logger service for UI binding errors. +/// +public class UiDiagnosticLogger : IUiDiagnosticLogger +{ + public const int DefaultMaxEntries = 500; + + public static UiDiagnosticLogger Instance { get; } = new(); + + private readonly int _maxCapacity; + private readonly object _lock = new(); + private readonly Queue _entries; + + public int MaxCapacity => _maxCapacity; + + public int Count + { + get + { + lock (_lock) + { + return _entries.Count; + } + } + } + + public UiDiagnosticLogger(int maxCapacity = DefaultMaxEntries) + { + _maxCapacity = maxCapacity > 0 ? maxCapacity : DefaultMaxEntries; + _entries = new Queue(_maxCapacity); + } + + public void RecordBindingError(string message, string? source = null) + { + lock (_lock) + { + while (_entries.Count >= _maxCapacity) + { + _entries.Dequeue(); + } + + _entries.Enqueue(new BindingDiagnosticEntry(DateTime.UtcNow, message ?? string.Empty, source)); + } + } + + public IReadOnlyList GetRecentErrors(int maxCount = 50) + { + if (maxCount <= 0) + { + return Array.Empty(); + } + + lock (_lock) + { + if (_entries.Count == 0) + { + return Array.Empty(); + } + + int count = Math.Min(maxCount, _entries.Count); + int skip = _entries.Count - count; + return _entries.Skip(skip).Take(count).ToList().AsReadOnly(); + } + } + + public void Clear() + { + lock (_lock) + { + _entries.Clear(); + } + } + + public string ExportLogSummary() + { + lock (_lock) + { + if (_entries.Count == 0) + { + return "No binding errors recorded."; + } + + var sb = new StringBuilder(); + sb.AppendLine($"=== UI Diagnostic Summary ({_entries.Count} error(s)) ==="); + foreach (var entry in _entries) + { + var sourceInfo = string.IsNullOrWhiteSpace(entry.Source) ? string.Empty : $" [{entry.Source}]"; + sb.AppendLine($"[{entry.Timestamp:yyyy-MM-dd HH:mm:ss.fff}]{sourceInfo}: {entry.Message}"); + } + + return sb.ToString().TrimEnd(); + } + } +} diff --git a/LocalLLMServerManager.Tests/UiDiagnosticLoggerTests.cs b/LocalLLMServerManager.Tests/UiDiagnosticLoggerTests.cs new file mode 100644 index 0000000..f3accb1 --- /dev/null +++ b/LocalLLMServerManager.Tests/UiDiagnosticLoggerTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using LocalLLMServerManager.Shared.Services; +using Xunit; + +namespace LocalLLMServerManager.Tests; + +public class UiDiagnosticLoggerTests +{ + [Fact] + public void RecordBindingError_AddsEntryWithAccurateTimestampAndSource() + { + var logger = new UiDiagnosticLogger(); + var beforeUtc = DateTime.UtcNow; + + logger.RecordBindingError("Cannot find property 'TestProp' on ViewModel", "TestView.axaml"); + + var afterUtc = DateTime.UtcNow; + + var entries = logger.GetRecentErrors(); + Assert.Single(entries); + + var entry = entries[0]; + Assert.Equal("Cannot find property 'TestProp' on ViewModel", entry.Message); + Assert.Equal("TestView.axaml", entry.Source); + Assert.InRange(entry.Timestamp, beforeUtc, afterUtc); + + // Also verify entry with null source + logger.RecordBindingError("Null source error"); + var allEntries = logger.GetRecentErrors(); + Assert.Equal(2, allEntries.Count); + Assert.Null(allEntries[1].Source); + Assert.Equal("Null source error", allEntries[1].Message); + } + + [Fact] + public void GetRecentErrors_LimitsReturnCount() + { + var logger = new UiDiagnosticLogger(); + + for (int i = 1; i <= 20; i++) + { + logger.RecordBindingError($"Error {i}", $"Source{i}"); + } + + // Limit to 5 + var recent5 = logger.GetRecentErrors(5); + Assert.Equal(5, recent5.Count); + Assert.Equal("Error 16", recent5[0].Message); + Assert.Equal("Error 20", recent5[4].Message); + + // When maxCount exceeds available items, return all available + var recent30 = logger.GetRecentErrors(30); + Assert.Equal(20, recent30.Count); + Assert.Equal("Error 1", recent30[0].Message); + Assert.Equal("Error 20", recent30[19].Message); + + // Edge case: maxCount <= 0 returns empty list + var recentZero = logger.GetRecentErrors(0); + Assert.Empty(recentZero); + + var recentNegative = logger.GetRecentErrors(-5); + Assert.Empty(recentNegative); + + // Default count is 50, so should return all 20 + var recentDefault = logger.GetRecentErrors(); + Assert.Equal(20, recentDefault.Count); + } + + [Fact] + public void BoundedHistory_DiscardsOldestEntriesWhenCapacityExceeded() + { + // Test with custom small capacity + const int capacity = 5; + var logger = new UiDiagnosticLogger(capacity); + + for (int i = 1; i <= 8; i++) + { + logger.RecordBindingError($"Error {i}"); + } + + var entries = logger.GetRecentErrors(10); + Assert.Equal(capacity, entries.Count); + + // Oldest entries 1, 2, 3 should have been discarded + Assert.DoesNotContain(entries, e => e.Message == "Error 1"); + Assert.DoesNotContain(entries, e => e.Message == "Error 2"); + Assert.DoesNotContain(entries, e => e.Message == "Error 3"); + + // Remaining entries should be 4 through 8 in FIFO order + Assert.Equal("Error 4", entries[0].Message); + Assert.Equal("Error 5", entries[1].Message); + Assert.Equal("Error 6", entries[2].Message); + Assert.Equal("Error 7", entries[3].Message); + Assert.Equal("Error 8", entries[4].Message); + } + + [Fact] + public void Clear_RemovesAllEntries() + { + var logger = new UiDiagnosticLogger(); + logger.RecordBindingError("Error 1"); + logger.RecordBindingError("Error 2"); + Assert.Equal(2, logger.GetRecentErrors().Count); + + logger.Clear(); + + var entries = logger.GetRecentErrors(); + Assert.Empty(entries); + + var summary = logger.ExportLogSummary(); + Assert.Contains("No binding errors recorded", summary, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ExportLogSummary_ReturnsFormattedSummary() + { + var logger = new UiDiagnosticLogger(); + + // Empty state + var emptySummary = logger.ExportLogSummary(); + Assert.Contains("No binding errors recorded", emptySummary, StringComparison.OrdinalIgnoreCase); + + // Populated state + logger.RecordBindingError("Binding failed for ItemCount", "DashboardView.axaml"); + logger.RecordBindingError("NullReference during converter evaluation", null); + + var summary = logger.ExportLogSummary(); + + Assert.Contains("DashboardView.axaml", summary); + Assert.Contains("Binding failed for ItemCount", summary); + Assert.Contains("NullReference during converter evaluation", summary); + Assert.Contains("2 error", summary, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RecordBindingError_IsThreadSafeUnderConcurrentAccess() + { + const int threadCount = 10; + const int iterationsPerThread = 50; + const int capacity = 200; + var logger = new UiDiagnosticLogger(capacity); + + Parallel.For(0, threadCount, threadIndex => + { + for (int i = 0; i < iterationsPerThread; i++) + { + logger.RecordBindingError($"Thread {threadIndex} message {i}", $"Source{threadIndex}"); + _ = logger.GetRecentErrors(10); + } + }); + + var entries = logger.GetRecentErrors(capacity); + Assert.Equal(capacity, entries.Count); + Assert.True(entries.All(e => !string.IsNullOrEmpty(e.Message))); + } +} diff --git a/LocalLLMServerManager.csproj b/LocalLLMServerManager.csproj index 37503bc..bed68dc 100644 --- a/LocalLLMServerManager.csproj +++ b/LocalLLMServerManager.csproj @@ -39,6 +39,11 @@ + + + + + diff --git a/Program.cs b/Program.cs index 4547547..6639af0 100644 --- a/Program.cs +++ b/Program.cs @@ -8,6 +8,9 @@ using LocalLLMServerManager.Services; using LocalLLMServerManager.Shared.Interfaces; using LocalLLMServerManager.Shared.Services; +#if DEBUG +using AvaloniaMcp.Diagnostics; +#endif namespace LocalLLMServerManager; @@ -71,10 +74,16 @@ public static bool IsPortInUse(int port) } public static AppBuilder BuildAvaloniaApp() - => AppBuilder.Configure() + { + var builder = AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .LogToTrace(); +#if DEBUG + builder = builder.UseMcpDiagnostics(); +#endif + return builder; + } public static string SettingsFilePath() => new SettingsService().SettingsFilePath(); diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs index e3c4608..a56558e 100644 --- a/Views/MainWindow.axaml.cs +++ b/Views/MainWindow.axaml.cs @@ -12,6 +12,9 @@ public partial class MainWindow : Window public MainWindow() { InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif DataContext = new MainViewModel(); PropertyChanged += (sender, e) => diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index d6dce4e..702c7d7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -73,6 +73,21 @@ export default defineConfig({ ] } ], + '/guide/': [ + { + text: 'Technical Reference (Developers & Agents)', + items: [ + { text: 'Technical Overview', link: '/technical/' }, + { text: 'System Architecture', link: '/technical/architecture' }, + { text: 'Development & Build Guide', link: '/technical/development' }, + { text: 'Collaborative UI Debugging', link: '/guide/collaborative-debugging' }, + { text: 'Requirements Specification', link: '/technical/requirements' }, + { text: 'Windows Process Validation', link: '/technical/validation' }, + { text: 'Test Coverage Benchmarks', link: '/technical/test-coverage' }, + { text: 'AI Assistant Internals', link: '/technical/ai-assistant-internals' } + ] + } + ], '/technical/': [ { text: 'Technical Reference (Developers & Agents)', @@ -80,6 +95,7 @@ export default defineConfig({ { text: 'Technical Overview', link: '/technical/' }, { text: 'System Architecture', link: '/technical/architecture' }, { text: 'Development & Build Guide', link: '/technical/development' }, + { text: 'Collaborative UI Debugging', link: '/guide/collaborative-debugging' }, { text: 'Requirements Specification', link: '/technical/requirements' }, { text: 'Windows Process Validation', link: '/technical/validation' }, { text: 'Test Coverage Benchmarks', link: '/technical/test-coverage' }, diff --git a/docs/guide/collaborative-debugging.md b/docs/guide/collaborative-debugging.md new file mode 100644 index 0000000..40b96e5 --- /dev/null +++ b/docs/guide/collaborative-debugging.md @@ -0,0 +1,375 @@ +--- +title: Collaborative UI Debugging with AvaloniaMcp & DevTools +description: Comprehensive guide for developers and AI coding assistants collaborating on desktop UI debugging, visual tree inspection, and runtime diagnostics. +outline: deep +--- + +# Collaborative UI Debugging with AvaloniaMcp & DevTools + +Local LLM Server Manager includes an in-app debugging mode designed for live, collaborative problem-solving between software developers and AI coding assistants (such as Antigravity). + +By uniting **Avalonia F12 DevTools** for human visual inspection with the **AvaloniaMcp** Model Context Protocol server for AI-driven programmatic inspection, teams can diagnose visual defects, inspect runtime data contexts, catch silent XAML binding errors, and verify fixes in real time. + +--- + +## Architecture & Dual-Channel Inspection + +Collaborative debugging operates on a dual-channel architecture: + +1. **Human Developer Channel**: The developer interacts with the running desktop application, evaluates ergonomics and visual aesthetics, and uses built-in F12 DevTools to inspect layout bounds, active styles, and element trees interactively. +2. **AI Assistant Channel**: The AI coding assistant connects through the `AvaloniaMcp` protocol server over a local named pipe, querying visual trees, serialized ViewModel state, and binding diagnostic logs via structured JSON-RPC tool calls. + +``` ++-------------------------------------------------------------------------+ +| DEVELOPER WORKSPACE | +| - Runs application in Debug mode | +| - Inspects visual layout & controls via F12 DevTools | +| - Reports visual anomalies or UX friction in conversation | ++------------------------------------+------------------------------------+ + | + v ++-------------------------------------------------------------------------+ +| AVALONIA RUNTIME PROCESS (PID: {pid}) | +| | +| - AppBuilder.Configure() | +| .UsePlatformDetect() | +| .UseMcpDiagnostics() <-- AvaloniaMcp Named Pipe Endpoint | +| .LogToTrace() | +| | +| - Named Pipe Endpoint: `avalonia-mcp-{pid}` | +| - Process Discovery File: `%TEMP%/avalonia-mcp/{pid}.json` | +| - UI Diagnostic Logger (Binding error trace capture) | ++------------------------------------+------------------------------------+ + | Named Pipe (JSON-RPC) + v ++-------------------------------------------------------------------------+ +| AVALONIA MCP SERVER (`avaloniamcp`) | +| | +| - Global .NET Tool (`dotnet avalonia-mcp`) | +| - Exposes 15 MCP Tools to AI Assistant over stdio | ++------------------------------------+------------------------------------+ + | MCP Protocol (stdio) + v ++-------------------------------------------------------------------------+ +| ANTIGRAVITY AI ASSISTANT | +| - Discovers running UI app instance (`discover_apps`) | +| - Queries visual/logical trees, DataContexts, & broken bindings | +| - Mutates properties live to test layout hypotheses | +| - Captures element screenshots for visual inspection | +| - Formulates and applies codebase fixes directly | ++-------------------------------------------------------------------------+ +``` + +### Production Isolation & Security + +All debugging and diagnostic bridges are conditionally compiled: + +* **Conditional Project References**: `AvaloniaMcp.Diagnostics` and `Avalonia.Diagnostics` are included in `LocalLLMServerManager.csproj` only when `'$(Configuration)' == 'Debug'`. +* **Preprocessor Directives**: Initialization calls (`UseMcpDiagnostics()` and `this.AttachDevTools()`) are wrapped in `#if DEBUG` preprocessor blocks. +* **Release Cleanliness**: In `Release` builds, no named pipe server is started, no discovery files are generated, F12 DevTools cannot be activated, and the compiled binaries contain zero runtime overhead or external attack surface. +* **IPC Security**: Named pipes bind exclusively to local OS inter-process communication (`%TEMP%/avalonia-mcp/`) accessible only by the current authenticated user session. + +--- + +## Launching in Debug Mode + +To enable collaborative debugging, compile and launch the project under the `Debug` configuration. + +### Command + +Run the following command from the repository root: + +```bash +dotnet run -c Debug +``` + +### Startup Initialization + +When launched in `Debug` configuration, the application executes the following startup sequence: + +1. **AppBuilder Diagnostics**: In `Program.cs`, `BuildAvaloniaApp()` invokes `.UseMcpDiagnostics()` and `.LogToTrace()`. +2. **Named Pipe Creation**: `AvaloniaMcp.Diagnostics` allocates a local named pipe identified as `avalonia-mcp-{pid}` where `{pid}` is the process ID of the running application. +3. **Discovery Metadata**: A JSON discovery file is written to `%TEMP%/avalonia-mcp/{pid}.json` containing the process ID, application name, start time, and named pipe endpoint. +4. **DevTools Attachment**: In `Views/MainWindow.axaml.cs`, the window constructor calls `this.AttachDevTools()`, registering the `F12` global shortcut. +5. **Diagnostic Logging**: The `UiDiagnosticLogger` registers trace listeners to intercept Avalonia binding warnings and errors. + +--- + +## Human Developer: Using F12 DevTools + +The built-in Avalonia DevTools provide immediate visual inspection without requiring external browsers or agents. + +### Opening DevTools + +1. Ensure the desktop application window is focused. +2. Press F12. +3. A separate **Avalonia DevTools** diagnostic window opens. + +### Key DevTools Capabilities + +#### 1. Visual Tree & Logical Tree Inspection +* **Visual Tree**: Displays every rendered visual primitive (e.g., `Border`, `ContentPresenter`, `TextBlock`, `LayoutTransformControl`). Use this to determine actual render sizes, margins, padding, clipping rectangles, and alignment. +* **Logical Tree**: Displays controls as declared in high-level XAML markup (e.g., `Button`, `ListBox`, `Grid`), making it straightforward to match UI elements with their source `.axaml` files. +* **Pointer Selection**: Click the crosshair icon in the DevTools toolbar, then click any element in the main application window to jump directly to that element in the tree. + +#### 2. Property Inspector & Live Editing +* Selecting any node in the tree shows all registered Avalonia properties in the right-hand panel. +* **Property Precedence**: Observe whether a property value originates from a local assignment, an active style setter, an inherited value, or default metadata. +* **Live Property Modification**: Double-click editable values (such as `Width`, `Height`, `Margin`, `HorizontalAlignment`, `Background`, or `IsVisible`) to change them at runtime. This allows rapid verification of layout fixes before editing source code. + +#### 3. Style Debugging +* The **Styles** tab lists every style rule currently evaluated against the selected control. +* Active rules are highlighted, while overridden or unmatched rules are dimmed. +* **Pseudo-Class Tracking**: Observe dynamic pseudo-classes such as `:pointerover`, `:pressed`, `:focus`, and `:disabled` update live as you interact with the UI. + +#### 4. Event Tracking & Layout Diagnostics +* The **Events** tab records routed events (pointer moved, pointer pressed, key down) bubbling or tunneling through the tree. +* Use this to diagnose why a button click is not reaching an expected handler or if an invisible overlay is intercepting pointer input. + +--- + +## AI Assistant: Connecting via AvaloniaMcp + +AI assistants connect to the running application using the open-source `avaloniamcp` Model Context Protocol server. + +### 1. Installing the Global Tool + +Install the `avaloniamcp` global .NET tool from NuGet: + +```bash +dotnet tool install -g avaloniamcp +``` + +To update an existing installation to the latest release: + +```bash +dotnet tool update -g avaloniamcp +``` + +### 2. Running the MCP Server + +Start the server using standard stdio communication: + +```bash +dotnet avalonia-mcp +``` + +The server automatically monitors `%TEMP%/avalonia-mcp/`, discovers any active Avalonia application running with `.UseMcpDiagnostics()`, and bridges MCP tool calls directly to the application's UI thread via the local named pipe. + +### 3. MCP Client Configuration + +To configure Antigravity, Claude Desktop, Cursor, or other MCP-compatible AI clients, add the server to your configuration file (e.g., `mcpServers` block): + +```json +{ + "mcpServers": { + "avalonia_mcp": { + "command": "dotnet", + "args": ["avalonia-mcp"] + } + } +} +``` + +### 4. CLI Verification & Diagnostics + +You can verify the connection manually using the `avaloniamcp` CLI: + +```bash +# Discover running Avalonia processes +dotnet avalonia-mcp cli discover_apps + +# List open windows +dotnet avalonia-mcp cli list_windows + +# Check for active binding errors +dotnet avalonia-mcp cli get_binding_errors + +# Inspect top-level visual elements +dotnet avalonia-mcp cli get_visual_tree --maxDepth 3 +``` + +--- + +## The 15 AvaloniaMcp Tools + +`avaloniamcp` exposes 15 specialized tools to AI assistants, categorized into five operational domains: + +| Category | Tool Name | Parameters | Description | +| :--- | :--- | :--- | :--- | +| **Inspection** | `list_windows` | *None* | Lists all open windows, titles, dimensions, positions, and window states. | +| | `get_visual_tree` | `maxDepth` *(optional)* | Returns the complete rendered visual hierarchy with element types, names, bounds, and visibility. | +| | `get_logical_tree` | `maxDepth` *(optional)* | Returns the logical hierarchy matching the developer's XAML markup declarations. | +| | `find_control` | `name`, `typeName`, `text` *(optional)* | Fast lookup of UI elements by name (`#Name`), control type, or displayed text. | +| | `get_control_properties` | `controlId` | Dumps all registered Avalonia properties, current values, types, and inheritance sources for an element. | +| **Data & Bindings** | `get_data_context` | `controlId` *(optional)* | Serializes the bound ViewModel properties and values into clean JSON. | +| | `get_binding_errors` | *None* | Retrieves all active and logged Avalonia binding errors, including target properties and source paths. | +| **Visual & Styles** | `take_screenshot` | `controlId` *(optional)* | Captures a high-resolution base64 PNG of the entire window or a specific control for visual analysis. | +| | `get_applied_styles` | `controlId` | Inspects matching style selectors, active setters, and pseudo-classes (`:pointerover`, `:pressed`). | +| | `get_resources` | `controlId` *(optional)* | Enumerates XAML resources (brushes, colors, geometry, templates) accessible at the element's scope. | +| | `get_focused_element` | *None* | Returns the currently focused control and its keyboard tab navigation index. | +| **Interaction** | `click_control` | `controlId` | Programmatically triggers a click event and executes bound `ICommand` handlers. | +| | `input_text` | `controlId`, `text` | Types text into `TextBox` or other editable input controls. | +| | `set_property` | `controlId`, `propertyName`, `value` | Mutates control properties at runtime to test layout fixes live on the UI thread. | +| **Discovery** | `discover_apps` | *None* | Discovers all running Avalonia applications instrumented with `AvaloniaMcp.Diagnostics`. | + +### Tool Deep Dive + +#### `list_windows` +Returns an array of active desktop windows, indicating whether each window is active, minimized, normal, or maximized, along with screen coordinates and dimensions (`X`, `Y`, `Width`, `Height`). + +#### `get_visual_tree` +Generates a structured tree representation of every visual element. Each node includes a unique `controlId`, type name (e.g., `Avalonia.Controls.Button`), element name, bounds, and visibility state. Limiting `maxDepth` prevents overwhelming output on deeply nested layouts. + +#### `get_logical_tree` +Summarizes the UI hierarchy from the perspective of XAML logical parenting. This view omits internal layout primitives (such as internal borders and presenters), making it easier to reason about high-level component organization. + +#### `find_control` +Allows the AI assistant to search across the UI hierarchy in a single step. For example, calling `find_control(name: "HuggingFaceSearchBox")` or `find_control(text: "Download")` quickly returns the target `controlId` without traversing the entire tree. + +#### `get_control_properties` +Fetches the full property dictionary of a specific control. Includes layout metrics (`Margin`, `Padding`, `HorizontalAlignment`, `ActualWidth`), state properties (`IsEnabled`, `IsVisible`), and control-specific configurations. + +#### `get_data_context` +Inspects the ViewModel instance bound to the target control. The server traverses the object graph and serializes properties, collections, and commands into JSON. If a control has no local `DataContext`, it inherits and inspects the ancestor context. + +#### `get_binding_errors` +Queries the in-app diagnostic log for binding failures. This captures silent failures where Avalonia encounters a missing ViewModel property, invalid cast, or null path element during evaluation. + +#### `take_screenshot` +Renders the specified control or the entire window into an off-screen render target and returns a base64-encoded PNG image. This enables multimodal AI models to visually inspect alignment, clipping, contrast, and layout rendering. + +#### `get_applied_styles` +Returns the cascade of styles matching the control. Identifies active selectors, applied setters, and pseudo-classes, pinpointing whether an unintended theme rule or local style is overriding expected colors or margins. + +#### `get_resources` +Inspects local and inherited XAML `ResourceDictionary` trees. Enables verifying whether brush keys (e.g., `AccentColorBrush`, `SystemControlBackgroundBaseMediumBrush`) resolve to intended color definitions. + +#### `get_focused_element` +Returns the element currently holding keyboard focus. Useful for diagnosing keyboard navigation bugs, focus trapping, or broken tab indexing. + +#### `click_control` +Dispatches a synthetic pointer click event on the UI thread for the target control. Verifies whether button commands execute correctly and updates the UI state accordingly. + +#### `input_text` +Sets text on editable controls, raising corresponding text change and binding notification events to simulate user data entry. + +#### `set_property` +Dispatches a property update directly to the Avalonia property system on the UI thread. The AI assistant can test candidate values (e.g., changing `Width` from `NaN` to `200`, or `IsVisible` from `false` to `true`) and immediately verify the visual outcome before modifying files. + +#### `discover_apps` +Scans local IPC registration files in `%TEMP%/avalonia-mcp/` and reports all detectable Avalonia instances with their process IDs and executable paths. + +--- + +## Diagnostic Logging & Binding Error Inspection + +In XAML-based frameworks, data binding errors fail silently by default to prevent application crashes during render loops. However, silent failures lead to empty lists, unresponsive buttons, and blank labels that are difficult to diagnose from application logs alone. + +### Common Binding Failure Modes + +1. **Path Typo**: The XAML binding `{Binding ModelTitel}` references a misspelled property (`ModelTitle`). +2. **Missing Notification**: A ViewModel property lacks `SetProperty(ref _field, value)` or `[ObservableProperty]`, preventing UI updates when values change. +3. **Null Path Navigation**: Binding `{Binding SelectedEngine.Config.Port}` fails because `SelectedEngine` or `Config` is null during initialization. +4. **Type Conversion Mismatch**: Binding a string to an enum property without an appropriate `IValueConverter`. + +### In-App Diagnostic Logger (`UiDiagnosticLogger`) + +To capture these issues, `LocalLLMServerManager` includes a dedicated `UiDiagnosticLogger` service: + +* **Trace Interception**: Hooks into Avalonia's internal `Trace.Listeners` and `Logger` system, filtering for `LogEventLevel.Warning` and `LogEventLevel.Error` on the `Binding` log category. +* **Ring Buffer Storage**: Stores recent entries in a bounded circular buffer (capped at 250 entries) to prevent unbounded memory growth during long debugging sessions. +* **Structured Records**: Each diagnostic entry captures: + * Timestamp (UTC) + * Target control type and name + * Bound target property name + * Source path expression + * Full exception or warning message + +### Example Diagnostic Log Entry + +When `get_binding_errors` is called, the AI assistant receives structured error information: + +```json +[ + { + "timestamp": "2026-09-18T20:15:32.410Z", + "target": "Avalonia.Controls.Button #FilterLoraButton", + "property": "Command", + "sourcePath": "ToggleLoraFilterCommand", + "message": "Could not find property 'ToggleLoraFilterCommand' on 'CivitaiSearchViewModel'." + } +] +``` + +This precise output immediately indicates that `CivitaiSearchViewModel` lacks the expected command or named it differently (e.g., `FilterLoraCommand`), eliminating guesswork. + +--- + +## Collaborative Bug-Hunting Workflow + +The diagram below illustrates the typical workflow between the developer, the running application, the `avaloniamcp` server, and the AI assistant: + +```mermaid +sequenceDiagram + autonumber + actor Dev as Developer + participant App as Avalonia App (Debug) + participant MCP as avaloniamcp Server + participant AI as Antigravity AI + + Dev->>App: Launch via `dotnet run -c Debug` + App->>MCP: Register named pipe in %TEMP%/avalonia-mcp/ + Dev->>App: Interacts with UI (e.g. Hugging Face / CivitAI Hub) + Dev->>AI: Reports symptom: "The CivitAI LoRA toggle button does not activate" + + rect rgb(30, 40, 60) + Note over AI,MCP: Autonomous AI Inspection Loop + AI->>MCP: `discover_apps()` + MCP-->>AI: Returns active process ID + AI->>MCP: `find_control(name: "FilterLoraButton")` + MCP-->>AI: Returns controlId "c-1048" + AI->>MCP: `get_control_properties(controlId: "c-1048")` + MCP-->>AI: Returns IsEnabled: false, Classes: [] + AI->>MCP: `get_binding_errors()` + MCP-->>AI: Returns Command binding error on ToggleLoraFilterCommand + AI->>MCP: `get_data_context(controlId: "c-1048")` + MCP-->>AI: Returns ViewModel state (FilterType: Checkpoint) + end + + AI->>Dev: Explains root cause: XAML bound to non-existent command + AI->>App: Applies code fix in CivitaiSearchViewModel.cs & XAML + AI->>AI: Runs `dotnet test`, `npm run lint`, `npx tsc --noEmit` + Dev->>App: Validates fix live in the desktop UI +``` + +### Step-by-Step Problem Resolution + +1. **Reproduction**: The developer reproduces a UI glitch or unexpected state in the running application. +2. **Report**: The developer describes the observation to the AI assistant (e.g., *"The Hugging Face search box doesn't submit when pressing Enter"* or *"The status badge is clipped"*). +3. **Targeted Inspection**: + * The AI assistant calls `find_control` to locate the relevant element. + * Calls `get_data_context` to inspect current ViewModel state. + * Calls `get_binding_errors` to check for silent binding failures. +4. **Visual Verification**: If the issue involves alignment or styling, the AI assistant calls `take_screenshot` or `get_applied_styles`. +5. **Interactive Prototyping**: The AI assistant can invoke `set_property` to verify whether a proposed property change resolves the issue live. +6. **Codebase Modification**: The AI assistant updates the appropriate `.axaml` or `.cs` file in the repository. +7. **Verification**: The AI assistant runs automated quality gates (`npm run lint`, `npx tsc --noEmit`, `dotnet test`). +8. **Confirmation**: The developer reviews the running UI and confirms the fix. + +--- + +## Developer Quick Reference + +| Action | Command / Shortcut | Purpose | +| :--- | :--- | :--- | +| **Launch in Debug Mode** | `dotnet run -c Debug` | Launches app with DevTools and `AvaloniaMcp` active. | +| **Toggle DevTools** | F12 (in app) | Opens the interactive Avalonia DevTools inspector window. | +| **Install MCP Tool** | `dotnet tool install -g avaloniamcp` | Installs the global MCP server on developer machines. | +| **Update MCP Tool** | `dotnet tool update -g avaloniamcp` | Updates `avaloniamcp` to the latest version. | +| **Test MCP Connection** | `dotnet avalonia-mcp cli discover_apps` | Lists active Avalonia instances available for debugging. | +| **Inspect Binding Errors** | `dotnet avalonia-mcp cli get_binding_errors` | Dumps current binding diagnostic log directly to terminal. | +| **Run Unit Tests** | `dotnet test` | Executes solution test suite. | +| **Build Documentation** | `npm run docs:build` | Verifies VitePress documentation builds cleanly with zero errors. | +| **Lint Codebase** | `npm run lint` | Runs ESLint across TypeScript and tooling scripts. | +| **Typecheck Codebase** | `npx tsc --noEmit` | Runs TypeScript typechecker. | From 967c39e29d115cf37a5ee7a6817d05ce0bb0d505 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 20:43:51 -0500 Subject: [PATCH 10/28] fix(debug): wire UiDiagnosticTraceListener to trace logging, register DI singleton, and align docs --- .../Services/UiDiagnosticTraceListener.cs | 47 +++++++++++++++++++ .../UiDiagnosticLoggerTests.cs | 34 ++++++++++++++ LocalLLMServerManager.csproj | 1 + Program.cs | 5 ++ docs/guide/collaborative-debugging.md | 2 +- 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 LocalLLMServerManager.Shared/Services/UiDiagnosticTraceListener.cs diff --git a/LocalLLMServerManager.Shared/Services/UiDiagnosticTraceListener.cs b/LocalLLMServerManager.Shared/Services/UiDiagnosticTraceListener.cs new file mode 100644 index 0000000..ede469f --- /dev/null +++ b/LocalLLMServerManager.Shared/Services/UiDiagnosticTraceListener.cs @@ -0,0 +1,47 @@ +using System; +using System.Diagnostics; + +namespace LocalLLMServerManager.Shared.Services; + +/// +/// TraceListener that intercepts Avalonia UI binding diagnostic and warning messages +/// and records them into an IUiDiagnosticLogger instance. +/// +public class UiDiagnosticTraceListener : TraceListener +{ + private readonly IUiDiagnosticLogger _logger; + + public UiDiagnosticTraceListener(IUiDiagnosticLogger logger) + : base("UiDiagnosticTraceListener") + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public override void Write(string? message) + { + ProcessMessage(message); + } + + public override void WriteLine(string? message) + { + ProcessMessage(message); + } + + private void ProcessMessage(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + // Intercept messages related to Avalonia data binding errors and warnings + if (message.Contains("[Binding]", StringComparison.OrdinalIgnoreCase) || + message.Contains("Error in binding", StringComparison.OrdinalIgnoreCase) || + message.Contains("Binding error", StringComparison.OrdinalIgnoreCase) || + (message.Contains("Binding", StringComparison.OrdinalIgnoreCase) && + (message.Contains("Error", StringComparison.OrdinalIgnoreCase) || message.Contains("Warning", StringComparison.OrdinalIgnoreCase)))) + { + _logger.RecordBindingError(message.Trim(), "Avalonia.Binding"); + } + } +} diff --git a/LocalLLMServerManager.Tests/UiDiagnosticLoggerTests.cs b/LocalLLMServerManager.Tests/UiDiagnosticLoggerTests.cs index f3accb1..75329f6 100644 --- a/LocalLLMServerManager.Tests/UiDiagnosticLoggerTests.cs +++ b/LocalLLMServerManager.Tests/UiDiagnosticLoggerTests.cs @@ -155,4 +155,38 @@ public void RecordBindingError_IsThreadSafeUnderConcurrentAccess() Assert.Equal(capacity, entries.Count); Assert.True(entries.All(e => !string.IsNullOrEmpty(e.Message))); } + + [Fact] + public void UiDiagnosticTraceListener_InterceptsBindingMessages_AndRecordsToLogger() + { + var logger = new UiDiagnosticLogger(); + var listener = new UiDiagnosticTraceListener(logger); + + listener.WriteLine("[Binding] Error in binding to 'MissingProperty': Path not found on ViewModel"); + listener.Write("Error in binding to 'Command': Null reference"); + listener.WriteLine("[Warning][Binding] Cannot convert 'abc' to integer"); + + var errors = logger.GetRecentErrors(); + Assert.Equal(3, errors.Count); + Assert.Contains("MissingProperty", errors[0].Message); + Assert.Equal("Avalonia.Binding", errors[0].Source); + Assert.Contains("Command", errors[1].Message); + Assert.Contains("Cannot convert", errors[2].Message); + } + + [Fact] + public void UiDiagnosticTraceListener_IgnoresUnrelatedTraceMessages() + { + var logger = new UiDiagnosticLogger(); + var listener = new UiDiagnosticTraceListener(logger); + + listener.WriteLine("[Http] GET /api/health 200 OK"); + listener.WriteLine("Application started successfully on port 5246"); + listener.Write(null); + listener.WriteLine(" "); + + var errors = logger.GetRecentErrors(); + Assert.Empty(errors); + } } + diff --git a/LocalLLMServerManager.csproj b/LocalLLMServerManager.csproj index bed68dc..d1b353a 100644 --- a/LocalLLMServerManager.csproj +++ b/LocalLLMServerManager.csproj @@ -41,6 +41,7 @@ + diff --git a/Program.cs b/Program.cs index 6639af0..48042ff 100644 --- a/Program.cs +++ b/Program.cs @@ -81,6 +81,10 @@ public static AppBuilder BuildAvaloniaApp() .LogToTrace(); #if DEBUG builder = builder.UseMcpDiagnostics(); + if (!System.Diagnostics.Trace.Listeners.OfType().Any()) + { + System.Diagnostics.Trace.Listeners.Add(new UiDiagnosticTraceListener(UiDiagnosticLogger.Instance)); + } #endif return builder; } @@ -168,6 +172,7 @@ public static WebApplication CreateWebApplication(string[] args, bool isServiceM builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(UiDiagnosticLogger.Instance); // Register MCP Server builder.Services.AddMcpServer() diff --git a/docs/guide/collaborative-debugging.md b/docs/guide/collaborative-debugging.md index 40b96e5..1a1f94e 100644 --- a/docs/guide/collaborative-debugging.md +++ b/docs/guide/collaborative-debugging.md @@ -278,7 +278,7 @@ In XAML-based frameworks, data binding errors fail silently by default to preven To capture these issues, `LocalLLMServerManager` includes a dedicated `UiDiagnosticLogger` service: * **Trace Interception**: Hooks into Avalonia's internal `Trace.Listeners` and `Logger` system, filtering for `LogEventLevel.Warning` and `LogEventLevel.Error` on the `Binding` log category. -* **Ring Buffer Storage**: Stores recent entries in a bounded circular buffer (capped at 250 entries) to prevent unbounded memory growth during long debugging sessions. +* **Ring Buffer Storage**: Stores recent entries in a bounded circular buffer (capped at 500 entries) to prevent unbounded memory growth during long debugging sessions. * **Structured Records**: Each diagnostic entry captures: * Timestamp (UTC) * Target control type and name From a31c9c94314b30cc85fc8c60dc5edd40389e729e Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Fri, 18 Sep 2026 22:09:32 -0500 Subject: [PATCH 11/28] fix(ui): use vector path icons for tab headers and fix AI Copilot connectivity - Replace text emojis in MainView and ModelsTabControl with vector PathIcon headers referencing DesignTokens to prevent font fallback sticking issues on hover - Fix AI Copilot URI resolution using ApiBase and BuildUrl for Desktop and WASM clients - Add robust error handling in AiAssistantViewModel to prevent unhandled task exceptions - Inject SettingsService and PromptManagementService in MainWindow - Increase connection validation timeout in AiAssistantService from 10s to 30s for cold local models - Add unit tests in AiAssistantViewModelTests covering HTTP fallback, ApiBase, and error states --- .../Styles/DesignTokens.axaml | 11 +++ .../ViewModels/AiAssistantViewModel.cs | 43 ++++++++-- .../ViewModels/MainViewModel.cs | 27 +++++- .../Controls/AiAssistantTabControl.axaml | 2 +- .../Views/Controls/ModelsTabControl.axaml | 24 +++++- .../Views/MainView.axaml | 50 +++++++++-- .../AiAssistantViewModelTests.cs | 84 +++++++++++++++++++ Services/AiAssistantService.cs | 2 +- Views/MainWindow.axaml.cs | 15 +++- 9 files changed, 238 insertions(+), 20 deletions(-) diff --git a/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml b/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml index 62616d0..55f6305 100644 --- a/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml +++ b/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml @@ -33,4 +33,15 @@ + + + M21,16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V7.5C3,7.12 3.21,6.79 3.53,6.62L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.79,6.79 21,7.12 21,7.5V16.5M12,4.15L6.04,7.5L12,10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V9.21L13,12.58V19.29L19,15.91Z + M13 2L3 14h6l-2 8 10-12h-6l2-8z + M6 2v2H4a2 2 0 0 0-2 2v2h2v2H2v2h2v2H2v2a2 2 0 0 0 2 2h2v2h2v-2h2v2h2v-2h2v2h2a2 2 0 0 0 2-2v-2h2v-2h-2v-2h2v-2h-2V6a2 2 0 0 0-2-2h-2V2h-2v2h-2V2h-2v2H8V2H6zm0 4h12v12H6V6zm2 2v8h8V8H8z + M12 2a2 2 0 0 1 2 2v1h3a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3h3V4a2 2 0 0 1 2-2zm-4 8a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm8 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-6 5a1 1 0 0 0 0 2h4a1 1 0 1 0 0-2H10z + M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4zm12 16H6v-2h12v2zm0-4H6v-2h12v2zm0-4h-5V4h5v8z + M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54A.48.48 0 0 0 14 2h-4a.48.48 0 0 0-.48.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.64 8.47a.49.49 0 0 0 .12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h4c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.03-1.58zM12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7z + M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z + M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z + M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10a2.5 2.5 0 0 0 2.5-2.5c0-.61-.23-1.2-.64-1.67-.4-.46-.61-1.04-.61-1.63 0-1.38 1.12-2.5 2.5-2.5H17c2.76 0 5-2.24 5-5 0-4.74-4.48-8.7-10-8.7zm-5.5 9a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm3-4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm3 4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z diff --git a/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs index 0acd5d9..e709138 100644 --- a/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs @@ -52,6 +52,13 @@ public partial class AiAssistantViewModel : ObservableObject [ObservableProperty] private string _connectionStatusMessage = ""; [ObservableProperty] private bool? _isConnectionSuccess = null; [ObservableProperty] private string _promptDirectory = ""; + [ObservableProperty] private string _apiBase = ""; + + public string BuildUrl(string relativePath) + { + if (string.IsNullOrWhiteSpace(ApiBase)) return relativePath; + return $"{ApiBase.TrimEnd('/')}/{relativePath.TrimStart('/')}"; + } // Chat UI States [ObservableProperty] private string _inputText = ""; @@ -298,7 +305,8 @@ public async Task SendMessageAsync() else if (_httpClient != null) { // Remote / WASM client streaming via SSE or POST - var response = await _httpClient.PostAsJsonAsync("/api/ai/chat", request with { Stream = false }, ct); + var url = BuildUrl("/api/ai/chat"); + var response = await _httpClient.PostAsJsonAsync(url, request with { Stream = false }, ct); if (response.IsSuccessStatusCode) { var chatResp = await response.Content.ReadFromJsonAsync(JsonOptions, ct); @@ -411,7 +419,8 @@ public async Task TestConnectionAsync() else if (_httpClient != null) { var payload = new { endpoint = Endpoint, apiKey = ApiKey, model = SelectedModel }; - var resp = await _httpClient.PostAsJsonAsync("/api/ai/validate", payload); + var url = BuildUrl("/api/ai/validate"); + var resp = await _httpClient.PostAsJsonAsync(url, payload); if (resp.IsSuccessStatusCode) { result = await resp.Content.ReadFromJsonAsync(JsonOptions); @@ -480,7 +489,7 @@ public async Task LoadAvailableModelsAsync(bool refresh = false) { var ep = Uri.EscapeDataString(Endpoint ?? ""); var key = Uri.EscapeDataString(ApiKey ?? ""); - var url = $"/api/ai/models?endpoint={ep}&apiKey={key}&includeLocal=false"; + var url = BuildUrl($"/api/ai/models?endpoint={ep}&apiKey={key}&includeLocal=false"); caps = await _httpClient.GetFromJsonAsync>(url, JsonOptions); } @@ -513,7 +522,7 @@ public async Task LoadAvailableModelsAsync(bool refresh = false) } [RelayCommand] - public void SaveConfiguration() + public async Task SaveConfiguration() { if (_settingsService != null) { @@ -527,6 +536,29 @@ public void SaveConfiguration() }; _settingsService.SaveSettings(updated); } + else if (_httpClient != null) + { + try + { + var settingsUrl = BuildUrl("/api/settings"); + var currentSettings = await _httpClient.GetFromJsonAsync(settingsUrl, JsonOptions); + if (currentSettings != null) + { + var updated = currentSettings with + { + AiAssistantEnabled = IsEnabled, + AiAssistantEndpoint = Endpoint, + AiAssistantApiKey = ApiKey, + AiAssistantModel = SelectedModel + }; + await _httpClient.PostAsJsonAsync(settingsUrl, updated, JsonOptions); + } + } + catch + { + // Suppress remote save error + } + } IsSetupCardVisible = false; ConnectionStatusMessage = "Settings saved successfully."; @@ -543,7 +575,8 @@ public async Task ReloadPromptsAsync() } else if (_httpClient != null) { - await _httpClient.PostAsync("/api/ai/prompts/reload", null); + var url = BuildUrl("/api/ai/prompts/reload"); + await _httpClient.PostAsync(url, null); ConnectionStatusMessage = "Prompts reloaded via API."; } } diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs index c40377b..fd69d5c 100644 --- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs @@ -158,7 +158,14 @@ public MainViewModel( }; Settings = new SettingsViewModel(PresetService) { ApiBase = ApiBase }; Audio = new AudioStudioViewModel(PresetService, _canIRunItService) { ApiBase = ApiBase }; - Assistant = new AiAssistantViewModel(assistantService, settingsService, promptService, httpClient ?? Http); + Assistant = new AiAssistantViewModel( + assistantService, + settingsService, + promptService ?? new PromptManagementService(), + httpClient ?? Http) + { + ApiBase = ApiBase + }; Documentation.OnNavigateToTabRequested = tab => SelectedTabIndex = tab; LoadStudioPresets(); @@ -188,7 +195,23 @@ private static string GetDefaultApiBase() return "http://127.0.0.1:5246"; } - public string ApiBase { get; set; } = GetDefaultApiBase(); + private string _apiBase = GetDefaultApiBase(); + public string ApiBase + { + get => _apiBase; + set + { + _apiBase = value; + if (Telemetry != null) Telemetry.ApiBase = value; + if (HardwareFit != null) HardwareFit.ApiBase = value; + if (Ollama != null) Ollama.ApiBase = value; + if (HuggingFace != null) HuggingFace.ApiBase = value; + if (Civitai != null) Civitai.ApiBase = value; + if (Settings != null) Settings.ApiBase = value; + if (Audio != null) Audio.ApiBase = value; + if (Assistant != null) Assistant.ApiBase = value; + } + } // Backward-compatible properties forwarding to sub-ViewModels public string GpuName { get => Telemetry.GpuName; set => Telemetry.GpuName = value; } diff --git a/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml index 8eaff51..1265d75 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml @@ -299,7 +299,7 @@ - + - + + + + + + + + + - @@ -63,21 +50,27 @@ - + + + + + + + + + + - - - - diff --git a/LocalLLMServerManager.Shared/Views/MainView.axaml b/LocalLLMServerManager.Shared/Views/MainView.axaml index 08e7846..287bbf3 100644 --- a/LocalLLMServerManager.Shared/Views/MainView.axaml +++ b/LocalLLMServerManager.Shared/Views/MainView.axaml @@ -77,6 +77,9 @@ + @@ -94,14 +97,6 @@ - - - - - diff --git a/Views/DocumentationWindow.axaml b/Views/DocumentationWindow.axaml index 51c1ee1..6b2ff68 100644 --- a/Views/DocumentationWindow.axaml +++ b/Views/DocumentationWindow.axaml @@ -9,6 +9,7 @@ Width="880" Height="680" MinWidth="600" MinHeight="450" WindowStartupLocation="CenterOwner" + WindowDecorations="BorderOnly" Background="{StaticResource BgDarkBrush}"> From 678ab64c826ef01a20843a708dde568244052b3a Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Sat, 19 Sep 2026 19:46:05 -0500 Subject: [PATCH 17/28] Implement real engine test flight, interactive telemetry badges, and modality combobox --- .../AvaloniaHeadlessInteractionTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs b/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs index 3ae9180..a8ab841 100644 --- a/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs +++ b/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs @@ -174,7 +174,7 @@ public void MainView_Tab5_CanIRunItNavigation_SwitchesTabAndRendersCanIRunItView } [AvaloniaFact] - public void EngineStudioTabControl_ComboBoxSelection_UpdatesStudioMode() + public void EngineStudioTabControl_RadioButtonSelection_UpdatesStudioMode() { var vm = new MainViewModel(); var control = new EngineStudioTabControl { DataContext = vm }; @@ -184,7 +184,7 @@ public void EngineStudioTabControl_ComboBoxSelection_UpdatesStudioMode() // Find Studio Mode ComboBox var comboBoxes = control.GetVisualDescendants().OfType().ToList(); - var modeComboBox = comboBoxes.FirstOrDefault(); + var modeComboBox = comboBoxes.FirstOrDefault(c => c.Items.Cast().Any(i => i.ToString() == "Images")); Assert.NotNull(modeComboBox); // Simulate user selecting Audio mode From 161fedb66e7a630fb9d4d67983af00e8652ebcb9 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Sat, 19 Sep 2026 19:48:18 -0500 Subject: [PATCH 18/28] feat: complete AI Assist rebrand, pop-out support, and dynamic LiteLLM models - Rebrand to AI Assist - Implement AiAssistWindow and wire up Pop Out action - Convert model textbox to ComboBox with model capabilities parsing (id, provider, owned_by, context_window, max_tokens) --- .../Views/AiAssistWindow.axaml | 38 +++++++++++++++++++ .../Views/AiAssistWindow.axaml.cs | 23 +++++++++++ .../Controls/AiAssistantTabControl.axaml | 11 +++++- Services/AiAssistantService.cs | 4 +- 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 LocalLLMServerManager.Shared/Views/AiAssistWindow.axaml create mode 100644 LocalLLMServerManager.Shared/Views/AiAssistWindow.axaml.cs diff --git a/LocalLLMServerManager.Shared/Views/AiAssistWindow.axaml b/LocalLLMServerManager.Shared/Views/AiAssistWindow.axaml new file mode 100644 index 0000000..b275a66 --- /dev/null +++ b/LocalLLMServerManager.Shared/Views/AiAssistWindow.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + diff --git a/LocalLLMServerManager.Shared/Views/MainView.axaml.cs b/LocalLLMServerManager.Shared/Views/MainView.axaml.cs index 731322e..8a4d924 100644 --- a/LocalLLMServerManager.Shared/Views/MainView.axaml.cs +++ b/LocalLLMServerManager.Shared/Views/MainView.axaml.cs @@ -9,7 +9,6 @@ public partial class MainView : UserControl public MainView() { InitializeComponent(); - DataContext = new MainViewModel(); } private void InitializeComponent() diff --git a/LocalLLMServerManager.Web/App.axaml.cs b/LocalLLMServerManager.Web/App.axaml.cs index c3abef6..62e4b6e 100644 --- a/LocalLLMServerManager.Web/App.axaml.cs +++ b/LocalLLMServerManager.Web/App.axaml.cs @@ -31,7 +31,7 @@ public override void OnFrameworkInitializationCompleted() LocalLLMServerManager.Shared.ViewModels.MainViewModel.EnableAutomaticPolling = true; if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) { - singleView.MainView = new MainView(); + singleView.MainView = new MainView { DataContext = new LocalLLMServerManager.Shared.ViewModels.MainViewModel() }; } base.OnFrameworkInitializationCompleted(); diff --git a/Views/DocumentationWindow.axaml b/Views/DocumentationWindow.axaml index 6b2ff68..33d3c7f 100644 --- a/Views/DocumentationWindow.axaml +++ b/Views/DocumentationWindow.axaml @@ -13,10 +13,11 @@ Background="{StaticResource BgDarkBrush}"> - - - - + + + + @@ -24,10 +25,13 @@ - - + +``` +Styling in `MatteTheme.axaml`: +- Default (snapped): Accent border (`#4f46e5` / `#06b6d4`), subtle glow background. +- Detached: `#27272a` background, `#52525b` border, subtle hover highlight. + +### 3.2 Main Navigation Bar Integration +- Top segmented navigation bar buttons: + - `NavAiAssistBtn`: Opens `AiAssistWindow`, ensures it is snapped to the Right Flank, and brings to front. + - `NavDocumentationBtn`: Opens `DocumentationWindow`, ensures it is snapped to the Left Flank, and brings to front. + - Workspace tabs (`Models`, `Workflows`, `Can I Run It`, `Settings`) remain stable and unselected when opening either companion. + +--- + +## 4. Testing & Verification + +### 4.1 Automated Headless Tests (`LocalLLMServerManager.Tests`) +1. `WindowSnapManager_CalculatesFlanksAccurately`: + - Checks Left and Right position math against DPI scaling factors. +2. `WindowSnapManager_FollowsMainWindowMovement`: + - Verifies companion positions change by exact $\Delta X, \Delta Y$ when `MainWindow.Position` shifts. +3. `WindowSnapManager_SyncsHeight`: + - Verifies companion height changes when `MainWindow.Bounds.Height` shifts. +4. `WindowSnapManager_DetachOnDrag`: + - Simulates companion position delta $> 35px$ and asserts `IsSnapped == false`. +5. `WindowSnapManager_ToggleSnap`: + - Invokes toggle command and asserts transition between snapped and free-floating. +6. `WindowSnapManager_ProximitySnap`: + - Positions companion within $30px$ of target flank and asserts `IsSnapped == true`. + +### 4.2 Quality Gates +- `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj` (zero failures). +- `npm run lint` and `npx tsc --noEmit` (clean). +- Desktop GUI verification with live window management. From 0ebe5aded9a9fc3dcb2e048f3312d6575eb6abb7 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Sat, 19 Sep 2026 20:42:04 -0500 Subject: [PATCH 22/28] docs: add magnetic companion windows implementation plan --- .../2026-09-19-magnetic-companion-windows.md | 595 ++++++++++++++++++ 1 file changed, 595 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-19-magnetic-companion-windows.md diff --git a/docs/superpowers/plans/2026-09-19-magnetic-companion-windows.md b/docs/superpowers/plans/2026-09-19-magnetic-companion-windows.md new file mode 100644 index 0000000..3f87160 --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-magnetic-companion-windows.md @@ -0,0 +1,595 @@ +# Magnetic Snapped Companion Windows Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement magnetic companion windows for `DocumentationWindow` (Left Flank) and `AiAssistWindow` (Right Flank) that automatically attach flush against `MainWindow`, synchronize vertical height and position in lockstep, support drag-to-detach into free-floating windows, and support magnetic re-attachment via title-bar toggle buttons or edge proximity. + +**Architecture:** A centralized `WindowSnapManager` service monitors `MainWindow`'s position, size, and state changes to adjust attached companion windows with pixel precision. Each companion window exposes an interactive magnet toggle (`🧲 Attached` / `🧲 Snap to Side`) in its custom title bar and monitors pointer drag events to distinguish lockstep tracking from intentional user detachment. + +**Tech Stack:** C# 13, .NET 10, Avalonia 12.1.2 (Headless XUnit testing, Custom Windows, PixelPoint positioning, Screen WorkingArea boundary detection), XUnit v3. + +## Global Constraints +- Target Framework: .NET 10.0 (`net10.0`). +- Minimum window dimensions: `MinWidth: 380`, `MinHeight: 450`. +- Left Flank docks `DocumentationWindow` with default width `480px`. +- Right Flank docks `AiAssistWindow` with default width `440px`. +- Magnetic snap threshold: `35px` tolerance for proximity docking and drag detachment. +- Zero view switching in `MainWindow`: opening either companion window must not alter `SelectedTabIndex`. +- Quality gates: All 708+ xUnit tests must pass, `npm run lint` and `npx tsc --noEmit` must pass with 0 errors. + +--- + +### Task 1: Core WindowSnapManager Math & Flank Calculations + +**Files:** +- Create: `Services/WindowSnapManager.cs` +- Test: `LocalLLMServerManager.Tests/WindowSnapManagerTests.cs` + +**Interfaces:** +- Consumes: `Avalonia.Controls.Window`, `Avalonia.PixelPoint`, `Avalonia.Platform.Screen` +- Produces: `enum SnapFlank { Left, Right }`, `WindowSnapManager.Instance`, `CalculateSnappedPosition(Window main, Window companion, SnapFlank flank, double scaling)` + +- [ ] **Step 1: Write failing unit test for flank position calculations** + +```csharp +// In LocalLLMServerManager.Tests/WindowSnapManagerTests.cs +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using LocalLLMServerManager.Services; +using Xunit; + +namespace LocalLLMServerManager.Tests; + +public class WindowSnapManagerTests +{ + [AvaloniaFact] + public void CalculateSnappedPosition_RightFlank_PositionsFlushAgainstRightEdge() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(200, 150); + + var companion = new Window { Width = 400, Height = 700 }; + companion.Position = new PixelPoint(0, 0); + + var targetPos = manager.CalculateSnappedPosition(main, companion, SnapFlank.Right, scaling: 1.0); + + // Target X should be main.Position.X (200) + main.Width (1000) = 1200 + Assert.Equal(1200, targetPos.X); + Assert.Equal(150, targetPos.Y); + } + + [AvaloniaFact] + public void CalculateSnappedPosition_LeftFlank_PositionsFlushAgainstLeftEdge() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(600, 150); + + var companion = new Window { Width = 450, Height = 700 }; + companion.Position = new PixelPoint(0, 0); + + var targetPos = manager.CalculateSnappedPosition(main, companion, SnapFlank.Left, scaling: 1.0); + + // Target X should be main.Position.X (600) - companion.Width (450) = 150 + Assert.Equal(150, targetPos.X); + Assert.Equal(150, targetPos.Y); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~WindowSnapManagerTests"` +Expected: Compilation failure or FAIL (types not yet declared). + +- [ ] **Step 3: Implement core WindowSnapManager calculations** + +```csharp +// In Services/WindowSnapManager.cs +using System; +using Avalonia; +using Avalonia.Controls; + +namespace LocalLLMServerManager.Services; + +public enum SnapFlank +{ + Left, + Right +} + +public class WindowSnapManager +{ + public static WindowSnapManager Instance { get; } = new(); + + public PixelPoint CalculateSnappedPosition(Window mainWindow, Window companion, SnapFlank flank, double scaling = 1.0) + { + ArgumentNullException.ThrowIfNull(mainWindow); + ArgumentNullException.ThrowIfNull(companion); + + if (scaling <= 0) scaling = 1.0; + + int mainX = mainWindow.Position.X; + int mainY = mainWindow.Position.Y; + int mainWidth = (int)Math.Round((mainWindow.Bounds.Width > 0 ? mainWindow.Bounds.Width : mainWindow.Width) * scaling); + int compWidth = (int)Math.Round((companion.Bounds.Width > 0 ? companion.Bounds.Width : companion.Width) * scaling); + + int targetX = flank switch + { + SnapFlank.Left => mainX - compWidth, + SnapFlank.Right => mainX + mainWidth, + _ => mainX + }; + + return new PixelPoint(targetX, mainY); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~WindowSnapManagerTests"` +Expected: PASS (2 passed). + +- [ ] **Step 5: Commit Task 1** + +```bash +git add Services/WindowSnapManager.cs LocalLLMServerManager.Tests/WindowSnapManagerTests.cs +git commit -m "feat: implement WindowSnapManager flank math and position calculations" +``` + +--- + +### Task 2: Lockstep Synchronization, Detachment & Proximity Snapping + +**Files:** +- Modify: `Services/WindowSnapManager.cs` +- Modify: `LocalLLMServerManager.Tests/WindowSnapManagerTests.cs` + +**Interfaces:** +- Consumes: `Window.PositionChanged`, `Window.SizeChanged`, `Window.PropertyChanged` +- Produces: `RegisterCompanion(Window main, Window comp, SnapFlank flank)`, `Attach(Window comp)`, `Detach(Window comp)`, `ToggleSnap(Window comp)`, `IsSnapped(Window comp)` + +- [ ] **Step 1: Write failing tests for synchronization, detachment, and proximity snap** + +```csharp +// Add to LocalLLMServerManager.Tests/WindowSnapManagerTests.cs +[AvaloniaFact] +public void RegisterCompanion_TracksMainWindowPosition_WhenSnapped() +{ + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: true); + + Assert.True(manager.IsSnapped(companion)); + Assert.Equal(1100, companion.Position.X); + Assert.Equal(100, companion.Position.Y); + + // Move MainWindow + main.Position = new PixelPoint(200, 250); + manager.SynchronizeCompanion(companion); + + Assert.Equal(1200, companion.Position.X); + Assert.Equal(250, companion.Position.Y); +} + +[AvaloniaFact] +public void Detach_AllowsFreeMovement_WithoutFollowingMainWindow() +{ + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: true); + + manager.Detach(companion); + Assert.False(manager.IsSnapped(companion)); + + // Move companion freely + companion.Position = new PixelPoint(50, 50); + + // Move MainWindow - companion should remain at (50, 50) + main.Position = new PixelPoint(400, 400); + manager.SynchronizeCompanion(companion); + + Assert.Equal(50, companion.Position.X); + Assert.Equal(50, companion.Position.Y); +} + +[AvaloniaFact] +public void IsWithinSnapThreshold_DetectsProximityAccurately() +{ + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + // Snapped target X would be 1100. If companion is at 1120 (within 35px), threshold detects true. + companion.Position = new PixelPoint(1120, 110); + + bool inRange = manager.IsWithinSnapThreshold(main, companion, SnapFlank.Right, tolerancePixels: 35, scaling: 1.0); + Assert.True(inRange); + + // If companion is far away at 1300, threshold detects false + companion.Position = new PixelPoint(1300, 100); + bool farAway = manager.IsWithinSnapThreshold(main, companion, SnapFlank.Right, tolerancePixels: 35, scaling: 1.0); + Assert.False(farAway); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~WindowSnapManagerTests"` +Expected: FAIL (methods not yet implemented). + +- [ ] **Step 3: Implement full synchronization logic in WindowSnapManager** + +Expand `Services/WindowSnapManager.cs` to maintain companion registrations: +```csharp +using System; +using System.Collections.Concurrent; +using Avalonia; +using Avalonia.Controls; + +namespace LocalLLMServerManager.Services; + +public class SnappedCompanionState +{ + public Window MainWindow { get; } + public Window Companion { get; } + public SnapFlank Flank { get; set; } + public bool IsSnapped { get; set; } + public double PreferredWidth { get; set; } + + public SnappedCompanionState(Window main, Window comp, SnapFlank flank) + { + MainWindow = main; + Companion = comp; + Flank = flank; + IsSnapped = true; + PreferredWidth = comp.Bounds.Width > 0 ? comp.Bounds.Width : comp.Width; + } +} + +public class WindowSnapManager +{ + public static WindowSnapManager Instance { get; } = new(); + + private readonly ConcurrentDictionary _states = new(); + public const int DefaultSnapThreshold = 35; + + public void RegisterCompanion(Window mainWindow, Window companion, SnapFlank flank, bool autoAttach = true) + { + ArgumentNullException.ThrowIfNull(mainWindow); + ArgumentNullException.ThrowIfNull(companion); + + var state = new SnappedCompanionState(mainWindow, companion, flank) + { + IsSnapped = autoAttach + }; + _states[companion] = state; + + if (autoAttach) + { + Attach(companion); + } + + // Hook window position and state events + mainWindow.PositionChanged += (s, e) => SynchronizeAllForMain(mainWindow); + mainWindow.PropertyChanged += (s, e) => + { + if (e.Property == Visual.BoundsProperty || e.Property == Window.WindowStateProperty) + { + SynchronizeAllForMain(mainWindow); + } + }; + + companion.Closed += (s, e) => _states.TryRemove(companion, out _); + } + + public bool IsSnapped(Window companion) => + _states.TryGetValue(companion, out var state) && state.IsSnapped; + + public void Attach(Window companion) + { + if (_states.TryGetValue(companion, out var state)) + { + state.IsSnapped = true; + SynchronizeCompanion(companion); + } + } + + public void Detach(Window companion) + { + if (_states.TryGetValue(companion, out var state)) + { + state.IsSnapped = false; + } + } + + public void ToggleSnap(Window companion) + { + if (IsSnapped(companion)) + { + Detach(companion); + } + else + { + Attach(companion); + } + } + + public void SynchronizeCompanion(Window companion) + { + if (!_states.TryGetValue(companion, out var state) || !state.IsSnapped) + return; + + var main = state.MainWindow; + if (main.WindowState == WindowState.Minimized) + { + if (companion.WindowState != WindowState.Minimized) + companion.WindowState = WindowState.Minimized; + return; + } + else if (companion.WindowState == WindowState.Minimized) + { + companion.WindowState = WindowState.Normal; + } + + // Align height + double targetHeight = main.Bounds.Height > 0 ? main.Bounds.Height : main.Height; + if (targetHeight >= 450) + { + companion.Height = targetHeight; + } + + double scaling = main.RenderScaling > 0 ? main.RenderScaling : 1.0; + var targetPos = CalculateSnappedPosition(main, companion, state.Flank, scaling); + companion.Position = targetPos; + } + + public void SynchronizeAllForMain(Window mainWindow) + { + foreach (var kvp in _states) + { + if (kvp.Value.MainWindow == mainWindow && kvp.Value.IsSnapped) + { + SynchronizeCompanion(kvp.Key); + } + } + } + + public bool IsWithinSnapThreshold(Window mainWindow, Window companion, SnapFlank flank, int tolerancePixels = DefaultSnapThreshold, double scaling = 1.0) + { + var target = CalculateSnappedPosition(mainWindow, companion, flank, scaling); + int dx = Math.Abs(companion.Position.X - target.X); + int dy = Math.Abs(companion.Position.Y - target.Y); + return dx <= tolerancePixels && dy <= tolerancePixels * 2; + } + + public PixelPoint CalculateSnappedPosition(Window mainWindow, Window companion, SnapFlank flank, double scaling = 1.0) + { + if (scaling <= 0) scaling = 1.0; + + int mainX = mainWindow.Position.X; + int mainY = mainWindow.Position.Y; + int mainWidth = (int)Math.Round((mainWindow.Bounds.Width > 0 ? mainWindow.Bounds.Width : mainWindow.Width) * scaling); + int compWidth = (int)Math.Round((companion.Bounds.Width > 0 ? companion.Bounds.Width : companion.Width) * scaling); + + int targetX = flank switch + { + SnapFlank.Left => mainX - compWidth, + SnapFlank.Right => mainX + mainWidth, + _ => mainX + }; + + return new PixelPoint(targetX, mainY); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~WindowSnapManagerTests"` +Expected: PASS (all tests pass). + +- [ ] **Step 5: Commit Task 2** + +```bash +git add Services/WindowSnapManager.cs LocalLLMServerManager.Tests/WindowSnapManagerTests.cs +git commit -m "feat: implement WindowSnapManager lockstep sync, detach and proximity snap" +``` + +--- + +### Task 3: Title Bar Magnet Button UI & Theme Styles + +**Files:** +- Modify: `LocalLLMServerManager.Shared/Styles/MatteTheme.axaml` +- Modify: `Views/AiAssistWindow.axaml` and `Views/AiAssistWindow.axaml.cs` +- Modify: `Views/DocumentationWindow.axaml` and `Views/DocumentationWindow.axaml.cs` +- Test: `LocalLLMServerManager.Tests/WindowSnapManagerTests.cs` + +**Interfaces:** +- Consumes: `WindowSnapManager.Instance.ToggleSnap()`, `WindowSnapManager.Instance.IsSnapped()` +- Produces: Magnet button `x:Name="SnapToggleButton"` in both window headers, `UpdateSnapButtonVisuals()` + +- [ ] **Step 1: Add Magnet Button styling in MatteTheme.axaml** + +Add `.magnet-snap-btn` style: +```xml + + + + +``` + +- [ ] **Step 2: Add SnapToggleButton in AiAssistWindow.axaml & code-behind** + +In `Views/AiAssistWindow.axaml`: +```xml + + +``` + +In `Views/AiAssistWindow.axaml.cs`: +Add `OnSnapToggleClicked`, wire `WindowSnapManager.Instance.ToggleSnap(this)`, and update button text & styles. On pointer move/drag, check if moved beyond threshold when snapped to automatically flip to detached. + +- [ ] **Step 3: Add SnapToggleButton in DocumentationWindow.axaml & code-behind** + +In `Views/DocumentationWindow.axaml`: +Add identical `SnapToggleButton` beside `PinButton`. + +In `Views/DocumentationWindow.axaml.cs`: +Add `OnSnapToggleClicked`, wire `WindowSnapManager.Instance.ToggleSnap(this)`, and update button text & styles. + +- [ ] **Step 4: Run headless tests and build to verify UI compiles cleanly** + +Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj` +Expected: PASS. + +- [ ] **Step 5: Commit Task 3** + +```bash +git add LocalLLMServerManager.Shared/Styles/MatteTheme.axaml Views/AiAssistWindow.* Views/DocumentationWindow.* +git commit -m "feat: add magnetic snap button and styling to companion window titlebars" +``` + +--- + +### Task 4: Integrate WindowSnapManager with MainWindow Lifecycle + +**Files:** +- Modify: `Views/MainWindow.axaml.cs` +- Test: `LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs` + +**Interfaces:** +- Consumes: `WindowSnapManager.Instance.RegisterCompanion()` +- Produces: Automatic Left flank snapping for `DocumentationWindow` and Right flank snapping for `AiAssistWindow` upon open. + +- [ ] **Step 1: Write headless test asserting companion windows snap upon opening** + +In `LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs`: +```csharp +[AvaloniaFact] +public void MainWindow_OpeningCompanionWindows_RegistersWithWindowSnapManager() +{ + var mainWindow = new MainWindow(); + mainWindow.Position = new PixelPoint(500, 200); + mainWindow.Show(); + + var vm = (MainViewModel)mainWindow.DataContext!; + + // Open Docs + vm.Documentation.PopOutNativeWindow(); + // Open AI Assist + vm.Assistant.RequestPopOut(); + + // Verify TabControl remained on its current tab (SelectedTabIndex == 0) + Assert.Equal(0, vm.SelectedTabIndex); + + mainWindow.Close(); +} +``` + +- [ ] **Step 2: Connect WindowSnapManager in Views/MainWindow.axaml.cs** + +In `Views/MainWindow.axaml.cs`: +```csharp +mainVm.Documentation.OnPopOutNativeWindowRequested = () => +{ + if (_docWindow == null || !_docWindow.IsVisible) + { + _docWindow = new DocumentationWindow(mainVm.Documentation); + _docWindow.Closed += (s, e) => _docWindow = null; + _docWindow.Show(); + WindowSnapManager.Instance.RegisterCompanion(this, _docWindow, SnapFlank.Left, autoAttach: true); + } + else + { + WindowSnapManager.Instance.Attach(_docWindow); + _docWindow.Activate(); + } +}; + +mainVm.Assistant.OnPopOutNativeWindowRequested = () => +{ + if (_aiAssistWindow == null || !_aiAssistWindow.IsVisible) + { + _aiAssistWindow = new AiAssistWindow(mainVm.Assistant); + _aiAssistWindow.Closed += (s, e) => _aiAssistWindow = null; + _aiAssistWindow.Show(); + WindowSnapManager.Instance.RegisterCompanion(this, _aiAssistWindow, SnapFlank.Right, autoAttach: true); + } + else + { + WindowSnapManager.Instance.Attach(_aiAssistWindow); + _aiAssistWindow.Activate(); + } +}; +``` + +- [ ] **Step 3: Run test to verify it passes** + +Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~MainWindow_OpeningCompanionWindows"` +Expected: PASS. + +- [ ] **Step 4: Commit Task 4** + +```bash +git add Views/MainWindow.axaml.cs LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs +git commit -m "feat: wire companion windows to WindowSnapManager in MainWindow" +``` + +--- + +### Task 5: Full Regression Testing, Linters & Live Desktop Verification + +**Files:** +- All touched files + +- [ ] **Step 1: Run complete dotnet test suite** + +Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj` +Expected: 100% PASS across all tests (0 failures). + +- [ ] **Step 2: Run linters and typecheckers** + +Run: `npm run lint` and `npx tsc --noEmit` +Expected: 0 errors. + +- [ ] **Step 3: Rebuild and launch desktop app to visually verify magnetic snapping** + +Run: Rebuild `LocalLLMServerManager.csproj -c Debug`, launch app on desktop, click `Documentation` and `AI Assist` buttons, verify dual flanks snap flush and follow `MainWindow` when dragged. + +- [ ] **Step 4: Commit final changes** + +```bash +git add -A +git commit -m "chore: complete magnetic snapped companion windows implementation" +``` From 14541f284c4f759876d003978f63a4f3a3bacf82 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Sat, 19 Sep 2026 20:43:46 -0500 Subject: [PATCH 23/28] feat: implement WindowSnapManager flank math and position calculations --- .../WindowSnapManagerTests.cs | 45 +++++++++++++++++++ Services/WindowSnapManager.cs | 38 ++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 LocalLLMServerManager.Tests/WindowSnapManagerTests.cs create mode 100644 Services/WindowSnapManager.cs diff --git a/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs b/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs new file mode 100644 index 0000000..3df8b0d --- /dev/null +++ b/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs @@ -0,0 +1,45 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using LocalLLMServerManager.Services; +using Xunit; + +namespace LocalLLMServerManager.Tests; + +public class WindowSnapManagerTests +{ + [AvaloniaFact] + public void CalculateSnappedPosition_RightFlank_PositionsFlushAgainstRightEdge() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(200, 150); + + var companion = new Window { Width = 400, Height = 700 }; + companion.Position = new PixelPoint(0, 0); + + var targetPos = manager.CalculateSnappedPosition(main, companion, SnapFlank.Right, scaling: 1.0); + + // Target X should be main.Position.X (200) + main.Width (1000) = 1200 + Assert.Equal(1200, targetPos.X); + Assert.Equal(150, targetPos.Y); + } + + [AvaloniaFact] + public void CalculateSnappedPosition_LeftFlank_PositionsFlushAgainstLeftEdge() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(600, 150); + + var companion = new Window { Width = 450, Height = 700 }; + companion.Position = new PixelPoint(0, 0); + + var targetPos = manager.CalculateSnappedPosition(main, companion, SnapFlank.Left, scaling: 1.0); + + // Target X should be main.Position.X (600) - companion.Width (450) = 150 + Assert.Equal(150, targetPos.X); + Assert.Equal(150, targetPos.Y); + } +} diff --git a/Services/WindowSnapManager.cs b/Services/WindowSnapManager.cs new file mode 100644 index 0000000..2b59744 --- /dev/null +++ b/Services/WindowSnapManager.cs @@ -0,0 +1,38 @@ +using System; +using Avalonia; +using Avalonia.Controls; + +namespace LocalLLMServerManager.Services; + +public enum SnapFlank +{ + Left, + Right +} + +public class WindowSnapManager +{ + public static WindowSnapManager Instance { get; } = new(); + + public PixelPoint CalculateSnappedPosition(Window mainWindow, Window companion, SnapFlank flank, double scaling = 1.0) + { + ArgumentNullException.ThrowIfNull(mainWindow); + ArgumentNullException.ThrowIfNull(companion); + + if (scaling <= 0) scaling = 1.0; + + int mainX = mainWindow.Position.X; + int mainY = mainWindow.Position.Y; + int mainWidth = (int)Math.Round((mainWindow.Bounds.Width > 0 ? mainWindow.Bounds.Width : mainWindow.Width) * scaling); + int compWidth = (int)Math.Round((companion.Bounds.Width > 0 ? companion.Bounds.Width : companion.Width) * scaling); + + int targetX = flank switch + { + SnapFlank.Left => mainX - compWidth, + SnapFlank.Right => mainX + mainWidth, + _ => mainX + }; + + return new PixelPoint(targetX, mainY); + } +} From 0079c3cbf550b0460306d5760653d59eb3d60de2 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Sat, 19 Sep 2026 20:48:19 -0500 Subject: [PATCH 24/28] feat: implement WindowSnapManager lockstep sync, detach and proximity snap --- .../WindowSnapManagerTests.cs | 82 ++++++++++ Services/WindowSnapManager.cs | 142 +++++++++++++++++- 2 files changed, 222 insertions(+), 2 deletions(-) diff --git a/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs b/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs index 3df8b0d..e6f6fc4 100644 --- a/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs +++ b/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs @@ -42,4 +42,86 @@ public void CalculateSnappedPosition_LeftFlank_PositionsFlushAgainstLeftEdge() Assert.Equal(150, targetPos.X); Assert.Equal(150, targetPos.Y); } + + [AvaloniaFact] + public void RegisterCompanion_TracksMainWindowPosition_WhenSnapped() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: true); + + Assert.True(manager.IsSnapped(companion)); + Assert.Equal(1100, companion.Position.X); + Assert.Equal(100, companion.Position.Y); + + // Move MainWindow + main.Position = new PixelPoint(200, 250); + manager.SynchronizeCompanion(companion); + + Assert.Equal(1200, companion.Position.X); + Assert.Equal(250, companion.Position.Y); + } + + [AvaloniaFact] + public void Detach_AllowsFreeMovement_WithoutFollowingMainWindow() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: true); + + manager.Detach(companion); + Assert.False(manager.IsSnapped(companion)); + + // Move companion freely + companion.Position = new PixelPoint(50, 50); + + // Move MainWindow - companion should remain at (50, 50) + main.Position = new PixelPoint(400, 400); + manager.SynchronizeCompanion(companion); + + Assert.Equal(50, companion.Position.X); + Assert.Equal(50, companion.Position.Y); + } + + [AvaloniaFact] + public void IsWithinSnapThreshold_DetectsProximityAccurately() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + // Snapped target X would be 1100. If companion is at 1120 (within 35px), threshold detects true. + companion.Position = new PixelPoint(1120, 110); + + bool inRange = manager.IsWithinSnapThreshold(main, companion, SnapFlank.Right, tolerancePixels: 35, scaling: 1.0); + Assert.True(inRange); + + // If companion is far away at 1300, threshold detects false + companion.Position = new PixelPoint(1300, 100); + bool farAway = manager.IsWithinSnapThreshold(main, companion, SnapFlank.Right, tolerancePixels: 35, scaling: 1.0); + Assert.False(farAway); + } + + [AvaloniaFact] + public void CalculateSnappedPosition_WithFractionalScaling_CalculatesCorrectly() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + var target = manager.CalculateSnappedPosition(main, companion, SnapFlank.Right, scaling: 1.25); + + // 1000 * 1.25 = 1250, target X = 100 + 1250 = 1350 + Assert.Equal(1350, target.X); + Assert.Equal(100, target.Y); + } } + diff --git a/Services/WindowSnapManager.cs b/Services/WindowSnapManager.cs index 2b59744..6d88fc8 100644 --- a/Services/WindowSnapManager.cs +++ b/Services/WindowSnapManager.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using Avalonia; using Avalonia.Controls; @@ -10,10 +11,141 @@ public enum SnapFlank Right } +public class SnappedCompanionState +{ + public Window MainWindow { get; } + public Window Companion { get; } + public SnapFlank Flank { get; set; } + public bool IsSnapped { get; set; } + public double PreferredWidth { get; set; } + + public SnappedCompanionState(Window main, Window comp, SnapFlank flank) + { + MainWindow = main; + Companion = comp; + Flank = flank; + IsSnapped = true; + double w = comp.Bounds.Width > 0 ? comp.Bounds.Width : comp.Width; + PreferredWidth = double.IsFinite(w) && w > 0 ? w : 440; + } +} + public class WindowSnapManager { public static WindowSnapManager Instance { get; } = new(); + private readonly ConcurrentDictionary _states = new(); + public const int DefaultSnapThreshold = 35; + + public void RegisterCompanion(Window mainWindow, Window companion, SnapFlank flank, bool autoAttach = true) + { + ArgumentNullException.ThrowIfNull(mainWindow); + ArgumentNullException.ThrowIfNull(companion); + + var state = new SnappedCompanionState(mainWindow, companion, flank) + { + IsSnapped = autoAttach + }; + _states[companion] = state; + + if (autoAttach) + { + Attach(companion); + } + + // Hook window position and state events + mainWindow.PositionChanged += (s, e) => SynchronizeAllForMain(mainWindow); + mainWindow.PropertyChanged += (s, e) => + { + if (e.Property == Visual.BoundsProperty || e.Property == Window.WindowStateProperty) + { + SynchronizeAllForMain(mainWindow); + } + }; + + companion.Closed += (s, e) => _states.TryRemove(companion, out _); + } + + public bool IsSnapped(Window companion) => + _states.TryGetValue(companion, out var state) && state.IsSnapped; + + public void Attach(Window companion) + { + if (_states.TryGetValue(companion, out var state)) + { + state.IsSnapped = true; + SynchronizeCompanion(companion); + } + } + + public void Detach(Window companion) + { + if (_states.TryGetValue(companion, out var state)) + { + state.IsSnapped = false; + } + } + + public void ToggleSnap(Window companion) + { + if (IsSnapped(companion)) + { + Detach(companion); + } + else + { + Attach(companion); + } + } + + public void SynchronizeCompanion(Window companion) + { + if (!_states.TryGetValue(companion, out var state) || !state.IsSnapped) + return; + + var main = state.MainWindow; + if (main.WindowState == WindowState.Minimized) + { + if (companion.WindowState != WindowState.Minimized) + companion.WindowState = WindowState.Minimized; + return; + } + else if (companion.WindowState == WindowState.Minimized) + { + companion.WindowState = WindowState.Normal; + } + + // Align height + double targetHeight = main.Bounds.Height > 0 ? main.Bounds.Height : main.Height; + if (double.IsFinite(targetHeight) && targetHeight >= 450) + { + companion.Height = targetHeight; + } + + double scaling = main.RenderScaling > 0 ? main.RenderScaling : 1.0; + var targetPos = CalculateSnappedPosition(main, companion, state.Flank, scaling); + companion.Position = targetPos; + } + + public void SynchronizeAllForMain(Window mainWindow) + { + foreach (var kvp in _states) + { + if (kvp.Value.MainWindow == mainWindow && kvp.Value.IsSnapped) + { + SynchronizeCompanion(kvp.Key); + } + } + } + + public bool IsWithinSnapThreshold(Window mainWindow, Window companion, SnapFlank flank, int tolerancePixels = DefaultSnapThreshold, double scaling = 1.0) + { + var target = CalculateSnappedPosition(mainWindow, companion, flank, scaling); + int dx = Math.Abs(companion.Position.X - target.X); + int dy = Math.Abs(companion.Position.Y - target.Y); + return dx <= tolerancePixels && dy <= tolerancePixels * 2; + } + public PixelPoint CalculateSnappedPosition(Window mainWindow, Window companion, SnapFlank flank, double scaling = 1.0) { ArgumentNullException.ThrowIfNull(mainWindow); @@ -23,8 +155,14 @@ public PixelPoint CalculateSnappedPosition(Window mainWindow, Window companion, int mainX = mainWindow.Position.X; int mainY = mainWindow.Position.Y; - int mainWidth = (int)Math.Round((mainWindow.Bounds.Width > 0 ? mainWindow.Bounds.Width : mainWindow.Width) * scaling); - int compWidth = (int)Math.Round((companion.Bounds.Width > 0 ? companion.Bounds.Width : companion.Width) * scaling); + + double rawMainW = mainWindow.Bounds.Width > 0 ? mainWindow.Bounds.Width : mainWindow.Width; + double rawCompW = companion.Bounds.Width > 0 ? companion.Bounds.Width : companion.Width; + if (!double.IsFinite(rawMainW) || rawMainW <= 0) rawMainW = 1024; + if (!double.IsFinite(rawCompW) || rawCompW <= 0) rawCompW = 440; + + int mainWidth = (int)Math.Round(rawMainW * scaling); + int compWidth = (int)Math.Round(rawCompW * scaling); int targetX = flank switch { From a9504479fe5e98957624b87b97a672f6507462b7 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Sat, 19 Sep 2026 20:50:19 -0500 Subject: [PATCH 25/28] fix(snap-manager): deduplicate window event hooks and harden argument validation --- .../WindowSnapManagerTests.cs | 53 +++++++++++++++++++ Services/WindowSnapManager.cs | 44 ++++++++++----- 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs b/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs index e6f6fc4..46b5c46 100644 --- a/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs +++ b/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs @@ -123,5 +123,58 @@ public void CalculateSnappedPosition_WithFractionalScaling_CalculatesCorrectly() Assert.Equal(1350, target.X); Assert.Equal(100, target.Y); } + + [AvaloniaFact] + public void SynchronizeCompanion_ClampsHeightToMinimum450() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 300 }; + var companion = new Window { Width = 400, Height = 300 }; + + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: true); + manager.SynchronizeCompanion(companion); + + Assert.Equal(450, companion.Height); + } + + [AvaloniaFact] + public void ArgumentValidation_ThrowsArgumentNullException_WhenNullPassed() + { + var manager = new WindowSnapManager(); + var window = new Window(); + + Assert.Throws(() => manager.RegisterCompanion(null!, window, SnapFlank.Right)); + Assert.Throws(() => manager.RegisterCompanion(window, null!, SnapFlank.Right)); + Assert.Throws(() => manager.IsSnapped(null!)); + Assert.Throws(() => manager.Attach(null!)); + Assert.Throws(() => manager.Detach(null!)); + Assert.Throws(() => manager.ToggleSnap(null!)); + Assert.Throws(() => manager.SynchronizeCompanion(null!)); + Assert.Throws(() => manager.SynchronizeAllForMain(null!)); + Assert.Throws(() => manager.IsWithinSnapThreshold(null!, window, SnapFlank.Right)); + Assert.Throws(() => manager.IsWithinSnapThreshold(window, null!, SnapFlank.Right)); + } + + [AvaloniaFact] + public void RegisterCompanion_MultipleCompanions_SharesMainWindowHookSafely() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var comp1 = new Window { Width = 300, Height = 700 }; + var comp2 = new Window { Width = 400, Height = 700 }; + + manager.RegisterCompanion(main, comp1, SnapFlank.Left, autoAttach: true); + manager.RegisterCompanion(main, comp2, SnapFlank.Right, autoAttach: true); + + // Move MainWindow and trigger sync for all + main.Position = new PixelPoint(200, 200); + manager.SynchronizeAllForMain(main); + + Assert.Equal(200 - 300, comp1.Position.X); + Assert.Equal(200 + 1000, comp2.Position.X); + } } + diff --git a/Services/WindowSnapManager.cs b/Services/WindowSnapManager.cs index 6d88fc8..0e0cb57 100644 --- a/Services/WindowSnapManager.cs +++ b/Services/WindowSnapManager.cs @@ -21,6 +21,9 @@ public class SnappedCompanionState public SnappedCompanionState(Window main, Window comp, SnapFlank flank) { + ArgumentNullException.ThrowIfNull(main); + ArgumentNullException.ThrowIfNull(comp); + MainWindow = main; Companion = comp; Flank = flank; @@ -35,6 +38,7 @@ public class WindowSnapManager public static WindowSnapManager Instance { get; } = new(); private readonly ConcurrentDictionary _states = new(); + private readonly ConcurrentDictionary _hookedMainWindows = new(); public const int DefaultSnapThreshold = 35; public void RegisterCompanion(Window mainWindow, Window companion, SnapFlank flank, bool autoAttach = true) @@ -53,24 +57,32 @@ public void RegisterCompanion(Window mainWindow, Window companion, SnapFlank fla Attach(companion); } - // Hook window position and state events - mainWindow.PositionChanged += (s, e) => SynchronizeAllForMain(mainWindow); - mainWindow.PropertyChanged += (s, e) => + // Deduplicate event subscriptions on MainWindow + if (_hookedMainWindows.TryAdd(mainWindow, 0)) { - if (e.Property == Visual.BoundsProperty || e.Property == Window.WindowStateProperty) + mainWindow.PositionChanged += (s, e) => SynchronizeAllForMain(mainWindow); + mainWindow.PropertyChanged += (s, e) => { - SynchronizeAllForMain(mainWindow); - } - }; + if (e.Property == Visual.BoundsProperty || e.Property == Window.WindowStateProperty) + { + SynchronizeAllForMain(mainWindow); + } + }; + mainWindow.Closed += (s, e) => _hookedMainWindows.TryRemove(mainWindow, out _); + } companion.Closed += (s, e) => _states.TryRemove(companion, out _); } - public bool IsSnapped(Window companion) => - _states.TryGetValue(companion, out var state) && state.IsSnapped; + public bool IsSnapped(Window companion) + { + ArgumentNullException.ThrowIfNull(companion); + return _states.TryGetValue(companion, out var state) && state.IsSnapped; + } public void Attach(Window companion) { + ArgumentNullException.ThrowIfNull(companion); if (_states.TryGetValue(companion, out var state)) { state.IsSnapped = true; @@ -80,6 +92,7 @@ public void Attach(Window companion) public void Detach(Window companion) { + ArgumentNullException.ThrowIfNull(companion); if (_states.TryGetValue(companion, out var state)) { state.IsSnapped = false; @@ -88,6 +101,7 @@ public void Detach(Window companion) public void ToggleSnap(Window companion) { + ArgumentNullException.ThrowIfNull(companion); if (IsSnapped(companion)) { Detach(companion); @@ -100,6 +114,7 @@ public void ToggleSnap(Window companion) public void SynchronizeCompanion(Window companion) { + ArgumentNullException.ThrowIfNull(companion); if (!_states.TryGetValue(companion, out var state) || !state.IsSnapped) return; @@ -115,11 +130,11 @@ public void SynchronizeCompanion(Window companion) companion.WindowState = WindowState.Normal; } - // Align height + // Align height (clamp minimum 450) double targetHeight = main.Bounds.Height > 0 ? main.Bounds.Height : main.Height; - if (double.IsFinite(targetHeight) && targetHeight >= 450) + if (double.IsFinite(targetHeight)) { - companion.Height = targetHeight; + companion.Height = Math.Max(450, targetHeight); } double scaling = main.RenderScaling > 0 ? main.RenderScaling : 1.0; @@ -129,6 +144,8 @@ public void SynchronizeCompanion(Window companion) public void SynchronizeAllForMain(Window mainWindow) { + ArgumentNullException.ThrowIfNull(mainWindow); + foreach (var kvp in _states) { if (kvp.Value.MainWindow == mainWindow && kvp.Value.IsSnapped) @@ -140,6 +157,9 @@ public void SynchronizeAllForMain(Window mainWindow) public bool IsWithinSnapThreshold(Window mainWindow, Window companion, SnapFlank flank, int tolerancePixels = DefaultSnapThreshold, double scaling = 1.0) { + ArgumentNullException.ThrowIfNull(mainWindow); + ArgumentNullException.ThrowIfNull(companion); + var target = CalculateSnappedPosition(mainWindow, companion, flank, scaling); int dx = Math.Abs(companion.Position.X - target.X); int dy = Math.Abs(companion.Position.Y - target.Y); From ac3ad5a18349031c59cbd39058911eb41fa9f5d2 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Sat, 19 Sep 2026 20:58:09 -0500 Subject: [PATCH 26/28] feat: add magnetic snap button and styling to companion window titlebars --- .../Styles/MatteTheme.axaml | 61 +++++- .../WindowSnapManagerTests.cs | 206 ++++++++++++++++++ Services/WindowSnapManager.cs | 30 +++ Views/AiAssistWindow.axaml | 8 +- Views/AiAssistWindow.axaml.cs | 49 +++++ Views/DocumentationWindow.axaml | 9 +- Views/DocumentationWindow.axaml.cs | 50 +++++ 7 files changed, 407 insertions(+), 6 deletions(-) diff --git a/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml b/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml index d79eff7..5b5c985 100644 --- a/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml +++ b/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml @@ -5,6 +5,8 @@ + + @@ -115,8 +117,11 @@ Margin="0,0,0,16" Padding="4" HorizontalAlignment="Left"> - + + + + + + + + + + + + + + + + + diff --git a/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs b/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs index 46b5c46..847608e 100644 --- a/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs +++ b/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs @@ -149,12 +149,218 @@ public void ArgumentValidation_ThrowsArgumentNullException_WhenNullPassed() Assert.Throws(() => manager.Attach(null!)); Assert.Throws(() => manager.Detach(null!)); Assert.Throws(() => manager.ToggleSnap(null!)); + Assert.Throws(() => manager.CheckDragDetachment(null!)); + Assert.Throws(() => manager.CheckProximitySnap(null!)); Assert.Throws(() => manager.SynchronizeCompanion(null!)); Assert.Throws(() => manager.SynchronizeAllForMain(null!)); Assert.Throws(() => manager.IsWithinSnapThreshold(null!, window, SnapFlank.Right)); Assert.Throws(() => manager.IsWithinSnapThreshold(window, null!, SnapFlank.Right)); } + [AvaloniaFact] + public void CheckDragDetachment_WhenSnappedAndWithinThreshold_ReturnsFalse() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: true); + + Assert.True(manager.IsSnapped(companion)); + bool detached = manager.CheckDragDetachment(companion); + + Assert.False(detached); + Assert.True(manager.IsSnapped(companion)); + } + + [AvaloniaFact] + public void CheckDragDetachment_WhenSnappedAndDraggedBeyondThreshold_DetachesAndReturnsTrue() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: true); + + // Move companion 100px away from target (1100, 100) -> (1200, 100) + companion.Position = new PixelPoint(1200, 100); + + bool detached = manager.CheckDragDetachment(companion); + + Assert.True(detached); + Assert.False(manager.IsSnapped(companion)); + + // Subsequent check returns false because already detached + Assert.False(manager.CheckDragDetachment(companion)); + } + + [AvaloniaFact] + public void CheckProximitySnap_WhenDetachedAndMovedWithinThreshold_SnapsAndReturnsTrue() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: false); + Assert.False(manager.IsSnapped(companion)); + + // Place companion close to snapped position (1100, 100) -> (1110, 105) + companion.Position = new PixelPoint(1110, 105); + + bool snapped = manager.CheckProximitySnap(companion); + + Assert.True(snapped); + Assert.True(manager.IsSnapped(companion)); + Assert.Equal(1100, companion.Position.X); + Assert.Equal(100, companion.Position.Y); + } + + [AvaloniaFact] + public void CheckProximitySnap_WhenDetachedAndFarAway_ReturnsFalseAndRemainsDetached() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + + var companion = new Window { Width = 400, Height = 700 }; + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: false); + + companion.Position = new PixelPoint(500, 500); + + bool snapped = manager.CheckProximitySnap(companion); + + Assert.False(snapped); + Assert.False(manager.IsSnapped(companion)); + } + + [AvaloniaFact] + public void CheckDragDetachment_UnregisteredWindow_ReturnsFalse() + { + var manager = new WindowSnapManager(); + var unregistered = new Window(); + + Assert.False(manager.CheckDragDetachment(unregistered)); + Assert.False(manager.CheckProximitySnap(unregistered)); + } + + [AvaloniaFact] + public void AiAssistWindow_UpdateSnapVisuals_TogglesButtonClassesAndText() + { + var win = new LocalLLMServerManager.Views.AiAssistWindow(); + var btn = win.FindControl diff --git a/Views/DocumentationWindow.axaml.cs b/Views/DocumentationWindow.axaml.cs index a23d5c5..9341132 100644 --- a/Views/DocumentationWindow.axaml.cs +++ b/Views/DocumentationWindow.axaml.cs @@ -1,8 +1,10 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using Avalonia.Media; +using LocalLLMServerManager.Services; using LocalLLMServerManager.Shared.ViewModels; namespace LocalLLMServerManager.Views; @@ -12,6 +14,8 @@ public partial class DocumentationWindow : Window public DocumentationWindow() { InitializeComponent(); + PositionChanged += OnWindowPositionChanged; + Opened += (s, e) => UpdateSnapVisuals(); } public DocumentationWindow(DocumentationViewModel viewModel) : this() @@ -24,6 +28,52 @@ private void InitializeComponent() AvaloniaXamlLoader.Load(this); } + private void OnWindowPositionChanged(object? sender, PixelPointEventArgs e) + { + if (WindowSnapManager.Instance.IsSnapped(this)) + { + if (WindowSnapManager.Instance.CheckDragDetachment(this)) + { + UpdateSnapVisuals(); + } + } + else + { + if (WindowSnapManager.Instance.CheckProximitySnap(this)) + { + UpdateSnapVisuals(); + } + } + } + + private void OnSnapToggleClicked(object? sender, RoutedEventArgs e) + { + WindowSnapManager.Instance.ToggleSnap(this); + UpdateSnapVisuals(); + } + + public void UpdateSnapVisuals() + { + bool isSnapped = WindowSnapManager.Instance.IsSnapped(this); + var snapText = this.FindControl("SnapButtonText"); + var snapBtn = this.FindControl + + + + @@ -60,32 +86,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/Services/WindowSnapManager.cs b/Services/WindowSnapManager.cs index 97ba633..29be64a 100644 --- a/Services/WindowSnapManager.cs +++ b/Services/WindowSnapManager.cs @@ -60,15 +60,29 @@ public void RegisterCompanion(Window mainWindow, Window companion, SnapFlank fla // Deduplicate event subscriptions on MainWindow if (_hookedMainWindows.TryAdd(mainWindow, 0)) { - mainWindow.PositionChanged += (s, e) => SynchronizeAllForMain(mainWindow); - mainWindow.PropertyChanged += (s, e) => + EventHandler? onPosChanged = null; + EventHandler? onPropChanged = null; + EventHandler? onClosed = null; + + onPosChanged = (s, e) => SynchronizeAllForMain(mainWindow); + onPropChanged = (s, e) => { if (e.Property == Visual.BoundsProperty || e.Property == Window.WindowStateProperty) { SynchronizeAllForMain(mainWindow); } }; - mainWindow.Closed += (s, e) => _hookedMainWindows.TryRemove(mainWindow, out _); + onClosed = (s, e) => + { + mainWindow.PositionChanged -= onPosChanged; + mainWindow.PropertyChanged -= onPropChanged; + mainWindow.Closed -= onClosed; + _hookedMainWindows.TryRemove(mainWindow, out _); + }; + + mainWindow.PositionChanged += onPosChanged; + mainWindow.PropertyChanged += onPropChanged; + mainWindow.Closed += onClosed; } companion.Closed += (s, e) => _states.TryRemove(companion, out _);