From 557022174bf40394aa06ef050b0c20e520931e59 Mon Sep 17 00:00:00 2001 From: DevMando Date: Sun, 30 Aug 2026 19:07:20 -0700 Subject: [PATCH] Collapse completed work into transcript rollups --- CHANGELOG.md | 21 +++ .../Assets/web/transcript/transcript.css | 26 ++++ .../Assets/web/transcript/transcript.js | 134 ++++++++++++++++++ .../Controls/ChatTabView.Transcript.cs | 43 +++++- .../Controls/ChatTabView.xaml.cs | 8 ++ src/MandoCode.Desktop/MainWindow.Mcp.cs | 1 + .../Services/TranscriptHtmlBuilder.cs | 40 ++++-- .../Services/TranscriptWriter.cs | 6 + .../Services/WinUiApprovalService.cs | 45 +++--- .../ViewModels/ChatController.cs | 18 ++- 10 files changed, 301 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48445ae..1a18430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,27 @@ the engine generation, so it moves 0.14.1 → 0.15.0. replacement is shown for review, and execution resumes only after approval. ### Changed +- **Completed turns now keep routine activity out of the conversation flow.** File operations, + tool calls, and routine connection progress remain visible while work is running, + then fold into an expandable Activity section when it completes. Assistant replies, warnings, + errors, pending approvals, plans, update notices, and session-restore notices remain visible. + Restored activity starts collapsed so old operational detail does not look like fresh work. +- **Long completed changes now have one Work completed rollup.** Related tool activity, diffs, + commands, and approval outcomes collapse together after a turn ends, with the number of files + changed, line totals, distinct approval states, and commands shown in the summary. Expanding it + preserves the original sequence and individual controls. Auto-approved deletions and MCP tool + requests stay part of that routine sequence; destructive warnings remain visible only while a + manual decision is needed. +- **Completed approval notices collapse into a compact state card.** Successful approvals and + auto-approval notices keep their full existing appearance while work is active, then summarize + each distinct state once (for example, `✅ ⚠️`) with the original cards available on expand. + Approval and tool-activity summaries share the same compact card and rotating chevron. Pending + prompts, denials, and approval errors remain visible. +- **Restored Desktop sessions no longer repeat the success message for conversation memory.** The + restored transcript is already visible, so full memory recovery is silent. Desktop calls out only + reduced-memory restores and unavailable conversation memory. +- **`@` directory references now render as a normal List operation.** Missing references use a + clear warning instead of raw `[Directory]` or `[Not found]` parser-style labels. - **Automatic plans now start for the work that actually benefits from them.** Desktop recognizes explicit checklists, cross-cutting changes, and multiple deliverables instead of treating a long message as complex. Questions, research, explanations, and narrow edits stay conversational, and diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css index ff912f4..fd9bde5 100644 --- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css +++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css @@ -515,6 +515,32 @@ details.op-group summary { color: var(--dim); font-size: 12px; cursor: pointer; user-select: none; } details.op-group summary:hover { color: var(--fg); } details.op-group > .op { margin-left: 16px; } + details.activity-group, details.approval-activity-group, details.work-group { margin: 4px 0; } + details.activity-group:not([open]), details.approval-activity-group:not([open]), + details.work-group:not([open]) { width: fit-content; } + details.activity-group > summary, details.approval-activity-group > summary, details.work-group > summary { + display: flex; align-items: center; gap: 6px; width: fit-content; list-style: none; + padding: 4px 8px; color: var(--fg); background: var(--panel); border: 1px solid var(--border); + border-radius: 7px; cursor: pointer; user-select: none; font-size: 12px; line-height: 18px; } + details.activity-group > summary::-webkit-details-marker, + details.approval-activity-group > summary::-webkit-details-marker, + details.work-group > summary::-webkit-details-marker { display: none; } + details.activity-group > summary::before, details.approval-activity-group > summary::before, + details.work-group > summary::before { + content: '›'; color: var(--dim); font-size: 17px; line-height: 14px; + transform-origin: center; transition: transform 120ms ease; } + details.activity-group[open] > summary::before, + details.approval-activity-group[open] > summary::before, + details.work-group[open] > summary::before { transform: rotate(90deg); } + details.activity-group > summary:hover, details.approval-activity-group > summary:hover, + details.work-group > summary:hover { + border-color: var(--accent); } + details.activity-group[open] > summary, details.approval-activity-group[open] > summary { + margin-bottom: 4px; } + details.work-group[open] > summary { margin-bottom: 6px; } + details.work-group > summary { font-weight: 600; border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } + details.approval-activity-group > summary { + font-size: 14px; font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; } /* Jump-to-bottom pill — shows when scrolled away from the live end of the chat. */ #jump-pill { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%); diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js index e70c087..f60f9f8 100644 --- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js +++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js @@ -6,6 +6,135 @@ const n = d.querySelectorAll(':scope > .op').length; d.querySelector('summary').textContent = '⚙ ' + n + ' operation' + (n === 1 ? '' : 's'); } + // Activity renders through the original append path while work is running. Only the explicit + // completion signal wraps finished activity, so live operation cards look and behave exactly as + // they did before completed-turn collapsing existed. + function activitySummary(d) { + const ops = d.querySelectorAll('.op').length; + const tools = d.querySelectorAll('.tool-pill').length; + const status = d.querySelectorAll('.notice-card.activity-item').length; + const parts = []; + if (ops) parts.push(ops + ' operation' + (ops === 1 ? '' : 's')); + if (tools) parts.push(tools + ' tool call' + (tools === 1 ? '' : 's')); + if (status) parts.push(status + ' update' + (status === 1 ? '' : 's')); + d.querySelector('summary').textContent = 'Activity' + (parts.length ? ' · ' + parts.join(' · ') : ''); + } + function completeActivity() { + const items = Array.from(log.querySelectorAll('.activity-item:not([data-activity-completed])')); + const tops = []; + items.forEach(function (item) { + item.setAttribute('data-activity-completed', '1'); + let top = item; + while (top.parentElement && top.parentElement !== log) top = top.parentElement; + if (top.parentElement === log && tops.indexOf(top) < 0) tops.push(top); + }); + + let run = []; + function flush() { + if (!run.length) return; + const d = document.createElement('details'); + d.className = 'activity-group'; + d.appendChild(document.createElement('summary')); + log.insertBefore(d, run[0]); + run.forEach(function (node) { d.appendChild(node); }); + activitySummary(d); + run = []; + } + + tops.forEach(function (top) { + if (run.length && run[run.length - 1].nextElementSibling !== top) flush(); + run.push(top); + }); + flush(); + } + function approvalActivitySummary(d) { + const icons = []; + d.querySelectorAll('.approval-activity-item[data-activity-icon]').forEach(function (item) { + const icon = item.getAttribute('data-activity-icon'); + if (icon && icons.indexOf(icon) < 0) icons.push(icon); + }); + const summary = d.querySelector('summary'); + summary.textContent = icons.join(' '); + summary.title = 'Approval activity'; + summary.setAttribute('aria-label', 'Approval activity: ' + icons.join(' ')); + } + function completeApprovalActivity() { + const items = Array.from(log.querySelectorAll( + '.approval-activity-item:not([data-approval-activity-completed])')); + const tops = []; + items.forEach(function (item) { + item.setAttribute('data-approval-activity-completed', '1'); + let top = item; + while (top.parentElement && top.parentElement !== log) top = top.parentElement; + if (top.parentElement === log && tops.indexOf(top) < 0) tops.push(top); + }); + + let run = []; + function flush() { + if (!run.length) return; + const d = document.createElement('details'); + d.className = 'approval-activity-group'; + d.appendChild(document.createElement('summary')); + log.insertBefore(d, run[0]); + run.forEach(function (node) { d.appendChild(node); }); + approvalActivitySummary(d); + run = []; + } + + tops.forEach(function (top) { + if (run.length && run[run.length - 1].nextElementSibling !== top) flush(); + run.push(top); + }); + flush(); + } + // Finished work commonly alternates between a tool/activity card, its resulting diff or + // command, and an approval result. Keep that complete sequence together once the turn is + // over, without changing the individual cards or how they stream while the work is active. + function isCompletedWork(node) { + return !node.hasAttribute('data-work-completed') && + (node.matches('details.activity-group, details.approval-activity-group') || + node.matches('.panel[data-work-kind]')); + } + function workSummary(d) { + const files = d.querySelectorAll('.panel[data-work-kind="diff"]').length; + const additions = d.querySelectorAll('.d-add').length; + const deletions = d.querySelectorAll('.d-rem').length; + const commands = d.querySelectorAll('.panel[data-work-kind="command"]').length; + const icons = []; + d.querySelectorAll('.approval-activity-item[data-activity-icon]').forEach(function (item) { + const icon = item.getAttribute('data-activity-icon'); + if (icon && icons.indexOf(icon) < 0) icons.push(icon); + }); + + const parts = []; + if (files) parts.push(files + ' file' + (files === 1 ? '' : 's') + ' changed'); + if (additions || deletions) parts.push('+' + additions + ' / −' + deletions); + if (icons.length) parts.push(icons.join(' ')); + if (commands) parts.push(commands + ' command' + (commands === 1 ? '' : 's')); + d.querySelector('summary').textContent = 'Work completed' + + (parts.length ? ' · ' + parts.join(' · ') : ''); + } + function completeWorkRollups() { + let run = []; + function flush() { + if (run.length < 2) return run = []; + const d = document.createElement('details'); + d.className = 'work-group'; + d.appendChild(document.createElement('summary')); + log.insertBefore(d, run[0]); + run.forEach(function (node) { + node.setAttribute('data-work-completed', '1'); + d.appendChild(node); + }); + workSummary(d); + run = []; + } + Array.from(log.children).forEach(function (node) { + if (isCompletedWork(node)) run.push(node); + else flush(); + }); + flush(); + } function placeChild(c) { if (c.nodeType !== 1) { log.appendChild(c); return; } if (c.classList.contains('op')) { @@ -387,6 +516,11 @@ if (nearBottom) window.scrollTo(0, document.body.scrollHeight); updatePill(); }; + window.__completeActivity = function () { + completeActivity(); + completeApprovalActivity(); + completeWorkRollups(); + }; window.__clear = function () { log.innerHTML = ''; updatePill(); }; document.addEventListener('click', function (e) { diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs index d8efe3a..63e9bd0 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs @@ -21,6 +21,7 @@ public sealed partial class ChatTabView // ============================================================ private bool _journalRestored; + private bool _pendingActivityCompletion; /// Replays this session's journaled transcript into the fresh WebView — via /// ExecuteScript directly, NOT through TranscriptWriter (that would re-journal every @@ -47,6 +48,7 @@ private async Task RestoreJournaledTranscriptAsync() } } if (chunk.Length > 0) await AppendRawAsync(chunk.ToString()); + await CompleteTranscriptActivityAsync(); // Restored work is history, so it starts collapsed. // Divider goes through AppendRawAsync too — journaling it would stack one // divider per relaunch. Memory restore happens LATER (RestoreConversationMemoryAsync, @@ -82,10 +84,8 @@ public async Task RestoreConversationMemoryAsync() var restored = await Task.Run(() => Session.Ai.TryRestoreHistoryJson(historyJson)); if (restored > 0) { - await AppendRawAsync(_html.StatusCard( - "Conversation memory restored", - $"The agent remembers this session ({restored} messages).", - "success")); + // The preceding “Previous session restored” card is enough on Desktop: the + // replay is already visible, and a second success card only repeats it. return; } } @@ -114,17 +114,25 @@ await AppendRawAsync(_html.StatusCard( _controller.ArmRestoredConversation( "From \"your previous session in this tab\" (verbatim excerpt, not a recap):\n" + sb.ToString().TrimEnd()); - await AppendRawAsync(_html.Dim( - "Context re-armed — the agent will be briefed on this conversation with your next message.")); + await AppendRawAsync(_html.StatusCard( + "Conversation context will be re-briefed", + "The agent will receive recent context with your next message.", + "warning")); return; } // 3) Transcript was replayed but no memory of any kind exists — say so to the model. if (_replayedBlockCount > 0) + { _controller.NoteWorkspaceEvent( "This tab was restored from a previous session. The transcript the user sees above is a replay " + "for their benefit; it is NOT in your context and you have no memory of it. If the user refers " + "to earlier work, say so honestly and re-read files instead of guessing."); + await AppendRawAsync(_html.StatusCard( + "Conversation memory unavailable", + "The transcript was restored, but the agent cannot recall it.", + "warning")); + } } catch { /* memory restore is best-effort; a fresh conversation always works */ } } @@ -180,6 +188,29 @@ private async void AppendHtml(string html) } } + /// Collapses the current group of routine tool/status output without touching visible + /// messages that need attention. If a turn ends before WebView initialization, apply it after + /// the queued blocks have reached the document. + private void CompleteTranscriptActivity() + { + if (!CanScript) + { + _pendingActivityCompletion = true; + return; + } + _ = CompleteTranscriptActivityAsync(); + } + + private async Task CompleteTranscriptActivityAsync() + { + // Journal replay deliberately runs before _webViewReady is set, but it still has a live + // CoreWebView2 and must be able to close restored activity groups. + var core = _shutDown ? null : TranscriptView.CoreWebView2; + if (core == null) return; + try { await core.ExecuteScriptAsync("window.__completeActivity && window.__completeActivity()"); } + catch { /* transient during navigation/teardown */ } + } + private async void ClearTranscript() { var core = CanScript ? TranscriptView.CoreWebView2 : null; diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs index 452fbd7..4e7d46b 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs @@ -110,6 +110,7 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm // drives them into a WebView2 that no longer has a CoreWebView2. _transcript.BlockAdded += OnTranscriptBlock; _transcript.Cleared += OnTranscriptCleared; + _transcript.ActivityCompleted += OnTranscriptActivityCompleted; Session.Busy.Changed += OnBusyChanged; Session.TitleChanged += OnAgentTitleChanged; @@ -128,6 +129,7 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm // Harness events arrive on background threads; each hop marshals to the UI thread. private void OnTranscriptBlock(string html) => OnUi(() => AppendHtml(html)); private void OnTranscriptCleared() => OnUi(ClearTranscript); + private void OnTranscriptActivityCompleted() => OnUi(CompleteTranscriptActivity); private void OnBusyChanged(bool busy, string? activity) => OnUi(() => UpdateBusy(busy, activity)); private void OnAgentTitleChanged(string _) => OnUi(UpdateHeader); private void OnControllerStateChanged() => OnUi(UpdateHeader); @@ -182,6 +184,11 @@ public async Task InitializeAsync() await RestoreJournaledTranscriptAsync(); _webViewReady = true; while (_pendingHtml.Count > 0) AppendHtml(_pendingHtml.Dequeue()); + if (_pendingActivityCompletion) + { + _pendingActivityCompletion = false; + CompleteTranscriptActivity(); + } }; // The WebView hosts only the transcript document. Any link click opens in the @@ -333,6 +340,7 @@ public void Shutdown() // WebView2 whose CoreWebView2 is about to be null. _transcript.BlockAdded -= OnTranscriptBlock; _transcript.Cleared -= OnTranscriptCleared; + _transcript.ActivityCompleted -= OnTranscriptActivityCompleted; Session.Busy.Changed -= OnBusyChanged; Session.TitleChanged -= OnAgentTitleChanged; _controller.StateChanged -= OnControllerStateChanged; diff --git a/src/MandoCode.Desktop/MainWindow.Mcp.cs b/src/MandoCode.Desktop/MainWindow.Mcp.cs index c3f3fe4..18a6d7b 100644 --- a/src/MandoCode.Desktop/MainWindow.Mcp.cs +++ b/src/MandoCode.Desktop/MainWindow.Mcp.cs @@ -382,6 +382,7 @@ private async void McpEditorSave_Click(object sender, RoutedEventArgs e) var originalName = _mcpEditOriginalName; var (_, message) = await Task.Run(() => _controller.SaveMcpServerAsync(originalName, name, server)); + _controller.CompleteTranscriptActivity(); McpPageStatus.Text = message; await RefreshMcpListAsync(); McpPageStatus.Text = message; diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs index 4942603..5e84ea9 100644 --- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs +++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs @@ -74,6 +74,15 @@ public string PlanStepResult(string markdown, string? speaker = null) => public string Error(string text) => NoticeCard(text, "error"); public string Dim(string text) => NoticeCard(text, "dim"); + /// Low-priority operational output. Unlike notices, this is folded into the turn's + /// expandable Activity section once the work finishes. + public string Activity(string text, string state = "") => NoticeCard(text, state, " activity-item"); + + /// A completed approval decision. It keeps the existing card while live, then joins + /// the compact, distinct-emoji approval summary when the turn finishes. + public string ApprovalNotice(string text, string state = "success") => + NoticeCard(text, state, " approval-activity-item", ActivityEmoji(state)); + /// /// A high-contrast, live plan-status card. Plan execution and step headers must remain readable /// even when a user has turned their chat wallpaper opacity up, so they get a themed surface @@ -110,8 +119,8 @@ public string StatusChip(string primary, string? secondary = null, string state return sb.ToString(); } - private static string NoticeCard(string text, string state) => - $"
{NoticeEmoji(state)}" + + private static string NoticeCard(string text, string state, string extraClass = "", string? activityIcon = null) => + $"
{NoticeEmoji(state)}" + $"{E(text)}
"; private static string NoticeEmoji(string state) => state switch @@ -136,7 +145,7 @@ private static string NoticeCard(string text, string state) => /// reproduced the CPU/stuck issue and was removed; the dot stays neutral. ///
public string ToolChip(string label) => - $"
" + + $"
" + $"{E(label).Replace(".", " · ")}
"; /// Pre-formatted block (config listings, model lists) in monospace. @@ -156,10 +165,10 @@ private static string FileLink(string path) => $"{E(path)}"; public string CommandCard(string command) => - $"
Command
$ {E(command)}
"; + $"
Command
$ {E(command)}
"; public string CommandOutputCard(string command, string output, bool failed = false) => - $"
$ {E(command)}
{E(output)}
"; + $"
$ {E(command)}
{E(output)}
"; /// adds Undo-changes / Clear chips to the header — /// used ONLY for diffs the user requested from the Changes tab, never for diffs the agent @@ -172,7 +181,7 @@ public string DiffCard(string relativePath, IReadOnlyList lines, strin "title=\"Discard this file's uncommitted changes (asks first)\">↩ Undo changes" + "" : ""; - sb.Append($"
Diff: {FileLink(relativePath)}{actions}
");
+        sb.Append($"
Diff: {FileLink(relativePath)}{actions}
");
         AppendDiffLines(sb, lines);
         sb.Append("
"); sb.Append($"
{E(summary)}
"); @@ -180,7 +189,7 @@ public string DiffCard(string relativePath, IReadOnlyList lines, strin } public string FolderDeleteCard(string relativePath, string listing) => - $"
Delete Folder: {FileLink(relativePath)}/
{E(listing)}
"; + $"
Delete Folder: {FileLink(relativePath)}/
{E(listing)}
"; private static void AppendDiffLines(StringBuilder sb, IReadOnlyList lines) { @@ -229,7 +238,7 @@ public string OperationCard(OperationDisplayEvent op) && op.OperationType is "Write" or "Update" or "Read" or "Delete" or "CreateFolder" or "List"; var sb = new StringBuilder(); - sb.Append("
"); + sb.Append("
"); sb.Append($"{icon} {E(op.OperationType)} "); sb.Append(pathIsOpenable ? $"{FileLink(op.FilePath!)}" @@ -306,11 +315,22 @@ public string PlanFinished(string title, string detail, string state) => public string StatusCard(string title, string detail, string state = "") => PlanActivity(title, detail, state); - private static string PlanActivity(string title, string detail, string state = "") => - $"
" + + public string ApprovalActivity(string title, string detail, string state) => + PlanActivity(title, detail, state, " approval-activity-item", ActivityEmoji(state)); + + private static string PlanActivity( + string title, + string detail, + string state = "", + string extraClass = "", + string? activityIcon = null) => + $"
" + $"
{ActivityEmoji(state)}{E(title)}
" + $"
{E(detail)}
"; + private static string ActivityIconAttribute(string? icon) => + icon == null ? "" : $" data-activity-icon=\"{E(icon)}\""; + private static string ActivityEmoji(string state) => state switch { "success" => "✅", diff --git a/src/MandoCode.Desktop/Services/TranscriptWriter.cs b/src/MandoCode.Desktop/Services/TranscriptWriter.cs index da01033..19179e2 100644 --- a/src/MandoCode.Desktop/Services/TranscriptWriter.cs +++ b/src/MandoCode.Desktop/Services/TranscriptWriter.cs @@ -14,7 +14,13 @@ public sealed class TranscriptWriter /// Raised when the transcript should be cleared (e.g. /clear). public event Action? Cleared; + /// Raised after routine output for the current interaction is complete. The transcript + /// surface uses it to collapse activity while leaving important notices and chat visible. + public event Action? ActivityCompleted; + public void Append(string html) => BlockAdded?.Invoke(html); public void Clear() => Cleared?.Invoke(); + + public void CompleteActivity() => ActivityCompleted?.Invoke(); } diff --git a/src/MandoCode.Desktop/Services/WinUiApprovalService.cs b/src/MandoCode.Desktop/Services/WinUiApprovalService.cs index 8288d55..3fa141c 100644 --- a/src/MandoCode.Desktop/Services/WinUiApprovalService.cs +++ b/src/MandoCode.Desktop/Services/WinUiApprovalService.cs @@ -75,7 +75,7 @@ public async Task HandleWriteApprovalAsync(string relativePa if (_globalWriteBypass || _approvedFiles.Contains(relativePath)) { - _transcript.Append(_html.StatusCard("Auto-approved", $"{fileName} was already approved for this session.", "success")); + _transcript.Append(_html.ApprovalActivity("Auto-approved", $"{fileName} was already approved for this session.", "success")); _busy.Start(); return new DiffApprovalResult { Response = DiffApprovalResponse.Approved }; } @@ -108,21 +108,21 @@ public async Task HandleWriteApprovalAsync(string relativePa DiffApprovalResult result; if (choice == ApproveLabel) { - _transcript.Append(_html.StatusCard("Changes approved", $"{fileName} can be updated.", "success")); + _transcript.Append(_html.ApprovalActivity("Changes approved", $"{fileName} can be updated.", "success")); result = new DiffApprovalResult { Response = DiffApprovalResponse.Approved }; } else if (choice == noAskLabel) { - _transcript.Append(_html.StatusCard("Changes approved", $"{fileName} can be updated.", "success")); + _transcript.Append(_html.ApprovalActivity("Changes approved", $"{fileName} can be updated.", "success")); if (isNewFile) { _globalWriteBypass = true; - _transcript.Append(_html.StatusCard("Auto-approval enabled", "All future writes will be auto-approved for this session.", "warning")); + _transcript.Append(_html.ApprovalActivity("Auto-approval enabled", "All future writes will be auto-approved for this session.", "warning")); } else { _approvedFiles.Add(relativePath); - _transcript.Append(_html.StatusCard("Auto-approval enabled", $"Future modifications to {fileName} will be auto-approved.", "warning")); + _transcript.Append(_html.ApprovalActivity("Auto-approval enabled", $"Future modifications to {fileName} will be auto-approved.", "warning")); } result = new DiffApprovalResult { Response = DiffApprovalResponse.ApprovedNoAskAgain }; } @@ -159,7 +159,7 @@ public async Task HandleCommandApprovalAsync(string command) if (_globalWriteBypass) { - _transcript.Append(_html.StatusCard("Command auto-approved", "Commands are currently auto-approved for this session.", "success")); + _transcript.Append(_html.ApprovalActivity("Command auto-approved", "Commands are currently auto-approved for this session.", "success")); _busy.Start(); return new DiffApprovalResult { Response = DiffApprovalResponse.Approved }; } @@ -189,14 +189,14 @@ public async Task HandleCommandApprovalAsync(string command) DiffApprovalResult result; if (choice == ApproveLabel) { - _transcript.Append(_html.StatusCard("Command approved", "The command can now run.", "success")); + _transcript.Append(_html.ApprovalActivity("Command approved", "The command can now run.", "success")); result = new DiffApprovalResult { Response = DiffApprovalResponse.Approved }; } else if (choice == ApproveNoAskRunLabel) { - _transcript.Append(_html.StatusCard("Command approved", "The command can now run.", "success")); + _transcript.Append(_html.ApprovalActivity("Command approved", "The command can now run.", "success")); _globalWriteBypass = true; - _transcript.Append(_html.StatusCard("Auto-approval enabled", "All future writes, deletions, and commands will be auto-approved for this session.", "warning")); + _transcript.Append(_html.ApprovalActivity("Auto-approval enabled", "All future writes, deletions, and commands will be auto-approved for this session.", "warning")); result = new DiffApprovalResult { Response = DiffApprovalResponse.ApprovedNoAskAgain }; } else if (choice == DenyLabel) @@ -257,15 +257,17 @@ public async Task HandleDeleteApprovalAsync(string relativeP _transcript.Append(_html.DiffCard(relativePath, displayLines, $"{diffLines.Count} deletion(s)")); warning = $"This will DELETE the file: {relativePath}"; } - _transcript.Append(_html.Error(warning)); - if (_globalWriteBypass) { - _transcript.Append(_html.StatusCard("Deletion auto-approved", "Deletions are currently auto-approved for this session.", "success")); + _transcript.Append(_html.ApprovalActivity("Deletion auto-approved", "Deletions are currently auto-approved for this session.", "success")); _busy.Start(); return new DiffApprovalResult { Response = DiffApprovalResponse.Approved }; } + // The approval overlay already presents this destructive-action warning while a decision + // is needed. Leaving a second error-style card in the transcript after auto-approval made + // a completed multi-file delete look like a failure and split its work rollup. + var options = new List { new(ApproveDeletionLabel, ApprovalOptionKind.Proceed, GlyphApprove), @@ -292,14 +294,14 @@ public async Task HandleDeleteApprovalAsync(string relativeP DiffApprovalResult result; if (choice == ApproveDeletionLabel) { - _transcript.Append(_html.StatusCard("Deletion approved", "The deletion can now run.", "success")); + _transcript.Append(_html.ApprovalActivity("Deletion approved", "The deletion can now run.", "success")); result = new DiffApprovalResult { Response = DiffApprovalResponse.Approved }; } else if (choice == ApproveNoAskDeleteLabel) { - _transcript.Append(_html.StatusCard("Deletion approved", "The deletion can now run.", "success")); + _transcript.Append(_html.ApprovalActivity("Deletion approved", "The deletion can now run.", "success")); _globalWriteBypass = true; - _transcript.Append(_html.StatusCard("Auto-approval enabled", "All future writes and deletions will be auto-approved for this session.", "warning")); + _transcript.Append(_html.ApprovalActivity("Auto-approval enabled", "All future writes and deletions will be auto-approved for this session.", "warning")); result = new DiffApprovalResult { Response = DiffApprovalResponse.ApprovedNoAskAgain }; } else if (choice == DenyLabel) @@ -331,15 +333,18 @@ public async Task HandleMcpApprovalAsync(string serverName, using var promptHold = await _promptGate.AcquireAsync(); _busy.Stop(); - _transcript.Append(_html.Info($"MCP tool request: {toolName} (from {serverName})")); - if (_globalWriteBypass) { - _transcript.Append(_html.StatusCard("MCP tool auto-approved", "MCP tools are currently auto-approved for this session.", "success")); + _transcript.Append(_html.Activity($"MCP tool request: {toolName} (from {serverName})")); + _transcript.Append(_html.ApprovalActivity("MCP tool auto-approved", "MCP tools are currently auto-approved for this session.", "success")); _busy.Start(); return new DiffApprovalResult { Response = DiffApprovalResponse.Approved }; } + // A manual MCP request needs to remain in the conversation while its approval prompt is + // open; successful auto-approved requests are routine work and collapse at turn end. + _transcript.Append(_html.Info($"MCP tool request: {toolName} (from {serverName})")); + var noAskMcpLabel = $"Approve - don't ask again for {toolName} this session"; var options = new List { @@ -362,12 +367,12 @@ public async Task HandleMcpApprovalAsync(string serverName, DiffApprovalResult result; if (choice == ApproveLabel) { - _transcript.Append(_html.Success("Approved.")); + _transcript.Append(_html.ApprovalNotice("Approved.")); result = new DiffApprovalResult { Response = DiffApprovalResponse.Approved }; } else if (choice == noAskMcpLabel) { - _transcript.Append(_html.Success("Approved for session.")); + _transcript.Append(_html.ApprovalNotice("Approved for session.")); result = new DiffApprovalResult { Response = DiffApprovalResponse.ApprovedNoAskAgain }; } else if (choice == CancelPlanLabel) diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index 3f582ba..421629c 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -471,6 +471,7 @@ public async Task SubmitAsync(string input) _isProcessing = false; _busy.Reset(); StateChanged?.Invoke(); + _transcript.CompleteActivity(); } } @@ -607,11 +608,15 @@ private string ProcessFileReferences(string input) contextBlocks.AppendLine(); totalExpansionChars += dirListing.Length; - _transcript.Append(_html.Dim($"[Directory] {filePath}/")); + _transcript.Append(_html.OperationCard(new OperationDisplayEvent + { + OperationType = "List", + FilePath = filePath + })); } else { - _transcript.Append(_html.Warn($"[Not found] {filePath}")); + _transcript.Append(_html.Warn($"Couldn't find the referenced file or folder: {filePath}")); } } } @@ -1945,7 +1950,7 @@ private async Task HandleMcpCommandAsync(string rawArgs) return (false, $"Failed to save config: {ex.Message}"); } - _transcript.Append(_html.Dim(originalName == null + _transcript.Append(_html.Activity(originalName == null ? $"MCP server '{name}' added. Connecting..." : $"MCP server '{name}' updated. Reconnecting...")); @@ -1963,14 +1968,14 @@ private async Task HandleMcpCommandAsync(string rawArgs) if (server.Disabled) { - _transcript.Append(_html.Success($"✓ '{name}' saved (disabled).")); + _transcript.Append(_html.Activity($"✓ '{name}' saved (disabled).", "success")); return (true, $"✓ '{name}' saved (disabled)."); } if (_mcpManager.ActiveClients.TryGetValue(name, out var client)) { var toolCount = "?"; try { toolCount = (await client.ListToolsAsync()).Count.ToString(); } catch { } - _transcript.Append(_html.Success($"✓ Server '{name}' connected ({toolCount} tool(s)).")); + _transcript.Append(_html.Activity($"✓ Server '{name}' connected ({toolCount} tool(s)).", "success")); return (true, $"✓ '{name}' connected ({toolCount} tool(s))."); } if (_mcpManager.StartupErrors.TryGetValue(name, out var err)) @@ -1982,6 +1987,9 @@ private async Task HandleMcpCommandAsync(string rawArgs) return (true, $"'{name}' saved but did not appear in active clients."); } + /// Ends a routine management-action activity group after its final status line has + /// been written. Chat turns do this automatically in . + public void CompleteTranscriptActivity() => _transcript.CompleteActivity(); public sealed record McpToolInfo(string Name, string? Description); public sealed record McpTestResult(bool Ok, string Message, IReadOnlyList Tools);