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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions src/Conclave.App/Claude/ClaudeCapabilities.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using Conclave.App.Sessions;
using Conclave.App.Views;

namespace Conclave.App.Claude;
Expand All @@ -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
{
Expand All @@ -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 () =>
Expand All @@ -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}");
Expand Down
8 changes: 4 additions & 4 deletions src/Conclave.App/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@
<shell:RightPanel Grid.Row="1" Grid.Column="4"
IsVisible="{Binding HasActiveSession}" />
</Grid>
<shell:NewSessionModal />
<shell:NewFusionProjectModal />
<shell:PreferencesModal />
<shell:AboutModal />
<!-- Modal host: NewSession / NewFusion / Preferences / About are added on first
open by MainWindow.axaml.cs, so a fresh launch doesn't pay the XAML parse +
layout cost for modal trees the user may never touch. -->
<Panel x:Name="ModalHost" />
<shell:Toast />
</Panel>
</Window>
50 changes: 44 additions & 6 deletions src/Conclave.App/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Conclave.App.Design;
using Conclave.App.Sessions;
using Conclave.App.ViewModels;
using Conclave.App.Views.Shell;

namespace Conclave.App;

Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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)
Expand All @@ -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<T>(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)
Expand Down
67 changes: 33 additions & 34 deletions src/Conclave.App/Sessions/GhService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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")
Expand All @@ -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) =>
Expand Down
49 changes: 44 additions & 5 deletions src/Conclave.App/Sessions/SessionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
});
}

// 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
Expand Down
1 change: 1 addition & 0 deletions src/Conclave.App/Sessions/SettingsKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 4 additions & 0 deletions src/Conclave.App/ViewModels/ShellVm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
Notify(nameof(HasActiveSession));
Notify(nameof(ActiveProjectName));
Expand Down
Loading