From 52f4bdfa253b50fcb71fbd3c3c1b51c7c673176b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 17 Apr 2026 15:22:40 +0300 Subject: [PATCH 1/5] fix(ui): show tool diff as side-by-side before/after for changed tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quarantine panel previously rendered the current description as a plain paragraph and, below it, a single diff box that mixed added, removed, and same tokens — making it look like the same text was being shown twice with random green highlights. Split the diff into two labelled boxes so each side only renders its own words: the "Before (approved)" box shows the previous description (with any removed words highlighted red), and the "After (current)" box shows the new description (with added words highlighted green). The plain paragraph is suppressed when a diff is available so the current text is not duplicated above the "After" box. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/views/ServerDetail.vue | 32 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/frontend/src/views/ServerDetail.vue b/frontend/src/views/ServerDetail.vue index 4f3ea964f..d96e79a2e 100644 --- a/frontend/src/views/ServerDetail.vue +++ b/frontend/src/views/ServerDetail.vue @@ -347,15 +347,29 @@ {{ tool.status }} -

{{ tool.description }}

- -
-
- +

{{ tool.description }}

+ +
+
+
Before (approved)
+
+ +
+
+
+
After (current)
+
+ +
From 5e37a494489cd8f7e25e541792e58479eb0c2599 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 17 Apr 2026 15:47:23 +0300 Subject: [PATCH 2/5] fix(tray/macos): mirror web before/after word-diff in ServerDetailView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the macOS tray's Tool Quarantine diff in line with the web UI's new before/after layout. Previously the tray stacked a red "minus" box above a green "plus" box containing the raw old/new text, which gave no hint about which specific words actually changed. Now the diff section renders two labelled boxes ("Before (approved)" and "After (current)") built from a longest-common-subsequence word diff; each side only shows its own words with removals/additions highlighted inside their respective boxes via AttributedString background color. Ports the same LCS used in ServerDetail.vue (split-on-whitespace tokens, backtrack to build parts, merge consecutive parts of the same kind). Adds ToolDiffTests covering identical inputs, empty sides, the real gcore short→docstring expansion, and reconstruction invariants. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MCPProxy/Views/ServerDetailView.swift | 180 +++++++++++++++--- .../MCPProxyTests/ToolDiffTests.swift | 84 ++++++++ 2 files changed, 239 insertions(+), 25 deletions(-) create mode 100644 native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift index 3359c9c8f..b4e25e651 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift @@ -1158,12 +1158,7 @@ struct ToolRow: View { .foregroundStyle(.secondary) if oldDesc != newDesc { - if !oldDesc.isEmpty { - diffLine(text: oldDesc, isOld: true) - } - if !newDesc.isEmpty { - diffLine(text: newDesc, isOld: false) - } + diffBeforeAfter(previous: oldDesc, current: newDesc) } else { Text("No description changes") .font(.scaled(.caption, scale: fontScale)) @@ -1188,12 +1183,7 @@ struct ToolRow: View { .padding(.top, 4) if oldSchema != newSchema { - if !oldSchema.isEmpty { - diffLine(text: oldSchema, isOld: true) - } - if !newSchema.isEmpty { - diffLine(text: newSchema, isOld: false) - } + diffBeforeAfter(previous: oldSchema, current: newSchema) } else { Text("No schema changes") .font(.scaled(.caption, scale: fontScale)) @@ -1219,20 +1209,78 @@ struct ToolRow: View { } @ViewBuilder - private func diffLine(text: String, isOld: Bool) -> some View { - HStack(alignment: .top, spacing: 4) { - Image(systemName: isOld ? "minus.circle.fill" : "plus.circle.fill") - .font(.scaled(.caption2, scale: fontScale)) - .foregroundStyle(isOld ? .red : .green) - Text(text) - .font(.scaledMonospaced(.caption, scale: fontScale)) - .foregroundStyle(isOld ? .secondary : .primary) - .lineLimit(isOld ? 6 : nil) + private func diffBeforeAfter(previous: String, current: String) -> some View { + let parts = computeWordDiff(previous, current) + VStack(alignment: .leading, spacing: 6) { + diffBox( + label: "BEFORE (APPROVED)", + attributed: renderDiffSide(parts, keep: .removed), + accent: .red, + isEmpty: previous.isEmpty + ) + diffBox( + label: "AFTER (CURRENT)", + attributed: renderDiffSide(parts, keep: .added), + accent: .green, + isEmpty: current.isEmpty + ) } - .padding(4) - .frame(maxWidth: .infinity, alignment: .leading) - .background((isOld ? Color.red : Color.green).opacity(0.08)) - .cornerRadius(4) + } + + @ViewBuilder + private func diffBox(label: String, attributed: AttributedString, accent: Color, isEmpty: Bool) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.system(size: 9 * fontScale, weight: .semibold)) + .tracking(0.5) + .foregroundStyle(.secondary) + if isEmpty { + Text("(empty)") + .font(.scaledMonospaced(.caption, scale: fontScale)) + .foregroundStyle(.tertiary) + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.04)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(accent.opacity(0.25), lineWidth: 1) + ) + .cornerRadius(4) + } else { + Text(attributed) + .font(.scaledMonospaced(.caption, scale: fontScale)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(6) + .background(accent.opacity(0.04)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(accent.opacity(0.25), lineWidth: 1) + ) + .cornerRadius(4) + } + } + } + + /// Build AttributedString for one side of the diff. `keep` is the change-type + /// (removed or added) that belongs in this side; same-type parts render plain + /// in both sides; the opposite change-type is dropped. + private func renderDiffSide(_ parts: [ToolDiffPart], keep: ToolDiffPart.Kind) -> AttributedString { + var result = AttributedString() + for part in parts { + switch part.kind { + case .same: + result += AttributedString(part.text) + case .added, .removed: + if part.kind != keep { continue } + var span = AttributedString(part.text) + let accent: Color = part.kind == .removed ? .red : .green + span.backgroundColor = accent.opacity(0.25) + span.foregroundColor = accent + result += span + } + } + return result } private func loadDiff() { @@ -1314,6 +1362,88 @@ struct ToolRow: View { } } +// MARK: - Tool Description Word Diff + +struct ToolDiffPart { + enum Kind { case same, added, removed } + let kind: Kind + let text: String +} + +/// Split a string into tokens, preserving runs of whitespace as their own tokens +/// (matches the web UI's `split(/(\s+)/)` so the LCS behaves identically). +private func splitPreservingWhitespace(_ s: String) -> [String] { + var tokens: [String] = [] + var buffer = "" + var bufferIsWhitespace: Bool? = nil + for ch in s { + let chIsWhitespace = ch.isWhitespace + if bufferIsWhitespace == nil { + bufferIsWhitespace = chIsWhitespace + buffer.append(ch) + } else if bufferIsWhitespace == chIsWhitespace { + buffer.append(ch) + } else { + tokens.append(buffer) + buffer = String(ch) + bufferIsWhitespace = chIsWhitespace + } + } + if !buffer.isEmpty { tokens.append(buffer) } + return tokens +} + +/// Word-level diff via longest common subsequence. Returns parts in original order. +func computeWordDiff(_ oldText: String, _ newText: String) -> [ToolDiffPart] { + let oldTokens = splitPreservingWhitespace(oldText) + let newTokens = splitPreservingWhitespace(newText) + let m = oldTokens.count + let n = newTokens.count + if m == 0 && n == 0 { return [] } + if m == 0 { return [ToolDiffPart(kind: .added, text: newText)] } + if n == 0 { return [ToolDiffPart(kind: .removed, text: oldText)] } + + var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1) + for i in 1...m { + for j in 1...n { + if oldTokens[i - 1] == newTokens[j - 1] { + dp[i][j] = dp[i - 1][j - 1] + 1 + } else { + dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + } + } + } + + var parts: [ToolDiffPart] = [] + var i = m + var j = n + while i > 0 || j > 0 { + if i > 0 && j > 0 && oldTokens[i - 1] == newTokens[j - 1] { + parts.append(ToolDiffPart(kind: .same, text: oldTokens[i - 1])) + i -= 1 + j -= 1 + } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) { + parts.append(ToolDiffPart(kind: .added, text: newTokens[j - 1])) + j -= 1 + } else { + parts.append(ToolDiffPart(kind: .removed, text: oldTokens[i - 1])) + i -= 1 + } + } + parts.reverse() + + // Merge consecutive parts of the same kind to minimize attribute spans. + var merged: [ToolDiffPart] = [] + for part in parts { + if let last = merged.last, last.kind == part.kind { + merged[merged.count - 1] = ToolDiffPart(kind: last.kind, text: last.text + part.text) + } else { + merged.append(part) + } + } + return merged +} + // MARK: - Flow Layout (for wrapping annotation badges) /// A simple horizontal flow layout that wraps items to the next line. diff --git a/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift b/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift new file mode 100644 index 000000000..ed48c5021 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import MCPProxy + +final class ToolDiffTests: XCTestCase { + + private func partsByKind(_ parts: [ToolDiffPart]) -> (same: String, added: String, removed: String) { + var same = "" + var added = "" + var removed = "" + for p in parts { + switch p.kind { + case .same: same += p.text + case .added: added += p.text + case .removed: removed += p.text + } + } + return (same, added, removed) + } + + func testIdenticalStringsProduceOnlySameParts() { + let text = "Get the list of Alibaba Cloud regions." + let parts = computeWordDiff(text, text) + let buckets = partsByKind(parts) + XCTAssertEqual(buckets.same, text) + XCTAssertEqual(buckets.added, "") + XCTAssertEqual(buckets.removed, "") + } + + func testEmptyBeforeMarksEverythingAdded() { + let parts = computeWordDiff("", "Hello world") + XCTAssertEqual(parts.count, 1) + XCTAssertEqual(parts[0].kind, .added) + XCTAssertEqual(parts[0].text, "Hello world") + } + + func testEmptyAfterMarksEverythingRemoved() { + let parts = computeWordDiff("Hello world", "") + XCTAssertEqual(parts.count, 1) + XCTAssertEqual(parts[0].kind, .removed) + XCTAssertEqual(parts[0].text, "Hello world") + } + + func testExpansionOfAlibabaRegions() { + // Real case from gcore-mcp-server: short one-liner expanded to docstring. + let before = "Get the list of Alibaba Cloud regions." + let after = "Get the list of Alibaba Cloud regions. Args: limit: Maximum number of items." + let parts = computeWordDiff(before, after) + let buckets = partsByKind(parts) + + // The common prefix must survive intact in "same" parts. + XCTAssertTrue(buckets.same.contains("Get the list of Alibaba Cloud regions.")) + // The trailing docstring must be in the "added" bucket. + XCTAssertTrue(buckets.added.contains("Args:")) + XCTAssertTrue(buckets.added.contains("limit:")) + // Nothing should be removed in this direction. + XCTAssertEqual(buckets.removed, "") + } + + func testReconstructionFromAddedAndRemoved() { + // Reconstructing before = same + removed; after = same + added (in order) + let before = "alpha beta gamma" + let after = "alpha delta gamma" + let parts = computeWordDiff(before, after) + + let reconstructedBefore = parts.compactMap { p -> String? in + p.kind == .added ? nil : p.text + }.joined() + let reconstructedAfter = parts.compactMap { p -> String? in + p.kind == .removed ? nil : p.text + }.joined() + + XCTAssertEqual(reconstructedBefore, before) + XCTAssertEqual(reconstructedAfter, after) + } + + func testConsecutivePartsOfSameKindAreMerged() { + let parts = computeWordDiff("one two three", "one two three four five") + // Trailing addition should be a single merged part, not many tokens. + let addedParts = parts.filter { $0.kind == .added } + XCTAssertEqual(addedParts.count, 1, "Consecutive added tokens must merge") + XCTAssertTrue(addedParts[0].text.contains("four")) + XCTAssertTrue(addedParts[0].text.contains("five")) + } +} From 7e1232d0fc5e9979766893c3e80243d04bdfb9aa Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 17 Apr 2026 16:55:50 +0300 Subject: [PATCH 3/5] feat(ui+tray): refine tool diff to character level inside changed words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The word-level diff highlighted the entire differing token — so "1 April" → "8 April" lit up the whole "1"/"8" tokens, and more insidiously "version-1.2.3" → "version-1.2.4" lit up the entire version string. Now we do a two-pass diff: word-level LCS for coarse alignment, then for each adjacent (removed, added) pair we refine with a character-level LCS. The result highlights only the characters that actually changed (a single "1" vs "8", or "3" vs "4" inside a version token), while large docstring expansions continue to highlight cleanly at the word level because they have no paired removed run to refine against. Includes a safety cap (`maxChars = 1500`) that falls back to the raw removed/added pair when a run is too long, so the O(N×M) char-level dp table can never blow up on giant payloads. Ports the refined algorithm to both ServerDetail.vue and ServerDetailView.swift and extends ToolDiffTests with coverage for "1 April" → "8 April", "version-1.2.3" → "version-1.2.4", and the long-run safeguard. Verified live in Chrome against hugginface/hf_doc_search (the real "1 April" → "8 April" case): only one char is highlighted on each side (`removedTexts: ["1"]`, `addedTexts: ["8"]`). Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/views/ServerDetail.vue | 89 +++++++++++++------ .../MCPProxy/Views/ServerDetailView.swift | 82 +++++++++++++---- .../MCPProxyTests/ToolDiffTests.swift | 43 +++++++++ 3 files changed, 172 insertions(+), 42 deletions(-) diff --git a/frontend/src/views/ServerDetail.vue b/frontend/src/views/ServerDetail.vue index d96e79a2e..1aaf60deb 100644 --- a/frontend/src/views/ServerDetail.vue +++ b/frontend/src/views/ServerDetail.vue @@ -966,18 +966,18 @@ interface DiffPart { text: string } -function computeWordDiff(oldText: string, newText: string): DiffPart[] { - const oldWords = oldText.split(/(\s+)/) - const newWords = newText.split(/(\s+)/) +/// Generic LCS over arrays of strings (works for word tokens or single chars). +function lcsDiff(oldElems: string[], newElems: string[]): DiffPart[] { + const m = oldElems.length + const n = newElems.length + if (m === 0 && n === 0) return [] + if (m === 0) return newElems.map(t => ({ type: 'added', text: t })) + if (n === 0) return oldElems.map(t => ({ type: 'removed', text: t })) - // Longest Common Subsequence to find matching words - const m = oldWords.length - const n = newWords.length const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)) - for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { - if (oldWords[i - 1] === newWords[j - 1]) { + if (oldElems[i - 1] === newElems[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1 } else { dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]) @@ -985,38 +985,75 @@ function computeWordDiff(oldText: string, newText: string): DiffPart[] { } } - // Backtrack to build diff - const parts: DiffPart[] = [] + const out: DiffPart[] = [] let i = m, j = n - const stack: DiffPart[] = [] - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) { - stack.push({ type: 'same', text: oldWords[i - 1] }) - i-- - j-- + if (i > 0 && j > 0 && oldElems[i - 1] === newElems[j - 1]) { + out.push({ type: 'same', text: oldElems[i - 1] }) + i--; j-- } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { - stack.push({ type: 'added', text: newWords[j - 1] }) + out.push({ type: 'added', text: newElems[j - 1] }) j-- } else { - stack.push({ type: 'removed', text: oldWords[i - 1] }) + out.push({ type: 'removed', text: oldElems[i - 1] }) i-- } } + return out.reverse() +} - // Reverse since we built from end to start - stack.reverse() +/// Char-level diff for short strings, with a safety cap on input length +/// to keep the O(N×M) dp table bounded. +function characterLevelDiff(oldText: string, newText: string, maxChars = 1500): DiffPart[] { + if (oldText.length > maxChars || newText.length > maxChars) { + return [ + { type: 'removed', text: oldText }, + { type: 'added', text: newText }, + ] + } + return lcsDiff(Array.from(oldText), Array.from(newText)) +} - // Merge consecutive parts of the same type - for (const part of stack) { - if (parts.length > 0 && parts[parts.length - 1].type === part.type) { - parts[parts.length - 1].text += part.text +function mergeSameKind(parts: DiffPart[]): DiffPart[] { + const out: DiffPart[] = [] + for (const p of parts) { + const last = out[out.length - 1] + if (last && last.type === p.type) { + last.text += p.text } else { - parts.push({ ...part }) + out.push({ ...p }) } } + return out +} - return parts +/// Word-level diff with character-level refinement inside adjacent +/// (removed, added) pairs. Keeps whole-token highlights for large docstring +/// expansions while narrowing substring changes like "1 April" → "8 April" +/// down to just the differing characters. +function computeWordDiff(oldText: string, newText: string): DiffPart[] { + const oldWords = oldText.split(/(\s+)/).filter(t => t.length > 0) + const newWords = newText.split(/(\s+)/).filter(t => t.length > 0) + const wordDiff = mergeSameKind(lcsDiff(oldWords, newWords)) + + const refined: DiffPart[] = [] + for (let idx = 0; idx < wordDiff.length; idx++) { + const current = wordDiff[idx] + const next = wordDiff[idx + 1] + if ( + next && + ((current.type === 'removed' && next.type === 'added') || + (current.type === 'added' && next.type === 'removed')) + ) { + const removedText = current.type === 'removed' ? current.text : next.text + const addedText = current.type === 'added' ? current.text : next.text + refined.push(...characterLevelDiff(removedText, addedText)) + idx++ // skip the paired part + continue + } + refined.push(current) + } + return mergeSameKind(refined) } // Methods diff --git a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift index b4e25e651..0c079dd9d 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift @@ -1393,20 +1393,18 @@ private func splitPreservingWhitespace(_ s: String) -> [String] { return tokens } -/// Word-level diff via longest common subsequence. Returns parts in original order. -func computeWordDiff(_ oldText: String, _ newText: String) -> [ToolDiffPart] { - let oldTokens = splitPreservingWhitespace(oldText) - let newTokens = splitPreservingWhitespace(newText) - let m = oldTokens.count - let n = newTokens.count +/// Generic LCS diff over `Element` sequences (words or characters). +private func lcsDiff(_ oldElems: [Element], _ newElems: [Element]) -> [(kind: ToolDiffPart.Kind, elem: Element)] { + let m = oldElems.count + let n = newElems.count if m == 0 && n == 0 { return [] } - if m == 0 { return [ToolDiffPart(kind: .added, text: newText)] } - if n == 0 { return [ToolDiffPart(kind: .removed, text: oldText)] } + if m == 0 { return newElems.map { (.added, $0) } } + if n == 0 { return oldElems.map { (.removed, $0) } } var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1) for i in 1...m { for j in 1...n { - if oldTokens[i - 1] == newTokens[j - 1] { + if oldElems[i - 1] == newElems[j - 1] { dp[i][j] = dp[i - 1][j - 1] + 1 } else { dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) @@ -1414,25 +1412,43 @@ func computeWordDiff(_ oldText: String, _ newText: String) -> [ToolDiffPart] { } } - var parts: [ToolDiffPart] = [] + var out: [(ToolDiffPart.Kind, Element)] = [] var i = m var j = n while i > 0 || j > 0 { - if i > 0 && j > 0 && oldTokens[i - 1] == newTokens[j - 1] { - parts.append(ToolDiffPart(kind: .same, text: oldTokens[i - 1])) + if i > 0 && j > 0 && oldElems[i - 1] == newElems[j - 1] { + out.append((.same, oldElems[i - 1])) i -= 1 j -= 1 } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) { - parts.append(ToolDiffPart(kind: .added, text: newTokens[j - 1])) + out.append((.added, newElems[j - 1])) j -= 1 } else { - parts.append(ToolDiffPart(kind: .removed, text: oldTokens[i - 1])) + out.append((.removed, oldElems[i - 1])) i -= 1 } } - parts.reverse() + out.reverse() + return out +} - // Merge consecutive parts of the same kind to minimize attribute spans. +/// Character-level diff between two strings. Falls back to the raw +/// `removed → added` pair when either side exceeds `maxChars` to avoid +/// quadratic blowup on very long runs. +private func characterLevelDiff(_ oldText: String, _ newText: String, maxChars: Int = 1500) -> [ToolDiffPart] { + if oldText.count > maxChars || newText.count > maxChars { + return [ + ToolDiffPart(kind: .removed, text: oldText), + ToolDiffPart(kind: .added, text: newText), + ] + } + let oldChars = Array(oldText) + let newChars = Array(newText) + return lcsDiff(oldChars, newChars).map { ToolDiffPart(kind: $0.kind, text: String($0.elem)) } +} + +/// Merge consecutive parts of the same kind to minimize attribute spans. +private func mergeSameKind(_ parts: [ToolDiffPart]) -> [ToolDiffPart] { var merged: [ToolDiffPart] = [] for part in parts { if let last = merged.last, last.kind == part.kind { @@ -1444,6 +1460,40 @@ func computeWordDiff(_ oldText: String, _ newText: String) -> [ToolDiffPart] { return merged } +/// Word-level diff with character-level refinement inside adjacent +/// (removed, added) pairs. This keeps whole-token highlights for large +/// docstring expansions while narrowing substring changes like +/// `"1 April"` → `"8 April"` down to just the `1`/`8` characters. +func computeWordDiff(_ oldText: String, _ newText: String) -> [ToolDiffPart] { + let oldTokens = splitPreservingWhitespace(oldText) + let newTokens = splitPreservingWhitespace(newText) + let wordDiff = lcsDiff(oldTokens, newTokens).map { ToolDiffPart(kind: $0.kind, text: $0.elem) } + + // First merge consecutive same-kind parts, then refine adjacent + // (removed, added) runs into character-level diffs. + let merged = mergeSameKind(wordDiff) + var refined: [ToolDiffPart] = [] + var idx = 0 + while idx < merged.count { + let current = merged[idx] + if idx + 1 < merged.count { + let next = merged[idx + 1] + if (current.kind == .removed && next.kind == .added) || + (current.kind == .added && next.kind == .removed) { + let removedText = current.kind == .removed ? current.text : next.text + let addedText = current.kind == .added ? current.text : next.text + refined.append(contentsOf: characterLevelDiff(removedText, addedText)) + idx += 2 + continue + } + } + refined.append(current) + idx += 1 + } + + return mergeSameKind(refined) +} + // MARK: - Flow Layout (for wrapping annotation badges) /// A simple horizontal flow layout that wraps items to the next line. diff --git a/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift b/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift index ed48c5021..8fed8a39a 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift @@ -73,6 +73,49 @@ final class ToolDiffTests: XCTestCase { XCTAssertEqual(reconstructedAfter, after) } + func testCharLevelRefinementOnSingleCharChange() { + // The real screenshot case: date changed from "1 April" to "8 April". + // Word-level alone would highlight the "1" and "8" tokens. Char-level + // refinement produces the same result here (single-char tokens), but + // it also handles the common "gs_1234" → "gs_1235" style where the + // differing chars are in the MIDDLE of a word. + let before = "Knowledge up-to-date as at 1 April 2026." + let after = "Knowledge up-to-date as at 8 April 2026." + let parts = computeWordDiff(before, after) + + let addedParts = parts.filter { $0.kind == .added } + let removedParts = parts.filter { $0.kind == .removed } + + // Exactly one char each on each side — nothing else touched. + XCTAssertEqual(addedParts.map { $0.text }.joined(), "8") + XCTAssertEqual(removedParts.map { $0.text }.joined(), "1") + + // Reconstruction still matches. + let recBefore = parts.compactMap { $0.kind == .added ? nil : $0.text }.joined() + let recAfter = parts.compactMap { $0.kind == .removed ? nil : $0.text }.joined() + XCTAssertEqual(recBefore, before) + XCTAssertEqual(recAfter, after) + } + + func testCharLevelRefinementInsideWord() { + // Differing chars inside a single token — word-level alone would + // highlight the whole token ("version-1.2.3" vs "version-1.2.4"), + // char-level should narrow to just "3" → "4". + let before = "server version-1.2.3 release" + let after = "server version-1.2.4 release" + let parts = computeWordDiff(before, after) + + let removed = parts.filter { $0.kind == .removed }.map { $0.text }.joined() + let added = parts.filter { $0.kind == .added }.map { $0.text }.joined() + XCTAssertEqual(removed, "3") + XCTAssertEqual(added, "4") + + let recBefore = parts.compactMap { $0.kind == .added ? nil : $0.text }.joined() + let recAfter = parts.compactMap { $0.kind == .removed ? nil : $0.text }.joined() + XCTAssertEqual(recBefore, before) + XCTAssertEqual(recAfter, after) + } + func testConsecutivePartsOfSameKindAreMerged() { let parts = computeWordDiff("one two three", "one two three four five") // Trailing addition should be a single merged part, not many tokens. From c5c06d3211af520eea41483513222766a4afbd0d Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 17 Apr 2026 17:26:31 +0300 Subject: [PATCH 4/5] fix(ui+tray): stable ordering in server/session lists + rename dashboard section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related UI stability fixes: 1. **Stable server order across API polls.** `Runtime.GetAllServers` iterates the Supervisor StateView's `map[string]*ServerStatus`, which has non-deterministic iteration order in Go. Every poll returned the same servers in a different order, so filtered views like the tray's "Servers Needing Attention" shuffled every few seconds (a server would be first, then third, then second). Sort by name at the end of GetAllServers — stable alphabetical, deterministic across polls. 2. **Stable Recent Sessions order.** `Runtime.GetRecentSessions` relied on BBolt cursor order (newest-started-first by storage key). The tray's dashboard then deduped by client name and re-sorted by tool call count — which is zero for all sessions, so the final order came from `Dictionary.values` iteration and reshuffled each poll. Sort in the backend by `LastActivity` desc (break ties by ID), and replace the tray's tool-count sort with `lastActive` desc + tool-count + session ID as tiebreakers. Dashboard now shows sessions top-to-bottom by actual recency. 3. **Dashboard "Recent Tool Calls" → "Recent Activity".** The section filtered `recentActivity` down to tool_call/internal_tool_call types only, so users without any indexed tool calls saw "No tool calls recorded" despite having plenty of real activity (security scans, tool quarantine changes, OAuth events). Rename the section, widen the filter to all activity types except low-signal system events (`system_start`, `system_stop`), and rename the column header from "Tool" to "Event". Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/runtime/runtime.go | 19 ++++++++ .../MCPProxy/Views/DashboardView.swift | 43 ++++++++++++++----- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index a80a1789f..894cec94b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -1103,6 +1103,15 @@ func (r *Runtime) GetRecentSessions(limit int) ([]*contracts.MCPSession, int, er }) } + // Stable order for UI consumers: most-recently-active first, break ties + // by session ID so the list doesn't reshuffle on identical timestamps. + sort.SliceStable(sessions, func(i, j int) bool { + if !sessions[i].LastActivity.Equal(sessions[j].LastActivity) { + return sessions[i].LastActivity.After(sessions[j].LastActivity) + } + return sessions[i].ID < sessions[j].ID + }) + return sessions, total, nil } @@ -1797,6 +1806,16 @@ func (r *Runtime) GetAllServers() ([]map[string]interface{}, error) { result = append(result, serverMap) } + // Stable alphabetical order by name — StateView is backed by a map, so + // iteration order is non-deterministic. UI consumers (tray, web) expect + // a stable list so the "Servers Needing Attention" and similar filtered + // views don't shuffle between polls. + sort.SliceStable(result, func(i, j int) bool { + ni, _ := result[i]["name"].(string) + nj, _ := result[j]["name"].(string) + return ni < nj + }) + r.logger.Debug("GetAllServers completed", zap.Int("server_count", len(result))) return result, nil } diff --git a/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift b/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift index 3e0a9c714..aeae963ff 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift @@ -41,7 +41,7 @@ struct DashboardView: View { recentSessionsSection // Recent tool calls table - recentToolCallsSection + recentActivitySection } .padding(20) } @@ -486,8 +486,19 @@ struct DashboardView: View { byClient[key] = s } } - return byClient.values - .sorted { ($0.toolCallCount ?? 0) > ($1.toolCallCount ?? 0) } + // Sort most-recently-active first, break ties by tool call count, + // then by session ID so the order is deterministic when timestamps + // and call counts match. `.values` iteration is random, so an + // explicit sort key is required to keep the UI stable. + return byClient.values.sorted { lhs, rhs in + let lTime = lhs.lastActive ?? lhs.startTime ?? "" + let rTime = rhs.lastActive ?? rhs.startTime ?? "" + if lTime != rTime { return lTime > rTime } + let lCalls = lhs.toolCallCount ?? 0 + let rCalls = rhs.toolCallCount ?? 0 + if lCalls != rCalls { return lCalls > rCalls } + return lhs.id < rhs.id + } }() if sessions.isEmpty { HStack { @@ -558,16 +569,28 @@ struct DashboardView: View { } } - // MARK: - Recent Tool Calls + // MARK: - Recent Activity + // + // Shows the full activity log on the dashboard (not just tool calls) so + // users with a quiet proxy still see something useful — security scans, + // tool quarantine changes, OAuth events, etc. System-level events + // (`system_start`, `system_stop`) are filtered out because they're noise + // on the dashboard. + + private static let activityDashboardHiddenTypes: Set = [ + "system_start", "system_stop", + ] @ViewBuilder - private var recentToolCallsSection: some View { - let toolCalls = appState.recentActivity.filter { $0.type == "tool_call" || $0.type == "internal_tool_call" } - let recent = Array(toolCalls.prefix(10)) + private var recentActivitySection: some View { + let entries = appState.recentActivity.filter { entry in + !Self.activityDashboardHiddenTypes.contains(entry.type) + } + let recent = Array(entries.prefix(10)) VStack(alignment: .leading, spacing: 8) { HStack { - Label("Recent Tool Calls", systemImage: "wrench.and.screwdriver") + Label("Recent Activity", systemImage: "clock.arrow.circlepath") .font(.scaled(.headline, scale: fontScale)) Spacer() } @@ -575,7 +598,7 @@ struct DashboardView: View { if recent.isEmpty { HStack { Spacer() - Text("No tool calls recorded") + Text("No activity recorded") .font(.scaled(.body, scale: fontScale)) .foregroundStyle(.secondary) .padding(.vertical, 20) @@ -591,7 +614,7 @@ struct DashboardView: View { .frame(width: 70, alignment: .leading) Text("Server") .frame(width: 120, alignment: .leading) - Text("Tool") + Text("Event") .frame(maxWidth: .infinity, alignment: .leading) Text("Status") .frame(width: 80, alignment: .center) From bcc25ef96fbede5c10a4785562f4d80427f68389 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 17 Apr 2026 20:47:01 +0300 Subject: [PATCH 5/5] fix(tray/macos): keep sidebar intact when activity detail panel opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Activity Log used an inner `HSplitView` for its list + detail layout, nested inside the main window's `NavigationSplitView`. When the detail panel expanded on row click, the inner HSplitView's width demands competed with the outer sidebar column and collapsed it below its minimum — so the sidebar's "Dashboard / Servers / Activity Log / …" labels got truncated or hidden. Replace `HSplitView` with a plain `HStack` that renders the detail pane only when an entry is selected, tighten the list's fixed column widths a few pts so the list + detail + sidebar all fit at the default 800pt window width, and add a close (X) button in the detail header for an explicit dismissal affordance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MCPProxy/Views/ActivityView.swift | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift b/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift index b40910ed7..263dcce7c 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift @@ -87,16 +87,18 @@ struct ActivityView: View { } // MARK: - Column widths - private let colTime: CGFloat = 60 - private let colType: CGFloat = 100 - private let colServer: CGFloat = 100 + // Kept tight so that with the detail panel open the list can still coexist + // with the outer NavigationSplitView sidebar at the default 800pt window width. + private let colTime: CGFloat = 56 + private let colType: CGFloat = 92 + private let colServer: CGFloat = 96 private let colDetails: CGFloat = 0 // flexible (minWidth enforced in layout) - private let colIntent: CGFloat = 60 - private let colStatus: CGFloat = 70 - private let colDuration: CGFloat = 60 + private let colIntent: CGFloat = 52 + private let colStatus: CGFloat = 64 + private let colDuration: CGFloat = 56 var body: some View { - HSplitView { + HStack(spacing: 0) { // Left: activity table with filters VStack(alignment: .leading, spacing: 0) { activityListHeader @@ -145,17 +147,23 @@ struct ActivityView: View { } } } - .frame(minWidth: 560) + .frame(maxWidth: .infinity, maxHeight: .infinity) - // Right: detail panel + // Right: detail panel (only rendered when an entry is selected) + // NOTE: avoid HSplitView here — it fights the outer NavigationSplitView's + // sidebar column for width and can cause the app sidebar to collapse when + // the detail panel appears. A plain HStack with a fixed-width detail column + // keeps the sidebar stable. if let selectedID = selectedActivityID, let selected = activities.first(where: { $0.id == selectedID }) { - ActivityDetailView(entry: selected, recentSessions: appState.recentSessions) - } else { - Text("Select an activity entry") - .font(.scaled(.body, scale: fontScale)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + ActivityDetailView( + entry: selected, + recentSessions: appState.recentSessions, + onDismiss: { selectedActivityID = nil } + ) + .frame(minWidth: 300, idealWidth: 380, maxWidth: 520, maxHeight: .infinity) + .layoutPriority(1) } } .task { @@ -184,7 +192,7 @@ struct ActivityView: View { .frame(width: colServer, alignment: .leading) Text("Details") .lineLimit(1) - .frame(minWidth: 80, maxWidth: .infinity, alignment: .leading) + .frame(minWidth: 60, maxWidth: .infinity, alignment: .leading) Text("Intent") .frame(width: colIntent, alignment: .center) Text("Status") @@ -489,7 +497,7 @@ struct ActivityTableRow: View { .accessibilityLabel("Contains sensitive data") } } - .frame(minWidth: 80, maxWidth: .infinity, alignment: .leading) + .frame(minWidth: 60, maxWidth: .infinity, alignment: .leading) // Intent column if let op = entry.intentOperationType { @@ -695,6 +703,7 @@ struct IntentBadge: View { struct ActivityDetailView: View { let entry: ActivityEntry var recentSessions: [APIClient.MCPSession] = [] + var onDismiss: (() -> Void)? = nil @Environment(\.fontScale) var fontScale @State private var copiedField: String? @@ -830,6 +839,18 @@ struct ActivityDetailView: View { } } Spacer() + if let onDismiss = onDismiss { + Button { + onDismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .help("Close detail panel") + .accessibilityLabel("Close detail panel") + .accessibilityIdentifier("activity-detail-close") + } } }