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; } public MissionControlViewModel? MissionControl { get; } public UsagePillViewModel? UsagePill { get; } public UpdateCheckService UpdateCheck => _updateCheck; public string ConnectionText => Worker?.IsConnected == true ? Loc.T("vm.connection.online") : Worker?.IsReconnecting == true ? Loc.T("vm.connection.connecting") : Loc.T("vm.connection.offline"); public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true; private readonly UpdateCheckService _updateCheck = null!; private readonly InstallerLocator _installerLocator = null!; private readonly WorkerLocator _workerLocator = null!; private readonly IDbContextFactory? _dbFactory; private readonly Func _worktreesOverviewVmFactory = () => null!; private readonly Func _weeklyReportVmFactory = () => null!; private readonly Func _usageMonitorVmFactory = () => null!; private readonly Func _mergeVmFactory = () => null!; private readonly Func? _repoImportVmFactory; public Func ResolveMergeVm => _mergeVmFactory; // Layer C seam: composition root sets the factory; the dialog service shows the resolver. public Func? 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 lists island. 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; } } 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; [ObservableProperty] private double _windowWidth = 1280; [ObservableProperty] private string? _workerLogText; [ObservableProperty] private WorkerLogLevel _workerLogLevel; [ObservableProperty] private bool _isWorkerLogVisible; public bool ShowDetails => WindowWidth >= 1100; 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) { OnPropertyChanged(nameof(ShowDetails)); OnPropertyChanged(nameof(ShowLists)); } 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) { WorkerLogText = $"{DateTime.Now:HH:mm} · {message}"; WorkerLogLevel = WorkerLogLevel.Error; 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(); } private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList conflictedFiles) { // Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post). // A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge. _ = 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 dbFactory, Func worktreesOverviewVmFactory, Func weeklyReportVmFactory, Func usageMonitorVmFactory, Func mergeVmFactory, Func 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.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.NotesRequested += () => Details.ShowNotes(); Tasks.PrepRequested += () => Details.ShowPrep(); Tasks.ErrorReported += FlashFooterError; Lists.ErrorReported += FlashFooterError; 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); }; 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); }; Details.ErrorReported += FlashFooterError; Details.CloseDetail = () => Tasks.SelectedTask = null; 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)) { OnPropertyChanged(nameof(ConnectionText)); OnPropertyChanged(nameof(IsOffline)); } }; Worker.WorkerLogReceivedEvent += OnWorkerLogReceived; Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict; 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(); } }; // Fire-and-forget startup check — never block UI. _ = Task.Run(async () => { try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { } }); } public void Dispose() { _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); } private void SyncInteractiveSessionChips() { if (MissionControl 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(); if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm); } [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); } [RelayCommand] private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync(); [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.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; await vm.LoadAsync(); await Dialogs.ShowUsageMonitorAsync(vm); } finally { _usageMonitorOpen = false; } } [RelayCommand] private async Task CheckForUpdatesAsync() { await _updateCheck.CheckNowAsync(CancellationToken.None); } [ObservableProperty] private string? _restartWorkerStatus; [RelayCommand] private async Task RestartWorkerAsync() { RestartWorkerStatus = Loc.T("vm.shell.restartingWorker"); try { await Task.Run(RestartWorkerService); await FlashRestartStatusAsync("Worker restarted."); } catch (Exception ex) { await FlashRestartStatusAsync($"Restart failed: {ex.Message}"); } } private void RestartWorkerService() { var exe = _workerLocator.Find(); if (exe is null) throw new InvalidOperationException("Worker executable not found."); // Only kill the worker belonging to THIS installation — not any other // ClaudeDo.Worker on the machine (e.g. a second install). 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(); } } System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); } private async Task FlashRestartStatusAsync(string text) { RestartWorkerStatus = text; await Task.Delay(3000); if (RestartWorkerStatus == text) RestartWorkerStatus = null; } [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 (\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. } } }