Skip to content

Commit 74c0113

Browse files
mimecuvaloTodePondmax-drakeds300
authored
fairies: pixie edition (tldraw#7102)
### Change type - [ ] `bugfix` - [ ] `improvement` - [ ] `feature` - [ ] `api` - [x] `other` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Replaces shared todos with a per-page Fairy Tasks/Projects system and refactors agents to new mode/actions architecture, alongside significant UI, persistence, worker, and editor API updates. > > - **Architecture/Agent**: > - Introduce mode-based agent framework (`idling/soloing/working/orchestrating`) via `FairyModeDefinition`; remove wands. > - Consolidate and expand action schemas (tasks, projects, pages, sleep, etc.); update prompt part system with definitions and logging/debug flags. > - Track cumulative token usage; ensure fairies act on correct page; add follow/summon behavior. > - **Data/Persistence**: > - Replace shared todos with `FairyTask` and `FairyProject`; persist tasks/projects and update `PersistedFairyState`. > - Filter fairies/tasks by current page. > - **UI**: > - New task list (inline, dropdown, context menu), in-canvas task rendering, revamped HUD/sidebar, group chat updates, i18n labels, and a comprehensive Fairy Debug dialog. > - Context/dropdown z-index fixes; select tool namespaced (`select.fairy-throw`, `select.task-drag`). > - **Worker/Backend**: > - Add admin detection/header; optional logging of system prompt/messages; stream usage metadata. > - **Editor API**: > - `setTool`/`removeTool` accept optional parent `StateNode`; add tests. > - **Fixes**: > - Safe label updates only for shapes supporting `richText`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4637dad. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Lu[ke] Wilson <l2wilson94@gmail.com> Co-authored-by: Max Drake <maxdrake46@gmail.com> Co-authored-by: David Sheldrick <d.j.sheldrick@gmail.com>
1 parent 71de3c1 commit 74c0113

152 files changed

Lines changed: 5229 additions & 3319 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
1-
import { useMemo } from 'react'
1+
import { useEditor, useValue } from 'tldraw'
22
import Fairy from './Fairy'
33
import { FairyAgent } from './fairy-agent/agent/FairyAgent'
44

55
export function Fairies({ agents }: { agents: FairyAgent[] }) {
6-
const activeAgents = useMemo(
7-
() => agents.filter((agent) => agent.$fairyEntity.get() !== undefined),
8-
[agents]
6+
const editor = useEditor()
7+
const currentPageId = useValue('current page id', () => editor.getCurrentPageId(), [editor])
8+
9+
// Reactively filter fairies based on current page and each fairy's currentPageId
10+
const activeAgents = useValue(
11+
'active fairies on page',
12+
() => {
13+
return agents.filter((agent) => {
14+
const entity = agent.$fairyEntity.get()
15+
// Only show fairies that exist and are on the current page
16+
return entity !== undefined && entity.currentPageId === currentPageId
17+
})
18+
},
19+
[agents, currentPageId]
920
)
1021

1122
return (

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

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ export default function Fairy({ agent }: { agent: FairyAgent }) {
3636
const flipX = useValue('fairy flipX', () => fairy.get()?.flipX ?? false, [fairy])
3737
const isSelected = useValue('fairy isSelected', () => fairy.get()?.isSelected ?? false, [fairy])
3838
const isInSelectTool = useValue('is in select tool', () => editor.isIn('select.idle'), [editor])
39+
const isInThrowTool = useValue('is in throw tool', () => editor.isIn('select.fairy-throw'), [
40+
editor,
41+
])
3942
const isGenerating = useValue('is generating', () => agent.isGenerating(), [agent])
40-
const isFairyGrabbable = !isGenerating && isInSelectTool
43+
const isFairyGrabbable = isInSelectTool
4144

4245
// Listen to brush selection events and update fairy selection
4346
const brush = useValue('editor brush', () => editor.getInstanceState().brush, [editor])
@@ -124,12 +127,7 @@ export default function Fairy({ agent }: { agent: FairyAgent }) {
124127
// Skip dragging behavior on right-click (context menu will handle it)
125128
if (e.button === 2) return
126129
if (!editor.isIn('select.idle')) return
127-
if (editor.getCurrentTool().id === 'fairy-throw') return
128-
129-
// right now we don't have a way for the fairy to break out of a user's grasp,
130-
// but currently the UI is structured such that you cant be dragging the fairy
131-
// when you press generate, so this is enough for now
132-
if (agent.isGenerating()) return
130+
if (editor.isIn('select.fairy-throw')) return
133131

134132
// Determine which fairies to drag before updating selection
135133
const fairyAgents = $fairyAgentsAtom.get(editor)
@@ -199,11 +197,11 @@ export default function Fairy({ agent }: { agent: FairyAgent }) {
199197
document.removeEventListener('pointerup', handlePointerUp)
200198

201199
// Activate the tool with all fairies that were selected at pointer down
202-
const tool = editor.getStateDescendant('fairy-throw')
200+
const tool = editor.getStateDescendant('select.fairy-throw')
203201
if (tool && 'setFairies' in tool) {
204202
;(tool as FairyThrowTool).setFairies(fairiesToDrag)
205203
}
206-
editor.setCurrentTool('fairy-throw')
204+
editor.setCurrentTool('select.fairy-throw')
207205
}
208206
}
209207

@@ -236,7 +234,10 @@ export default function Fairy({ agent }: { agent: FairyAgent }) {
236234
height: `${FAIRY_SIZE}px`,
237235
transform: `translate(-75%, -25%) scale(var(--tl-scale)) ${flipX ? ' scaleX(-1)' : ''}`,
238236
transformOrigin: '75% 25%',
239-
transition: isGenerating ? 'left 0.1s ease-in-out, top 0.1s ease-in-out' : 'none',
237+
transition:
238+
isGenerating && !isInThrowTool
239+
? 'left 0.1s ease-in-out, top 0.1s ease-in-out'
240+
: 'none',
240241
}}
241242
className={isSelected ? 'fairy-selected' : ''}
242243
>

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

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import {
44
PersistedFairyState,
55
} from '@tldraw/fairy-shared'
66
import { useCallback, useEffect, useRef } from 'react'
7-
import { react, throttle, useEditor, useValue } from 'tldraw'
7+
import { react, throttle, useEditor, useToasts, useValue } from 'tldraw'
88
import { useApp } from '../tla/hooks/useAppState'
99
import { useTldrawUser } from '../tla/hooks/useUser'
1010
import { FairyAgent } from './fairy-agent/agent/FairyAgent'
11+
import { $fairyProjects } from './FairyProjects'
12+
import { FairyTaskDragTool } from './FairyTaskDragTool'
13+
import { $fairyTasks, $showCanvasFairyTasks } from './FairyTaskList'
1114
import { FairyThrowTool } from './FairyThrowTool'
12-
import { $sharedTodoList, $showCanvasTodos } from './SharedTodoList'
13-
import { TodoDragTool } from './TodoDragTool'
1415

1516
export function FairyApp({
1617
setAgents,
@@ -22,6 +23,7 @@ export function FairyApp({
2223
const editor = useEditor()
2324
const user = useTldrawUser()
2425
const app = useApp()
26+
const toasts = useToasts()
2527
const fairyConfigs = useValue(
2628
'fairyConfigs',
2729
() => JSON.parse(app?.getUser().fairies || '{}') as PersistedFairyConfigs,
@@ -33,9 +35,18 @@ export function FairyApp({
3335
return await user.getToken()
3436
}, [user])
3537

36-
const handleError = useCallback((e: any) => {
37-
console.error('Error:', e)
38-
}, [])
38+
const handleError = useCallback(
39+
(e: any) => {
40+
const message = typeof e === 'string' ? e : e instanceof Error && e.message
41+
toasts.addToast({
42+
title: 'Error',
43+
description: message || 'An error occurred',
44+
severity: 'error',
45+
})
46+
console.error(e)
47+
},
48+
[toasts]
49+
)
3950

4051
// Track whether we're currently loading state to prevent premature saves
4152
const isLoadingStateRef = useRef(false)
@@ -45,18 +56,20 @@ export function FairyApp({
4556
const loadedAgentIdsRef = useRef<Set<string>>(new Set())
4657
const sharedTodoListLoadedRef = useRef(false)
4758
const showCanvasTodosLoadedRef = useRef(false)
59+
const projectsLoadedRef = useRef(false)
4860

4961
// Create agents dynamically from configs
5062
useEffect(() => {
5163
if (!editor) return
5264

5365
// Register the FairyThrowTool
54-
editor.removeTool(FairyThrowTool)
55-
editor.setTool(FairyThrowTool)
66+
const selectTool = editor.root.children!.select
67+
editor.removeTool(FairyThrowTool, selectTool)
68+
editor.setTool(FairyThrowTool, selectTool)
5669

5770
// Register the TodoDragTool
58-
editor.removeTool(TodoDragTool)
59-
editor.setTool(TodoDragTool)
71+
editor.removeTool(FairyTaskDragTool, selectTool)
72+
editor.setTool(FairyTaskDragTool, selectTool)
6073

6174
const configIds = Object.keys(fairyConfigs)
6275
const existingAgents = agentsRef.current
@@ -105,7 +118,7 @@ export function FairyApp({
105118

106119
// Load fairy state from backend when agents are created
107120
useEffect(() => {
108-
if (!app || agentsRef.current.length === 0 || !$sharedTodoList || !$showCanvasTodos || !fileId)
121+
if (!app || agentsRef.current.length === 0 || !$fairyTasks || !$showCanvasFairyTasks || !fileId)
109122
return
110123

111124
const fileState = app.getFileState(fileId)
@@ -130,16 +143,22 @@ export function FairyApp({
130143

131144
// Load shared todo list only once
132145
if (fairyState.sharedTodoList && !sharedTodoListLoadedRef.current) {
133-
$sharedTodoList.set(fairyState.sharedTodoList)
146+
$fairyTasks.set(fairyState.sharedTodoList)
134147
sharedTodoListLoadedRef.current = true
135148
}
136149

137150
// Load show canvas todos only once
138151
if (fairyState.showCanvasTodos && !showCanvasTodosLoadedRef.current) {
139-
$showCanvasTodos.set(fairyState.showCanvasTodos)
152+
$showCanvasFairyTasks.set(fairyState.showCanvasTodos)
140153
showCanvasTodosLoadedRef.current = true
141154
}
142155

156+
// Load projects only once
157+
if (fairyState.projects && !projectsLoadedRef.current) {
158+
$fairyProjects.set(fairyState.projects)
159+
projectsLoadedRef.current = true
160+
}
161+
143162
// Allow a tick for state to settle before allowing saves
144163
setTimeout(() => {
145164
isLoadingStateRef.current = false
@@ -154,8 +173,7 @@ export function FairyApp({
154173
// Todo: Use FileStateUpdater for this
155174
// Save fairy state to backend periodically
156175
useEffect(() => {
157-
if (!app || agentsRef.current.length === 0 || !$sharedTodoList || !$showCanvasTodos || !fileId)
158-
return
176+
if (!app || agentsRef.current.length === 0 || !fileId) return
159177

160178
const updateFairyState = throttle(() => {
161179
// Don't save if we're currently loading state
@@ -169,8 +187,9 @@ export function FairyApp({
169187
},
170188
{} as Record<string, PersistedFairyAgentState>
171189
),
172-
sharedTodoList: $sharedTodoList.get(),
173-
showCanvasTodos: $showCanvasTodos.get(),
190+
sharedTodoList: $fairyTasks.get(),
191+
showCanvasTodos: $showCanvasFairyTasks.get(),
192+
projects: $fairyProjects.get(),
174193
}
175194
app.onFairyStateUpdate(fileId, fairyState)
176195
}, 2000) // Save maximum every 2 seconds
@@ -182,26 +201,21 @@ export function FairyApp({
182201
agent.$fairyEntity.get()
183202
agent.$chatHistory.get()
184203
agent.$todoList.get()
185-
agent.$contextItems.get()
186204
updateFairyState()
187205
})
188206
fairyCleanupFns.push(cleanup)
189207
})
190208

191-
const cleanupSharedTodoList = react('shared todo list', () => {
192-
$sharedTodoList.get()
193-
updateFairyState()
194-
})
195-
196-
const cleanupShowCanvasTodos = react('show canvas todos', () => {
197-
$showCanvasTodos.get()
209+
const cleanupSharedFairyState = react('shared fairy atom state', () => {
210+
$fairyTasks.get()
211+
$showCanvasFairyTasks.get()
212+
$fairyProjects.get()
198213
updateFairyState()
199214
})
200215

201216
return () => {
202217
updateFairyState.flush()
203-
cleanupSharedTodoList()
204-
cleanupShowCanvasTodos()
218+
cleanupSharedFairyState()
205219
fairyCleanupFns.forEach((cleanup) => cleanup())
206220
}
207221
}, [app, fairyConfigs, fileId])

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

Lines changed: 22 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -10,62 +10,50 @@ import {
1010
TldrawUiInput,
1111
useValue,
1212
} from 'tldraw'
13+
import { F, useMsg } from '../tla/utils/i18n'
1314
import { FairyAgent } from './fairy-agent/agent/FairyAgent'
15+
import { fairyMessages } from './fairy-messages'
1416

1517
export function FairyConfigDialog({ agent, onClose }: { agent: FairyAgent; onClose(): void }) {
1618
const config = useValue(agent.$fairyConfig)
17-
// const currentMode = getFairyMode(config.mode)
18-
// const availableWands = currentMode.availableWands
19+
20+
const fairyNamePlaceholder = useMsg(fairyMessages.fairyNamePlaceholder)
21+
const fairyPersonalityPlaceholder = useMsg(fairyMessages.fairyPersonalityPlaceholder)
1922

2023
return (
2124
<>
2225
<TldrawUiDialogHeader>
23-
<TldrawUiDialogTitle>Fairy customization</TldrawUiDialogTitle>
26+
<TldrawUiDialogTitle>
27+
<F defaultMessage="Fairy customization" />
28+
</TldrawUiDialogTitle>
2429
<TldrawUiDialogCloseButton />
2530
</TldrawUiDialogHeader>
2631
<TldrawUiDialogBody style={{ maxWidth: 400 }}>
2732
<div
2833
className="fairy-config-dialog"
2934
style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
3035
>
31-
<label htmlFor="name">Name</label>
36+
<label htmlFor="name">
37+
<F defaultMessage="Name" />
38+
</label>
3239
<TldrawUiInput
3340
className="fairy-config-input"
3441
value={config.name}
3542
onValueChange={(value) => agent.updateFairyConfig({ name: value })}
36-
placeholder="Fairy's name"
43+
placeholder={fairyNamePlaceholder}
3744
/>
38-
<label htmlFor="name">Personality</label>
45+
<label htmlFor="name">
46+
<F defaultMessage="Personality" />
47+
</label>
3948
<TldrawUiInput
4049
className="fairy-config-input"
4150
value={config.personality}
4251
onValueChange={(value) => agent.updateFairyConfig({ personality: value })}
43-
placeholder="Fairy's personality"
52+
placeholder={fairyPersonalityPlaceholder}
4453
/>
45-
{/* <label htmlFor="mode">Mode</label>
46-
<select
47-
id="mode"
48-
value={config.mode}
49-
onChange={(e) => {
50-
const newMode = getFairyMode(e.target.value as typeof config.mode)
51-
const newConfig = {
52-
...config,
53-
mode: newMode.id,
54-
// If current wand isn't available in new mode, use the mode's default wand
55-
wand: (newMode.availableWands as readonly Wand['type'][]).includes(config.wand)
56-
? config.wand
57-
: newMode.defaultWand,
58-
}
59-
agent.$fairyConfig.set(newConfig)
60-
}}
61-
>
62-
{FAIRY_MODE_DEFINITIONS.map((mode) => (
63-
<option key={mode.id} value={mode.id}>
64-
{mode.id.charAt(0).toUpperCase() + mode.id.slice(1)}
65-
</option>
66-
))}
67-
</select> */}
68-
<label htmlFor="hat">Hat</label>
54+
<label htmlFor="hat">
55+
<F defaultMessage="Hat" />
56+
</label>
6957
<select
7058
id="hat"
7159
value={config.outfit.hat}
@@ -81,28 +69,13 @@ export function FairyConfigDialog({ agent, onClose }: { agent: FairyAgent; onClo
8169
</option>
8270
))}
8371
</select>
84-
{/* <label htmlFor="wand">Wand</label>
85-
<select
86-
id="wand"
87-
value={config.wand}
88-
onChange={(e) => {
89-
agent.$fairyConfig.set({ ...config, wand: e.target.value as Wand['type'] })
90-
}}
91-
>
92-
{WAND_DEFINITIONS.map((wand) => {
93-
const isAvailable = (availableWands as readonly Wand['type'][]).includes(wand.type)
94-
return (
95-
<option key={wand.type} value={wand.type} disabled={!isAvailable}>
96-
{wand.name} — {wand.description}
97-
</option>
98-
)
99-
})}
100-
</select> */}
10172
</div>
10273
</TldrawUiDialogBody>
10374
<TldrawUiDialogFooter className="tlui-dialog__footer__actions">
10475
<TldrawUiButton type="normal" onClick={onClose}>
105-
<TldrawUiButtonLabel>Close</TldrawUiButtonLabel>
76+
<TldrawUiButtonLabel>
77+
<F defaultMessage="Close" />
78+
</TldrawUiButtonLabel>
10679
</TldrawUiButton>
10780
</TldrawUiDialogFooter>
10881
</>

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ export function FairyContextMenuContent({ agent }: { agent: FairyAgent }) {
1111
<_ContextMenu.Content
1212
className="tlui-menu fairy-sidebar-dropdown"
1313
collisionPadding={4}
14-
onClick={(e) => e.stopPropagation()}
15-
style={{ zIndex: 10000000 }}
14+
onPointerDown={(e) => e.stopPropagation()}
15+
style={{ zIndex: 'var(--tl-layer-canvas-in-front)' }}
1616
>
1717
<FairyMenuContent agent={agent} menuType="context-menu" />
1818
</_ContextMenu.Content>

0 commit comments

Comments
 (0)