Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
T
mika kunsandClaude Sonnet 5.5 feb0afa33a fix(merge): pass the stash consent to the conflict resolver so it opens on a dirty target
The resolver restarts the merge via StartConflictMerge, which had no autostash.
After a stash-retry approve reported 'conflict', the restart hit the same dirty
target, got 'dirty_target' and the resolver silently never opened. Thread the
consent through the hub, client, coordinator and resolver; surface a refusal in
the footer instead of dropping it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:25:52 +02:00

897 lines
36 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, bool autostash = false)
{
if (ConflictResolverFactory is null || Dialogs is null) return;
var vm = ConflictResolverFactory(taskId);
var hasConflicts = await vm.OpenAsync(targetBranch, autostash);
if (hasConflicts)
await Dialogs.ShowConflictResolverAsync(vm);
else if (vm.Error is { } error)
FlashFooterError(error);
}
[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));
}
private const int FooterHistoryCap = 500;
/// <summary>Every line the footer strip has shown this session, oldest-first, capped; UI thread only.</summary>
private readonly List<WorkerLogEntry> _footerHistory = new();
internal IReadOnlyList<WorkerLogEntry> FooterHistorySnapshot() => _footerHistory.ToArray();
private void RecordFooter(WorkerLogEntry entry)
{
_footerHistory.Add(entry);
if (_footerHistory.Count > FooterHistoryCap)
_footerHistory.RemoveAt(0);
}
public void OnWorkerLogReceived(WorkerLogEntry entry)
{
RecordFooter(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);
RecordFooter(new WorkerLogEntry(message, level, DateTime.UtcNow));
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, FooterHistorySnapshot);
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.
}
}
}