@semiont/react-ui 0.5.0 → 0.5.2

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 (56) hide show
  1. package/README.md +13 -0
  2. package/dist/{chunk-4NOUO3W6.mjs → chunk-7VWNZ5YX.mjs} +5032 -2876
  3. package/dist/chunk-7VWNZ5YX.mjs.map +1 -0
  4. package/dist/index.d.mts +292 -25
  5. package/dist/index.mjs +1021 -332
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/test-utils.d.mts +1 -1
  8. package/dist/test-utils.mjs +4 -2352
  9. package/dist/test-utils.mjs.map +1 -1
  10. package/package.json +1 -1
  11. package/src/components/StatusDisplay.tsx +1 -1
  12. package/src/components/modals/PermissionDeniedModal.tsx +2 -2
  13. package/src/components/modals/SessionExpiredModal.tsx +4 -4
  14. package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
  15. package/src/components/resource/panels/AssistSection.tsx +10 -1
  16. package/src/components/resource/panels/CollaborationPanel.tsx +1 -1
  17. package/src/components/resource/panels/CommentsPanel.tsx +4 -0
  18. package/src/components/resource/panels/HighlightPanel.tsx +4 -0
  19. package/src/components/resource/panels/ReferencesPanel.tsx +11 -0
  20. package/src/components/resource/panels/TaggingPanel.tsx +10 -0
  21. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +11 -1
  22. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +2 -2
  23. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +1 -1
  24. package/src/features/admin-exchange/components/AdminExchangePage.tsx +1 -1
  25. package/src/features/admin-exchange/components/ImportCard.tsx +1 -1
  26. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +171 -0
  27. package/src/features/admin-exchange/state/exchange-state-unit.ts +131 -0
  28. package/src/features/admin-security/components/AdminSecurityPage.tsx +1 -1
  29. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +68 -0
  30. package/src/features/admin-security/state/admin-security-state-unit.ts +46 -0
  31. package/src/features/admin-users/components/AdminUsersPage.tsx +1 -1
  32. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +86 -0
  33. package/src/features/admin-users/state/admin-users-state-unit.ts +73 -0
  34. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +86 -0
  35. package/src/features/auth-welcome/state/welcome-state-unit.ts +44 -0
  36. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +1 -1
  37. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +102 -0
  38. package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +64 -0
  39. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +1 -1
  40. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -1
  41. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +1 -1
  42. package/src/features/resource-compose/__tests__/UploadProgressBar.test.tsx +225 -0
  43. package/src/features/resource-compose/components/ResourceComposePage.tsx +19 -4
  44. package/src/features/resource-compose/components/UploadProgressBar.tsx +94 -0
  45. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +187 -0
  46. package/src/features/resource-compose/state/compose-page-state-unit.ts +209 -0
  47. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +1 -1
  48. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +76 -0
  49. package/src/features/resource-discovery/state/discover-state-unit.ts +54 -0
  50. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +4 -2
  51. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +36 -32
  52. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +46 -0
  53. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +203 -0
  54. package/src/features/resource-viewer/state/resource-loader-state-unit.ts +26 -0
  55. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +180 -0
  56. package/dist/chunk-4NOUO3W6.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "React components and hooks for Semiont",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.mts",
@@ -30,7 +30,7 @@ export function StatusDisplay({
30
30
  if (!semiont) { setLoading(false); return; }
31
31
 
32
32
  const fetchStatus = () => {
33
- semiont.admin.status()
33
+ semiont.admin!.status()
34
34
  .then((result) => {
35
35
  setData(result as StatusData);
36
36
  setError(null);
@@ -6,10 +6,10 @@ import { useObservable } from '../../hooks/useObservable';
6
6
 
7
7
  /**
8
8
  * Modal that surfaces when a 403 forbidden error is reported via
9
- * `notifyPermissionDenied` (called from QueryCache.onError).
9
+ * the active session's `signals.notifyPermissionDenied(...)`.
10
10
  *
11
11
  * Reads `permissionDeniedAt$` and `permissionDeniedMessage$` from the
12
- * active `FrontendSessionSignals`. The signals instance clears the
12
+ * active `SessionSignals`. The signals instance clears the
13
13
  * flag when the user dismisses the modal. Modal state lives on
14
14
  * signals (not the session itself) so headless sessions
15
15
  * (workers/CLIs) don't carry dead observables.
@@ -5,11 +5,11 @@ import { useSemiont } from '../../session/SemiontProvider';
5
5
  import { useObservable } from '../../hooks/useObservable';
6
6
 
7
7
  /**
8
- * Modal that surfaces when the active KB's session expires (a 401 from
9
- * either the session's own JWT validation or from any React Query call
10
- * via the QueryCache.onError handler).
8
+ * Modal that surfaces when the active KB's session expires (a 401
9
+ * surfaced by the session's own JWT validation or by the host's
10
+ * error-routing path).
11
11
  *
12
- * Reads `sessionExpiredAt$` from the active `FrontendSessionSignals`.
12
+ * Reads `sessionExpiredAt$` from the active `SessionSignals`.
13
13
  * When the user dismisses the modal, the signals instance clears the
14
14
  * flag. Modal state lives on signals (not the session itself) so
15
15
  * headless sessions (workers/CLIs) don't carry dead observables.
@@ -45,6 +45,8 @@ interface AssessmentPanelProps {
45
45
  isAssisting?: boolean;
46
46
  progress?: JobProgress | null;
47
47
  locale?: string;
48
+ /** BCP-47 tag of the resource being analyzed — forwarded to the assist call. */
49
+ sourceLanguage?: string;
48
50
  annotateMode?: boolean;
49
51
  scrollToAnnotationId?: string | null;
50
52
  onScrollCompleted?: () => void;
@@ -64,6 +66,7 @@ export function AssessmentPanel({
64
66
  isAssisting = false,
65
67
  progress,
66
68
  locale,
69
+ sourceLanguage,
67
70
  annotateMode = true,
68
71
  scrollToAnnotationId,
69
72
  onScrollCompleted,
@@ -243,6 +246,7 @@ export function AssessmentPanel({
243
246
  annotationType="assessment"
244
247
  isAssisting={isAssisting}
245
248
  locale={locale}
249
+ sourceLanguage={sourceLanguage}
246
250
  progress={progress}
247
251
  />
248
252
  )}
@@ -12,7 +12,10 @@ type JobProgress = components['schemas']['JobProgress'];
12
12
  interface AssistSectionProps {
13
13
  annotationType: 'highlight' | 'assessment' | 'comment';
14
14
  isAssisting: boolean;
15
+ /** User UI locale — written into the annotation body's `language` field for comment/assessment. */
15
16
  locale?: string;
17
+ /** BCP-47 tag of the resource being analyzed. Forwarded to the prompt so the LLM analyzes non-English source correctly. */
18
+ sourceLanguage?: string;
16
19
  progress?: JobProgress | null | undefined;
17
20
  }
18
21
 
@@ -34,6 +37,7 @@ export function AssistSection({
34
37
  annotationType,
35
38
  isAssisting,
36
39
  locale,
40
+ sourceLanguage,
37
41
  progress,
38
42
  }: AssistSectionProps) {
39
43
 
@@ -74,13 +78,18 @@ export function AssistSection({
74
78
  instructions: instructions.trim() || undefined,
75
79
  tone: (annotationType === 'comment' || annotationType === 'assessment') && tone ? tone : undefined,
76
80
  density: (annotationType === 'comment' || annotationType === 'assessment' || annotationType === 'highlight') && useDensity ? density : undefined,
81
+ // Body locale only applies where the LLM writes natural-language text:
82
+ // comment/assessment have a body, highlight does not.
77
83
  language: (annotationType === 'comment' || annotationType === 'assessment') ? locale : undefined,
84
+ // Source locale applies to all three — affects analysis quality on
85
+ // non-English source, regardless of whether a body is produced.
86
+ sourceLanguage,
78
87
  });
79
88
 
80
89
  setInstructions('');
81
90
  setTone('');
82
91
  // Don't reset density/useDensity - persist across assists
83
- }, [annotationType, instructions, tone, useDensity, density, locale, session]);
92
+ }, [annotationType, instructions, tone, useDensity, density, locale, sourceLanguage, session]);
84
93
 
85
94
  const handleDismissProgress = useCallback(() => {
86
95
  session?.client.mark.dismissProgress();
@@ -7,7 +7,7 @@ import './CollaborationPanel.css';
7
7
  interface Props {
8
8
  /**
9
9
  * Connection state from `client.actor.state$`. See
10
- * `packages/api-client/src/view-models/domain/actor-vm.ts`.
10
+ * `packages/api-client/src/state/domain/actor-state-unit.ts`.
11
11
  *
12
12
  * UI mapping:
13
13
  * `open` | `reconnecting` | `initial` | `connecting`
@@ -46,6 +46,8 @@ interface CommentsPanelProps {
46
46
  isAssisting?: boolean;
47
47
  progress?: JobProgress | null;
48
48
  locale?: string;
49
+ /** BCP-47 tag of the resource being analyzed — forwarded to the assist call. */
50
+ sourceLanguage?: string;
49
51
  scrollToAnnotationId?: string | null;
50
52
  onScrollCompleted?: () => void;
51
53
  hoveredAnnotationId?: string | null;
@@ -65,6 +67,7 @@ export function CommentsPanel({
65
67
  isAssisting = false,
66
68
  progress,
67
69
  locale,
70
+ sourceLanguage,
68
71
  scrollToAnnotationId,
69
72
  onScrollCompleted,
70
73
  hoveredAnnotationId,
@@ -253,6 +256,7 @@ export function CommentsPanel({
253
256
  annotationType="comment"
254
257
  isAssisting={isAssisting}
255
258
  locale={locale}
259
+ sourceLanguage={sourceLanguage}
256
260
  progress={progress}
257
261
  />
258
262
  )}
@@ -31,6 +31,8 @@ interface HighlightPanelProps {
31
31
  scrollToAnnotationId?: string | null;
32
32
  onScrollCompleted?: () => void;
33
33
  hoveredAnnotationId?: string | null;
34
+ /** BCP-47 tag of the resource being analyzed — forwarded to the assist call so the LLM analyzes non-English source correctly. */
35
+ sourceLanguage?: string;
34
36
  }
35
37
 
36
38
  /**
@@ -48,6 +50,7 @@ export function HighlightPanel({
48
50
  scrollToAnnotationId,
49
51
  onScrollCompleted,
50
52
  hoveredAnnotationId,
53
+ sourceLanguage,
51
54
  }: HighlightPanelProps) {
52
55
 
53
56
  const t = useTranslations('HighlightPanel');
@@ -151,6 +154,7 @@ export function HighlightPanel({
151
154
  annotationType="highlight"
152
155
  isAssisting={isAssisting}
153
156
  progress={progress}
157
+ sourceLanguage={sourceLanguage}
154
158
  />
155
159
  )}
156
160
 
@@ -60,6 +60,11 @@ interface Props {
60
60
  scrollToAnnotationId?: string | null;
61
61
  onScrollCompleted?: () => void;
62
62
  hoveredAnnotationId?: string | null;
63
+
64
+ /** User UI locale — stamped on the unresolved-reference body's `language` field. */
65
+ locale?: string;
66
+ /** BCP-47 tag of the resource being analyzed — fed into the prompt for source-aware analysis. */
67
+ sourceLanguage?: string;
63
68
  }
64
69
 
65
70
  /**
@@ -85,6 +90,8 @@ export function ReferencesPanel({
85
90
  scrollToAnnotationId,
86
91
  onScrollCompleted,
87
92
  hoveredAnnotationId,
93
+ locale,
94
+ sourceLanguage,
88
95
  }: Props) {
89
96
  const t = useTranslations('ReferencesPanel');
90
97
  const session = useObservable(useSemiont().activeSession$);
@@ -206,6 +213,10 @@ export function ReferencesPanel({
206
213
  session?.client.mark.requestAssist('linking', {
207
214
  entityTypes: selectedEntityTypes,
208
215
  includeDescriptiveReferences,
216
+ // Body locale stamps the unresolved-reference body's `language`;
217
+ // sourceLanguage tunes the prompt for non-English source content.
218
+ language: locale,
219
+ sourceLanguage,
209
220
  });
210
221
  };
211
222
 
@@ -48,6 +48,10 @@ interface TaggingPanelProps {
48
48
  scrollToAnnotationId?: string | null;
49
49
  onScrollCompleted?: () => void;
50
50
  hoveredAnnotationId?: string | null;
51
+ /** User UI locale — stamped on the tagging body's `language` field. */
52
+ locale?: string;
53
+ /** BCP-47 tag of the resource being analyzed — fed into the prompt for source-aware analysis. */
54
+ sourceLanguage?: string;
51
55
  }
52
56
 
53
57
  /**
@@ -67,6 +71,8 @@ export function TaggingPanel({
67
71
  scrollToAnnotationId,
68
72
  onScrollCompleted,
69
73
  hoveredAnnotationId,
74
+ locale,
75
+ sourceLanguage,
70
76
  }: TaggingPanelProps) {
71
77
  const t = useTranslations('TaggingPanel');
72
78
  const session = useObservable(useSemiont().activeSession$);
@@ -192,6 +198,10 @@ export function TaggingPanel({
192
198
  session?.client.mark.requestAssist('tagging', {
193
199
  schemaId: selectedSchemaId,
194
200
  categories: Array.from(selectedCategories),
201
+ // Body locale stamps the tagging body's `language`; sourceLanguage
202
+ // tunes the prompt for non-English source content.
203
+ language: locale,
204
+ sourceLanguage,
195
205
  });
196
206
  setSelectedCategories(new Set()); // Reset after annotation
197
207
  }
@@ -71,9 +71,16 @@ interface UnifiedAnnotationsPanelProps {
71
71
  // Hover coordination (for bidirectional hover highlighting)
72
72
  hoveredAnnotationId?: string | null;
73
73
 
74
- // Locale for AI-generated text language
74
+ // Locale for AI-generated text language (annotation body locale)
75
75
  locale?: string;
76
76
 
77
+ /**
78
+ * BCP-47 tag of the resource being analyzed (source-resource locale).
79
+ * Independent from `locale` — a German user can analyze a French source
80
+ * and get German bodies back. Fed into detection prompts.
81
+ */
82
+ sourceLanguage?: string;
83
+
77
84
  // Routing
78
85
  Link: React.ComponentType<LinkComponentProps>;
79
86
  routes: RouteBuilder;
@@ -243,6 +250,7 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
243
250
  progress,
244
251
  annotateMode: props.annotateMode,
245
252
  locale: props.locale,
253
+ sourceLanguage: props.sourceLanguage,
246
254
  scrollToAnnotationId: props.scrollToAnnotationId,
247
255
  onScrollCompleted: props.onScrollCompleted,
248
256
  hoveredAnnotationId: props.hoveredAnnotationId
@@ -268,6 +276,8 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
268
276
  scrollToAnnotationId={commonProps.scrollToAnnotationId}
269
277
  onScrollCompleted={commonProps.onScrollCompleted}
270
278
  hoveredAnnotationId={commonProps.hoveredAnnotationId}
279
+ locale={commonProps.locale}
280
+ sourceLanguage={commonProps.sourceLanguage}
271
281
  allEntityTypes={props.allEntityTypes || []}
272
282
  generatingReferenceId={props.generatingReferenceId}
273
283
  referencedBy={props.referencedBy}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Triangulation test for the VM → useObservable → prop → chip render chain.
2
+ * Triangulation test for the state unit → useObservable → prop → chip render chain.
3
3
  *
4
4
  * Written after an e2e failure (test 05) where `ReferencesPanel` rendered
5
5
  * "No entity types available" even though the client provably received a
@@ -90,7 +90,7 @@ const renderWithBus = (ui: React.ReactElement) => {
90
90
  return render(<SemiontWrapper>{ui}</SemiontWrapper>);
91
91
  };
92
92
 
93
- describe('Layer 5-6 — VM observable → useObservable → ReferencesPanel chips', () => {
93
+ describe('Layer 5-6 — state-unit observable → useObservable → ReferencesPanel chips', () => {
94
94
  it('an observable seeded with [9 strings] renders 9 pending-reference chips', async () => {
95
95
  const source$ = new BehaviorSubject<string[]>(NINE_TYPES);
96
96
  renderWithBus(<ObservableHarness source$={source$} />);
@@ -9,7 +9,7 @@ import React from 'react';
9
9
  import {
10
10
  CommandLineIcon
11
11
  } from '@heroicons/react/24/outline';
12
- import { COMMON_PANELS, type ToolbarPanelType } from '@semiont/sdk';
12
+ import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
13
13
  export interface DevOpsFeature {
14
14
  title: string;
15
15
  description: string;
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import React from 'react';
8
- import { COMMON_PANELS, type ToolbarPanelType } from '@semiont/sdk';
8
+ import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
9
9
  import { ExportCard, type ExportCardTranslations } from './ExportCard';
10
10
  import { ImportCard, type ImportCardProps, type ImportCardTranslations } from './ImportCard';
11
11
  import { ImportProgress, type ImportProgressTranslations } from './ImportProgress';
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import React, { useRef, useState, useCallback } from 'react';
8
- import type { ImportPreview } from '@semiont/sdk';
8
+ import type { ImportPreview } from '../state/exchange-state-unit';
9
9
  export type { ImportPreview };
10
10
 
11
11
  export interface ImportCardTranslations {
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { firstValueFrom, of, throwError } from 'rxjs';
3
+ import type { ShellStateUnit } from '../../../../state/shell-state-unit';
4
+ import { createExchangeStateUnit } from '../exchange-state-unit';
5
+
6
+ function mockBrowse(): ShellStateUnit {
7
+ return { dispose: vi.fn() } as unknown as ShellStateUnit;
8
+ }
9
+
10
+ function makeMockFile(name: string): File {
11
+ return new File(['content'], name, { type: 'application/gzip' });
12
+ }
13
+
14
+ describe('createExchangeStateUnit', () => {
15
+ it('initializes with null/empty state', async () => {
16
+ const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
17
+
18
+ expect(await firstValueFrom(stateUnit.selectedFile$)).toBeNull();
19
+ expect(await firstValueFrom(stateUnit.preview$)).toBeNull();
20
+ expect(await firstValueFrom(stateUnit.importPhase$)).toBeNull();
21
+ expect(await firstValueFrom(stateUnit.isExporting$)).toBe(false);
22
+ expect(await firstValueFrom(stateUnit.isImporting$)).toBe(false);
23
+
24
+ stateUnit.dispose();
25
+ });
26
+
27
+ it('selectFile sets file and generates preview', async () => {
28
+ const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
29
+
30
+ stateUnit.selectFile(makeMockFile('backup.tar.gz'));
31
+
32
+ const file = await firstValueFrom(stateUnit.selectedFile$);
33
+ expect(file?.name).toBe('backup.tar.gz');
34
+
35
+ const preview = await firstValueFrom(stateUnit.preview$);
36
+ expect(preview?.format).toBe('semiont-linked-data');
37
+
38
+ stateUnit.dispose();
39
+ });
40
+
41
+ it('selectFile detects unknown format', async () => {
42
+ const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
43
+
44
+ stateUnit.selectFile(makeMockFile('data.json'));
45
+
46
+ const preview = await firstValueFrom(stateUnit.preview$);
47
+ expect(preview?.format).toBe('unknown');
48
+
49
+ stateUnit.dispose();
50
+ });
51
+
52
+ it('cancelImport resets all state', async () => {
53
+ const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
54
+
55
+ stateUnit.selectFile(makeMockFile('backup.tar.gz'));
56
+ stateUnit.cancelImport();
57
+
58
+ expect(await firstValueFrom(stateUnit.selectedFile$)).toBeNull();
59
+ expect(await firstValueFrom(stateUnit.preview$)).toBeNull();
60
+ expect(await firstValueFrom(stateUnit.importPhase$)).toBeNull();
61
+
62
+ stateUnit.dispose();
63
+ });
64
+
65
+ // jsdom doesn't implement `Blob.prototype.stream()` portably, so build
66
+ // the stream literal — same shape, no environment dependency.
67
+ function streamOf(text: string): ReadableStream<Uint8Array> {
68
+ return new ReadableStream<Uint8Array>({
69
+ start(controller) {
70
+ controller.enqueue(new TextEncoder().encode(text));
71
+ controller.close();
72
+ },
73
+ });
74
+ }
75
+
76
+ it('doExport calls exportFn and returns blob + filename from BackendDownload', async () => {
77
+ // exportFn now returns a BackendDownload — a transport-neutral
78
+ // { stream, contentType, filename? } object. The state unit converts the
79
+ // stream to a Blob and threads filename through.
80
+ const exportFn = vi.fn().mockResolvedValue({
81
+ stream: streamOf('data'),
82
+ contentType: 'application/x-tar',
83
+ filename: 'export.tar.gz',
84
+ });
85
+
86
+ const stateUnit = createExchangeStateUnit(mockBrowse(), exportFn, vi.fn());
87
+
88
+ const result = await stateUnit.doExport();
89
+ expect(result.filename).toBe('export.tar.gz');
90
+ expect(await result.blob.text()).toBe('data');
91
+
92
+ expect(await firstValueFrom(stateUnit.isExporting$)).toBe(false);
93
+
94
+ stateUnit.dispose();
95
+ });
96
+
97
+ it('doExport falls back to a synthesized filename when the download omits one', async () => {
98
+ const exportFn = vi.fn().mockResolvedValue({
99
+ stream: streamOf('data'),
100
+ contentType: 'application/x-tar',
101
+ // no filename
102
+ });
103
+
104
+ const stateUnit = createExchangeStateUnit(mockBrowse(), exportFn, vi.fn());
105
+
106
+ const result = await stateUnit.doExport();
107
+ expect(result.filename).toMatch(/^semiont-export-\d+\.tar\.gz$/);
108
+
109
+ stateUnit.dispose();
110
+ });
111
+
112
+ it('doExport propagates errors from exportFn and clears isExporting$', async () => {
113
+ // The state unit no longer inspects HTTP status — non-OK responses are the
114
+ // transport's concern (ky throws on non-OK by default). The state unit just
115
+ // propagates whatever the exportFn rejects with and resets state.
116
+ const exportFn = vi.fn().mockRejectedValue(new Error('transport boom'));
117
+
118
+ const stateUnit = createExchangeStateUnit(mockBrowse(), exportFn, vi.fn());
119
+
120
+ await expect(stateUnit.doExport()).rejects.toThrow('transport boom');
121
+ expect(await firstValueFrom(stateUnit.isExporting$)).toBe(false);
122
+
123
+ stateUnit.dispose();
124
+ });
125
+
126
+ it('doImport subscribes to importFn Observable and mirrors each progress event', async () => {
127
+ // importFn now returns Observable<ProgressEvent>. The state unit subscribes,
128
+ // mirrors each emit into its state subjects, and resolves when the
129
+ // observable completes.
130
+ const importFn = vi.fn().mockReturnValue(
131
+ of(
132
+ { phase: 'uploading', message: '50%' },
133
+ { phase: 'complete', result: { resources: 10 } },
134
+ ),
135
+ );
136
+
137
+ const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), importFn);
138
+ stateUnit.selectFile(makeMockFile('import.tar.gz'));
139
+
140
+ await stateUnit.doImport();
141
+
142
+ expect(importFn).toHaveBeenCalledOnce();
143
+ expect(await firstValueFrom(stateUnit.importResult$)).toEqual({ resources: 10 });
144
+ expect(await firstValueFrom(stateUnit.importPhase$)).toBe('complete');
145
+ expect(await firstValueFrom(stateUnit.isImporting$)).toBe(false);
146
+
147
+ stateUnit.dispose();
148
+ });
149
+
150
+ it('doImport propagates errors from the importFn Observable and clears isImporting$', async () => {
151
+ const importFn = vi.fn().mockReturnValue(throwError(() => new Error('import boom')));
152
+
153
+ const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), importFn);
154
+ stateUnit.selectFile(makeMockFile('import.tar.gz'));
155
+
156
+ await expect(stateUnit.doImport()).rejects.toThrow('import boom');
157
+ expect(await firstValueFrom(stateUnit.isImporting$)).toBe(false);
158
+
159
+ stateUnit.dispose();
160
+ });
161
+
162
+ it('doImport is no-op without selected file', async () => {
163
+ const importFn = vi.fn();
164
+ const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), importFn);
165
+
166
+ await stateUnit.doImport();
167
+ expect(importFn).not.toHaveBeenCalled();
168
+
169
+ stateUnit.dispose();
170
+ });
171
+ });
@@ -0,0 +1,131 @@
1
+ import { BehaviorSubject, lastValueFrom, type Observable } from 'rxjs';
2
+ import { tap } from 'rxjs/operators';
3
+ import { createDisposer } from '@semiont/sdk';
4
+ import type { StateUnit } from '@semiont/sdk';
5
+ import type { ShellStateUnit } from '../../../state/shell-state-unit';
6
+ import type { BackendDownload, ProgressEvent } from '@semiont/core';
7
+
8
+ export interface ImportPreview {
9
+ format: string;
10
+ version: number;
11
+ sourceUrl: string;
12
+ stats: Record<string, number>;
13
+ }
14
+
15
+ export interface ExchangeStateUnit extends StateUnit {
16
+ browse: ShellStateUnit;
17
+ selectedFile$: Observable<File | null>;
18
+ preview$: Observable<ImportPreview | null>;
19
+ importPhase$: Observable<string | null>;
20
+ importMessage$: Observable<string | undefined>;
21
+ importResult$: Observable<Record<string, unknown> | undefined>;
22
+ isExporting$: Observable<boolean>;
23
+ isImporting$: Observable<boolean>;
24
+ selectFile(file: File): void;
25
+ cancelImport(): void;
26
+ doExport(): Promise<{ blob: Blob; filename: string }>;
27
+ doImport(): Promise<void>;
28
+ }
29
+
30
+ export function createExchangeStateUnit(
31
+ browse: ShellStateUnit,
32
+ exportFn: (params?: { includeArchived?: boolean }) => Promise<BackendDownload>,
33
+ importFn: (file: File) => Observable<ProgressEvent>,
34
+ ): ExchangeStateUnit {
35
+ const disposer = createDisposer();
36
+ disposer.add(browse);
37
+
38
+ const selectedFile$ = new BehaviorSubject<File | null>(null);
39
+ const preview$ = new BehaviorSubject<ImportPreview | null>(null);
40
+ const importPhase$ = new BehaviorSubject<string | null>(null);
41
+ const importMessage$ = new BehaviorSubject<string | undefined>(undefined);
42
+ const importResult$ = new BehaviorSubject<Record<string, unknown> | undefined>(undefined);
43
+ const isExporting$ = new BehaviorSubject<boolean>(false);
44
+ const isImporting$ = new BehaviorSubject<boolean>(false);
45
+
46
+ const selectFile = (file: File): void => {
47
+ selectedFile$.next(file);
48
+ importPhase$.next(null);
49
+ importMessage$.next(undefined);
50
+ importResult$.next(undefined);
51
+ preview$.next({
52
+ format: file.name.endsWith('.tar.gz') || file.name.endsWith('.gz') ? 'semiont-linked-data' : 'unknown',
53
+ version: 1,
54
+ sourceUrl: '',
55
+ stats: {} as Record<string, number>,
56
+ });
57
+ };
58
+
59
+ const cancelImport = (): void => {
60
+ selectedFile$.next(null);
61
+ preview$.next(null);
62
+ importPhase$.next(null);
63
+ importMessage$.next(undefined);
64
+ importResult$.next(undefined);
65
+ };
66
+
67
+ const doExport = async (): Promise<{ blob: Blob; filename: string }> => {
68
+ isExporting$.next(true);
69
+ try {
70
+ const download = await exportFn();
71
+ // Wrap the stream in a Response purely as a Blob-collection helper —
72
+ // BackendDownload itself carries no fetch dependency.
73
+ const blob = await new Response(download.stream).blob();
74
+ const filename = download.filename ?? `semiont-export-${Date.now()}.tar.gz`;
75
+ return { blob, filename };
76
+ } finally {
77
+ isExporting$.next(false);
78
+ }
79
+ };
80
+
81
+ const doImport = async (): Promise<void> => {
82
+ const file = selectedFile$.getValue();
83
+ if (!file) return;
84
+ isImporting$.next(true);
85
+ importPhase$.next('started');
86
+ importMessage$.next(undefined);
87
+ importResult$.next(undefined);
88
+ try {
89
+ // The importFn is `Observable<ProgressEvent>` — every emit is a
90
+ // progress event; the final emit before complete is the outcome.
91
+ // `tap` mirrors each event into our state subjects; `lastValueFrom`
92
+ // awaits the last value (so callers can `await vm.doImport()`).
93
+ await lastValueFrom(
94
+ importFn(file).pipe(
95
+ tap((event) => {
96
+ importPhase$.next(event.phase);
97
+ importMessage$.next(event.message);
98
+ if (event.result) importResult$.next(event.result);
99
+ }),
100
+ ),
101
+ );
102
+ } finally {
103
+ isImporting$.next(false);
104
+ }
105
+ };
106
+
107
+ return {
108
+ browse,
109
+ selectedFile$: selectedFile$.asObservable(),
110
+ preview$: preview$.asObservable(),
111
+ importPhase$: importPhase$.asObservable(),
112
+ importMessage$: importMessage$.asObservable(),
113
+ importResult$: importResult$.asObservable(),
114
+ isExporting$: isExporting$.asObservable(),
115
+ isImporting$: isImporting$.asObservable(),
116
+ selectFile,
117
+ cancelImport,
118
+ doExport,
119
+ doImport,
120
+ dispose: () => {
121
+ selectedFile$.complete();
122
+ preview$.complete();
123
+ importPhase$.complete();
124
+ importMessage$.complete();
125
+ importResult$.complete();
126
+ isExporting$.complete();
127
+ isImporting$.complete();
128
+ disposer.dispose();
129
+ },
130
+ };
131
+ }
@@ -12,7 +12,7 @@ import {
12
12
  CheckCircleIcon,
13
13
  InformationCircleIcon
14
14
  } from '@heroicons/react/24/outline';
15
- import { COMMON_PANELS, type ToolbarPanelType } from '@semiont/sdk';
15
+ import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
16
16
  export interface OAuthProvider {
17
17
  name: string;
18
18
  clientId?: string;