diff --git a/Components/ChatMessageList.razor b/Components/ChatMessageList.razor new file mode 100644 index 0000000000..53afe92239 --- /dev/null +++ b/Components/ChatMessageList.razor @@ -0,0 +1,375 @@ +@using AutoPilot.App.Models +@using Markdig + +@* Shared chat message list — used by both Home.razor (full) and Dashboard.razor (compact) *@ + +
+ @if (!Messages.Any() && string.IsNullOrEmpty(StreamingContent)) + { +

@(Compact ? "No messages yet" : "Start a conversation with Copilot!")

+ } + else + { + @foreach (var msg in Messages) + { + @switch (msg.MessageType) + { + case ChatMessageType.User: +
+ @if (!Compact) + { +
+ } +
+ @if (Compact) + { + You + } +
@((MarkupString)FormatUserMessage(msg.Content))
+ @if (!Compact) + { +
@msg.Timestamp.ToString("HH:mm")
+ } +
+
+ break; + + case ChatMessageType.Assistant: +
+ @if (!Compact) + { +
+ } +
+ @if (Compact) + { + AI + } +
@((MarkupString)RenderMarkdown(msg.Content))
+ @if (!Compact) + { +
@msg.Timestamp.ToString("HH:mm")
+ } +
+
+ break; + + case ChatMessageType.Reasoning: + @if (Compact) + { +
+ + @(msg.IsComplete ? "Thought" : "Thinking...") +
+ } + else + { +
+ + @if (!msg.IsCollapsed || LineCount(msg.Content) <= 5) + { +
@msg.Content
+ } + else + { +
@FirstLines(msg.Content, 3)
+ } +
+ } + break; + + case ChatMessageType.ToolCall: + @if (Compact) + { +
+ + @FormatToolName(msg.ToolName ?? "tool") @(msg.IsComplete ? (msg.IsSuccess ? "✓" : "✗") : "…") +
+ } + else if (msg.ToolName == "task_complete") + { +
+ + @(string.IsNullOrEmpty(msg.Content) || IsUnusableResult(msg.Content) ? "Task complete" : msg.Content) +
+ } + else + { +
+
+ @FormatToolName(msg.ToolName ?? "") + + @if (!msg.IsComplete) + { + Running + } + else if (msg.IsSuccess) + { + Done + } + else + { + Failed + } + +
+ @if (msg.IsComplete && !string.IsNullOrEmpty(msg.Content) && !IsUnusableResult(msg.Content)) + { + @if (GetImagePath(msg.Content) is string imgPath && FileToDataUri(imgPath) is string dataUri) + { +
+ @System.IO.Path.GetFileName(imgPath) +
+ } + else if (LineCount(msg.Content) <= 5) + { +
+
@TruncateResult(msg.Content)
+
+ } + else + { +
+ + @if (!msg.IsCollapsed) + { +
@TruncateResult(msg.Content)
+ } + else + { +
@FirstLines(msg.Content, 3)
+ } +
+ } + } +
+ } + break; + + case ChatMessageType.Error: +
+ @if (Compact) + { + + @msg.Content + } + else + { + + @msg.Content + } +
+ break; + + case ChatMessageType.System: +
+
@msg.Content
+
+ break; + } + } + + @* Current tool activity *@ + @if (!string.IsNullOrEmpty(CurrentToolName)) + { + @if (Compact) + { +
+ + @FormatToolName(CurrentToolName) … +
+ } + else + { +
+
+ @FormatToolName(CurrentToolName) + @ToolCount tool@(ToolCount != 1 ? "s" : "") +
+
+ } + } + + @* Streaming content *@ + @if (!string.IsNullOrEmpty(StreamingContent)) + { +
+ @if (!Compact) + { +
+ } +
+ @if (Compact) + { + AI + } +
@((MarkupString)RenderMarkdown(StreamingContent))
+
+
+ } + + @* Activity indicator *@ + @if (IsProcessing && string.IsNullOrEmpty(StreamingContent) && string.IsNullOrEmpty(CurrentToolName)) + { +
+ @if (Compact) + { + AI + @(string.IsNullOrEmpty(ActivityText) ? "Thinking..." : ActivityText) + } + else + { +
+
+
@(string.IsNullOrEmpty(ActivityText) ? "Thinking..." : ActivityText)
+
+ } +
+ } + } +
+ +@code { + [Parameter] public List Messages { get; set; } = new(); + [Parameter] public string StreamingContent { get; set; } = ""; + [Parameter] public string CurrentToolName { get; set; } = ""; + [Parameter] public int ToolCount { get; set; } + [Parameter] public string ActivityText { get; set; } = ""; + [Parameter] public bool IsProcessing { get; set; } + [Parameter] public bool Compact { get; set; } + + private static readonly MarkdownPipeline MdPipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions().Build(); + + private static readonly Dictionary _imageCache = new(); + private static readonly Dictionary _markdownCache = new(); + + private static readonly System.Text.RegularExpressions.Regex ImagePathRegex = new( + @"(? + { + var path = match.Value; + var dataUri = FileToDataUri(path); + if (dataUri != null) + return $"\"{System.IO.Path.GetFileName(path)}\""; + return match.Value; + }); + if (_markdownCache.Count < 500) _markdownCache[key] = html; + return html; + } + catch { return System.Net.WebUtility.HtmlEncode(content).Replace("\n", "
"); } + } + + internal static string FormatUserMessage(string content) + { + var escaped = System.Net.WebUtility.HtmlEncode(content); + return escaped.Replace("\n", "
"); + } + + internal static int LineCount(string? text) + { + if (string.IsNullOrEmpty(text)) return 0; + var count = 1; + foreach (var c in text) { if (c == '\n') count++; } + return count; + } + + internal 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++) + { + var next = text.IndexOf('\n', idx); + if (next < 0) break; + idx = next + 1; + } + if (idx <= 0 || idx >= text.Length) return text; + return text[..idx] + "…"; + } + + internal 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)); + } + + internal static string TruncateResult(string result, int maxLength = 1500) + { + if (string.IsNullOrEmpty(result) || result.Length <= maxLength) return result ?? ""; + return result[..maxLength] + "\n… (truncated)"; + } + + internal 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 readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".tiff" }; + + internal static string? GetImagePath(string? content) + { + if (string.IsNullOrWhiteSpace(content)) return null; + var trimmed = content.Trim(); + foreach (var ext in ImageExtensions) + { + var idx = trimmed.LastIndexOf(ext, StringComparison.OrdinalIgnoreCase); + if (idx < 0) continue; + var endIdx = idx + ext.Length; + var pathStart = trimmed.LastIndexOf(' ', idx) + 1; + if (pathStart < 0) pathStart = 0; + var path = trimmed[pathStart..endIdx]; + if (path.StartsWith('/') && System.IO.File.Exists(path)) + return path; + } + return null; + } + + internal static string? FileToDataUri(string path) + { + if (_imageCache.TryGetValue(path, out var cached)) return cached; + try + { + if (!System.IO.File.Exists(path)) return null; + var bytes = System.IO.File.ReadAllBytes(path); + var ext = System.IO.Path.GetExtension(path).ToLowerInvariant(); + var mime = ext switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + ".bmp" => "image/bmp", + ".tiff" or ".tif" => "image/tiff", + _ => "application/octet-stream" + }; + var dataUri = $"data:{mime};base64,{Convert.ToBase64String(bytes)}"; + _imageCache[path] = dataUri; + return dataUri; + } + catch { return null; } + } +} diff --git a/Components/ChatMessageList.razor.css b/Components/ChatMessageList.razor.css new file mode 100644 index 0000000000..797543eb53 --- /dev/null +++ b/Components/ChatMessageList.razor.css @@ -0,0 +1,298 @@ +.chat-message-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.chat-empty { + color: rgba(255,255,255,0.3); + text-align: center; + margin: auto; + font-size: 0.95rem; +} + +/* === Compact mode (dashboard cards) === */ +.chat-message-list.compact .chat-msg { + display: flex; + gap: 0.5rem; + font-size: 0.95rem; + line-height: 1.4; +} + +.chat-message-list.compact .chat-msg-role { + font-weight: 600; + flex-shrink: 0; + font-size: 0.8rem; + padding: 0.1rem 0.3rem; + border-radius: 3px; + align-self: flex-start; +} + +.chat-message-list.compact .chat-msg.user .chat-msg-role { + background: rgba(59, 130, 246, 0.3); + color: #93c5fd; +} + +.chat-message-list.compact .chat-msg.assistant .chat-msg-role { + background: rgba(72, 187, 120, 0.3); + color: #86efac; +} + +.chat-message-list.compact .chat-msg-text { + color: rgba(255,255,255,0.8); + word-break: break-word; +} + +.chat-message-list.compact .chat-msg-text.thinking { + color: rgba(255,255,255,0.4); + font-style: italic; +} + +/* === Full mode (chat page) === */ +.chat-message-list.full .chat-msg { + display: flex; + gap: 0.75rem; + flex-shrink: 0; + max-width: 100%; + padding: 0.75rem 1rem; +} + +.chat-message-list.full .chat-msg.user { + justify-content: flex-end; +} + +.chat-message-list.full .chat-msg.user .chat-msg-content { + background: rgba(59, 130, 246, 0.2); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 12px 12px 0 12px; + padding: 0.75rem 1rem; + max-width: 80%; +} + +.chat-message-list.full .chat-msg.assistant .chat-msg-content { + background: rgba(72, 187, 120, 0.1); + border: 1px solid rgba(72, 187, 120, 0.2); + border-radius: 12px 12px 12px 0; + padding: 0.75rem 1rem; + max-width: 80%; +} + +.chat-message-list.full .chat-msg-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.chat-message-list.full .chat-msg.user .chat-msg-avatar { + order: 1; + background: rgba(59, 130, 246, 0.3); +} + +.chat-message-list.full .chat-msg.assistant .chat-msg-avatar { + background: rgba(72, 187, 120, 0.3); +} + +.chat-message-list.full .chat-msg-text { + color: white; + word-break: break-word; + line-height: 1.6; +} + +.chat-message-list.full .chat-msg-text.thinking { + color: rgba(255,255,255,0.4); + font-style: italic; +} + +.chat-message-list.full .chat-msg-time { + font-size: 0.75rem; + color: rgba(255,255,255,0.3); + margin-top: 0.25rem; +} + +.chat-message-list.full .chat-msg.system { + justify-content: center; +} + +.chat-message-list.full .system-text { + font-size: 0.85rem; + color: rgba(255,255,255,0.4); + font-style: italic; + text-align: center; + padding: 0.5rem; +} + +/* Shared tool/reasoning styles for full mode */ +::deep .reasoning-block { + flex-shrink: 0; + margin: 0.25rem 1rem; + border-left: 2px solid rgba(168, 85, 247, 0.3); + padding-left: 0.75rem; +} + +::deep .reasoning-header { + background: none; + border: none; + color: rgba(168, 85, 247, 0.7); + font-size: 0.85rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; + width: 100%; +} + +::deep .reasoning-content { + font-size: 0.85rem; + color: rgba(255,255,255,0.5); + white-space: pre-wrap; + line-height: 1.5; + padding: 0.25rem 0; +} + +::deep .reasoning-content.preview { + max-height: 4.5em; + overflow: hidden; +} + +::deep .tool-card { + flex-shrink: 0; + margin: 0.25rem 1rem; + border-radius: 8px; + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.03); +} + +::deep .tool-card.running { border-color: rgba(251, 191, 36, 0.3); } +::deep .tool-card.success { border-color: rgba(72, 187, 120, 0.3); } +::deep .tool-card.error { border-color: rgba(248, 113, 113, 0.3); } + +::deep .tool-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +::deep .tool-info { color: rgba(255,255,255,0.7); display: flex; align-items: center; gap: 0.35rem; } +::deep .tool-status { display: flex; align-items: center; gap: 0.35rem; color: rgba(255,255,255,0.5); } + +::deep .tool-result-section { + margin-top: 0.5rem; + border-top: 1px solid rgba(255,255,255,0.05); + padding-top: 0.5rem; +} + +::deep .tool-result-content { + font-size: 0.8rem; + color: rgba(255,255,255,0.5); + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; + margin: 0; +} + +::deep .tool-result-toggle { + background: none; + border: none; + color: rgba(59, 130, 246, 0.7); + font-size: 0.8rem; + cursor: pointer; + padding: 0; +} + +::deep .tool-image-result img { + max-width: 100%; + max-height: 400px; + border-radius: 6px; +} + +::deep .task-complete-card { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.25rem 1rem; + padding: 0.75rem 1rem; + background: rgba(72, 187, 120, 0.1); + border: 1px solid rgba(72, 187, 120, 0.3); + border-radius: 8px; + color: #86efac; + font-size: 0.95rem; +} + +::deep .error-card { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.25rem 1rem; + padding: 0.5rem 0.75rem; + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: 8px; + color: #fca5a5; + font-size: 0.9rem; +} + +::deep .markdown-body { + color: white; +} + +::deep .markdown-body h1, ::deep .markdown-body h2, ::deep .markdown-body h3 { + margin: 0.5em 0 0.25em; + border: none; + color: white; +} + +::deep .markdown-body code { + background: rgba(255,255,255,0.1); + padding: 0.15em 0.3em; + border-radius: 3px; + font-size: 0.9em; +} + +::deep .markdown-body pre { + background: rgba(0,0,0,0.3); + padding: 0.75rem; + border-radius: 6px; + overflow-x: auto; +} + +::deep .markdown-body pre code { + background: none; + padding: 0; +} + +::deep .markdown-body a { color: #60a5fa; } +::deep .markdown-body blockquote { + border-left: 3px solid rgba(255,255,255,0.2); + margin: 0.5rem 0; + padding: 0 0.75rem; + color: rgba(255,255,255,0.6); +} + +::deep .markdown-body ul, ::deep .markdown-body ol { + padding-left: 1.5rem; + margin: 0.25rem 0; +} + +::deep .markdown-body table { + border-collapse: collapse; + width: 100%; +} + +::deep .markdown-body th, ::deep .markdown-body td { + border: 1px solid rgba(255,255,255,0.15); + padding: 0.4rem 0.6rem; + text-align: left; +} + +@keyframes spin { to { transform: rotate(360deg); } } +::deep .spin { animation: spin 1s linear infinite; } diff --git a/Components/Layout/SessionSidebar.razor b/Components/Layout/SessionSidebar.razor index f3cec441b9..8dcc743bce 100644 --- a/Components/Layout/SessionSidebar.razor +++ b/Components/Layout/SessionSidebar.razor @@ -125,8 +125,11 @@ else } else { + var sessionIndex = 0; @foreach (var session in sessions) { + sessionIndex++; + var idx = sessionIndex; var cssClass = session.Name == CopilotService.ActiveSessionName ? "active" : completedSessions.Contains(session.Name) ? "completed" : session.IsProcessing ? "busy" : ""; @@ -134,6 +137,10 @@ else @onclick="() => SelectSession(session.Name)">
+ @if (idx <= 9) + { + @idx + } @if (completedSessions.Contains(session.Name)) { diff --git a/Components/Layout/SessionSidebar.razor.css b/Components/Layout/SessionSidebar.razor.css index 902a24b1a0..d9e6f75d33 100644 --- a/Components/Layout/SessionSidebar.razor.css +++ b/Components/Layout/SessionSidebar.razor.css @@ -253,6 +253,21 @@ color: #fbbf24; } +.shortcut-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.2em; + height: 1.2em; + font-size: 0.7rem; + font-weight: 600; + background: rgba(255,255,255,0.12); + border: 1px solid rgba(255,255,255,0.2); + border-radius: 3px; + color: rgba(255,255,255,0.5); + flex-shrink: 0; +} + .session-meta { font-size: 0.95rem; color: rgba(255,255,255,0.5); diff --git a/Components/Pages/Dashboard.razor b/Components/Pages/Dashboard.razor index ac8ba965ed..e776464790 100644 --- a/Components/Pages/Dashboard.razor +++ b/Components/Pages/Dashboard.razor @@ -3,6 +3,7 @@ @using AutoPilot.App.Models @inject CopilotService CopilotService @inject IJSRuntime JS +@inject NavigationManager Nav @implements IDisposable
@@ -26,7 +27,7 @@ var cardClass = session.IsProcessing ? "processing" : isCompleted ? "completed" : "idle";
-
+

@session.Name

@@ -34,67 +35,18 @@
+
@{ - var lastMessages = session.History.ToList().TakeLast(6).ToList(); - } - @if (!lastMessages.Any()) - { -

No messages yet

- } - else - { - @foreach (var msg in lastMessages) - { - @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) - { - @if (streamingBySession.TryGetValue(session.Name, out var streaming) && !string.IsNullOrEmpty(streaming)) - { -
- AI - @streaming -
- } -
- AI - @(activityBySession.TryGetValue(session.Name, out var act) && !string.IsNullOrEmpty(act) ? act : "Thinking...") -
+ List lastMessages; + try { lastMessages = session.History.TakeLast(6).ToList(); } + catch { lastMessages = new(); } } +
@@ -140,17 +92,30 @@ { if (firstRender) { - // Set up Enter key handlers for all card inputs + // Set up Enter and Tab key handlers for card inputs await JS.InvokeVoidAsync("eval", @" document.addEventListener('keydown', function(e) { - if (e.key === 'Enter' && e.target.closest('.card-input input')) { + var isCardInput = e.target.matches && e.target.matches('.card-input input'); + var isBroadcast = e.target.id === 'broadcastInput'; + if (e.key === 'Enter' && isCardInput) { e.preventDefault(); e.target.closest('.card-input').querySelector('button')?.click(); } - if (e.key === 'Enter' && e.target.id === 'broadcastInput') { + if (e.key === 'Enter' && isBroadcast) { e.preventDefault(); document.querySelector('.broadcast-input button')?.click(); } + // Tab / Shift+Tab to cycle between card inputs only when focused on one + if (e.key === 'Tab' && (isCardInput || isBroadcast)) { + e.preventDefault(); + e.stopImmediatePropagation(); + var inputs = Array.from(document.querySelectorAll('.card-input input:not(:disabled), #broadcastInput')); + if (inputs.length < 2) return; + var idx = inputs.indexOf(e.target); + if (idx < 0) idx = 0; + idx = e.shiftKey ? (idx - 1 + inputs.length) % inputs.length : (idx + 1) % inputs.length; + inputs[idx].focus(); + } }); "); } @@ -248,11 +213,11 @@ await CopilotService.CloseSessionAsync(sessionName); } - private string Truncate(string text, int maxLength) + private void GoToSession(string sessionName) { - if (string.IsNullOrEmpty(text)) return ""; - text = text.Replace("\n", " ").Replace("\r", ""); - return text.Length > maxLength ? text[..maxLength] + "..." : text; + CopilotService.SwitchSession(sessionName); + CopilotService.SaveUiState("/", sessionName); + Nav.NavigateTo("/"); } public void Dispose() diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index 7f6ae0a35a..15617da4cc 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -1,7 +1,6 @@ @page "/" @using AutoPilot.App.Services @using AutoPilot.App.Models -@using Markdig @inject CopilotService CopilotService @inject IJSRuntime JS @inject NavigationManager Nav @@ -30,7 +29,9 @@ else {
+

@activeSession.Name

+ @activeSession.Model @if (activeSession.SessionId != null) { @@ -43,165 +44,19 @@
- @if (!activeSession.History.Any() && string.IsNullOrEmpty(streamingContent)) + @if (HasMoreMessages) { -
-

Start a conversation with Copilot!

-
- } - else - { - @if (HasMoreMessages) - { - - } - - @foreach (var msg in VisibleHistory) - { - @switch (msg.MessageType) - { - case ChatMessageType.User: -
-
-
-
@((MarkupString)FormatUserMessage(msg.Content))
-
@msg.Timestamp.ToString("HH:mm")
-
-
- break; - - case ChatMessageType.Assistant: -
-
-
-
@((MarkupString)RenderMarkdown(msg.Content))
-
@msg.Timestamp.ToString("HH:mm")
-
-
- break; - - case ChatMessageType.Reasoning: -
- - @if (!msg.IsCollapsed || LineCount(msg.Content) <= 5) - { -
@msg.Content
- } - else - { -
@FirstLines(msg.Content, 3)
- } -
- break; - - case ChatMessageType.ToolCall: - @if (msg.ToolName == "task_complete") - { -
- - @(string.IsNullOrEmpty(msg.Content) || IsUnusableResult(msg.Content) ? "Task complete" : msg.Content) -
- } - else - { -
-
- @FormatToolName(msg.ToolName ?? "") - - @if (!msg.IsComplete) - { - Running - } - else if (msg.IsSuccess) - { - Done - } - else - { - Failed - } - -
- @if (msg.IsComplete && !string.IsNullOrEmpty(msg.Content) && !IsUnusableResult(msg.Content)) - { - @if (GetImagePath(msg.Content) is string imgPath && FileToDataUri(imgPath) is string dataUri) - { -
- @System.IO.Path.GetFileName(imgPath) -
- } - else if (LineCount(msg.Content) <= 5) - { -
-
@TruncateResult(msg.Content)
-
- } - else - { -
- - @if (!msg.IsCollapsed) - { -
@TruncateResult(msg.Content)
- } - else - { -
@FirstLines(msg.Content, 3)
- } -
- } - } -
- } - break; - - case ChatMessageType.Error: -
- - @msg.Content -
- break; - - case ChatMessageType.System: -
-
@msg.Content
-
- break; - } - } - - @* Show current tool activity inline *@ - @if (!string.IsNullOrEmpty(currentToolName)) - { -
-
- @FormatToolName(currentToolName) - @currentTurnToolCount tool@(currentTurnToolCount != 1 ? "s" : "") -
-
- } - - @if (!string.IsNullOrEmpty(streamingContent)) - { -
-
-
-
@((MarkupString)RenderMarkdown(streamingContent))
-
-
- } + } +
@if (!string.IsNullOrEmpty(lastError)) @@ -309,10 +164,6 @@
@code { - private static readonly MarkdownPipeline MdPipeline = new MarkdownPipelineBuilder() - .UseAdvancedExtensions() - .Build(); - private AgentSessionInfo? activeSession; private string userInput = ""; private bool isPlanMode = false; @@ -326,22 +177,28 @@ private bool showDebugPanel = false; private ElementReference messagesContainer; private ElementReference textareaRef; + private DotNetObjectReference? _dotNetRef; private bool _needsRedirect; private string? _redirectTo; private bool _needsScroll = true; - private bool shouldPreventDefault; private int visibleMessageCount = 50; - private static readonly Dictionary _imageCache = new(); - private static readonly Dictionary _markdownCache = new(); private List VisibleHistory { get { if (activeSession == null) return new(); - var all = activeSession.History; - if (all.Count <= visibleMessageCount) return all.ToList(); - return all.Skip(all.Count - visibleMessageCount).ToList(); + try + { + var all = activeSession.History; + if (all.Count <= visibleMessageCount) return all.ToList(); + return all.Skip(all.Count - visibleMessageCount).ToList(); + } + catch (InvalidOperationException) + { + // Collection modified during enumeration — retry with snapshot + return activeSession.History.ToArray().ToList(); + } } } @@ -385,6 +242,11 @@ await ForceScrollToBottom(); } try { await JS.InvokeVoidAsync("setupTextareaEnterHandler", textareaRef); } catch { } + if (_dotNetRef == null) + { + _dotNetRef = DotNetObjectReference.Create(this); + } + try { await JS.InvokeVoidAsync("setupTabNavigation", _dotNetRef); } catch { } } private string currentToolName = ""; @@ -508,6 +370,10 @@ { if (sessionName == CopilotService.ActiveSessionName) { + // Suppress cancellation errors (expected during app restart/abort) + if (error.Contains("cancel", StringComparison.OrdinalIgnoreCase) || + error.Contains("TaskCanceled", StringComparison.OrdinalIgnoreCase)) + return; lastError = error; InvokeAsync(StateHasChanged); } @@ -555,14 +421,34 @@ }); } + private string _lastKeyDebug = ""; + private async Task HandleKeyDown(KeyboardEventArgs e) { + _lastKeyDebug = $"Key:{e.Key} Code:{e.Code} Meta:{e.MetaKey} Ctrl:{e.CtrlKey} Shift:{e.ShiftKey}"; + if (e.Key == "Enter" && !e.ShiftKey) { await SendMessage(); - // Force-clear via JS to remove any residual newline await JS.InvokeVoidAsync("eval", "document.querySelector('.input-row textarea').value = ''"); } + // Tab / Shift+Tab to cycle sessions + if (e.Key == "Tab") + { + CycleSession(e.ShiftKey); + } + // Cmd+1 through Cmd+9 to switch sessions + if (e.MetaKey && e.Key.Length == 1 && e.Key[0] >= '1' && e.Key[0] <= '9') + { + var sessions = CopilotService.GetAllSessions().ToList(); + var idx = (int)(e.Key[0] - '1'); + if (idx < sessions.Count) + { + CopilotService.SwitchSession(sessions[idx].Name); + CopilotService.SaveUiState("/", sessions[idx].Name); + StateHasChanged(); + } + } } private async Task StopResponse() @@ -598,6 +484,14 @@ await CopilotService.SendPromptAsync(activeSession.Name, prompt); streamingContent = ""; } + catch (TaskCanceledException) + { + // Expected during abort or app restart — not an error + } + catch (OperationCanceledException) + { + // Expected during abort or app restart — not an error + } catch (Exception ex) { lastError = $"Error: {ex.Message}"; @@ -640,132 +534,6 @@ return text.Length > maxLen ? text[..maxLen] + "…" : text; } - private static readonly System.Text.RegularExpressions.Regex ImagePathRegex = new( - @"(? - { - var path = match.Value; - var dataUri = FileToDataUri(path); - if (dataUri != null) - return $"\"{System.IO.Path.GetFileName(path)}\""; - return match.Value; - }); - - // Only cache completed (non-streaming) content — limit cache size - if (_markdownCache.Count < 500) - _markdownCache[key] = html; - - return html; - } - catch { return System.Net.WebUtility.HtmlEncode(content).Replace("\n", "
"); } - } - - private static string FormatUserMessage(string content) - { - var escaped = System.Net.WebUtility.HtmlEncode(content); - return escaped.Replace("\n", "
"); - } - - private static int LineCount(string? text) - { - 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++) - { - var next = text.IndexOf('\n', idx); - if (next < 0) break; - idx = next + 1; - } - 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 readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".tiff" }; - - private static string? GetImagePath(string? content) - { - if (string.IsNullOrWhiteSpace(content)) return null; - var trimmed = content.Trim(); - foreach (var ext in ImageExtensions) - { - var idx = trimmed.LastIndexOf(ext, StringComparison.OrdinalIgnoreCase); - if (idx < 0) continue; - var endIdx = idx + ext.Length; - var pathStart = trimmed.LastIndexOf(' ', idx) + 1; - if (pathStart < 0) pathStart = 0; - var path = trimmed[pathStart..endIdx]; - if (path.StartsWith('/') && System.IO.File.Exists(path)) - return path; - } - return null; - } - - private static string? FileToDataUri(string path) - { - if (_imageCache.TryGetValue(path, out var cached)) return cached; - try - { - if (!System.IO.File.Exists(path)) return null; - var bytes = System.IO.File.ReadAllBytes(path); - var ext = System.IO.Path.GetExtension(path).ToLowerInvariant(); - var mime = ext switch - { - ".png" => "image/png", - ".jpg" or ".jpeg" => "image/jpeg", - ".gif" => "image/gif", - ".webp" => "image/webp", - ".svg" => "image/svg+xml", - ".bmp" => "image/bmp", - ".tiff" or ".tif" => "image/tiff", - _ => "application/octet-stream" - }; - var dataUri = $"data:{mime};base64,{Convert.ToBase64String(bytes)}"; - _imageCache[path] = dataUri; - return dataUri; - } - catch { return null; } - } private static string FormatTokenCount(int count) => count >= 1000 ? $"{count / 1000}k" : count.ToString(); @@ -782,8 +550,32 @@ catch { } } + [JSInvokable] + public void CycleSession(bool reverse) + { + var sessions = CopilotService.GetAllSessions().OrderBy(s => s.Name).ToList(); + if (sessions.Count < 2) return; + + var currentName = CopilotService.ActiveSessionName; + var idx = sessions.FindIndex(s => s.Name == currentName); + if (idx < 0) idx = 0; + + idx = reverse + ? (idx - 1 + sessions.Count) % sessions.Count + : (idx + 1) % sessions.Count; + + CopilotService.SwitchSession(sessions[idx].Name); + CopilotService.SaveUiState("/", sessions[idx].Name); + streamingContent = ""; + currentToolName = ""; + currentIntent = ""; + visibleMessageCount = 50; + InvokeAsync(StateHasChanged); + } + public void Dispose() { + _dotNetRef?.Dispose(); CopilotService.OnStateChanged -= RefreshState; CopilotService.OnContentReceived -= HandleContentReceived; CopilotService.OnSessionComplete -= HandleSessionComplete; diff --git a/Components/Pages/Home.razor.css b/Components/Pages/Home.razor.css index 000f2d3a57..548538e1bc 100644 --- a/Components/Pages/Home.razor.css +++ b/Components/Pages/Home.razor.css @@ -58,6 +58,22 @@ .chat-header h2 { margin: 0; font-size: 1.6rem; } +.session-nav-btn { + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.15); + border-radius: 6px; + color: rgba(255,255,255,0.6); + cursor: pointer; + font-size: 0.9rem; + padding: 0.25rem 0.5rem; + transition: all 0.15s; +} +.session-nav-btn:hover { + background: rgba(59,130,246,0.3); + color: white; + border-color: rgba(59,130,246,0.5); +} + .model-badge { padding: 0.25rem 0.5rem; background: rgba(59, 130, 246, 0.2); diff --git a/MauiProgram.cs b/MauiProgram.cs index e10defd842..e60cff0401 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -37,6 +37,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); diff --git a/Models/AgentSessionInfo.cs b/Models/AgentSessionInfo.cs index ae73e42a62..ce4fbac39a 100644 --- a/Models/AgentSessionInfo.cs +++ b/Models/AgentSessionInfo.cs @@ -15,4 +15,7 @@ public class AgentSessionInfo // For resumed sessions public string? SessionId { get; set; } public bool IsResumed { get; init; } + + // Timestamp of last state change (message received, turn end, etc.) + public DateTime LastUpdatedAt { get; set; } = DateTime.Now; } diff --git a/Services/CopilotService.cs b/Services/CopilotService.cs index 3b8554811b..790461b794 100644 --- a/Services/CopilotService.cs +++ b/Services/CopilotService.cs @@ -288,6 +288,78 @@ private PersistedSessionInfo CreatePersistedSessionInfo(DirectoryInfo di) }; } + /// + /// Check if a session was still processing when the app last closed + /// + private bool IsSessionStillProcessing(string sessionId) + { + var eventsFile = Path.Combine(SessionStatePath, sessionId, "events.jsonl"); + if (!File.Exists(eventsFile)) return false; + + try + { + string? lastLine = null; + foreach (var line in File.ReadLines(eventsFile)) + { + if (!string.IsNullOrWhiteSpace(line)) + lastLine = line; + } + if (lastLine == null) return false; + + using var doc = JsonDocument.Parse(lastLine); + var type = doc.RootElement.GetProperty("type").GetString(); + + var activeEvents = new[] { + "assistant.turn_start", "tool.execution_start", + "tool.execution_progress", "assistant.message_delta", + "assistant.reasoning", "assistant.reasoning_delta", + "assistant.intent" + }; + return activeEvents.Contains(type); + } + catch { return false; } + } + + /// + /// Get the last tool name and assistant message from events.jsonl for status display + /// + private (string? lastTool, string? lastContent) GetLastSessionActivity(string sessionId) + { + var eventsFile = Path.Combine(SessionStatePath, sessionId, "events.jsonl"); + if (!File.Exists(eventsFile)) return (null, null); + + try + { + string? lastTool = null; + string? lastContent = null; + + foreach (var line in File.ReadLines(eventsFile)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + var type = root.GetProperty("type").GetString(); + + if (type == "tool.execution_start" && root.TryGetProperty("data", out var toolData)) + { + if (toolData.TryGetProperty("toolName", out var tn)) + lastTool = tn.GetString(); + } + else if (type == "assistant.message" && root.TryGetProperty("data", out var msgData)) + { + if (msgData.TryGetProperty("content", out var content)) + { + var c = content.GetString(); + if (!string.IsNullOrEmpty(c)) + lastContent = c; + } + } + } + return (lastTool, lastContent); + } + catch { return (null, null); } + } + /// /// Load conversation history from events.jsonl /// @@ -428,20 +500,12 @@ public async Task ResumeSessionAsync(string sessionId, string if (_sessions.ContainsKey(displayName)) throw new InvalidOperationException($"Session '{displayName}' already exists."); - // Load history: try DB first, fall back to parsing events.jsonl - List history; - if (await _chatDb.HasMessagesAsync(sessionId)) - { - history = await _chatDb.GetAllMessagesAsync(sessionId); - } - else + // Load history: always parse events.jsonl as source of truth, then sync to DB + List history = LoadHistoryFromDisk(sessionId); + if (history.Count > 0) { - history = LoadHistoryFromDisk(sessionId); - if (history.Count > 0) - { - // Persist to DB for future fast loading - await _chatDb.BulkInsertAsync(sessionId, history); - } + // Replace DB contents with fresh parse (events.jsonl may have grown since last DB sync) + await _chatDb.BulkInsertAsync(sessionId, history); } // Resume the session using the SDK @@ -463,8 +527,21 @@ public async Task ResumeSessionAsync(string sessionId, string } info.MessageCount = info.History.Count; - // Add reconnection indicator - info.History.Add(ChatMessage.SystemMessage("🔄 Session reconnected")); + // Add reconnection indicator with status context + var reconnectMsg = "🔄 Session reconnected"; + var isStillProcessing = IsSessionStillProcessing(sessionId); + if (isStillProcessing) + { + var (lastTool, lastContent) = GetLastSessionActivity(sessionId); + if (!string.IsNullOrEmpty(lastTool)) + reconnectMsg += $" — running {lastTool}"; + if (!string.IsNullOrEmpty(lastContent)) + reconnectMsg += $"\n💬 Last: {(lastContent.Length > 100 ? lastContent[..100] + "…" : lastContent)}"; + } + info.History.Add(ChatMessage.SystemMessage(reconnectMsg)); + + // Set processing state if session was mid-turn when app died + info.IsProcessing = isStillProcessing; var state = new SessionState { @@ -472,6 +549,13 @@ public async Task ResumeSessionAsync(string sessionId, string Info = info }; + // If still processing, set up ResponseCompletion so events flow properly + if (isStillProcessing) + { + state.ResponseCompletion = new TaskCompletionSource(); + Debug($"Session '{displayName}' is still processing (was mid-turn when app restarted)"); + } + copilotSession.On(evt => HandleSessionEvent(state, evt)); if (!_sessions.TryAdd(displayName, state)) @@ -609,11 +693,13 @@ void Invoke(Action action) { state.LastMessageId = msgId; state.CurrentResponse.Append(msgContent); + state.Info.LastUpdatedAt = DateTime.Now; Invoke(() => OnContentReceived?.Invoke(sessionName, msgContent)); } break; case ToolExecutionStartEvent toolStart: + if (toolStart.Data == null) break; var startToolName = toolStart.Data.ToolName ?? "unknown"; var startCallId = toolStart.Data.ToolCallId ?? ""; if (!FilteredTools.Contains(startToolName)) @@ -627,6 +713,7 @@ void Invoke(Action action) break; case ToolExecutionCompleteEvent toolDone: + if (toolDone.Data == null) break; var completeCallId = toolDone.Data.ToolCallId ?? ""; var completeToolName = toolDone.Data?.GetType().GetProperty("ToolName")?.GetValue(toolDone.Data)?.ToString(); var resultStr = FormatToolResult(toolDone.Data.Result); @@ -778,6 +865,7 @@ private void CompleteResponse(SessionState state) state.ResponseCompletion?.TrySetResult(response); state.CurrentResponse.Clear(); state.Info.IsProcessing = false; + state.Info.LastUpdatedAt = DateTime.Now; OnStateChanged?.Invoke(); // Fire completion notification diff --git a/Services/KeyCommandService.cs b/Services/KeyCommandService.cs new file mode 100644 index 0000000000..a305c6e879 --- /dev/null +++ b/Services/KeyCommandService.cs @@ -0,0 +1,11 @@ +namespace AutoPilot.App.Services; + +/// +/// Bridges native Mac Catalyst key commands to Blazor components. +/// +public class KeyCommandService +{ + public event Action? OnCycleSession; // bool = reverse (shift+tab) + + public void CycleSession(bool reverse) => OnCycleSession?.Invoke(reverse); +} diff --git a/wwwroot/index.html b/wwwroot/index.html index 3fd494c359..cc8d2859af 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -30,6 +30,18 @@ Notification.requestPermission(); } + // Global keyboard listener — registered immediately on load + document.addEventListener('keydown', function(e) { + // Ctrl+] for next session, Ctrl+[ for previous + if (e.ctrlKey && e.code === 'BracketRight' && window._tabDotNetRef) { + e.preventDefault(); + window._tabDotNetRef.invokeMethodAsync('CycleSession', false); + } else if (e.ctrlKey && e.code === 'BracketLeft' && window._tabDotNetRef) { + e.preventDefault(); + window._tabDotNetRef.invokeMethodAsync('CycleSession', true); + } + }, true); + window.setupTextareaEnterHandler = function(element) { if (!element || element._enterHandlerSet) return; element._enterHandlerSet = true; @@ -37,6 +49,12 @@ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); } + // Tab / Shift+Tab to cycle sessions + if (e.key === 'Tab' && window._tabDotNetRef) { + e.preventDefault(); + e.stopPropagation(); + window._tabDotNetRef.invokeMethodAsync('CycleSession', !e.shiftKey); + } }); }; @@ -84,6 +102,10 @@ return result; }; + window.setupTabNavigation = function(dotnetRef) { + window._tabDotNetRef = dotnetRef; + }; + window.showNotification = function(sessionName, summary) { // Play a sound try {