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:
Mika Kuns
2026-09-28 12:11:21 +02:00
parent 216620c52e
commit d3db4455fa
23 changed files with 1630 additions and 2 deletions
@@ -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()
{