From 5bf08873bd2a00e1c2f99c92253174207246c6f2 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 17:35:13 +0200 Subject: [PATCH] feat(mission-control): rename sessions inline in the Recent sessions flyout Co-Authored-By: Claude Sonnet 5.5 --- .../MissionControlSessionRepository.cs | 5 ++ src/ClaudeDo.Localization/locales/de.json | 2 + src/ClaudeDo.Localization/locales/en.json | 2 + .../ViewModels/MissionControlViewModel.cs | 54 ++++++++++++++++--- .../MissionControl/MissionControlView.axaml | 19 +++++-- .../MissionControlView.axaml.cs | 26 +++++++++ .../MissionControlSessionRepositoryTests.cs | 52 ++++++++++++++++++ .../MissionControlViewModelTests.cs | 49 +++++++++++++++++ 8 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 tests/ClaudeDo.Data.Tests/MissionControlSessionRepositoryTests.cs diff --git a/src/ClaudeDo.Data/Repositories/MissionControlSessionRepository.cs b/src/ClaudeDo.Data/Repositories/MissionControlSessionRepository.cs index 572694e1..596b23df 100644 --- a/src/ClaudeDo.Data/Repositories/MissionControlSessionRepository.cs +++ b/src/ClaudeDo.Data/Repositories/MissionControlSessionRepository.cs @@ -19,6 +19,11 @@ public sealed class MissionControlSessionRepository await _context.SaveChangesAsync(ct); } + /// Sets the session's title; a no-op for an unknown id. never overwrites it. + 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); + /// The recorded session, or null. public async Task GetAsync(string sessionId, CancellationToken ct = default) => await _context.MissionControlSessions.AsNoTracking().FirstOrDefaultAsync(s => s.SessionId == sessionId, ct); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 073bb51d..7f20badb 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index d5314640..989331d7 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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", diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index a690b500..8d093871 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -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 }); } + /// Commits an inline rename: trims, ignores empty/unchanged input, persists the title. + 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 } /// One row in the "Recent sessions" flyout. -public sealed class RecentSessionViewModel +public sealed partial class RecentSessionViewModel : ObservableObject { - public required string Title { get; init; } + public required string SessionId { get; init; } + /// Display title; updated in place once a rename is persisted. + [ObservableProperty] private string _title = ""; + /// True while the inline title TextBox is shown. + [ObservableProperty] private bool _isEditing; + /// The TextBox's working value, kept apart from so Escape needs no restore. + [ObservableProperty] private string _editText = ""; + /// Commits as the new title. + public required IRelayCommand CommitRenameCommand { get; init; } + + /// Switches the row into inline edit mode, seeded with the current title. + public void BeginEdit() + { + EditText = Title; + IsEditing = true; + } + + /// Leaves edit mode without saving. + public void CancelEdit() => IsEditing = false; + /// Start time plus cwd (origin) or the handoff label (handoff). public required string Meta { get; init; } /// Indents the row under its chain's origin session. diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml index e799e7cb..f47160cf 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml @@ -83,10 +83,21 @@ - + + + + ().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; diff --git a/tests/ClaudeDo.Data.Tests/MissionControlSessionRepositoryTests.cs b/tests/ClaudeDo.Data.Tests/MissionControlSessionRepositoryTests.cs new file mode 100644 index 00000000..89631d31 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/MissionControlSessionRepositoryTests.cs @@ -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() + .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")); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index 0a493f31..16860acb 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -59,6 +59,55 @@ public class MissionControlViewModelTests : IDisposable rows); } + private async Task 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 // 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