@semiont/react-ui 0.5.18 → 0.5.20

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 (33) hide show
  1. package/README.md +4 -0
  2. package/dist/{PdfAnnotationCanvas.client-75GY2EDR.js → PdfAnnotationCanvas.client-V7Q3BEEQ.js} +42 -45
  3. package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +1 -0
  4. package/dist/index.d.ts +231 -55
  5. package/dist/index.js +2115 -2161
  6. package/dist/index.js.map +1 -1
  7. package/package.json +4 -4
  8. package/src/components/AssistProgress.tsx +170 -0
  9. package/src/components/__tests__/AssistProgress.test.tsx +164 -0
  10. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +23 -54
  11. package/src/components/pdf-annotation/__tests__/rects-for-page.test.ts +96 -0
  12. package/src/components/pdf-annotation/rects-for-page.ts +47 -0
  13. package/src/components/resource/ResourceViewer.tsx +23 -55
  14. package/src/components/resource/panels/AssessmentPanel.tsx +1 -1
  15. package/src/components/resource/panels/AssistSection.tsx +111 -180
  16. package/src/components/resource/panels/AssistShell.tsx +77 -0
  17. package/src/components/resource/panels/CommentsPanel.tsx +1 -1
  18. package/src/components/resource/panels/HighlightPanel.tsx +1 -1
  19. package/src/components/resource/panels/ReferenceEntry.tsx +8 -1
  20. package/src/components/resource/panels/ReferencesPanel.tsx +101 -132
  21. package/src/components/resource/panels/TaggingPanel.tsx +23 -86
  22. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +6 -14
  23. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +59 -0
  24. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +20 -0
  25. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +0 -1
  26. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +14 -20
  27. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +30 -0
  28. package/src/features/resource-compose/components/ResourceComposePage.tsx +19 -1
  29. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +24 -64
  30. package/src/styles/features/compose.css +7 -0
  31. package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +0 -1
  32. package/src/components/AnnotateReferencesProgressWidget.tsx +0 -125
  33. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +0 -101
@@ -7,7 +7,7 @@ import { BrowseView, type ReferenceHover } from './BrowseView';
7
7
  import { PopupContainer } from '../annotation-popups/SharedPopupElements';
8
8
  import { JsonLdView } from '../annotation-popups/JsonLdView';
9
9
  import type { Annotation, AnnotationId, ResourceDescriptor as SemiontResource, components, EventMap, AnchorRect } from '@semiont/core';
10
- import { getExactText, getTargetSelector, isHighlight, isAssessment, isReference, isComment, isTag, getBodySource } from '@semiont/core';
10
+ import { getExactText, getTargetSelector, getPrimaryMediaType, isHighlight, isAssessment, isReference, isComment, isTag, getBodySource } from '@semiont/core';
11
11
  import type { SemiontSession } from '@semiont/sdk';
12
12
  import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
13
13
  import { ANNOTATORS } from '../../lib/annotation-registry';
@@ -29,9 +29,6 @@ import type { AnnotationsCollection } from '../../types/annotation-props';
29
29
  *
30
30
  * Event flow:
31
31
  * make-meaning → EventLog → SSE → EventBus → ResourceViewer → Cache invalidation
32
- *
33
- * Phase 2 complete: Event-based cache invalidation replaces manual refetch
34
- * Phase 3 complete: Fully event-driven - all user interactions use unified event bus
35
32
  */
36
33
  interface Props {
37
34
  resource: SemiontResource & { content: string };
@@ -81,13 +78,14 @@ interface Props {
81
78
  }
82
79
 
83
80
  /**
84
- * @emits mark:delete - User requested to delete annotation. Payload: { annotationId: string }
85
- * @emits panel:open - Request to open panel with annotation. Payload: { panel: string, scrollToAnnotationId?: string, motivation?: Motivation }
81
+ * Deletion calls `session.client.mark.delete(...)` directly (no bus emit);
82
+ * panel opening invokes the host's `onOpenPanel` callback the host owns
83
+ * the panel and any `panel:open` emission.
86
84
  *
87
85
  * @subscribes mark:added - New annotation was added. Payload: { annotation: Annotation }
88
86
  * @subscribes mark:removed - Annotation was removed. Payload: { annotationId: string }
89
87
  * @subscribes mark:body-updated - Annotation was updated. Payload: { annotation: Annotation }
90
- * @subscribes browse:click - User clicked on annotation. Payload: { annotationId: string }
88
+ * @subscribes browse:click - User clicked on annotation. Payload: { annotationId: string, motivation, anchorRect? }
91
89
  */
92
90
  export function ResourceViewer({
93
91
  resource,
@@ -125,16 +123,8 @@ export function ResourceViewer({
125
123
  }
126
124
  const rUri = resource['@id'];
127
125
 
128
- // Helper to get MIME type from resource
129
- const getMimeType = (): string => {
130
- const reps = resource.representations;
131
- if (Array.isArray(reps) && reps.length > 0 && reps[0]) {
132
- return reps[0].mediaType;
133
- }
134
- return 'text/plain';
135
- };
136
-
137
- const mimeType = getMimeType();
126
+ // Same primary-representation semantics as the page and the worker — one helper.
127
+ const mimeType = getPrimaryMediaType(resource) || 'text/plain';
138
128
 
139
129
  // Toolbar preferences (TOOLBAR-PREFS-AS-PROPS): controlled (prop supplied) or a
140
130
  // plain uncontrolled default. Preferences are state, not events — no localStorage
@@ -152,15 +142,8 @@ export function ResourceViewer({
152
142
 
153
143
  const semiont = session?.client;
154
144
 
155
- const handleAnnotateAdded = useCallback(() => {
156
- semiont?.browse.invalidateAnnotationList(rUri);
157
- }, [semiont, rUri]);
158
-
159
- const handleAnnotateRemoved = useCallback(() => {
160
- semiont?.browse.invalidateAnnotationList(rUri);
161
- }, [semiont, rUri]);
162
-
163
- const handleAnnotateBodyUpdated = useCallback(() => {
145
+ // One invalidation for every annotation mutation event (added/removed/body-updated).
146
+ const handleAnnotationsChanged = useCallback(() => {
164
147
  semiont?.browse.invalidateAnnotationList(rUri);
165
148
  }, [semiont, rUri]);
166
149
 
@@ -196,20 +179,9 @@ export function ResourceViewer({
196
179
  position: { x: number; y: number };
197
180
  } | null>(null);
198
181
 
199
- // Internal UI state for hover, focus, and scroll
182
+ // Internal UI state for hover
200
183
  // Use prop value when provided (controlled by parent), otherwise null
201
184
  const hoveredAnnotationId = hoveredAnnotationIdProp ?? null;
202
- const [scrollToAnnotationId, setScrollToAnnotationId] = useState<string | null>(null);
203
- const [_focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
204
-
205
- // Focus annotation helper
206
- const focusAnnotation = useCallback((annotationId: string) => {
207
- setFocusedAnnotationId(annotationId);
208
- setScrollToAnnotationId(annotationId);
209
-
210
- // Clear focus after 3 seconds
211
- setTimeout(() => setFocusedAnnotationId(null), 3000);
212
- }, []);
213
185
 
214
186
  // Calculate centered position for JSON-LD modal
215
187
  const getJsonLdModalPosition = () => {
@@ -235,16 +207,11 @@ export function ResourceViewer({
235
207
 
236
208
  // If annotation has a side panel, only open it when Detail mode is active
237
209
  // For delete/jsonld/follow modes, let those handlers below process it
238
- if (metadata?.hasSidePanel) {
239
- if (selectedClick === 'detail') {
240
- // Focus annotation (sets internal focus and scroll state, plus calls parent callback for backward compat)
241
- focusAnnotation(annotation.id);
242
- return;
243
- }
244
- // Don't return early for delete/jsonld/follow modes - let them be handled below
245
- if (selectedClick !== 'deleting' && selectedClick !== 'jsonld' && selectedClick !== 'follow') {
246
- return;
247
- }
210
+ // Side-panel annotations in detail mode are routed to the host's panel by
211
+ // handleAnnotationClickEvent before this is ever called; here only the
212
+ // toolbar click modes (delete / jsonld / follow) fall through.
213
+ if (metadata?.hasSidePanel && selectedClick !== 'deleting' && selectedClick !== 'jsonld' && selectedClick !== 'follow') {
214
+ return;
248
215
  }
249
216
 
250
217
  // Check if this is a highlight, assessment, comment, reference, or tag
@@ -280,7 +247,7 @@ export function ResourceViewer({
280
247
  setDeleteConfirmation({ annotation, position });
281
248
  return;
282
249
  }
283
- }, [annotateMode, selectedClick, focusAnnotation, onOpenResource]);
250
+ }, [annotateMode, selectedClick, onOpenResource]);
284
251
 
285
252
  // Annotation click coordinator - handles panel opening and scrolling
286
253
  const handleAnnotationClickEvent = useCallback(({ annotationId, motivation, anchorRect }: {
@@ -317,13 +284,12 @@ export function ResourceViewer({
317
284
  onOpenPanel?.({ panel: 'annotations', scrollToAnnotationId: annotationId, motivation, ...(anchorRect ? { anchorRect } : {}) });
318
285
  }, [highlights, references, assessments, comments, tags, handleAnnotationClick, selectedClick, onOpenPanel]);
319
286
 
320
- // Event subscriptions - Combined into single useEventSubscriptions call to prevent hook ordering issues
321
- // IMPORTANT: All event subscriptions MUST be in a single call to maintain consistent hook order between renders
287
+ // Single subscription call per file (see scripts/compliance/audit-hooks-ordering.ts).
322
288
  useSessionEventSubscriptions(session, {
323
289
  // Annotation cache invalidation
324
- 'mark:added': handleAnnotateAdded,
325
- 'mark:removed': handleAnnotateRemoved,
326
- 'mark:body-updated': handleAnnotateBodyUpdated,
290
+ 'mark:added': handleAnnotationsChanged,
291
+ 'mark:removed': handleAnnotationsChanged,
292
+ 'mark:body-updated': handleAnnotationsChanged,
327
293
 
328
294
  // Annotation clicks
329
295
  'browse:click': handleAnnotationClickEvent,
@@ -335,12 +301,14 @@ export function ResourceViewer({
335
301
  [highlights, references, assessments, comments, tags]
336
302
  );
337
303
 
304
+ // No scrollToAnnotationId: panel-entry scrolling is the host's loop (page →
305
+ // UnifiedAnnotationsPanel → onScrollCompleted); in-content scrolling stays a
306
+ // host-facing capability on AnnotateView/CodeMirrorRenderer, unfed here.
338
307
  const uiState = {
339
308
  selectedMotivation,
340
309
  selectedClick,
341
310
  selectedShape,
342
311
  hoveredAnnotationId,
343
- scrollToAnnotationId
344
312
  };
345
313
 
346
314
  // Define getTargetResourceName callback OUTSIDE the conditional
@@ -245,7 +245,7 @@ export function AssessmentPanel({
245
245
 
246
246
  {/* Scrollable content area */}
247
247
  <div ref={containerRef} className="semiont-panel__content">
248
- {/* Assist Section - only in Annotate mode and for text resources */}
248
+ {/* Assist Section - only in Annotate mode; shown for any media type (AI detection is media-agnostic — text is resolved via the media-type registry, incl. PDF text layers) */}
249
249
  {annotateMode && (
250
250
  <AssistSection
251
251
  session={session}
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useCallback } from 'react';
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
5
  import type { SemiontSession } from '@semiont/sdk';
6
6
  import type { Motivation, components } from '@semiont/core';
7
+ import { AssistShell } from './AssistShell';
7
8
  import './AssistSection.css';
8
9
 
9
10
  type JobProgress = components['schemas']['JobProgress'];
@@ -20,16 +21,11 @@ interface AssistSectionProps {
20
21
  progress?: JobProgress | null | undefined;
21
22
  }
22
23
 
23
- // Color schemes are now handled via CSS data attributes
24
-
25
24
  /**
26
- * Shared assist section for Highlight, Assessment, and Comment panels
27
- *
28
- * Provides:
29
- * - Optional instructions textarea
30
- * - Optional tone selector (for comments)
31
- * - Assist button with sparkle animation
32
- * - Progress display during annotation assist
25
+ * Assist fields for the text motivations (highlight, assessment, comment):
26
+ * instructions, tone (comment/assessment), density — composed into the shared
27
+ * AssistShell chrome. Reference and tag panels compose the same shell with
28
+ * their own fields (entity chips; schema + categories).
33
29
  *
34
30
  * @emits mark:assist-request - Start assist for annotation type. Payload: { motivation: Motivation, options: { instructions?: string, tone?: string, density?: number } }
35
31
  * @emits mark:progress-dismiss - Dismiss the annotation progress display
@@ -51,23 +47,10 @@ export function AssistSection({
51
47
  type ToneValue = 'scholarly' | 'explanatory' | 'conversational' | 'technical' | 'analytical' | 'critical' | 'balanced' | 'constructive' | '';
52
48
  const [tone, setTone] = useState<ToneValue>('');
53
49
  // Default density depends on annotation type
54
- const defaultDensity = annotationType === 'comment' ? 5 : annotationType === 'assessment' ? 4 : annotationType === 'highlight' ? 5 : 5;
50
+ const defaultDensity = annotationType === 'assessment' ? 4 : 5;
55
51
  const [density, setDensity] = useState(defaultDensity);
56
52
  const [useDensity, setUseDensity] = useState(true); // Enabled by default
57
53
 
58
- // Collapsible section state - load from localStorage, default expanded
59
- const [isExpanded, setIsExpanded] = useState(() => {
60
- if (typeof window === 'undefined') return true;
61
- const stored = localStorage.getItem(`assist-section-expanded-${annotationType}`);
62
- return stored ? stored === 'true' : true;
63
- });
64
-
65
- // Persist expanded state to localStorage
66
- useEffect(() => {
67
- if (typeof window === 'undefined') return;
68
- localStorage.setItem(`assist-section-expanded-${annotationType}`, String(isExpanded));
69
- }, [isExpanded, annotationType]);
70
-
71
54
  const handleAssist = useCallback(() => {
72
55
  // Map annotation type to motivation
73
56
  const motivation: Motivation =
@@ -78,7 +61,7 @@ export function AssistSection({
78
61
  session?.client.mark.requestAssist(motivation, {
79
62
  instructions: instructions.trim() || undefined,
80
63
  tone: (annotationType === 'comment' || annotationType === 'assessment') && tone ? tone : undefined,
81
- density: (annotationType === 'comment' || annotationType === 'assessment' || annotationType === 'highlight') && useDensity ? density : undefined,
64
+ density: useDensity ? density : undefined,
82
65
  // Body locale only applies where the LLM writes natural-language text:
83
66
  // comment/assessment have a body, highlight does not.
84
67
  language: (annotationType === 'comment' || annotationType === 'assessment') ? locale : undefined,
@@ -97,169 +80,117 @@ export function AssistSection({
97
80
  }, [session]);
98
81
 
99
82
  return (
100
- <div className="semiont-panel__section">
101
- <button
102
- onClick={() => setIsExpanded(!isExpanded)}
103
- className="semiont-panel__section-title semiont-panel__section-title--collapsible"
104
- aria-expanded={isExpanded}
105
- type="button"
106
- >
107
- <span>
108
- {t(annotationType === 'highlight' ? 'annotateHighlights' :
109
- annotationType === 'assessment' ? 'annotateAssessments' :
110
- 'annotateComments')}
111
- </span>
112
- <span className="semiont-panel__section-chevron" data-expanded={isExpanded}>
113
-
114
- </span>
115
- </button>
116
- {isExpanded && (
117
- <div
118
- className="semiont-assist-widget"
119
- data-assisting={isAssisting && progress ? 'true' : 'false'}
120
- data-type={annotationType}
121
- >
122
- {/* Show form when NOT assisting and NO progress to display */}
123
- {!progress && (
124
- <>
83
+ <AssistShell
84
+ assistType={annotationType}
85
+ title={t(annotationType === 'highlight' ? 'annotateHighlights' :
86
+ annotationType === 'assessment' ? 'annotateAssessments' :
87
+ 'annotateComments')}
88
+ isAssisting={isAssisting}
89
+ progress={progress}
90
+ progressProps={{
91
+ onDismiss: handleDismissProgress,
92
+ translations: { close: t('closeProgress') },
93
+ }}
94
+ form={
95
+ <>
96
+ <div className="semiont-form-field">
97
+ <label className="semiont-form-field__label">
98
+ {t('instructions')} {t('optional')}
99
+ </label>
100
+ <textarea
101
+ value={instructions}
102
+ onChange={(e) => setInstructions(e.target.value)}
103
+ className="semiont-textarea"
104
+ rows={3}
105
+ placeholder={t('instructionsPlaceholder')}
106
+ maxLength={500}
107
+ />
108
+ <div className="semiont-form-field__char-count">
109
+ {instructions.length}/500
110
+ </div>
111
+ </div>
112
+
113
+ {/* Tone selector - for comments and assessments */}
114
+ {(annotationType === 'comment' || annotationType === 'assessment') && (
125
115
  <div className="semiont-form-field">
126
116
  <label className="semiont-form-field__label">
127
- {t('instructions')} {t('optional')}
117
+ {t('toneLabel')} {t('toneOptional')}
128
118
  </label>
129
- <textarea
130
- value={instructions}
131
- onChange={(e) => setInstructions(e.target.value)}
132
- className="semiont-textarea"
133
- rows={3}
134
- placeholder={t('instructionsPlaceholder')}
135
- maxLength={500}
136
- />
137
- <div className="semiont-form-field__char-count">
138
- {instructions.length}/500
139
- </div>
140
- </div>
141
-
142
- {/* Tone selector - for comments and assessments */}
143
- {(annotationType === 'comment' || annotationType === 'assessment') && (
144
- <div className="semiont-form-field">
145
- <label className="semiont-form-field__label">
146
- {t('toneLabel')} {t('toneOptional')}
147
- </label>
148
- <select
149
- value={tone}
150
- onChange={(e) => setTone(e.target.value as ToneValue)}
151
- className="semiont-select"
152
- >
153
- <option value="">Default</option>
154
- {annotationType === 'comment' && (
155
- <>
156
- <option value="scholarly">{t('toneScholarly')}</option>
157
- <option value="explanatory">{t('toneExplanatory')}</option>
158
- <option value="conversational">{t('toneConversational')}</option>
159
- <option value="technical">{t('toneTechnical')}</option>
160
- </>
161
- )}
162
- {annotationType === 'assessment' && (
163
- <>
164
- <option value="analytical">{t('toneAnalytical')}</option>
165
- <option value="critical">{t('toneCritical')}</option>
166
- <option value="balanced">{t('toneBalanced')}</option>
167
- <option value="constructive">{t('toneConstructive')}</option>
168
- </>
169
- )}
170
- </select>
171
- </div>
172
- )}
173
-
174
- {/* Density selector - for comments, assessments, and highlights */}
175
- {(annotationType === 'comment' || annotationType === 'assessment' || annotationType === 'highlight') && (
176
- <div className="semiont-form-field">
177
- {/* Header with toggle */}
178
- <div className="semiont-form-field__header">
179
- <label className="semiont-form-field__label semiont-form-field__label--with-checkbox">
180
- <input
181
- type="checkbox"
182
- checked={useDensity}
183
- onChange={(e) => setUseDensity(e.target.checked)}
184
- className="semiont-checkbox"
185
- data-variant={annotationType}
186
- />
187
- <span>{t('densityLabel')}</span>
188
- </label>
189
- {useDensity && (
190
- <span className="semiont-form-field__info">{density} per 2000 words</span>
191
- )}
192
- </div>
193
-
194
- {/* Slider - only shown when enabled */}
195
- {useDensity && (
119
+ <select
120
+ value={tone}
121
+ onChange={(e) => setTone(e.target.value as ToneValue)}
122
+ className="semiont-select"
123
+ >
124
+ <option value="">Default</option>
125
+ {annotationType === 'comment' && (
196
126
  <>
197
- <input
198
- type="range"
199
- min={annotationType === 'comment' ? '2' : '1'}
200
- max={annotationType === 'comment' ? '12' : annotationType === 'assessment' ? '10' : '15'}
201
- value={density}
202
- onChange={(e) => setDensity(Number(e.target.value))}
203
- className="semiont-slider"
204
- />
205
- <div className="semiont-slider__labels">
206
- <span>{t('densitySparse')}</span>
207
- <span>{t('densityDense')}</span>
208
- </div>
127
+ <option value="scholarly">{t('toneScholarly')}</option>
128
+ <option value="explanatory">{t('toneExplanatory')}</option>
129
+ <option value="conversational">{t('toneConversational')}</option>
130
+ <option value="technical">{t('toneTechnical')}</option>
209
131
  </>
210
132
  )}
211
- </div>
212
- )}
213
-
214
- <button
215
- onClick={handleAssist}
216
- className="semiont-button"
217
- data-variant="assist"
218
- data-type={annotationType}
219
- >
220
- <span className="semiont-button-icon">✨</span>
221
- <span>{t('annotate')}</span>
222
- </button>
223
- </>
224
- )}
225
-
226
- {/* Annotation Progress - show whenever we have progress (during or after assist) */}
227
- {progress && (
228
- <div className="semiont-annotation-progress" data-type={annotationType}>
229
- {/* Request Parameters */}
230
- {progress.requestParams && progress.requestParams.length > 0 && (
231
- <div className="semiont-annotation-progress__params" data-type={annotationType}>
232
- <div className="semiont-annotation-progress__params-title">Request Parameters:</div>
233
- {progress.requestParams.map((param, idx) => (
234
- <div key={idx} className="semiont-annotation-progress__param">
235
- <span className="semiont-annotation-progress__param-label">{param.label}:</span> {param.value}
236
- </div>
237
- ))}
238
- </div>
239
- )}
240
-
241
- <div className="semiont-annotation-progress__status">
242
- <div className="semiont-annotation-progress__message">
243
- <span className="semiont-annotation-progress__icon">✨</span>
244
- <span>{progress.message}</span>
245
- </div>
246
- {/* Close button - shown after assist completes (when not actively assisting) */}
247
- {!isAssisting && (
248
- <button
249
- onClick={handleDismissProgress}
250
- className="semiont-annotation-progress__close"
251
- aria-label={t('closeProgress')}
252
- title={t('closeProgress')}
253
- type="button"
254
- >
255
- ×
256
- </button>
133
+ {annotationType === 'assessment' && (
134
+ <>
135
+ <option value="analytical">{t('toneAnalytical')}</option>
136
+ <option value="critical">{t('toneCritical')}</option>
137
+ <option value="balanced">{t('toneBalanced')}</option>
138
+ <option value="constructive">{t('toneConstructive')}</option>
139
+ </>
140
+ )}
141
+ </select>
142
+ </div>
143
+ )}
144
+
145
+ {/* Density selector — applies to every assist type */}
146
+ <div className="semiont-form-field">
147
+ {/* Header with toggle */}
148
+ <div className="semiont-form-field__header">
149
+ <label className="semiont-form-field__label semiont-form-field__label--with-checkbox">
150
+ <input
151
+ type="checkbox"
152
+ checked={useDensity}
153
+ onChange={(e) => setUseDensity(e.target.checked)}
154
+ className="semiont-checkbox"
155
+ data-variant={annotationType}
156
+ />
157
+ <span>{t('densityLabel')}</span>
158
+ </label>
159
+ {useDensity && (
160
+ <span className="semiont-form-field__info">{density} per 2000 words</span>
257
161
  )}
258
162
  </div>
163
+
164
+ {/* Slider - only shown when enabled */}
165
+ {useDensity && (
166
+ <>
167
+ <input
168
+ type="range"
169
+ min={annotationType === 'comment' ? '2' : '1'}
170
+ max={annotationType === 'comment' ? '12' : annotationType === 'assessment' ? '10' : '15'}
171
+ value={density}
172
+ onChange={(e) => setDensity(Number(e.target.value))}
173
+ className="semiont-slider"
174
+ />
175
+ <div className="semiont-slider__labels">
176
+ <span>{t('densitySparse')}</span>
177
+ <span>{t('densityDense')}</span>
178
+ </div>
179
+ </>
180
+ )}
259
181
  </div>
260
- )}
261
- </div>
262
- )}
263
- </div>
182
+
183
+ <button
184
+ onClick={handleAssist}
185
+ className="semiont-button"
186
+ data-variant="assist"
187
+ data-type={annotationType}
188
+ >
189
+ <span className="semiont-button-icon">✨</span>
190
+ <span>{t('annotate')}</span>
191
+ </button>
192
+ </>
193
+ }
194
+ />
264
195
  );
265
196
  }
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, type ReactNode } from 'react';
4
+ import type { components } from '@semiont/core';
5
+ import { AssistProgress, type AssistProgressProps } from '../../AssistProgress';
6
+
7
+ type JobProgress = components['schemas']['JobProgress'];
8
+
9
+ export interface AssistShellProps {
10
+ /** localStorage persist-key suffix and CSS `data-type` ('highlight' | 'comment' | 'assessment' | 'reference' | 'tag'). */
11
+ assistType: string;
12
+ /** Collapsible section title (already translated). */
13
+ title: string;
14
+ isAssisting: boolean;
15
+ progress: JobProgress | null | undefined;
16
+ /** The per-motivation form (fields + submit) — shown when no progress is displayed. */
17
+ form: ReactNode;
18
+ /**
19
+ * Pass-through config for the progress renderer (cancel/dismiss wiring,
20
+ * translations, percent bar). Dismiss policy lives HERE: the shell forwards
21
+ * `onDismiss` only once the assist is no longer running.
22
+ */
23
+ progressProps?: Omit<AssistProgressProps, 'progress' | 'dataType'>;
24
+ }
25
+
26
+ /**
27
+ * The one assist-section chrome (#7): collapsible header with persisted expand
28
+ * state, the assisting wrapper, and the form-vs-progress switch. Every
29
+ * motivation's panel composes this shell with its own fields — the fields
30
+ * differ per motivation by design (instructions/tone/density vs entity chips
31
+ * vs schema+categories), so the shell owns only what is genuinely shared.
32
+ */
33
+ export function AssistShell({ assistType, title, isAssisting, progress, form, progressProps }: AssistShellProps) {
34
+ const [isExpanded, setIsExpanded] = useState(() => {
35
+ if (typeof window === 'undefined') return true;
36
+ const stored = localStorage.getItem(`assist-section-expanded-${assistType}`);
37
+ return stored ? stored === 'true' : true;
38
+ });
39
+
40
+ useEffect(() => {
41
+ if (typeof window === 'undefined') return;
42
+ localStorage.setItem(`assist-section-expanded-${assistType}`, String(isExpanded));
43
+ }, [isExpanded, assistType]);
44
+
45
+ return (
46
+ <div className="semiont-panel__section">
47
+ <button
48
+ onClick={() => setIsExpanded(!isExpanded)}
49
+ className="semiont-panel__section-title semiont-panel__section-title--collapsible"
50
+ aria-expanded={isExpanded}
51
+ type="button"
52
+ >
53
+ <span>{title}</span>
54
+ <span className="semiont-panel__section-chevron" data-expanded={isExpanded}>
55
+
56
+ </span>
57
+ </button>
58
+ {isExpanded && (
59
+ <div
60
+ className="semiont-assist-widget"
61
+ data-assisting={isAssisting && progress ? 'true' : 'false'}
62
+ data-type={assistType}
63
+ >
64
+ {!progress && form}
65
+ {progress && (
66
+ <AssistProgress
67
+ progress={progress}
68
+ dataType={assistType}
69
+ {...progressProps}
70
+ {...(isAssisting ? { onDismiss: undefined } : {})}
71
+ />
72
+ )}
73
+ </div>
74
+ )}
75
+ </div>
76
+ );
77
+ }
@@ -255,7 +255,7 @@ export function CommentsPanel({
255
255
 
256
256
  {/* Scrollable content area */}
257
257
  <div ref={containerRef} className="semiont-panel__content">
258
- {/* Assist Section - only in Annotate mode and for text resources */}
258
+ {/* Assist Section - only in Annotate mode; shown for any media type (AI detection is media-agnostic — text is resolved via the media-type registry, incl. PDF text layers) */}
259
259
  {annotateMode && (
260
260
  <AssistSection
261
261
  session={session}
@@ -153,7 +153,7 @@ export function HighlightPanel({
153
153
 
154
154
  {/* Scrollable content area */}
155
155
  <div ref={containerRef} className="semiont-panel__content">
156
- {/* Assist Section - only in Annotate mode and for text resources */}
156
+ {/* Assist Section - only in Annotate mode; shown for any media type (AI detection is media-agnostic — text is resolved via the media-type registry, incl. PDF text layers) */}
157
157
  {annotateMode && (
158
158
  <AssistSection
159
159
  session={session}
@@ -78,7 +78,14 @@ export function ReferenceEntry({
78
78
  resourceId(source),
79
79
  reference.id,
80
80
  [{ op: 'remove', item: { type: 'SpecificResource', source: resolvedResourceUri, purpose: 'linking' } }],
81
- ).catch(() => { /* error handled by events-stream */ });
81
+ ).catch((error: unknown) => {
82
+ // This component has no toast surface — report the client-local,
83
+ // resource-stamped bind error; useOutcomeToasts surfaces it.
84
+ semiont.bind.reportBodyError({
85
+ resourceId: source,
86
+ message: error instanceof Error ? error.message : String(error),
87
+ });
88
+ });
82
89
  }
83
90
  };
84
91