879 lines
35 KiB
C#
879 lines
35 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls.ApplicationLifetimes;
|
|
using Avalonia.Threading;
|
|
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Islands;
|
|
using ClaudeDo.Ui.ViewModels.Modals;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels;
|
|
|
|
public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|
{
|
|
public ListsIslandViewModel? Lists { get; }
|
|
public TasksIslandViewModel? Tasks { get; }
|
|
public DetailsIslandViewModel? Details { get; }
|
|
public IWorkerClient? Worker { get; internal set; }
|
|
public MissionControlViewModel? MissionControl { get; }
|
|
public UsagePillViewModel? UsagePill { get; }
|
|
public UpdateCheckService UpdateCheck => _updateCheck;
|
|
|
|
// Stable opKey the six Lifecycle/*Recovery startup sweeps broadcast under (see
|
|
// OperationProgressOpKeys.StartupRecovery on the worker side — not shared across the
|
|
// SignalR wire, so the literal is duplicated here on purpose).
|
|
private const string StartupRecoveryOpKey = "startup-recovery";
|
|
|
|
// Last startup-recovery text received this connection cycle, shown in place of the generic
|
|
// "connecting" label. Cleared once actually connected so a later transient reconnect (not a
|
|
// fresh worker boot) doesn't replay stale recovery text.
|
|
private string? _startupRecoveryText;
|
|
|
|
public string ConnectionText =>
|
|
Worker?.IsConnected == true ? Loc.T("vm.connection.online")
|
|
: Worker?.IsReconnecting == true ? (_startupRecoveryText ?? Loc.T("vm.connection.connecting"))
|
|
: Loc.T("vm.connection.offline");
|
|
|
|
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
|
|
|
|
// Gate for the footer pill: the help dialog says "WORKER NOT REACHABLE", so it must never open
|
|
// on a connected worker. Not `IsOffline` — the retry loop stays in "connecting" forever while
|
|
// the worker is down, and that's exactly when the dialog's "Start Worker" is needed.
|
|
public bool CanOpenWorkerConnectionHelp => DecideCanOpenConnectionHelp(Worker?.IsConnected == true);
|
|
|
|
internal static bool DecideCanOpenConnectionHelp(bool isConnected) => !isConnected;
|
|
|
|
private readonly UpdateCheckService _updateCheck = null!;
|
|
private readonly InstallerLocator _installerLocator = null!;
|
|
private readonly WorkerLocator _workerLocator = null!;
|
|
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
|
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
|
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
|
|
private readonly Func<UsageMonitorModalViewModel> _usageMonitorVmFactory = () => null!;
|
|
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
|
|
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
|
|
|
|
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
|
|
|
// Layer C seam: composition root sets the factory; the dialog service shows the resolver.
|
|
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
|
|
|
// Set by MainWindow so a reveal can bring the main window to the foreground.
|
|
public Action? BringToFront { get; set; }
|
|
|
|
// Single dialog seam (set by MainWindow); propagated to the islands.
|
|
private IDialogService? _dialogs;
|
|
public IDialogService? Dialogs
|
|
{
|
|
get => _dialogs;
|
|
set
|
|
{
|
|
_dialogs = value;
|
|
if (Lists is not null) Lists.Dialogs = value;
|
|
if (Tasks is not null) Tasks.Dialogs = value;
|
|
if (Details is not null) Details.Dialogs = value;
|
|
}
|
|
}
|
|
|
|
public async Task RevealTaskAsync(string taskId)
|
|
{
|
|
if (Tasks is null || Lists is null) { BringToFront?.Invoke(); return; }
|
|
|
|
string? listId = null;
|
|
if (_dbFactory is not null)
|
|
{
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
|
listId = entity?.ListId;
|
|
}
|
|
catch { /* best-effort list resolution */ }
|
|
}
|
|
|
|
if (listId is not null)
|
|
{
|
|
var navItem = Lists.Items.FirstOrDefault(i => i.Id == $"user:{listId}");
|
|
if (navItem is not null && !ReferenceEquals(Lists.SelectedList, navItem))
|
|
Lists.SelectedList = navItem; // raises SelectionChanged → Tasks.LoadForList
|
|
}
|
|
|
|
await Tasks.SelectByIdAsync(taskId);
|
|
BringToFront?.Invoke();
|
|
}
|
|
|
|
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
|
{
|
|
if (ConflictResolverFactory is null || Dialogs is null) return;
|
|
var vm = ConflictResolverFactory(taskId);
|
|
var hasConflicts = await vm.OpenAsync(targetBranch);
|
|
if (hasConflicts)
|
|
await Dialogs.ShowConflictResolverAsync(vm);
|
|
}
|
|
|
|
[ObservableProperty] private bool _isUpdateBannerVisible;
|
|
[ObservableProperty] private string? _updateBannerLatestVersion;
|
|
private bool _bannerDismissedThisSession;
|
|
|
|
// planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started.
|
|
// Kept as a dictionary so a later conflict on the same planning task updates in place instead
|
|
// of piling up duplicate entries.
|
|
private readonly Dictionary<string, string> _externalMergeConflicts = new();
|
|
[ObservableProperty] private bool _isExternalMergeBannerVisible;
|
|
|
|
|
|
[ObservableProperty]
|
|
private double _windowWidth = 1280;
|
|
|
|
[ObservableProperty]
|
|
private string? _workerLogText;
|
|
|
|
[ObservableProperty]
|
|
private WorkerLogLevel _workerLogLevel;
|
|
|
|
[ObservableProperty]
|
|
private bool _isWorkerLogVisible;
|
|
|
|
/// <summary>The notes list has nothing to show a detail pane for, so the pane goes away and
|
|
/// the editor takes the space instead of leaving an empty column beside it.</summary>
|
|
public bool ShowDetails => WindowWidth >= 1100 && Tasks?.IsNotesList != true;
|
|
|
|
/// <summary>Widens the tasks island over the splitter and the details column when that column
|
|
/// is hidden — a fixed-width ColumnDefinition keeps its space even with an invisible child.</summary>
|
|
public int TasksColumnSpan => ShowDetails ? 1 : 3;
|
|
|
|
public bool ShowLists => WindowWidth >= 780;
|
|
|
|
private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
|
|
private readonly System.Timers.Timer _connectTimer = new(12_000) { AutoReset = false };
|
|
|
|
[ObservableProperty] private string? _primeStatus;
|
|
private readonly System.Timers.Timer _primeStatusTimer = new(5_000) { AutoReset = false };
|
|
|
|
[RelayCommand]
|
|
private void FocusSearch() => Lists?.RequestFocusSearch();
|
|
|
|
[RelayCommand]
|
|
private void FocusAddTask() => Tasks?.RequestFocusAddTask();
|
|
|
|
public async Task ToggleSelectedDoneAsync()
|
|
{
|
|
if (Tasks?.SelectedTask is { } row)
|
|
await Tasks.ToggleDoneCommand.ExecuteAsync(row);
|
|
}
|
|
|
|
partial void OnWindowWidthChanged(double value)
|
|
{
|
|
NotifyDetailsVisibility();
|
|
OnPropertyChanged(nameof(ShowLists));
|
|
}
|
|
|
|
private void NotifyDetailsVisibility()
|
|
{
|
|
OnPropertyChanged(nameof(ShowDetails));
|
|
OnPropertyChanged(nameof(TasksColumnSpan));
|
|
}
|
|
|
|
public void OnWorkerLogReceived(WorkerLogEntry entry)
|
|
{
|
|
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
|
|
WorkerLogText = $"{hhmm} · {entry.Message}";
|
|
WorkerLogLevel = entry.Level;
|
|
IsWorkerLogVisible = true;
|
|
_clearTimer.Stop();
|
|
_clearTimer.Start();
|
|
}
|
|
|
|
public void ClearWorkerLog()
|
|
{
|
|
IsWorkerLogVisible = false;
|
|
WorkerLogText = null;
|
|
}
|
|
|
|
// Surfaces a UI-originated failure in the footer status strip (same line as the
|
|
// worker log), color-coded as an error and auto-cleared by _clearTimer.
|
|
public void FlashFooterError(string message) => FlashFooter(message, WorkerLogLevel.Error);
|
|
|
|
// Same strip, for an action that succeeded and has something to report ("3 new tasks").
|
|
// The strip has always carried a level — routing a success through FlashFooterError would
|
|
// paint a good outcome red, which reads as a failure.
|
|
public void FlashFooterSuccess(string message) => FlashFooter(message, WorkerLogLevel.Success);
|
|
|
|
// Same strip, for neutral in-progress text (e.g. a CLI's progress lines).
|
|
public void FlashFooterInfo(string message) => FlashFooter(message, WorkerLogLevel.Info);
|
|
|
|
private void FlashFooter(string message, WorkerLogLevel level)
|
|
{
|
|
if (level == WorkerLogLevel.Error)
|
|
Serilog.Log.Warning("Footer error: {message}", message);
|
|
|
|
WorkerLogText = $"{DateTime.Now:HH:mm} · {message}";
|
|
WorkerLogLevel = level;
|
|
IsWorkerLogVisible = true;
|
|
_clearTimer.Stop();
|
|
_clearTimer.Start();
|
|
}
|
|
|
|
private void OnPrimeFired(PrimeFiredEvent evt)
|
|
{
|
|
var when = evt.FiredAt.LocalDateTime.ToString("HH:mm");
|
|
PrimeStatus = evt.Success
|
|
? $"✓ Primed Claude at {when}"
|
|
: $"⚠ Prime failed: {evt.Message}";
|
|
_primeStatusTimer.Stop();
|
|
_primeStatusTimer.Start();
|
|
}
|
|
|
|
public void OnPlanningMergeConflict(
|
|
string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles, bool externallyDriven)
|
|
{
|
|
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
|
if (externallyDriven)
|
|
{
|
|
// An MCP session (review_task/continue_merge) is driving this merge — it owns
|
|
// resolution. Auto-opening the resolver here raced with the session's own writes
|
|
// (two parties editing the same shared checkout at once); show a banner instead and
|
|
// leave the resolver reachable only via a deliberate click.
|
|
_externalMergeConflicts[planningTaskId] = subtaskId;
|
|
IsExternalMergeBannerVisible = true;
|
|
return;
|
|
}
|
|
|
|
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
|
|
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
|
}
|
|
|
|
public void OnPlanningMergeAborted(string planningTaskId, string? reason = null)
|
|
{
|
|
ClearExternalMergeConflict(planningTaskId);
|
|
// A deliberate abort/conflict pause and a real merge failure both flow through this
|
|
// event; only the latter carries a reason worth surfacing in the footer strip.
|
|
if (!string.IsNullOrWhiteSpace(reason))
|
|
FlashFooterError(reason);
|
|
}
|
|
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
|
|
|
|
// Wired to Worker.OperationProgressEvent; also called directly by tests. A foreign opKey
|
|
// (merge phases, worktree cleanup, ...) is left untouched — this only tracks the
|
|
// startup-recovery channel.
|
|
public void OnOperationProgress(string opKey, string phase, int current, int total)
|
|
{
|
|
if (opKey != StartupRecoveryOpKey) return;
|
|
_startupRecoveryText = total > 0
|
|
? $"{Loc.T("ops.worker.startupRecovery")} ({current}/{total})"
|
|
: Loc.T("ops.worker.startupRecovery");
|
|
OnPropertyChanged(nameof(ConnectionText));
|
|
}
|
|
|
|
private void ClearExternalMergeConflict(string planningTaskId)
|
|
{
|
|
_externalMergeConflicts.Remove(planningTaskId);
|
|
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
|
|
}
|
|
|
|
/// <summary>Re-syncs the external-merge banner from the worker on (re)connect — the
|
|
/// one-shot PlanningMergeConflict broadcast isn't replayed after a UI restart, so this is
|
|
/// the recovery path. The worker checks MERGE_HEAD before reporting a conflict as active,
|
|
/// so a session that died mid-merge without cleaning up doesn't leave a stale banner up.</summary>
|
|
private async Task RefreshExternalMergeConflictsAsync()
|
|
{
|
|
if (Worker is null) return;
|
|
IReadOnlyList<PlanningMergeConflictStateDto> active;
|
|
try { active = await Worker.GetActiveExternalPlanningMergeConflictsAsync(); }
|
|
catch { return; }
|
|
|
|
_externalMergeConflicts.Clear();
|
|
foreach (var c in active)
|
|
_externalMergeConflicts[c.PlanningTaskId] = c.SubtaskId;
|
|
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private Task OpenExternalMergeConflictAsync()
|
|
{
|
|
if (_externalMergeConflicts.Count == 0) return Task.CompletedTask;
|
|
var (planningTaskId, subtaskId) = _externalMergeConflicts.First();
|
|
return OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
|
}
|
|
|
|
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
|
|
{
|
|
if (ConflictResolverFactory is null || Dialogs is null) return;
|
|
var vm = ConflictResolverFactory(subtaskId);
|
|
var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
|
|
if (hasConflicts)
|
|
await Dialogs.ShowConflictResolverAsync(vm);
|
|
}
|
|
|
|
// For tests only — does NOT wire up events.
|
|
internal IslandsShellViewModel() { }
|
|
|
|
public IslandsShellViewModel(
|
|
ListsIslandViewModel lists,
|
|
TasksIslandViewModel tasks,
|
|
DetailsIslandViewModel details,
|
|
IWorkerClient worker,
|
|
UpdateCheckService updateCheck,
|
|
InstallerLocator installerLocator,
|
|
WorkerLocator workerLocator,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
|
|
Func<WeeklyReportModalViewModel> weeklyReportVmFactory,
|
|
Func<UsageMonitorModalViewModel> usageMonitorVmFactory,
|
|
Func<MergeModalViewModel> mergeVmFactory,
|
|
Func<RepoImportModalViewModel> repoImportVmFactory,
|
|
MissionControlViewModel missionControl,
|
|
UsagePillViewModel usagePill)
|
|
{
|
|
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
|
MissionControl = missionControl;
|
|
UsagePill = usagePill;
|
|
UsagePill.OpenMonitorRequested += () => _ = OpenUsageMonitor();
|
|
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
|
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
|
MissionControl.OpenRequested = OpenMissionControl;
|
|
MissionControl.ErrorReported += FlashFooterError;
|
|
// Keep the task rows' "Interactive" chip in step with Mission Control's open ConPTY panes.
|
|
MissionControl.ConPtySessions.CollectionChanged += (_, _) => SyncInteractiveSessionChips();
|
|
_updateCheck = updateCheck;
|
|
_installerLocator = installerLocator;
|
|
_workerLocator = workerLocator;
|
|
_dbFactory = dbFactory;
|
|
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
|
_weeklyReportVmFactory = weeklyReportVmFactory;
|
|
_usageMonitorVmFactory = usageMonitorVmFactory;
|
|
_mergeVmFactory = mergeVmFactory;
|
|
_repoImportVmFactory = repoImportVmFactory;
|
|
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
|
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
|
|
Tasks.PrepRequested += () => Details.ShowPrep();
|
|
Tasks.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(TasksIslandViewModel.IsNotesList)) NotifyDetailsVisibility();
|
|
};
|
|
Tasks.ErrorReported += FlashFooterError;
|
|
Lists.ErrorReported += FlashFooterError;
|
|
Lists.SuccessReported += FlashFooterSuccess;
|
|
Tasks.OpenConPtySessionRequested += taskId =>
|
|
{
|
|
OpenMissionControl();
|
|
_ = MissionControl.OpenConPtySessionAsync(taskId);
|
|
};
|
|
Tasks.OpenQuickClaudeSessionRequested += dir =>
|
|
{
|
|
OpenMissionControl();
|
|
_ = MissionControl.OpenAdHocConPtySessionAsync(dir);
|
|
};
|
|
Tasks.OpenPlanningConPtyRequested += (taskId, resume) =>
|
|
{
|
|
OpenMissionControl();
|
|
_ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume);
|
|
};
|
|
Lists.LetClaudeHandleRequested += req =>
|
|
{
|
|
OpenMissionControl();
|
|
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds, req.TriageModel);
|
|
};
|
|
Lists.MergeReviewsRequested += req =>
|
|
{
|
|
OpenMissionControl();
|
|
_ = MissionControl.OpenMergeReviewConPtySessionAsync(req.ListId, req.TaskIds);
|
|
};
|
|
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
|
Tasks.OpenListSettingsRequested += (_, _) =>
|
|
{
|
|
if (Lists.SelectedList is { } row)
|
|
Lists.OpenListSettingsCommand.Execute(row);
|
|
};
|
|
Tasks.LetClaudeHandleRequested += (_, _) =>
|
|
{
|
|
if (Lists.SelectedList is { } row)
|
|
Lists.LetClaudeHandleListCommand.Execute(row);
|
|
};
|
|
Tasks.MergeReviewsRequested += (_, _) => Lists.MergeReviewsCommand.Execute(null);
|
|
Details.ErrorReported += FlashFooterError;
|
|
Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail");
|
|
Details.DeleteFromList = row =>
|
|
{
|
|
Tasks.LoadForList(Lists.SelectedList);
|
|
_ = Lists.RefreshCountsAsync();
|
|
return System.Threading.Tasks.Task.CompletedTask;
|
|
};
|
|
Worker.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName is nameof(IWorkerClient.IsConnected) or nameof(IWorkerClient.IsReconnecting))
|
|
{
|
|
if (Worker.IsConnected) _startupRecoveryText = null;
|
|
OnPropertyChanged(nameof(ConnectionText));
|
|
OnPropertyChanged(nameof(IsOffline));
|
|
OnPropertyChanged(nameof(CanOpenWorkerConnectionHelp));
|
|
}
|
|
};
|
|
Worker.OperationProgressEvent += OnOperationProgress;
|
|
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
|
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
|
|
Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
|
|
Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
|
|
Worker.ConnectionRestoredEvent += () => _ = RefreshExternalMergeConflictsAsync();
|
|
Worker.PrimeFired += OnPrimeFired;
|
|
_clearTimer.Elapsed += (_, _) =>
|
|
{
|
|
if (Dispatcher.UIThread.CheckAccess())
|
|
ClearWorkerLog();
|
|
else
|
|
Dispatcher.UIThread.Post(ClearWorkerLog);
|
|
};
|
|
_primeStatusTimer.Elapsed += (_, _) =>
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
|
|
_connectTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (DecideShowConnectionPrompt(IsOffline)) _ = OpenWorkerConnectionHelpAsync();
|
|
});
|
|
_connectTimer.Start();
|
|
_ = Lists.LoadAsync();
|
|
_updateCheck.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
|
|
{
|
|
RefreshBannerFromStatus();
|
|
}
|
|
};
|
|
_updateCheck.Op.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(OperationStatus.IsRunning)) CheckForUpdatesCommand.NotifyCanExecuteChanged();
|
|
};
|
|
// Fire-and-forget startup check — never block UI. Deliberately *not* Task.Run: the check is
|
|
// pure async I/O (no sync blocking), and `OperationStatus` writes its observable properties
|
|
// on the calling thread. From a threadpool thread the final `ShowIndicator=false` never
|
|
// reaches the binding, so the indicator froze on the last tick ("Checking… 00:03").
|
|
_ = StartupUpdateCheckAsync();
|
|
}
|
|
|
|
private async Task StartupUpdateCheckAsync()
|
|
{
|
|
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Worker is not null) Worker.OperationProgressEvent -= OnOperationProgress;
|
|
_clearTimer.Stop();
|
|
_clearTimer.Dispose();
|
|
_connectTimer.Stop();
|
|
_connectTimer.Dispose();
|
|
_primeStatusTimer.Stop();
|
|
_primeStatusTimer.Dispose();
|
|
}
|
|
|
|
private void RefreshBannerFromStatus()
|
|
{
|
|
switch (_updateCheck.LastCheckStatus)
|
|
{
|
|
case UpdateCheckStatus.UpdateAvailable:
|
|
if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
|
|
UpdateBannerLatestVersion = _updateCheck.LatestVersion;
|
|
IsUpdateBannerVisible = true;
|
|
break;
|
|
case UpdateCheckStatus.UpToDate:
|
|
IsUpdateBannerVisible = false;
|
|
break;
|
|
case UpdateCheckStatus.CheckFailed:
|
|
break;
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenMissionControl()
|
|
{
|
|
if (Dialogs is not null && MissionControl is not null)
|
|
Dialogs.ShowMissionControl(MissionControl);
|
|
}
|
|
|
|
/// <summary>Number of Mission Control ConPTY panes currently open — the main window's
|
|
/// close guard asks for confirmation whenever this is greater than zero.</summary>
|
|
public int OpenMissionControlSessionCount => MissionControl?.ConPtySessions.Count ?? 0;
|
|
|
|
// Pure decision extracted for testability — closing the main window kills every open
|
|
// Mission Control pane's `claude` process (ConPtyPaneViewModel.Dispose), so ask first
|
|
// rather than let it happen silently.
|
|
internal static bool RequiresCloseConfirmation(int openMissionControlSessionCount) =>
|
|
openMissionControlSessionCount > 0;
|
|
|
|
private void SyncInteractiveSessionChips()
|
|
{
|
|
if (MissionControl is null || Tasks is null) return;
|
|
Tasks.SyncInteractiveSessions(
|
|
MissionControl.ConPtySessions
|
|
.Select(s => s.TaskId)
|
|
.Where(id => !string.IsNullOrEmpty(id))
|
|
.Select(id => id!));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task OpenAbout()
|
|
{
|
|
var vm = new AboutModalViewModel();
|
|
vm.ErrorReported += FlashFooterError;
|
|
if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm);
|
|
}
|
|
|
|
// One shared VM so the sheets are parsed once and the window keeps its selection/pin
|
|
// across reopens; the window itself is single-instance in the dialog service.
|
|
private CheatSheetsViewModel? _cheatSheets;
|
|
|
|
[RelayCommand]
|
|
private void OpenCheatSheet(string? key)
|
|
{
|
|
if (Dialogs is null) return;
|
|
_cheatSheets ??= new CheatSheetsViewModel();
|
|
if (!string.IsNullOrEmpty(key)) _cheatSheets.Select(key);
|
|
Dialogs.ShowCheatSheets(_cheatSheets);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task OpenLogVisualizer()
|
|
{
|
|
if (Dialogs is null || Worker is null) return;
|
|
var vm = new LogVisualizerViewModel(Worker);
|
|
await vm.RefreshAsync();
|
|
await Dialogs.ShowLogVisualizerAsync(vm);
|
|
}
|
|
|
|
private bool _connectionPromptShown;
|
|
|
|
internal bool DecideShowConnectionPrompt(bool isOffline)
|
|
{
|
|
if (!isOffline) return false;
|
|
if (_connectionPromptShown) return false;
|
|
_connectionPromptShown = true;
|
|
return true;
|
|
}
|
|
|
|
private async Task OpenWorkerConnectionHelpAsync()
|
|
{
|
|
var vm = new WorkerConnectionModalViewModel(_workerLocator, _installerLocator);
|
|
if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm);
|
|
}
|
|
|
|
// The gate lives in the body, not in CanExecute: the footer pill *is* this button, so a
|
|
// disabled command renders the "ONLINE" chip greyed out — which reads as a broken connection,
|
|
// the opposite of what it says.
|
|
[RelayCommand]
|
|
private Task OpenWorkerConnectionHelp() =>
|
|
CanOpenWorkerConnectionHelp ? OpenWorkerConnectionHelpAsync() : Task.CompletedTask;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenRepoImport()
|
|
{
|
|
if (Dialogs is null || _repoImportVmFactory is null) return;
|
|
var vm = _repoImportVmFactory();
|
|
await vm.LoadAsync();
|
|
await Dialogs.ShowRepoImportAsync(vm);
|
|
if (Lists is not null) await Lists.LoadAsync();
|
|
}
|
|
|
|
private bool _worktreesOverviewOpen;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenWorktreesOverviewGlobalAsync()
|
|
{
|
|
if (Dialogs is null || _worktreesOverviewOpen) return;
|
|
_worktreesOverviewOpen = true;
|
|
try
|
|
{
|
|
var vm = _worktreesOverviewVmFactory();
|
|
vm.ErrorReported += FlashFooterError;
|
|
vm.Configure(null, null);
|
|
await vm.LoadAsync();
|
|
await Dialogs.ShowWorktreesOverviewAsync(vm);
|
|
}
|
|
finally { _worktreesOverviewOpen = false; }
|
|
}
|
|
|
|
private bool _weeklyReportOpen;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenWeeklyReport()
|
|
{
|
|
if (Dialogs is null || _weeklyReportOpen) return;
|
|
_weeklyReportOpen = true;
|
|
try
|
|
{
|
|
var vm = _weeklyReportVmFactory();
|
|
await vm.InitializeAsync();
|
|
await Dialogs.ShowWeeklyReportAsync(vm);
|
|
}
|
|
finally { _weeklyReportOpen = false; }
|
|
}
|
|
|
|
private bool _usageMonitorOpen;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenUsageMonitor()
|
|
{
|
|
if (Dialogs is null || _usageMonitorOpen) return;
|
|
_usageMonitorOpen = true;
|
|
try
|
|
{
|
|
var vm = _usageMonitorVmFactory();
|
|
vm.ErrorReported += FlashFooterError;
|
|
// Show first, load after: the initial transcript scan takes seconds, and awaiting it
|
|
// here left the pill looking unresponsive until the window finally appeared.
|
|
vm.BeginLoad();
|
|
await Dialogs.ShowUsageMonitorAsync(vm);
|
|
}
|
|
finally { _usageMonitorOpen = false; }
|
|
}
|
|
|
|
private bool CanCheckForUpdates() => !_updateCheck.Op.IsRunning;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
|
|
private async Task CheckForUpdatesAsync()
|
|
{
|
|
await _updateCheck.CheckNowAsync(CancellationToken.None);
|
|
}
|
|
|
|
// How long to wait after launching the new worker process before deciding it survived
|
|
// (e.g. a port conflict kills it almost immediately — see #292 for the worker-side fix).
|
|
private const int RestartSurvivalCheckMs = 2000;
|
|
|
|
// Test seam: overrides the real kill-and-relaunch body, so tests can assert the
|
|
// success/failure footer routing without spawning a real worker process.
|
|
internal Func<Task>? RestartWorkerOperation { get; set; }
|
|
|
|
[RelayCommand]
|
|
private async Task RestartWorkerAsync()
|
|
{
|
|
try
|
|
{
|
|
await (RestartWorkerOperation ?? (() => Task.Run(RestartWorkerService)))();
|
|
FlashFooterSuccess("Worker restarted.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
FlashFooterError($"Restart failed: {ex.Message} — check the worker log.");
|
|
}
|
|
}
|
|
|
|
private void RestartWorkerService()
|
|
{
|
|
// Prefer the exe path of the worker that's actually running: _workerLocator.Find()
|
|
// walks up from AppContext.BaseDirectory looking for install.json and falls back to
|
|
// the registry's InstallLocation otherwise — on a debug build launched from the repo
|
|
// there's no install.json above it, so it returns the *installed* worker's path. That
|
|
// never matches the running debug process, so KillMatchingWorkerProcesses silently
|
|
// skips it (it only kills a process whose path equals the given exe) and this either
|
|
// launches a second worker that immediately dies on the already-bound port, or leaves
|
|
// the real one running untouched while nothing visibly changes.
|
|
var exe = RunningWorkerExePath() ?? _workerLocator.Find();
|
|
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
|
|
|
|
KillMatchingWorkerProcesses(exe);
|
|
|
|
var started = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
|
|
if (started is not null && started.WaitForExit(RestartSurvivalCheckMs))
|
|
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
|
|
}
|
|
|
|
private static string? RunningWorkerExePath()
|
|
{
|
|
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
|
{
|
|
try
|
|
{
|
|
if (p.MainModule?.FileName is { } path) return path;
|
|
}
|
|
catch { /* inaccessible */ }
|
|
finally { p.Dispose(); }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Only kill the worker belonging to THIS installation — not any other
|
|
// ClaudeDo.Worker on the machine (e.g. a second install).
|
|
private static void KillMatchingWorkerProcesses(string exe)
|
|
{
|
|
var exeFull = System.IO.Path.GetFullPath(exe);
|
|
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
|
{
|
|
try
|
|
{
|
|
var path = p.MainModule?.FileName;
|
|
if (path is not null &&
|
|
!string.Equals(System.IO.Path.GetFullPath(path), exeFull, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
p.Kill(entireProcessTree: true);
|
|
p.WaitForExit(10000);
|
|
}
|
|
catch { /* may have exited or be inaccessible */ }
|
|
finally { p.Dispose(); }
|
|
}
|
|
}
|
|
|
|
// Test seam: overrides the real kill-worker + close-window body.
|
|
internal Action? ShutdownOperation { get; set; }
|
|
|
|
[RelayCommand]
|
|
private async Task ShutdownAsync()
|
|
{
|
|
if (Dialogs is null) return;
|
|
var confirmed = await Dialogs.ConfirmAsync(Loc.T("vm.shell.shutdownConfirm"));
|
|
if (!confirmed) return;
|
|
(ShutdownOperation ?? ShutdownWorkerAndCloseApp)();
|
|
}
|
|
|
|
private void ShutdownWorkerAndCloseApp()
|
|
{
|
|
var exe = _workerLocator.Find();
|
|
if (exe is not null) KillMatchingWorkerProcesses(exe);
|
|
|
|
// Closing just the MainWindow relies on ShutdownMode.OnMainWindowClose, which asks
|
|
// every open window to close and backs off if any of them cancels — and
|
|
// MissionControlWindow always cancels its own close (it hides instead, see
|
|
// MissionControlWindow.OnClosing), so the process never actually exited. Shutdown()
|
|
// force-closes every window, ignoring that cancellation.
|
|
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
|
desktop.Shutdown();
|
|
}
|
|
|
|
private bool _updatingClaudeCli;
|
|
|
|
private bool CanUpdateClaudeCli() => !_updatingClaudeCli;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanUpdateClaudeCli))]
|
|
private async Task UpdateClaudeCliAsync()
|
|
{
|
|
_updatingClaudeCli = true;
|
|
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
|
|
var finished = false;
|
|
try
|
|
{
|
|
FlashFooterInfo(Loc.T("vm.shell.updatingClaudeCli"));
|
|
var dto = Worker is null ? null : await Worker.GetClaudeBinAsync();
|
|
if (dto is null)
|
|
{
|
|
FlashFooterError("Update failed: worker offline.");
|
|
return;
|
|
}
|
|
|
|
// `finished` is only touched on the UI thread: a queued line can't overwrite the final flash.
|
|
var (success, message) = await RunClaudeUpdateAsync(dto, line => Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (!finished) FlashFooterInfo(line);
|
|
}));
|
|
finished = true;
|
|
if (success) FlashFooterSuccess(message);
|
|
else FlashFooterError(message);
|
|
}
|
|
finally
|
|
{
|
|
finished = true;
|
|
_updatingClaudeCli = false;
|
|
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
|
|
}
|
|
}
|
|
|
|
private static async Task<(bool Success, string Message)> RunClaudeUpdateAsync(ClaudeBinDto dto, Action<string> onStdoutLine)
|
|
{
|
|
var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(dto.Value);
|
|
if (resolved is null) return (false, $"Update failed: '{dto.Value}' not found.");
|
|
|
|
var psi = resolved.IsShim
|
|
? BuildShimUpdatePsi(resolved.Path)
|
|
: new System.Diagnostics.ProcessStartInfo { FileName = resolved.Path, ArgumentList = { "update" } };
|
|
psi.UseShellExecute = false;
|
|
psi.RedirectStandardOutput = true;
|
|
psi.RedirectStandardError = true;
|
|
psi.CreateNoWindow = true;
|
|
|
|
using var process = new System.Diagnostics.Process { StartInfo = psi };
|
|
var stdout = new System.Text.StringBuilder();
|
|
var stderr = new System.Text.StringBuilder();
|
|
process.OutputDataReceived += (_, e) =>
|
|
{
|
|
if (e.Data is null) return;
|
|
stdout.AppendLine(e.Data);
|
|
if (!string.IsNullOrWhiteSpace(e.Data)) onStdoutLine(e.Data.Trim());
|
|
};
|
|
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
|
|
|
|
try { process.Start(); }
|
|
catch (Exception ex) { return (false, $"Update failed: {ex.Message}"); }
|
|
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
|
var timedOut = false;
|
|
// On timeout: kill the tree, which unblocks WaitForExitAsync below and lets the
|
|
// async output readers drain/complete naturally (mirrors VerifyCommandRunner).
|
|
await using var ctr = cts.Token.Register(() =>
|
|
{
|
|
timedOut = true;
|
|
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
|
|
});
|
|
|
|
await process.WaitForExitAsync(CancellationToken.None);
|
|
|
|
return timedOut
|
|
? (false, "Update failed: timed out after 5 minutes.")
|
|
: SummarizeClaudeUpdateOutcome(process.ExitCode, stdout.ToString(), stderr.ToString());
|
|
}
|
|
|
|
private static System.Diagnostics.ProcessStartInfo BuildShimUpdatePsi(string shimPath)
|
|
{
|
|
var shim = ClaudeDo.Data.Environment.ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "update" });
|
|
return new System.Diagnostics.ProcessStartInfo { FileName = shim.FileName, Arguments = shim.Arguments };
|
|
}
|
|
|
|
// Pure so a test can drive it with fake process output instead of spawning a real `claude`.
|
|
internal static (bool Success, string Message) SummarizeClaudeUpdateOutcome(int exitCode, string stdout, string stderr)
|
|
{
|
|
if (exitCode == 0)
|
|
return (true, LastNonEmptyLine(stdout) ?? "Claude CLI is already up to date.");
|
|
|
|
var detail = LastNonEmptyLine(stderr) ?? LastNonEmptyLine(stdout) ?? "";
|
|
return (false, $"Update failed (exit {exitCode}): {detail}");
|
|
}
|
|
|
|
private static string? LastNonEmptyLine(string text) =>
|
|
text.Split('\n').Select(l => l.Trim()).LastOrDefault(l => l.Length > 0);
|
|
|
|
[RelayCommand]
|
|
private void DismissBanner()
|
|
{
|
|
_bannerDismissedThisSession = true;
|
|
IsUpdateBannerVisible = false;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void UpdateNow()
|
|
{
|
|
var path = _installerLocator.Find();
|
|
if (path is null) return;
|
|
|
|
try
|
|
{
|
|
// WorkingDirectory must NOT stay empty: the child would inherit ours (<InstallDir>\app)
|
|
// and its locked current directory blocks the installer's own app\ rename.
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
|
|
{
|
|
UseShellExecute = true,
|
|
WorkingDirectory = System.IO.Path.GetTempPath(),
|
|
});
|
|
Environment.Exit(0);
|
|
}
|
|
catch
|
|
{
|
|
// Intentionally silent — if this fails there's nothing useful to show.
|
|
}
|
|
}
|
|
}
|