@semiont/react-ui 0.5.22 → 0.5.23

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 (170) hide show
  1. package/dist/{ar-ZHKO4XDR.js → ar-JRTKCMTF.js} +11 -4
  2. package/dist/ar-JRTKCMTF.js.map +1 -0
  3. package/dist/{bn-3BUDKAK5.js → bn-LO6EDSCC.js} +11 -4
  4. package/dist/bn-LO6EDSCC.js.map +1 -0
  5. package/dist/{chunk-R52YAGHK.js → chunk-5Y5JDCKC.js} +11 -4
  6. package/dist/chunk-5Y5JDCKC.js.map +1 -0
  7. package/dist/{chunk-PFUXQPWJ.js → chunk-RO5CRVIA.js} +31 -31
  8. package/dist/{cs-2EFEUKTW.js → cs-LCHHOR4O.js} +11 -4
  9. package/dist/cs-LCHHOR4O.js.map +1 -0
  10. package/dist/{da-SUD3Q44Q.js → da-TARGPMJR.js} +11 -4
  11. package/dist/da-TARGPMJR.js.map +1 -0
  12. package/dist/{de-6IC5QRX7.js → de-SBH4QBPJ.js} +11 -4
  13. package/dist/de-SBH4QBPJ.js.map +1 -0
  14. package/dist/{el-Q4NAXFRC.js → el-2D2OLGWB.js} +11 -4
  15. package/dist/el-2D2OLGWB.js.map +1 -0
  16. package/dist/{en-P2L4X27N.js → en-QFIEG3CA.js} +2 -2
  17. package/dist/{es-YUKZBD4W.js → es-U6YK2LX6.js} +11 -4
  18. package/dist/es-U6YK2LX6.js.map +1 -0
  19. package/dist/{fa-OJ47LILE.js → fa-S7SZE37M.js} +11 -4
  20. package/dist/fa-S7SZE37M.js.map +1 -0
  21. package/dist/{fi-QFFL5CUB.js → fi-CBL3NIXN.js} +11 -4
  22. package/dist/fi-CBL3NIXN.js.map +1 -0
  23. package/dist/{fr-BOQZZWP4.js → fr-EABUHV6S.js} +11 -4
  24. package/dist/fr-EABUHV6S.js.map +1 -0
  25. package/dist/{he-LXDY3TFA.js → he-THWD244Y.js} +11 -4
  26. package/dist/he-THWD244Y.js.map +1 -0
  27. package/dist/{hi-HOJER77V.js → hi-GHGFZJ44.js} +11 -4
  28. package/dist/hi-GHGFZJ44.js.map +1 -0
  29. package/dist/{id-I5WQ6EWD.js → id-POZOUMDW.js} +11 -4
  30. package/dist/id-POZOUMDW.js.map +1 -0
  31. package/dist/index.d.ts +253 -41
  32. package/dist/index.js +1935 -1678
  33. package/dist/index.js.map +1 -1
  34. package/dist/{it-UYH642IJ.js → it-KDYJADSH.js} +11 -4
  35. package/dist/it-KDYJADSH.js.map +1 -0
  36. package/dist/{ja-JTVWAC4L.js → ja-Z5Z6ZF3P.js} +11 -4
  37. package/dist/ja-Z5Z6ZF3P.js.map +1 -0
  38. package/dist/{ko-IHXFMOIZ.js → ko-7X3XUXRN.js} +11 -4
  39. package/dist/ko-7X3XUXRN.js.map +1 -0
  40. package/dist/{ms-JH25VFLO.js → ms-BTWEIW43.js} +11 -4
  41. package/dist/ms-BTWEIW43.js.map +1 -0
  42. package/dist/{nl-SURXW7IQ.js → nl-IS2CGF5I.js} +11 -4
  43. package/dist/nl-IS2CGF5I.js.map +1 -0
  44. package/dist/{no-HXIKQFDZ.js → no-AAMEVA26.js} +11 -4
  45. package/dist/no-AAMEVA26.js.map +1 -0
  46. package/dist/{pl-7A4KU6XK.js → pl-PVXUQYFT.js} +11 -4
  47. package/dist/pl-PVXUQYFT.js.map +1 -0
  48. package/dist/{pt-6MGYP7Q5.js → pt-3ITQ76TL.js} +11 -4
  49. package/dist/pt-3ITQ76TL.js.map +1 -0
  50. package/dist/{ro-IROCHNIA.js → ro-FRB4IIVS.js} +11 -4
  51. package/dist/ro-FRB4IIVS.js.map +1 -0
  52. package/dist/{sv-WDJD75CV.js → sv-DS3RO24M.js} +11 -4
  53. package/dist/sv-DS3RO24M.js.map +1 -0
  54. package/dist/test-utils.js +7 -2
  55. package/dist/test-utils.js.map +1 -1
  56. package/dist/{th-RIO2E6B3.js → th-AX66MWWB.js} +11 -4
  57. package/dist/th-AX66MWWB.js.map +1 -0
  58. package/dist/{tr-TZ7ERCIN.js → tr-7KTB67TT.js} +11 -4
  59. package/dist/tr-7KTB67TT.js.map +1 -0
  60. package/dist/{uk-ZFWDKLUE.js → uk-SMQS2MAQ.js} +11 -4
  61. package/dist/uk-SMQS2MAQ.js.map +1 -0
  62. package/dist/{vi-UIOSP2JG.js → vi-D3JXL2EZ.js} +11 -4
  63. package/dist/vi-D3JXL2EZ.js.map +1 -0
  64. package/dist/{zh-6R6YUY46.js → zh-S754AWFS.js} +11 -4
  65. package/dist/zh-S754AWFS.js.map +1 -0
  66. package/package.json +4 -4
  67. package/src/components/modals/ResourceGenerateModal.tsx +10 -4
  68. package/src/components/modals/ResourceSearchModal.tsx +3 -1
  69. package/src/components/modals/SearchModal.tsx +3 -1
  70. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +5 -0
  71. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +2 -1
  72. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +2 -1
  73. package/src/components/resource/AnnotateView.tsx +70 -138
  74. package/src/components/resource/AnnotationHistory.tsx +53 -17
  75. package/src/components/resource/BrowseView.tsx +3 -2
  76. package/src/components/resource/ResourceViewer.tsx +39 -22
  77. package/src/components/resource/__tests__/AnnotateView.registry.test.tsx +114 -0
  78. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +63 -41
  79. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +2 -1
  80. package/src/components/resource/__tests__/ResourceViewer.multi-mount-invalidation.test.tsx +140 -0
  81. package/src/components/resource/__tests__/ResourceViewer.registry.test.tsx +89 -0
  82. package/src/components/resource/annotate-renderers.tsx +155 -0
  83. package/src/components/resource/panels/ReferencesPanel.tsx +38 -3
  84. package/src/components/resource/panels/TagEntry.tsx +5 -1
  85. package/src/components/resource/panels/TaggingPanel.tsx +6 -1
  86. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +41 -0
  87. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +90 -0
  88. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +2 -2
  89. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +2 -2
  90. package/src/components/resource/panels/__tests__/UnifiedAnnotationsPanel.failure.test.tsx +78 -0
  91. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +9 -8
  92. package/src/features/admin-security/state/admin-security-state-unit.ts +3 -2
  93. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +6 -5
  94. package/src/features/admin-users/state/admin-users-state-unit.ts +3 -2
  95. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +34 -24
  96. package/src/features/auth-welcome/state/welcome-state-unit.ts +7 -2
  97. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +36 -11
  98. package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +9 -10
  99. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +32 -11
  100. package/src/features/resource-compose/state/compose-page-state-unit.ts +9 -8
  101. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +3 -1
  102. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +25 -0
  103. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +89 -24
  104. package/src/features/resource-discovery/state/discover-state-unit.ts +30 -30
  105. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +1 -0
  106. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +67 -30
  107. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +110 -7
  108. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +166 -26
  109. package/src/features/resource-viewer/state/resource-loader-state-unit.ts +74 -9
  110. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +27 -26
  111. package/translations/ar.json +10 -3
  112. package/translations/bn.json +10 -3
  113. package/translations/cs.json +10 -3
  114. package/translations/da.json +10 -3
  115. package/translations/de.json +10 -3
  116. package/translations/el.json +10 -3
  117. package/translations/en.json +10 -3
  118. package/translations/es.json +10 -3
  119. package/translations/fa.json +10 -3
  120. package/translations/fi.json +10 -3
  121. package/translations/fr.json +10 -3
  122. package/translations/he.json +10 -3
  123. package/translations/hi.json +10 -3
  124. package/translations/id.json +10 -3
  125. package/translations/it.json +10 -3
  126. package/translations/ja.json +10 -3
  127. package/translations/ko.json +10 -3
  128. package/translations/ms.json +10 -3
  129. package/translations/nl.json +10 -3
  130. package/translations/no.json +10 -3
  131. package/translations/pl.json +10 -3
  132. package/translations/pt.json +10 -3
  133. package/translations/ro.json +10 -3
  134. package/translations/sv.json +10 -3
  135. package/translations/th.json +10 -3
  136. package/translations/tr.json +10 -3
  137. package/translations/uk.json +10 -3
  138. package/translations/vi.json +10 -3
  139. package/translations/zh.json +10 -3
  140. package/dist/ar-ZHKO4XDR.js.map +0 -1
  141. package/dist/bn-3BUDKAK5.js.map +0 -1
  142. package/dist/chunk-R52YAGHK.js.map +0 -1
  143. package/dist/cs-2EFEUKTW.js.map +0 -1
  144. package/dist/da-SUD3Q44Q.js.map +0 -1
  145. package/dist/de-6IC5QRX7.js.map +0 -1
  146. package/dist/el-Q4NAXFRC.js.map +0 -1
  147. package/dist/es-YUKZBD4W.js.map +0 -1
  148. package/dist/fa-OJ47LILE.js.map +0 -1
  149. package/dist/fi-QFFL5CUB.js.map +0 -1
  150. package/dist/fr-BOQZZWP4.js.map +0 -1
  151. package/dist/he-LXDY3TFA.js.map +0 -1
  152. package/dist/hi-HOJER77V.js.map +0 -1
  153. package/dist/id-I5WQ6EWD.js.map +0 -1
  154. package/dist/it-UYH642IJ.js.map +0 -1
  155. package/dist/ja-JTVWAC4L.js.map +0 -1
  156. package/dist/ko-IHXFMOIZ.js.map +0 -1
  157. package/dist/ms-JH25VFLO.js.map +0 -1
  158. package/dist/nl-SURXW7IQ.js.map +0 -1
  159. package/dist/no-HXIKQFDZ.js.map +0 -1
  160. package/dist/pl-7A4KU6XK.js.map +0 -1
  161. package/dist/pt-6MGYP7Q5.js.map +0 -1
  162. package/dist/ro-IROCHNIA.js.map +0 -1
  163. package/dist/sv-WDJD75CV.js.map +0 -1
  164. package/dist/th-RIO2E6B3.js.map +0 -1
  165. package/dist/tr-TZ7ERCIN.js.map +0 -1
  166. package/dist/uk-ZFWDKLUE.js.map +0 -1
  167. package/dist/vi-UIOSP2JG.js.map +0 -1
  168. package/dist/zh-6R6YUY46.js.map +0 -1
  169. /package/dist/{chunk-PFUXQPWJ.js.map → chunk-RO5CRVIA.js.map} +0 -0
  170. /package/dist/{en-P2L4X27N.js.map → en-QFIEG3CA.js.map} +0 -0
@@ -1,4 +1,6 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
+ import { asStates } from '../../../../__tests__/test-client';
3
+ import { sessionOf } from '../../../../__tests__/test-client';
2
4
  import { BehaviorSubject, firstValueFrom } from 'rxjs';
3
5
  import { filter, skip, take, toArray } from 'rxjs/operators';
4
6
  import type { SemiontClient } from '@semiont/sdk';
@@ -34,9 +36,9 @@ function mockClient(overrides: {
34
36
  browse: {
35
37
  resources: (filters: BrowseFilters = {}) => {
36
38
  resourceCalls.push(filters);
37
- return resourcesFn(filters).asObservable();
39
+ return asStates(resourcesFn(filters).asObservable());
38
40
  },
39
- entityTypes: () => entityTypes$.asObservable(),
41
+ entityTypes: () => asStates(entityTypes$.asObservable()),
40
42
  },
41
43
  } as unknown as SemiontClient;
42
44
 
@@ -46,9 +48,9 @@ function mockClient(overrides: {
46
48
  describe('createDiscoverStateUnit', () => {
47
49
  it('exposes recent resources from browse namespace', async () => {
48
50
  const { client } = mockClient();
49
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
51
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
50
52
 
51
- const recent = await firstValueFrom(stateUnit.recentResources$);
53
+ const recent = await firstValueFrom(stateUnit.recent.value$);
52
54
  expect(recent).toEqual([{ '@id': 'r1' }]);
53
55
 
54
56
  stateUnit.dispose();
@@ -56,9 +58,9 @@ describe('createDiscoverStateUnit', () => {
56
58
 
57
59
  it('exposes entity types from browse namespace', async () => {
58
60
  const { client } = mockClient();
59
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
61
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
60
62
 
61
- const types = await firstValueFrom(stateUnit.entityTypes$);
63
+ const types = await firstValueFrom(stateUnit.entityTypes.value$);
62
64
  expect(types).toEqual(['Person']);
63
65
 
64
66
  stateUnit.dispose();
@@ -67,9 +69,9 @@ describe('createDiscoverStateUnit', () => {
67
69
  it('falls back to [] when entityTypes() emits undefined', async () => {
68
70
  const entityTypes$ = new BehaviorSubject<string[] | undefined>(undefined);
69
71
  const { client } = mockClient({ entityTypes$ });
70
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
72
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
71
73
 
72
- const types = await firstValueFrom(stateUnit.entityTypes$);
74
+ const types = await firstValueFrom(stateUnit.entityTypes.value$);
73
75
  expect(types).toEqual([]);
74
76
 
75
77
  stateUnit.dispose();
@@ -78,13 +80,13 @@ describe('createDiscoverStateUnit', () => {
78
80
  it('reports loading when resources are undefined', async () => {
79
81
  const resources$ = new BehaviorSubject<unknown[] | undefined>(undefined);
80
82
  const { client } = mockClient({ resources$ });
81
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
83
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
82
84
 
83
- const loading = await firstValueFrom(stateUnit.isLoadingRecent$);
85
+ const loading = await firstValueFrom(stateUnit.recent.loading$);
84
86
  expect(loading).toBe(true);
85
87
 
86
88
  resources$.next([]);
87
- const loaded = await firstValueFrom(stateUnit.isLoadingRecent$.pipe(filter((l) => !l)));
89
+ const loaded = await firstValueFrom(stateUnit.recent.loading$.pipe(filter((l) => !l)));
88
90
  expect(loaded).toBe(false);
89
91
 
90
92
  stateUnit.dispose();
@@ -92,7 +94,7 @@ describe('createDiscoverStateUnit', () => {
92
94
 
93
95
  it('exposes a search pipeline', () => {
94
96
  const { client } = mockClient();
95
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
97
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
96
98
 
97
99
  expect(stateUnit.search).toBeDefined();
98
100
  expect(typeof stateUnit.search.setQuery).toBe('function');
@@ -103,9 +105,9 @@ describe('createDiscoverStateUnit', () => {
103
105
 
104
106
  it('initial selectedEntityType$ is empty and recent fetch carries no entityType', async () => {
105
107
  const { client, resourceCalls } = mockClient();
106
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
108
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
107
109
 
108
- await firstValueFrom(stateUnit.recentResources$);
110
+ await firstValueFrom(stateUnit.recent.value$);
109
111
 
110
112
  expect(resourceCalls).toHaveLength(1);
111
113
  expect(resourceCalls[0]).toEqual({ limit: 10, archived: false });
@@ -116,10 +118,10 @@ describe('createDiscoverStateUnit', () => {
116
118
 
117
119
  it('setSelectedEntityType drives a refetch with the entityType filter', async () => {
118
120
  const { client, resourceCalls } = mockClient();
119
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
121
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
120
122
 
121
123
  // Prime the first subscription so the switchMap is live.
122
- const sub = stateUnit.recentResources$.subscribe();
124
+ const sub = stateUnit.recent.value$.subscribe();
123
125
 
124
126
  stateUnit.setSelectedEntityType('Person');
125
127
 
@@ -136,7 +138,7 @@ describe('createDiscoverStateUnit', () => {
136
138
  vi.useFakeTimers();
137
139
  try {
138
140
  const { client, resourceCalls } = mockClient();
139
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
141
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
140
142
 
141
143
  const collected: Array<{ results: unknown[]; isSearching: boolean }> = [];
142
144
  const sub = stateUnit.search.state$.subscribe((s) => collected.push(s));
@@ -161,7 +163,7 @@ describe('createDiscoverStateUnit', () => {
161
163
  const { client, resourceCalls } = mockClient({
162
164
  resourcesFn: (filters) => (filters.search ? results$ : new BehaviorSubject<unknown[] | undefined>([])),
163
165
  });
164
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
166
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
165
167
 
166
168
  const sub = stateUnit.search.state$.subscribe();
167
169
  stateUnit.setSelectedEntityType('Person');
@@ -191,7 +193,7 @@ describe('createDiscoverStateUnit', () => {
191
193
  const { client } = mockClient({
192
194
  resourcesFn: () => results$,
193
195
  });
194
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
196
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
195
197
 
196
198
  const collected: Array<{ results: unknown[]; isSearching: boolean }> = [];
197
199
  const sub = stateUnit.search.state$.subscribe((s) => collected.push(s));
@@ -215,7 +217,7 @@ describe('createDiscoverStateUnit', () => {
215
217
  const { client } = mockClient({
216
218
  resourcesFn: () => inflight$,
217
219
  });
218
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
220
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
219
221
 
220
222
  const collected: Array<{ results: unknown[]; isSearching: boolean }> = [];
221
223
  const sub = stateUnit.search.state$.subscribe((s) => collected.push(s));
@@ -234,7 +236,7 @@ describe('createDiscoverStateUnit', () => {
234
236
 
235
237
  it('search query observable echoes the latest setQuery value', async () => {
236
238
  const { client } = mockClient();
237
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
239
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
238
240
 
239
241
  const queries = stateUnit.search.query$.pipe(skip(1), take(1), toArray()).toPromise();
240
242
  stateUnit.search.setQuery('alpha');
@@ -246,9 +248,9 @@ describe('createDiscoverStateUnit', () => {
246
248
 
247
249
  it('omits entityType from the filter when the empty sentinel is selected', async () => {
248
250
  const { client, resourceCalls } = mockClient();
249
- const stateUnit = createDiscoverStateUnit(client, mockBrowse());
251
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
250
252
 
251
- const sub = stateUnit.recentResources$.subscribe();
253
+ const sub = stateUnit.recent.value$.subscribe();
252
254
  stateUnit.setSelectedEntityType('Person');
253
255
  stateUnit.setSelectedEntityType('');
254
256
 
@@ -266,10 +268,73 @@ describe('DiscoverStateUnit — StateUnit axioms', () => {
266
268
  setup: () => {
267
269
  const browse = disposeProbe();
268
270
  const { client } = mockClient();
269
- return { unit: createDiscoverStateUnit(client, browse as unknown as ShellStateUnit), passedIn: [browse] };
271
+ return { unit: createDiscoverStateUnit(sessionOf(client), browse as unknown as ShellStateUnit), passedIn: [browse] };
270
272
  },
271
273
  surfaces: (u) => [u.selectedEntityType$],
272
274
  invocations: (u) => [() => u.setSelectedEntityType('')],
273
275
  });
274
276
  });
275
277
  });
278
+
279
+ describe('createDiscoverStateUnit — terminal load failure', () => {
280
+ // `isLoadingRecent$` was `recent$.pipe(map(r => r === undefined))`, so a
281
+ // terminally failed list (B15) — which has no value either — left
282
+ // /know/discover spinning for ever. Same defect as the References and
283
+ // History panels. See .plans/PANEL-FAILURE-STATES.md
284
+
285
+ it('recent: stops loading and surfaces the reason instead of spinning for ever', async () => {
286
+ const resources$ = new BehaviorSubject<unknown[] | undefined>(undefined);
287
+ const { client } = mockClient({ resources$ });
288
+ const unit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
289
+
290
+ expect(await firstValueFrom(unit.recent.loading$)).toBe(true);
291
+ resources$.next({ status: 'failed', error: new Error('Resource not found') } as never);
292
+
293
+ expect(await firstValueFrom(unit.recent.loading$)).toBe(false);
294
+ expect((await firstValueFrom(unit.recent.error$) as Error | null)?.message)
295
+ .toBe('Resource not found');
296
+
297
+ unit.dispose();
298
+ });
299
+
300
+ it('entityTypes: same, and keeps an empty list to render', async () => {
301
+ const entityTypes$ = new BehaviorSubject<string[] | undefined>(undefined);
302
+ const { client } = mockClient({ entityTypes$ });
303
+ const unit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
304
+
305
+ entityTypes$.next({ status: 'failed', error: new Error('boom') } as never);
306
+
307
+ expect(await firstValueFrom(unit.entityTypes.loading$)).toBe(false);
308
+ expect(await firstValueFrom(unit.entityTypes.error$)).not.toBeNull();
309
+ expect(await firstValueFrom(unit.entityTypes.value$)).toEqual([]);
310
+
311
+ unit.dispose();
312
+ });
313
+
314
+ it('recent.retry() re-issues the query and can recover', async () => {
315
+ // A fresh `observe()` is what clears the B15 marker, so retry must reach
316
+ // `browse.resources()` again rather than re-subscribing to a dead stream.
317
+ const attempts: BehaviorSubject<unknown[] | undefined>[] = [];
318
+ const { client } = mockClient({
319
+ resourcesFn: () => {
320
+ const s = new BehaviorSubject<unknown[] | undefined>(undefined);
321
+ attempts.push(s);
322
+ return s;
323
+ },
324
+ });
325
+ const unit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
326
+
327
+ attempts[0]!.next({ status: 'failed', error: new Error('nope') } as never);
328
+ expect(await firstValueFrom(unit.recent.error$)).not.toBeNull();
329
+
330
+ unit.recent.retry();
331
+ expect(attempts.length).toBeGreaterThan(1);
332
+ expect(await firstValueFrom(unit.recent.error$)).toBeNull();
333
+
334
+ attempts[attempts.length - 1]!.next([{ '@id': 'r1' }]);
335
+ expect(await firstValueFrom(unit.recent.value$)).toHaveLength(1);
336
+ expect(await firstValueFrom(unit.recent.loading$)).toBe(false);
337
+
338
+ unit.dispose();
339
+ });
340
+ });
@@ -1,10 +1,12 @@
1
1
  import { BehaviorSubject, Subject, combineLatest, of, type Observable } from 'rxjs';
2
+ import { readyValue } from '@semiont/sdk';
2
3
  import { debounceTime, distinctUntilChanged, map, startWith, switchMap, shareReplay } from 'rxjs/operators';
3
4
  import type { ResourceDescriptor } from '@semiont/core';
4
- import type { SemiontClient } from '@semiont/sdk';
5
+ import type { SemiontSession } from '@semiont/sdk';
5
6
  import type { StateUnit } from '@semiont/core';
6
7
  import { createDisposer } from '@semiont/sdk';
7
8
  import type { ShellStateUnit } from '../../../state/shell-state-unit';
9
+ import { trackList, type ListState } from '../../../state/list-state';
8
10
 
9
11
  const RECENT_LIMIT = 10;
10
12
  const SEARCH_LIMIT = 20;
@@ -19,17 +21,17 @@ export interface DiscoverSearchPipeline {
19
21
  export interface DiscoverStateUnit extends StateUnit {
20
22
  browse: ShellStateUnit;
21
23
  search: DiscoverSearchPipeline;
22
- recentResources$: Observable<ResourceDescriptor[]>;
23
- entityTypes$: Observable<string[]>;
24
- isLoadingRecent$: Observable<boolean>;
24
+ recent: ListState<ResourceDescriptor[]>;
25
+ entityTypes: ListState<string[]>;
25
26
  selectedEntityType$: Observable<string>;
26
27
  setSelectedEntityType(value: string): void;
27
28
  }
28
29
 
29
30
  export function createDiscoverStateUnit(
30
- client: SemiontClient,
31
+ session: SemiontSession,
31
32
  browse: ShellStateUnit,
32
33
  ): DiscoverStateUnit {
34
+ const { client } = session;
33
35
  const disposer = createDisposer();
34
36
  // `browse` (ShellStateUnit) is a *passed-in* dependency owned by the caller
35
37
  // (`useShellStateUnit`), not this unit — do NOT add it to the disposer (it's the
@@ -44,28 +46,27 @@ export function createDiscoverStateUnit(
44
46
  const queryInput$ = new Subject<string>();
45
47
  disposer.add(() => queryInput$.complete());
46
48
 
47
- const recent$ = selectedEntityType$.pipe(
48
- switchMap((et) =>
49
- client.browse.resources({
50
- limit: RECENT_LIMIT,
51
- archived: false,
52
- ...(et ? { entityType: et } : {}),
53
- }),
49
+ // `trackList` holds the single subscription and multicasts through its own
50
+ // subjects, so the previous `shareReplay` is unnecessary — and it would have
51
+ // been actively wrong here: a replayed ERROR cannot be retried away, and
52
+ // retry must reach `browse.resources()` again for B15 to clear the marker.
53
+ // The thunk therefore rebuilds the whole chain per attempt.
54
+ const recent = trackList<ResourceDescriptor[]>(
55
+ () => selectedEntityType$.pipe(
56
+ switchMap((et) =>
57
+ client.browse.resources({
58
+ limit: RECENT_LIMIT,
59
+ archived: false,
60
+ ...(et ? { entityType: et } : {}),
61
+ }),
62
+ ),
54
63
  ),
55
- shareReplay({ bufferSize: 1, refCount: true }),
56
- );
57
-
58
- const recentResources$: Observable<ResourceDescriptor[]> = recent$.pipe(
59
- map((r) => r ?? []),
64
+ [],
60
65
  );
66
+ disposer.add(recent.dispose);
61
67
 
62
- const isLoadingRecent$: Observable<boolean> = recent$.pipe(
63
- map((r) => r === undefined),
64
- );
65
-
66
- const entityTypes$: Observable<string[]> = client.browse.entityTypes().pipe(
67
- map((e) => e ?? []),
68
- );
68
+ const entityTypes = trackList<string[]>(() => client.browse.entityTypes(), []);
69
+ disposer.add(entityTypes.dispose);
69
70
 
70
71
  const debouncedQuery$ = queryInput$.pipe(
71
72
  startWith(''),
@@ -87,9 +88,9 @@ export function createDiscoverStateUnit(
87
88
  ...(et ? { entityType: et } : {}),
88
89
  })
89
90
  .pipe(
90
- map((results) => ({
91
- results: results ?? [],
92
- isSearching: results === undefined,
91
+ map((st) => ({
92
+ results: readyValue(st) ?? [],
93
+ isSearching: st.status === 'pending',
93
94
  })),
94
95
  startWith({ results: [] as ResourceDescriptor[], isSearching: true }),
95
96
  );
@@ -106,9 +107,8 @@ export function createDiscoverStateUnit(
106
107
  return {
107
108
  browse,
108
109
  search,
109
- recentResources$,
110
- entityTypes$,
111
- isLoadingRecent$,
110
+ recent: recent.state,
111
+ entityTypes: entityTypes.state,
112
112
  selectedEntityType$: selectedEntityType$.asObservable(),
113
113
  setSelectedEntityType: (value) => selectedEntityType$.next(value),
114
114
  dispose: () => disposer.dispose(),
@@ -57,6 +57,7 @@ const { stubBrowser } = vi.hoisted(() => {
57
57
  removeOpenResource: vi.fn(),
58
58
  updateOpenResourceName: vi.fn(),
59
59
  reorderOpenResources: vi.fn(),
60
+ setLastViewedResource: vi.fn(),
60
61
  emit: vi.fn(),
61
62
  on: vi.fn(() => () => {}),
62
63
  stream: vi.fn(() => ({ subscribe: () => ({ unsubscribe: () => {} }) })),
@@ -20,6 +20,7 @@ import { JsonLdPanel } from '@semiont/react-ui';
20
20
  import { Toolbar } from '@semiont/react-ui';
21
21
  import { useResourceLoadingAnnouncements } from '@semiont/react-ui';
22
22
  import { ResourceViewer } from '@semiont/react-ui';
23
+ import type { BrowseMediaRenderers, AnnotateMediaRenderers } from '@semiont/react-ui';
23
24
  import { useObservable } from '@semiont/react-ui';
24
25
  import { useResourceContent } from '../../../hooks/useResourceContent';
25
26
  import { useMediaToken } from '../../../hooks/useMediaToken';
@@ -35,7 +36,7 @@ import { getSelectorType } from '../../../lib/media-shapes';
35
36
  import { useResourceAnnotations } from '../../../contexts/ResourceAnnotationsContext';
36
37
  import { useSemiont } from '../../../session/SemiontProvider';
37
38
  import { createResourceViewerPageStateUnit } from '../state/resource-viewer-page-state-unit';
38
- import { useStateUnit } from '../../../hooks/useStateUnit';
39
+ import { useSessionStateUnit } from '../../../hooks/useSessionStateUnit';
39
40
  import { useShellStateUnit } from '../../../hooks/useShellStateUnit';
40
41
  import { useTranslations } from '../../../contexts/TranslationContext';
41
42
  import { ReferenceWizardModal } from '../../../components/modals/ReferenceWizardModal';
@@ -92,6 +93,14 @@ export interface ResourceViewerPageProps {
92
93
  * Name of the active knowledge base (for display in panels)
93
94
  */
94
95
  knowledgeBaseName?: string | undefined;
96
+
97
+ /**
98
+ * Media-renderer overrides, forwarded to `ResourceViewer`. Present at this
99
+ * tier too so a host embedding the whole page — not just the viewer — can
100
+ * still swap a renderer. See .plans/ANNOTATE-RENDERER-REGISTRY.md (D5)
101
+ */
102
+ browseRenderers?: BrowseMediaRenderers;
103
+ annotateRenderers?: AnnotateMediaRenderers;
95
104
  }
96
105
 
97
106
  /**
@@ -126,6 +135,8 @@ export function ResourceViewerPage({
126
135
  refetchDocument,
127
136
  streamStatus,
128
137
  knowledgeBaseName,
138
+ browseRenderers,
139
+ annotateRenderers,
129
140
  }: ResourceViewerPageProps) {
130
141
  // Translations
131
142
  const tw = useTranslations('ReferenceWizard');
@@ -185,27 +196,40 @@ export function ResourceViewerPage({
185
196
 
186
197
  // Composite state unit — owns all flow VMs, wizard state, annotations, entity types
187
198
  const browseStateUnit = useShellStateUnit();
188
- const stateUnit = useStateUnit(() => createResourceViewerPageStateUnit(semiont!, rUri, locale, browseStateUnit));
189
-
190
- const annotations = useObservable(stateUnit.annotations$) ?? [];
191
- const groups = useObservable(stateUnit.annotationGroups$);
192
- const allEntityTypes = useObservable(stateUnit.entityTypes$) ?? [];
193
- const referencedByRaw = useObservable(stateUnit.referencedBy$);
194
- const referencedBy = referencedByRaw ?? [];
195
- const referencedByLoading = referencedByRaw === undefined;
196
- const hoveredAnnotationId = useObservable(stateUnit.beckon.hoveredAnnotationId$) ?? null;
197
- const pendingAnnotation = useObservable(stateUnit.mark.pendingAnnotation$) ?? null;
198
- const assistingMotivation = useObservable(stateUnit.mark.assistingMotivation$) ?? null;
199
- const progress = useObservable(stateUnit.mark.progress$) ?? null;
200
- const activePanel = useObservable(stateUnit.browse.activePanel$) ?? null;
201
- const scrollToAnnotationId = useObservable(stateUnit.browse.scrollToAnnotationId$) ?? null;
202
- const panelInitialTab = useObservable(stateUnit.browse.panelInitialTab$) ?? null;
203
- const onScrollCompleted = stateUnit.browse.onScrollCompleted;
204
- const generationProgress = useObservable(stateUnit.yield.progress$) ?? null;
205
- const gatherContext = useObservable(stateUnit.gather.context$) ?? null;
206
- const gatherLoading = useObservable(stateUnit.gather.loading$) ?? false;
207
- const gatherError = useObservable(stateUnit.gather.error$) ?? null;
208
- const wizardState = useObservable(stateUnit.wizard$);
199
+ // Session-typed + session-keyed (SESSION-TYPED-FACTORIES.md): no `!`, no
200
+ // construction without a session, dispose-first rebuild on session swap.
201
+ const stateUnit = useSessionStateUnit(
202
+ session ?? undefined,
203
+ (s) => createResourceViewerPageStateUnit(s, rUri, locale, browseStateUnit),
204
+ );
205
+
206
+ const annotations = useObservable(stateUnit?.annotations.value$) ?? [];
207
+ const annotationsError = useObservable(stateUnit?.annotations.error$) ?? null;
208
+ const groups = useObservable(stateUnit?.annotationGroups$);
209
+ const allEntityTypes = useObservable(stateUnit?.entityTypes.value$) ?? [];
210
+ const entityTypesError = useObservable(stateUnit?.entityTypes.error$) ?? null;
211
+ // Three states, not two: a terminally failed list has no value EITHER, so
212
+ // deriving "loading" from `undefined` leaves a dead request spinning for
213
+ // ever. See .plans/PANEL-FAILURE-STATES.md
214
+ const referencedBy = useObservable(stateUnit?.referencedBy.value$) ?? [];
215
+ const referencedByLoading = useObservable(stateUnit?.referencedBy.loading$) ?? true;
216
+ const referencedByError = useObservable(stateUnit?.referencedBy.error$) ?? null;
217
+ const events = useObservable(stateUnit?.events.value$) ?? [];
218
+ const eventsLoading = useObservable(stateUnit?.events.loading$) ?? true;
219
+ const eventsError = useObservable(stateUnit?.events.error$) ?? null;
220
+ const hoveredAnnotationId = useObservable(stateUnit?.beckon.hoveredAnnotationId$) ?? null;
221
+ const pendingAnnotation = useObservable(stateUnit?.mark.pendingAnnotation$) ?? null;
222
+ const assistingMotivation = useObservable(stateUnit?.mark.assistingMotivation$) ?? null;
223
+ const progress = useObservable(stateUnit?.mark.progress$) ?? null;
224
+ const activePanel = useObservable(stateUnit?.browse.activePanel$) ?? null;
225
+ const scrollToAnnotationId = useObservable(stateUnit?.browse.scrollToAnnotationId$) ?? null;
226
+ const panelInitialTab = useObservable(stateUnit?.browse.panelInitialTab$) ?? null;
227
+ const onScrollCompleted = stateUnit?.browse.onScrollCompleted;
228
+ const generationProgress = useObservable(stateUnit?.yield.progress$) ?? null;
229
+ const gatherContext = useObservable(stateUnit?.gather.context$) ?? null;
230
+ const gatherLoading = useObservable(stateUnit?.gather.loading$) ?? false;
231
+ const gatherError = useObservable(stateUnit?.gather.error$) ?? null;
232
+ const wizardState = useObservable(stateUnit?.wizard$);
209
233
  const wizardOpen = wizardState?.open ?? false;
210
234
  const wizardAnnotationId = wizardState?.annotationId ?? null;
211
235
  const wizardResourceId = wizardState?.resourceId ?? null;
@@ -214,12 +238,12 @@ export function ResourceViewerPage({
214
238
  const [generateOpen, setGenerateOpen] = useState(false);
215
239
 
216
240
  const handleWizardClose = useCallback(() => {
217
- stateUnit.closeWizard();
241
+ stateUnit?.closeWizard();
218
242
  }, [stateUnit]);
219
243
 
220
244
  const handleWizardGenerateSubmit = useCallback((referenceId: string, config: GenerationConfig) => {
221
245
  clearNewAnnotationId(annotationId(referenceId));
222
- stateUnit.yield.generate(referenceId, {
246
+ stateUnit?.yield.generate(referenceId, {
223
247
  title: config.title,
224
248
  storageUri: config.storagePath,
225
249
  prompt: config.prompt,
@@ -239,7 +263,7 @@ export function ResourceViewerPage({
239
263
  // NOT a toast. `generateFromResource` is Phase 6 (the @semiont/sdk session);
240
264
  // this is declared RED until that method lands. Do not re-impl it here.
241
265
  const handleResourceGenerateSubmit = useCallback((_resourceId: string, config: GenerationConfig) => {
242
- stateUnit.yield.generateFromResource({
266
+ stateUnit?.yield.generateFromResource({
243
267
  title: config.title,
244
268
  storageUri: config.storagePath,
245
269
  ...(config.prompt ? { prompt: config.prompt } : {}),
@@ -286,14 +310,15 @@ export function ResourceViewerPage({
286
310
  });
287
311
  }, [browser]);
288
312
 
289
- // Add resource to open tabs when it loads
313
+ // Add resource to open tabs when it loads, and record it as this KB's
314
+ // last-viewed for the /know landing route to resume from. Both are per-KB
315
+ // state owned by the browser — a global last-viewed id sends that redirect
316
+ // into the previously active KB's resource after a switch.
290
317
  useEffect(() => {
291
318
  if (resource && rUri) {
292
319
  const mediaType = getPrimaryMediaType(resource);
293
320
  browser.addOpenResource(rUri, resource.name, mediaType || undefined, resource.storageUri);
294
- if (typeof localStorage !== 'undefined') {
295
- localStorage.setItem('lastViewedDocumentId', rUri);
296
- }
321
+ browser.setLastViewedResource(rUri);
297
322
  }
298
323
  }, [resource, rUri, browser]);
299
324
 
@@ -503,6 +528,8 @@ export function ResourceViewerPage({
503
528
  showLineNumbers={showLineNumbers}
504
529
  hoverDelayMs={hoverDelayMs}
505
530
  hoveredAnnotationId={hoveredAnnotationId}
531
+ {...(browseRenderers && { browseRenderers })}
532
+ {...(annotateRenderers && { annotateRenderers })}
506
533
  />
507
534
  )}
508
535
  </ErrorBoundary>
@@ -543,9 +570,14 @@ export function ResourceViewerPage({
543
570
  progress={progress}
544
571
  pendingAnnotation={pendingAnnotation}
545
572
  allEntityTypes={allEntityTypes}
573
+ annotationsError={annotationsError}
574
+ onRetryAnnotations={stateUnit?.annotations.retry}
575
+ entityTypesError={entityTypesError}
546
576
  generatingReferenceId={generationProgress?.annotationId ?? null}
547
577
  referencedBy={referencedBy}
548
578
  referencedByLoading={referencedByLoading}
579
+ referencedByError={referencedByError}
580
+ onRetryReferencedBy={stateUnit?.referencedBy.retry}
549
581
  resourceId={rUri}
550
582
  locale={locale}
551
583
  sourceLanguage={getLanguage(resource)}
@@ -562,7 +594,11 @@ export function ResourceViewerPage({
562
594
  {/* History Panel */}
563
595
  {activePanel === 'history' && (
564
596
  <AnnotationHistory
565
- rUri={rUri}
597
+ events={events}
598
+ eventsLoading={eventsLoading}
599
+ eventsError={eventsError}
600
+ onRetryEvents={stateUnit?.events.retry}
601
+ annotations={annotations}
566
602
  hoveredAnnotationId={hoveredAnnotationId}
567
603
  onEventHover={handleEventHover}
568
604
  onEventClick={handleEventClick}
@@ -676,6 +712,7 @@ export function ResourceViewerPage({
676
712
  resourceId={rUri}
677
713
  defaultTitle=""
678
714
  locale={locale}
715
+ entityTypeOptions={allEntityTypes}
679
716
  onGenerateSubmit={handleResourceGenerateSubmit}
680
717
  translations={{
681
718
  gatherTitle: tg('gatherTitle'),