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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/SETTINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ Turn learning off to keep verdicts and notes for review only.

**Likely cause** is a heuristic from trigger kind and changed file paths (e.g. Cursor/agent tooling folders vs source edits). **Your note** is free text — use it to record what you were doing (e.g. “Cursor ask mode chat”) when marking unexpected rebuilds. Status panel **AI working?** appears in the **header** during a rebuild countdown (extends the wait) or while a build is running (marks that trigger **Unexpected**). The countdown auto-extends when Cursor/agent tooling is active (`useAgentTranscriptActivity`) and resets when meaningful source files (e.g. `.cs`) are saved.

Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layout.json` (Settings, build log, diagnostics — including trigger grid column widths — and status panel width — height auto-fits content up to 460 px).
Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layout.json` (Settings, build log, diagnostics — including trigger grid column widths — and status panel width — height auto-fits content up to 460 px). If a saved position is no longer sufficiently visible on any monitor (for example after RDP from a single-monitor client, or after unplugging a display), BuildMonitor clamps the window into the tray/primary work area when it opens and again when Windows fires a display-settings change.

## Watch / file-watcher excludes

Expand Down
99 changes: 99 additions & 0 deletions src/BuildMonitor.Tests/WindowScreenVisibilityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using BuildMonitor.Core.Rules;

namespace BuildMonitor.Tests;

public sealed class WindowScreenVisibilityTests
{
private static WindowScreenVisibility.Rect Primary => new(0, 0, 1920, 1080);
private static WindowScreenVisibility.Rect Secondary => new(1920, 0, 1920, 1080);

[Fact]
public void IsSufficientlyVisible_true_when_center_on_primary()
{
var window = new WindowScreenVisibility.Rect(100, 100, 800, 600);
Assert.True(WindowScreenVisibility.IsSufficientlyVisible(window, [Primary]));
}

[Fact]
public void IsSufficientlyVisible_false_when_fully_on_missing_secondary()
{
var window = new WindowScreenVisibility.Rect(2200, 100, 800, 600);
Assert.False(WindowScreenVisibility.IsSufficientlyVisible(window, [Primary]));
}

[Fact]
public void IsSufficientlyVisible_true_on_secondary_when_present()
{
var window = new WindowScreenVisibility.Rect(2200, 100, 800, 600);
Assert.True(WindowScreenVisibility.IsSufficientlyVisible(window, [Primary, Secondary]));
}

[Fact]
public void IsSufficientlyVisible_false_for_one_pixel_edge_overlap()
{
// Almost entirely off to the right of a single 1920-wide primary; only 1px still intersects.
var window = new WindowScreenVisibility.Rect(1919, 100, 800, 600);
Assert.False(WindowScreenVisibility.IsSufficientlyVisible(window, [Primary]));
}

[Fact]
public void IsSufficientlyVisible_true_when_majority_overlaps_even_if_center_off()
{
// Center sits on the right edge (not inside primary); half the width still overlaps.
var window = new WindowScreenVisibility.Rect(960, 100, 1920, 600);
Assert.Equal(1920, window.CenterX);
Assert.True(WindowScreenVisibility.IsSufficientlyVisible(window, [Primary]));
}

[Fact]
public void ClampToWorkArea_moves_offscreen_window_fully_inside()
{
var window = new WindowScreenVisibility.Rect(2500, 100, 800, 600);
var clamped = WindowScreenVisibility.ClampToWorkArea(window, Primary);
Assert.Equal(1120, clamped.X); // 1920 - 800
Assert.Equal(100, clamped.Y);
Assert.Equal(800, clamped.Width);
Assert.Equal(600, clamped.Height);
Assert.True(WindowScreenVisibility.IsSufficientlyVisible(clamped, [Primary]));
}

[Fact]
public void ClampToWorkArea_shrinks_when_larger_than_work_area()
{
var window = new WindowScreenVisibility.Rect(0, 0, 3000, 2000);
var clamped = WindowScreenVisibility.ClampToWorkArea(window, Primary);
Assert.Equal(0, clamped.X);
Assert.Equal(0, clamped.Y);
Assert.Equal(1920, clamped.Width);
Assert.Equal(1080, clamped.Height);
}

[Fact]
public void EnsureVisible_leaves_on_screen_window_unchanged()
{
var window = new WindowScreenVisibility.Rect(100, 100, 800, 600);
var result = WindowScreenVisibility.EnsureVisible(window, [Primary]);
Assert.Equal(window, result);
}

[Fact]
public void EnsureVisible_uses_preferred_work_area_when_clamping()
{
// Secondary coords with only primary still present (RDP / unplug).
var window = new WindowScreenVisibility.Rect(2500, 100, 400, 300);
var preferred = new WindowScreenVisibility.Rect(100, 100, 800, 600);
var result = WindowScreenVisibility.EnsureVisible(window, [Primary], preferred);
Assert.True(result.X >= preferred.X);
Assert.True(result.Right <= preferred.Right);
Assert.True(result.Y >= preferred.Y);
Assert.True(result.Bottom <= preferred.Bottom);
}

[Fact]
public void ResolveTargetWorkArea_picks_nearest_when_no_preferred()
{
var window = new WindowScreenVisibility.Rect(2500, 100, 400, 300);
var target = WindowScreenVisibility.ResolveTargetWorkArea(window, [Primary, Secondary]);
Assert.Equal(Secondary, target);
}
}
142 changes: 142 additions & 0 deletions src/Core/Rules/WindowScreenVisibility.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
namespace BuildMonitor.Core.Rules;

/// <summary>
/// Pure geometry for deciding whether a window is sufficiently on-screen and clamping it into a work area.
/// </summary>
public static class WindowScreenVisibility
{
/// <summary>Minimum fraction of window area that must intersect work areas to count as visible.</summary>
public const double MinVisibleAreaFraction = 0.5;

public readonly record struct Rect(double X, double Y, double Width, double Height)
{
public double Right => X + Width;
public double Bottom => Y + Height;
public double CenterX => X + (Width / 2.0);
public double CenterY => Y + (Height / 2.0);
public double Area => Math.Max(0, Width) * Math.Max(0, Height);
}

/// <summary>
/// True when the window center lies in any work area, or at least
/// <see cref="MinVisibleAreaFraction"/> of the window area intersects the union of work areas.
/// </summary>
public static bool IsSufficientlyVisible(Rect window, IReadOnlyList<Rect> workAreas)
{
if (workAreas.Count == 0 || window.Width <= 0 || window.Height <= 0)
{
return false;
}

if (workAreas.Any(area => ContainsPoint(area, window.CenterX, window.CenterY)))
{
return true;
}

var windowArea = window.Area;
if (windowArea <= 0)
{
return false;
}

var visible = 0.0;
foreach (var area in workAreas)
{
visible += IntersectionArea(window, area);
}

return visible / windowArea >= MinVisibleAreaFraction;
}

/// <summary>
/// Places <paramref name="window"/> fully inside <paramref name="workArea"/>, shrinking only when larger than the area.
/// </summary>
public static Rect ClampToWorkArea(Rect window, Rect workArea)
{
if (workArea.Width <= 0 || workArea.Height <= 0)
{
return window;
}

var width = Math.Min(Math.Max(1, window.Width), workArea.Width);
var height = Math.Min(Math.Max(1, window.Height), workArea.Height);
var maxX = workArea.Right - width;
var maxY = workArea.Bottom - height;
var x = double.IsFinite(window.X) ? Math.Clamp(window.X, workArea.X, Math.Max(workArea.X, maxX)) : workArea.X;
var y = double.IsFinite(window.Y) ? Math.Clamp(window.Y, workArea.Y, Math.Max(workArea.Y, maxY)) : workArea.Y;
return new Rect(x, y, width, height);
}

/// <summary>
/// Prefers <paramref name="preferredWorkArea"/> when provided; otherwise the work area whose center is nearest the window center.
/// </summary>
public static Rect ResolveTargetWorkArea(
Rect window,
IReadOnlyList<Rect> workAreas,
Rect? preferredWorkArea = null)
{
if (preferredWorkArea is { Width: > 0, Height: > 0 } preferred)
{
return preferred;
}

if (workAreas.Count == 0)
{
return new Rect(0, 0, 1920, 1080);
}

if (workAreas.Count == 1)
{
return workAreas[0];
}

Rect best = workAreas[0];
var bestDistance = double.PositiveInfinity;
foreach (var area in workAreas)
{
var dx = area.CenterX - window.CenterX;
var dy = area.CenterY - window.CenterY;
var distance = (dx * dx) + (dy * dy);
if (distance < bestDistance)
{
bestDistance = distance;
best = area;
}
}

return best;
}

/// <summary>
/// If the window is not sufficiently visible, clamp it into the resolved target work area; otherwise return unchanged.
/// </summary>
public static Rect EnsureVisible(Rect window, IReadOnlyList<Rect> workAreas, Rect? preferredWorkArea = null)
{
if (IsSufficientlyVisible(window, workAreas))
{
return window;
}

var target = ResolveTargetWorkArea(window, workAreas, preferredWorkArea);
return ClampToWorkArea(window, target);
}

private static bool ContainsPoint(Rect area, double x, double y) =>
x >= area.X && x < area.Right && y >= area.Y && y < area.Bottom;

private static double IntersectionArea(Rect a, Rect b)
{
var left = Math.Max(a.X, b.X);
var top = Math.Max(a.Y, b.Y);
var right = Math.Min(a.Right, b.Right);
var bottom = Math.Min(a.Bottom, b.Bottom);
var width = right - left;
var height = bottom - top;
if (width <= 0 || height <= 0)
{
return 0;
}

return width * height;
}
}
101 changes: 36 additions & 65 deletions src/TrayApp/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public partial class App : System.Windows.Application
private DateTimeOffset trayHoverStatusPanelSuppressedUntil = DateTimeOffset.MinValue;
private readonly Dictionary<string, bool> previousEditGatingActive =
new(StringComparer.OrdinalIgnoreCase);
private WindowDisplayChangeWatcher? displayChangeWatcher;
private readonly BuildLifecycleToastNotifier buildLifecycleToastNotifier = new();
private readonly TrayContextMenuBuilder trayMenuBuilder = new();
private int settingsApplyVersion;
Expand All @@ -73,7 +74,7 @@ protected override async void OnStartup(StartupEventArgs e)
appDataDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"BuildMonitor");
MigrateLegacyAppDataIfNeeded(appDataDirectory);
AppLaunchPolicy.MigrateLegacyAppDataIfNeeded(appDataDirectory);
Directory.CreateDirectory(appDataDirectory);

var settingsPath = Path.Combine(appDataDirectory, "settings.json");
Expand Down Expand Up @@ -125,8 +126,38 @@ protected override async void OnStartup(StartupEventArgs e)
ApplyThemeToUi();
notifyIcon = BuildNotifyIcon();
notifyIcon.Visible = true;
displayChangeWatcher = new WindowDisplayChangeWatcher(
Dispatcher,
() => Volatile.Read(ref exitRequested) != 0,
() =>
{
var windows = new List<Window>(openLogViewers.Count + 2);
windows.AddRange(openLogViewers.Values);
if (diagnosticsWindow is not null)
{
windows.Add(diagnosticsWindow);
}

if (buildMonitorHealthWindow is not null)
{
windows.Add(buildMonitorHealthWindow);
}

return windows;
},
() => hoverPanel?.InvalidateTrayPlacementCache(),
() =>
{
if (hoverPanel is not { IsVisible: true })
{
return;
}

GetTrayPlacementContext(out var trayIconBounds, out var trayWindowHandle);
hoverPanel.FollowTray(trayIconBounds, trayWindowHandle);
});

if (ShouldAutoOpenBuildMonitorHealth())
if (AppLaunchPolicy.ShouldAutoOpenBuildMonitorHealth(currentSettings))
{
await OpenBuildMonitorHealthWhenReadyAsync();
await WaitForUiIdleAsync();
Expand Down Expand Up @@ -170,7 +201,7 @@ private async Task ApplySettingsAndStartAsync()
orchestrator.ApplySettings(currentSettings);
ApplyControlPlaneHost();

if (ShouldAutoStartAnyProjectsOnLaunch())
if (AppLaunchPolicy.ShouldAutoStartAnyProjectsOnLaunch(currentSettings))
{
await orchestrator.StartActiveProjectsAsync(CancellationToken.None).ConfigureAwait(false);
}
Expand Down Expand Up @@ -707,6 +738,8 @@ private void ApplyControlPlaneHost()

protected override void OnExit(ExitEventArgs e)
{
displayChangeWatcher?.Dispose();
displayChangeWatcher = null;
buildIconAnimationTimer?.Stop();
buildIconAnimationTimer = null;
dispatcherHealthProbe?.Dispose();
Expand Down Expand Up @@ -1867,66 +1900,4 @@ private void ApplyTrayIconFrame()
RefreshStatusPanelIfVisible();
}
}

private static void MigrateLegacyAppDataIfNeeded(string newAppDataDirectory)
{
if (Directory.Exists(newAppDataDirectory))
{
return;
}

var legacyDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AzureBuildMonitor");
if (!Directory.Exists(legacyDirectory))
{
return;
}

try
{
Directory.Move(legacyDirectory, newAppDataDirectory);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Legacy app data migration failed: {ex.Message}");
}
}

private static bool ShouldSkipProjectStart()
{
var value = Environment.GetEnvironmentVariable("BUILDMONITOR_SKIP_PROJECT_START");
return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}

private bool ShouldAutoStartAnyProjectsOnLaunch() =>
!ShouldSkipProjectStart()
&& currentSettings.Projects.Any(p => p.IsActiveInSession && p.StartOnLaunch);

private bool ShouldAutoOpenBuildMonitorHealth()
{
var value = Environment.GetEnvironmentVariable("BUILDMONITOR_AUTO_BUILD_MONITOR_HEALTH");
if (string.IsNullOrWhiteSpace(value))
{
value = Environment.GetEnvironmentVariable("BUILDMONITOR_AUTO_THREAD_HEALTH");
}

if (!string.IsNullOrWhiteSpace(value))
{
if (string.Equals(value, "0", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "false", StringComparison.OrdinalIgnoreCase))
{
return false;
}

if (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}

return currentSettings.Monitor.AutoOpenBuildMonitorHealthOnStartup;
}
}
Loading
Loading