feat(mission-control): rename sessions inline in the Recent sessions flyout

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-10-01 17:35:13 +02:00
co-authored by Claude Sonnet 5.5
parent d9355383a4
commit 5bf08873bd
8 changed files with 198 additions and 11 deletions
@@ -19,6 +19,11 @@ public sealed class MissionControlSessionRepository
await _context.SaveChangesAsync(ct);
}
/// <summary>Sets the session's title; a no-op for an unknown id. <see cref="RecordAsync"/> never overwrites it.</summary>
public async Task RenameAsync(string sessionId, string title, CancellationToken ct = default) =>
await _context.MissionControlSessions.Where(s => s.SessionId == sessionId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Title, title), ct);
/// <summary>The recorded session, or null.</summary>
public async Task<MissionControlSessionEntity?> GetAsync(string sessionId, CancellationToken ct = default) =>
await _context.MissionControlSessions.AsNoTracking().FirstOrDefaultAsync(s => s.SessionId == sessionId, ct);
@@ -403,6 +403,8 @@
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
"resumeSession": "Fortsetzen",
"showBrief": "Brief",
"renameSession": "Doppelklick zum Umbenennen",
"renameFailed": "Sitzung konnte nicht umbenannt werden: {0}",
"handoffSession": "Übergabe",
"briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}",
"focusMode": "Fokus",
@@ -403,6 +403,8 @@
"recentSessionsEmpty": "No sessions in the last 14 days",
"resumeSession": "Resume",
"showBrief": "Brief",
"renameSession": "Double-click to rename",
"renameFailed": "Couldn't rename the session: {0}",
"handoffSession": "Handoff",
"briefOpenFailed": "Couldn't open the brief: {0}",
"focusMode": "Focus",
@@ -326,14 +326,18 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
{
var time = DateTime.SpecifyKind(s.StartedAt, DateTimeKind.Utc).ToLocalTime().ToString("g");
var briefPath = s.BriefPath;
RecentSessions.Add(new RecentSessionViewModel
RecentSessionViewModel row = null!;
row = new RecentSessionViewModel
{
SessionId = s.SessionId,
Title = s.Title,
Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}",
IsHandoff = isHandoff,
ResumeCommand = new RelayCommand(() => ResumeSession(s, taskCurrent.Contains(s.SessionId))),
ResumeCommand = new RelayCommand(() => ResumeSession(s, row.Title, taskCurrent.Contains(s.SessionId))),
ShowBriefCommand = briefPath is null ? null : new RelayCommand(() => ShowBrief(briefPath)),
});
CommitRenameCommand = new RelayCommand(() => _ = RenameSessionAsync(row)),
};
RecentSessions.Add(row);
}
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
@@ -365,7 +369,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// 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)
private void ResumeSession(MissionControlSessionEntity session, string title, bool isTaskCurrent)
{
if (session.TaskId is { } taskId && isTaskCurrent)
{
@@ -374,10 +378,27 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
var paneId = Guid.NewGuid().ToString("n");
var sessionId = session.SessionId;
AddConPtyPane(new ConPtyPaneViewModel(null, session.Title,
AddConPtyPane(new ConPtyPaneViewModel(null, title,
() => DescribeAsync(() => _worker.GetResumeSessionLaunchSpecAsync(sessionId, paneId))) { IdentityKey = paneId });
}
/// <summary>Commits an inline rename: trims, ignores empty/unchanged input, persists the title.</summary>
internal async System.Threading.Tasks.Task RenameSessionAsync(RecentSessionViewModel row)
{
if (!row.IsEditing) return;
row.IsEditing = false;
var title = row.EditText.Trim();
if (title.Length == 0 || title == row.Title) return;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
await new MissionControlSessionRepository(ctx).RenameAsync(row.SessionId, title);
row.Title = title;
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.renameFailed", ex.Message)); }
}
private void ShowBrief(string briefPath)
{
var (ok, error) = ShellOpen.Path(briefPath);
@@ -941,9 +962,28 @@ public sealed partial class RunningOperationViewModel : ObservableObject
}
/// <summary>One row in the "Recent sessions" flyout.</summary>
public sealed class RecentSessionViewModel
public sealed partial class RecentSessionViewModel : ObservableObject
{
public required string Title { get; init; }
public required string SessionId { get; init; }
/// <summary>Display title; updated in place once a rename is persisted.</summary>
[ObservableProperty] private string _title = "";
/// <summary>True while the inline title TextBox is shown.</summary>
[ObservableProperty] private bool _isEditing;
/// <summary>The TextBox's working value, kept apart from <see cref="Title"/> so Escape needs no restore.</summary>
[ObservableProperty] private string _editText = "";
/// <summary>Commits <see cref="EditText"/> as the new title.</summary>
public required IRelayCommand CommitRenameCommand { get; init; }
/// <summary>Switches the row into inline edit mode, seeded with the current title.</summary>
public void BeginEdit()
{
EditText = Title;
IsEditing = true;
}
/// <summary>Leaves edit mode without saving.</summary>
public void CancelEdit() => IsEditing = false;
/// <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>
@@ -83,10 +83,21 @@
<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}" />
<Panel>
<TextBlock Text="{Binding Title}"
IsVisible="{Binding !IsEditing}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{loc:Tr missionControl.renameSession}"
Foreground="{DynamicResource TextDimBrush}"
Cursor="Ibeam"
DoubleTapped="OnSessionTitleDoubleTapped" />
<TextBox Text="{Binding EditText, Mode=TwoWay}"
IsVisible="{Binding IsEditing}"
AcceptsReturn="False"
MinHeight="0" Padding="4,2"
KeyDown="OnSessionTitleEditKeyDown"
LostFocus="OnSessionTitleEditLostFocus" />
</Panel>
<TextBlock Classes="meta"
Text="{Binding Meta}"
TextTrimming="CharacterEllipsis"
@@ -5,6 +5,7 @@ using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
@@ -40,6 +41,31 @@ public partial class MissionControlView : UserControl
OverviewGrid.AddHandler(PointerReleasedEvent, OnTilePointerReleased);
}
// Inline rename in the Recent sessions flyout. Deliberately not Buttons: the Click handler above would hide the flyout.
private void OnSessionTitleDoubleTapped(object? sender, TappedEventArgs e)
{
if (sender is not TextBlock { DataContext: RecentSessionViewModel row, Parent: Panel panel }) return;
row.BeginEdit();
var box = panel.Children.OfType<TextBox>().FirstOrDefault();
// Deferred: the TextBox only becomes visible (focusable) after the binding update.
Avalonia.Threading.Dispatcher.UIThread.Post(() => { box?.Focus(); box?.SelectAll(); });
}
private void OnSessionTitleEditKeyDown(object? sender, KeyEventArgs e)
{
if (sender is not TextBox { DataContext: RecentSessionViewModel row }) return;
if (e.Key == Key.Enter) row.CommitRenameCommand.Execute(null);
else if (e.Key == Key.Escape) row.CancelEdit();
else return;
e.Handled = true; // Escape would otherwise close the flyout too
}
private void OnSessionTitleEditLostFocus(object? sender, RoutedEventArgs e)
{
if (sender is TextBox { DataContext: RecentSessionViewModel row })
row.CommitRenameCommand.Execute(null);
}
// Ticks the "Running now" elapsed times while the view is on screen.
private Avalonia.Threading.DispatcherTimer? _elapsedTimer;