@semiont/react-ui 0.5.22 → 0.5.24

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 (197) 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-PFUXQPWJ.js → chunk-4QMTEPQ3.js} +92 -60
  6. package/dist/chunk-4QMTEPQ3.js.map +1 -0
  7. package/dist/{chunk-R52YAGHK.js → chunk-5Y5JDCKC.js} +11 -4
  8. package/dist/chunk-5Y5JDCKC.js.map +1 -0
  9. package/dist/{cs-2EFEUKTW.js → cs-LCHHOR4O.js} +11 -4
  10. package/dist/cs-LCHHOR4O.js.map +1 -0
  11. package/dist/{da-SUD3Q44Q.js → da-TARGPMJR.js} +11 -4
  12. package/dist/da-TARGPMJR.js.map +1 -0
  13. package/dist/{de-6IC5QRX7.js → de-SBH4QBPJ.js} +11 -4
  14. package/dist/de-SBH4QBPJ.js.map +1 -0
  15. package/dist/{el-Q4NAXFRC.js → el-2D2OLGWB.js} +11 -4
  16. package/dist/el-2D2OLGWB.js.map +1 -0
  17. package/dist/{en-P2L4X27N.js → en-QFIEG3CA.js} +2 -2
  18. package/dist/{es-YUKZBD4W.js → es-U6YK2LX6.js} +11 -4
  19. package/dist/es-U6YK2LX6.js.map +1 -0
  20. package/dist/{fa-OJ47LILE.js → fa-S7SZE37M.js} +11 -4
  21. package/dist/fa-S7SZE37M.js.map +1 -0
  22. package/dist/{fi-QFFL5CUB.js → fi-CBL3NIXN.js} +11 -4
  23. package/dist/fi-CBL3NIXN.js.map +1 -0
  24. package/dist/{fr-BOQZZWP4.js → fr-EABUHV6S.js} +11 -4
  25. package/dist/fr-EABUHV6S.js.map +1 -0
  26. package/dist/{he-LXDY3TFA.js → he-THWD244Y.js} +11 -4
  27. package/dist/he-THWD244Y.js.map +1 -0
  28. package/dist/{hi-HOJER77V.js → hi-GHGFZJ44.js} +11 -4
  29. package/dist/hi-GHGFZJ44.js.map +1 -0
  30. package/dist/{id-I5WQ6EWD.js → id-POZOUMDW.js} +11 -4
  31. package/dist/id-POZOUMDW.js.map +1 -0
  32. package/dist/index.d.ts +269 -69
  33. package/dist/index.js +2080 -1861
  34. package/dist/index.js.map +1 -1
  35. package/dist/{it-UYH642IJ.js → it-KDYJADSH.js} +11 -4
  36. package/dist/it-KDYJADSH.js.map +1 -0
  37. package/dist/{ja-JTVWAC4L.js → ja-Z5Z6ZF3P.js} +11 -4
  38. package/dist/ja-Z5Z6ZF3P.js.map +1 -0
  39. package/dist/{ko-IHXFMOIZ.js → ko-7X3XUXRN.js} +11 -4
  40. package/dist/ko-7X3XUXRN.js.map +1 -0
  41. package/dist/{ms-JH25VFLO.js → ms-BTWEIW43.js} +11 -4
  42. package/dist/ms-BTWEIW43.js.map +1 -0
  43. package/dist/{nl-SURXW7IQ.js → nl-IS2CGF5I.js} +11 -4
  44. package/dist/nl-IS2CGF5I.js.map +1 -0
  45. package/dist/{no-HXIKQFDZ.js → no-AAMEVA26.js} +11 -4
  46. package/dist/no-AAMEVA26.js.map +1 -0
  47. package/dist/{pl-7A4KU6XK.js → pl-PVXUQYFT.js} +11 -4
  48. package/dist/pl-PVXUQYFT.js.map +1 -0
  49. package/dist/{pt-6MGYP7Q5.js → pt-3ITQ76TL.js} +11 -4
  50. package/dist/pt-3ITQ76TL.js.map +1 -0
  51. package/dist/{ro-IROCHNIA.js → ro-FRB4IIVS.js} +11 -4
  52. package/dist/ro-FRB4IIVS.js.map +1 -0
  53. package/dist/{sv-WDJD75CV.js → sv-DS3RO24M.js} +11 -4
  54. package/dist/sv-DS3RO24M.js.map +1 -0
  55. package/dist/test-utils.js +9 -3
  56. package/dist/test-utils.js.map +1 -1
  57. package/dist/{th-RIO2E6B3.js → th-AX66MWWB.js} +11 -4
  58. package/dist/th-AX66MWWB.js.map +1 -0
  59. package/dist/{tr-TZ7ERCIN.js → tr-7KTB67TT.js} +11 -4
  60. package/dist/tr-7KTB67TT.js.map +1 -0
  61. package/dist/{uk-ZFWDKLUE.js → uk-SMQS2MAQ.js} +11 -4
  62. package/dist/uk-SMQS2MAQ.js.map +1 -0
  63. package/dist/{vi-UIOSP2JG.js → vi-D3JXL2EZ.js} +11 -4
  64. package/dist/vi-D3JXL2EZ.js.map +1 -0
  65. package/dist/{zh-6R6YUY46.js → zh-S754AWFS.js} +11 -4
  66. package/dist/zh-S754AWFS.js.map +1 -0
  67. package/package.json +4 -4
  68. package/src/components/annotation-popups/JsonLdView.tsx +1 -1
  69. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +8 -2
  70. package/src/components/modals/ReferenceWizardModal.tsx +2 -1
  71. package/src/components/modals/ResourceGenerateModal.tsx +10 -4
  72. package/src/components/modals/ResourceSearchModal.tsx +3 -1
  73. package/src/components/modals/SearchModal.tsx +3 -1
  74. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +5 -0
  75. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +2 -1
  76. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +2 -1
  77. package/src/components/resource/AnnotateView.tsx +70 -138
  78. package/src/components/resource/AnnotationHistory.tsx +53 -17
  79. package/src/components/resource/BrowseView.tsx +3 -2
  80. package/src/components/resource/ResourceViewer.tsx +39 -22
  81. package/src/components/resource/__tests__/AnnotateView.registry.test.tsx +114 -0
  82. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +63 -41
  83. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +2 -1
  84. package/src/components/resource/__tests__/ResourceViewer.multi-mount-invalidation.test.tsx +140 -0
  85. package/src/components/resource/__tests__/ResourceViewer.registry.test.tsx +89 -0
  86. package/src/components/resource/annotate-renderers.tsx +155 -0
  87. package/src/components/resource/panels/JsonLdPanel.tsx +1 -1
  88. package/src/components/resource/panels/ReferencesPanel.tsx +38 -3
  89. package/src/components/resource/panels/TagEntry.tsx +5 -1
  90. package/src/components/resource/panels/TaggingPanel.tsx +6 -1
  91. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +41 -0
  92. package/src/components/resource/panels/__tests__/JsonLdPanel.test.tsx +8 -2
  93. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +90 -0
  94. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +2 -2
  95. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +2 -2
  96. package/src/components/resource/panels/__tests__/UnifiedAnnotationsPanel.failure.test.tsx +78 -0
  97. package/src/components/settings/SettingsPanel.tsx +6 -2
  98. package/src/components/settings/__tests__/SettingsPanel.test.tsx +28 -3
  99. package/src/features/admin-devops/__tests__/AdminDevOpsPage.test.tsx +3 -11
  100. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +0 -3
  101. package/src/features/admin-exchange/__tests__/AdminExchangePage.test.tsx +0 -1
  102. package/src/features/admin-exchange/components/AdminExchangePage.tsx +0 -3
  103. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +1 -1
  104. package/src/features/admin-security/__tests__/AdminSecurityPage.test.tsx +0 -8
  105. package/src/features/admin-security/components/AdminSecurityPage.tsx +0 -3
  106. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +10 -9
  107. package/src/features/admin-security/state/admin-security-state-unit.ts +3 -2
  108. package/src/features/admin-users/__tests__/AdminUsersPage.test.tsx +0 -1
  109. package/src/features/admin-users/components/AdminUsersPage.tsx +0 -3
  110. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +7 -6
  111. package/src/features/admin-users/state/admin-users-state-unit.ts +3 -2
  112. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +36 -26
  113. package/src/features/auth-welcome/state/welcome-state-unit.ts +7 -2
  114. package/src/features/moderate-entity-tags/__tests__/EntityTagsPage.test.tsx +0 -2
  115. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +0 -3
  116. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +37 -12
  117. package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +9 -10
  118. package/src/features/moderate-recent/__tests__/RecentDocumentsPage.test.tsx +3 -5
  119. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +0 -3
  120. package/src/features/moderate-tag-schemas/__tests__/TagSchemasPage.test.tsx +3 -5
  121. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +0 -3
  122. package/src/features/moderation-linked-data/__tests__/LinkedDataPage.test.tsx +0 -1
  123. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +0 -3
  124. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +17 -7
  125. package/src/features/resource-compose/components/ResourceComposePage.tsx +5 -3
  126. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +33 -12
  127. package/src/features/resource-compose/state/compose-page-state-unit.ts +9 -8
  128. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +3 -2
  129. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +25 -3
  130. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +90 -25
  131. package/src/features/resource-discovery/state/discover-state-unit.ts +30 -30
  132. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +9 -5
  133. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +70 -40
  134. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +111 -8
  135. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +167 -27
  136. package/src/features/resource-viewer/state/resource-loader-state-unit.ts +74 -9
  137. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +29 -28
  138. package/translations/ar.json +10 -3
  139. package/translations/bn.json +10 -3
  140. package/translations/cs.json +10 -3
  141. package/translations/da.json +10 -3
  142. package/translations/de.json +10 -3
  143. package/translations/el.json +10 -3
  144. package/translations/en.json +10 -3
  145. package/translations/es.json +10 -3
  146. package/translations/fa.json +10 -3
  147. package/translations/fi.json +10 -3
  148. package/translations/fr.json +10 -3
  149. package/translations/he.json +10 -3
  150. package/translations/hi.json +10 -3
  151. package/translations/id.json +10 -3
  152. package/translations/it.json +10 -3
  153. package/translations/ja.json +10 -3
  154. package/translations/ko.json +10 -3
  155. package/translations/ms.json +10 -3
  156. package/translations/nl.json +10 -3
  157. package/translations/no.json +10 -3
  158. package/translations/pl.json +10 -3
  159. package/translations/pt.json +10 -3
  160. package/translations/ro.json +10 -3
  161. package/translations/sv.json +10 -3
  162. package/translations/th.json +10 -3
  163. package/translations/tr.json +10 -3
  164. package/translations/uk.json +10 -3
  165. package/translations/vi.json +10 -3
  166. package/translations/zh.json +10 -3
  167. package/dist/ar-ZHKO4XDR.js.map +0 -1
  168. package/dist/bn-3BUDKAK5.js.map +0 -1
  169. package/dist/chunk-PFUXQPWJ.js.map +0 -1
  170. package/dist/chunk-R52YAGHK.js.map +0 -1
  171. package/dist/cs-2EFEUKTW.js.map +0 -1
  172. package/dist/da-SUD3Q44Q.js.map +0 -1
  173. package/dist/de-6IC5QRX7.js.map +0 -1
  174. package/dist/el-Q4NAXFRC.js.map +0 -1
  175. package/dist/es-YUKZBD4W.js.map +0 -1
  176. package/dist/fa-OJ47LILE.js.map +0 -1
  177. package/dist/fi-QFFL5CUB.js.map +0 -1
  178. package/dist/fr-BOQZZWP4.js.map +0 -1
  179. package/dist/he-LXDY3TFA.js.map +0 -1
  180. package/dist/hi-HOJER77V.js.map +0 -1
  181. package/dist/id-I5WQ6EWD.js.map +0 -1
  182. package/dist/it-UYH642IJ.js.map +0 -1
  183. package/dist/ja-JTVWAC4L.js.map +0 -1
  184. package/dist/ko-IHXFMOIZ.js.map +0 -1
  185. package/dist/ms-JH25VFLO.js.map +0 -1
  186. package/dist/nl-SURXW7IQ.js.map +0 -1
  187. package/dist/no-HXIKQFDZ.js.map +0 -1
  188. package/dist/pl-7A4KU6XK.js.map +0 -1
  189. package/dist/pt-6MGYP7Q5.js.map +0 -1
  190. package/dist/ro-IROCHNIA.js.map +0 -1
  191. package/dist/sv-WDJD75CV.js.map +0 -1
  192. package/dist/th-RIO2E6B3.js.map +0 -1
  193. package/dist/tr-TZ7ERCIN.js.map +0 -1
  194. package/dist/uk-ZFWDKLUE.js.map +0 -1
  195. package/dist/vi-UIOSP2JG.js.map +0 -1
  196. package/dist/zh-6R6YUY46.js.map +0 -1
  197. /package/dist/{en-P2L4X27N.js.map → en-QFIEG3CA.js.map} +0 -0
@@ -20,6 +20,13 @@ export interface ResourceDiscoveryPageProps {
20
20
  searchDocuments: ResourceDescriptor[];
21
21
  entityTypes: string[];
22
22
  isLoadingRecent: boolean;
23
+ /**
24
+ * The recent-resources load failed terminally (B15). Without it, "loading"
25
+ * derived from an absent value never resolves and this route freezes.
26
+ * See .plans/PANEL-FAILURE-STATES.md
27
+ */
28
+ recentError?: Error | null;
29
+ onRetryRecent?: () => void;
23
30
  isSearching: boolean;
24
31
 
25
32
  // Controlled search state
@@ -33,7 +40,6 @@ export interface ResourceDiscoveryPageProps {
33
40
 
34
41
  // UI state props
35
42
  theme: 'light' | 'dark';
36
- showLineNumbers: boolean;
37
43
  activePanel: string | null;
38
44
 
39
45
  // Navigation props
@@ -58,6 +64,8 @@ export interface ResourceDiscoveryPageProps {
58
64
  archived: string;
59
65
  created: string;
60
66
  loadingKnowledgeBase: string;
67
+ recentFailed: string;
68
+ retry: string;
61
69
  };
62
70
 
63
71
  // Component dependencies
@@ -69,13 +77,14 @@ export function ResourceDiscoveryPage({
69
77
  searchDocuments,
70
78
  entityTypes,
71
79
  isLoadingRecent,
80
+ recentError = null,
81
+ onRetryRecent,
72
82
  isSearching,
73
83
  searchQuery,
74
84
  onSearchQueryChange,
75
85
  selectedEntityType,
76
86
  onSelectedEntityTypeChange,
77
87
  theme,
78
- showLineNumbers,
79
88
  activePanel,
80
89
  onNavigateToResource,
81
90
  onNavigateToCompose,
@@ -116,6 +125,20 @@ export function ResourceDiscoveryPage({
116
125
  }
117
126
  }, []);
118
127
 
128
+ // A terminally failed load is not still loading — check it first.
129
+ if (recentError) {
130
+ return (
131
+ <div className="semiont-page__loading">
132
+ <p className="semiont-page__loading-text">{t.recentFailed}</p>
133
+ {onRetryRecent && (
134
+ <button type="button" onClick={onRetryRecent} className="semiont-button" data-variant="secondary">
135
+ {t.retry}
136
+ </button>
137
+ )}
138
+ </div>
139
+ );
140
+ }
141
+
119
142
  // Loading state
120
143
  if (isLoadingRecent) {
121
144
  return (
@@ -261,7 +284,6 @@ export function ResourceDiscoveryPage({
261
284
  <ToolbarPanels
262
285
  activePanel={activePanel}
263
286
  theme={theme}
264
- showLineNumbers={showLineNumbers}
265
287
  />
266
288
 
267
289
  {/* Toolbar - Always visible on the right */}
@@ -1,10 +1,12 @@
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';
5
7
  import type { ShellStateUnit } from '../../../../state/shell-state-unit';
6
8
  import { createDiscoverStateUnit } from '../discover-state-unit';
7
- import { assertStateUnitAxioms, disposeProbe } from '@semiont/core/testing';
9
+ import { assertStateUnitAxioms, disposeProbe } from '@semiont/core/testing/axioms';
8
10
 
9
11
  function mockBrowse(): ShellStateUnit {
10
12
  return { dispose: vi.fn() } as unknown as ShellStateUnit;
@@ -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(),
@@ -12,6 +12,7 @@ import { ResourceViewerPage } from '../components/ResourceViewerPage';
12
12
  import type { ResourceViewerPageProps } from '../components/ResourceViewerPage';
13
13
  import { ToastProvider } from '../../../components/Toast';
14
14
  import { ThemeProvider } from '../../../contexts/ThemeContext';
15
+ import { LineNumbersProvider } from '../../../contexts/LineNumbersContext';
15
16
  import { createTestSemiontWrapper } from '../../../test-utils';
16
17
  import type { ResourceId } from '@semiont/core';
17
18
 
@@ -57,6 +58,7 @@ const { stubBrowser } = vi.hoisted(() => {
57
58
  removeOpenResource: vi.fn(),
58
59
  updateOpenResourceName: vi.fn(),
59
60
  reorderOpenResources: vi.fn(),
61
+ setLastViewedResource: vi.fn(),
60
62
  emit: vi.fn(),
61
63
  on: vi.fn(() => () => {}),
62
64
  stream: vi.fn(() => ({ subscribe: () => ({ unsubscribe: () => {} }) })),
@@ -162,11 +164,13 @@ const renderWithProviders = (ui: React.ReactElement) => {
162
164
  const { SemiontWrapper } = createTestSemiontWrapper();
163
165
  return render(
164
166
  <ThemeProvider>
165
- <ToastProvider>
166
- <SemiontWrapper>
167
- {ui}
168
- </SemiontWrapper>
169
- </ToastProvider>
167
+ <LineNumbersProvider>
168
+ <ToastProvider>
169
+ <SemiontWrapper>
170
+ {ui}
171
+ </SemiontWrapper>
172
+ </ToastProvider>
173
+ </LineNumbersProvider>
170
174
  </ThemeProvider>
171
175
  );
172
176
  };