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