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
@@ -5,6 +5,8 @@ using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.MissionControl;
@@ -75,6 +77,12 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// fresh session is two clicks instead of navigating a folder picker.
public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new();
/// <summary>"Recent sessions" flyout rows: each chain's origin session, its handoffs right below it.</summary>
public ObservableCollection<RecentSessionViewModel> RecentSessions { get; } = new();
/// <summary>False shows the flyout's empty-state line.</summary>
public bool HasRecentSessions => RecentSessions.Count > 0;
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
// MissionControlView.OnNewSessionClicked).
public Action? BrowseForSessionRequested { get; set; }
@@ -215,6 +223,84 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
}
/// <summary>Rebuilds <see cref="RecentSessions"/> from the last 14 days (max 50 sessions); runs on
/// every flyout open.</summary>
internal async System.Threading.Tasks.Task RefreshRecentSessionsAsync()
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var sessions = await new MissionControlSessionRepository(ctx).ListRecentAsync(DateTime.UtcNow.AddDays(-14), 50);
var ids = sessions.Select(s => s.SessionId).ToList();
// A task's current session resumes through its task tile, so it stays deduped by TaskId.
var taskCurrent = (await ctx.Tasks.AsNoTracking()
.Where(t => t.InteractiveSessionId != null && ids.Contains(t.InteractiveSessionId))
.Select(t => t.InteractiveSessionId!)
.ToListAsync()).ToHashSet();
RecentSessions.Clear();
foreach (var (s, isHandoff) in NestHandoffs(sessions))
{
var time = DateTime.SpecifyKind(s.StartedAt, DateTimeKind.Utc).ToLocalTime().ToString("g");
var briefPath = s.BriefPath;
RecentSessions.Add(new RecentSessionViewModel
{
Title = s.Title,
Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}",
IsHandoff = isHandoff,
ResumeCommand = new RelayCommand(() => ResumeSession(s, taskCurrent.Contains(s.SessionId))),
ShowBriefCommand = briefPath is null ? null : new RelayCommand(() => ShowBrief(briefPath)),
});
}
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
OnPropertyChanged(nameof(HasRecentSessions));
}
/// <summary>Orders newest-first sessions into chains: each origin session (no recorded predecessor
/// in the set) followed by every session handed off from it, directly or indirectly, oldest
/// first. Chains are ordered by their newest member.</summary>
internal static IEnumerable<(MissionControlSessionEntity Session, bool IsHandoff)> NestHandoffs(
IReadOnlyList<MissionControlSessionEntity> newestFirst)
{
var byId = newestFirst.ToDictionary(s => s.SessionId);
string RootOf(MissionControlSessionEntity s)
{
var seen = new HashSet<string>();
while (s.PredecessorSessionId is { } p && byId.TryGetValue(p, out var prev) && seen.Add(s.SessionId))
s = prev;
return s.SessionId;
}
return newestFirst
.GroupBy(RootOf)
.SelectMany(chain => chain
.OrderBy(s => s.SessionId != chain.Key)
.ThenBy(s => s.StartedAt)
.Select(s => (s, s.SessionId != chain.Key)));
}
// A task's current session reopens its task tile (deduped, same worktree guards); any other
// session gets a fresh ad-hoc tile resuming it in its recorded cwd.
private void ResumeSession(MissionControlSessionEntity session, bool isTaskCurrent)
{
if (session.TaskId is { } taskId && isTaskCurrent)
{
_ = OpenConPtySessionAsync(taskId);
return;
}
var paneId = Guid.NewGuid().ToString("n");
var sessionId = session.SessionId;
AddConPtyPane(new ConPtyPaneViewModel(null, session.Title,
() => DescribeAsync(() => _worker.GetResumeSessionLaunchSpecAsync(sessionId, paneId))) { IdentityKey = paneId });
}
private void ShowBrief(string briefPath)
{
var (ok, error) = ShellOpen.Path(briefPath);
if (!ok) ErrorReported?.Invoke(Loc.T("missionControl.briefOpenFailed", error ?? briefPath));
}
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
// manual/draft-child guards apply here too. The interactive-session check has to live here
@@ -674,6 +760,20 @@ public sealed class QueuedTaskViewModel
public IRelayCommand? OpenSessionCommand { get; init; }
}
/// <summary>One row in the "Recent sessions" flyout.</summary>
public sealed class RecentSessionViewModel
{
public required string Title { get; init; }
/// <summary>Start time plus cwd (origin) or the handoff label (handoff).</summary>
public required string Meta { get; init; }
/// <summary>Indents the row under its chain's origin session.</summary>
public bool IsHandoff { get; init; }
public required IRelayCommand ResumeCommand { get; init; }
/// <summary>Opens the session's brief.md; null for a session that did not start from one.</summary>
public IRelayCommand? ShowBriefCommand { get; init; }
public bool HasBrief => ShowBriefCommand is not null;
}
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
public sealed class SessionTargetViewModel
{