diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md
index 9081fd6..f41485a 100644
--- a/docs/SETTINGS.md
+++ b/docs/SETTINGS.md
@@ -46,9 +46,13 @@ File: `%LOCALAPPDATA%/BuildMonitor/settings.json`
## Settings UI tabs
- **Projects** — per-project folder, csproj/sln, launch profile, run/watch options, **start build when app launches**, and **active in session** checkbox (left of each project name). Unchecked projects remain in the list but are not built or run until checked and settings are saved.
-- **Monitor** — concurrency, debounce, **batch watch-mode rebuilds**, health refresh, auto-open log on failure, **auto-open Build Monitor Health on startup**, max log bytes.
+- **Monitor** — concurrency, debounce, **batch watch-mode rebuilds**, health refresh, **auto-open Build Monitor Health on startup**, max log bytes.
- **App** — theme (`System`, `Light`, `Dark`) and startup behavior. **Run when Windows starts** adds/removes an entry under `HKCU\...\Run` named `LocalBuildMonitor`.
+Per project (**Projects** tab → **File watching**):
+
+- **`autoOpenLog`**: `Never` (default), `Errors`, `Warnings`, or `Always` — when to open the log viewer automatically after a build or test. `Warnings` opens on amber health (build succeeded with warnings) as well as failures. `Always` opens after every build or test completes. Replaces the old global `monitor.autoOpenLogOnFailure` flag (schema v11).
+
## Health colors
- **Green (Success)** — build/run healthy; no errors in the active context.
@@ -69,6 +73,9 @@ See [features/health-and-logs.md](features/health-and-logs.md) for how build vs
- **`fileChangeDebounceMode`**: `Manual` (default) or `Auto`. **Auto** learns per project from save burst length (time from first to last file change before rebuild), using p90 × 1.25 smoothed into **1500–12000 ms**. The manual ms value is used until **five** bursts are recorded. Stats persist in `%LOCALAPPDATA%/BuildMonitor/debounce-stats.json`.
- **Agent session coalescing** — after the first file-triggered build in a 90-second window, further saves wait for a full quiet period since the **last** change (not a fixed 3 s post-build cooldown). Debounce increases up to **2×** when multiple file-triggered builds happen in that window. Turn on **Auto** debounce mode for longer agent sessions.
- **`coalesceWatchRebuilds`** (default **true**) — in **Watch** run mode, BuildMonitor watches the project folder, waits for edits to settle, then runs one `dotnet build` and restarts the app. This replaces per-save `dotnet watch` rebuilds during agent sessions. Turn off to use `dotnet watch` hot reload instead (more rebuilds, faster feedback on single-file edits).
+- **`deferStartupBuildUntilQuiet`** (default **true**) — when starting Build Monitor while an agent is still saving, wait for the quiet period before the first `dotnet build`.
+- **`cancelSupersededBuilds`** (default **true**) — cancel in-flight **startup** or **file-change** builds when newer saves arrive; coalesce into one rebuild after edits settle. Manual tray rebuilds are never cancelled.
+- **`useAgentTranscriptActivity`** (default **true**) — treat writes under `agent-transcripts` / `.cursor` as “agent still active” for gating (signal only; does not trigger rebuilds).
Restart the project from the tray after changing this option so the run process switches between `dotnet run` and `dotnet watch`.
@@ -110,13 +117,13 @@ Output uses `--verbosity normal` and a detailed console logger (per-test pass/fa
## Build diagnostics
-Tray → **Build diagnostics…** opens **one tab per project**: a compact **rebuild timing** panel (wait bar, learning progress, save-burst chart) and today's build triggers for that project.
+Tray → **Build diagnostics…** opens **one tab per active project**: a compact **rebuild timing** panel (metric tiles, save-burst and build-duration charts, rebuild countdown) and today's build triggers for that project.
| Column | Meaning |
|--------|---------|
| **Kind** | Session start, file watcher, manual rebuild, hot reload, `dotnet watch`, etc. |
| **Files** | Paths that triggered a debounced file-watcher rebuild (relative to project root) |
-| **Detail** | Extra context (e.g. a `dotnet watch` output line) |
+| **Detail** | Extra context — file-watcher debounce/hold timing, or a `dotnet watch` / hot-reload output line |
| **Verdict** | Mark **Expected** or **Unexpected** to track spurious rebuilds |
Persisted at `%LOCALAPPDATA%/BuildMonitor/diagnostics/build-triggers.jsonl` (**today's entries only**, local calendar day; up to 500 per day). Mark **Unexpected** triggers to spot spurious rebuilds during agent sessions.
@@ -125,12 +132,13 @@ Persisted at `%LOCALAPPDATA%/BuildMonitor/diagnostics/build-triggers.jsonl` (**t
**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.
-Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layout.json` (Settings, build log, diagnostics, and status panel width — height auto-fits content up to 520 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).
## Watch / file-watcher excludes
- **`watchExcludeSegments`** — semicolon-separated folder names ignored by BuildMonitor’s debounced file watcher (`TriggerRebuild` mode). Defaults include `.cursor`, `agent-transcripts`, `logs`, `bin`, `obj`, and similar tooling/output folders.
-- Noisy file types (`.log`, `.dll`, `.pdb`, `.tmp`, etc.) are also ignored so build output and tooling writes are less likely to trigger rebuilds.
+- Noisy file types (`.log`, `.dll`, `.pdb`, `.tmp`, common image formats under `wwwroot`, etc.) are also ignored so build output and static assets are less likely to trigger rebuilds.
+- **`wwwroot/Images`** — image saves are ignored by default (`.png`, `.jpg`, `.gif`, `.webp`, `.svg`, `.ico`, …). **`wwwroot/Files`** (PDFs, Office docs, etc.) still trigger rebuilds unless you add `Files` or `wwwroot` to **`watchExcludeSegments`**.
- For **dotnet watch**, also add `` (and similar) to the monitored `.csproj`. Defaults and behaviour: [features/health-and-logs.md](features/health-and-logs.md).
## Developer environment (not in settings UI)
@@ -154,3 +162,4 @@ Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layou
- **Restart app after rebuild** — when run mode is Watch or Run, start (or restart) the app after a successful rebuild, including the first successful build after a prior failure.
- **Restart app** — stop and start run/watch with `--no-build` (no full rebuild).
- **Rebuild & restart** — full `dotnet build`, then start run/watch (shows build progress in status panel).
+- **Show status panel while building** (default **off**) — per project. Opens the hover status panel when a build starts and hides it when the build finishes. Does not auto-hide if you already had the panel open before the build started.
diff --git a/docs/features/health-and-logs.md b/docs/features/health-and-logs.md
index 9317113..c81bb56 100644
--- a/docs/features/health-and-logs.md
+++ b/docs/features/health-and-logs.md
@@ -11,10 +11,17 @@ How BuildMonitor decides what failed and what the tray, status panel, and log vi
| 3 | Tray receives immutable snapshot list at bounded rate | `ProjectOrchestrator.HealthUpdated` → `App.OnHealthUpdated` (`ApplicationIdle`, coalesced) |
| 4 | Run/watch output updates `runErrorCount` / `runWarningCount` | `DotNetRunOutputParser`, `OnRunProcessOutputLine` (coalesced same as build) |
| 5 | Snapshot picks display counts by lifecycle state | `HealthIssueCountsFormatter.SelectPrimaryCounts` |
-| 6 | Tray tooltip uses headline project + failure phase + error preview | `App.FormatTrayTooltip` |
+| 5b | Incremental file builds with 0/0 MSBuild summary reuse counts from metadata, `.prev`, or the BuildMonitor incremental note | `IncrementalBuildDetector`, `BuildIssueCountResolver`, `BuildLogStore` |
+| 5c | **Startup**, **Rebuild**, and **Rebuild & restart** pass `--no-incremental` so MSBuild recompiles and reports real warning/error counts | `DotNetBuildArguments` |
+| 5d | Post-build tests no longer clear build warning/error counts used for tray health | `ProjectRuntime.TestAsync` |
+| 5e | **Agent-aware build suppression** — defer startup until edits settle; cancel superseded startup/file-change builds; optional agent-transcript activity signal | `EditActivityEvaluator`, `BuildSuppressionPolicy`, `ProjectRuntime` |
+| 5f | Edit gating auto-shows status panel with hold detail + countdown | `App.AutoShowStatusPanelForEditGating`, `HoverStatusPanel` |
+| 6 | Tray hover opens the status panel (project health, counts, gating detail, actions); native shell tooltip is suppressed | `HoverStatusPanel`, `TrayIconShellInterop` |
| 7 | Status panel shows `IssueCountsText` (build vs run context) | `HoverStatusPanel` |
| 8 | Log viewer parses Build / Run / Test tabs with matching parsers | `BuildLogViewerWindow.ParseIssuesForCurrentLog` |
-| 9 | Failure auto-opens log on correct tab + Errors filter | `App.AutoOpenLogsOnFailureTransition` |
+| 8b | Log viewer footer and issues summary use the same resolved counts (metadata, `.prev`, incremental note); incremental 0/0 builds show a carry-forward note in the issues pane | `BuildLogViewerWindow.RefreshResolvedIssueCounts` |
+| 9 | Auto-open log per project (`Never` / `Errors` / `Warnings` / `Always`) | `App.AutoOpenLogsOnTransition`, `AutoOpenLogTransitionEvaluator` |
+| 9b | Auto-show status panel while building (per project, default off) | `App.AutoShowStatusPanelWhileBuilding`, `StatusPanelBuildVisibilityEvaluator` |
| 10 | **Restart app** stops run/watch and starts with `--no-build` | `ProjectRuntime.RestartAppCoreAsync(rebuildFirst: false)` |
| 11 | **Rebuild & restart** runs full build then starts app | `ProjectRuntime.RestartAppCoreAsync(rebuildFirst: true)` |
| 12 | Hot-reload “requires restart/rebuild” lines trigger auto-restart when enabled | `HotReloadRestartDetector`, `ProjectRuntime.TryHandleHotReloadRestartRequest` |
@@ -38,7 +45,7 @@ Per-project dirty flags; up to `MaxConcurrentActiveProjects` (default 3) share o
**Extension points:** add run-error heuristics in `DotNetRunOutputParser`; adjust status formatting in `HealthIssueCountsFormatter`.
-**Failure / fallback:** tray tooltip truncated to 63 characters; issue scroll uses text-match fallback when line index drifts after log truncation.
+**Failure / fallback:** native shell tooltip is suppressed via empty `NotifyIcon.Text` (custom hint only). `TrayIconShellInterop` resolves icon bounds for hint dismiss. Issue scroll uses text-match fallback when line index drifts after log truncation.
## Watch excludes (dotnet watch)
diff --git a/src/BuildMonitor.Tests/AutoOpenLogTransitionEvaluatorTests.cs b/src/BuildMonitor.Tests/AutoOpenLogTransitionEvaluatorTests.cs
new file mode 100644
index 0000000..786c2fd
--- /dev/null
+++ b/src/BuildMonitor.Tests/AutoOpenLogTransitionEvaluatorTests.cs
@@ -0,0 +1,80 @@
+using BuildMonitor.Core.Models;
+using BuildMonitor.Core.Rules;
+
+namespace BuildMonitor.Tests;
+
+public sealed class AutoOpenLogTransitionEvaluatorTests
+{
+ [Theory]
+ [InlineData(AutoOpenLogMode.Never, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Building, ProjectLifecycleState.BuildFailed, false)]
+ [InlineData(AutoOpenLogMode.Errors, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Watching, ProjectLifecycleState.BuildFailed, false)]
+ [InlineData(AutoOpenLogMode.Errors, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Building, ProjectLifecycleState.BuildFailed, true)]
+ [InlineData(AutoOpenLogMode.Errors, MonitorHealth.Red, MonitorHealth.Red, ProjectLifecycleState.BuildFailed, ProjectLifecycleState.BuildFailed, false)]
+ [InlineData(AutoOpenLogMode.Errors, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Building, ProjectLifecycleState.Building, false)]
+ [InlineData(AutoOpenLogMode.Warnings, MonitorHealth.Green, MonitorHealth.Amber, ProjectLifecycleState.Watching, ProjectLifecycleState.Watching, true)]
+ [InlineData(AutoOpenLogMode.Warnings, MonitorHealth.Amber, MonitorHealth.Red, ProjectLifecycleState.Watching, ProjectLifecycleState.BuildFailed, true)]
+ [InlineData(AutoOpenLogMode.Always, MonitorHealth.Green, MonitorHealth.Green, ProjectLifecycleState.Building, ProjectLifecycleState.Watching, true)]
+ [InlineData(AutoOpenLogMode.Always, MonitorHealth.Green, MonitorHealth.Green, ProjectLifecycleState.Testing, ProjectLifecycleState.TestOk, true)]
+ public void ShouldOpen_respects_mode_and_transition(
+ AutoOpenLogMode mode,
+ MonitorHealth previousHealth,
+ MonitorHealth currentHealth,
+ ProjectLifecycleState previousState,
+ ProjectLifecycleState currentState,
+ bool expected) =>
+ Assert.Equal(
+ expected,
+ AutoOpenLogTransitionEvaluator.ShouldOpen(
+ mode,
+ previousHealth,
+ currentHealth,
+ previousState,
+ currentState,
+ errorCount: 0));
+
+ [Fact]
+ public void ShouldOpen_errors_mode_on_run_crash()
+ {
+ Assert.True(
+ AutoOpenLogTransitionEvaluator.ShouldOpen(
+ AutoOpenLogMode.Errors,
+ MonitorHealth.Green,
+ MonitorHealth.Red,
+ ProjectLifecycleState.Running,
+ ProjectLifecycleState.Crashed));
+ }
+
+ [Fact]
+ public void ResolveIssueFilters_selects_errors_for_failed_build_without_parsed_count()
+ {
+ var snapshot = new ProjectHealthSnapshot(
+ "p1",
+ "Demo",
+ MonitorHealth.Red,
+ "Failed",
+ ProjectLifecycleState.BuildFailed,
+ LastExitCode: 1,
+ LastDuration: TimeSpan.FromSeconds(3),
+ LastErrorPreview: "targets(269,5): error : asset missing",
+ ErrorCount: 0,
+ WarningCount: 1065,
+ LastChangedUtc: DateTimeOffset.UtcNow,
+ LastBuildFinishedAtUtc: DateTimeOffset.UtcNow,
+ IsActive: true,
+ ProgressSteps: []);
+
+ var (errors, warnings) = AutoOpenLogTransitionEvaluator.ResolveIssueFilters(
+ AutoOpenLogMode.Errors,
+ snapshot);
+
+ Assert.True(errors);
+ Assert.False(warnings);
+ }
+
+ [Fact]
+ public void PreferWarningsFilter_only_when_no_errors()
+ {
+ Assert.True(AutoOpenLogTransitionEvaluator.PreferWarningsFilter(0, 3));
+ Assert.False(AutoOpenLogTransitionEvaluator.PreferWarningsFilter(1, 3));
+ }
+}
diff --git a/src/BuildMonitor.Tests/BuildIntelligenceSnapshotTests.cs b/src/BuildMonitor.Tests/BuildIntelligenceSnapshotTests.cs
index 07166c5..674418b 100644
--- a/src/BuildMonitor.Tests/BuildIntelligenceSnapshotTests.cs
+++ b/src/BuildMonitor.Tests/BuildIntelligenceSnapshotTests.cs
@@ -1,3 +1,4 @@
+using BuildMonitor.Core.Models;
using BuildMonitor.Core.Settings;
using BuildMonitor.Infrastructure.Diagnostics;
using BuildMonitor.Infrastructure.LocalBuild;
@@ -115,13 +116,65 @@ public void BurstBars_scale_relative_to_largest_sample()
Assert.Equal(1.0, snapshot.BurstBars.Last().HeightRatio, 3);
}
+ [Fact]
+ public void NextRebuildReasonText_timer_reset_mentions_files_and_restart()
+ {
+ var snapshot = CreateSnapshot(
+ pendingFileChangeRebuild: true,
+ rebuildQuietUntilUtc: DateTimeOffset.UtcNow.AddSeconds(3),
+ holdReason: PendingRebuildHoldReason.EditsStillArriving,
+ pendingRebuildFileCount: 3,
+ pendingRebuildSamplePaths: ["Foo.cs", "Bar.cs"],
+ rebuildTimerResetCount: 2);
+
+ Assert.Contains("Wait timer reset", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("3 file(s)", snapshot.NextRebuildReasonText, StringComparison.Ordinal);
+ Assert.Contains("Quiet period restarted", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Foo.cs", snapshot.NextRebuildReasonText, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void NextRebuildReasonText_build_in_progress_explains_wait()
+ {
+ var snapshot = CreateSnapshot(
+ pendingFileChangeRebuild: true,
+ holdReason: PendingRebuildHoldReason.BuildInProgress);
+
+ Assert.Contains("current build", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void NextRebuildReasonText_startup_deferred_mentions_quiet_period()
+ {
+ var snapshot = CreateSnapshot(
+ pendingFileChangeRebuild: true,
+ holdReason: PendingRebuildHoldReason.StartupDeferred);
+
+ Assert.Contains("Startup build deferred", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Theory]
+ [InlineData(new[] { "src/A.cs" }, 1, " (src/A.cs)")]
+ [InlineData(new[] { "A.cs", "B.cs" }, 4, " (A.cs, B.cs +2 more)")]
+ public void FormatPendingFileSample_formats_path_suffix(
+ string[] paths,
+ int totalCount,
+ string expectedSuffix) =>
+ Assert.EndsWith(
+ expectedSuffix,
+ BuildIntelligenceSnapshot.FormatPendingFileSample(paths, totalCount));
+
private static BuildIntelligenceSnapshot CreateSnapshot(
FileChangeDebounceMode debounceMode = FileChangeDebounceMode.Auto,
int baseEffectiveDebounceMs = 3000,
int liveEffectiveDebounceMs = 3000,
int recentFileChangeBuildsIn90s = 0,
bool pendingFileChangeRebuild = false,
- DateTimeOffset? rebuildQuietUntilUtc = null) =>
+ DateTimeOffset? rebuildQuietUntilUtc = null,
+ PendingRebuildHoldReason holdReason = PendingRebuildHoldReason.None,
+ int pendingRebuildFileCount = 0,
+ IReadOnlyList? pendingRebuildSamplePaths = null,
+ int rebuildTimerResetCount = 0) =>
BuildIntelligenceSnapshot.Create(
SampleProject(),
new GlobalMonitorSettings { FileChangeDebounceMode = debounceMode },
@@ -134,7 +187,11 @@ private static BuildIntelligenceSnapshot CreateSnapshot(
coalesceWatchRebuilds: true,
lastMeaningfulFileChangeUtc: null,
pendingFileChangeRebuild: pendingFileChangeRebuild,
- rebuildQuietUntilUtc: rebuildQuietUntilUtc);
+ rebuildQuietUntilUtc: rebuildQuietUntilUtc,
+ holdReason: holdReason,
+ pendingRebuildFileCount: pendingRebuildFileCount,
+ pendingRebuildSamplePaths: pendingRebuildSamplePaths,
+ rebuildTimerResetCount: rebuildTimerResetCount);
private static LocalProjectDefinition SampleProject() => new()
{
diff --git a/src/BuildMonitor.Tests/BuildIssueCountResolverTests.cs b/src/BuildMonitor.Tests/BuildIssueCountResolverTests.cs
new file mode 100644
index 0000000..22b19c9
--- /dev/null
+++ b/src/BuildMonitor.Tests/BuildIssueCountResolverTests.cs
@@ -0,0 +1,45 @@
+using BuildMonitor.Infrastructure.LocalBuild;
+
+namespace BuildMonitor.Tests;
+
+public sealed class BuildIssueCountResolverTests
+{
+ private const string FullLog = """
+ C:\app\Pages\Index.razor(1,1): warning CS8618: Field required [C:\app\app.csproj]
+
+ Build succeeded.
+ 1066 Warning(s)
+ 0 Error(s)
+ """;
+
+ private const string IncrementalLog = """
+ WitherbyConnect -> C:\app\bin\Debug\net9.0\WitherbyConnect.dll
+
+ Build succeeded.
+ 0 Warning(s)
+ 0 Error(s)
+ """;
+
+ [Fact]
+ public void Resolve_incremental_output_uses_previous_log_counts()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"bm-resolver-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ var logPath = Path.Combine(dir, "last-build.log");
+ var prevPath = logPath + ".prev";
+
+ try
+ {
+ File.WriteAllText(prevPath, FullLog);
+
+ var (errors, warnings) = BuildIssueCountResolver.Resolve(IncrementalLog, logPath);
+
+ Assert.Equal(0, errors);
+ Assert.Equal(1066, warnings);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+}
diff --git a/src/BuildMonitor.Tests/BuildLogParserTests.cs b/src/BuildMonitor.Tests/BuildLogParserTests.cs
index 375013a..1be656a 100644
--- a/src/BuildMonitor.Tests/BuildLogParserTests.cs
+++ b/src/BuildMonitor.Tests/BuildLogParserTests.cs
@@ -58,4 +58,63 @@ public void ParseErrorCount_reads_combined_terminal_logger_summary()
Assert.Equal(2, BuildLogParser.ParseErrorCount(log));
Assert.Equal(17, BuildLogParser.ParseWarningCount(log));
}
+
+ [Fact]
+ public void ParseWarningCount_reads_incremental_health_note()
+ {
+ const string log = """
+ Build succeeded.
+ 0 Warning(s)
+ 0 Error(s)
+
+ [BuildMonitor] Incremental build — compiler skipped (outputs up-to-date). Tray health uses 1065 warning(s) from the previous full build log.
+ """;
+
+ Assert.Equal(1065, BuildLogParser.ParseWarningCount(log));
+ }
+
+ [Fact]
+ public void ParseErrorCount_finds_msbuild_error_before_build_failed_line()
+ {
+ const string log = """
+ [BuildMonitor] ===== Build #3 started 2026-06-24 10:00:00 — file change =====
+ C:\proj\Microsoft.NET.Sdk.StaticWebAssets.Compression.targets(269,5): error : The asset 'C:\proj\obj\Debug\net9.0\compressed\foo.gz' can not be found at any of the searched locations 'wwwroot\css\app.css'.
+ Build FAILED.
+ C:\proj\Microsoft.NET.Sdk.StaticWebAssets.Compression.targets(269,5): error : The asset 'C:\proj\obj\Debug\net9.0\compressed\foo.gz' can not be found at any of the searched locations 'wwwroot\css\app.css'.
+ """;
+
+ Assert.Equal(1, BuildLogParser.ParseErrorCount(log));
+ }
+
+ [Fact]
+ public void ResolveBuildIssues_falls_back_to_previous_log_when_incremental()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"bm-prev-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ var currentPath = Path.Combine(dir, "last-build.log");
+ var prevPath = currentPath + ".prev";
+ try
+ {
+ File.WriteAllText(prevPath, """
+ C:\app\Foo.cs(1,1): warning CS8618: required [C:\app\app.csproj]
+
+ Build succeeded.
+ 1 Warning(s)
+ 0 Error(s)
+ """);
+ File.WriteAllText(currentPath, """
+ Build succeeded.
+ 0 Warning(s)
+ 0 Error(s)
+ """);
+
+ var issues = BuildLogParser.ResolveBuildIssues(File.ReadAllText(currentPath), currentPath);
+ Assert.Single(issues);
+ Assert.False(issues[0].IsError);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
}
diff --git a/src/BuildMonitor.Tests/BuildLogStoreTests.cs b/src/BuildMonitor.Tests/BuildLogStoreTests.cs
new file mode 100644
index 0000000..c5c0249
--- /dev/null
+++ b/src/BuildMonitor.Tests/BuildLogStoreTests.cs
@@ -0,0 +1,62 @@
+using BuildMonitor.Core.Models;
+using BuildMonitor.Infrastructure.LocalBuild;
+
+namespace BuildMonitor.Tests;
+
+public sealed class BuildLogStoreTests
+{
+ private const string FullLog = """
+ C:\app\Pages\Index.razor(1,1): warning CS8618: Field required [C:\app\app.csproj]
+
+ Build succeeded.
+ 1065 Warning(s)
+ 0 Error(s)
+ """;
+
+ private const string IncrementalLog = """
+ WitherbyConnect -> C:\app\bin\Debug\net9.0\WitherbyConnect.dll
+
+ Build succeeded.
+ 0 Warning(s)
+ 0 Error(s)
+ """;
+
+ [Fact]
+ public async Task SaveAsync_preserves_warning_counts_when_incremental_overwrites_log()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"bm-store-{Guid.NewGuid():N}");
+ var store = new BuildLogStore(dir);
+ const string projectId = "demo";
+ var started = DateTimeOffset.UtcNow;
+
+ try
+ {
+ await store.SaveAsync(
+ projectId,
+ BuildLogKind.Build,
+ "dotnet build",
+ exitCode: 0,
+ started,
+ FullLog);
+
+ var incremental = await store.SaveAsync(
+ projectId,
+ BuildLogKind.Build,
+ "dotnet build",
+ exitCode: 0,
+ started,
+ IncrementalLog);
+
+ Assert.Equal(0, incremental.ErrorCount);
+ Assert.Equal(1065, incremental.WarningCount);
+
+ var loaded = await store.LoadMetadataAsync(projectId, BuildLogKind.Build);
+ Assert.NotNull(loaded);
+ Assert.Equal(1065, loaded.WarningCount);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+}
diff --git a/src/BuildMonitor.Tests/BuildSuppressionPolicyTests.cs b/src/BuildMonitor.Tests/BuildSuppressionPolicyTests.cs
new file mode 100644
index 0000000..c8add1f
--- /dev/null
+++ b/src/BuildMonitor.Tests/BuildSuppressionPolicyTests.cs
@@ -0,0 +1,37 @@
+using BuildMonitor.Core.Models;
+using BuildMonitor.Core.Rules;
+
+namespace BuildMonitor.Tests;
+
+public sealed class BuildSuppressionPolicyTests
+{
+ private static readonly BuildSuppressionSettings Enabled = new(true, true);
+
+ [Theory]
+ [InlineData("startup", true)]
+ [InlineData("file change", true)]
+ [InlineData("file change (queued)", true)]
+ [InlineData("manual rebuild", false)]
+ [InlineData("rebuild & restart", false)]
+ [InlineData("startup (lock retry)", false)]
+ public void ShouldCancelInFlightBuild_respects_build_reason(string reason, bool expected) =>
+ Assert.Equal(expected, BuildSuppressionPolicy.ShouldCancelInFlightBuild(Enabled, reason));
+
+ [Fact]
+ public void ShouldDeferStartupBuild_when_activity_active()
+ {
+ var activity = new EditActivitySnapshot(true, DateTimeOffset.UtcNow.AddSeconds(5), "pending");
+ Assert.True(BuildSuppressionPolicy.ShouldDeferStartupBuild(Enabled, activity));
+ }
+
+ [Fact]
+ public void IsEditGatingActive_for_startup_deferred_hold()
+ {
+ var activity = EditActivitySnapshot.Inactive;
+ Assert.True(BuildSuppressionPolicy.IsEditGatingActive(
+ Enabled,
+ pendingFileChangeRebuild: true,
+ activity,
+ PendingRebuildHoldReason.StartupDeferred));
+ }
+}
diff --git a/src/BuildMonitor.Tests/BuildTriggerDetailFormatterTests.cs b/src/BuildMonitor.Tests/BuildTriggerDetailFormatterTests.cs
new file mode 100644
index 0000000..bb6a817
--- /dev/null
+++ b/src/BuildMonitor.Tests/BuildTriggerDetailFormatterTests.cs
@@ -0,0 +1,26 @@
+using BuildMonitor.Core.Models;
+using BuildMonitor.Infrastructure.Diagnostics;
+
+namespace BuildMonitor.Tests;
+
+public sealed class BuildTriggerDetailFormatterTests
+{
+ [Fact]
+ public void FormatImmediateDebounce_includes_ms()
+ {
+ var detail = BuildTriggerDetailFormatter.FormatImmediateDebounce(3000);
+ Assert.Contains("3000", detail, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void FormatCoalescedBuild_includes_hold_reason()
+ {
+ var detail = BuildTriggerDetailFormatter.FormatCoalescedBuild(
+ 4500,
+ PendingRebuildHoldReason.BuildInProgress,
+ timerResetCount: 0);
+
+ Assert.Contains("4500", detail, StringComparison.Ordinal);
+ Assert.Contains("build finished", detail, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/BuildMonitor.Tests/DotNetBuildArgumentsTests.cs b/src/BuildMonitor.Tests/DotNetBuildArgumentsTests.cs
new file mode 100644
index 0000000..191c8f4
--- /dev/null
+++ b/src/BuildMonitor.Tests/DotNetBuildArgumentsTests.cs
@@ -0,0 +1,24 @@
+using BuildMonitor.Infrastructure.LocalBuild;
+
+namespace BuildMonitor.Tests;
+
+public sealed class DotNetBuildArgumentsTests
+{
+ [Theory]
+ [InlineData("manual rebuild", true)]
+ [InlineData("rebuild & restart", true)]
+ [InlineData("startup", true)]
+ [InlineData("file change", false)]
+ public void RequiresFullRebuild_matches_explicit_user_actions(string reason, bool expected) =>
+ Assert.Equal(expected, DotNetBuildArguments.RequiresFullRebuild(reason));
+
+ [Fact]
+ public void ApplyFullRebuildFlag_adds_no_incremental_once()
+ {
+ var args = new List { "build", "App.csproj" };
+ DotNetBuildArguments.ApplyFullRebuildFlag(args, forceFullRebuild: true);
+ DotNetBuildArguments.ApplyFullRebuildFlag(args, forceFullRebuild: true);
+ Assert.Equal(3, args.Count);
+ Assert.Contains("--no-incremental", args);
+ }
+}
diff --git a/src/BuildMonitor.Tests/EditActivityEvaluatorTests.cs b/src/BuildMonitor.Tests/EditActivityEvaluatorTests.cs
new file mode 100644
index 0000000..84d27c6
--- /dev/null
+++ b/src/BuildMonitor.Tests/EditActivityEvaluatorTests.cs
@@ -0,0 +1,57 @@
+using BuildMonitor.Core.Rules;
+
+namespace BuildMonitor.Tests;
+
+public sealed class EditActivityEvaluatorTests
+{
+ [Fact]
+ public void Evaluate_inactive_when_no_signals()
+ {
+ var snapshot = EditActivitySnapshot.Evaluate(
+ new EditActivityInput(
+ SourceWatcherHasPendingChanges: false,
+ SourceBurstStartedUtc: null,
+ LastMeaningfulFileChangeUtc: DateTimeOffset.MinValue,
+ LastAgentActivityUtc: null,
+ DebounceMs: 3000,
+ UseAgentTranscriptActivity: true),
+ DateTimeOffset.UtcNow);
+
+ Assert.False(snapshot.IsActive);
+ }
+
+ [Fact]
+ public void Evaluate_active_when_watcher_has_pending_changes()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var snapshot = EditActivitySnapshot.Evaluate(
+ new EditActivityInput(
+ SourceWatcherHasPendingChanges: true,
+ SourceBurstStartedUtc: now,
+ LastMeaningfulFileChangeUtc: DateTimeOffset.MinValue,
+ LastAgentActivityUtc: null,
+ DebounceMs: 3000,
+ UseAgentTranscriptActivity: false),
+ now);
+
+ Assert.True(snapshot.IsActive);
+ Assert.True(snapshot.QuietUntilUtc > now);
+ }
+
+ [Fact]
+ public void Evaluate_active_from_recent_agent_transcript_activity()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var snapshot = EditActivitySnapshot.Evaluate(
+ new EditActivityInput(
+ SourceWatcherHasPendingChanges: false,
+ SourceBurstStartedUtc: null,
+ LastMeaningfulFileChangeUtc: DateTimeOffset.MinValue,
+ LastAgentActivityUtc: now.AddMilliseconds(-500),
+ DebounceMs: 3000,
+ UseAgentTranscriptActivity: true),
+ now);
+
+ Assert.True(snapshot.IsActive);
+ }
+}
diff --git a/src/BuildMonitor.Tests/EditGatingDetailFormatterTests.cs b/src/BuildMonitor.Tests/EditGatingDetailFormatterTests.cs
new file mode 100644
index 0000000..1556f36
--- /dev/null
+++ b/src/BuildMonitor.Tests/EditGatingDetailFormatterTests.cs
@@ -0,0 +1,24 @@
+using BuildMonitor.Core.Rules;
+
+namespace BuildMonitor.Tests;
+
+public sealed class EditGatingDetailFormatterTests
+{
+ [Fact]
+ public void FormatCountdownRemaining_shows_whole_seconds()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var text = EditGatingDetailFormatter.FormatCountdownRemaining(now.AddSeconds(12.2), now);
+
+ Assert.Equal("Rebuild in 13 s", text);
+ }
+
+ [Fact]
+ public void FormatCountdownRemaining_at_zero_shows_starting()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var text = EditGatingDetailFormatter.FormatCountdownRemaining(now, now);
+
+ Assert.Equal("Rebuild starting…", text);
+ }
+}
diff --git a/src/BuildMonitor.Tests/HealthIssueCountsFormatterTests.cs b/src/BuildMonitor.Tests/HealthIssueCountsFormatterTests.cs
index f55fe5c..3396750 100644
--- a/src/BuildMonitor.Tests/HealthIssueCountsFormatterTests.cs
+++ b/src/BuildMonitor.Tests/HealthIssueCountsFormatterTests.cs
@@ -32,6 +32,20 @@ public void SelectPrimaryCounts_uses_run_counts_when_crashed_with_run_errors()
Assert.Equal(2, warnings);
}
+ [Fact]
+ public void SelectPrimaryCounts_uses_build_warnings_when_running_with_clean_run_output()
+ {
+ var (errors, warnings) = HealthIssueCountsFormatter.SelectPrimaryCounts(
+ ProjectLifecycleState.Running,
+ 0,
+ 1067,
+ 0,
+ 0);
+
+ Assert.Equal(0, errors);
+ Assert.Equal(1067, warnings);
+ }
+
[Fact]
public void FormatFailurePhase_maps_crashed_to_run_failed()
{
diff --git a/src/BuildMonitor.Tests/IncrementalBuildDetectorTests.cs b/src/BuildMonitor.Tests/IncrementalBuildDetectorTests.cs
new file mode 100644
index 0000000..01e3420
--- /dev/null
+++ b/src/BuildMonitor.Tests/IncrementalBuildDetectorTests.cs
@@ -0,0 +1,71 @@
+using BuildMonitor.Infrastructure.LocalBuild;
+
+namespace BuildMonitor.Tests;
+
+public sealed class IncrementalBuildDetectorTests
+{
+ private const string IncrementalLog = """
+ WitherbyConnect -> C:\app\bin\Debug\net9.0\WitherbyConnect.dll
+
+ Build succeeded.
+ 0 Warning(s)
+ 0 Error(s)
+ """;
+
+ private const string FullLog = """
+ C:\app\Pages\Index.razor(1,1): warning CS8618: Field required [C:\app\app.csproj]
+
+ Build succeeded.
+ 1065 Warning(s)
+ 0 Error(s)
+ """;
+
+ [Fact]
+ public void WasCompileSkipped_true_for_up_to_date_summary_without_diagnostics() =>
+ Assert.True(IncrementalBuildDetector.WasCompileSkipped(IncrementalLog));
+
+ [Fact]
+ public void WasCompileSkipped_false_when_warnings_in_summary() =>
+ Assert.False(IncrementalBuildDetector.WasCompileSkipped(FullLog));
+
+ [Fact]
+ public void Resolve_reuses_previous_log_counts_when_compile_skipped()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"bm-prev-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ var logPath = Path.Combine(dir, "last-build.log");
+ try
+ {
+ File.WriteAllText(logPath + ".prev", FullLog);
+ File.WriteAllText(logPath, IncrementalLog);
+ var (errors, warnings) = BuildIssueCountResolver.Resolve(IncrementalLog, logPath);
+ Assert.Equal(0, errors);
+ Assert.Equal(1065, warnings);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void Resolve_reads_warning_count_from_saved_metadata_when_logs_are_incremental()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"bm-meta-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ var logPath = Path.Combine(dir, "last-build.log");
+ var metaPath = Path.Combine(dir, "last-build.meta.json");
+ try
+ {
+ File.WriteAllText(logPath, IncrementalLog);
+ File.WriteAllText(metaPath, """{"ErrorCount":0,"WarningCount":1065}""");
+
+ var (_, warnings) = BuildIssueCountResolver.Resolve(IncrementalLog, logPath);
+ Assert.Equal(1065, warnings);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+}
diff --git a/src/BuildMonitor.Tests/ProjectHealthEvaluatorTests.cs b/src/BuildMonitor.Tests/ProjectHealthEvaluatorTests.cs
index abc5a26..ee4dfd8 100644
--- a/src/BuildMonitor.Tests/ProjectHealthEvaluatorTests.cs
+++ b/src/BuildMonitor.Tests/ProjectHealthEvaluatorTests.cs
@@ -65,6 +65,18 @@ public void Evaluate_returns_green_when_watching_despite_failed_last_build_exit_
Assert.Equal(MonitorHealth.Green, health);
}
+ [Fact]
+ public void Evaluate_returns_amber_after_tests_when_build_had_warnings()
+ {
+ var health = ProjectHealthEvaluator.Evaluate(
+ ProjectLifecycleState.TestOk,
+ lastBuildExitCode: 0,
+ errorCount: 0,
+ warningCount: 1065);
+
+ Assert.Equal(MonitorHealth.Amber, health);
+ }
+
[Fact]
public void Evaluate_returns_green_when_restarting_despite_crashed_state()
{
diff --git a/src/BuildMonitor.Tests/StatusPanelBuildVisibilityEvaluatorTests.cs b/src/BuildMonitor.Tests/StatusPanelBuildVisibilityEvaluatorTests.cs
new file mode 100644
index 0000000..16c5245
--- /dev/null
+++ b/src/BuildMonitor.Tests/StatusPanelBuildVisibilityEvaluatorTests.cs
@@ -0,0 +1,226 @@
+using BuildMonitor.Core.Models;
+using BuildMonitor.Core.Rules;
+
+namespace BuildMonitor.Tests;
+
+public sealed class StatusPanelBuildVisibilityEvaluatorTests
+{
+ [Theory]
+ [InlineData(true, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, true)]
+ [InlineData(true, ProjectLifecycleState.Building, ProjectLifecycleState.Building, false)]
+ [InlineData(false, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, false)]
+ [InlineData(true, ProjectLifecycleState.Building, ProjectLifecycleState.BuildOk, false)]
+ public void ShouldAutoShow_respects_enabled_and_build_transition(
+ bool enabled,
+ ProjectLifecycleState previous,
+ ProjectLifecycleState current,
+ bool expected) =>
+ Assert.Equal(
+ expected,
+ StatusPanelBuildVisibilityEvaluator.ShouldAutoShow(enabled, previous, current));
+
+ [Fact]
+ public void ShouldAutoHide_when_auto_shown_and_no_enabled_project_building()
+ {
+ var projects = new[]
+ {
+ (ShowWhileBuildingEnabled: true, State: ProjectLifecycleState.Watching),
+ (ShowWhileBuildingEnabled: false, State: ProjectLifecycleState.Building)
+ };
+
+ Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldAutoHide(true, projects));
+ }
+
+ [Fact]
+ public void ShouldAutoHide_false_while_enabled_project_still_building()
+ {
+ var projects = new[]
+ {
+ (ShowWhileBuildingEnabled: true, State: ProjectLifecycleState.Building),
+ (ShowWhileBuildingEnabled: true, State: ProjectLifecycleState.Watching)
+ };
+
+ Assert.False(StatusPanelBuildVisibilityEvaluator.ShouldAutoHide(true, projects));
+ }
+
+ [Fact]
+ public void ShouldAutoShowForEditGating_on_transition_to_active()
+ {
+ Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldAutoShowForEditGating(
+ suppressionEnabled: true,
+ isGatingActive: true,
+ wasGatingActive: false));
+ }
+
+ [Fact]
+ public void ShouldAutoHideForEditGating_when_gating_ends()
+ {
+ Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldAutoHideForEditGating(
+ autoShownForEditGating: true,
+ isGatingActive: false));
+ }
+
+ [Theory]
+ [InlineData(true, true, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, true)]
+ [InlineData(true, false, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, false)]
+ [InlineData(true, true, ProjectLifecycleState.Building, ProjectLifecycleState.Building, false)]
+ [InlineData(true, true, ProjectLifecycleState.Watching, ProjectLifecycleState.WaitingForEdits, true)]
+ [InlineData(true, false, ProjectLifecycleState.Watching, ProjectLifecycleState.WaitingForEdits, true)]
+ public void ShouldAutoShowForBusyWork_respects_show_while_building_for_build_states(
+ bool suppressionEnabled,
+ bool showWhileBuilding,
+ ProjectLifecycleState previous,
+ ProjectLifecycleState current,
+ bool expected) =>
+ Assert.Equal(
+ expected,
+ StatusPanelBuildVisibilityEvaluator.ShouldAutoShowForBusyWork(
+ suppressionEnabled,
+ showWhileBuilding,
+ previous,
+ current));
+
+ [Theory]
+ [InlineData(false, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, true, true)]
+ [InlineData(true, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, true, false)]
+ [InlineData(false, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, false, false)]
+ public void ShouldHideWhenBuildStartsWithoutShowSetting(
+ bool showWhileBuilding,
+ ProjectLifecycleState previous,
+ ProjectLifecycleState current,
+ bool autoShownForEditGatingOnly,
+ bool expected) =>
+ Assert.Equal(
+ expected,
+ StatusPanelBuildVisibilityEvaluator.ShouldHideWhenBuildStartsWithoutShowSetting(
+ showWhileBuilding,
+ previous,
+ current,
+ autoShownForEditGatingOnly));
+
+ [Theory]
+ [InlineData(ProjectLifecycleState.Watching, ProjectLifecycleState.Building, true)]
+ [InlineData(ProjectLifecycleState.Building, ProjectLifecycleState.Building, false)]
+ [InlineData(ProjectLifecycleState.Watching, ProjectLifecycleState.WaitingForEdits, true)]
+ public void ShouldAutoShowForBusyWork_when_show_while_building_enabled(
+ ProjectLifecycleState previous,
+ ProjectLifecycleState current,
+ bool expected) =>
+ Assert.Equal(
+ expected,
+ StatusPanelBuildVisibilityEvaluator.ShouldAutoShowForBusyWork(
+ suppressionEnabled: true,
+ showStatusPanelWhileBuilding: true,
+ previous,
+ current));
+
+ [Fact]
+ public void ShouldAutoHideForBusyWork_when_no_project_busy()
+ {
+ Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldAutoHideForBusyWork(
+ autoShown: true,
+ [ProjectLifecycleState.Watching, ProjectLifecycleState.Running]));
+ }
+
+ [Fact]
+ public void ShouldKeepPanelVisibleUntilSiteReady_while_listen_url_not_ready()
+ {
+ var snapshot = new ProjectHealthSnapshot(
+ "p1",
+ "Demo",
+ MonitorHealth.Green,
+ "Success",
+ ProjectLifecycleState.Running,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ DateTimeOffset.UtcNow,
+ null,
+ true,
+ [],
+ "http://localhost:5000",
+ ListenUrlReady: false,
+ SupportsAppRestart: true);
+
+ Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldKeepPanelVisibleUntilSiteReady([snapshot]));
+ }
+
+ [Fact]
+ public void ShouldKeepPanelVisibleUntilSiteReady_false_when_site_is_ready()
+ {
+ var snapshot = new ProjectHealthSnapshot(
+ "p1",
+ "Demo",
+ MonitorHealth.Green,
+ "Success",
+ ProjectLifecycleState.Watching,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ DateTimeOffset.UtcNow,
+ null,
+ true,
+ [],
+ "http://localhost:5000",
+ ListenUrlReady: true,
+ SupportsAppRestart: true);
+
+ Assert.False(StatusPanelBuildVisibilityEvaluator.ShouldKeepPanelVisibleUntilSiteReady([snapshot]));
+ }
+
+ [Fact]
+ public void ShouldShowSiteStatus_false_while_building()
+ {
+ var snapshot = new ProjectHealthSnapshot(
+ "p1",
+ "Demo",
+ MonitorHealth.Green,
+ "Success",
+ ProjectLifecycleState.Building,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ DateTimeOffset.UtcNow,
+ null,
+ true,
+ [],
+ "http://localhost:5000",
+ ListenUrlReady: false,
+ SupportsAppRestart: true);
+
+ Assert.False(StatusPanelBuildVisibilityEvaluator.ShouldShowSiteStatus(snapshot));
+ Assert.False(StatusPanelBuildVisibilityEvaluator.IsAwaitingSiteReady(snapshot));
+ }
+
+ [Fact]
+ public void ShouldShowSiteStatus_true_when_running_and_not_ready()
+ {
+ var snapshot = new ProjectHealthSnapshot(
+ "p1",
+ "Demo",
+ MonitorHealth.Green,
+ "Success",
+ ProjectLifecycleState.Running,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ DateTimeOffset.UtcNow,
+ null,
+ true,
+ [],
+ "http://localhost:5000",
+ ListenUrlReady: false,
+ SupportsAppRestart: true);
+
+ Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldShowSiteStatus(snapshot));
+ Assert.True(StatusPanelBuildVisibilityEvaluator.IsAwaitingSiteReady(snapshot));
+ }
+}
diff --git a/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs b/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs
index 9fb15ee..83584aa 100644
--- a/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs
+++ b/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs
@@ -1,77 +1,211 @@
using BuildMonitor.Core.Models;
+
using BuildMonitor.Core.Rules;
+
+
namespace BuildMonitor.Tests;
+
+
public sealed class TrayTooltipFormatterTests
+
{
+
[Fact]
- public void Format_building_uses_project_name()
+
+ public void Format_building_merges_project_name_and_status()
+
{
+
var snapshot = new ProjectHealthSnapshot(
+
"p1",
+
"Alpha",
+
MonitorHealth.Green,
+
"OK",
+
ProjectLifecycleState.Building,
+
null,
+
null,
+
null,
+
0,
+
0,
+
DateTimeOffset.UtcNow,
+
null,
+
true,
+
[],
+
null,
+
false,
+
true,
+
null,
+
null,
+
false);
- var text = TrayTooltipFormatter.Format(snapshot, MonitorHealth.Green, isBuilding: true);
+
+
+ var text = TrayTooltipFormatter.FormatMultiline(snapshot, MonitorHealth.Green, isBuilding: true);
+
+
Assert.Equal("Building — Alpha", text);
+
}
+
+
[Fact]
- public void Format_failure_includes_error_preview_truncated()
+
+ public void FormatMultiline_merges_project_name_with_build_results()
+
{
+
var longError = new string('x', 80);
+
+ var snapshot = new ProjectHealthSnapshot(
+
+ "p1",
+
+ "Vessel Compliance",
+
+ MonitorHealth.Amber,
+
+ "Warnings",
+
+ ProjectLifecycleState.Watching,
+
+ 0,
+
+ null,
+
+ longError,
+
+ 0,
+
+ 1065,
+
+ DateTimeOffset.UtcNow,
+
+ null,
+
+ true,
+
+ [],
+
+ null,
+
+ false,
+
+ true,
+
+ "Build: 0 errors | 1065 warnings",
+
+ "Watching",
+
+ false);
+
+
+
+ var text = TrayTooltipFormatter.FormatMultiline(snapshot, MonitorHealth.Amber, isBuilding: false);
+
+
+
+ Assert.StartsWith("Vessel Compliance — Warnings", text, StringComparison.Ordinal);
+
+ Assert.Contains("Build: 0 errors | 1065 warnings", text, StringComparison.Ordinal);
+
+ Assert.Contains(longError, text, StringComparison.Ordinal);
+
+ }
+
+
+
+ [Fact]
+
+ public void FormatShort_is_empty_for_suppressed_shell_tooltip()
+
+ {
+
var snapshot = new ProjectHealthSnapshot(
+
"p1",
+
"Beta",
+
MonitorHealth.Red,
+
"Failed",
+
ProjectLifecycleState.BuildFailed,
+
1,
- TimeSpan.FromSeconds(3),
- longError,
- 2,
+
+ null,
+
+ "error",
+
+ 1,
+
0,
+
DateTimeOffset.UtcNow,
- DateTimeOffset.UtcNow,
+
+ null,
+
true,
+
[],
+
null,
+
false,
+
true,
- "Build failed",
- "Build",
+
+ null,
+
+ null,
+
false);
- var text = TrayTooltipFormatter.Format(snapshot, MonitorHealth.Red, isBuilding: false);
- Assert.StartsWith("Beta — Build: ", text);
- Assert.True(text.Length <= TrayTooltipFormatter.MaxTooltipLength);
- Assert.EndsWith("…", text);
+
+ Assert.Equal(string.Empty, TrayTooltipFormatter.FormatShort(snapshot, MonitorHealth.Red, isBuilding: false));
+
}
+
+
[Fact]
+
public void DescribeHealthTooltip_maps_rollup_colours()
+
{
+
Assert.Equal("Build monitor - Success", TrayTooltipFormatter.DescribeHealthTooltip(MonitorHealth.Green));
+
Assert.Equal("Build monitor - Failed", TrayTooltipFormatter.DescribeHealthTooltip(MonitorHealth.Red));
+
}
+
}
+
+
diff --git a/src/BuildMonitor.Tests/WatchIgnoreRulesTests.cs b/src/BuildMonitor.Tests/WatchIgnoreRulesTests.cs
index 27b2bde..9dd22cb 100644
--- a/src/BuildMonitor.Tests/WatchIgnoreRulesTests.cs
+++ b/src/BuildMonitor.Tests/WatchIgnoreRulesTests.cs
@@ -11,6 +11,8 @@ public class WatchIgnoreRulesTests
[InlineData(@"C:\proj\logs\last-build.log", true)]
[InlineData(@"C:\proj\src\Page.razor", false)]
[InlineData(@"C:\proj\Thumbs.db", true)]
+ [InlineData(@"C:\proj\wwwroot\Images\logo.png", true)]
+ [InlineData(@"C:\proj\wwwroot\Files\guide.pdf", false)]
public void ShouldIgnorePath_classifies_noise_and_source(string path, bool expected) =>
Assert.Equal(
expected,
diff --git a/src/Core/Models/AutoOpenLogMode.cs b/src/Core/Models/AutoOpenLogMode.cs
new file mode 100644
index 0000000..650ffae
--- /dev/null
+++ b/src/Core/Models/AutoOpenLogMode.cs
@@ -0,0 +1,10 @@
+namespace BuildMonitor.Core.Models;
+
+/// When to open the log viewer automatically for this project.
+public enum AutoOpenLogMode
+{
+ Never = 0,
+ Errors = 1,
+ Warnings = 2,
+ Always = 3
+}
diff --git a/src/Core/Models/LocalProjectModels.cs b/src/Core/Models/LocalProjectModels.cs
index 165975e..72666c1 100644
--- a/src/Core/Models/LocalProjectModels.cs
+++ b/src/Core/Models/LocalProjectModels.cs
@@ -32,7 +32,9 @@ public enum ProjectLifecycleState
Crashed = 6,
Testing = 7,
TestFailed = 8,
- TestOk = 9
+ TestOk = 9,
+ /// Waiting for source edits to settle before building.
+ WaitingForEdits = 10
}
public enum BuildLogKind
@@ -70,7 +72,8 @@ public sealed record BuildLogRecord(
DateTimeOffset FinishedAtUtc,
string LogFilePath,
int ErrorCount,
- IReadOnlyList ErrorLines);
+ IReadOnlyList ErrorLines,
+ int WarningCount = 0);
public sealed record LiveBuildLogView(
string Text,
@@ -100,7 +103,10 @@ public sealed record ProjectHealthSnapshot(
bool SupportsAppRestart = false,
string? IssueCountsText = null,
string? FailurePhase = null,
- bool IsRestarting = false);
+ bool IsRestarting = false,
+ bool IsEditGatingActive = false,
+ string? EditGatingDetailText = null,
+ DateTimeOffset? RebuildQuietUntilUtc = null);
public enum BuildTriggerKind
{
diff --git a/src/Core/Models/PendingRebuildHoldReason.cs b/src/Core/Models/PendingRebuildHoldReason.cs
new file mode 100644
index 0000000..c6b0667
--- /dev/null
+++ b/src/Core/Models/PendingRebuildHoldReason.cs
@@ -0,0 +1,21 @@
+namespace BuildMonitor.Core.Models;
+
+/// Why a file-triggered rebuild is waiting instead of building immediately.
+public enum PendingRebuildHoldReason
+{
+ None = 0,
+ /// Quiet period after the last save (agent session coalescing).
+ EditsSettling = 1,
+ /// New saves arrived — the wait timer was restarted.
+ EditsStillArriving = 2,
+ /// A build is already running.
+ BuildInProgress = 3,
+ /// Tests are running.
+ TestsInProgress = 4,
+ /// Post-build cooldown window.
+ PostBuildCooldown = 5,
+ /// Startup build waiting for edit quiet period.
+ StartupDeferred = 6,
+ /// In-flight build cancelled; waiting for edits to settle before rebuild.
+ SupersededByNewEdits = 7
+}
diff --git a/src/Core/Rules/AutoOpenLogTransitionEvaluator.cs b/src/Core/Rules/AutoOpenLogTransitionEvaluator.cs
new file mode 100644
index 0000000..d9eb650
--- /dev/null
+++ b/src/Core/Rules/AutoOpenLogTransitionEvaluator.cs
@@ -0,0 +1,104 @@
+using BuildMonitor.Core.Models;
+
+namespace BuildMonitor.Core.Rules;
+
+public static class AutoOpenLogTransitionEvaluator
+{
+ public static bool ShouldOpen(
+ AutoOpenLogMode mode,
+ MonitorHealth previousHealth,
+ MonitorHealth currentHealth,
+ ProjectLifecycleState previousState,
+ ProjectLifecycleState currentState,
+ int errorCount = 0)
+ {
+ if (mode == AutoOpenLogMode.Never)
+ {
+ return false;
+ }
+
+ if (mode == AutoOpenLogMode.Always)
+ {
+ return CompletedBuild(previousState, currentState)
+ || CompletedTest(previousState, currentState);
+ }
+
+ if (mode == AutoOpenLogMode.Errors)
+ {
+ if (currentState is ProjectLifecycleState.Building or ProjectLifecycleState.Testing)
+ {
+ return false;
+ }
+
+ if (previousState == ProjectLifecycleState.Building
+ && currentState == ProjectLifecycleState.BuildFailed)
+ {
+ return true;
+ }
+
+ if (previousState == ProjectLifecycleState.Testing
+ && currentState == ProjectLifecycleState.TestFailed)
+ {
+ return true;
+ }
+
+ return currentState == ProjectLifecycleState.Crashed
+ && previousState is ProjectLifecycleState.Running or ProjectLifecycleState.Watching;
+ }
+
+ if (mode == AutoOpenLogMode.Warnings)
+ {
+ return currentHealth is MonitorHealth.Amber or MonitorHealth.Red
+ && previousHealth != currentHealth;
+ }
+
+ return false;
+ }
+
+ public static (bool SelectErrorsFilter, bool SelectWarningsFilter) ResolveIssueFilters(
+ AutoOpenLogMode mode,
+ ProjectHealthSnapshot snapshot)
+ {
+ if (mode == AutoOpenLogMode.Warnings
+ && snapshot.Health == MonitorHealth.Amber
+ && snapshot.ErrorCount == 0
+ && snapshot.WarningCount > 0)
+ {
+ return (false, true);
+ }
+
+ if (mode == AutoOpenLogMode.Errors
+ || snapshot.State is ProjectLifecycleState.BuildFailed
+ or ProjectLifecycleState.TestFailed
+ or ProjectLifecycleState.Crashed
+ || snapshot.ErrorCount > 0)
+ {
+ return (true, false);
+ }
+
+ return (false, false);
+ }
+
+ public static bool ShouldResetOpenLatch(AutoOpenLogMode mode, MonitorHealth currentHealth) =>
+ mode switch
+ {
+ AutoOpenLogMode.Never => true,
+ AutoOpenLogMode.Errors => currentHealth != MonitorHealth.Red,
+ AutoOpenLogMode.Warnings => currentHealth is MonitorHealth.Green or MonitorHealth.Unknown,
+ AutoOpenLogMode.Always => false,
+ _ => true
+ };
+
+ public static bool PreferErrorsFilter(int errorCount, int warningCount) => errorCount > 0;
+
+ public static bool PreferWarningsFilter(int errorCount, int warningCount) =>
+ errorCount == 0 && warningCount > 0;
+
+ private static bool CompletedBuild(ProjectLifecycleState previous, ProjectLifecycleState current) =>
+ previous == ProjectLifecycleState.Building
+ && current is not ProjectLifecycleState.Building;
+
+ private static bool CompletedTest(ProjectLifecycleState previous, ProjectLifecycleState current) =>
+ previous == ProjectLifecycleState.Testing
+ && current is ProjectLifecycleState.TestOk or ProjectLifecycleState.TestFailed;
+}
diff --git a/src/Core/Rules/BuildSuppressionPolicy.cs b/src/Core/Rules/BuildSuppressionPolicy.cs
new file mode 100644
index 0000000..b2f208f
--- /dev/null
+++ b/src/Core/Rules/BuildSuppressionPolicy.cs
@@ -0,0 +1,53 @@
+namespace BuildMonitor.Core.Rules;
+
+using BuildMonitor.Core.Models;
+
+public sealed record BuildSuppressionSettings(
+ bool DeferStartupBuildUntilQuiet,
+ bool CancelSupersededBuilds);
+
+public static class BuildSuppressionPolicy
+{
+ public static bool ShouldDeferStartupBuild(
+ BuildSuppressionSettings settings,
+ EditActivitySnapshot activity) =>
+ settings.DeferStartupBuildUntilQuiet && activity.IsActive;
+
+ public static bool ShouldCancelInFlightBuild(
+ BuildSuppressionSettings settings,
+ string? buildReason)
+ {
+ if (!settings.CancelSupersededBuilds || string.IsNullOrWhiteSpace(buildReason))
+ {
+ return false;
+ }
+
+ if (buildReason.Contains("(lock retry)", StringComparison.OrdinalIgnoreCase)
+ || buildReason.Contains("(output repair retry)", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return IsStartupReason(buildReason)
+ || IsFileChangeReason(buildReason);
+ }
+
+ public static bool IsEditGatingActive(
+ BuildSuppressionSettings settings,
+ bool pendingFileChangeRebuild,
+ EditActivitySnapshot activity,
+ PendingRebuildHoldReason holdReason) =>
+ (settings.DeferStartupBuildUntilQuiet || settings.CancelSupersededBuilds)
+ && (pendingFileChangeRebuild
+ || holdReason is PendingRebuildHoldReason.StartupDeferred
+ or PendingRebuildHoldReason.SupersededByNewEdits
+ || activity.IsActive);
+
+ private static bool IsStartupReason(string buildReason) =>
+ string.Equals(buildReason, "startup", StringComparison.OrdinalIgnoreCase)
+ || buildReason.StartsWith("startup ", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsFileChangeReason(string buildReason) =>
+ string.Equals(buildReason, "file change", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(buildReason, "file change (queued)", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/Core/Rules/EditActivityEvaluator.cs b/src/Core/Rules/EditActivityEvaluator.cs
new file mode 100644
index 0000000..638d636
--- /dev/null
+++ b/src/Core/Rules/EditActivityEvaluator.cs
@@ -0,0 +1,66 @@
+namespace BuildMonitor.Core.Rules;
+
+public sealed record EditActivityInput(
+ bool SourceWatcherHasPendingChanges,
+ DateTimeOffset? SourceBurstStartedUtc,
+ DateTimeOffset LastMeaningfulFileChangeUtc,
+ DateTimeOffset? LastAgentActivityUtc,
+ int DebounceMs,
+ bool UseAgentTranscriptActivity);
+
+public sealed record EditActivitySnapshot(
+ bool IsActive,
+ DateTimeOffset QuietUntilUtc,
+ string PrimaryReason)
+{
+ public static EditActivitySnapshot Inactive { get; } = new(false, DateTimeOffset.UtcNow, string.Empty);
+
+ public static EditActivitySnapshot Evaluate(EditActivityInput input, DateTimeOffset utcNow)
+ {
+ var debounceMs = Math.Clamp(input.DebounceMs, 1500, 12000);
+ var reasons = new List();
+
+ if (input.SourceWatcherHasPendingChanges)
+ {
+ reasons.Add("source saves in debounce window");
+ }
+
+ if (input.LastMeaningfulFileChangeUtc != DateTimeOffset.MinValue)
+ {
+ var quietUntil = ComputeQuietUntil(input.LastMeaningfulFileChangeUtc, debounceMs);
+ if (quietUntil > utcNow)
+ {
+ reasons.Add("quiet period after last save");
+ return new EditActivitySnapshot(true, quietUntil, string.Join("; ", reasons));
+ }
+ }
+
+ if (input.UseAgentTranscriptActivity
+ && input.LastAgentActivityUtc is { } agentActivity)
+ {
+ var agentQuietUntil = ComputeQuietUntil(agentActivity, debounceMs);
+ if (agentQuietUntil > utcNow)
+ {
+ reasons.Add("agent tooling activity");
+ return new EditActivitySnapshot(true, agentQuietUntil, string.Join("; ", reasons));
+ }
+ }
+
+ if (input.SourceWatcherHasPendingChanges)
+ {
+ var burstStart = input.SourceBurstStartedUtc ?? utcNow;
+ var burstQuietUntil = ComputeQuietUntil(input.LastMeaningfulFileChangeUtc != DateTimeOffset.MinValue
+ ? input.LastMeaningfulFileChangeUtc
+ : burstStart, debounceMs);
+ if (burstQuietUntil > utcNow)
+ {
+ return new EditActivitySnapshot(true, burstQuietUntil, string.Join("; ", reasons));
+ }
+ }
+
+ return Inactive;
+ }
+
+ private static DateTimeOffset ComputeQuietUntil(DateTimeOffset lastChangeUtc, int debounceMs) =>
+ lastChangeUtc.AddMilliseconds(debounceMs);
+}
diff --git a/src/Core/Rules/EditGatingDetailFormatter.cs b/src/Core/Rules/EditGatingDetailFormatter.cs
new file mode 100644
index 0000000..1de59be
--- /dev/null
+++ b/src/Core/Rules/EditGatingDetailFormatter.cs
@@ -0,0 +1,93 @@
+using BuildMonitor.Core.Models;
+
+namespace BuildMonitor.Core.Rules;
+
+/// Shared hold-reason text for status panel and build intelligence.
+public static class EditGatingDetailFormatter
+{
+ public static string FormatHoldReason(
+ PendingRebuildHoldReason holdReason,
+ int pendingFileCount,
+ IReadOnlyList? samplePaths,
+ int timerResetCount,
+ int liveDebounceMs,
+ bool agentSessionBackoff)
+ {
+ if (holdReason == PendingRebuildHoldReason.None)
+ {
+ return string.Empty;
+ }
+
+ var files = FormatPendingFileSample(samplePaths, pendingFileCount);
+
+ return holdReason switch
+ {
+ PendingRebuildHoldReason.EditsStillArriving when timerResetCount > 1 =>
+ $"Wait timer reset ({timerResetCount}×) — {pendingFileCount} file(s) just saved{files}. Quiet period restarted.",
+ PendingRebuildHoldReason.EditsStillArriving =>
+ $"Wait timer reset — {pendingFileCount} file(s) just saved{files}. Quiet period restarted.",
+ PendingRebuildHoldReason.EditsSettling => agentSessionBackoff
+ ? $"Agent session — waiting {FormatDuration(liveDebounceMs)} after the last save{files}."
+ : $"Waiting {FormatDuration(liveDebounceMs)} after the last save{files}.",
+ PendingRebuildHoldReason.BuildInProgress =>
+ "Rebuild queued — waiting for the current build to finish.",
+ PendingRebuildHoldReason.TestsInProgress =>
+ "Rebuild queued — waiting for tests to finish.",
+ PendingRebuildHoldReason.PostBuildCooldown =>
+ $"Rebuild queued — post-build cooldown; {pendingFileCount} file(s) arrived{files}.",
+ PendingRebuildHoldReason.StartupDeferred =>
+ $"Startup build deferred — waiting {FormatDuration(liveDebounceMs)} for edits to settle{files}.",
+ PendingRebuildHoldReason.SupersededByNewEdits =>
+ $"Build cancelled — newer changes detected; rebuilding when edits settle{files}.",
+ _ => string.Empty
+ };
+ }
+
+ public static string FormatCountdownRemaining(DateTimeOffset? quietUntilUtc, DateTimeOffset utcNow)
+ {
+ if (quietUntilUtc is not { } quietUntil)
+ {
+ return string.Empty;
+ }
+
+ var remainingMs = (int)Math.Max(0, (quietUntil - utcNow).TotalMilliseconds);
+ if (remainingMs <= 0)
+ {
+ return "Rebuild starting…";
+ }
+
+ var remainingSeconds = (remainingMs + 999) / 1000;
+ return remainingSeconds == 1
+ ? "Rebuild in 1 s"
+ : $"Rebuild in {remainingSeconds} s";
+ }
+
+ public static string FormatPendingFileSample(IReadOnlyList? paths, int totalCount)
+ {
+ if (paths is not { Count: > 0 })
+ {
+ return string.Empty;
+ }
+
+ var shown = string.Join(", ", paths.Take(2));
+ if (totalCount > paths.Count)
+ {
+ return $" ({shown} +{totalCount - paths.Count} more)";
+ }
+
+ return $" ({shown})";
+ }
+
+ private static string FormatDuration(int milliseconds)
+ {
+ if (milliseconds < 1000)
+ {
+ return $"{milliseconds} ms";
+ }
+
+ var seconds = milliseconds / 1000.0;
+ return seconds < 60
+ ? $"{seconds:0.#} s"
+ : $"{(int)Math.Round(seconds / 60)} min";
+ }
+}
diff --git a/src/Core/Rules/HealthIssueCountsFormatter.cs b/src/Core/Rules/HealthIssueCountsFormatter.cs
index bfc73f2..3aa11d2 100644
--- a/src/Core/Rules/HealthIssueCountsFormatter.cs
+++ b/src/Core/Rules/HealthIssueCountsFormatter.cs
@@ -39,13 +39,19 @@ public static (int DisplayErrors, int DisplayWarnings) SelectPrimaryCounts(
int buildErrors,
int buildWarnings,
int runErrors,
- int runWarnings) =>
- state is ProjectLifecycleState.Crashed
+ int runWarnings)
+ {
+ var isRunPhase = state is ProjectLifecycleState.Crashed
or ProjectLifecycleState.Running
- or ProjectLifecycleState.Watching
- && (runErrors > 0 || runWarnings > 0 || state == ProjectLifecycleState.Crashed)
- ? (runErrors, runWarnings)
- : (buildErrors, buildWarnings);
+ or ProjectLifecycleState.Watching;
+
+ if (isRunPhase && (runErrors > 0 || runWarnings > 0 || state == ProjectLifecycleState.Crashed))
+ {
+ return (runErrors, runWarnings);
+ }
+
+ return (buildErrors, buildWarnings);
+ }
public static string FormatFailurePhase(ProjectLifecycleState state) =>
state switch
diff --git a/src/Core/Rules/LocalTrayIconRollupEvaluator.cs b/src/Core/Rules/LocalTrayIconRollupEvaluator.cs
index 57f65a4..5ecd6ff 100644
--- a/src/Core/Rules/LocalTrayIconRollupEvaluator.cs
+++ b/src/Core/Rules/LocalTrayIconRollupEvaluator.cs
@@ -16,7 +16,7 @@ public static MonitorHealth Rollup(IReadOnlyList activePr
return MonitorHealth.Red;
}
- if (activeProjects.Any(p => p.Health == MonitorHealth.Amber))
+ if (activeProjects.Any(p => p.Health == MonitorHealth.Amber || p.WarningCount > 0))
{
return MonitorHealth.Amber;
}
@@ -31,7 +31,9 @@ public static MonitorHealth Rollup(IReadOnlyList activePr
public static bool IsBuilding(IReadOnlyList activeProjects) =>
activeProjects.Any(p => p.IsRestarting
- || p.State is ProjectLifecycleState.Building or ProjectLifecycleState.Testing);
+ || p.State is ProjectLifecycleState.Building
+ or ProjectLifecycleState.Testing
+ or ProjectLifecycleState.WaitingForEdits);
public static bool IsWebReady(ProjectHealthSnapshot? headline) =>
headline is not null
@@ -50,7 +52,8 @@ headline is not null
.Where(p => p.State is ProjectLifecycleState.Building
or ProjectLifecycleState.Running
or ProjectLifecycleState.Watching
- or ProjectLifecycleState.Testing)
+ or ProjectLifecycleState.Testing
+ or ProjectLifecycleState.WaitingForEdits)
.OrderByDescending(p => p.LastChangedUtc)
.FirstOrDefault();
diff --git a/src/Core/Rules/ProjectHealthEvaluator.cs b/src/Core/Rules/ProjectHealthEvaluator.cs
index 30c545f..aa17c99 100644
--- a/src/Core/Rules/ProjectHealthEvaluator.cs
+++ b/src/Core/Rules/ProjectHealthEvaluator.cs
@@ -11,7 +11,10 @@ public static MonitorHealth Evaluate(
int warningCount,
bool inProgress = false)
{
- if (inProgress || state is ProjectLifecycleState.Building or ProjectLifecycleState.Testing)
+ if (inProgress
+ || state is ProjectLifecycleState.Building
+ or ProjectLifecycleState.Testing
+ or ProjectLifecycleState.WaitingForEdits)
{
if (errorCount > 0)
{
diff --git a/src/Core/Rules/StatusPanelBuildVisibilityEvaluator.cs b/src/Core/Rules/StatusPanelBuildVisibilityEvaluator.cs
new file mode 100644
index 0000000..8830140
--- /dev/null
+++ b/src/Core/Rules/StatusPanelBuildVisibilityEvaluator.cs
@@ -0,0 +1,121 @@
+using BuildMonitor.Core.Models;
+
+
+
+namespace BuildMonitor.Core.Rules;
+
+
+
+public static class StatusPanelBuildVisibilityEvaluator
+
+{
+
+ public static bool ShouldAutoShow(
+
+ bool showWhileBuildingEnabled,
+
+ ProjectLifecycleState previousState,
+
+ ProjectLifecycleState currentState) =>
+
+ showWhileBuildingEnabled
+
+ && currentState == ProjectLifecycleState.Building
+
+ && previousState != ProjectLifecycleState.Building;
+
+
+
+ public static bool ShouldAutoHide(
+
+ bool autoShownForBuild,
+
+ IEnumerable<(bool ShowWhileBuildingEnabled, ProjectLifecycleState State)> activeProjects) =>
+
+ autoShownForBuild
+
+ && !activeProjects.Any(p =>
+
+ p.ShowWhileBuildingEnabled && p.State == ProjectLifecycleState.Building);
+
+
+
+ public static bool ShouldAutoShowForEditGating(
+
+ bool suppressionEnabled,
+
+ bool isGatingActive,
+
+ bool wasGatingActive) =>
+
+ suppressionEnabled && isGatingActive && !wasGatingActive;
+
+
+
+ public static bool ShouldAutoHideForEditGating(
+
+ bool autoShownForEditGating,
+
+ bool isGatingActive) =>
+
+ autoShownForEditGating && !isGatingActive;
+
+ public static bool IsBusyWorkState(ProjectLifecycleState state) =>
+ state is ProjectLifecycleState.Building
+ or ProjectLifecycleState.WaitingForEdits
+ or ProjectLifecycleState.Testing;
+
+ public static bool ShouldAutoShowForBusyWork(
+ bool suppressionEnabled,
+ bool showStatusPanelWhileBuilding,
+ ProjectLifecycleState previousState,
+ ProjectLifecycleState currentState)
+ {
+ if (!suppressionEnabled
+ || !IsBusyWorkState(currentState)
+ || IsBusyWorkState(previousState))
+ {
+ return false;
+ }
+
+ return currentState switch
+ {
+ ProjectLifecycleState.Building or ProjectLifecycleState.Testing => showStatusPanelWhileBuilding,
+ _ => true
+ };
+ }
+
+ public static bool ShouldHideWhenBuildStartsWithoutShowSetting(
+ bool showStatusPanelWhileBuilding,
+ ProjectLifecycleState previousState,
+ ProjectLifecycleState currentState,
+ bool autoShownForEditGatingOnly) =>
+ !showStatusPanelWhileBuilding
+ && autoShownForEditGatingOnly
+ && currentState == ProjectLifecycleState.Building
+ && previousState != ProjectLifecycleState.Building;
+
+ public static bool ShouldAutoHideForBusyWork(
+ bool autoShown,
+ IEnumerable activeStates) =>
+ autoShown && !activeStates.Any(IsBusyWorkState);
+
+ public static bool IsAwaitingSiteReady(ProjectHealthSnapshot snapshot) =>
+ ShouldShowSiteStatus(snapshot)
+ && !snapshot.ListenUrlReady;
+
+ public static bool ShouldShowSiteStatus(ProjectHealthSnapshot snapshot) =>
+ snapshot.IsActive
+ && snapshot.SupportsAppRestart
+ && !string.IsNullOrWhiteSpace(snapshot.ListenUrl)
+ && (snapshot.IsRestarting
+ || snapshot.State is ProjectLifecycleState.Running or ProjectLifecycleState.Watching);
+
+ public static bool HasSiteLaunchConfigured(ProjectHealthSnapshot snapshot) =>
+ snapshot.SupportsAppRestart && !string.IsNullOrWhiteSpace(snapshot.ListenUrl);
+
+ public static bool ShouldKeepPanelVisibleUntilSiteReady(
+ IEnumerable activeProjects) =>
+ activeProjects.Any(IsAwaitingSiteReady);
+}
+
diff --git a/src/Core/Rules/TrayTooltipFormatter.cs b/src/Core/Rules/TrayTooltipFormatter.cs
index 82ebeda..52d14c9 100644
--- a/src/Core/Rules/TrayTooltipFormatter.cs
+++ b/src/Core/Rules/TrayTooltipFormatter.cs
@@ -1,71 +1,369 @@
using BuildMonitor.Core.Models;
+
+
namespace BuildMonitor.Core.Rules;
+
+
public static class TrayTooltipFormatter
+
{
+
+ /// Legacy shell tooltip limit — native tooltip is suppressed; full text is in the custom hover hint.
+
public const int MaxTooltipLength = 63;
+
+
public static string Format(
+
+ ProjectHealthSnapshot? headline,
+
+ MonitorHealth health,
+
+ bool isBuilding) =>
+
+ FormatMultiline(headline, health, isBuilding);
+
+
+
+ public static string FormatMultiline(
+
ProjectHealthSnapshot? headline,
+
MonitorHealth health,
+
bool isBuilding)
+
{
+
if (isBuilding)
+
{
- var name = headline?.DisplayName ?? "project";
- return Truncate($"Building — {name}");
+
+ var lines = new List();
+
+ if (headline is not null)
+
+ {
+
+ lines.Add($"Building — {headline.DisplayName}");
+
+ AppendIssueCountLine(lines, headline.ErrorCount, headline.WarningCount, headline.IssueCountsText);
+
+ }
+
+ else
+
+ {
+
+ lines.Add("Building…");
+
+ }
+
+
+
+ return JoinLines(lines);
+
}
+
+
if (headline is null)
+
{
+
return DescribeHealthTooltip(health);
+
}
- if (headline.Health == MonitorHealth.Red)
+
+
+ var result = new List
+
{
- var phase = string.IsNullOrWhiteSpace(headline.FailurePhase)
- ? "Failed"
- : headline.FailurePhase;
- if (!string.IsNullOrWhiteSpace(headline.LastErrorPreview))
- {
- return Truncate($"{headline.DisplayName} — {phase}: {headline.LastErrorPreview}");
- }
- return Truncate($"{headline.DisplayName} — {phase}");
+ $"{headline.DisplayName} — {BuildStatusLine(headline)}"
+
+ };
+
+ AppendIssueCountLine(result, headline.ErrorCount, headline.WarningCount, headline.IssueCountsText);
+
+
+
+ if (!string.IsNullOrWhiteSpace(headline.LastErrorPreview))
+
+ {
+
+ result.Add(headline.LastErrorPreview.Trim());
+
}
- if (headline.Health == MonitorHealth.Amber)
+ else if (headline.ListenUrlReady && !string.IsNullOrWhiteSpace(headline.ListenUrl))
+
{
- return Truncate($"{headline.DisplayName} — Warnings");
+
+ result.Add(headline.ListenUrl.Trim());
+
}
- if (headline.ListenUrlReady && !string.IsNullOrWhiteSpace(headline.ListenUrl))
+
+
+ return JoinLines(result);
+
+ }
+
+
+
+ public static string FormatMultiline(
+
+ IReadOnlyList activeProjects,
+
+ MonitorHealth rollupHealth,
+
+ bool isBuilding)
+
+ {
+
+ var active = activeProjects.Where(p => p.IsActive).ToList();
+
+ if (active.Count == 0)
+
+ {
+
+ return DescribeHealthTooltip(rollupHealth);
+
+ }
+
+
+
+ if (active.Count == 1)
+
{
- return Truncate($"{headline.DisplayName} — Site up · {headline.ListenUrl}");
+
+ return FormatMultiline(active[0], rollupHealth, isBuilding);
+
}
- return Truncate($"{headline.DisplayName} — OK");
+
+
+ var blocks = active
+
+ .OrderBy(p => p.DisplayName, StringComparer.OrdinalIgnoreCase)
+
+ .Select(p => FormatMultiline(
+
+ p,
+
+ p.Health,
+
+ p.State is ProjectLifecycleState.Building or ProjectLifecycleState.Testing))
+
+ .Where(block => !string.IsNullOrWhiteSpace(block));
+
+
+
+ return JoinBlocks(blocks);
+
}
+
+
+ public static string FormatShort(
+
+ ProjectHealthSnapshot? headline,
+
+ MonitorHealth health,
+
+ bool isBuilding) =>
+
+ string.Empty;
+
+
+
+ public static string FormatCompactIssueCounts(int errorCount, int warningCount)
+
+ {
+
+ if (errorCount <= 0 && warningCount <= 0)
+
+ {
+
+ return string.Empty;
+
+ }
+
+
+
+ if (errorCount > 0 && warningCount > 0)
+
+ {
+
+ return $" · {errorCount}e/{warningCount}w";
+
+ }
+
+
+
+ return errorCount > 0
+
+ ? $" · {errorCount} err"
+
+ : $" · {warningCount} warn";
+
+ }
+
+
+
public static string Truncate(string text, int maxLength = MaxTooltipLength) =>
+
text.Length <= maxLength ? text : text[..(maxLength - 1)] + "…";
+
+
public static string DescribeHealth(MonitorHealth health) =>
+
health switch
+
{
+
MonitorHealth.Green => "OK",
+
MonitorHealth.Amber => "Warnings",
+
MonitorHealth.Red => "Errors",
+
_ => "Unknown"
+
};
+
+
public static string DescribeHealthTooltip(MonitorHealth health) =>
+
health switch
+
{
+
MonitorHealth.Green => "Build monitor - Success",
+
MonitorHealth.Amber => "Build monitor - Warnings",
+
MonitorHealth.Red => "Build monitor - Failed",
+
_ => "Build Monitor"
+
};
+
+
+
+ private static string BuildStatusLine(ProjectHealthSnapshot headline)
+
+ {
+
+ if (headline.Health == MonitorHealth.Red)
+
+ {
+
+ return string.IsNullOrWhiteSpace(headline.FailurePhase)
+
+ ? "Failed"
+
+ : headline.FailurePhase;
+
+ }
+
+
+
+ if (headline.Health == MonitorHealth.Amber)
+
+ {
+
+ return "Warnings";
+
+ }
+
+
+
+ if (headline.ListenUrlReady && !string.IsNullOrWhiteSpace(headline.ListenUrl))
+
+ {
+
+ return "Site up";
+
+ }
+
+
+
+ return "OK";
+
+ }
+
+
+
+ private static void AppendIssueCountLine(
+
+ List lines,
+
+ int errorCount,
+
+ int warningCount,
+
+ string? issueCountsText)
+
+ {
+
+ if (!string.IsNullOrWhiteSpace(issueCountsText))
+
+ {
+
+ lines.Add(issueCountsText);
+
+ return;
+
+ }
+
+
+
+ if (errorCount <= 0 && warningCount <= 0)
+
+ {
+
+ return;
+
+ }
+
+
+
+ lines.Add(errorCount > 0 && warningCount > 0
+
+ ? $"{errorCount} errors · {warningCount} warnings"
+
+ : errorCount > 0
+
+ ? $"{errorCount} errors"
+
+ : $"{warningCount} warnings");
+
+ }
+
+
+
+ private static string JoinLines(IEnumerable lines) =>
+
+ string.Join(
+
+ Environment.NewLine,
+
+ lines.Where(line => !string.IsNullOrWhiteSpace(line)));
+
+
+
+ private static string JoinBlocks(IEnumerable blocks) =>
+
+ string.Join($"{Environment.NewLine}{Environment.NewLine}", blocks);
+
}
+
+
diff --git a/src/Core/Settings/LocalAppSettings.cs b/src/Core/Settings/LocalAppSettings.cs
index 7da39bb..827aaf8 100644
--- a/src/Core/Settings/LocalAppSettings.cs
+++ b/src/Core/Settings/LocalAppSettings.cs
@@ -1,3 +1,4 @@
+using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using BuildMonitor.Core.Models;
@@ -6,7 +7,7 @@ namespace BuildMonitor.Core.Settings;
public sealed class AppSettings
{
- public int SchemaVersion { get; set; } = 10;
+ public int SchemaVersion { get; set; } = 13;
public List Projects { get; set; } = [];
public GlobalMonitorSettings Monitor { get; set; } = new();
public AppBehaviorSettings AppBehavior { get; set; } = new();
@@ -125,6 +126,10 @@ public sealed class ProjectRunOptions
/// Path segments ignored by file watcher (semicolon-separated). Default includes IDE folders.
public string WatchExcludeSegments { get; set; } =
".cursor;agent-transcripts;terminals;mcps;.specstory;plans;.idea;.vscode";
+ /// When to open the log viewer automatically after builds or tests.
+ public AutoOpenLogMode AutoOpenLog { get; set; } = AutoOpenLogMode.Never;
+ /// When true, open the hover status panel when a build starts and hide it when the build finishes.
+ public bool ShowStatusPanelWhileBuilding { get; set; }
}
public enum FileChangeDebounceMode
@@ -142,12 +147,19 @@ public sealed class GlobalMonitorSettings
/// When watch mode is enabled, batch file changes and rebuild once edits settle (instead of dotnet watch per-save rebuilds).
public bool CoalesceWatchRebuilds { get; set; } = true;
public int MaxConcurrentActiveProjects { get; set; } = 3;
+ [Obsolete("Migrated to per-project ProjectRunOptions.AutoOpenLog (schema v11).")]
public bool AutoOpenLogOnFailure { get; set; }
/// Open the Build Monitor Health window when the app starts.
public bool AutoOpenBuildMonitorHealthOnStartup { get; set; } = true;
public bool PlaySoundOnBuildError { get; set; } = true;
public bool PlaySoundOnBuildSuccess { get; set; }
public int MaxLogDisplayBytes { get; set; } = 2_097_152;
+ /// Wait for edit quiet before the first startup build.
+ public bool DeferStartupBuildUntilQuiet { get; set; } = true;
+ /// Cancel startup/file-change builds when newer saves arrive.
+ public bool CancelSupersededBuilds { get; set; } = true;
+ /// Treat agent-transcripts / .cursor writes as active editing.
+ public bool UseAgentTranscriptActivity { get; set; } = true;
}
public enum AppThemePreference
diff --git a/src/Infrastructure/Diagnostics/BuildIntelligenceSnapshot.cs b/src/Infrastructure/Diagnostics/BuildIntelligenceSnapshot.cs
index 0a721ed..5b378f3 100644
--- a/src/Infrastructure/Diagnostics/BuildIntelligenceSnapshot.cs
+++ b/src/Infrastructure/Diagnostics/BuildIntelligenceSnapshot.cs
@@ -1,3 +1,5 @@
+using BuildMonitor.Core.Models;
+using BuildMonitor.Core.Rules;
using BuildMonitor.Core.Settings;
using BuildMonitor.Infrastructure.LocalBuild;
@@ -23,8 +25,17 @@ public sealed record BuildIntelligenceSnapshot(
string? LastFileChangeLocal,
bool PendingFileChangeRebuild,
IReadOnlyList RecentBurstSamplesMs,
- DateTimeOffset? RebuildQuietUntilUtc)
+ IReadOnlyList RecentBuildDurationSamplesMs,
+ int TodayTriggerCount,
+ DateTimeOffset? RebuildQuietUntilUtc,
+ PendingRebuildHoldReason HoldReason = PendingRebuildHoldReason.None,
+ int PendingRebuildFileCount = 0,
+ IReadOnlyList? PendingRebuildSamplePaths = null,
+ int RebuildTimerResetCount = 0)
{
+ /// Why the rebuild wait was deferred or the quiet timer restarted.
+ public string NextRebuildReasonText => BuildNextRebuildReasonText();
+
/// One-line summary of what the monitor is doing for file-change rebuilds.
public string StatusHeadline => BuildStatusHeadline();
@@ -59,8 +70,44 @@ public sealed record BuildIntelligenceSnapshot(
public bool HasBurstChartData => RecentBurstSamplesMs.Count > 0;
+ public bool HasBuildDurationChartData => RecentBuildDurationSamplesMs.Count > 0;
+
public IReadOnlyList BurstBars => BuildBurstBars();
+ public IReadOnlyList BuildDurationBars => BuildDurationBarVisuals();
+
+ public string CountdownRemainingText => BuildCountdownRemainingText();
+
+ public string TodayTriggersText => TodayTriggerCount switch
+ {
+ 0 => "No triggers logged today.",
+ 1 => "1 trigger today.",
+ _ => $"{TodayTriggerCount} triggers today."
+ };
+
+ public string AverageRecentBuildDurationText
+ {
+ get
+ {
+ if (RecentBuildDurationSamplesMs.Count == 0)
+ {
+ return "No build times yet.";
+ }
+
+ var avg = (int)Math.Round(RecentBuildDurationSamplesMs.Average());
+ return $"Typical build ~{FormatDuration(avg)}";
+ }
+ }
+
+ public string RecentBuildDurationLabel =>
+ RecentBuildDurationSamplesMs.Count == 0
+ ? "—"
+ : FormatDuration((int)Math.Round(RecentBuildDurationSamplesMs.Average()));
+
+ public string BuildDurationChartCaption => HasBuildDurationChartData
+ ? $"Newest on the right · {AverageRecentBuildDurationText}"
+ : "Build times appear after file-triggered rebuilds complete.";
+
public string StatusChipText => BuildStatusChipText();
public bool ShowStatusChip => StatusChipText.Length > 0;
@@ -105,6 +152,8 @@ public static BuildIntelligenceSnapshot FromStoredStats(
null,
false,
TakeRecentBurstSamples(stats.BurstSamplesMs),
+ TakeRecentBuildDurationSamples(stats.BuildSamplesMs),
+ 0,
null);
}
@@ -120,7 +169,12 @@ internal static BuildIntelligenceSnapshot Create(
bool coalesceWatchRebuilds,
DateTimeOffset? lastMeaningfulFileChangeUtc,
bool pendingFileChangeRebuild,
- DateTimeOffset? rebuildQuietUntilUtc)
+ DateTimeOffset? rebuildQuietUntilUtc,
+ int todayTriggerCount = 0,
+ PendingRebuildHoldReason holdReason = PendingRebuildHoldReason.None,
+ int pendingRebuildFileCount = 0,
+ IReadOnlyList? pendingRebuildSamplePaths = null,
+ int rebuildTimerResetCount = 0)
{
var agentBackoff = recentFileChangeBuildsIn90s >= 1;
return new BuildIntelligenceSnapshot(
@@ -143,7 +197,24 @@ internal static BuildIntelligenceSnapshot Create(
FormatLastFileChangeLocal(lastMeaningfulFileChangeUtc),
pendingFileChangeRebuild,
TakeRecentBurstSamples(stats.BurstSamplesMs),
- rebuildQuietUntilUtc);
+ TakeRecentBuildDurationSamples(stats.BuildSamplesMs),
+ todayTriggerCount,
+ rebuildQuietUntilUtc,
+ holdReason,
+ pendingRebuildFileCount,
+ pendingRebuildSamplePaths ?? [],
+ rebuildTimerResetCount);
+ }
+
+ private string BuildCountdownRemainingText()
+ {
+ if (!ShowRebuildCountdown || RebuildQuietUntilUtc is not { } quietUntil)
+ {
+ return string.Empty;
+ }
+
+ var remainingMs = (int)Math.Max(0, (quietUntil - DateTimeOffset.UtcNow).TotalMilliseconds);
+ return remainingMs > 0 ? FormatDuration(remainingMs) : "now";
}
private string BuildNextRebuildText()
@@ -169,6 +240,25 @@ private string BuildNextRebuildText()
: "Rebuild starting…";
}
+ private string BuildNextRebuildReasonText()
+ {
+ if (!PendingFileChangeRebuild || HoldReason == PendingRebuildHoldReason.None)
+ {
+ return string.Empty;
+ }
+
+ return EditGatingDetailFormatter.FormatHoldReason(
+ HoldReason,
+ PendingRebuildFileCount,
+ PendingRebuildSamplePaths,
+ RebuildTimerResetCount,
+ LiveEffectiveDebounceMs,
+ AgentSessionBackoff);
+ }
+
+ internal static string FormatPendingFileSample(IReadOnlyList? paths, int totalCount) =>
+ EditGatingDetailFormatter.FormatPendingFileSample(paths, totalCount);
+
private double BuildRebuildCountdownPercent()
{
if (!ShowRebuildCountdown || LiveEffectiveDebounceMs <= 0)
@@ -302,7 +392,10 @@ private string BuildStatusChipText()
if (PendingFileChangeRebuild)
{
- return "Rebuild queued";
+ var remaining = BuildCountdownRemainingText();
+ return string.IsNullOrWhiteSpace(remaining) || remaining == "now"
+ ? "Rebuild queued"
+ : $"Rebuild queued · ~{remaining}";
}
if (AgentSessionBackoff && LiveEffectiveDebounceMs > BaseEffectiveDebounceMs)
@@ -334,11 +427,32 @@ private IReadOnlyList BuildBurstBars()
.ToList();
}
+ private IReadOnlyList BuildDurationBarVisuals()
+ {
+ if (RecentBuildDurationSamplesMs.Count == 0)
+ {
+ return [];
+ }
+
+ var max = Math.Max(
+ RecentBuildDurationSamplesMs.Max(),
+ AdaptiveFileChangeDebounce.MinDebounceMs);
+
+ return RecentBuildDurationSamplesMs
+ .Select(ms => new BurstBarVisual(FormatDuration(ms), Math.Max(0.12, ms / (double)max)))
+ .ToList();
+ }
+
private static IReadOnlyList TakeRecentBurstSamples(IReadOnlyList burstSamplesMs) =>
burstSamplesMs.Count == 0
? []
: burstSamplesMs.TakeLast(5).ToList();
+ private static IReadOnlyList TakeRecentBuildDurationSamples(IReadOnlyList buildSamplesMs) =>
+ buildSamplesMs.Count == 0
+ ? []
+ : buildSamplesMs.TakeLast(5).ToList();
+
private static string? FormatLastFileChangeLocal(DateTimeOffset? utc) =>
utc is { } t && t != DateTimeOffset.MinValue
? t.ToLocalTime().ToString("t")
diff --git a/src/Infrastructure/Diagnostics/BuildTriggerDetailFormatter.cs b/src/Infrastructure/Diagnostics/BuildTriggerDetailFormatter.cs
new file mode 100644
index 0000000..0aaeae2
--- /dev/null
+++ b/src/Infrastructure/Diagnostics/BuildTriggerDetailFormatter.cs
@@ -0,0 +1,46 @@
+using BuildMonitor.Core.Models;
+
+namespace BuildMonitor.Infrastructure.Diagnostics;
+
+public static class BuildTriggerDetailFormatter
+{
+ public static string FormatImmediateDebounce(int debounceMs) =>
+ $"Quiet period {debounceMs} ms (saved immediately)";
+
+ public static string FormatCoalescedBuild(
+ int debounceMs,
+ PendingRebuildHoldReason holdReason,
+ int timerResetCount)
+ {
+ var parts = new List { $"Quiet period {debounceMs} ms" };
+
+ if (holdReason != PendingRebuildHoldReason.None)
+ {
+ parts.Add(DescribeHoldReason(holdReason, timerResetCount));
+ }
+
+ return string.Join(" · ", parts);
+ }
+
+ private static string DescribeHoldReason(PendingRebuildHoldReason reason, int timerResetCount) =>
+ reason switch
+ {
+ PendingRebuildHoldReason.EditsStillArriving when timerResetCount > 1 =>
+ $"timer reset {timerResetCount}× while edits continued",
+ PendingRebuildHoldReason.EditsStillArriving =>
+ "timer reset while edits continued",
+ PendingRebuildHoldReason.EditsSettling =>
+ "waiting for edits to settle",
+ PendingRebuildHoldReason.BuildInProgress =>
+ "held until build finished",
+ PendingRebuildHoldReason.TestsInProgress =>
+ "held until tests finished",
+ PendingRebuildHoldReason.PostBuildCooldown =>
+ "held for post-build cooldown",
+ PendingRebuildHoldReason.StartupDeferred =>
+ "startup deferred until edits settle",
+ PendingRebuildHoldReason.SupersededByNewEdits =>
+ "build superseded by newer edits",
+ _ => string.Empty
+ };
+}
diff --git a/src/Infrastructure/LocalBuild/AgentActivityWatcher.cs b/src/Infrastructure/LocalBuild/AgentActivityWatcher.cs
new file mode 100644
index 0000000..f3bf1a1
--- /dev/null
+++ b/src/Infrastructure/LocalBuild/AgentActivityWatcher.cs
@@ -0,0 +1,81 @@
+namespace BuildMonitor.Infrastructure.LocalBuild;
+
+///
+/// Watches agent tooling folders for activity signals only — never triggers rebuilds.
+///
+public sealed class AgentActivityWatcher : IDisposable
+{
+ private static readonly string[] ActivitySegments =
+ [
+ "agent-transcripts",
+ ".cursor"
+ ];
+
+ private readonly FileSystemWatcher watcher;
+ private readonly object sync = new();
+ private DateTimeOffset lastActivityUtc = DateTimeOffset.MinValue;
+ private bool isSuspended;
+
+ public AgentActivityWatcher(string rootPath)
+ {
+ watcher = new FileSystemWatcher(rootPath)
+ {
+ IncludeSubdirectories = true,
+ NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size
+ };
+
+ watcher.Changed += OnFsEvent;
+ watcher.Created += OnFsEvent;
+ watcher.Renamed += OnFsEvent;
+ watcher.EnableRaisingEvents = true;
+ }
+
+ public DateTimeOffset LastActivityUtc
+ {
+ get
+ {
+ lock (sync)
+ {
+ return lastActivityUtc;
+ }
+ }
+ }
+
+ public void Suspend() => isSuspended = true;
+
+ public void Resume() => isSuspended = false;
+
+ private void OnFsEvent(object sender, FileSystemEventArgs e)
+ {
+ if (isSuspended || !IsAgentActivityPath(e.FullPath))
+ {
+ return;
+ }
+
+ lock (sync)
+ {
+ lastActivityUtc = DateTimeOffset.UtcNow;
+ }
+ }
+
+ internal static bool IsAgentActivityPath(string fullPath)
+ {
+ if (string.IsNullOrWhiteSpace(fullPath))
+ {
+ return false;
+ }
+
+ var parts = fullPath.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries);
+ foreach (var segment in ActivitySegments)
+ {
+ if (parts.Any(p => string.Equals(p, segment, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public void Dispose() => watcher.Dispose();
+}
diff --git a/src/Infrastructure/LocalBuild/BuildIssueCountResolver.cs b/src/Infrastructure/LocalBuild/BuildIssueCountResolver.cs
new file mode 100644
index 0000000..4db4a6c
--- /dev/null
+++ b/src/Infrastructure/LocalBuild/BuildIssueCountResolver.cs
@@ -0,0 +1,102 @@
+using System.Text.Json;
+
+namespace BuildMonitor.Infrastructure.LocalBuild;
+
+public static class BuildIssueCountResolver
+{
+ public static (int Errors, int Warnings) Resolve(string buildOutput, string? existingLogPath)
+ {
+ if (!IncrementalBuildDetector.WasCompileSkipped(buildOutput))
+ {
+ return (
+ BuildLogParser.ParseErrorCount(buildOutput),
+ BuildLogParser.ParseWarningCount(buildOutput));
+ }
+
+ if (!string.IsNullOrWhiteSpace(existingLogPath))
+ {
+ var prevPath = existingLogPath + ".prev";
+ if (File.Exists(prevPath))
+ {
+ var fromPrev = ReadCountsFromLogText(File.ReadAllText(prevPath));
+ if (fromPrev.Errors > 0 || fromPrev.Warnings > 0)
+ {
+ return fromPrev;
+ }
+ }
+
+ var fromMeta = TryReadMetadataCounts(existingLogPath);
+ if (fromMeta.Errors > 0 || fromMeta.Warnings > 0)
+ {
+ return fromMeta;
+ }
+ }
+
+ var fromNote = BuildLogParser.TryParseIncrementalHealthNote(buildOutput);
+ if (fromNote.Errors > 0 || fromNote.Warnings > 0)
+ {
+ return fromNote;
+ }
+
+ return (
+ BuildLogParser.ParseErrorCount(buildOutput),
+ BuildLogParser.ParseWarningCount(buildOutput));
+ }
+
+ private static (int Errors, int Warnings) ReadCountsFromLogText(string logText)
+ {
+ var errors = BuildLogParser.ParseErrorCount(logText);
+ var warnings = BuildLogParser.ParseWarningCount(logText);
+ if (errors > 0 || warnings > 0)
+ {
+ return (errors, warnings);
+ }
+
+ return BuildLogParser.TryParseIncrementalHealthNote(logText);
+ }
+
+ private static (int Errors, int Warnings) TryReadMetadataCounts(string logFilePath)
+ {
+ var metaPath = ResolveMetadataPath(logFilePath);
+ if (metaPath is null || !File.Exists(metaPath))
+ {
+ return (0, 0);
+ }
+
+ try
+ {
+ var json = File.ReadAllText(metaPath);
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+ var errors = root.TryGetProperty("ErrorCount", out var errorNode)
+ ? errorNode.GetInt32()
+ : 0;
+ var warnings = root.TryGetProperty("WarningCount", out var warningNode)
+ ? warningNode.GetInt32()
+ : 0;
+ return (errors, warnings);
+ }
+ catch
+ {
+ return (0, 0);
+ }
+ }
+
+ internal static string? ResolveMetadataPath(string? logFilePath)
+ {
+ if (string.IsNullOrWhiteSpace(logFilePath))
+ {
+ return null;
+ }
+
+ var directory = Path.GetDirectoryName(logFilePath) ?? string.Empty;
+ var fileName = Path.GetFileName(logFilePath);
+ if (fileName.EndsWith(".log.prev", StringComparison.OrdinalIgnoreCase))
+ {
+ fileName = fileName[..^5];
+ }
+
+ var stem = Path.GetFileNameWithoutExtension(fileName);
+ return Path.Combine(directory, stem + ".meta.json");
+ }
+}
diff --git a/src/Infrastructure/LocalBuild/BuildLogMetadataDto.cs b/src/Infrastructure/LocalBuild/BuildLogMetadataDto.cs
index 9de8974..981263c 100644
--- a/src/Infrastructure/LocalBuild/BuildLogMetadataDto.cs
+++ b/src/Infrastructure/LocalBuild/BuildLogMetadataDto.cs
@@ -12,6 +12,7 @@ internal sealed class BuildLogMetadataDto
public DateTimeOffset FinishedAtUtc { get; set; }
public string LogFilePath { get; set; } = string.Empty;
public int ErrorCount { get; set; }
+ public int WarningCount { get; set; }
public List ErrorLines { get; set; } = [];
public BuildLogRecord ToRecord() => new(
@@ -23,5 +24,6 @@ internal sealed class BuildLogMetadataDto
FinishedAtUtc,
LogFilePath,
ErrorCount,
- ErrorLines);
+ ErrorLines,
+ WarningCount);
}
diff --git a/src/Infrastructure/LocalBuild/BuildLogParser.cs b/src/Infrastructure/LocalBuild/BuildLogParser.cs
index ec2c2ee..1a48c02 100644
--- a/src/Infrastructure/LocalBuild/BuildLogParser.cs
+++ b/src/Infrastructure/LocalBuild/BuildLogParser.cs
@@ -26,6 +26,14 @@ public static class BuildLogParser
@"\berror\s+(CS|MSB|NU|BC|SA|IDE|CA|FS|VB|AD|SYSLIB|NETSDK|CS)\d+\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex IncrementalHealthWarningRegex = new(
+ @"(\d+)\s+warning\(s\)",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ private static readonly Regex IncrementalHealthErrorRegex = new(
+ @"(\d+)\s+error\(s\)",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
private static readonly Regex CompilerWarningRegex = new(
@"\bwarning\s+(CS|MSB|NU|BC|SA|IDE|CA|FS|VB|AD|SYSLIB|NETSDK|CS)\d+\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
@@ -92,43 +100,126 @@ public static (int ErrorCount, IReadOnlyList ErrorLines) ParseErrors(str
return (ParseErrorCount(logText), errors);
}
+ public static int ParseWarningCount(string logText)
+ {
+ var segment = ExtractLatestBuildResultSegment(logText);
+ var summary = ParseBuildSummaryCount(segment, warnings: true);
+ if (summary > 0)
+ {
+ return summary;
+ }
+
+ var fromIssues = ParseIssues(segment).Count(i => !i.IsError);
+ if (fromIssues > 0)
+ {
+ return fromIssues;
+ }
+
+ var fromNote = TryParseIncrementalHealthNote(logText);
+ return fromNote.Warnings > 0 ? fromNote.Warnings : summary >= 0 ? summary : 0;
+ }
+
public static int ParseErrorCount(string logText)
{
var segment = ExtractLatestBuildResultSegment(logText);
var summary = ParseBuildSummaryCount(segment, warnings: false);
- if (summary >= 0)
+ if (summary > 0)
{
return summary;
}
- return ParseIssues(segment).Count(i => i.IsError);
+ var fromIssues = ParseIssues(segment)
+ .Where(i => i.IsError)
+ .Select(i => i.Text)
+ .Distinct(StringComparer.Ordinal)
+ .Count();
+ if (fromIssues > 0)
+ {
+ return fromIssues;
+ }
+
+ var fromNote = TryParseIncrementalHealthNote(logText);
+ return fromNote.Errors > 0 ? fromNote.Errors : summary >= 0 ? summary : 0;
}
- public static bool IsOutputLockError(string logText)
+ /// Reads resolved tray-health counts from a BuildMonitor incremental-build note line.
+ public static (int Errors, int Warnings) TryParseIncrementalHealthNote(string logText)
{
if (string.IsNullOrWhiteSpace(logText))
{
- return false;
+ return (0, 0);
}
- return OutputLockMarkers.Any(marker =>
- logText.Contains(marker, StringComparison.OrdinalIgnoreCase));
+ var marker = "Tray health uses";
+ var index = logText.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (index < 0)
+ {
+ return (0, 0);
+ }
+
+ var note = logText[index..];
+ var warnings = ParseLastMatchCount(note, IncrementalHealthWarningRegex);
+ var errors = ParseLastMatchCount(note, IncrementalHealthErrorRegex);
+ return (Math.Max(0, errors), Math.Max(0, warnings));
}
- public static int ParseWarningCount(string logText)
+ ///
+ /// Returns compiler issues from the log, or from the previous on-disk build log when this build was incremental.
+ ///
+ public static IReadOnlyList ResolveBuildIssues(string logText, string? logFilePath)
{
- var segment = ExtractLatestBuildResultSegment(logText);
- var summary = ParseBuildSummaryCount(segment, warnings: true);
- if (summary >= 0)
+ var issues = ParseIssues(logText);
+ if (issues.Count > 0 || !IncrementalBuildDetector.WasCompileSkipped(logText))
{
- return summary;
+ return issues;
}
- return ParseIssues(segment).Count(i => !i.IsError);
+ if (string.IsNullOrWhiteSpace(logFilePath))
+ {
+ return issues;
+ }
+
+ var prevPath = logFilePath + ".prev";
+ if (!File.Exists(prevPath))
+ {
+ return issues;
+ }
+
+ var previousIssues = ParseIssues(File.ReadAllText(prevPath));
+ return previousIssues.Count > 0 ? previousIssues : issues;
+ }
+
+ internal static IEnumerable CandidatePreviousLogPaths(string? logFilePath)
+ {
+ if (string.IsNullOrWhiteSpace(logFilePath))
+ {
+ yield break;
+ }
+
+ yield return logFilePath;
+
+ var previous = logFilePath + ".prev";
+ if (!string.Equals(previous, logFilePath, StringComparison.OrdinalIgnoreCase))
+ {
+ yield return previous;
+ }
+ }
+
+ public static bool IsOutputLockError(string logText)
+ {
+ if (string.IsNullOrWhiteSpace(logText))
+ {
+ return false;
+ }
+
+ return OutputLockMarkers.Any(marker =>
+ logText.Contains(marker, StringComparison.OrdinalIgnoreCase));
}
///
/// Limits parsing to the most recent MSBuild result in accumulated dotnet watch output.
+ /// When BuildMonitor banners are present, uses the latest build block so errors above
+ /// Build FAILED are not dropped from count/issue parsing.
///
internal static string ExtractLatestBuildResultSegment(string logText)
{
@@ -138,11 +229,46 @@ internal static string ExtractLatestBuildResultSegment(string logText)
}
var normalized = logText.Replace("\r\n", "\n");
+ const string buildBanner = "[BuildMonitor] ===== Build #";
+ var lastBanner = normalized.LastIndexOf(buildBanner, StringComparison.Ordinal);
+ if (lastBanner >= 0)
+ {
+ return normalized[lastBanner..];
+ }
+
var lastSucceeded = normalized.LastIndexOf("Build succeeded", StringComparison.OrdinalIgnoreCase);
var lastFailed = normalized.LastIndexOf("Build FAILED", StringComparison.OrdinalIgnoreCase);
var lastFailedSentence = normalized.LastIndexOf("The build failed", StringComparison.OrdinalIgnoreCase);
var start = Math.Max(Math.Max(lastSucceeded, lastFailed), lastFailedSentence);
- return start < 0 ? normalized : normalized[start..];
+ if (start < 0)
+ {
+ return normalized;
+ }
+
+ if (lastFailed >= 0 && start == lastFailed)
+ {
+ // MSBuild prints diagnostics before the Build FAILED line — include them.
+ var lineStart = lastFailed > 0
+ ? normalized.LastIndexOf('\n', lastFailed - 1)
+ : -1;
+ var searchFrom = lineStart >= 0 ? lineStart + 1 : 0;
+ var previousFailedEnd = lastFailed > 0 ? lastFailed - 1 : 0;
+ var previousBoundary = Math.Max(
+ normalized.LastIndexOf(buildBanner, lastFailed, StringComparison.Ordinal),
+ Math.Max(
+ normalized.LastIndexOf("Build succeeded", lastFailed, StringComparison.OrdinalIgnoreCase),
+ lastFailed > 0
+ ? normalized.LastIndexOf("Build FAILED", previousFailedEnd, StringComparison.OrdinalIgnoreCase)
+ : -1));
+ if (previousBoundary >= 0 && previousBoundary < lastFailed)
+ {
+ searchFrom = previousBoundary;
+ }
+
+ return normalized[searchFrom..];
+ }
+
+ return normalized[start..];
}
///
@@ -272,11 +398,19 @@ private static int ParseLastMatchCount(string text, Regex regex)
return int.Parse(matches[^1].Groups[1].Value);
}
- private static bool IsSummaryLine(string line) =>
- ClassicErrorSummaryRegex.IsMatch(line)
- || ClassicWarningSummaryRegex.IsMatch(line)
- || TerminalErrorCountRegex.IsMatch(line)
- || TerminalWarningCountRegex.IsMatch(line);
+ private static bool IsSummaryLine(string line)
+ {
+ var trimmed = line.TrimEnd('.', ' ');
+ if (trimmed.Equals("Build FAILED", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return ClassicErrorSummaryRegex.IsMatch(line)
+ || ClassicWarningSummaryRegex.IsMatch(line)
+ || TerminalErrorCountRegex.IsMatch(line)
+ || TerminalWarningCountRegex.IsMatch(line);
+ }
private static bool IsErrorLine(string line) =>
ErrorMarkers.Any(marker => line.Contains(marker, StringComparison.OrdinalIgnoreCase))
diff --git a/src/Infrastructure/LocalBuild/BuildLogStore.cs b/src/Infrastructure/LocalBuild/BuildLogStore.cs
index 4991f38..7ec072d 100644
--- a/src/Infrastructure/LocalBuild/BuildLogStore.cs
+++ b/src/Infrastructure/LocalBuild/BuildLogStore.cs
@@ -6,6 +6,19 @@ namespace BuildMonitor.Infrastructure.LocalBuild;
public sealed class BuildLogStore(string logsRootDirectory)
{
+ public string GetLogPath(string projectId, BuildLogKind kind)
+ {
+ var fileName = kind switch
+ {
+ BuildLogKind.Test => "last-test.log",
+ BuildLogKind.WatchCompile => "last-watch.log",
+ BuildLogKind.Run => "last-run.log",
+ _ => "last-build.log"
+ };
+
+ return Path.Combine(logsRootDirectory, projectId, fileName);
+ }
+
public async Task SaveAsync(
string projectId,
BuildLogKind kind,
@@ -35,7 +48,20 @@ public async Task SaveAsync(
await File.WriteAllTextAsync(logPath, logText, cancellationToken);
- var (errorCount, errorLines) = BuildLogParser.ParseErrors(logText);
+ var (resolvedErrors, resolvedWarnings) = BuildIssueCountResolver.Resolve(logText, logPath);
+ var (parsedErrors, errorLines) = BuildLogParser.ParseErrors(logText);
+ if (resolvedErrors == 0 && resolvedWarnings == 0
+ && IncrementalBuildDetector.WasCompileSkipped(logText)
+ && File.Exists(prevPath))
+ {
+ var prevText = await File.ReadAllTextAsync(prevPath, cancellationToken);
+ (parsedErrors, errorLines) = BuildLogParser.ParseErrors(prevText);
+ resolvedErrors = parsedErrors;
+ resolvedWarnings = BuildLogParser.ParseWarningCount(prevText);
+ }
+
+ var errorCount = Math.Max(parsedErrors, resolvedErrors);
+ var warningCount = Math.Max(BuildLogParser.ParseWarningCount(logText), resolvedWarnings);
var finishedAt = DateTimeOffset.UtcNow;
var record = new BuildLogRecord(
projectId,
@@ -46,7 +72,8 @@ public async Task SaveAsync(
finishedAt,
logPath,
errorCount,
- errorLines);
+ errorLines,
+ warningCount);
var metaPath = Path.Combine(projectDir, $"{Path.GetFileNameWithoutExtension(fileName)}.meta.json");
var dto = new BuildLogMetadataDto
@@ -59,6 +86,7 @@ public async Task SaveAsync(
FinishedAtUtc = record.FinishedAtUtc,
LogFilePath = record.LogFilePath,
ErrorCount = record.ErrorCount,
+ WarningCount = record.WarningCount,
ErrorLines = record.ErrorLines.ToList()
};
await File.WriteAllTextAsync(metaPath, JsonSerializer.Serialize(dto), cancellationToken);
@@ -129,3 +157,4 @@ public static string TruncateTailForDisplay(string text, int maxBytes)
return $"... (truncated, showing last {maxBytes} bytes)\n{tail}";
}
}
+
diff --git a/src/Infrastructure/LocalBuild/BuildMonitorLogBanner.cs b/src/Infrastructure/LocalBuild/BuildMonitorLogBanner.cs
index 7f6bedb..f02bf0c 100644
--- a/src/Infrastructure/LocalBuild/BuildMonitorLogBanner.cs
+++ b/src/Infrastructure/LocalBuild/BuildMonitorLogBanner.cs
@@ -14,6 +14,24 @@ public static string FormatFinished(int buildNumber, int exitCode)
return $"[BuildMonitor] ===== Build #{buildNumber} finished — {status} (exit {exitCode}) =====";
}
+ public static string FormatIncrementalNote(int errorCount, int warningCount)
+ {
+ var parts = new List();
+ if (errorCount > 0)
+ {
+ parts.Add($"{errorCount} error(s)");
+ }
+
+ if (warningCount > 0)
+ {
+ parts.Add($"{warningCount} warning(s)");
+ }
+
+ var counts = parts.Count > 0 ? string.Join(", ", parts) : "prior issue counts";
+ return $"[BuildMonitor] Incremental build — compiler skipped (outputs up-to-date). "
+ + $"Tray health uses {counts} from the previous full build log.";
+ }
+
public static string FormatTest(int testNumber, string reason, DateTimeOffset? timestamp = null)
{
var ts = (timestamp ?? DateTimeOffset.Now).ToString("yyyy-MM-dd HH:mm:ss");
diff --git a/src/Infrastructure/LocalBuild/DebouncedFileWatcher.cs b/src/Infrastructure/LocalBuild/DebouncedFileWatcher.cs
index 47a5815..5641541 100644
--- a/src/Infrastructure/LocalBuild/DebouncedFileWatcher.cs
+++ b/src/Infrastructure/LocalBuild/DebouncedFileWatcher.cs
@@ -14,6 +14,28 @@ public sealed class DebouncedFileWatcher : IDisposable
public bool IsSuspended { get; private set; }
+ public bool HasPendingChanges
+ {
+ get
+ {
+ lock (pendingPathsSync)
+ {
+ return pendingPaths.Count > 0 || debounceTimer.Enabled;
+ }
+ }
+ }
+
+ public DateTimeOffset? BurstStartedUtc
+ {
+ get
+ {
+ lock (pendingPathsSync)
+ {
+ return burstStartedUtc;
+ }
+ }
+ }
+
public DebouncedFileWatcher(string rootPath, int debounceMs, IEnumerable? extraIgnoreSegments = null)
{
ignoreSegments = new HashSet(
diff --git a/src/Infrastructure/LocalBuild/DotNetBuildArguments.cs b/src/Infrastructure/LocalBuild/DotNetBuildArguments.cs
new file mode 100644
index 0000000..128b194
--- /dev/null
+++ b/src/Infrastructure/LocalBuild/DotNetBuildArguments.cs
@@ -0,0 +1,17 @@
+namespace BuildMonitor.Infrastructure.LocalBuild;
+
+public static class DotNetBuildArguments
+{
+ public static bool RequiresFullRebuild(string? buildReason) =>
+ string.Equals(buildReason, "startup", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(buildReason, "manual rebuild", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(buildReason, "rebuild & restart", StringComparison.OrdinalIgnoreCase);
+
+ public static void ApplyFullRebuildFlag(IList arguments, bool forceFullRebuild)
+ {
+ if (forceFullRebuild && !arguments.Contains("--no-incremental", StringComparer.OrdinalIgnoreCase))
+ {
+ arguments.Add("--no-incremental");
+ }
+ }
+}
diff --git a/src/Infrastructure/LocalBuild/DotNetCliRunner.cs b/src/Infrastructure/LocalBuild/DotNetCliRunner.cs
index 3e9a097..b91dae2 100644
--- a/src/Infrastructure/LocalBuild/DotNetCliRunner.cs
+++ b/src/Infrastructure/LocalBuild/DotNetCliRunner.cs
@@ -7,10 +7,14 @@ public sealed record CliRunResult(
int ExitCode,
string Output,
TimeSpan Duration,
- string CommandLine);
+ string CommandLine,
+ bool WasCancelled = false);
public sealed class DotNetCliRunner
{
+ private readonly object processSync = new();
+ private Process? activeProcess;
+
public async Task RunAsync(
string workingDirectory,
IReadOnlyList arguments,
@@ -39,6 +43,11 @@ public async Task RunAsync(
DotNetProcessConfigurator.Apply(psi, dotnetArgs);
using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ lock (processSync)
+ {
+ activeProcess = process;
+ }
+
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
@@ -56,14 +65,67 @@ public async Task RunAsync(
}
};
+ using var registration = cancellationToken.Register(() => TryKillActiveProcess(process));
+
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
- await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ TryKillActiveProcess(process);
+ if (!process.HasExited)
+ {
+ try
+ {
+ await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Process may already be gone after kill.
+ }
+ }
+
+ var cancelledDuration = DateTimeOffset.UtcNow - started;
+ var cancelledText = BuildLogTextNormalizer.Normalize(
+ BuildLogParser.DeduplicateConsecutiveLines(output.ToString()));
+ return new CliRunResult(-1, cancelledText, cancelledDuration, commandLine, WasCancelled: true);
+ }
+ finally
+ {
+ lock (processSync)
+ {
+ if (ReferenceEquals(activeProcess, process))
+ {
+ activeProcess = null;
+ }
+ }
+ }
+
var duration = DateTimeOffset.UtcNow - started;
var text = BuildLogTextNormalizer.Normalize(
BuildLogParser.DeduplicateConsecutiveLines(output.ToString()));
return new CliRunResult(process.ExitCode, text, duration, commandLine);
}
+
+ private void TryKillActiveProcess(Process process)
+ {
+ if (process.HasExited)
+ {
+ return;
+ }
+
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ catch
+ {
+ // Best effort — caller handles cancellation outcome.
+ }
+ }
}
diff --git a/src/Infrastructure/LocalBuild/IncrementalBuildDetector.cs b/src/Infrastructure/LocalBuild/IncrementalBuildDetector.cs
new file mode 100644
index 0000000..b46feb2
--- /dev/null
+++ b/src/Infrastructure/LocalBuild/IncrementalBuildDetector.cs
@@ -0,0 +1,25 @@
+namespace BuildMonitor.Infrastructure.LocalBuild;
+
+public static class IncrementalBuildDetector
+{
+ ///
+ /// True when MSBuild succeeded with a 0/0 summary and no compiler diagnostic lines —
+ /// typical of an incremental build where outputs were already up-to-date.
+ ///
+ public static bool WasCompileSkipped(string logText)
+ {
+ if (string.IsNullOrWhiteSpace(logText)
+ || !logText.Contains("Build succeeded", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (BuildLogParser.ParseErrorCount(logText) > 0
+ || BuildLogParser.ParseWarningCount(logText) > 0)
+ {
+ return false;
+ }
+
+ return BuildLogParser.ParseIssues(logText, maxWarnings: 1).Count == 0;
+ }
+}
diff --git a/src/Infrastructure/LocalBuild/WatchIgnoreRules.cs b/src/Infrastructure/LocalBuild/WatchIgnoreRules.cs
index 3707211..769ef6d 100644
--- a/src/Infrastructure/LocalBuild/WatchIgnoreRules.cs
+++ b/src/Infrastructure/LocalBuild/WatchIgnoreRules.cs
@@ -24,7 +24,16 @@ public static class WatchIgnoreRules
".bak",
".swp",
".mdb",
- ".meta"
+ ".meta",
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".webp",
+ ".svg",
+ ".ico",
+ ".bmp",
+ ".avif"
};
private static readonly HashSet IgnoredFileNames = new(StringComparer.OrdinalIgnoreCase)
diff --git a/src/Infrastructure/Services/ProjectOrchestrator.cs b/src/Infrastructure/Services/ProjectOrchestrator.cs
index 381e93f..1c872b7 100644
--- a/src/Infrastructure/Services/ProjectOrchestrator.cs
+++ b/src/Infrastructure/Services/ProjectOrchestrator.cs
@@ -72,20 +72,33 @@ public IReadOnlyList GetBuildIntelligenceSnapshots()
lock (sync)
{
var monitor = settings.Monitor;
+ var activeProjects = settings.Projects
+ .Where(p => p.IsActiveInSession)
+ .ToList();
+ var activeIds = new HashSet(
+ activeProjects.Select(p => p.Id),
+ StringComparer.OrdinalIgnoreCase);
+ var triggerCounts = triggerJournal.GetEntries()
+ .GroupBy(e => e.ProjectId, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var snapshots = new List();
- var activeIds = new HashSet(runtimes.Keys, StringComparer.OrdinalIgnoreCase);
- foreach (var runtime in runtimes.Values)
+ foreach (var runtime in runtimes.Values.Where(r => activeIds.Contains(r.ProjectId)))
{
- snapshots.Add(runtime.GetIntelligenceSnapshot(monitor));
+ var count = triggerCounts.GetValueOrDefault(runtime.ProjectId);
+ snapshots.Add(runtime.GetIntelligenceSnapshot(monitor, count));
}
- foreach (var project in settings.Projects.Where(p => !activeIds.Contains(p.Id)))
+ foreach (var project in activeProjects.Where(p => !runtimes.ContainsKey(p.Id)))
{
+ var count = triggerCounts.GetValueOrDefault(project.Id);
snapshots.Add(BuildIntelligenceSnapshot.FromStoredStats(
project,
monitor,
- burstStatsStore.GetOrDefault(project.Id)));
+ burstStatsStore.GetOrDefault(project.Id)) with
+ {
+ TodayTriggerCount = count
+ });
}
return snapshots
diff --git a/src/Infrastructure/Services/ProjectRuntime.Build.cs b/src/Infrastructure/Services/ProjectRuntime.Build.cs
index 1174b28..52bf316 100644
--- a/src/Infrastructure/Services/ProjectRuntime.Build.cs
+++ b/src/Infrastructure/Services/ProjectRuntime.Build.cs
@@ -62,18 +62,21 @@ public async Task BuildAsync(CancellationToken cancellationToken)
RecordBuildTrigger(
BuildTriggerKindFormatter.FromBuildReason(buildReason, triggeredByFileChange),
buildReason,
- detail: null,
+ detail: triggeredByFileChange ? lastFileChangeTriggerDetail : null,
fileChangePaths);
+ lastFileChangeTriggerDetail = null;
- fileWatcher?.Suspend();
+ buildCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ currentBuildReasonInFlight = buildReason;
+ var buildToken = buildCancellationSource.Token;
try
{
if (runProcess is not null)
{
SetProjectCurrentAction("Building — stopping app");
- await StopRunProcessAsync(cancellationToken);
- await Task.Delay(500, cancellationToken);
+ await StopRunProcessAsync(buildToken);
+ await Task.Delay(500, buildToken);
}
lock (liveOutputSync)
@@ -83,8 +86,6 @@ public async Task BuildAsync(CancellationToken cancellationToken)
watchRebuildInProgress = false;
Interlocked.Exchange(ref liveOutputRevision, 0);
- buildErrorCount = 0;
- buildWarningCount = 0;
lastErrorPreview = null;
var buildBanner = WriteBuildStartBanner(buildReason);
@@ -100,12 +101,18 @@ public async Task BuildAsync(CancellationToken cancellationToken)
if (releaseLocks)
{
SetProjectCurrentAction("Building — releasing output locks");
- await ReleaseOutputLocksAsync(cancellationToken);
+ await ReleaseOutputLocksAsync(buildToken);
}
SetProjectCurrentAction("Building — dotnet build");
- var args = BuildProjectArgs();
- var result = await RunBuildAttemptAsync(args, cancellationToken, buildBanner);
+ var args = BuildProjectArgs(DotNetBuildArguments.RequiresFullRebuild(buildReason));
+ var result = await RunBuildAttemptAsync(args, buildToken, buildBanner);
+
+ if (result.WasCancelled)
+ {
+ await HandleCancelledBuildAsync(buildReason, result, buildBanner, cancellationToken);
+ return;
+ }
if (releaseLocks
&& result.ExitCode != 0
@@ -126,7 +133,12 @@ public async Task BuildAsync(CancellationToken cancellationToken)
progressSteps = buildProgressTracker.Steps;
NotifyProgressChanged(force: true);
- result = await RunBuildAttemptAsync(args, cancellationToken, retryBanner);
+ result = await RunBuildAttemptAsync(args, buildToken, retryBanner);
+ if (result.WasCancelled)
+ {
+ await HandleCancelledBuildAsync(buildReason, result, retryBanner, cancellationToken);
+ return;
+ }
}
if (result.ExitCode != 0
@@ -155,7 +167,12 @@ public async Task BuildAsync(CancellationToken cancellationToken)
buildProgressTracker.Reset();
progressSteps = buildProgressTracker.Steps;
NotifyProgressChanged(force: true);
- result = await RunBuildAttemptAsync(args, cancellationToken, repairBanner);
+ result = await RunBuildAttemptAsync(args, buildToken, repairBanner);
+ if (result.WasCancelled)
+ {
+ await HandleCancelledBuildAsync(buildReason, result, repairBanner, cancellationToken);
+ return;
+ }
}
}
@@ -163,8 +180,30 @@ public async Task BuildAsync(CancellationToken cancellationToken)
lastExitCode = result.ExitCode;
lastDuration = result.Duration;
+ var existingLogPath = logStore.GetLogPath(definition.Id, BuildLogKind.Build);
+ var (resolvedErrors, resolvedWarnings) = BuildIssueCountResolver.Resolve(
+ result.Output,
+ existingLogPath);
+ var parsedErrors = BuildLogParser.ParseErrorCount(result.Output);
+ buildErrorCount = Math.Max(resolvedErrors, parsedErrors);
+ buildWarningCount = resolvedWarnings;
+ if (result.ExitCode != 0 && buildErrorCount == 0)
+ {
+ var (_, errorLines) = BuildLogParser.ParseErrors(result.Output);
+ if (errorLines.Count > 0)
+ {
+ buildErrorCount = errorLines.Count;
+ }
+ }
+
var finishBanner = BuildMonitorLogBanner.FormatFinished(buildNumber, result.ExitCode);
var logText = result.Output + Environment.NewLine + finishBanner;
+ if (IncrementalBuildDetector.WasCompileSkipped(result.Output)
+ && (resolvedErrors > 0 || resolvedWarnings > 0))
+ {
+ logText += Environment.NewLine
+ + BuildMonitorLogBanner.FormatIncrementalNote(resolvedErrors, resolvedWarnings);
+ }
var buildLog = await logStore.SaveAsync(
definition.Id,
@@ -176,8 +215,6 @@ public async Task BuildAsync(CancellationToken cancellationToken)
cancellationToken);
lastBuildFinishedAtUtc = buildLog.FinishedAtUtc;
- buildErrorCount = buildLog.ErrorCount;
- buildWarningCount = BuildLogParser.ParseWarningCount(result.Output);
lastErrorPreview = buildLog.ErrorLines.FirstOrDefault();
if (result.Duration.TotalMilliseconds > 0)
{
@@ -186,6 +223,7 @@ public async Task BuildAsync(CancellationToken cancellationToken)
if (result.ExitCode == 0)
{
+ progressSteps = [];
SetState(ProjectLifecycleState.BuildOk);
if (definition.RunOptions.RunTests == TestRunTrigger.OnBuildSuccess)
{
@@ -217,7 +255,7 @@ public async Task BuildAsync(CancellationToken cancellationToken)
{
if (triggeredByFileChange)
{
- await Task.Delay(1500, cancellationToken);
+ await Task.Delay(1500, buildToken);
}
StartRunProcess(skipEmbeddedBuild: true);
@@ -228,9 +266,11 @@ public async Task BuildAsync(CancellationToken cancellationToken)
}
finally
{
+ buildCancellationSource?.Dispose();
+ buildCancellationSource = null;
+ currentBuildReasonInFlight = null;
Interlocked.Exchange(ref buildInProgress, 0);
Interlocked.Exchange(ref buildTriggeredByFileChange, 0);
- fileWatcher?.Resume();
if (triggeredByFileChange)
{
@@ -246,21 +286,66 @@ public async Task BuildAsync(CancellationToken cancellationToken)
}
}
- if (pendingFileChangeRebuild && lastFileChangePaths.Count > 0)
+ if (pendingFileChangeRebuild)
{
- pendingFileChangeRebuild = false;
- _ = ScheduleCoalescedFileChangeRebuildAsync();
+ var nextReason = pendingRebuildHoldReason == PendingRebuildHoldReason.StartupDeferred
+ ? "startup"
+ : "file change (queued)";
+ _ = WaitForEditQuietThenBuildAsync(nextReason);
}
}
}
- private async Task ScheduleCoalescedFileChangeRebuildAsync()
+ private async Task HandleCancelledBuildAsync(
+ string buildReason,
+ CliRunResult result,
+ string? buildBanner,
+ CancellationToken cancellationToken)
+ {
+ var cancelBanner = "[BuildMonitor] Build cancelled — superseded by newer source changes.";
+ var logText = result.Output;
+ if (!string.IsNullOrWhiteSpace(logText) && !logText.EndsWith('\n'))
+ {
+ logText += Environment.NewLine;
+ }
+
+ logText += cancelBanner;
+
+ await logStore.SaveAsync(
+ definition.Id,
+ BuildLogKind.Build,
+ result.CommandLine,
+ result.ExitCode,
+ DateTimeOffset.UtcNow - result.Duration,
+ logText,
+ cancellationToken);
+
+ progressSteps = [];
+ buildProgressTracker = null;
+ EnterWaitingForEditsState("Build cancelled — waiting for edits to settle");
+
+ notifyUser?.Invoke(
+ definition.Id,
+ $"Build cancelled — {definition.DisplayName}",
+ "Newer source changes detected. Rebuilding when edits settle.",
+ UserNotificationKind.Info,
+ UserNotificationCategory.FileChangeDetected);
+ }
+
+ private async Task WaitForEditQuietThenBuildAsync(string buildReason)
{
var generation = Interlocked.Increment(ref fileChangeRebuildScheduleGeneration);
+ EnterWaitingForEditsState("Waiting for edits to settle…");
while (generation == Volatile.Read(ref fileChangeRebuildScheduleGeneration))
{
+ var activity = EvaluateEditActivity();
var waitUntil = GetFileChangeQuietUntilUtc();
+ if (activity.IsActive && activity.QuietUntilUtc > waitUntil)
+ {
+ waitUntil = activity.QuietUntilUtc;
+ }
+
if (fileChangeBuildCooldownUntil > waitUntil)
{
waitUntil = fileChangeBuildCooldownUntil;
@@ -275,11 +360,16 @@ private async Task ScheduleCoalescedFileChangeRebuildAsync()
if (Volatile.Read(ref buildInProgress) != 0)
{
- pendingFileChangeRebuild = true;
+ QueuePendingRebuild(
+ PendingRebuildHoldReason.BuildInProgress,
+ lastFileChangePaths,
+ wasAlreadyPending: true,
+ pathsAlreadyRelative: true);
return;
}
- if (DateTimeOffset.UtcNow < GetFileChangeQuietUntilUtc())
+ activity = EvaluateEditActivity();
+ if (activity.IsActive || DateTimeOffset.UtcNow < GetFileChangeQuietUntilUtc())
{
continue;
}
@@ -294,11 +384,28 @@ private async Task ScheduleCoalescedFileChangeRebuildAsync()
if (Volatile.Read(ref buildInProgress) != 0)
{
- pendingFileChangeRebuild = true;
+ QueuePendingRebuild(
+ PendingRebuildHoldReason.BuildInProgress,
+ lastFileChangePaths,
+ wasAlreadyPending: true,
+ pathsAlreadyRelative: true);
return;
}
+ lastFileChangeTriggerDetail = BuildTriggerDetailFormatter.FormatCoalescedBuild(
+ GetSessionAdjustedFileChangeDebounceMs(),
+ pendingRebuildHoldReason,
+ pendingRebuildTimerResetCount);
+ ClearPendingRebuildHold();
pendingFileChangeRebuild = false;
+
+ if (string.Equals(buildReason, "startup", StringComparison.OrdinalIgnoreCase))
+ {
+ pendingBuildReason = "startup";
+ await BuildAsync(CancellationToken.None);
+ return;
+ }
+
Interlocked.Exchange(ref buildTriggeredByFileChange, 1);
pendingBuildReason = "file change (queued)";
@@ -325,14 +432,18 @@ private async Task HydrateLastBuildFromStoreAsync(CancellationToken cancellation
lastDuration = metadata.FinishedAtUtc - metadata.StartedAtUtc;
lastBuildFinishedAtUtc = metadata.FinishedAtUtc;
buildErrorCount = metadata.ErrorCount;
+ buildWarningCount = metadata.WarningCount;
lastErrorPreview = metadata.ErrorLines.FirstOrDefault();
- var logText = await logStore.LoadLogTextAsync(metadata, maxBytes: 512_000, cancellationToken);
- if (!string.IsNullOrWhiteSpace(logText))
+ if (buildWarningCount == 0)
{
- buildWarningCount = BuildLogParser.ParseWarningCount(logText);
- if (buildErrorCount == 0)
+ var logText = await logStore.LoadLogTextAsync(metadata, maxBytes: 512_000, cancellationToken);
+ if (!string.IsNullOrWhiteSpace(logText))
{
- buildErrorCount = BuildLogParser.ParseErrorCount(logText);
+ var (resolvedErrors, resolvedWarnings) = BuildIssueCountResolver.Resolve(
+ logText,
+ metadata.LogFilePath);
+ buildErrorCount = Math.Max(buildErrorCount, resolvedErrors);
+ buildWarningCount = Math.Max(buildWarningCount, resolvedWarnings);
}
}
@@ -437,34 +548,49 @@ private void OnFileWatcherChanged(IReadOnlyList changedPaths, int burstD
lastFileChangePaths = RelativizePaths(meaningful);
SyncFileWatcherDebounceMs();
+ var wasAlreadyPending = pendingFileChangeRebuild;
if (DateTimeOffset.UtcNow < fileChangeBuildCooldownUntil)
{
- pendingFileChangeRebuild = true;
+ QueuePendingRebuild(PendingRebuildHoldReason.PostBuildCooldown, meaningful, wasAlreadyPending);
return;
}
if (Volatile.Read(ref testInProgress) != 0)
{
- pendingFileChangeRebuild = true;
+ QueuePendingRebuild(PendingRebuildHoldReason.TestsInProgress, meaningful, wasAlreadyPending);
return;
}
if (Volatile.Read(ref buildInProgress) != 0)
{
- pendingFileChangeRebuild = true;
+ QueuePendingRebuild(PendingRebuildHoldReason.BuildInProgress, meaningful, wasAlreadyPending);
+ if (BuildSuppressionPolicy.ShouldCancelInFlightBuild(
+ GetSuppressionSettings(),
+ currentBuildReasonInFlight))
+ {
+ pendingRebuildHoldReason = PendingRebuildHoldReason.SupersededByNewEdits;
+ RequestBuildCancellation();
+ Interlocked.Increment(ref fileChangeRebuildScheduleGeneration);
+ }
+
return;
}
- if (IsAgentEditSessionActive())
+ if (IsAgentEditSessionActive() || EvaluateEditActivity().IsActive)
{
- pendingFileChangeRebuild = true;
- _ = ScheduleCoalescedFileChangeRebuildAsync();
+ var reason = wasAlreadyPending
+ ? PendingRebuildHoldReason.EditsStillArriving
+ : PendingRebuildHoldReason.EditsSettling;
+ QueuePendingRebuild(reason, meaningful, wasAlreadyPending);
+ _ = WaitForEditQuietThenBuildAsync("file change (queued)");
return;
}
Interlocked.Exchange(ref buildTriggeredByFileChange, 1);
pendingBuildReason = "file change";
+ lastFileChangeTriggerDetail = BuildTriggerDetailFormatter.FormatImmediateDebounce(
+ GetSessionAdjustedFileChangeDebounceMs());
notifyUser?.Invoke(
definition.Id,
@@ -476,4 +602,35 @@ private void OnFileWatcherChanged(IReadOnlyList changedPaths, int burstD
_ = BuildAsync(CancellationToken.None);
}
+ private void QueuePendingRebuild(
+ PendingRebuildHoldReason reason,
+ IReadOnlyList meaningfulPaths,
+ bool wasAlreadyPending,
+ bool pathsAlreadyRelative = false)
+ {
+ pendingFileChangeRebuild = true;
+
+ pendingRebuildHoldReason = reason == PendingRebuildHoldReason.EditsSettling && wasAlreadyPending
+ ? PendingRebuildHoldReason.EditsStillArriving
+ : reason;
+
+ if (pendingRebuildHoldReason == PendingRebuildHoldReason.EditsStillArriving)
+ {
+ pendingRebuildTimerResetCount++;
+ }
+
+ pendingRebuildHoldFileCount = meaningfulPaths.Count;
+ pendingRebuildHoldSamplePaths = pathsAlreadyRelative
+ ? meaningfulPaths.Take(3).ToList()
+ : RelativizePaths(meaningfulPaths).Take(3).ToList();
+ }
+
+ private void ClearPendingRebuildHold()
+ {
+ pendingRebuildHoldReason = PendingRebuildHoldReason.None;
+ pendingRebuildHoldFileCount = 0;
+ pendingRebuildHoldSamplePaths = [];
+ pendingRebuildTimerResetCount = 0;
+ }
+
}
diff --git a/src/Infrastructure/Services/ProjectRuntime.BuildOutput.cs b/src/Infrastructure/Services/ProjectRuntime.BuildOutput.cs
index 8658d21..28d25dd 100644
--- a/src/Infrastructure/Services/ProjectRuntime.BuildOutput.cs
+++ b/src/Infrastructure/Services/ProjectRuntime.BuildOutput.cs
@@ -188,8 +188,7 @@ private bool RefreshBuildIssueCountsFromWatchOutput(bool force)
return false;
}
- var parsedErrors = BuildLogParser.ParseErrorCount(output);
- var parsedWarnings = BuildLogParser.ParseWarningCount(output);
+ var (parsedErrors, parsedWarnings) = CountLiveBuildIssues(output);
if (parsedErrors == buildErrorCount && parsedWarnings == buildWarningCount)
{
return false;
@@ -222,8 +221,10 @@ private bool RefreshLiveIssueCounts(bool force)
: liveBuildOutput.ToString();
}
- var parsedErrors = BuildLogParser.ParseErrorCount(output);
- var parsedWarnings = BuildLogParser.ParseWarningCount(output);
+ var normalized = BuildLogTextNormalizer.Normalize(output);
+ var (parsedErrors, parsedWarnings) = state == ProjectLifecycleState.Testing
+ ? CountLiveIssues(BuildLogKind.Test, normalized)
+ : CountLiveBuildIssues(normalized);
if (parsedErrors == buildErrorCount && parsedWarnings == buildWarningCount)
{
return false;
@@ -236,9 +237,11 @@ private bool RefreshLiveIssueCounts(bool force)
private void NotifyProgressChanged(bool force = false) =>
RequestHealthCoalesce(force);
- private List BuildProjectArgs()
+
+ private List BuildProjectArgs(bool forceFullRebuild = false)
{
var args = new List { "build", ResolveProjectFileArg() };
+ DotNetBuildArguments.ApplyFullRebuildFlag(args, forceFullRebuild);
AppendExtraArgs(args);
return args;
}
diff --git a/src/Infrastructure/Services/ProjectRuntime.EditGating.cs b/src/Infrastructure/Services/ProjectRuntime.EditGating.cs
new file mode 100644
index 0000000..37dc327
--- /dev/null
+++ b/src/Infrastructure/Services/ProjectRuntime.EditGating.cs
@@ -0,0 +1,122 @@
+using BuildMonitor.Core.Models;
+using BuildMonitor.Core.Rules;
+using BuildMonitor.Core.Settings;
+using BuildMonitor.Infrastructure.LocalBuild;
+
+namespace BuildMonitor.Infrastructure.Services;
+
+internal sealed partial class ProjectRuntime
+{
+ private AgentActivityWatcher? agentActivityWatcher;
+ private bool deferStartupBuildUntilQuiet = true;
+ private bool cancelSupersededBuilds = true;
+ private bool useAgentTranscriptActivity = true;
+ private CancellationTokenSource? buildCancellationSource;
+ private string? currentBuildReasonInFlight;
+
+ private BuildSuppressionSettings GetSuppressionSettings() =>
+ new(deferStartupBuildUntilQuiet, cancelSupersededBuilds);
+
+ private void ApplyMonitorSuppressionSettings(GlobalMonitorSettings monitor)
+ {
+ deferStartupBuildUntilQuiet = monitor.DeferStartupBuildUntilQuiet;
+ cancelSupersededBuilds = monitor.CancelSupersededBuilds;
+ useAgentTranscriptActivity = monitor.UseAgentTranscriptActivity;
+ }
+
+ private EditActivitySnapshot EvaluateEditActivity()
+ {
+ var agentActivity = useAgentTranscriptActivity
+ && agentActivityWatcher is not null
+ && agentActivityWatcher.LastActivityUtc != DateTimeOffset.MinValue
+ ? agentActivityWatcher.LastActivityUtc
+ : (DateTimeOffset?)null;
+
+ return EditActivitySnapshot.Evaluate(
+ new EditActivityInput(
+ fileWatcher?.HasPendingChanges == true,
+ fileWatcher?.BurstStartedUtc,
+ lastMeaningfulFileChangeUtc,
+ agentActivity,
+ GetSessionAdjustedFileChangeDebounceMs(),
+ useAgentTranscriptActivity),
+ DateTimeOffset.UtcNow);
+ }
+
+ private bool IsEditGatingActive()
+ {
+ var activity = EvaluateEditActivity();
+ return BuildSuppressionPolicy.IsEditGatingActive(
+ GetSuppressionSettings(),
+ pendingFileChangeRebuild,
+ activity,
+ pendingRebuildHoldReason);
+ }
+
+ private DateTimeOffset? GetEditGatingQuietUntilUtc()
+ {
+ if (pendingFileChangeRebuild && lastMeaningfulFileChangeUtc != DateTimeOffset.MinValue)
+ {
+ return AdaptiveFileChangeDebounce.ComputeQuietUntilUtc(
+ lastMeaningfulFileChangeUtc,
+ GetSessionAdjustedFileChangeDebounceMs());
+ }
+
+ var activity = EvaluateEditActivity();
+ return activity.IsActive ? activity.QuietUntilUtc : null;
+ }
+
+ private string? BuildEditGatingDetailText()
+ {
+ if (!IsEditGatingActive() && pendingRebuildHoldReason == PendingRebuildHoldReason.None)
+ {
+ return null;
+ }
+
+ PruneRecentFileChangeBuildStarts();
+ return EditGatingDetailFormatter.FormatHoldReason(
+ pendingRebuildHoldReason,
+ pendingRebuildHoldFileCount,
+ pendingRebuildHoldSamplePaths,
+ pendingRebuildTimerResetCount,
+ GetSessionAdjustedFileChangeDebounceMs(),
+ recentFileChangeBuildStarts.Count >= 1);
+ }
+
+ private void TryStartAgentActivityWatcher()
+ {
+ if (!useAgentTranscriptActivity || agentActivityWatcher is not null)
+ {
+ return;
+ }
+
+ try
+ {
+ agentActivityWatcher = new AgentActivityWatcher(definition.RootFolder);
+ }
+ catch
+ {
+ agentActivityWatcher = null;
+ }
+ }
+
+ private void RequestBuildCancellation()
+ {
+ try
+ {
+ buildCancellationSource?.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Build already finished.
+ }
+ }
+
+ private void EnterWaitingForEditsState(string action)
+ {
+ SetState(ProjectLifecycleState.WaitingForEdits);
+ SetProjectCurrentAction(action);
+ MarkHealthDirty();
+ HealthCoalesceRequested?.Invoke(true);
+ }
+}
diff --git a/src/Infrastructure/Services/ProjectRuntime.RunOutput.cs b/src/Infrastructure/Services/ProjectRuntime.RunOutput.cs
index 1b38efc..36c331a 100644
--- a/src/Infrastructure/Services/ProjectRuntime.RunOutput.cs
+++ b/src/Infrastructure/Services/ProjectRuntime.RunOutput.cs
@@ -254,6 +254,9 @@ private async Task RestartAppCoreAsync(
try
{
+ SetProjectCurrentAction(rebuildFirst
+ ? "Restarting — rebuild then start app"
+ : "Restarting app (dotnet run --no-build)");
await StopRunProcessAsync(cancellationToken);
restartCount = 0;
runErrorCount = 0;
@@ -271,6 +274,15 @@ private async Task RestartAppCoreAsync(
"Hot reload requested app restart (no rebuild)",
detail: null);
}
+ else
+ {
+ notifyUser?.Invoke(
+ definition.Id,
+ $"Restarting app — {definition.DisplayName}",
+ "Stopping run/watch and starting again with --no-build.",
+ UserNotificationKind.Info,
+ UserNotificationCategory.Info);
+ }
EnsureRunProcessStartedAfterBuild();
}
diff --git a/src/Infrastructure/Services/ProjectRuntime.Test.cs b/src/Infrastructure/Services/ProjectRuntime.Test.cs
index 2f811f1..d0e2026 100644
--- a/src/Infrastructure/Services/ProjectRuntime.Test.cs
+++ b/src/Infrastructure/Services/ProjectRuntime.Test.cs
@@ -39,6 +39,8 @@ public async Task TestAsync(CancellationToken cancellationToken)
var wasRunProcessActive = runProcess?.IsRunning == true;
var releaseLocksSetting = definition.RunOptions.ReleaseOutputLocksBeforeBuild;
var stoppedAppForTests = false;
+ var preservedBuildErrors = buildErrorCount;
+ var preservedBuildWarnings = buildWarningCount;
fileWatcher?.Suspend();
fileChangeBuildCooldownUntil = DateTimeOffset.UtcNow.AddMinutes(2);
@@ -142,6 +144,8 @@ await logStore.SaveAsync(
if (effectiveExitCode == 0)
{
+ buildErrorCount = preservedBuildErrors;
+ buildWarningCount = preservedBuildWarnings;
SetState(ProjectLifecycleState.TestOk);
}
else
diff --git a/src/Infrastructure/Services/ProjectRuntime.cs b/src/Infrastructure/Services/ProjectRuntime.cs
index 70fbcc4..ee9866d 100644
--- a/src/Infrastructure/Services/ProjectRuntime.cs
+++ b/src/Infrastructure/Services/ProjectRuntime.cs
@@ -61,6 +61,11 @@ internal sealed partial class ProjectRuntime : IDisposable
private int fileChangeRebuildScheduleGeneration;
private readonly Queue recentFileChangeBuildStarts = new();
private IReadOnlyList lastFileChangePaths = [];
+ private string? lastFileChangeTriggerDetail;
+ private PendingRebuildHoldReason pendingRebuildHoldReason;
+ private int pendingRebuildHoldFileCount;
+ private IReadOnlyList pendingRebuildHoldSamplePaths = [];
+ private int pendingRebuildTimerResetCount;
private int runProcessGeneration;
private Action? runProcessExitedHandler;
private string? pendingListenUrl;
@@ -119,7 +124,10 @@ public ProjectHealthSnapshot BuildSnapshot()
runErrorCount,
runWarningCount),
HealthIssueCountsFormatter.FormatFailurePhase(state),
- isRestarting);
+ isRestarting,
+ IsEditGatingActive(),
+ BuildEditGatingDetailText(),
+ GetEditGatingQuietUntilUtc());
}
public void MarkHealthDirty() => Interlocked.Exchange(ref healthDirty, 1);
@@ -144,6 +152,12 @@ public void ForceCoalesceHealth()
private void CoalesceHealthCore()
{
RefreshLiveIssueCounts(force: true);
+ if (UsesDotNetWatchProcess()
+ && state is ProjectLifecycleState.Watching or ProjectLifecycleState.Running)
+ {
+ RefreshBuildIssueCountsFromWatchOutput(force: false);
+ }
+
RefreshHealth();
}
@@ -188,6 +202,7 @@ public void UpdateDefinition(LocalProjectDefinition updated, GlobalMonitorSettin
fileChangeDebounceMs = ResolveFileChangeDebounceMs();
fileWatcher?.SetDebounceMs(fileChangeDebounceMs);
coalesceWatchRebuilds = monitor.CoalesceWatchRebuilds;
+ ApplyMonitorSuppressionSettings(monitor);
}
private int ResolveFileChangeDebounceMs() =>
@@ -244,7 +259,7 @@ private bool IsAgentEditSessionActive()
return recentFileChangeBuildStarts.Count >= 1;
}
- public BuildIntelligenceSnapshot GetIntelligenceSnapshot(GlobalMonitorSettings monitor)
+ public BuildIntelligenceSnapshot GetIntelligenceSnapshot(GlobalMonitorSettings monitor, int todayTriggerCount = 0)
{
PruneRecentFileChangeBuildStarts();
var stats = burstStatsStore.GetOrDefault(definition.Id);
@@ -268,7 +283,12 @@ public BuildIntelligenceSnapshot GetIntelligenceSnapshot(GlobalMonitorSettings m
coalesceWatchRebuilds,
lastMeaningfulFileChangeUtc == DateTimeOffset.MinValue ? null : lastMeaningfulFileChangeUtc,
pendingFileChangeRebuild,
- rebuildQuietUntilUtc);
+ rebuildQuietUntilUtc,
+ todayTriggerCount,
+ pendingRebuildHoldReason,
+ pendingRebuildHoldFileCount,
+ pendingRebuildHoldSamplePaths,
+ pendingRebuildTimerResetCount);
}
private bool UsesCoalescedWatchRebuilds() =>
@@ -296,6 +316,11 @@ private bool ShouldStartFileWatcher()
public void SetUserNotifier(Action? notifier) =>
notifyUser = notifier;
+ private (int Errors, int Warnings) CountLiveBuildIssues(string normalized) =>
+ BuildIssueCountResolver.Resolve(
+ normalized,
+ logStore.GetLogPath(definition.Id, BuildLogKind.Build));
+
private static (int Errors, int Warnings) CountLiveIssues(BuildLogKind kind, string normalized) =>
kind switch
{
@@ -361,7 +386,9 @@ private static (int Errors, int Warnings) CountTestIssues(string normalized)
var revision = kind == BuildLogKind.Test
? Volatile.Read(ref liveTestOutputRevision)
: Volatile.Read(ref liveOutputRevision);
- var (liveErrors, liveWarnings) = CountLiveIssues(kind, normalized);
+ var (liveErrors, liveWarnings) = kind == BuildLogKind.Build
+ ? CountLiveBuildIssues(normalized)
+ : CountLiveIssues(kind, normalized);
return new LiveBuildLogView(
normalized,
true,
@@ -375,7 +402,23 @@ public async Task StartAsync(CancellationToken cancellationToken)
{
SetProjectCurrentAction("Starting — loading saved build state");
await HydrateLastBuildFromStoreAsync(cancellationToken);
- await BuildAsync(cancellationToken);
+ TryStartFileWatcher();
+ TryStartAgentActivityWatcher();
+
+ var activity = EvaluateEditActivity();
+ if (BuildSuppressionPolicy.ShouldDeferStartupBuild(GetSuppressionSettings(), activity))
+ {
+ pendingFileChangeRebuild = true;
+ QueuePendingRebuild(
+ PendingRebuildHoldReason.StartupDeferred,
+ [],
+ wasAlreadyPending: false);
+ await WaitForEditQuietThenBuildAsync("startup");
+ }
+ else
+ {
+ await BuildAsync(cancellationToken);
+ }
if (definition.RunOptions.RunMode == ProjectRunMode.None)
{
@@ -390,7 +433,10 @@ public async Task StartAsync(CancellationToken cancellationToken)
// Build already completed above — skip watch/run's embedded rebuild.
StartRunProcess(skipEmbeddedBuild: true);
- TryStartFileWatcher();
+ if (fileWatcher is null)
+ {
+ TryStartFileWatcher();
+ }
}
private void SetState(ProjectLifecycleState newState)
{
@@ -416,6 +462,7 @@ private static string FormatLifecycleAction(ProjectLifecycleState state) =>
ProjectLifecycleState.Testing => "Running tests",
ProjectLifecycleState.TestOk => "Tests passed",
ProjectLifecycleState.TestFailed => "Tests failed",
+ ProjectLifecycleState.WaitingForEdits => "Waiting for edits to settle",
_ => state.ToString()
};
@@ -434,7 +481,8 @@ private void RefreshHealth()
displayWarnings,
inProgress: isRestarting
|| state is ProjectLifecycleState.Building
- || state is ProjectLifecycleState.Testing);
+ || state is ProjectLifecycleState.Testing
+ || state is ProjectLifecycleState.WaitingForEdits);
}
private void RecordBuildTrigger(
BuildTriggerKind kind,
@@ -485,6 +533,7 @@ public void Dispose()
StopListenUrlPolling();
StopRunLogSaveTimer();
fileWatcher?.Dispose();
+ agentActivityWatcher?.Dispose();
runProcess?.Dispose();
}
diff --git a/src/TrayApp/App.xaml.cs b/src/TrayApp/App.xaml.cs
index 3e3bba3..9554316 100644
--- a/src/TrayApp/App.xaml.cs
+++ b/src/TrayApp/App.xaml.cs
@@ -1,3 +1,4 @@
+using System.Drawing;
using System.IO;
using System.Windows;
using System.Windows.Threading;
@@ -21,17 +22,28 @@ public partial class App : System.Windows.Application
private BuildMonitorHealthWindow? buildMonitorHealthWindow;
private DispatcherHealthProbe? dispatcherHealthProbe;
private HoverStatusPanel? hoverPanel;
+ private DispatcherTimer? trayHoverPollTimer;
private SettingsStore? settingsStore;
private AppWindowsLayoutStore? windowsLayoutStore;
private AppSettings currentSettings = new();
private string appDataDirectory = string.Empty;
private DispatcherTimer? hideStatusPanelTimer;
+ private DispatcherTimer? siteReadyDismissTimer;
private DispatcherTimer? statusPanelLayoutSaveTimer;
private bool pointerOverStatusPanel;
private readonly Dictionary previousProjectHealth = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary openLogViewers = new(StringComparer.OrdinalIgnoreCase);
- private readonly HashSet autoOpenedLogForFailure = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet autoOpenedLogForTransition = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary previousProjectLifecycleState =
+ new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet fileChangeBuildStarts = new(StringComparer.OrdinalIgnoreCase);
+ private bool statusPanelAutoShownForBuild;
+ private bool statusPanelAutoShownForEditGating;
+ private bool statusPanelPinnedAutoFlow;
+ private bool statusPanelDismissScheduled;
+ private DateTimeOffset trayHoverStatusPanelSuppressedUntil = DateTimeOffset.MinValue;
+ private readonly Dictionary previousEditGatingActive =
+ new(StringComparer.OrdinalIgnoreCase);
private readonly BuildLifecycleToastNotifier buildLifecycleToastNotifier = new();
private readonly TrayContextMenuBuilder trayMenuBuilder = new();
private int settingsApplyVersion;
@@ -134,7 +146,9 @@ private async Task ApplySettingsAndStartAsync()
previousProjectHealth.Clear();
buildLifecycleToastNotifier.Reset();
- autoOpenedLogForFailure.Clear();
+ autoOpenedLogForTransition.Clear();
+ previousProjectLifecycleState.Clear();
+ statusPanelAutoShownForBuild = false;
fileChangeBuildStarts.Clear();
ToastNotificationService.ApplySettings(currentSettings.AppBehavior);
await orchestrator.StopAllAsync();
@@ -257,7 +271,10 @@ private void ApplyPendingHealthUi()
if (Volatile.Read(ref trayMenuOpen) == 0)
{
- AutoOpenLogsOnFailureTransition(snapshots);
+ AutoOpenLogsOnTransition(snapshots);
+ AutoShowStatusPanelWhileBuilding(snapshots);
+ AutoShowStatusPanelForEditGating(snapshots);
+ UpdateStatusPanelSiteReadyPin(snapshots);
buildLifecycleToastNotifier.Process(snapshots, fileChangeBuildStarts);
PlayBuildNotificationSounds(snapshots);
}
@@ -274,34 +291,293 @@ private void ApplyPendingHealthUi()
}
}
- private void AutoOpenLogsOnFailureTransition(IReadOnlyList snapshots)
+ private void AutoOpenLogsOnTransition(IReadOnlyList snapshots)
{
- if (!currentSettings.Monitor.AutoOpenLogOnFailure)
- {
- return;
- }
-
foreach (var snapshot in snapshots.Where(s => s.IsActive))
{
- previousProjectHealth.TryGetValue(snapshot.ProjectId, out var previousHealth);
+ var project = currentSettings.Projects.FirstOrDefault(p =>
+ p.Id.Equals(snapshot.ProjectId, StringComparison.OrdinalIgnoreCase));
+ var mode = project?.RunOptions.AutoOpenLog ?? AutoOpenLogMode.Never;
+ if (mode == AutoOpenLogMode.Never)
+ {
+ continue;
+ }
- if (snapshot.Health == MonitorHealth.Red && previousHealth != MonitorHealth.Red)
+ previousProjectHealth.TryGetValue(snapshot.ProjectId, out var previousHealth);
+ previousProjectLifecycleState.TryGetValue(snapshot.ProjectId, out var previousState);
+
+ if (AutoOpenLogTransitionEvaluator.ShouldOpen(
+ mode,
+ previousHealth,
+ snapshot.Health,
+ previousState,
+ snapshot.State,
+ snapshot.ErrorCount))
{
- if (autoOpenedLogForFailure.Add(snapshot.ProjectId))
+ var useLatch = mode is AutoOpenLogMode.Errors or AutoOpenLogMode.Warnings;
+ if (!useLatch || autoOpenedLogForTransition.Add(snapshot.ProjectId))
{
- var logKind = LogKindForFailure(snapshot.State);
+ var logKind = LogKindForAutoOpen(snapshot.State, previousState);
+ var (selectErrorsFilter, selectWarningsFilter) =
+ AutoOpenLogTransitionEvaluator.ResolveIssueFilters(mode, snapshot);
OpenLogViewer(
snapshot.ProjectId,
snapshot.DisplayName,
logKind,
- selectErrorsFilter: snapshot.ErrorCount > 0);
+ selectErrorsFilter,
+ selectWarningsFilter);
+ }
+ }
+ else if (AutoOpenLogTransitionEvaluator.ShouldResetOpenLatch(mode, snapshot.Health))
+ {
+ autoOpenedLogForTransition.Remove(snapshot.ProjectId);
+ }
+ }
+ }
+
+ private void AutoShowStatusPanelWhileBuilding(IReadOnlyList snapshots)
+ {
+ var active = snapshots.Where(s => s.IsActive).ToList();
+ foreach (var snapshot in active)
+ {
+ var enabled = currentSettings.Projects
+ .FirstOrDefault(p => p.Id.Equals(snapshot.ProjectId, StringComparison.OrdinalIgnoreCase))
+ ?.RunOptions.ShowStatusPanelWhileBuilding == true;
+ if (!enabled)
+ {
+ continue;
+ }
+
+ previousProjectLifecycleState.TryGetValue(snapshot.ProjectId, out var previousState);
+ if (!StatusPanelBuildVisibilityEvaluator.ShouldAutoShow(enabled, previousState, snapshot.State))
+ {
+ continue;
+ }
+
+ if (hoverPanel is not { IsVisible: true })
+ {
+ ShowStatusPanel();
+ statusPanelAutoShownForBuild = true;
+ MarkStatusPanelAutoPinned();
+ }
+ }
+
+ var visibilityProjects = active.Select(snapshot =>
+ {
+ var enabled = currentSettings.Projects
+ .FirstOrDefault(p => p.Id.Equals(snapshot.ProjectId, StringComparison.OrdinalIgnoreCase))
+ ?.RunOptions.ShowStatusPanelWhileBuilding == true;
+ return (ShowWhileBuildingEnabled: enabled == true, snapshot.State);
+ });
+
+ if (StatusPanelBuildVisibilityEvaluator.ShouldAutoHide(statusPanelAutoShownForBuild, visibilityProjects))
+ {
+ if (!statusPanelAutoShownForEditGating
+ && !statusPanelPinnedAutoFlow
+ && !StatusPanelBuildVisibilityEvaluator.ShouldKeepPanelVisibleUntilSiteReady(active))
+ {
+ HideAutoStatusPanel();
+ statusPanelAutoShownForBuild = false;
+ }
+ }
+ }
+
+ private void MarkStatusPanelAutoPinned() => statusPanelPinnedAutoFlow = true;
+
+ private void UpdateStatusPanelSiteReadyPin(IReadOnlyList snapshots)
+ {
+ if (!statusPanelPinnedAutoFlow)
+ {
+ statusPanelDismissScheduled = false;
+ return;
+ }
+
+ var active = snapshots.Where(s => s.IsActive).ToList();
+ var awaitingSite = StatusPanelBuildVisibilityEvaluator.ShouldKeepPanelVisibleUntilSiteReady(active);
+ var hasListenUrl = active.Any(StatusPanelBuildVisibilityEvaluator.HasSiteLaunchConfigured);
+ var busy = active.Any(s =>
+ StatusPanelBuildVisibilityEvaluator.IsBusyWorkState(s.State)
+ || s.IsEditGatingActive
+ || s.IsRestarting
+ || s.State is ProjectLifecycleState.Building);
+
+ if (hasListenUrl)
+ {
+ if (awaitingSite || busy)
+ {
+ statusPanelDismissScheduled = false;
+ siteReadyDismissTimer?.Stop();
+ return;
+ }
+
+ ScheduleSiteReadyDismissOnce(TimeSpan.FromSeconds(4));
+ return;
+ }
+
+ if (!busy)
+ {
+ ScheduleSiteReadyDismissOnce(TimeSpan.FromSeconds(2));
+ }
+ else
+ {
+ statusPanelDismissScheduled = false;
+ siteReadyDismissTimer?.Stop();
+ }
+ }
+
+ private void ScheduleSiteReadyDismissOnce(TimeSpan delay)
+ {
+ if (statusPanelDismissScheduled)
+ {
+ return;
+ }
+
+ statusPanelDismissScheduled = true;
+ ScheduleSiteReadyDismiss(delay);
+ }
+
+ private void ScheduleSiteReadyDismiss(TimeSpan delay)
+ {
+ siteReadyDismissTimer ??= new DispatcherTimer();
+ siteReadyDismissTimer.Interval = delay;
+ siteReadyDismissTimer.Tick -= SiteReadyDismissTick;
+ siteReadyDismissTimer.Tick += SiteReadyDismissTick;
+ siteReadyDismissTimer.Stop();
+ siteReadyDismissTimer.Start();
+ }
+
+ private void SiteReadyDismissTick(object? sender, EventArgs e)
+ {
+ siteReadyDismissTimer?.Stop();
+ if (!statusPanelPinnedAutoFlow)
+ {
+ return;
+ }
+
+ if (pointerOverStatusPanel)
+ {
+ statusPanelDismissScheduled = false;
+ ScheduleSiteReadyDismiss(TimeSpan.FromSeconds(2));
+ return;
+ }
+
+ HideAutoStatusPanel(suppressTrayHover: true);
+ statusPanelAutoShownForBuild = false;
+ statusPanelAutoShownForEditGating = false;
+ }
+
+ private void AutoShowStatusPanelForEditGating(IReadOnlyList snapshots)
+ {
+ var suppressionEnabled = currentSettings.Monitor.DeferStartupBuildUntilQuiet
+ || currentSettings.Monitor.CancelSupersededBuilds;
+ if (!suppressionEnabled)
+ {
+ return;
+ }
+
+ var active = snapshots.Where(s => s.IsActive).ToList();
+ var anyGatingActive = false;
+ var anyBusyWork = false;
+
+ foreach (var snapshot in active)
+ {
+ var showWhileBuilding = currentSettings.Projects
+ .FirstOrDefault(p => p.Id.Equals(snapshot.ProjectId, StringComparison.OrdinalIgnoreCase))
+ ?.RunOptions.ShowStatusPanelWhileBuilding == true;
+ previousEditGatingActive.TryGetValue(snapshot.ProjectId, out var wasGating);
+ previousProjectLifecycleState.TryGetValue(snapshot.ProjectId, out var previousState);
+ var isBusy = StatusPanelBuildVisibilityEvaluator.IsBusyWorkState(snapshot.State);
+
+ if (StatusPanelBuildVisibilityEvaluator.ShouldHideWhenBuildStartsWithoutShowSetting(
+ showWhileBuilding,
+ previousState,
+ snapshot.State,
+ statusPanelAutoShownForEditGating && !statusPanelAutoShownForBuild))
+ {
+ HideAutoStatusPanel();
+ statusPanelAutoShownForEditGating = false;
+ continue;
+ }
+
+ if (StatusPanelBuildVisibilityEvaluator.ShouldAutoShowForEditGating(
+ suppressionEnabled,
+ snapshot.IsEditGatingActive,
+ wasGating)
+ || StatusPanelBuildVisibilityEvaluator.ShouldAutoShowForBusyWork(
+ suppressionEnabled,
+ showWhileBuilding,
+ previousState,
+ snapshot.State))
+ {
+ if (hoverPanel is not { IsVisible: true })
+ {
+ ShowStatusPanel();
}
+
+ statusPanelAutoShownForEditGating = true;
+ MarkStatusPanelAutoPinned();
+ }
+
+ if (snapshot.IsEditGatingActive)
+ {
+ anyGatingActive = true;
}
- else if (snapshot.Health != MonitorHealth.Red)
+
+ if (isBusy)
{
- autoOpenedLogForFailure.Remove(snapshot.ProjectId);
+ anyBusyWork = true;
}
+
+ previousEditGatingActive[snapshot.ProjectId] = snapshot.IsEditGatingActive;
+ }
+
+ if (statusPanelAutoShownForEditGating
+ && !anyGatingActive
+ && !anyBusyWork
+ && !statusPanelAutoShownForBuild
+ && !statusPanelPinnedAutoFlow
+ && !StatusPanelBuildVisibilityEvaluator.ShouldKeepPanelVisibleUntilSiteReady(active))
+ {
+ HideAutoStatusPanel();
+ statusPanelAutoShownForEditGating = false;
+ }
+ }
+
+ private void HideAutoStatusPanel(bool suppressTrayHover = false)
+ {
+ CancelStatusPanelTimers();
+ siteReadyDismissTimer?.Stop();
+ statusPanelPinnedAutoFlow = false;
+ statusPanelDismissScheduled = false;
+ if (suppressTrayHover)
+ {
+ trayHoverStatusPanelSuppressedUntil = DateTimeOffset.UtcNow.AddSeconds(5);
+ trayHoverPollTimer?.Stop();
+ }
+ pointerOverStatusPanel = false;
+ hoverPanel?.Hide();
+ FlushStatusPanelLayout();
+ }
+
+ private static BuildLogKind? LogKindForAutoOpen(
+ ProjectLifecycleState currentState,
+ ProjectLifecycleState previousState)
+ {
+ if (previousState == ProjectLifecycleState.Testing
+ || currentState is ProjectLifecycleState.Testing
+ or ProjectLifecycleState.TestOk
+ or ProjectLifecycleState.TestFailed)
+ {
+ return BuildLogKind.Test;
+ }
+
+ if (currentState is ProjectLifecycleState.Crashed
+ && previousState is ProjectLifecycleState.Running or ProjectLifecycleState.Watching)
+ {
+ return BuildLogKind.Run;
}
+
+ return BuildLogKind.Build;
}
protected override void OnExit(ExitEventArgs e)
@@ -330,7 +606,7 @@ private Forms.NotifyIcon BuildNotifyIcon()
{
var icon = new Forms.NotifyIcon
{
- Text = "Local Build Monitor",
+ Text = string.Empty,
Icon = TrafficLightIconFactory.GetIcon(MonitorHealth.Unknown)
};
@@ -379,38 +655,142 @@ private Forms.NotifyIcon BuildNotifyIcon()
}
};
+ icon.MouseMove += (_, _) => OnNotifyIconMouseMove();
+
return icon;
}
+ private void OnNotifyIconMouseMove()
+ {
+ if (!CanShowStatusPanelOnTrayHover())
+ {
+ ScheduleHideStatusPanel();
+ return;
+ }
+
+ ShowStatusPanel();
+ EnsureTrayHoverPollTimer();
+ }
+
+ private bool CanShowStatusPanelOnTrayHover() =>
+ Volatile.Read(ref trayMenuOpen) == 0
+ && DateTimeOffset.UtcNow >= trayHoverStatusPanelSuppressedUntil;
+
+ private void EnsureTrayHoverPollTimer()
+ {
+ trayHoverPollTimer ??= new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
+ trayHoverPollTimer.Tick -= TrayHoverPollTick;
+ trayHoverPollTimer.Tick += TrayHoverPollTick;
+ if (!trayHoverPollTimer.IsEnabled)
+ {
+ trayHoverPollTimer.Start();
+ }
+ }
+
+ private void TrayHoverPollTick(object? sender, EventArgs e)
+ {
+ if (!CanShowStatusPanelOnTrayHover())
+ {
+ trayHoverPollTimer?.Stop();
+ hoverPanel?.Hide();
+ return;
+ }
+
+ if (notifyIcon is null)
+ {
+ trayHoverPollTimer?.Stop();
+ hoverPanel?.Hide();
+ return;
+ }
+
+ if (TrayIconShellInterop.TryGetIconScreenBounds(notifyIcon, out _)
+ && !TrayIconShellInterop.IsCursorOverIcon(notifyIcon)
+ && !pointerOverStatusPanel
+ && !statusPanelPinnedAutoFlow)
+ {
+ ScheduleHideStatusPanel();
+ trayHoverPollTimer?.Stop();
+ return;
+ }
+
+ if (hoverPanel is { IsVisible: true })
+ {
+ hoverPanel.Update(orchestrator?.GetHealthSnapshots() ?? []);
+ }
+ }
+
+ private void RefreshStatusPanelIfVisible()
+ {
+ if (hoverPanel is not { IsVisible: true })
+ {
+ return;
+ }
+
+ if (notifyIcon is not null
+ && TrayIconShellInterop.TryGetIconScreenBounds(notifyIcon, out _)
+ && !TrayIconShellInterop.IsCursorOverIcon(notifyIcon)
+ && !pointerOverStatusPanel
+ && !statusPanelPinnedAutoFlow)
+ {
+ ScheduleHideStatusPanel();
+ return;
+ }
+
+ hoverPanel.Update(orchestrator?.GetHealthSnapshots() ?? []);
+ }
+
private void ToggleStatusPanel()
{
CancelStatusPanelTimers();
- TrayScreenPlacement.CaptureFromCursor();
EnsureHoverPanel();
if (hoverPanel is { IsVisible: true })
{
hoverPanel.Hide();
+ statusPanelPinnedAutoFlow = false;
+ statusPanelAutoShownForBuild = false;
+ statusPanelAutoShownForEditGating = false;
+ siteReadyDismissTimer?.Stop();
return;
}
hoverPanel!.Update(orchestrator?.GetHealthSnapshots() ?? []);
- hoverPanel.ShowNearTray();
+ ShowStatusPanelNearTray();
+ }
+
+ private void ShowStatusPanelNearTray()
+ {
+ if (hoverPanel is null)
+ {
+ return;
+ }
+
+ Rectangle? trayIconBounds = null;
+ if (notifyIcon is not null && TrayIconShellInterop.TryGetIconScreenBounds(notifyIcon, out var bounds))
+ {
+ trayIconBounds = bounds;
+ }
+
+ hoverPanel.ShowNearTray(trayIconBounds);
}
private void ShowStatusPanel()
{
CancelStatusPanelTimers();
- TrayScreenPlacement.CaptureFromCursor();
EnsureHoverPanel();
hoverPanel!.Update(orchestrator?.GetHealthSnapshots() ?? []);
- hoverPanel.ShowNearTray();
+ ShowStatusPanelNearTray();
}
private void CancelStatusPanelTimers() => hideStatusPanelTimer?.Stop();
private void ScheduleHideStatusPanel()
{
+ if (statusPanelPinnedAutoFlow)
+ {
+ return;
+ }
+
pointerOverStatusPanel = false;
hideStatusPanelTimer ??= new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) };
hideStatusPanelTimer.Tick -= HideStatusPanelTick;
@@ -422,11 +802,13 @@ private void ScheduleHideStatusPanel()
private void HideStatusPanelTick(object? sender, EventArgs e)
{
hideStatusPanelTimer?.Stop();
- if (!pointerOverStatusPanel)
+ if (statusPanelPinnedAutoFlow || pointerOverStatusPanel)
{
- hoverPanel?.Hide();
- FlushStatusPanelLayout();
+ return;
}
+
+ hoverPanel?.Hide();
+ FlushStatusPanelLayout();
}
private void EnsureHoverPanel()
@@ -595,7 +977,8 @@ private void OpenLogViewer(
string projectId,
string? displayName = null,
BuildLogKind? logKind = null,
- bool selectErrorsFilter = false)
+ bool selectErrorsFilter = false,
+ bool selectWarningsFilter = false)
{
if (orchestrator is null)
{
@@ -634,6 +1017,10 @@ private void OpenLogViewer(
{
existing.SelectErrorsFilter();
}
+ else if (selectWarningsFilter)
+ {
+ existing.SelectWarningsFilter();
+ }
existing.Activate();
existing.Focus();
@@ -666,6 +1053,10 @@ private void OpenLogViewer(
{
viewer.SelectErrorsFilter();
}
+ else if (selectWarningsFilter)
+ {
+ viewer.SelectWarningsFilter();
+ }
}
private void ShowBuildDiagnostics()
@@ -1042,12 +1433,14 @@ private void PlayBuildNotificationSounds(IReadOnlyList sn
}
previousProjectHealth[snapshot.ProjectId] = snapshot.Health;
+ previousProjectLifecycleState[snapshot.ProjectId] = snapshot.State;
}
var activeIds = snapshots.Where(s => s.IsActive).Select(s => s.ProjectId).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var staleId in previousProjectHealth.Keys.Where(id => !activeIds.Contains(id)).ToList())
{
previousProjectHealth.Remove(staleId);
+ previousProjectLifecycleState.Remove(staleId);
}
}
@@ -1161,7 +1554,12 @@ private void ApplyTrayIconFrame()
currentTrayBuilding,
buildIconAnimationFrame,
currentTrayWebReady);
- notifyIcon.Text = TrayTooltipFormatter.Format(currentTrayHeadline, currentTrayHealth, currentTrayBuilding);
+ notifyIcon.Text = string.Empty;
+
+ if (hoverPanel is { IsVisible: true })
+ {
+ RefreshStatusPanelIfVisible();
+ }
}
private static void MigrateLegacyAppDataIfNeeded(string newAppDataDirectory)
diff --git a/src/TrayApp/BuildDiagnosticsWindow.xaml b/src/TrayApp/BuildDiagnosticsWindow.xaml
index ea49a34..b527550 100644
--- a/src/TrayApp/BuildDiagnosticsWindow.xaml
+++ b/src/TrayApp/BuildDiagnosticsWindow.xaml
@@ -30,7 +30,7 @@
TextWrapping="Wrap"
Opacity="0.8"
FontSize="11"
- Text="One tab per project — rebuild timing at the top, today's triggers below. Mark unexpected triggers to spot spurious rebuilds." />
+ Text="One tab per active project — rebuild timing at the top, today's triggers below. Mark unexpected triggers to spot spurious rebuilds." />
@@ -58,7 +58,9 @@
CanUserDeleteRows="False"
SelectionMode="Single"
HeadersVisibility="Column"
- GridLinesVisibility="Horizontal">
+ GridLinesVisibility="Horizontal"
+ Loaded="TriggersGridLoaded"
+ LayoutUpdated="TriggersGridLayoutUpdated">
@@ -75,6 +77,7 @@
BorderThickness="0"
Background="Transparent"
Tag="{Binding}"
+ GotFocus="UserNoteGotFocus"
LostFocus="UserNoteLostFocus" />
@@ -110,7 +113,7 @@
ApplyTheme(theme);
private void ApplyTheme(ResolvedTheme theme)
@@ -98,16 +150,9 @@ private void RefreshAll()
var snapshots = orchestrator.GetBuildIntelligenceSnapshots();
var unexpectedOnly = UnexpectedOnlyCheck.IsChecked == true;
var entries = journal.GetEntries();
- var projectIds = snapshots.Select(s => s.ProjectId)
- .Concat(entries.Select(e => e.ProjectId))
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToList();
-
var order = snapshots
.OrderBy(s => s.ProjectDisplayName, StringComparer.OrdinalIgnoreCase)
.Select(s => s.ProjectId)
- .Concat(projectIds.Where(id => snapshots.All(s => !s.ProjectId.Equals(id, StringComparison.OrdinalIgnoreCase))))
- .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
suppressSelectionTracking = true;
@@ -154,7 +199,14 @@ private void RefreshAll()
new GlobalMonitorSettings(),
new FileChangeBurstStats());
- tab.RefreshTriggers(entries, unexpectedOnly, journal);
+ if (!IsNoteEditorFocused())
+ {
+ tab.RefreshTriggers(entries, unexpectedOnly, journal);
+ }
+ else
+ {
+ refreshTriggersAfterNoteEdit = true;
+ }
}
var hasProjects = projectTabs.Count > 0;
@@ -208,12 +260,41 @@ private void ClearVerdictClicked(object sender, RoutedEventArgs e)
}
}
+ private void UserNoteGotFocus(object sender, RoutedEventArgs e)
+ {
+ if (sender is WpfTextBox { Tag: BuildTriggerRowViewModel })
+ {
+ noteEditorFocused = true;
+ }
+ }
+
private void UserNoteLostFocus(object sender, RoutedEventArgs e)
{
- if (sender is WpfTextBox { Tag: BuildTriggerRowViewModel row })
+ if (sender is not WpfTextBox { Tag: BuildTriggerRowViewModel row })
+ {
+ return;
+ }
+
+ row.SaveUserNote();
+ noteEditorFocused = false;
+
+ if (!refreshTriggersAfterNoteEdit)
+ {
+ return;
+ }
+
+ refreshTriggersAfterNoteEdit = false;
+ RefreshAll();
+ }
+
+ private bool IsNoteEditorFocused()
+ {
+ if (noteEditorFocused)
{
- row.SaveUserNote();
+ return true;
}
+
+ return Keyboard.FocusedElement is WpfTextBox { Tag: BuildTriggerRowViewModel };
}
private void FilterChanged(object sender, RoutedEventArgs e) => RefreshAll();
@@ -274,16 +355,40 @@ public void RefreshTriggers(
bool unexpectedOnly,
BuildTriggerJournal triggerJournal)
{
- Triggers.Clear();
- foreach (var entry in entries.Where(e =>
- e.ProjectId.Equals(ProjectId, StringComparison.OrdinalIgnoreCase)))
+ var filtered = entries
+ .Where(e => e.ProjectId.Equals(ProjectId, StringComparison.OrdinalIgnoreCase))
+ .Where(e => !unexpectedOnly || e.Verdict == BuildTriggerVerdict.Unexpected)
+ .ToList();
+
+ var desiredIds = new HashSet(
+ filtered.Select(e => e.Id),
+ StringComparer.OrdinalIgnoreCase);
+
+ for (var i = Triggers.Count - 1; i >= 0; i--)
{
- if (unexpectedOnly && entry.Verdict != BuildTriggerVerdict.Unexpected)
+ if (!desiredIds.Contains(Triggers[i].Record.Id))
{
+ Triggers.RemoveAt(i);
+ }
+ }
+
+ for (var i = 0; i < filtered.Count; i++)
+ {
+ var entry = filtered[i];
+ var existing = Triggers.FirstOrDefault(t =>
+ t.Record.Id.Equals(entry.Id, StringComparison.OrdinalIgnoreCase));
+ if (existing is null)
+ {
+ Triggers.Insert(i, new BuildTriggerRowViewModel(entry, triggerJournal));
continue;
}
- Triggers.Add(new BuildTriggerRowViewModel(entry, triggerJournal));
+ existing.SyncFrom(entry);
+ var currentIndex = Triggers.IndexOf(existing);
+ if (currentIndex != i)
+ {
+ Triggers.Move(currentIndex, i);
+ }
}
}
@@ -294,16 +399,17 @@ private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =
private sealed class BuildTriggerRowViewModel : INotifyPropertyChanged
{
private readonly BuildTriggerJournal journal;
+ private BuildTriggerRecord record;
private string userNote;
public BuildTriggerRowViewModel(BuildTriggerRecord record, BuildTriggerJournal journal)
{
- Record = record;
+ this.record = record;
this.journal = journal;
userNote = record.UserNote ?? string.Empty;
}
- public BuildTriggerRecord Record { get; }
+ public BuildTriggerRecord Record => record;
public event PropertyChangedEventHandler? PropertyChanged;
@@ -349,7 +455,56 @@ public string UserNote
}
}
- public void SaveUserNote() => journal.SetUserNote(Record.Id, userNote);
+ public void SaveUserNote() => journal.SetUserNote(record.Id, userNote);
+
+ public void SyncFrom(BuildTriggerRecord latest)
+ {
+ if (record.Id.Equals(latest.Id, StringComparison.OrdinalIgnoreCase)
+ && record.Verdict == latest.Verdict
+ && string.Equals(record.UserNote, latest.UserNote, StringComparison.Ordinal)
+ && string.Equals(record.Summary, latest.Summary, StringComparison.Ordinal)
+ && string.Equals(record.Detail, latest.Detail, StringComparison.Ordinal)
+ && string.Equals(record.InferredCause, latest.InferredCause, StringComparison.Ordinal)
+ && record.OccurredAtUtc == latest.OccurredAtUtc
+ && PathsEqual(record.ChangedPaths, latest.ChangedPaths))
+ {
+ return;
+ }
+
+ record = latest;
+ userNote = latest.UserNote ?? string.Empty;
+ OnPropertyChanged(nameof(WhenLocal));
+ OnPropertyChanged(nameof(KindLabel));
+ OnPropertyChanged(nameof(Summary));
+ OnPropertyChanged(nameof(InferredCause));
+ OnPropertyChanged(nameof(Detail));
+ OnPropertyChanged(nameof(ChangedPathsText));
+ OnPropertyChanged(nameof(VerdictLabel));
+ OnPropertyChanged(nameof(UserNote));
+ }
+
+ private static bool PathsEqual(IReadOnlyList? left, IReadOnlyList? right)
+ {
+ if (left is null || left.Count == 0)
+ {
+ return right is null || right.Count == 0;
+ }
+
+ if (right is null || left.Count != right.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < left.Count; i++)
+ {
+ if (!string.Equals(left[i], right[i], StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
diff --git a/src/TrayApp/BuildIntelligencePanel.xaml b/src/TrayApp/BuildIntelligencePanel.xaml
index ce3ecf5..1ffa958 100644
--- a/src/TrayApp/BuildIntelligencePanel.xaml
+++ b/src/TrayApp/BuildIntelligencePanel.xaml
@@ -5,7 +5,7 @@
Focusable="False"
IsTabStop="False"
KeyboardNavigation.TabNavigation="None"
- MinHeight="88">
+ MinHeight="140">
@@ -23,6 +23,14 @@
+
-
+
-
-
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+ Text="Save burst length"
+ ToolTip="Each bar is how long a multi-file save burst lasted." />
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
"Build in progress…"
};
FooterText.Text = FormatFooterText(
- allIssues.Count(i => i.IsError),
- allIssues.Count(i => !i.IsError),
+ resolvedDisplayErrorCount,
+ resolvedDisplayWarningCount,
isLive: true);
if (followTail)
@@ -300,10 +328,9 @@ private async Task LoadSelectedLogCoreAsync(BuildLogKind kind)
await logStore.LoadLogTextAsync(currentRecord, maxDisplayBytes));
allIssues = ParseIssuesForCurrentLog();
RenderLogText();
+ RefreshResolvedIssueCounts();
ApplyIssueFilter(selectFirstIssue: allIssues.Count > 0);
- var errorCount = allIssues.Count(i => i.IsError);
- var warningCount = allIssues.Count(i => !i.IsError);
var finishedLocal = BuildTimestampFormatter.FormatLocal(currentRecord.FinishedAtUtc);
var kindLabel = kind switch
{
@@ -313,7 +340,7 @@ private async Task LoadSelectedLogCoreAsync(BuildLogKind kind)
};
BuildTimeText.Text = $"{kindLabel}: {finishedLocal}";
FooterText.Text =
- $"{currentRecord.CommandLine} | exit {currentRecord.ExitCode} | {FormatFooterText(errorCount, warningCount, isLive: false)} | duration {currentRecord.FinishedAtUtc - currentRecord.StartedAtUtc:g}";
+ $"{currentRecord.CommandLine} | exit {currentRecord.ExitCode} | {FormatFooterText(resolvedDisplayErrorCount, resolvedDisplayWarningCount, isLive: false)} | duration {currentRecord.FinishedAtUtc - currentRecord.StartedAtUtc:g}";
if (ShouldFollowOutput())
{
@@ -373,21 +400,25 @@ private void ApplyIssueFilter(bool selectFirstIssue = false)
ErrorsList.ItemsSource = visibleIssues;
- var errorCount = allIssues.Count(i => i.IsError);
- var warningCount = allIssues.Count(i => !i.IsError);
+ var errorCount = resolvedDisplayErrorCount;
+ var warningCount = resolvedDisplayWarningCount;
UpdateErrorBanner(errorCount);
+ var carryNote = issuesCarriedFromPreviousBuild
+ ? " — issues from last full compile (this build reported 0/0)"
+ : string.Empty;
+
IssueSummaryText.Text = filter switch
{
IssueFilter.Errors => currentLogKind == BuildLogKind.Test
- ? FormatIssueCountLabel("failure", "failures", visibleIssues.Count, errorCount)
- : FormatIssueCountLabel("error", "errors", visibleIssues.Count, errorCount),
+ ? FormatIssueCountLabel("failure", "failures", visibleIssues.Count, errorCount) + carryNote
+ : FormatIssueCountLabel("error", "errors", visibleIssues.Count, errorCount) + carryNote,
IssueFilter.Warnings => currentLogKind == BuildLogKind.Test
- ? FormatIssueCountLabel("skipped test", "skipped tests", visibleIssues.Count, warningCount)
- : FormatIssueCountLabel("warning", "warnings", visibleIssues.Count, warningCount),
+ ? FormatIssueCountLabel("skipped test", "skipped tests", visibleIssues.Count, warningCount) + carryNote
+ : FormatIssueCountLabel("warning", "warnings", visibleIssues.Count, warningCount) + carryNote,
_ => currentLogKind == BuildLogKind.Test
- ? $"{errorCount} failed, {warningCount} skipped"
- : $"{errorCount} errors, {warningCount} warnings",
+ ? $"{errorCount} failed, {warningCount} skipped{carryNote}"
+ : $"{errorCount} errors, {warningCount} warnings{carryNote}",
};
if (filter == IssueFilter.Errors && errorCount == 0)
@@ -407,13 +438,49 @@ private void ApplyIssueFilter(bool selectFirstIssue = false)
}
}
- private IReadOnlyList ParseIssuesForCurrentLog() =>
- currentLogKind switch
+ private IReadOnlyList ParseIssuesForCurrentLog()
+ {
+ var logPath = currentRecord?.LogFilePath ?? logStore.GetLogPath(projectId, currentLogKind);
+ return currentLogKind switch
{
BuildLogKind.Test => DotNetTestOutputParser.ParseIssues(currentLogText),
BuildLogKind.Run => DotNetRunOutputParser.ParseIssues(currentLogText),
- _ => BuildLogParser.ParseIssues(currentLogText)
+ _ => BuildLogParser.ResolveBuildIssues(currentLogText, logPath)
};
+ }
+
+ private void RefreshResolvedIssueCounts(LiveBuildLogView? live = null)
+ {
+ var parsedErrors = allIssues.Count(i => i.IsError);
+ var parsedWarnings = allIssues.Count(i => !i.IsError);
+
+ if (currentLogKind is BuildLogKind.Test or BuildLogKind.Run)
+ {
+ issuesCarriedFromPreviousBuild = false;
+ resolvedDisplayErrorCount = parsedErrors;
+ resolvedDisplayWarningCount = parsedWarnings;
+ return;
+ }
+
+ var logPath = currentRecord?.LogFilePath ?? logStore.GetLogPath(projectId, currentLogKind);
+ var resolved = BuildIssueCountResolver.Resolve(currentLogText, logPath);
+ var metaErrors = live?.ErrorCount ?? currentRecord?.ErrorCount ?? 0;
+ var metaWarnings = live?.WarningCount ?? currentRecord?.WarningCount ?? 0;
+ var note = BuildLogParser.TryParseIncrementalHealthNote(currentLogText);
+
+ resolvedDisplayErrorCount = Math.Max(
+ parsedErrors,
+ Math.Max(metaErrors, Math.Max(note.Errors, resolved.Errors)));
+ resolvedDisplayWarningCount = Math.Max(
+ parsedWarnings,
+ Math.Max(metaWarnings, Math.Max(note.Warnings, resolved.Warnings)));
+
+ issuesCarriedFromPreviousBuild =
+ IncrementalBuildDetector.WasCompileSkipped(currentLogText)
+ && resolvedDisplayWarningCount + resolvedDisplayErrorCount > 0
+ && BuildLogParser.ParseWarningCount(currentLogText) == 0
+ && BuildLogParser.ParseErrorCount(currentLogText) == 0;
+ }
private string FormatFooterText(int errorCount, int warningCount, bool isLive)
{
diff --git a/src/TrayApp/HoverStatusPanel.xaml b/src/TrayApp/HoverStatusPanel.xaml
index 4536ce2..0906e12 100644
--- a/src/TrayApp/HoverStatusPanel.xaml
+++ b/src/TrayApp/HoverStatusPanel.xaml
@@ -2,9 +2,9 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Build Status"
- Width="480"
- MinHeight="120"
- MaxHeight="520"
+ Width="400"
+ MinHeight="96"
+ MaxHeight="460"
WindowStyle="None"
SizeToContent="Manual"
AllowsTransparency="True"
@@ -12,9 +12,9 @@
ResizeMode="NoResize"
ShowInTaskbar="False"
Topmost="True">
-
+
-
+
diff --git a/src/TrayApp/HoverStatusPanel.xaml.cs b/src/TrayApp/HoverStatusPanel.xaml.cs
index fd69e78..18a38b9 100644
--- a/src/TrayApp/HoverStatusPanel.xaml.cs
+++ b/src/TrayApp/HoverStatusPanel.xaml.cs
@@ -1,10 +1,10 @@
-using System.Diagnostics;
+using System.Drawing;
using System.Windows;
using System.Windows.Controls;
-using System.Windows.Documents;
using System.Windows.Media;
+using System.Windows.Threading;
using BuildMonitor.Core.Models;
-using BuildMonitor.Infrastructure.LocalBuild;
+using BuildMonitor.Core.Rules;
using BuildMonitor.TrayApp.Services;
using WpfBrush = System.Windows.Media.Brush;
using WpfColor = System.Windows.Media.Color;
@@ -18,6 +18,9 @@ namespace BuildMonitor.TrayApp;
public partial class HoverStatusPanel : Window
{
private ResolvedTheme currentTheme = ResolvedTheme.Light;
+ private readonly DispatcherTimer countdownTimer;
+ private IReadOnlyList lastSnapshots = [];
+ private Rectangle? lastTrayIconBounds;
public event Action? ViewLogRequested;
public event Action? CopyErrorsRequested;
@@ -28,6 +31,9 @@ public partial class HoverStatusPanel : Window
public HoverStatusPanel()
{
InitializeComponent();
+ countdownTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
+ countdownTimer.Tick += (_, _) => OnCountdownTick();
+ Closed += (_, _) => countdownTimer.Stop();
}
public void ApplyTheme(ResolvedTheme theme)
@@ -47,6 +53,7 @@ private static SolidColorBrush BrushFromResource(string key, WpfColor fallback)
public void Update(IReadOnlyList snapshots)
{
+ lastSnapshots = snapshots;
var palette = ThemeService.GetPalette(currentTheme);
ProjectCards.Items.Clear();
@@ -58,8 +65,8 @@ public void Update(IReadOnlyList snapshots)
{
BorderBrush = new SolidColorBrush(palette.Border),
BorderThickness = new Thickness(1),
- Margin = new Thickness(0, 0, 0, 8),
- Padding = new Thickness(8),
+ Margin = new Thickness(0, 0, 0, 5),
+ Padding = new Thickness(6, 5, 6, 5),
Background = new SolidColorBrush(palette.CardBackground)
};
@@ -68,44 +75,78 @@ public void Update(IReadOnlyList snapshots)
{
Text = snapshot.DisplayName,
FontWeight = FontWeights.SemiBold,
+ FontSize = 12,
Foreground = new SolidColorBrush(palette.Foreground)
});
+
+ var statusLine = snapshot.IsRestarting
+ ? "Restarting app…"
+ : snapshot.State == ProjectLifecycleState.WaitingForEdits
+ ? $"Waiting — {snapshot.HealthLabel}"
+ : $"{snapshot.HealthLabel} — {snapshot.State}";
+ var issueSuffix = snapshot.IssueCountsText
+ ?? (snapshot.ErrorCount > 0 || snapshot.WarningCount > 0
+ ? $" · {snapshot.ErrorCount}e / {snapshot.WarningCount}w"
+ : string.Empty);
panel.Children.Add(new TextBlock
{
- Text = $"{snapshot.HealthLabel} — {snapshot.State}",
+ Text = statusLine + issueSuffix,
Foreground = healthBrush,
- Margin = new Thickness(0, 4, 0, 0)
- });
- panel.Children.Add(new TextBlock
- {
- Text = snapshot.IsRestarting
- ? "Restarting app…"
- : snapshot.IssueCountsText
- ?? $"Errors: {snapshot.ErrorCount} | Warnings: {snapshot.WarningCount}",
- Foreground = new SolidColorBrush(palette.Foreground),
- Opacity = 0.85,
+ FontSize = 11,
Margin = new Thickness(0, 2, 0, 0)
});
panel.Children.Add(new TextBlock
{
Text = FormatLastBuildLine(snapshot),
Foreground = new SolidColorBrush(palette.Foreground),
- Opacity = 0.85,
- Margin = new Thickness(0, 2, 0, 0)
+ Opacity = 0.8,
+ FontSize = 11,
+ Margin = new Thickness(0, 1, 0, 0)
});
- if (snapshot.SupportsAppRestart
- && !string.IsNullOrWhiteSpace(snapshot.ListenUrl)
- && ShouldShowListenUrl(snapshot))
+ if (!string.IsNullOrWhiteSpace(snapshot.EditGatingDetailText)
+ || snapshot.RebuildQuietUntilUtc is not null)
+ {
+ var countdown = EditGatingDetailFormatter.FormatCountdownRemaining(
+ snapshot.RebuildQuietUntilUtc,
+ DateTimeOffset.UtcNow);
+ if (!string.IsNullOrWhiteSpace(snapshot.EditGatingDetailText))
+ {
+ panel.Children.Add(new TextBlock
+ {
+ Text = snapshot.EditGatingDetailText,
+ Foreground = new SolidColorBrush(palette.Foreground),
+ Opacity = 0.9,
+ FontSize = 11,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(0, 3, 0, 0)
+ });
+ }
+
+ if (!string.IsNullOrWhiteSpace(countdown))
+ {
+ panel.Children.Add(new TextBlock
+ {
+ Text = countdown,
+ Foreground = new SolidColorBrush(WpfColor.FromRgb(255, 193, 7)),
+ FontWeight = FontWeights.SemiBold,
+ FontSize = 11,
+ Margin = new Thickness(0, 2, 0, 0)
+ });
+ }
+ }
+
+ if (StatusPanelBuildVisibilityEvaluator.ShouldShowSiteStatus(snapshot))
{
- var showLink = snapshot.ListenUrlReady
- && snapshot.State is ProjectLifecycleState.Running or ProjectLifecycleState.Watching;
- panel.Children.Add(showLink
- ? BuildListenUrlBlock(snapshot.ListenUrl, palette)
- : BuildListenUrlPendingBlock(snapshot.ListenUrl, palette));
+ panel.Children.Add(snapshot.ListenUrlReady
+ ? StatusPanelVisuals.BuildSiteReadyBlock(snapshot.ListenUrl!, palette)
+ : StatusPanelVisuals.BuildSiteAwaitingBlock(snapshot.ListenUrl!, palette));
}
- if (snapshot.ProgressSteps.Count > 0)
+ if (snapshot.ProgressSteps.Count > 0
+ && snapshot.State is ProjectLifecycleState.Building
+ or ProjectLifecycleState.Testing
+ or ProjectLifecycleState.BuildFailed)
{
panel.Children.Add(StatusPanelVisuals.BuildStepProgressChart(snapshot.ProgressSteps, palette));
}
@@ -115,8 +156,9 @@ public void Update(IReadOnlyList snapshots)
{
Text = snapshot.LastErrorPreview,
TextWrapping = TextWrapping.Wrap,
+ FontSize = 11,
Foreground = new SolidColorBrush(WpfColor.FromRgb(220, 53, 69)),
- Margin = new Thickness(0, 4, 0, 0)
+ Margin = new Thickness(0, 3, 0, 0)
});
}
else if (snapshot.State is ProjectLifecycleState.Building or ProjectLifecycleState.Testing)
@@ -125,13 +167,22 @@ public void Update(IReadOnlyList snapshots)
}
else if (snapshot.ErrorCount > 0 || snapshot.WarningCount > 0)
{
- panel.Children.Add(StatusPanelVisuals.BuildIssueMeter(snapshot.ErrorCount, snapshot.WarningCount, palette));
+ panel.Children.Add(StatusPanelVisuals.BuildIssueSummary(snapshot.ErrorCount, snapshot.WarningCount, palette));
+ }
+
+ if (snapshot.ProgressSteps.Count > 0
+ && snapshot.State is ProjectLifecycleState.Building
+ or ProjectLifecycleState.Testing
+ or ProjectLifecycleState.BuildFailed
+ && (snapshot.ErrorCount > 0 || snapshot.WarningCount > 0))
+ {
+ panel.Children.Add(StatusPanelVisuals.BuildIssueSummary(snapshot.ErrorCount, snapshot.WarningCount, palette));
}
var actions = new StackPanel
{
Orientation = WpfOrientation.Horizontal,
- Margin = new Thickness(0, 6, 0, 0)
+ Margin = new Thickness(0, 4, 0, 0)
};
var viewLog = new WpfButton
@@ -210,18 +261,51 @@ public void Update(IReadOnlyList snapshots)
var activeCount = snapshots.Count(s => s.IsActive);
HeaderText.Text = activeCount switch
{
- 0 => "Local build status",
- 1 => "Local build status",
- _ => $"Local build status ({activeCount} projects)"
+ 0 => "Build status",
+ 1 => "Build status",
+ _ => $"Build status ({activeCount})"
};
FitHeightToContent();
+ SyncCountdownTimer(snapshots);
+ ApplyTrayPlacement();
+ }
+
+ private void OnCountdownTick()
+ {
+ if (!IsVisible || !HasActiveCountdown(lastSnapshots))
+ {
+ countdownTimer.Stop();
+ return;
+ }
+
+ Update(lastSnapshots);
+ }
+
+ private void SyncCountdownTimer(IReadOnlyList snapshots)
+ {
+ if (IsVisible && HasActiveCountdown(snapshots))
+ {
+ if (!countdownTimer.IsEnabled)
+ {
+ countdownTimer.Start();
+ }
+ }
+ else
+ {
+ countdownTimer.Stop();
+ }
}
+ private static bool HasActiveCountdown(IReadOnlyList snapshots) =>
+ snapshots.Any(s =>
+ s.RebuildQuietUntilUtc is { } until
+ && until > DateTimeOffset.UtcNow);
+
private void FitHeightToContent()
{
- const double chrome = 52;
- var innerWidth = Math.Max(200, Width - 22);
+ const double chrome = 40;
+ var innerWidth = Math.Max(180, Width - 18);
ProjectCards.Measure(new WpfSize(innerWidth, double.PositiveInfinity));
var contentHeight = ProjectCards.DesiredSize.Height;
var maxBody = MaxHeight - chrome;
@@ -240,59 +324,6 @@ private void FitHeightToContent()
}
}
- private static bool ShouldShowListenUrl(ProjectHealthSnapshot snapshot) =>
- snapshot.IsRestarting
- || snapshot.State is ProjectLifecycleState.Running
- or ProjectLifecycleState.Watching
- or ProjectLifecycleState.Building
- or ProjectLifecycleState.BuildOk;
-
- private static UIElement BuildListenUrlPendingBlock(string listenUrl, ThemePalette palette) =>
- new TextBlock
- {
- Text = $"Starting {listenUrl}…",
- Foreground = new SolidColorBrush(palette.Foreground) { Opacity = 0.7 },
- Margin = new Thickness(0, 2, 0, 0)
- };
-
- private static UIElement BuildListenUrlBlock(string listenUrl, ThemePalette palette)
- {
- var openUrl = LocalPortProbe.NormalizeBrowserUrl(listenUrl);
- var labelBrush = new SolidColorBrush(palette.Foreground) { Opacity = 0.9 };
- var block = new TextBlock { Margin = new Thickness(0, 2, 0, 0) };
- block.Inlines.Add(new Run("URL: ") { Foreground = labelBrush });
-
- if (!Uri.TryCreate(openUrl, UriKind.Absolute, out var uri))
- {
- block.Inlines.Add(new Run(openUrl) { Foreground = labelBrush });
- return block;
- }
-
- var link = new Hyperlink
- {
- NavigateUri = uri,
- Foreground = new SolidColorBrush(palette.Accent),
- TextDecorations = TextDecorations.Underline,
- Cursor = System.Windows.Input.Cursors.Hand
- };
- link.Inlines.Add(openUrl);
- link.RequestNavigate += (_, e) =>
- {
- try
- {
- Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
- }
- catch
- {
- // Best effort only.
- }
-
- e.Handled = true;
- };
- block.Inlines.Add(link);
- return block;
- }
-
private static string FormatLastBuildLine(ProjectHealthSnapshot snapshot)
{
var isBuilding = snapshot.State is ProjectLifecycleState.Building or ProjectLifecycleState.Testing;
@@ -325,14 +356,32 @@ public void ApplyLayout(WindowLayoutState layout)
public void CaptureLayout(WindowLayoutState layout) =>
WindowLayoutService.Capture(this, layout, sizeOnly: true);
- public void ShowNearTray()
+ public void ShowNearTray(Rectangle? trayIconBounds = null)
{
- TrayScreenPlacement.PlaceNearTrayBottomRight(this);
+ if (trayIconBounds is { Width: > 0, Height: > 0 } bounds)
+ {
+ lastTrayIconBounds = bounds;
+ }
+
if (!IsVisible)
{
Show();
}
FitHeightToContent();
+ SyncCountdownTimer(lastSnapshots);
+ ApplyTrayPlacement();
+ }
+
+ private void ApplyTrayPlacement()
+ {
+ if (lastTrayIconBounds is { Width: > 0, Height: > 0 } bounds)
+ {
+ TrayScreenPlacement.PlaceAboveTrayIcon(this, bounds);
+ }
+ else
+ {
+ TrayScreenPlacement.PlaceNearTrayBottomRight(this);
+ }
}
}
diff --git a/src/TrayApp/Services/AppWindowsLayout.cs b/src/TrayApp/Services/AppWindowsLayout.cs
index 465e531..c445d13 100644
--- a/src/TrayApp/Services/AppWindowsLayout.cs
+++ b/src/TrayApp/Services/AppWindowsLayout.cs
@@ -11,6 +11,11 @@ public class WindowLayoutState
public int WindowState { get; set; }
}
+public sealed class DiagnosticsWindowLayoutState : WindowLayoutState
+{
+ public Dictionary TriggerGridColumnWidths { get; set; } = new(StringComparer.Ordinal);
+}
+
public sealed class BuildLogViewerLayoutState : WindowLayoutState
{
public double LogPanelRatio { get; set; } = 0.65;
@@ -21,7 +26,7 @@ public sealed class AppWindowsLayout
{
public BuildLogViewerLayoutState BuildLog { get; set; } = new();
public WindowLayoutState Settings { get; set; } = new();
- public WindowLayoutState Diagnostics { get; set; } = new();
+ public DiagnosticsWindowLayoutState Diagnostics { get; set; } = new();
[JsonPropertyName("threadHealth")]
public WindowLayoutState BuildMonitorHealth { get; set; } = new();
public WindowLayoutState StatusPanel { get; set; } = new() { Width = 480, Height = 420 };
diff --git a/src/TrayApp/Services/DiagnosticsGridLayoutService.cs b/src/TrayApp/Services/DiagnosticsGridLayoutService.cs
new file mode 100644
index 0000000..175d7c7
--- /dev/null
+++ b/src/TrayApp/Services/DiagnosticsGridLayoutService.cs
@@ -0,0 +1,52 @@
+using System.Windows.Controls;
+using WpfDataGrid = System.Windows.Controls.DataGrid;
+
+namespace BuildMonitor.TrayApp.Services;
+
+internal static class DiagnosticsGridLayoutService
+{
+ private static readonly string[] ColumnKeys =
+ [
+ "When",
+ "Kind",
+ "Summary",
+ "Files",
+ "LikelyCause",
+ "Detail",
+ "Verdict",
+ "YourNote",
+ "Mark"
+ ];
+
+ public static void ApplyColumnWidths(WpfDataGrid grid, IReadOnlyDictionary? savedWidths)
+ {
+ if (savedWidths is null || savedWidths.Count == 0)
+ {
+ return;
+ }
+
+ for (var i = 0; i < grid.Columns.Count && i < ColumnKeys.Length; i++)
+ {
+ var key = ColumnKeys[i];
+ if (savedWidths.TryGetValue(key, out var width) && width >= 40)
+ {
+ grid.Columns[i].Width = new DataGridLength(width);
+ }
+ }
+ }
+
+ public static Dictionary CaptureColumnWidths(WpfDataGrid grid)
+ {
+ var widths = new Dictionary(StringComparer.Ordinal);
+ for (var i = 0; i < grid.Columns.Count && i < ColumnKeys.Length; i++)
+ {
+ var width = grid.Columns[i].ActualWidth;
+ if (width >= 40)
+ {
+ widths[ColumnKeys[i]] = width;
+ }
+ }
+
+ return widths;
+ }
+}
diff --git a/src/TrayApp/Services/SettingsStore.cs b/src/TrayApp/Services/SettingsStore.cs
index fffdfd8..f3ccaf2 100644
--- a/src/TrayApp/Services/SettingsStore.cs
+++ b/src/TrayApp/Services/SettingsStore.cs
@@ -1,5 +1,6 @@
using System.IO;
using System.Text.Json;
+using BuildMonitor.Core.Models;
using BuildMonitor.Core.Settings;
namespace BuildMonitor.TrayApp.Services;
@@ -89,9 +90,49 @@ public async Task LoadOrCreateDefaultAsync()
settings.SchemaVersion = 10;
}
+ if (settings.SchemaVersion < 11)
+ {
+ MigrateAutoOpenLog(json, settings);
+ settings.SchemaVersion = 11;
+ }
+
+ if (settings.SchemaVersion < 12)
+ {
+ settings.SchemaVersion = 12;
+ }
+
+ if (settings.SchemaVersion < 13)
+ {
+ settings.SchemaVersion = 13;
+ }
+
return settings;
}
+ private static void MigrateAutoOpenLog(string json, AppSettings settings)
+ {
+ var legacyErrorsOnly = false;
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("monitor", out var monitor)
+ && monitor.TryGetProperty("autoOpenLogOnFailure", out var legacy))
+ {
+ legacyErrorsOnly = legacy.GetBoolean();
+ }
+ }
+ catch (JsonException)
+ {
+ // keep default false
+ }
+
+ var migratedMode = legacyErrorsOnly ? AutoOpenLogMode.Errors : AutoOpenLogMode.Never;
+ foreach (var project in settings.Projects)
+ {
+ project.RunOptions.AutoOpenLog = migratedMode;
+ }
+ }
+
private static void MigrateStartOnLaunch(string json, AppSettings settings)
{
var autoStart = true;
@@ -143,7 +184,7 @@ private static void MigrateAutoOpenBuildMonitorHealth(string json, AppSettings s
public Task SaveAsync(AppSettings settings)
{
- settings.SchemaVersion = 10;
+ settings.SchemaVersion = 13;
var json = JsonSerializer.Serialize(settings, JsonOptions);
return File.WriteAllTextAsync(settingsPath, json);
}
diff --git a/src/TrayApp/Services/StatusPanelVisuals.cs b/src/TrayApp/Services/StatusPanelVisuals.cs
index 7c0c0ff..ed5c6ba 100644
--- a/src/TrayApp/Services/StatusPanelVisuals.cs
+++ b/src/TrayApp/Services/StatusPanelVisuals.cs
@@ -1,8 +1,11 @@
+using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Shapes;
using BuildMonitor.Core.Models;
+using BuildMonitor.Infrastructure.LocalBuild;
using WpfColor = System.Windows.Media.Color;
using WpfHorizontalAlignment = System.Windows.HorizontalAlignment;
using WpfOrientation = System.Windows.Controls.Orientation;
@@ -14,19 +17,19 @@ internal static class StatusPanelVisuals
{
public static UIElement BuildStepProgressChart(IReadOnlyList steps, ThemePalette palette)
{
- var container = new StackPanel { Margin = new Thickness(0, 8, 0, 0) };
+ var container = new StackPanel { Margin = new Thickness(0, 4, 0, 0) };
container.Children.Add(new TextBlock
{
Text = "Build pipeline",
- FontSize = 11,
+ FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(palette.Foreground),
Opacity = 0.9,
- Margin = new Thickness(0, 0, 0, 6)
+ Margin = new Thickness(0, 0, 0, 4)
});
- var bar = new Grid { Height = 10 };
+ var bar = new Grid { Height = 8 };
foreach (var _ in steps)
{
bar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
@@ -47,7 +50,7 @@ public static UIElement BuildStepProgressChart(IReadOnlyList
container.Children.Add(bar);
- var legend = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) };
+ var legend = new WrapPanel { Margin = new Thickness(0, 4, 0, 0) };
foreach (var step in steps)
{
legend.Children.Add(new TextBlock
@@ -63,21 +66,21 @@ public static UIElement BuildStepProgressChart(IReadOnlyList
return container;
}
- public static UIElement BuildIssueMeter(int errors, int warnings, ThemePalette palette)
+ public static UIElement BuildIssueSummary(int errors, int warnings, ThemePalette palette)
{
- var container = new StackPanel { Margin = new Thickness(0, 8, 0, 0) };
- container.Children.Add(new TextBlock
- {
- Text = "Issue counts",
- FontSize = 11,
- FontWeight = FontWeights.SemiBold,
- Foreground = new SolidColorBrush(palette.Foreground),
- Opacity = 0.9,
- Margin = new Thickness(0, 0, 0, 6)
- });
-
- container.Children.Add(MeterRow("Errors", errors, palette, WpfColor.FromRgb(220, 53, 69)));
- container.Children.Add(MeterRow("Warnings", warnings, palette, WpfColor.FromRgb(255, 193, 7)));
+ var container = new WrapPanel { Margin = new Thickness(0, 4, 0, 0) };
+ container.Children.Add(IssueChip(
+ errors,
+ errors == 1 ? "error" : "errors",
+ WpfColor.FromRgb(220, 53, 69),
+ palette,
+ errors > 0));
+ container.Children.Add(IssueChip(
+ warnings,
+ warnings == 1 ? "warning" : "warnings",
+ WpfColor.FromRgb(255, 193, 7),
+ palette,
+ warnings > 0));
return container;
}
@@ -97,20 +100,20 @@ public static UIElement BuildActivityIndicator(ProjectLifecycleState state, Them
_ => WpfColor.FromRgb(255, 193, 7)
};
- var panel = new StackPanel { Margin = new Thickness(0, 8, 0, 0) };
+ var panel = new StackPanel { Margin = new Thickness(0, 4, 0, 0) };
panel.Children.Add(new TextBlock
{
Text = label,
- FontSize = 11,
+ FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(palette.Foreground),
Opacity = 0.9,
- Margin = new Thickness(0, 0, 0, 6)
+ Margin = new Thickness(0, 0, 0, 4)
});
var track = new Grid
{
- Height = 8,
+ Height = 6,
Background = new SolidColorBrush(palette.Border) { Opacity = 0.35 }
};
track.Children.Add(new WpfRectangle
@@ -125,57 +128,45 @@ public static UIElement BuildActivityIndicator(ProjectLifecycleState state, Them
return panel;
}
- private static UIElement MeterRow(string label, int value, ThemePalette palette, WpfColor fill)
+ private static UIElement IssueChip(
+ int count,
+ string label,
+ WpfColor accent,
+ ThemePalette palette,
+ bool emphasized)
{
- var row = new Grid { Margin = new Thickness(0, 0, 0, 4) };
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(68) });
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) });
-
- row.Children.Add(new TextBlock
- {
- Text = label,
- FontSize = 10,
- VerticalAlignment = VerticalAlignment.Center,
- Foreground = new SolidColorBrush(palette.Foreground),
- Opacity = 0.8
- });
-
- var track = new Border
- {
- Height = 8,
- Margin = new Thickness(0, 0, 8, 0),
- Background = new SolidColorBrush(palette.Border) { Opacity = 0.35 },
- CornerRadius = new CornerRadius(2),
- VerticalAlignment = VerticalAlignment.Center
- };
+ var foreground = emphasized
+ ? accent
+ : palette.Foreground;
+ var background = emphasized
+ ? Blend(accent, palette.CardBackground, 0.82f)
+ : Blend(palette.Border, palette.CardBackground, 0.55f);
- var max = Math.Max(10, value);
- var fillWidth = value == 0 ? 0 : Math.Max(4, 120.0 * value / max);
- track.Child = new Border
+ return new Border
{
- Width = fillWidth,
- HorizontalAlignment = WpfHorizontalAlignment.Left,
- Background = new SolidColorBrush(fill),
- CornerRadius = new CornerRadius(2)
- };
- Grid.SetColumn(track, 1);
- row.Children.Add(track);
-
- var count = new TextBlock
- {
- Text = value.ToString(),
- FontSize = 10,
- FontWeight = FontWeights.SemiBold,
- VerticalAlignment = VerticalAlignment.Center,
- HorizontalAlignment = WpfHorizontalAlignment.Right,
- Foreground = new SolidColorBrush(value > 0 ? fill : palette.Foreground),
- Opacity = value > 0 ? 1 : 0.5
+ Background = new SolidColorBrush(background),
+ BorderBrush = new SolidColorBrush(emphasized ? accent : palette.Border) { Opacity = emphasized ? 0.85 : 0.5 },
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(8, 3, 8, 3),
+ Margin = new Thickness(0, 0, 6, 4),
+ Child = new TextBlock
+ {
+ Text = $"{count:N0} {label}",
+ FontSize = 11,
+ FontWeight = emphasized ? FontWeights.SemiBold : FontWeights.Normal,
+ Foreground = new SolidColorBrush(foreground) { Opacity = emphasized ? 1 : 0.65 }
+ }
};
- Grid.SetColumn(count, 2);
- row.Children.Add(count);
+ }
- return row;
+ private static WpfColor Blend(WpfColor from, WpfColor to, float amount)
+ {
+ amount = Math.Clamp(amount, 0f, 1f);
+ var r = (byte)(from.R + (to.R - from.R) * amount);
+ var g = (byte)(from.G + (to.G - from.G) * amount);
+ var b = (byte)(from.B + (to.B - from.B) * amount);
+ return WpfColor.FromRgb(r, g, b);
}
private static string FormatLegendStep(BuildProgressStep step) =>
@@ -195,4 +186,103 @@ private static SolidColorBrush StepBrush(BuildStepStatus status, ThemePalette pa
BuildStepStatus.Failed => new SolidColorBrush(WpfColor.FromRgb(220, 53, 69)),
_ => new SolidColorBrush(palette.Foreground) { Opacity = 0.35 }
};
+
+ public static UIElement BuildSiteAwaitingBlock(string listenUrl, ThemePalette palette)
+ {
+ var openUrl = LocalPortProbe.NormalizeBrowserUrl(listenUrl);
+ var accent = WpfColor.FromRgb(255, 193, 7);
+ return new Border
+ {
+ Margin = new Thickness(0, 4, 0, 0),
+ Padding = new Thickness(8, 5, 8, 5),
+ CornerRadius = new CornerRadius(4),
+ BorderThickness = new Thickness(1),
+ BorderBrush = new SolidColorBrush(accent) { Opacity = 0.75 },
+ Background = new SolidColorBrush(Blend(accent, palette.CardBackground, 0.88f)),
+ Child = new StackPanel
+ {
+ Children =
+ {
+ new TextBlock
+ {
+ Text = "Site starting…",
+ FontSize = 11,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = new SolidColorBrush(accent)
+ },
+ new TextBlock
+ {
+ Text = openUrl,
+ FontSize = 10,
+ TextWrapping = TextWrapping.Wrap,
+ Foreground = new SolidColorBrush(palette.Foreground) { Opacity = 0.75 },
+ Margin = new Thickness(0, 2, 0, 0)
+ }
+ }
+ }
+ };
+ }
+
+ public static UIElement BuildSiteReadyBlock(string listenUrl, ThemePalette palette)
+ {
+ var openUrl = LocalPortProbe.NormalizeBrowserUrl(listenUrl);
+ var readyGreen = WpfColor.FromRgb(40, 167, 69);
+ var panel = new StackPanel();
+ panel.Children.Add(new TextBlock
+ {
+ Text = "Site ready",
+ FontSize = 11,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = new SolidColorBrush(readyGreen)
+ });
+
+ var linkRow = new TextBlock { Margin = new Thickness(0, 3, 0, 0) };
+ if (Uri.TryCreate(openUrl, UriKind.Absolute, out var uri))
+ {
+ var link = new Hyperlink
+ {
+ NavigateUri = uri,
+ Foreground = new SolidColorBrush(palette.Accent),
+ FontWeight = FontWeights.SemiBold,
+ TextDecorations = TextDecorations.Underline,
+ Cursor = System.Windows.Input.Cursors.Hand
+ };
+ link.Inlines.Add($"Open {openUrl}");
+ link.RequestNavigate += (_, e) =>
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
+ }
+ catch
+ {
+ // Best effort only.
+ }
+
+ e.Handled = true;
+ };
+ linkRow.Inlines.Add(link);
+ }
+ else
+ {
+ linkRow.Inlines.Add(new Run(openUrl)
+ {
+ Foreground = new SolidColorBrush(palette.Foreground),
+ FontWeight = FontWeights.SemiBold
+ });
+ }
+
+ panel.Children.Add(linkRow);
+
+ return new Border
+ {
+ Margin = new Thickness(0, 4, 0, 0),
+ Padding = new Thickness(8, 5, 8, 5),
+ CornerRadius = new CornerRadius(4),
+ BorderThickness = new Thickness(1.5),
+ BorderBrush = new SolidColorBrush(readyGreen),
+ Background = new SolidColorBrush(Blend(readyGreen, palette.CardBackground, 0.86f)),
+ Child = panel
+ };
+ }
}
diff --git a/src/TrayApp/Services/TrayIconShellInterop.cs b/src/TrayApp/Services/TrayIconShellInterop.cs
new file mode 100644
index 0000000..56d3bda
--- /dev/null
+++ b/src/TrayApp/Services/TrayIconShellInterop.cs
@@ -0,0 +1,116 @@
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Windows.Forms;
+
+namespace BuildMonitor.TrayApp.Services;
+
+///
+/// Resolves tray icon screen bounds for the custom hover hint (hide when cursor leaves the icon).
+/// Native shell tooltip is suppressed via = empty — do not call
+/// Shell_NotifyIcon(NIM_MODIFY) with NIF_MESSAGE; that clears the callback and breaks the menu.
+///
+internal static class TrayIconShellInterop
+{
+ [StructLayout(LayoutKind.Sequential)]
+ private struct NotifyIconIdentifier
+ {
+ public int cbSize;
+ public IntPtr hWnd;
+ public int uID;
+ public Guid guidItem;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RectNative
+ {
+ public int Left;
+ public int Top;
+ public int Right;
+ public int Bottom;
+ }
+
+ [DllImport("shell32.dll", SetLastError = true)]
+ private static extern int Shell_NotifyIconGetRect(ref NotifyIconIdentifier identifier, out RectNative rect);
+
+ public static bool TryGetIconScreenBounds(NotifyIcon notifyIcon, out Rectangle bounds)
+ {
+ bounds = Rectangle.Empty;
+ if (!OperatingSystem.IsWindows())
+ {
+ return false;
+ }
+
+ var windowHandle = GetNotifyIconWindowHandle(notifyIcon);
+ var iconId = GetNotifyIconId(notifyIcon);
+ if (windowHandle == IntPtr.Zero || iconId == 0)
+ {
+ return false;
+ }
+
+ var identifier = new NotifyIconIdentifier
+ {
+ cbSize = Marshal.SizeOf(),
+ hWnd = windowHandle,
+ uID = (int)iconId
+ };
+
+ if (Shell_NotifyIconGetRect(ref identifier, out var rect) != 0)
+ {
+ return false;
+ }
+
+ bounds = Rectangle.FromLTRB(rect.Left, rect.Top, rect.Right, rect.Bottom);
+ return bounds.Width > 0 && bounds.Height > 0;
+ }
+
+ public static bool IsCursorOverIcon(NotifyIcon notifyIcon, int inflatePixels = 12)
+ {
+ if (!TryGetIconScreenBounds(notifyIcon, out var bounds))
+ {
+ // Cannot resolve icon bounds — do not treat as "left icon".
+ return true;
+ }
+
+ if (inflatePixels > 0)
+ {
+ bounds.Inflate(inflatePixels, inflatePixels);
+ }
+
+ return bounds.Contains(Control.MousePosition);
+ }
+
+ private static IntPtr GetNotifyIconWindowHandle(NotifyIcon notifyIcon)
+ {
+ if (GetInstanceField(notifyIcon, "_window", "window") is NativeWindow window)
+ {
+ return window.Handle;
+ }
+
+ return IntPtr.Zero;
+ }
+
+ private static uint GetNotifyIconId(NotifyIcon notifyIcon)
+ {
+ var value = GetInstanceField(notifyIcon, "_id", "id");
+ return value switch
+ {
+ uint uintId => uintId,
+ int intId => (uint)intId,
+ _ => 0
+ };
+ }
+
+ private static object? GetInstanceField(NotifyIcon notifyIcon, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ var field = typeof(NotifyIcon).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
+ if (field is not null)
+ {
+ return field.GetValue(notifyIcon);
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/TrayApp/Services/TrayScreenPlacement.cs b/src/TrayApp/Services/TrayScreenPlacement.cs
index 44760d4..cdab121 100644
--- a/src/TrayApp/Services/TrayScreenPlacement.cs
+++ b/src/TrayApp/Services/TrayScreenPlacement.cs
@@ -5,7 +5,7 @@
namespace BuildMonitor.TrayApp.Services;
///
-/// Places WPF windows on the monitor where the user last interacted with the tray icon.
+/// Places WPF windows relative to the system tray (icon bounds or captured work area).
///
public static class TrayScreenPlacement
{
@@ -43,6 +43,39 @@ public static void PlaceNearTrayBottomRight(Window window, double margin = 12)
window.Top = area.Bottom - height - margin;
}
+ ///
+ /// Positions the window above the tray notify icon with its bottom edge above the icon,
+ /// clamped to that monitor's work area so it does not cover the taskbar.
+ ///
+ public static void PlaceAboveTrayIcon(Window window, Rectangle iconBounds, double margin = 12)
+ {
+ window.WindowStartupLocation = WindowStartupLocation.Manual;
+ window.UpdateLayout();
+
+ var area = FormsScreen.FromRectangle(iconBounds).WorkingArea;
+ var width = ResolveDimension(window.ActualWidth, window.Width, 360);
+ var height = ResolveDimension(window.ActualHeight, window.Height, 96);
+
+ var maxBottom = iconBounds.Top - margin;
+ var availableHeight = Math.Max(96, maxBottom - area.Top);
+ if (height > availableHeight)
+ {
+ height = availableHeight;
+ }
+
+ var top = maxBottom - height;
+ var left = iconBounds.Left + (iconBounds.Width - width) / 2.0;
+ left = Math.Clamp(left, area.Left, Math.Max(area.Left, area.Right - width));
+ top = Math.Clamp(top, area.Top, Math.Max(area.Top, maxBottom - height));
+
+ window.Left = left;
+ window.Top = top;
+ if (Math.Abs(window.Height - height) > 0.5)
+ {
+ window.Height = height;
+ }
+ }
+
private static double ResolveDimension(double actual, double design, double fallback)
{
if (actual > 0)
diff --git a/src/TrayApp/SettingsWindow.xaml b/src/TrayApp/SettingsWindow.xaml
index 3420fb6..ecf9c92 100644
--- a/src/TrayApp/SettingsWindow.xaml
+++ b/src/TrayApp/SettingsWindow.xaml
@@ -394,7 +394,16 @@
-
+
+
+
+
+
+
+
@@ -549,6 +558,21 @@
ToolTip="When enabled, watch mode uses a debounced file watcher and rebuilds once after edits stop instead of dotnet watch rebuilding on every save. Turn off for dotnet watch hot reload." />
+
+
+
+
+
+
@@ -569,12 +593,6 @@
-
-
();
RunTestsCombo.ItemsSource = Enum.GetValues();
+ AutoOpenLogCombo.ItemsSource = Enum.GetValues();
FileChangesCombo.ItemsSource = Enum.GetValues();
ThemeCombo.ItemsSource = Enum.GetValues();
ThemeCombo.SelectedItem = Settings.AppBehavior.Theme;
@@ -52,8 +53,10 @@ public SettingsWindow(AppSettings settings, AppWindowsLayoutStore windowsLayoutS
DebounceModeCombo.SelectedItem = Settings.Monitor.FileChangeDebounceMode;
UpdateDebounceModeUi();
CoalesceWatchRebuildsCheck.IsChecked = Settings.Monitor.CoalesceWatchRebuilds;
+ DeferStartupBuildUntilQuietCheck.IsChecked = Settings.Monitor.DeferStartupBuildUntilQuiet;
+ CancelSupersededBuildsCheck.IsChecked = Settings.Monitor.CancelSupersededBuilds;
+ UseAgentTranscriptActivityCheck.IsChecked = Settings.Monitor.UseAgentTranscriptActivity;
HealthRefreshText.Text = Settings.Monitor.HealthRefreshSeconds.ToString();
- AutoOpenLogCheck.IsChecked = Settings.Monitor.AutoOpenLogOnFailure;
AutoOpenBuildMonitorHealthCheck.IsChecked = Settings.Monitor.AutoOpenBuildMonitorHealthOnStartup;
PlaySoundOnErrorCheck.IsChecked = Settings.Monitor.PlaySoundOnBuildError;
PlaySoundOnSuccessCheck.IsChecked = Settings.Monitor.PlaySoundOnBuildSuccess;
@@ -149,6 +152,8 @@ private void LoadEditorFromProject(LocalProjectDefinition project)
AutoRestartOnHotReloadRequestCheck.IsChecked = project.RunOptions.AutoRestartOnHotReloadRequest;
RestartAppAfterRebuildCheck.IsChecked = project.RunOptions.RestartAppAfterRebuild;
RunTestsCombo.SelectedItem = project.RunOptions.RunTests;
+ AutoOpenLogCombo.SelectedItem = project.RunOptions.AutoOpenLog;
+ ShowStatusPanelWhileBuildingCheck.IsChecked = project.RunOptions.ShowStatusPanelWhileBuilding;
FileChangesCombo.SelectedItem = project.RunOptions.FileChanges;
WatchExcludeSegmentsText.Text = project.RunOptions.WatchExcludeSegments;
ReleaseOutputLocksCheck.IsChecked = project.RunOptions.ReleaseOutputLocksBeforeBuild;
@@ -316,6 +321,8 @@ private void CommitEditorToSelected()
selectedProject.RunOptions.RestartAppAfterRebuild = RestartAppAfterRebuildCheck.IsChecked == true;
selectedProject.RunOptions.RunTests = (TestRunTrigger)(RunTestsCombo.SelectedItem ?? TestRunTrigger.Off);
+ selectedProject.RunOptions.AutoOpenLog = (AutoOpenLogMode)(AutoOpenLogCombo.SelectedItem ?? AutoOpenLogMode.Never);
+ selectedProject.RunOptions.ShowStatusPanelWhileBuilding = ShowStatusPanelWhileBuildingCheck.IsChecked == true;
selectedProject.RunOptions.FileChanges = (FileChangeMode)(FileChangesCombo.SelectedItem ?? FileChangeMode.WatchOnly);
selectedProject.RunOptions.WatchExcludeSegments = WatchExcludeSegmentsText.Text.Trim();
selectedProject.RunOptions.ReleaseOutputLocksBeforeBuild = ReleaseOutputLocksCheck.IsChecked == true;
@@ -420,7 +427,9 @@ private void CommitMonitorAndAppSettings()
}
Settings.Monitor.CoalesceWatchRebuilds = CoalesceWatchRebuildsCheck.IsChecked == true;
- Settings.Monitor.AutoOpenLogOnFailure = AutoOpenLogCheck.IsChecked == true;
+ Settings.Monitor.DeferStartupBuildUntilQuiet = DeferStartupBuildUntilQuietCheck.IsChecked == true;
+ Settings.Monitor.CancelSupersededBuilds = CancelSupersededBuildsCheck.IsChecked == true;
+ Settings.Monitor.UseAgentTranscriptActivity = UseAgentTranscriptActivityCheck.IsChecked == true;
Settings.Monitor.AutoOpenBuildMonitorHealthOnStartup = AutoOpenBuildMonitorHealthCheck.IsChecked == true;
Settings.Monitor.PlaySoundOnBuildError = PlaySoundOnErrorCheck.IsChecked == true;
Settings.Monitor.PlaySoundOnBuildSuccess = PlaySoundOnSuccessCheck.IsChecked == true;