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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user