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); 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> /// <summary>The recorded session, or null.</summary>
public async Task<MissionControlSessionEntity?> GetAsync(string sessionId, CancellationToken ct = default) => public async Task<MissionControlSessionEntity?> GetAsync(string sessionId, CancellationToken ct = default) =>
await _context.MissionControlSessions.AsNoTracking().FirstOrDefaultAsync(s => s.SessionId == sessionId, ct); await _context.MissionControlSessions.AsNoTracking().FirstOrDefaultAsync(s => s.SessionId == sessionId, ct);
@@ -403,6 +403,8 @@
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen", "recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
"resumeSession": "Fortsetzen", "resumeSession": "Fortsetzen",
"showBrief": "Brief", "showBrief": "Brief",
"renameSession": "Doppelklick zum Umbenennen",
"renameFailed": "Sitzung konnte nicht umbenannt werden: {0}",
"handoffSession": "Übergabe", "handoffSession": "Übergabe",
"briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}", "briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}",
"focusMode": "Fokus", "focusMode": "Fokus",
@@ -403,6 +403,8 @@
"recentSessionsEmpty": "No sessions in the last 14 days", "recentSessionsEmpty": "No sessions in the last 14 days",
"resumeSession": "Resume", "resumeSession": "Resume",
"showBrief": "Brief", "showBrief": "Brief",
"renameSession": "Double-click to rename",
"renameFailed": "Couldn't rename the session: {0}",
"handoffSession": "Handoff", "handoffSession": "Handoff",
"briefOpenFailed": "Couldn't open the brief: {0}", "briefOpenFailed": "Couldn't open the brief: {0}",
"focusMode": "Focus", "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 time = DateTime.SpecifyKind(s.StartedAt, DateTimeKind.Utc).ToLocalTime().ToString("g");
var briefPath = s.BriefPath; var briefPath = s.BriefPath;
RecentSessions.Add(new RecentSessionViewModel RecentSessionViewModel row = null!;
row = new RecentSessionViewModel
{ {
SessionId = s.SessionId,
Title = s.Title, Title = s.Title,
Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}", Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}",
IsHandoff = isHandoff, 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)), 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)); } 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 // 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. // 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) if (session.TaskId is { } taskId && isTaskCurrent)
{ {
@@ -374,10 +378,27 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
var paneId = Guid.NewGuid().ToString("n"); var paneId = Guid.NewGuid().ToString("n");
var sessionId = session.SessionId; var sessionId = session.SessionId;
AddConPtyPane(new ConPtyPaneViewModel(null, session.Title, AddConPtyPane(new ConPtyPaneViewModel(null, title,
() => DescribeAsync(() => _worker.GetResumeSessionLaunchSpecAsync(sessionId, paneId))) { IdentityKey = paneId }); () => 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) private void ShowBrief(string briefPath)
{ {
var (ok, error) = ShellOpen.Path(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> /// <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> /// <summary>Start time plus cwd (origin) or the handoff label (handoff).</summary>
public required string Meta { get; init; } public required string Meta { get; init; }
/// <summary>Indents the row under its chain's origin session.</summary> /// <summary>Indents the row under its chain's origin session.</summary>
@@ -83,10 +83,21 @@
<DataTemplate x:DataType="vm:RecentSessionViewModel"> <DataTemplate x:DataType="vm:RecentSessionViewModel">
<Grid ColumnDefinitions="*,Auto" Classes.handoff="{Binding IsHandoff}"> <Grid ColumnDefinitions="*,Auto" Classes.handoff="{Binding IsHandoff}">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,4" VerticalAlignment="Center"> <StackPanel Grid.Column="0" Spacing="2" Margin="0,4" VerticalAlignment="Center">
<Panel>
<TextBlock Text="{Binding Title}" <TextBlock Text="{Binding Title}"
IsVisible="{Binding !IsEditing}"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}" ToolTip.Tip="{loc:Tr missionControl.renameSession}"
Foreground="{DynamicResource TextDimBrush}" /> 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" <TextBlock Classes="meta"
Text="{Binding Meta}" Text="{Binding Meta}"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
@@ -5,6 +5,7 @@ using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
@@ -40,6 +41,31 @@ public partial class MissionControlView : UserControl
OverviewGrid.AddHandler(PointerReleasedEvent, OnTilePointerReleased); 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. // Ticks the "Running now" elapsed times while the view is on screen.
private Avalonia.Threading.DispatcherTimer? _elapsedTimer; private Avalonia.Threading.DispatcherTimer? _elapsedTimer;
@@ -0,0 +1,52 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Tests;
public sealed class MissionControlSessionRepositoryTests : IDisposable
{
private readonly string _dbPath;
private readonly ClaudeDoDbContext _ctx;
private readonly MissionControlSessionRepository _repo;
public MissionControlSessionRepositoryTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_mcs_{Guid.NewGuid():N}.db");
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
_ctx = new ClaudeDoDbContext(options);
_ctx.Database.EnsureCreated();
_repo = new MissionControlSessionRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
foreach (var suffix in new[] { "", "-wal", "-shm" })
try { File.Delete(_dbPath + suffix); } catch { }
}
private static MissionControlSessionEntity Session(string title) =>
new() { SessionId = "s1", Cwd = "c", Title = title, StartedAt = DateTime.UtcNow };
[Fact]
public async Task RenameAsync_updates_the_title_and_a_later_RecordAsync_does_not_revert_it()
{
await _repo.RecordAsync(Session("mika.kuns"));
await _repo.RenameAsync("s1", "Fix login");
await _repo.RecordAsync(Session("mika.kuns"));
Assert.Equal("Fix login", (await _repo.GetAsync("s1"))!.Title);
}
[Fact]
public async Task RenameAsync_is_a_noop_for_an_unknown_session()
{
await _repo.RenameAsync("missing", "x");
Assert.Null(await _repo.GetAsync("missing"));
}
}
@@ -59,6 +59,55 @@ public class MissionControlViewModelTests : IDisposable
rows); rows);
} }
private async Task<MissionControlViewModel> BuildVmWithSessionAsync(string title)
{
await using (var ctx = NewContext())
{
ctx.MissionControlSessions.Add(new MissionControlSessionEntity
{
SessionId = "s1", Cwd = "c", Title = title, StartedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var vm = BuildVm(new FakeWorker());
await vm.RefreshRecentSessionsAsync();
return vm;
}
[Fact]
public async Task RenameSession_PersistsAndSurvivesRefresh()
{
using var vm = await BuildVmWithSessionAsync("mika.kuns");
var row = vm.RecentSessions.Single();
row.BeginEdit();
row.EditText = " Fix login ";
await vm.RenameSessionAsync(row);
Assert.Equal("Fix login", row.Title);
Assert.False(row.IsEditing);
await vm.RefreshRecentSessionsAsync();
Assert.Equal("Fix login", vm.RecentSessions.Single().Title);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("mika.kuns")]
public async Task RenameSession_IgnoresEmptyOrUnchangedInput(string input)
{
using var vm = await BuildVmWithSessionAsync("mika.kuns");
var row = vm.RecentSessions.Single();
row.BeginEdit();
row.EditText = input;
await vm.RenameSessionAsync(row);
Assert.Equal("mika.kuns", row.Title);
await vm.RefreshRecentSessionsAsync();
Assert.Equal("mika.kuns", vm.RecentSessions.Single().Title);
}
// Gates the title/list-name DB lookup behind a manually-released TaskCompletionSource so two // Gates the title/list-name DB lookup behind a manually-released TaskCompletionSource so two
// overlapping Open*ConPtySessionAsync calls both run their synchronous dedupe-check prefix // overlapping Open*ConPtySessionAsync calls both run their synchronous dedupe-check prefix
// to completion before either resumes past the DB await. This reproduces the open-path races // to completion before either resumes past the DB await. This reproduces the open-path races