Skip to content

Commit debe26f

Browse files
Dumbrisclaude
andauthored
fix(ui+tray): tool quarantine diff as side-by-side before/after (#391)
* fix(ui): show tool diff as side-by-side before/after for changed tools 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) <noreply@anthropic.com> * fix(tray/macos): mirror web before/after word-diff in ServerDetailView 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) <noreply@anthropic.com> * feat(ui+tray): refine tool diff to character level inside changed words 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) <noreply@anthropic.com> * fix(ui+tray): stable ordering in server/session lists + rename dashboard section 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) <noreply@anthropic.com> * fix(tray/macos): keep sidebar intact when activity detail panel opens 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent b85e7db commit debe26f

6 files changed

Lines changed: 508 additions & 87 deletions

File tree

frontend/src/views/ServerDetail.vue

Lines changed: 86 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -347,15 +347,29 @@
347347
{{ tool.status }}
348348
</span>
349349
</div>
350-
<p class="text-sm text-base-content/70 mt-1">{{ tool.description }}</p>
351-
<!-- Show word-level diff for changed tools -->
352-
<div v-if="tool.status === 'changed' && tool.previous_description" class="mt-2 text-xs">
353-
<div class="bg-base-300/50 px-2 py-1.5 rounded font-mono leading-relaxed">
354-
<template v-for="(part, i) in computeWordDiff(tool.previous_description, tool.current_description || tool.description)" :key="i">
355-
<span v-if="part.type === 'removed'" class="bg-error/20 text-error line-through px-0.5 rounded">{{ part.text }}</span>
356-
<span v-else-if="part.type === 'added'" class="bg-success/20 text-success font-semibold px-0.5 rounded">{{ part.text }}</span>
357-
<span v-else>{{ part.text }}</span>
358-
</template>
350+
<p
351+
v-if="tool.status !== 'changed' || !tool.previous_description"
352+
class="text-sm text-base-content/70 mt-1"
353+
>{{ tool.description }}</p>
354+
<!-- Show before/after diff for changed tools -->
355+
<div v-if="tool.status === 'changed' && tool.previous_description" class="mt-2 space-y-2 text-xs">
356+
<div>
357+
<div class="text-[10px] font-semibold uppercase tracking-wide text-base-content/60 mb-1">Before (approved)</div>
358+
<div class="bg-error/5 border border-error/20 px-2 py-1.5 rounded font-mono leading-relaxed">
359+
<template v-for="(part, i) in computeWordDiff(tool.previous_description, tool.current_description || tool.description)" :key="'b'+i">
360+
<span v-if="part.type === 'removed'" class="bg-error/20 text-error font-semibold px-0.5 rounded">{{ part.text }}</span>
361+
<span v-else-if="part.type === 'same'">{{ part.text }}</span>
362+
</template>
363+
</div>
364+
</div>
365+
<div>
366+
<div class="text-[10px] font-semibold uppercase tracking-wide text-base-content/60 mb-1">After (current)</div>
367+
<div class="bg-success/5 border border-success/20 px-2 py-1.5 rounded font-mono leading-relaxed">
368+
<template v-for="(part, i) in computeWordDiff(tool.previous_description, tool.current_description || tool.description)" :key="'a'+i">
369+
<span v-if="part.type === 'added'" class="bg-success/20 text-success font-semibold px-0.5 rounded">{{ part.text }}</span>
370+
<span v-else-if="part.type === 'same'">{{ part.text }}</span>
371+
</template>
372+
</div>
359373
</div>
360374
</div>
361375
</div>
@@ -952,57 +966,94 @@ interface DiffPart {
952966
text: string
953967
}
954968
955-
function computeWordDiff(oldText: string, newText: string): DiffPart[] {
956-
const oldWords = oldText.split(/(\s+)/)
957-
const newWords = newText.split(/(\s+)/)
969+
/// Generic LCS over arrays of strings (works for word tokens or single chars).
970+
function lcsDiff(oldElems: string[], newElems: string[]): DiffPart[] {
971+
const m = oldElems.length
972+
const n = newElems.length
973+
if (m === 0 && n === 0) return []
974+
if (m === 0) return newElems.map(t => ({ type: 'added', text: t }))
975+
if (n === 0) return oldElems.map(t => ({ type: 'removed', text: t }))
958976
959-
// Longest Common Subsequence to find matching words
960-
const m = oldWords.length
961-
const n = newWords.length
962977
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
963-
964978
for (let i = 1; i <= m; i++) {
965979
for (let j = 1; j <= n; j++) {
966-
if (oldWords[i - 1] === newWords[j - 1]) {
980+
if (oldElems[i - 1] === newElems[j - 1]) {
967981
dp[i][j] = dp[i - 1][j - 1] + 1
968982
} else {
969983
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
970984
}
971985
}
972986
}
973987
974-
// Backtrack to build diff
975-
const parts: DiffPart[] = []
988+
const out: DiffPart[] = []
976989
let i = m, j = n
977-
const stack: DiffPart[] = []
978-
979990
while (i > 0 || j > 0) {
980-
if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) {
981-
stack.push({ type: 'same', text: oldWords[i - 1] })
982-
i--
983-
j--
991+
if (i > 0 && j > 0 && oldElems[i - 1] === newElems[j - 1]) {
992+
out.push({ type: 'same', text: oldElems[i - 1] })
993+
i--; j--
984994
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
985-
stack.push({ type: 'added', text: newWords[j - 1] })
995+
out.push({ type: 'added', text: newElems[j - 1] })
986996
j--
987997
} else {
988-
stack.push({ type: 'removed', text: oldWords[i - 1] })
998+
out.push({ type: 'removed', text: oldElems[i - 1] })
989999
i--
9901000
}
9911001
}
1002+
return out.reverse()
1003+
}
9921004
993-
// Reverse since we built from end to start
994-
stack.reverse()
1005+
/// Char-level diff for short strings, with a safety cap on input length
1006+
/// to keep the O(N×M) dp table bounded.
1007+
function characterLevelDiff(oldText: string, newText: string, maxChars = 1500): DiffPart[] {
1008+
if (oldText.length > maxChars || newText.length > maxChars) {
1009+
return [
1010+
{ type: 'removed', text: oldText },
1011+
{ type: 'added', text: newText },
1012+
]
1013+
}
1014+
return lcsDiff(Array.from(oldText), Array.from(newText))
1015+
}
9951016
996-
// Merge consecutive parts of the same type
997-
for (const part of stack) {
998-
if (parts.length > 0 && parts[parts.length - 1].type === part.type) {
999-
parts[parts.length - 1].text += part.text
1017+
function mergeSameKind(parts: DiffPart[]): DiffPart[] {
1018+
const out: DiffPart[] = []
1019+
for (const p of parts) {
1020+
const last = out[out.length - 1]
1021+
if (last && last.type === p.type) {
1022+
last.text += p.text
10001023
} else {
1001-
parts.push({ ...part })
1024+
out.push({ ...p })
10021025
}
10031026
}
1027+
return out
1028+
}
10041029
1005-
return parts
1030+
/// Word-level diff with character-level refinement inside adjacent
1031+
/// (removed, added) pairs. Keeps whole-token highlights for large docstring
1032+
/// expansions while narrowing substring changes like "1 April" → "8 April"
1033+
/// down to just the differing characters.
1034+
function computeWordDiff(oldText: string, newText: string): DiffPart[] {
1035+
const oldWords = oldText.split(/(\s+)/).filter(t => t.length > 0)
1036+
const newWords = newText.split(/(\s+)/).filter(t => t.length > 0)
1037+
const wordDiff = mergeSameKind(lcsDiff(oldWords, newWords))
1038+
1039+
const refined: DiffPart[] = []
1040+
for (let idx = 0; idx < wordDiff.length; idx++) {
1041+
const current = wordDiff[idx]
1042+
const next = wordDiff[idx + 1]
1043+
if (
1044+
next &&
1045+
((current.type === 'removed' && next.type === 'added') ||
1046+
(current.type === 'added' && next.type === 'removed'))
1047+
) {
1048+
const removedText = current.type === 'removed' ? current.text : next.text
1049+
const addedText = current.type === 'added' ? current.text : next.text
1050+
refined.push(...characterLevelDiff(removedText, addedText))
1051+
idx++ // skip the paired part
1052+
continue
1053+
}
1054+
refined.push(current)
1055+
}
1056+
return mergeSameKind(refined)
10061057
}
10071058
10081059
// Methods

internal/runtime/runtime.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,15 @@ func (r *Runtime) GetRecentSessions(limit int) ([]*contracts.MCPSession, int, er
11031103
})
11041104
}
11051105

1106+
// Stable order for UI consumers: most-recently-active first, break ties
1107+
// by session ID so the list doesn't reshuffle on identical timestamps.
1108+
sort.SliceStable(sessions, func(i, j int) bool {
1109+
if !sessions[i].LastActivity.Equal(sessions[j].LastActivity) {
1110+
return sessions[i].LastActivity.After(sessions[j].LastActivity)
1111+
}
1112+
return sessions[i].ID < sessions[j].ID
1113+
})
1114+
11061115
return sessions, total, nil
11071116
}
11081117

@@ -1797,6 +1806,16 @@ func (r *Runtime) GetAllServers() ([]map[string]interface{}, error) {
17971806
result = append(result, serverMap)
17981807
}
17991808

1809+
// Stable alphabetical order by name — StateView is backed by a map, so
1810+
// iteration order is non-deterministic. UI consumers (tray, web) expect
1811+
// a stable list so the "Servers Needing Attention" and similar filtered
1812+
// views don't shuffle between polls.
1813+
sort.SliceStable(result, func(i, j int) bool {
1814+
ni, _ := result[i]["name"].(string)
1815+
nj, _ := result[j]["name"].(string)
1816+
return ni < nj
1817+
})
1818+
18001819
r.logger.Debug("GetAllServers completed", zap.Int("server_count", len(result)))
18011820
return result, nil
18021821
}

native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,18 @@ struct ActivityView: View {
8787
}
8888

8989
// MARK: - Column widths
90-
private let colTime: CGFloat = 60
91-
private let colType: CGFloat = 100
92-
private let colServer: CGFloat = 100
90+
// Kept tight so that with the detail panel open the list can still coexist
91+
// with the outer NavigationSplitView sidebar at the default 800pt window width.
92+
private let colTime: CGFloat = 56
93+
private let colType: CGFloat = 92
94+
private let colServer: CGFloat = 96
9395
private let colDetails: CGFloat = 0 // flexible (minWidth enforced in layout)
94-
private let colIntent: CGFloat = 60
95-
private let colStatus: CGFloat = 70
96-
private let colDuration: CGFloat = 60
96+
private let colIntent: CGFloat = 52
97+
private let colStatus: CGFloat = 64
98+
private let colDuration: CGFloat = 56
9799

98100
var body: some View {
99-
HSplitView {
101+
HStack(spacing: 0) {
100102
// Left: activity table with filters
101103
VStack(alignment: .leading, spacing: 0) {
102104
activityListHeader
@@ -145,17 +147,23 @@ struct ActivityView: View {
145147
}
146148
}
147149
}
148-
.frame(minWidth: 560)
150+
.frame(maxWidth: .infinity, maxHeight: .infinity)
149151

150-
// Right: detail panel
152+
// Right: detail panel (only rendered when an entry is selected)
153+
// NOTE: avoid HSplitView here — it fights the outer NavigationSplitView's
154+
// sidebar column for width and can cause the app sidebar to collapse when
155+
// the detail panel appears. A plain HStack with a fixed-width detail column
156+
// keeps the sidebar stable.
151157
if let selectedID = selectedActivityID,
152158
let selected = activities.first(where: { $0.id == selectedID }) {
153-
ActivityDetailView(entry: selected, recentSessions: appState.recentSessions)
154-
} else {
155-
Text("Select an activity entry")
156-
.font(.scaled(.body, scale: fontScale))
157-
.foregroundStyle(.secondary)
158-
.frame(maxWidth: .infinity, maxHeight: .infinity)
159+
Divider()
160+
ActivityDetailView(
161+
entry: selected,
162+
recentSessions: appState.recentSessions,
163+
onDismiss: { selectedActivityID = nil }
164+
)
165+
.frame(minWidth: 300, idealWidth: 380, maxWidth: 520, maxHeight: .infinity)
166+
.layoutPriority(1)
159167
}
160168
}
161169
.task {
@@ -184,7 +192,7 @@ struct ActivityView: View {
184192
.frame(width: colServer, alignment: .leading)
185193
Text("Details")
186194
.lineLimit(1)
187-
.frame(minWidth: 80, maxWidth: .infinity, alignment: .leading)
195+
.frame(minWidth: 60, maxWidth: .infinity, alignment: .leading)
188196
Text("Intent")
189197
.frame(width: colIntent, alignment: .center)
190198
Text("Status")
@@ -489,7 +497,7 @@ struct ActivityTableRow: View {
489497
.accessibilityLabel("Contains sensitive data")
490498
}
491499
}
492-
.frame(minWidth: 80, maxWidth: .infinity, alignment: .leading)
500+
.frame(minWidth: 60, maxWidth: .infinity, alignment: .leading)
493501

494502
// Intent column
495503
if let op = entry.intentOperationType {
@@ -695,6 +703,7 @@ struct IntentBadge: View {
695703
struct ActivityDetailView: View {
696704
let entry: ActivityEntry
697705
var recentSessions: [APIClient.MCPSession] = []
706+
var onDismiss: (() -> Void)? = nil
698707
@Environment(\.fontScale) var fontScale
699708
@State private var copiedField: String?
700709

@@ -830,6 +839,18 @@ struct ActivityDetailView: View {
830839
}
831840
}
832841
Spacer()
842+
if let onDismiss = onDismiss {
843+
Button {
844+
onDismiss()
845+
} label: {
846+
Image(systemName: "xmark.circle.fill")
847+
.foregroundStyle(.secondary)
848+
}
849+
.buttonStyle(.borderless)
850+
.help("Close detail panel")
851+
.accessibilityLabel("Close detail panel")
852+
.accessibilityIdentifier("activity-detail-close")
853+
}
833854
}
834855
}
835856

native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ struct DashboardView: View {
4141
recentSessionsSection
4242

4343
// Recent tool calls table
44-
recentToolCallsSection
44+
recentActivitySection
4545
}
4646
.padding(20)
4747
}
@@ -486,8 +486,19 @@ struct DashboardView: View {
486486
byClient[key] = s
487487
}
488488
}
489-
return byClient.values
490-
.sorted { ($0.toolCallCount ?? 0) > ($1.toolCallCount ?? 0) }
489+
// Sort most-recently-active first, break ties by tool call count,
490+
// then by session ID so the order is deterministic when timestamps
491+
// and call counts match. `.values` iteration is random, so an
492+
// explicit sort key is required to keep the UI stable.
493+
return byClient.values.sorted { lhs, rhs in
494+
let lTime = lhs.lastActive ?? lhs.startTime ?? ""
495+
let rTime = rhs.lastActive ?? rhs.startTime ?? ""
496+
if lTime != rTime { return lTime > rTime }
497+
let lCalls = lhs.toolCallCount ?? 0
498+
let rCalls = rhs.toolCallCount ?? 0
499+
if lCalls != rCalls { return lCalls > rCalls }
500+
return lhs.id < rhs.id
501+
}
491502
}()
492503
if sessions.isEmpty {
493504
HStack {
@@ -558,24 +569,36 @@ struct DashboardView: View {
558569
}
559570
}
560571

561-
// MARK: - Recent Tool Calls
572+
// MARK: - Recent Activity
573+
//
574+
// Shows the full activity log on the dashboard (not just tool calls) so
575+
// users with a quiet proxy still see something useful — security scans,
576+
// tool quarantine changes, OAuth events, etc. System-level events
577+
// (`system_start`, `system_stop`) are filtered out because they're noise
578+
// on the dashboard.
579+
580+
private static let activityDashboardHiddenTypes: Set<String> = [
581+
"system_start", "system_stop",
582+
]
562583

563584
@ViewBuilder
564-
private var recentToolCallsSection: some View {
565-
let toolCalls = appState.recentActivity.filter { $0.type == "tool_call" || $0.type == "internal_tool_call" }
566-
let recent = Array(toolCalls.prefix(10))
585+
private var recentActivitySection: some View {
586+
let entries = appState.recentActivity.filter { entry in
587+
!Self.activityDashboardHiddenTypes.contains(entry.type)
588+
}
589+
let recent = Array(entries.prefix(10))
567590

568591
VStack(alignment: .leading, spacing: 8) {
569592
HStack {
570-
Label("Recent Tool Calls", systemImage: "wrench.and.screwdriver")
593+
Label("Recent Activity", systemImage: "clock.arrow.circlepath")
571594
.font(.scaled(.headline, scale: fontScale))
572595
Spacer()
573596
}
574597

575598
if recent.isEmpty {
576599
HStack {
577600
Spacer()
578-
Text("No tool calls recorded")
601+
Text("No activity recorded")
579602
.font(.scaled(.body, scale: fontScale))
580603
.foregroundStyle(.secondary)
581604
.padding(.vertical, 20)
@@ -591,7 +614,7 @@ struct DashboardView: View {
591614
.frame(width: 70, alignment: .leading)
592615
Text("Server")
593616
.frame(width: 120, alignment: .leading)
594-
Text("Tool")
617+
Text("Event")
595618
.frame(maxWidth: .infinity, alignment: .leading)
596619
Text("Status")
597620
.frame(width: 80, alignment: .center)

0 commit comments

Comments
 (0)