@useatlas/react 0.0.1

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 (98) hide show
  1. package/README.md +95 -0
  2. package/dist/chunk-2WFDP7G5.js +231 -0
  3. package/dist/chunk-2WFDP7G5.js.map +1 -0
  4. package/dist/chunk-44HBZYKP.js +224 -0
  5. package/dist/chunk-44HBZYKP.js.map +1 -0
  6. package/dist/chunk-5SEVKHS5.cjs +229 -0
  7. package/dist/chunk-5SEVKHS5.cjs.map +1 -0
  8. package/dist/chunk-UIRB6L36.cjs +249 -0
  9. package/dist/chunk-UIRB6L36.cjs.map +1 -0
  10. package/dist/hooks.cjs +251 -0
  11. package/dist/hooks.cjs.map +1 -0
  12. package/dist/hooks.d.cts +132 -0
  13. package/dist/hooks.d.ts +132 -0
  14. package/dist/hooks.js +237 -0
  15. package/dist/hooks.js.map +1 -0
  16. package/dist/index.cjs +2976 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +69 -0
  19. package/dist/index.d.ts +69 -0
  20. package/dist/index.js +2926 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/result-chart-NFAJ4IQ5.js +398 -0
  23. package/dist/result-chart-NFAJ4IQ5.js.map +1 -0
  24. package/dist/result-chart-YLCKBNV4.cjs +400 -0
  25. package/dist/result-chart-YLCKBNV4.cjs.map +1 -0
  26. package/dist/styles.css +59 -0
  27. package/dist/use-dark-mode-rFxawUv1.d.cts +123 -0
  28. package/dist/use-dark-mode-rFxawUv1.d.ts +123 -0
  29. package/dist/widget.css +2 -0
  30. package/dist/widget.js +445 -0
  31. package/package.json +113 -0
  32. package/src/components/__tests__/tool-renderers.test.tsx +239 -0
  33. package/src/components/actions/action-approval-card.tsx +296 -0
  34. package/src/components/actions/action-status-badge.tsx +50 -0
  35. package/src/components/admin/change-password-dialog.tsx +128 -0
  36. package/src/components/atlas-chat.tsx +656 -0
  37. package/src/components/chart/chart-detection.ts +318 -0
  38. package/src/components/chart/result-chart.tsx +590 -0
  39. package/src/components/chat/api-key-bar.tsx +66 -0
  40. package/src/components/chat/copy-button.tsx +25 -0
  41. package/src/components/chat/data-table.tsx +104 -0
  42. package/src/components/chat/error-banner.tsx +32 -0
  43. package/src/components/chat/explore-card.tsx +41 -0
  44. package/src/components/chat/follow-up-chips.tsx +29 -0
  45. package/src/components/chat/loading-card.tsx +10 -0
  46. package/src/components/chat/managed-auth-card.tsx +116 -0
  47. package/src/components/chat/markdown.tsx +146 -0
  48. package/src/components/chat/python-result-card.tsx +245 -0
  49. package/src/components/chat/sql-block.tsx +54 -0
  50. package/src/components/chat/sql-result-card.tsx +163 -0
  51. package/src/components/chat/starter-prompts.ts +6 -0
  52. package/src/components/chat/tool-part.tsx +106 -0
  53. package/src/components/chat/typing-indicator.tsx +22 -0
  54. package/src/components/conversations/conversation-item.tsx +135 -0
  55. package/src/components/conversations/conversation-list.tsx +69 -0
  56. package/src/components/conversations/conversation-sidebar.tsx +113 -0
  57. package/src/components/conversations/delete-confirmation.tsx +27 -0
  58. package/src/components/schema-explorer/schema-explorer.tsx +517 -0
  59. package/src/components/ui/alert-dialog.tsx +196 -0
  60. package/src/components/ui/badge.tsx +48 -0
  61. package/src/components/ui/button.tsx +64 -0
  62. package/src/components/ui/card.tsx +92 -0
  63. package/src/components/ui/dialog.tsx +158 -0
  64. package/src/components/ui/dropdown-menu.tsx +257 -0
  65. package/src/components/ui/input.tsx +21 -0
  66. package/src/components/ui/label.tsx +24 -0
  67. package/src/components/ui/scroll-area.tsx +62 -0
  68. package/src/components/ui/separator.tsx +28 -0
  69. package/src/components/ui/sheet.tsx +143 -0
  70. package/src/components/ui/table.tsx +116 -0
  71. package/src/components/ui/toggle-group.tsx +83 -0
  72. package/src/components/ui/toggle.tsx +47 -0
  73. package/src/context.tsx +85 -0
  74. package/src/env.d.ts +9 -0
  75. package/src/hooks/__tests__/provider.test.tsx +83 -0
  76. package/src/hooks/__tests__/use-atlas-auth.test.tsx +283 -0
  77. package/src/hooks/__tests__/use-atlas-chat.test.tsx +157 -0
  78. package/src/hooks/__tests__/use-atlas-conversations.test.tsx +159 -0
  79. package/src/hooks/__tests__/use-atlas-theme.test.tsx +56 -0
  80. package/src/hooks/index.ts +47 -0
  81. package/src/hooks/provider.tsx +77 -0
  82. package/src/hooks/theme-init-script.ts +17 -0
  83. package/src/hooks/use-atlas-auth.ts +131 -0
  84. package/src/hooks/use-atlas-chat.ts +102 -0
  85. package/src/hooks/use-atlas-conversations.ts +61 -0
  86. package/src/hooks/use-atlas-theme.ts +34 -0
  87. package/src/hooks/use-conversations.ts +189 -0
  88. package/src/hooks/use-dark-mode.ts +150 -0
  89. package/src/index.ts +36 -0
  90. package/src/lib/action-types.ts +11 -0
  91. package/src/lib/helpers.ts +198 -0
  92. package/src/lib/tool-renderer-types.ts +76 -0
  93. package/src/lib/types.ts +29 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/styles.css +59 -0
  96. package/src/test-setup.ts +55 -0
  97. package/src/widget-entry.ts +20 -0
  98. package/src/widget.css +12 -0
@@ -0,0 +1,656 @@
1
+ "use client";
2
+
3
+ import { useChat } from "@ai-sdk/react";
4
+ import { DefaultChatTransport, isToolUIPart } from "ai";
5
+ import { useState, useRef, useEffect, useMemo, useCallback } from "react";
6
+ import { AUTH_MODES, type AuthMode } from "../lib/types";
7
+ import type { ToolRenderers } from "../lib/tool-renderer-types";
8
+ import { AtlasUIProvider, useAtlasConfig, ActionAuthProvider, type AtlasAuthClient } from "../context";
9
+ import { DarkModeContext, useDarkMode, useThemeMode, setTheme, applyBrandColor, OKLCH_RE, type ThemeMode } from "../hooks/use-dark-mode";
10
+ import { useConversations } from "../hooks/use-conversations";
11
+ import { ErrorBanner } from "./chat/error-banner";
12
+ import { ApiKeyBar } from "./chat/api-key-bar";
13
+ import { ManagedAuthCard } from "./chat/managed-auth-card";
14
+ import { TypingIndicator } from "./chat/typing-indicator";
15
+ import { ToolPart } from "./chat/tool-part";
16
+ import { Markdown } from "./chat/markdown";
17
+ import { STARTER_PROMPTS } from "./chat/starter-prompts";
18
+ import { FollowUpChips } from "./chat/follow-up-chips";
19
+ import { ConversationSidebar } from "./conversations/conversation-sidebar";
20
+ import { ChangePasswordDialog } from "./admin/change-password-dialog";
21
+ import { Sun, Moon, Monitor, Star, TableProperties } from "lucide-react";
22
+ import { SchemaExplorer } from "./schema-explorer/schema-explorer";
23
+ import {
24
+ DropdownMenu,
25
+ DropdownMenuContent,
26
+ DropdownMenuItem,
27
+ DropdownMenuTrigger,
28
+ } from "./ui/dropdown-menu";
29
+ import { Button } from "./ui/button";
30
+ import { ScrollArea } from "./ui/scroll-area";
31
+ import { parseSuggestions } from "../lib/helpers";
32
+
33
+ const API_KEY_STORAGE_KEY = "atlas-api-key";
34
+
35
+ export interface AtlasChatProps {
36
+ /** Atlas API server URL (e.g. "https://api.example.com" or "" for same-origin). */
37
+ apiUrl: string;
38
+ /** API key for simple-key auth mode. When provided, sent as Bearer token. */
39
+ apiKey?: string;
40
+ /** Theme preference. Defaults to "system". */
41
+ theme?: ThemeMode;
42
+ /** Enable conversation history sidebar. Defaults to false. */
43
+ sidebar?: boolean;
44
+ /** Enable schema explorer button. Defaults to false. */
45
+ schemaExplorer?: boolean;
46
+ /** Custom auth client for managed auth mode. */
47
+ authClient?: AtlasAuthClient;
48
+ /** Custom renderers for tool results. Keys are tool names (e.g. "executeSQL", "explore", "executePython"). */
49
+ toolRenderers?: ToolRenderers;
50
+ }
51
+
52
+ /** No-op auth client for non-managed auth modes. */
53
+ const noopAuthClient: AtlasAuthClient = {
54
+ signIn: { email: async () => ({ error: { message: "Not supported" } }) },
55
+ signUp: { email: async () => ({ error: { message: "Not supported" } }) },
56
+ signOut: async () => {},
57
+ useSession: () => ({ data: null, isPending: false }),
58
+ };
59
+
60
+ /* Static SVG icons — hoisted to avoid recreation on every render */
61
+ const MenuIcon = (
62
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5">
63
+ <path fillRule="evenodd" d="M2 4.75A.75.75 0 0 1 2.75 4h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 4.75ZM2 10a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 10Zm0 5.25a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z" clipRule="evenodd" />
64
+ </svg>
65
+ );
66
+
67
+ const AtlasLogo = (
68
+ <svg data-atlas-logo viewBox="0 0 256 256" fill="none" className="h-7 w-7 shrink-0 text-primary" aria-hidden="true">
69
+ <path d="M128 24 L232 208 L24 208 Z" stroke="currentColor" strokeWidth="14" fill="none" strokeLinejoin="round"/>
70
+ <circle cx="128" cy="28" r="16" fill="currentColor"/>
71
+ </svg>
72
+ );
73
+
74
+ const THEME_OPTIONS = [
75
+ { value: "light", label: "Light", icon: Sun },
76
+ { value: "dark", label: "Dark", icon: Moon },
77
+ { value: "system", label: "System", icon: Monitor },
78
+ ] as const satisfies readonly { value: ThemeMode; label: string; icon: typeof Sun }[];
79
+
80
+ function ThemeToggle() {
81
+ const mode = useThemeMode();
82
+ const CurrentIcon = mode === "dark" ? Moon : mode === "light" ? Sun : Monitor;
83
+
84
+ return (
85
+ <DropdownMenu>
86
+ <DropdownMenuTrigger asChild>
87
+ <Button variant="ghost" size="icon" className="size-11 sm:size-8 text-zinc-500 dark:text-zinc-400">
88
+ <CurrentIcon className="size-4" />
89
+ <span className="sr-only">Toggle theme</span>
90
+ </Button>
91
+ </DropdownMenuTrigger>
92
+ <DropdownMenuContent align="end">
93
+ {THEME_OPTIONS.map(({ value, label, icon: Icon }) => (
94
+ <DropdownMenuItem
95
+ key={value}
96
+ onClick={() => setTheme(value)}
97
+ className={mode === value ? "bg-accent" : ""}
98
+ >
99
+ <Icon className="mr-2 size-4" />
100
+ {label}
101
+ </DropdownMenuItem>
102
+ ))}
103
+ </DropdownMenuContent>
104
+ </DropdownMenu>
105
+ );
106
+ }
107
+
108
+ function SaveButton({
109
+ conversationId,
110
+ conversations,
111
+ onStar,
112
+ }: {
113
+ conversationId: string;
114
+ conversations: { id: string; starred: boolean }[];
115
+ onStar: (id: string, starred: boolean) => Promise<boolean>;
116
+ }) {
117
+ const isStarred = conversations.find((c) => c.id === conversationId)?.starred ?? false;
118
+ const [pending, setPending] = useState(false);
119
+
120
+ async function handleToggle() {
121
+ setPending(true);
122
+ try {
123
+ await onStar(conversationId, !isStarred);
124
+ } catch (err) {
125
+ console.warn("Failed to update star:", err);
126
+ } finally {
127
+ setPending(false);
128
+ }
129
+ }
130
+
131
+ return (
132
+ <Button
133
+ variant="ghost"
134
+ size="xs"
135
+ onClick={handleToggle}
136
+ disabled={pending}
137
+ className={
138
+ isStarred
139
+ ? "text-amber-500 hover:text-amber-600 dark:text-amber-400 dark:hover:text-amber-300"
140
+ : "text-zinc-400 hover:text-amber-500 dark:text-zinc-500 dark:hover:text-amber-400"
141
+ }
142
+ aria-label={isStarred ? "Unsave conversation" : "Save conversation"}
143
+ >
144
+ <Star className="h-3.5 w-3.5" fill={isStarred ? "currentColor" : "none"} />
145
+ <span>{isStarred ? "Saved" : "Save"}</span>
146
+ </Button>
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Standalone Atlas chat component.
152
+ *
153
+ * Wraps itself in AtlasUIProvider so consumers only need to pass props.
154
+ * For advanced usage (e.g. custom auth client), pass `authClient`.
155
+ */
156
+ export function AtlasChat(props: AtlasChatProps) {
157
+ const {
158
+ apiUrl,
159
+ apiKey: propApiKey,
160
+ theme: propTheme = "system",
161
+ sidebar = false,
162
+ schemaExplorer: schemaExplorerEnabled = false,
163
+ authClient = noopAuthClient,
164
+ toolRenderers,
165
+ } = props;
166
+
167
+ // Apply theme from props on mount and when it changes
168
+ useEffect(() => {
169
+ setTheme(propTheme);
170
+ }, [propTheme]);
171
+
172
+ return (
173
+ <AtlasUIProvider config={{ apiUrl, authClient }}>
174
+ <AtlasChatInner
175
+ propApiKey={propApiKey}
176
+ sidebar={sidebar}
177
+ schemaExplorerEnabled={schemaExplorerEnabled}
178
+ toolRenderers={toolRenderers}
179
+ />
180
+ </AtlasUIProvider>
181
+ );
182
+ }
183
+
184
+ function AtlasChatInner({
185
+ propApiKey,
186
+ sidebar,
187
+ schemaExplorerEnabled,
188
+ toolRenderers,
189
+ }: {
190
+ propApiKey?: string;
191
+ sidebar: boolean;
192
+ schemaExplorerEnabled: boolean;
193
+ toolRenderers?: ToolRenderers;
194
+ }) {
195
+ const { apiUrl, isCrossOrigin, authClient } = useAtlasConfig();
196
+ const dark = useDarkMode();
197
+ const [input, setInput] = useState("");
198
+ const [authMode, setAuthMode] = useState<AuthMode | null>(null);
199
+ const [healthWarning, setHealthWarning] = useState("");
200
+ const [healthFailed, setHealthFailed] = useState(false);
201
+ const [apiKey, setApiKey] = useState(propApiKey ?? "");
202
+ const [conversationId, setConversationId] = useState<string | null>(null);
203
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
204
+ const [loadingConversation, setLoadingConversation] = useState(false);
205
+ const [passwordChangeRequired, setPasswordChangeRequired] = useState(false);
206
+ const [schemaExplorerOpen, setSchemaExplorerOpen] = useState(false);
207
+ const scrollRef = useRef<HTMLDivElement>(null);
208
+
209
+ // Sync prop API key changes
210
+ useEffect(() => {
211
+ if (propApiKey !== undefined) setApiKey(propApiKey);
212
+ }, [propApiKey]);
213
+
214
+ const managedSession = authClient.useSession();
215
+ const authResolved = authMode !== null;
216
+ const isManaged = authMode === "managed";
217
+ const isSignedIn = isManaged && !!managedSession.data?.user;
218
+
219
+ const getHeaders = useCallback(() => {
220
+ const headers: Record<string, string> = {};
221
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
222
+ return headers;
223
+ }, [apiKey]);
224
+
225
+ const getCredentials = useCallback((): RequestCredentials => {
226
+ return isCrossOrigin ? "include" : "same-origin";
227
+ }, [isCrossOrigin]);
228
+
229
+ const convos = useConversations({
230
+ apiUrl,
231
+ enabled: sidebar,
232
+ getHeaders,
233
+ getCredentials,
234
+ });
235
+
236
+ const refreshConvosRef = useRef(convos.refresh);
237
+ refreshConvosRef.current = convos.refresh;
238
+
239
+ const conversationIdRef = useRef(conversationId);
240
+ conversationIdRef.current = conversationId;
241
+
242
+ // Load API key from sessionStorage on mount + fetch auth mode
243
+ useEffect(() => {
244
+ if (!propApiKey) {
245
+ try {
246
+ const stored = sessionStorage.getItem(API_KEY_STORAGE_KEY);
247
+ if (stored) setApiKey(stored);
248
+ } catch (err) {
249
+ console.warn("Cannot read API key from sessionStorage:", err);
250
+ }
251
+ }
252
+
253
+ async function fetchHealth(attempt: number): Promise<void> {
254
+ try {
255
+ const res = await fetch(`${apiUrl}/api/health`, {
256
+ credentials: isCrossOrigin ? "include" : "same-origin",
257
+ });
258
+ if (!res.ok) {
259
+ console.warn(`Health check returned ${res.status}`);
260
+ if (attempt < 2) {
261
+ await new Promise((r) => setTimeout(r, 2000));
262
+ return fetchHealth(attempt + 1);
263
+ }
264
+ setHealthWarning("Health check failed — check server logs. Try refreshing the page.");
265
+ setHealthFailed(true);
266
+ setAuthMode("none");
267
+ return;
268
+ }
269
+ const data = await res.json();
270
+ const mode = data?.checks?.auth?.mode;
271
+ if (typeof mode === "string" && AUTH_MODES.includes(mode as AuthMode)) {
272
+ setAuthMode(mode as AuthMode);
273
+ } else {
274
+ console.warn("Health check succeeded but returned no valid auth mode:", data);
275
+ setHealthWarning("Could not determine authentication mode from the server.");
276
+ setAuthMode("none");
277
+ }
278
+ if (typeof data?.brandColor === "string" && OKLCH_RE.test(data.brandColor)) {
279
+ applyBrandColor(data.brandColor);
280
+ }
281
+ } catch (err) {
282
+ console.warn("Health endpoint unavailable:", err);
283
+ if (attempt < 2) {
284
+ await new Promise((r) => setTimeout(r, 2000));
285
+ return fetchHealth(attempt + 1);
286
+ }
287
+ setHealthWarning("Unable to reach the API server. Try refreshing the page.");
288
+ setHealthFailed(true);
289
+ setAuthMode("none");
290
+ }
291
+ }
292
+ fetchHealth(1);
293
+ }, [apiUrl, isCrossOrigin]);
294
+
295
+ // Fetch conversation list after auth is resolved
296
+ useEffect(() => {
297
+ if (sidebar) convos.fetchList();
298
+ }, [authMode, sidebar, convos.fetchList]);
299
+
300
+ // Check if managed auth user needs to change their default password
301
+ useEffect(() => {
302
+ if (!isManaged || !managedSession.data?.user) return;
303
+
304
+ async function checkPasswordStatus() {
305
+ try {
306
+ const res = await fetch(`${apiUrl}/api/v1/admin/me/password-status`, {
307
+ credentials: isCrossOrigin ? "include" : "same-origin",
308
+ });
309
+ if (!res.ok) {
310
+ console.warn(`Password status check returned HTTP ${res.status}`);
311
+ return;
312
+ }
313
+ const data = await res.json();
314
+ if (data.passwordChangeRequired) setPasswordChangeRequired(true);
315
+ } catch (err) {
316
+ console.warn("Failed to check password status:", err);
317
+ }
318
+ }
319
+ checkPasswordStatus();
320
+ }, [isManaged, managedSession.data?.user, apiUrl, isCrossOrigin]);
321
+
322
+ const handleSaveApiKey = useCallback((key: string) => {
323
+ setApiKey(key);
324
+ try {
325
+ sessionStorage.setItem(API_KEY_STORAGE_KEY, key);
326
+ } catch (err) {
327
+ console.warn("Could not persist API key to sessionStorage:", err);
328
+ }
329
+ }, []);
330
+
331
+ const transport = useMemo(() => {
332
+ const headers: Record<string, string> = {};
333
+ if (apiKey) {
334
+ headers["Authorization"] = `Bearer ${apiKey}`;
335
+ }
336
+ return new DefaultChatTransport({
337
+ api: `${apiUrl}/api/chat`,
338
+ headers,
339
+ credentials: isCrossOrigin ? "include" : undefined,
340
+ body: () => (conversationIdRef.current ? { conversationId: conversationIdRef.current } : {}),
341
+ fetch: (async (input, init) => {
342
+ const response = await globalThis.fetch(input, init);
343
+ const convId = response.headers.get("x-conversation-id");
344
+ if (convId && convId !== conversationIdRef.current) {
345
+ setConversationId(convId);
346
+ setTimeout(() => {
347
+ refreshConvosRef.current().catch((err) => {
348
+ console.warn("Sidebar refresh failed:", err);
349
+ });
350
+ }, 500);
351
+ }
352
+ return response;
353
+ }) as typeof fetch,
354
+ });
355
+ }, [apiKey, apiUrl, isCrossOrigin]);
356
+
357
+ const { messages, setMessages, sendMessage, status, error } = useChat({ transport });
358
+
359
+ const isLoading = status === "streaming" || status === "submitted";
360
+
361
+ useEffect(() => {
362
+ const el = scrollRef.current;
363
+ if (!el) return;
364
+ const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
365
+ if (isNearBottom) el.scrollTop = el.scrollHeight;
366
+ }, [messages, status]);
367
+
368
+ function handleSend(text: string) {
369
+ if (!text.trim()) return;
370
+ const saved = text;
371
+ setInput("");
372
+ sendMessage({ text: saved }).catch((err) => {
373
+ console.error("Failed to send message:", err);
374
+ setInput(saved);
375
+ setHealthWarning("Failed to send message. Please try again.");
376
+ setTimeout(() => setHealthWarning(""), 5000);
377
+ });
378
+ }
379
+
380
+ async function handleSelectConversation(id: string) {
381
+ if (loadingConversation) return;
382
+ setLoadingConversation(true);
383
+ try {
384
+ 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);
396
+ setHealthWarning("Failed to load conversation. Please try again.");
397
+ setTimeout(() => setHealthWarning(""), 5000);
398
+ } finally {
399
+ setLoadingConversation(false);
400
+ }
401
+ }
402
+
403
+ function handleNewChat() {
404
+ setMessages([]);
405
+ setConversationId(null);
406
+ convos.setSelectedId(null);
407
+ setInput("");
408
+ setMobileMenuOpen(false);
409
+ }
410
+
411
+ if (!authResolved || (isManaged && managedSession.isPending)) {
412
+ return (
413
+ <DarkModeContext.Provider value={dark}>
414
+ <div className="atlas-root flex h-dvh items-center justify-center bg-white dark:bg-zinc-950" />
415
+ </DarkModeContext.Provider>
416
+ );
417
+ }
418
+
419
+ const showSidebar = sidebar && convos.available;
420
+
421
+ return (
422
+ <DarkModeContext.Provider value={dark}>
423
+ <div className="atlas-root flex h-dvh">
424
+ {showSidebar && (
425
+ <ConversationSidebar
426
+ conversations={convos.conversations}
427
+ selectedId={convos.selectedId}
428
+ loading={convos.loading}
429
+ onSelect={handleSelectConversation}
430
+ onDelete={(id) => convos.deleteConversation(id)}
431
+ onStar={(id, starred) => convos.starConversation(id, starred)}
432
+ onNewChat={handleNewChat}
433
+ mobileOpen={mobileMenuOpen}
434
+ onMobileClose={() => setMobileMenuOpen(false)}
435
+ />
436
+ )}
437
+
438
+ <main className="flex flex-1 flex-col overflow-hidden">
439
+ <div className="mx-auto flex w-full max-w-4xl flex-1 flex-col overflow-hidden p-4">
440
+ <header className="mb-4 flex-none border-b border-zinc-100 pb-3 dark:border-zinc-800">
441
+ <div className="flex items-center justify-between">
442
+ <div className="flex items-center gap-3">
443
+ {showSidebar && (
444
+ <button
445
+ 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"
447
+ aria-label="Open conversation history"
448
+ >
449
+ {MenuIcon}
450
+ </button>
451
+ )}
452
+ <div className="flex items-center gap-2.5">
453
+ {AtlasLogo}
454
+ <div>
455
+ <h1 className="text-xl font-semibold tracking-tight">Atlas</h1>
456
+ <p className="text-sm text-zinc-500">Ask your data anything</p>
457
+ </div>
458
+ </div>
459
+ </div>
460
+ <div className="flex items-center gap-2">
461
+ {schemaExplorerEnabled && (
462
+ <Button
463
+ variant="ghost"
464
+ size="icon"
465
+ className="size-11 sm:size-8 text-zinc-500 dark:text-zinc-400"
466
+ onClick={() => setSchemaExplorerOpen(true)}
467
+ aria-label="Open schema explorer"
468
+ >
469
+ <TableProperties className="size-4" />
470
+ </Button>
471
+ )}
472
+ <ThemeToggle />
473
+ {isSignedIn && (
474
+ <>
475
+ <span className="hidden text-xs text-zinc-500 sm:inline dark:text-zinc-400">
476
+ {managedSession.data?.user?.email}
477
+ </span>
478
+ <button
479
+ onClick={() => {
480
+ authClient.signOut().catch((err: unknown) => {
481
+ console.error("Sign out failed:", err);
482
+ setHealthWarning("Sign out failed. Please try again.");
483
+ setTimeout(() => setHealthWarning(""), 5000);
484
+ });
485
+ }}
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"
487
+ >
488
+ Sign out
489
+ </button>
490
+ </>
491
+ )}
492
+ </div>
493
+ </div>
494
+ </header>
495
+
496
+ {healthWarning && (
497
+ <p className="mb-2 text-xs text-zinc-400 dark:text-zinc-500">{healthWarning}</p>
498
+ )}
499
+
500
+ {isManaged && !isSignedIn ? (
501
+ <ManagedAuthCard />
502
+ ) : (
503
+ <ActionAuthProvider getHeaders={getHeaders} getCredentials={getCredentials}>
504
+ {authMode === "simple-key" && !propApiKey && (
505
+ <div className="mb-3 flex-none">
506
+ <ApiKeyBar apiKey={apiKey} onSave={handleSaveApiKey} />
507
+ </div>
508
+ )}
509
+
510
+ <ScrollArea viewportRef={scrollRef} className="min-h-0 flex-1">
511
+ <div data-atlas-messages className="space-y-4 pb-4 pr-3">
512
+ {messages.length === 0 && !error && (
513
+ <div className="flex h-full flex-col items-center justify-center gap-6">
514
+ <div className="text-center">
515
+ <p className="text-lg font-medium text-zinc-500 dark:text-zinc-400">
516
+ What would you like to know?
517
+ </p>
518
+ <p className="mt-1 text-sm text-zinc-400 dark:text-zinc-600">
519
+ Ask a question about your data to get started
520
+ </p>
521
+ </div>
522
+ <div className="grid w-full max-w-lg grid-cols-1 gap-2 sm:grid-cols-2">
523
+ {STARTER_PROMPTS.map((prompt) => (
524
+ <button
525
+ key={prompt}
526
+ 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"
528
+ >
529
+ {prompt}
530
+ </button>
531
+ ))}
532
+ </div>
533
+ </div>
534
+ )}
535
+
536
+ {messages.map((m, msgIndex) => {
537
+ if (m.role === "user") {
538
+ return (
539
+ <div key={m.id} className="flex justify-end">
540
+ <div className="max-w-[85%] rounded-xl bg-blue-600 px-4 py-3 text-sm text-white">
541
+ {m.parts?.map((part, i) =>
542
+ part.type === "text" ? (
543
+ <p key={i} className="whitespace-pre-wrap">
544
+ {part.text}
545
+ </p>
546
+ ) : null,
547
+ )}
548
+ </div>
549
+ </div>
550
+ );
551
+ }
552
+
553
+ const isLastAssistant =
554
+ m.role === "assistant" &&
555
+ msgIndex === messages.length - 1;
556
+
557
+ const lastTextWithSuggestions = m.parts
558
+ ?.filter((p): p is typeof p & { type: "text"; text: string } => p.type === "text" && !!p.text.trim())
559
+ .findLast((p) => parseSuggestions(p.text).suggestions.length > 0);
560
+ const suggestions = lastTextWithSuggestions
561
+ ? parseSuggestions(lastTextWithSuggestions.text).suggestions
562
+ : [];
563
+
564
+ return (
565
+ <div key={m.id} className="space-y-2">
566
+ {m.parts?.map((part, i) => {
567
+ if (part.type === "text" && part.text.trim()) {
568
+ const displayText = parseSuggestions(part.text).text;
569
+ if (!displayText.trim()) return null;
570
+ return (
571
+ <div key={i} className="max-w-[90%]">
572
+ <div className="rounded-xl bg-zinc-100 px-4 py-3 text-sm text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
573
+ <Markdown content={displayText} />
574
+ </div>
575
+ </div>
576
+ );
577
+ }
578
+ if (isToolUIPart(part)) {
579
+ return (
580
+ <div key={i} className="max-w-[95%]">
581
+ <ToolPart part={part} toolRenderers={toolRenderers} />
582
+ </div>
583
+ );
584
+ }
585
+ return null;
586
+ })}
587
+ {isLastAssistant && !isLoading && (
588
+ <>
589
+ <FollowUpChips
590
+ suggestions={suggestions}
591
+ onSelect={handleSend}
592
+ />
593
+ {conversationId && sidebar && convos.available && (
594
+ <SaveButton
595
+ conversationId={conversationId}
596
+ conversations={convos.conversations}
597
+ onStar={convos.starConversation}
598
+ />
599
+ )}
600
+ </>
601
+ )}
602
+ </div>
603
+ );
604
+ })}
605
+
606
+ {isLoading && messages.length > 0 && <TypingIndicator />}
607
+ </div>
608
+ </ScrollArea>
609
+
610
+ {error && <ErrorBanner error={error} authMode={authMode} />}
611
+
612
+ <form
613
+ data-atlas-form
614
+ onSubmit={(e) => {
615
+ e.preventDefault();
616
+ handleSend(input);
617
+ }}
618
+ className="flex flex-none gap-2 border-t border-zinc-100 pt-4 dark:border-zinc-800"
619
+ >
620
+ <input
621
+ data-atlas-input
622
+ value={input}
623
+ onChange={(e) => setInput(e.target.value)}
624
+ 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"
626
+ disabled={isLoading || healthFailed}
627
+ />
628
+ <button
629
+ 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"
632
+ >
633
+ Ask
634
+ </button>
635
+ </form>
636
+ </ActionAuthProvider>
637
+ )}
638
+ </div>
639
+ </main>
640
+ </div>
641
+ {schemaExplorerEnabled && (
642
+ <SchemaExplorer
643
+ open={schemaExplorerOpen}
644
+ onOpenChange={setSchemaExplorerOpen}
645
+ onInsertQuery={(text) => setInput(text)}
646
+ getHeaders={getHeaders}
647
+ getCredentials={getCredentials}
648
+ />
649
+ )}
650
+ <ChangePasswordDialog
651
+ open={passwordChangeRequired}
652
+ onComplete={() => setPasswordChangeRequired(false)}
653
+ />
654
+ </DarkModeContext.Provider>
655
+ );
656
+ }