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:
co-authored by
Claude Sonnet 5.5
parent
d9355383a4
commit
5bf08873bd
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user