From 6c400dfb1ad98578152339726f387856e6573f93 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 2 Oct 2026 14:29:26 +0200 Subject: [PATCH] feat(ui): ssh workspaces as quick-session targets in mission control --- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + .../Services/Interfaces/IWorkerClient.cs | 6 +++++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 9 ++++++++ .../ViewModels/MissionControlViewModel.cs | 19 +++++++++++++++ tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 4 ++++ .../MissionControlViewModelTests.cs | 23 +++++++++++++++++++ 7 files changed, 63 insertions(+) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index edd631df..fd8a2ab1 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -399,6 +399,7 @@ "roadblock": "Blockade gemeldet", "openSession": "Sitzung öffnen", "homeFolder": "Home (Schnellsitzung)", + "sshTarget": "{0} (SSH)", "browseForFolder": "Ordner wählen…", "recentSessions": "Letzte Sitzungen", "recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index c31efbdf..ac705af9 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -399,6 +399,7 @@ "roadblock": "Roadblock reported", "openSession": "Open session", "homeFolder": "Home (quick session)", + "sshTarget": "{0} (SSH)", "browseForFolder": "Choose folder…", "recentSessions": "Recent sessions", "recentSessionsEmpty": "No sessions in the last 14 days", diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index c0a8111a..27469290 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -104,6 +104,10 @@ public interface IWorkerClient : INotifyPropertyChanged Task> GetRepoImportFoldersAsync(); /// Persists the repo-import folder list. Throws on a failed hub call. Task SetRepoImportFoldersAsync(List folders); + /// The configured SSH workspaces (quick-session targets in Mission Control). + Task> GetSshWorkspacesAsync(); + /// Replaces the SSH workspace list; the worker rejects invalid entries. + Task SetSshWorkspacesAsync(List workspaces); Task> GetSessionSkillsAsync(); Task> InstallSessionSkillAsync(string url); Task UpdateSessionSkillAsync(string sourceUrl); @@ -147,6 +151,8 @@ public interface IWorkerClient : INotifyPropertyChanged /// Launch spec for an ad-hoc interactive session in an arbitrary directory -- /// no task, no worktree. paneId (the tile's IdentityKey) is rendered into the handoff hint. Task GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default); + /// Launch spec for an interactive claude session on an SSH workspace's server, as tile paneId. + Task GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default); /// Launch spec for the fresh session a tile hands off to after handoff_session. Task GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default); /// Launch spec resuming a recorded Mission Control session in its cwd, as tile paneId. diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index eeea9239..c5d93183 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -572,6 +572,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public Task SetRepoImportFoldersAsync(List folders) => InvokeTimedAsync("SetRepoImportFolders", () => _hub.InvokeAsync("SetRepoImportFolders", folders)); + public async Task> GetSshWorkspacesAsync() + => await InvokeTimedAsync>("GetSshWorkspaces", () => _hub.InvokeAsync>("GetSshWorkspaces")); + + public Task SetSshWorkspacesAsync(List workspaces) + => InvokeTimedAsync("SetSshWorkspaces", () => _hub.InvokeAsync("SetSshWorkspaces", workspaces)); + public async Task> GetSessionSkillsAsync() => await TryInvokeAsync>("GetSessionSkills") ?? []; @@ -668,6 +674,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default) => await InvokeTimedAsync("GetAdHocLaunchSpec", () => _hub.InvokeAsync("GetAdHocLaunchSpec", directory, paneId, ct)); + public async Task GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default) + => await InvokeTimedAsync("GetSshLaunchSpec", () => _hub.InvokeAsync("GetSshLaunchSpec", workspaceId, paneId, ct)); + public async Task GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default) => await InvokeTimedAsync("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct)); diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 8d093871..d88c0614 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -302,6 +302,16 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(dir)), }); } + + foreach (var ws in await new AppSettingsRepository(ctx).GetSshWorkspacesAsync()) + { + var w = ws; + SessionTargets.Add(new SessionTargetViewModel + { + Label = Loc.T("missionControl.sshTarget", w.Name), + StartCommand = new RelayCommand(() => _ = OpenSshConPtySessionAsync(w)), + }); + } } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } } @@ -546,6 +556,15 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable return System.Threading.Tasks.Task.CompletedTask; } + /// Interactive session on an SSH workspace's server. Never deduped, never recorded for resume. + public System.Threading.Tasks.Task OpenSshConPtySessionAsync(SshWorkspace workspace) + { + var paneId = Guid.NewGuid().ToString("n"); + AddConPtyPane(new ConPtyPaneViewModel(null, workspace.Name, + () => DescribeAsync(() => _worker.GetSshLaunchSpecAsync(workspace.Id, paneId))) { IdentityKey = paneId }); + return System.Threading.Tasks.Task.CompletedTask; + } + /// A session called handoff_session: replaces its tile (same slot, same IdentityKey, so /// same colour) with a fresh session in the same cwd that starts from the brief. No matching tile /// (closed meanwhile, or another app instance) = nothing to replace. diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 4ddc8bfc..cb8dd5d7 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -103,6 +103,10 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task GetListConfigAsync(string listId) => Task.FromResult(null); public virtual Task> GetRepoImportFoldersAsync() => Task.FromResult(new List()); public virtual Task SetRepoImportFoldersAsync(List folders) => Task.CompletedTask; + public virtual Task> GetSshWorkspacesAsync() => Task.FromResult(new List()); + public virtual Task SetSshWorkspacesAsync(List workspaces) => Task.CompletedTask; + public virtual Task GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(".", "ssh", Array.Empty(), new Dictionary())); public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; public virtual Task> GetSessionSkillsAsync() => Task.FromResult(new List()); public virtual Task> InstallSessionSkillAsync(string url) => Task.FromResult(new List()); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index 16860acb..070cf512 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -1,6 +1,7 @@ using System.Linq; using ClaudeDo.Data; using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels.MissionControl; @@ -303,6 +304,28 @@ public class MissionControlViewModelTests : IDisposable Assert.True(vm.Queued[4].CanOpenSession); } + [Fact] + public async Task RefreshSessionTargetsAsync_AppendsSshWorkspaces_AndStartsSshPane() + { + await using (var db = NewContext()) + { + db.Lists.Add(new ListEntity { Id = "L1", Name = "Alpha", WorkingDir = @"C:\repos\alpha", CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + await new AppSettingsRepository(db).SetSshWorkspacesAsync([new SshWorkspace("w1", "VPS", "vps", "/srv/app")]); + } + + using var vm = BuildVm(new FakeWorker()); + + await vm.RefreshSessionTargetsAsync(); + + Assert.Equal(4, vm.SessionTargets.Count); // home, browse, Alpha, VPS + Assert.Contains("VPS", vm.SessionTargets[3].Label); + + vm.SessionTargets[3].StartCommand.Execute(null); + var pane = Assert.Single(vm.ConPtySessions); + Assert.Equal("VPS", pane.DisplayTitle); + } + [Fact] public async Task RefreshSessionTargetsAsync_ListsWithWorkingDir_PlusBrowse() {