@semiont/react-ui 0.2.33-build.80 → 0.2.33-build.81

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 (27) hide show
  1. package/dist/{EventBusContext-7GvDyO0d.d.mts → EventBusContext-CJjL_cCf.d.mts} +67 -19
  2. package/dist/{chunk-ZR4ZV2LY.mjs → chunk-QB52Q7EQ.mjs} +7 -7
  3. package/dist/chunk-QB52Q7EQ.mjs.map +1 -0
  4. package/dist/index.d.mts +166 -199
  5. package/dist/index.mjs +1486 -1599
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/test-utils.d.mts +2 -2
  8. package/dist/test-utils.mjs +1 -1
  9. package/package.json +1 -1
  10. package/src/components/LiveRegion.tsx +18 -18
  11. package/src/components/SessionExpiryBanner.tsx +2 -3
  12. package/src/components/SessionTimer.tsx +2 -2
  13. package/src/components/__tests__/SessionTimer.test.tsx +27 -27
  14. package/src/components/resource/panels/AssessmentPanel.tsx +2 -2
  15. package/src/components/resource/panels/DetectSection.tsx +13 -7
  16. package/src/components/resource/panels/ReferenceEntry.tsx +1 -1
  17. package/src/components/resource/panels/TaggingPanel.tsx +2 -3
  18. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +1 -1
  19. package/src/features/resource-viewer/__tests__/AnnotationDeletionIntegration.test.tsx +16 -13
  20. package/src/features/resource-viewer/__tests__/DetectionFlowBug.test.tsx +1 -1
  21. package/src/features/resource-viewer/__tests__/DetectionFlowIntegration.test.tsx +13 -13
  22. package/src/features/resource-viewer/__tests__/DetectionProgressDismissal.test.tsx +5 -5
  23. package/src/features/resource-viewer/__tests__/GenerationFlowIntegration.test.tsx +5 -6
  24. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +11 -12
  25. package/src/features/resource-viewer/__tests__/detection-progress-flow.test.tsx +3 -3
  26. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +130 -93
  27. package/dist/chunk-ZR4ZV2LY.mjs.map +0 -1
@@ -2,8 +2,8 @@ import { ReactElement } from 'react';
2
2
  import { RenderOptions, RenderResult } from '@testing-library/react';
3
3
  export * from '@testing-library/react';
4
4
  import { QueryClient } from '@tanstack/react-query';
5
- import { T as TranslationManager, S as SessionManager, O as OpenResourcesManager, E as EventBus } from './EventBusContext-7GvDyO0d.mjs';
6
- export { r as resetEventBusForTesting } from './EventBusContext-7GvDyO0d.mjs';
5
+ import { T as TranslationManager, S as SessionManager, O as OpenResourcesManager, E as EventBus } from './EventBusContext-CJjL_cCf.mjs';
6
+ export { r as resetEventBusForTesting } from './EventBusContext-CJjL_cCf.mjs';
7
7
  export { vi } from 'vitest';
8
8
  import 'react/jsx-runtime';
9
9
  import 'mitt';
@@ -8,7 +8,7 @@ import {
8
8
  TranslationProvider,
9
9
  resetEventBusForTesting,
10
10
  useEventBus
11
- } from "./chunk-ZR4ZV2LY.mjs";
11
+ } from "./chunk-QB52Q7EQ.mjs";
12
12
  import "./chunk-D7NBW4RV.mjs";
13
13
  import {
14
14
  __commonJS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.2.33-build.80",
3
+ "version": "0.2.33-build.81",
4
4
  "description": "React components and hooks for Semiont",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.mts",
@@ -76,11 +76,11 @@ export function useSearchAnnouncements() {
76
76
  } else {
77
77
  announce(`${count} result${count === 1 ? '' : 's'} found for ${query}`, 'polite');
78
78
  }
79
- }, []);
79
+ }, [announce]);
80
80
 
81
81
  const announceSearching = useCallback(() => {
82
82
  announce('Searching...', 'polite');
83
- }, []);
83
+ }, [announce]);
84
84
 
85
85
  return {
86
86
  announceSearchResults,
@@ -93,31 +93,31 @@ export function useDocumentAnnouncements(annotators?: Record<string, Annotator>)
93
93
 
94
94
  const announceDocumentSaved = useCallback(() => {
95
95
  announce('Document saved successfully', 'polite');
96
- }, []);
96
+ }, [announce]);
97
97
 
98
98
  const announceDocumentDeleted = useCallback(() => {
99
99
  announce('Document deleted', 'polite');
100
- }, []);
100
+ }, [announce]);
101
101
 
102
102
  const announceAnnotationCreated = useCallback((annotation: Annotation) => {
103
103
  const metadata = annotators ? Object.values(annotators).find(a => a.matchesAnnotation(annotation)) : null;
104
104
  const message = metadata?.announceOnCreate ?? 'Annotation created';
105
105
  announce(message, 'polite');
106
- }, [annotators]);
106
+ }, [announce, annotators]);
107
107
 
108
108
  const announceAnnotationDeleted = useCallback(() => {
109
109
  announce('Annotation deleted', 'polite');
110
- }, []);
110
+ }, [announce]);
111
111
 
112
112
  const announceAnnotationUpdated = useCallback((annotation: Annotation) => {
113
113
  const metadata = annotators ? Object.values(annotators).find(a => a.matchesAnnotation(annotation)) : null;
114
114
  const message = `${metadata?.displayName ?? 'Annotation'} updated`;
115
115
  announce(message, 'polite');
116
- }, [annotators]);
116
+ }, [announce, annotators]);
117
117
 
118
118
  const announceError = useCallback((message: string) => {
119
119
  announce(`Error: ${message}`, 'assertive');
120
- }, []);
120
+ }, [announce]);
121
121
 
122
122
  return {
123
123
  announceDocumentSaved,
@@ -138,22 +138,22 @@ export function useResourceLoadingAnnouncements() {
138
138
  ? `Loading ${resourceName}...`
139
139
  : 'Loading resource...';
140
140
  announce(message, 'polite');
141
- }, []);
141
+ }, [announce]);
142
142
 
143
143
  const announceResourceLoaded = useCallback((resourceName: string) => {
144
144
  announce(`${resourceName} loaded successfully`, 'polite');
145
- }, []);
145
+ }, [announce]);
146
146
 
147
147
  const announceResourceLoadError = useCallback((resourceName?: string) => {
148
148
  const message = resourceName
149
149
  ? `Failed to load ${resourceName}`
150
150
  : 'Failed to load resource';
151
151
  announce(message, 'assertive');
152
- }, []);
152
+ }, [announce]);
153
153
 
154
154
  const announceResourceUpdating = useCallback((resourceName: string) => {
155
155
  announce(`Updating ${resourceName}...`, 'polite');
156
- }, []);
156
+ }, [announce]);
157
157
 
158
158
  return {
159
159
  announceResourceLoading,
@@ -169,22 +169,22 @@ export function useFormAnnouncements() {
169
169
 
170
170
  const announceFormSubmitting = useCallback(() => {
171
171
  announce('Submitting form...', 'polite');
172
- }, []);
172
+ }, [announce]);
173
173
 
174
174
  const announceFormSuccess = useCallback((message?: string) => {
175
175
  announce(message || 'Form submitted successfully', 'polite');
176
- }, []);
176
+ }, [announce]);
177
177
 
178
178
  const announceFormError = useCallback((message?: string) => {
179
179
  announce(message || 'Form submission failed. Please check your entries and try again.', 'assertive');
180
- }, []);
180
+ }, [announce]);
181
181
 
182
182
  const announceFormValidationError = useCallback((fieldCount: number) => {
183
183
  const message = fieldCount === 1
184
184
  ? 'There is 1 field with an error'
185
185
  : `There are ${fieldCount} fields with errors`;
186
186
  announce(message, 'assertive');
187
- }, []);
187
+ }, [announce]);
188
188
 
189
189
  return {
190
190
  announceFormSubmitting,
@@ -200,11 +200,11 @@ export function useLanguageChangeAnnouncements() {
200
200
 
201
201
  const announceLanguageChanging = useCallback((newLanguage: string) => {
202
202
  announce(`Changing language to ${newLanguage}...`, 'polite');
203
- }, []);
203
+ }, [announce]);
204
204
 
205
205
  const announceLanguageChanged = useCallback((newLanguage: string) => {
206
206
  announce(`Language changed to ${newLanguage}`, 'polite');
207
- }, []);
207
+ }, [announce]);
208
208
 
209
209
  return {
210
210
  announceLanguageChanging,
@@ -1,13 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
- import { useSessionExpiry } from '@semiont/react-ui';
5
- import { useFormattedTime } from '@semiont/react-ui';
4
+ import { useSessionExpiry, formatTime } from '@semiont/react-ui';
6
5
 
7
6
  export function SessionExpiryBanner() {
8
7
  const { timeRemaining, isExpiringSoon } = useSessionExpiry();
9
8
  const [dismissed, setDismissed] = useState(false);
10
- const formattedTime = useFormattedTime(timeRemaining);
9
+ const formattedTime = formatTime(timeRemaining);
11
10
 
12
11
  // Don't show if:
13
12
  // - Session is not expiring soon
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { useSessionExpiry } from '../hooks/useSessionExpiry';
4
- import { useFormattedTime } from '../hooks/useFormattedTime';
4
+ import { formatTime } from '../lib/formatTime';
5
5
 
6
6
  export function SessionTimer() {
7
7
  const { timeRemaining } = useSessionExpiry();
8
- const formattedTime = useFormattedTime(timeRemaining);
8
+ const formattedTime = formatTime(timeRemaining);
9
9
 
10
10
  if (!formattedTime) return null;
11
11
 
@@ -7,18 +7,18 @@ vi.mock('../../hooks/useSessionExpiry', () => ({
7
7
  useSessionExpiry: vi.fn(),
8
8
  }));
9
9
 
10
- vi.mock('../../hooks/useFormattedTime', () => ({
11
- useFormattedTime: vi.fn(),
10
+ vi.mock('../../lib/formatTime', () => ({
11
+ formatTime: vi.fn(),
12
12
  }));
13
13
 
14
14
  import { useSessionExpiry } from '../../hooks/useSessionExpiry';
15
- import { useFormattedTime } from '../../hooks/useFormattedTime';
15
+ import { formatTime } from '../../lib/formatTime';
16
16
 
17
17
  describe('SessionTimer', () => {
18
18
  describe('Rendering', () => {
19
19
  it('should render formatted time when available', () => {
20
20
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 300000 } as any);
21
- vi.mocked(useFormattedTime).mockReturnValue('5:00');
21
+ vi.mocked(formatTime).mockReturnValue('5:00');
22
22
 
23
23
  render(<SessionTimer />);
24
24
 
@@ -28,7 +28,7 @@ describe('SessionTimer', () => {
28
28
 
29
29
  it('should have correct class name', () => {
30
30
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 300000 } as any);
31
- vi.mocked(useFormattedTime).mockReturnValue('5:00');
31
+ vi.mocked(formatTime).mockReturnValue('5:00');
32
32
 
33
33
  const { container } = render(<SessionTimer />);
34
34
 
@@ -37,7 +37,7 @@ describe('SessionTimer', () => {
37
37
 
38
38
  it('should display complete message with formatted time', () => {
39
39
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 120000 } as any);
40
- vi.mocked(useFormattedTime).mockReturnValue('2:00');
40
+ vi.mocked(formatTime).mockReturnValue('2:00');
41
41
 
42
42
  render(<SessionTimer />);
43
43
 
@@ -49,7 +49,7 @@ describe('SessionTimer', () => {
49
49
  describe('Null Cases', () => {
50
50
  it('should return null when formattedTime is null', () => {
51
51
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 0 } as any);
52
- vi.mocked(useFormattedTime).mockReturnValue(null);
52
+ vi.mocked(formatTime).mockReturnValue(null);
53
53
 
54
54
  const { container } = render(<SessionTimer />);
55
55
 
@@ -58,7 +58,7 @@ describe('SessionTimer', () => {
58
58
 
59
59
  it('should return null when formattedTime is undefined', () => {
60
60
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 0 } as any);
61
- vi.mocked(useFormattedTime).mockReturnValue(undefined as any);
61
+ vi.mocked(formatTime).mockReturnValue(undefined as any);
62
62
 
63
63
  const { container } = render(<SessionTimer />);
64
64
 
@@ -67,7 +67,7 @@ describe('SessionTimer', () => {
67
67
 
68
68
  it('should return null when formattedTime is empty string', () => {
69
69
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 0 } as any);
70
- vi.mocked(useFormattedTime).mockReturnValue('');
70
+ vi.mocked(formatTime).mockReturnValue('');
71
71
 
72
72
  const { container } = render(<SessionTimer />);
73
73
 
@@ -79,31 +79,31 @@ describe('SessionTimer', () => {
79
79
  it('should call useSessionExpiry hook', () => {
80
80
  const mockUseSessionExpiry = vi.mocked(useSessionExpiry);
81
81
  mockUseSessionExpiry.mockReturnValue({ timeRemaining: 100000 } as any);
82
- vi.mocked(useFormattedTime).mockReturnValue('1:40');
82
+ vi.mocked(formatTime).mockReturnValue('1:40');
83
83
 
84
84
  render(<SessionTimer />);
85
85
 
86
86
  expect(mockUseSessionExpiry).toHaveBeenCalled();
87
87
  });
88
88
 
89
- it('should call useFormattedTime with timeRemaining', () => {
90
- const mockUseFormattedTime = vi.mocked(useFormattedTime);
89
+ it('should call formatTime with timeRemaining', () => {
90
+ const mockFormatTime = vi.mocked(formatTime);
91
91
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 300000 } as any);
92
- mockUseFormattedTime.mockReturnValue('5:00');
92
+ mockFormatTime.mockReturnValue('5:00');
93
93
 
94
94
  render(<SessionTimer />);
95
95
 
96
- expect(mockUseFormattedTime).toHaveBeenCalledWith(300000);
96
+ expect(mockFormatTime).toHaveBeenCalledWith(300000);
97
97
  });
98
98
 
99
- it('should pass correct timeRemaining to useFormattedTime', () => {
100
- const mockUseFormattedTime = vi.mocked(useFormattedTime);
99
+ it('should pass correct timeRemaining to formatTime', () => {
100
+ const mockFormatTime = vi.mocked(formatTime);
101
101
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 12345 } as any);
102
- mockUseFormattedTime.mockReturnValue('0:12');
102
+ mockFormatTime.mockReturnValue('0:12');
103
103
 
104
104
  render(<SessionTimer />);
105
105
 
106
- expect(mockUseFormattedTime).toHaveBeenCalledWith(12345);
106
+ expect(mockFormatTime).toHaveBeenCalledWith(12345);
107
107
  });
108
108
  });
109
109
 
@@ -118,7 +118,7 @@ describe('SessionTimer', () => {
118
118
 
119
119
  testCases.forEach(({ timeRemaining, formatted }) => {
120
120
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining } as any);
121
- vi.mocked(useFormattedTime).mockReturnValue(formatted);
121
+ vi.mocked(formatTime).mockReturnValue(formatted);
122
122
 
123
123
  const { unmount } = render(<SessionTimer />);
124
124
 
@@ -132,17 +132,17 @@ describe('SessionTimer', () => {
132
132
  describe('Re-rendering', () => {
133
133
  it('should update when timeRemaining changes', () => {
134
134
  const mockUseSessionExpiry = vi.mocked(useSessionExpiry);
135
- const mockUseFormattedTime = vi.mocked(useFormattedTime);
135
+ const mockFormatTime = vi.mocked(formatTime);
136
136
 
137
137
  mockUseSessionExpiry.mockReturnValue({ timeRemaining: 60000 } as any);
138
- mockUseFormattedTime.mockReturnValue('1:00');
138
+ mockFormatTime.mockReturnValue('1:00');
139
139
 
140
140
  const { rerender } = render(<SessionTimer />);
141
141
  expect(screen.getByText('Session: 1:00 remaining')).toBeInTheDocument();
142
142
 
143
143
  // Simulate time passing
144
144
  mockUseSessionExpiry.mockReturnValue({ timeRemaining: 30000 } as any);
145
- mockUseFormattedTime.mockReturnValue('0:30');
145
+ mockFormatTime.mockReturnValue('0:30');
146
146
 
147
147
  rerender(<SessionTimer />);
148
148
  expect(screen.getByText('Session: 0:30 remaining')).toBeInTheDocument();
@@ -150,13 +150,13 @@ describe('SessionTimer', () => {
150
150
 
151
151
  it('should hide when formattedTime becomes null', () => {
152
152
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 60000 } as any);
153
- vi.mocked(useFormattedTime).mockReturnValue('1:00');
153
+ vi.mocked(formatTime).mockReturnValue('1:00');
154
154
 
155
155
  const { container, rerender } = render(<SessionTimer />);
156
156
  expect(screen.getByText('Session: 1:00 remaining')).toBeInTheDocument();
157
157
 
158
158
  // Time expires
159
- vi.mocked(useFormattedTime).mockReturnValue(null);
159
+ vi.mocked(formatTime).mockReturnValue(null);
160
160
 
161
161
  rerender(<SessionTimer />);
162
162
  expect(container.firstChild).toBeNull();
@@ -166,7 +166,7 @@ describe('SessionTimer', () => {
166
166
  describe('Edge Cases', () => {
167
167
  it('should handle zero timeRemaining', () => {
168
168
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 0 } as any);
169
- vi.mocked(useFormattedTime).mockReturnValue('0:00');
169
+ vi.mocked(formatTime).mockReturnValue('0:00');
170
170
 
171
171
  render(<SessionTimer />);
172
172
 
@@ -175,7 +175,7 @@ describe('SessionTimer', () => {
175
175
 
176
176
  it('should handle negative timeRemaining', () => {
177
177
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: -1000 } as any);
178
- vi.mocked(useFormattedTime).mockReturnValue('expired');
178
+ vi.mocked(formatTime).mockReturnValue('expired');
179
179
 
180
180
  render(<SessionTimer />);
181
181
 
@@ -184,7 +184,7 @@ describe('SessionTimer', () => {
184
184
 
185
185
  it('should handle very large timeRemaining', () => {
186
186
  vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 999999999 } as any);
187
- vi.mocked(useFormattedTime).mockReturnValue('16666:39');
187
+ vi.mocked(formatTime).mockReturnValue('16666:39');
188
188
 
189
189
  render(<SessionTimer />);
190
190
 
@@ -137,8 +137,8 @@ export function AssessmentPanel({
137
137
 
138
138
  const handleSaveNewAssessment = () => {
139
139
  if (pendingAnnotation) {
140
- const body = newAssessmentText.trim()
141
- ? [{ type: 'TextualBody', value: newAssessmentText, purpose: 'assessing' }]
140
+ const body: components['schemas']['AnnotationBody'][] = newAssessmentText.trim()
141
+ ? [{ type: 'TextualBody' as const, value: newAssessmentText, purpose: 'assessing' as const }]
142
142
  : [];
143
143
 
144
144
  eventBus.emit('annotation:create', {
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
5
  import { useEventBus } from '../../../contexts/EventBusContext';
6
6
  import type { Motivation } from '@semiont/api-client';
@@ -29,6 +29,7 @@ interface DetectSectionProps {
29
29
  * - Progress display during detection
30
30
  *
31
31
  * @emits detection:start - Start detection for annotation type. Payload: { motivation: Motivation, options: { instructions?: string, tone?: string, density?: number } }
32
+ * @emits detection:dismiss-progress - Dismiss the detection progress display
32
33
  */
33
34
  export function DetectSection({
34
35
  annotationType,
@@ -42,7 +43,8 @@ export function DetectSection({
42
43
  const t = useTranslations(panelName);
43
44
  const eventBus = useEventBus();
44
45
  const [instructions, setInstructions] = useState('');
45
- const [tone, setTone] = useState('');
46
+ type ToneValue = 'scholarly' | 'explanatory' | 'conversational' | 'technical' | 'analytical' | 'critical' | 'balanced' | 'constructive' | '';
47
+ const [tone, setTone] = useState<ToneValue>('');
46
48
  // Default density depends on annotation type
47
49
  const defaultDensity = annotationType === 'comment' ? 5 : annotationType === 'assessment' ? 4 : annotationType === 'highlight' ? 5 : 5;
48
50
  const [density, setDensity] = useState(defaultDensity);
@@ -61,7 +63,7 @@ export function DetectSection({
61
63
  localStorage.setItem(`detect-section-expanded-${annotationType}`, String(isExpanded));
62
64
  }, [isExpanded, annotationType]);
63
65
 
64
- const handleDetect = () => {
66
+ const handleDetect = useCallback(() => {
65
67
  // Map annotation type to motivation
66
68
  const motivation: Motivation =
67
69
  annotationType === 'highlight' ? 'highlighting' :
@@ -73,7 +75,7 @@ export function DetectSection({
73
75
  motivation,
74
76
  options: {
75
77
  instructions: instructions.trim() || undefined,
76
- tone: (annotationType === 'comment' || annotationType === 'assessment') && tone ? tone as any : undefined,
78
+ tone: (annotationType === 'comment' || annotationType === 'assessment') && tone ? tone : undefined,
77
79
  density: (annotationType === 'comment' || annotationType === 'assessment' || annotationType === 'highlight') && useDensity ? density : undefined,
78
80
  },
79
81
  });
@@ -81,7 +83,11 @@ export function DetectSection({
81
83
  setInstructions('');
82
84
  setTone('');
83
85
  // Don't reset density/useDensity - persist across detections
84
- };
86
+ }, [annotationType, instructions, tone, useDensity, density]); // eventBus is stable singleton - never in deps
87
+
88
+ const handleDismissProgress = useCallback(() => {
89
+ eventBus.emit('detection:dismiss-progress', undefined);
90
+ }, []); // eventBus is stable singleton - never in deps
85
91
 
86
92
  return (
87
93
  <div className="semiont-panel__section">
@@ -134,7 +140,7 @@ export function DetectSection({
134
140
  </label>
135
141
  <select
136
142
  value={tone}
137
- onChange={(e) => setTone(e.target.value)}
143
+ onChange={(e) => setTone(e.target.value as ToneValue)}
138
144
  className="semiont-select"
139
145
  >
140
146
  <option value="">Default</option>
@@ -233,7 +239,7 @@ export function DetectSection({
233
239
  {/* Close button - shown after detection completes (when not actively detecting) */}
234
240
  {!isDetecting && (
235
241
  <button
236
- onClick={() => eventBus.emit('detection:dismiss-progress', undefined)}
242
+ onClick={handleDismissProgress}
237
243
  className="semiont-detection-progress__close"
238
244
  aria-label={t('closeProgress')}
239
245
  title={t('closeProgress')}
@@ -87,7 +87,7 @@ export const ReferenceEntry = forwardRef<HTMLDivElement, ReferenceEntryProps>(
87
87
  eventBus.emit('annotation:update-body', {
88
88
  annotationUri: reference.id,
89
89
  resourceId: sourceUri.split('/resources/')[1] || '',
90
- operations: [{ op: 'remove', item: null }], // Remove all body items
90
+ operations: [{ op: 'remove' }], // Remove all body items
91
91
  });
92
92
  }
93
93
  };
@@ -279,10 +279,9 @@ export function TaggingPanel({
279
279
  selector: pendingAnnotation.selector,
280
280
  body: [
281
281
  {
282
- type: 'TextualBody',
282
+ type: 'TextualBody' as const,
283
283
  value: e.target.value,
284
- purpose: 'tagging',
285
- schema: selectedSchemaId,
284
+ purpose: 'tagging' as const,
286
285
  },
287
286
  ],
288
287
  });
@@ -381,7 +381,7 @@ describe('TaggingPanel Component', () => {
381
381
  e.event === 'annotation:create' &&
382
382
  e.payload?.motivation === 'tagging' &&
383
383
  e.payload?.body?.[0]?.value === 'Issue' &&
384
- e.payload?.body?.[0]?.schema === 'legal-irac'
384
+ e.payload?.body?.[0]?.type === 'TextualBody'
385
385
  )).toBe(true);
386
386
  });
387
387
  });
@@ -4,8 +4,7 @@
4
4
  * Tests the COMPLETE annotation deletion flow with real component composition:
5
5
  * - EventBusProvider (REAL)
6
6
  * - ApiClientProvider (REAL, with MOCKED client)
7
- * - useAnnotationFlow (REAL)
8
- * - useEventOperations (REAL)
7
+ * - useDetectionFlow (REAL) — single registration point for useResolutionFlow
9
8
  * - useEventSubscriptions (REAL)
10
9
  *
11
10
  * This test focuses on ARCHITECTURE and EVENT WIRING:
@@ -16,14 +15,18 @@
16
15
  *
17
16
  * CRITICAL: This test prevents regressions where:
18
17
  * - Multiple deletion paths exist (event-driven vs direct)
19
- * - useEventOperations not called in useAnnotationFlow
18
+ * - useResolutionFlow called in more than one hook (causes duplicate subscriptions)
20
19
  * - Auth token missing from API calls (401 errors)
20
+ *
21
+ * ARCHITECTURE: useResolutionFlow is called ONLY in useDetectionFlow.
22
+ * useDetectionFlow handles all detection state (manual annotation selection
23
+ * and AI-driven SSE detection) plus all API operations via useResolutionFlow.
21
24
  */
22
25
 
23
26
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
24
27
  import { render, waitFor } from '@testing-library/react';
25
28
  import { act } from 'react';
26
- import { useAnnotationFlow } from '../../../hooks/useAnnotationFlow';
29
+ import { useDetectionFlow } from '../../../hooks/useDetectionFlow';
27
30
  import { EventBusProvider, useEventBus, resetEventBusForTesting } from '../../../contexts/EventBusContext';
28
31
  import { ApiClientProvider } from '../../../contexts/ApiClientContext';
29
32
  import { AuthTokenProvider } from '../../../contexts/AuthTokenContext';
@@ -56,7 +59,9 @@ describe('Annotation Deletion - Feature Integration', () => {
56
59
 
57
60
  function TestComponent() {
58
61
  eventBusInstance = useEventBus();
59
- useAnnotationFlow(testUri);
62
+ // useDetectionFlow is the single registration point for useResolutionFlow
63
+ // (handles annotation:delete, annotation:create, detection:start, etc.)
64
+ useDetectionFlow(testUri);
60
65
  return null;
61
66
  }
62
67
 
@@ -186,16 +191,14 @@ describe('Annotation Deletion - Feature Integration', () => {
186
191
  expect(deleteAnnotationSpy.mock.calls[1][0]).toContain('annotation-2');
187
192
  });
188
193
 
189
- it('ARCHITECTURE: useEventOperations is called in useAnnotationFlow (not elsewhere)', async () => {
194
+ it('ARCHITECTURE: useResolutionFlow is called in useDetectionFlow (single registration point)', async () => {
190
195
  /**
191
196
  * This test validates that there's only ONE event-driven deletion path:
192
- * - useAnnotationFlow calls useEventOperations
193
- * - useEventOperations subscribes to annotation:delete
194
- * - No other component/hook subscribes to annotation:delete
197
+ * - useDetectionFlow calls useResolutionFlow (the single registration point)
198
+ * - useResolutionFlow subscribes to annotation:delete
195
199
  *
196
- * If this test fails, it means either:
197
- * 1. useEventOperations removed from useAnnotationFlow (CRITICAL BUG)
198
- * 2. Duplicate subscription added elsewhere (ARCHITECTURE VIOLATION)
200
+ * If this test fails with 2 API calls, it means useResolutionFlow was added
201
+ * to a second hook, causing duplicate subscriptions (ARCHITECTURE VIOLATION).
199
202
  */
200
203
 
201
204
  const { emitDelete } = renderAnnotationFlow();
@@ -216,7 +219,7 @@ describe('Annotation Deletion - Feature Integration', () => {
216
219
  *
217
220
  * The correct pattern is event-driven only:
218
221
  * - UI emits annotation:delete event
219
- * - useEventOperations handles it
222
+ * - useResolutionFlow handles it
220
223
  * - No direct function calls
221
224
  */
222
225
 
@@ -25,7 +25,7 @@ describe('REPRODUCING BUG: Detection state not updating', () => {
25
25
  vi.clearAllMocks();
26
26
 
27
27
  // Minimal mock - SSE streams not needed for this test
28
- vi.spyOn(SSEClient.prototype, 'detectAnnotations').mockReturnValue({ onProgress: vi.fn().mockReturnThis(), onComplete: vi.fn().mockReturnThis(), onError: vi.fn().mockReturnThis(), close: vi.fn() } as any);
28
+ vi.spyOn(SSEClient.prototype, 'detectReferences').mockReturnValue({ onProgress: vi.fn().mockReturnThis(), onComplete: vi.fn().mockReturnThis(), onError: vi.fn().mockReturnThis(), close: vi.fn() } as any);
29
29
  vi.spyOn(SSEClient.prototype, 'detectHighlights').mockReturnValue({ onProgress: vi.fn().mockReturnThis(), onComplete: vi.fn().mockReturnThis(), onError: vi.fn().mockReturnThis(), close: vi.fn() } as any);
30
30
  vi.spyOn(SSEClient.prototype, 'detectComments').mockReturnValue({ onProgress: vi.fn().mockReturnThis(), onComplete: vi.fn().mockReturnThis(), onError: vi.fn().mockReturnThis(), close: vi.fn() } as any);
31
31
  vi.spyOn(SSEClient.prototype, 'detectAssessments').mockReturnValue({ onProgress: vi.fn().mockReturnThis(), onComplete: vi.fn().mockReturnThis(), onError: vi.fn().mockReturnThis(), close: vi.fn() } as any);
@@ -5,7 +5,7 @@
5
5
  * - EventBusProvider (REAL)
6
6
  * - ApiClientProvider (REAL, with MOCKED client)
7
7
  * - useDetectionFlow (REAL)
8
- * - useEventOperations (REAL)
8
+ * - useResolutionFlow (REAL)
9
9
  * - useEventSubscriptions (REAL)
10
10
  *
11
11
  * This test focuses on ARCHITECTURE and EVENT WIRING:
@@ -60,7 +60,7 @@ const createMockSSEStream = () => {
60
60
 
61
61
  describe('Detection Flow - Feature Integration', () => {
62
62
  let mockStream: ReturnType<typeof createMockSSEStream>;
63
- let detectAnnotationsSpy: any;
63
+ let detectReferencesSpy: any;
64
64
  let detectHighlightsSpy: any;
65
65
  let detectCommentsSpy: any;
66
66
 
@@ -72,7 +72,7 @@ describe('Detection Flow - Feature Integration', () => {
72
72
  mockStream = createMockSSEStream();
73
73
 
74
74
  // Spy on SSEClient prototype methods
75
- detectAnnotationsSpy = vi.spyOn(SSEClient.prototype, 'detectAnnotations').mockReturnValue(mockStream as any);
75
+ detectReferencesSpy = vi.spyOn(SSEClient.prototype, 'detectReferences').mockReturnValue(mockStream as any);
76
76
  detectHighlightsSpy = vi.spyOn(SSEClient.prototype, 'detectHighlights').mockReturnValue(mockStream as any);
77
77
  detectCommentsSpy = vi.spyOn(SSEClient.prototype, 'detectComments').mockReturnValue(mockStream as any);
78
78
  vi.spyOn(SSEClient.prototype, 'detectAssessments').mockReturnValue(mockStream as any);
@@ -82,13 +82,13 @@ describe('Detection Flow - Feature Integration', () => {
82
82
  vi.restoreAllMocks();
83
83
  });
84
84
 
85
- it('should call detectAnnotations exactly ONCE when detection starts (not twice)', async () => {
85
+ it('should call detectReferences exactly ONCE when detection starts (not twice)', async () => {
86
86
  const testUri = resourceUri('http://localhost:4000/resources/test-resource');
87
87
 
88
88
  // Render with real component composition
89
89
  const { emitDetectionStart } = renderDetectionFlow(testUri);
90
90
 
91
- // Trigger detection for linking (uses detectAnnotations)
91
+ // Trigger detection for linking (uses detectReferences)
92
92
  act(() => {
93
93
  emitDetectionStart('linking', {
94
94
  entityTypes: ['Person', 'Organization'],
@@ -97,13 +97,13 @@ describe('Detection Flow - Feature Integration', () => {
97
97
  });
98
98
 
99
99
  // CRITICAL ASSERTION: API called exactly once (not twice!)
100
- // This would FAIL if useEventOperations was called in multiple places
100
+ // This would FAIL if useResolutionFlow was called in multiple places
101
101
  await waitFor(() => {
102
- expect(detectAnnotationsSpy).toHaveBeenCalledTimes(1);
102
+ expect(detectReferencesSpy).toHaveBeenCalledTimes(1);
103
103
  });
104
104
 
105
105
  // Verify correct parameters
106
- expect(detectAnnotationsSpy).toHaveBeenCalledWith(
106
+ expect(detectReferencesSpy).toHaveBeenCalledWith(
107
107
  testUri,
108
108
  {
109
109
  entityTypes: ['Person', 'Organization'],
@@ -128,7 +128,7 @@ describe('Detection Flow - Feature Integration', () => {
128
128
 
129
129
  // Wait for stream to be created
130
130
  await waitFor(() => {
131
- expect(detectAnnotationsSpy).toHaveBeenCalled();
131
+ expect(detectReferencesSpy).toHaveBeenCalled();
132
132
  });
133
133
 
134
134
  // Simulate SSE progress callback being invoked
@@ -316,8 +316,8 @@ describe('Detection Flow - Feature Integration', () => {
316
316
  it('should only call API once even with multiple event listeners', async () => {
317
317
  const testUri = resourceUri('http://localhost:4000/resources/test-resource');
318
318
 
319
- // This test specifically catches the duplicate useEventOperations bug
320
- // If multiple components call useEventOperations, we'll see multiple API calls
319
+ // This test specifically catches the duplicate useResolutionFlow bug
320
+ // If multiple components call useResolutionFlow, we'll see multiple API calls
321
321
  const { emitDetectionStart, getEventBus } = renderDetectionFlow(testUri);
322
322
 
323
323
  // Add an additional event listener (simulating multiple subscribers)
@@ -331,11 +331,11 @@ describe('Detection Flow - Feature Integration', () => {
331
331
 
332
332
  // Wait for operation to complete
333
333
  await waitFor(() => {
334
- expect(detectAnnotationsSpy).toHaveBeenCalled();
334
+ expect(detectReferencesSpy).toHaveBeenCalled();
335
335
  });
336
336
 
337
337
  // VERIFY: API called exactly once, even though multiple listeners exist
338
- expect(detectAnnotationsSpy).toHaveBeenCalledTimes(1);
338
+ expect(detectReferencesSpy).toHaveBeenCalledTimes(1);
339
339
 
340
340
  // VERIFY: Our additional listener was called (events work)
341
341
  expect(additionalListener).toHaveBeenCalledTimes(1);