@semiont/react-ui 0.4.14 → 0.4.15

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 (49) hide show
  1. package/README.md +18 -12
  2. package/dist/KnowledgeBaseSessionContext-CpYaCbnC.d.mts +174 -0
  3. package/dist/{PdfAnnotationCanvas.client-CW6SKH2U.mjs → PdfAnnotationCanvas.client-CHDCGQBR.mjs} +3 -3
  4. package/dist/{chunk-HNZOXH4L.mjs → chunk-OZICDVH7.mjs} +5 -3
  5. package/dist/chunk-OZICDVH7.mjs.map +1 -0
  6. package/dist/chunk-R2U7P4TK.mjs +865 -0
  7. package/dist/chunk-R2U7P4TK.mjs.map +1 -0
  8. package/dist/{chunk-BQJWOK4C.mjs → chunk-VN5NY4SN.mjs} +9 -8
  9. package/dist/chunk-VN5NY4SN.mjs.map +1 -0
  10. package/dist/index.d.mts +139 -169
  11. package/dist/index.mjs +2197 -1947
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/test-utils.d.mts +13 -62
  14. package/dist/test-utils.mjs +40 -21
  15. package/dist/test-utils.mjs.map +1 -1
  16. package/package.json +5 -3
  17. package/src/components/ProtectedErrorBoundary.tsx +95 -0
  18. package/src/components/__tests__/ProtectedErrorBoundary.test.tsx +197 -0
  19. package/src/components/modals/PermissionDeniedModal.tsx +140 -0
  20. package/src/components/modals/ReferenceWizardModal.tsx +3 -2
  21. package/src/components/modals/SessionExpiredModal.tsx +101 -0
  22. package/src/components/modals/__tests__/PermissionDeniedModal.test.tsx +150 -0
  23. package/src/components/modals/__tests__/SessionExpiredModal.test.tsx +115 -0
  24. package/src/components/resource/AnnotationHistory.tsx +5 -6
  25. package/src/components/resource/HistoryEvent.tsx +7 -7
  26. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +33 -34
  27. package/src/components/resource/__tests__/HistoryEvent.test.tsx +17 -19
  28. package/src/components/resource/__tests__/event-formatting.test.ts +70 -94
  29. package/src/components/resource/event-formatting.ts +56 -56
  30. package/src/components/resource/panels/ReferenceEntry.tsx +7 -5
  31. package/src/components/resource/panels/ResourceInfoPanel.tsx +8 -6
  32. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +12 -12
  33. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +1 -0
  34. package/src/features/resource-viewer/__tests__/AnnotationCreationPending.test.tsx +1 -1
  35. package/src/features/resource-viewer/__tests__/AnnotationDeletionIntegration.test.tsx +4 -4
  36. package/src/features/resource-viewer/__tests__/AnnotationProgressDismissal.test.tsx +5 -10
  37. package/src/features/resource-viewer/__tests__/BindFlowIntegration.test.tsx +23 -54
  38. package/src/features/resource-viewer/__tests__/DetectionFlowBug.test.tsx +6 -6
  39. package/src/features/resource-viewer/__tests__/DetectionFlowIntegration.test.tsx +7 -19
  40. package/src/features/resource-viewer/__tests__/ToastNotifications.test.tsx +1 -1
  41. package/src/features/resource-viewer/__tests__/YieldFlowIntegration.test.tsx +18 -44
  42. package/src/features/resource-viewer/__tests__/annotation-progress-flow.test.tsx +6 -6
  43. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +24 -26
  44. package/dist/TranslationManager-CudgH3gw.d.mts +0 -107
  45. package/dist/chunk-BQJWOK4C.mjs.map +0 -1
  46. package/dist/chunk-HNZOXH4L.mjs.map +0 -1
  47. package/dist/chunk-OL5UST25.mjs +0 -413
  48. package/dist/chunk-OL5UST25.mjs.map +0 -1
  49. /package/dist/{PdfAnnotationCanvas.client-CW6SKH2U.mjs.map → PdfAnnotationCanvas.client-CHDCGQBR.mjs.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
4
4
  "description": "React components and hooks for Semiont",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.mts",
@@ -64,7 +64,8 @@
64
64
  "test:components:modals:keyboard": "NODE_OPTIONS=--max-old-space-size=8192 vitest run src/components/modals/__tests__/SearchModal.keyboard.test.tsx",
65
65
  "test:components:modals:accessibility": "NODE_OPTIONS=--max-old-space-size=8192 vitest run src/components/modals/__tests__/SearchModal.accessibility.test.tsx",
66
66
  "test:components:modals:visual": "NODE_OPTIONS=--max-old-space-size=8192 vitest run src/components/modals/__tests__/SearchModal.visual.test.tsx",
67
- "test:components:modals": "npm run test:components:modals:basic && npm run test:components:modals:keyboard && npm run test:components:modals:accessibility && npm run test:components:modals:visual",
67
+ "test:components:modals:auth": "NODE_OPTIONS=--max-old-space-size=8192 vitest run src/components/modals/__tests__/SessionExpiredModal.test.tsx src/components/modals/__tests__/PermissionDeniedModal.test.tsx",
68
+ "test:components:modals": "npm run test:components:modals:basic && npm run test:components:modals:keyboard && npm run test:components:modals:accessibility && npm run test:components:modals:visual && npm run test:components:modals:auth",
68
69
  "test:components:other": "NODE_OPTIONS=--max-old-space-size=8192 vitest run src/components/annotation src/components/Button src/components/resource/__tests__ src/components/__tests__",
69
70
  "test:features": "NODE_OPTIONS=--max-old-space-size=8192 vitest run src/features",
70
71
  "test:other": "NODE_OPTIONS=--max-old-space-size=8192 vitest run src/contexts src/hooks src/lib src/assets",
@@ -103,6 +104,7 @@
103
104
  },
104
105
  "dependencies": {
105
106
  "@semiont/api-client": "*",
106
- "@semiont/core": "*"
107
+ "@semiont/core": "*",
108
+ "react-error-boundary": "^4.1.2"
107
109
  }
108
110
  }
@@ -0,0 +1,95 @@
1
+ import React from 'react';
2
+ import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
3
+
4
+ interface ProtectedErrorBoundaryProps {
5
+ children: React.ReactNode;
6
+ /**
7
+ * Values that, when any change, reset the boundary back to its non-error
8
+ * state. Apps typically pass `[location.pathname]` so navigating away from
9
+ * a crashed page automatically recovers.
10
+ */
11
+ resetKeys?: unknown[];
12
+ }
13
+
14
+ /**
15
+ * Error boundary for protected (authenticated) routes.
16
+ *
17
+ * Catches unexpected render-time crashes inside the protected tree and
18
+ * shows a generic "something went wrong" fallback with a refresh option.
19
+ *
20
+ * NOT auth-specific. Auth state changes (sign-in, sign-out, expiry) flow
21
+ * through the KnowledgeBaseSession context, not exceptions — so this
22
+ * boundary will never catch an "auth error" in normal operation. Its job
23
+ * is purely to keep a render bug from blanking the screen.
24
+ *
25
+ * The optional `resetKeys` prop lets callers wire automatic recovery on
26
+ * navigation (e.g. `resetKeys={[location.pathname]}`).
27
+ */
28
+ export function ProtectedErrorBoundary({
29
+ children,
30
+ resetKeys,
31
+ }: ProtectedErrorBoundaryProps) {
32
+ return (
33
+ <ErrorBoundary
34
+ FallbackComponent={ProtectedErrorFallback}
35
+ onError={(error, info) => {
36
+ if (process.env.NODE_ENV === 'development') {
37
+ console.error('ProtectedErrorBoundary caught:', error, info);
38
+ }
39
+ }}
40
+ {...(resetKeys && { resetKeys })}
41
+ >
42
+ {children}
43
+ </ErrorBoundary>
44
+ );
45
+ }
46
+
47
+ function ProtectedErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
48
+ return (
49
+ <div className="min-h-[400px] flex items-center justify-center p-4">
50
+ <div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
51
+ <div className="flex items-center gap-3 mb-4">
52
+ <div className="flex-shrink-0 w-10 h-10 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
53
+ <svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
54
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
55
+ </svg>
56
+ </div>
57
+ <h2 className="text-xl font-semibold text-gray-900 dark:text-white">
58
+ Something went wrong
59
+ </h2>
60
+ </div>
61
+
62
+ <p className="text-gray-600 dark:text-gray-300 mb-6">
63
+ An unexpected error occurred. Try again, or refresh the page.
64
+ </p>
65
+
66
+ {process.env.NODE_ENV === 'development' && (
67
+ <details className="mb-4">
68
+ <summary className="text-sm text-gray-500 cursor-pointer hover:text-gray-700 dark:hover:text-gray-300">
69
+ Error details (development only)
70
+ </summary>
71
+ <pre className="mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded-sm overflow-auto">
72
+ {error.message}
73
+ {error.stack}
74
+ </pre>
75
+ </details>
76
+ )}
77
+
78
+ <div className="flex gap-3">
79
+ <button
80
+ onClick={resetErrorBoundary}
81
+ className="flex-1 px-4 py-2 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
82
+ >
83
+ Try Again
84
+ </button>
85
+ <button
86
+ onClick={() => window.location.reload()}
87
+ className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
88
+ >
89
+ Refresh Page
90
+ </button>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ProtectedErrorBoundary Tests
3
+ *
4
+ * The boundary catches render-time crashes inside the protected tree and
5
+ * shows a generic "something went wrong" fallback. It is NOT auth-specific.
6
+ * Auth state changes flow through context, not exceptions.
7
+ *
8
+ * Implementation: thin wrapper around `react-error-boundary`'s `ErrorBoundary`,
9
+ * with a custom fallback and an optional `resetKeys` prop for navigation-based
10
+ * auto-recovery.
11
+ */
12
+
13
+ import React from 'react';
14
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
15
+ import { render, screen, fireEvent } from '@testing-library/react';
16
+ import '@testing-library/jest-dom';
17
+
18
+ import { ProtectedErrorBoundary } from '../ProtectedErrorBoundary';
19
+
20
+ function ThrowOnRender({ message }: { message: string }): React.ReactElement {
21
+ throw new Error(message);
22
+ }
23
+
24
+ /**
25
+ * A child that throws on first render but stops throwing if `shouldThrow`
26
+ * becomes false. Used to test boundary reset behavior.
27
+ */
28
+ function ThrowOnce({ shouldThrow }: { shouldThrow: boolean }): React.ReactElement {
29
+ if (shouldThrow) throw new Error('boom');
30
+ return <div data-testid="recovered">recovered</div>;
31
+ }
32
+
33
+ describe('ProtectedErrorBoundary', () => {
34
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
35
+
36
+ beforeEach(() => {
37
+ // React logs caught render errors to console.error in development;
38
+ // suppress to keep test output clean.
39
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
40
+ });
41
+
42
+ afterEach(() => {
43
+ consoleErrorSpy.mockRestore();
44
+ });
45
+
46
+ describe('happy path', () => {
47
+ it('renders children unchanged when no error', () => {
48
+ render(
49
+ <ProtectedErrorBoundary>
50
+ <div data-testid="child">protected content</div>
51
+ </ProtectedErrorBoundary>
52
+ );
53
+
54
+ expect(screen.getByTestId('child')).toBeInTheDocument();
55
+ expect(screen.getByText('protected content')).toBeInTheDocument();
56
+ expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
57
+ });
58
+ });
59
+
60
+ describe('on render error', () => {
61
+ it('shows the generic fallback heading', () => {
62
+ render(
63
+ <ProtectedErrorBoundary>
64
+ <ThrowOnRender message="boom" />
65
+ </ProtectedErrorBoundary>
66
+ );
67
+
68
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
69
+ });
70
+
71
+ it('does NOT show "Authentication Error" — the boundary is not auth-themed', () => {
72
+ // Throw an error whose message contains the word "session" — the old
73
+ // AuthErrorBoundary substring-matched on this and switched its UI to
74
+ // an auth-flavored fallback. The new boundary must not.
75
+ render(
76
+ <ProtectedErrorBoundary>
77
+ <ThrowOnRender message="session blew up" />
78
+ </ProtectedErrorBoundary>
79
+ );
80
+
81
+ expect(screen.queryByText(/Authentication Error/i)).not.toBeInTheDocument();
82
+ expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument();
83
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
84
+ });
85
+
86
+ it('renders both a Try Again and a Refresh Page button', () => {
87
+ render(
88
+ <ProtectedErrorBoundary>
89
+ <ThrowOnRender message="boom" />
90
+ </ProtectedErrorBoundary>
91
+ );
92
+
93
+ expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
94
+ expect(screen.getByRole('button', { name: /refresh page/i })).toBeInTheDocument();
95
+ });
96
+
97
+ it('calls window.location.reload when the Refresh Page button is clicked', () => {
98
+ const originalLocation = window.location;
99
+ const reload = vi.fn();
100
+ Object.defineProperty(window, 'location', {
101
+ value: { ...originalLocation, reload },
102
+ writable: true,
103
+ configurable: true,
104
+ });
105
+
106
+ try {
107
+ render(
108
+ <ProtectedErrorBoundary>
109
+ <ThrowOnRender message="boom" />
110
+ </ProtectedErrorBoundary>
111
+ );
112
+
113
+ fireEvent.click(screen.getByRole('button', { name: /refresh page/i }));
114
+ expect(reload).toHaveBeenCalled();
115
+ } finally {
116
+ Object.defineProperty(window, 'location', {
117
+ value: originalLocation,
118
+ writable: true,
119
+ configurable: true,
120
+ });
121
+ }
122
+ });
123
+
124
+ it('Try Again resets the boundary so children re-render', () => {
125
+ // The child reads `shouldThrow` from a closed-over variable that we
126
+ // flip between the initial mount and the click. Using an external
127
+ // flag (rather than counting renders) sidesteps React 18's
128
+ // strict-mode double-invocation of component bodies.
129
+ let shouldThrow = true;
130
+ function MaybeThrow(): React.ReactElement {
131
+ if (shouldThrow) throw new Error('boom');
132
+ return <div data-testid="recovered">recovered</div>;
133
+ }
134
+
135
+ render(
136
+ <ProtectedErrorBoundary>
137
+ <MaybeThrow />
138
+ </ProtectedErrorBoundary>
139
+ );
140
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
141
+
142
+ // Stop throwing so the next render succeeds
143
+ shouldThrow = false;
144
+ fireEvent.click(screen.getByRole('button', { name: /try again/i }));
145
+
146
+ expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
147
+ expect(screen.getByTestId('recovered')).toBeInTheDocument();
148
+ });
149
+ });
150
+
151
+ describe('resetKeys', () => {
152
+ it('auto-resets when a resetKey changes (simulating navigation)', () => {
153
+ function Harness({ pathname, shouldThrow }: { pathname: string; shouldThrow: boolean }) {
154
+ return (
155
+ <ProtectedErrorBoundary resetKeys={[pathname]}>
156
+ <ThrowOnce shouldThrow={shouldThrow} />
157
+ </ProtectedErrorBoundary>
158
+ );
159
+ }
160
+
161
+ const { rerender } = render(<Harness pathname="/know" shouldThrow={true} />);
162
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
163
+
164
+ // Simulate navigation to a different path AND a render that no longer throws
165
+ rerender(<Harness pathname="/admin" shouldThrow={false} />);
166
+
167
+ // Boundary auto-recovers because the resetKey changed
168
+ expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
169
+ expect(screen.getByTestId('recovered')).toBeInTheDocument();
170
+ });
171
+
172
+ it('logs the error to console.error in development', () => {
173
+ // Vitest's NODE_ENV defaults to "test", but the boundary's
174
+ // componentDidCatch logs only when NODE_ENV === 'development'.
175
+ // Force it for this test.
176
+ const originalEnv = process.env.NODE_ENV;
177
+ process.env.NODE_ENV = 'development';
178
+
179
+ try {
180
+ render(
181
+ <ProtectedErrorBoundary>
182
+ <ThrowOnRender message="instrumented boom" />
183
+ </ProtectedErrorBoundary>
184
+ );
185
+
186
+ // Multiple console.error calls happen (React's own logs, plus ours).
187
+ // Look for the boundary's prefix specifically.
188
+ const ourCall = consoleErrorSpy.mock.calls.find(
189
+ call => typeof call[0] === 'string' && call[0].includes('ProtectedErrorBoundary caught:')
190
+ );
191
+ expect(ourCall).toBeDefined();
192
+ } finally {
193
+ process.env.NODE_ENV = originalEnv;
194
+ }
195
+ });
196
+ });
197
+ });
@@ -0,0 +1,140 @@
1
+ 'use client';
2
+
3
+ import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
4
+ import { useKnowledgeBaseSession } from '../../contexts/KnowledgeBaseSessionContext';
5
+
6
+ /**
7
+ * Modal that surfaces when a 403 forbidden error is reported via
8
+ * `notifyPermissionDenied` (called from QueryCache.onError).
9
+ *
10
+ * Reads `permissionDeniedAt` and `permissionDeniedMessage` from
11
+ * KnowledgeBaseSessionContext. The provider clears the flag when the user
12
+ * dismisses the modal.
13
+ *
14
+ * Must be mounted inside KnowledgeBaseSessionProvider.
15
+ */
16
+ export function PermissionDeniedModal() {
17
+ const {
18
+ permissionDeniedAt,
19
+ permissionDeniedMessage,
20
+ acknowledgePermissionDenied,
21
+ } = useKnowledgeBaseSession();
22
+ const showModal = permissionDeniedAt !== null;
23
+ const message = permissionDeniedMessage ?? 'You do not have permission to perform this action.';
24
+
25
+ const handleGoBack = () => {
26
+ acknowledgePermissionDenied();
27
+ window.history.back();
28
+ };
29
+
30
+ const handleGoHome = () => {
31
+ acknowledgePermissionDenied();
32
+ window.location.href = '/';
33
+ };
34
+
35
+ const handleSwitchAccount = () => {
36
+ acknowledgePermissionDenied();
37
+ window.location.href = `/auth/connect?callbackUrl=${encodeURIComponent(window.location.pathname)}`;
38
+ };
39
+
40
+ return (
41
+ <Transition appear show={showModal}>
42
+ <Dialog as="div" className="semiont-modal" onClose={handleGoBack}>
43
+ <TransitionChild
44
+ enter="ease-out duration-200"
45
+ enterFrom="opacity-0"
46
+ enterTo="opacity-100"
47
+ leave="ease-in duration-150"
48
+ leaveFrom="opacity-100"
49
+ leaveTo="opacity-0"
50
+ >
51
+ <div className="semiont-modal__backdrop" />
52
+ </TransitionChild>
53
+
54
+ <div className="semiont-modal__container">
55
+ <div className="semiont-modal__wrapper">
56
+ <TransitionChild
57
+ enter="ease-out duration-200"
58
+ enterFrom="opacity-0 scale-95"
59
+ enterTo="opacity-100 scale-100"
60
+ leave="ease-in duration-150"
61
+ leaveFrom="opacity-100 scale-100"
62
+ leaveTo="opacity-0 scale-95"
63
+ >
64
+ <DialogPanel className="semiont-modal__panel semiont-modal__panel--medium">
65
+ <div className="semiont-modal__icon-wrapper">
66
+ <div className="semiont-modal__icon">
67
+ <svg style={{ width: '1.5rem', height: '1.5rem', color: 'var(--semiont-color-amber-600, #d97706)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
68
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
69
+ </svg>
70
+ </div>
71
+ </div>
72
+
73
+ <div className="semiont-modal__content">
74
+ <DialogTitle className="semiont-modal__title semiont-modal__title--centered">
75
+ Access Denied
76
+ </DialogTitle>
77
+ <p className="semiont-modal__description">
78
+ {message}
79
+ </p>
80
+ </div>
81
+
82
+ <div style={{
83
+ padding: '0.75rem',
84
+ backgroundColor: 'var(--semiont-bg-secondary)',
85
+ borderRadius: 'var(--semiont-radius-md, 0.375rem)',
86
+ border: '1px solid var(--semiont-border-primary)',
87
+ fontSize: 'var(--semiont-text-sm, 0.875rem)',
88
+ marginBottom: '1rem',
89
+ }}>
90
+ <p style={{ color: 'var(--semiont-text-secondary)', marginBottom: '0.5rem' }}>
91
+ This could be because:
92
+ </p>
93
+ <ul style={{
94
+ listStyle: 'disc',
95
+ listStylePosition: 'inside',
96
+ color: 'var(--semiont-text-tertiary)',
97
+ display: 'flex',
98
+ flexDirection: 'column',
99
+ gap: '0.25rem',
100
+ }}>
101
+ <li>You don't have the required permissions</li>
102
+ <li>The resource is restricted to specific users or teams</li>
103
+ <li>Your account type doesn't include this feature</li>
104
+ </ul>
105
+ </div>
106
+
107
+ <div className="semiont-modal__actions">
108
+ <button
109
+ type="button"
110
+ onClick={handleGoBack}
111
+ className="semiont-button--primary semiont-button--flex"
112
+ >
113
+ Go Back
114
+ </button>
115
+ <button
116
+ type="button"
117
+ onClick={handleGoHome}
118
+ className="semiont-button--secondary semiont-button--flex"
119
+ >
120
+ Go to Home
121
+ </button>
122
+ </div>
123
+ <div className="semiont-modal__actions" style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid var(--semiont-border-primary)' }}>
124
+ <button
125
+ type="button"
126
+ onClick={handleSwitchAccount}
127
+ className="semiont-button--secondary semiont-button--flex"
128
+ style={{ fontSize: 'var(--semiont-text-sm, 0.875rem)' }}
129
+ >
130
+ Switch Account
131
+ </button>
132
+ </div>
133
+ </DialogPanel>
134
+ </TransitionChild>
135
+ </div>
136
+ </div>
137
+ </Dialog>
138
+ </Transition>
139
+ );
140
+ }
@@ -144,18 +144,19 @@ export function ReferenceWizardModal({
144
144
  }, []);
145
145
 
146
146
  const handleSearchSubmit = useCallback((config: SearchConfig) => {
147
- if (!annotationId || !context) return;
147
+ if (!annotationId || !context || !resourceId) return;
148
148
  setIsSearching(true);
149
149
  const contextWithHint = userHint ? { ...context, userHint } : context;
150
150
  eventBus.get('match:search-requested').next({
151
151
  correlationId: crypto.randomUUID(),
152
+ resourceId,
152
153
  referenceId: annotationId,
153
154
  context: contextWithHint,
154
155
  limit: config.limit,
155
156
  useSemanticScoring: config.useSemanticScoring,
156
157
  });
157
158
  // Stay on configure-search until results arrive (subscription above handles transition)
158
- }, [annotationId, context, eventBus, userHint]);
159
+ }, [annotationId, resourceId, context, eventBus, userHint]);
159
160
 
160
161
  const handleGenerateSubmit = useCallback((config: GenerationConfig) => {
161
162
  if (!annotationId) return;
@@ -0,0 +1,101 @@
1
+ 'use client';
2
+
3
+ import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
4
+ import { useKnowledgeBaseSession } from '../../contexts/KnowledgeBaseSessionContext';
5
+
6
+ /**
7
+ * Modal that surfaces when the active KB's session expires (a 401 from
8
+ * either the provider's own JWT validation or from any React Query call
9
+ * via the QueryCache.onError handler).
10
+ *
11
+ * Reads `sessionExpiredAt` from KnowledgeBaseSessionContext. When the user
12
+ * dismisses the modal, the provider clears the flag.
13
+ *
14
+ * Must be mounted inside KnowledgeBaseSessionProvider.
15
+ */
16
+ export function SessionExpiredModal() {
17
+ const {
18
+ sessionExpiredAt,
19
+ sessionExpiredMessage,
20
+ acknowledgeSessionExpired,
21
+ } = useKnowledgeBaseSession();
22
+ const showModal = sessionExpiredAt !== null;
23
+
24
+ const handleSignIn = () => {
25
+ acknowledgeSessionExpired();
26
+ window.location.href = `/auth/connect?callbackUrl=${encodeURIComponent(window.location.pathname)}`;
27
+ };
28
+
29
+ const handleClose = () => {
30
+ acknowledgeSessionExpired();
31
+ window.location.href = '/';
32
+ };
33
+
34
+ return (
35
+ <Transition appear show={showModal}>
36
+ <Dialog as="div" className="semiont-modal" onClose={handleClose}>
37
+ <TransitionChild
38
+ enter="ease-out duration-200"
39
+ enterFrom="opacity-0"
40
+ enterTo="opacity-100"
41
+ leave="ease-in duration-150"
42
+ leaveFrom="opacity-100"
43
+ leaveTo="opacity-0"
44
+ >
45
+ <div className="semiont-modal__backdrop" />
46
+ </TransitionChild>
47
+
48
+ <div className="semiont-modal__container">
49
+ <div className="semiont-modal__wrapper">
50
+ <TransitionChild
51
+ enter="ease-out duration-200"
52
+ enterFrom="opacity-0 scale-95"
53
+ enterTo="opacity-100 scale-100"
54
+ leave="ease-in duration-150"
55
+ leaveFrom="opacity-100 scale-100"
56
+ leaveTo="opacity-0 scale-95"
57
+ >
58
+ <DialogPanel className="semiont-modal__panel semiont-modal__panel--medium">
59
+ <div className="semiont-modal__icon-wrapper">
60
+ <div className="semiont-modal__icon" style={{
61
+ background: 'linear-gradient(to bottom right, var(--semiont-color-red-100, #fee2e2), var(--semiont-color-red-300, #fca5a5))',
62
+ }}>
63
+ <svg style={{ width: '1.5rem', height: '1.5rem', color: 'var(--semiont-color-red-600, #dc2626)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
64
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
65
+ </svg>
66
+ </div>
67
+ </div>
68
+
69
+ <div className="semiont-modal__content">
70
+ <DialogTitle className="semiont-modal__title semiont-modal__title--centered">
71
+ Session Expired
72
+ </DialogTitle>
73
+ <p className="semiont-modal__description">
74
+ {sessionExpiredMessage ?? 'Your session has expired for security reasons. Please sign in again to continue working.'}
75
+ </p>
76
+ </div>
77
+
78
+ <div className="semiont-modal__actions">
79
+ <button
80
+ type="button"
81
+ onClick={handleClose}
82
+ className="semiont-button--secondary semiont-button--flex"
83
+ >
84
+ Go to Home
85
+ </button>
86
+ <button
87
+ type="button"
88
+ onClick={handleSignIn}
89
+ className="semiont-button--primary semiont-button--flex"
90
+ >
91
+ Sign In Again
92
+ </button>
93
+ </div>
94
+ </DialogPanel>
95
+ </TransitionChild>
96
+ </div>
97
+ </div>
98
+ </Dialog>
99
+ </Transition>
100
+ );
101
+ }