feat(ui): Mission Control Recent Sessions mit verschachtelten Handoffs
Neue Tabelle mission_control_sessions (Migration AddMissionControlSessions), befuellt vom InteractiveLaunchSpecService bei Task-, Ad-hoc- und Handoff-Kacheln. Flyout im Mission-Control-Header listet die letzten 14 Tage / 50 Sessions, Handoffs eingerueckt unter ihrer Ursprungs-Session, mit Fortsetzen (--resume im gespeicherten cwd) und Brief anzeigen.
This commit is contained in:
@@ -130,6 +130,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(cwd, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<string> CreateMergeHelperTaskAsync(
|
||||
|
||||
@@ -42,6 +42,23 @@ public class MissionControlViewModelTests : IDisposable
|
||||
private MissionControlViewModel BuildVm(StubWorkerClient worker)
|
||||
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker));
|
||||
|
||||
[Fact]
|
||||
public void RecentSessions_NestHandoffs_PutsIndirectHandoffsUnderTheirOrigin()
|
||||
{
|
||||
var t0 = new DateTime(2026, 9, 28, 8, 0, 0, DateTimeKind.Utc);
|
||||
MissionControlSessionEntity S(string id, int minutes, string? from = null)
|
||||
=> new() { SessionId = id, Cwd = "c", Title = id, StartedAt = t0.AddMinutes(minutes), PredecessorSessionId = from };
|
||||
// Newest first, as ListRecentAsync returns them. "orphan" names a predecessor outside the window.
|
||||
var newestFirst = new[] { S("c", 40, "b"), S("other", 30), S("b", 20, "a"), S("orphan", 15, "gone"), S("a", 10) };
|
||||
|
||||
var rows = MissionControlViewModel.NestHandoffs(newestFirst)
|
||||
.Select(r => (r.Session.SessionId, r.IsHandoff)).ToList();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { ("a", false), ("b", true), ("c", true), ("other", false), ("orphan", false) },
|
||||
rows);
|
||||
}
|
||||
|
||||
// Gates the title/list-name DB lookup behind a manually-released TaskCompletionSource so two
|
||||
// overlapping Open*ConPtySessionAsync calls both run their synchronous dedupe-check prefix
|
||||
// to completion before either resumes past the DB await. This reproduces the open-path races
|
||||
|
||||
@@ -712,6 +712,43 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
Assert.Equal(ArgAfter(spec, "--session-id"), await ReadInteractiveSessionIdAsync(taskId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecentSession_AdHocThenHandoff_RecordsChainAndResumesInCwd()
|
||||
{
|
||||
var paneId = NewPaneId();
|
||||
var svc = BuildService();
|
||||
var origin = ArgAfter(await svc.BuildForDirectoryAsync(_tempDir, paneId, CancellationToken.None), "--session-id");
|
||||
|
||||
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", "h1");
|
||||
Directory.CreateDirectory(dir);
|
||||
var briefPath = Path.Combine(dir, "brief.md");
|
||||
File.WriteAllText(briefPath, $"Handed off from session `{origin}`.\n\nbrief");
|
||||
var handoff = ArgAfter(await svc.BuildHandoffAsync(paneId, _tempDir, briefPath, null, CancellationToken.None), "--session-id");
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
var repo = new MissionControlSessionRepository(ctx);
|
||||
var first = await repo.GetAsync(origin);
|
||||
var second = await repo.GetAsync(handoff);
|
||||
Assert.NotNull(first);
|
||||
Assert.Null(first!.PredecessorSessionId);
|
||||
Assert.Equal(origin, second!.PredecessorSessionId);
|
||||
Assert.Equal(first.Title, second.Title);
|
||||
Assert.Equal(briefPath, second.BriefPath);
|
||||
}
|
||||
|
||||
var resumePane = NewPaneId();
|
||||
var resume = await svc.BuildResumeAsync(handoff, resumePane, CancellationToken.None);
|
||||
Assert.Equal(_tempDir, resume.Cwd);
|
||||
Assert.Equal(handoff, ArgAfter(resume, "--resume"));
|
||||
AssertHandoffHint(resume, resumePane, handoff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildResumeAsync_UnknownSession_ThrowsKeyNotFound()
|
||||
=> await Assert.ThrowsAsync<KeyNotFoundException>(
|
||||
() => BuildService().BuildResumeAsync(Guid.NewGuid().ToString(), NewPaneId(), CancellationToken.None));
|
||||
|
||||
[Fact]
|
||||
public async Task BuildHandoffAsync_BriefOutsidePaneDir_Throws()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user