From 203cb5118de56183b4d64645d0ddf2291b92525a Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 6 Feb 2026 11:39:26 -0500 Subject: [PATCH] Polish UI, Rename Sessions, Markdown Output Support, Queued Messages UX polish and feature additions across the chat/dashboard UI: add Markdig package for markdown rendering; replace many emoji labels with inline SVG icons; implement inline session renaming (JS focus/blur handling) and visual active-nav state in the sidebar; improve persisted/session lists and filters; add message queuing and a planning mode for input; enhance dashboard cards to render different ChatMessage types (tool calls, reasoning, errors) and truncate/preview long results; track intent and usage info with new event handlers and UI indicators; add smart scrolling JS interop and minor CSS tweaks for better layout and hover states. Also includes small model/service updates to support new message types and events. --- AutoPilot.App.csproj | 1 + Components/Layout/SessionSidebar.razor | 80 +++- Components/Layout/SessionSidebar.razor.css | 59 ++- Components/Pages/Dashboard.razor | 45 +- Components/Pages/Dashboard.razor.css | 22 +- Components/Pages/Home.razor | 520 +++++++++++++++++---- Components/Pages/Home.razor.css | 459 +++++++++++++----- Models/AgentSessionInfo.cs | 3 +- Models/ChatMessage.cs | 55 ++- Services/CopilotService.cs | 232 ++++++++- wwwroot/index.html | 14 + 11 files changed, 1225 insertions(+), 265 deletions(-) diff --git a/AutoPilot.App.csproj b/AutoPilot.App.csproj index 54a19316e4..dbf473b1a1 100644 --- a/AutoPilot.App.csproj +++ b/AutoPilot.App.csproj @@ -68,6 +68,7 @@ + diff --git a/Components/Layout/SessionSidebar.razor b/Components/Layout/SessionSidebar.razor index 255f8a0188..10f81d96a3 100644 --- a/Components/Layout/SessionSidebar.razor +++ b/Components/Layout/SessionSidebar.razor @@ -2,16 +2,17 @@ @using AutoPilot.App.Models @inject CopilotService CopilotService @inject IJSRuntime JS +@inject NavigationManager Nav } + +
-
Active Sessions
+
Active Sessions
@if (!sessions.Any()) {

No active sessions.
Create one above or resume below.

@@ -60,9 +63,20 @@ @if (completedSessions.Contains(session.Name)) { - โœ“ + + } + @if (renamingSession == session.Name) + { + + } + else + { + @session.Name } - @session.Name @if (session.IsResumed) { resumed @@ -81,6 +95,8 @@ { โ— } +
@@ -90,8 +106,9 @@ @if (persistedSessions.Any()) { +
- ๐Ÿ“ Saved Sessions (@filteredPersistedSessions.Count()) + Saved Sessions (@filteredPersistedSessions.Count()) @(showPersistedSessions ? "โ–ผ" : "โ–ถ")
@@ -99,7 +116,7 @@ {
+ placeholder="Filter sessions..." class="filter-input" />
@foreach (var persisted in filteredPersistedSessions.Take(30)) @@ -117,7 +134,7 @@ } - โ†ป Resume + Resume } } @@ -132,6 +149,8 @@ private bool isCreating = false; private bool showPersistedSessions = false; private bool showDirectoryInput = false; + private string? renamingSession = null; + private string currentPage = "/"; private List sessions = new(); private List persistedSessions = new(); @@ -159,6 +178,8 @@ protected override void OnInitialized() { + currentPage = new Uri(Nav.Uri).AbsolutePath; + Nav.LocationChanged += (_, e) => { currentPage = new Uri(e.Location).AbsolutePath; InvokeAsync(StateHasChanged); }; CopilotService.OnStateChanged += RefreshSessions; CopilotService.OnSessionComplete += HandleSessionComplete; RefreshSessions(); @@ -296,6 +317,43 @@ } } + private async Task StartRename(string sessionName) + { + renamingSession = sessionName; + StateHasChanged(); + // Focus the input after render + await Task.Yield(); + await JS.InvokeVoidAsync("eval", @" + var el = document.getElementById('renameInput'); + if (el) { el.focus(); el.select(); } + "); + } + + private async Task CommitRename() + { + if (renamingSession == null) return; + var oldName = renamingSession; + renamingSession = null; + + var newName = await JS.InvokeAsync("eval", "document.getElementById('renameInput')?.value || ''"); + if (!string.IsNullOrWhiteSpace(newName) && newName.Trim() != oldName) + { + CopilotService.RenameSession(oldName, newName.Trim()); + } + } + + private async Task HandleRenameKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + await CommitRename(); + } + else if (e.Key == "Escape") + { + renamingSession = null; + } + } + private void SelectSession(string name) { completedSessions.Remove(name); diff --git a/Components/Layout/SessionSidebar.razor.css b/Components/Layout/SessionSidebar.razor.css index e94d7625f0..2a9dd3b531 100644 --- a/Components/Layout/SessionSidebar.razor.css +++ b/Components/Layout/SessionSidebar.razor.css @@ -11,6 +11,18 @@ border-bottom: 1px solid rgba(255,255,255,0.1); } +.sidebar-divider { + min-height: 1px; + height: 1px; + background: rgba(255,255,255,0.15); + margin: 0; + flex-shrink: 0; +} + +.session-list > .sidebar-divider { + margin: 0.5rem -0.5rem; +} + .sidebar-header h3 { margin: 0 0 0.5rem 0; font-size: 1.4rem; @@ -33,7 +45,6 @@ display: flex; gap: 0.5rem; padding: 1rem; - border-bottom: 1px solid rgba(255,255,255,0.1); flex-wrap: wrap; } @@ -147,15 +158,22 @@ .section-header { padding: 0.5rem 0.75rem; - font-size: 1rem; + font-size: 0.8rem; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.03em; color: rgba(255,255,255,0.5); display: flex; justify-content: space-between; align-items: center; } +.section-header-label { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + .toggle-icon { font-size: 0.6rem; } @@ -250,7 +268,7 @@ 50% { opacity: 0.5; } } -.close-btn, .resume-btn { +.close-btn, .resume-btn, .rename-btn { width: 1.5rem; height: 1.5rem; border: none; @@ -265,7 +283,8 @@ } .session-item:hover .close-btn, -.session-item:hover .resume-btn { +.session-item:hover .resume-btn, +.session-item:hover .rename-btn { opacity: 1; } @@ -274,6 +293,15 @@ color: #ef4444; } +.rename-btn:hover { + background: rgba(59, 130, 246, 0.3); + color: #60a5fa; +} + +.rename-btn { + font-size: 0.8rem; +} + .resume-btn:hover { background: rgba(59, 130, 246, 0.3); color: #60a5fa; @@ -339,6 +367,8 @@ display: flex; gap: 0.5rem; margin-top: 0.75rem; + --bs-nav-tabs-border-color: transparent; + border-bottom: none; } .nav-tab { @@ -346,7 +376,9 @@ display: flex; align-items: center; justify-content: center; + gap: 0.4rem; padding: 0.5rem; + border: 1px solid transparent; border-radius: 6px; background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.7); @@ -360,3 +392,22 @@ background: rgba(255,255,255,0.15); color: white; } + +.nav-tab.active { + background: rgba(59, 130, 246, 0.25); + color: #60a5fa; + border: 1px solid rgba(59, 130, 246, 0.4); +} + +.rename-input { + width: 100%; + padding: 0.2rem 0.4rem; + border: 1px solid #60a5fa; + border-radius: 4px; + background: rgba(255,255,255,0.15); + color: white; + font-size: 1rem; + font-weight: 500; + font-family: inherit; + outline: none; +} diff --git a/Components/Pages/Dashboard.razor b/Components/Pages/Dashboard.razor index d4f969da65..cc53e67f3f 100644 --- a/Components/Pages/Dashboard.razor +++ b/Components/Pages/Dashboard.razor @@ -7,7 +7,7 @@
-

๐ŸŽ›๏ธ Session Orchestrator

+

Session Orchestrator

@sessions.Count active sessions
@@ -30,8 +30,8 @@

@session.Name

- @session.Model +
@@ -46,10 +46,39 @@ { @foreach (var msg in lastMessages) { -
- @(msg.IsUser ? "You" : "AI") - @msg.Content -
+ @switch (msg.MessageType) + { + case ChatMessageType.User: +
+ You + @Truncate(msg.Content, 120) +
+ break; + case ChatMessageType.Assistant: +
+ AI + @Truncate(msg.Content, 120) +
+ break; + case ChatMessageType.ToolCall: +
+ + @(msg.ToolName ?? "tool") @(msg.IsComplete ? (msg.IsSuccess ? "โœ“" : "โœ—") : "โ€ฆ") +
+ break; + case ChatMessageType.Reasoning: +
+ + @(msg.IsComplete ? "Thought" : "Thinking...") +
+ break; + case ChatMessageType.Error: +
+ + @Truncate(msg.Content, 80) +
+ break; + } } } @if (session.IsProcessing) @@ -74,7 +103,7 @@ disabled="@session.IsProcessing" />
@@ -82,7 +111,7 @@
-

๐Ÿ“ข Broadcast to All

+

Broadcast to All

diff --git a/Components/Pages/Dashboard.razor.css b/Components/Pages/Dashboard.razor.css index 6ecdb02b0a..e80398d88e 100644 --- a/Components/Pages/Dashboard.razor.css +++ b/Components/Pages/Dashboard.razor.css @@ -42,6 +42,7 @@ } .session-card { + position: relative; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; @@ -74,32 +75,27 @@ .card-header { display: flex; - justify-content: space-between; align-items: center; - flex-wrap: wrap; - gap: 0.25rem; + gap: 0.5rem; } .card-close { background: none; - border: 1px solid rgba(255,255,255,0.15); + border: none; color: rgba(255,255,255,0.4); font-size: 0.85rem; - width: 24px; - height: 24px; - border-radius: 6px; + padding: 0.2rem 0.4rem; + border-radius: 4px; cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: 0; line-height: 1; + flex-shrink: 0; transition: all 0.15s ease; } +.session-card:hover .card-close { } + .card-close:hover { background: rgba(239, 68, 68, 0.2); - border-color: rgba(239, 68, 68, 0.5); color: #ef4444; } @@ -107,6 +103,8 @@ display: flex; align-items: center; gap: 0.5rem; + flex: 1; + min-width: 0; } .card-title h3 { diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index 65bd9d12b7..f6a8bdba69 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -1,6 +1,7 @@ @page "/" @using AutoPilot.App.Services @using AutoPilot.App.Models +@using Markdig @inject CopilotService CopilotService @inject IJSRuntime JS @inject NavigationManager Nav @@ -22,7 +23,7 @@ else if (activeSession == null) {
-

๐Ÿ‘‹ Welcome to AutoPilot

+

Welcome to AutoPilot

Create a new session from the sidebar to start chatting with Copilot.

} @@ -33,7 +34,7 @@ @activeSession.Model @if (activeSession.SessionId != null) { - ๐Ÿ“Ž @activeSession.SessionId[..8] + @activeSession.SessionId[..8] } @if (activeSession.IsProcessing) { @@ -42,7 +43,7 @@
- @if (!activeSession.History.Any()) + @if (!activeSession.History.Any() && string.IsNullOrEmpty(streamingContent)) {

Start a conversation with Copilot!

@@ -50,64 +51,200 @@ } else { - @foreach (var message in activeSession.History) + @foreach (var message in activeSession.History.ToList()) { -
-
- @(message.IsUser ? "๐Ÿ‘ค" : "๐Ÿค–") -
-
-
@((MarkupString)FormatMessage(message.Content))
-
@message.Timestamp.ToString("HH:mm")
-
-
+ @switch (message.MessageType) + { + case ChatMessageType.User: +
+
+
+
@((MarkupString)FormatUserMessage(message.Content))
+
@message.Timestamp.ToString("HH:mm")
+
+
+ break; + + case ChatMessageType.Assistant: +
+
+
+
@((MarkupString)RenderMarkdown(message.Content))
+
@message.Timestamp.ToString("HH:mm")
+
+
+ break; + + case ChatMessageType.Reasoning: +
+ + @if (!message.IsCollapsed || LineCount(message.Content) <= 5) + { +
@message.Content
+ } + else + { +
@FirstLines(message.Content, 3)
+ } +
+ break; + + case ChatMessageType.ToolCall: + @if (message.ToolName == "task_complete") + { +
+ + @(string.IsNullOrEmpty(message.Content) || IsUnusableResult(message.Content) ? "Task complete" : message.Content) +
+ } + else + { +
+
+ @FormatToolName(message.ToolName ?? "") + + @if (!message.IsComplete) + { + Running + } + else if (message.IsSuccess) + { + Done + } + else + { + Failed + } + +
+ @if (message.IsComplete && !string.IsNullOrEmpty(message.Content) && !IsUnusableResult(message.Content)) + { + @if (LineCount(message.Content) <= 5) + { +
+
@TruncateResult(message.Content)
+
+ } + else + { +
+ + @if (!message.IsCollapsed) + { +
@TruncateResult(message.Content)
+ } + else + { +
@FirstLines(message.Content, 3)
+ } +
+ } + } +
+ } + break; + + case ChatMessageType.Error: +
+ + @message.Content +
+ break; + } } + @if (!string.IsNullOrEmpty(streamingContent)) {
-
๐Ÿค–
+
-
@((MarkupString)FormatMessage(streamingContent))
+
@((MarkupString)RenderMarkdown(streamingContent))
} - @if (activeSession.IsProcessing) - { -
- @foreach (var activity in activityLog) - { -
@activity
- } - @if (!string.IsNullOrEmpty(currentActivity)) - { -
- โ— - @currentActivity -
- } -
- } }
@if (!string.IsNullOrEmpty(lastError)) {
- โš ๏ธ @lastError + @lastError
}
- - + @if (!string.IsNullOrEmpty(currentIntent)) + { +
@currentIntent
+ } + @if (activeSession.MessageQueue.Any()) + { +
+
+ Queued (@activeSession.MessageQueue.Count) + +
+ @for (var i = 0; i < activeSession.MessageQueue.Count; i++) + { + var index = i; + var msg = activeSession.MessageQueue[i]; +
+ @(index + 1) + @Truncate(msg, 80) + +
+ } +
+ } +
+ + +
+
+ + ยท + @(currentUsage?.Model ?? activeSession.Model) + @if (currentUsage != null) + { + @if (currentUsage.InputTokens.HasValue || currentUsage.OutputTokens.HasValue) + { + ยท + โ†‘@FormatTokenCount(currentUsage.InputTokens ?? 0) โ†“@FormatTokenCount(currentUsage.OutputTokens ?? 0) + } + @if (currentUsage.CurrentTokens.HasValue && currentUsage.TokenLimit.HasValue) + { + ยท + @FormatTokenCount(currentUsage.CurrentTokens.Value)/@FormatTokenCount(currentUsage.TokenLimit.Value) ctx + } + } + ยท + @activeSession.History.Count msgs +
} @@ -121,17 +258,24 @@
@code { + private static readonly MarkdownPipeline MdPipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + private AgentSessionInfo? activeSession; private string userInput = ""; + private bool isPlanMode = false; private string streamingContent = ""; - private string currentActivity = ""; - private List activityLog = new(); + private string currentIntent = ""; + private SessionUsageInfo? currentUsage; private string? initError; private string? lastError; private string debugLog = ""; private ElementReference messagesContainer; private bool _needsRedirect; private string? _redirectTo; + private bool _needsScroll = true; + private bool shouldPreventDefault; protected override async Task OnInitializedAsync() { @@ -140,35 +284,142 @@ CopilotService.OnSessionComplete += HandleSessionComplete; CopilotService.OnError += HandleError; CopilotService.OnDebug += HandleDebug; - CopilotService.OnActivity += HandleActivity; + CopilotService.OnToolStarted += HandleToolStarted; + CopilotService.OnToolCompleted += HandleToolCompleted; + CopilotService.OnReasoningReceived += HandleReasoningReceived; + CopilotService.OnReasoningComplete += HandleReasoningComplete; + CopilotService.OnIntentChanged += HandleIntentChanged; + CopilotService.OnUsageInfoChanged += HandleUsageInfoChanged; + CopilotService.OnTurnStart += HandleTurnStart; + CopilotService.OnTurnEnd += HandleTurnEnd; await Initialize(); } - protected override void OnAfterRender(bool firstRender) + protected override async Task OnAfterRenderAsync(bool firstRender) { if (_needsRedirect && _redirectTo != null) { _needsRedirect = false; Nav.NavigateTo(_redirectTo); } + if (firstRender || _needsScroll) + { + _needsScroll = false; + await ForceScrollToBottom(); + } } - private void HandleActivity(string sessionName, string activity) + private void HandleToolStarted(string sessionName, string toolName, string callId) { - if (sessionName == CopilotService.ActiveSessionName) + if (sessionName != CopilotService.ActiveSessionName) return; + var msg = ChatMessage.ToolCallMessage(toolName, callId); + activeSession?.History.Add(msg); + InvokeAsync(async () => { StateHasChanged(); await ScrollToBottom(); }); + } + + private void HandleToolCompleted(string sessionName, string callId, string result, bool success) + { + if (sessionName != CopilotService.ActiveSessionName || activeSession == null) return; + var msg = activeSession.History.LastOrDefault(m => + m.MessageType == ChatMessageType.ToolCall && !m.IsComplete && + (!string.IsNullOrEmpty(callId) ? m.ToolCallId == callId : true)); + if (msg == null) + msg = activeSession.History.LastOrDefault(m => m.MessageType == ChatMessageType.ToolCall && !m.IsComplete); + if (msg != null) + { + msg.IsComplete = true; + msg.IsSuccess = success; + msg.Content = result; + msg.IsCollapsed = true; + } + InvokeAsync(async () => { StateHasChanged(); await ScrollToBottom(); }); + } + + private void HandleReasoningReceived(string sessionName, string reasoningId, string content) + { + if (sessionName != CopilotService.ActiveSessionName || activeSession == null) return; + var msg = activeSession.History.LastOrDefault(m => + m.MessageType == ChatMessageType.Reasoning && m.ReasoningId == reasoningId); + if (msg == null) + { + msg = activeSession.History.LastOrDefault(m => m.MessageType == ChatMessageType.Reasoning && !m.IsComplete); + } + if (msg == null) + { + msg = ChatMessage.ReasoningMessage(reasoningId); + activeSession.History.Add(msg); + } + msg.Content += content; + InvokeAsync(async () => { StateHasChanged(); await ScrollToBottom(); }); + } + + private void HandleReasoningComplete(string sessionName, string reasoningId) + { + if (sessionName != CopilotService.ActiveSessionName || activeSession == null) return; + var msg = activeSession.History.LastOrDefault(m => + m.MessageType == ChatMessageType.Reasoning && + (m.ReasoningId == reasoningId || !m.IsComplete)); + if (msg != null) + { + msg.IsComplete = true; + msg.IsCollapsed = true; + } + InvokeAsync(StateHasChanged); + } + + private void HandleIntentChanged(string sessionName, string intent) + { + if (sessionName != CopilotService.ActiveSessionName) return; + currentIntent = intent; + InvokeAsync(StateHasChanged); + } + + private void HandleUsageInfoChanged(string sessionName, SessionUsageInfo info) + { + if (sessionName != CopilotService.ActiveSessionName) return; + if (currentUsage == null) + { + currentUsage = info; + } + else + { + currentUsage = new SessionUsageInfo( + info.Model ?? currentUsage.Model, + info.CurrentTokens ?? currentUsage.CurrentTokens, + info.TokenLimit ?? currentUsage.TokenLimit, + info.InputTokens ?? currentUsage.InputTokens, + info.OutputTokens ?? currentUsage.OutputTokens); + } + InvokeAsync(StateHasChanged); + } + + private void HandleTurnStart(string sessionName) + { + if (sessionName != CopilotService.ActiveSessionName || activeSession == null) return; + // Complete any open reasoning blocks from prior turn + foreach (var m in activeSession.History.Where(m => m.MessageType == ChatMessageType.Reasoning && !m.IsComplete)) + { + m.IsComplete = true; + m.IsCollapsed = true; + } + InvokeAsync(StateHasChanged); + } + + private void HandleTurnEnd(string sessionName) + { + if (sessionName != CopilotService.ActiveSessionName) return; + currentIntent = ""; + // Complete any open reasoning blocks + if (activeSession != null) { - currentActivity = activity; - if (!string.IsNullOrEmpty(activity)) + foreach (var m in activeSession.History.Where(m => m.MessageType == ChatMessageType.Reasoning && !m.IsComplete)) { - activityLog.Add(activity); + m.IsComplete = true; + m.IsCollapsed = true; } - InvokeAsync(async () => - { - StateHasChanged(); - await ScrollToBottom(); - }); } + InvokeAsync(StateHasChanged); } private void HandleDebug(string message) @@ -184,16 +435,12 @@ try { await CopilotService.InitializeAsync(); - // Restore previously active sessions await CopilotService.RestorePreviousSessionsAsync(); - - // Restore UI state (active session and page) var uiState = CopilotService.LoadUiState(); if (uiState != null) { if (!string.IsNullOrEmpty(uiState.ActiveSession)) CopilotService.SetActiveSession(uiState.ActiveSession); - if (uiState.CurrentPage is "/dashboard") { _needsRedirect = true; @@ -219,7 +466,10 @@ private void RefreshState() { + var prev = activeSession?.Name; activeSession = CopilotService.GetActiveSession(); + if (activeSession?.Name != prev) + _needsScroll = true; InvokeAsync(async () => { StateHasChanged(); @@ -244,15 +494,12 @@ { if (sessionName == CopilotService.ActiveSessionName) { - currentActivity = ""; + currentIntent = ""; } InvokeAsync(async () => { - try - { - await JS.InvokeVoidAsync("showNotification", sessionName, summary); - } - catch { /* JS might not be ready */ } + try { await JS.InvokeVoidAsync("showNotification", sessionName, summary); } + catch { } }); } @@ -260,20 +507,33 @@ { if (e.Key == "Enter" && !e.ShiftKey) { + shouldPreventDefault = true; await SendMessage(); } + else + { + shouldPreventDefault = false; + } } private async Task SendMessage() { - if (string.IsNullOrWhiteSpace(userInput) || activeSession == null || activeSession.IsProcessing) + if (string.IsNullOrWhiteSpace(userInput) || activeSession == null) return; var prompt = userInput.Trim(); + if (isPlanMode) + prompt = $"[[PLAN]] {prompt}"; userInput = ""; + + if (activeSession.IsProcessing) + { + CopilotService.EnqueueMessage(activeSession.Name, prompt); + return; + } + streamingContent = ""; - currentActivity = ""; - activityLog.Clear(); + currentIntent = ""; lastError = null; try @@ -284,45 +544,102 @@ catch (Exception ex) { lastError = $"Error: {ex.Message}"; - Console.WriteLine($"Error: {ex.Message}"); } + await ForceScrollToBottom(); + } + + private void RemoveQueuedMessage(int index) + { + if (activeSession != null) CopilotService.RemoveQueuedMessage(activeSession.Name, index); + } + + private void ClearQueue() + { + if (activeSession != null) CopilotService.ClearQueue(activeSession.Name); } - private string FormatMessage(string content) + private string GetInputPlaceholder() + { + if (isPlanMode) return "Plan mode: describe what you want planned..."; + if (activeSession?.IsProcessing == true) return "Type to queue next message... (sent when idle)"; + return "Type a message... (Enter to send, Shift+Enter for new line)"; + } + + private string Truncate(string text, int maxLen) + { + text = text.Replace("\n", " ").Replace("\r", ""); + return text.Length > maxLen ? text[..maxLen] + "โ€ฆ" : text; + } + + private static string RenderMarkdown(string content) + { + if (string.IsNullOrEmpty(content)) return ""; + try { return Markdig.Markdown.ToHtml(content, MdPipeline); } + catch { return System.Net.WebUtility.HtmlEncode(content).Replace("\n", "
"); } + } + + private static string FormatUserMessage(string content) { var escaped = System.Net.WebUtility.HtmlEncode(content); - - // Code blocks - escaped = System.Text.RegularExpressions.Regex.Replace( - escaped, - @"```(\w*)\n?([\s\S]*?)```", - "
$2
"); - - // Inline code - escaped = System.Text.RegularExpressions.Regex.Replace( - escaped, - @"`([^`]+)`", - "$1"); - - // Bold - escaped = System.Text.RegularExpressions.Regex.Replace( - escaped, - @"\*\*([^*]+)\*\*", - "$1"); - - // Line breaks - escaped = escaped.Replace("\n", "
"); - - return escaped; + return escaped.Replace("\n", "
"); } - private async Task ScrollToBottom() + private static int LineCount(string? text) { - try + if (string.IsNullOrEmpty(text)) return 0; + var count = 1; + foreach (var c in text) { if (c == '\n') count++; } + return count; + } + + private static string FirstLines(string? text, int lines) + { + if (string.IsNullOrEmpty(text)) return ""; + var idx = 0; + for (var i = 0; i < lines && idx < text.Length; i++) { - await JS.InvokeVoidAsync("scrollToBottom", messagesContainer); + var next = text.IndexOf('\n', idx); + if (next < 0) break; + idx = next + 1; } - catch { /* JS might not be ready */ } + if (idx <= 0 || idx >= text.Length) return text; + return text[..idx] + "โ€ฆ"; + } + + private static string FormatToolName(string toolName) + { + if (string.IsNullOrEmpty(toolName)) return ""; + return string.Join(" ", toolName.Split('_').Select(w => + w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant() : w)); + } + + private static string TruncateResult(string result, int maxLength = 1500) + { + if (string.IsNullOrEmpty(result) || result.Length <= maxLength) return result ?? ""; + return result[..maxLength] + "\nโ€ฆ (truncated)"; + } + + private static bool IsUnusableResult(string? content) + { + if (string.IsNullOrEmpty(content)) return true; + if (content.StartsWith("GitHub.Copilot.SDK.")) return true; + if (content is "(no result)" or "Intent logged") return true; + return false; + } + + private static string FormatTokenCount(int count) => + count >= 1000 ? $"{count / 1000}k" : count.ToString(); + + private async Task ScrollToBottom() + { + try { await JS.InvokeVoidAsync("smartScrollToBottom", messagesContainer); } + catch { } + } + + private async Task ForceScrollToBottom() + { + try { await JS.InvokeVoidAsync("scrollToBottom", messagesContainer); } + catch { } } public void Dispose() @@ -332,6 +649,13 @@ CopilotService.OnSessionComplete -= HandleSessionComplete; CopilotService.OnError -= HandleError; CopilotService.OnDebug -= HandleDebug; - CopilotService.OnActivity -= HandleActivity; + CopilotService.OnToolStarted -= HandleToolStarted; + CopilotService.OnToolCompleted -= HandleToolCompleted; + CopilotService.OnReasoningReceived -= HandleReasoningReceived; + CopilotService.OnReasoningComplete -= HandleReasoningComplete; + CopilotService.OnIntentChanged -= HandleIntentChanged; + CopilotService.OnUsageInfoChanged -= HandleUsageInfoChanged; + CopilotService.OnTurnStart -= HandleTurnStart; + CopilotService.OnTurnEnd -= HandleTurnEnd; } } diff --git a/Components/Pages/Home.razor.css b/Components/Pages/Home.razor.css index 7ef100805f..d8700588d6 100644 --- a/Components/Pages/Home.razor.css +++ b/Components/Pages/Home.razor.css @@ -18,9 +18,7 @@ font-size: 18px; } -.initializing .error { - color: #ef4444; -} +.initializing .error { color: #ef4444; } .initializing button { padding: 0.5rem 1rem; @@ -40,18 +38,14 @@ animation: spin 1s linear infinite; } -@keyframes spin { - to { transform: rotate(360deg); } -} +@keyframes spin { to { transform: rotate(360deg); } } -.no-session h2 { - margin: 0; -} +.no-session h2 { margin: 0; } +.no-session p { margin: 0; color: rgba(255,255,255,0.5); } -.no-session p { - margin: 0; - color: rgba(255,255,255,0.5); -} +/* === Inline icons === */ +.icon { vertical-align: middle; flex-shrink: 0; } +.icon.spin { animation: spin 1s linear infinite; } .chat-header { display: flex; @@ -62,10 +56,7 @@ border-bottom: 1px solid rgba(255,255,255,0.1); } -.chat-header h2 { - margin: 0; - font-size: 1.6rem; -} +.chat-header h2 { margin: 0; font-size: 1.6rem; } .model-badge { padding: 0.25rem 0.5rem; @@ -83,10 +74,7 @@ animation: pulse 1.5s infinite; } -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .messages { flex: 1; @@ -94,7 +82,7 @@ padding: 1rem; display: flex; flex-direction: column; - gap: 1rem; + gap: 0.75rem; } .empty-chat { @@ -105,6 +93,7 @@ color: rgba(255,255,255,0.5); } +/* === Messages === */ .message { display: flex; gap: 0.75rem; @@ -117,22 +106,18 @@ } .message-avatar { - width: 40px; - height: 40px; + width: 36px; + height: 36px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 50%; - font-size: 1.3rem; + font-size: 1.2rem; flex-shrink: 0; - line-height: 1; - text-align: center; } -.message.user .message-avatar { - background: rgba(59, 130, 246, 0.3); -} +.message.user .message-avatar { background: rgba(59, 130, 246, 0.3); } .message-content { display: flex; @@ -143,9 +128,10 @@ .message-text { padding: 0.75rem 1rem; border-radius: 12px; - line-height: 1.7; - font-size: 1.1rem; + line-height: 1.6; + font-size: 1.05rem; word-break: break-word; + min-height: 1.6em; } .message.user .message-text { @@ -163,48 +149,295 @@ animation: blink 1s infinite; } -@keyframes blink { - 0%, 50% { opacity: 1; } - 51%, 100% { opacity: 0; } +@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } + +.message-time { + font-size: 0.75rem; + color: rgba(255,255,255,0.35); + padding: 0 0.5rem; } -.message-text pre { - background: rgba(0,0,0,0.3); +.message.user .message-time { text-align: right; } + +/* === Markdown body (::deep for MarkupString inner content) === */ +::deep .markdown-body p { margin: 0 0 0.5rem 0; } +::deep .markdown-body p:last-child { margin-bottom: 0; } + +::deep .markdown-body pre { + background: rgba(0,0,0,0.4); padding: 0.75rem; border-radius: 6px; overflow-x: auto; margin: 0.5rem 0; } -.message-text code { +::deep .markdown-body code { font-family: 'SF Mono', Monaco, Consolas, monospace; - font-size: 0.95rem; + font-size: 0.9rem; } -.message-text :not(pre) > code { - background: rgba(0,0,0,0.3); - padding: 0.15rem 0.3rem; +::deep .markdown-body :not(pre) > code { + background: rgba(0,0,0,0.35); + padding: 0.15rem 0.35rem; border-radius: 3px; } -.message-time { - font-size: 0.8rem; +::deep .markdown-body ul, ::deep .markdown-body ol { + margin: 0.4rem 0; + padding-left: 1.5rem; +} + +::deep .markdown-body li { margin: 0.15rem 0; } + +::deep .markdown-body h1, ::deep .markdown-body h2, ::deep .markdown-body h3 { + margin: 0.75rem 0 0.4rem 0; +} +::deep .markdown-body h1 { font-size: 1.25em; } +::deep .markdown-body h2 { font-size: 1.15em; } +::deep .markdown-body h3 { font-size: 1.05em; } + +::deep .markdown-body blockquote { + border-left: 3px solid #60a5fa; + margin: 0.5rem 0; + padding: 0.4rem 0.75rem; + background: rgba(59, 130, 246, 0.08); + border-radius: 0 6px 6px 0; +} + +::deep .markdown-body a { color: #60a5fa; text-decoration: none; } +::deep .markdown-body a:hover { text-decoration: underline; } + +::deep .markdown-body table { + border-collapse: collapse; + margin: 0.5rem 0; + width: 100%; + font-size: 0.9rem; +} + +::deep .markdown-body th, ::deep .markdown-body td { + border: 1px solid rgba(255,255,255,0.15); + padding: 0.4rem 0.6rem; +} + +::deep .markdown-body th { + background: rgba(255,255,255,0.08); + font-weight: 600; +} + +/* === Reasoning blocks === */ +.reasoning-block { + background: rgba(139, 92, 246, 0.08); + border: 1px solid rgba(139, 92, 246, 0.25); + border-radius: 8px; + max-width: 90%; + overflow: hidden; +} + +.reasoning-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.5rem 0.75rem; + background: transparent; + border: none; + cursor: pointer; + color: white; + font-size: 0.85rem; +} + +.reasoning-header:hover { background: rgba(139, 92, 246, 0.12); } + +.reasoning-title { + font-weight: 600; + color: #a78bfa; +} + +.collapse-icon { + font-size: 0.7rem; color: rgba(255,255,255,0.4); - padding: 0 0.5rem; } -.message.user .message-time { - text-align: right; +.reasoning-content { + padding: 0 0.75rem 0.75rem 0.75rem; + font-size: 0.85rem; + line-height: 1.5; + color: rgba(255,255,255,0.6); + font-style: italic; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; } -.input-area { +.reasoning-content.preview { + max-height: none; + overflow: hidden; + position: relative; + mask-image: linear-gradient(to bottom, black 50%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 50%, transparent 100%); +} + +.reasoning-block.collapsed .reasoning-content:not(.preview) { display: none; } + +/* === Tool cards === */ +.tool-card { + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.15); + max-width: 90%; + overflow: hidden; + background: rgba(255,255,255,0.04); +} + +.tool-card.running { border-color: rgba(59, 130, 246, 0.4); } +.tool-card.success { border-color: rgba(34, 197, 94, 0.35); } +.tool-card.error { border-color: rgba(239, 68, 68, 0.35); } + +.tool-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.45rem 0.75rem; + font-size: 0.85rem; +} + +.tool-info { + font-weight: 600; + color: rgba(255,255,255,0.8); +} + +.tool-status { font-size: 0.8rem; color: rgba(255,255,255,0.5); } +.tool-card.running .tool-status { color: #60a5fa; } +.tool-card.success .tool-status { color: #4ade80; } +.tool-card.error .tool-status { color: #f87171; } + +.tool-result-section { border-top: 1px solid rgba(255,255,255,0.08); } + +.tool-result-toggle { + width: 100%; + padding: 0.35rem 0.75rem; + background: transparent; + border: none; + cursor: pointer; + font-size: 0.75rem; + color: rgba(255,255,255,0.4); + text-align: left; +} + +.tool-result-toggle:hover { background: rgba(255,255,255,0.05); } + +.tool-result-content { + margin: 0; + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + background: rgba(0,0,0,0.2); + color: rgba(255,255,255,0.6); + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; + font-family: 'SF Mono', Monaco, Consolas, monospace; +} + +.tool-result-content.preview { + max-height: none; + overflow: hidden; + mask-image: linear-gradient(to bottom, black 50%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 50%, transparent 100%); +} + +/* === Task complete card === */ +.task-complete-card { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.7rem; + background: rgba(34, 197, 94, 0.08); + border: 1px solid rgba(34, 197, 94, 0.25); + border-radius: 6px; + max-width: 90%; + font-size: 0.85rem; +} + +.task-complete-text { + color: #86efac; + line-height: 1.4; +} + +/* === Error cards === */ +.error-card { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 8px; + max-width: 90%; + font-size: 0.9rem; +} + +.error-icon { flex-shrink: 0; } +.error-text { color: #fca5a5; line-height: 1.4; } + +/* === Intent pill === */ +.intent-pill { + padding: 0.3rem 0.65rem; + background: rgba(139, 92, 246, 0.12); + border: 1px solid rgba(139, 92, 246, 0.25); + border-radius: 6px; + font-size: 0.8rem; + color: #a78bfa; + align-self: flex-start; +} + +/* === Input status bar === */ +.input-status-bar { display: flex; + align-items: center; gap: 0.75rem; - padding: 1rem 1.5rem; + padding: 0 0.25rem; + font-size: 0.75rem; + color: rgba(255,255,255,0.35); +} + +.plan-icon-toggle { + all: unset; + display: flex; + align-items: center; + gap: 0.3rem; + color: rgba(255,255,255,0.35); + font-size: 0.75rem; + cursor: pointer; + transition: color 0.15s ease; +} + +.plan-icon-toggle:hover { + color: rgba(255,255,255,0.55); +} + +.plan-icon-toggle.active { + color: #60a5fa; +} + +.status-model { color: rgba(59, 130, 246, 0.6); } +.status-tokens { } +.status-ctx { } +.status-msgs { } +.status-sep { color: rgba(255,255,255,0.15); user-select: none; } + +/* === Input area === */ +.input-area { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1.5rem; background: rgba(255,255,255,0.05); border-top: 1px solid rgba(255,255,255,0.1); } +.input-row { display: flex; gap: 0.75rem; } + .input-area textarea { flex: 1; padding: 0.75rem 1rem; @@ -219,20 +452,11 @@ max-height: 150px; } -.input-area textarea::placeholder { - color: rgba(255,255,255,0.4); -} - -.input-area textarea:focus { - outline: none; - border-color: #3b82f6; -} - -.input-area textarea:disabled { - opacity: 0.6; -} +.input-area textarea::placeholder { color: rgba(255,255,255,0.4); } +.input-area textarea:focus { outline: none; border-color: #3b82f6; } +.input-area textarea:disabled { opacity: 0.6; } -.input-area button { +.input-area .input-row button { padding: 0.75rem 1.5rem; border: none; border-radius: 8px; @@ -244,15 +468,10 @@ transition: background 0.15s ease; } -.input-area button:hover:not(:disabled) { - background: #2563eb; -} - -.input-area button:disabled { - opacity: 0.5; - cursor: not-allowed; -} +.input-area .input-row button:hover:not(:disabled) { background: #2563eb; } +.input-area .input-row button:disabled { opacity: 0.5; cursor: not-allowed; } +/* === Error bar === */ .error-bar { display: flex; align-items: center; @@ -273,27 +492,9 @@ padding: 0 0.5rem; } -.error-bar button:hover { - color: white; -} - -.message-text.thinking { - color: rgba(255,255,255,0.6); - font-style: italic; -} - -.message-text.thinking::after { - content: ""; - animation: dots 1.5s infinite; -} - -@keyframes dots { - 0%, 20% { content: ""; } - 40% { content: "."; } - 60% { content: ".."; } - 80%, 100% { content: "..."; } -} +.error-bar button:hover { color: white; } +/* === Badges === */ .session-id-badge { padding: 0.25rem 0.5rem; background: rgba(251, 191, 36, 0.2); @@ -304,6 +505,7 @@ font-family: monospace; } +/* === Debug panel === */ .debug-panel { position: fixed; bottom: 80px; @@ -348,32 +550,73 @@ word-break: break-all; } -.activity-log { - padding: 0.5rem 1rem; - color: rgba(255,255,255,0.5); +/* === Message queue === */ +.message-queue { + border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: 8px; + background: rgba(59, 130, 246, 0.08); + overflow: hidden; +} + +.queue-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.4rem 0.75rem; font-size: 0.85rem; - border-left: 2px solid rgba(255,255,255,0.1); - margin-left: 3rem; - margin-bottom: 0.5rem; + color: rgba(255,255,255,0.6); + border-bottom: 1px solid rgba(59, 130, 246, 0.15); } -.activity-entry { - padding: 0.15rem 0; - opacity: 0.5; +.queue-clear-btn { + padding: 0.15rem 0.5rem !important; + font-size: 0.75rem !important; + background: rgba(239, 68, 68, 0.2) !important; + border: 1px solid rgba(239, 68, 68, 0.3) !important; + border-radius: 4px !important; + color: #fca5a5 !important; + cursor: pointer; } -.activity-entry.current { - opacity: 1; +.queue-clear-btn:hover { background: rgba(239, 68, 68, 0.35) !important; } + +.queue-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + font-size: 0.9rem; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.queue-item:last-child { border-bottom: none; } + +.queue-index { + color: rgba(59, 130, 246, 0.7); + font-weight: 600; + font-size: 0.8rem; + min-width: 1.2rem; +} + +.queue-text { + flex: 1; color: rgba(255,255,255,0.7); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.activity-spinner { - color: #fbbf24; - animation: pulse-activity 1s infinite; - margin-right: 0.4rem; +.queue-remove-btn { + padding: 0.1rem 0.4rem !important; + font-size: 0.85rem !important; + background: transparent !important; + border: none !important; + color: rgba(255,255,255,0.3) !important; + cursor: pointer; + border-radius: 3px !important; } -@keyframes pulse-activity { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } +.queue-remove-btn:hover { + background: rgba(239, 68, 68, 0.2) !important; + color: #ef4444 !important; } diff --git a/Models/AgentSessionInfo.cs b/Models/AgentSessionInfo.cs index eb4f85dc41..ae73e42a62 100644 --- a/Models/AgentSessionInfo.cs +++ b/Models/AgentSessionInfo.cs @@ -2,12 +2,13 @@ namespace AutoPilot.App.Models; public class AgentSessionInfo { - public required string Name { get; init; } + public required string Name { get; set; } public required string Model { get; init; } public DateTime CreatedAt { get; init; } public int MessageCount { get; set; } public bool IsProcessing { get; set; } public List History { get; } = new(); + public List MessageQueue { get; } = new(); public string? WorkingDirectory { get; set; } diff --git a/Models/ChatMessage.cs b/Models/ChatMessage.cs index feaf0add29..4e5e55cc06 100644 --- a/Models/ChatMessage.cs +++ b/Models/ChatMessage.cs @@ -1,8 +1,59 @@ namespace AutoPilot.App.Models; -public record ChatMessage(string Role, string Content, DateTime Timestamp) +public enum ChatMessageType { + User, + Assistant, + Reasoning, + ToolCall, + Error +} + +public class ChatMessage +{ + public ChatMessage(string role, string content, DateTime timestamp, ChatMessageType messageType = ChatMessageType.User) + { + Role = role; + Content = content; + Timestamp = timestamp; + MessageType = messageType; + + if (role == "user") MessageType = ChatMessageType.User; + else if (messageType == ChatMessageType.User) MessageType = ChatMessageType.Assistant; + } + + public string Role { get; set; } + public string Content { get; set; } + public DateTime Timestamp { get; set; } + public ChatMessageType MessageType { get; set; } + + // Tool call fields + public string? ToolName { get; set; } + public string? ToolCallId { get; set; } + public bool IsComplete { get; set; } = true; + public bool IsCollapsed { get; set; } + public bool IsSuccess { get; set; } + + // Reasoning fields + public string? ReasoningId { get; set; } + + // Convenience properties public bool IsUser => Role == "user"; public bool IsAssistant => Role == "assistant"; - public bool IsSystem => Role == "system"; + + // Factory methods + public static ChatMessage UserMessage(string content) => + new("user", content, DateTime.Now, ChatMessageType.User) { IsComplete = true }; + + public static ChatMessage AssistantMessage(string content) => + new("assistant", content, DateTime.Now, ChatMessageType.Assistant) { IsComplete = true }; + + public static ChatMessage ReasoningMessage(string reasoningId) => + new("assistant", "", DateTime.Now, ChatMessageType.Reasoning) { ReasoningId = reasoningId, IsComplete = false, IsCollapsed = false }; + + public static ChatMessage ToolCallMessage(string toolName, string? toolCallId = null) => + new("assistant", "", DateTime.Now, ChatMessageType.ToolCall) { ToolName = toolName, ToolCallId = toolCallId, IsComplete = false }; + + public static ChatMessage ErrorMessage(string content, string? toolName = null) => + new("assistant", content, DateTime.Now, ChatMessageType.Error) { ToolName = toolName, IsComplete = true }; } diff --git a/Services/CopilotService.cs b/Services/CopilotService.cs index 668d153cf4..32869d9ac8 100644 --- a/Services/CopilotService.cs +++ b/Services/CopilotService.cs @@ -58,6 +58,16 @@ private static string FindProjectDir() public event Action? OnActivity; // sessionName, activity description public event Action? OnDebug; // debug messages + // Rich event types + public event Action? OnToolStarted; // sessionName, toolName, callId + public event Action? OnToolCompleted; // sessionName, callId, result, success + public event Action? OnReasoningReceived; // sessionName, reasoningId, deltaContent + public event Action? OnReasoningComplete; // sessionName, reasoningId + public event Action? OnIntentChanged; // sessionName, intent + public event Action? OnUsageInfoChanged; // sessionName, usageInfo + public event Action? OnTurnStart; // sessionName + public event Action? OnTurnEnd; // sessionName + private class SessionState { public required CopilotSession Session { get; init; } @@ -326,7 +336,7 @@ public async Task ResumeSessionAsync(string sessionId, string Info = info }; - copilotSession.On(evt => HandleSessionEvent(displayName, state, evt)); + copilotSession.On(evt => HandleSessionEvent(state, evt)); if (!_sessions.TryAdd(displayName, state)) { @@ -405,7 +415,7 @@ ALWAYS run the relaunch script as the final step after making changes to this pr Info = info }; - copilotSession.On(evt => HandleSessionEvent(name, state, evt)); + copilotSession.On(evt => HandleSessionEvent(state, evt)); if (!_sessions.TryAdd(name, state)) { @@ -419,9 +429,11 @@ ALWAYS run the relaunch script as the final step after making changes to this pr return info; } - private void HandleSessionEvent(string sessionName, SessionState state, SessionEvent evt) + private static readonly HashSet FilteredTools = new() { "report_intent", "skill", "store_memory" }; + + private void HandleSessionEvent(SessionState state, SessionEvent evt) { - // Marshal to UI thread if we have a sync context + var sessionName = state.Info.Name; void Invoke(Action action) { if (_syncContext != null) @@ -432,6 +444,20 @@ void Invoke(Action action) switch (evt) { + case AssistantReasoningEvent reasoning: + Invoke(() => + { + OnReasoningReceived?.Invoke(sessionName, reasoning.Data.ReasoningId ?? "", reasoning.Data.Content ?? ""); + }); + break; + + case AssistantReasoningDeltaEvent reasoningDelta: + Invoke(() => + { + OnReasoningReceived?.Invoke(sessionName, reasoningDelta.Data.ReasoningId ?? "", reasoningDelta.Data.DeltaContent ?? ""); + }); + break; + case AssistantMessageDeltaEvent delta: var deltaContent = delta.Data.DeltaContent; state.CurrentResponse.Append(deltaContent); @@ -445,39 +471,67 @@ void Invoke(Action action) state.CurrentResponse.Append(msgContent); Invoke(() => OnContentReceived?.Invoke(sessionName, msgContent)); } - // Show tool requests as activity - var toolReqs = msg.Data.ToolRequests; - if (toolReqs != null && toolReqs.Any()) - { - foreach (var tool in toolReqs!) - { - Invoke(() => OnActivity?.Invoke(sessionName, $"๐Ÿ”ง Calling {tool.Name}...")); - } - } break; case ToolExecutionStartEvent toolStart: - Invoke(() => OnActivity?.Invoke(sessionName, $"๐Ÿ”ง Running {toolStart.Data.ToolName}...")); + var startToolName = toolStart.Data.ToolName ?? "unknown"; + var startCallId = toolStart.Data.ToolCallId ?? ""; + if (!FilteredTools.Contains(startToolName)) + { + Invoke(() => + { + OnToolStarted?.Invoke(sessionName, startToolName, startCallId); + OnActivity?.Invoke(sessionName, $"๐Ÿ”ง Running {startToolName}..."); + }); + } break; case ToolExecutionCompleteEvent toolDone: - Invoke(() => OnActivity?.Invoke(sessionName, $"โœ… Tool completed")); + var completeCallId = toolDone.Data.ToolCallId ?? ""; + var completeToolName = toolDone.Data?.GetType().GetProperty("ToolName")?.GetValue(toolDone.Data)?.ToString(); + var resultStr = FormatToolResult(toolDone.Data.Result); + var hasError = toolDone.Data.Error != null; + + // Skip filtered tools + if (completeToolName != null && FilteredTools.Contains(completeToolName)) + break; + if (resultStr == "Intent logged") + break; + + Invoke(() => + { + OnToolCompleted?.Invoke(sessionName, completeCallId, resultStr, !hasError); + OnActivity?.Invoke(sessionName, hasError ? "โŒ Tool failed" : "โœ… Tool completed"); + }); break; - case ToolExecutionProgressEvent toolProgress: + case ToolExecutionProgressEvent: Invoke(() => OnActivity?.Invoke(sessionName, "โš™๏ธ Tool executing...")); break; case AssistantIntentEvent intent: - Invoke(() => OnActivity?.Invoke(sessionName, $"๐Ÿ’ญ {intent.Data.Intent}")); + var intentText = intent.Data.Intent ?? ""; + Invoke(() => + { + OnIntentChanged?.Invoke(sessionName, intentText); + OnActivity?.Invoke(sessionName, $"๐Ÿ’ญ {intentText}"); + }); break; case AssistantTurnStartEvent: - Invoke(() => OnActivity?.Invoke(sessionName, "๐Ÿค” Thinking...")); + Invoke(() => + { + OnTurnStart?.Invoke(sessionName); + OnActivity?.Invoke(sessionName, "๐Ÿค” Thinking..."); + }); break; case AssistantTurnEndEvent: - Invoke(() => OnActivity?.Invoke(sessionName, "")); + Invoke(() => + { + OnTurnEnd?.Invoke(sessionName); + OnActivity?.Invoke(sessionName, ""); + }); break; case SessionIdleEvent: @@ -490,6 +544,27 @@ void Invoke(Action action) SaveActiveSessionsToDisk(); break; + case SessionUsageInfoEvent usageInfo: + var uData = usageInfo.Data; + var uModel = uData?.GetType().GetProperty("Model")?.GetValue(uData)?.ToString(); + var uCurrentTokens = uData?.GetType().GetProperty("CurrentTokens")?.GetValue(uData) as int?; + var uTokenLimit = uData?.GetType().GetProperty("TokenLimit")?.GetValue(uData) as int?; + var uInputTokens = uData?.GetType().GetProperty("InputTokens")?.GetValue(uData) as int?; + var uOutputTokens = uData?.GetType().GetProperty("OutputTokens")?.GetValue(uData) as int?; + Invoke(() => OnUsageInfoChanged?.Invoke(sessionName, new SessionUsageInfo(uModel, uCurrentTokens, uTokenLimit, uInputTokens, uOutputTokens))); + break; + + case AssistantUsageEvent assistantUsage: + var aData = assistantUsage.Data; + var aModel = aData?.GetType().GetProperty("Model")?.GetValue(aData)?.ToString(); + var aInput = aData?.GetType().GetProperty("InputTokens")?.GetValue(aData) as int?; + var aOutput = aData?.GetType().GetProperty("OutputTokens")?.GetValue(aData) as int?; + if (aInput.HasValue || aOutput.HasValue) + { + Invoke(() => OnUsageInfoChanged?.Invoke(sessionName, new SessionUsageInfo(aModel, null, null, aInput, aOutput))); + } + break; + case SessionErrorEvent err: Invoke(() => OnError?.Invoke(sessionName, err.Data.Message)); state.ResponseCompletion?.TrySetException(new Exception(err.Data.Message)); @@ -502,6 +577,29 @@ void Invoke(Action action) } } + private static string FormatToolResult(object? result) + { + if (result == null) return ""; + if (result is string str) return str; + try + { + var resultType = result.GetType(); + foreach (var propName in new[] { "Content", "content", "Message", "message", "Text", "text", "Value", "value" }) + { + var prop = resultType.GetProperty(propName); + if (prop != null) + { + var val = prop.GetValue(result)?.ToString(); + if (!string.IsNullOrEmpty(val)) return val; + } + } + var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + if (json != "{}" && json != "null") return json; + } + catch { } + return result.ToString() ?? ""; + } + private void CompleteResponse(SessionState state) { var response = state.CurrentResponse.ToString(); @@ -518,6 +616,27 @@ private void CompleteResponse(SessionState state) // Fire completion notification var summary = response.Length > 100 ? response[..100] + "..." : response; OnSessionComplete?.Invoke(state.Info.Name, summary); + + // Auto-dispatch next queued message + if (state.Info.MessageQueue.Count > 0) + { + var nextPrompt = state.Info.MessageQueue[0]; + state.Info.MessageQueue.RemoveAt(0); + _ = Task.Run(async () => + { + try + { + // Small delay to let UI update + await Task.Delay(500); + await SendPromptAsync(state.Info.Name, nextPrompt); + } + catch (Exception ex) + { + Debug($"Failed to send queued message: {ex.Message}"); + OnError?.Invoke(state.Info.Name, $"Queued message failed: {ex.Message}"); + } + }); + } } public async Task SendPromptAsync(string sessionName, string prompt, CancellationToken cancellationToken = default) @@ -571,6 +690,36 @@ await state.Session.SendAsync(new MessageOptions return await state.ResponseCompletion.Task; } + public void EnqueueMessage(string sessionName, string prompt) + { + if (!_sessions.TryGetValue(sessionName, out var state)) + throw new InvalidOperationException($"Session '{sessionName}' not found."); + + state.Info.MessageQueue.Add(prompt); + OnStateChanged?.Invoke(); + } + + public void RemoveQueuedMessage(string sessionName, int index) + { + if (!_sessions.TryGetValue(sessionName, out var state)) + return; + + if (index >= 0 && index < state.Info.MessageQueue.Count) + { + state.Info.MessageQueue.RemoveAt(index); + OnStateChanged?.Invoke(); + } + } + + public void ClearQueue(string sessionName) + { + if (_sessions.TryGetValue(sessionName, out var state)) + { + state.Info.MessageQueue.Clear(); + OnStateChanged?.Invoke(); + } + } + public AgentSessionInfo? GetSession(string name) { return _sessions.TryGetValue(name, out var state) ? state.Info : null; @@ -591,6 +740,39 @@ public bool SwitchSession(string name) return true; } + public bool RenameSession(string oldName, string newName) + { + if (string.IsNullOrWhiteSpace(newName)) + return false; + + newName = newName.Trim(); + if (oldName == newName) + return true; + + if (_sessions.ContainsKey(newName)) + return false; + + if (!_sessions.TryRemove(oldName, out var state)) + return false; + + state.Info.Name = newName; + + if (!_sessions.TryAdd(newName, state)) + { + // Rollback + state.Info.Name = oldName; + _sessions.TryAdd(oldName, state); + return false; + } + + if (_activeSessionName == oldName) + _activeSessionName = newName; + + SaveActiveSessionsToDisk(); + OnStateChanged?.Invoke(); + return true; + } + public void SetActiveSession(string? name) { if (name != null && _sessions.ContainsKey(name)) @@ -756,7 +938,15 @@ public class PersistedSessionInfo public required string SessionId { get; init; } public DateTime LastModified { get; init; } public string? Path { get; init; } - public string? Title { get; init; } // First user message truncated - public string? Preview { get; init; } // Full first user message for tooltip + public string? Title { get; init; } + public string? Preview { get; init; } public string? WorkingDirectory { get; init; } } + +public record SessionUsageInfo( + string? Model, + int? CurrentTokens, + int? TokenLimit, + int? InputTokens, + int? OutputTokens +); diff --git a/wwwroot/index.html b/wwwroot/index.html index 5cec3db0ab..5ae313a27b 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -36,6 +36,20 @@ } }; + window.smartScrollToBottom = function(element) { + if (!element) return; + var threshold = 150; + var isAtBottom = (element.scrollHeight - element.scrollTop - element.clientHeight) < threshold; + if (isAtBottom) { + element.scrollTop = element.scrollHeight; + } + }; + + window.isScrolledToBottom = function(element) { + if (!element) return true; + return (element.scrollHeight - element.scrollTop - element.clientHeight) < 150; + }; + window.showNotification = function(sessionName, summary) { // Play a sound try {