Building the Tree
This phase produced NestedSortableTree.tsx, the component
that ended up handling the entire creative graph UI. It started simple.
It didn't stay that way – and I was uncomfortable with how heavy it
became. But refactoring would have taken significant time away from
exploration at a stage where the app was still being shaped. The
technical debt was accepted deliberately.
The First Tree
Commit: 592ac7a – "Enhanced Tree Component with Insertion Line Drop Targets"
The initial implementation used @dnd-kit for drag-and-drop, with
insertion line indicators showing exactly where a dragged node would land:
- Insertion line drop targets: Blue visual indicators showing where items would land
- Before/after positioning: Drop above or below existing nodes
- Depth awareness: Indentation showing hierarchy level
- Animated pulse effects: Visual feedback during drag operations
- Collision detection: Changed from
closestCentertopointerWithinfor precision
1
2
3
4
5
6
7
ThoughtNode "Project Ideas"
├─ before indicator
├─ PromptNode "What if...?"
│ ├─ InsightNode "Interesting connection"
│ └─ after indicator
├─ PromptNode "Consider this..."
└─ after indicator
Node Capabilities System
Commit: f0b6e46 – "Implement Node Capabilities System with BaseNode Refactor"
Not all node types can have all children – and that was a deliberate UX
decision, not just a data model constraint. The goal was enough structure
to guide the user through idea-chaining without so many options that
choosing the next node type became its own decision. The capabilities
existed informally before this commit; the refactor gave them a proper
type system and a single source of truth in DEFAULT_NODE_CAPABILITIES.
Kept frontend-only by design – the API could mirror it later, but keeping
it in the UI allowed experimentation without locking in the schema.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
export const DEFAULT_NODE_CAPABILITIES: NodeCapabilityConfig = {
thought: {
canHaveChildren: true,
allowedChildTypes: ['prompt', 'insight', 'result'],
allowedActions: ['add_child', 'edit', 'delete', 'duplicate'],
canBeEdited: true,
canBeDeleted: true,
},
prompt: {
canHaveChildren: true,
allowedChildTypes: ['insight', 'result'],
allowedActions: ['add_child', 'favorite', 'delete', 'duplicate'],
canBeEdited: false,
canBeDeleted: true,
},
insight: {
canHaveChildren: true,
allowedChildTypes: ['prompt', 'insight', 'result'],
allowedActions: ['add_child', 'edit', 'delete', 'duplicate'],
canBeEdited: true,
canBeDeleted: true,
},
result: {
canHaveChildren: false,
allowedChildTypes: [],
allowedActions: ['edit', 'delete', 'duplicate'],
canBeEdited: true,
canBeDeleted: true,
},
};
The shape of this changed considerably by Phase 9 – edge rules and additional node types were added as the graph model matured.
Feature-Based Restructure
Commit: 8cce63f – "Restructured to feature/domain based directory and file layout"
As the tree grew more complex, the codebase was reorganised:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
src/
├── features/
│ ├── editor/ # Node editing forms
│ ├── nodes/ # Node component library
│ │ ├── components/ # ThoughtNode, PromptNode, InsightNode, ResultNode, BaseNode
│ │ ├── types/
│ │ └── utils/
│ └── tree/ # The tree component
│ ├── components/ # NestedSortableTree, SortableTreeNode, InsertionIndicator, NodeDropTarget
│ ├── services/ # NodeWorkflowManager
│ └── utils/ # TreeDataAdapter
└── shared/
├── api/
└── types/
Graph Integration & the Edge Manager
Commits: 4b7d3d6, eeee0d3
The tree needed to sync with the backend's edge system. Two managers were created:
NodeWorkflowManager
Type-safe tree operations:
addNode(): insert new node with validationmoveNode(): reorder or reparent nodesremoveNode(): delete with cascade handlingupdateNode(): modify node contentfindNodeById(): recursive tree searchwouldCreateCircularReference(): prevent infinite loops
EdgeManager (later removed)
A separate EdgeManager handled edge creation/deletion initially. It was removed because strict SOC was getting in the way of actually writing the app – the boundary between tree mutations and edge mutations was too blurry to enforce cleanly without the full architectural picture. Not my usual approach, but I believed it was the right call at this stage. The edges were folded back into the mutation pipeline and properly addressed in Phase 6.
API Integration
Commit: 8c8ea27 – "Set up API integration infrastructure and restructure TreeTest page"
The tree component was connected to the FastAPI backend. Drag operations now synced with Firestore rather than just updating local state.
Optimistic updates weren't really a choice – waiting for a server round-trip on every drag or node creation would break the flow the app was built around. The UI updated immediately; the API caught up in the background. That decision defined the next several weeks of work.
Optimistic Updates & Mutation Queue
Commits: b2fd496, c32d085
Creating a node optimistically meant giving it a client-side
temp_abc123 ID immediately, inserting it into the tree, then
replacing that with the real Firestore ID once the API responded – recursively
across the whole tree:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const replaceNodeIds = (items: TreeItemData[]): TreeItemData[] => {
return items.map(item => {
const newId = mappings.get(item.id);
const updatedItem = newId
? { ...item, id: newId, nodeData: { ...item.nodeData, id: newId } }
: item;
if (updatedItem.children && updatedItem.children.length > 0) {
return {
...updatedItem,
children: replaceNodeIds(updatedItem.children) // Recurse!
};
}
return updatedItem;
});
};
Mutation Queue
A debounced queue prevented API spam during rapid drag operations – local state updated immediately, deltas synced after 200ms of inactivity, sending only changed edges rather than the full tree.
Batched Deletion & Tombstones
Commits: 4eb16dd, b3b89b1
Deleting nodes optimistically had its own problem: what if the server
rejected the delete? The tombstone pattern solved it – a node was added
to a pendingDeletions[] array and hidden from the UI immediately,
then the delete was sent to the API after 200ms. On success it was removed
from the tree; on failure it was quietly restored. No flicker.
The 23 Hooks
By end of August the component worked. But 23 hooks in a single component felt like a code smell – I was new enough to React that I couldn't fully articulate why, just that the quantity felt off. The more immediate problem was a ~1 second drag activation lag. Phase 5 was about that.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// State Management
useState × 5 (treeItems, activeId, pendingNodes, pendingDeletions, hasOptimisticUpdates)
// Refs
useRef × 5 (previousTreeRef, debounceTimerRef, lastCreatedNodeIdRef,
onNodesChangeRef, onEdgesChangeRef)
// Memoization
useMemo × 4 (sortableIds, filteredTree, updateTreeMutations, dndSensors)
// Callbacks
useCallback × 6 (handleDragStart, handleDragEnd, onNodeCreate, onNodeDelete,
fetchAISuggestions, applyTombstones)
// Effects
useEffect × 3 (sync from props, cleanup timers, reconcile server state)