@semiont/react-ui 0.5.21 → 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,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
+ import { sessionOf } from '../../../../__tests__/test-client';
2
3
  import { firstValueFrom } from 'rxjs';
3
4
  import { filter } from 'rxjs/operators';
4
5
  import type { SemiontClient } from '@semiont/sdk';
@@ -28,7 +29,7 @@ describe('createAdminUsersStateUnit', () => {
28
29
  it('fetches users and stats on creation', async () => {
29
30
  const listUsers = vi.fn().mockResolvedValue({ users: [{ id: 'u1' }] });
30
31
  const getUserStats = vi.fn().mockResolvedValue({ stats: { total: 1 } });
31
- const stateUnit = createAdminUsersStateUnit(mockClient({ listUsers, getUserStats }), mockBrowse());
32
+ const stateUnit = createAdminUsersStateUnit(sessionOf(mockClient({ listUsers, getUserStats })), mockBrowse());
32
33
 
33
34
  const users = await firstValueFrom(stateUnit.users$.pipe(filter((u) => u.length > 0)));
34
35
  expect(users).toEqual([{ id: 'u1' }]);
@@ -40,7 +41,7 @@ describe('createAdminUsersStateUnit', () => {
40
41
  });
41
42
 
42
43
  it('starts with loading true, resolves to false', async () => {
43
- const stateUnit = createAdminUsersStateUnit(mockClient(), mockBrowse());
44
+ const stateUnit = createAdminUsersStateUnit(sessionOf(mockClient()), mockBrowse());
44
45
 
45
46
  await firstValueFrom(stateUnit.usersLoading$.pipe(filter((l) => !l)));
46
47
  await firstValueFrom(stateUnit.statsLoading$.pipe(filter((l) => !l)));
@@ -51,7 +52,7 @@ describe('createAdminUsersStateUnit', () => {
51
52
  it('sets loading false on fetch error', async () => {
52
53
  const listUsers = vi.fn().mockRejectedValue(new Error('fail'));
53
54
  const getUserStats = vi.fn().mockRejectedValue(new Error('fail'));
54
- const stateUnit = createAdminUsersStateUnit(mockClient({ listUsers, getUserStats }), mockBrowse());
55
+ const stateUnit = createAdminUsersStateUnit(sessionOf(mockClient({ listUsers, getUserStats })), mockBrowse());
55
56
 
56
57
  await firstValueFrom(stateUnit.usersLoading$.pipe(filter((l) => !l)));
57
58
  await firstValueFrom(stateUnit.statsLoading$.pipe(filter((l) => !l)));
@@ -63,7 +64,7 @@ describe('createAdminUsersStateUnit', () => {
63
64
  const listUsers = vi.fn().mockResolvedValue({ users: [] });
64
65
  const getUserStats = vi.fn().mockResolvedValue({ stats: null });
65
66
  const updateUser = vi.fn().mockResolvedValue(undefined);
66
- const stateUnit = createAdminUsersStateUnit(mockClient({ listUsers, getUserStats, updateUser }), mockBrowse());
67
+ const stateUnit = createAdminUsersStateUnit(sessionOf(mockClient({ listUsers, getUserStats, updateUser })), mockBrowse());
67
68
 
68
69
  await firstValueFrom(stateUnit.usersLoading$.pipe(filter((l) => !l)));
69
70
  listUsers.mockClear();
@@ -85,7 +86,7 @@ describe('AdminUsersStateUnit — StateUnit axioms', () => {
85
86
  assertStateUnitAxioms({
86
87
  setup: () => {
87
88
  const browse = disposeProbe();
88
- return { unit: createAdminUsersStateUnit(mockClient(), browse as unknown as ShellStateUnit), passedIn: [browse] };
89
+ return { unit: createAdminUsersStateUnit(sessionOf(mockClient()), browse as unknown as ShellStateUnit), passedIn: [browse] };
89
90
  },
90
91
  surfaces: (u) => [u.users$, u.stats$, u.usersLoading$, u.statsLoading$],
91
92
  });
@@ -3,7 +3,7 @@ import { userDID } from '@semiont/core';
3
3
  import { createDisposer } from '@semiont/sdk';
4
4
  import type { StateUnit } from '@semiont/core';
5
5
  import type { ShellStateUnit } from '../../../state/shell-state-unit';
6
- import type { SemiontClient } from '@semiont/sdk';
6
+ import type { SemiontSession } from '@semiont/sdk';
7
7
 
8
8
  export interface AdminUsersStateUnit extends StateUnit {
9
9
  browse: ShellStateUnit;
@@ -15,9 +15,10 @@ export interface AdminUsersStateUnit extends StateUnit {
15
15
  }
16
16
 
17
17
  export function createAdminUsersStateUnit(
18
- client: SemiontClient,
18
+ session: SemiontSession,
19
19
  browse: ShellStateUnit,
20
20
  ): AdminUsersStateUnit {
21
+ const { client } = session;
21
22
  const disposer = createDisposer();
22
23
  // `browse` (ShellStateUnit) is a *passed-in* dependency owned by the caller
23
24
  // (`useShellStateUnit`), not this unit — do NOT add it to the disposer (it's the
@@ -1,26 +1,37 @@
1
- import { describe, it, expect, vi } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { firstValueFrom } from 'rxjs';
3
3
  import { filter } from 'rxjs/operators';
4
- import type { SemiontClient } from '@semiont/sdk';
4
+ import type { SemiontSession } from '@semiont/sdk';
5
+ import { AuthNamespace } from '@semiont/sdk';
6
+ import { createTestSession, stubBackend } from '@semiont/sdk/testing';
5
7
  import { createWelcomeStateUnit } from '../welcome-state-unit';
6
8
  import { assertStateUnitAxioms } from '@semiont/core/testing';
7
9
 
8
- function mockClient(overrides: {
9
- getMe?: ReturnType<typeof vi.fn>;
10
- acceptTerms?: ReturnType<typeof vi.fn>;
11
- } = {}): SemiontClient {
12
- return {
13
- auth: {
14
- me: overrides.getMe ?? vi.fn().mockResolvedValue({ termsAcceptedAt: undefined }),
15
- acceptTerms: overrides.acceptTerms ?? vi.fn().mockResolvedValue(undefined),
16
- },
17
- } as unknown as SemiontClient;
10
+ // Real session over the scriptable transport (SESSION-TYPED-FACTORIES pilot);
11
+ // auth behavior scripted by prototype spies, the AuthShell precedent — the
12
+ // previous `as unknown as SemiontClient` hand-mock was the M1 disease.
13
+ let getMe: ReturnType<typeof vi.spyOn>;
14
+ let acceptTermsSpy: ReturnType<typeof vi.spyOn>;
15
+
16
+ beforeEach(() => {
17
+ getMe = vi.spyOn(AuthNamespace.prototype, 'me');
18
+ getMe.mockResolvedValue({ termsAcceptedAt: undefined } as never);
19
+ acceptTermsSpy = vi.spyOn(AuthNamespace.prototype, 'acceptTerms');
20
+ acceptTermsSpy.mockResolvedValue(undefined as never);
21
+ });
22
+
23
+ afterEach(() => {
24
+ vi.restoreAllMocks();
25
+ });
26
+
27
+ function makeSession(): SemiontSession {
28
+ return createTestSession({ backend: stubBackend() }).session;
18
29
  }
19
30
 
20
31
  describe('createWelcomeStateUnit', () => {
21
32
  it('fetches user data on creation', async () => {
22
- const getMe = vi.fn().mockResolvedValue({ termsAcceptedAt: '2026-01-01' });
23
- const stateUnit = createWelcomeStateUnit(mockClient({ getMe }));
33
+ getMe.mockResolvedValue({ termsAcceptedAt: '2026-01-01' } as never);
34
+ const stateUnit = createWelcomeStateUnit(makeSession());
24
35
 
25
36
  const data = await firstValueFrom(stateUnit.userData$.pipe(filter((d) => d !== null)));
26
37
  expect(data).toEqual({ termsAcceptedAt: '2026-01-01' });
@@ -29,8 +40,8 @@ describe('createWelcomeStateUnit', () => {
29
40
  });
30
41
 
31
42
  it('initializes with null userData and not processing', async () => {
32
- const getMe = vi.fn().mockReturnValue(new Promise(() => {}));
33
- const stateUnit = createWelcomeStateUnit(mockClient({ getMe }));
43
+ getMe.mockReturnValue(new Promise(() => {}) as never);
44
+ const stateUnit = createWelcomeStateUnit(makeSession());
34
45
 
35
46
  const data = await firstValueFrom(stateUnit.userData$);
36
47
  const processing = await firstValueFrom(stateUnit.isProcessing$);
@@ -41,14 +52,13 @@ describe('createWelcomeStateUnit', () => {
41
52
  });
42
53
 
43
54
  it('acceptTerms sets isProcessing and updates userData', async () => {
44
- const acceptTerms = vi.fn().mockResolvedValue(undefined);
45
- const stateUnit = createWelcomeStateUnit(mockClient({ acceptTerms }));
55
+ const stateUnit = createWelcomeStateUnit(makeSession());
46
56
 
47
57
  await firstValueFrom(stateUnit.userData$.pipe(filter((d) => d !== null)));
48
58
 
49
59
  await stateUnit.acceptTerms();
50
60
 
51
- expect(acceptTerms).toHaveBeenCalledOnce();
61
+ expect(acceptTermsSpy).toHaveBeenCalledOnce();
52
62
 
53
63
  const data = await firstValueFrom(stateUnit.userData$);
54
64
  expect(data?.termsAcceptedAt).toBeDefined();
@@ -60,8 +70,8 @@ describe('createWelcomeStateUnit', () => {
60
70
  });
61
71
 
62
72
  it('acceptTerms resets isProcessing on error', async () => {
63
- const acceptTerms = vi.fn().mockRejectedValue(new Error('fail'));
64
- const stateUnit = createWelcomeStateUnit(mockClient({ acceptTerms }));
73
+ acceptTermsSpy.mockRejectedValue(new Error('fail') as never);
74
+ const stateUnit = createWelcomeStateUnit(makeSession());
65
75
 
66
76
  await firstValueFrom(stateUnit.userData$.pipe(filter((d) => d !== null)));
67
77
 
@@ -74,8 +84,8 @@ describe('createWelcomeStateUnit', () => {
74
84
  });
75
85
 
76
86
  it('handles getMe failure gracefully', async () => {
77
- const getMe = vi.fn().mockRejectedValue(new Error('unauthorized'));
78
- const stateUnit = createWelcomeStateUnit(mockClient({ getMe }));
87
+ getMe.mockRejectedValue(new Error('unauthorized') as never);
88
+ const stateUnit = createWelcomeStateUnit(makeSession());
79
89
 
80
90
  await vi.waitFor(() => expect(getMe).toHaveBeenCalled());
81
91
 
@@ -89,7 +99,7 @@ describe('createWelcomeStateUnit', () => {
89
99
  describe('WelcomeStateUnit — StateUnit axioms', () => {
90
100
  it('satisfies the StateUnit axioms', () => {
91
101
  assertStateUnitAxioms({
92
- setup: () => createWelcomeStateUnit(mockClient()),
102
+ setup: () => createWelcomeStateUnit(makeSession()),
93
103
  surfaces: (u) => [u.userData$, u.isProcessing$],
94
104
  invocations: (u) => [() => u.acceptTerms()],
95
105
  });
@@ -1,7 +1,7 @@
1
1
  import { BehaviorSubject, type Observable } from 'rxjs';
2
2
  import { createDisposer } from '@semiont/sdk';
3
3
  import type { StateUnit } from '@semiont/core';
4
- import type { SemiontClient } from '@semiont/sdk';
4
+ import type { SemiontSession } from '@semiont/sdk';
5
5
 
6
6
  export interface WelcomeStateUnit extends StateUnit {
7
7
  userData$: Observable<{ termsAcceptedAt?: string } | null>;
@@ -9,9 +9,14 @@ export interface WelcomeStateUnit extends StateUnit {
9
9
  acceptTerms(): Promise<void>;
10
10
  }
11
11
 
12
+ // Session-typed (SESSION-TYPED-FACTORIES.md D1): the parameter is the
13
+ // lifetime this unit must not outlive. This page crashed in production when
14
+ // the unit was constructed with `undefined` during session activation —
15
+ // `useSessionStateUnit` + this signature make that unrepresentable.
12
16
  export function createWelcomeStateUnit(
13
- client: SemiontClient,
17
+ session: SemiontSession,
14
18
  ): WelcomeStateUnit {
19
+ const { client } = session;
15
20
  const disposer = createDisposer();
16
21
 
17
22
  const userData$ = new BehaviorSubject<{ termsAcceptedAt?: string } | null>(null);
@@ -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 } from 'rxjs/operators';
4
6
  import type { SemiontClient } from '@semiont/sdk';
@@ -17,7 +19,7 @@ function mockClient(overrides: {
17
19
  const entityTypes$ = overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person', 'Place']);
18
20
  return {
19
21
  browse: {
20
- entityTypes: () => entityTypes$.asObservable(),
22
+ entityTypes: () => asStates(entityTypes$.asObservable()),
21
23
  },
22
24
  frame: {
23
25
  addEntityType: overrides.addEntityType ?? vi.fn().mockResolvedValue(undefined),
@@ -27,9 +29,9 @@ function mockClient(overrides: {
27
29
 
28
30
  describe('createEntityTagsStateUnit', () => {
29
31
  it('exposes entity types from browse namespace', async () => {
30
- const stateUnit = createEntityTagsStateUnit(mockClient(), mockBrowse());
32
+ const stateUnit = createEntityTagsStateUnit(sessionOf(mockClient()), mockBrowse());
31
33
 
32
- const types = await firstValueFrom(stateUnit.entityTypes$);
34
+ const types = await firstValueFrom(stateUnit.entityTypes.value$);
33
35
  expect(types).toEqual(['Person', 'Place']);
34
36
 
35
37
  stateUnit.dispose();
@@ -37,20 +39,20 @@ describe('createEntityTagsStateUnit', () => {
37
39
 
38
40
  it('reports loading when entity types are undefined', async () => {
39
41
  const entityTypes$ = new BehaviorSubject<string[] | undefined>(undefined);
40
- const stateUnit = createEntityTagsStateUnit(mockClient({ entityTypes$ }), mockBrowse());
42
+ const stateUnit = createEntityTagsStateUnit(sessionOf(mockClient({ entityTypes$ })), mockBrowse());
41
43
 
42
- const loading = await firstValueFrom(stateUnit.isLoading$);
44
+ const loading = await firstValueFrom(stateUnit.entityTypes.loading$);
43
45
  expect(loading).toBe(true);
44
46
 
45
47
  entityTypes$.next(['Tag']);
46
- const loaded = await firstValueFrom(stateUnit.isLoading$.pipe(filter((l) => !l)));
48
+ const loaded = await firstValueFrom(stateUnit.entityTypes.loading$.pipe(filter((l) => !l)));
47
49
  expect(loaded).toBe(false);
48
50
 
49
51
  stateUnit.dispose();
50
52
  });
51
53
 
52
54
  it('setNewTag updates newTag$', async () => {
53
- const stateUnit = createEntityTagsStateUnit(mockClient(), mockBrowse());
55
+ const stateUnit = createEntityTagsStateUnit(sessionOf(mockClient()), mockBrowse());
54
56
 
55
57
  stateUnit.setNewTag('Organization');
56
58
  const tag = await firstValueFrom(stateUnit.newTag$);
@@ -61,7 +63,7 @@ describe('createEntityTagsStateUnit', () => {
61
63
 
62
64
  it('addTag calls client and clears newTag$', async () => {
63
65
  const addEntityType = vi.fn().mockResolvedValue(undefined);
64
- const stateUnit = createEntityTagsStateUnit(mockClient({ addEntityType }), mockBrowse());
66
+ const stateUnit = createEntityTagsStateUnit(sessionOf(mockClient({ addEntityType })), mockBrowse());
65
67
 
66
68
  stateUnit.setNewTag('Event');
67
69
  await stateUnit.addTag();
@@ -75,7 +77,7 @@ describe('createEntityTagsStateUnit', () => {
75
77
 
76
78
  it('addTag ignores empty/whitespace input', async () => {
77
79
  const addEntityType = vi.fn();
78
- const stateUnit = createEntityTagsStateUnit(mockClient({ addEntityType }), mockBrowse());
80
+ const stateUnit = createEntityTagsStateUnit(sessionOf(mockClient({ addEntityType })), mockBrowse());
79
81
 
80
82
  stateUnit.setNewTag(' ');
81
83
  await stateUnit.addTag();
@@ -87,7 +89,7 @@ describe('createEntityTagsStateUnit', () => {
87
89
 
88
90
  it('addTag sets error on failure', async () => {
89
91
  const addEntityType = vi.fn().mockRejectedValue(new Error('duplicate'));
90
- const stateUnit = createEntityTagsStateUnit(mockClient({ addEntityType }), mockBrowse());
92
+ const stateUnit = createEntityTagsStateUnit(sessionOf(mockClient({ addEntityType })), mockBrowse());
91
93
 
92
94
  stateUnit.setNewTag('Person');
93
95
  await stateUnit.addTag();
@@ -107,10 +109,33 @@ describe('EntityTagsStateUnit — StateUnit axioms', () => {
107
109
  assertStateUnitAxioms({
108
110
  setup: () => {
109
111
  const browse = disposeProbe();
110
- return { unit: createEntityTagsStateUnit(mockClient(), browse as unknown as ShellStateUnit), passedIn: [browse] };
112
+ return { unit: createEntityTagsStateUnit(sessionOf(mockClient()), browse as unknown as ShellStateUnit), passedIn: [browse] };
111
113
  },
112
114
  surfaces: (u) => [u.newTag$, u.error$, u.isAdding$],
113
115
  invocations: (u) => [() => u.setNewTag(''), () => u.addTag()],
114
116
  });
115
117
  });
116
118
  });
119
+
120
+ describe('createEntityTagsStateUnit — terminal load failure', () => {
121
+ // `isLoading$` was `raw$.pipe(map(e => e === undefined))` and
122
+ // moderate/entity-tags/page.tsx returns a full-page loading screen off it,
123
+ // so a failed load froze the route. The existing `error$` is the ADD-tag
124
+ // error and is deliberately left alone.
125
+ // See .plans/PANEL-FAILURE-STATES.md
126
+
127
+ it('stops loading and surfaces the load failure, distinct from the add-tag error', async () => {
128
+ const entityTypes$ = new BehaviorSubject<string[] | undefined>(undefined);
129
+ const unit = createEntityTagsStateUnit(sessionOf(mockClient({ entityTypes$ })), mockBrowse());
130
+
131
+ expect(await firstValueFrom(unit.entityTypes.loading$)).toBe(true);
132
+ entityTypes$.next({ status: 'failed', error: new Error('Resource not found') } as never);
133
+
134
+ expect(await firstValueFrom(unit.entityTypes.loading$)).toBe(false);
135
+ expect(await firstValueFrom(unit.entityTypes.error$)).not.toBeNull();
136
+ // The add-tag error channel is untouched by a load failure.
137
+ expect(await firstValueFrom(unit.error$)).toBe('');
138
+
139
+ unit.dispose();
140
+ });
141
+ });
@@ -1,13 +1,13 @@
1
- import { BehaviorSubject, type Observable, map } from 'rxjs';
1
+ import { BehaviorSubject, type Observable } from 'rxjs';
2
2
  import { createDisposer } from '@semiont/sdk';
3
3
  import type { StateUnit } from '@semiont/core';
4
4
  import type { ShellStateUnit } from '../../../state/shell-state-unit';
5
- import type { SemiontClient } from '@semiont/sdk';
5
+ import { trackList, type ListState } from '../../../state/list-state';
6
+ import type { SemiontSession } from '@semiont/sdk';
6
7
 
7
8
  export interface EntityTagsStateUnit extends StateUnit {
8
9
  browse: ShellStateUnit;
9
- entityTypes$: Observable<string[]>;
10
- isLoading$: Observable<boolean>;
10
+ entityTypes: ListState<string[]>;
11
11
  newTag$: Observable<string>;
12
12
  error$: Observable<string>;
13
13
  isAdding$: Observable<boolean>;
@@ -16,9 +16,10 @@ export interface EntityTagsStateUnit extends StateUnit {
16
16
  }
17
17
 
18
18
  export function createEntityTagsStateUnit(
19
- client: SemiontClient,
19
+ session: SemiontSession,
20
20
  browse: ShellStateUnit,
21
21
  ): EntityTagsStateUnit {
22
+ const { client } = session;
22
23
  const disposer = createDisposer();
23
24
  // `browse` (ShellStateUnit) is a *passed-in* dependency owned by the caller
24
25
  // (`useShellStateUnit`), not this unit — do NOT add it to the disposer (it's the
@@ -28,9 +29,8 @@ export function createEntityTagsStateUnit(
28
29
  const error$ = new BehaviorSubject<string>('');
29
30
  const isAdding$ = new BehaviorSubject<boolean>(false);
30
31
 
31
- const raw$ = client.browse.entityTypes();
32
- const entityTypes$: Observable<string[]> = raw$.pipe(map((e) => e ?? []));
33
- const isLoading$: Observable<boolean> = raw$.pipe(map((e) => e === undefined));
32
+ const entityTypes = trackList<string[]>(() => client.browse.entityTypes(), []);
33
+ disposer.add(entityTypes.dispose);
34
34
 
35
35
  const addTag = async (): Promise<void> => {
36
36
  const tag = newTag$.getValue().trim();
@@ -49,8 +49,7 @@ export function createEntityTagsStateUnit(
49
49
 
50
50
  return {
51
51
  browse,
52
- entityTypes$,
53
- isLoading$,
52
+ entityTypes: entityTypes.state,
54
53
  newTag$: newTag$.asObservable(),
55
54
  error$: error$.asObservable(),
56
55
  isAdding$: isAdding$.asObservable(),
@@ -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, Observable, firstValueFrom } from 'rxjs';
3
5
  import { filter } from 'rxjs/operators';
4
6
  import type { SemiontClient } from '@semiont/sdk';
@@ -32,7 +34,7 @@ function mockClient(overrides: {
32
34
  const entityTypes$ = overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person']);
33
35
  return {
34
36
  browse: {
35
- entityTypes: () => entityTypes$.asObservable(),
37
+ entityTypes: () => asStates(entityTypes$.asObservable()),
36
38
  },
37
39
  yield: {
38
40
  fromToken: overrides.fromToken ?? vi.fn().mockResolvedValue({ '@id': 'src-1', representations: [{ mediaType: 'text/plain' }] }),
@@ -51,7 +53,7 @@ function mockClient(overrides: {
51
53
 
52
54
  describe('createComposePageStateUnit', () => {
53
55
  it('defaults to "new" mode', async () => {
54
- const stateUnit = createComposePageStateUnit(mockClient(), mockBrowse(), {});
56
+ const stateUnit = createComposePageStateUnit(sessionOf(mockClient()), mockBrowse(), {});
55
57
 
56
58
  const mode = await firstValueFrom(stateUnit.mode$);
57
59
  expect(mode).toBe('new');
@@ -63,7 +65,7 @@ describe('createComposePageStateUnit', () => {
63
65
  });
64
66
 
65
67
  it('detects reference mode from params', async () => {
66
- const stateUnit = createComposePageStateUnit(mockClient(), mockBrowse(), {
68
+ const stateUnit = createComposePageStateUnit(sessionOf(mockClient()), mockBrowse(), {
67
69
  annotationUri: 'ann-1',
68
70
  sourceDocumentId: 'doc-1',
69
71
  name: 'Reference Doc',
@@ -82,7 +84,7 @@ describe('createComposePageStateUnit', () => {
82
84
 
83
85
  it('parses storedContext in reference mode', async () => {
84
86
  const context = { annotation: { id: 'ann-1' }, sourceContext: 'text' };
85
- const stateUnit = createComposePageStateUnit(mockClient(), mockBrowse(), {
87
+ const stateUnit = createComposePageStateUnit(sessionOf(mockClient()), mockBrowse(), {
86
88
  annotationUri: 'ann-1',
87
89
  sourceDocumentId: 'doc-1',
88
90
  name: 'Ref',
@@ -96,7 +98,7 @@ describe('createComposePageStateUnit', () => {
96
98
  });
97
99
 
98
100
  it('ignores malformed storedContext', async () => {
99
- const stateUnit = createComposePageStateUnit(mockClient(), mockBrowse(), {
101
+ const stateUnit = createComposePageStateUnit(sessionOf(mockClient()), mockBrowse(), {
100
102
  annotationUri: 'ann-1',
101
103
  sourceDocumentId: 'doc-1',
102
104
  name: 'Ref',
@@ -113,9 +115,9 @@ describe('createComposePageStateUnit', () => {
113
115
  });
114
116
 
115
117
  it('exposes entity types', async () => {
116
- const stateUnit = createComposePageStateUnit(mockClient(), mockBrowse(), {});
118
+ const stateUnit = createComposePageStateUnit(sessionOf(mockClient()), mockBrowse(), {});
117
119
 
118
- const types = await firstValueFrom(stateUnit.entityTypes$);
120
+ const types = await firstValueFrom(stateUnit.entityTypes.value$);
119
121
  expect(types).toEqual(['Person']);
120
122
 
121
123
  stateUnit.dispose();
@@ -123,7 +125,7 @@ describe('createComposePageStateUnit', () => {
123
125
 
124
126
  it('save in new mode calls yield.resource', async () => {
125
127
  const resource = mockUpload('new-3');
126
- const stateUnit = createComposePageStateUnit(mockClient({ resource }), mockBrowse(), {});
128
+ const stateUnit = createComposePageStateUnit(sessionOf(mockClient({ resource })), mockBrowse(), {});
127
129
 
128
130
  const id = await stateUnit.save({
129
131
  mode: 'new',
@@ -143,7 +145,7 @@ describe('createComposePageStateUnit', () => {
143
145
  it('save in reference mode calls yield.resource then bind.body', async () => {
144
146
  const resource = mockUpload('new-4');
145
147
  const body = vi.fn().mockResolvedValue(undefined);
146
- const stateUnit = createComposePageStateUnit(mockClient({ resource, body }), mockBrowse(), {
148
+ const stateUnit = createComposePageStateUnit(sessionOf(mockClient({ resource, body })), mockBrowse(), {
147
149
  annotationUri: 'ann-1',
148
150
  sourceDocumentId: 'doc-1',
149
151
  name: 'Ref',
@@ -167,7 +169,7 @@ describe('createComposePageStateUnit', () => {
167
169
 
168
170
  it('save in clone mode calls yield.createFromToken', async () => {
169
171
  const createFromToken = vi.fn().mockResolvedValue({ resourceId: 'cloned-1' });
170
- const stateUnit = createComposePageStateUnit(mockClient({ createFromToken }), mockBrowse(), {
172
+ const stateUnit = createComposePageStateUnit(sessionOf(mockClient({ createFromToken })), mockBrowse(), {
171
173
  mode: 'clone',
172
174
  token: 'tok-abc',
173
175
  });
@@ -192,9 +194,28 @@ describe('ComposePageStateUnit — StateUnit axioms', () => {
192
194
  assertStateUnitAxioms({
193
195
  setup: () => {
194
196
  const browse = disposeProbe();
195
- return { unit: createComposePageStateUnit(mockClient(), browse as unknown as ShellStateUnit, {}), passedIn: [browse] };
197
+ return { unit: createComposePageStateUnit(sessionOf(mockClient()), browse as unknown as ShellStateUnit, {}), passedIn: [browse] };
196
198
  },
197
199
  surfaces: (u) => [u.mode$, u.loading$, u.cloneData$, u.referenceData$, u.gatheredContext$, u.uploadProgress$],
198
200
  });
199
201
  });
200
202
  });
203
+
204
+ describe('createComposePageStateUnit — entity types load failure', () => {
205
+ // Lower severity than the two hangs: compose defaults to an empty picker,
206
+ // which misrepresents a failed load as "this KB has no entity types".
207
+ // See .plans/PANEL-FAILURE-STATES.md
208
+
209
+ it('surfaces the failure rather than presenting an empty picker as fact', async () => {
210
+ const entityTypes$ = new BehaviorSubject<string[] | undefined>(undefined);
211
+ const stateUnit = createComposePageStateUnit(sessionOf(mockClient({ entityTypes$ })), mockBrowse(), {});
212
+
213
+ entityTypes$.next({ status: 'failed', error: new Error('boom') } as never);
214
+
215
+ expect(await firstValueFrom(stateUnit.entityTypes.loading$)).toBe(false);
216
+ expect(await firstValueFrom(stateUnit.entityTypes.error$)).not.toBeNull();
217
+ expect(await firstValueFrom(stateUnit.entityTypes.value$)).toEqual([]);
218
+
219
+ stateUnit.dispose();
220
+ });
221
+ });
@@ -1,10 +1,11 @@
1
- import { BehaviorSubject, type Observable, map } from 'rxjs';
1
+ import { BehaviorSubject, type Observable } from 'rxjs';
2
2
  import type { GatheredContext, AnnotationId, AccessToken, ResourceDescriptor, ResourceId } from '@semiont/core';
3
3
  import { resourceId as makeResourceId, annotationId as makeAnnotationId } from '@semiont/core';
4
4
  import { createDisposer } from '@semiont/sdk';
5
5
  import type { StateUnit } from '@semiont/core';
6
6
  import type { ShellStateUnit } from '../../../state/shell-state-unit';
7
- import type { SemiontClient } from '@semiont/sdk';
7
+ import { trackList, type ListState } from '../../../state/list-state';
8
+ import type { SemiontSession } from '@semiont/sdk';
8
9
  import { decodeWithCharset, extensionForMediaType } from '@semiont/core';
9
10
  import type { UploadProgress } from '@semiont/sdk';
10
11
 
@@ -54,7 +55,7 @@ export interface ComposePageStateUnit extends StateUnit {
54
55
  cloneData$: Observable<CloneData | null>;
55
56
  referenceData$: Observable<ReferenceData | null>;
56
57
  gatheredContext$: Observable<GatheredContext | null>;
57
- entityTypes$: Observable<string[]>;
58
+ entityTypes: ListState<string[]>;
58
59
  /**
59
60
  * Live upload-progress for the in-flight `save(...)` call. Emits the
60
61
  * full `UploadProgress` lifecycle (started → finished) while a save is
@@ -66,11 +67,12 @@ export interface ComposePageStateUnit extends StateUnit {
66
67
  }
67
68
 
68
69
  export function createComposePageStateUnit(
69
- client: SemiontClient,
70
+ session: SemiontSession,
70
71
  browse: ShellStateUnit,
71
72
  params: ComposeParams,
72
73
  auth?: AccessToken,
73
74
  ): ComposePageStateUnit {
75
+ const { client } = session;
74
76
  const disposer = createDisposer();
75
77
  // `browse` (ShellStateUnit) is a *passed-in* dependency owned by the caller
76
78
  // (`useShellStateUnit`), not this unit — do NOT add it to the disposer (it's the
@@ -87,9 +89,8 @@ export function createComposePageStateUnit(
87
89
  const gatheredContext$ = new BehaviorSubject<GatheredContext | null>(null);
88
90
  const uploadProgress$ = new BehaviorSubject<UploadProgress | null>(null);
89
91
 
90
- const entityTypes$: Observable<string[]> = client.browse.entityTypes().pipe(
91
- map((e) => e ?? []),
92
- );
92
+ const entityTypes = trackList<string[]>(() => client.browse.entityTypes(), []);
93
+ disposer.add(entityTypes.dispose);
93
94
 
94
95
  // Initialize based on mode
95
96
  if (isReferenceMode) {
@@ -192,7 +193,7 @@ export function createComposePageStateUnit(
192
193
  cloneData$: cloneData$.asObservable(),
193
194
  referenceData$: referenceData$.asObservable(),
194
195
  gatheredContext$: gatheredContext$.asObservable(),
195
- entityTypes$,
196
+ entityTypes: entityTypes.state,
196
197
  uploadProgress$: uploadProgress$.asObservable(),
197
198
  save,
198
199
  dispose: () => {
@@ -56,7 +56,9 @@ const createMockProps = (overrides?: Partial<ResourceDiscoveryPageProps>): Resou
56
56
  composeFirstResource: 'Compose First Resource',
57
57
  archived: 'Archived',
58
58
  created: 'Created:',
59
- loadingKnowledgeBase: 'Loading knowledge base...',
59
+ recentFailed: 'Could not load resources.',
60
+ retry: 'Try again',
61
+ loadingKnowledgeBase: 'Loading knowledge base...',
60
62
  },
61
63
  ToolbarPanels: ({ children }: any) => <div data-testid="toolbar-panels">{children}</div>,
62
64
  ...overrides,
@@ -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
@@ -58,6 +65,8 @@ export interface ResourceDiscoveryPageProps {
58
65
  archived: string;
59
66
  created: string;
60
67
  loadingKnowledgeBase: string;
68
+ recentFailed: string;
69
+ retry: string;
61
70
  };
62
71
 
63
72
  // Component dependencies
@@ -69,6 +78,8 @@ export function ResourceDiscoveryPage({
69
78
  searchDocuments,
70
79
  entityTypes,
71
80
  isLoadingRecent,
81
+ recentError = null,
82
+ onRetryRecent,
72
83
  isSearching,
73
84
  searchQuery,
74
85
  onSearchQueryChange,
@@ -116,6 +127,20 @@ export function ResourceDiscoveryPage({
116
127
  }
117
128
  }, []);
118
129
 
130
+ // A terminally failed load is not still loading — check it first.
131
+ if (recentError) {
132
+ return (
133
+ <div className="semiont-page__loading">
134
+ <p className="semiont-page__loading-text">{t.recentFailed}</p>
135
+ {onRetryRecent && (
136
+ <button type="button" onClick={onRetryRecent} className="semiont-button" data-variant="secondary">
137
+ {t.retry}
138
+ </button>
139
+ )}
140
+ </div>
141
+ );
142
+ }
143
+
119
144
  // Loading state
120
145
  if (isLoadingRecent) {
121
146
  return (