Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 86 additions & 35 deletions frontend/src/views/ServerDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -347,15 +347,29 @@
{{ tool.status }}
</span>
</div>
<p class="text-sm text-base-content/70 mt-1">{{ tool.description }}</p>
<!-- Show word-level diff for changed tools -->
<div v-if="tool.status === 'changed' && tool.previous_description" class="mt-2 text-xs">
<div class="bg-base-300/50 px-2 py-1.5 rounded font-mono leading-relaxed">
<template v-for="(part, i) in computeWordDiff(tool.previous_description, tool.current_description || tool.description)" :key="i">
<span v-if="part.type === 'removed'" class="bg-error/20 text-error line-through px-0.5 rounded">{{ part.text }}</span>
<span v-else-if="part.type === 'added'" class="bg-success/20 text-success font-semibold px-0.5 rounded">{{ part.text }}</span>
<span v-else>{{ part.text }}</span>
</template>
<p
v-if="tool.status !== 'changed' || !tool.previous_description"
class="text-sm text-base-content/70 mt-1"
>{{ tool.description }}</p>
<!-- Show before/after diff for changed tools -->
<div v-if="tool.status === 'changed' && tool.previous_description" class="mt-2 space-y-2 text-xs">
<div>
<div class="text-[10px] font-semibold uppercase tracking-wide text-base-content/60 mb-1">Before (approved)</div>
<div class="bg-error/5 border border-error/20 px-2 py-1.5 rounded font-mono leading-relaxed">
<template v-for="(part, i) in computeWordDiff(tool.previous_description, tool.current_description || tool.description)" :key="'b'+i">
<span v-if="part.type === 'removed'" class="bg-error/20 text-error font-semibold px-0.5 rounded">{{ part.text }}</span>
<span v-else-if="part.type === 'same'">{{ part.text }}</span>
</template>
</div>
</div>
<div>
<div class="text-[10px] font-semibold uppercase tracking-wide text-base-content/60 mb-1">After (current)</div>
<div class="bg-success/5 border border-success/20 px-2 py-1.5 rounded font-mono leading-relaxed">
<template v-for="(part, i) in computeWordDiff(tool.previous_description, tool.current_description || tool.description)" :key="'a'+i">
<span v-if="part.type === 'added'" class="bg-success/20 text-success font-semibold px-0.5 rounded">{{ part.text }}</span>
<span v-else-if="part.type === 'same'">{{ part.text }}</span>
</template>
</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -952,57 +966,94 @@ 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])
}
}
}

// 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
Expand Down
19 changes: 19 additions & 0 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down
55 changes: 38 additions & 17 deletions native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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?

Expand Down Expand Up @@ -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")
}
}
}

Expand Down
43 changes: 33 additions & 10 deletions native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct DashboardView: View {
recentSessionsSection

// Recent tool calls table
recentToolCallsSection
recentActivitySection
}
.padding(20)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -558,24 +569,36 @@ 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<String> = [
"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()
}

if recent.isEmpty {
HStack {
Spacer()
Text("No tool calls recorded")
Text("No activity recorded")
.font(.scaled(.body, scale: fontScale))
.foregroundStyle(.secondary)
.padding(.vertical, 20)
Expand All @@ -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)
Expand Down
Loading
Loading