@semiont/react-ui 0.5.1 → 0.5.3

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 (177) hide show
  1. package/README.md +13 -0
  2. package/dist/{ar-3W37O3R3.mjs → ar-UUMMNQKF.mjs} +2 -17
  3. package/dist/ar-UUMMNQKF.mjs.map +1 -0
  4. package/dist/{bn-JZTJLMVE.mjs → bn-AL5BJSR3.mjs} +2 -17
  5. package/dist/bn-AL5BJSR3.mjs.map +1 -0
  6. package/dist/{chunk-4NOUO3W6.mjs → chunk-EBBL3VJI.mjs} +5062 -2906
  7. package/dist/chunk-EBBL3VJI.mjs.map +1 -0
  8. package/dist/{chunk-NOD3NCXE.mjs → chunk-OJSRLEER.mjs} +2 -17
  9. package/dist/chunk-OJSRLEER.mjs.map +1 -0
  10. package/dist/{cs-XYHH7HNE.mjs → cs-UMINALSU.mjs} +2 -17
  11. package/dist/cs-UMINALSU.mjs.map +1 -0
  12. package/dist/{da-MZKIECVT.mjs → da-FKUX6CDL.mjs} +2 -17
  13. package/dist/da-FKUX6CDL.mjs.map +1 -0
  14. package/dist/{de-AYXTMRQW.mjs → de-XSJ3E25S.mjs} +2 -17
  15. package/dist/de-XSJ3E25S.mjs.map +1 -0
  16. package/dist/{el-A6CVQWAW.mjs → el-UJXNRCBP.mjs} +2 -17
  17. package/dist/el-UJXNRCBP.mjs.map +1 -0
  18. package/dist/{en-YPQQBI4T.mjs → en-J5DHKLQ5.mjs} +2 -2
  19. package/dist/{es-M2HXLJGT.mjs → es-VURP62BU.mjs} +2 -17
  20. package/dist/es-VURP62BU.mjs.map +1 -0
  21. package/dist/{fa-V6JZJDYP.mjs → fa-TIT5ZPZY.mjs} +2 -17
  22. package/dist/fa-TIT5ZPZY.mjs.map +1 -0
  23. package/dist/{fi-ONDTZ5H7.mjs → fi-F7VTGT4H.mjs} +2 -17
  24. package/dist/fi-F7VTGT4H.mjs.map +1 -0
  25. package/dist/{fr-PAPV4H4G.mjs → fr-2ZR26VF7.mjs} +2 -17
  26. package/dist/fr-2ZR26VF7.mjs.map +1 -0
  27. package/dist/{he-F6VTLJLW.mjs → he-BXP2KYVZ.mjs} +2 -17
  28. package/dist/he-BXP2KYVZ.mjs.map +1 -0
  29. package/dist/{hi-CFUAV4BF.mjs → hi-PSWTP3NC.mjs} +2 -17
  30. package/dist/hi-PSWTP3NC.mjs.map +1 -0
  31. package/dist/{id-NBKLCCI7.mjs → id-HO6TXGTO.mjs} +2 -17
  32. package/dist/id-HO6TXGTO.mjs.map +1 -0
  33. package/dist/index.d.mts +292 -27
  34. package/dist/index.mjs +1134 -592
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/{it-SLSOWVVU.mjs → it-AGTDMBL3.mjs} +2 -17
  37. package/dist/it-AGTDMBL3.mjs.map +1 -0
  38. package/dist/{ja-L5IG4ECE.mjs → ja-TTGOVF5K.mjs} +2 -17
  39. package/dist/ja-TTGOVF5K.mjs.map +1 -0
  40. package/dist/{ko-QYMTULKK.mjs → ko-FF77IQ7N.mjs} +2 -17
  41. package/dist/ko-FF77IQ7N.mjs.map +1 -0
  42. package/dist/{ms-5DGSFKM2.mjs → ms-UPQWWIL4.mjs} +2 -17
  43. package/dist/ms-UPQWWIL4.mjs.map +1 -0
  44. package/dist/{nl-VZPCGONO.mjs → nl-W75HEPFL.mjs} +2 -17
  45. package/dist/nl-W75HEPFL.mjs.map +1 -0
  46. package/dist/{no-MF6F352I.mjs → no-R4W7W7ZU.mjs} +2 -17
  47. package/dist/no-R4W7W7ZU.mjs.map +1 -0
  48. package/dist/{pl-WIK72JUO.mjs → pl-GQC2ELWO.mjs} +2 -17
  49. package/dist/pl-GQC2ELWO.mjs.map +1 -0
  50. package/dist/{pt-RRP5ZF6A.mjs → pt-YGVT62RU.mjs} +2 -17
  51. package/dist/pt-YGVT62RU.mjs.map +1 -0
  52. package/dist/{ro-XHQLC3T7.mjs → ro-TST6XS6X.mjs} +2 -17
  53. package/dist/ro-TST6XS6X.mjs.map +1 -0
  54. package/dist/{sv-EWULDN6E.mjs → sv-TQLF6HV7.mjs} +2 -17
  55. package/dist/sv-TQLF6HV7.mjs.map +1 -0
  56. package/dist/test-utils.d.mts +1 -1
  57. package/dist/test-utils.mjs +5 -2353
  58. package/dist/test-utils.mjs.map +1 -1
  59. package/dist/{th-TGOBHFG4.mjs → th-HJUIETVR.mjs} +2 -17
  60. package/dist/th-HJUIETVR.mjs.map +1 -0
  61. package/dist/{tr-LMMPBMV7.mjs → tr-CW3C46TW.mjs} +2 -17
  62. package/dist/tr-CW3C46TW.mjs.map +1 -0
  63. package/dist/{uk-IPGRRJY6.mjs → uk-WTHZQB2U.mjs} +2 -17
  64. package/dist/uk-WTHZQB2U.mjs.map +1 -0
  65. package/dist/{vi-Q676OJQS.mjs → vi-PHWHJLKP.mjs} +2 -17
  66. package/dist/vi-PHWHJLKP.mjs.map +1 -0
  67. package/dist/{zh-F3MTWQDX.mjs → zh-MO3FCUD6.mjs} +2 -17
  68. package/dist/zh-MO3FCUD6.mjs.map +1 -0
  69. package/package.json +1 -1
  70. package/src/components/StatusDisplay.tsx +1 -1
  71. package/src/components/modals/PermissionDeniedModal.tsx +2 -2
  72. package/src/components/modals/SessionExpiredModal.tsx +4 -4
  73. package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
  74. package/src/components/resource/panels/AssistSection.tsx +10 -1
  75. package/src/components/resource/panels/CollaborationPanel.tsx +1 -1
  76. package/src/components/resource/panels/CommentsPanel.tsx +4 -0
  77. package/src/components/resource/panels/HighlightPanel.tsx +4 -0
  78. package/src/components/resource/panels/ReferencesPanel.tsx +11 -0
  79. package/src/components/resource/panels/TagEntry.tsx +13 -2
  80. package/src/components/resource/panels/TaggingPanel.tsx +93 -41
  81. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +11 -1
  82. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +2 -2
  83. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +26 -19
  84. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +128 -38
  85. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +1 -1
  86. package/src/features/admin-exchange/components/AdminExchangePage.tsx +1 -1
  87. package/src/features/admin-exchange/components/ImportCard.tsx +1 -1
  88. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +171 -0
  89. package/src/features/admin-exchange/state/exchange-state-unit.ts +131 -0
  90. package/src/features/admin-security/components/AdminSecurityPage.tsx +1 -1
  91. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +68 -0
  92. package/src/features/admin-security/state/admin-security-state-unit.ts +46 -0
  93. package/src/features/admin-users/components/AdminUsersPage.tsx +1 -1
  94. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +86 -0
  95. package/src/features/admin-users/state/admin-users-state-unit.ts +73 -0
  96. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +86 -0
  97. package/src/features/auth-welcome/state/welcome-state-unit.ts +44 -0
  98. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +1 -1
  99. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +102 -0
  100. package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +64 -0
  101. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +1 -1
  102. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +4 -4
  103. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +1 -1
  104. package/src/features/resource-compose/__tests__/UploadProgressBar.test.tsx +225 -0
  105. package/src/features/resource-compose/components/ResourceComposePage.tsx +19 -4
  106. package/src/features/resource-compose/components/UploadProgressBar.tsx +94 -0
  107. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +187 -0
  108. package/src/features/resource-compose/state/compose-page-state-unit.ts +209 -0
  109. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +1 -1
  110. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +76 -0
  111. package/src/features/resource-discovery/state/discover-state-unit.ts +54 -0
  112. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +4 -2
  113. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +36 -32
  114. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +46 -0
  115. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +203 -0
  116. package/src/features/resource-viewer/state/resource-loader-state-unit.ts +26 -0
  117. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +180 -0
  118. package/translations/ar.json +1 -16
  119. package/translations/bn.json +1 -16
  120. package/translations/cs.json +1 -16
  121. package/translations/da.json +1 -16
  122. package/translations/de.json +1 -16
  123. package/translations/el.json +1 -16
  124. package/translations/en.json +1 -16
  125. package/translations/es.json +1 -16
  126. package/translations/fa.json +1 -16
  127. package/translations/fi.json +1 -16
  128. package/translations/fr.json +1 -16
  129. package/translations/he.json +1 -16
  130. package/translations/hi.json +1 -16
  131. package/translations/id.json +1 -16
  132. package/translations/it.json +1 -16
  133. package/translations/ja.json +1 -16
  134. package/translations/ko.json +1 -16
  135. package/translations/ms.json +1 -16
  136. package/translations/nl.json +1 -16
  137. package/translations/no.json +1 -16
  138. package/translations/pl.json +1 -16
  139. package/translations/pt.json +1 -16
  140. package/translations/ro.json +1 -16
  141. package/translations/sv.json +1 -16
  142. package/translations/th.json +1 -16
  143. package/translations/tr.json +1 -16
  144. package/translations/uk.json +1 -16
  145. package/translations/vi.json +1 -16
  146. package/translations/zh.json +1 -16
  147. package/dist/ar-3W37O3R3.mjs.map +0 -1
  148. package/dist/bn-JZTJLMVE.mjs.map +0 -1
  149. package/dist/chunk-4NOUO3W6.mjs.map +0 -1
  150. package/dist/chunk-NOD3NCXE.mjs.map +0 -1
  151. package/dist/cs-XYHH7HNE.mjs.map +0 -1
  152. package/dist/da-MZKIECVT.mjs.map +0 -1
  153. package/dist/de-AYXTMRQW.mjs.map +0 -1
  154. package/dist/el-A6CVQWAW.mjs.map +0 -1
  155. package/dist/es-M2HXLJGT.mjs.map +0 -1
  156. package/dist/fa-V6JZJDYP.mjs.map +0 -1
  157. package/dist/fi-ONDTZ5H7.mjs.map +0 -1
  158. package/dist/fr-PAPV4H4G.mjs.map +0 -1
  159. package/dist/he-F6VTLJLW.mjs.map +0 -1
  160. package/dist/hi-CFUAV4BF.mjs.map +0 -1
  161. package/dist/id-NBKLCCI7.mjs.map +0 -1
  162. package/dist/it-SLSOWVVU.mjs.map +0 -1
  163. package/dist/ja-L5IG4ECE.mjs.map +0 -1
  164. package/dist/ko-QYMTULKK.mjs.map +0 -1
  165. package/dist/ms-5DGSFKM2.mjs.map +0 -1
  166. package/dist/nl-VZPCGONO.mjs.map +0 -1
  167. package/dist/no-MF6F352I.mjs.map +0 -1
  168. package/dist/pl-WIK72JUO.mjs.map +0 -1
  169. package/dist/pt-RRP5ZF6A.mjs.map +0 -1
  170. package/dist/ro-XHQLC3T7.mjs.map +0 -1
  171. package/dist/sv-EWULDN6E.mjs.map +0 -1
  172. package/dist/th-TGOBHFG4.mjs.map +0 -1
  173. package/dist/tr-LMMPBMV7.mjs.map +0 -1
  174. package/dist/uk-IPGRRJY6.mjs.map +0 -1
  175. package/dist/vi-Q676OJQS.mjs.map +0 -1
  176. package/dist/zh-F3MTWQDX.mjs.map +0 -1
  177. /package/dist/{en-YPQQBI4T.mjs.map → en-J5DHKLQ5.mjs.map} +0 -0
@@ -0,0 +1,209 @@
1
+ import { BehaviorSubject, type Observable, map } from 'rxjs';
2
+ import type { GatheredContext, AnnotationId, ContentFormat, AccessToken, ResourceDescriptor, ResourceId } from '@semiont/core';
3
+ import { resourceId as makeResourceId, annotationId as makeAnnotationId } from '@semiont/core';
4
+ import { createDisposer } from '@semiont/sdk';
5
+ import type { StateUnit } from '@semiont/sdk';
6
+ import type { ShellStateUnit } from '../../../state/shell-state-unit';
7
+ import type { SemiontClient } from '@semiont/sdk';
8
+ import { getPrimaryMediaType, decodeWithCharset } from '@semiont/core';
9
+ import type { UploadProgress } from '@semiont/sdk';
10
+
11
+ export type ComposeMode = 'new' | 'clone' | 'reference';
12
+
13
+ export interface ComposeParams {
14
+ mode?: string | undefined;
15
+ token?: string | undefined;
16
+ annotationUri?: string | undefined;
17
+ sourceDocumentId?: string | undefined;
18
+ name?: string | undefined;
19
+ entityTypes?: string | undefined;
20
+ storedContext?: string | undefined;
21
+ }
22
+
23
+ export interface CloneData {
24
+ sourceResource: ResourceDescriptor;
25
+ sourceContent: string;
26
+ }
27
+
28
+ export interface ReferenceData {
29
+ annotationUri: string;
30
+ sourceDocumentId: string;
31
+ name: string;
32
+ entityTypes: string[];
33
+ }
34
+
35
+ export interface SaveResourceParams {
36
+ mode: ComposeMode;
37
+ name: string;
38
+ storageUri: string;
39
+ content?: string;
40
+ file?: File;
41
+ format?: string;
42
+ charset?: string;
43
+ entityTypes?: string[];
44
+ language: string;
45
+ archiveOriginal?: boolean;
46
+ annotationUri?: string;
47
+ sourceDocumentId?: string;
48
+ }
49
+
50
+ export interface ComposePageStateUnit extends StateUnit {
51
+ browse: ShellStateUnit;
52
+ mode$: Observable<ComposeMode>;
53
+ loading$: Observable<boolean>;
54
+ cloneData$: Observable<CloneData | null>;
55
+ referenceData$: Observable<ReferenceData | null>;
56
+ gatheredContext$: Observable<GatheredContext | null>;
57
+ entityTypes$: Observable<string[]>;
58
+ /**
59
+ * Live upload-progress for the in-flight `save(...)` call. Emits the
60
+ * full `UploadProgress` lifecycle (started → finished) while a save is
61
+ * underway; resets to `null` between saves and after completion. UI
62
+ * components can subscribe to render an upload-in-progress indicator.
63
+ */
64
+ uploadProgress$: Observable<UploadProgress | null>;
65
+ save(params: SaveResourceParams): Promise<string>;
66
+ }
67
+
68
+ export function createComposePageStateUnit(
69
+ client: SemiontClient,
70
+ browse: ShellStateUnit,
71
+ params: ComposeParams,
72
+ auth?: AccessToken,
73
+ ): ComposePageStateUnit {
74
+ const disposer = createDisposer();
75
+ disposer.add(browse);
76
+
77
+ const isReferenceMode = Boolean(params.annotationUri && params.sourceDocumentId && params.name);
78
+ const isCloneMode = params.mode === 'clone' && Boolean(params.token);
79
+ const pageMode: ComposeMode = isCloneMode ? 'clone' : isReferenceMode ? 'reference' : 'new';
80
+
81
+ const mode$ = new BehaviorSubject<ComposeMode>(pageMode);
82
+ const loading$ = new BehaviorSubject<boolean>(true);
83
+ const cloneData$ = new BehaviorSubject<CloneData | null>(null);
84
+ const referenceData$ = new BehaviorSubject<ReferenceData | null>(null);
85
+ const gatheredContext$ = new BehaviorSubject<GatheredContext | null>(null);
86
+ const uploadProgress$ = new BehaviorSubject<UploadProgress | null>(null);
87
+
88
+ const entityTypes$: Observable<string[]> = client.browse.entityTypes().pipe(
89
+ map((e) => e ?? []),
90
+ );
91
+
92
+ // Initialize based on mode
93
+ if (isReferenceMode) {
94
+ const entityTypes = params.entityTypes ? params.entityTypes.split(',') : [];
95
+ referenceData$.next({
96
+ annotationUri: params.annotationUri!,
97
+ sourceDocumentId: params.sourceDocumentId!,
98
+ name: params.name!,
99
+ entityTypes,
100
+ });
101
+ if (params.storedContext) {
102
+ try { gatheredContext$.next(JSON.parse(params.storedContext)); } catch { /* ignore malformed */ }
103
+ }
104
+ loading$.next(false);
105
+ } else if (isCloneMode) {
106
+ void (async () => {
107
+ try {
108
+ const tokenResult = await client.yield.fromToken(params.token!);
109
+ if (tokenResult && auth) {
110
+ const rId = makeResourceId(tokenResult['@id']);
111
+ const mediaType = getPrimaryMediaType(tokenResult) || 'text/plain';
112
+ const { data } = await client.browse.resourceRepresentation(rId, {
113
+ accept: mediaType as ContentFormat,
114
+ });
115
+ const content = decodeWithCharset(data, mediaType);
116
+ cloneData$.next({ sourceResource: tokenResult, sourceContent: content });
117
+ }
118
+ } catch {
119
+ // Error handling is the consumer's responsibility (toast)
120
+ }
121
+ loading$.next(false);
122
+ })();
123
+ } else {
124
+ loading$.next(false);
125
+ }
126
+
127
+ const save = async (saveParams: SaveResourceParams): Promise<string> => {
128
+ if (saveParams.mode === 'clone') {
129
+ const response = await client.yield.createFromToken({
130
+ token: params.token!,
131
+ name: saveParams.name,
132
+ content: saveParams.content!,
133
+ archiveOriginal: saveParams.archiveOriginal ?? true,
134
+ });
135
+ return response.resourceId;
136
+ }
137
+
138
+ let fileToUpload: File;
139
+ let mimeType: string;
140
+
141
+ if (saveParams.file) {
142
+ fileToUpload = saveParams.file;
143
+ mimeType = saveParams.format ?? 'application/octet-stream';
144
+ } else {
145
+ const blob = new Blob([saveParams.content || ''], { type: saveParams.format ?? 'application/octet-stream' });
146
+ const extension = saveParams.format === 'text/plain' ? '.txt' : saveParams.format === 'text/html' ? '.html' : '.md';
147
+ fileToUpload = new File([blob], saveParams.name + extension, { type: saveParams.format ?? 'application/octet-stream' });
148
+ mimeType = saveParams.format ?? 'application/octet-stream';
149
+ }
150
+
151
+ const format = saveParams.charset && !saveParams.file ? `${mimeType}; charset=${saveParams.charset}` : mimeType;
152
+
153
+ // Subscribe to the upload's full progress lifecycle so the UI can
154
+ // render an upload-in-progress indicator. Resolve the save() promise
155
+ // on the `finished` event and clear the progress signal on completion
156
+ // (success or error).
157
+ const newResourceId = await new Promise<ResourceId>((resolve, reject) => {
158
+ client.yield.resource({
159
+ name: saveParams.name,
160
+ file: fileToUpload,
161
+ format,
162
+ entityTypes: saveParams.entityTypes || [],
163
+ language: saveParams.language,
164
+ creationMethod: 'ui',
165
+ storageUri: saveParams.storageUri,
166
+ }).subscribe({
167
+ next: (event) => {
168
+ uploadProgress$.next(event);
169
+ if (event.phase === 'finished') resolve(event.resourceId);
170
+ },
171
+ error: (err) => {
172
+ uploadProgress$.next(null);
173
+ reject(err);
174
+ },
175
+ complete: () => uploadProgress$.next(null),
176
+ });
177
+ });
178
+
179
+ if (saveParams.mode === 'reference' && saveParams.annotationUri && saveParams.sourceDocumentId) {
180
+ await client.bind.body(
181
+ makeResourceId(saveParams.sourceDocumentId),
182
+ makeAnnotationId(saveParams.annotationUri) as AnnotationId,
183
+ [{ op: 'add', item: { type: 'SpecificResource' as const, source: newResourceId, purpose: 'linking' as const } }],
184
+ );
185
+ }
186
+
187
+ return newResourceId;
188
+ };
189
+
190
+ return {
191
+ browse,
192
+ mode$: mode$.asObservable(),
193
+ loading$: loading$.asObservable(),
194
+ cloneData$: cloneData$.asObservable(),
195
+ referenceData$: referenceData$.asObservable(),
196
+ gatheredContext$: gatheredContext$.asObservable(),
197
+ entityTypes$,
198
+ uploadProgress$: uploadProgress$.asObservable(),
199
+ save,
200
+ dispose: () => {
201
+ mode$.complete();
202
+ loading$.complete();
203
+ cloneData$.complete();
204
+ referenceData$.complete();
205
+ gatheredContext$.complete();
206
+ disposer.dispose();
207
+ },
208
+ };
209
+ }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import React, { useState, useCallback, useRef } from 'react';
9
9
  import { getResourceId } from '@semiont/core';
10
- import { COMMON_PANELS, type ToolbarPanelType } from '@semiont/sdk';
10
+ import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
11
11
  import { useRovingTabIndex } from '../../../hooks/useRovingTabIndex';
12
12
  import { Toolbar } from '../../../components/Toolbar';
13
13
  import { ResourceCard } from './ResourceCard';
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { BehaviorSubject, firstValueFrom } from 'rxjs';
3
+ import { filter } from 'rxjs/operators';
4
+ import type { SemiontClient } from '@semiont/sdk';
5
+ import type { ShellStateUnit } from '../../../../state/shell-state-unit';
6
+ import { createDiscoverStateUnit } from '../discover-state-unit';
7
+
8
+ function mockBrowse(): ShellStateUnit {
9
+ return { dispose: vi.fn() } as unknown as ShellStateUnit;
10
+ }
11
+
12
+ function mockClient(overrides: {
13
+ resources$?: BehaviorSubject<unknown[] | undefined>;
14
+ entityTypes$?: BehaviorSubject<string[] | undefined>;
15
+ } = {}): SemiontClient {
16
+ const resources$ = overrides.resources$ ?? new BehaviorSubject<unknown[] | undefined>([{ '@id': 'r1' }]);
17
+ const entityTypes$ = overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person']);
18
+ return {
19
+ browse: {
20
+ resources: () => resources$.asObservable(),
21
+ entityTypes: () => entityTypes$.asObservable(),
22
+ },
23
+ } as unknown as SemiontClient;
24
+ }
25
+
26
+ describe('createDiscoverStateUnit', () => {
27
+ it('exposes recent resources from browse namespace', async () => {
28
+ const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
29
+
30
+ const recent = await firstValueFrom(stateUnit.recentResources$);
31
+ expect(recent).toEqual([{ '@id': 'r1' }]);
32
+
33
+ stateUnit.dispose();
34
+ });
35
+
36
+ it('exposes entity types from browse namespace', async () => {
37
+ const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
38
+
39
+ const types = await firstValueFrom(stateUnit.entityTypes$);
40
+ expect(types).toEqual(['Person']);
41
+
42
+ stateUnit.dispose();
43
+ });
44
+
45
+ it('reports loading when resources are undefined', async () => {
46
+ const resources$ = new BehaviorSubject<unknown[] | undefined>(undefined);
47
+ const stateUnit = createDiscoverStateUnit(mockClient({ resources$ }), mockBrowse());
48
+
49
+ const loading = await firstValueFrom(stateUnit.isLoadingRecent$);
50
+ expect(loading).toBe(true);
51
+
52
+ resources$.next([]);
53
+ const loaded = await firstValueFrom(stateUnit.isLoadingRecent$.pipe(filter((l) => !l)));
54
+ expect(loaded).toBe(false);
55
+
56
+ stateUnit.dispose();
57
+ });
58
+
59
+ it('exposes a search pipeline', () => {
60
+ const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
61
+
62
+ expect(stateUnit.search).toBeDefined();
63
+ expect(typeof stateUnit.search.setQuery).toBe('function');
64
+ expect(stateUnit.search.state$).toBeDefined();
65
+
66
+ stateUnit.dispose();
67
+ });
68
+
69
+ it('disposes browse and search on dispose', () => {
70
+ const browse = mockBrowse();
71
+ const stateUnit = createDiscoverStateUnit(mockClient(), browse);
72
+ stateUnit.dispose();
73
+
74
+ expect(browse.dispose).toHaveBeenCalled();
75
+ });
76
+ });
@@ -0,0 +1,54 @@
1
+ import { map, type Observable } from 'rxjs';
2
+ import type { ResourceDescriptor } from '@semiont/core';
3
+ import type { StateUnit } from '@semiont/sdk';
4
+ import { createDisposer } from '@semiont/sdk';
5
+ import type { ShellStateUnit } from '../../../state/shell-state-unit';
6
+ import { createSearchPipeline, type SearchPipeline } from '@semiont/sdk';
7
+ import type { SemiontClient } from '@semiont/sdk';
8
+
9
+ const RECENT_LIMIT = 10;
10
+ const SEARCH_LIMIT = 20;
11
+
12
+ export interface DiscoverStateUnit extends StateUnit {
13
+ browse: ShellStateUnit;
14
+ search: SearchPipeline<ResourceDescriptor>;
15
+ recentResources$: Observable<ResourceDescriptor[]>;
16
+ entityTypes$: Observable<string[]>;
17
+ isLoadingRecent$: Observable<boolean>;
18
+ }
19
+
20
+ export function createDiscoverStateUnit(
21
+ client: SemiontClient,
22
+ browse: ShellStateUnit,
23
+ ): DiscoverStateUnit {
24
+ const disposer = createDisposer();
25
+
26
+ const search = createSearchPipeline<ResourceDescriptor>((q) =>
27
+ client.browse.resources({ search: q, limit: SEARCH_LIMIT }),
28
+ );
29
+ disposer.add(search);
30
+ disposer.add(browse);
31
+
32
+ const recent$ = client.browse.resources({ limit: RECENT_LIMIT, archived: false });
33
+
34
+ const recentResources$: Observable<ResourceDescriptor[]> = recent$.pipe(
35
+ map((r) => r ?? []),
36
+ );
37
+
38
+ const isLoadingRecent$: Observable<boolean> = recent$.pipe(
39
+ map((r) => r === undefined),
40
+ );
41
+
42
+ const entityTypes$: Observable<string[]> = client.browse.entityTypes().pipe(
43
+ map((e) => e ?? []),
44
+ );
45
+
46
+ return {
47
+ browse,
48
+ search,
49
+ recentResources$,
50
+ entityTypes$,
51
+ isLoadingRecent$,
52
+ dispose: () => disposer.dispose(),
53
+ };
54
+ }
@@ -37,14 +37,16 @@ vi.mock('../../../hooks/useResourceContent', () => ({
37
37
 
38
38
  // Stub SemiontBrowser whose activeSession$ emits a session carrying a real
39
39
  // SemiontClient (wired to a dummy baseUrl). The real client surface lets
40
- // createResourceViewerPageVM run against the full namespace API without us
40
+ // createResourceViewerPageStateUnit run against the full namespace API without us
41
41
  // hand-stubbing every method it touches.
42
42
  const { stubBrowser } = vi.hoisted(() => {
43
43
  const { BehaviorSubject } = require('rxjs');
44
44
  const { SemiontClient, HttpTransport, HttpContentTransport } = require('@semiont/sdk');
45
45
  const { baseUrl } = require('@semiont/core');
46
46
  const transport = new HttpTransport({ baseUrl: baseUrl('http://localhost:4000') });
47
- const client = new SemiontClient(transport, new HttpContentTransport(transport));
47
+ // HttpTransport implements both ITransport and IBackendOperations; pass it
48
+ // as backend so `client.auth` (used by useMediaToken) is wired.
49
+ const client = new SemiontClient(transport, new HttpContentTransport(transport), transport);
48
50
  const stubActiveSession$ = new BehaviorSubject({ client });
49
51
  const stubOpenResources$ = new BehaviorSubject([]);
50
52
  const stubBrowser = {
@@ -31,9 +31,9 @@ import { useHoverDelay } from '../../../hooks/useHoverDelay';
31
31
  import { useEventSubscriptions } from '../../../contexts/useEventSubscription';
32
32
  import { useResourceAnnotations } from '../../../contexts/ResourceAnnotationsContext';
33
33
  import { useSemiont } from '../../../session/SemiontProvider';
34
- import { createResourceViewerPageVM } from '@semiont/sdk';
35
- import { useViewModel } from '../../../hooks/useViewModel';
36
- import { useShellVM } from '../../../hooks/useShellVM';
34
+ import { createResourceViewerPageStateUnit } from '../state/resource-viewer-page-state-unit';
35
+ import { useStateUnit } from '../../../hooks/useStateUnit';
36
+ import { useShellStateUnit } from '../../../hooks/useShellStateUnit';
37
37
  import { useTranslations } from '../../../contexts/TranslationContext';
38
38
  import { ReferenceWizardModal } from '../../../components/modals/ReferenceWizardModal';
39
39
  import type { GenerationConfig } from '../../../components/modals/ConfigureGenerationStep';
@@ -157,29 +157,29 @@ export function ResourceViewerPage({
157
157
  const content = isBinary ? binaryContent : textContent;
158
158
  const contentLoading = isBinary ? mediaTokenLoading : textLoading;
159
159
 
160
- // Composite VM — owns all flow VMs, wizard state, annotations, entity types
161
- const browseVM = useShellVM();
162
- const vm = useViewModel(() => createResourceViewerPageVM(semiont!, rUri, locale, browseVM));
160
+ // Composite state unit — owns all flow VMs, wizard state, annotations, entity types
161
+ const browseStateUnit = useShellStateUnit();
162
+ const stateUnit = useStateUnit(() => createResourceViewerPageStateUnit(semiont!, rUri, locale, browseStateUnit));
163
163
 
164
- const annotations = useObservable(vm.annotations$) ?? [];
165
- const groups = useObservable(vm.annotationGroups$);
166
- const allEntityTypes = useObservable(vm.entityTypes$) ?? [];
167
- const referencedByRaw = useObservable(vm.referencedBy$);
164
+ const annotations = useObservable(stateUnit.annotations$) ?? [];
165
+ const groups = useObservable(stateUnit.annotationGroups$);
166
+ const allEntityTypes = useObservable(stateUnit.entityTypes$) ?? [];
167
+ const referencedByRaw = useObservable(stateUnit.referencedBy$);
168
168
  const referencedBy = referencedByRaw ?? [];
169
169
  const referencedByLoading = referencedByRaw === undefined;
170
- const hoveredAnnotationId = useObservable(vm.beckon.hoveredAnnotationId$) ?? null;
171
- const pendingAnnotation = useObservable(vm.mark.pendingAnnotation$) ?? null;
172
- const assistingMotivation = useObservable(vm.mark.assistingMotivation$) ?? null;
173
- const progress = useObservable(vm.mark.progress$) ?? null;
174
- const activePanel = useObservable(vm.browse.activePanel$) ?? null;
175
- const scrollToAnnotationId = useObservable(vm.browse.scrollToAnnotationId$) ?? null;
176
- const panelInitialTab = useObservable(vm.browse.panelInitialTab$) ?? null;
177
- const onScrollCompleted = vm.browse.onScrollCompleted;
178
- const generationProgress = useObservable(vm.yield.progress$) ?? null;
179
- const gatherContext = useObservable(vm.gather.context$) ?? null;
180
- const gatherLoading = useObservable(vm.gather.loading$) ?? false;
181
- const gatherError = useObservable(vm.gather.error$) ?? null;
182
- const wizardState = useObservable(vm.wizard$);
170
+ const hoveredAnnotationId = useObservable(stateUnit.beckon.hoveredAnnotationId$) ?? null;
171
+ const pendingAnnotation = useObservable(stateUnit.mark.pendingAnnotation$) ?? null;
172
+ const assistingMotivation = useObservable(stateUnit.mark.assistingMotivation$) ?? null;
173
+ const progress = useObservable(stateUnit.mark.progress$) ?? null;
174
+ const activePanel = useObservable(stateUnit.browse.activePanel$) ?? null;
175
+ const scrollToAnnotationId = useObservable(stateUnit.browse.scrollToAnnotationId$) ?? null;
176
+ const panelInitialTab = useObservable(stateUnit.browse.panelInitialTab$) ?? null;
177
+ const onScrollCompleted = stateUnit.browse.onScrollCompleted;
178
+ const generationProgress = useObservable(stateUnit.yield.progress$) ?? null;
179
+ const gatherContext = useObservable(stateUnit.gather.context$) ?? null;
180
+ const gatherLoading = useObservable(stateUnit.gather.loading$) ?? false;
181
+ const gatherError = useObservable(stateUnit.gather.error$) ?? null;
182
+ const wizardState = useObservable(stateUnit.wizard$);
183
183
  const wizardOpen = wizardState?.open ?? false;
184
184
  const wizardAnnotationId = wizardState?.annotationId ?? null;
185
185
  const wizardResourceId = wizardState?.resourceId ?? null;
@@ -187,21 +187,25 @@ export function ResourceViewerPage({
187
187
  const wizardEntityTypes = wizardState?.entityTypes ?? [];
188
188
 
189
189
  const handleWizardClose = useCallback(() => {
190
- vm.closeWizard();
191
- }, [vm]);
190
+ stateUnit.closeWizard();
191
+ }, [stateUnit]);
192
192
 
193
193
  const handleWizardGenerateSubmit = useCallback((referenceId: string, config: GenerationConfig) => {
194
194
  clearNewAnnotationId(annotationId(referenceId));
195
- vm.yield.generate(referenceId, {
195
+ stateUnit.yield.generate(referenceId, {
196
196
  title: config.title,
197
197
  storageUri: config.storagePath,
198
198
  prompt: config.prompt,
199
199
  language: config.language,
200
+ // The source resource is the one the user is viewing — fed into the
201
+ // prompt so the LLM understands the embedded context (selected
202
+ // passage, surrounding text) regardless of UI/target language.
203
+ sourceLanguage: getLanguage(resource),
200
204
  temperature: config.temperature,
201
205
  maxTokens: config.maxTokens,
202
206
  context: config.context,
203
207
  });
204
- }, [vm, clearNewAnnotationId]);
208
+ }, [stateUnit, clearNewAnnotationId, resource]);
205
209
 
206
210
  const handleWizardLinkResource = useCallback(async (referenceId: string, targetResourceId: string) => {
207
211
  if (!semiont) return;
@@ -249,8 +253,8 @@ export function ResourceViewerPage({
249
253
  }
250
254
  }, [resource, rUri, browser]);
251
255
 
252
- // Bridge: when the mark VM produces a pending annotation, open the
253
- // annotations panel. The mark VM (session-scoped) can't emit `panel:open`
256
+ // Bridge: when the mark state unit produces a pending annotation, open the
257
+ // annotations panel. The mark state unit (session-scoped) can't emit `panel:open`
254
258
  // (app-scoped) directly — the React tree is the natural seam between
255
259
  // the two buses.
256
260
  useEffect(() => {
@@ -259,9 +263,9 @@ export function ResourceViewerPage({
259
263
  }
260
264
  }, [pendingAnnotation, browser]);
261
265
 
262
- // Domain events flow through the bus gateway (ActorVM → local EventBus).
266
+ // Domain events flow through the bus gateway (ActorStateUnit → local EventBus).
263
267
  // BrowseNamespace cache invalidation handles annotation/resource updates.
264
- // The resource-viewer-page-vm calls client.subscribeToResource(resourceId)
268
+ // The resource-viewer-page-state-unit calls client.subscribeToResource(resourceId)
265
269
  // which bridges scoped domain events into the local EventBus.
266
270
 
267
271
  const handleResourceArchive = useCallback(async () => {
@@ -490,7 +494,6 @@ export function ResourceViewerPage({
490
494
  activePanel={activePanel}
491
495
  theme={theme}
492
496
  showLineNumbers={showLineNumbers}
493
- hoverDelayMs={hoverDelayMs}
494
497
  width={
495
498
  activePanel === 'jsonld' ? 'w-[600px]' :
496
499
  activePanel === 'annotations' ? 'w-[400px]' :
@@ -521,6 +524,7 @@ export function ResourceViewerPage({
521
524
  referencedByLoading={referencedByLoading}
522
525
  resourceId={rUri}
523
526
  locale={locale}
527
+ sourceLanguage={getLanguage(resource)}
524
528
  scrollToAnnotationId={scrollToAnnotationId}
525
529
  hoveredAnnotationId={hoveredAnnotationId}
526
530
  onScrollCompleted={onScrollCompleted}
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { BehaviorSubject, firstValueFrom } from 'rxjs';
3
+ import { filter } from 'rxjs/operators';
4
+ import { resourceId as makeResourceId } from '@semiont/core';
5
+ import type { SemiontClient } from '@semiont/sdk';
6
+ import { createResourceLoaderStateUnit } from '../resource-loader-state-unit';
7
+
8
+ const RID = makeResourceId('res-1');
9
+
10
+ function mockClient(resource$?: BehaviorSubject<unknown>): SemiontClient {
11
+ const subject = resource$ ?? new BehaviorSubject<unknown>({ '@id': 'res-1', name: 'Test' });
12
+ const invalidate = vi.fn();
13
+ return {
14
+ browse: {
15
+ resource: () => subject.asObservable(),
16
+ invalidateResourceDetail: invalidate,
17
+ },
18
+ } as unknown as SemiontClient;
19
+ }
20
+
21
+ describe('createResourceLoaderStateUnit', () => {
22
+ it('exposes resource from browse namespace', async () => {
23
+ const stateUnit = createResourceLoaderStateUnit(mockClient(), RID);
24
+ const resource = await firstValueFrom(stateUnit.resource$.pipe(filter((r) => r !== undefined)));
25
+ expect((resource as { name: string }).name).toBe('Test');
26
+ stateUnit.dispose();
27
+ });
28
+
29
+ it('reports loading when resource is undefined', async () => {
30
+ const subject = new BehaviorSubject<unknown>(undefined);
31
+ const stateUnit = createResourceLoaderStateUnit(mockClient(subject), RID);
32
+ expect(await firstValueFrom(stateUnit.isLoading$)).toBe(true);
33
+
34
+ subject.next({ '@id': 'res-1' });
35
+ expect(await firstValueFrom(stateUnit.isLoading$.pipe(filter((l) => !l)))).toBe(false);
36
+ stateUnit.dispose();
37
+ });
38
+
39
+ it('invalidate calls browse.invalidateResourceDetail', () => {
40
+ const client = mockClient();
41
+ const stateUnit = createResourceLoaderStateUnit(client, RID);
42
+ stateUnit.invalidate();
43
+ expect(client.browse.invalidateResourceDetail).toHaveBeenCalledWith(RID);
44
+ stateUnit.dispose();
45
+ });
46
+ });