@semiont/react-ui 0.4.19 → 0.4.21

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 (108) hide show
  1. package/README.md +8 -5
  2. package/dist/{PdfAnnotationCanvas.client-CHDCGQBR.mjs → PdfAnnotationCanvas.client-6ZGFEN2N.mjs} +9 -13
  3. package/dist/PdfAnnotationCanvas.client-6ZGFEN2N.mjs.map +1 -0
  4. package/dist/TranslationManager-9Xj3MIWQ.d.mts +16 -0
  5. package/dist/chunk-KEDFYI6N.mjs +7788 -0
  6. package/dist/chunk-KEDFYI6N.mjs.map +1 -0
  7. package/dist/index.d.mts +171 -1140
  8. package/dist/index.mjs +3263 -13644
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/test-utils.d.mts +46 -21
  11. package/dist/test-utils.mjs +2499 -87
  12. package/dist/test-utils.mjs.map +1 -1
  13. package/package.json +1 -2
  14. package/src/components/AnnotateReferencesProgressWidget.tsx +21 -28
  15. package/src/components/CodeMirrorRenderer.tsx +9 -11
  16. package/src/components/StatusDisplay.tsx +42 -16
  17. package/src/components/Toolbar.tsx +4 -4
  18. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +34 -20
  19. package/src/components/__tests__/StatusDisplay.test.tsx +47 -64
  20. package/src/components/__tests__/Toolbar.test.tsx +4 -4
  21. package/src/components/annotation/AnnotateToolbar.tsx +8 -7
  22. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +31 -77
  23. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +0 -1
  24. package/src/components/image-annotation/AnnotationOverlay.tsx +12 -13
  25. package/src/components/image-annotation/SvgDrawingCanvas.tsx +7 -7
  26. package/src/components/modals/PermissionDeniedModal.tsx +11 -11
  27. package/src/components/modals/ReferenceWizardModal.tsx +14 -18
  28. package/src/components/modals/ResourceSearchModal.tsx +10 -6
  29. package/src/components/modals/SearchModal.tsx +10 -6
  30. package/src/components/modals/SessionExpiredModal.tsx +11 -11
  31. package/src/components/modals/__tests__/PermissionDeniedModal.test.tsx +7 -7
  32. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +10 -8
  33. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +10 -7
  34. package/src/components/modals/__tests__/SessionExpiredModal.test.tsx +5 -5
  35. package/src/components/navigation/CollapsibleResourceNavigation.tsx +10 -10
  36. package/src/components/navigation/ObservableLink.tsx +6 -6
  37. package/src/components/navigation/SimpleNavigation.tsx +4 -4
  38. package/src/components/navigation/__tests__/ObservableLink.test.tsx +4 -4
  39. package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +4 -4
  40. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +9 -11
  41. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +0 -1
  42. package/src/components/resource/AnnotateView.tsx +7 -6
  43. package/src/components/resource/AnnotationHistory.tsx +9 -12
  44. package/src/components/resource/BrowseView.tsx +8 -7
  45. package/src/components/resource/ResourceViewer.tsx +17 -25
  46. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +54 -192
  47. package/src/components/resource/__tests__/BrowseView.test.tsx +34 -83
  48. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +40 -31
  49. package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
  50. package/src/components/resource/panels/AssessmentPanel.tsx +19 -15
  51. package/src/components/resource/panels/AssistSection.tsx +11 -13
  52. package/src/components/resource/panels/CollaborationPanel.tsx +29 -7
  53. package/src/components/resource/panels/CommentEntry.tsx +5 -4
  54. package/src/components/resource/panels/CommentsPanel.tsx +9 -11
  55. package/src/components/resource/panels/HighlightEntry.tsx +5 -4
  56. package/src/components/resource/panels/HighlightPanel.tsx +10 -11
  57. package/src/components/resource/panels/ReferenceEntry.tsx +8 -8
  58. package/src/components/resource/panels/ReferencesPanel.tsx +13 -12
  59. package/src/components/resource/panels/ResourceInfoPanel.tsx +7 -6
  60. package/src/components/resource/panels/TagEntry.tsx +5 -4
  61. package/src/components/resource/panels/TaggingPanel.tsx +10 -16
  62. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +3 -2
  63. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +18 -52
  64. package/src/components/resource/panels/__tests__/CollaborationPanel.test.tsx +51 -20
  65. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +18 -56
  66. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +0 -1
  67. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +4 -5
  68. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +153 -0
  69. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +51 -106
  70. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +15 -47
  71. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +15 -47
  72. package/src/components/settings/SettingsPanel.tsx +8 -8
  73. package/src/components/settings/__tests__/SettingsPanel.test.tsx +12 -12
  74. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +1 -1
  75. package/src/features/admin-exchange/components/AdminExchangePage.tsx +1 -1
  76. package/src/features/admin-exchange/components/ImportCard.tsx +2 -6
  77. package/src/features/admin-security/components/AdminSecurityPage.tsx +1 -1
  78. package/src/features/admin-users/components/AdminUsersPage.tsx +1 -1
  79. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +1 -1
  80. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +1 -1
  81. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -1
  82. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +1 -1
  83. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +5 -3
  84. package/src/features/resource-compose/components/ResourceComposePage.tsx +5 -22
  85. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +4 -3
  86. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +1 -1
  87. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +38 -45
  88. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +123 -192
  89. package/dist/KnowledgeBaseSessionContext-BNNunwzO.d.mts +0 -175
  90. package/dist/PdfAnnotationCanvas.client-CHDCGQBR.mjs.map +0 -1
  91. package/dist/chunk-OZICDVH7.mjs +0 -62
  92. package/dist/chunk-OZICDVH7.mjs.map +0 -1
  93. package/dist/chunk-R4CCMFJH.mjs +0 -877
  94. package/dist/chunk-R4CCMFJH.mjs.map +0 -1
  95. package/dist/chunk-VN5NY4SN.mjs +0 -200
  96. package/dist/chunk-VN5NY4SN.mjs.map +0 -1
  97. package/src/components/modals/ProposeEntitiesModal.tsx +0 -179
  98. package/src/components/modals/__tests__/ProposeEntitiesModal.test.tsx +0 -129
  99. package/src/features/resource-viewer/__tests__/AnnotationCreationPending.test.tsx +0 -323
  100. package/src/features/resource-viewer/__tests__/AnnotationDeletionIntegration.test.tsx +0 -245
  101. package/src/features/resource-viewer/__tests__/AnnotationProgressDismissal.test.tsx +0 -303
  102. package/src/features/resource-viewer/__tests__/BindFlowIntegration.test.tsx +0 -150
  103. package/src/features/resource-viewer/__tests__/DetectionFlowBug.test.tsx +0 -243
  104. package/src/features/resource-viewer/__tests__/DetectionFlowIntegration.test.tsx +0 -383
  105. package/src/features/resource-viewer/__tests__/ResourceMutations.test.tsx +0 -299
  106. package/src/features/resource-viewer/__tests__/ToastNotifications.test.tsx +0 -186
  107. package/src/features/resource-viewer/__tests__/YieldFlowIntegration.test.tsx +0 -429
  108. package/src/features/resource-viewer/__tests__/annotation-progress-flow.test.tsx +0 -348
@@ -66,15 +66,15 @@ describe('Toolbar', () => {
66
66
  });
67
67
 
68
68
  describe('event emission', () => {
69
- it('emits browse:panel-toggle with panel name on click', () => {
69
+ it('emits panel:toggle with panel name on click', () => {
70
70
  const handler = vi.fn();
71
71
 
72
- const { eventBus } = renderWithProviders(
72
+ const { shellBus } = renderWithProviders(
73
73
  <Toolbar context="document" activePanel={null} />,
74
- { returnEventBus: true }
74
+ { returnShellBus: true }
75
75
  );
76
76
 
77
- const subscription = eventBus!.get('browse:panel-toggle').subscribe(handler);
77
+ const subscription = shellBus!.get('panel:toggle').subscribe(handler);
78
78
 
79
79
  fireEvent.click(screen.getByLabelText('Toolbar.resourceInfo'));
80
80
  expect(handler).toHaveBeenCalledWith({ panel: 'info' });
@@ -2,7 +2,8 @@
2
2
 
3
3
  import React, { useState, useRef, useEffect } from 'react';
4
4
  import { useTranslations } from '../../contexts/TranslationContext';
5
- import { useEventBus } from '../../contexts/EventBusContext';
5
+ import { useSemiont } from '../../session/SemiontProvider';
6
+ import { useObservable } from '../../hooks/useObservable';
6
7
  import { getSupportedShapes } from '../../lib/media-shapes';
7
8
  import type { Annotator } from '../../lib/annotation-registry';
8
9
  import './annotations.css';
@@ -119,7 +120,7 @@ export function AnnotateToolbar({
119
120
  annotators
120
121
  }: AnnotateToolbarProps) {
121
122
  const t = useTranslations('AnnotateToolbar');
122
- const eventBus = useEventBus();
123
+ const session = useObservable(useSemiont().activeSession$);
123
124
 
124
125
  // Helper to get emoji from annotators by motivation (with fallback for safety)
125
126
  const getMotivationEmoji = (motivation: SelectionMotivation): string => {
@@ -188,9 +189,9 @@ export function AnnotateToolbar({
188
189
  const handleSelectionClick = (motivation: SelectionMotivation | null) => {
189
190
  // If null is clicked, always deselect. Otherwise toggle.
190
191
  if (motivation === null) {
191
- eventBus.get('mark:selection-changed').next({ motivation: null });
192
+ session?.client.emit('mark:selection-changed', { motivation: null });
192
193
  } else {
193
- eventBus.get('mark:selection-changed').next({
194
+ session?.client.emit('mark:selection-changed', {
194
195
  motivation: selectedMotivation === motivation ? null : motivation
195
196
  });
196
197
  }
@@ -200,21 +201,21 @@ export function AnnotateToolbar({
200
201
  };
201
202
 
202
203
  const handleClickClick = (action: ClickAction) => {
203
- eventBus.get('mark:click-changed').next({ action });
204
+ session?.client.emit('mark:click-changed', { action });
204
205
  // Close dropdown after selection
205
206
  setClickPinned(false);
206
207
  setClickHovered(false);
207
208
  };
208
209
 
209
210
  const handleShapeClick = (shape: ShapeType) => {
210
- eventBus.get('mark:shape-changed').next({ shape });
211
+ session?.client.emit('mark:shape-changed', { shape });
211
212
  // Close dropdown after selection
212
213
  setShapePinned(false);
213
214
  setShapeHovered(false);
214
215
  };
215
216
 
216
217
  const handleModeToggle = () => {
217
- eventBus.get('mark:mode-toggled').next(undefined);
218
+ session?.client.emit('mark:mode-toggled', undefined);
218
219
  setModePinned(false);
219
220
  setModeHovered(false);
220
221
  };
@@ -3,9 +3,10 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea
3
3
  import { vi, beforeEach, describe, it, expect } from 'vitest';
4
4
  import { AnnotateToolbar, type SelectionMotivation, type ClickAction } from '../AnnotateToolbar';
5
5
  import { ANNOTATORS } from '../../../lib/annotation-registry';
6
- import { EventBusProvider, useEventBus } from '../../../contexts/EventBusContext';
7
6
  import { TranslationProvider } from '../../../contexts/TranslationContext';
7
+ import { createTestSemiontWrapper } from '../../../test-utils';
8
8
  import type { TranslationManager } from '../../../types/TranslationManager';
9
+ import type { EventBus } from '@semiont/core';
9
10
 
10
11
  // Mock translations
11
12
  const messages: Record<string, Record<string, string>> = {
@@ -43,68 +44,37 @@ interface TrackedEvent {
43
44
 
44
45
  function createEventTracker() {
45
46
  const events: TrackedEvent[] = [];
46
-
47
- function EventTrackingWrapper({ children }: { children: React.ReactNode }) {
48
- const eventBus = useEventBus();
49
-
50
- React.useEffect(() => {
51
- const handlers: Array<() => void> = [];
52
-
53
- // Track toolbar-related events
54
- const trackEvent = (eventName: string) => (payload: any) => {
55
- events.push({ event: eventName, payload });
56
- };
57
-
58
- const toolbarEvents = [
47
+ return {
48
+ events,
49
+ clear: () => { events.length = 0; },
50
+ _attach(eventBus: EventBus) {
51
+ const trackedEvents = [
59
52
  'mark:mode-toggled',
60
53
  'mark:click-changed',
61
54
  'mark:selection-changed',
62
55
  'mark:shape-changed',
63
56
  ] as const;
64
-
65
- toolbarEvents.forEach(eventName => {
66
- const handler = trackEvent(eventName);
67
- const subscription = eventBus.get(eventName).subscribe(handler);
68
- handlers.push(subscription);
57
+ trackedEvents.forEach((eventName) => {
58
+ eventBus.get(eventName).subscribe((payload: any) => {
59
+ events.push({ event: eventName, payload });
60
+ });
69
61
  });
70
-
71
- return () => {
72
- handlers.forEach(sub => sub.unsubscribe());
73
- };
74
- }, [eventBus]);
75
-
76
- return <>{children}</>;
77
- }
78
-
79
- return {
80
- EventTrackingWrapper,
81
- events,
82
- clear: () => {
83
- events.length = 0;
84
62
  },
85
63
  };
86
64
  }
87
65
 
88
66
  const renderWithIntl = (component: React.ReactElement, tracker?: ReturnType<typeof createEventTracker>) => {
89
- if (tracker) {
90
- return render(
91
- <EventBusProvider>
92
- <TranslationProvider translationManager={translationManager}>
93
- <tracker.EventTrackingWrapper>
94
- {component}
95
- </tracker.EventTrackingWrapper>
96
- </TranslationProvider>
97
- </EventBusProvider>
98
- );
99
- }
100
-
101
- return render(
102
- <EventBusProvider>
67
+ const { SemiontWrapper, eventBus } = createTestSemiontWrapper();
68
+ if (tracker) tracker._attach(eventBus);
69
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
70
+ <SemiontWrapper>
103
71
  <TranslationProvider translationManager={translationManager}>
104
- {component}
72
+ {children}
105
73
  </TranslationProvider>
106
- </EventBusProvider>
74
+ </SemiontWrapper>
107
75
  );
76
+ const result = render(component, { wrapper: Wrapper });
77
+ return { ...result, eventBus };
108
78
  };
109
79
 
110
80
  describe('AnnotateToolbar', () => {
@@ -187,14 +157,10 @@ describe('AnnotateToolbar', () => {
187
157
  expect(screen.getByText('Browse')).toBeInTheDocument();
188
158
 
189
159
  rerender(
190
- <EventBusProvider>
191
- <TranslationProvider translationManager={translationManager}>
192
- <AnnotateToolbar
193
- {...defaultProps}
194
- annotateMode={true}
195
- />
196
- </TranslationProvider>
197
- </EventBusProvider>
160
+ <AnnotateToolbar
161
+ {...defaultProps}
162
+ annotateMode={true}
163
+ />
198
164
  );
199
165
  expect(screen.getByText('Annotate')).toBeInTheDocument();
200
166
  });
@@ -292,16 +258,10 @@ describe('AnnotateToolbar', () => {
292
258
 
293
259
  // Simulate mode change by rerendering with new mode
294
260
  rerender(
295
- <EventBusProvider>
296
- <TranslationProvider translationManager={translationManager}>
297
- <tracker.EventTrackingWrapper>
298
- <AnnotateToolbar
299
- {...defaultProps}
300
- annotateMode={true}
301
- />
302
- </tracker.EventTrackingWrapper>
303
- </TranslationProvider>
304
- </EventBusProvider>
261
+ <AnnotateToolbar
262
+ {...defaultProps}
263
+ annotateMode={true}
264
+ />
305
265
  );
306
266
 
307
267
  // After mode change, the collapsed content should show "Annotate"
@@ -399,16 +359,10 @@ describe('AnnotateToolbar', () => {
399
359
 
400
360
  // Simulate selection
401
361
  rerender(
402
- <EventBusProvider>
403
- <TranslationProvider translationManager={translationManager}>
404
- <tracker.EventTrackingWrapper>
405
- <AnnotateToolbar
406
- {...defaultProps}
407
- selectedMotivation="highlighting"
408
- />
409
- </tracker.EventTrackingWrapper>
410
- </TranslationProvider>
411
- </EventBusProvider>
362
+ <AnnotateToolbar
363
+ {...defaultProps}
364
+ selectedMotivation="highlighting"
365
+ />
412
366
  );
413
367
 
414
368
  // Click again to deselect
@@ -70,7 +70,6 @@ const createMockAnnotation = (overrides?: Partial<Annotation>): Annotation => ({
70
70
  end: 10,
71
71
  },
72
72
  },
73
- body: [],
74
73
  ...overrides,
75
74
  });
76
75
 
@@ -2,10 +2,9 @@
2
2
 
3
3
  import { useMemo } from 'react';
4
4
  import type { components } from '@semiont/core';
5
- import { createHoverHandlers } from '../../hooks/useBeckonFlow';
6
- import { getSvgSelector, isHighlight, isReference, isAssessment, isComment, isTag, isBodyResolved, isResolvedReference } from '@semiont/api-client';
5
+ import { createHoverHandlers, getSvgSelector, isHighlight, isReference, isAssessment, isComment, isTag, isBodyResolved, isResolvedReference } from '@semiont/api-client';
7
6
  import { parseSvgSelector } from '@semiont/api-client';
8
- import type { EventBus } from "@semiont/core"
7
+ import type { SemiontSession } from '@semiont/api-client';
9
8
 
10
9
  type Annotation = components['schemas']['Annotation'];
11
10
 
@@ -15,7 +14,7 @@ interface AnnotationOverlayProps {
15
14
  imageHeight: number;
16
15
  displayWidth: number;
17
16
  displayHeight: number;
18
- eventBus?: EventBus;
17
+ session?: SemiontSession | null | undefined;
19
18
  hoveredAnnotationId?: string | null;
20
19
  selectedAnnotationId?: string | null;
21
20
  hoverDelayMs: number;
@@ -72,7 +71,7 @@ export function AnnotationOverlay({
72
71
  imageHeight,
73
72
  displayWidth,
74
73
  displayHeight,
75
- eventBus,
74
+ session,
76
75
  hoveredAnnotationId,
77
76
  selectedAnnotationId,
78
77
  hoverDelayMs
@@ -81,8 +80,8 @@ export function AnnotationOverlay({
81
80
  const scaleY = displayHeight / imageHeight;
82
81
 
83
82
  const { handleMouseEnter, handleMouseLeave } = useMemo(
84
- () => createHoverHandlers((annotationId) => eventBus?.get('beckon:hover').next({ annotationId }), hoverDelayMs),
85
- [eventBus, hoverDelayMs]
83
+ () => createHoverHandlers((annotationId) => session?.client.emit('beckon:hover', { annotationId }), hoverDelayMs),
84
+ [session, hoverDelayMs]
86
85
  );
87
86
 
88
87
  return (
@@ -131,7 +130,7 @@ export function AnnotationOverlay({
131
130
  className="semiont-annotation-overlay__shape"
132
131
  data-hovered={isHovered ? 'true' : 'false'}
133
132
  data-selected={isSelected ? 'true' : 'false'}
134
- onClick={() => eventBus?.get('browse:click').next({ annotationId: annotation.id, motivation: annotation.motivation })}
133
+ onClick={() => session?.client.emit('browse:click', { annotationId: annotation.id, motivation: annotation.motivation })}
135
134
  onMouseEnter={() => handleMouseEnter(annotation.id)}
136
135
  onMouseLeave={handleMouseLeave}
137
136
  />
@@ -144,7 +143,7 @@ export function AnnotationOverlay({
144
143
  style={{ userSelect: 'none' }}
145
144
  onClick={(e) => {
146
145
  e.stopPropagation();
147
- eventBus?.get('browse:click').next({ annotationId: annotation.id, motivation: annotation.motivation });
146
+ session?.client.emit('browse:click', { annotationId: annotation.id, motivation: annotation.motivation });
148
147
  }}
149
148
  onMouseEnter={() => handleMouseEnter(annotation.id)}
150
149
  onMouseLeave={handleMouseLeave}
@@ -178,7 +177,7 @@ export function AnnotationOverlay({
178
177
  className="semiont-annotation-overlay__shape"
179
178
  data-hovered={isHovered ? 'true' : 'false'}
180
179
  data-selected={isSelected ? 'true' : 'false'}
181
- onClick={() => eventBus?.get('browse:click').next({ annotationId: annotation.id, motivation: annotation.motivation })}
180
+ onClick={() => session?.client.emit('browse:click', { annotationId: annotation.id, motivation: annotation.motivation })}
182
181
  onMouseEnter={() => handleMouseEnter(annotation.id)}
183
182
  onMouseLeave={handleMouseLeave}
184
183
  />
@@ -191,7 +190,7 @@ export function AnnotationOverlay({
191
190
  style={{ userSelect: 'none' }}
192
191
  onClick={(e) => {
193
192
  e.stopPropagation();
194
- eventBus?.get('browse:click').next({ annotationId: annotation.id, motivation: annotation.motivation });
193
+ session?.client.emit('browse:click', { annotationId: annotation.id, motivation: annotation.motivation });
195
194
  }}
196
195
  onMouseEnter={() => handleMouseEnter(annotation.id)}
197
196
  onMouseLeave={handleMouseLeave}
@@ -238,7 +237,7 @@ export function AnnotationOverlay({
238
237
  className="semiont-annotation-overlay__shape"
239
238
  data-hovered={isHovered ? 'true' : 'false'}
240
239
  data-selected={isSelected ? 'true' : 'false'}
241
- onClick={() => eventBus?.get('browse:click').next({ annotationId: annotation.id, motivation: annotation.motivation })}
240
+ onClick={() => session?.client.emit('browse:click', { annotationId: annotation.id, motivation: annotation.motivation })}
242
241
  onMouseEnter={() => handleMouseEnter(annotation.id)}
243
242
  onMouseLeave={handleMouseLeave}
244
243
  />
@@ -251,7 +250,7 @@ export function AnnotationOverlay({
251
250
  style={{ userSelect: 'none' }}
252
251
  onClick={(e) => {
253
252
  e.stopPropagation();
254
- eventBus?.get('browse:click').next({ annotationId: annotation.id, motivation: annotation.motivation });
253
+ session?.client.emit('browse:click', { annotationId: annotation.id, motivation: annotation.motivation });
255
254
  }}
256
255
  onMouseEnter={() => handleMouseEnter(annotation.id)}
257
256
  onMouseLeave={handleMouseLeave}
@@ -5,7 +5,7 @@ import type { components } from '@semiont/core';
5
5
  import { createRectangleSvg, createCircleSvg, createPolygonSvg, scaleSvgToNative, parseSvgSelector, Point } from '@semiont/api-client';
6
6
  import { AnnotationOverlay } from './AnnotationOverlay';
7
7
  import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
8
- import type { EventBus } from "@semiont/core"
8
+ import type { SemiontSession } from '@semiont/api-client';
9
9
  import { useHoverDelay } from '../../hooks/useHoverDelay';
10
10
 
11
11
  type Annotation = components['schemas']['Annotation'];
@@ -40,7 +40,7 @@ interface SvgDrawingCanvasProps {
40
40
  existingAnnotations?: Annotation[];
41
41
  drawingMode: DrawingMode;
42
42
  selectedMotivation?: SelectionMotivation | null;
43
- eventBus?: EventBus;
43
+ session?: SemiontSession | null | undefined;
44
44
  hoveredAnnotationId?: string | null;
45
45
  selectedAnnotationId?: string | null;
46
46
  hoverDelayMs?: number;
@@ -57,7 +57,7 @@ export function SvgDrawingCanvas({
57
57
  existingAnnotations = [],
58
58
  drawingMode,
59
59
  selectedMotivation,
60
- eventBus,
60
+ session,
61
61
  hoveredAnnotationId,
62
62
  selectedAnnotationId
63
63
  }: SvgDrawingCanvasProps) {
@@ -212,7 +212,7 @@ export function SvgDrawingCanvas({
212
212
  });
213
213
 
214
214
  if (clickedAnnotation) {
215
- eventBus?.get('browse:click').next({ annotationId: clickedAnnotation.id, motivation: clickedAnnotation.motivation });
215
+ session?.client.emit('browse:click', { annotationId: clickedAnnotation.id, motivation: clickedAnnotation.motivation });
216
216
  setIsDrawing(false);
217
217
  setStartPoint(null);
218
218
  setCurrentPoint(null);
@@ -274,8 +274,8 @@ export function SvgDrawingCanvas({
274
274
  );
275
275
 
276
276
  // Emit annotation:requested event with SvgSelector
277
- if (eventBus && selectedMotivation) {
278
- eventBus.get('mark:requested').next({
277
+ if (session && selectedMotivation) {
278
+ session.client.emit('mark:requested', {
279
279
  selector: {
280
280
  type: 'SvgSelector',
281
281
  value: nativeSvg
@@ -338,7 +338,7 @@ export function SvgDrawingCanvas({
338
338
  displayWidth={displayDimensions.width}
339
339
  displayHeight={displayDimensions.height}
340
340
  hoverDelayMs={hoverDelayMs}
341
- {...(eventBus && { eventBus })}
341
+ {...(session && { session })}
342
342
  {...(hoveredAnnotationId !== undefined && { hoveredAnnotationId })}
343
343
  {...(selectedAnnotationId !== undefined && { selectedAnnotationId })}
344
344
  />
@@ -1,24 +1,24 @@
1
1
  'use client';
2
2
 
3
3
  import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
4
- import { useKnowledgeBaseSession } from '../../contexts/KnowledgeBaseSessionContext';
4
+ import { useSemiont } from '../../session/SemiontProvider';
5
+ import { useObservable } from '../../hooks/useObservable';
5
6
 
6
7
  /**
7
8
  * Modal that surfaces when a 403 forbidden error is reported via
8
9
  * `notifyPermissionDenied` (called from QueryCache.onError).
9
10
  *
10
- * Reads `permissionDeniedAt` and `permissionDeniedMessage` from
11
- * KnowledgeBaseSessionContext. The provider clears the flag when the user
12
- * dismisses the modal.
13
- *
14
- * Must be mounted inside KnowledgeBaseSessionProvider.
11
+ * Reads `permissionDeniedAt$` and `permissionDeniedMessage$` from the
12
+ * active `FrontendSessionSignals`. The signals instance clears the
13
+ * flag when the user dismisses the modal. Modal state lives on
14
+ * signals (not the session itself) so headless sessions
15
+ * (workers/CLIs) don't carry dead observables.
15
16
  */
16
17
  export function PermissionDeniedModal() {
17
- const {
18
- permissionDeniedAt,
19
- permissionDeniedMessage,
20
- acknowledgePermissionDenied,
21
- } = useKnowledgeBaseSession();
18
+ const signals = useObservable(useSemiont().activeSignals$);
19
+ const permissionDeniedAt = useObservable(signals?.permissionDeniedAt$) ?? null;
20
+ const permissionDeniedMessage = useObservable(signals?.permissionDeniedMessage$) ?? null;
21
+ const acknowledgePermissionDenied = () => signals?.acknowledgePermissionDenied();
22
22
  const showModal = permissionDeniedAt !== null;
23
23
  const message = permissionDeniedMessage ?? 'You do not have permission to perform this action.';
24
24
 
@@ -2,7 +2,10 @@
2
2
 
3
3
  import { useState, useEffect, useCallback } from 'react';
4
4
  import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
5
- import type { GatheredContext, EventBus } from '@semiont/core';
5
+ import type { GatheredContext } from '@semiont/core';
6
+ import { useSemiont } from '../../session/SemiontProvider';
7
+ import { useObservable } from '../../hooks/useObservable';
8
+ import { useEventSubscription } from '../../contexts/useEventSubscription';
6
9
  import { GatherContextStep } from './GatherContextStep';
7
10
  import { ConfigureGenerationStep } from './ConfigureGenerationStep';
8
11
  import type { GenerationConfig } from './ConfigureGenerationStep';
@@ -34,8 +37,6 @@ export interface ReferenceWizardModalProps {
34
37
  context: GatheredContext | null;
35
38
  contextLoading: boolean;
36
39
  contextError: Error | null;
37
- /** Event bus for emitting downstream events */
38
- eventBus: EventBus;
39
40
  /** Callbacks */
40
41
  onGenerateSubmit: (referenceId: string, config: GenerationConfig) => void;
41
42
  onLinkResource: (referenceId: string, targetResourceId: string) => void;
@@ -91,12 +92,12 @@ export function ReferenceWizardModal({
91
92
  context,
92
93
  contextLoading,
93
94
  contextError,
94
- eventBus,
95
95
  onGenerateSubmit,
96
96
  onLinkResource,
97
97
  onComposeNavigate,
98
98
  translations: t,
99
99
  }: ReferenceWizardModalProps) {
100
+ const session = useObservable(useSemiont().activeSession$);
100
101
  const [wizardStep, setWizardStep] = useState<WizardStep>({ step: 'gather' });
101
102
  const [isSearching, setIsSearching] = useState(false);
102
103
  const [userHint, setUserHint] = useState('');
@@ -110,19 +111,14 @@ export function ReferenceWizardModal({
110
111
  }
111
112
  }, [isOpen]);
112
113
 
113
- // Subscribe to search results
114
- useEffect(() => {
114
+ // Subscribe to search results (only react while open and for the current annotation)
115
+ useEventSubscription('match:search-results', (event) => {
115
116
  if (!isOpen) return;
116
-
117
- const subscription = eventBus.get('match:search-results').subscribe((event) => {
118
- if (annotationId && event.referenceId === annotationId) {
119
- setIsSearching(false);
120
- setWizardStep({ step: 'search-results', results: event.response as ScoredResult[] });
121
- }
122
- });
123
-
124
- return () => subscription.unsubscribe();
125
- }, [isOpen, eventBus, annotationId]);
117
+ if (annotationId && event.referenceId === annotationId) {
118
+ setIsSearching(false);
119
+ setWizardStep({ step: 'search-results', results: event.response as ScoredResult[] });
120
+ }
121
+ });
126
122
 
127
123
  const handleBind = useCallback(() => {
128
124
  setWizardStep({ step: 'configure-search' });
@@ -147,7 +143,7 @@ export function ReferenceWizardModal({
147
143
  if (!annotationId || !context || !resourceId) return;
148
144
  setIsSearching(true);
149
145
  const contextWithHint = userHint ? { ...context, userHint } : context;
150
- eventBus.get('match:search-requested').next({
146
+ session?.client.emit('match:search-requested', {
151
147
  correlationId: crypto.randomUUID(),
152
148
  resourceId,
153
149
  referenceId: annotationId,
@@ -156,7 +152,7 @@ export function ReferenceWizardModal({
156
152
  useSemanticScoring: config.useSemanticScoring,
157
153
  });
158
154
  // Stay on configure-search until results arrive (subscription above handles transition)
159
- }, [annotationId, resourceId, context, eventBus, userHint]);
155
+ }, [annotationId, resourceId, context, session, userHint]);
160
156
 
161
157
  const handleGenerateSubmit = useCallback((config: GenerationConfig) => {
162
158
  if (!annotationId) return;
@@ -1,14 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect } from 'react';
3
+ import { useState, useEffect, useRef } from 'react';
4
4
  import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
5
5
  import { map } from 'rxjs/operators';
6
6
  import type { components } from '@semiont/core';
7
- import { getResourceId, getPrimaryRepresentation } from '@semiont/api-client';
8
- import { useApiClient } from '../../contexts/ApiClientContext';
7
+ import { getResourceId, getPrimaryRepresentation, createSearchPipeline } from '@semiont/api-client';
8
+ import { useSemiont } from '../../session/SemiontProvider';
9
9
  import { useObservable } from '../../hooks/useObservable';
10
10
  import { useSearchAnnouncements } from '../../hooks/useSearchAnnouncements';
11
- import { createSearchPipeline } from '../../lib/search-pipeline';
12
11
 
13
12
  type ResourceDescriptor = components['schemas']['ResourceDescriptor'];
14
13
 
@@ -56,7 +55,12 @@ export function ResourceSearchModal({
56
55
  translations = {}
57
56
  }: ResourceSearchModalProps) {
58
57
  const { announceSearchResults, announceSearching, announceNavigation } = useSearchAnnouncements();
59
- const semiont = useApiClient();
58
+ const semiont = useObservable(useSemiont().activeSession$)?.client;
59
+ // Pipeline factory captures `semiont` once via useState; if semiont is
60
+ // still loading at first render the captured value would be undefined.
61
+ // Route through a ref so the fetch closure reads the latest client.
62
+ const semiontRef = useRef(semiont);
63
+ semiontRef.current = semiont;
60
64
 
61
65
  const t = {
62
66
  title: translations.title || 'Search Resources',
@@ -70,7 +74,7 @@ export function ResourceSearchModal({
70
74
  const [pipeline] = useState(() =>
71
75
  createSearchPipeline<SearchResult>(
72
76
  (q) =>
73
- semiont.browse.resources({ search: q, limit: SEARCH_LIMIT }).pipe(
77
+ semiontRef.current!.browse.resources({ search: q, limit: SEARCH_LIMIT }).pipe(
74
78
  map((resources) => {
75
79
  if (resources === undefined) return undefined;
76
80
  return resources
@@ -1,13 +1,12 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useEffect } from 'react';
3
+ import React, { useState, useEffect, useRef } from 'react';
4
4
  import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
5
5
  import { map } from 'rxjs/operators';
6
- import { getResourceId } from '@semiont/api-client';
6
+ import { getResourceId, createSearchPipeline } from '@semiont/api-client';
7
7
  import { useSearchAnnouncements } from '../../hooks/useSearchAnnouncements';
8
- import { useApiClient } from '../../contexts/ApiClientContext';
8
+ import { useSemiont } from '../../session/SemiontProvider';
9
9
  import { useObservable } from '../../hooks/useObservable';
10
- import { createSearchPipeline } from '../../lib/search-pipeline';
11
10
  import './SearchModal.css';
12
11
 
13
12
  const SEARCH_DEBOUNCE_MS = 300;
@@ -45,7 +44,12 @@ export function SearchModal({
45
44
  translations = {}
46
45
  }: SearchModalProps) {
47
46
  const { announceSearchResults, announceSearching } = useSearchAnnouncements();
48
- const semiont = useApiClient();
47
+ const semiont = useObservable(useSemiont().activeSession$)?.client;
48
+ // Pipeline factory captures `semiont` once via useState; if semiont is
49
+ // still loading at first render the captured value would be undefined.
50
+ // Route through a ref so the fetch closure reads the latest client.
51
+ const semiontRef = useRef(semiont);
52
+ semiontRef.current = semiont;
49
53
  const [selectedIndex, setSelectedIndex] = useState(0);
50
54
 
51
55
  const t = {
@@ -66,7 +70,7 @@ export function SearchModal({
66
70
  const [pipeline] = useState(() =>
67
71
  createSearchPipeline<SearchResult>(
68
72
  (q) =>
69
- semiont.browse.resources({ search: q, limit: SEARCH_LIMIT }).pipe(
73
+ semiontRef.current!.browse.resources({ search: q, limit: SEARCH_LIMIT }).pipe(
70
74
  map((resources) => {
71
75
  if (resources === undefined) return undefined;
72
76
  return resources
@@ -1,24 +1,24 @@
1
1
  'use client';
2
2
 
3
3
  import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
4
- import { useKnowledgeBaseSession } from '../../contexts/KnowledgeBaseSessionContext';
4
+ import { useSemiont } from '../../session/SemiontProvider';
5
+ import { useObservable } from '../../hooks/useObservable';
5
6
 
6
7
  /**
7
8
  * Modal that surfaces when the active KB's session expires (a 401 from
8
- * either the provider's own JWT validation or from any React Query call
9
+ * either the session's own JWT validation or from any React Query call
9
10
  * via the QueryCache.onError handler).
10
11
  *
11
- * Reads `sessionExpiredAt` from KnowledgeBaseSessionContext. When the user
12
- * dismisses the modal, the provider clears the flag.
13
- *
14
- * Must be mounted inside KnowledgeBaseSessionProvider.
12
+ * Reads `sessionExpiredAt$` from the active `FrontendSessionSignals`.
13
+ * When the user dismisses the modal, the signals instance clears the
14
+ * flag. Modal state lives on signals (not the session itself) so
15
+ * headless sessions (workers/CLIs) don't carry dead observables.
15
16
  */
16
17
  export function SessionExpiredModal() {
17
- const {
18
- sessionExpiredAt,
19
- sessionExpiredMessage,
20
- acknowledgeSessionExpired,
21
- } = useKnowledgeBaseSession();
18
+ const signals = useObservable(useSemiont().activeSignals$);
19
+ const sessionExpiredAt = useObservable(signals?.sessionExpiredAt$) ?? null;
20
+ const sessionExpiredMessage = useObservable(signals?.sessionExpiredMessage$) ?? null;
21
+ const acknowledgeSessionExpired = () => signals?.acknowledgeSessionExpired();
22
22
  const showModal = sessionExpiredAt !== null;
23
23
 
24
24
  const handleSignIn = () => {