@semiont/react-ui 0.4.20 → 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
@@ -12,14 +12,11 @@
12
12
 
13
13
  import { describe, it, expect, vi, beforeEach } from 'vitest';
14
14
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
15
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
15
+ import { BehaviorSubject } from 'rxjs';
16
16
  import { ResourceViewer } from '../ResourceViewer';
17
- import { EventBusProvider } from '../../../contexts/EventBusContext';
18
- import { CacheProvider } from '../../../contexts/CacheContext';
17
+ import { createTestSemiontWrapper } from '../../../test-utils';
19
18
  import { TranslationProvider } from '../../../contexts/TranslationContext';
20
19
  import { ResourceAnnotationsProvider } from '../../../contexts/ResourceAnnotationsContext';
21
- import { ApiClientProvider } from '../../../contexts/ApiClientContext';
22
- import { AuthTokenProvider } from '../../../contexts/AuthTokenContext';
23
20
  import type { components } from '@semiont/core';
24
21
 
25
22
  type SemiontResource = components['schemas']['ResourceDescriptor'];
@@ -29,6 +26,38 @@ vi.mock('../../../hooks/useObservableBrowse', () => ({
29
26
  useObservableExternalNavigation: () => vi.fn(),
30
27
  }));
31
28
 
29
+ // ResourceViewer (and ResourceAnnotationsContext) now resolve the client via
30
+ // useSemiont(). Mock useSemiont to emit a minimal session carrying a stub
31
+ // client with the methods the subject component touches. The session also
32
+ // exposes `on` and `emit` stubs so useEventSubscription(s) don't explode.
33
+ const stubClient = {
34
+ browse: { invalidateAnnotationList: vi.fn() },
35
+ markAnnotation: vi.fn(),
36
+ on: vi.fn(() => () => {}),
37
+ emit: vi.fn(),
38
+ stream: vi.fn(() => ({ subscribe: () => ({ unsubscribe: () => {} }) })),
39
+ };
40
+ const stubSession = {
41
+ client: stubClient,
42
+ };
43
+ const stubActiveSession$ = new BehaviorSubject<any>(stubSession);
44
+ const stubBrowser = {
45
+ activeSession$: stubActiveSession$,
46
+ emit: vi.fn(),
47
+ on: vi.fn(() => () => {}),
48
+ stream: vi.fn(() => ({ subscribe: () => ({ unsubscribe: () => {} }) })),
49
+ };
50
+
51
+ vi.mock('../../../session/SemiontProvider', async () => {
52
+ const actual = await vi.importActual<typeof import('../../../session/SemiontProvider')>(
53
+ '../../../session/SemiontProvider'
54
+ );
55
+ return {
56
+ ...actual,
57
+ useSemiont: () => stubBrowser,
58
+ };
59
+ });
60
+
32
61
  const mockResource: SemiontResource & { content: string } = {
33
62
  '@context': 'https://www.w3.org/ns/activitystreams',
34
63
  '@id': 'test-123',
@@ -53,19 +82,6 @@ const mockAnnotations = {
53
82
  tags: [],
54
83
  };
55
84
 
56
- const mockCacheManager = {
57
- invalidateAnnotations: vi.fn(),
58
- invalidate: vi.fn(),
59
- invalidateEvents: vi.fn(),
60
- };
61
-
62
- const queryClient = new QueryClient({
63
- defaultOptions: {
64
- queries: { retry: false },
65
- mutations: { retry: false },
66
- },
67
- });
68
-
69
85
  const mockTranslationManager = {
70
86
  t: (namespace: string, key: string, params?: Record<string, any>) => {
71
87
  return `${namespace}.${key}`;
@@ -73,21 +89,14 @@ const mockTranslationManager = {
73
89
  };
74
90
 
75
91
  function TestWrapper({ children }: { children: React.ReactNode }) {
92
+ const { SemiontWrapper } = createTestSemiontWrapper();
76
93
  return (
77
94
  <TranslationProvider translationManager={mockTranslationManager}>
78
- <EventBusProvider>
79
- <AuthTokenProvider token="test-token">
80
- <ApiClientProvider baseUrl="http://localhost:4000">
81
- <QueryClientProvider client={queryClient}>
82
- <ResourceAnnotationsProvider>
83
- <CacheProvider cacheManager={mockCacheManager}>
84
- {children}
85
- </CacheProvider>
86
- </ResourceAnnotationsProvider>
87
- </QueryClientProvider>
88
- </ApiClientProvider>
89
- </AuthTokenProvider>
90
- </EventBusProvider>
95
+ <SemiontWrapper>
96
+ <ResourceAnnotationsProvider>
97
+ {children}
98
+ </ResourceAnnotationsProvider>
99
+ </SemiontWrapper>
91
100
  </TranslationProvider>
92
101
  );
93
102
  }
@@ -3,8 +3,9 @@
3
3
  import type { Ref } from 'react';
4
4
  import type { components } from '@semiont/core';
5
5
  import { getAnnotationExactText } from '@semiont/api-client';
6
- import { useEventBus } from '../../../contexts/EventBusContext';
7
- import { useHoverEmitter } from '../../../hooks/useBeckonFlow';
6
+ import { useSemiont } from '../../../session/SemiontProvider';
7
+ import { useObservable } from '../../../hooks/useObservable';
8
+ import { useHoverEmitter } from '../../../hooks/useHoverEmitter';
8
9
 
9
10
  type Annotation = components['schemas']['Annotation'];
10
11
 
@@ -74,7 +75,7 @@ export function AssessmentEntry({
74
75
  isHovered = false,
75
76
  ref,
76
77
  }: AssessmentEntryProps) {
77
- const eventBus = useEventBus();
78
+ const session = useObservable(useSemiont().activeSession$);
78
79
  const hoverProps = useHoverEmitter(assessment.id);
79
80
 
80
81
  const selectedText = getAnnotationExactText(assessment);
@@ -87,7 +88,7 @@ export function AssessmentEntry({
87
88
  data-type="assessment"
88
89
  data-focused={isFocused ? 'true' : 'false'}
89
90
  onClick={() => {
90
- eventBus.get('browse:click').next({ annotationId: assessment.id, motivation: assessment.motivation });
91
+ session?.client.emit('browse:click', { annotationId: assessment.id, motivation: assessment.motivation });
91
92
  }}
92
93
  {...hoverProps}
93
94
  >
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback, useMemo } 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 { useEventSubscriptions } from '../../../contexts/useEventSubscription';
7
8
  import type { components, Selector } from '@semiont/core';
8
9
  import { getTextPositionSelector, getTargetSelector } from '@semiont/api-client';
@@ -13,6 +14,7 @@ import './AssessmentPanel.css';
13
14
 
14
15
  type Annotation = components['schemas']['Annotation'];
15
16
  type Motivation = components['schemas']['Motivation'];
17
+ type JobProgress = components['schemas']['JobProgress'];
16
18
 
17
19
  // Unified pending annotation type
18
20
  interface PendingAnnotation {
@@ -41,11 +43,7 @@ interface AssessmentPanelProps {
41
43
  annotations: Annotation[];
42
44
  pendingAnnotation: PendingAnnotation | null;
43
45
  isAssisting?: boolean;
44
- progress?: {
45
- status: string;
46
- percentage?: number;
47
- message?: string;
48
- } | null;
46
+ progress?: JobProgress | null;
49
47
  locale?: string;
50
48
  annotateMode?: boolean;
51
49
  scrollToAnnotationId?: string | null;
@@ -72,7 +70,7 @@ export function AssessmentPanel({
72
70
  hoveredAnnotationId,
73
71
  }: AssessmentPanelProps) {
74
72
  const t = useTranslations('AssessmentPanel');
75
- const eventBus = useEventBus();
73
+ const session = useObservable(useSemiont().activeSession$);
76
74
  const [newAssessmentText, setNewAssessmentText] = useState('');
77
75
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
78
76
  const containerRef = useRef<HTMLDivElement>(null);
@@ -139,14 +137,20 @@ export function AssessmentPanel({
139
137
 
140
138
  const handleSaveNewAssessment = () => {
141
139
  if (pendingAnnotation) {
142
- const body: components['schemas']['AnnotationBody'][] = newAssessmentText.trim()
143
- ? [{ type: 'TextualBody' as const, value: newAssessmentText, purpose: 'assessing' as const }]
144
- : [];
140
+ // Body is optional for assessments. When the user typed text,
141
+ // emit a single TextualBody (matching the worker's output and
142
+ // the majority of historical persisted assessments). When they
143
+ // didn't, omit body entirely — motivation:'assessing' on a
144
+ // target is a valid empty-content assessment.
145
+ const trimmed = newAssessmentText.trim();
146
+ const body: components['schemas']['AnnotationBody'] | undefined = trimmed
147
+ ? { type: 'TextualBody', value: trimmed, purpose: 'assessing' }
148
+ : undefined;
145
149
 
146
- eventBus.get('mark:submit').next({
150
+ session?.client.emit('mark:submit', {
147
151
  motivation: 'assessing',
148
152
  selector: pendingAnnotation.selector,
149
- body,
153
+ ...(body !== undefined ? { body } : {}),
150
154
  });
151
155
  setNewAssessmentText('');
152
156
  }
@@ -158,14 +162,14 @@ export function AssessmentPanel({
158
162
 
159
163
  const handleEscape = (e: KeyboardEvent) => {
160
164
  if (e.key === 'Escape') {
161
- eventBus.get('mark:cancel-pending').next(undefined);
165
+ session?.client.emit('mark:cancel-pending', undefined);
162
166
  setNewAssessmentText('');
163
167
  }
164
168
  };
165
169
 
166
170
  document.addEventListener('keydown', handleEscape);
167
171
  return () => document.removeEventListener('keydown', handleEscape);
168
- }, [pendingAnnotation]);
172
+ }, [pendingAnnotation, session]);
169
173
 
170
174
  // Event handler for annotation clicks (extracted to avoid inline arrow function)
171
175
  const handleAnnotationClick = useCallback(({ annotationId }: { annotationId: string }) => {
@@ -211,7 +215,7 @@ export function AssessmentPanel({
211
215
  <div className="semiont-annotation-prompt__actions">
212
216
  <button
213
217
  onClick={() => {
214
- eventBus.get('mark:cancel-pending').next(undefined);
218
+ session?.client.emit('mark:cancel-pending', undefined);
215
219
  setNewAssessmentText('');
216
220
  }}
217
221
  className="semiont-button semiont-button--secondary"
@@ -2,20 +2,18 @@
2
2
 
3
3
  import { useState, useEffect, useCallback } from 'react';
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
- import { useEventBus } from '../../../contexts/EventBusContext';
6
- import type { Motivation } from '@semiont/core';
5
+ import { useSemiont } from '../../../session/SemiontProvider';
6
+ import { useObservable } from '../../../hooks/useObservable';
7
+ import type { Motivation, components } from '@semiont/core';
7
8
  import './AssistSection.css';
8
9
 
10
+ type JobProgress = components['schemas']['JobProgress'];
11
+
9
12
  interface AssistSectionProps {
10
13
  annotationType: 'highlight' | 'assessment' | 'comment';
11
14
  isAssisting: boolean;
12
15
  locale?: string;
13
- progress?: {
14
- status: string;
15
- percentage?: number;
16
- message?: string;
17
- requestParams?: Array<{ label: string; value: string }>;
18
- } | null | undefined;
16
+ progress?: JobProgress | null | undefined;
19
17
  }
20
18
 
21
19
  // Color schemes are now handled via CSS data attributes
@@ -43,7 +41,7 @@ export function AssistSection({
43
41
  annotationType === 'assessment' ? 'AssessmentPanel' :
44
42
  'CommentsPanel';
45
43
  const t = useTranslations(panelName);
46
- const eventBus = useEventBus();
44
+ const session = useObservable(useSemiont().activeSession$);
47
45
  const [instructions, setInstructions] = useState('');
48
46
  type ToneValue = 'scholarly' | 'explanatory' | 'conversational' | 'technical' | 'analytical' | 'critical' | 'balanced' | 'constructive' | '';
49
47
  const [tone, setTone] = useState<ToneValue>('');
@@ -73,7 +71,7 @@ export function AssistSection({
73
71
  'commenting';
74
72
 
75
73
  // Emit mark:assist-request event with options
76
- eventBus.get('mark:assist-request').next({
74
+ session?.client.emit('mark:assist-request', {
77
75
  motivation,
78
76
  options: {
79
77
  instructions: instructions.trim() || undefined,
@@ -86,11 +84,11 @@ export function AssistSection({
86
84
  setInstructions('');
87
85
  setTone('');
88
86
  // Don't reset density/useDensity - persist across assists
89
- }, [annotationType, instructions, tone, useDensity, density, locale]); // eventBus is stable singleton - never in deps
87
+ }, [annotationType, instructions, tone, useDensity, density, locale, session]);
90
88
 
91
89
  const handleDismissProgress = useCallback(() => {
92
- eventBus.get('mark:progress-dismiss').next(undefined);
93
- }, []); // eventBus is stable singleton - never in deps
90
+ session?.client.emit('mark:progress-dismiss', undefined);
91
+ }, [session]);
94
92
 
95
93
  return (
96
94
  <div className="semiont-panel__section">
@@ -1,23 +1,45 @@
1
1
  'use client';
2
2
 
3
+ import type { ConnectionState } from '@semiont/api-client';
3
4
  import { useTranslations } from '../../../contexts/TranslationContext';
4
5
  import './CollaborationPanel.css';
5
6
 
6
7
  interface Props {
7
- isConnected: boolean;
8
+ /**
9
+ * Connection state from `client.actor.state$`. See
10
+ * `packages/api-client/src/view-models/domain/actor-vm.ts`.
11
+ *
12
+ * UI mapping:
13
+ * `open` | `reconnecting` | `initial` | `connecting`
14
+ * → treated as "healthy" (green dot, "Live" label, event count visible).
15
+ * `reconnecting` is specifically INCLUDED in healthy because a
16
+ * brief reconnect (mount churn, channel-set change, quick blip)
17
+ * shouldn't alarm the user. The 100 ms reconnect debounce and
18
+ * sub-second fetch retry make `reconnecting` a transient state.
19
+ * `degraded` | `closed`
20
+ * → treated as "disconnected" (red dot, "Disconnected" label).
21
+ * `degraded` is the 3 s threshold at which the state machine
22
+ * decides the disconnect is sustained; this is the UI-banner
23
+ * trigger the plan was designed around.
24
+ */
25
+ state: ConnectionState;
8
26
  eventCount: number;
9
27
  lastEventTimestamp?: string;
10
28
  knowledgeBaseName?: string;
11
29
  }
12
30
 
13
31
  export function CollaborationPanel({
14
- isConnected,
32
+ state,
15
33
  eventCount,
16
34
  lastEventTimestamp,
17
35
  knowledgeBaseName
18
36
  }: Props) {
19
37
  const t = useTranslations('CollaborationPanel');
20
38
 
39
+ // Healthy = live, or briefly flapping. Only genuinely sustained
40
+ // disconnects surface as "Disconnected" in the UI.
41
+ const isHealthy = state === 'open' || state === 'reconnecting' || state === 'initial' || state === 'connecting';
42
+
21
43
  // Format last sync time
22
44
  let lastSyncText: string;
23
45
  if (!lastEventTimestamp) {
@@ -71,16 +93,16 @@ export function CollaborationPanel({
71
93
  <span className="semiont-collaboration-panel__indicator">
72
94
  <span
73
95
  className="semiont-collaboration-panel__dot"
74
- data-connected={isConnected ? 'true' : 'false'}
96
+ data-connected={isHealthy ? 'true' : 'false'}
75
97
  ></span>
76
98
  <span
77
99
  className="semiont-collaboration-panel__status-text"
78
- data-connected={isConnected ? 'true' : 'false'}
100
+ data-connected={isHealthy ? 'true' : 'false'}
79
101
  >
80
- {isConnected ? t('live') : t('disconnected')}
102
+ {isHealthy ? t('live') : t('disconnected')}
81
103
  </span>
82
104
  </span>
83
- {isConnected && eventCount > 0 && (
105
+ {isHealthy && eventCount > 0 && (
84
106
  <span className="semiont-collaboration-panel__event-count">
85
107
  ({t('events', { count: eventCount })})
86
108
  </span>
@@ -93,7 +115,7 @@ export function CollaborationPanel({
93
115
  <span className="semiont-collaboration-panel__label">{t('lastSync')}</span> {lastSyncText}
94
116
  </div>
95
117
  <div>
96
- {isConnected
118
+ {isHealthy
97
119
  ? t('realtimeActive')
98
120
  : t('reconnecting')}
99
121
  </div>
@@ -4,8 +4,9 @@ import { useState, useEffect, useRef, useImperativeHandle, type Ref } from 'reac
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
5
  import type { components } from '@semiont/core';
6
6
  import { getAnnotationExactText, getCommentText } from '@semiont/api-client';
7
- import { useEventBus } from '../../../contexts/EventBusContext';
8
- import { useHoverEmitter } from '../../../hooks/useBeckonFlow';
7
+ import { useSemiont } from '../../../session/SemiontProvider';
8
+ import { useObservable } from '../../../hooks/useObservable';
9
+ import { useHoverEmitter } from '../../../hooks/useHoverEmitter';
9
10
 
10
11
  type Annotation = components['schemas']['Annotation'];
11
12
 
@@ -42,7 +43,7 @@ export function CommentEntry({
42
43
  ref,
43
44
  }: CommentEntryProps) {
44
45
  const t = useTranslations('CommentsPanel');
45
- const eventBus = useEventBus();
46
+ const session = useObservable(useSemiont().activeSession$);
46
47
  const hoverProps = useHoverEmitter(comment.id);
47
48
  const [isEditing, setIsEditing] = useState(false);
48
49
  const [editText, setEditText] = useState('');
@@ -86,7 +87,7 @@ export function CommentEntry({
86
87
  data-type="comment"
87
88
  data-focused={isFocused ? 'true' : 'false'}
88
89
  onClick={() => {
89
- eventBus.get('browse:click').next({ annotationId: comment.id, motivation: comment.motivation });
90
+ session?.client.emit('browse:click', { annotationId: comment.id, motivation: comment.motivation });
90
91
  }}
91
92
  {...hoverProps}
92
93
  >
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback, useMemo } 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 { useEventSubscriptions } from '../../../contexts/useEventSubscription';
7
8
  import type { components, Selector } from '@semiont/core';
8
9
  import { getTextPositionSelector, getTargetSelector } from '@semiont/api-client';
@@ -13,6 +14,7 @@ import './CommentsPanel.css';
13
14
 
14
15
  type Annotation = components['schemas']['Annotation'];
15
16
  type Motivation = components['schemas']['Motivation'];
17
+ type JobProgress = components['schemas']['JobProgress'];
16
18
 
17
19
  // Unified pending annotation type
18
20
  interface PendingAnnotation {
@@ -42,11 +44,7 @@ interface CommentsPanelProps {
42
44
  pendingAnnotation: PendingAnnotation | null;
43
45
  annotateMode?: boolean;
44
46
  isAssisting?: boolean;
45
- progress?: {
46
- status: string;
47
- percentage?: number;
48
- message?: string;
49
- } | null;
47
+ progress?: JobProgress | null;
50
48
  locale?: string;
51
49
  scrollToAnnotationId?: string | null;
52
50
  onScrollCompleted?: () => void;
@@ -72,7 +70,7 @@ export function CommentsPanel({
72
70
  hoveredAnnotationId,
73
71
  }: CommentsPanelProps) {
74
72
  const t = useTranslations('CommentsPanel');
75
- const eventBus = useEventBus();
73
+ const session = useObservable(useSemiont().activeSession$);
76
74
  const [newCommentText, setNewCommentText] = useState('');
77
75
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
78
76
  const containerRef = useRef<HTMLDivElement>(null);
@@ -169,7 +167,7 @@ export function CommentsPanel({
169
167
 
170
168
  const handleSaveNewComment = () => {
171
169
  if (newCommentText.trim() && pendingAnnotation) {
172
- eventBus.get('mark:submit').next({
170
+ session?.client.emit('mark:submit', {
173
171
  motivation: 'commenting',
174
172
  selector: pendingAnnotation.selector,
175
173
  body: [{ type: 'TextualBody', value: newCommentText, purpose: 'commenting' }],
@@ -184,14 +182,14 @@ export function CommentsPanel({
184
182
 
185
183
  const handleEscape = (e: KeyboardEvent) => {
186
184
  if (e.key === 'Escape') {
187
- eventBus.get('mark:cancel-pending').next(undefined);
185
+ session?.client.emit('mark:cancel-pending', undefined);
188
186
  setNewCommentText('');
189
187
  }
190
188
  };
191
189
 
192
190
  document.addEventListener('keydown', handleEscape);
193
191
  return () => document.removeEventListener('keydown', handleEscape);
194
- }, [pendingAnnotation]);
192
+ }, [pendingAnnotation, session]);
195
193
 
196
194
  return (
197
195
  <div className="semiont-panel">
@@ -226,7 +224,7 @@ export function CommentsPanel({
226
224
  <div className="semiont-annotation-prompt__actions">
227
225
  <button
228
226
  onClick={() => {
229
- eventBus.get('mark:cancel-pending').next(undefined);
227
+ session?.client.emit('mark:cancel-pending', undefined);
230
228
  setNewCommentText('');
231
229
  }}
232
230
  className="semiont-button semiont-button--secondary"
@@ -3,8 +3,9 @@
3
3
  import type { Ref } from 'react';
4
4
  import type { components } from '@semiont/core';
5
5
  import { getAnnotationExactText } from '@semiont/api-client';
6
- import { useEventBus } from '../../../contexts/EventBusContext';
7
- import { useHoverEmitter } from '../../../hooks/useBeckonFlow';
6
+ import { useSemiont } from '../../../session/SemiontProvider';
7
+ import { useObservable } from '../../../hooks/useObservable';
8
+ import { useHoverEmitter } from '../../../hooks/useHoverEmitter';
8
9
 
9
10
  type Annotation = components['schemas']['Annotation'];
10
11
 
@@ -38,7 +39,7 @@ export function HighlightEntry({
38
39
  isHovered = false,
39
40
  ref,
40
41
  }: HighlightEntryProps) {
41
- const eventBus = useEventBus();
42
+ const session = useObservable(useSemiont().activeSession$);
42
43
  const hoverProps = useHoverEmitter(highlight.id);
43
44
 
44
45
  const selectedText = getAnnotationExactText(highlight);
@@ -50,7 +51,7 @@ export function HighlightEntry({
50
51
  data-type="highlight"
51
52
  data-focused={isFocused ? 'true' : 'false'}
52
53
  onClick={() => {
53
- eventBus.get('browse:click').next({ annotationId: highlight.id, motivation: highlight.motivation });
54
+ session?.client.emit('browse:click', { annotationId: highlight.id, motivation: highlight.motivation });
54
55
  }}
55
56
  {...hoverProps}
56
57
  >
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { useEffect, useState, useRef, useCallback, useMemo } 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 { useEventSubscriptions } from '../../../contexts/useEventSubscription';
7
8
  import type { components, Selector } from '@semiont/core';
8
9
  import { getTextPositionSelector, getTargetSelector } from '@semiont/api-client';
@@ -13,6 +14,7 @@ import './HighlightPanel.css';
13
14
 
14
15
  type Annotation = components['schemas']['Annotation'];
15
16
  type Motivation = components['schemas']['Motivation'];
17
+ type JobProgress = components['schemas']['JobProgress'];
16
18
 
17
19
  // Unified pending annotation type
18
20
  interface PendingAnnotation {
@@ -24,11 +26,7 @@ interface HighlightPanelProps {
24
26
  annotations: Annotation[];
25
27
  pendingAnnotation: PendingAnnotation | null;
26
28
  isAssisting?: boolean;
27
- progress?: {
28
- status: string;
29
- percentage?: number;
30
- message?: string;
31
- } | null;
29
+ progress?: JobProgress | null;
32
30
  annotateMode?: boolean;
33
31
  scrollToAnnotationId?: string | null;
34
32
  onScrollCompleted?: () => void;
@@ -53,7 +51,7 @@ export function HighlightPanel({
53
51
  }: HighlightPanelProps) {
54
52
 
55
53
  const t = useTranslations('HighlightPanel');
56
- const eventBus = useEventBus();
54
+ const session = useObservable(useSemiont().activeSession$);
57
55
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
58
56
  const containerRef = useRef<HTMLDivElement>(null);
59
57
 
@@ -129,16 +127,17 @@ export function HighlightPanel({
129
127
  });
130
128
 
131
129
  // Highlights auto-create: when pendingAnnotation arrives with highlighting motivation,
132
- // immediately emit mark:submit event
130
+ // immediately emit mark:submit event. Highlights carry no body —
131
+ // motivation:'highlighting' on a target is a complete annotation
132
+ // per the W3C Web Annotation Model.
133
133
  useEffect(() => {
134
134
  if (pendingAnnotation && pendingAnnotation.motivation === 'highlighting') {
135
- eventBus.get('mark:submit').next({
135
+ session?.client.emit('mark:submit', {
136
136
  motivation: 'highlighting',
137
137
  selector: pendingAnnotation.selector,
138
- body: [],
139
138
  });
140
139
  }
141
- }, [pendingAnnotation]);
140
+ }, [pendingAnnotation, session]);
142
141
 
143
142
  return (
144
143
  <div className="semiont-panel">
@@ -8,10 +8,10 @@ import { annotationId, resourceId } from '@semiont/core';
8
8
  import { getAnnotationExactText, isBodyResolved, getBodySource, getFragmentSelector, getSvgSelector, getTargetSelector } from '@semiont/api-client';
9
9
  import { getEntityTypes } from '@semiont/ontology';
10
10
  import { getResourceIcon } from '../../../lib/resource-utils';
11
- import { useEventBus } from '../../../contexts/EventBusContext';
12
- import { useApiClient } from '../../../contexts/ApiClientContext';
11
+ import { useSemiont } from '../../../session/SemiontProvider';
12
+ import { useObservable } from '../../../hooks/useObservable';
13
13
  import { useObservableExternalNavigation } from '../../../hooks/useObservableBrowse';
14
- import { useHoverEmitter } from '../../../hooks/useBeckonFlow';
14
+ import { useHoverEmitter } from '../../../hooks/useHoverEmitter';
15
15
 
16
16
  type Annotation = components['schemas']['Annotation'];
17
17
 
@@ -41,8 +41,8 @@ export function ReferenceEntry({
41
41
  ref,
42
42
  }: ReferenceEntryProps) {
43
43
  const t = useTranslations('ReferencesPanel');
44
- const eventBus = useEventBus();
45
- const semiont = useApiClient();
44
+ const session = useObservable(useSemiont().activeSession$);
45
+ const semiont = session?.client;
46
46
  const navigate = useObservableExternalNavigation();
47
47
  const hoverProps = useHoverEmitter(reference.id);
48
48
 
@@ -75,7 +75,7 @@ export function ReferenceEntry({
75
75
  : '';
76
76
 
77
77
  const handleUnlink = () => {
78
- if (source && resolvedResourceUri) {
78
+ if (source && resolvedResourceUri && semiont) {
79
79
  semiont.bind.body(
80
80
  resourceId(source),
81
81
  annotationId(reference.id),
@@ -85,7 +85,7 @@ export function ReferenceEntry({
85
85
  };
86
86
 
87
87
  const handleInitiateWizard = () => {
88
- eventBus.get('bind:initiate').next({
88
+ session?.client.emit('bind:initiate', {
89
89
  annotationId: annotationId(reference.id),
90
90
  resourceId: resourceId(source),
91
91
  defaultTitle: selectedText,
@@ -112,7 +112,7 @@ export function ReferenceEntry({
112
112
  data-type="reference"
113
113
  data-focused={isFocused ? 'true' : 'false'}
114
114
  onClick={() => {
115
- eventBus.get('browse:click').next({ annotationId: reference.id, motivation: reference.motivation });
115
+ session?.client.emit('browse:click', { annotationId: reference.id, motivation: reference.motivation });
116
116
  }}
117
117
  {...hoverProps}
118
118
  >