diff --git a/frontend/src/views/ServerDetail.vue b/frontend/src/views/ServerDetail.vue index 4f3ea964f..1aaf60deb 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)
+
+ +
@@ -952,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]) @@ -971,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/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/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") + } } } 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) diff --git a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift index 3359c9c8f..0c079dd9d 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 + ) + } + } + + @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) + } } - .padding(4) - .frame(maxWidth: .infinity, alignment: .leading) - .background((isOld ? Color.red : Color.green).opacity(0.08)) - .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,138 @@ 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 +} + +/// 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 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 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]) + } + } + } + + var out: [(ToolDiffPart.Kind, Element)] = [] + var i = m + var j = n + while i > 0 || j > 0 { + 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]) { + out.append((.added, newElems[j - 1])) + j -= 1 + } else { + out.append((.removed, oldElems[i - 1])) + i -= 1 + } + } + out.reverse() + return out +} + +/// 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 { + merged[merged.count - 1] = ToolDiffPart(kind: last.kind, text: last.text + part.text) + } else { + merged.append(part) + } + } + 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 new file mode 100644 index 000000000..8fed8a39a --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift @@ -0,0 +1,127 @@ +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 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. + 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")) + } +}