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:
+13
-1
@@ -53,13 +53,25 @@ public sealed class SessionHandoffMcpTools
|
||||
Directory.CreateDirectory(dir);
|
||||
var briefPath = Path.Combine(dir, "brief.md");
|
||||
var fromSession = headers![SessionHeader].ToString();
|
||||
var header = fromSession.Length == 0 ? "" : $"Handed off from session `{fromSession}`.\n\n";
|
||||
var header = fromSession.Length == 0 ? "" : $"{HandedOffFromPrefix}{fromSession}`.\n\n";
|
||||
await File.WriteAllTextAsync(briefPath, header + brief.Trim() + "\n", cancellationToken);
|
||||
|
||||
await _broadcaster.SessionHandoffRequested(paneId, briefPath);
|
||||
return new SessionHandoffResult(true, briefPath);
|
||||
}
|
||||
|
||||
/// <summary>Start of a brief's first line; the handing-off session id and a closing backtick follow.</summary>
|
||||
private const string HandedOffFromPrefix = "Handed off from session `";
|
||||
|
||||
/// <summary>The session id a brief was handed off from, or null when its first line carries none.</summary>
|
||||
public static string? ReadHandedOffFrom(string briefPath)
|
||||
{
|
||||
var first = File.ReadLines(briefPath).FirstOrDefault();
|
||||
if (first is null || !first.StartsWith(HandedOffFromPrefix, StringComparison.Ordinal)) return null;
|
||||
var end = first.IndexOf('`', HandedOffFromPrefix.Length);
|
||||
return end > HandedOffFromPrefix.Length ? first[HandedOffFromPrefix.Length..end] : null;
|
||||
}
|
||||
|
||||
/// <summary>List-tools filter: a request carrying <see cref="PaneHeader"/> (the per-tile entry) sees
|
||||
/// only handoff_session; every other request sees everything but it.</summary>
|
||||
public static McpRequestHandler<ListToolsRequestParams, ListToolsResult> FilterByPane(
|
||||
|
||||
@@ -798,6 +798,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return _interactiveLaunchSpec.BuildHandoffAsync(paneId, cwd, briefPath, taskId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
/// <summary>Launch spec resuming a recorded Mission Control session ("Recent sessions") in a new tile.</summary>
|
||||
public Task<LaunchSpec> GetResumeSessionLaunchSpec(string sessionId, string paneId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildResumeAsync(sessionId, paneId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
|
||||
// and cwd to that list. handlerTaskId is the id returned by CreateMergeHelperTask -- it is
|
||||
|
||||
@@ -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