Skip to content

Commit eb3bbfa

Browse files
steveruizokclaude
andauthored
feat(fairy): add UI event tracking for fairy interactions (tldraw#7245)
Adds comprehensive event tracking for fairy-related UI interactions to enable analytics and usage insights. ### Change type - [x] `improvement` ### Test plan 1. Open a document with fairies enabled 2. Interact with fairies (select, drag, chat, etc.) 3. Verify events are tracked in analytics ### Release notes - Added event tracking for fairy UI interactions including selection, messaging, project management, navigation, and panel state changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds analytics tracking to key fairy interactions (select/drag/chat/follow/sleep/project, etc.), extends app UI event schema/sources, and updates a strict-mode test expectation. > > - **Analytics instrumentation (fairy)**: > - Add `useTldrawAppUiEvents` to `fairy` components to track interactions: selection/multi-select (`fairy-select`, `fairy-add-to-selection`, `fairy-deselect`, `fairy-select-all`), drag/long-press (`fairy-drag-start`, `fairy-panic`), navigation (`fairy-zoom-to`, `fairy-summon`, `fairy-summon-all`, `fairy-follow`, `fairy-unfollow`), chat (`fairy-send-message`, `fairy-cancel-generation`, `fairy-reset-chat`, `fairy-reset-all-chats`), state (`fairy-sleep`, `fairy-sleep-all`, `fairy-wake`), UI/panel (`fairy-switch-to-manual`, `fairy-close-manual`, `fairy-switch-manual-tab`, `click-fairy-teaser`), and projects (`fairy-start-project`, `fairy-disband-group`). > - Updated files: `fairy/Fairy.tsx`, `fairy-ui/hud/FairyHUDHeader.tsx`, `fairy-ui/FairyHUDTeaser.tsx`, `fairy-ui/hud/FairySingleChatInput.tsx`, `fairy-ui/hud/useFairySelection.ts`, `fairy-ui/menus/FairyMenuContent.tsx`, `fairy-ui/project/FairyProjectView.tsx`. > - **Event schema**: > - Extend `TLAppUiEventSource` and `TLAppUiEventMap` in `tla/utils/app-ui-events.tsx` with new fairy sources and events. > - **Tests**: > - Relax strict assertion in `packages/tldraw/src/test/TldrawEditor.test.tsx` for `onMount` under React StrictMode. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c91ea34. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2af7239 commit eb3bbfa

9 files changed

Lines changed: 160 additions & 35 deletions

File tree

apps/dotcom/client/src/fairy/Fairy.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ContextMenu as _ContextMenu } from 'radix-ui'
44
import React, { useEffect, useRef } from 'react'
55
import { Atom, TLEventInfo, getPointerInfo, useEditor, useQuickReactor, useValue } from 'tldraw'
66
import '../tla/styles/fairy.css'
7+
import { TLAppUiHandler, useTldrawAppUiEvents } from '../tla/utils/app-ui-events'
78
import { FairyAgent } from './fairy-agent/FairyAgent'
89
import { $fairyAgentsAtom } from './fairy-globals'
910
import { getProjectColor } from './fairy-helpers/getProjectColor'
@@ -46,11 +47,13 @@ function updateFairySelection(
4647
fairyAgents: FairyAgent[],
4748
clickedAgent: FairyAgent,
4849
wasSelected: boolean,
49-
isMultiSelect: boolean
50+
isMultiSelect: boolean,
51+
trackEvent: TLAppUiHandler
5052
) {
5153
if (!isMultiSelect) {
5254
// Regular click: select clicked fairy, deselect others
5355
if (!wasSelected) {
56+
trackEvent('fairy-select', { source: 'fairy-canvas', fairyId: clickedAgent.id })
5457
fairyAgents.forEach((a) => {
5558
if (a.id === clickedAgent.id) {
5659
a.$fairyEntity.update((f) => (f ? { ...f, isSelected: true } : f))
@@ -63,6 +66,7 @@ function updateFairySelection(
6366
} else {
6467
// Multi-select: add to selection if not selected
6568
if (!wasSelected) {
69+
trackEvent('fairy-add-to-selection', { source: 'fairy-canvas', fairyId: clickedAgent.id })
6670
clickedAgent.$fairyEntity.update((f) => (f ? { ...f, isSelected: true } : f))
6771
}
6872
// If already selected, do nothing (will handle deselection on pointer up)
@@ -88,7 +92,8 @@ function useFairyPointerInteraction(
8892
ref: React.RefObject<HTMLDivElement>,
8993
agent: FairyAgent,
9094
editor: ReturnType<typeof useEditor>,
91-
isFairyGrabbable: boolean
95+
isFairyGrabbable: boolean,
96+
trackEvent: TLAppUiHandler
9297
) {
9398
const interactionState = useRef<FairyInteractionState>({ status: 'idle' })
9499
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -128,6 +133,7 @@ function useFairyPointerInteraction(
128133
cleanupPointerListeners()
129134
cleanupEditorEventListener()
130135

136+
trackEvent('fairy-drag-start', { source: 'fairy-canvas', fairyId: agent.id })
131137
setFairiesToThrowTool(editor, currentState.fairiesAtPointerDown)
132138
editor.setCurrentTool('select.fairy-throw')
133139
interactionState.current = { status: 'idle' }
@@ -228,7 +234,7 @@ function useFairyPointerInteraction(
228234
const wasClickedFairySelected = clickedFairyEntity?.isSelected ?? false
229235
const isMultiSelect = e.shiftKey || e.ctrlKey || e.metaKey
230236

231-
updateFairySelection(fairyAgents, agent, wasClickedFairySelected, isMultiSelect)
237+
updateFairySelection(fairyAgents, agent, wasClickedFairySelected, isMultiSelect, trackEvent)
232238

233239
const fairiesToDrag: Atom<FairyEntity>[] = []
234240
const selectedFairies = getSelectedFairyAtoms(fairyAgents)
@@ -256,6 +262,7 @@ function useFairyPointerInteraction(
256262
const currentState = interactionState.current
257263
// Only trigger panicking if still pressed and not dragging
258264
if (currentState.status === 'pressed' && !editor.inputs.isDragging) {
265+
trackEvent('fairy-panic', { source: 'fairy-canvas', fairyId: agent.id })
259266
agent.gestureManager.push('panicking')
260267
}
261268
longPressTimerRef.current = null
@@ -275,11 +282,12 @@ function useFairyPointerInteraction(
275282
document.removeEventListener('pointermove', handlePointerMove)
276283
document.removeEventListener('pointerup', handlePointerUp)
277284
}
278-
}, [agent, editor, isFairyGrabbable, $fairyEntity, ref])
285+
}, [agent, editor, isFairyGrabbable, $fairyEntity, ref, trackEvent])
279286
}
280287

281288
export function Fairy({ agent }: { agent: FairyAgent }) {
282289
const editor = useEditor()
290+
const trackEvent = useTldrawAppUiEvents()
283291
const fairyRef = useRef<HTMLDivElement>(null)
284292
const $fairyEntity = agent.$fairyEntity
285293
const $fairyConfig = agent.$fairyConfig
@@ -322,7 +330,7 @@ export function Fairy({ agent }: { agent: FairyAgent }) {
322330
const isGenerating = useValue('is generating', () => agent.requestManager.isGenerating(), [agent])
323331
const isFairyGrabbable = isInSelectTool
324332

325-
useFairyPointerInteraction(fairyRef, agent, editor, isFairyGrabbable)
333+
useFairyPointerInteraction(fairyRef, agent, editor, isFairyGrabbable, trackEvent)
326334

327335
useQuickReactor(
328336
'fairy position',

apps/dotcom/client/src/fairy/fairy-ui/FairyHUDTeaser.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
TlaMenuTabsTabs,
2323
} from '../../tla/components/tla-menu/tla-menu'
2424
import '../../tla/styles/fairy.css'
25+
import { useTldrawAppUiEvents } from '../../tla/utils/app-ui-events'
2526
import { F, useMsg } from '../../tla/utils/i18n'
2627
import { getLocalSessionState, updateLocalSessionState } from '../../tla/utils/local-session-state'
2728
import { fairyMessages } from '../fairy-messages'
@@ -31,6 +32,7 @@ import { FairyManualPanel } from './manual/FairyManualPanel'
3132
export function FairyHUDTeaser() {
3233
// should match fairy FairyHUD, but with one fairy sprite here
3334
const editor = useEditor()
35+
const trackEvent = useTldrawAppUiEvents()
3436
const breakpoint = useBreakpoint()
3537
const isDebugMode = useValue('debug', () => editor.getInstanceState().isDebugMode, [editor])
3638
const [mobileMenuOffset, setMobileMenuOffset] = useState<number | null>(null)
@@ -53,13 +55,23 @@ export function FairyHUDTeaser() {
5355
[]
5456
)
5557

56-
const handleTabChange = useCallback((value: 'introduction' | 'usage' | 'about') => {
57-
updateLocalSessionState(() => ({ fairyManualActiveTab: value }))
58-
}, [])
58+
const handleTabChange = useCallback(
59+
(value: 'introduction' | 'usage' | 'about') => {
60+
trackEvent('fairy-switch-manual-tab', { source: 'fairy-teaser', tab: value })
61+
updateLocalSessionState(() => ({ fairyManualActiveTab: value }))
62+
},
63+
[trackEvent]
64+
)
5965

6066
const handleToggleManual = useCallback(() => {
61-
setIsManualOpen((prev) => !prev)
62-
}, [])
67+
const wasOpen = isManualOpen
68+
if (wasOpen) {
69+
trackEvent('fairy-close-manual', { source: 'fairy-teaser' })
70+
} else {
71+
trackEvent('fairy-switch-to-manual', { source: 'fairy-teaser' })
72+
}
73+
setIsManualOpen(!wasOpen)
74+
}, [trackEvent, isManualOpen])
6375

6476
// Position HUD above mobile style menu button on mobile
6577
useEffect(() => {
@@ -154,6 +166,7 @@ export function FairyHUDTeaser() {
154166
aria-label="Fairies"
155167
value="off"
156168
onClick={() => {
169+
trackEvent('click-fairy-teaser', { source: 'fairy-teaser' })
157170
addDialog({
158171
component: FairyComingSoonDialog,
159172
})

apps/dotcom/client/src/fairy/fairy-ui/hud/FairyHUDHeader.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
TlaMenuTabsTab,
1515
TlaMenuTabsTabs,
1616
} from '../../../tla/components/tla-menu/tla-menu'
17+
import { useTldrawAppUiEvents } from '../../../tla/utils/app-ui-events'
1718
import { F } from '../../../tla/utils/i18n'
1819
import {
1920
getLocalSessionState,
@@ -45,6 +46,7 @@ export function FairyHUDHeader({
4546
isMobile,
4647
onToggleManual,
4748
}: FairyHUDHeaderProps) {
49+
const trackEvent = useTldrawAppUiEvents()
4850
const fairyConfig = useValue('fairy config', () => shownFairy?.$fairyConfig.get(), [shownFairy])
4951

5052
// Get the project for the shown fairy
@@ -73,8 +75,9 @@ export function FairyHUDHeader({
7375
const zoomToFairy = useCallback(() => {
7476
if (!fairyClickable || !shownFairy) return
7577

78+
trackEvent('fairy-zoom-to', { source: 'fairy-panel', fairyId: shownFairy.id })
7679
shownFairy.positionManager.zoomTo()
77-
}, [shownFairy, fairyClickable])
80+
}, [shownFairy, fairyClickable, trackEvent])
7881

7982
// const hasChatHistory = useValue(
8083
// 'has-chat-history',
@@ -115,15 +118,20 @@ export function FairyHUDHeader({
115118
[]
116119
)
117120

118-
const handleTabChange = useCallback((value: 'introduction' | 'usage' | 'about') => {
119-
updateLocalSessionState(() => ({ fairyManualActiveTab: value }))
120-
}, [])
121+
const handleTabChange = useCallback(
122+
(value: 'introduction' | 'usage' | 'about') => {
123+
trackEvent('fairy-switch-manual-tab', { source: 'fairy-panel', tab: value })
124+
updateLocalSessionState(() => ({ fairyManualActiveTab: value }))
125+
},
126+
[trackEvent]
127+
)
121128

122129
const selectAllFairies = useCallback(() => {
130+
trackEvent('fairy-select-all', { source: 'fairy-panel' })
123131
allAgents.forEach((agent) => {
124132
agent.$fairyEntity.update((f) => (f ? { ...f, isSelected: true } : f))
125133
})
126-
}, [allAgents])
134+
}, [allAgents, trackEvent])
127135

128136
if (panelState === 'manual') {
129137
return (
@@ -220,13 +228,15 @@ function FairyDropdownContent({ agents }: { agents: FairyAgent[] }) {
220228
}
221229

222230
function ResetChatHistoryButton({ agent }: { agent: FairyAgent }) {
231+
const trackEvent = useTldrawAppUiEvents()
223232
return (
224233
<TldrawUiButton
225234
type="icon"
226235
className="fairy-toolbar-button"
227236
// Maybe needs to be reactive
228237
disabled={agent.chatManager.getHistory().length === 0}
229238
onClick={() => {
239+
trackEvent('fairy-reset-chat', { source: 'fairy-panel', fairyId: agent.id })
230240
agent.chatManager.reset()
231241
}}
232242
>

apps/dotcom/client/src/fairy/fairy-ui/hud/FairySingleChatInput.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CancelIcon, FAIRY_VISION_DIMENSIONS, LipsIcon } from '@tldraw/fairy-shared'
22
import { KeyboardEvent, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
33
import { Box, useEditor, useValue } from 'tldraw'
4+
import { useTldrawAppUiEvents } from '../../../tla/utils/app-ui-events'
45
import { getIsCoarsePointer } from '../../../tla/utils/getIsCoarsePointer'
56
import { useMsg } from '../../../tla/utils/i18n'
67
import { FairyAgent } from '../../fairy-agent/FairyAgent'
@@ -9,6 +10,7 @@ import { fairyMessages } from '../../fairy-messages'
910

1011
export function FairySingleChatInput({ agent, onCancel }: { agent: FairyAgent; onCancel(): void }) {
1112
const editor = useEditor()
13+
const trackEvent = useTldrawAppUiEvents()
1214
const textareaRef = useRef<HTMLTextAreaElement>(null)
1315
const [inputValue, setInputValue] = useState('')
1416
const isGenerating = useValue('isGenerating', () => agent.requestManager.isGenerating(), [agent])
@@ -62,9 +64,10 @@ export function FairySingleChatInput({ agent, onCancel }: { agent: FairyAgent; o
6264
// Clear the input
6365
setInputValue('')
6466

67+
trackEvent('fairy-send-message', { source: 'fairy-chat', fairyId: agent.id })
6568
handlePrompt(value)
6669
},
67-
[handlePrompt]
70+
[handlePrompt, trackEvent, agent.id]
6871
)
6972

7073
// Handle keyboard input for Enter and Shift+Enter
@@ -92,6 +95,7 @@ export function FairySingleChatInput({ agent, onCancel }: { agent: FairyAgent; o
9295
const handleButtonClick = () => {
9396
if (showCancel) {
9497
// Hard stop - cancel only, don't send
98+
trackEvent('fairy-cancel-generation', { source: 'fairy-chat', fairyId: agent.id })
9599
agent.cancel()
96100
} else {
97101
// Send (will interrupt if generating)

apps/dotcom/client/src/fairy/fairy-ui/hud/useFairySelection.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { FairyProject } from '@tldraw/fairy-shared'
22
import { MouseEvent, useCallback, useEffect, useState } from 'react'
33
import { useValue } from 'tldraw'
4+
import { useTldrawAppUiEvents } from '../../../tla/utils/app-ui-events'
45
import { FairyAgent } from '../../fairy-agent/FairyAgent'
56
import { getProjectOrchestrator } from '../../fairy-projects'
67

78
export type FairyHUDPanelState = 'fairy' | 'manual' | 'closed'
89

910
export function useFairySelection(agents: FairyAgent[]) {
11+
const trackEvent = useTldrawAppUiEvents()
1012
const [manualOpen, setManualOpen] = useState(false)
1113
const [shownFairy, setShownFairy] = useState<FairyAgent | null>(null)
1214

@@ -162,6 +164,8 @@ export function useFairySelection(agents: FairyAgent[]) {
162164

163165
const handleDoubleClickFairy = useCallback(
164166
(clickedAgent: FairyAgent) => {
167+
trackEvent('fairy-double-click', { source: 'fairy-sidebar', fairyId: clickedAgent.id })
168+
trackEvent('fairy-zoom-to', { source: 'fairy-sidebar', fairyId: clickedAgent.id })
165169
clickedAgent.positionManager.zoomTo()
166170

167171
// If the clicked fairy is part of an active project, select the orchestrator instead
@@ -179,13 +183,19 @@ export function useFairySelection(agents: FairyAgent[]) {
179183

180184
selectFairy(clickedAgent)
181185
},
182-
[selectFairy, agents]
186+
[selectFairy, agents, trackEvent]
183187
)
184188

185189
const handleToggleManual = useCallback(() => {
186190
// Close manual if open, otherwise deselect all fairies
187-
setManualOpen((prev) => !prev)
188-
}, [])
191+
const wasOpen = manualOpen
192+
if (wasOpen) {
193+
trackEvent('fairy-close-manual', { source: 'fairy-panel' })
194+
} else {
195+
trackEvent('fairy-switch-to-manual', { source: 'fairy-panel' })
196+
}
197+
setManualOpen(!wasOpen)
198+
}, [trackEvent, manualOpen])
189199

190200
return {
191201
panelState,

0 commit comments

Comments
 (0)