diff --git a/src/Conclave.App/Claude/ClaudeCapabilities.cs b/src/Conclave.App/Claude/ClaudeCapabilities.cs index 10295ef..6c3583e 100644 --- a/src/Conclave.App/Claude/ClaudeCapabilities.cs +++ b/src/Conclave.App/Claude/ClaudeCapabilities.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Conclave.App.Sessions; using Conclave.App.Views; namespace Conclave.App.Claude; @@ -9,10 +10,12 @@ namespace Conclave.App.Claude; // // The probe runs on a background thread (BeginProbe) — on Windows, `claude` is a // node/npm shim that can take >1s to spin up, and we used to block the UI thread -// waiting for it. The instance starts in an empty state (Available == false) and -// raises PropertyChanged once the result lands so XAML bindings update in place. +// waiting for it. The instance is seeded from the SQLite settings cache so the titlebar +// paints with the previous launch's version immediately; the background probe then +// re-validates and writes back if the user upgraded the CLI between launches. public sealed class ClaudeCapabilities : Observable { + private readonly Database? _db; private string? _version; public string? Version { @@ -33,9 +36,20 @@ private set // hides on older builds where the flag would error out. public bool SupportsForkSession => AtLeast(_version, "2.0.0"); + public ClaudeCapabilities() { } + + // Seeded variant: paints the cached version immediately so the titlebar doesn't flash + // an "Claude not detected" state on every launch while the background probe runs. + public ClaudeCapabilities(Database db) + { + _db = db; + _version = db.GetSetting(SettingsKeys.ClaudeVersion); + } + // Kick off `claude --version` on the thread pool. Result is published back to the // capabilities object via PropertyChanged; bindings update wherever they sit. Fire - // and forget — failures leave Version null, which matches the "not detected" UX. + // and forget — failures leave Version unchanged from the cached seed (so a transient + // probe failure doesn't blank out the titlebar). public void BeginProbe() { _ = Task.Run(async () => @@ -45,7 +59,22 @@ public void BeginProbe() // auto-marshal INotifyPropertyChanged events, so the bound IsVisible / // Version controls in TitleBar.axaml + Sidebar.axaml would otherwise be // mutated from the thread pool. Same pattern as SessionManager.RefreshPr. - await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => Version = version); + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + if (version is not null && version != _version) + { + Version = version; + _db?.SetSetting(SettingsKeys.ClaudeVersion, version); + } + else if (version is null && _version is null) + { + // Probe failed and we have no cached version either — surface the empty state. + Version = null; + } + // version == _version: nothing to do (cache still valid). + // version == null && _version != null: keep the cached value rather than + // blanking the titlebar over a transient probe failure. + }); StartupLog.Mark(version is null ? "claude probe complete (not detected)" : $"claude probe complete: {version}"); diff --git a/src/Conclave.App/MainWindow.axaml b/src/Conclave.App/MainWindow.axaml index 9bd3036..e0f02f3 100644 --- a/src/Conclave.App/MainWindow.axaml +++ b/src/Conclave.App/MainWindow.axaml @@ -69,10 +69,10 @@ - - - - + + diff --git a/src/Conclave.App/MainWindow.axaml.cs b/src/Conclave.App/MainWindow.axaml.cs index 326170d..ade3e87 100644 --- a/src/Conclave.App/MainWindow.axaml.cs +++ b/src/Conclave.App/MainWindow.axaml.cs @@ -4,6 +4,7 @@ using Conclave.App.Design; using Conclave.App.Sessions; using Conclave.App.ViewModels; +using Conclave.App.Views.Shell; namespace Conclave.App; @@ -30,6 +31,15 @@ public partial class MainWindow : Window private ColumnDefinition RightSplit => ShellGrid.ColumnDefinitions[3]; private ColumnDefinition RightCol => ShellGrid.ColumnDefinitions[4]; + // Lazy-instantiated modal user-controls. Each is created on first open and kept + // around for the life of the window so subsequent opens are instantaneous (the + // first-open cost is what we cared about; tearing down on close would just push + // it onto the next open). DataContext inherits from MainWindow → ShellVm. + private NewSessionModal? _newSessionModal; + private NewFusionProjectModal? _newFusionModal; + private PreferencesModal? _preferencesModal; + private AboutModal? _aboutModal; + public MainWindow() { StartupLog.Mark("MainWindow ctor: begin"); @@ -67,12 +77,12 @@ public MainWindow() _manager.Permissions = _permissions; var claudeService = new ClaudeService(_manager); - // Empty capabilities up front — XAML bindings paint with Available=false. The - // probe runs on the thread pool and re-fires PropertyChanged when `claude - // --version` returns. Used to be synchronous and waited up to 2.5s on the UI - // thread, which on Windows (npm/node shim, AV scan) was a big chunk of the - // pre-paint time. - var capabilities = new ClaudeCapabilities(); + // Seed the title-bar capabilities from the cached version in the settings table so + // the very first paint already shows the previous launch's claude version. The + // background probe re-validates and writes back if the user upgraded the CLI + // between launches. Pre-cache (or first-ever launch) the seed is empty and the + // title bar binding paints with Available=false until the probe returns. + var capabilities = new ClaudeCapabilities(_manager.Db); capabilities.BeginProbe(); _shell = new ShellVm(tokens, _manager, capabilities); StartupLog.Mark("MainWindow ctor: ShellVm built"); @@ -107,6 +117,15 @@ protected override void OnLoaded(Avalonia.Interactivity.RoutedEventArgs e) base.OnLoaded(e); ApplyResponsiveLayout(Bounds.Width, _shell?.HasActiveSession ?? false); StartupLog.Mark("MainWindow OnLoaded"); + + // Now that the first frame has rendered, kick a staggered sweep that refreshes + // diff + PR state for every session. BuildSessionVm intentionally skips this on + // load — bursting N×2 git/gh subprocesses at startup is the single largest cause + // of slow first-paint on Windows where every Process.Start hits the AV scanner. + // 75ms between sessions keeps the pipeline busy without monopolising it. Skip + // the active session — the ShellVm constructor's auto-select fired + // RefreshOnActivation for it already, and we don't want to double its budget. + _manager?.RefreshAllStaggered(TimeSpan.FromMilliseconds(75), _shell?.ActiveSession); } private void OnShellPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) @@ -115,6 +134,25 @@ private void OnShellPropertyChanged(object? sender, System.ComponentModel.Proper // When it's restored, restore the right column to its remembered width. if (e.PropertyName == nameof(ShellVm.HasActiveSession)) ApplyResponsiveLayout(Bounds.Width, _shell?.HasActiveSession ?? false); + + // Lazy-materialise the heavier modals the first time the user opens them. The + // modal stays in the visual tree once instantiated so re-opens are immediate; + // we just toggle IsVisible via the same binding the modal's XAML already has. + if (e.PropertyName == nameof(ShellVm.IsNewSessionOpen) && _shell?.IsNewSessionOpen == true) + EnsureModal(ref _newSessionModal); + else if (e.PropertyName == nameof(ShellVm.IsNewFusionOpen) && _shell?.IsNewFusionOpen == true) + EnsureModal(ref _newFusionModal); + else if (e.PropertyName == nameof(ShellVm.IsPreferencesOpen) && _shell?.IsPreferencesOpen == true) + EnsureModal(ref _preferencesModal); + else if (e.PropertyName == nameof(ShellVm.IsAboutOpen) && _shell?.IsAboutOpen == true) + EnsureModal(ref _aboutModal); + } + + private void EnsureModal(ref T? slot) where T : Control, new() + { + if (slot is not null) return; + slot = new T(); + ModalHost.Children.Add(slot); } private void ApplyResponsiveLayout(double windowWidth, bool hasActiveSession) diff --git a/src/Conclave.App/Sessions/GhService.cs b/src/Conclave.App/Sessions/GhService.cs index 923ca29..6df4e12 100644 --- a/src/Conclave.App/Sessions/GhService.cs +++ b/src/Conclave.App/Sessions/GhService.cs @@ -19,8 +19,12 @@ public readonly record struct PullRequestInfo( public static PullRequestInfo? TryGetPullRequest(string worktreePath) { if (string.IsNullOrEmpty(worktreePath) || !Directory.Exists(worktreePath)) return null; - if (!GhAvailable()) return null; + // No `gh --version` preflight: just attempt the call. A missing gh fails the + // Process.Start inside Run with a Win32Exception, which we map to (-1, "", "") + // and treat as "no PR". The previous probe spawned a fresh gh per session at + // startup — on Windows where every Process.Start carries a 50–150ms AV-scan + // tax, that doubled the gh fan-out for no signal we actually used. var (code, stdout, _) = Run(worktreePath, "pr", "view", "--json", "number,state,isDraft,headRefName,baseRefName,title,mergedAt"); @@ -59,26 +63,6 @@ public readonly record struct PullRequestInfo( return ms <= 0 ? null : ms; } - private static bool GhAvailable() - { - try - { - var psi = new ProcessStartInfo("gh") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - psi.ArgumentList.Add("--version"); - using var p = Process.Start(psi); - if (p is null) return false; - p.WaitForExit(800); - return p.HasExited && p.ExitCode == 0; - } - catch { return false; } - } - private static (int Code, string Stdout, string Stderr) Run(string cwd, params string[] args) { var psi = new ProcessStartInfo("gh") @@ -91,21 +75,36 @@ private static (int Code, string Stdout, string Stderr) Run(string cwd, params s }; foreach (var a in args) psi.ArgumentList.Add(a); - using var proc = Process.Start(psi) - ?? throw new InvalidOperationException("failed to start gh"); - var sout = new StringBuilder(); - var serr = new StringBuilder(); - proc.OutputDataReceived += (_, e) => { if (e.Data != null) sout.AppendLine(e.Data); }; - proc.ErrorDataReceived += (_, e) => { if (e.Data != null) serr.AppendLine(e.Data); }; - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); - // Hard cap so a network hang doesn't freeze the UI thread. - if (!proc.WaitForExit(5000)) + Process? proc; + try + { + proc = Process.Start(psi); + } + catch (System.ComponentModel.Win32Exception) { - try { proc.Kill(); } catch { } - return (-1, sout.ToString(), serr.ToString()); + // gh isn't on PATH. This is the "no gh installed" branch — equivalent to a + // failed availability check, but without the extra subprocess that the old + // GhAvailable() preflight cost us per call. + return (-1, "", ""); + } + if (proc is null) return (-1, "", ""); + + using (proc) + { + var sout = new StringBuilder(); + var serr = new StringBuilder(); + proc.OutputDataReceived += (_, e) => { if (e.Data != null) sout.AppendLine(e.Data); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) serr.AppendLine(e.Data); }; + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + // Hard cap so a network hang doesn't freeze the UI thread. + if (!proc.WaitForExit(5000)) + { + try { proc.Kill(); } catch { } + return (-1, sout.ToString(), serr.ToString()); + } + return (proc.ExitCode, sout.ToString(), serr.ToString()); } - return (proc.ExitCode, sout.ToString(), serr.ToString()); } private static string? Str(JsonElement el, string prop) => diff --git a/src/Conclave.App/Sessions/SessionManager.cs b/src/Conclave.App/Sessions/SessionManager.cs index fdd1204..d1ac93e 100644 --- a/src/Conclave.App/Sessions/SessionManager.cs +++ b/src/Conclave.App/Sessions/SessionManager.cs @@ -154,14 +154,53 @@ private SessionVm BuildSessionVm(Session s) Base = s.BaseBranch, }; } - // Both refreshes off the UI thread. Diff is two git calls; PR is gated to bound - // gh-process concurrency at startup. - RefreshDiff(vm); - RefreshPr(vm); - + // No per-session refresh here. The DB columns we just hydrated render the sidebar + // straight away, and the freshness sweep (RefreshAllStaggered, called by MainWindow + // after first paint) re-fetches diff + PR data without bursting N subprocesses + // simultaneously at startup. Sessions activated by the user before the sweep + // catches them are refreshed eagerly via RefreshOnActivation. return vm; } + // Refresh diff + PR for one session — used when the user picks a session in the sidebar + // before the post-startup sweep gets to it. Idempotent against the staggered sweep: + // both paths land on RefreshDiff/RefreshPr which already gate their subprocesses. + public void RefreshOnActivation(SessionVm s) + { + RefreshDiff(s); + RefreshPr(s); + } + + // Walks every session and dispatches RefreshDiff + RefreshPr with a small per-session + // delay so we don't fan out N×2 git/gh subprocesses simultaneously at startup. On + // Windows each Process.Start carries an AV-scan tax, so a synchronous burst at boot + // hurts perceived startup time even though the work runs off the UI thread. + // + // `skip` is honoured so the just-activated session — which already refreshed eagerly + // via RefreshOnActivation — doesn't double its subprocess budget at startup. + // + // Cheap to call from the UI thread; everything off-loads to Task.Run. + public void RefreshAllStaggered(TimeSpan stagger, SessionVm? skip = null) + { + var sessions = Projects.SelectMany(p => p.Sessions) + .Where(s => !ReferenceEquals(s, skip)) + .ToArray(); + if (sessions.Length == 0) return; + _ = Task.Run(async () => + { + foreach (var s in sessions) + { + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + RefreshDiff(s); + RefreshPr(s); + }); + if (stagger > TimeSpan.Zero) + await Task.Delay(stagger); + } + }); + } + // Refresh PR info for one session from `gh pr view`. Safe no-op if gh isn't installed // or the branch has no associated PR. Called after a claude turn and on session load. // Runs the gh subprocess on a thread-pool thread, behind a concurrency gate, then diff --git a/src/Conclave.App/Sessions/SettingsKeys.cs b/src/Conclave.App/Sessions/SettingsKeys.cs index e5a13a2..87a11b6 100644 --- a/src/Conclave.App/Sessions/SettingsKeys.cs +++ b/src/Conclave.App/Sessions/SettingsKeys.cs @@ -6,6 +6,7 @@ public static class SettingsKeys public const string AutoCleanupEnabled = "auto_cleanup.enabled"; public const string AutoCleanupDays = "auto_cleanup.days"; public const string NotificationsEnabled = "notifications.enabled"; + public const string ClaudeVersion = "claude.version"; public const int DefaultAutoCleanupDays = 7; diff --git a/src/Conclave.App/ViewModels/ShellVm.cs b/src/Conclave.App/ViewModels/ShellVm.cs index e49ec88..305ce12 100644 --- a/src/Conclave.App/ViewModels/ShellVm.cs +++ b/src/Conclave.App/ViewModels/ShellVm.cs @@ -32,6 +32,10 @@ public SessionVm? ActiveSession { value.IsActive = true; Manager.LoadTranscriptIfNeeded(value); + // Eager refresh for the just-activated session — the post-startup sweep + // staggers all sessions, but a session the user picks should reflect + // current diff/PR state without waiting in the sweep queue. + Manager.RefreshOnActivation(value); } Notify(nameof(HasActiveSession)); Notify(nameof(ActiveProjectName));