Skip to content

Commit bb9ea73

Browse files
authored
feat(webui): meaningful session names in the Activity Log + link the Sessions page (#836)
* feat(webui): meaningful session names in the Activity Log + link the Sessions page The Activity Log's Session filter showed opaque id suffixes ("...139c9", "...851e1"), which tell the user nothing about which AI client a session was. Root cause was a two-sided miss. Activity.vue already read `a.metadata?.client_name` — but the backend never writes that field, so the label always fell through to the id suffix. Meanwhile the client name has existed all along on the session record (captured from the MCP `initialize` handshake's clientInfo) and is already served by GET /api/v1/sessions. Fixed by joining the two at read time: activity rows already carry session_id as a foreign key, and the session record is the canonical home for the client name. The alternative — denormalizing the name into every activity record — would mean a storage lookup and cache in the tool-call hot path, and EmitActivityToolCallCompleted already takes 17 parameters. It would also only fix records written after the change, leaving existing rows showing hashes. The filter now reads "Claude Code · 14:32". Two sessions of the same client started in the same minute get a disambiguating suffix; a session with no clientInfo keeps the old id-suffix fallback, so this is never worse than before. Also links the Sessions page. A complete Sessions.vue and a /sessions route already existed — they were simply never added to the personal-edition sidebar (only to the Teams admin menu). So this unhides a page rather than building one. Placed above Activity Log: a session is the parent of activity records. Display names mirror the macOS tray's connectedClientNames so the two surfaces agree on what a client is called. * fix(webui): resolve sessions that appear after mount; bound and disambiguate labels Four defects found by cross-model (Codex) review of the session-name join. 1. Stale join (the important one). loadSessions ran only on mount, but the Activity Log is a LIVE page: a client that connects while it is open delivers rows for a session the map has never seen, so its filter option stayed an opaque "...139c9" until the page was remounted. Now a watch on `activities` refreshes the join whenever a genuinely new session id appears. Concurrent fetches are coalesced so an SSE burst cannot fan out into N parallel requests. 2. Refetch storm, latent in the fix for (1). The core retains only the 100 most recent sessions, so an old session's activity rows can outlive its record and never become resolvable. Left alone, every refresh would see them as unknown and refetch forever. Ids that are still missing after a fresh fetch are remembered as unresolvable and never asked about again. (A failed FETCH is deliberately not marked unresolvable — that is transient and should retry.) Those rows keep the id-suffix fallback, i.e. exactly today's behaviour. 3. Colliding "disambiguators". The suffix was a fixed 5 chars, which is not unique: two same-client sessions started in the same minute whose ids end in the same 5 characters produced byte-identical labels — and for sessions with no client name, the suffix IS the whole label. The suffix now grows until it actually tells the group apart. 4. Unbounded client name. clientInfo.name is attacker-controllable: any MCP client can send an arbitrary string at initialize and the core stores it verbatim. Vue escapes it, so this is not XSS — but a 10KB name would be dumped into a <select> option and wreck the filter layout. Unrecognised names are now whitespace-collapsed and truncated to 32 chars. Recognised clients are unaffected (they map to a short display name). Also sorts the picker by epoch ms rather than by comparing ISO strings: those carry a timezone offset, so lexical order is not chronological order across offsets. Ties break on id, so the order is stable instead of depending on Map insertion. Verified live, without a page reload: a session created while the Activity Log was open resolved from a hash to "Gemini · 08:33 AM" on the next refresh, using exactly one extra /api/v1/sessions call — and five further refreshes triggered zero more. 282/282 frontend tests pass (6 new, covering each defect above).
1 parent 3bf4f3a commit bb9ea73

6 files changed

Lines changed: 429 additions & 15 deletions

File tree

ROADMAP.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ graph LR
336336
337337
```mermaid
338338
graph LR
339+
sessions_web_ui["Sessions in the Web UI: meaningful session na…"]
339340
action_log_glance_view["At-a-glance action log view (top signals, hea…"]
340341
action_log_tray_menu["Activity in the tray menu (recent tool calls…"]
341342
tray_menu_open_telemetry["tray_menu_opened counter: Swift menuWillOpen…"]
@@ -344,12 +345,15 @@ graph LR
344345
action_log_glance_view --> action_log_tray_menu
345346
action_log_glance_view --> action_log_retention_tie_in
346347
348+
classDef done fill:#1f7a1f,stroke:#0d3d0d,color:#ffffff;
347349
classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff;
350+
class sessions_web_ui done;
348351
class action_log_glance_view,action_log_tray_menu,tray_menu_open_telemetry,action_log_retention_tie_in todo;
349352
```
350353

351354
| Task | Status | Refs |
352355
| --- | --- | --- |
356+
| Sessions in the Web UI: meaningful session names in the Activity Log filter + the existing /sessions page linked in the sidebar | 🟢 Done ||
353357
| At-a-glance action log view (top signals, health) | ⚪ Todo ||
354358
| Activity in the tray menu (recent tool calls + security events, jump to full log) | ⚪ Todo ||
355359
| tray_menu_opened counter: Swift menuWillOpen (MCPProxyApp.swift:192) -> lightweight POST /api/v1/telemetry/tray-menu-opened -> registry counter -> heartbeat tray_menu_opened_24h | ⚪ Todo ||

frontend/src/components/SidebarNav.vue

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,18 @@
263263
<span v-show="!collapsed">Agent Tokens</span>
264264
</router-link>
265265
</li>
266+
<li>
267+
<router-link
268+
to="/sessions"
269+
:class="{ 'active': isActiveRoute('/sessions') }"
270+
class="rounded-lg font-medium"
271+
:title="collapsed ? 'Sessions' : ''"
272+
data-test="sidebar-sessions"
273+
>
274+
<IconSessions class="w-5 h-5 shrink-0" />
275+
<span v-show="!collapsed">Sessions</span>
276+
</router-link>
277+
</li>
266278
<li>
267279
<router-link
268280
to="/activity"
@@ -542,6 +554,10 @@ const IconTokens = makeIcon(
542554
const IconActivity = makeIcon(
543555
'M4 12h3l3-8 4 16 3-8h3'
544556
)
557+
// Two chat bubbles — a session is one AI client's conversation with the proxy.
558+
const IconSessions = makeIcon(
559+
'M8 10h8M8 14h5M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H9l-5 4V5z'
560+
)
545561
const IconShield = makeIcon(
546562
'M12 3l8 3v6c0 5-3.5 8.5-8 9-4.5-.5-8-4-8-9V6l8-3zm-3 9l2 2 4-4'
547563
)

frontend/src/utils/sessionLabel.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* Human-meaningful labels for MCP sessions.
3+
*
4+
* A session is one AI client's conversation with the proxy. The raw session id
5+
* is an opaque uuid, so surfacing it (`...139c9`) tells the user nothing. The
6+
* MCP `initialize` handshake already gives us `clientInfo.name`, which the core
7+
* persists on the session record and serves on `GET /api/v1/sessions` — that is
8+
* the name we show.
9+
*
10+
* Display names mirror the macOS tray (DashboardView.swift `connectedClientNames`)
11+
* so the two surfaces agree on what a client is called.
12+
*/
13+
14+
/** Known clients, matched case-insensitively as substrings of clientInfo.name. */
15+
const KNOWN_CLIENTS: Array<[needle: string, display: string]> = [
16+
['claude', 'Claude Code'],
17+
['cursor', 'Cursor'],
18+
['vscode', 'VS Code'],
19+
['copilot', 'VS Code'],
20+
['codex', 'Codex'],
21+
['gemini', 'Gemini'],
22+
['antigravity', 'Antigravity'],
23+
['windsurf', 'Windsurf'],
24+
]
25+
26+
/**
27+
* Max rendered length of an unrecognised client name.
28+
*
29+
* `clientInfo.name` is attacker-controllable: any MCP client can send an
30+
* arbitrary string at initialize and the core stores it verbatim. Vue escapes it
31+
* on render, so there is no XSS — but an unbounded name would still be dumped
32+
* into a `<select>` option and blow out the filter layout. Bound it.
33+
*/
34+
const MAX_CLIENT_NAME = 32
35+
36+
/** Shortest id suffix we ever show. Grows on demand to stay unique. */
37+
const MIN_SUFFIX = 5
38+
39+
/**
40+
* Map a raw MCP clientInfo.name to a display name. Unknown clients are shown
41+
* verbatim but length-bounded — a name we don't recognise is still far better
42+
* than a hash.
43+
*/
44+
export function prettyClientName(raw?: string): string {
45+
const name = (raw ?? '').trim()
46+
if (!name) return ''
47+
48+
const lower = name.toLowerCase()
49+
for (const [needle, display] of KNOWN_CLIENTS) {
50+
if (lower.includes(needle)) return display
51+
}
52+
53+
// Unrecognised, and therefore untrusted: collapse whitespace (a newline would
54+
// break the option label) and truncate.
55+
const flat = name.replace(/\s+/g, ' ')
56+
return flat.length > MAX_CLIENT_NAME ? `${flat.slice(0, MAX_CLIENT_NAME - 1)}…` : flat
57+
}
58+
59+
/** Last `len` chars of a session id. */
60+
export function sessionIdSuffix(sessionId: string, len: number = MIN_SUFFIX): string {
61+
return sessionId.slice(-len)
62+
}
63+
64+
function clockTime(iso?: string): string {
65+
if (!iso) return ''
66+
const d = new Date(iso)
67+
if (Number.isNaN(d.getTime())) return ''
68+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
69+
}
70+
71+
export interface SessionLabelInput {
72+
sessionId: string
73+
/** Raw clientInfo.name, e.g. "claude-code". Absent for sessions we never saw initialize. */
74+
clientName?: string
75+
/** ISO start time, used to tell two sessions of the same client apart. */
76+
startTime?: string
77+
}
78+
79+
/** The label with no disambiguating suffix. "" when there is no client name. */
80+
function baseLabel(input: SessionLabelInput): string {
81+
const pretty = prettyClientName(input.clientName)
82+
if (!pretty) return '' // no client name → the id is all we have
83+
const time = clockTime(input.startTime)
84+
return time ? `${pretty} · ${time}` : pretty
85+
}
86+
87+
/**
88+
* Build the label shown in the Activity Log session filter.
89+
*
90+
* "Claude Code · 14:32" — the common case
91+
* "Claude Code · 14:32 (139c9)" — two Claude Code sessions started the same minute
92+
* "...139c9" — no clientInfo (pre-initialize, or an evicted session)
93+
*
94+
* `suffixLen` is chosen by buildSessionLabels so the suffix is long enough to
95+
* actually disambiguate — a fixed-width suffix can itself collide.
96+
*/
97+
export function formatSessionLabel(
98+
input: SessionLabelInput,
99+
opts: { ambiguous?: boolean; suffixLen?: number } = {}
100+
): string {
101+
const len = opts.suffixLen ?? MIN_SUFFIX
102+
const base = baseLabel(input)
103+
104+
// No client name: the id suffix IS the label (unchanged legacy behaviour).
105+
if (!base) return `...${sessionIdSuffix(input.sessionId, len)}`
106+
107+
return opts.ambiguous ? `${base} (${sessionIdSuffix(input.sessionId, len)})` : base
108+
}
109+
110+
/**
111+
* Shortest suffix length that tells every id in `ids` apart.
112+
*
113+
* A fixed 5-char suffix is NOT guaranteed unique — two ids can share their last
114+
* five characters — so grow it until the ids are distinguished, falling back to
115+
* the longest id. Session ids are unique, so this always terminates.
116+
*/
117+
function uniqueSuffixLen(ids: string[]): number {
118+
const maxLen = Math.max(...ids.map(id => id.length))
119+
for (let len = MIN_SUFFIX; len < maxLen; len++) {
120+
if (new Set(ids.map(id => id.slice(-len))).size === ids.length) return len
121+
}
122+
return maxLen
123+
}
124+
125+
/**
126+
* Label a whole list at once, resolving collisions so no two sessions ever share
127+
* a label. Returns a Map keyed by session id.
128+
*/
129+
export function buildSessionLabels(sessions: SessionLabelInput[]): Map<string, string> {
130+
// Group by the label they would get with no disambiguation. Sessions with no
131+
// client name all share the "" group: their label is the id suffix, which must
132+
// be unique among themselves too.
133+
const groups = new Map<string, SessionLabelInput[]>()
134+
for (const s of sessions) {
135+
const key = baseLabel(s)
136+
const group = groups.get(key)
137+
if (group) group.push(s)
138+
else groups.set(key, [s])
139+
}
140+
141+
const labels = new Map<string, string>()
142+
for (const [key, group] of groups) {
143+
const named = key !== ''
144+
145+
// A named session alone in its group needs no suffix at all.
146+
if (named && group.length === 1) {
147+
labels.set(group[0].sessionId, formatSessionLabel(group[0]))
148+
continue
149+
}
150+
151+
// Otherwise every member gets a suffix, long enough to be unique within the
152+
// group. (Unnamed sessions always carry one — it is their whole label.)
153+
const suffixLen = group.length > 1 ? uniqueSuffixLen(group.map(s => s.sessionId)) : MIN_SUFFIX
154+
for (const s of group) {
155+
labels.set(s.sessionId, formatSessionLabel(s, { ambiguous: named, suffixLen }))
156+
}
157+
}
158+
return labels
159+
}

frontend/src/views/Activity.vue

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,7 @@ import { useRoute } from 'vue-router'
694694
import { useSystemStore } from '@/stores/system'
695695
import api from '@/services/api'
696696
import type { ActivityRecord, ActivitySummaryResponse } from '@/types/api'
697+
import { buildSessionLabels } from '@/utils/sessionLabel'
697698
import JsonViewer from '@/components/JsonViewer.vue'
698699
699700
const route = useRoute()
@@ -766,29 +767,111 @@ interface SessionOption {
766767
id: string
767768
label: string
768769
clientName?: string
770+
startTime?: string
769771
}
772+
773+
// Client identity lives on the session record, not the activity record — an
774+
// activity row only carries session_id. We join the two here so the filter can
775+
// show "Claude Code · 14:32" instead of an opaque "...139c9".
776+
const sessionInfo = ref(new Map<string, { clientName?: string; startTime?: string }>())
777+
778+
// Session ids we have already tried and failed to resolve. The core keeps only
779+
// the 100 most recent sessions, so an old session's activity rows can outlive
780+
// its record and never become resolvable. Without this set, every refresh would
781+
// see them as "unknown" and refetch forever.
782+
const unresolvableSessions = ref(new Set<string>())
783+
784+
let sessionsInFlight: Promise<void> | null = null
785+
786+
const loadSessions = async () => {
787+
// Coalesce: a burst of SSE events must not fan out into N parallel fetches.
788+
if (sessionsInFlight) return sessionsInFlight
789+
790+
sessionsInFlight = (async () => {
791+
try {
792+
const response = await api.getSessions(100)
793+
const next = new Map<string, { clientName?: string; startTime?: string }>()
794+
for (const s of response.data?.sessions ?? []) {
795+
next.set(s.id, { clientName: s.client_name, startTime: s.start_time })
796+
}
797+
sessionInfo.value = next
798+
799+
// Anything still unknown after a fresh fetch is gone for good (evicted by
800+
// the 100-session cap). Remember it so we stop asking.
801+
for (const a of activities.value) {
802+
if (a.session_id && !next.has(a.session_id)) {
803+
unresolvableSessions.value.add(a.session_id)
804+
}
805+
}
806+
} catch {
807+
// Non-fatal: without session metadata the filter degrades to id suffixes,
808+
// which is exactly the previous behaviour. Never block the activity log.
809+
// Deliberately NOT marked unresolvable — a transient failure should be
810+
// retried the next time an unknown session shows up.
811+
} finally {
812+
sessionsInFlight = null
813+
}
814+
})()
815+
816+
return sessionsInFlight
817+
}
818+
819+
// The Activity Log is a live page: a client can connect while it is open, and
820+
// SSE will deliver its rows. Those sessions were not in the map we fetched on
821+
// mount, so refresh the join whenever a genuinely new session id appears.
822+
const refreshSessionsIfUnknown = () => {
823+
const hasUnknown = activities.value.some(
824+
a =>
825+
a.session_id &&
826+
!sessionInfo.value.has(a.session_id) &&
827+
!unresolvableSessions.value.has(a.session_id)
828+
)
829+
if (hasUnknown) void loadSessions()
830+
}
831+
770832
const availableSessions = computed((): SessionOption[] => {
771-
const sessionsMap = new Map<string, { clientName?: string }>()
833+
const seen = new Map<string, { clientName?: string; startTime?: string }>()
772834
activities.value.forEach(a => {
773-
if (a.session_id && !sessionsMap.has(a.session_id)) {
774-
// Try to get client name from metadata or any available source
775-
const clientName = a.metadata?.client_name as string | undefined
776-
sessionsMap.set(a.session_id, { clientName })
835+
if (a.session_id && !seen.has(a.session_id)) {
836+
const info = sessionInfo.value.get(a.session_id)
837+
seen.set(a.session_id, {
838+
// Prefer the session record; fall back to metadata for records that
839+
// carry it, then to nothing (→ id suffix).
840+
clientName: info?.clientName ?? (a.metadata?.client_name as string | undefined),
841+
startTime: info?.startTime ?? a.timestamp,
842+
})
777843
}
778844
})
779845
780-
return Array.from(sessionsMap.entries())
781-
.map(([sessionId, info]) => {
782-
// Format: "ClientName ...12345" or "...12345" if no client name
783-
const suffix = sessionId.slice(-5)
784-
const label = info.clientName
785-
? `${info.clientName} ...${suffix}`
786-
: `...${suffix}`
787-
return { id: sessionId, label, clientName: info.clientName }
788-
})
789-
.sort((a, b) => a.label.localeCompare(b.label))
846+
const entries = Array.from(seen.entries()).map(([sessionId, info]) => ({
847+
sessionId,
848+
clientName: info.clientName,
849+
startTime: info.startTime,
850+
}))
851+
const labels = buildSessionLabels(entries)
852+
853+
return entries
854+
.map(e => ({
855+
id: e.sessionId,
856+
label: labels.get(e.sessionId) ?? `...${e.sessionId.slice(-5)}`,
857+
clientName: e.clientName,
858+
startTime: e.startTime,
859+
}))
860+
// Most recent session first — in a session picker, recency beats alphabet.
861+
// Compare epoch ms, not the ISO strings: those carry a timezone offset
862+
// ("...+03:00"), so lexical order is not chronological order across offsets.
863+
// A missing or unparseable start time sorts last; id breaks ties so the
864+
// order is stable rather than dependent on Map insertion.
865+
.sort((a, b) => epochOf(b.startTime) - epochOf(a.startTime) || a.id.localeCompare(b.id))
790866
})
791867
868+
/** Epoch ms for sorting; -Infinity for missing/unparseable, so it sorts last. */
869+
const epochOf = (iso?: string): number => {
870+
if (!iso) return -Infinity
871+
const t = new Date(iso).getTime()
872+
return Number.isNaN(t) ? -Infinity : t
873+
}
874+
792875
// Get session label by ID for display in Active Filters
793876
const getSessionLabel = (sessionId: string): string => {
794877
const session = availableSessions.value.find(s => s.id === sessionId)
@@ -1182,6 +1265,12 @@ const handleKeydown = (event: KeyboardEvent) => {
11821265
}
11831266
}
11841267
1268+
// Keep the session join fresh no matter how activities arrived — polling,
1269+
// manual refresh, or an SSE event for a client that connected just now. Watching
1270+
// the list covers every mutation path; refreshSessionsIfUnknown is a no-op
1271+
// unless a genuinely new session id showed up, and coalesces concurrent fetches.
1272+
watch(activities, refreshSessionsIfUnknown)
1273+
11851274
// Lifecycle
11861275
onMounted(() => {
11871276
// Check for session filter from URL query params (linked from Dashboard/Sessions pages)
@@ -1191,6 +1280,7 @@ onMounted(() => {
11911280
}
11921281
11931282
loadActivities()
1283+
loadSessions()
11941284
11951285
// Listen for SSE activity events
11961286
window.addEventListener('mcpproxy:activity', handleActivityEvent as EventListener)

0 commit comments

Comments
 (0)