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:
@@ -158,6 +158,7 @@
|
||||
<Thickness x:Key="IslandHeaderPadding">18,16,18,12</Thickness>
|
||||
<Thickness x:Key="IslandBodyPadding">14</Thickness>
|
||||
<Thickness x:Key="WindowBodyPadding">14</Thickness>
|
||||
<Thickness x:Key="NestedRowIndent">18,0,0,0</Thickness> <!-- SpaceXl: a child row under its parent -->
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CORNERS & BORDERS -->
|
||||
|
||||
@@ -141,6 +141,8 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default);
|
||||
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
||||
Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default);
|
||||
/// <summary>Launch spec resuming a recorded Mission Control session in its cwd, as tile paneId.</summary>
|
||||
Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default);
|
||||
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.
|
||||
/// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session
|
||||
|
||||
@@ -632,6 +632,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetResumeSessionLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetResumeSessionLaunchSpec", sessionId, paneId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -48,6 +48,60 @@
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<Button x:Name="RecentSessionsButton" Classes="icon-btn"
|
||||
ToolTip.Tip="{loc:Tr missionControl.recentSessions}">
|
||||
<PathIcon Data="{StaticResource Icon.Clock}" Width="15" Height="15"/>
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard" Opening="OnRecentSessionsFlyoutOpening">
|
||||
<StackPanel Width="380" Spacing="8" Margin="4">
|
||||
<TextBlock Classes="eyebrow"
|
||||
Text="{loc:Tr missionControl.recentSessions}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
LetterSpacing="1.4" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.recentSessionsEmpty}"
|
||||
IsVisible="{Binding !HasRecentSessions}"
|
||||
Foreground="{DynamicResource TextMuteBrush}" />
|
||||
<ScrollViewer MaxHeight="420">
|
||||
<ItemsControl x:Name="RecentSessionsList" ItemsSource="{Binding RecentSessions}">
|
||||
<ItemsControl.Styles>
|
||||
<Style Selector="Grid.handoff">
|
||||
<Setter Property="Margin" Value="{StaticResource NestedRowIndent}" />
|
||||
</Style>
|
||||
</ItemsControl.Styles>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RecentSessionViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto" Classes.handoff="{Binding IsHandoff}">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,4" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Title}"
|
||||
Foreground="{DynamicResource TextDimBrush}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{Binding Meta}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Meta}"
|
||||
Foreground="{DynamicResource TextMuteBrush}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
|
||||
VerticalAlignment="Center">
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr missionControl.showBrief}"
|
||||
Command="{Binding ShowBriefCommand}"
|
||||
IsVisible="{Binding HasBrief}" />
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr missionControl.resumeSession}"
|
||||
Command="{Binding ResumeCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<Button Classes="icon-btn"
|
||||
Command="{Binding ToggleLayoutCommand}"
|
||||
ToolTip.Tip="{Binding LayoutToggleLabel}">
|
||||
|
||||
@@ -29,10 +29,14 @@ public partial class MissionControlView : UserControl
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
|
||||
// Resume/Brief buttons don't close a plain Flyout on their own.
|
||||
RecentSessionsList.AddHandler(Button.ClickEvent, (_, _) => RecentSessionsButton.Flyout?.Hide());
|
||||
}
|
||||
|
||||
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
|
||||
|
||||
private void OnRecentSessionsFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshRecentSessionsAsync();
|
||||
|
||||
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
|
||||
private async void BrowseForSessionAsync()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user