Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions Components/ChatMessageList.razor
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,27 @@

internal static string FormatUserMessage(string content)
{
var escaped = System.Net.WebUtility.HtmlEncode(content);
return escaped.Replace("\n", "<br/>");
if (string.IsNullOrEmpty(content)) return "";
var lines = content.Split('\n');
var sb = new System.Text.StringBuilder();
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith('/') && ImageExtensions.Any(ext =>
trimmed.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) &&
System.IO.File.Exists(trimmed))
{
var dataUri = FileToDataUri(trimmed);
if (dataUri != null)
{
sb.Append($"<div class=\"user-image-attachment\"><img src=\"{dataUri}\" alt=\"{System.Net.WebUtility.HtmlEncode(System.IO.Path.GetFileName(trimmed))}\" /><span class=\"user-image-name\">{System.Net.WebUtility.HtmlEncode(System.IO.Path.GetFileName(trimmed))}</span></div>");
continue;
}
}
if (sb.Length > 0) sb.Append("<br/>");
sb.Append(System.Net.WebUtility.HtmlEncode(line));
}
return sb.ToString();
}

internal static int LineCount(string? text)
Expand Down
31 changes: 31 additions & 0 deletions Components/ChatMessageList.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -296,3 +296,34 @@

@keyframes spin { to { transform: rotate(360deg); } }
::deep .spin { animation: spin 1s linear infinite; }

/* === User image attachments inline === */
::deep .user-image-attachment {
display: inline-block;
margin: 0.3rem 0.2rem 0.3rem 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(0,0,0,0.2);
max-width: 300px;
vertical-align: top;
}

::deep .user-image-attachment img {
max-width: 300px;
max-height: 200px;
object-fit: contain;
display: block;
border-radius: 8px 8px 0 0;
}

::deep .user-image-name {
display: block;
font-size: 0.65rem;
color: rgba(255,255,255,0.4);
padding: 2px 6px 4px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
127 changes: 124 additions & 3 deletions Components/Pages/Home.razor
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
</div>
}

<div class="input-area">
<div class="input-area" @ref="inputAreaRef">
@if (!string.IsNullOrEmpty(currentIntent))
{
<div class="intent-pill"><svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg> @currentIntent</div>
Expand All @@ -92,7 +92,26 @@
}
</div>
}
@if (pendingImages.Count > 0)
{
<div class="attachment-strip">
@for (var i = 0; i < pendingImages.Count; i++)
{
var idx = i;
var img = pendingImages[i];
<div class="attachment-thumb">
<img src="@img.DataUri" alt="@img.FileName" />
<button class="attachment-remove" @onclick="() => RemoveAttachment(idx)" title="Remove">×</button>
<span class="attachment-name">@TruncateFileName(img.FileName, 15)</span>
</div>
}
</div>
}
<div class="input-row">
<button class="attach-btn" @onclick="OpenFilePicker" title="Attach image (or drag & drop)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.49"/></svg>
</button>
<input type="file" @ref="fileInputRef" accept="image/*" multiple style="display:none" @onchange="HandleFileSelected" />
<textarea @ref="textareaRef" @bind="userInput" @bind:event="oninput"
@onkeydown="HandleKeyDown"
placeholder="@GetInputPlaceholder()"
Expand All @@ -104,7 +123,7 @@
</button>
}
<button @onclick="SendMessage"
disabled="@(string.IsNullOrWhiteSpace(userInput))"
disabled="@(string.IsNullOrWhiteSpace(userInput) && pendingImages.Count == 0)"
title="@(activeSession.IsProcessing ? "Queue message" : "Send message")">
@if (activeSession.IsProcessing)
{
Expand Down Expand Up @@ -178,11 +197,20 @@
private bool showDebugPanel = false;
private ElementReference messagesContainer;
private ElementReference textareaRef;
private ElementReference inputAreaRef;
private ElementReference fileInputRef;
private DotNetObjectReference<Home>? _dotNetRef;
private bool _needsRedirect;
private string? _redirectTo;
private bool _needsScroll = true;
private int visibleMessageCount = 50;
private List<PendingImage> pendingImages = new();

private static readonly string ImageTempDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AutoPilot", "image-attachments");

private record PendingImage(string FilePath, string FileName, string DataUri);

private List<ChatMessage> VisibleHistory
{
Expand Down Expand Up @@ -248,6 +276,7 @@
_dotNetRef = DotNetObjectReference.Create(this);
}
try { await JS.InvokeVoidAsync("setupTabNavigation", _dotNetRef); } catch { }
try { await JS.InvokeVoidAsync("setupImageDropZone", inputAreaRef, _dotNetRef); } catch { }
}

private string currentToolName = "";
Expand Down Expand Up @@ -482,10 +511,21 @@

private async Task SendMessage()
{
if (string.IsNullOrWhiteSpace(userInput) || activeSession == null)
if ((string.IsNullOrWhiteSpace(userInput) && pendingImages.Count == 0) || activeSession == null)
return;

var prompt = userInput.Trim();

// Prepend image file paths
if (pendingImages.Count > 0)
{
var imagePaths = string.Join("\n", pendingImages.Select(img => img.FilePath));
prompt = string.IsNullOrEmpty(prompt)
? $"I've attached {pendingImages.Count} image(s) for you to look at:\n{imagePaths}"
: $"{imagePaths}\n\n{prompt}";
pendingImages.Clear();
}

if (isPlanMode)
prompt = $"[[PLAN]] {prompt}";
userInput = "";
Expand Down Expand Up @@ -530,6 +570,87 @@
if (activeSession != null) CopilotService.ClearQueue(activeSession.Name);
}

// Image attachment methods
[JSInvokable]
public void OnImageDropped(string base64Data, string fileName, string extension)
{
try
{
Directory.CreateDirectory(ImageTempDir);
var safeName = $"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid().ToString("N")[..8]}.{extension}";
var filePath = Path.Combine(ImageTempDir, safeName);
var bytes = Convert.FromBase64String(base64Data);
File.WriteAllBytes(filePath, bytes);

var mime = extension.ToLowerInvariant() switch
{
"png" => "image/png",
"jpg" or "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
_ => "image/png"
};
var dataUri = $"data:{mime};base64,{base64Data}";
pendingImages.Add(new PendingImage(filePath, fileName, dataUri));
InvokeAsync(StateHasChanged);
}
catch (Exception ex)
{
lastError = $"Failed to save image: {ex.Message}";
InvokeAsync(StateHasChanged);
}
}

private void RemoveAttachment(int index)
{
if (index >= 0 && index < pendingImages.Count)
{
try { File.Delete(pendingImages[index].FilePath); } catch { }
pendingImages.RemoveAt(index);
}
}

private async Task OpenFilePicker()
{
try { await JS.InvokeVoidAsync("triggerImageFilePicker", fileInputRef); } catch { }
}

private async Task HandleFileSelected(ChangeEventArgs e)
{
// For Blazor file input, we need to read via JS since ChangeEventArgs doesn't have file data
// Instead, use InputFile approach — read the selected files via JS
try
{
var fileData = await JS.InvokeAsync<string[]>("readSelectedFiles", fileInputRef);
if (fileData != null)
{
for (var i = 0; i < fileData.Length; i += 3)
{
if (i + 2 < fileData.Length)
{
OnImageDropped(fileData[i], fileData[i + 1], fileData[i + 2]);
}
}
}
}
catch
{
// Fallback: file picker worked but we couldn't read — that's OK
}
}

private static string TruncateFileName(string name, int maxLen)
{
if (name.Length <= maxLen) return name;
var ext = Path.GetExtension(name);
var stem = Path.GetFileNameWithoutExtension(name);
var maxStem = maxLen - ext.Length - 1;
if (maxStem < 3) return name[..maxLen];
return stem[..maxStem] + "…" + ext;
}

private async Task CopySessionId(string sessionId)
{
await JS.InvokeVoidAsync("eval", $"navigator.clipboard.writeText('{sessionId}')");
Expand Down
88 changes: 88 additions & 0 deletions Components/Pages/Home.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,94 @@
}
.input-area .input-row .stop-btn:hover { background: #dc2626; }

/* === Drag & drop / image attachments === */
.input-area.drag-over {
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.5);
box-shadow: inset 0 0 0 2px rgba(59, 130, 246, 0.3);
}

.attach-btn {
padding: 0.45rem 0.5rem;
border: none;
border-radius: 8px;
background: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.6);
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.attach-btn:hover {
background: rgba(59, 130, 246, 0.3);
color: white;
}

.attachment-strip {
display: flex;
gap: 0.5rem;
padding: 0.25rem 0;
overflow-x: auto;
scrollbar-width: thin;
}

.attachment-thumb {
position: relative;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.05);
width: 80px;
display: flex;
flex-direction: column;
align-items: center;
}

.attachment-thumb img {
width: 80px;
height: 60px;
object-fit: cover;
display: block;
}

.attachment-thumb .attachment-name {
font-size: 0.65rem;
color: rgba(255,255,255,0.5);
padding: 2px 4px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}

.attachment-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0,0,0,0.7);
border: none;
color: white;
font-size: 12px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s;
}

.attachment-thumb:hover .attachment-remove {
opacity: 1;
}

/* === Error bar === */
.error-bar {
display: flex;
Expand Down
Loading