nuxt-devtools-observatory 0.1.30 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +117 -30
  2. package/client/.env.example +2 -1
  3. package/client/dist/assets/index-5Wl1XYRH.js +17 -0
  4. package/client/dist/assets/index-DT_QUiIh.css +1 -0
  5. package/client/dist/index.html +2 -2
  6. package/client/src/App.vue +4 -0
  7. package/client/src/components/Flamegraph.vue +442 -0
  8. package/client/src/components/SpanInspector.vue +446 -0
  9. package/client/src/components/TraceFilter.vue +342 -0
  10. package/client/src/components/WaterfallView.vue +443 -0
  11. package/client/src/composables/composable-search.ts +124 -0
  12. package/client/src/composables/trace-render-aggregation.ts +254 -0
  13. package/client/src/composables/useExportImport.ts +63 -0
  14. package/client/src/composables/useTraceFilter.ts +160 -0
  15. package/client/src/stores/observatory.ts +13 -1
  16. package/client/src/views/ComposableTracker.vue +65 -30
  17. package/client/src/views/RenderHeatmap.vue +63 -1
  18. package/client/src/views/TraceViewer.vue +1212 -0
  19. package/client/src/views/TransitionTimeline.vue +1 -6
  20. package/dist/module.d.mts +5 -0
  21. package/dist/module.json +1 -1
  22. package/dist/module.mjs +31 -45
  23. package/dist/runtime/composables/composable-registry.d.ts +19 -0
  24. package/dist/runtime/composables/composable-registry.js +63 -5
  25. package/dist/runtime/composables/render-registry.js +74 -110
  26. package/dist/runtime/composables/transition-registry.js +103 -28
  27. package/dist/runtime/instrumentation/asyncData.d.ts +9 -0
  28. package/dist/runtime/instrumentation/asyncData.js +49 -0
  29. package/dist/runtime/instrumentation/component.d.ts +2 -0
  30. package/dist/runtime/instrumentation/component.js +126 -0
  31. package/dist/runtime/instrumentation/fetch.d.ts +2 -0
  32. package/dist/runtime/instrumentation/fetch.js +89 -0
  33. package/dist/runtime/instrumentation/route.d.ts +6 -0
  34. package/dist/runtime/instrumentation/route.js +41 -0
  35. package/dist/runtime/nitro/fetch-capture.d.ts +1 -2
  36. package/dist/runtime/nitro/fetch-capture.js +85 -7
  37. package/dist/runtime/nitro/ssr-trace-store.d.ts +85 -0
  38. package/dist/runtime/nitro/ssr-trace-store.js +84 -0
  39. package/dist/runtime/plugin.js +82 -2
  40. package/dist/runtime/tracing/context.d.ts +9 -0
  41. package/dist/runtime/tracing/context.js +15 -0
  42. package/dist/runtime/tracing/trace.d.ts +25 -0
  43. package/dist/runtime/tracing/trace.js +0 -0
  44. package/dist/runtime/tracing/traceStore.d.ts +39 -0
  45. package/dist/runtime/tracing/traceStore.js +101 -0
  46. package/dist/runtime/tracing/tracing.d.ts +27 -0
  47. package/dist/runtime/tracing/tracing.js +48 -0
  48. package/package.json +5 -1
  49. package/client/.env +0 -16
  50. package/client/dist/assets/index-BCaKoHBH.js +0 -17
  51. package/client/dist/assets/index-BmGW_M3W.css +0 -1
@@ -0,0 +1,254 @@
1
+ import type { TraceEntry, TraceSpan } from '@observatory/types/snapshot'
2
+
3
+ interface SpanMetadata {
4
+ uid?: string | number
5
+ componentName?: string
6
+ file?: string
7
+ lifecycle?: string
8
+ }
9
+
10
+ export interface TraceRenderStatsRow {
11
+ componentKey: string
12
+ componentName: string
13
+ file: string
14
+ uid: string | number
15
+ mountCount: number
16
+ rerenderCount: number
17
+ totalMs: number
18
+ avgMs: number
19
+ }
20
+
21
+ export interface CrossTraceRenderSummaryRow {
22
+ componentKey: string
23
+ componentName: string
24
+ file: string
25
+ tracesSeen: number
26
+ avgRerendersPerTrace: number
27
+ totalRerenders: number
28
+ totalMs: number
29
+ avgMsPerRender: number
30
+ selectedUid?: string | number
31
+ selectedRerenders?: number
32
+ baselineRerenders?: number
33
+ deltaVsBaseline?: number
34
+ }
35
+
36
+ function asMetadata(span: TraceSpan): SpanMetadata {
37
+ return ((span.metadata as Record<string, unknown> | undefined) ?? {}) as SpanMetadata
38
+ }
39
+
40
+ function normalizeMs(value?: number): number {
41
+ return Number.isFinite(value) ? (value as number) : 0
42
+ }
43
+
44
+ function getComponentIdentity(metadata: SpanMetadata, fallbackId: string): { key: string; name: string; file: string } {
45
+ const name = String(metadata.componentName ?? '').trim()
46
+ const file = String(metadata.file ?? '').trim()
47
+
48
+ if (name && file) {
49
+ return {
50
+ key: `${file}::${name}`,
51
+ name,
52
+ file,
53
+ }
54
+ }
55
+
56
+ if (name) {
57
+ return {
58
+ key: `name::${name}`,
59
+ name,
60
+ file,
61
+ }
62
+ }
63
+
64
+ return {
65
+ key: `unknown::${fallbackId}`,
66
+ name: fallbackId,
67
+ file,
68
+ }
69
+ }
70
+
71
+ /** Build per-trace render stats grouped by component UID. */
72
+ export function buildRenderSummaryForTrace(trace?: TraceEntry): TraceRenderStatsRow[] {
73
+ if (!trace) {
74
+ return []
75
+ }
76
+
77
+ const byUid = new Map<string | number, { key: string; name: string; file: string; spans: TraceSpan[] }>()
78
+
79
+ for (const span of trace.spans) {
80
+ if (span.type !== 'render') {
81
+ continue
82
+ }
83
+
84
+ const metadata = asMetadata(span)
85
+ const uid = (metadata.uid as string | number | undefined) ?? span.id
86
+ const identity = getComponentIdentity(metadata, String(uid))
87
+
88
+ if (!byUid.has(uid)) {
89
+ byUid.set(uid, {
90
+ key: identity.key,
91
+ name: identity.name,
92
+ file: identity.file,
93
+ spans: [],
94
+ })
95
+ }
96
+
97
+ byUid.get(uid)!.spans.push(span)
98
+ }
99
+
100
+ const rows: TraceRenderStatsRow[] = []
101
+
102
+ for (const [uid, { key, name, file, spans }] of byUid) {
103
+ let mountCount = 0
104
+ let rerenderCount = 0
105
+ let totalMs = 0
106
+
107
+ for (const span of spans) {
108
+ const lifecycle = String(asMetadata(span).lifecycle ?? '')
109
+ const ms = normalizeMs(span.durationMs)
110
+
111
+ if (lifecycle === 'render:mount') {
112
+ mountCount++
113
+ } else {
114
+ rerenderCount++
115
+ }
116
+
117
+ totalMs += ms
118
+ }
119
+
120
+ const totalRenders = mountCount + rerenderCount
121
+ rows.push({
122
+ componentKey: key,
123
+ componentName: name || String(uid),
124
+ file,
125
+ uid,
126
+ mountCount,
127
+ rerenderCount,
128
+ totalMs,
129
+ avgMs: totalRenders > 0 ? totalMs / totalRenders : 0,
130
+ })
131
+ }
132
+
133
+ rows.sort((a, b) => b.rerenderCount - a.rerenderCount || b.totalMs - a.totalMs)
134
+
135
+ return rows
136
+ }
137
+
138
+ /** Build cross-trace render aggregation and comparison against a selected trace. */
139
+ export function buildCrossTraceRenderSummary(traces: TraceEntry[], selectedTraceId?: string): CrossTraceRenderSummaryRow[] {
140
+ if (traces.length === 0) {
141
+ return []
142
+ }
143
+
144
+ const aggregate = new Map<
145
+ string,
146
+ {
147
+ componentName: string
148
+ file: string
149
+ tracesSeen: number
150
+ totalRerenders: number
151
+ totalRenders: number
152
+ totalMs: number
153
+ selectedUid?: string | number
154
+ selectedRerenders?: number
155
+ }
156
+ >()
157
+
158
+ for (const trace of traces) {
159
+ const rows = buildRenderSummaryForTrace(trace)
160
+
161
+ // Collapse multiple UIDs for the same component identity within one trace.
162
+ const perTrace = new Map<
163
+ string,
164
+ { name: string; file: string; rerenders: number; renders: number; ms: number; uid: string | number; peakUidRerenders: number }
165
+ >()
166
+
167
+ for (const row of rows) {
168
+ const existing = perTrace.get(row.componentKey)
169
+
170
+ if (!existing) {
171
+ perTrace.set(row.componentKey, {
172
+ name: row.componentName,
173
+ file: row.file,
174
+ rerenders: row.rerenderCount,
175
+ renders: row.mountCount + row.rerenderCount,
176
+ ms: row.totalMs,
177
+ uid: row.uid,
178
+ peakUidRerenders: row.rerenderCount,
179
+ })
180
+ continue
181
+ }
182
+
183
+ existing.rerenders += row.rerenderCount
184
+ existing.renders += row.mountCount + row.rerenderCount
185
+ existing.ms += row.totalMs
186
+
187
+ // Keep the UID with higher re-render count for potential highlight mapping.
188
+ if (row.rerenderCount > existing.peakUidRerenders) {
189
+ existing.uid = row.uid
190
+ existing.peakUidRerenders = row.rerenderCount
191
+ }
192
+ }
193
+
194
+ for (const [componentKey, value] of perTrace) {
195
+ if (!aggregate.has(componentKey)) {
196
+ aggregate.set(componentKey, {
197
+ componentName: value.name,
198
+ file: value.file,
199
+ tracesSeen: 0,
200
+ totalRerenders: 0,
201
+ totalRenders: 0,
202
+ totalMs: 0,
203
+ })
204
+ }
205
+
206
+ const target = aggregate.get(componentKey)!
207
+ target.tracesSeen += 1
208
+ target.totalRerenders += value.rerenders
209
+ target.totalRenders += value.renders
210
+ target.totalMs += value.ms
211
+
212
+ if (selectedTraceId && trace.id === selectedTraceId) {
213
+ target.selectedUid = value.uid
214
+ target.selectedRerenders = value.rerenders
215
+ }
216
+ }
217
+ }
218
+
219
+ const rows: CrossTraceRenderSummaryRow[] = []
220
+
221
+ for (const [componentKey, value] of aggregate) {
222
+ const avgRerendersPerTrace = value.tracesSeen > 0 ? value.totalRerenders / value.tracesSeen : 0
223
+ const avgMsPerRender = value.totalRenders > 0 ? value.totalMs / value.totalRenders : 0
224
+
225
+ let baselineRerenders: number | undefined
226
+ let deltaVsBaseline: number | undefined
227
+
228
+ if (value.selectedRerenders !== undefined && value.tracesSeen > 1) {
229
+ const others = value.tracesSeen - 1
230
+ const othersTotal = value.totalRerenders - value.selectedRerenders
231
+ baselineRerenders = others > 0 ? othersTotal / others : 0
232
+ deltaVsBaseline = value.selectedRerenders - baselineRerenders
233
+ }
234
+
235
+ rows.push({
236
+ componentKey,
237
+ componentName: value.componentName,
238
+ file: value.file,
239
+ tracesSeen: value.tracesSeen,
240
+ avgRerendersPerTrace,
241
+ totalRerenders: value.totalRerenders,
242
+ totalMs: value.totalMs,
243
+ avgMsPerRender,
244
+ selectedUid: value.selectedUid,
245
+ selectedRerenders: value.selectedRerenders,
246
+ baselineRerenders,
247
+ deltaVsBaseline,
248
+ })
249
+ }
250
+
251
+ rows.sort((a, b) => b.avgRerendersPerTrace - a.avgRerendersPerTrace || b.totalMs - a.totalMs)
252
+
253
+ return rows
254
+ }
@@ -0,0 +1,63 @@
1
+ export interface ObservatoryExportFile<T> {
2
+ type: 'observatory-traces' | 'observatory-renders'
3
+ version: '1'
4
+ exportedAt: number
5
+ count: number
6
+ data: T[]
7
+ }
8
+
9
+ export function exportJson(filename: string, envelope: ObservatoryExportFile<unknown>): void {
10
+ const json = JSON.stringify(envelope, null, 2)
11
+ const blob = new Blob([json], { type: 'application/json' })
12
+ const url = URL.createObjectURL(blob)
13
+ const anchor = document.createElement('a')
14
+ anchor.href = url
15
+ anchor.download = filename
16
+ anchor.click()
17
+ URL.revokeObjectURL(url)
18
+ }
19
+
20
+ export function importJson(): Promise<unknown> {
21
+ return new Promise((resolve, reject) => {
22
+ const input = document.createElement('input')
23
+ input.type = 'file'
24
+ input.accept = '.json,application/json'
25
+
26
+ input.addEventListener('change', () => {
27
+ const file = input.files?.[0]
28
+
29
+ if (!file) {
30
+ reject(new Error('No file selected'))
31
+ return
32
+ }
33
+
34
+ const reader = new FileReader()
35
+
36
+ reader.addEventListener('load', () => {
37
+ try {
38
+ resolve(JSON.parse(reader.result as string))
39
+ } catch {
40
+ reject(new Error('Invalid JSON file'))
41
+ }
42
+ })
43
+
44
+ reader.addEventListener('error', () => reject(new Error('Failed to read file')))
45
+ reader.readAsText(file)
46
+ })
47
+
48
+ // Reject if the dialog is cancelled (focus returns without a change event)
49
+ window.addEventListener(
50
+ 'focus',
51
+ () => {
52
+ setTimeout(() => {
53
+ if (!input.files?.length) {
54
+ reject(new Error('cancelled'))
55
+ }
56
+ }, 300)
57
+ },
58
+ { once: true }
59
+ )
60
+
61
+ input.click()
62
+ })
63
+ }
@@ -0,0 +1,160 @@
1
+ import { computed, ref } from 'vue'
2
+ import type { TraceEntry } from '@observatory/types/snapshot'
3
+
4
+ export function getSpanTypesFromTraces(traces: TraceEntry[]): string[] {
5
+ const types = new Set<string>()
6
+
7
+ for (const trace of traces) {
8
+ for (const span of trace.spans) {
9
+ types.add(span.type)
10
+ }
11
+ }
12
+
13
+ return Array.from(types).sort()
14
+ }
15
+
16
+ export function useTraceFilter() {
17
+ const searchQuery = ref<string>('')
18
+ const selectedSpanTypes = ref<Set<string>>(new Set())
19
+ const minDuration = ref<number>(0)
20
+ const maxDuration = ref<number>(Infinity)
21
+ const routeFilter = ref<string>('')
22
+
23
+ function matchesSearch(trace: TraceEntry, query: string): boolean {
24
+ if (!query) {
25
+ return true
26
+ }
27
+
28
+ const lowerQuery = query.toLowerCase()
29
+
30
+ // Search in trace name
31
+ if (trace.name.toLowerCase().includes(lowerQuery)) {
32
+ return true
33
+ }
34
+
35
+ // Search in span names
36
+ for (const span of trace.spans) {
37
+ if (span.name.toLowerCase().includes(lowerQuery)) {
38
+ return true
39
+ }
40
+ }
41
+
42
+ // Search in metadata (e.g., route, URL endpoint)
43
+ for (const span of trace.spans) {
44
+ if (span.metadata) {
45
+ for (const value of Object.values(span.metadata)) {
46
+ if (typeof value === 'string' && value.toLowerCase().includes(lowerQuery)) {
47
+ return true
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ return false
54
+ }
55
+
56
+ function matchesSpanTypeFilter(trace: TraceEntry, types: Set<string>): boolean {
57
+ if (types.size === 0) return true
58
+
59
+ for (const span of trace.spans) {
60
+ if (types.has(span.type)) {
61
+ return true
62
+ }
63
+ }
64
+
65
+ return false
66
+ }
67
+
68
+ function matchesDurationFilter(trace: TraceEntry, min: number, max: number): boolean {
69
+ const hasExplicitDurationFilter = min > 0 || max < Infinity
70
+
71
+ if (trace.durationMs === undefined) {
72
+ return !hasExplicitDurationFilter
73
+ }
74
+
75
+ return trace.durationMs >= min && trace.durationMs <= max
76
+ }
77
+
78
+ function matchesRouteFilter(trace: TraceEntry, route: string): boolean {
79
+ if (!route) {
80
+ return true
81
+ }
82
+
83
+ // Check trace metadata for route
84
+ if (trace.metadata?.route && typeof trace.metadata.route === 'string') {
85
+ if (trace.metadata.route.toLowerCase().includes(route.toLowerCase())) {
86
+ return true
87
+ }
88
+ }
89
+
90
+ // Check span metadata for route or path
91
+ for (const span of trace.spans) {
92
+ if (span.metadata?.route) {
93
+ const routeValue = String(span.metadata.route).toLowerCase()
94
+
95
+ if (routeValue.includes(route.toLowerCase())) {
96
+ return true
97
+ }
98
+ }
99
+
100
+ if (span.metadata?.path) {
101
+ const pathValue = String(span.metadata.path).toLowerCase()
102
+
103
+ if (pathValue.includes(route.toLowerCase())) {
104
+ return true
105
+ }
106
+ }
107
+ }
108
+
109
+ return false
110
+ }
111
+
112
+ function filterTraces(traces: TraceEntry[]): TraceEntry[] {
113
+ return traces.filter((trace) => {
114
+ return (
115
+ matchesSearch(trace, searchQuery.value) &&
116
+ matchesSpanTypeFilter(trace, selectedSpanTypes.value) &&
117
+ matchesDurationFilter(trace, minDuration.value, maxDuration.value) &&
118
+ matchesRouteFilter(trace, routeFilter.value)
119
+ )
120
+ })
121
+ }
122
+
123
+ function toggleSpanType(type: string) {
124
+ if (selectedSpanTypes.value.has(type)) {
125
+ selectedSpanTypes.value.delete(type)
126
+ } else {
127
+ selectedSpanTypes.value.add(type)
128
+ }
129
+ }
130
+
131
+ function clearFilters() {
132
+ searchQuery.value = ''
133
+ selectedSpanTypes.value.clear()
134
+ minDuration.value = 0
135
+ maxDuration.value = Infinity
136
+ routeFilter.value = ''
137
+ }
138
+
139
+ const hasActiveFilters = computed(() => {
140
+ return (
141
+ searchQuery.value.length > 0 ||
142
+ selectedSpanTypes.value.size > 0 ||
143
+ minDuration.value > 0 ||
144
+ maxDuration.value < Infinity ||
145
+ routeFilter.value.length > 0
146
+ )
147
+ })
148
+
149
+ return {
150
+ searchQuery,
151
+ selectedSpanTypes,
152
+ minDuration,
153
+ maxDuration,
154
+ routeFilter,
155
+ filterTraces,
156
+ toggleSpanType,
157
+ clearFilters,
158
+ hasActiveFilters,
159
+ }
160
+ }
@@ -1,7 +1,15 @@
1
1
  import { ref } from 'vue'
2
2
  import { useDevtoolsClient, onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
3
3
  import type { ObservatorySnapshot, ObservatoryServerFunctions, ObservatoryClientFunctions } from '@observatory/types/rpc'
4
- import type { FetchEntry, ProvideEntry, InjectEntry, ComposableEntry, RenderEntry, TransitionEntry } from '@observatory/types/snapshot'
4
+ import type {
5
+ FetchEntry,
6
+ ProvideEntry,
7
+ InjectEntry,
8
+ ComposableEntry,
9
+ RenderEntry,
10
+ TransitionEntry,
11
+ TraceEntry,
12
+ } from '@observatory/types/snapshot'
5
13
 
6
14
  type ProvideInjectSnapshot = { provides: ProvideEntry[]; injects: InjectEntry[] }
7
15
 
@@ -10,6 +18,7 @@ const provideInject = ref<ProvideInjectSnapshot>({ provides: [], injects: [] })
10
18
  const composables = ref<ComposableEntry[]>([])
11
19
  const renders = ref<RenderEntry[]>([])
12
20
  const transitions = ref<TransitionEntry[]>([])
21
+ const traces = ref<TraceEntry[]>([])
13
22
  const connected = ref(false)
14
23
  const features = ref<ObservatorySnapshot['features']>({})
15
24
  const debugRpc = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugRpc')
@@ -50,6 +59,7 @@ function applySnapshot(data: ObservatorySnapshot) {
50
59
  composables.value = cloneArray(data.composables as ComposableEntry[] | undefined)
51
60
  renders.value = normalizeRenderEntries(data.renders as RenderEntry[] | undefined)
52
61
  transitions.value = cloneArray(data.transitions as TransitionEntry[] | undefined)
62
+ traces.value = cloneArray(data.traces as TraceEntry[] | undefined)
53
63
  features.value = data.features || {}
54
64
 
55
65
  // If the server snapshot disagrees with the user's requested mode,
@@ -79,6 +89,7 @@ function applySnapshot(data: ObservatorySnapshot) {
79
89
  composables: composables.value.length,
80
90
  renders: renders.value.length,
81
91
  transitions: transitions.value.length,
92
+ traces: traces.value.length,
82
93
  })
83
94
  }
84
95
  }
@@ -224,6 +235,7 @@ export function useObservatoryData() {
224
235
  composables,
225
236
  renders,
226
237
  transitions,
238
+ traces,
227
239
  features,
228
240
  connected,
229
241
  refresh,
@@ -6,6 +6,7 @@ import {
6
6
  editComposableValue,
7
7
  openInEditor as openInEditorFromStore,
8
8
  } from '@observatory-client/stores/observatory'
9
+ import { matchesComposableEntryQuery } from '@observatory-client/composables/composable-search'
9
10
  import type { ComposableEntry as RuntimeComposableEntry } from '@observatory/types/snapshot'
10
11
 
11
12
  const { composables: rawEntries, connected, features, clearComposables } = useObservatoryData()
@@ -118,23 +119,8 @@ const filtered = computed(() => {
118
119
  return false
119
120
  }
120
121
 
121
- const q = search.value.toLowerCase()
122
-
123
- if (q) {
124
- const matchesName = entry.name.toLowerCase().includes(q)
125
- const matchesFile = entry.componentFile.toLowerCase().includes(q)
126
- const matchesRef = Object.keys(entry.refs).some((k) => k.toLowerCase().includes(q))
127
- const matchesVal = Object.values(entry.refs).some((v) => {
128
- try {
129
- return String(JSON.stringify(v.value)).toLowerCase().includes(q)
130
- } catch {
131
- return false
132
- }
133
- })
134
-
135
- if (!matchesName && !matchesFile && !matchesRef && !matchesVal) {
136
- return false
137
- }
122
+ if (search.value.trim() && !matchesComposableEntryQuery(entry, search.value)) {
123
+ return false
138
124
  }
139
125
 
140
126
  return true
@@ -234,22 +220,67 @@ function toggleRefExpand(entryId: string, refKey: string) {
234
220
  }
235
221
 
236
222
  // ── Reverse lookup ────────────────────────────────────────────────────────
237
- // Clicking a ref key shows every mounted instance that exposes the same key.
223
+ // Clicking a ref key prefers identity-based lookup for shared/global refs.
224
+ // For non-shared keys, fallback to legacy key-name lookup.
238
225
 
239
- const lookupKey = ref<string | null>(null)
226
+ interface LookupTarget {
227
+ key: string
228
+ composableName: string
229
+ identityGroup?: string
230
+ }
231
+
232
+ const lookupTarget = ref<LookupTarget | null>(null)
240
233
 
241
234
  const lookupResults = computed(() => {
242
- if (!lookupKey.value) {
235
+ if (!lookupTarget.value) {
243
236
  return []
244
237
  }
245
238
 
246
- const key = lookupKey.value
239
+ const target = lookupTarget.value
240
+
241
+ if (target.identityGroup) {
242
+ return entries.value.filter(
243
+ (entry) =>
244
+ entry.name === target.composableName &&
245
+ entry.sharedKeyGroups?.[target.key] === target.identityGroup &&
246
+ target.key in entry.refs
247
+ )
248
+ }
247
249
 
248
- return entries.value.filter((e) => key in e.refs)
250
+ return entries.value.filter((entry) => target.key in entry.refs)
249
251
  })
250
252
 
251
- function openLookup(key: string) {
252
- lookupKey.value = lookupKey.value === key ? null : key
253
+ const lookupTitle = computed(() => {
254
+ if (!lookupTarget.value) {
255
+ return ''
256
+ }
257
+
258
+ if (lookupTarget.value.identityGroup) {
259
+ return `${lookupTarget.value.key} (shared identity)`
260
+ }
261
+
262
+ return lookupTarget.value.key
263
+ })
264
+
265
+ function openLookup(entry: RuntimeComposableEntry, key: string) {
266
+ const identityGroup = entry.sharedKeyGroups?.[key]
267
+ const next: LookupTarget = {
268
+ key,
269
+ composableName: entry.name,
270
+ identityGroup,
271
+ }
272
+
273
+ if (
274
+ lookupTarget.value?.key === next.key &&
275
+ lookupTarget.value?.composableName === next.composableName &&
276
+ lookupTarget.value?.identityGroup === next.identityGroup
277
+ ) {
278
+ lookupTarget.value = null
279
+
280
+ return
281
+ }
282
+
283
+ lookupTarget.value = next
253
284
  }
254
285
 
255
286
  // ── Inline editing ────────────────────────────────────────────────────────
@@ -408,8 +439,12 @@ function applyEdit() {
408
439
  <div v-for="[k, v] in Object.entries(entry.refs)" :key="k" class="composable-tracker__ref-row">
409
440
  <span
410
441
  class="composable-tracker__ref-key composable-tracker__ref-key--clickable mono text-sm"
411
- :title="`click to see all instances exposing '${k}'`"
412
- @click.stop="openLookup(k)"
442
+ :title="
443
+ entry.sharedKeyGroups?.[k]
444
+ ? `click to see instances sharing this exact '${k}' state`
445
+ : `click to see all instances exposing '${k}'`
446
+ "
447
+ @click.stop="openLookup(entry, k)"
413
448
  >
414
449
  {{ k }}
415
450
  </span>
@@ -530,14 +565,14 @@ function applyEdit() {
530
565
 
531
566
  <!-- ── Reverse lookup panel ──────────────────────────────────────── -->
532
567
  <Transition name="slide">
533
- <div v-if="lookupKey" class="composable-tracker__lookup-panel">
568
+ <div v-if="lookupTarget" class="composable-tracker__lookup-panel">
534
569
  <div class="composable-tracker__lookup-header">
535
- <span class="mono text-sm">{{ lookupKey }}</span>
570
+ <span class="mono text-sm">{{ lookupTitle }}</span>
536
571
  <span class="muted text-sm">— {{ lookupResults.length }} instance{{ lookupResults.length !== 1 ? 's' : '' }}</span>
537
- <button class="composable-tracker__clear-btn composable-tracker__lookup-close" @click="lookupKey = null">✕</button>
572
+ <button class="composable-tracker__clear-btn composable-tracker__lookup-close" @click="lookupTarget = null">✕</button>
538
573
  </div>
539
574
  <div v-if="!lookupResults.length" class="composable-tracker__lookup-empty muted text-sm">
540
- No mounted instances expose this key.
575
+ No instances matched this lookup.
541
576
  </div>
542
577
  <div v-for="r in lookupResults" :key="r.id" class="composable-tracker__lookup-row">
543
578
  <span class="mono text-sm">{{ r.name }}</span>