@robosystems/core 0.11.2 → 0.11.4

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.
@@ -10,6 +10,13 @@ export interface TokenData {
10
10
  refreshThreshold?: number;
11
11
  }
12
12
  export type TokenStatus = 'valid' | 'warning' | 'expired';
13
+ /**
14
+ * How long past its local expiry a token is kept. The API renews a
15
+ * recently-expired token at `/v1/auth/refresh` within this window, so the
16
+ * token must survive locally until then — for the refresh call only. Data
17
+ * calls stop sending it at expiry.
18
+ */
19
+ export declare const TOKEN_REFRESH_GRACE_MS: number;
13
20
  /**
14
21
  * Store JWT token in localStorage
15
22
  * @param token - The JWT token from login response
@@ -56,3 +63,8 @@ export declare function handleAuthResponse(response: any): void;
56
63
  * @returns Valid token or null if expired
57
64
  */
58
65
  export declare function getValidToken(): Promise<string | null>;
66
+ /**
67
+ * The token to present to `/v1/auth/refresh`: the stored token while it is
68
+ * unexpired or within the server's refresh grace after expiry, else null.
69
+ */
70
+ export declare function getRefreshToken(): string | null;
@@ -7,6 +7,24 @@
7
7
  const TOKEN_KEY = 'robosystems_jwt_token';
8
8
  const TOKEN_EXPIRY_KEY = 'robosystems_jwt_expiry';
9
9
  const TOKEN_THRESHOLD_KEY = 'robosystems_jwt_threshold';
10
+ /**
11
+ * How long past its local expiry a token is kept. The API renews a
12
+ * recently-expired token at `/v1/auth/refresh` within this window, so the
13
+ * token must survive locally until then — for the refresh call only. Data
14
+ * calls stop sending it at expiry.
15
+ */
16
+ export const TOKEN_REFRESH_GRACE_MS = 5 * 60 * 1000;
17
+ function readExpiry() {
18
+ const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY);
19
+ if (!expiryStr)
20
+ return null;
21
+ const expiry = parseInt(expiryStr, 10);
22
+ return Number.isFinite(expiry) ? expiry : null;
23
+ }
24
+ /** Past expiry *and* the refresh grace: nothing can use it any more. */
25
+ function isPastGrace(expiry) {
26
+ return expiry !== null && Date.now() > expiry + TOKEN_REFRESH_GRACE_MS;
27
+ }
10
28
  /**
11
29
  * Store JWT token in localStorage
12
30
  * @param token - The JWT token from login response
@@ -38,16 +56,15 @@ export function getToken() {
38
56
  return null;
39
57
  try {
40
58
  const token = localStorage.getItem(TOKEN_KEY);
41
- const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY);
42
59
  if (!token)
43
60
  return null;
44
- // Check if token is expired
45
- if (expiryStr) {
46
- const expiry = parseInt(expiryStr, 10);
47
- if (Date.now() > expiry) {
61
+ // An expired token is not sent on data calls, but it is kept until the
62
+ // refresh grace runs out so `getRefreshToken` can still renew it.
63
+ const expiry = readExpiry();
64
+ if (expiry !== null && Date.now() > expiry) {
65
+ if (isPastGrace(expiry))
48
66
  clearToken();
49
- return null;
50
- }
67
+ return null;
51
68
  }
52
69
  return token;
53
70
  }
@@ -152,20 +169,29 @@ export function handleAuthResponse(response) {
152
169
  * @returns Valid token or null if expired
153
170
  */
154
171
  export async function getValidToken() {
155
- const token = getToken();
156
- if (!token)
172
+ return getToken();
173
+ }
174
+ /**
175
+ * The token to present to `/v1/auth/refresh`: the stored token while it is
176
+ * unexpired or within the server's refresh grace after expiry, else null.
177
+ */
178
+ export function getRefreshToken() {
179
+ if (typeof window === 'undefined')
157
180
  return null;
158
- // Just check if token is expired - let backend handle refresh
159
- const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY);
160
- if (expiryStr) {
161
- const expiry = parseInt(expiryStr, 10);
162
- if (Date.now() > expiry) {
163
- // Token is expired, clear it and let AuthProvider handle re-auth
181
+ try {
182
+ const token = localStorage.getItem(TOKEN_KEY);
183
+ if (!token)
184
+ return null;
185
+ if (isPastGrace(readExpiry())) {
164
186
  clearToken();
165
187
  return null;
166
188
  }
189
+ return token;
190
+ }
191
+ catch (error) {
192
+ console.error('[TokenStorage] Failed to retrieve refresh token:', error);
193
+ return null;
167
194
  }
168
- return token;
169
195
  }
170
196
  /**
171
197
  * Security event logging (for monitoring)
@@ -104,6 +104,7 @@ export interface AuthContextType {
104
104
  register: (email: string, password: string, name?: string) => Promise<AuthUser>;
105
105
  logout: (reason?: string, options?: {
106
106
  redirectTo?: string;
107
+ skipServerLogout?: boolean;
107
108
  }) => Promise<void>;
108
109
  refreshUser: () => Promise<AuthUser | null>;
109
110
  refreshSession: (force?: boolean) => Promise<void>;
@@ -61,6 +61,14 @@ export function ConsoleContent({ config }) {
61
61
  const { state: graphState } = useGraphContext();
62
62
  const graphId = graphState.currentGraphId;
63
63
  const streamingQuery = useStreamingQuery();
64
+ // The graph the console is on right now, readable after an await: an
65
+ // answer for a graph the user has since left must not be printed (or
66
+ // recorded as history) under the new graph.
67
+ const currentGraphRef = useRef(graphId);
68
+ currentGraphRef.current = graphId;
69
+ // Moves on at every graph switch, so a switch away and back (A → B → A)
70
+ // still retires a request made before it.
71
+ const graphEpochRef = useRef(0);
64
72
  // Terminal state
65
73
  const [terminalMessages, setTerminalMessages] = useState([]);
66
74
  const [commandInput, setCommandInput] = useState('');
@@ -190,16 +198,21 @@ export function ConsoleContent({ config }) {
190
198
  if (!graphId)
191
199
  return;
192
200
  if (previousGraphId.current && previousGraphId.current !== graphId) {
193
- if (streamingQuery.isStreaming) {
194
- streamingQuery.cancelQuery();
195
- }
201
+ const fromGraphId = previousGraphId.current;
202
+ previousGraphId.current = graphId;
203
+ graphEpochRef.current++;
204
+ // Stop any stream still running for the previous graph; its rows must
205
+ // not land under the new graph's banner.
206
+ streamingQuery.cancelQuery();
207
+ operatorProgressMessageId.current = null;
208
+ setOperatorProgress({ isRunning: false, message: '' });
196
209
  setTerminalMessages([]);
197
210
  // A follow-up on the new graph must not resolve against the previous
198
211
  // graph's answers.
199
212
  conversationRef.current = [];
200
213
  setCurrentQueryStartTime(null);
201
214
  addSystemMessage(`═══════════════════════════════════════════════════════════════\n` +
202
- `${config.welcome.contextLabel} context changed: ${previousGraphId.current} → ${graphId}\n` +
215
+ `${config.welcome.contextLabel} context changed: ${fromGraphId} → ${graphId}\n` +
203
216
  `═══════════════════════════════════════════════════════════════\n\n` +
204
217
  `Console has been reset for the new ${config.welcome.contextLabel.toLowerCase()} context.\n` +
205
218
  `All queries will now execute against: ${graphId}\n\n` +
@@ -322,6 +335,10 @@ export function ConsoleContent({ config }) {
322
335
  return;
323
336
  }
324
337
  const startTime = Date.now();
338
+ const requestGraphId = graphId;
339
+ const requestEpoch = graphEpochRef.current;
340
+ const isStale = () => currentGraphRef.current !== requestGraphId ||
341
+ graphEpochRef.current !== requestEpoch;
325
342
  try {
326
343
  const { clients } = await import('@robosystems/client/clients');
327
344
  const history = conversationRef.current;
@@ -331,6 +348,8 @@ export function ConsoleContent({ config }) {
331
348
  mode: 'standard' }, (history.length > 0 ? { history } : {})), {
332
349
  mode: 'auto',
333
350
  onProgress: (message, percentage) => {
351
+ if (isStale())
352
+ return;
334
353
  setOperatorProgress({
335
354
  isRunning: true,
336
355
  message,
@@ -338,6 +357,11 @@ export function ConsoleContent({ config }) {
338
357
  });
339
358
  },
340
359
  });
360
+ // The user switched graphs while the operator ran: the reset already
361
+ // cleared the console, so drop the answer rather than print it (and
362
+ // record it as history) under the new graph.
363
+ if (isStale())
364
+ return;
341
365
  setOperatorProgress({ isRunning: false, message: '' });
342
366
  // The API reports operator failures (credit pre-flight, timeouts,
343
367
  // unbilled calls) as HTTP 200 with `error_details` set and an
@@ -419,6 +443,8 @@ export function ConsoleContent({ config }) {
419
443
  }
420
444
  }
421
445
  catch (error) {
446
+ if (isStale())
447
+ return;
422
448
  setOperatorProgress({ isRunning: false, message: '' });
423
449
  const errorMessage = error.message ||
424
450
  ((_a = error.data) === null || _a === void 0 ? void 0 : _a.detail) ||
@@ -206,9 +206,11 @@ LIMIT 20`,
206
206
  };
207
207
  // ── RoboInvestor entity graph (portfolios / positions / securities) ──────────
208
208
  // Data: Entity -> Portfolio -> Position -> Security, and Entity -> Security
209
- // (issuance). Position carries cost_basis and current_value, so gains and
210
- // allocation are computed in Cypher. Trade/Benchmark/MarketData exist in the
211
- // schema but aren't populated yet — keep examples off them.
209
+ // (issuance). Position carries cost_basis, current_value and currency, so
210
+ // gains and allocation are computed in Cypher. Sums count only marked
211
+ // positions (current_value set) and never add across currencies.
212
+ // Security.ticker, Trade, Benchmark and MarketData exist in the schema but
213
+ // aren't populated yet — keep examples off them.
212
214
  const ROBOINVESTOR_EXAMPLE_SET = {
213
215
  subtitle: 'AI analyst for your investment portfolios',
214
216
  description: 'Ask in plain English and an AI analyst queries your portfolios, positions, and securities — holdings, cost basis vs. current value, allocation, and unrealized gains. Every answer comes back with the data and the Cypher behind it.',
@@ -221,47 +223,55 @@ const ROBOINVESTOR_EXAMPLE_SET = {
221
223
  directQueryExamples: [
222
224
  'MATCH (p:Portfolio) RETURN p.name, p.strategy, p.base_currency',
223
225
  'MATCH (p:Portfolio)-[:PORTFOLIO_HAS_POSITION]->(pos:Position) RETURN p.name, count(pos) AS positions ORDER BY positions DESC',
224
- 'MATCH (s:Security) RETURN s.ticker, s.name, s.security_type LIMIT 20',
226
+ 'MATCH (s:Security) RETURN s.name, s.security_type, s.security_subtype LIMIT 20',
225
227
  ],
226
228
  sampleQueries: [
227
229
  {
228
230
  name: 'Portfolio value',
229
231
  query: `MATCH (p:Portfolio)-[:PORTFOLIO_HAS_POSITION]->(pos:Position)
232
+ WHERE pos.current_value IS NOT NULL
230
233
  RETURN p.name,
231
- count(pos) AS positions,
232
- sum(pos.cost_basis) AS total_cost,
233
- sum(pos.current_value) AS total_value
234
- ORDER BY total_value DESC`,
234
+ pos.currency AS currency,
235
+ count(pos) AS marked_positions,
236
+ sum(pos.current_value) AS total_value,
237
+ count(pos.cost_basis) AS positions_with_cost,
238
+ sum(pos.cost_basis) AS total_cost
239
+ ORDER BY p.name, currency`,
235
240
  },
236
241
  {
237
242
  name: 'Holdings by current value',
238
243
  query: `MATCH (p:Portfolio)-[:PORTFOLIO_HAS_POSITION]->(pos:Position)-[:POSITION_IN_SECURITY]->(s:Security)
239
- RETURN p.name, s.ticker, s.name, pos.quantity, pos.cost_basis, pos.current_value
240
- ORDER BY pos.current_value DESC
244
+ WHERE pos.current_value IS NOT NULL
245
+ RETURN p.name, s.name, pos.quantity, pos.currency AS currency,
246
+ pos.cost_basis, pos.current_value
247
+ ORDER BY currency, pos.current_value DESC
241
248
  LIMIT 25`,
242
249
  },
243
250
  {
244
251
  name: 'Unrealized gains',
245
252
  query: `MATCH (p:Portfolio)-[:PORTFOLIO_HAS_POSITION]->(pos:Position)-[:POSITION_IN_SECURITY]->(s:Security)
246
253
  WHERE pos.cost_basis IS NOT NULL AND pos.current_value IS NOT NULL
247
- RETURN s.ticker, s.name, pos.cost_basis, pos.current_value,
254
+ RETURN p.name, s.name, pos.currency AS currency,
255
+ pos.cost_basis, pos.current_value,
248
256
  pos.current_value - pos.cost_basis AS unrealized_gain
249
- ORDER BY unrealized_gain DESC
257
+ ORDER BY currency, unrealized_gain DESC
250
258
  LIMIT 20`,
251
259
  },
252
260
  {
253
261
  name: 'Allocation by security type',
254
262
  query: `MATCH (pos:Position)-[:POSITION_IN_SECURITY]->(s:Security)
255
- RETURN s.security_type,
256
- count(pos) AS positions,
263
+ WHERE pos.current_value IS NOT NULL
264
+ RETURN pos.currency AS currency,
265
+ s.security_type,
266
+ count(pos) AS marked_positions,
257
267
  sum(pos.current_value) AS total_value
258
- ORDER BY total_value DESC`,
268
+ ORDER BY currency, total_value DESC`,
259
269
  },
260
270
  {
261
271
  name: 'Securities issued by entity',
262
272
  query: `MATCH (e:Entity)-[:ENTITY_ISSUES_SECURITY]->(s:Security)
263
- RETURN e.name, s.ticker, s.name, s.security_type
264
- ORDER BY s.ticker
273
+ RETURN e.name, s.name, s.security_type, s.security_subtype
274
+ ORDER BY e.name, s.name
265
275
  LIMIT 25`,
266
276
  },
267
277
  ],
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import * as SDK from '@robosystems/client';
4
4
  import { Badge, Button, Card, Spinner } from 'flowbite-react';
5
5
  import { useCallback, useEffect, useState } from 'react';
@@ -9,9 +9,13 @@ import { useGraphContext } from '../../contexts/graph-context';
9
9
  import { useOrg } from '../../contexts/org-context';
10
10
  import { useServiceOfferings } from '../../contexts/service-offerings-context';
11
11
  import { useToast } from '../../hooks/use-toast';
12
+ import { unwrapSdk } from '../../lib/sdk-errors';
12
13
  export function ActiveSubscriptions({ onOpenConsole, onOpenUsage, onGettingStarted, onBackups, onBilling, onBrowse, onCancel, emptyState, }) {
13
14
  const [userSubscriptions, setUserSubscriptions] = useState([]);
14
15
  const [loading, setLoading] = useState(true);
16
+ // A failed subscription read must not render as "no subscriptions": that
17
+ // offers Subscribe on repositories the org already holds.
18
+ const [loadError, setLoadError] = useState(null);
15
19
  const { showError, ToastContainer } = useToast();
16
20
  const { currentOrg } = useOrg();
17
21
  const { offerings, isLoading: offeringsLoading } = useServiceOfferings();
@@ -23,11 +27,12 @@ export function ActiveSubscriptions({ onOpenConsole, onOpenUsage, onGettingStart
23
27
  }
24
28
  try {
25
29
  setLoading(true);
26
- const subscriptionsResponse = await SDK.listOrgSubscriptions({
30
+ setLoadError(null);
31
+ const subscriptions = unwrapSdk(await SDK.listOrgSubscriptions({
27
32
  path: { org_id: currentOrg.id },
28
- });
29
- if (subscriptionsResponse.data) {
30
- const repositorySubscriptions = (subscriptionsResponse.data || []).filter((sub) => sub.resource_type === 'repository');
33
+ }));
34
+ if (subscriptions) {
35
+ const repositorySubscriptions = (subscriptions || []).filter((sub) => sub.resource_type === 'repository');
31
36
  setUserSubscriptions(repositorySubscriptions);
32
37
  }
33
38
  else {
@@ -36,7 +41,11 @@ export function ActiveSubscriptions({ onOpenConsole, onOpenUsage, onGettingStart
36
41
  }
37
42
  catch (error) {
38
43
  console.error('Failed to load user subscriptions:', error);
39
- showError('Failed to load user subscriptions');
44
+ const message = error instanceof Error && error.message
45
+ ? `Failed to load subscriptions: ${error.message}`
46
+ : 'Failed to load subscriptions';
47
+ setLoadError(message);
48
+ showError(message);
40
49
  }
41
50
  finally {
42
51
  setLoading(false);
@@ -48,6 +57,9 @@ export function ActiveSubscriptions({ onOpenConsole, onOpenUsage, onGettingStart
48
57
  if (loading || offeringsLoading) {
49
58
  return (_jsx("div", { className: "flex min-h-[400px] items-center justify-center", children: _jsx(Spinner, { size: "xl" }) }));
50
59
  }
60
+ if (loadError) {
61
+ return (_jsxs("div", { className: "space-y-6", children: [_jsx(ToastContainer, {}), _jsx(Card, { children: _jsxs("div", { className: "flex flex-col items-center gap-4 py-8 text-center", children: [_jsx("p", { className: "text-zinc-700 dark:text-zinc-300", children: loadError }), _jsx(Button, { color: "gray", onClick: () => loadData(), children: "Try again" })] }) })] }));
62
+ }
51
63
  // Treat a period-end-canceled sub as "still active" until its access
52
64
  // window actually closes. Otherwise the row would disappear from this
53
65
  // view the moment the user clicks Cancel — even though the modal
@@ -9,10 +9,14 @@ import { useGraphContext } from '../../contexts/graph-context';
9
9
  import { useOrg } from '../../contexts/org-context';
10
10
  import { useServiceOfferings } from '../../contexts/service-offerings-context';
11
11
  import { useToast } from '../../hooks/use-toast';
12
+ import { unwrapSdk } from '../../lib/sdk-errors';
12
13
  import { useRepositorySubscription } from '../../task-monitoring/operationHooks';
13
14
  export function BrowseRepositories({ onSubscribed, onViewSubscriptions, showHeader = true, showInfoCard = true, }) {
14
15
  const [userSubscriptions, setUserSubscriptions] = useState([]);
15
16
  const [loading, setLoading] = useState(true);
17
+ // A failed subscription read must not render as "no subscriptions": that
18
+ // offers Subscribe on repositories the org already holds.
19
+ const [loadError, setLoadError] = useState(null);
16
20
  const [subscribing, setSubscribing] = useState(null);
17
21
  const [changingPlan, setChangingPlan] = useState(false);
18
22
  const [planChangeModal, setPlanChangeModal] = useState(null);
@@ -28,12 +32,13 @@ export function BrowseRepositories({ onSubscribed, onViewSubscriptions, showHead
28
32
  }
29
33
  try {
30
34
  setLoading(true);
35
+ setLoadError(null);
31
36
  // Load org subscriptions and filter for repositories
32
- const subscriptionsResponse = await SDK.listOrgSubscriptions({
37
+ const subscriptions = unwrapSdk(await SDK.listOrgSubscriptions({
33
38
  path: { org_id: currentOrg.id },
34
- });
35
- if (subscriptionsResponse.data) {
36
- const repositorySubscriptions = (subscriptionsResponse.data || []).filter((sub) => sub.resource_type === 'repository');
39
+ }));
40
+ if (subscriptions) {
41
+ const repositorySubscriptions = (subscriptions || []).filter((sub) => sub.resource_type === 'repository');
37
42
  setUserSubscriptions(repositorySubscriptions);
38
43
  }
39
44
  else {
@@ -42,7 +47,11 @@ export function BrowseRepositories({ onSubscribed, onViewSubscriptions, showHead
42
47
  }
43
48
  catch (error) {
44
49
  console.error('Failed to load user subscriptions:', error);
45
- showError('Failed to load user subscriptions');
50
+ const message = error instanceof Error && error.message
51
+ ? `Failed to load subscriptions: ${error.message}`
52
+ : 'Failed to load subscriptions';
53
+ setLoadError(message);
54
+ showError(message);
46
55
  }
47
56
  finally {
48
57
  setLoading(false);
@@ -131,6 +140,9 @@ export function BrowseRepositories({ onSubscribed, onViewSubscriptions, showHead
131
140
  if (loading || offeringsLoading) {
132
141
  return (_jsx("div", { className: "flex min-h-[400px] items-center justify-center", children: _jsx(Spinner, { size: "xl" }) }));
133
142
  }
143
+ if (loadError) {
144
+ return (_jsxs("div", { className: "space-y-6", children: [_jsx(ToastContainer, {}), _jsx(Card, { children: _jsxs("div", { className: "flex flex-col items-center gap-4 py-8 text-center", children: [_jsx("p", { className: "text-zinc-700 dark:text-zinc-300", children: loadError }), _jsx(Button, { color: "gray", onClick: () => loadData(), children: "Try again" })] }) })] }));
145
+ }
134
146
  // Check if user has any active subscriptions
135
147
  const hasActiveSubscriptions = userSubscriptions.some((sub) => sub.status === 'active');
136
148
  return (_jsxs("div", { className: "space-y-6", children: [_jsx(ToastContainer, {}), showHeader && (_jsxs("div", { className: "flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3 sm:gap-4", children: [_jsx("div", { className: "from-primary-500 to-secondary-600 shrink-0 rounded-lg bg-gradient-to-br p-2.5 sm:p-3", children: _jsx(HiGlobeAlt, { className: "h-6 w-6 text-white sm:h-8 sm:w-8" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "font-heading text-2xl font-bold break-words text-zinc-900 sm:text-3xl dark:text-zinc-100", children: "Browse Repositories" }), _jsx("p", { className: "mt-1 text-sm text-zinc-500 dark:text-zinc-400", children: "Subscribe to read-only graph databases containing curated data" })] })] }), hasActiveSubscriptions && onViewSubscriptions && (_jsx(Button, { onClick: onViewSubscriptions, color: "gray", children: "View My Subscriptions" }))] })), showInfoCard && (_jsx(Card, { children: _jsxs("div", { className: "flex items-start gap-4", children: [_jsx("div", { className: "bg-primary-100 dark:bg-primary-900/30 rounded-lg p-3", children: _jsx(HiInformationCircle, { className: "text-primary-600 dark:text-primary-400 h-6 w-6" }) }), _jsxs("div", { className: "flex-1", children: [_jsx("h3", { className: "font-heading mb-3 text-lg font-semibold text-zinc-900 dark:text-white", children: "How Shared Repositories Work" }), _jsxs("div", { className: "grid gap-3 sm:grid-cols-2 lg:grid-cols-4", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "bg-primary-500 mt-1 h-2 w-2 rounded-full" }), _jsx("span", { className: "text-sm text-zinc-600 dark:text-zinc-400", children: "Subscribe to gain read-only access to curated graph databases" })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "bg-primary-500 mt-1 h-2 w-2 rounded-full" }), _jsx("span", { className: "text-sm text-zinc-600 dark:text-zinc-400", children: "Credits are used for AI agent calls - queries are unlimited" })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "bg-primary-500 mt-1 h-2 w-2 rounded-full" }), _jsx("span", { className: "text-sm text-zinc-600 dark:text-zinc-400", children: "Generate API keys to access repositories via MCP tools in AI apps" })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "bg-primary-500 mt-1 h-2 w-2 rounded-full" }), _jsx("span", { className: "text-sm text-zinc-600 dark:text-zinc-400", children: "Different tiers offer varying credit limits and features" })] })] })] })] }) })), (offerings === null || offerings === void 0 ? void 0 : offerings.repositoryPlans) &&
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as SDK from '@robosystems/client';
4
4
  import { Alert, Card, Select, Spinner, TextInput, ToggleSwitch, } from 'flowbite-react';
5
- import { useCallback, useEffect, useState } from 'react';
5
+ import { useCallback, useEffect, useRef, useState } from 'react';
6
6
  import { HiChevronDown, HiChevronUp, HiSearch } from 'react-icons/hi';
7
7
  import { useIsRepository } from '../../components/RepositoryGuard';
8
8
  import { useGraphContext } from '../../contexts';
@@ -38,6 +38,10 @@ export function SearchContent({ config }) {
38
38
  const [expandedDocId, setExpandedDocId] = useState(null);
39
39
  const [sectionContent, setSectionContent] = useState(null);
40
40
  const [sectionLoading, setSectionLoading] = useState(false);
41
+ // Request counters: a response is applied only if no newer search (or
42
+ // section read), and no graph switch, has happened since it was sent.
43
+ const searchSeq = useRef(0);
44
+ const sectionSeq = useRef(0);
41
45
  // Filters visibility
42
46
  const [showFilters, setShowFilters] = useState((_b = config.showFilters) !== null && _b !== void 0 ? _b : false);
43
47
  const filters = (_c = config.filters) !== null && _c !== void 0 ? _c : { sourceType: true };
@@ -47,16 +51,24 @@ export function SearchContent({ config }) {
47
51
  setDocCount(null);
48
52
  return;
49
53
  }
54
+ let cancelled = false;
50
55
  SDK.listDocuments({ path: { graph_id: graphId } })
51
56
  .then((res) => {
52
- if (res.data) {
57
+ if (!cancelled && res.data) {
53
58
  setDocCount(res.data.total);
54
59
  }
55
60
  })
56
61
  .catch(() => { });
62
+ return () => {
63
+ cancelled = true;
64
+ };
57
65
  }, [graphId, isRepository]);
58
66
  // Reset when graph changes
59
67
  useEffect(() => {
68
+ searchSeq.current++;
69
+ sectionSeq.current++;
70
+ setLoading(false);
71
+ setSectionLoading(false);
60
72
  setResults([]);
61
73
  setTotal(0);
62
74
  setOffset(0);
@@ -68,6 +80,8 @@ export function SearchContent({ config }) {
68
80
  const handleSearch = useCallback(async (newOffset = 0) => {
69
81
  if (!graphId || !query.trim())
70
82
  return;
83
+ const seq = ++searchSeq.current;
84
+ sectionSeq.current++;
71
85
  setLoading(true);
72
86
  setError(null);
73
87
  setExpandedDocId(null);
@@ -95,6 +109,8 @@ export function SearchContent({ config }) {
95
109
  path: { graph_id: graphId },
96
110
  body: body,
97
111
  });
112
+ if (seq !== searchSeq.current)
113
+ return;
98
114
  if (res.data) {
99
115
  const data = res.data;
100
116
  setResults(data.hits);
@@ -107,16 +123,21 @@ export function SearchContent({ config }) {
107
123
  }
108
124
  }
109
125
  catch (_a) {
126
+ if (seq !== searchSeq.current)
127
+ return;
110
128
  setError('An error occurred while searching.');
111
129
  }
112
130
  finally {
113
- setLoading(false);
131
+ if (seq === searchSeq.current)
132
+ setLoading(false);
114
133
  }
115
134
  }, [graphId, query, sourceType, entity, formType, fiscalYear, semantic]);
116
135
  const handleExpand = async (docId) => {
136
+ const seq = ++sectionSeq.current;
117
137
  if (expandedDocId === docId) {
118
138
  setExpandedDocId(null);
119
139
  setSectionContent(null);
140
+ setSectionLoading(false);
120
141
  return;
121
142
  }
122
143
  if (!graphId)
@@ -128,15 +149,20 @@ export function SearchContent({ config }) {
128
149
  const res = await SDK.getDocumentSection({
129
150
  path: { graph_id: graphId, document_id: docId },
130
151
  });
152
+ if (seq !== sectionSeq.current)
153
+ return;
131
154
  if (res.data) {
132
155
  setSectionContent(res.data);
133
156
  }
134
157
  }
135
158
  catch (_a) {
159
+ if (seq !== sectionSeq.current)
160
+ return;
136
161
  setSectionContent(null);
137
162
  }
138
163
  finally {
139
- setSectionLoading(false);
164
+ if (seq === sectionSeq.current)
165
+ setSectionLoading(false);
140
166
  }
141
167
  };
142
168
  const hasFilters = filters.entity ||
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import * as SDK from '@robosystems/client';
4
- import { createContext, useCallback, useContext, useEffect, useState, } from 'react';
4
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react';
5
+ import { unwrapSdk } from '../lib/sdk-errors';
5
6
  // Factory function to create a typed graph context
6
7
  export function createGraphContext() {
7
8
  return createContext(null);
@@ -15,6 +16,9 @@ export function createGraphProvider(Context) {
15
16
  isLoading: false,
16
17
  error: null,
17
18
  });
19
+ // Latest graph list, readable synchronously from callbacks
20
+ const graphsRef = useRef(baseState.graphs);
21
+ graphsRef.current = baseState.graphs;
18
22
  // Transform state if needed
19
23
  const state = transformState ? transformState(baseState) : baseState;
20
24
  // Load all user graphs
@@ -22,8 +26,9 @@ export function createGraphProvider(Context) {
22
26
  setBaseState((prev) => (Object.assign(Object.assign({}, prev), { isLoading: true, error: null })));
23
27
  try {
24
28
  const response = await SDK.getGraphs();
25
- if (response.data) {
26
- const graphsData = response.data;
29
+ const data = unwrapSdk(response);
30
+ if (data) {
31
+ const graphsData = data;
27
32
  let graphs = graphsData.graphs || [];
28
33
  // Apply optional filter
29
34
  if (graphFilter) {
@@ -35,6 +40,9 @@ export function createGraphProvider(Context) {
35
40
  const validSelectedId = selectedGraphId && graphIds.includes(selectedGraphId)
36
41
  ? selectedGraphId
37
42
  : null;
43
+ // Visible to setCurrentGraph before the next render: a caller that
44
+ // awaits refreshGraphs() and then selects the new graph must find it.
45
+ graphsRef.current = graphs;
38
46
  setBaseState((prev) => {
39
47
  // Determine which graph should be selected
40
48
  let newCurrentGraphId = validSelectedId || (graphs.length > 0 ? graphs[0].graphId : null);
@@ -51,6 +59,9 @@ export function createGraphProvider(Context) {
51
59
  return Object.assign(Object.assign({}, prev), { graphs, currentGraphId: newCurrentGraphId, isLoading: false });
52
60
  });
53
61
  }
62
+ else {
63
+ throw new Error('Empty response from the graph list endpoint');
64
+ }
54
65
  }
55
66
  catch (error) {
56
67
  console.error('Failed to load graphs:', error);
@@ -60,21 +71,20 @@ export function createGraphProvider(Context) {
60
71
  // Set current graph with persistence
61
72
  const setCurrentGraph = useCallback(async (graphId) => {
62
73
  try {
63
- // Use functional setState to access current graphs and determine if repository
64
- let isUserGraph = false;
65
- setBaseState((prev) => {
66
- const graph = prev.graphs.find((g) => g.graphId === graphId);
67
- // Only treat as a user graph if found and NOT a repository
68
- isUserGraph = graph ? !graph.isRepository : false;
69
- return Object.assign(Object.assign({}, prev), { currentGraphId: graphId });
70
- });
74
+ // Decide from the current graph list, not inside a state updater:
75
+ // React may defer the updater, which left the flag unset and
76
+ // skipped the API call.
77
+ const graph = graphsRef.current.find((g) => g.graphId === graphId);
78
+ // Only treat as a user graph if found and NOT a repository
79
+ const isUserGraph = graph ? !graph.isRepository : false;
80
+ setBaseState((prev) => (Object.assign(Object.assign({}, prev), { currentGraphId: graphId })));
71
81
  // Only call selectGraph API for user graphs, not repositories
72
82
  // Repositories can be accessed but not "selected" in the backend
73
83
  // If graph is not found in local state, skip the API call (likely a repository)
74
84
  if (isUserGraph) {
75
- await SDK.selectGraph({
85
+ unwrapSdk(await SDK.selectGraph({
76
86
  path: { graph_id: graphId },
77
- });
87
+ }));
78
88
  }
79
89
  }
80
90
  catch (error) {