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
@@ -203,7 +203,13 @@ public sealed class InteractiveLaunchSpecService
}
if (paneId is not null)
{
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args];
await new MissionControlSessionRepository(ctx).RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId, Cwd = sessionDir, TaskId = taskId, Title = task.Title,
}, ct);
}
// Same run environment variable ClaudeProcess sets for every headless run: the
// AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting
@@ -288,6 +294,10 @@ public sealed class InteractiveLaunchSpecService
// Pre-assigned so the pane entry can carry it -- a handoff records it as the brief's origin.
var sessionId = Guid.NewGuid().ToString();
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args, "--session-id", sessionId];
await new MissionControlSessionRepository(ctx).RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId, Cwd = directory, Title = FolderTitle(directory),
}, ct);
}
return new LaunchSpec(directory, resolvedClaude, args, env);
}
@@ -316,14 +326,28 @@ public sealed class InteractiveLaunchSpecService
var sessionId = Guid.NewGuid().ToString();
var model = settings.DefaultModel;
TaskEntity? task = null;
if (taskId is not null)
{
var taskRepo = new TaskRepository(ctx);
var task = await taskRepo.GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task not found: {taskId}");
task = await taskRepo.GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task not found: {taskId}");
model = task.Model ?? (await new ListRepository(ctx).GetConfigAsync(task.ListId, ct))?.Model;
await taskRepo.SetInteractiveSessionIdAsync(taskId, sessionId, ct);
}
var sessions = new MissionControlSessionRepository(ctx);
var fromSession = SessionHandoffMcpTools.ReadHandedOffFrom(briefPath);
var predecessor = fromSession is null ? null : await sessions.GetAsync(fromSession, ct);
await sessions.RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId,
Cwd = cwd,
TaskId = taskId ?? predecessor?.TaskId,
Title = predecessor?.Title ?? task?.Title ?? FolderTitle(cwd),
PredecessorSessionId = fromSession,
BriefPath = briefPath,
}, ct);
// --add-dir is variadic: the single-value flags after it end it, and the kickoff stays last.
var args = new List<string>(await PaneSessionArgsAsync(paneId, sessionId, ct))
{
@@ -342,6 +366,37 @@ public sealed class InteractiveLaunchSpecService
return new LaunchSpec(Paths.TrimTrailingSeparator(cwd)!, resolvedClaude, args, env);
}
/// <summary>Builds the LaunchSpec that resumes a recorded Mission Control session (`--resume`) in
/// its own cwd, with the handoff hint so the resumed session can hand off again. Throws
/// KeyNotFoundException for an unrecorded session, InvalidOperationException for a missing cwd.</summary>
public async Task<LaunchSpec> BuildResumeAsync(string sessionId, string paneId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var session = await new MissionControlSessionRepository(ctx).GetAsync(sessionId, ct)
?? throw new KeyNotFoundException($"Session not found: {sessionId}");
if (!Directory.Exists(session.Cwd))
throw new InvalidOperationException($"Directory does not exist: {session.Cwd}");
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
IReadOnlyList<string> args =
[
.. await PaneSessionArgsAsync(paneId, sessionId, ct),
.. WithEffort(WindowsTerminalLauncher.BuildResumeArgs(sessionId), EffortFor(settings, settings.DefaultModel)),
];
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
return new LaunchSpec(session.Cwd, resolvedClaude, args, env);
}
// Same title an ad-hoc tile shows: the folder name, or the whole path for a drive root.
private static string FolderTitle(string directory) =>
Path.GetFileName(directory.TrimEnd('\\', '/')) is { Length: > 0 } name ? name : directory;
/// <summary>Per-tile dir under the app data root holding the handoff hint and every brief the
/// tile's sessions handed off with. Throws InvalidOperationException unless paneId is a GUID —
/// it becomes a path segment.</summary>