feat(ui): ssh workspaces as quick-session targets in mission control
This commit is contained in:
@@ -399,6 +399,7 @@
|
|||||||
"roadblock": "Blockade gemeldet",
|
"roadblock": "Blockade gemeldet",
|
||||||
"openSession": "Sitzung öffnen",
|
"openSession": "Sitzung öffnen",
|
||||||
"homeFolder": "Home (Schnellsitzung)",
|
"homeFolder": "Home (Schnellsitzung)",
|
||||||
|
"sshTarget": "{0} (SSH)",
|
||||||
"browseForFolder": "Ordner wählen…",
|
"browseForFolder": "Ordner wählen…",
|
||||||
"recentSessions": "Letzte Sitzungen",
|
"recentSessions": "Letzte Sitzungen",
|
||||||
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
|
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
|
||||||
|
|||||||
@@ -399,6 +399,7 @@
|
|||||||
"roadblock": "Roadblock reported",
|
"roadblock": "Roadblock reported",
|
||||||
"openSession": "Open session",
|
"openSession": "Open session",
|
||||||
"homeFolder": "Home (quick session)",
|
"homeFolder": "Home (quick session)",
|
||||||
|
"sshTarget": "{0} (SSH)",
|
||||||
"browseForFolder": "Choose folder…",
|
"browseForFolder": "Choose folder…",
|
||||||
"recentSessions": "Recent sessions",
|
"recentSessions": "Recent sessions",
|
||||||
"recentSessionsEmpty": "No sessions in the last 14 days",
|
"recentSessionsEmpty": "No sessions in the last 14 days",
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task<List<string>> GetRepoImportFoldersAsync();
|
Task<List<string>> GetRepoImportFoldersAsync();
|
||||||
/// <summary>Persists the repo-import folder list. Throws on a failed hub call.</summary>
|
/// <summary>Persists the repo-import folder list. Throws on a failed hub call.</summary>
|
||||||
Task SetRepoImportFoldersAsync(List<string> folders);
|
Task SetRepoImportFoldersAsync(List<string> folders);
|
||||||
|
/// <summary>The configured SSH workspaces (quick-session targets in Mission Control).</summary>
|
||||||
|
Task<List<SshWorkspace>> GetSshWorkspacesAsync();
|
||||||
|
/// <summary>Replaces the SSH workspace list; the worker rejects invalid entries.</summary>
|
||||||
|
Task SetSshWorkspacesAsync(List<SshWorkspace> workspaces);
|
||||||
Task<List<SessionSkillDto>> GetSessionSkillsAsync();
|
Task<List<SessionSkillDto>> GetSessionSkillsAsync();
|
||||||
Task<List<string>> InstallSessionSkillAsync(string url);
|
Task<List<string>> InstallSessionSkillAsync(string url);
|
||||||
Task UpdateSessionSkillAsync(string sourceUrl);
|
Task UpdateSessionSkillAsync(string sourceUrl);
|
||||||
@@ -147,6 +151,8 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
|
/// <summary>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.</summary>
|
/// no task, no worktree. paneId (the tile's IdentityKey) is rendered into the handoff hint.</summary>
|
||||||
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default);
|
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default);
|
||||||
|
/// <summary>Launch spec for an interactive claude session on an SSH workspace's server, as tile paneId.</summary>
|
||||||
|
Task<LaunchSpec> GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default);
|
||||||
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
||||||
Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default);
|
Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default);
|
||||||
/// <summary>Launch spec resuming a recorded Mission Control session in its cwd, as tile paneId.</summary>
|
/// <summary>Launch spec resuming a recorded Mission Control session in its cwd, as tile paneId.</summary>
|
||||||
|
|||||||
@@ -572,6 +572,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public Task SetRepoImportFoldersAsync(List<string> folders)
|
public Task SetRepoImportFoldersAsync(List<string> folders)
|
||||||
=> InvokeTimedAsync("SetRepoImportFolders", () => _hub.InvokeAsync("SetRepoImportFolders", folders));
|
=> InvokeTimedAsync("SetRepoImportFolders", () => _hub.InvokeAsync("SetRepoImportFolders", folders));
|
||||||
|
|
||||||
|
public async Task<List<SshWorkspace>> GetSshWorkspacesAsync()
|
||||||
|
=> await InvokeTimedAsync<List<SshWorkspace>>("GetSshWorkspaces", () => _hub.InvokeAsync<List<SshWorkspace>>("GetSshWorkspaces"));
|
||||||
|
|
||||||
|
public Task SetSshWorkspacesAsync(List<SshWorkspace> workspaces)
|
||||||
|
=> InvokeTimedAsync("SetSshWorkspaces", () => _hub.InvokeAsync("SetSshWorkspaces", workspaces));
|
||||||
|
|
||||||
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
|
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
|
||||||
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
|
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
|
||||||
|
|
||||||
@@ -668,6 +674,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
|
||||||
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, paneId, ct));
|
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, paneId, ct));
|
||||||
|
|
||||||
|
public async Task<LaunchSpec> GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default)
|
||||||
|
=> await InvokeTimedAsync<LaunchSpec>("GetSshLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSshLaunchSpec", workspaceId, paneId, ct));
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
|
||||||
=> await InvokeTimedAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct));
|
=> await InvokeTimedAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct));
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,16 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(dir)),
|
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)); }
|
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;
|
return System.Threading.Tasks.Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Interactive session on an SSH workspace's server. Never deduped, never recorded for resume.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A session called handoff_session: replaces its tile (same slot, same IdentityKey, so
|
/// <summary>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
|
/// 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.</summary>
|
/// (closed meanwhile, or another app instance) = nothing to replace.</summary>
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||||
public virtual Task<List<string>> GetRepoImportFoldersAsync() => Task.FromResult(new List<string>());
|
public virtual Task<List<string>> GetRepoImportFoldersAsync() => Task.FromResult(new List<string>());
|
||||||
public virtual Task SetRepoImportFoldersAsync(List<string> folders) => Task.CompletedTask;
|
public virtual Task SetRepoImportFoldersAsync(List<string> folders) => Task.CompletedTask;
|
||||||
|
public virtual Task<List<SshWorkspace>> GetSshWorkspacesAsync() => Task.FromResult(new List<SshWorkspace>());
|
||||||
|
public virtual Task SetSshWorkspacesAsync(List<SshWorkspace> workspaces) => Task.CompletedTask;
|
||||||
|
public virtual Task<LaunchSpec> GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new LaunchSpec(".", "ssh", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||||
public virtual Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(new List<SessionSkillDto>());
|
public virtual Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(new List<SessionSkillDto>());
|
||||||
public virtual Task<List<string>> InstallSessionSkillAsync(string url) => Task.FromResult(new List<string>());
|
public virtual Task<List<string>> InstallSessionSkillAsync(string url) => Task.FromResult(new List<string>());
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||||
@@ -303,6 +304,28 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
Assert.True(vm.Queued[4].CanOpenSession);
|
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]
|
[Fact]
|
||||||
public async Task RefreshSessionTargetsAsync_ListsWithWorkingDir_PlusBrowse()
|
public async Task RefreshSessionTargetsAsync_ListsWithWorkingDir_PlusBrowse()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user