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));