@useatlas/react 0.0.1 → 0.0.2
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.
- package/LICENSE +21 -0
- package/README.md +79 -2
- package/dist/{chunk-5SEVKHS5.cjs → chunk-35SCTKSW.js} +100 -7
- package/dist/chunk-35SCTKSW.js.map +1 -0
- package/dist/{chunk-UIRB6L36.cjs → chunk-DZFSZSQB.cjs} +46 -54
- package/dist/chunk-DZFSZSQB.cjs.map +1 -0
- package/dist/{chunk-2WFDP7G5.js → chunk-FMSGREKS.js} +46 -54
- package/dist/chunk-FMSGREKS.js.map +1 -0
- package/dist/{chunk-44HBZYKP.js → chunk-IDXGFWFS.cjs} +109 -3
- package/dist/chunk-IDXGFWFS.cjs.map +1 -0
- package/dist/global.d.ts +36 -0
- package/dist/hooks.cjs +10 -10
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.d.cts +2 -2
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +3 -3
- package/dist/hooks.js.map +1 -1
- package/dist/index.cjs +385 -265
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +224 -4
- package/dist/index.d.ts +224 -4
- package/dist/index.js +328 -208
- package/dist/index.js.map +1 -1
- package/dist/lib/widget-types.d.ts +232 -0
- package/dist/{result-chart-YLCKBNV4.cjs → result-chart-ANZOT6FL.cjs} +24 -34
- package/dist/result-chart-ANZOT6FL.cjs.map +1 -0
- package/dist/{result-chart-NFAJ4IQ5.js → result-chart-C3EJTN5G.js} +22 -32
- package/dist/result-chart-C3EJTN5G.js.map +1 -0
- package/dist/widget.css +2 -2
- package/dist/widget.js +215 -246
- package/package.json +26 -16
- package/src/components/__tests__/data-table.test.tsx +125 -0
- package/src/components/actions/action-approval-card.tsx +26 -19
- package/src/components/actions/action-status-badge.tsx +3 -3
- package/src/components/atlas-chat.tsx +97 -37
- package/src/components/chart/result-chart.tsx +13 -37
- package/src/components/chat/api-key-bar.tsx +4 -4
- package/src/components/chat/data-table.tsx +42 -3
- package/src/components/chat/error-banner.tsx +108 -5
- package/src/components/chat/follow-up-chips.tsx +1 -1
- package/src/components/chat/managed-auth-card.tsx +6 -6
- package/src/components/conversations/conversation-item.tsx +19 -14
- package/src/components/conversations/conversation-list.tsx +3 -3
- package/src/components/conversations/conversation-sidebar.tsx +15 -4
- package/src/components/conversations/delete-confirmation.tsx +2 -2
- package/src/components/error-boundary.tsx +66 -0
- package/src/components/schema-explorer/schema-explorer.tsx +4 -0
- package/src/env.d.ts +9 -7
- package/src/global.d.ts +36 -0
- package/src/hooks/__tests__/use-atlas-conversations.test.tsx +4 -6
- package/src/hooks/use-atlas-chat.ts +1 -1
- package/src/hooks/use-atlas-conversations.ts +2 -2
- package/src/hooks/use-conversations.ts +60 -68
- package/src/index.ts +8 -0
- package/src/lib/action-types.ts +2 -2
- package/src/lib/helpers.ts +16 -16
- package/src/lib/types.ts +3 -2
- package/src/lib/widget-types.ts +232 -0
- package/src/test-setup.ts +2 -2
- package/dist/chunk-2WFDP7G5.js.map +0 -1
- package/dist/chunk-44HBZYKP.js.map +0 -1
- package/dist/chunk-5SEVKHS5.cjs.map +0 -1
- package/dist/chunk-UIRB6L36.cjs.map +0 -1
- package/dist/result-chart-NFAJ4IQ5.js.map +0 -1
- package/dist/result-chart-YLCKBNV4.cjs.map +0 -1
|
@@ -27,8 +27,10 @@ import {
|
|
|
27
27
|
DropdownMenuTrigger,
|
|
28
28
|
} from "./ui/dropdown-menu";
|
|
29
29
|
import { Button } from "./ui/button";
|
|
30
|
+
import { Input } from "./ui/input";
|
|
30
31
|
import { ScrollArea } from "./ui/scroll-area";
|
|
31
32
|
import { parseSuggestions } from "../lib/helpers";
|
|
33
|
+
import { ErrorBoundary } from "./error-boundary";
|
|
32
34
|
|
|
33
35
|
const API_KEY_STORAGE_KEY = "atlas-api-key";
|
|
34
36
|
|
|
@@ -47,6 +49,10 @@ export interface AtlasChatProps {
|
|
|
47
49
|
authClient?: AtlasAuthClient;
|
|
48
50
|
/** Custom renderers for tool results. Keys are tool names (e.g. "executeSQL", "explore", "executePython"). */
|
|
49
51
|
toolRenderers?: ToolRenderers;
|
|
52
|
+
/** Custom chat API endpoint path. Defaults to "/api/v1/chat". */
|
|
53
|
+
chatEndpoint?: string;
|
|
54
|
+
/** Custom conversations API endpoint path. Defaults to "/api/v1/conversations". */
|
|
55
|
+
conversationsEndpoint?: string;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
/** No-op auth client for non-managed auth modes. */
|
|
@@ -112,7 +118,7 @@ function SaveButton({
|
|
|
112
118
|
}: {
|
|
113
119
|
conversationId: string;
|
|
114
120
|
conversations: { id: string; starred: boolean }[];
|
|
115
|
-
onStar: (id: string, starred: boolean) => Promise<
|
|
121
|
+
onStar: (id: string, starred: boolean) => Promise<void>;
|
|
116
122
|
}) {
|
|
117
123
|
const isStarred = conversations.find((c) => c.id === conversationId)?.starred ?? false;
|
|
118
124
|
const [pending, setPending] = useState(false);
|
|
@@ -162,6 +168,8 @@ export function AtlasChat(props: AtlasChatProps) {
|
|
|
162
168
|
schemaExplorer: schemaExplorerEnabled = false,
|
|
163
169
|
authClient = noopAuthClient,
|
|
164
170
|
toolRenderers,
|
|
171
|
+
chatEndpoint = "/api/v1/chat",
|
|
172
|
+
conversationsEndpoint = "/api/v1/conversations",
|
|
165
173
|
} = props;
|
|
166
174
|
|
|
167
175
|
// Apply theme from props on mount and when it changes
|
|
@@ -176,6 +184,8 @@ export function AtlasChat(props: AtlasChatProps) {
|
|
|
176
184
|
sidebar={sidebar}
|
|
177
185
|
schemaExplorerEnabled={schemaExplorerEnabled}
|
|
178
186
|
toolRenderers={toolRenderers}
|
|
187
|
+
chatEndpoint={chatEndpoint}
|
|
188
|
+
conversationsEndpoint={conversationsEndpoint}
|
|
179
189
|
/>
|
|
180
190
|
</AtlasUIProvider>
|
|
181
191
|
);
|
|
@@ -186,11 +196,15 @@ function AtlasChatInner({
|
|
|
186
196
|
sidebar,
|
|
187
197
|
schemaExplorerEnabled,
|
|
188
198
|
toolRenderers,
|
|
199
|
+
chatEndpoint,
|
|
200
|
+
conversationsEndpoint,
|
|
189
201
|
}: {
|
|
190
202
|
propApiKey?: string;
|
|
191
203
|
sidebar: boolean;
|
|
192
204
|
schemaExplorerEnabled: boolean;
|
|
193
205
|
toolRenderers?: ToolRenderers;
|
|
206
|
+
chatEndpoint: string;
|
|
207
|
+
conversationsEndpoint: string;
|
|
194
208
|
}) {
|
|
195
209
|
const { apiUrl, isCrossOrigin, authClient } = useAtlasConfig();
|
|
196
210
|
const dark = useDarkMode();
|
|
@@ -231,6 +245,7 @@ function AtlasChatInner({
|
|
|
231
245
|
enabled: sidebar,
|
|
232
246
|
getHeaders,
|
|
233
247
|
getCredentials,
|
|
248
|
+
conversationsEndpoint,
|
|
234
249
|
});
|
|
235
250
|
|
|
236
251
|
const refreshConvosRef = useRef(convos.refresh);
|
|
@@ -334,7 +349,7 @@ function AtlasChatInner({
|
|
|
334
349
|
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
335
350
|
}
|
|
336
351
|
return new DefaultChatTransport({
|
|
337
|
-
api: `${apiUrl}
|
|
352
|
+
api: `${apiUrl}${chatEndpoint}`,
|
|
338
353
|
headers,
|
|
339
354
|
credentials: isCrossOrigin ? "include" : undefined,
|
|
340
355
|
body: () => (conversationIdRef.current ? { conversationId: conversationIdRef.current } : {}),
|
|
@@ -352,7 +367,7 @@ function AtlasChatInner({
|
|
|
352
367
|
return response;
|
|
353
368
|
}) as typeof fetch,
|
|
354
369
|
});
|
|
355
|
-
}, [apiKey, apiUrl, isCrossOrigin]);
|
|
370
|
+
}, [apiKey, apiUrl, isCrossOrigin, chatEndpoint]);
|
|
356
371
|
|
|
357
372
|
const { messages, setMessages, sendMessage, status, error } = useChat({ transport });
|
|
358
373
|
|
|
@@ -382,17 +397,12 @@ function AtlasChatInner({
|
|
|
382
397
|
setLoadingConversation(true);
|
|
383
398
|
try {
|
|
384
399
|
const uiMessages = await convos.loadConversation(id);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
setHealthWarning("Could not load conversation. It may have been deleted.");
|
|
392
|
-
setTimeout(() => setHealthWarning(""), 5000);
|
|
393
|
-
}
|
|
394
|
-
} catch (err) {
|
|
395
|
-
console.warn("Failed to load conversation:", err);
|
|
400
|
+
setMessages(uiMessages);
|
|
401
|
+
setConversationId(id);
|
|
402
|
+
convos.setSelectedId(id);
|
|
403
|
+
setMobileMenuOpen(false);
|
|
404
|
+
} catch (err: unknown) {
|
|
405
|
+
console.warn("Failed to load conversation:", err instanceof Error ? err.message : String(err));
|
|
396
406
|
setHealthWarning("Failed to load conversation. Please try again.");
|
|
397
407
|
setTimeout(() => setHealthWarning(""), 5000);
|
|
398
408
|
} finally {
|
|
@@ -435,19 +445,21 @@ function AtlasChatInner({
|
|
|
435
445
|
/>
|
|
436
446
|
)}
|
|
437
447
|
|
|
438
|
-
<main className="flex flex-1 flex-col overflow-hidden">
|
|
448
|
+
<main id="main" tabIndex={-1} className="flex flex-1 flex-col overflow-hidden">
|
|
439
449
|
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col overflow-hidden p-4">
|
|
440
450
|
<header className="mb-4 flex-none border-b border-zinc-100 pb-3 dark:border-zinc-800">
|
|
441
451
|
<div className="flex items-center justify-between">
|
|
442
452
|
<div className="flex items-center gap-3">
|
|
443
453
|
{showSidebar && (
|
|
444
|
-
<
|
|
454
|
+
<Button
|
|
455
|
+
variant="ghost"
|
|
456
|
+
size="icon"
|
|
445
457
|
onClick={() => setMobileMenuOpen(true)}
|
|
446
|
-
className="
|
|
458
|
+
className="size-11 text-zinc-400 hover:text-zinc-700 md:hidden dark:hover:text-zinc-200"
|
|
447
459
|
aria-label="Open conversation history"
|
|
448
460
|
>
|
|
449
461
|
{MenuIcon}
|
|
450
|
-
</
|
|
462
|
+
</Button>
|
|
451
463
|
)}
|
|
452
464
|
<div className="flex items-center gap-2.5">
|
|
453
465
|
{AtlasLogo}
|
|
@@ -475,7 +487,9 @@ function AtlasChatInner({
|
|
|
475
487
|
<span className="hidden text-xs text-zinc-500 sm:inline dark:text-zinc-400">
|
|
476
488
|
{managedSession.data?.user?.email}
|
|
477
489
|
</span>
|
|
478
|
-
<
|
|
490
|
+
<Button
|
|
491
|
+
variant="outline"
|
|
492
|
+
size="sm"
|
|
479
493
|
onClick={() => {
|
|
480
494
|
authClient.signOut().catch((err: unknown) => {
|
|
481
495
|
console.error("Sign out failed:", err);
|
|
@@ -483,18 +497,18 @@ function AtlasChatInner({
|
|
|
483
497
|
setTimeout(() => setHealthWarning(""), 5000);
|
|
484
498
|
});
|
|
485
499
|
}}
|
|
486
|
-
className="
|
|
500
|
+
className="text-xs text-zinc-500 dark:text-zinc-400"
|
|
487
501
|
>
|
|
488
502
|
Sign out
|
|
489
|
-
</
|
|
503
|
+
</Button>
|
|
490
504
|
</>
|
|
491
505
|
)}
|
|
492
506
|
</div>
|
|
493
507
|
</div>
|
|
494
508
|
</header>
|
|
495
509
|
|
|
496
|
-
{healthWarning && (
|
|
497
|
-
<p className="mb-2 text-xs text-zinc-400 dark:text-zinc-500">{healthWarning}</p>
|
|
510
|
+
{(healthWarning || convos.fetchError) && (
|
|
511
|
+
<p className="mb-2 text-xs text-zinc-400 dark:text-zinc-500">{healthWarning || convos.fetchError}</p>
|
|
498
512
|
)}
|
|
499
513
|
|
|
500
514
|
{isManaged && !isSignedIn ? (
|
|
@@ -508,6 +522,14 @@ function AtlasChatInner({
|
|
|
508
522
|
)}
|
|
509
523
|
|
|
510
524
|
<ScrollArea viewportRef={scrollRef} className="min-h-0 flex-1">
|
|
525
|
+
<ErrorBoundary
|
|
526
|
+
fallbackRender={(_error, reset) => (
|
|
527
|
+
<div className="flex flex-col items-center justify-center gap-2 p-6 text-sm text-red-600 dark:text-red-400">
|
|
528
|
+
<p>Failed to render messages.</p>
|
|
529
|
+
<Button variant="link" size="sm" onClick={reset} className="text-xs">Try again</Button>
|
|
530
|
+
</div>
|
|
531
|
+
)}
|
|
532
|
+
>
|
|
511
533
|
<div data-atlas-messages className="space-y-4 pb-4 pr-3">
|
|
512
534
|
{messages.length === 0 && !error && (
|
|
513
535
|
<div className="flex h-full flex-col items-center justify-center gap-6">
|
|
@@ -515,19 +537,20 @@ function AtlasChatInner({
|
|
|
515
537
|
<p className="text-lg font-medium text-zinc-500 dark:text-zinc-400">
|
|
516
538
|
What would you like to know?
|
|
517
539
|
</p>
|
|
518
|
-
<p className="mt-1 text-sm text-zinc-
|
|
540
|
+
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-500">
|
|
519
541
|
Ask a question about your data to get started
|
|
520
542
|
</p>
|
|
521
543
|
</div>
|
|
522
544
|
<div className="grid w-full max-w-lg grid-cols-1 gap-2 sm:grid-cols-2">
|
|
523
545
|
{STARTER_PROMPTS.map((prompt) => (
|
|
524
|
-
<
|
|
546
|
+
<Button
|
|
525
547
|
key={prompt}
|
|
548
|
+
variant="outline"
|
|
526
549
|
onClick={() => handleSend(prompt)}
|
|
527
|
-
className="
|
|
550
|
+
className="h-auto whitespace-normal justify-start rounded-lg bg-zinc-50 px-3 py-2.5 text-left text-sm text-zinc-500 hover:text-zinc-800 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-200"
|
|
528
551
|
>
|
|
529
552
|
{prompt}
|
|
530
|
-
</
|
|
553
|
+
</Button>
|
|
531
554
|
))}
|
|
532
555
|
</div>
|
|
533
556
|
</div>
|
|
@@ -536,7 +559,7 @@ function AtlasChatInner({
|
|
|
536
559
|
{messages.map((m, msgIndex) => {
|
|
537
560
|
if (m.role === "user") {
|
|
538
561
|
return (
|
|
539
|
-
<div key={m.id} className="flex justify-end">
|
|
562
|
+
<div key={m.id} className="flex justify-end" role="article" aria-label="Message from you">
|
|
540
563
|
<div className="max-w-[85%] rounded-xl bg-blue-600 px-4 py-3 text-sm text-white">
|
|
541
564
|
{m.parts?.map((part, i) =>
|
|
542
565
|
part.type === "text" ? (
|
|
@@ -554,6 +577,13 @@ function AtlasChatInner({
|
|
|
554
577
|
m.role === "assistant" &&
|
|
555
578
|
msgIndex === messages.length - 1;
|
|
556
579
|
|
|
580
|
+
// Skip rendering assistant messages with no visible content
|
|
581
|
+
// (happens when stream errors before producing any text)
|
|
582
|
+
const hasVisibleParts = m.parts?.some(
|
|
583
|
+
(p) => (p.type === "text" && p.text.trim()) || isToolUIPart(p),
|
|
584
|
+
);
|
|
585
|
+
if (!hasVisibleParts && !isLastAssistant) return null;
|
|
586
|
+
|
|
557
587
|
const lastTextWithSuggestions = m.parts
|
|
558
588
|
?.filter((p): p is typeof p & { type: "text"; text: string } => p.type === "text" && !!p.text.trim())
|
|
559
589
|
.findLast((p) => parseSuggestions(p.text).suggestions.length > 0);
|
|
@@ -562,7 +592,7 @@ function AtlasChatInner({
|
|
|
562
592
|
: [];
|
|
563
593
|
|
|
564
594
|
return (
|
|
565
|
-
<div key={m.id} className="space-y-2">
|
|
595
|
+
<div key={m.id} className="space-y-2" role="article" aria-label="Message from Atlas">
|
|
566
596
|
{m.parts?.map((part, i) => {
|
|
567
597
|
if (part.type === "text" && part.text.trim()) {
|
|
568
598
|
const displayText = parseSuggestions(part.text).text;
|
|
@@ -584,7 +614,17 @@ function AtlasChatInner({
|
|
|
584
614
|
}
|
|
585
615
|
return null;
|
|
586
616
|
})}
|
|
587
|
-
{
|
|
617
|
+
{/* Show inline error when the last assistant message is empty (stream failed before producing content) */}
|
|
618
|
+
{isLastAssistant && !hasVisibleParts && !isLoading && error && (
|
|
619
|
+
<div className="max-w-[90%]">
|
|
620
|
+
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-400">
|
|
621
|
+
{error.message
|
|
622
|
+
? `Something went wrong generating a response: ${error.message}. Try sending your message again.`
|
|
623
|
+
: "Something went wrong generating a response. Try sending your message again."}
|
|
624
|
+
</div>
|
|
625
|
+
</div>
|
|
626
|
+
)}
|
|
627
|
+
{isLastAssistant && !isLoading && hasVisibleParts && (
|
|
588
628
|
<>
|
|
589
629
|
<FollowUpChips
|
|
590
630
|
suggestions={suggestions}
|
|
@@ -605,9 +645,27 @@ function AtlasChatInner({
|
|
|
605
645
|
|
|
606
646
|
{isLoading && messages.length > 0 && <TypingIndicator />}
|
|
607
647
|
</div>
|
|
648
|
+
</ErrorBoundary>
|
|
608
649
|
</ScrollArea>
|
|
609
650
|
|
|
610
|
-
{error &&
|
|
651
|
+
{error && (
|
|
652
|
+
<ErrorBanner
|
|
653
|
+
error={error}
|
|
654
|
+
authMode={authMode ?? "none"}
|
|
655
|
+
onRetry={
|
|
656
|
+
messages.some((m) => m.role === "user")
|
|
657
|
+
? () => {
|
|
658
|
+
const lastUserMsg = messages.toReversed().find((m) => m.role === "user");
|
|
659
|
+
const text = lastUserMsg?.parts
|
|
660
|
+
?.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
661
|
+
.map((p) => p.text)
|
|
662
|
+
.join(" ");
|
|
663
|
+
if (text) handleSend(text);
|
|
664
|
+
}
|
|
665
|
+
: undefined
|
|
666
|
+
}
|
|
667
|
+
/>
|
|
668
|
+
)}
|
|
611
669
|
|
|
612
670
|
<form
|
|
613
671
|
data-atlas-form
|
|
@@ -617,21 +675,23 @@ function AtlasChatInner({
|
|
|
617
675
|
}}
|
|
618
676
|
className="flex flex-none gap-2 border-t border-zinc-100 pt-4 dark:border-zinc-800"
|
|
619
677
|
>
|
|
620
|
-
<
|
|
678
|
+
<Input
|
|
621
679
|
data-atlas-input
|
|
622
680
|
value={input}
|
|
623
681
|
onChange={(e) => setInput(e.target.value)}
|
|
624
682
|
placeholder="Ask a question about your data..."
|
|
625
|
-
className="min-w-0 flex-1
|
|
683
|
+
className="min-w-0 flex-1 py-3 text-base sm:text-sm"
|
|
626
684
|
disabled={isLoading || healthFailed}
|
|
685
|
+
aria-label="Chat message"
|
|
627
686
|
/>
|
|
628
|
-
<
|
|
687
|
+
<Button
|
|
629
688
|
type="submit"
|
|
630
|
-
disabled={isLoading || healthFailed
|
|
631
|
-
|
|
689
|
+
disabled={isLoading || healthFailed}
|
|
690
|
+
aria-disabled={!(isLoading || healthFailed) && !input.trim() ? true : undefined}
|
|
691
|
+
className="shrink-0 px-5"
|
|
632
692
|
>
|
|
633
693
|
Ask
|
|
634
|
-
</
|
|
694
|
+
</Button>
|
|
635
695
|
</form>
|
|
636
696
|
</ActionAuthProvider>
|
|
637
697
|
)}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useMemo, useId, useState } from "react";
|
|
4
|
+
import { ErrorBoundary } from "../error-boundary";
|
|
4
5
|
import {
|
|
5
6
|
ResponsiveContainer,
|
|
6
7
|
BarChart,
|
|
@@ -32,39 +33,6 @@ import {
|
|
|
32
33
|
type ChartDetectionResult,
|
|
33
34
|
} from "./chart-detection";
|
|
34
35
|
|
|
35
|
-
/* ------------------------------------------------------------------ */
|
|
36
|
-
/* Error boundary */
|
|
37
|
-
/* ------------------------------------------------------------------ */
|
|
38
|
-
|
|
39
|
-
class ChartErrorBoundary extends Component<
|
|
40
|
-
{ children: ReactNode; fallback?: ReactNode },
|
|
41
|
-
{ hasError: boolean }
|
|
42
|
-
> {
|
|
43
|
-
constructor(props: { children: ReactNode; fallback?: ReactNode }) {
|
|
44
|
-
super(props);
|
|
45
|
-
this.state = { hasError: false };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
static getDerivedStateFromError(): { hasError: boolean } {
|
|
49
|
-
return { hasError: true };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
53
|
-
console.error("Chart rendering failed:", error, info.componentStack);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
render() {
|
|
57
|
-
if (this.state.hasError) {
|
|
58
|
-
return this.props.fallback ?? (
|
|
59
|
-
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-3 py-2 text-xs text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-950/20 dark:text-yellow-400">
|
|
60
|
-
Chart could not be rendered. Switch to Table view to see your data.
|
|
61
|
-
</div>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
return this.props.children;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
36
|
/* ------------------------------------------------------------------ */
|
|
69
37
|
/* Theme helpers */
|
|
70
38
|
/* ------------------------------------------------------------------ */
|
|
@@ -493,7 +461,8 @@ function ChartTypeSelector({
|
|
|
493
461
|
<button
|
|
494
462
|
key={rec.type}
|
|
495
463
|
onClick={() => onChange(rec.type)}
|
|
496
|
-
|
|
464
|
+
aria-pressed={active === rec.type}
|
|
465
|
+
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 ${
|
|
497
466
|
active === rec.type
|
|
498
467
|
? "bg-blue-100 text-blue-700 dark:bg-blue-600/20 dark:text-blue-400"
|
|
499
468
|
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
|
|
@@ -576,7 +545,14 @@ export function ResultChart({
|
|
|
576
545
|
onChange={setActiveType}
|
|
577
546
|
/>
|
|
578
547
|
</div>
|
|
579
|
-
<
|
|
548
|
+
<ErrorBoundary
|
|
549
|
+
key={currentType}
|
|
550
|
+
fallback={
|
|
551
|
+
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-3 py-2 text-xs text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-950/20 dark:text-yellow-400">
|
|
552
|
+
Unable to render chart. Switch to Table view to see your data.
|
|
553
|
+
</div>
|
|
554
|
+
}
|
|
555
|
+
>
|
|
580
556
|
<ChartRenderer
|
|
581
557
|
rows={rows}
|
|
582
558
|
rec={currentRec}
|
|
@@ -584,7 +560,7 @@ export function ResultChart({
|
|
|
584
560
|
defaultRec={result.recommendations[0]}
|
|
585
561
|
dark={dark}
|
|
586
562
|
/>
|
|
587
|
-
</
|
|
563
|
+
</ErrorBoundary>
|
|
588
564
|
</div>
|
|
589
565
|
);
|
|
590
566
|
}
|
|
@@ -18,7 +18,7 @@ export function ApiKeyBar({
|
|
|
18
18
|
<span className="text-zinc-500 dark:text-zinc-400">API key configured</span>
|
|
19
19
|
<button
|
|
20
20
|
onClick={() => { setDraft(apiKey); setEditing(true); }}
|
|
21
|
-
className="rounded border border-zinc-200 px-2 py-0.5 text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-800 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
|
|
21
|
+
className="rounded border border-zinc-200 px-2 py-0.5 text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-800 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
|
|
22
22
|
>
|
|
23
23
|
Change
|
|
24
24
|
</button>
|
|
@@ -42,13 +42,13 @@ export function ApiKeyBar({
|
|
|
42
42
|
value={draft}
|
|
43
43
|
onChange={(e) => setDraft(e.target.value)}
|
|
44
44
|
placeholder="Enter your API key..."
|
|
45
|
-
className="flex-1 bg-transparent text-xs text-zinc-900 placeholder-zinc-400 outline-none dark:text-zinc-100 dark:placeholder-zinc-600"
|
|
45
|
+
className="flex-1 bg-transparent text-xs text-zinc-900 placeholder-zinc-400 outline-none focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:text-zinc-100 dark:placeholder-zinc-600"
|
|
46
46
|
autoFocus
|
|
47
47
|
/>
|
|
48
48
|
<button
|
|
49
49
|
type="submit"
|
|
50
50
|
disabled={!draft.trim()}
|
|
51
|
-
className="rounded border border-zinc-200 px-2 py-0.5 text-xs text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-800 disabled:opacity-40 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
|
|
51
|
+
className="rounded border border-zinc-200 px-2 py-0.5 text-xs text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-800 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
|
|
52
52
|
>
|
|
53
53
|
Save
|
|
54
54
|
</button>
|
|
@@ -56,7 +56,7 @@ export function ApiKeyBar({
|
|
|
56
56
|
<button
|
|
57
57
|
type="button"
|
|
58
58
|
onClick={() => setEditing(false)}
|
|
59
|
-
className="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
|
|
59
|
+
className="rounded text-xs text-zinc-400 hover:text-zinc-600 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:hover:text-zinc-300"
|
|
60
60
|
>
|
|
61
61
|
Cancel
|
|
62
62
|
</button>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useState, type ComponentProps } from "react";
|
|
4
4
|
import { formatCell } from "../../lib/helpers";
|
|
5
|
+
import { ErrorBoundary } from "../error-boundary";
|
|
6
|
+
import { Button } from "../ui/button";
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
function DataTableInner({
|
|
7
9
|
columns,
|
|
8
10
|
rows,
|
|
9
11
|
maxRows = 10,
|
|
@@ -15,6 +17,19 @@ export function DataTable({
|
|
|
15
17
|
const [sortCol, setSortCol] = useState<number | null>(null);
|
|
16
18
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
|
17
19
|
|
|
20
|
+
if (rows.length === 0) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="rounded-lg border border-zinc-200 px-4 py-8 text-center dark:border-zinc-700">
|
|
23
|
+
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
|
24
|
+
Query returned no results
|
|
25
|
+
</p>
|
|
26
|
+
<p className="mt-1 text-xs text-zinc-400 dark:text-zinc-500">
|
|
27
|
+
Try adjusting your query filters or criteria
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
const hasMore = rows.length > maxRows;
|
|
19
34
|
|
|
20
35
|
const cell = (row: Record<string, unknown> | unknown[], colIdx: number): unknown => {
|
|
@@ -67,8 +82,17 @@ export function DataTable({
|
|
|
67
82
|
{columns.map((col, i) => (
|
|
68
83
|
<th
|
|
69
84
|
key={i}
|
|
85
|
+
tabIndex={0}
|
|
86
|
+
role="columnheader"
|
|
87
|
+
aria-sort={sortCol === i ? (sortDir === "asc" ? "ascending" : "descending") : "none"}
|
|
70
88
|
onClick={() => handleSort(i)}
|
|
71
|
-
|
|
89
|
+
onKeyDown={(e) => {
|
|
90
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
handleSort(i);
|
|
93
|
+
}
|
|
94
|
+
}}
|
|
95
|
+
className="group cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left font-medium text-zinc-500 transition-colors hover:text-zinc-800 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:text-zinc-400 dark:hover:text-zinc-200"
|
|
72
96
|
>
|
|
73
97
|
{col}
|
|
74
98
|
{sortCol === i
|
|
@@ -102,3 +126,18 @@ export function DataTable({
|
|
|
102
126
|
</div>
|
|
103
127
|
);
|
|
104
128
|
}
|
|
129
|
+
|
|
130
|
+
export function DataTable(props: ComponentProps<typeof DataTableInner>) {
|
|
131
|
+
return (
|
|
132
|
+
<ErrorBoundary
|
|
133
|
+
fallbackRender={(_error, reset) => (
|
|
134
|
+
<div className="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/50 dark:bg-red-950/20 dark:text-red-400">
|
|
135
|
+
<span>Unable to render results.</span>
|
|
136
|
+
<Button variant="link" onClick={reset}>Retry</Button>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
<DataTableInner {...props} />
|
|
141
|
+
</ErrorBoundary>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -1,12 +1,42 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useMemo } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
import { parseChatError, type AuthMode, type ClientErrorCode } from "../../lib/types";
|
|
6
|
+
import { WifiOff, ServerCrash, ShieldAlert, Clock, AlertTriangle } from "lucide-react";
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
/** Icon for each client error code */
|
|
9
|
+
function ErrorIcon({ clientCode }: { clientCode?: ClientErrorCode }) {
|
|
10
|
+
switch (clientCode) {
|
|
11
|
+
case "offline":
|
|
12
|
+
return <WifiOff className="size-4 shrink-0" />;
|
|
13
|
+
case "api_unreachable":
|
|
14
|
+
return <ServerCrash className="size-4 shrink-0" />;
|
|
15
|
+
case "auth_failure":
|
|
16
|
+
return <ShieldAlert className="size-4 shrink-0" />;
|
|
17
|
+
case "rate_limited_http":
|
|
18
|
+
return <Clock className="size-4 shrink-0" />;
|
|
19
|
+
case "server_error":
|
|
20
|
+
return <ServerCrash className="size-4 shrink-0" />;
|
|
21
|
+
default:
|
|
22
|
+
return <AlertTriangle className="size-4 shrink-0" />;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ErrorBanner({
|
|
27
|
+
error,
|
|
28
|
+
authMode,
|
|
29
|
+
onRetry,
|
|
30
|
+
}: {
|
|
31
|
+
error: Error;
|
|
32
|
+
authMode: AuthMode;
|
|
33
|
+
onRetry?: () => void;
|
|
34
|
+
}) {
|
|
7
35
|
const info = useMemo(() => parseChatError(error, authMode), [error, authMode]);
|
|
8
36
|
const [countdown, setCountdown] = useState(info.retryAfterSeconds ?? 0);
|
|
37
|
+
const [restoredOnline, setRestoredOnline] = useState(false);
|
|
9
38
|
|
|
39
|
+
// Countdown timer for rate-limited errors
|
|
10
40
|
useEffect(() => {
|
|
11
41
|
if (!info.retryAfterSeconds) return;
|
|
12
42
|
setCountdown(info.retryAfterSeconds);
|
|
@@ -19,14 +49,87 @@ export function ErrorBanner({ error, authMode }: { error: Error; authMode: AuthM
|
|
|
19
49
|
return () => clearInterval(interval);
|
|
20
50
|
}, [info.retryAfterSeconds]);
|
|
21
51
|
|
|
52
|
+
// Auto-retry when countdown reaches 0 for rate-limited errors
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (info.retryAfterSeconds && countdown === 0 && onRetry) {
|
|
55
|
+
onRetry();
|
|
56
|
+
}
|
|
57
|
+
}, [countdown, info.retryAfterSeconds, onRetry]);
|
|
58
|
+
|
|
59
|
+
// Offline auto-recovery — listen for online event
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (info.clientCode !== "offline") return;
|
|
62
|
+
|
|
63
|
+
function handleOnline() {
|
|
64
|
+
setRestoredOnline(true);
|
|
65
|
+
// Auto-retry when coming back online
|
|
66
|
+
onRetry?.();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
window.addEventListener("online", handleOnline);
|
|
70
|
+
return () => {
|
|
71
|
+
window.removeEventListener("online", handleOnline);
|
|
72
|
+
};
|
|
73
|
+
}, [info.clientCode, onRetry]);
|
|
74
|
+
|
|
75
|
+
// Emit postMessage error event for widget host pages
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
try {
|
|
78
|
+
if (typeof window !== "undefined" && window.parent !== window) {
|
|
79
|
+
window.parent.postMessage(
|
|
80
|
+
{
|
|
81
|
+
type: "atlas:error",
|
|
82
|
+
error: {
|
|
83
|
+
code: info.clientCode ?? info.code ?? "unknown",
|
|
84
|
+
message: info.title,
|
|
85
|
+
detail: info.detail,
|
|
86
|
+
retryable: info.retryable,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
"*",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Silently ignore postMessage errors (cross-origin restrictions)
|
|
94
|
+
}
|
|
95
|
+
}, [info.clientCode, info.code, info.title, info.detail, info.retryable]);
|
|
96
|
+
|
|
97
|
+
// If we were offline but came back online, hide the banner
|
|
98
|
+
if (info.clientCode === "offline" && restoredOnline) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
22
102
|
const detail = info.retryAfterSeconds && countdown > 0
|
|
23
103
|
? `Try again in ${countdown} second${countdown !== 1 ? "s" : ""}.`
|
|
24
104
|
: info.detail;
|
|
25
105
|
|
|
106
|
+
const showRetry = info.retryable && onRetry && countdown === 0 && info.clientCode !== "offline";
|
|
107
|
+
|
|
26
108
|
return (
|
|
27
|
-
<div
|
|
28
|
-
|
|
29
|
-
|
|
109
|
+
<div
|
|
110
|
+
className="mb-2 rounded-lg border border-red-300 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-950/20 dark:text-red-400 px-4 py-3 text-sm"
|
|
111
|
+
role="alert"
|
|
112
|
+
>
|
|
113
|
+
<div className="flex items-start gap-2">
|
|
114
|
+
<ErrorIcon clientCode={info.clientCode} />
|
|
115
|
+
<div className="min-w-0 flex-1">
|
|
116
|
+
<p className="font-medium">{info.title}</p>
|
|
117
|
+
{detail && <p className="mt-1 text-xs opacity-80">{detail}</p>}
|
|
118
|
+
{info.requestId && (
|
|
119
|
+
<p className="mt-1 text-xs opacity-60">Request ID: {info.requestId}</p>
|
|
120
|
+
)}
|
|
121
|
+
{showRetry && (
|
|
122
|
+
<Button
|
|
123
|
+
variant="link"
|
|
124
|
+
size="sm"
|
|
125
|
+
onClick={onRetry}
|
|
126
|
+
className="mt-2 h-auto p-0 text-xs font-medium text-red-700 dark:text-red-400"
|
|
127
|
+
>
|
|
128
|
+
Try again
|
|
129
|
+
</Button>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
30
133
|
</div>
|
|
31
134
|
);
|
|
32
135
|
}
|
|
@@ -12,7 +12,7 @@ export function FollowUpChips({
|
|
|
12
12
|
if (suggestions.length === 0) return null;
|
|
13
13
|
|
|
14
14
|
return (
|
|
15
|
-
<div className="flex flex-wrap gap-2 pt-1">
|
|
15
|
+
<div className="flex flex-wrap gap-2 pt-1" role="group" aria-label="Suggested follow-up questions">
|
|
16
16
|
{suggestions.map((s, i) => (
|
|
17
17
|
<Button
|
|
18
18
|
key={`${i}-${s}`}
|