@useatlas/react 0.0.1 → 0.0.3

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/dist/{chunk-5SEVKHS5.cjs → chunk-35SCTKSW.js} +100 -7
  4. package/dist/chunk-35SCTKSW.js.map +1 -0
  5. package/dist/{chunk-UIRB6L36.cjs → chunk-DZFSZSQB.cjs} +46 -54
  6. package/dist/chunk-DZFSZSQB.cjs.map +1 -0
  7. package/dist/{chunk-2WFDP7G5.js → chunk-FMSGREKS.js} +46 -54
  8. package/dist/chunk-FMSGREKS.js.map +1 -0
  9. package/dist/{chunk-44HBZYKP.js → chunk-IDXGFWFS.cjs} +109 -3
  10. package/dist/chunk-IDXGFWFS.cjs.map +1 -0
  11. package/dist/global.d.ts +36 -0
  12. package/dist/hooks.cjs +10 -10
  13. package/dist/hooks.cjs.map +1 -1
  14. package/dist/hooks.d.cts +2 -2
  15. package/dist/hooks.d.ts +2 -2
  16. package/dist/hooks.js +3 -3
  17. package/dist/hooks.js.map +1 -1
  18. package/dist/index.cjs +385 -265
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +224 -4
  21. package/dist/index.d.ts +224 -4
  22. package/dist/index.js +328 -208
  23. package/dist/index.js.map +1 -1
  24. package/dist/lib/widget-types.d.ts +232 -0
  25. package/dist/{result-chart-YLCKBNV4.cjs → result-chart-ANZOT6FL.cjs} +24 -34
  26. package/dist/result-chart-ANZOT6FL.cjs.map +1 -0
  27. package/dist/{result-chart-NFAJ4IQ5.js → result-chart-C3EJTN5G.js} +22 -32
  28. package/dist/result-chart-C3EJTN5G.js.map +1 -0
  29. package/dist/widget.css +2 -2
  30. package/dist/widget.js +215 -246
  31. package/package.json +27 -17
  32. package/src/components/__tests__/data-table.test.tsx +125 -0
  33. package/src/components/actions/action-approval-card.tsx +26 -19
  34. package/src/components/actions/action-status-badge.tsx +3 -3
  35. package/src/components/atlas-chat.tsx +97 -37
  36. package/src/components/chart/result-chart.tsx +13 -37
  37. package/src/components/chat/api-key-bar.tsx +4 -4
  38. package/src/components/chat/data-table.tsx +42 -3
  39. package/src/components/chat/error-banner.tsx +108 -5
  40. package/src/components/chat/follow-up-chips.tsx +1 -1
  41. package/src/components/chat/managed-auth-card.tsx +6 -6
  42. package/src/components/conversations/conversation-item.tsx +19 -14
  43. package/src/components/conversations/conversation-list.tsx +3 -3
  44. package/src/components/conversations/conversation-sidebar.tsx +15 -4
  45. package/src/components/conversations/delete-confirmation.tsx +2 -2
  46. package/src/components/error-boundary.tsx +66 -0
  47. package/src/components/schema-explorer/schema-explorer.tsx +4 -0
  48. package/src/env.d.ts +9 -7
  49. package/src/global.d.ts +36 -0
  50. package/src/hooks/__tests__/use-atlas-conversations.test.tsx +4 -6
  51. package/src/hooks/use-atlas-chat.ts +1 -1
  52. package/src/hooks/use-atlas-conversations.ts +2 -2
  53. package/src/hooks/use-conversations.ts +60 -68
  54. package/src/index.ts +8 -0
  55. package/src/lib/action-types.ts +2 -2
  56. package/src/lib/helpers.ts +16 -16
  57. package/src/lib/types.ts +3 -2
  58. package/src/lib/widget-types.ts +232 -0
  59. package/src/test-setup.ts +2 -2
  60. package/dist/chunk-2WFDP7G5.js.map +0 -1
  61. package/dist/chunk-44HBZYKP.js.map +0 -1
  62. package/dist/chunk-5SEVKHS5.cjs.map +0 -1
  63. package/dist/chunk-UIRB6L36.cjs.map +0 -1
  64. package/dist/result-chart-NFAJ4IQ5.js.map +0 -1
  65. 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<boolean>;
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}/api/chat`,
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
- if (uiMessages) {
386
- setMessages(uiMessages);
387
- setConversationId(id);
388
- convos.setSelectedId(id);
389
- setMobileMenuOpen(false);
390
- } else {
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
- <button
454
+ <Button
455
+ variant="ghost"
456
+ size="icon"
445
457
  onClick={() => setMobileMenuOpen(true)}
446
- className="flex size-11 items-center justify-center rounded text-zinc-400 hover:text-zinc-700 md:hidden dark:hover:text-zinc-200"
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
- </button>
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
- <button
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="rounded border border-zinc-200 px-3 py-2 text-xs 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"
500
+ className="text-xs text-zinc-500 dark:text-zinc-400"
487
501
  >
488
502
  Sign out
489
- </button>
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-400 dark:text-zinc-600">
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
- <button
546
+ <Button
525
547
  key={prompt}
548
+ variant="outline"
526
549
  onClick={() => handleSend(prompt)}
527
- className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2.5 text-left text-sm text-zinc-500 transition-colors hover:border-zinc-400 hover:bg-zinc-100 hover:text-zinc-800 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
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
- </button>
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
- {isLastAssistant && !isLoading && (
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 && <ErrorBanner error={error} authMode={authMode} />}
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
- <input
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 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-base text-zinc-900 placeholder-zinc-400 outline-none focus:border-blue-500 sm:text-sm dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder-zinc-600"
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
- <button
687
+ <Button
629
688
  type="submit"
630
- disabled={isLoading || healthFailed || !input.trim()}
631
- className="shrink-0 rounded-lg bg-blue-600 px-5 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:opacity-40"
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
- </button>
694
+ </Button>
635
695
  </form>
636
696
  </ActionAuthProvider>
637
697
  )}
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { Component, type ReactNode, type ErrorInfo, useMemo, useId, useState } from "react";
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
- className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
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
- <ChartErrorBoundary key={currentType}>
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
- </ChartErrorBoundary>
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
- export function DataTable({
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
- 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 dark:text-zinc-400 dark:hover:text-zinc-200"
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 { parseChatError, type AuthMode } from "../../lib/types";
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
- export function ErrorBanner({ error, authMode }: { error: Error; authMode: AuthMode }) {
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 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">
28
- <p className="font-medium">{info.title}</p>
29
- {detail && <p className="mt-1 text-xs opacity-80">{detail}</p>}
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}`}