diff --git a/src/ClaudeDo.Ui/Services/ClipboardBitmap.cs b/src/ClaudeDo.Ui/Services/ClipboardBitmap.cs
new file mode 100644
index 00000000..c1be4a9d
--- /dev/null
+++ b/src/ClaudeDo.Ui/Services/ClipboardBitmap.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media.Imaging;
+
+namespace ClaudeDo.Ui.Services;
+
+/// Reads a bitmap off the clipboard; shared by the terminal view and the description editor.
+public static class ClipboardBitmap
+{
+ /// The clipboard bitmap (caller disposes), or null if there is none or the read failed.
+ public static async Task TryGetAsync(TopLevel? topLevel)
+ {
+ if (topLevel?.Clipboard is not { } clipboard) return null;
+ try
+ {
+ using var data = await clipboard.TryGetDataAsync();
+ return data is null ? null : await AsyncDataTransferExtensions.TryGetBitmapAsync(data);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs b/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs
index 20479501..33e6b01d 100644
--- a/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs
+++ b/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs
@@ -22,8 +22,11 @@ public static class ClipboardImageTempStore
/// Test seam for — takes the target dir explicitly
/// instead of the real app data root.
- internal static string NewPngPath(string dir, DateTime utcNow) =>
- Path.Combine(dir, $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png");
+ internal static string NewPngPath(string dir, DateTime utcNow) => Path.Combine(dir, NewPngFileName(utcNow));
+
+ /// A fresh, collision-free paste-….png file name (also used as an attachment name).
+ public static string NewPngFileName(DateTime utcNow) =>
+ $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png";
/// Test seam for — takes the target dir
/// explicitly instead of the real app data root.
diff --git a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs
index b0c2f2a7..2a358ab3 100644
--- a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs
@@ -67,7 +67,7 @@ public partial class InteractiveTerminalView : UserControl
Avalonia.Media.Imaging.Bitmap? bitmap = null;
try
{
- bitmap = await TryGetClipboardBitmapAsync(TopLevel.GetTopLevel(view));
+ bitmap = await ClipboardBitmap.TryGetAsync(TopLevel.GetTopLevel(view));
if (bitmap is null)
{
if (!await vm.PasteAsync()) vm.ReportSendFailed();
@@ -90,20 +90,6 @@ public partial class InteractiveTerminalView : UserControl
}
}
- private static async System.Threading.Tasks.Task TryGetClipboardBitmapAsync(TopLevel? topLevel)
- {
- if (topLevel?.Clipboard is not { } clipboard) return null;
- try
- {
- using var data = await clipboard.TryGetDataAsync();
- return data is null ? null : await Avalonia.Input.AsyncDataTransferExtensions.TryGetBitmapAsync(data);
- }
- catch (Exception)
- {
- return null;
- }
- }
-
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
private void OnDragOver(object? sender, DragEventArgs e)
diff --git a/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml b/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml
index 7af59769..ee089612 100644
--- a/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml
+++ b/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml
@@ -103,7 +103,8 @@
- Attaches a clipboard bitmap as a PNG file; anything else falls back to the normal text paste.
+ private async System.Threading.Tasks.Task PasteAsync()
+ {
+ try
+ {
+ using var bitmap = await ClipboardBitmap.TryGetAsync(TopLevel.GetTopLevel(this));
+ if (bitmap is null || DataContext is not DetailsIslandViewModel vm)
+ {
+ DescriptionBox.Paste();
+ return;
+ }
+
+ using var png = new System.IO.MemoryStream();
+ bitmap.Save(png);
+ png.Position = 0;
+ await vm.AddFilesAsync(new[] { (ClipboardImageTempStore.NewPngFileName(DateTime.UtcNow), (System.IO.Stream)png) });
+ }
+ catch (Exception)
+ {
+ // Paste is best-effort; a failed clipboard/encode must not surface as an unhandled UI exception.
+ }
}
private async void OnCopyClick(object? sender, RoutedEventArgs e)
diff --git a/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs b/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs
index f335ccdc..b1263e7f 100644
--- a/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs
@@ -27,6 +27,20 @@ public class ClipboardImageTempStoreTests : IDisposable
Assert.Equal(".png", Path.GetExtension(a));
}
+ [Fact]
+ public void NewPngFileName_IsUniquePasteNameWithoutDirectory()
+ {
+ var now = new DateTime(2026, 9, 16, 10, 30, 0, 123, DateTimeKind.Utc);
+
+ var a = ClipboardImageTempStore.NewPngFileName(now);
+ var b = ClipboardImageTempStore.NewPngFileName(now);
+
+ Assert.NotEqual(a, b);
+ Assert.StartsWith("paste-20260916-103000123-", a);
+ Assert.EndsWith(".png", a);
+ Assert.Equal(a, Path.GetFileName(a));
+ }
+
[Fact]
public void DeleteOlderThan_RemovesOnlyFilesPastTheMaxAge()
{