@affectively/aeon-pages 1.3.0 → 1.4.0

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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Provides:
5
5
  * - Real-time sync via Aeon SyncCoordinator
6
- * - Presence tracking via AgentPresenceManager
6
+ * - Presence tracking via WebSocket channel
7
7
  * - Offline support via OfflineOperationQueue
8
8
  * - Schema versioning via SchemaVersionManager
9
9
  */
@@ -17,14 +17,73 @@ import React, {
17
17
  useRef,
18
18
  type ReactNode,
19
19
  } from 'react';
20
- import { getSyncCoordinator } from '@affectively/aeon-pages-runtime/sync/coordinator';
21
- import { getOfflineQueue } from '@affectively/aeon-pages-runtime/offline/encrypted-queue';
20
+ import {
21
+ getSyncCoordinator,
22
+ getOfflineQueue,
23
+ } from '@affectively/aeon-pages-runtime';
22
24
 
23
25
  // Types
26
+ export interface PresenceSelection {
27
+ start: number;
28
+ end: number;
29
+ direction?: 'forward' | 'backward' | 'none';
30
+ path?: string;
31
+ }
32
+
33
+ export interface PresenceTyping {
34
+ isTyping: boolean;
35
+ field?: string;
36
+ isComposing?: boolean;
37
+ startedAt?: string;
38
+ stoppedAt?: string;
39
+ }
40
+
41
+ export interface PresenceScroll {
42
+ depth: number;
43
+ y?: number;
44
+ viewportHeight?: number;
45
+ documentHeight?: number;
46
+ path?: string;
47
+ }
48
+
49
+ export interface PresenceViewport {
50
+ width: number;
51
+ height: number;
52
+ }
53
+
54
+ export interface PresenceInputState {
55
+ field: string;
56
+ hasFocus: boolean;
57
+ valueLength?: number;
58
+ selectionStart?: number;
59
+ selectionEnd?: number;
60
+ isComposing?: boolean;
61
+ inputMode?: string;
62
+ }
63
+
64
+ export interface PresenceEmotion {
65
+ primary?: string;
66
+ secondary?: string;
67
+ confidence?: number;
68
+ intensity?: number;
69
+ valence?: number;
70
+ arousal?: number;
71
+ dominance?: number;
72
+ source?: 'self-report' | 'inferred' | 'sensor' | 'hybrid';
73
+ updatedAt?: string;
74
+ }
75
+
24
76
  export interface PresenceUser {
25
77
  userId: string;
26
78
  role: 'user' | 'assistant' | 'monitor' | 'admin';
27
79
  cursor?: { x: number; y: number };
80
+ focusNode?: string;
81
+ selection?: PresenceSelection;
82
+ typing?: PresenceTyping;
83
+ scroll?: PresenceScroll;
84
+ viewport?: PresenceViewport;
85
+ inputState?: PresenceInputState;
86
+ emotion?: PresenceEmotion;
28
87
  editing?: string;
29
88
  status: 'online' | 'away' | 'offline';
30
89
  lastActivity: string;
@@ -51,12 +110,24 @@ export interface AeonPageContextValue {
51
110
  // Presence
52
111
  presence: PresenceUser[];
53
112
  localUser: PresenceUser | null;
54
- updateCursor: (position: { x: number; y: number }) => void;
113
+ updateCursor: (position: { x: number; y: number }, path?: string) => void;
55
114
  updateEditing: (elementPath: string | null) => void;
115
+ updateFocusNode: (nodePath: string) => void;
116
+ updateSelection: (selection: PresenceSelection) => void;
117
+ updateTyping: (
118
+ isTyping: boolean,
119
+ field?: string,
120
+ isComposing?: boolean,
121
+ ) => void;
122
+ updateScroll: (scroll: PresenceScroll) => void;
123
+ updateViewport: (viewport: PresenceViewport) => void;
124
+ updateInputState: (inputState: PresenceInputState) => void;
125
+ updateEmotionState: (emotion: PresenceEmotion) => void;
56
126
 
57
127
  // Sync
58
128
  sync: SyncState;
59
- forcSync: () => Promise<void>;
129
+ forcSync: () => Promise<void>; // Kept for backwards compatibility
130
+ forceSync: () => Promise<void>;
60
131
 
61
132
  // Versioning
62
133
  version: VersionInfo;
@@ -79,6 +150,21 @@ export interface AeonPageProviderProps {
79
150
  initialData?: Record<string, unknown>;
80
151
  }
81
152
 
153
+ interface SocketMessage {
154
+ type: string;
155
+ payload?: unknown;
156
+ users?: unknown;
157
+ data?: unknown;
158
+ tree?: unknown;
159
+ version?: unknown;
160
+ }
161
+
162
+ interface PresenceEnvelope {
163
+ action?: 'join' | 'leave' | 'update';
164
+ user?: PresenceUser;
165
+ userId?: string;
166
+ }
167
+
82
168
  /**
83
169
  * AeonPageProvider - Wraps a page with Aeon collaborative features
84
170
  */
@@ -107,20 +193,314 @@ export function AeonPageProvider({
107
193
  const [data, setDataState] = useState<Record<string, unknown>>(initialData);
108
194
  const [tree, setTree] = useState<unknown>(null);
109
195
 
110
- // Refs for Aeon modules (lazy loaded)
196
+ // Refs for Aeon modules (initialized on demand)
111
197
  const syncCoordinatorRef = useRef<unknown>(null);
112
198
  const presenceManagerRef = useRef<unknown>(null);
113
199
  const offlineQueueRef = useRef<unknown>(null);
114
200
  const versionManagerRef = useRef<unknown>(null);
115
201
  const wsRef = useRef<WebSocket | null>(null);
116
202
 
203
+ const updatePresenceUser = useCallback(
204
+ (userId: string, patch: Partial<PresenceUser>) => {
205
+ const now = new Date().toISOString();
206
+
207
+ setPresence((prev) => {
208
+ const index = prev.findIndex((user) => user.userId === userId);
209
+ if (index < 0) {
210
+ return [
211
+ ...prev,
212
+ {
213
+ userId,
214
+ role: 'user',
215
+ status: 'online',
216
+ lastActivity: now,
217
+ ...patch,
218
+ },
219
+ ];
220
+ }
221
+
222
+ const next = [...prev];
223
+ next[index] = {
224
+ ...next[index],
225
+ ...patch,
226
+ lastActivity: patch.lastActivity ?? now,
227
+ };
228
+ return next;
229
+ });
230
+
231
+ setLocalUser((prev) => {
232
+ if (!prev || prev.userId !== userId) {
233
+ return prev;
234
+ }
235
+ return {
236
+ ...prev,
237
+ ...patch,
238
+ lastActivity: patch.lastActivity ?? now,
239
+ };
240
+ });
241
+ },
242
+ [],
243
+ );
244
+
245
+ const removePresenceUser = useCallback((userId: string) => {
246
+ setPresence((prev) => prev.filter((user) => user.userId !== userId));
247
+ }, []);
248
+
249
+ const setPresenceSnapshot = useCallback((users: PresenceUser[]) => {
250
+ setPresence(users);
251
+ }, []);
252
+
253
+ const sendSocketMessage = useCallback((message: Record<string, unknown>) => {
254
+ const ws = wsRef.current;
255
+ if (ws && ws.readyState === WebSocket.OPEN) {
256
+ ws.send(JSON.stringify(message));
257
+ }
258
+ }, []);
259
+
260
+ const connectWebSocket = useCallback(
261
+ (
262
+ targetSessionId: string,
263
+ userId: string,
264
+ role: PresenceUser['role'] = 'user',
265
+ ) => {
266
+ if (typeof window === 'undefined') return;
267
+
268
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
269
+ const params = new URLSearchParams({
270
+ session: targetSessionId,
271
+ userId,
272
+ role,
273
+ });
274
+ const wsUrl = `${protocol}//${window.location.host}/_aeon/ws?${params.toString()}`;
275
+
276
+ const ws = new WebSocket(wsUrl);
277
+ wsRef.current = ws;
278
+
279
+ ws.onopen = () => {
280
+ console.log('[aeon-provider] WebSocket connected');
281
+ sendSocketMessage({
282
+ type: 'presence',
283
+ payload: { status: 'online' },
284
+ });
285
+ };
286
+
287
+ ws.onmessage = (event) => {
288
+ try {
289
+ const message = JSON.parse(event.data as string) as SocketMessage;
290
+ handleSyncMessage(message);
291
+ } catch (error) {
292
+ console.error('[aeon-provider] Error parsing message:', error);
293
+ }
294
+ };
295
+
296
+ ws.onclose = () => {
297
+ console.log('[aeon-provider] WebSocket disconnected');
298
+ setTimeout(() => connectWebSocket(targetSessionId, userId, role), 1000);
299
+ };
300
+
301
+ ws.onerror = (error) => {
302
+ console.error('[aeon-provider] WebSocket error:', error);
303
+ };
304
+ },
305
+ [sendSocketMessage],
306
+ );
307
+
308
+ const handleSyncMessage = useCallback(
309
+ (message: unknown) => {
310
+ if (!isRecord(message) || typeof message.type !== 'string') {
311
+ return;
312
+ }
313
+
314
+ const msg = message as unknown as SocketMessage;
315
+
316
+ switch (msg.type) {
317
+ case 'init': {
318
+ if (!isRecord(msg.payload)) break;
319
+
320
+ const payload = msg.payload;
321
+ if (isRecord(payload.session)) {
322
+ const session = payload.session;
323
+ if ('tree' in session) {
324
+ setTree(session.tree);
325
+ }
326
+ if (isRecord(session.data)) {
327
+ setDataState((prev) => ({
328
+ ...prev,
329
+ ...(session.data as Record<string, unknown>),
330
+ }));
331
+ }
332
+ }
333
+
334
+ if (Array.isArray(payload.presence)) {
335
+ setPresenceSnapshot(payload.presence as PresenceUser[]);
336
+ }
337
+ break;
338
+ }
339
+
340
+ case 'presence-update': {
341
+ if (Array.isArray(msg.users)) {
342
+ setPresenceSnapshot(msg.users as PresenceUser[]);
343
+ break;
344
+ }
345
+ if (isRecord(msg.payload) && Array.isArray(msg.payload.users)) {
346
+ setPresenceSnapshot(msg.payload.users as PresenceUser[]);
347
+ }
348
+ break;
349
+ }
350
+
351
+ case 'presence': {
352
+ if (!isRecord(msg.payload)) break;
353
+
354
+ const payload = msg.payload as PresenceEnvelope;
355
+ if (
356
+ (payload.action === 'join' || payload.action === 'update') &&
357
+ payload.user
358
+ ) {
359
+ const user = payload.user;
360
+ updatePresenceUser(user.userId, user);
361
+ } else if (
362
+ payload.action === 'leave' &&
363
+ typeof payload.userId === 'string'
364
+ ) {
365
+ removePresenceUser(payload.userId);
366
+ }
367
+ break;
368
+ }
369
+
370
+ case 'cursor': {
371
+ if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
372
+ break;
373
+ }
374
+ if (isRecord(msg.payload.cursor)) {
375
+ updatePresenceUser(msg.payload.userId, {
376
+ cursor: {
377
+ x: Number(msg.payload.cursor.x ?? 0),
378
+ y: Number(msg.payload.cursor.y ?? 0),
379
+ },
380
+ });
381
+ }
382
+ break;
383
+ }
384
+
385
+ case 'typing': {
386
+ if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
387
+ break;
388
+ }
389
+ const typing = toPresenceTyping(msg.payload.typing);
390
+ if (typing) {
391
+ updatePresenceUser(msg.payload.userId, {
392
+ typing,
393
+ });
394
+ }
395
+ break;
396
+ }
397
+
398
+ case 'focus': {
399
+ if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
400
+ break;
401
+ }
402
+ if (typeof msg.payload.focusNode === 'string') {
403
+ updatePresenceUser(msg.payload.userId, {
404
+ focusNode: msg.payload.focusNode,
405
+ });
406
+ }
407
+ break;
408
+ }
409
+
410
+ case 'selection': {
411
+ if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
412
+ break;
413
+ }
414
+ const selection = toPresenceSelection(msg.payload.selection);
415
+ if (selection) {
416
+ updatePresenceUser(msg.payload.userId, {
417
+ selection,
418
+ });
419
+ }
420
+ break;
421
+ }
422
+
423
+ case 'scroll': {
424
+ if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
425
+ break;
426
+ }
427
+ const scroll = toPresenceScroll(msg.payload.scroll);
428
+ if (scroll) {
429
+ updatePresenceUser(msg.payload.userId, {
430
+ scroll,
431
+ });
432
+ }
433
+ break;
434
+ }
435
+
436
+ case 'viewport': {
437
+ if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
438
+ break;
439
+ }
440
+ const viewport = toPresenceViewport(msg.payload.viewport);
441
+ if (viewport) {
442
+ updatePresenceUser(msg.payload.userId, {
443
+ viewport,
444
+ });
445
+ }
446
+ break;
447
+ }
448
+
449
+ case 'input-state': {
450
+ if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
451
+ break;
452
+ }
453
+ const inputState = toPresenceInputState(msg.payload.inputState);
454
+ if (inputState) {
455
+ updatePresenceUser(msg.payload.userId, {
456
+ inputState,
457
+ });
458
+ }
459
+ break;
460
+ }
461
+
462
+ case 'emotion': {
463
+ if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
464
+ break;
465
+ }
466
+ const emotion = toPresenceEmotion(msg.payload.emotion);
467
+ if (emotion) {
468
+ updatePresenceUser(msg.payload.userId, {
469
+ emotion,
470
+ });
471
+ }
472
+ break;
473
+ }
474
+
475
+ case 'data-update':
476
+ if (isRecord(msg.data)) {
477
+ setDataState((prev) => ({
478
+ ...prev,
479
+ ...(msg.data as Record<string, unknown>),
480
+ }));
481
+ }
482
+ break;
483
+
484
+ case 'tree-update':
485
+ setTree(msg.tree);
486
+ break;
487
+
488
+ case 'version-info': {
489
+ const parsedVersion = toVersionInfo(msg.version);
490
+ if (parsedVersion) {
491
+ setVersion(parsedVersion);
492
+ }
493
+ break;
494
+ }
495
+ }
496
+ },
497
+ [removePresenceUser, setPresenceSnapshot, updatePresenceUser],
498
+ );
499
+
117
500
  // Initialize Aeon modules
118
501
  useEffect(() => {
119
502
  const initAeon = async () => {
120
503
  try {
121
-
122
-
123
-
124
504
  // Initialize sync coordinator
125
505
  syncCoordinatorRef.current = getSyncCoordinator();
126
506
 
@@ -133,15 +513,16 @@ export function AeonPageProvider({
133
513
 
134
514
  // Set up local user
135
515
  const userId = generateUserId();
136
- setLocalUser({
516
+ const initialUser: PresenceUser = {
137
517
  userId,
138
518
  role: 'user',
139
519
  status: 'online',
140
520
  lastActivity: new Date().toISOString(),
141
- });
521
+ };
522
+ setLocalUser(initialUser);
142
523
 
143
524
  // Connect WebSocket for real-time sync
144
- connectWebSocket(sessionId);
525
+ connectWebSocket(sessionId, initialUser.userId, initialUser.role);
145
526
  } catch (error) {
146
527
  console.warn('[aeon-provider] Aeon modules not available:', error);
147
528
  }
@@ -153,7 +534,7 @@ export function AeonPageProvider({
153
534
  return () => {
154
535
  wsRef.current?.close();
155
536
  };
156
- }, [sessionId]);
537
+ }, [connectWebSocket, sessionId]);
157
538
 
158
539
  // Online/offline detection
159
540
  useEffect(() => {
@@ -161,7 +542,6 @@ export function AeonPageProvider({
161
542
 
162
543
  const handleOnline = () => {
163
544
  setSync((prev) => ({ ...prev, isOnline: true }));
164
- // Flush offline queue
165
545
  flushOfflineQueue();
166
546
  };
167
547
 
@@ -178,66 +558,16 @@ export function AeonPageProvider({
178
558
  };
179
559
  }, []);
180
560
 
181
- // Connect WebSocket
182
- const connectWebSocket = useCallback((sessionId: string) => {
183
- if (typeof window === 'undefined') return;
184
-
185
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
186
- const wsUrl = `${protocol}//${window.location.host}/_aeon/ws?session=${sessionId}`;
187
-
188
- const ws = new WebSocket(wsUrl);
189
- wsRef.current = ws;
190
-
191
- ws.onopen = () => {
192
- console.log('[aeon-provider] WebSocket connected');
193
- // Join session
194
- ws.send(JSON.stringify({ type: 'join', sessionId }));
195
- };
196
-
197
- ws.onmessage = (event) => {
198
- try {
199
- const message = JSON.parse(event.data);
200
- handleSyncMessage(message);
201
- } catch (error) {
202
- console.error('[aeon-provider] Error parsing message:', error);
203
- }
204
- };
205
-
206
- ws.onclose = () => {
207
- console.log('[aeon-provider] WebSocket disconnected');
208
- // Reconnect after delay
209
- setTimeout(() => connectWebSocket(sessionId), 1000);
210
- };
211
-
212
- ws.onerror = (error) => {
213
- console.error('[aeon-provider] WebSocket error:', error);
214
- };
215
- }, []);
216
-
217
- // Handle incoming sync messages
218
- const handleSyncMessage = useCallback((message: unknown) => {
219
- const msg = message as { type: string; [key: string]: unknown };
220
-
221
- switch (msg.type) {
222
- case 'presence-update':
223
- setPresence(msg.users as PresenceUser[]);
224
- break;
225
-
226
- case 'data-update':
227
- setDataState((prev) => ({
228
- ...prev,
229
- ...(msg.data as Record<string, unknown>),
230
- }));
231
- break;
232
-
233
- case 'tree-update':
234
- setTree(msg.tree);
235
- break;
236
-
237
- case 'version-info':
238
- setVersion(msg.version as VersionInfo);
239
- break;
240
- }
561
+ const patchLocalUser = useCallback((patch: Partial<PresenceUser>) => {
562
+ const now = new Date().toISOString();
563
+ setLocalUser((prev) => {
564
+ if (!prev) return prev;
565
+ return {
566
+ ...prev,
567
+ ...patch,
568
+ lastActivity: now,
569
+ };
570
+ });
241
571
  }, []);
242
572
 
243
573
  // Flush offline queue when back online
@@ -261,56 +591,138 @@ export function AeonPageProvider({
261
591
  }
262
592
  }, []);
263
593
 
264
- // Update cursor position
594
+ // Presence update helpers
265
595
  const updateCursor = useCallback(
266
- (position: { x: number; y: number }) => {
267
- if (!localUser) return;
268
-
269
- setLocalUser((prev) =>
270
- prev
271
- ? {
272
- ...prev,
273
- cursor: position,
274
- lastActivity: new Date().toISOString(),
275
- }
276
- : null,
277
- );
278
-
279
- // Send to WebSocket
280
- wsRef.current?.send(
281
- JSON.stringify({
282
- type: 'cursor-update',
283
- position,
284
- }),
285
- );
596
+ (position: { x: number; y: number }, path?: string) => {
597
+ patchLocalUser({
598
+ cursor: position,
599
+ focusNode: path,
600
+ });
601
+
602
+ sendSocketMessage({
603
+ type: 'cursor',
604
+ payload: {
605
+ ...position,
606
+ path,
607
+ },
608
+ });
286
609
  },
287
- [localUser],
610
+ [patchLocalUser, sendSocketMessage],
288
611
  );
289
612
 
290
- // Update editing element
291
613
  const updateEditing = useCallback(
292
614
  (elementPath: string | null) => {
293
- if (!localUser) return;
615
+ patchLocalUser({
616
+ editing: elementPath ?? undefined,
617
+ });
618
+
619
+ sendSocketMessage({
620
+ type: 'presence',
621
+ payload: {
622
+ status: localUser?.status ?? 'online',
623
+ editing: elementPath,
624
+ },
625
+ });
626
+ },
627
+ [localUser?.status, patchLocalUser, sendSocketMessage],
628
+ );
294
629
 
295
- setLocalUser((prev) =>
296
- prev
297
- ? {
298
- ...prev,
299
- editing: elementPath ?? undefined,
300
- lastActivity: new Date().toISOString(),
301
- }
302
- : null,
303
- );
304
-
305
- // Send to WebSocket
306
- wsRef.current?.send(
307
- JSON.stringify({
308
- type: 'editing-update',
309
- elementPath,
310
- }),
311
- );
630
+ const updateFocusNode = useCallback(
631
+ (nodePath: string) => {
632
+ patchLocalUser({ focusNode: nodePath });
633
+ sendSocketMessage({
634
+ type: 'focus',
635
+ payload: { nodePath },
636
+ });
637
+ },
638
+ [patchLocalUser, sendSocketMessage],
639
+ );
640
+
641
+ const updateSelection = useCallback(
642
+ (selection: PresenceSelection) => {
643
+ patchLocalUser({ selection });
644
+ sendSocketMessage({
645
+ type: 'selection',
646
+ payload: selection,
647
+ });
648
+ },
649
+ [patchLocalUser, sendSocketMessage],
650
+ );
651
+
652
+ const updateTyping = useCallback(
653
+ (isTyping: boolean, field?: string, isComposing = false) => {
654
+ const now = new Date().toISOString();
655
+ patchLocalUser({
656
+ typing: {
657
+ isTyping,
658
+ field,
659
+ isComposing,
660
+ startedAt: isTyping ? now : undefined,
661
+ stoppedAt: isTyping ? undefined : now,
662
+ },
663
+ });
664
+ sendSocketMessage({
665
+ type: 'typing',
666
+ payload: {
667
+ isTyping,
668
+ field,
669
+ isComposing,
670
+ },
671
+ });
672
+ },
673
+ [patchLocalUser, sendSocketMessage],
674
+ );
675
+
676
+ const updateScroll = useCallback(
677
+ (scroll: PresenceScroll) => {
678
+ const normalized: PresenceScroll = {
679
+ ...scroll,
680
+ depth: Math.max(0, Math.min(1, scroll.depth)),
681
+ };
682
+ patchLocalUser({ scroll: normalized });
683
+ sendSocketMessage({
684
+ type: 'scroll',
685
+ payload: normalized,
686
+ });
687
+ },
688
+ [patchLocalUser, sendSocketMessage],
689
+ );
690
+
691
+ const updateViewport = useCallback(
692
+ (viewport: PresenceViewport) => {
693
+ patchLocalUser({ viewport });
694
+ sendSocketMessage({
695
+ type: 'viewport',
696
+ payload: viewport,
697
+ });
698
+ },
699
+ [patchLocalUser, sendSocketMessage],
700
+ );
701
+
702
+ const updateInputState = useCallback(
703
+ (inputState: PresenceInputState) => {
704
+ patchLocalUser({ inputState });
705
+ sendSocketMessage({
706
+ type: 'input-state',
707
+ payload: inputState,
708
+ });
709
+ },
710
+ [patchLocalUser, sendSocketMessage],
711
+ );
712
+
713
+ const updateEmotionState = useCallback(
714
+ (emotion: PresenceEmotion) => {
715
+ const enrichedEmotion: PresenceEmotion = {
716
+ ...emotion,
717
+ updatedAt: new Date().toISOString(),
718
+ };
719
+ patchLocalUser({ emotion: enrichedEmotion });
720
+ sendSocketMessage({
721
+ type: 'emotion',
722
+ payload: emotion,
723
+ });
312
724
  },
313
- [localUser],
725
+ [patchLocalUser, sendSocketMessage],
314
726
  );
315
727
 
316
728
  // Force sync
@@ -353,13 +765,11 @@ export function AeonPageProvider({
353
765
 
354
766
  // Queue for sync
355
767
  if (sync.isOnline && wsRef.current) {
356
- wsRef.current.send(
357
- JSON.stringify({
358
- type: 'data-set',
359
- key,
360
- value,
361
- }),
362
- );
768
+ sendSocketMessage({
769
+ type: 'data-set',
770
+ key,
771
+ value,
772
+ });
363
773
  } else {
364
774
  // Queue offline
365
775
  // @ts-expect-error - Aeon module method
@@ -374,20 +784,21 @@ export function AeonPageProvider({
374
784
  }));
375
785
  }
376
786
  },
377
- [sync.isOnline],
787
+ [sendSocketMessage, sync.isOnline],
378
788
  );
379
789
 
380
790
  // Update tree
381
- const updateTree = useCallback((path: string, value: unknown) => {
382
- // This would apply a patch to the tree
383
- wsRef.current?.send(
384
- JSON.stringify({
791
+ const updateTree = useCallback(
792
+ (path: string, value: unknown) => {
793
+ // This applies a patch to the remote tree
794
+ sendSocketMessage({
385
795
  type: 'tree-patch',
386
796
  path,
387
797
  value,
388
- }),
389
- );
390
- }, []);
798
+ });
799
+ },
800
+ [sendSocketMessage],
801
+ );
391
802
 
392
803
  // Context value
393
804
  const contextValue: AeonPageContextValue = {
@@ -397,8 +808,16 @@ export function AeonPageProvider({
397
808
  localUser,
398
809
  updateCursor,
399
810
  updateEditing,
811
+ updateFocusNode,
812
+ updateSelection,
813
+ updateTyping,
814
+ updateScroll,
815
+ updateViewport,
816
+ updateInputState,
817
+ updateEmotionState,
400
818
  sync,
401
819
  forcSync: forceSync,
820
+ forceSync,
402
821
  version,
403
822
  migrate,
404
823
  data,
@@ -429,15 +848,39 @@ export function useAeonPage(): AeonPageContextValue {
429
848
  * usePresence - Just the presence data
430
849
  */
431
850
  export function usePresence() {
432
- const { presence, localUser, updateCursor, updateEditing } = useAeonPage();
433
- return { presence, localUser, updateCursor, updateEditing };
851
+ const {
852
+ presence,
853
+ localUser,
854
+ updateCursor,
855
+ updateEditing,
856
+ updateFocusNode,
857
+ updateSelection,
858
+ updateTyping,
859
+ updateScroll,
860
+ updateViewport,
861
+ updateInputState,
862
+ updateEmotionState,
863
+ } = useAeonPage();
864
+ return {
865
+ presence,
866
+ localUser,
867
+ updateCursor,
868
+ updateEditing,
869
+ updateFocusNode,
870
+ updateSelection,
871
+ updateTyping,
872
+ updateScroll,
873
+ updateViewport,
874
+ updateInputState,
875
+ updateEmotionState,
876
+ };
434
877
  }
435
878
 
436
879
  /**
437
880
  * useAeonSync - Just the sync state
438
881
  */
439
882
  export function useAeonSync() {
440
- const { sync, forcSync: forceSync } = useAeonPage();
883
+ const { sync, forceSync } = useAeonPage();
441
884
  return { ...sync, forceSync };
442
885
  }
443
886
 
@@ -464,4 +907,145 @@ function generateUserId(): string {
464
907
  return `user-${Date.now()}-${Math.random().toString(36).slice(2)}`;
465
908
  }
466
909
 
910
+ function isRecord(value: unknown): value is Record<string, unknown> {
911
+ return typeof value === 'object' && value !== null;
912
+ }
913
+
914
+ function toPresenceSelection(value: unknown): PresenceSelection | null {
915
+ if (!isRecord(value)) return null;
916
+ if (typeof value.start !== 'number' || typeof value.end !== 'number') {
917
+ return null;
918
+ }
919
+ return {
920
+ start: value.start,
921
+ end: value.end,
922
+ direction:
923
+ value.direction === 'forward' ||
924
+ value.direction === 'backward' ||
925
+ value.direction === 'none'
926
+ ? value.direction
927
+ : undefined,
928
+ path: typeof value.path === 'string' ? value.path : undefined,
929
+ };
930
+ }
931
+
932
+ function toPresenceTyping(value: unknown): PresenceTyping | null {
933
+ if (!isRecord(value) || typeof value.isTyping !== 'boolean') {
934
+ return null;
935
+ }
936
+ return {
937
+ isTyping: value.isTyping,
938
+ field: typeof value.field === 'string' ? value.field : undefined,
939
+ isComposing:
940
+ typeof value.isComposing === 'boolean' ? value.isComposing : undefined,
941
+ startedAt: typeof value.startedAt === 'string' ? value.startedAt : undefined,
942
+ stoppedAt: typeof value.stoppedAt === 'string' ? value.stoppedAt : undefined,
943
+ };
944
+ }
945
+
946
+ function toPresenceScroll(value: unknown): PresenceScroll | null {
947
+ if (!isRecord(value) || typeof value.depth !== 'number') {
948
+ return null;
949
+ }
950
+ return {
951
+ depth: value.depth,
952
+ y: typeof value.y === 'number' ? value.y : undefined,
953
+ viewportHeight:
954
+ typeof value.viewportHeight === 'number' ? value.viewportHeight : undefined,
955
+ documentHeight:
956
+ typeof value.documentHeight === 'number' ? value.documentHeight : undefined,
957
+ path: typeof value.path === 'string' ? value.path : undefined,
958
+ };
959
+ }
960
+
961
+ function toPresenceViewport(value: unknown): PresenceViewport | null {
962
+ if (
963
+ !isRecord(value) ||
964
+ typeof value.width !== 'number' ||
965
+ typeof value.height !== 'number'
966
+ ) {
967
+ return null;
968
+ }
969
+ return {
970
+ width: value.width,
971
+ height: value.height,
972
+ };
973
+ }
974
+
975
+ function toPresenceInputState(value: unknown): PresenceInputState | null {
976
+ if (
977
+ !isRecord(value) ||
978
+ typeof value.field !== 'string' ||
979
+ typeof value.hasFocus !== 'boolean'
980
+ ) {
981
+ return null;
982
+ }
983
+ return {
984
+ field: value.field,
985
+ hasFocus: value.hasFocus,
986
+ valueLength: typeof value.valueLength === 'number' ? value.valueLength : undefined,
987
+ selectionStart:
988
+ typeof value.selectionStart === 'number' ? value.selectionStart : undefined,
989
+ selectionEnd:
990
+ typeof value.selectionEnd === 'number' ? value.selectionEnd : undefined,
991
+ isComposing:
992
+ typeof value.isComposing === 'boolean' ? value.isComposing : undefined,
993
+ inputMode: typeof value.inputMode === 'string' ? value.inputMode : undefined,
994
+ };
995
+ }
996
+
997
+ function toPresenceEmotion(value: unknown): PresenceEmotion | null {
998
+ if (!isRecord(value)) return null;
999
+
1000
+ const emotion: PresenceEmotion = {
1001
+ primary: typeof value.primary === 'string' ? value.primary : undefined,
1002
+ secondary: typeof value.secondary === 'string' ? value.secondary : undefined,
1003
+ confidence: typeof value.confidence === 'number' ? value.confidence : undefined,
1004
+ intensity: typeof value.intensity === 'number' ? value.intensity : undefined,
1005
+ valence: typeof value.valence === 'number' ? value.valence : undefined,
1006
+ arousal: typeof value.arousal === 'number' ? value.arousal : undefined,
1007
+ dominance: typeof value.dominance === 'number' ? value.dominance : undefined,
1008
+ source:
1009
+ value.source === 'self-report' ||
1010
+ value.source === 'inferred' ||
1011
+ value.source === 'sensor' ||
1012
+ value.source === 'hybrid'
1013
+ ? value.source
1014
+ : undefined,
1015
+ updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : undefined,
1016
+ };
1017
+
1018
+ if (
1019
+ !emotion.primary &&
1020
+ !emotion.secondary &&
1021
+ emotion.confidence === undefined &&
1022
+ emotion.intensity === undefined &&
1023
+ emotion.valence === undefined &&
1024
+ emotion.arousal === undefined &&
1025
+ emotion.dominance === undefined &&
1026
+ !emotion.source &&
1027
+ !emotion.updatedAt
1028
+ ) {
1029
+ return null;
1030
+ }
1031
+
1032
+ return emotion;
1033
+ }
1034
+
1035
+ function toVersionInfo(value: unknown): VersionInfo | null {
1036
+ if (
1037
+ !isRecord(value) ||
1038
+ typeof value.current !== 'string' ||
1039
+ typeof value.latest !== 'string' ||
1040
+ typeof value.needsMigration !== 'boolean'
1041
+ ) {
1042
+ return null;
1043
+ }
1044
+ return {
1045
+ current: value.current,
1046
+ latest: value.latest,
1047
+ needsMigration: value.needsMigration,
1048
+ };
1049
+ }
1050
+
467
1051
  export default AeonPageProvider;