@databricks/appkit-ui 0.16.0 → 0.17.0

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.
@@ -9,12 +9,18 @@ interface GenieChatMessageListProps {
9
9
  status: GenieChatStatus;
10
10
  /** Additional CSS class for the scroll area */
11
11
  className?: string;
12
+ /** Whether a previous page of older messages exists */
13
+ hasPreviousPage?: boolean;
14
+ /** Callback to fetch the previous page of messages */
15
+ onFetchPreviousPage?: () => void;
12
16
  }
13
17
  /** Scrollable message list that renders Genie chat messages with auto-scroll, skeleton loaders, and a streaming indicator. */
14
18
  declare function GenieChatMessageList({
15
19
  messages,
16
20
  status,
17
- className
21
+ className,
22
+ hasPreviousPage,
23
+ onFetchPreviousPage
18
24
  }: GenieChatMessageListProps): react_jsx_runtime0.JSX.Element;
19
25
  //#endregion
20
26
  export { GenieChatMessageList };
@@ -1 +1 @@
1
- {"version":3,"file":"genie-chat-message-list.d.ts","names":[],"sources":["../../../src/react/genie/genie-chat-message-list.tsx"],"mappings":";;;;UAQiB,yBAAA;;EAEf,QAAA,EAAU,gBAAA;EAFK;EAIf,MAAA,EAAQ,eAAA;;EAER,SAAA;AAAA;;iBA4Bc,oBAAA,CAAA;EACd,QAAA;EACA,MAAA;EACA;AAAA,GACC,yBAAA,GAAyB,kBAAA,CAAA,GAAA,CAAA,OAAA"}
1
+ {"version":3,"file":"genie-chat-message-list.d.ts","names":[],"sources":["../../../src/react/genie/genie-chat-message-list.tsx"],"mappings":";;;;UAQiB,yBAAA;;EAEf,QAAA,EAAU,gBAAA;EAFK;EAIf,MAAA,EAAQ,eAAA;;EAER,SAAA;EAJA;EAMA,eAAA;EAJA;EAMA,mBAAA;AAAA;;iBAkIc,oBAAA,CAAA;EACd,QAAA;EACA,MAAA;EACA,SAAA;EACA,eAAA;EACA;AAAA,GACC,yBAAA,GAAyB,kBAAA,CAAA,GAAA,CAAA,OAAA"}
@@ -3,7 +3,7 @@ import { ScrollArea } from "../ui/scroll-area.js";
3
3
  import { Skeleton } from "../ui/skeleton.js";
4
4
  import { Spinner } from "../ui/spinner.js";
5
5
  import { GenieChatMessage } from "./genie-chat-message.js";
6
- import { useEffect, useRef } from "react";
6
+ import { useEffect, useLayoutEffect, useRef } from "react";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
8
 
9
9
  //#region src/react/genie/genie-chat-message-list.tsx
@@ -16,27 +16,102 @@ const STATUS_LABELS = {
16
16
  function formatStatus(status) {
17
17
  return STATUS_LABELS[status] ?? status.replace(/_/g, " ").toLowerCase();
18
18
  }
19
- function StreamingIndicator({ messages }) {
20
- const last = messages[messages.length - 1];
21
- if (last?.role === "assistant" && last.id === "") return /* @__PURE__ */ jsxs("div", {
22
- className: "flex items-center gap-2 text-sm text-muted-foreground px-11",
23
- children: [/* @__PURE__ */ jsx(Spinner, { className: "h-3 w-3" }), /* @__PURE__ */ jsx("span", { children: formatStatus(last.status) })]
24
- });
25
- return null;
19
+ function getViewport(scrollRef) {
20
+ return scrollRef.current?.querySelector("[data-slot=\"scroll-area-viewport\"]");
26
21
  }
27
- /** Scrollable message list that renders Genie chat messages with auto-scroll, skeleton loaders, and a streaming indicator. */
28
- function GenieChatMessageList({ messages, status, className }) {
29
- const scrollRef = useRef(null);
22
+ /**
23
+ * Manages scroll position: scrolls to bottom on append/initial load,
24
+ * preserves position when older messages are prepended.
25
+ */
26
+ function useScrollManagement(scrollRef, messages, status) {
27
+ const prevFirstMessageIdRef = useRef(null);
28
+ const prevScrollHeightRef = useRef(0);
29
+ const prevMessageCountRef = useRef(0);
30
30
  useEffect(() => {
31
- const viewport = scrollRef.current?.querySelector("[data-slot=\"scroll-area-viewport\"]");
32
- if (viewport) viewport.scrollTop = viewport.scrollHeight;
31
+ const viewport = getViewport(scrollRef);
32
+ if (!viewport) return;
33
+ const observer = new ResizeObserver(() => {
34
+ prevScrollHeightRef.current = viewport.scrollHeight;
35
+ });
36
+ observer.observe(viewport);
37
+ return () => observer.disconnect();
38
+ }, [scrollRef]);
39
+ useLayoutEffect(() => {
40
+ const viewport = getViewport(scrollRef);
41
+ if (!viewport) return;
42
+ const count = messages.length;
43
+ const countChanged = count !== prevMessageCountRef.current;
44
+ prevMessageCountRef.current = count;
45
+ if (!countChanged) {
46
+ prevScrollHeightRef.current = viewport.scrollHeight;
47
+ return;
48
+ }
49
+ const firstMessageId = messages[0]?.id ?? null;
50
+ if (prevFirstMessageIdRef.current !== null && firstMessageId !== prevFirstMessageIdRef.current && prevScrollHeightRef.current > 0) {
51
+ const delta = viewport.scrollHeight - prevScrollHeightRef.current;
52
+ viewport.scrollTop += delta;
53
+ } else viewport.scrollTop = viewport.scrollHeight;
54
+ prevFirstMessageIdRef.current = firstMessageId;
55
+ prevScrollHeightRef.current = viewport.scrollHeight;
33
56
  }, [messages.length, status]);
57
+ }
58
+ /**
59
+ * Observes a sentinel element at the top of the scroll area and triggers
60
+ * `onFetchPreviousPage` when the user scrolls to the top (only if content overflows).
61
+ * Returns a ref to attach to the sentinel element.
62
+ */
63
+ function useLoadOlderOnScroll(scrollRef, shouldObserve, onFetchPreviousPage) {
64
+ const sentinelRef = useRef(null);
65
+ const onFetchPreviousPageRef = useRef(onFetchPreviousPage);
66
+ onFetchPreviousPageRef.current = onFetchPreviousPage;
67
+ useEffect(() => {
68
+ const sentinel = sentinelRef.current;
69
+ const viewport = getViewport(scrollRef);
70
+ if (!sentinel || !viewport || !shouldObserve) return;
71
+ let armed = false;
72
+ const frameId = requestAnimationFrame(() => {
73
+ armed = true;
74
+ });
75
+ const observer = new IntersectionObserver((entries) => {
76
+ if (!armed) return;
77
+ const isScrollable = viewport.scrollHeight > viewport.clientHeight;
78
+ if (entries[0]?.isIntersecting && isScrollable) onFetchPreviousPageRef.current?.();
79
+ }, {
80
+ root: viewport,
81
+ threshold: 0
82
+ });
83
+ observer.observe(sentinel);
84
+ return () => {
85
+ cancelAnimationFrame(frameId);
86
+ observer.disconnect();
87
+ };
88
+ }, [scrollRef, shouldObserve]);
89
+ return sentinelRef;
90
+ }
91
+ /** Scrollable message list that renders Genie chat messages with auto-scroll, skeleton loaders, and a streaming indicator. */
92
+ function GenieChatMessageList({ messages, status, className, hasPreviousPage = false, onFetchPreviousPage }) {
93
+ const scrollRef = useRef(null);
94
+ const sentinelRef = useLoadOlderOnScroll(scrollRef, hasPreviousPage && status !== "loading-older", onFetchPreviousPage);
95
+ useScrollManagement(scrollRef, messages, status);
96
+ const lastMessage = messages[messages.length - 1];
97
+ const showStreamingIndicator = status === "streaming" && lastMessage?.role === "assistant" && lastMessage.id === "";
34
98
  return /* @__PURE__ */ jsx(ScrollArea, {
35
99
  ref: scrollRef,
36
100
  className: cn("flex-1 min-h-0 p-4", className),
37
101
  children: /* @__PURE__ */ jsxs("div", {
38
102
  className: "flex flex-col gap-4",
39
103
  children: [
104
+ hasPreviousPage && /* @__PURE__ */ jsx("div", {
105
+ ref: sentinelRef,
106
+ className: "h-px"
107
+ }),
108
+ status === "loading-older" && /* @__PURE__ */ jsxs("div", {
109
+ className: "flex items-center justify-center gap-2 py-2",
110
+ children: [/* @__PURE__ */ jsx(Spinner, { className: "h-3 w-3" }), /* @__PURE__ */ jsx("span", {
111
+ className: "text-sm text-muted-foreground",
112
+ children: "Loading older messages..."
113
+ })]
114
+ }),
40
115
  status === "loading-history" && messages.length === 0 && /* @__PURE__ */ jsxs("div", {
41
116
  className: "flex flex-col gap-4",
42
117
  children: [
@@ -45,11 +120,11 @@ function GenieChatMessageList({ messages, status, className }) {
45
120
  /* @__PURE__ */ jsx(Skeleton, { className: "h-12 w-2/3 self-end" })
46
121
  ]
47
122
  }),
48
- messages.map((msg) => {
49
- if (msg.role === "assistant" && msg.id === "" && !msg.content) return null;
50
- return /* @__PURE__ */ jsx(GenieChatMessage, { message: msg }, msg.id);
123
+ messages.filter((msg) => msg.role !== "assistant" || msg.id !== "" || msg.content).map((msg) => /* @__PURE__ */ jsx(GenieChatMessage, { message: msg }, msg.id)),
124
+ showStreamingIndicator && /* @__PURE__ */ jsxs("div", {
125
+ className: "flex items-center gap-2 text-sm text-muted-foreground px-11",
126
+ children: [/* @__PURE__ */ jsx(Spinner, { className: "h-3 w-3" }), /* @__PURE__ */ jsx("span", { children: formatStatus(lastMessage.status) })]
51
127
  }),
52
- status === "streaming" && messages.length > 0 && /* @__PURE__ */ jsx(StreamingIndicator, { messages }),
53
128
  messages.length === 0 && status === "idle" && /* @__PURE__ */ jsx("div", {
54
129
  className: "flex items-center justify-center h-full text-muted-foreground text-sm py-12",
55
130
  children: "Start a conversation by typing a question below."
@@ -1 +1 @@
1
- {"version":3,"file":"genie-chat-message-list.js","names":[],"sources":["../../../src/react/genie/genie-chat-message-list.tsx"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport { cn } from \"../lib/utils\";\nimport { ScrollArea } from \"../ui/scroll-area\";\nimport { Skeleton } from \"../ui/skeleton\";\nimport { Spinner } from \"../ui/spinner\";\nimport { GenieChatMessage } from \"./genie-chat-message\";\nimport type { GenieChatStatus, GenieMessageItem } from \"./types\";\n\nexport interface GenieChatMessageListProps {\n /** Array of messages to display */\n messages: GenieMessageItem[];\n /** Current chat status (controls loading indicators and skeleton placeholders) */\n status: GenieChatStatus;\n /** Additional CSS class for the scroll area */\n className?: string;\n}\n\nconst STATUS_LABELS: Record<string, string> = {\n ASKING_AI: \"Asking AI...\",\n EXECUTING_QUERY: \"Executing query...\",\n FILTERING_RESULTS: \"Filtering results...\",\n COMPLETED: \"Done\",\n};\n\nfunction formatStatus(status: string): string {\n return STATUS_LABELS[status] ?? status.replace(/_/g, \" \").toLowerCase();\n}\n\nfunction StreamingIndicator({ messages }: { messages: GenieMessageItem[] }) {\n const last = messages[messages.length - 1];\n if (last?.role === \"assistant\" && last.id === \"\") {\n return (\n <div className=\"flex items-center gap-2 text-sm text-muted-foreground px-11\">\n <Spinner className=\"h-3 w-3\" />\n <span>{formatStatus(last.status)}</span>\n </div>\n );\n }\n return null;\n}\n\n/** Scrollable message list that renders Genie chat messages with auto-scroll, skeleton loaders, and a streaming indicator. */\nexport function GenieChatMessageList({\n messages,\n status,\n className,\n}: GenieChatMessageListProps) {\n const scrollRef = useRef<HTMLDivElement>(null);\n\n // Scroll only the ScrollArea viewport, not the page\n // biome-ignore lint/correctness/useExhaustiveDependencies: intentional triggers for auto-scroll\n useEffect(() => {\n const viewport = scrollRef.current?.querySelector<HTMLElement>(\n '[data-slot=\"scroll-area-viewport\"]',\n );\n if (viewport) {\n viewport.scrollTop = viewport.scrollHeight;\n }\n }, [messages.length, status]);\n\n return (\n <ScrollArea ref={scrollRef} className={cn(\"flex-1 min-h-0 p-4\", className)}>\n <div className=\"flex flex-col gap-4\">\n {status === \"loading-history\" && messages.length === 0 && (\n <div className=\"flex flex-col gap-4\">\n <Skeleton className=\"h-12 w-3/4\" />\n <Skeleton className=\"h-20 w-4/5 self-start\" />\n <Skeleton className=\"h-12 w-2/3 self-end\" />\n </div>\n )}\n\n {messages.map((msg) => {\n if (msg.role === \"assistant\" && msg.id === \"\" && !msg.content) {\n return null;\n }\n return <GenieChatMessage key={msg.id} message={msg} />;\n })}\n\n {status === \"streaming\" && messages.length > 0 && (\n <StreamingIndicator messages={messages} />\n )}\n\n {messages.length === 0 && status === \"idle\" && (\n <div className=\"flex items-center justify-center h-full text-muted-foreground text-sm py-12\">\n Start a conversation by typing a question below.\n </div>\n )}\n </div>\n </ScrollArea>\n );\n}\n"],"mappings":";;;;;;;;;AAiBA,MAAM,gBAAwC;CAC5C,WAAW;CACX,iBAAiB;CACjB,mBAAmB;CACnB,WAAW;CACZ;AAED,SAAS,aAAa,QAAwB;AAC5C,QAAO,cAAc,WAAW,OAAO,QAAQ,MAAM,IAAI,CAAC,aAAa;;AAGzE,SAAS,mBAAmB,EAAE,YAA8C;CAC1E,MAAM,OAAO,SAAS,SAAS,SAAS;AACxC,KAAI,MAAM,SAAS,eAAe,KAAK,OAAO,GAC5C,QACE,qBAAC;EAAI,WAAU;aACb,oBAAC,WAAQ,WAAU,YAAY,EAC/B,oBAAC,oBAAM,aAAa,KAAK,OAAO,GAAQ;GACpC;AAGV,QAAO;;;AAIT,SAAgB,qBAAqB,EACnC,UACA,QACA,aAC4B;CAC5B,MAAM,YAAY,OAAuB,KAAK;AAI9C,iBAAgB;EACd,MAAM,WAAW,UAAU,SAAS,cAClC,uCACD;AACD,MAAI,SACF,UAAS,YAAY,SAAS;IAE/B,CAAC,SAAS,QAAQ,OAAO,CAAC;AAE7B,QACE,oBAAC;EAAW,KAAK;EAAW,WAAW,GAAG,sBAAsB,UAAU;YACxE,qBAAC;GAAI,WAAU;;IACZ,WAAW,qBAAqB,SAAS,WAAW,KACnD,qBAAC;KAAI,WAAU;;MACb,oBAAC,YAAS,WAAU,eAAe;MACnC,oBAAC,YAAS,WAAU,0BAA0B;MAC9C,oBAAC,YAAS,WAAU,wBAAwB;;MACxC;IAGP,SAAS,KAAK,QAAQ;AACrB,SAAI,IAAI,SAAS,eAAe,IAAI,OAAO,MAAM,CAAC,IAAI,QACpD,QAAO;AAET,YAAO,oBAAC,oBAA8B,SAAS,OAAjB,IAAI,GAAoB;MACtD;IAED,WAAW,eAAe,SAAS,SAAS,KAC3C,oBAAC,sBAA6B,WAAY;IAG3C,SAAS,WAAW,KAAK,WAAW,UACnC,oBAAC;KAAI,WAAU;eAA8E;MAEvF;;IAEJ;GACK"}
1
+ {"version":3,"file":"genie-chat-message-list.js","names":[],"sources":["../../../src/react/genie/genie-chat-message-list.tsx"],"sourcesContent":["import { useEffect, useLayoutEffect, useRef } from \"react\";\nimport { cn } from \"../lib/utils\";\nimport { ScrollArea } from \"../ui/scroll-area\";\nimport { Skeleton } from \"../ui/skeleton\";\nimport { Spinner } from \"../ui/spinner\";\nimport { GenieChatMessage } from \"./genie-chat-message\";\nimport type { GenieChatStatus, GenieMessageItem } from \"./types\";\n\nexport interface GenieChatMessageListProps {\n /** Array of messages to display */\n messages: GenieMessageItem[];\n /** Current chat status (controls loading indicators and skeleton placeholders) */\n status: GenieChatStatus;\n /** Additional CSS class for the scroll area */\n className?: string;\n /** Whether a previous page of older messages exists */\n hasPreviousPage?: boolean;\n /** Callback to fetch the previous page of messages */\n onFetchPreviousPage?: () => void;\n}\n\nconst STATUS_LABELS: Record<string, string> = {\n ASKING_AI: \"Asking AI...\",\n EXECUTING_QUERY: \"Executing query...\",\n FILTERING_RESULTS: \"Filtering results...\",\n COMPLETED: \"Done\",\n};\n\nfunction formatStatus(status: string): string {\n return STATUS_LABELS[status] ?? status.replace(/_/g, \" \").toLowerCase();\n}\n\nfunction getViewport(scrollRef: React.RefObject<HTMLDivElement | null>) {\n return scrollRef.current?.querySelector<HTMLElement>(\n '[data-slot=\"scroll-area-viewport\"]',\n );\n}\n\n/**\n * Manages scroll position: scrolls to bottom on append/initial load,\n * preserves position when older messages are prepended.\n */\nfunction useScrollManagement(\n scrollRef: React.RefObject<HTMLDivElement | null>,\n messages: GenieMessageItem[],\n status: GenieChatStatus,\n) {\n const prevFirstMessageIdRef = useRef<string | null>(null);\n const prevScrollHeightRef = useRef(0);\n const prevMessageCountRef = useRef(0);\n\n // Keep prevScrollHeightRef fresh when async content (images, embeds)\n // changes the viewport height between renders.\n useEffect(() => {\n const viewport = getViewport(scrollRef);\n if (!viewport) return;\n\n const observer = new ResizeObserver(() => {\n prevScrollHeightRef.current = viewport.scrollHeight;\n });\n observer.observe(viewport);\n return () => observer.disconnect();\n }, [scrollRef]);\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: react to message count AND status so prevScrollHeightRef stays accurate when the loading indicator appears/disappears\n useLayoutEffect(() => {\n const viewport = getViewport(scrollRef);\n if (!viewport) return;\n\n const count = messages.length;\n const countChanged = count !== prevMessageCountRef.current;\n prevMessageCountRef.current = count;\n\n // Nothing to do if message count didn't change (e.g. status-only transition)\n if (!countChanged) {\n prevScrollHeightRef.current = viewport.scrollHeight;\n return;\n }\n\n const firstMessageId = messages[0]?.id ?? null;\n const wasPrepend =\n prevFirstMessageIdRef.current !== null &&\n firstMessageId !== prevFirstMessageIdRef.current;\n\n if (wasPrepend && prevScrollHeightRef.current > 0) {\n // Older messages prepended — preserve scroll position\n const delta = viewport.scrollHeight - prevScrollHeightRef.current;\n viewport.scrollTop += delta;\n } else {\n // Messages appended or initial load — scroll to bottom\n viewport.scrollTop = viewport.scrollHeight;\n }\n\n prevFirstMessageIdRef.current = firstMessageId;\n prevScrollHeightRef.current = viewport.scrollHeight;\n }, [messages.length, status]);\n}\n\n/**\n * Observes a sentinel element at the top of the scroll area and triggers\n * `onFetchPreviousPage` when the user scrolls to the top (only if content overflows).\n * Returns a ref to attach to the sentinel element.\n */\nfunction useLoadOlderOnScroll(\n scrollRef: React.RefObject<HTMLDivElement | null>,\n shouldObserve: boolean,\n onFetchPreviousPage?: () => void,\n) {\n const sentinelRef = useRef<HTMLDivElement>(null);\n const onFetchPreviousPageRef = useRef(onFetchPreviousPage);\n onFetchPreviousPageRef.current = onFetchPreviousPage;\n\n useEffect(() => {\n const sentinel = sentinelRef.current;\n const viewport = getViewport(scrollRef);\n if (!sentinel || !viewport || !shouldObserve) return;\n\n // The observer fires synchronously on observe() if the sentinel is\n // already visible. We arm it on the next frame so that synchronous\n // initial fire is ignored, but a real intersection (user genuinely\n // at the top on a short conversation) triggers on subsequent frames.\n let armed = false;\n const frameId = requestAnimationFrame(() => {\n armed = true;\n });\n\n const observer = new IntersectionObserver(\n (entries) => {\n if (!armed) return;\n const isScrollable = viewport.scrollHeight > viewport.clientHeight;\n if (entries[0]?.isIntersecting && isScrollable) {\n onFetchPreviousPageRef.current?.();\n }\n },\n { root: viewport, threshold: 0 },\n );\n\n observer.observe(sentinel);\n return () => {\n cancelAnimationFrame(frameId);\n observer.disconnect();\n };\n }, [scrollRef, shouldObserve]);\n\n return sentinelRef;\n}\n\n/** Scrollable message list that renders Genie chat messages with auto-scroll, skeleton loaders, and a streaming indicator. */\nexport function GenieChatMessageList({\n messages,\n status,\n className,\n hasPreviousPage = false,\n onFetchPreviousPage,\n}: GenieChatMessageListProps) {\n const scrollRef = useRef<HTMLDivElement>(null);\n\n const sentinelRef = useLoadOlderOnScroll(\n scrollRef,\n hasPreviousPage && status !== \"loading-older\",\n onFetchPreviousPage,\n );\n useScrollManagement(scrollRef, messages, status);\n\n const lastMessage = messages[messages.length - 1];\n const showStreamingIndicator =\n status === \"streaming\" &&\n lastMessage?.role === \"assistant\" &&\n lastMessage.id === \"\";\n\n return (\n <ScrollArea ref={scrollRef} className={cn(\"flex-1 min-h-0 p-4\", className)}>\n <div className=\"flex flex-col gap-4\">\n {hasPreviousPage && <div ref={sentinelRef} className=\"h-px\" />}\n\n {status === \"loading-older\" && (\n <div className=\"flex items-center justify-center gap-2 py-2\">\n <Spinner className=\"h-3 w-3\" />\n <span className=\"text-sm text-muted-foreground\">\n Loading older messages...\n </span>\n </div>\n )}\n\n {status === \"loading-history\" && messages.length === 0 && (\n <div className=\"flex flex-col gap-4\">\n <Skeleton className=\"h-12 w-3/4\" />\n <Skeleton className=\"h-20 w-4/5 self-start\" />\n <Skeleton className=\"h-12 w-2/3 self-end\" />\n </div>\n )}\n\n {messages\n .filter(\n (msg) => msg.role !== \"assistant\" || msg.id !== \"\" || msg.content,\n )\n .map((msg) => (\n <GenieChatMessage key={msg.id} message={msg} />\n ))}\n\n {showStreamingIndicator && (\n <div className=\"flex items-center gap-2 text-sm text-muted-foreground px-11\">\n <Spinner className=\"h-3 w-3\" />\n <span>{formatStatus(lastMessage.status)}</span>\n </div>\n )}\n\n {messages.length === 0 && status === \"idle\" && (\n <div className=\"flex items-center justify-center h-full text-muted-foreground text-sm py-12\">\n Start a conversation by typing a question below.\n </div>\n )}\n </div>\n </ScrollArea>\n );\n}\n"],"mappings":"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"}
@@ -8,7 +8,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/react/genie/genie-chat.tsx
9
9
  /** Full-featured chat interface for a single Databricks AI/BI Genie space. Handles message streaming, conversation history, and auto-reconnection via SSE. */
10
10
  function GenieChat({ alias, basePath, placeholder, className }) {
11
- const { messages, status, error, sendMessage, reset } = useGenieChat({
11
+ const { messages, status, error, sendMessage, reset, hasPreviousPage, fetchPreviousPage } = useGenieChat({
12
12
  alias,
13
13
  basePath
14
14
  });
@@ -27,7 +27,9 @@ function GenieChat({ alias, basePath, placeholder, className }) {
27
27
  }),
28
28
  /* @__PURE__ */ jsx(GenieChatMessageList, {
29
29
  messages,
30
- status
30
+ status,
31
+ hasPreviousPage,
32
+ onFetchPreviousPage: fetchPreviousPage
31
33
  }),
32
34
  error && /* @__PURE__ */ jsx("div", {
33
35
  className: "shrink-0 px-4 py-2 text-sm text-destructive bg-destructive/10 border-t",
@@ -1 +1 @@
1
- {"version":3,"file":"genie-chat.js","names":[],"sources":["../../../src/react/genie/genie-chat.tsx"],"sourcesContent":["import { cn } from \"../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { GenieChatInput } from \"./genie-chat-input\";\nimport { GenieChatMessageList } from \"./genie-chat-message-list\";\nimport type { GenieChatProps } from \"./types\";\nimport { useGenieChat } from \"./use-genie-chat\";\n\n/** Full-featured chat interface for a single Databricks AI/BI Genie space. Handles message streaming, conversation history, and auto-reconnection via SSE. */\nexport function GenieChat({\n alias,\n basePath,\n placeholder,\n className,\n}: GenieChatProps) {\n const { messages, status, error, sendMessage, reset } = useGenieChat({\n alias,\n basePath,\n });\n\n return (\n <div className={cn(\"flex flex-col h-full overflow-hidden\", className)}>\n {messages.length > 0 && (\n <div className=\"shrink-0 flex justify-end px-4 pt-3 pb-1\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={reset}\n className=\"text-xs text-muted-foreground\"\n >\n New conversation\n </Button>\n </div>\n )}\n\n <GenieChatMessageList messages={messages} status={status} />\n\n {error && (\n <div className=\"shrink-0 px-4 py-2 text-sm text-destructive bg-destructive/10 border-t\">\n {error}\n </div>\n )}\n\n <GenieChatInput\n onSend={sendMessage}\n disabled={status === \"streaming\" || status === \"loading-history\"}\n placeholder={placeholder}\n />\n </div>\n );\n}\n"],"mappings":";;;;;;;;;AAQA,SAAgB,UAAU,EACxB,OACA,UACA,aACA,aACiB;CACjB,MAAM,EAAE,UAAU,QAAQ,OAAO,aAAa,UAAU,aAAa;EACnE;EACA;EACD,CAAC;AAEF,QACE,qBAAC;EAAI,WAAW,GAAG,wCAAwC,UAAU;;GAClE,SAAS,SAAS,KACjB,oBAAC;IAAI,WAAU;cACb,oBAAC;KACC,SAAQ;KACR,MAAK;KACL,SAAS;KACT,WAAU;eACX;MAEQ;KACL;GAGR,oBAAC;IAA+B;IAAkB;KAAU;GAE3D,SACC,oBAAC;IAAI,WAAU;cACZ;KACG;GAGR,oBAAC;IACC,QAAQ;IACR,UAAU,WAAW,eAAe,WAAW;IAClC;KACb;;GACE"}
1
+ {"version":3,"file":"genie-chat.js","names":[],"sources":["../../../src/react/genie/genie-chat.tsx"],"sourcesContent":["import { cn } from \"../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { GenieChatInput } from \"./genie-chat-input\";\nimport { GenieChatMessageList } from \"./genie-chat-message-list\";\nimport type { GenieChatProps } from \"./types\";\nimport { useGenieChat } from \"./use-genie-chat\";\n\n/** Full-featured chat interface for a single Databricks AI/BI Genie space. Handles message streaming, conversation history, and auto-reconnection via SSE. */\nexport function GenieChat({\n alias,\n basePath,\n placeholder,\n className,\n}: GenieChatProps) {\n const {\n messages,\n status,\n error,\n sendMessage,\n reset,\n hasPreviousPage,\n fetchPreviousPage,\n } = useGenieChat({\n alias,\n basePath,\n });\n\n return (\n <div className={cn(\"flex flex-col h-full overflow-hidden\", className)}>\n {messages.length > 0 && (\n <div className=\"shrink-0 flex justify-end px-4 pt-3 pb-1\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={reset}\n className=\"text-xs text-muted-foreground\"\n >\n New conversation\n </Button>\n </div>\n )}\n\n <GenieChatMessageList\n messages={messages}\n status={status}\n hasPreviousPage={hasPreviousPage}\n onFetchPreviousPage={fetchPreviousPage}\n />\n\n {error && (\n <div className=\"shrink-0 px-4 py-2 text-sm text-destructive bg-destructive/10 border-t\">\n {error}\n </div>\n )}\n\n <GenieChatInput\n onSend={sendMessage}\n disabled={status === \"streaming\" || status === \"loading-history\"}\n placeholder={placeholder}\n />\n </div>\n );\n}\n"],"mappings":";;;;;;;;;AAQA,SAAgB,UAAU,EACxB,OACA,UACA,aACA,aACiB;CACjB,MAAM,EACJ,UACA,QACA,OACA,aACA,OACA,iBACA,sBACE,aAAa;EACf;EACA;EACD,CAAC;AAEF,QACE,qBAAC;EAAI,WAAW,GAAG,wCAAwC,UAAU;;GAClE,SAAS,SAAS,KACjB,oBAAC;IAAI,WAAU;cACb,oBAAC;KACC,SAAQ;KACR,MAAK;KACL,SAAS;KACT,WAAU;eACX;MAEQ;KACL;GAGR,oBAAC;IACW;IACF;IACS;IACjB,qBAAqB;KACrB;GAED,SACC,oBAAC;IAAI,WAAU;cACZ;KACG;GAGR,oBAAC;IACC,QAAQ;IACR,UAAU,WAAW,eAAe,WAAW;IAClC;KACb;;GACE"}
@@ -2,7 +2,7 @@ import { GenieAttachmentResponse, GenieMessageResponse, GenieStreamEvent } from
2
2
  import "../../shared/src/index.js";
3
3
 
4
4
  //#region src/react/genie/types.d.ts
5
- type GenieChatStatus = "idle" | "loading-history" | "streaming" | "error";
5
+ type GenieChatStatus = "idle" | "loading-history" | "loading-older" | "streaming" | "error";
6
6
  interface GenieMessageItem {
7
7
  id: string;
8
8
  role: "user" | "assistant";
@@ -29,6 +29,12 @@ interface UseGenieChatReturn {
29
29
  error: string | null;
30
30
  sendMessage: (content: string) => void;
31
31
  reset: () => void;
32
+ /** Whether a previous page of older messages exists */
33
+ hasPreviousPage: boolean;
34
+ /** Whether a previous page is currently being fetched */
35
+ isFetchingPreviousPage: boolean;
36
+ /** Fetch the previous page of older messages */
37
+ fetchPreviousPage: () => void;
32
38
  }
33
39
  interface GenieChatProps {
34
40
  /** Genie space alias (must match a key registered with the genie plugin on the server) */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/react/genie/types.ts"],"mappings":";;;;KAQY,eAAA;AAAA,UAMK,gBAAA;EACf,EAAA;EACA,IAAA;EACA,OAAA;EACA,MAAA;EACA,WAAA,EAAa,uBAAA;EACb,YAAA,EAAc,GAAA;EACd,KAAA;AAAA;AAAA,UAGe,mBAAA;EATf;EAWA,KAAA;EATA;EAWA,QAAA;EATA;EAWA,YAAA;EAVA;EAYA,YAAA;AAAA;AAAA,UAGe,kBAAA;EACf,QAAA,EAAU,gBAAA;EACV,MAAA,EAAQ,eAAA;EACR,cAAA;EACA,KAAA;EACA,WAAA,GAAc,OAAA;EACd,KAAA;AAAA;AAAA,UAGe,cAAA;EAZf;EAcA,KAAA;EAdY;EAgBZ,QAAA;EAbiC;EAejC,WAAA;EAbuB;EAevB,SAAA;AAAA"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/react/genie/types.ts"],"mappings":";;;;KAQY,eAAA;AAAA,UAOK,gBAAA;EACf,EAAA;EACA,IAAA;EACA,OAAA;EACA,MAAA;EACA,WAAA,EAAa,uBAAA;EACb,YAAA,EAAc,GAAA;EACd,KAAA;AAAA;AAAA,UAGe,mBAAA;EATf;EAWA,KAAA;EATA;EAWA,QAAA;EATA;EAWA,YAAA;EAVA;EAYA,YAAA;AAAA;AAAA,UAGe,kBAAA;EACf,QAAA,EAAU,gBAAA;EACV,MAAA,EAAQ,eAAA;EACR,cAAA;EACA,KAAA;EACA,WAAA,GAAc,OAAA;EACd,KAAA;EAbA;EAeA,eAAA;EAXA;EAaA,sBAAA;EAbY;EAeZ,iBAAA;AAAA;AAAA,UAGe,cAAA;EAbQ;EAevB,KAAA;EAhBU;EAkBV,QAAA;EAjBQ;EAmBR,WAAA;EAjBA;EAmBA,SAAA;AAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"use-genie-chat.d.ts","names":[],"sources":["../../../src/react/genie/use-genie-chat.ts"],"mappings":";;;;;AAoFA;;;;;;;iBAAgB,YAAA,CAAa,OAAA,EAAS,mBAAA,GAAsB,kBAAA"}
1
+ {"version":3,"file":"use-genie-chat.d.ts","names":[],"sources":["../../../src/react/genie/use-genie-chat.ts"],"mappings":";;;;;AAqJA;;;;;;;iBAAgB,YAAA,CAAa,OAAA,EAAS,mBAAA,GAAsB,kBAAA"}
@@ -55,6 +55,47 @@ function messageResultToItems(msg) {
55
55
  return [makeUserItem(msg, "-user"), makeAssistantItem(msg)];
56
56
  }
57
57
  /**
58
+ * Streams a conversation page via SSE. Collects message items and query
59
+ * results into a buffer and returns them when the stream completes.
60
+ */
61
+ function fetchConversationPage(basePath, alias, convId, options) {
62
+ const params = new URLSearchParams({ requestId: crypto.randomUUID() });
63
+ if (options.pageToken) params.set("pageToken", options.pageToken);
64
+ const items = [];
65
+ return connectSSE({
66
+ url: `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}?${params}`,
67
+ signal: options.signal,
68
+ onMessage: async (message) => {
69
+ try {
70
+ const event = JSON.parse(message.data);
71
+ switch (event.type) {
72
+ case "message_result":
73
+ items.push(...messageResultToItems(event.message));
74
+ break;
75
+ case "query_result":
76
+ for (let i = items.length - 1; i >= 0; i--) {
77
+ const item = items[i];
78
+ if (item.attachments.some((a) => a.attachmentId === event.attachmentId)) {
79
+ item.queryResults.set(event.attachmentId, event.data);
80
+ break;
81
+ }
82
+ }
83
+ break;
84
+ case "history_info":
85
+ options.onPaginationInfo?.(event.nextPageToken);
86
+ break;
87
+ case "error":
88
+ options.onError?.(event.error);
89
+ break;
90
+ }
91
+ } catch {}
92
+ },
93
+ onError: (err) => options.onConnectionError?.(err)
94
+ }).then(() => items);
95
+ }
96
+ /** Minimum time (ms) to hold the loading-older state so scroll inertia settles before prepending messages. */
97
+ const MIN_PREVIOUS_PAGE_LOAD_MS = 800;
98
+ /**
58
99
  * Manages the full Genie chat lifecycle:
59
100
  * SSE streaming, conversation persistence via URL, and history replay.
60
101
  *
@@ -69,12 +110,20 @@ function useGenieChat(options) {
69
110
  const [status, setStatus] = useState("idle");
70
111
  const [conversationId, setConversationId] = useState(null);
71
112
  const [error, setError] = useState(null);
113
+ const [nextPageToken, setNextPageToken] = useState(null);
114
+ const hasPreviousPage = nextPageToken !== null;
115
+ const isFetchingPreviousPage = status === "loading-older";
72
116
  const abortControllerRef = useRef(null);
117
+ const paginationAbortRef = useRef(null);
73
118
  const conversationIdRef = useRef(null);
119
+ const nextPageTokenRef = useRef(null);
120
+ const isLoadingOlderRef = useRef(false);
74
121
  useEffect(() => {
75
122
  conversationIdRef.current = conversationId;
76
- }, [conversationId]);
77
- const processEvent = useCallback((event, isHistory) => {
123
+ nextPageTokenRef.current = nextPageToken;
124
+ }, [conversationId, nextPageToken]);
125
+ /** Process SSE events during live message streaming (sendMessage). */
126
+ const processStreamEvent = useCallback((event) => {
78
127
  switch (event.type) {
79
128
  case "message_start":
80
129
  setConversationId(event.conversationId);
@@ -92,11 +141,7 @@ function useGenieChat(options) {
92
141
  break;
93
142
  case "message_result": {
94
143
  const msg = event.message;
95
- const hasAttachments = (msg.attachments?.length ?? 0) > 0;
96
- if (isHistory) {
97
- const items = messageResultToItems(msg);
98
- setMessages((prev) => [...prev, ...items]);
99
- } else if (hasAttachments) {
144
+ if ((msg.attachments?.length ?? 0) > 0) {
100
145
  const item = makeAssistantItem(msg);
101
146
  setMessages((prev) => {
102
147
  const last = prev[prev.length - 1];
@@ -108,20 +153,18 @@ function useGenieChat(options) {
108
153
  }
109
154
  case "query_result":
110
155
  setMessages((prev) => {
111
- const updated = [...prev];
112
- for (let i = updated.length - 1; i >= 0; i--) {
113
- const msg = updated[i];
156
+ for (let i = prev.length - 1; i >= 0; i--) {
157
+ const msg = prev[i];
114
158
  if (msg.attachments.some((a) => a.attachmentId === event.attachmentId)) {
115
- const queryResults = new Map(msg.queryResults);
116
- queryResults.set(event.attachmentId, event.data);
159
+ const updated = prev.slice();
117
160
  updated[i] = {
118
161
  ...msg,
119
- queryResults
162
+ queryResults: new Map(msg.queryResults).set(event.attachmentId, event.data)
120
163
  };
121
- break;
164
+ return updated;
122
165
  }
123
166
  }
124
- return updated;
167
+ return prev;
125
168
  });
126
169
  break;
127
170
  case "error":
@@ -134,6 +177,7 @@ function useGenieChat(options) {
134
177
  const trimmed = content.trim();
135
178
  if (!trimmed) return;
136
179
  abortControllerRef.current?.abort();
180
+ paginationAbortRef.current?.abort();
137
181
  setError(null);
138
182
  setStatus("streaming");
139
183
  const userMessage = {
@@ -169,7 +213,7 @@ function useGenieChat(options) {
169
213
  signal: abortController.signal,
170
214
  onMessage: async (message) => {
171
215
  try {
172
- processEvent(JSON.parse(message.data), false);
216
+ processStreamEvent(JSON.parse(message.data));
173
217
  } catch {}
174
218
  },
175
219
  onError: (err) => {
@@ -187,44 +231,74 @@ function useGenieChat(options) {
187
231
  }, [
188
232
  alias,
189
233
  basePath,
190
- processEvent
234
+ processStreamEvent
191
235
  ]);
236
+ /** Creates an AbortController, stores it in the given ref, and fetches a conversation page. */
237
+ const fetchPage = useCallback((controllerRef, convId, options) => {
238
+ controllerRef.current?.abort();
239
+ const abortController = new AbortController();
240
+ controllerRef.current = abortController;
241
+ return {
242
+ promise: fetchConversationPage(basePath, alias, convId, {
243
+ pageToken: options?.pageToken,
244
+ signal: abortController.signal,
245
+ onPaginationInfo: setNextPageToken,
246
+ onError: (msg) => {
247
+ setError(msg);
248
+ setStatus("error");
249
+ },
250
+ onConnectionError: (err) => {
251
+ if (abortController.signal.aborted) return;
252
+ setError(err instanceof Error ? err.message : options?.errorMessage ?? "Failed to load messages.");
253
+ setStatus("error");
254
+ }
255
+ }),
256
+ abortController
257
+ };
258
+ }, [alias, basePath]);
192
259
  const loadHistory = useCallback((convId) => {
193
- abortControllerRef.current?.abort();
260
+ paginationAbortRef.current?.abort();
194
261
  setStatus("loading-history");
195
262
  setError(null);
196
263
  setMessages([]);
197
264
  setConversationId(convId);
198
- const abortController = new AbortController();
199
- abortControllerRef.current = abortController;
200
- const requestId = crypto.randomUUID();
201
- connectSSE({
202
- url: `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}?requestId=${encodeURIComponent(requestId)}`,
203
- signal: abortController.signal,
204
- onMessage: async (message) => {
205
- try {
206
- processEvent(JSON.parse(message.data), true);
207
- } catch {}
208
- },
209
- onError: (err) => {
210
- if (abortController.signal.aborted) return;
211
- setError(err instanceof Error ? err.message : "Failed to load conversation history.");
212
- setStatus("error");
265
+ const { promise, abortController } = fetchPage(abortControllerRef, convId, { errorMessage: "Failed to load conversation history." });
266
+ promise.then((items) => {
267
+ if (!abortController.signal.aborted) {
268
+ setMessages(items);
269
+ setStatus((prev) => prev === "error" ? "error" : "idle");
213
270
  }
214
- }).then(() => {
215
- if (!abortController.signal.aborted) setStatus((prev) => prev === "error" ? "error" : "idle");
216
271
  });
217
- }, [
218
- alias,
219
- basePath,
220
- processEvent
221
- ]);
272
+ }, [fetchPage]);
273
+ const fetchPreviousPage = useCallback(() => {
274
+ if (!nextPageTokenRef.current || !conversationIdRef.current || isLoadingOlderRef.current) return;
275
+ isLoadingOlderRef.current = true;
276
+ setStatus("loading-older");
277
+ setError(null);
278
+ const startTime = Date.now();
279
+ const { promise, abortController } = fetchPage(paginationAbortRef, conversationIdRef.current, {
280
+ pageToken: nextPageTokenRef.current,
281
+ errorMessage: "Failed to load older messages."
282
+ });
283
+ promise.then(async (items) => {
284
+ if (abortController.signal.aborted) return;
285
+ const elapsed = Date.now() - startTime;
286
+ if (elapsed < MIN_PREVIOUS_PAGE_LOAD_MS) await new Promise((r) => setTimeout(r, MIN_PREVIOUS_PAGE_LOAD_MS - elapsed));
287
+ if (abortController.signal.aborted) return;
288
+ if (items.length > 0) setMessages((prev) => [...items, ...prev]);
289
+ setStatus((current) => current === "loading-older" ? "idle" : current);
290
+ }).finally(() => {
291
+ isLoadingOlderRef.current = false;
292
+ });
293
+ }, [fetchPage]);
222
294
  const reset = useCallback(() => {
223
295
  abortControllerRef.current?.abort();
296
+ paginationAbortRef.current?.abort();
224
297
  setMessages([]);
225
298
  setConversationId(null);
226
299
  setError(null);
227
300
  setStatus("idle");
301
+ setNextPageToken(null);
228
302
  if (persistInUrl) removeUrlParam(urlParamName);
229
303
  }, [persistInUrl, urlParamName]);
230
304
  useEffect(() => {
@@ -233,6 +307,7 @@ function useGenieChat(options) {
233
307
  if (existingId) loadHistory(existingId);
234
308
  return () => {
235
309
  abortControllerRef.current?.abort();
310
+ paginationAbortRef.current?.abort();
236
311
  };
237
312
  }, [
238
313
  persistInUrl,
@@ -245,7 +320,10 @@ function useGenieChat(options) {
245
320
  conversationId,
246
321
  error,
247
322
  sendMessage,
248
- reset
323
+ reset,
324
+ hasPreviousPage,
325
+ isFetchingPreviousPage,
326
+ fetchPreviousPage
249
327
  };
250
328
  }
251
329
 
@@ -1 +1 @@
1
- {"version":3,"file":"use-genie-chat.js","names":[],"sources":["../../../src/react/genie/use-genie-chat.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { connectSSE } from \"@/js\";\nimport type {\n GenieChatStatus,\n GenieMessageItem,\n GenieMessageResponse,\n GenieStreamEvent,\n UseGenieChatOptions,\n UseGenieChatReturn,\n} from \"./types\";\n\nfunction getUrlParam(name: string): string | null {\n return new URLSearchParams(window.location.search).get(name);\n}\n\nfunction setUrlParam(name: string, value: string): void {\n const url = new URL(window.location.href);\n url.searchParams.set(name, value);\n window.history.replaceState({}, \"\", url.toString());\n}\n\nfunction removeUrlParam(name: string): void {\n const url = new URL(window.location.href);\n url.searchParams.delete(name);\n window.history.replaceState({}, \"\", url.toString());\n}\n\n/**\n * The Genie API puts the user's question in `message.content` and the\n * actual AI answer in text attachments. Extract the text attachment\n * content so we display the real answer, not the question echo.\n */\nfunction extractAssistantContent(msg: GenieMessageResponse): string {\n const textParts = (msg.attachments ?? [])\n .map((att) => att.text?.content)\n .filter(Boolean) as string[];\n return textParts.length > 0 ? textParts.join(\"\\n\\n\") : msg.content;\n}\n\nfunction makeUserItem(\n msg: GenieMessageResponse,\n idSuffix = \"\",\n): GenieMessageItem {\n return {\n id: `${msg.messageId}${idSuffix}`,\n role: \"user\",\n content: msg.content,\n status: msg.status,\n attachments: [],\n queryResults: new Map(),\n };\n}\n\nfunction makeAssistantItem(msg: GenieMessageResponse): GenieMessageItem {\n return {\n id: msg.messageId,\n role: \"assistant\",\n content: extractAssistantContent(msg),\n status: msg.status,\n attachments: msg.attachments ?? [],\n queryResults: new Map(),\n error: msg.error,\n };\n}\n\n/**\n * The API bundles user question (content) and AI answer (attachments) in one message.\n * Split into separate user + assistant items for display.\n */\nfunction messageResultToItems(msg: GenieMessageResponse): GenieMessageItem[] {\n const hasAttachments = (msg.attachments?.length ?? 0) > 0;\n if (!hasAttachments) return [makeUserItem(msg)];\n return [makeUserItem(msg, \"-user\"), makeAssistantItem(msg)];\n}\n\n/**\n * Manages the full Genie chat lifecycle:\n * SSE streaming, conversation persistence via URL, and history replay.\n *\n * @example\n * ```tsx\n * const { messages, status, sendMessage, reset } = useGenieChat({ alias: \"demo\" });\n * ```\n */\nexport function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn {\n const {\n alias,\n basePath = \"/api/genie\",\n persistInUrl = true,\n urlParamName = \"conversationId\",\n } = options;\n\n const [messages, setMessages] = useState<GenieMessageItem[]>([]);\n const [status, setStatus] = useState<GenieChatStatus>(\"idle\");\n const [conversationId, setConversationId] = useState<string | null>(null);\n const [error, setError] = useState<string | null>(null);\n\n const abortControllerRef = useRef<AbortController | null>(null);\n const conversationIdRef = useRef<string | null>(null);\n\n useEffect(() => {\n conversationIdRef.current = conversationId;\n }, [conversationId]);\n\n const processEvent = useCallback(\n (event: GenieStreamEvent, isHistory: boolean) => {\n switch (event.type) {\n case \"message_start\": {\n setConversationId(event.conversationId);\n if (persistInUrl) {\n setUrlParam(urlParamName, event.conversationId);\n }\n break;\n }\n\n case \"status\": {\n setMessages((prev) => {\n const last = prev[prev.length - 1];\n if (last?.role === \"assistant\") {\n return [...prev.slice(0, -1), { ...last, status: event.status }];\n }\n return prev;\n });\n break;\n }\n\n case \"message_result\": {\n const msg = event.message;\n const hasAttachments = (msg.attachments?.length ?? 0) > 0;\n\n if (isHistory) {\n const items = messageResultToItems(msg);\n setMessages((prev) => [...prev, ...items]);\n } else if (hasAttachments) {\n // During streaming we already appended the user message locally,\n // so only handle assistant results. Messages without attachments\n // are the user-message echo from the API — skip those.\n const item = makeAssistantItem(msg);\n setMessages((prev) => {\n const last = prev[prev.length - 1];\n if (last?.role === \"assistant\" && last.id === \"\") {\n return [...prev.slice(0, -1), item];\n }\n return [...prev, item];\n });\n }\n break;\n }\n\n case \"query_result\": {\n setMessages((prev) => {\n const updated = [...prev];\n for (let i = updated.length - 1; i >= 0; i--) {\n const msg = updated[i];\n if (\n msg.attachments.some(\n (a) => a.attachmentId === event.attachmentId,\n )\n ) {\n const queryResults = new Map(msg.queryResults);\n queryResults.set(event.attachmentId, event.data);\n updated[i] = { ...msg, queryResults };\n break;\n }\n }\n return updated;\n });\n break;\n }\n\n case \"error\": {\n setError(event.error);\n setStatus(\"error\");\n break;\n }\n }\n },\n [persistInUrl, urlParamName],\n );\n\n const sendMessage = useCallback(\n (content: string) => {\n const trimmed = content.trim();\n if (!trimmed) return;\n\n abortControllerRef.current?.abort();\n setError(null);\n setStatus(\"streaming\");\n\n const userMessage: GenieMessageItem = {\n id: crypto.randomUUID(),\n role: \"user\",\n content: trimmed,\n status: \"COMPLETED\",\n attachments: [],\n queryResults: new Map(),\n };\n\n const assistantPlaceholder: GenieMessageItem = {\n id: \"\",\n role: \"assistant\",\n content: \"\",\n status: \"ASKING_AI\",\n attachments: [],\n queryResults: new Map(),\n };\n\n setMessages((prev) => [...prev, userMessage, assistantPlaceholder]);\n\n const abortController = new AbortController();\n abortControllerRef.current = abortController;\n\n const requestId = crypto.randomUUID();\n\n connectSSE({\n url: `${basePath}/${encodeURIComponent(alias)}/messages?requestId=${encodeURIComponent(requestId)}`,\n payload: {\n content: trimmed,\n conversationId: conversationIdRef.current ?? undefined,\n },\n signal: abortController.signal,\n onMessage: async (message) => {\n try {\n processEvent(JSON.parse(message.data) as GenieStreamEvent, false);\n } catch {\n // Malformed SSE data\n }\n },\n onError: (err) => {\n if (abortController.signal.aborted) return;\n setError(\n err instanceof Error\n ? err.message\n : \"Connection error. Please try again.\",\n );\n setStatus(\"error\");\n setMessages((prev) => {\n const last = prev[prev.length - 1];\n return last?.role === \"assistant\" && last.id === \"\"\n ? prev.slice(0, -1)\n : prev;\n });\n },\n }).then(() => {\n if (!abortController.signal.aborted) {\n setStatus((prev) => (prev === \"error\" ? \"error\" : \"idle\"));\n }\n });\n },\n [alias, basePath, processEvent],\n );\n\n const loadHistory = useCallback(\n (convId: string) => {\n abortControllerRef.current?.abort();\n setStatus(\"loading-history\");\n setError(null);\n setMessages([]);\n setConversationId(convId);\n\n const abortController = new AbortController();\n abortControllerRef.current = abortController;\n\n const requestId = crypto.randomUUID();\n\n connectSSE({\n url: `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}?requestId=${encodeURIComponent(requestId)}`,\n signal: abortController.signal,\n onMessage: async (message) => {\n try {\n processEvent(JSON.parse(message.data) as GenieStreamEvent, true);\n } catch {\n // Malformed SSE data\n }\n },\n onError: (err) => {\n if (abortController.signal.aborted) return;\n setError(\n err instanceof Error\n ? err.message\n : \"Failed to load conversation history.\",\n );\n setStatus(\"error\");\n },\n }).then(() => {\n if (!abortController.signal.aborted) {\n setStatus((prev) => (prev === \"error\" ? \"error\" : \"idle\"));\n }\n });\n },\n [alias, basePath, processEvent],\n );\n\n const reset = useCallback(() => {\n abortControllerRef.current?.abort();\n setMessages([]);\n setConversationId(null);\n setError(null);\n setStatus(\"idle\");\n if (persistInUrl) {\n removeUrlParam(urlParamName);\n }\n }, [persistInUrl, urlParamName]);\n\n useEffect(() => {\n if (!persistInUrl) return;\n const existingId = getUrlParam(urlParamName);\n if (existingId) {\n loadHistory(existingId);\n }\n return () => {\n abortControllerRef.current?.abort();\n };\n }, [persistInUrl, urlParamName, loadHistory]);\n\n return { messages, status, conversationId, error, sendMessage, reset };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"use-genie-chat.js","names":[],"sources":["../../../src/react/genie/use-genie-chat.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { connectSSE } from \"@/js\";\nimport type {\n GenieChatStatus,\n GenieMessageItem,\n GenieMessageResponse,\n GenieStreamEvent,\n UseGenieChatOptions,\n UseGenieChatReturn,\n} from \"./types\";\n\nfunction getUrlParam(name: string): string | null {\n return new URLSearchParams(window.location.search).get(name);\n}\n\nfunction setUrlParam(name: string, value: string): void {\n const url = new URL(window.location.href);\n url.searchParams.set(name, value);\n window.history.replaceState({}, \"\", url.toString());\n}\n\nfunction removeUrlParam(name: string): void {\n const url = new URL(window.location.href);\n url.searchParams.delete(name);\n window.history.replaceState({}, \"\", url.toString());\n}\n\n/**\n * The Genie API puts the user's question in `message.content` and the\n * actual AI answer in text attachments. Extract the text attachment\n * content so we display the real answer, not the question echo.\n */\nfunction extractAssistantContent(msg: GenieMessageResponse): string {\n const textParts = (msg.attachments ?? [])\n .map((att) => att.text?.content)\n .filter(Boolean) as string[];\n return textParts.length > 0 ? textParts.join(\"\\n\\n\") : msg.content;\n}\n\nfunction makeUserItem(\n msg: GenieMessageResponse,\n idSuffix = \"\",\n): GenieMessageItem {\n return {\n id: `${msg.messageId}${idSuffix}`,\n role: \"user\",\n content: msg.content,\n status: msg.status,\n attachments: [],\n queryResults: new Map(),\n };\n}\n\nfunction makeAssistantItem(msg: GenieMessageResponse): GenieMessageItem {\n return {\n id: msg.messageId,\n role: \"assistant\",\n content: extractAssistantContent(msg),\n status: msg.status,\n attachments: msg.attachments ?? [],\n queryResults: new Map(),\n error: msg.error,\n };\n}\n\n/**\n * The API bundles user question (content) and AI answer (attachments) in one message.\n * Split into separate user + assistant items for display.\n */\nfunction messageResultToItems(msg: GenieMessageResponse): GenieMessageItem[] {\n const hasAttachments = (msg.attachments?.length ?? 0) > 0;\n if (!hasAttachments) return [makeUserItem(msg)];\n return [makeUserItem(msg, \"-user\"), makeAssistantItem(msg)];\n}\n\n/**\n * Streams a conversation page via SSE. Collects message items and query\n * results into a buffer and returns them when the stream completes.\n */\nfunction fetchConversationPage(\n basePath: string,\n alias: string,\n convId: string,\n options: {\n pageToken?: string;\n signal?: AbortSignal;\n onPaginationInfo?: (nextPageToken: string | null) => void;\n onError?: (error: string) => void;\n onConnectionError?: (err: unknown) => void;\n },\n): Promise<GenieMessageItem[]> {\n const params = new URLSearchParams({\n requestId: crypto.randomUUID(),\n });\n if (options.pageToken) {\n params.set(\"pageToken\", options.pageToken);\n }\n\n const items: GenieMessageItem[] = [];\n return connectSSE({\n url: `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}?${params}`,\n signal: options.signal,\n onMessage: async (message) => {\n try {\n const event = JSON.parse(message.data) as GenieStreamEvent;\n switch (event.type) {\n case \"message_result\":\n items.push(...messageResultToItems(event.message));\n break;\n case \"query_result\":\n for (let i = items.length - 1; i >= 0; i--) {\n const item = items[i];\n if (\n item.attachments.some(\n (a) => a.attachmentId === event.attachmentId,\n )\n ) {\n item.queryResults.set(event.attachmentId, event.data);\n break;\n }\n }\n break;\n case \"history_info\":\n options.onPaginationInfo?.(event.nextPageToken);\n break;\n case \"error\":\n options.onError?.(event.error);\n break;\n }\n } catch {\n // Malformed SSE data\n }\n },\n onError: (err) => options.onConnectionError?.(err),\n }).then(() => items);\n}\n\n/** Minimum time (ms) to hold the loading-older state so scroll inertia settles before prepending messages. */\nconst MIN_PREVIOUS_PAGE_LOAD_MS = 800;\n\n/**\n * Manages the full Genie chat lifecycle:\n * SSE streaming, conversation persistence via URL, and history replay.\n *\n * @example\n * ```tsx\n * const { messages, status, sendMessage, reset } = useGenieChat({ alias: \"demo\" });\n * ```\n */\nexport function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn {\n const {\n alias,\n basePath = \"/api/genie\",\n persistInUrl = true,\n urlParamName = \"conversationId\",\n } = options;\n\n const [messages, setMessages] = useState<GenieMessageItem[]>([]);\n const [status, setStatus] = useState<GenieChatStatus>(\"idle\");\n const [conversationId, setConversationId] = useState<string | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [nextPageToken, setNextPageToken] = useState<string | null>(null);\n\n const hasPreviousPage = nextPageToken !== null;\n const isFetchingPreviousPage = status === \"loading-older\";\n\n const abortControllerRef = useRef<AbortController | null>(null);\n const paginationAbortRef = useRef<AbortController | null>(null);\n const conversationIdRef = useRef<string | null>(null);\n const nextPageTokenRef = useRef<string | null>(null);\n const isLoadingOlderRef = useRef(false);\n\n useEffect(() => {\n conversationIdRef.current = conversationId;\n nextPageTokenRef.current = nextPageToken;\n }, [conversationId, nextPageToken]);\n\n /** Process SSE events during live message streaming (sendMessage). */\n const processStreamEvent = useCallback(\n (event: GenieStreamEvent) => {\n switch (event.type) {\n case \"message_start\": {\n setConversationId(event.conversationId);\n if (persistInUrl) {\n setUrlParam(urlParamName, event.conversationId);\n }\n break;\n }\n\n case \"status\": {\n setMessages((prev) => {\n const last = prev[prev.length - 1];\n if (last?.role === \"assistant\") {\n return [...prev.slice(0, -1), { ...last, status: event.status }];\n }\n return prev;\n });\n break;\n }\n\n case \"message_result\": {\n const msg = event.message;\n const hasAttachments = (msg.attachments?.length ?? 0) > 0;\n\n if (hasAttachments) {\n // During streaming we already appended the user message locally,\n // so only handle assistant results. Messages without attachments\n // are the user-message echo from the API — skip those.\n const item = makeAssistantItem(msg);\n setMessages((prev) => {\n const last = prev[prev.length - 1];\n if (last?.role === \"assistant\" && last.id === \"\") {\n return [...prev.slice(0, -1), item];\n }\n return [...prev, item];\n });\n }\n break;\n }\n\n case \"query_result\": {\n setMessages((prev) => {\n // Reverse scan — query results typically match recent messages\n for (let i = prev.length - 1; i >= 0; i--) {\n const msg = prev[i];\n if (\n msg.attachments.some(\n (a) => a.attachmentId === event.attachmentId,\n )\n ) {\n const updated = prev.slice();\n updated[i] = {\n ...msg,\n queryResults: new Map(msg.queryResults).set(\n event.attachmentId,\n event.data,\n ),\n };\n return updated;\n }\n }\n return prev;\n });\n break;\n }\n\n case \"error\": {\n setError(event.error);\n setStatus(\"error\");\n break;\n }\n }\n },\n [persistInUrl, urlParamName],\n );\n\n const sendMessage = useCallback(\n (content: string) => {\n const trimmed = content.trim();\n if (!trimmed) return;\n\n abortControllerRef.current?.abort();\n paginationAbortRef.current?.abort();\n setError(null);\n setStatus(\"streaming\");\n\n const userMessage: GenieMessageItem = {\n id: crypto.randomUUID(),\n role: \"user\",\n content: trimmed,\n status: \"COMPLETED\",\n attachments: [],\n queryResults: new Map(),\n };\n\n const assistantPlaceholder: GenieMessageItem = {\n id: \"\",\n role: \"assistant\",\n content: \"\",\n status: \"ASKING_AI\",\n attachments: [],\n queryResults: new Map(),\n };\n\n setMessages((prev) => [...prev, userMessage, assistantPlaceholder]);\n\n const abortController = new AbortController();\n abortControllerRef.current = abortController;\n\n const requestId = crypto.randomUUID();\n\n connectSSE({\n url: `${basePath}/${encodeURIComponent(alias)}/messages?requestId=${encodeURIComponent(requestId)}`,\n payload: {\n content: trimmed,\n conversationId: conversationIdRef.current ?? undefined,\n },\n signal: abortController.signal,\n onMessage: async (message) => {\n try {\n processStreamEvent(JSON.parse(message.data) as GenieStreamEvent);\n } catch {\n // Malformed SSE data\n }\n },\n onError: (err) => {\n if (abortController.signal.aborted) return;\n setError(\n err instanceof Error\n ? err.message\n : \"Connection error. Please try again.\",\n );\n setStatus(\"error\");\n setMessages((prev) => {\n const last = prev[prev.length - 1];\n return last?.role === \"assistant\" && last.id === \"\"\n ? prev.slice(0, -1)\n : prev;\n });\n },\n }).then(() => {\n if (!abortController.signal.aborted) {\n setStatus((prev) => (prev === \"error\" ? \"error\" : \"idle\"));\n }\n });\n },\n [alias, basePath, processStreamEvent],\n );\n\n /** Creates an AbortController, stores it in the given ref, and fetches a conversation page. */\n const fetchPage = useCallback(\n (\n controllerRef: { current: AbortController | null },\n convId: string,\n options?: { pageToken?: string; errorMessage?: string },\n ) => {\n controllerRef.current?.abort();\n const abortController = new AbortController();\n controllerRef.current = abortController;\n\n const promise = fetchConversationPage(basePath, alias, convId, {\n pageToken: options?.pageToken,\n signal: abortController.signal,\n onPaginationInfo: setNextPageToken,\n onError: (msg) => {\n setError(msg);\n setStatus(\"error\");\n },\n onConnectionError: (err) => {\n if (abortController.signal.aborted) return;\n setError(\n err instanceof Error\n ? err.message\n : (options?.errorMessage ?? \"Failed to load messages.\"),\n );\n setStatus(\"error\");\n },\n });\n\n return { promise, abortController };\n },\n [alias, basePath],\n );\n\n const loadHistory = useCallback(\n (convId: string) => {\n paginationAbortRef.current?.abort();\n setStatus(\"loading-history\");\n setError(null);\n setMessages([]);\n setConversationId(convId);\n\n const { promise, abortController } = fetchPage(\n abortControllerRef,\n convId,\n { errorMessage: \"Failed to load conversation history.\" },\n );\n promise.then((items) => {\n if (!abortController.signal.aborted) {\n setMessages(items);\n setStatus((prev) => (prev === \"error\" ? \"error\" : \"idle\"));\n }\n });\n },\n [fetchPage],\n );\n\n const fetchPreviousPage = useCallback(() => {\n if (\n !nextPageTokenRef.current ||\n !conversationIdRef.current ||\n isLoadingOlderRef.current\n )\n return;\n\n isLoadingOlderRef.current = true;\n setStatus(\"loading-older\");\n setError(null);\n\n const startTime = Date.now();\n const { promise, abortController } = fetchPage(\n paginationAbortRef,\n conversationIdRef.current,\n {\n pageToken: nextPageTokenRef.current,\n errorMessage: \"Failed to load older messages.\",\n },\n );\n promise\n .then(async (items) => {\n if (abortController.signal.aborted) return;\n const elapsed = Date.now() - startTime;\n if (elapsed < MIN_PREVIOUS_PAGE_LOAD_MS) {\n await new Promise((r) =>\n setTimeout(r, MIN_PREVIOUS_PAGE_LOAD_MS - elapsed),\n );\n }\n if (abortController.signal.aborted) return;\n if (items.length > 0) {\n setMessages((prev) => [...items, ...prev]);\n }\n setStatus((current) =>\n current === \"loading-older\" ? \"idle\" : current,\n );\n })\n .finally(() => {\n isLoadingOlderRef.current = false;\n });\n }, [fetchPage]);\n\n const reset = useCallback(() => {\n abortControllerRef.current?.abort();\n paginationAbortRef.current?.abort();\n setMessages([]);\n setConversationId(null);\n setError(null);\n setStatus(\"idle\");\n setNextPageToken(null);\n if (persistInUrl) {\n removeUrlParam(urlParamName);\n }\n }, [persistInUrl, urlParamName]);\n\n useEffect(() => {\n if (!persistInUrl) return;\n const existingId = getUrlParam(urlParamName);\n if (existingId) {\n loadHistory(existingId);\n }\n return () => {\n abortControllerRef.current?.abort();\n paginationAbortRef.current?.abort();\n };\n }, [persistInUrl, urlParamName, loadHistory]);\n\n return {\n messages,\n status,\n conversationId,\n error,\n sendMessage,\n reset,\n hasPreviousPage,\n isFetchingPreviousPage,\n fetchPreviousPage,\n };\n}\n"],"mappings":"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"}
@@ -19,6 +19,12 @@ type GenieStreamEvent = {
19
19
  } | {
20
20
  type: "error";
21
21
  error: string;
22
+ } | {
23
+ type: "history_info";
24
+ conversationId: string;
25
+ spaceId: string; /** Opaque token to fetch the next (older) page. Null means no more pages. */
26
+ nextPageToken: string | null; /** Total messages returned in this initial load */
27
+ loadedCount: number;
22
28
  };
23
29
  /** Cleaned response — subset of SDK GenieMessage */
24
30
  interface GenieMessageResponse {
@@ -1 +1 @@
1
- {"version":3,"file":"genie.d.ts","names":[],"sources":["../../../../shared/src/genie.ts"],"mappings":";;KACY,gBAAA;EAEN,IAAA;EACA,cAAA;EACA,SAAA;EACA,OAAA;AAAA;EAEA,IAAA;EAAgB,MAAA;AAAA;EAChB,IAAA;EAAwB,OAAA,EAAS,oBAAA;AAAA;EAEjC,IAAA;EACA,YAAA;EACA,WAAA;EACA,IAAA;AAAA;EAEA,IAAA;EAAe,KAAA;AAAA;;UAGJ,oBAAA;EACf,SAAA;EACA,cAAA;EACA,OAAA;EACA,MAAA;EACA,OAAA;EACA,WAAA,GAAc,uBAAA;EACd,KAAA;AAAA;AAAA,UAGe,uBAAA;EACf,YAAA;EACA,KAAA;IACE,KAAA;IACA,WAAA;IACA,KAAA;IACA,WAAA;EAAA;EAEF,IAAA;IAAS,OAAA;EAAA;EACT,kBAAA;AAAA"}
1
+ {"version":3,"file":"genie.d.ts","names":[],"sources":["../../../../shared/src/genie.ts"],"mappings":";;KACY,gBAAA;EAEN,IAAA;EACA,cAAA;EACA,SAAA;EACA,OAAA;AAAA;EAEA,IAAA;EAAgB,MAAA;AAAA;EAChB,IAAA;EAAwB,OAAA,EAAS,oBAAA;AAAA;EAEjC,IAAA;EACA,YAAA;EACA,WAAA;EACA,IAAA;AAAA;EAEA,IAAA;EAAe,KAAA;AAAA;EAEf,IAAA;EACA,cAAA;EACA,OAAA,UAIA;EAFA,aAAA,iBAEW;EAAX,WAAA;AAAA;;UAIW,oBAAA;EACf,SAAA;EACA,cAAA;EACA,OAAA;EACA,MAAA;EACA,OAAA;EACA,WAAA,GAAc,uBAAA;EACd,KAAA;AAAA;AAAA,UAGe,uBAAA;EACf,YAAA;EACA,KAAA;IACE,KAAA;IACA,WAAA;IACA,KAAA;IACA,WAAA;EAAA;EAEF,IAAA;IAAS,OAAA;EAAA;EACT,kBAAA;AAAA"}
@@ -10,11 +10,13 @@ Scrollable message list that renders Genie chat messages with auto-scroll, skele
10
10
 
11
11
  ### Props[​](#props "Direct link to Props")
12
12
 
13
- | Prop | Type | Required | Default | Description |
14
- | ----------- | -------------------- | -------- | ------- | --------------------------------------------------------------------------- |
15
- | `messages` | `GenieMessageItem[]` | ✓ | - | Array of messages to display |
16
- | `status` | `enum` | ✓ | - | Current chat status (controls loading indicators and skeleton placeholders) |
17
- | `className` | `string` | | - | Additional CSS class for the scroll area |
13
+ | Prop | Type | Required | Default | Description |
14
+ | --------------------- | -------------------- | -------- | ------- | --------------------------------------------------------------------------- |
15
+ | `messages` | `GenieMessageItem[]` | ✓ | - | Array of messages to display |
16
+ | `status` | `enum` | ✓ | - | Current chat status (controls loading indicators and skeleton placeholders) |
17
+ | `className` | `string` | | - | Additional CSS class for the scroll area |
18
+ | `hasPreviousPage` | `boolean` | | `false` | Whether a previous page of older messages exists |
19
+ | `onFetchPreviousPage` | `(() => void)` | | - | Callback to fetch the previous page of messages |
18
20
 
19
21
  ### Usage[​](#usage "Direct link to Usage")
20
22
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@databricks/appkit-ui",
3
3
  "type": "module",
4
- "version": "0.16.0",
4
+ "version": "0.17.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",