diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 5aa5e16..5fc5e78 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -26,9 +26,33 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase // Editable fields [ObservableProperty] private string _editableTitle = ""; + [ObservableProperty] private string _editableDescription = ""; + [ObservableProperty] private bool _isEditingDescription; + [ObservableProperty] private bool _isDescriptionExpanded = true; [ObservableProperty] private string _notes = ""; [ObservableProperty] private string _promptInput = ""; + public bool IsDescriptionEditorVisible => IsDescriptionExpanded && IsEditingDescription; + public bool IsDescriptionPreviewVisible => IsDescriptionExpanded && !IsEditingDescription; + + partial void OnIsDescriptionExpandedChanged(bool value) + { + OnPropertyChanged(nameof(IsDescriptionEditorVisible)); + OnPropertyChanged(nameof(IsDescriptionPreviewVisible)); + } + + partial void OnIsEditingDescriptionChanged(bool value) + { + OnPropertyChanged(nameof(IsDescriptionEditorVisible)); + OnPropertyChanged(nameof(IsDescriptionPreviewVisible)); + } + + [RelayCommand] + private void ToggleEditDescription() => IsEditingDescription = !IsEditingDescription; + + [RelayCommand] + private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded; + // Short task-id badge, e.g. "#T1A" public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : ""; @@ -78,6 +102,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase private bool _suppressAgentSave; private CancellationTokenSource? _agentSaveCts; + private bool _suppressDescSave; + private CancellationTokenSource? _descSaveCts; + public bool IsAgentSectionEnabled => !IsRunning; [ObservableProperty] private string? _worktreePath; @@ -269,6 +296,31 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave(); partial void OnTaskSelectedAgentChanged(AgentInfo? value) => QueueAgentSave(); + partial void OnEditableDescriptionChanged(string value) + { + if (_suppressDescSave || Task is null) return; + _descSaveCts?.Cancel(); + _descSaveCts = new CancellationTokenSource(); + _ = SaveDescriptionAsync(_descSaveCts.Token); + } + + private async System.Threading.Tasks.Task SaveDescriptionAsync(CancellationToken ct) + { + try + { + await System.Threading.Tasks.Task.Delay(400, ct); + if (Task is null) return; + await using var ctx = _dbFactory.CreateDbContext(); + var repo = new TaskRepository(ctx); + var entity = await repo.GetByIdAsync(Task.Id); + if (entity is null) return; + entity.Description = string.IsNullOrWhiteSpace(EditableDescription) ? null : EditableDescription; + await repo.UpdateAsync(entity); + } + catch (OperationCanceledException) { } + catch { } + } + private void QueueAgentSave() { if (_suppressAgentSave || Task is null) return; @@ -348,6 +400,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase { _subscribedTaskId = null; EditableTitle = ""; + EditableDescription = ""; Notes = ""; Model = null; WorktreePath = null; @@ -392,6 +445,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase if (entity == null) return; EditableTitle = entity.Title; + _suppressDescSave = true; + try { EditableDescription = entity.Description ?? ""; } + finally { _suppressDescSave = false; } Notes = entity.Notes ?? ""; Model = entity.Model; WorktreePath = entity.Worktree?.Path; diff --git a/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml b/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml new file mode 100644 index 0000000..ae7d4ef --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml @@ -0,0 +1,5 @@ + + + diff --git a/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml.cs new file mode 100644 index 0000000..08a218d --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml.cs @@ -0,0 +1,231 @@ +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Layout; +using Avalonia.Markup.Xaml; +using Avalonia.Media; + +namespace ClaudeDo.Ui.Views.Controls; + +public partial class MarkdownView : UserControl +{ + public static readonly StyledProperty MarkdownProperty = + AvaloniaProperty.Register(nameof(Markdown)); + + public string? Markdown + { + get => GetValue(MarkdownProperty); + set => SetValue(MarkdownProperty, value); + } + + private StackPanel? _host; + + public MarkdownView() + { + InitializeComponent(); + _host = this.FindControl("Host"); + } + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == MarkdownProperty) + Render(change.GetNewValue()); + } + + private static readonly Regex NumberedItem = new(@"^\s*(\d+)\.\s+(.*)$", RegexOptions.Compiled); + + private void Render(string? md) + { + _host ??= this.FindControl("Host"); + if (_host is null) return; + _host.Children.Clear(); + + if (string.IsNullOrWhiteSpace(md)) + return; + + var lines = md.Replace("\r\n", "\n").Split('\n'); + int i = 0; + while (i < lines.Length) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) { i++; continue; } + + if (line.StartsWith("### ")) + { + _host.Children.Add(Heading(line[4..], 13)); + i++; + continue; + } + if (line.StartsWith("## ")) + { + _host.Children.Add(Heading(line[3..], 15)); + i++; + continue; + } + if (line.StartsWith("# ")) + { + _host.Children.Add(Heading(line[2..], 17)); + i++; + continue; + } + + if (line.StartsWith("- ") || line.StartsWith("* ")) + { + while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* "))) + { + _host.Children.Add(Bullet(lines[i][2..])); + i++; + } + continue; + } + + var num = NumberedItem.Match(line); + if (num.Success) + { + while (i < lines.Length && NumberedItem.IsMatch(lines[i])) + { + var m = NumberedItem.Match(lines[i]); + _host.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value)); + i++; + } + continue; + } + + var paragraph = new System.Text.StringBuilder(); + while (i < lines.Length + && !string.IsNullOrWhiteSpace(lines[i]) + && !IsBlockStart(lines[i])) + { + if (paragraph.Length > 0) paragraph.Append(' '); + paragraph.Append(lines[i].Trim()); + i++; + } + _host.Children.Add(Paragraph(paragraph.ToString())); + } + } + + private static bool IsBlockStart(string line) => + line.StartsWith("# ") || line.StartsWith("## ") || line.StartsWith("### ") + || line.StartsWith("- ") || line.StartsWith("* ") + || NumberedItem.IsMatch(line); + + private static Control Heading(string text, double size) + { + var tb = new SelectableTextBlock + { + FontSize = size, + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 4, 0, 2), + }; + AppendInlines(tb.Inlines!, text); + return tb; + } + + private static Control Paragraph(string text) + { + var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; + AppendInlines(tb.Inlines!, text); + return tb; + } + + private static Control Bullet(string text) + { + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("14,*") }; + var dot = new TextBlock + { + Text = "•", + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(0, 0, 4, 0), + }; + Grid.SetColumn(dot, 0); + grid.Children.Add(dot); + + var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; + AppendInlines(tb.Inlines!, text); + Grid.SetColumn(tb, 1); + grid.Children.Add(tb); + return grid; + } + + private static Control Numbered(string num, string text) + { + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("20,*") }; + var lbl = new TextBlock + { + Text = $"{num}.", + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(0, 0, 4, 0), + }; + Grid.SetColumn(lbl, 0); + grid.Children.Add(lbl); + + var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; + AppendInlines(tb.Inlines!, text); + Grid.SetColumn(tb, 1); + grid.Children.Add(tb); + return grid; + } + + private static void AppendInlines(InlineCollection inlines, string text) + { + int i = 0; + var plain = new System.Text.StringBuilder(); + + void FlushPlain() + { + if (plain.Length == 0) return; + inlines.Add(new Run(plain.ToString())); + plain.Clear(); + } + + while (i < text.Length) + { + if (i + 1 < text.Length && text[i] == '*' && text[i + 1] == '*') + { + int close = text.IndexOf("**", i + 2, System.StringComparison.Ordinal); + if (close > i + 2) + { + FlushPlain(); + inlines.Add(new Run(text[(i + 2)..close]) { FontWeight = FontWeight.Bold }); + i = close + 2; + continue; + } + } + if (text[i] == '*') + { + int close = text.IndexOf('*', i + 1); + if (close > i + 1) + { + FlushPlain(); + inlines.Add(new Run(text[(i + 1)..close]) { FontStyle = FontStyle.Italic }); + i = close + 1; + continue; + } + } + if (text[i] == '`') + { + int close = text.IndexOf('`', i + 1); + if (close > i + 1) + { + FlushPlain(); + inlines.Add(new Run(text[(i + 1)..close]) + { + FontFamily = new FontFamily("Consolas,Menlo,monospace"), + Background = new SolidColorBrush(Color.FromArgb(60, 127, 127, 127)), + }); + i = close + 1; + continue; + } + } + + plain.Append(text[i]); + i++; + } + FlushPlain(); + } +} diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml index 608d81d..08dc8f6 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" xmlns:islands="using:ClaudeDo.Ui.Views.Islands" + xmlns:ctl="using:ClaudeDo.Ui.Views.Controls" x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView" x:DataType="vm:DetailsIslandViewModel"> @@ -214,6 +215,77 @@ + + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs index 9b2816f..923d4cc 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs @@ -1,5 +1,6 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; @@ -147,4 +148,12 @@ public partial class DetailsIslandView : UserControl if (DataContext is DetailsIslandViewModel vm) vm.SaveNotesCommand.Execute(null); } + + private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e) + { + if (DataContext is not DetailsIslandViewModel vm) return; + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard is null) return; + await clipboard.SetTextAsync(vm.EditableDescription ?? string.Empty); + } }