diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index a4880a3e..e1edeb01 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -129,6 +129,7 @@ "ctxMarkDone": "Erledigt", "ctxMarkCancelled": "Abgebrochen", "ctxRunInteractively": "Interaktiv ausführen", + "ctxOpenConPtySession": "ConPTY-Sitzung öffnen", "ctxPickUpInTerminal": "Im Terminal fortsetzen", "ctxOpenPlanningSession": "Planungssitzung öffnen", "ctxResumePlanningSession": "Planungssitzung fortsetzen", @@ -267,6 +268,10 @@ "settings": "Einstellungen", "queue": "Warteschlange", "blocked": "Blockiert", + "focusMode": "Fokus", + "overviewMode": "Übersicht", + "closeSession": "Sitzung schließen", + "conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}", "question": { "title": "Claude fragt nach", "placeholder": "Antwort eingeben…", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 948bd5a7..4be89921 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -129,6 +129,7 @@ "ctxMarkDone": "Done", "ctxMarkCancelled": "Cancelled", "ctxRunInteractively": "Run interactively", + "ctxOpenConPtySession": "Open ConPTY session", "ctxPickUpInTerminal": "Pick up in terminal", "ctxOpenPlanningSession": "Open planning Session", "ctxResumePlanningSession": "Resume planning Session", @@ -267,6 +268,10 @@ "settings": "Settings", "queue": "Queue", "blocked": "Blocked", + "focusMode": "Focus", + "overviewMode": "Overview", + "closeSession": "Close session", + "conptyLaunchFailed": "Couldn't open ConPTY session: {0}", "question": { "title": "Claude is asking", "placeholder": "Type your answer…", diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs index 1baa1a9c..612c2657 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs @@ -7,11 +7,12 @@ using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Helpers; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.MissionControl; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels.Islands; -public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable +public sealed partial class TaskMonitorViewModel : ViewModelBase, IMissionControlPane, IDisposable { private readonly IDbContextFactory _dbFactory; private readonly IWorkerClient _worker; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index ea1dcc0c..b4c5f522 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -819,6 +819,18 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.runInteractiveFailed", ex.Message)); } } + // Distinct from RunInteractivelyAsync (the old in-app streaming session) — this opens the + // task in an embedded ConPTY terminal pane in the Command Center. The shell owns the + // Mission Control view model, so this just raises an event for it to act on. + public event Action? OpenConPtySessionRequested; + + [RelayCommand] + private void OpenConPtySession(TaskRowViewModel? row) + { + if (row is null) return; + OpenConPtySessionRequested?.Invoke(row.Id); + } + [RelayCommand] private async Task PickUpInTerminalAsync(TaskRowViewModel? row) { diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 90bc427a..8066f6c4 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -215,6 +215,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable MissionControl.OpenInApp = id => _ = RevealTaskAsync(id); MissionControl.ShowDetached = (monitor, reDock) => Dialogs?.ShowDetachedMonitor(monitor, reDock); MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null); + MissionControl.ErrorReported += FlashFooterError; _updateCheck = updateCheck; _installerLocator = installerLocator; _workerLocator = workerLocator; @@ -228,6 +229,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable Tasks.NotesRequested += () => Details.ShowNotes(); Tasks.PrepRequested += () => Details.ShowPrep(); Tasks.ErrorReported += FlashFooterError; + Tasks.OpenConPtySessionRequested += taskId => + { + OpenMissionControl(); + _ = MissionControl.OpenConPtySessionAsync(taskId); + }; Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync(); Tasks.OpenListSettingsRequested += (_, _) => { diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs new file mode 100644 index 00000000..ec6a9cca --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs @@ -0,0 +1,52 @@ +using System; +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.ViewModels.MissionControl; + +/// +/// Command Center pane hosting an embedded ConPTY terminal for one task's interactive Claude +/// session (task-based only — no ad-hoc/free sessions yet). Distinct from the streamed-log +/// pane; the two coexist until +/// the streaming interactive stack is removed. +/// +public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControlPane, IDisposable +{ + public string TaskId { get; } + + [ObservableProperty] private string _displayTitle; + + public InteractiveTerminalViewModel Terminal { get; } = new(); + + /// Raised when the terminal failed to start — the host surfaces this via the footer error strip. + public event Action? ErrorReported; + + /// Set by the host (Mission Control) to remove this pane from its collection. + public Action? CloseRequested { get; set; } + + public ConPtyPaneViewModel(string taskId, string displayTitle, TerminalLaunchDescriptor descriptor) + { + TaskId = taskId; + _displayTitle = displayTitle; + Terminal.PropertyChanged += OnTerminalPropertyChanged; + Terminal.Start(descriptor); + } + + private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error) + ErrorReported?.Invoke(error); + } + + [RelayCommand] + private void Close() => CloseRequested?.Invoke(this); + + public void Dispose() + { + Terminal.PropertyChanged -= OnTerminalPropertyChanged; + Terminal.Kill(); + Terminal.Dispose(); + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControl/IMissionControlPane.cs b/src/ClaudeDo.Ui/ViewModels/MissionControl/IMissionControlPane.cs new file mode 100644 index 00000000..7ea5ad02 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/MissionControl/IMissionControlPane.cs @@ -0,0 +1,12 @@ +namespace ClaudeDo.Ui.ViewModels.MissionControl; + +/// +/// Common contract for anything hosted as a pane in the Command Center — implemented by both +/// the streamed-log and the +/// embedded ConPTY — so the layout toggle (grid/tabs) can +/// bind one heterogeneous pane collection. +/// +public interface IMissionControlPane +{ + string DisplayTitle { get; } +} diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 972c6631..dd2e2e16 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -5,8 +5,10 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Repositories; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Islands; +using ClaudeDo.Ui.ViewModels.MissionControl; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels; @@ -23,8 +25,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable public ObservableCollection Monitors { get; } = new(); + // Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the + // review/merge/status machinery. Mirrored into Panes alongside the streamed-log Monitors. + public ObservableCollection ConPtySessions { get; } = new(); + + // Unified view of Monitors ++ ConPtySessions (in that order) so the layout toggle can + // present one heterogeneous collection as either a grid or tabs. + public ObservableCollection Panes { get; } = new(); + [ObservableProperty] private int _columnCount = 1; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(LayoutToggleLabel))] + private bool _isFocusMode; + + [ObservableProperty] private IMissionControlPane? _focusedPane; + + public string LayoutToggleLabel => Loc.T(IsFocusMode ? "missionControl.overviewMode" : "missionControl.focusMode"); + + /// Surfaces a Command Center failure (e.g. a ConPTY launch spec fetch) — the shell + /// wires this into the footer error strip, same as the island view models' ErrorReported. + public event Action? ErrorReported; + private Action? _openInApp; public Action? OpenInApp { @@ -44,6 +66,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable public Action? OpenSettingsRequested { get; set; } public bool HasMonitors => Monitors.Count > 0; + public bool HasPanes => Panes.Count > 0; // Read-only view of the worker queue (tasks waiting to run), shown as a side strip. public ObservableCollection Queued { get; } = new(); @@ -55,6 +78,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker = worker; Monitors.CollectionChanged += OnMonitorsChanged; + ConPtySessions.CollectionChanged += OnConPtySessionsChanged; + Panes.CollectionChanged += OnPanesChanged; _onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); }; _worker.TaskStartedEvent += _onTaskStarted; @@ -192,6 +217,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable [RelayCommand] private void OpenSettings() => OpenSettingsRequested?.Invoke(); + [RelayCommand] + private void ToggleLayout() => IsFocusMode = !IsFocusMode; + public void MoveMonitor(TaskMonitorViewModel dragged, TaskMonitorViewModel target) { if (ReferenceEquals(dragged, target)) return; @@ -201,15 +229,106 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable Monitors.Move(from, to); } + // Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a + // Command Center pane (task-based only). A distinct entry point from RunInteractivelyAsync's + // streaming session — the two coexist until the streaming stack is removed. + public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId) + { + if (string.IsNullOrEmpty(taskId)) return; + if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing) + { + FocusedPane = existing; + return; + } + + var title = taskId; + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId); + if (entity?.Title is { Length: > 0 } t) title = t; + } + catch { /* best-effort title lookup */ } + + try + { + var spec = await _worker.GetInteractiveLaunchSpecAsync(taskId); + var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env); + var pane = new ConPtyPaneViewModel(taskId, title, descriptor); + pane.ErrorReported += OnConPtyPaneError; + pane.CloseRequested += CloseConPtySession; + ConPtySessions.Add(pane); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message)); + } + } + + private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message); + + private void CloseConPtySession(ConPtyPaneViewModel pane) + { + if (!ConPtySessions.Contains(pane)) return; + pane.ErrorReported -= OnConPtyPaneError; + pane.CloseRequested -= CloseConPtySession; + ConPtySessions.Remove(pane); + pane.Dispose(); + } + + // Mirrors Monitors' add/remove/move into the front (Monitors-prefix) section of Panes. private void OnMonitorsChanged(object? sender, NotifyCollectionChangedEventArgs e) { - ColumnCount = Monitors.Count switch + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Panes.Insert(e.NewStartingIndex, (TaskMonitorViewModel)e.NewItems![0]!); + break; + case NotifyCollectionChangedAction.Remove: + Panes.RemoveAt(e.OldStartingIndex); + break; + case NotifyCollectionChangedAction.Move: + Panes.Move(e.OldStartingIndex, e.NewStartingIndex); + break; + default: // Reset (Dispose's Monitors.Clear()) + foreach (var p in Panes.OfType().ToList()) + Panes.Remove(p); + break; + } + OnPropertyChanged(nameof(HasMonitors)); + } + + // Mirrors ConPtySessions' add/remove into the tail (ConPtySessions-suffix) section of Panes. + private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Panes.Insert(Monitors.Count + e.NewStartingIndex, (ConPtyPaneViewModel)e.NewItems![0]!); + break; + case NotifyCollectionChangedAction.Remove: + Panes.RemoveAt(Monitors.Count + e.OldStartingIndex); + break; + default: // Reset + foreach (var p in Panes.OfType().ToList()) + Panes.Remove(p); + break; + } + } + + private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + ColumnCount = Panes.Count switch { <= 1 => 1, <= 4 => 2, _ => 3, }; - OnPropertyChanged(nameof(HasMonitors)); + OnPropertyChanged(nameof(HasPanes)); + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is IMissionControlPane added) + FocusedPane = added; + else if (e.Action == NotifyCollectionChangedAction.Remove && ReferenceEquals(FocusedPane, e.OldItems?[0])) + FocusedPane = Panes.LastOrDefault(); } public void Dispose() @@ -220,8 +339,18 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.ConnectionRestoredEvent -= _onConnectionRestored; _worker.InteractiveSessionStartedEvent -= _onInteractiveStarted; Monitors.CollectionChanged -= OnMonitorsChanged; + ConPtySessions.CollectionChanged -= OnConPtySessionsChanged; + Panes.CollectionChanged -= OnPanesChanged; foreach (var m in Monitors) m.Dispose(); Monitors.Clear(); + foreach (var c in ConPtySessions.ToList()) + { + c.ErrorReported -= OnConPtyPaneError; + c.CloseRequested -= CloseConPtySession; + c.Dispose(); + } + ConPtySessions.Clear(); + Panes.Clear(); } } diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index 29471275..82fc1f60 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -51,6 +51,8 @@ + diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index fdf10b7e..8a5b8874 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -67,6 +67,12 @@ public partial class TaskRowView : UserControl await vm.RunInteractivelyCommand.ExecuteAsync(row); } + private void OnOpenConPtySessionClick(object? sender, RoutedEventArgs e) + { + if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) + vm.OpenConPtySessionCommand.Execute(row); + } + private async void OnPickUpInTerminalClick(object? sender, RoutedEventArgs e) { if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) diff --git a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml new file mode 100644 index 00000000..b8e18362 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml.cs b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml.cs new file mode 100644 index 00000000..be38e331 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml.cs @@ -0,0 +1,8 @@ +using Avalonia.Controls; + +namespace ClaudeDo.Ui.Views.MissionControl; + +public partial class ConPtyPaneView : UserControl +{ + public ConPtyPaneView() => InitializeComponent(); +} diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml index f242d429..e2862888 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml @@ -2,10 +2,21 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ClaudeDo.Ui.ViewModels" xmlns:vmi="using:ClaudeDo.Ui.ViewModels.Islands" + xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl" xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl" xmlns:loc="using:ClaudeDo.Ui.Localization" x:DataType="vm:MissionControlViewModel" x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView"> + + + + + + + + + @@ -21,6 +32,12 @@ LetterSpacing="1.4" VerticalAlignment="Center" /> +