@databricks/appkit-ui 0.61.1 → 0.63.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.
- package/CLAUDE.md +1 -0
- package/dist/cli/commands/codemod/index.js.map +1 -1
- package/dist/cli/commands/codemod/on-plugins-ready.js.map +1 -1
- package/dist/cli/commands/docs.js.map +1 -1
- package/dist/cli/commands/doctor/bundle.js.map +1 -1
- package/dist/cli/commands/doctor/index.js.map +1 -1
- package/dist/cli/commands/doctor/report.js.map +1 -1
- package/dist/cli/commands/doctor/resolve-targets.js.map +1 -1
- package/dist/cli/commands/doctor/run.js.map +1 -1
- package/dist/cli/commands/generate-types.js.map +1 -1
- package/dist/cli/commands/lint.js.map +1 -1
- package/dist/cli/commands/plugin/add-resource/add-resource.js.map +1 -1
- package/dist/cli/commands/plugin/create/create.js.map +1 -1
- package/dist/cli/commands/plugin/create/prompt-resource.js.map +1 -1
- package/dist/cli/commands/plugin/create/scaffold.js.map +1 -1
- package/dist/cli/commands/plugin/index.js.map +1 -1
- package/dist/cli/commands/plugin/list/list.js.map +1 -1
- package/dist/cli/commands/plugin/promote/promote.js.map +1 -1
- package/dist/cli/commands/plugin/sync/sync.js.map +1 -1
- package/dist/cli/commands/plugin/validate/validate-manifest.js.map +1 -1
- package/dist/cli/commands/plugin/validate/validate.js.map +1 -1
- package/dist/cli/commands/registry/add.js.map +1 -1
- package/dist/cli/commands/registry/client.js.map +1 -1
- package/dist/cli/commands/registry/config-writer.js.map +1 -1
- package/dist/cli/commands/registry/env-reconcile.js.map +1 -1
- package/dist/cli/commands/registry/env-writer.js.map +1 -1
- package/dist/cli/commands/registry/index.js.map +1 -1
- package/dist/cli/commands/registry/info.js.map +1 -1
- package/dist/cli/commands/registry/list.js.map +1 -1
- package/dist/cli/commands/registry/requirements.js.map +1 -1
- package/dist/cli/commands/registry/server-register.js.map +1 -1
- package/dist/cli/commands/registry/workspace-picker.js.map +1 -1
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/js/arrow/arrow-client.d.ts.map +1 -1
- package/dist/js/arrow/arrow-client.js.map +1 -1
- package/dist/react/charts/area/index.d.ts.map +1 -1
- package/dist/react/charts/area/index.js.map +1 -1
- package/dist/react/charts/bar/index.d.ts.map +1 -1
- package/dist/react/charts/bar/index.js.map +1 -1
- package/dist/react/charts/base.d.ts.map +1 -1
- package/dist/react/charts/base.js +1 -1
- package/dist/react/charts/base.js.map +1 -1
- package/dist/react/charts/heatmap/index.d.ts.map +1 -1
- package/dist/react/charts/heatmap/index.js.map +1 -1
- package/dist/react/charts/line/index.d.ts.map +1 -1
- package/dist/react/charts/line/index.js.map +1 -1
- package/dist/react/charts/normalize.d.ts.map +1 -1
- package/dist/react/charts/normalize.js.map +1 -1
- package/dist/react/charts/pie/index.d.ts.map +1 -1
- package/dist/react/charts/pie/index.js.map +1 -1
- package/dist/react/charts/radar/index.d.ts.map +1 -1
- package/dist/react/charts/radar/index.js.map +1 -1
- package/dist/react/charts/scatter/index.d.ts.map +1 -1
- package/dist/react/charts/scatter/index.js.map +1 -1
- package/dist/react/charts/theme.d.ts.map +1 -1
- package/dist/react/charts/theme.js.map +1 -1
- package/dist/react/charts/wrapper.d.ts.map +1 -1
- package/dist/react/charts/wrapper.js.map +1 -1
- package/dist/react/file-browser/directory-list.d.ts.map +1 -1
- package/dist/react/file-browser/directory-list.js.map +1 -1
- package/dist/react/file-browser/file-breadcrumb.d.ts.map +1 -1
- package/dist/react/file-browser/file-breadcrumb.js.map +1 -1
- package/dist/react/file-browser/file-entry.d.ts.map +1 -1
- package/dist/react/file-browser/file-entry.js.map +1 -1
- package/dist/react/file-browser/file-preview-panel.d.ts.map +1 -1
- package/dist/react/file-browser/file-preview-panel.js.map +1 -1
- package/dist/react/file-browser/new-folder-input.d.ts.map +1 -1
- package/dist/react/file-browser/new-folder-input.js.map +1 -1
- package/dist/react/genie/genie-chat-input.d.ts.map +1 -1
- package/dist/react/genie/genie-chat-input.js.map +1 -1
- package/dist/react/genie/genie-chat-message-list.d.ts.map +1 -1
- package/dist/react/genie/genie-chat-message-list.js.map +1 -1
- package/dist/react/genie/genie-chat-message.d.ts.map +1 -1
- package/dist/react/genie/genie-chat-message.js.map +1 -1
- package/dist/react/genie/genie-query-visualization.d.ts.map +1 -1
- package/dist/react/genie/genie-query-visualization.js.map +1 -1
- package/dist/react/genie/use-genie-chat.d.ts.map +1 -1
- package/dist/react/genie/use-genie-chat.js.map +1 -1
- package/dist/react/hooks/analytics-request-store.js +144 -0
- package/dist/react/hooks/analytics-request-store.js.map +1 -0
- package/dist/react/hooks/request-store.js +92 -0
- package/dist/react/hooks/request-store.js.map +1 -0
- package/dist/react/hooks/types.d.ts.map +1 -1
- package/dist/react/hooks/use-agent-chat.d.ts.map +1 -1
- package/dist/react/hooks/use-agent-chat.js.map +1 -1
- package/dist/react/hooks/use-ai-search-query.d.ts.map +1 -1
- package/dist/react/hooks/use-ai-search-query.js.map +1 -1
- package/dist/react/hooks/use-analytics-query.d.ts +6 -0
- package/dist/react/hooks/use-analytics-query.d.ts.map +1 -1
- package/dist/react/hooks/use-analytics-query.js +34 -143
- package/dist/react/hooks/use-analytics-query.js.map +1 -1
- package/dist/react/hooks/use-analytics-warehouse-status.js.map +1 -1
- package/dist/react/hooks/use-chart-data.d.ts.map +1 -1
- package/dist/react/hooks/use-chart-data.js.map +1 -1
- package/dist/react/hooks/use-metric-view.d.ts.map +1 -1
- package/dist/react/hooks/use-metric-view.js.map +1 -1
- package/dist/react/hooks/use-plugin-config.d.ts.map +1 -1
- package/dist/react/hooks/use-plugin-config.js.map +1 -1
- package/dist/react/hooks/use-serving-invoke.d.ts.map +1 -1
- package/dist/react/hooks/use-serving-invoke.js.map +1 -1
- package/dist/react/hooks/use-serving-stream.d.ts.map +1 -1
- package/dist/react/hooks/use-serving-stream.js.map +1 -1
- package/dist/react/resource-status-indicator.d.ts.map +1 -1
- package/dist/react/resource-status-indicator.js.map +1 -1
- package/dist/react/table/data-table.d.ts.map +1 -1
- package/dist/react/table/data-table.js.map +1 -1
- package/dist/react/table/table-wrapper.js.map +1 -1
- package/dist/react/ui/button.d.ts +1 -1
- package/dist/react/ui/input-group.d.ts +1 -1
- package/dist/react/ui-variants/variants.d.ts.map +1 -1
- package/dist/react/ui-variants/variants.js.map +1 -1
- package/dist/schemas/manifest.d.ts.map +1 -1
- package/dist/schemas/manifest.js.map +1 -1
- package/dist/schemas/metric-fqn.js.map +1 -1
- package/dist/shared/src/plugin.d.ts.map +1 -1
- package/dist/styles.css +0 -3
- package/dist/workspace-client/legacy.js.map +1 -1
- package/dist/workspace-client/types.d.ts.map +1 -1
- package/docs/plugins/testing.md +245 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/sbom.cdx.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"new-folder-input.js","names":[],"sources":["../../../src/react/file-browser/new-folder-input.tsx"],"sourcesContent":["import { FolderPlus, Loader2, X } from \"lucide-react\";\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport type { FileBrowserLabels } from \"./types\";\n\n/** Props for the NewFolderInput component */\nexport interface NewFolderInputProps
|
|
1
|
+
{"version":3,"file":"new-folder-input.js","names":[],"sources":["../../../src/react/file-browser/new-folder-input.tsx"],"sourcesContent":["import { FolderPlus, Loader2, X } from \"lucide-react\";\nimport { useEffect, useRef } from \"react\";\n\nimport { cn } from \"../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport type { FileBrowserLabels } from \"./types\";\n\n/** Props for the NewFolderInput component */\nexport interface NewFolderInputProps extends Omit<\n React.ComponentProps<\"div\">,\n \"children\" | \"onChange\"\n> {\n /** Current folder name value */\n value: string;\n /** Called when folder name changes */\n onChange: (value: string) => void;\n /** Called when the user confirms creation */\n onCreate: () => void;\n /** Called when the user cancels */\n onCancel: () => void;\n /** Whether folder creation is in progress */\n creating?: boolean;\n /** Auto-focus the input on mount */\n autoFocus?: boolean;\n /** Customizable labels */\n labels?: Pick<FileBrowserLabels, \"create\" | \"folderNamePlaceholder\">;\n}\n\n/** Inline folder-name input with create/cancel actions */\nexport function NewFolderInput({\n value,\n onChange,\n onCreate,\n onCancel,\n creating,\n autoFocus = true,\n labels,\n className,\n ...props\n}: NewFolderInputProps) {\n const inputRef = useRef<HTMLInputElement>(null);\n\n useEffect(() => {\n if (autoFocus) {\n inputRef.current?.focus();\n }\n }, [autoFocus]);\n\n return (\n <div\n data-slot=\"new-folder-input\"\n className={cn(\n \"flex items-center gap-2 px-4 py-3 border-b bg-muted/30\",\n className,\n )}\n {...props}\n >\n <FolderPlus className=\"h-5 w-5 text-blue-500 shrink-0\" />\n <input\n ref={inputRef}\n type=\"text\"\n value={value}\n onChange={(e) => onChange(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") onCreate();\n if (e.key === \"Escape\") onCancel();\n }}\n placeholder={labels?.folderNamePlaceholder ?? \"Folder name\"}\n className=\"flex-1 text-sm bg-background border rounded px-2 py-1 outline-none focus:ring-1 focus:ring-ring\"\n disabled={creating}\n />\n <Button\n variant=\"ghost\"\n size=\"sm\"\n disabled={creating || !value.trim()}\n onClick={onCreate}\n >\n {creating ? (\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n ) : (\n (labels?.create ?? \"Create\")\n )}\n </Button>\n <button\n type=\"button\"\n onClick={onCancel}\n className=\"text-muted-foreground hover:text-foreground\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n </div>\n );\n}\n"],"mappings":";;;;;;;;AA6BA,SAAgB,eAAe,EAC7B,OACA,UACA,UACA,UACA,UACA,YAAY,MACZ,QACA,WACA,GAAG,SACmB;CACtB,MAAM,WAAW,OAAyB,KAAK;AAE/C,iBAAgB;AACd,MAAI,UACF,UAAS,SAAS,OAAO;IAE1B,CAAC,UAAU,CAAC;AAEf,QACE,qBAAC;EACC,aAAU;EACV,WAAW,GACT,0DACA,UACD;EACD,GAAI;;GAEJ,oBAAC,cAAW,WAAU,mCAAmC;GACzD,oBAAC;IACC,KAAK;IACL,MAAK;IACE;IACP,WAAW,MAAM,SAAS,EAAE,OAAO,MAAM;IACzC,YAAY,MAAM;AAChB,SAAI,EAAE,QAAQ,QAAS,WAAU;AACjC,SAAI,EAAE,QAAQ,SAAU,WAAU;;IAEpC,aAAa,QAAQ,yBAAyB;IAC9C,WAAU;IACV,UAAU;KACV;GACF,oBAAC;IACC,SAAQ;IACR,MAAK;IACL,UAAU,YAAY,CAAC,MAAM,MAAM;IACnC,SAAS;cAER,WACC,oBAAC,WAAQ,WAAU,yBAAyB,GAE3C,QAAQ,UAAU;KAEd;GACT,oBAAC;IACC,MAAK;IACL,SAAS;IACT,WAAU;cAEV,oBAAC,KAAE,WAAU,YAAY;KAClB;;GACL"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"genie-chat-input.d.ts","names":[],"sources":["../../../src/react/genie/genie-chat-input.tsx"],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"genie-chat-input.d.ts","names":[],"sources":["../../../src/react/genie/genie-chat-input.tsx"],"mappings":";;;UAKU,mBAAA;;EAER,MAAA,GAAS,OAAA;EAFD;EAIR,QAAA;;EAEA,WAAA;EAJA;EAMA,SAAA;AAAA;;iBAIc,cAAA,CAAA;EACd,MAAA;EACA,QAAA;EACA,WAAA;EACA;AAAA,GACC,mBAAA,GAAmB,kBAAA,CAAA,GAAA,CAAA,OAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"genie-chat-input.js","names":[],"sources":["../../../src/react/genie/genie-chat-input.tsx"],"sourcesContent":["import { type KeyboardEvent, useRef, useState } from \"react\";\nimport { cn } from \"../lib/utils\";\nimport { Button } from \"../ui/button\";\n\ninterface GenieChatInputProps {\n /** Callback fired when the user submits a message */\n onSend: (content: string) => void;\n /** Disable the input and send button */\n disabled?: boolean;\n /** Placeholder text shown in the textarea */\n placeholder?: string;\n /** Additional CSS class for the container */\n className?: string;\n}\n\n/** Auto-expanding textarea input with a send button for chat messages. Submits on Enter (Shift+Enter for newline). */\nexport function GenieChatInput({\n onSend,\n disabled = false,\n placeholder = \"Ask a question...\",\n className,\n}: GenieChatInputProps) {\n const [value, setValue] = useState(\"\");\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n const handleSubmit = () => {\n const trimmed = value.trim();\n if (!trimmed || disabled) return;\n onSend(trimmed);\n setValue(\"\");\n if (textareaRef.current) {\n textareaRef.current.style.height = \"auto\";\n }\n };\n\n const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSubmit();\n }\n };\n\n const MAX_HEIGHT = 200;\n\n const handleInput = () => {\n const textarea = textareaRef.current;\n if (textarea) {\n textarea.style.height = \"auto\";\n const clamped = Math.min(textarea.scrollHeight, MAX_HEIGHT);\n textarea.style.height = `${clamped}px`;\n textarea.style.overflowY =\n textarea.scrollHeight > MAX_HEIGHT ? \"auto\" : \"hidden\";\n }\n };\n\n return (\n <div className={cn(\"flex gap-2 p-4 border-t shrink-0\", className)}>\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n disabled={disabled}\n rows={1}\n className={cn(\n \"flex-1 resize-none overflow-hidden rounded-md border border-input bg-background px-3 py-2\",\n \"text-sm placeholder:text-muted-foreground\",\n \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n )}\n />\n <Button\n onClick={handleSubmit}\n disabled={disabled || !value.trim()}\n size=\"default\"\n className=\"self-end\"\n >\n Send\n </Button>\n </div>\n );\n}\n"],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"genie-chat-input.js","names":[],"sources":["../../../src/react/genie/genie-chat-input.tsx"],"sourcesContent":["import { type KeyboardEvent, useRef, useState } from \"react\";\n\nimport { cn } from \"../lib/utils\";\nimport { Button } from \"../ui/button\";\n\ninterface GenieChatInputProps {\n /** Callback fired when the user submits a message */\n onSend: (content: string) => void;\n /** Disable the input and send button */\n disabled?: boolean;\n /** Placeholder text shown in the textarea */\n placeholder?: string;\n /** Additional CSS class for the container */\n className?: string;\n}\n\n/** Auto-expanding textarea input with a send button for chat messages. Submits on Enter (Shift+Enter for newline). */\nexport function GenieChatInput({\n onSend,\n disabled = false,\n placeholder = \"Ask a question...\",\n className,\n}: GenieChatInputProps) {\n const [value, setValue] = useState(\"\");\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n const handleSubmit = () => {\n const trimmed = value.trim();\n if (!trimmed || disabled) return;\n onSend(trimmed);\n setValue(\"\");\n if (textareaRef.current) {\n textareaRef.current.style.height = \"auto\";\n }\n };\n\n const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSubmit();\n }\n };\n\n const MAX_HEIGHT = 200;\n\n const handleInput = () => {\n const textarea = textareaRef.current;\n if (textarea) {\n textarea.style.height = \"auto\";\n const clamped = Math.min(textarea.scrollHeight, MAX_HEIGHT);\n textarea.style.height = `${clamped}px`;\n textarea.style.overflowY =\n textarea.scrollHeight > MAX_HEIGHT ? \"auto\" : \"hidden\";\n }\n };\n\n return (\n <div className={cn(\"flex gap-2 p-4 border-t shrink-0\", className)}>\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n disabled={disabled}\n rows={1}\n className={cn(\n \"flex-1 resize-none overflow-hidden rounded-md border border-input bg-background px-3 py-2\",\n \"text-sm placeholder:text-muted-foreground\",\n \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n )}\n />\n <Button\n onClick={handleSubmit}\n disabled={disabled || !value.trim()}\n size=\"default\"\n className=\"self-end\"\n >\n Send\n </Button>\n </div>\n );\n}\n"],"mappings":";;;;;;;AAiBA,SAAgB,eAAe,EAC7B,QACA,WAAW,OACX,cAAc,qBACd,aACsB;CACtB,MAAM,CAAC,OAAO,YAAY,SAAS,GAAG;CACtC,MAAM,cAAc,OAA4B,KAAK;CAErD,MAAM,qBAAqB;EACzB,MAAM,UAAU,MAAM,MAAM;AAC5B,MAAI,CAAC,WAAW,SAAU;AAC1B,SAAO,QAAQ;AACf,WAAS,GAAG;AACZ,MAAI,YAAY,QACd,aAAY,QAAQ,MAAM,SAAS;;CAIvC,MAAM,iBAAiB,MAA0C;AAC/D,MAAI,EAAE,QAAQ,WAAW,CAAC,EAAE,UAAU;AACpC,KAAE,gBAAgB;AAClB,iBAAc;;;CAIlB,MAAM,aAAa;CAEnB,MAAM,oBAAoB;EACxB,MAAM,WAAW,YAAY;AAC7B,MAAI,UAAU;AACZ,YAAS,MAAM,SAAS;GACxB,MAAM,UAAU,KAAK,IAAI,SAAS,cAAc,WAAW;AAC3D,YAAS,MAAM,SAAS,GAAG,QAAQ;AACnC,YAAS,MAAM,YACb,SAAS,eAAe,aAAa,SAAS;;;AAIpD,QACE,qBAAC;EAAI,WAAW,GAAG,oCAAoC,UAAU;aAC/D,oBAAC;GACC,KAAK;GACE;GACP,WAAW,MAAM,SAAS,EAAE,OAAO,MAAM;GACzC,WAAW;GACX,SAAS;GACI;GACH;GACV,MAAM;GACN,WAAW,GACT,6FACA,6CACA,2EACA,kDACD;IACD,EACF,oBAAC;GACC,SAAS;GACT,UAAU,YAAY,CAAC,MAAM,MAAM;GACnC,MAAK;GACL,WAAU;aACX;IAEQ;GACL"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"genie-chat-message-list.d.ts","names":[],"sources":["../../../src/react/genie/genie-chat-message-list.tsx"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"genie-chat-message-list.d.ts","names":[],"sources":["../../../src/react/genie/genie-chat-message-list.tsx"],"mappings":";;;;UAaU,yBAAA;;EAER,QAAA,EAAU,gBAAA;EAFF;EAIR,MAAA,EAAQ,eAAA;;EAER,SAAA;EAJA;EAMA,eAAA;EAJA;EAMA,mBAAA;AAAA;;iBAiIc,oBAAA,CAAA;EACd,QAAA;EACA,MAAA;EACA,SAAA;EACA,eAAA;EACA;AAAA,GACC,yBAAA,GAAyB,kBAAA,CAAA,GAAA,CAAA,OAAA"}
|
|
@@ -1 +1 @@
|
|
|
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 {\n type GenieChatStatus,\n type GenieMessageItem,\n TERMINAL_STATUSES,\n} from \"./types\";\n\ninterface 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.content &&\n !TERMINAL_STATUSES.has(lastMessage.status);\n\n return (\n <ScrollArea\n ref={scrollRef}\n className={cn(\n \"flex-1 min-h-0 p-4 [&_[data-slot=scroll-area-viewport]>div]:!block\",\n className,\n )}\n >\n <div className=\"flex flex-col gap-4 min-w-0\">\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) =>\n msg.role !== \"assistant\" ||\n msg.content ||\n (msg.id !== \"\" && TERMINAL_STATUSES.has(msg.status)),\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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"}
|
|
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\";\n\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 {\n type GenieChatStatus,\n type GenieMessageItem,\n TERMINAL_STATUSES,\n} from \"./types\";\n\ninterface 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 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.content &&\n !TERMINAL_STATUSES.has(lastMessage.status);\n\n return (\n <ScrollArea\n ref={scrollRef}\n className={cn(\n \"flex-1 min-h-0 p-4 [&_[data-slot=scroll-area-viewport]>div]:!block\",\n className,\n )}\n >\n <div className=\"flex flex-col gap-4 min-w-0\">\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) =>\n msg.role !== \"assistant\" ||\n msg.content ||\n (msg.id !== \"\" && TERMINAL_STATUSES.has(msg.status)),\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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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"genie-chat-message.d.ts","names":[],"sources":["../../../src/react/genie/genie-chat-message.tsx"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"genie-chat-message.d.ts","names":[],"sources":["../../../src/react/genie/genie-chat-message.tsx"],"mappings":";;;;UA6BU,qBAAA;;EAER,OAAA,EAAS,gBAAA;EAFD;EAIR,SAAA;AAAA;;iBAQc,gBAAA,CAAA;EACd,OAAA;EACA;AAAA,GACC,qBAAA,GAAqB,kBAAA,CAAA,GAAA,CAAA,OAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"genie-chat-message.js","names":[],"sources":["../../../src/react/genie/genie-chat-message.tsx"],"sourcesContent":["import DOMPurify from \"dompurify\";\nimport { marked } from \"marked\";\nimport { useMemo } from \"react\";\nimport { cn } from \"../lib/utils\";\nimport { Avatar, AvatarFallback } from \"../ui/avatar\";\nimport { Card } from \"../ui/card\";\nimport { GenieQueryVisualization } from \"./genie-query-visualization\";\nimport type { GenieAttachmentResponse, GenieMessageItem } from \"./types\";\n\n/**\n * Using `marked` instead of `react-markdown` because `react-markdown` depends on\n * `micromark-util-symbol` which has broken ESM exports with `rolldown-vite`.\n * Output is sanitized with DOMPurify before being passed to `dangerouslySetInnerHTML`.\n */\nmarked.setOptions({ breaks: true, gfm: true });\n\nconst markdownStyles = cn(\n \"text-sm break-words\",\n \"[&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0\",\n \"[&_pre]:bg-background/50 [&_pre]:p-2 [&_pre]:rounded [&_pre]:text-xs [&_pre]:overflow-x-auto\",\n \"[&_code]:text-xs [&_code]:bg-background/50 [&_code]:px-1 [&_code]:rounded\",\n \"[&_table]:text-xs [&_table]:block [&_table]:overflow-x-auto [&_table]:max-w-full\",\n \"[&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1\",\n \"[&_table]:border-collapse [&_th]:border [&_td]:border\",\n \"[&_th]:border-border [&_td]:border-border\",\n \"[&_a]:underline\",\n);\n\ninterface GenieChatMessageProps {\n /** The message object to render */\n message: GenieMessageItem;\n /** Additional CSS class */\n className?: string;\n}\n\nfunction isQueryAttachment(att: GenieAttachmentResponse): boolean {\n return !!(att.query?.title || att.query?.query);\n}\n\n/** Renders a single Genie message bubble with optional expandable SQL query attachments. */\nexport function GenieChatMessage({\n message,\n className,\n}: GenieChatMessageProps) {\n const isUser = message.role === \"user\";\n const queryAttachments = message.attachments.filter(isQueryAttachment);\n const html = useMemo(\n () =>\n message.content\n ? DOMPurify.sanitize(marked.parse(message.content) as string)\n : \"\",\n [message.content],\n );\n\n return (\n <div\n className={cn(\n \"flex gap-3\",\n isUser ? \"flex-row-reverse\" : \"flex-row\",\n className,\n )}\n >\n <Avatar className=\"h-8 w-8 shrink-0 mt-1\">\n <AvatarFallback\n className={cn(\n \"text-xs font-medium\",\n isUser ? \"bg-primary text-primary-foreground\" : \"bg-muted\",\n )}\n >\n {isUser ? \"You\" : \"AI\"}\n </AvatarFallback>\n </Avatar>\n\n <div className=\"flex flex-col gap-2 max-w-[80%] min-w-0 overflow-hidden\">\n <Card\n className={cn(\n \"w-full px-4 py-3 overflow-hidden\",\n isUser\n ? \"bg-primary text-primary-foreground [&_*::selection]:bg-primary-foreground/30 [&::selection]:bg-primary-foreground/30\"\n : \"bg-muted\",\n )}\n >\n {html && (\n <div\n className={markdownStyles}\n dangerouslySetInnerHTML={{ __html: html }}\n />\n )}\n\n {message.error && (\n <p className=\"text-sm text-destructive mt-1\">{message.error}</p>\n )}\n </Card>\n\n {queryAttachments.length > 0 && (\n <div className=\"flex flex-col gap-2 w-full min-w-0\">\n {queryAttachments.map((att) => {\n const key = att.attachmentId ?? \"query\";\n const queryResult = att.attachmentId\n ? message.queryResults.get(att.attachmentId)\n : undefined;\n\n return (\n <div key={key} className=\"flex flex-col gap-2\">\n <Card className=\"px-4 py-3 text-xs overflow-hidden shadow-none\">\n <details>\n <summary className=\"cursor-pointer select-none font-medium\">\n {att.query?.title ?? \"SQL Query\"}\n </summary>\n <div className=\"mt-2 flex flex-col gap-1\">\n {att.query?.description && (\n <span className=\"text-muted-foreground\">\n {att.query.description}\n </span>\n )}\n {att.query?.query && (\n <pre className=\"mt-1 p-2 rounded bg-background text-[11px] whitespace-pre-wrap break-all\">\n {att.query.query}\n </pre>\n )}\n </div>\n </details>\n </Card>\n {queryResult != null && (\n <Card className=\"w-full px-4 py-3 overflow-hidden\">\n <GenieQueryVisualization data={queryResult} />\n </Card>\n )}\n </div>\n );\n })}\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"genie-chat-message.js","names":[],"sources":["../../../src/react/genie/genie-chat-message.tsx"],"sourcesContent":["import DOMPurify from \"dompurify\";\nimport { marked } from \"marked\";\nimport { useMemo } from \"react\";\n\nimport { cn } from \"../lib/utils\";\nimport { Avatar, AvatarFallback } from \"../ui/avatar\";\nimport { Card } from \"../ui/card\";\nimport { GenieQueryVisualization } from \"./genie-query-visualization\";\nimport type { GenieAttachmentResponse, GenieMessageItem } from \"./types\";\n\n/**\n * Using `marked` instead of `react-markdown` because `react-markdown` depends on\n * `micromark-util-symbol` which has broken ESM exports with `rolldown-vite`.\n * Output is sanitized with DOMPurify before being passed to `dangerouslySetInnerHTML`.\n */\nmarked.setOptions({ breaks: true, gfm: true });\n\nconst markdownStyles = cn(\n \"text-sm break-words\",\n \"[&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0\",\n \"[&_pre]:bg-background/50 [&_pre]:p-2 [&_pre]:rounded [&_pre]:text-xs [&_pre]:overflow-x-auto\",\n \"[&_code]:text-xs [&_code]:bg-background/50 [&_code]:px-1 [&_code]:rounded\",\n \"[&_table]:text-xs [&_table]:block [&_table]:overflow-x-auto [&_table]:max-w-full\",\n \"[&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1\",\n \"[&_table]:border-collapse [&_th]:border [&_td]:border\",\n \"[&_th]:border-border [&_td]:border-border\",\n \"[&_a]:underline\",\n);\n\ninterface GenieChatMessageProps {\n /** The message object to render */\n message: GenieMessageItem;\n /** Additional CSS class */\n className?: string;\n}\n\nfunction isQueryAttachment(att: GenieAttachmentResponse): boolean {\n return !!(att.query?.title || att.query?.query);\n}\n\n/** Renders a single Genie message bubble with optional expandable SQL query attachments. */\nexport function GenieChatMessage({\n message,\n className,\n}: GenieChatMessageProps) {\n const isUser = message.role === \"user\";\n const queryAttachments = message.attachments.filter(isQueryAttachment);\n const html = useMemo(\n () =>\n message.content\n ? DOMPurify.sanitize(marked.parse(message.content) as string)\n : \"\",\n [message.content],\n );\n\n return (\n <div\n className={cn(\n \"flex gap-3\",\n isUser ? \"flex-row-reverse\" : \"flex-row\",\n className,\n )}\n >\n <Avatar className=\"h-8 w-8 shrink-0 mt-1\">\n <AvatarFallback\n className={cn(\n \"text-xs font-medium\",\n isUser ? \"bg-primary text-primary-foreground\" : \"bg-muted\",\n )}\n >\n {isUser ? \"You\" : \"AI\"}\n </AvatarFallback>\n </Avatar>\n\n <div className=\"flex flex-col gap-2 max-w-[80%] min-w-0 overflow-hidden\">\n <Card\n className={cn(\n \"w-full px-4 py-3 overflow-hidden\",\n isUser\n ? \"bg-primary text-primary-foreground [&_*::selection]:bg-primary-foreground/30 [&::selection]:bg-primary-foreground/30\"\n : \"bg-muted\",\n )}\n >\n {html && (\n <div\n className={markdownStyles}\n dangerouslySetInnerHTML={{ __html: html }}\n />\n )}\n\n {message.error && (\n <p className=\"text-sm text-destructive mt-1\">{message.error}</p>\n )}\n </Card>\n\n {queryAttachments.length > 0 && (\n <div className=\"flex flex-col gap-2 w-full min-w-0\">\n {queryAttachments.map((att) => {\n const key = att.attachmentId ?? \"query\";\n const queryResult = att.attachmentId\n ? message.queryResults.get(att.attachmentId)\n : undefined;\n\n return (\n <div key={key} className=\"flex flex-col gap-2\">\n <Card className=\"px-4 py-3 text-xs overflow-hidden shadow-none\">\n <details>\n <summary className=\"cursor-pointer select-none font-medium\">\n {att.query?.title ?? \"SQL Query\"}\n </summary>\n <div className=\"mt-2 flex flex-col gap-1\">\n {att.query?.description && (\n <span className=\"text-muted-foreground\">\n {att.query.description}\n </span>\n )}\n {att.query?.query && (\n <pre className=\"mt-1 p-2 rounded bg-background text-[11px] whitespace-pre-wrap break-all\">\n {att.query.query}\n </pre>\n )}\n </div>\n </details>\n </Card>\n {queryResult != null && (\n <Card className=\"w-full px-4 py-3 overflow-hidden\">\n <GenieQueryVisualization data={queryResult} />\n </Card>\n )}\n </div>\n );\n })}\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;AAeA,OAAO,WAAW;CAAE,QAAQ;CAAM,KAAK;CAAM,CAAC;AAE9C,MAAM,iBAAiB,GACrB,uBACA,kDACA,gGACA,6EACA,oFACA,mDACA,yDACA,6CACA,kBACD;AASD,SAAS,kBAAkB,KAAuC;AAChE,QAAO,CAAC,EAAE,IAAI,OAAO,SAAS,IAAI,OAAO;;;AAI3C,SAAgB,iBAAiB,EAC/B,SACA,aACwB;CACxB,MAAM,SAAS,QAAQ,SAAS;CAChC,MAAM,mBAAmB,QAAQ,YAAY,OAAO,kBAAkB;CACtE,MAAM,OAAO,cAET,QAAQ,UACJ,UAAU,SAAS,OAAO,MAAM,QAAQ,QAAQ,CAAW,GAC3D,IACN,CAAC,QAAQ,QAAQ,CAClB;AAED,QACE,qBAAC;EACC,WAAW,GACT,cACA,SAAS,qBAAqB,YAC9B,UACD;aAED,oBAAC;GAAO,WAAU;aAChB,oBAAC;IACC,WAAW,GACT,uBACA,SAAS,uCAAuC,WACjD;cAEA,SAAS,QAAQ;KACH;IACV,EAET,qBAAC;GAAI,WAAU;cACb,qBAAC;IACC,WAAW,GACT,oCACA,SACI,yHACA,WACL;eAEA,QACC,oBAAC;KACC,WAAW;KACX,yBAAyB,EAAE,QAAQ,MAAM;MACzC,EAGH,QAAQ,SACP,oBAAC;KAAE,WAAU;eAAiC,QAAQ;MAAU;KAE7D,EAEN,iBAAiB,SAAS,KACzB,oBAAC;IAAI,WAAU;cACZ,iBAAiB,KAAK,QAAQ;KAC7B,MAAM,MAAM,IAAI,gBAAgB;KAChC,MAAM,cAAc,IAAI,eACpB,QAAQ,aAAa,IAAI,IAAI,aAAa,GAC1C;AAEJ,YACE,qBAAC;MAAc,WAAU;iBACvB,oBAAC;OAAK,WAAU;iBACd,qBAAC,wBACC,oBAAC;QAAQ,WAAU;kBAChB,IAAI,OAAO,SAAS;SACb,EACV,qBAAC;QAAI,WAAU;mBACZ,IAAI,OAAO,eACV,oBAAC;SAAK,WAAU;mBACb,IAAI,MAAM;UACN,EAER,IAAI,OAAO,SACV,oBAAC;SAAI,WAAU;mBACZ,IAAI,MAAM;UACP;SAEJ,IACE;QACL,EACN,eAAe,QACd,oBAAC;OAAK,WAAU;iBACd,oBAAC,2BAAwB,MAAM,cAAe;QACzC;QAvBD,IAyBJ;MAER;KACE;IAEJ;GACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"genie-query-visualization.d.ts","names":[],"sources":["../../../src/react/genie/genie-query-visualization.tsx"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"genie-query-visualization.d.ts","names":[],"sources":["../../../src/react/genie/genie-query-visualization.tsx"],"mappings":";;;;;UA8CU,4BAAA;;EAER,IAAA,EAAM,sBAAA;;EAEN,SAAA;AAAA;;;;;;;;iBAUc,uBAAA,CAAA;EACd,IAAA;EACA;AAAA,GACC,4BAAA,GAA4B,kBAAA,CAAA,GAAA,CAAA,OAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"genie-query-visualization.js","names":[],"sources":["../../../src/react/genie/genie-query-visualization.tsx"],"sourcesContent":["import { BarChart3Icon, ChevronDownIcon } from \"lucide-react\";\nimport { useMemo, useState } from \"react\";\nimport type { GenieStatementResponse } from \"shared\";\nimport { BaseChart } from \"../charts/base\";\nimport { ChartErrorBoundary } from \"../charts/chart-error-boundary\";\nimport type { ChartType } from \"../charts/types\";\nimport { cn } from \"../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from \"../ui/dropdown-menu\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"../ui/table\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"../ui/tabs\";\nimport {\n getCompatibleChartTypes,\n inferChartType,\n} from \"./genie-chart-inference\";\nimport { transformGenieData } from \"./genie-query-transform\";\n\nconst TABLE_ROW_LIMIT = 50;\nconst CHART_HEIGHT = 250;\n\nconst CHART_TYPE_LABELS: Record<ChartType, string> = {\n bar: \"Bar\",\n line: \"Line\",\n area: \"Area\",\n pie: \"Pie\",\n donut: \"Donut\",\n scatter: \"Scatter\",\n radar: \"Radar\",\n heatmap: \"Heatmap\",\n};\n\ninterface GenieQueryVisualizationProps {\n /** Raw statement_response from the Genie API */\n data: GenieStatementResponse;\n /** Additional CSS classes */\n className?: string;\n}\n\n/**\n * Renders a chart + data table for a Genie query result.\n *\n * - When a chart type can be inferred: shows Tabs with \"Chart\" (default) and \"Table\"\n * - When no chart fits: shows only the data table\n * - When data is empty/malformed: renders nothing\n */\nexport function GenieQueryVisualization({\n data,\n className,\n}: GenieQueryVisualizationProps) {\n const transformed = useMemo(() => transformGenieData(data), [data]);\n const { inference, compatibleTypes } = useMemo(() => {\n if (!transformed)\n return { inference: null, compatibleTypes: [] as ChartType[] };\n const { rows, columns } = transformed;\n return {\n inference: inferChartType(rows, columns),\n compatibleTypes: getCompatibleChartTypes(rows, columns),\n };\n }, [transformed]);\n\n const [chartTypeOverride, setChartTypeOverride] = useState<ChartType | null>(\n null,\n );\n\n if (!transformed || transformed.rows.length === 0) return null;\n\n const { rows, columns } = transformed;\n const truncated = rows.length > TABLE_ROW_LIMIT;\n const displayRows = truncated ? rows.slice(0, TABLE_ROW_LIMIT) : rows;\n\n const activeChartType =\n chartTypeOverride && compatibleTypes.includes(chartTypeOverride)\n ? chartTypeOverride\n : (inference?.chartType ?? null);\n\n const dataTable = (\n <div className=\"overflow-auto max-h-[300px]\">\n <Table>\n <TableHeader>\n <TableRow>\n {columns.map((col) => (\n <TableHead key={col.name}>{col.name}</TableHead>\n ))}\n </TableRow>\n </TableHeader>\n <TableBody>\n {displayRows.map((row, i) => (\n
|
|
1
|
+
{"version":3,"file":"genie-query-visualization.js","names":[],"sources":["../../../src/react/genie/genie-query-visualization.tsx"],"sourcesContent":["import { BarChart3Icon, ChevronDownIcon } from \"lucide-react\";\nimport { useMemo, useState } from \"react\";\nimport type { GenieStatementResponse } from \"shared\";\n\nimport { BaseChart } from \"../charts/base\";\nimport { ChartErrorBoundary } from \"../charts/chart-error-boundary\";\nimport type { ChartType } from \"../charts/types\";\nimport { cn } from \"../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from \"../ui/dropdown-menu\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"../ui/table\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"../ui/tabs\";\nimport {\n getCompatibleChartTypes,\n inferChartType,\n} from \"./genie-chart-inference\";\nimport { transformGenieData } from \"./genie-query-transform\";\n\nconst TABLE_ROW_LIMIT = 50;\nconst CHART_HEIGHT = 250;\n\nconst CHART_TYPE_LABELS: Record<ChartType, string> = {\n bar: \"Bar\",\n line: \"Line\",\n area: \"Area\",\n pie: \"Pie\",\n donut: \"Donut\",\n scatter: \"Scatter\",\n radar: \"Radar\",\n heatmap: \"Heatmap\",\n};\n\ninterface GenieQueryVisualizationProps {\n /** Raw statement_response from the Genie API */\n data: GenieStatementResponse;\n /** Additional CSS classes */\n className?: string;\n}\n\n/**\n * Renders a chart + data table for a Genie query result.\n *\n * - When a chart type can be inferred: shows Tabs with \"Chart\" (default) and \"Table\"\n * - When no chart fits: shows only the data table\n * - When data is empty/malformed: renders nothing\n */\nexport function GenieQueryVisualization({\n data,\n className,\n}: GenieQueryVisualizationProps) {\n const transformed = useMemo(() => transformGenieData(data), [data]);\n const { inference, compatibleTypes } = useMemo(() => {\n if (!transformed)\n return { inference: null, compatibleTypes: [] as ChartType[] };\n const { rows, columns } = transformed;\n return {\n inference: inferChartType(rows, columns),\n compatibleTypes: getCompatibleChartTypes(rows, columns),\n };\n }, [transformed]);\n\n const [chartTypeOverride, setChartTypeOverride] = useState<ChartType | null>(\n null,\n );\n\n if (!transformed || transformed.rows.length === 0) return null;\n\n const { rows, columns } = transformed;\n const truncated = rows.length > TABLE_ROW_LIMIT;\n const displayRows = truncated ? rows.slice(0, TABLE_ROW_LIMIT) : rows;\n\n const activeChartType =\n chartTypeOverride && compatibleTypes.includes(chartTypeOverride)\n ? chartTypeOverride\n : (inference?.chartType ?? null);\n\n const dataTable = (\n <div className=\"overflow-auto max-h-[300px]\">\n <Table>\n <TableHeader>\n <TableRow>\n {columns.map((col) => (\n <TableHead key={col.name}>{col.name}</TableHead>\n ))}\n </TableRow>\n </TableHeader>\n <TableBody>\n {displayRows.map((row, i) => (\n <TableRow key={i}>\n {columns.map((col) => (\n <TableCell key={col.name}>\n {row[col.name] != null ? String(row[col.name]) : \"\"}\n </TableCell>\n ))}\n </TableRow>\n ))}\n </TableBody>\n </Table>\n {truncated && (\n <p className=\"text-xs text-muted-foreground px-2 py-1\">\n Showing {TABLE_ROW_LIMIT} of {rows.length} rows\n </p>\n )}\n </div>\n );\n\n if (!inference || !activeChartType) {\n return <div className={cn(\"min-w-0\", className)}>{dataTable}</div>;\n }\n\n return (\n <Tabs defaultValue=\"chart\" className={cn(\"min-w-0\", className)}>\n <div className=\"flex items-center justify-between\">\n <TabsList>\n <TabsTrigger value=\"chart\">Chart</TabsTrigger>\n <TabsTrigger value=\"table\">Table</TabsTrigger>\n </TabsList>\n {compatibleTypes.length > 1 && (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Change chart type\"\n className=\"gap-0.5\"\n >\n <BarChart3Icon className=\"size-3.5\" />\n <ChevronDownIcon className=\"size-3\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuLabel>Chart type</DropdownMenuLabel>\n <DropdownMenuRadioGroup\n value={activeChartType}\n onValueChange={(v) => setChartTypeOverride(v as ChartType)}\n >\n {compatibleTypes.map((type) => (\n <DropdownMenuRadioItem key={type} value={type}>\n {CHART_TYPE_LABELS[type]}\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n )}\n </div>\n <div className=\"grid min-w-0 [&>*]:col-start-1 [&>*]:row-start-1 [&>*]:min-w-0\">\n <TabsContent\n value=\"chart\"\n forceMount\n className=\"data-[state=inactive]:invisible\"\n >\n <ChartErrorBoundary fallback={dataTable}>\n <BaseChart\n data={rows}\n chartType={activeChartType}\n xKey={inference.xKey}\n yKey={inference.yKey}\n height={CHART_HEIGHT}\n showLegend={Array.isArray(inference.yKey)}\n />\n </ChartErrorBoundary>\n </TabsContent>\n <TabsContent\n value=\"table\"\n forceMount\n className=\"data-[state=inactive]:invisible\"\n >\n {dataTable}\n </TabsContent>\n </div>\n </Tabs>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;AAgCA,MAAM,kBAAkB;AACxB,MAAM,eAAe;AAErB,MAAM,oBAA+C;CACnD,KAAK;CACL,MAAM;CACN,MAAM;CACN,KAAK;CACL,OAAO;CACP,SAAS;CACT,OAAO;CACP,SAAS;CACV;;;;;;;;AAgBD,SAAgB,wBAAwB,EACtC,MACA,aAC+B;CAC/B,MAAM,cAAc,cAAc,mBAAmB,KAAK,EAAE,CAAC,KAAK,CAAC;CACnE,MAAM,EAAE,WAAW,oBAAoB,cAAc;AACnD,MAAI,CAAC,YACH,QAAO;GAAE,WAAW;GAAM,iBAAiB,EAAE;GAAiB;EAChE,MAAM,EAAE,MAAM,YAAY;AAC1B,SAAO;GACL,WAAW,eAAe,MAAM,QAAQ;GACxC,iBAAiB,wBAAwB,MAAM,QAAQ;GACxD;IACA,CAAC,YAAY,CAAC;CAEjB,MAAM,CAAC,mBAAmB,wBAAwB,SAChD,KACD;AAED,KAAI,CAAC,eAAe,YAAY,KAAK,WAAW,EAAG,QAAO;CAE1D,MAAM,EAAE,MAAM,YAAY;CAC1B,MAAM,YAAY,KAAK,SAAS;CAChC,MAAM,cAAc,YAAY,KAAK,MAAM,GAAG,gBAAgB,GAAG;CAEjE,MAAM,kBACJ,qBAAqB,gBAAgB,SAAS,kBAAkB,GAC5D,oBACC,WAAW,aAAa;CAE/B,MAAM,YACJ,qBAAC;EAAI,WAAU;aACb,qBAAC,oBACC,oBAAC,yBACC,oBAAC,sBACE,QAAQ,KAAK,QACZ,oBAAC,uBAA0B,IAAI,QAAf,IAAI,KAA4B,CAChD,GACO,GACC,EACd,oBAAC,uBACE,YAAY,KAAK,KAAK,MACrB,oBAAC,sBACE,QAAQ,KAAK,QACZ,oBAAC,uBACE,IAAI,IAAI,SAAS,OAAO,OAAO,IAAI,IAAI,MAAM,GAAG,MADnC,IAAI,KAER,CACZ,IALW,EAMJ,CACX,GACQ,IACN,EACP,aACC,qBAAC;GAAE,WAAU;;IAA0C;IAC5C;IAAgB;IAAK,KAAK;IAAO;;IACxC;GAEF;AAGR,KAAI,CAAC,aAAa,CAAC,gBACjB,QAAO,oBAAC;EAAI,WAAW,GAAG,WAAW,UAAU;YAAG;GAAgB;AAGpE,QACE,qBAAC;EAAK,cAAa;EAAQ,WAAW,GAAG,WAAW,UAAU;aAC5D,qBAAC;GAAI,WAAU;cACb,qBAAC,uBACC,oBAAC;IAAY,OAAM;cAAQ;KAAmB,EAC9C,oBAAC;IAAY,OAAM;cAAQ;KAAmB,IACrC,EACV,gBAAgB,SAAS,KACxB,qBAAC,2BACC,oBAAC;IAAoB;cACnB,qBAAC;KACC,SAAQ;KACR,MAAK;KACL,cAAW;KACX,WAAU;gBAEV,oBAAC,iBAAc,WAAU,aAAa,EACtC,oBAAC,mBAAgB,WAAU,WAAW;MAC/B;KACW,EACtB,qBAAC;IAAoB,OAAM;eACzB,oBAAC,+BAAkB,eAA8B,EACjD,oBAAC;KACC,OAAO;KACP,gBAAgB,MAAM,qBAAqB,EAAe;eAEzD,gBAAgB,KAAK,SACpB,oBAAC;MAAiC,OAAO;gBACtC,kBAAkB;QADO,KAEJ,CACxB;MACqB;KACL,IACT;IAEb,EACN,qBAAC;GAAI,WAAU;cACb,oBAAC;IACC,OAAM;IACN;IACA,WAAU;cAEV,oBAAC;KAAmB,UAAU;eAC5B,oBAAC;MACC,MAAM;MACN,WAAW;MACX,MAAM,UAAU;MAChB,MAAM,UAAU;MAChB,QAAQ;MACR,YAAY,MAAM,QAAQ,UAAU,KAAK;OACzC;MACiB;KACT,EACd,oBAAC;IACC,OAAM;IACN;IACA,WAAU;cAET;KACW;IACV;GACD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-genie-chat.d.ts","names":[],"sources":["../../../src/react/genie/use-genie-chat.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"use-genie-chat.d.ts","names":[],"sources":["../../../src/react/genie/use-genie-chat.ts"],"mappings":";;;;;AA4KA;;;;;;;iBAAgB,YAAA,CAAa,OAAA,EAAS,mBAAA,GAAsB,kBAAA"}
|
|
@@ -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 {\n type GenieChatStatus,\n type GenieMessageItem,\n type GenieMessageResponse,\n type GenieStreamEvent,\n TERMINAL_STATUSES,\n type UseGenieChatOptions,\n type 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 *\n * When a message is still in-progress (non-terminal status) and has no\n * attachments yet, we emit an empty assistant placeholder so the UI can\n * show a loading indicator and later poll for the completed response.\n */\nfunction messageResultToItems(msg: GenieMessageResponse): GenieMessageItem[] {\n const hasAttachments = (msg.attachments?.length ?? 0) > 0;\n\n if (!hasAttachments && TERMINAL_STATUSES.has(msg.status)) {\n return [makeUserItem(msg)];\n }\n if (!hasAttachments) {\n return [\n makeUserItem(msg, \"-user\"),\n {\n id: msg.messageId,\n role: \"assistant\",\n content: \"\",\n status: msg.status,\n attachments: [],\n queryResults: new Map(),\n },\n ];\n }\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 const processStreamEventRef = useRef<(event: GenieStreamEvent) => void>(\n () => {},\n );\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 item = makeAssistantItem(event.message);\n setMessages((prev) => {\n const last = prev[prev.length - 1];\n if (!last || last.role !== \"assistant\") return prev;\n\n if (last.id === event.message.messageId || last.id === \"\") {\n return [...prev.slice(0, -1), item];\n }\n\n return prev;\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 processStreamEventRef.current = processStreamEvent;\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 processStreamEventRef.current(\n JSON.parse(message.data) as GenieStreamEvent,\n );\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 })\n .then(() => {\n if (!abortController.signal.aborted) {\n setStatus((prev) => (prev === \"error\" ? \"error\" : \"idle\"));\n }\n })\n .catch(() => {\n if (abortController.signal.aborted) return;\n setError(\"Connection error. Please try again.\");\n setStatus(\"error\");\n });\n },\n [alias, basePath],\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 pollPendingMessage = useCallback(\n (\n convId: string,\n messageId: string,\n parentAbortController: AbortController,\n ) => {\n setStatus(\"streaming\");\n\n const requestId = crypto.randomUUID();\n const url =\n `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}` +\n `/messages/${encodeURIComponent(messageId)}?requestId=${encodeURIComponent(requestId)}`;\n\n connectSSE({\n url,\n signal: parentAbortController.signal,\n onMessage: async (message) => {\n try {\n processStreamEventRef.current(\n JSON.parse(message.data) as GenieStreamEvent,\n );\n } catch {\n // Malformed SSE data\n }\n },\n onError: (err) => {\n if (parentAbortController.signal.aborted) return;\n setError(\n err instanceof Error\n ? err.message\n : \"Failed to poll pending message.\",\n );\n setStatus(\"error\");\n },\n })\n .then(() => {\n if (!parentAbortController.signal.aborted) {\n setStatus((prev) => (prev === \"error\" ? \"error\" : \"idle\"));\n }\n })\n .catch(() => {\n if (parentAbortController.signal.aborted) return;\n setError(\"Failed to poll pending message.\");\n setStatus(\"error\");\n });\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) return;\n setMessages(items);\n\n const lastItem = items[items.length - 1];\n if (\n lastItem?.role === \"assistant\" &&\n !TERMINAL_STATUSES.has(lastItem.status)\n ) {\n pollPendingMessage(convId, lastItem.id, abortController);\n } else {\n setStatus((prev) => (prev === \"error\" ? \"error\" : \"idle\"));\n }\n });\n },\n [fetchPage, pollPendingMessage],\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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"}
|
|
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\";\n\nimport { connectSSE } from \"@/js\";\n\nimport {\n type GenieChatStatus,\n type GenieMessageItem,\n type GenieMessageResponse,\n type GenieStreamEvent,\n TERMINAL_STATUSES,\n type UseGenieChatOptions,\n type 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 *\n * When a message is still in-progress (non-terminal status) and has no\n * attachments yet, we emit an empty assistant placeholder so the UI can\n * show a loading indicator and later poll for the completed response.\n */\nfunction messageResultToItems(msg: GenieMessageResponse): GenieMessageItem[] {\n const hasAttachments = (msg.attachments?.length ?? 0) > 0;\n\n if (!hasAttachments && TERMINAL_STATUSES.has(msg.status)) {\n return [makeUserItem(msg)];\n }\n if (!hasAttachments) {\n return [\n makeUserItem(msg, \"-user\"),\n {\n id: msg.messageId,\n role: \"assistant\",\n content: \"\",\n status: msg.status,\n attachments: [],\n queryResults: new Map(),\n },\n ];\n }\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 const processStreamEventRef = useRef<(event: GenieStreamEvent) => void>(\n () => {},\n );\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 item = makeAssistantItem(event.message);\n setMessages((prev) => {\n const last = prev[prev.length - 1];\n if (!last || last.role !== \"assistant\") return prev;\n\n if (last.id === event.message.messageId || last.id === \"\") {\n return [...prev.slice(0, -1), item];\n }\n\n return prev;\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 processStreamEventRef.current = processStreamEvent;\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 processStreamEventRef.current(\n JSON.parse(message.data) as GenieStreamEvent,\n );\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 })\n .then(() => {\n if (!abortController.signal.aborted) {\n setStatus((prev) => (prev === \"error\" ? \"error\" : \"idle\"));\n }\n })\n .catch(() => {\n if (abortController.signal.aborted) return;\n setError(\"Connection error. Please try again.\");\n setStatus(\"error\");\n });\n },\n [alias, basePath],\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 pollPendingMessage = useCallback(\n (\n convId: string,\n messageId: string,\n parentAbortController: AbortController,\n ) => {\n setStatus(\"streaming\");\n\n const requestId = crypto.randomUUID();\n const url =\n `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}` +\n `/messages/${encodeURIComponent(messageId)}?requestId=${encodeURIComponent(requestId)}`;\n\n connectSSE({\n url,\n signal: parentAbortController.signal,\n onMessage: async (message) => {\n try {\n processStreamEventRef.current(\n JSON.parse(message.data) as GenieStreamEvent,\n );\n } catch {\n // Malformed SSE data\n }\n },\n onError: (err) => {\n if (parentAbortController.signal.aborted) return;\n setError(\n err instanceof Error\n ? err.message\n : \"Failed to poll pending message.\",\n );\n setStatus(\"error\");\n },\n })\n .then(() => {\n if (!parentAbortController.signal.aborted) {\n setStatus((prev) => (prev === \"error\" ? \"error\" : \"idle\"));\n }\n })\n .catch(() => {\n if (parentAbortController.signal.aborted) return;\n setError(\"Failed to poll pending message.\");\n setStatus(\"error\");\n });\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) return;\n setMessages(items);\n\n const lastItem = items[items.length - 1];\n if (\n lastItem?.role === \"assistant\" &&\n !TERMINAL_STATUSES.has(lastItem.status)\n ) {\n pollPendingMessage(convId, lastItem.id, abortController);\n } else {\n setStatus((prev) => (prev === \"error\" ? \"error\" : \"idle\"));\n }\n });\n },\n [fetchPage, pollPendingMessage],\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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"}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ArrowClient } from "../../js/arrow/arrow-client.js";
|
|
2
|
+
import { connectSSE } from "../../js/sse/connect-sse.js";
|
|
3
|
+
import { GENERIC_LOAD_ERROR, handleAnalyticsSseError, handleAnalyticsSseMessage, userFacingFetchError } from "./analytics-sse.js";
|
|
4
|
+
import { createRequestStore } from "./request-store.js";
|
|
5
|
+
|
|
6
|
+
//#region src/react/hooks/analytics-request-store.ts
|
|
7
|
+
/** Idle snapshot returned for keys with no live entry. Referentially stable. */
|
|
8
|
+
const EMPTY_SNAPSHOT = {
|
|
9
|
+
data: null,
|
|
10
|
+
loading: false,
|
|
11
|
+
error: null,
|
|
12
|
+
errorCode: null,
|
|
13
|
+
warehouseStatus: null
|
|
14
|
+
};
|
|
15
|
+
/** Snapshot a request resets to when it (re)starts. */
|
|
16
|
+
const LOADING_SNAPSHOT = {
|
|
17
|
+
data: null,
|
|
18
|
+
loading: true,
|
|
19
|
+
error: null,
|
|
20
|
+
errorCode: null,
|
|
21
|
+
warehouseStatus: null
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Fetch the real column names for a statement from the fallback endpoint,
|
|
25
|
+
* used when a very wide schema's names didn't fit in the response header.
|
|
26
|
+
* Returns undefined on any failure so decoding falls back to the raw Arrow
|
|
27
|
+
* schema names.
|
|
28
|
+
*/
|
|
29
|
+
async function fetchArrowColumns(statementId, signal) {
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`/api/analytics/columns/${encodeURIComponent(statementId)}`, { signal });
|
|
32
|
+
if (!res.ok) return void 0;
|
|
33
|
+
const body = await res.json();
|
|
34
|
+
return Array.isArray(body.columns) ? body.columns : void 0;
|
|
35
|
+
} catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Fetch an ARROW_STREAM query result as raw Arrow IPC bytes directly from
|
|
41
|
+
* the query endpoint (no SSE, no second /arrow-result request) and decode
|
|
42
|
+
* it into a Table. The server streams the bytes back as the POST response
|
|
43
|
+
* body; errors before the first byte arrive as a JSON `{ error, errorCode }`.
|
|
44
|
+
*/
|
|
45
|
+
async function fetchArrowDirect(controls, options) {
|
|
46
|
+
const { signal } = controls;
|
|
47
|
+
try {
|
|
48
|
+
const response = await fetch(options.url, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: options.payload,
|
|
52
|
+
signal
|
|
53
|
+
});
|
|
54
|
+
if (signal.aborted) return;
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
let message = GENERIC_LOAD_ERROR;
|
|
57
|
+
let code = null;
|
|
58
|
+
try {
|
|
59
|
+
const body = await response.json();
|
|
60
|
+
if (body.error) message = body.error;
|
|
61
|
+
if (typeof body.errorCode === "string") code = body.errorCode;
|
|
62
|
+
} catch {}
|
|
63
|
+
controls.patch({
|
|
64
|
+
loading: false,
|
|
65
|
+
error: message,
|
|
66
|
+
errorCode: code
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const buffer = await response.arrayBuffer();
|
|
71
|
+
if (signal.aborted) return;
|
|
72
|
+
let columnNames;
|
|
73
|
+
const header = response.headers.get("X-Appkit-Arrow-Columns");
|
|
74
|
+
if (header) try {
|
|
75
|
+
columnNames = JSON.parse(decodeURIComponent(header));
|
|
76
|
+
} catch {}
|
|
77
|
+
else {
|
|
78
|
+
const ref = response.headers.get("X-Appkit-Arrow-Columns-Ref");
|
|
79
|
+
if (ref) columnNames = await fetchArrowColumns(ref, signal);
|
|
80
|
+
}
|
|
81
|
+
const table = await ArrowClient.processArrowBuffer(new Uint8Array(buffer), columnNames);
|
|
82
|
+
controls.patch({
|
|
83
|
+
loading: false,
|
|
84
|
+
data: table
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (signal.aborted) return;
|
|
88
|
+
controls.patch({
|
|
89
|
+
loading: false,
|
|
90
|
+
error: userFacingFetchError(error)
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Build the runner for a request: reset to loading, then run the
|
|
96
|
+
* format-appropriate transport, reporting state through `controls.patch`.
|
|
97
|
+
*/
|
|
98
|
+
function runAnalyticsRequest(options) {
|
|
99
|
+
return (controls) => {
|
|
100
|
+
controls.patch(LOADING_SNAPSHOT);
|
|
101
|
+
if (options.format === "ARROW_STREAM") {
|
|
102
|
+
fetchArrowDirect(controls, options);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const sseContext = {
|
|
106
|
+
source: "useAnalyticsQuery",
|
|
107
|
+
resource: { url: options.url },
|
|
108
|
+
defaultExecutionError: "Unable to execute query",
|
|
109
|
+
unpublishOnMalformedMessage: false,
|
|
110
|
+
signal: controls.signal,
|
|
111
|
+
abort: controls.abort,
|
|
112
|
+
setLoading: (loading) => controls.patch({ loading }),
|
|
113
|
+
setError: (error) => controls.patch({ error }),
|
|
114
|
+
setErrorCode: (errorCode) => controls.patch({ errorCode }),
|
|
115
|
+
onWarehouseStatus: (status) => controls.patch({ warehouseStatus: status }),
|
|
116
|
+
onResult: (message) => controls.patch({ data: message.data }),
|
|
117
|
+
unpublishWarehouseStatus: () => {}
|
|
118
|
+
};
|
|
119
|
+
connectSSE({
|
|
120
|
+
url: options.url,
|
|
121
|
+
payload: options.payload,
|
|
122
|
+
signal: controls.signal,
|
|
123
|
+
onMessage: (message) => handleAnalyticsSseMessage(message.data, sseContext),
|
|
124
|
+
onError: (error) => handleAnalyticsSseError(error, sseContext)
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const store = createRequestStore(EMPTY_SNAPSHOT);
|
|
129
|
+
/**
|
|
130
|
+
* Register a subscriber for `key`, starting the shared request on first use.
|
|
131
|
+
* Returns a `release` function that must be called on unmount.
|
|
132
|
+
*/
|
|
133
|
+
function retain(key, options, autoStart = true) {
|
|
134
|
+
return store.retain(key, runAnalyticsRequest(options), autoStart);
|
|
135
|
+
}
|
|
136
|
+
const start = store.start;
|
|
137
|
+
const subscribe = store.subscribe;
|
|
138
|
+
const getSnapshot = store.getSnapshot;
|
|
139
|
+
/** Test-only: abort every in-flight request and clear the store. */
|
|
140
|
+
const resetAnalyticsRequestStore = store.reset;
|
|
141
|
+
|
|
142
|
+
//#endregion
|
|
143
|
+
export { getSnapshot, retain, start, subscribe };
|
|
144
|
+
//# sourceMappingURL=analytics-request-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analytics-request-store.js","names":[],"sources":["../../../src/react/hooks/analytics-request-store.ts"],"sourcesContent":["import { ArrowClient, connectSSE } from \"@/js\";\n\nimport {\n type AnalyticsSseHandlerContext,\n GENERIC_LOAD_ERROR,\n handleAnalyticsSseError,\n handleAnalyticsSseMessage,\n userFacingFetchError,\n} from \"./analytics-sse\";\nimport {\n createRequestStore,\n type RequestControls,\n type RequestRunner,\n} from \"./request-store\";\nimport type { WarehouseStatus } from \"./types\";\n\n/**\n * Shared in-flight request store for `useAnalyticsQuery`: an instance of the\n * generic {@link createRequestStore} lifecycle wired to the analytics\n * transports. Multiple hook instances resolving to the same request (query\n * key, parameters, format, dev mode) share one network request and see the\n * same result and mid-flight `warehouse_status` updates.\n *\n * The lifecycle (dedup, refcount, deferred teardown) lives in the factory; this\n * module only supplies the snapshot shape and how a request runs — SSE via\n * `analytics-sse.ts` or a direct Arrow fetch.\n */\n\n/** Options describing the request a keyed entry runs. */\ninterface AnalyticsRequestOptions {\n /** Full request URL (already includes the encoded query key and dev suffix). */\n url: string;\n /** Serialized `{ parameters, format }` body. */\n payload: string;\n /** Response format; selects the transport. */\n format: string;\n}\n\n/** Immutable per-key request state; mirrors the hook's public result shape. */\ninterface AnalyticsRequestSnapshot {\n data: unknown;\n loading: boolean;\n error: string | null;\n errorCode: string | null;\n warehouseStatus: WarehouseStatus | null;\n}\n\n/** Idle snapshot returned for keys with no live entry. Referentially stable. */\nconst EMPTY_SNAPSHOT: AnalyticsRequestSnapshot = {\n data: null,\n loading: false,\n error: null,\n errorCode: null,\n warehouseStatus: null,\n};\n\n/** Snapshot a request resets to when it (re)starts. */\nconst LOADING_SNAPSHOT: AnalyticsRequestSnapshot = {\n data: null,\n loading: true,\n error: null,\n errorCode: null,\n warehouseStatus: null,\n};\n\ntype Controls = RequestControls<AnalyticsRequestSnapshot>;\n\n/**\n * Fetch the real column names for a statement from the fallback endpoint,\n * used when a very wide schema's names didn't fit in the response header.\n * Returns undefined on any failure so decoding falls back to the raw Arrow\n * schema names.\n */\nasync function fetchArrowColumns(\n statementId: string,\n signal: AbortSignal,\n): Promise<string[] | undefined> {\n try {\n const res = await fetch(\n `/api/analytics/columns/${encodeURIComponent(statementId)}`,\n { signal },\n );\n if (!res.ok) return undefined;\n const body = (await res.json()) as { columns?: unknown };\n return Array.isArray(body.columns) ? (body.columns as string[]) : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Fetch an ARROW_STREAM query result as raw Arrow IPC bytes directly from\n * the query endpoint (no SSE, no second /arrow-result request) and decode\n * it into a Table. The server streams the bytes back as the POST response\n * body; errors before the first byte arrive as a JSON `{ error, errorCode }`.\n */\nasync function fetchArrowDirect(\n controls: Controls,\n options: AnalyticsRequestOptions,\n): Promise<void> {\n const { signal } = controls;\n try {\n const response = await fetch(options.url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: options.payload,\n signal,\n });\n if (signal.aborted) return;\n\n if (!response.ok) {\n let message = GENERIC_LOAD_ERROR;\n let code: string | null = null;\n try {\n const body = (await response.json()) as {\n error?: string;\n errorCode?: string;\n };\n if (body.error) message = body.error;\n if (typeof body.errorCode === \"string\") code = body.errorCode;\n } catch {\n // Non-JSON error body — keep the generic message.\n }\n controls.patch({ loading: false, error: message, errorCode: code });\n return;\n }\n\n const buffer = await response.arrayBuffer();\n if (signal.aborted) return;\n // Databricks encodes ARROW_STREAM columns positionally (col_0, …); the\n // server sends the real manifest names so we can relabel the decoded\n // Table (charts look columns up by name). Normally inline in the\n // `X-Appkit-Arrow-Columns` header; for very wide schemas the header\n // carries only a statement-id reference and we fetch the names.\n let columnNames: string[] | undefined;\n const header = response.headers.get(\"X-Appkit-Arrow-Columns\");\n if (header) {\n try {\n columnNames = JSON.parse(decodeURIComponent(header));\n } catch {\n // Malformed header — fall back to the raw Arrow schema names.\n }\n } else {\n const ref = response.headers.get(\"X-Appkit-Arrow-Columns-Ref\");\n if (ref) {\n columnNames = await fetchArrowColumns(ref, signal);\n }\n }\n const table = await ArrowClient.processArrowBuffer(\n new Uint8Array(buffer),\n columnNames,\n );\n controls.patch({ loading: false, data: table });\n } catch (error) {\n if (signal.aborted) return;\n controls.patch({ loading: false, error: userFacingFetchError(error) });\n }\n}\n\n/**\n * Build the runner for a request: reset to loading, then run the\n * format-appropriate transport, reporting state through `controls.patch`.\n */\nfunction runAnalyticsRequest(\n options: AnalyticsRequestOptions,\n): RequestRunner<AnalyticsRequestSnapshot> {\n return (controls) => {\n controls.patch(LOADING_SNAPSHOT);\n\n // ARROW_STREAM: the server streams raw Arrow IPC bytes back on the query\n // response body (no SSE). Fetch and decode directly.\n if (options.format === \"ARROW_STREAM\") {\n void fetchArrowDirect(controls, options);\n return;\n }\n\n // Adapt the shared SSE handler onto the snapshot model. No warehouse\n // publisher lives here — the hook mirrors status from the snapshot — so\n // `unpublishWarehouseStatus` is a no-op.\n const sseContext: AnalyticsSseHandlerContext = {\n source: \"useAnalyticsQuery\",\n resource: { url: options.url },\n defaultExecutionError: \"Unable to execute query\",\n unpublishOnMalformedMessage: false,\n signal: controls.signal,\n abort: controls.abort,\n setLoading: (loading) => controls.patch({ loading }),\n setError: (error) => controls.patch({ error }),\n setErrorCode: (errorCode) => controls.patch({ errorCode }),\n onWarehouseStatus: (status) =>\n controls.patch({ warehouseStatus: status }),\n onResult: (message) => controls.patch({ data: message.data }),\n unpublishWarehouseStatus: () => {},\n };\n\n connectSSE({\n url: options.url,\n payload: options.payload,\n signal: controls.signal,\n onMessage: (message) =>\n handleAnalyticsSseMessage(message.data, sseContext),\n onError: (error) => handleAnalyticsSseError(error, sseContext),\n });\n };\n}\n\nconst store = createRequestStore<AnalyticsRequestSnapshot>(EMPTY_SNAPSHOT);\n\n/**\n * Register a subscriber for `key`, starting the shared request on first use.\n * Returns a `release` function that must be called on unmount.\n */\nexport function retain(\n key: string,\n options: AnalyticsRequestOptions,\n autoStart = true,\n): () => void {\n return store.retain(key, runAnalyticsRequest(options), autoStart);\n}\n\nexport const start = store.start;\nexport const subscribe = store.subscribe;\nexport const getSnapshot = store.getSnapshot;\n\n/** Test-only: abort every in-flight request and clear the store. */\nexport const resetAnalyticsRequestStore = store.reset;\n"],"mappings":"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"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
//#region src/react/hooks/request-store.ts
|
|
2
|
+
function createRequestStore(idle) {
|
|
3
|
+
const entries = /* @__PURE__ */ new Map();
|
|
4
|
+
const listenersByKey = /* @__PURE__ */ new Map();
|
|
5
|
+
function notify(key) {
|
|
6
|
+
const listeners = listenersByKey.get(key);
|
|
7
|
+
if (!listeners) return;
|
|
8
|
+
for (const listener of listeners) listener();
|
|
9
|
+
}
|
|
10
|
+
function start(key) {
|
|
11
|
+
const entry = entries.get(key);
|
|
12
|
+
if (!entry) return;
|
|
13
|
+
entry.abortController?.abort();
|
|
14
|
+
entry.started = true;
|
|
15
|
+
const abortController = new AbortController();
|
|
16
|
+
entry.abortController = abortController;
|
|
17
|
+
entry.run({
|
|
18
|
+
signal: abortController.signal,
|
|
19
|
+
abort: () => abortController.abort(),
|
|
20
|
+
patch(next) {
|
|
21
|
+
entry.snapshot = {
|
|
22
|
+
...entry.snapshot,
|
|
23
|
+
...next
|
|
24
|
+
};
|
|
25
|
+
notify(key);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function release(key) {
|
|
30
|
+
const entry = entries.get(key);
|
|
31
|
+
if (!entry) return;
|
|
32
|
+
entry.refCount -= 1;
|
|
33
|
+
if (entry.refCount > 0) return;
|
|
34
|
+
entry.teardownTimer = setTimeout(() => {
|
|
35
|
+
const current = entries.get(key);
|
|
36
|
+
if (!current || current.refCount > 0) return;
|
|
37
|
+
current.abortController?.abort();
|
|
38
|
+
entries.delete(key);
|
|
39
|
+
}, 0);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
retain(key, run, autoStart = true) {
|
|
43
|
+
let entry = entries.get(key);
|
|
44
|
+
if (!entry) {
|
|
45
|
+
entry = {
|
|
46
|
+
snapshot: idle,
|
|
47
|
+
refCount: 0,
|
|
48
|
+
abortController: null,
|
|
49
|
+
teardownTimer: null,
|
|
50
|
+
started: false,
|
|
51
|
+
run
|
|
52
|
+
};
|
|
53
|
+
entries.set(key, entry);
|
|
54
|
+
}
|
|
55
|
+
if (entry.teardownTimer !== null) {
|
|
56
|
+
clearTimeout(entry.teardownTimer);
|
|
57
|
+
entry.teardownTimer = null;
|
|
58
|
+
}
|
|
59
|
+
entry.refCount += 1;
|
|
60
|
+
if (autoStart && !entry.started) start(key);
|
|
61
|
+
return () => release(key);
|
|
62
|
+
},
|
|
63
|
+
start,
|
|
64
|
+
subscribe(key, listener) {
|
|
65
|
+
let listeners = listenersByKey.get(key);
|
|
66
|
+
if (!listeners) {
|
|
67
|
+
listeners = /* @__PURE__ */ new Set();
|
|
68
|
+
listenersByKey.set(key, listeners);
|
|
69
|
+
}
|
|
70
|
+
listeners.add(listener);
|
|
71
|
+
return () => {
|
|
72
|
+
listeners.delete(listener);
|
|
73
|
+
if (listeners.size === 0) listenersByKey.delete(key);
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
getSnapshot(key) {
|
|
77
|
+
return entries.get(key)?.snapshot ?? idle;
|
|
78
|
+
},
|
|
79
|
+
reset() {
|
|
80
|
+
for (const entry of entries.values()) {
|
|
81
|
+
if (entry.teardownTimer !== null) clearTimeout(entry.teardownTimer);
|
|
82
|
+
entry.abortController?.abort();
|
|
83
|
+
}
|
|
84
|
+
entries.clear();
|
|
85
|
+
listenersByKey.clear();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
//#endregion
|
|
91
|
+
export { createRequestStore };
|
|
92
|
+
//# sourceMappingURL=request-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request-store.js","names":[],"sources":["../../../src/react/hooks/request-store.ts"],"sourcesContent":["/**\n * Generic keyed request store: coalesces identical in-flight requests so N\n * subscribers of the same key share one run, and fans state updates back out\n * via `useSyncExternalStore`.\n *\n * Owns only the lifecycle — refcount, deferred teardown, subscribe/notify — and\n * is transport-agnostic: the caller's `run` performs the actual fetch (SSE,\n * Arrow, plain fetch, …) and reports state through `controls.patch`. The\n * snapshot type `S` and its reset policy live entirely with the caller.\n *\n * Dedup-only: an entry lives exactly as long as it has subscribers. When the\n * last one releases, teardown is deferred a tick (so a React StrictMode\n * unmount→remount reuses the in-flight request instead of aborting it); if no\n * one re-subscribes by then, the request is aborted and the entry dropped.\n */\n\n/** What a `run` uses to drive its request and report state. */\nexport interface RequestControls<S> {\n /** Aborted when the request is superseded or torn down. */\n signal: AbortSignal;\n /** Abort this run's transport (e.g. to close a stream on a fatal frame). */\n abort(): void;\n /** Merge fields into the entry's snapshot and notify subscribers. */\n patch(next: Partial<S>): void;\n}\n\n/** Starts a request and reports state through `controls`. */\nexport type RequestRunner<S> = (controls: RequestControls<S>) => void;\n\ninterface RequestStore<S> {\n /**\n * Register a subscriber for `key`, creating and starting the shared request\n * on first use. Returns a `release` function to call on unmount.\n *\n * @param run Runs the request; stored on the entry and re-invoked by\n * `start`. Only the first caller's `run` is used (later joiners share it).\n * @param autoStart Start the request on creation. Default true.\n */\n retain(key: string, run: RequestRunner<S>, autoStart?: boolean): () => void;\n /** (Re)start the request for `key`: abort any in-flight run, then re-run. */\n start(key: string): void;\n subscribe(key: string, listener: () => void): () => void;\n getSnapshot(key: string): S;\n /** Test-only: abort every in-flight request and clear the store. */\n reset(): void;\n}\n\ninterface Entry<S> {\n snapshot: S;\n refCount: number;\n abortController: AbortController | null;\n teardownTimer: ReturnType<typeof setTimeout> | null;\n /** True once `start` has run at least once; guards re-run on late `retain`. */\n started: boolean;\n run: RequestRunner<S>;\n}\n\nexport function createRequestStore<S>(idle: S): RequestStore<S> {\n const entries = new Map<string, Entry<S>>();\n\n // Keyed separately from `entries`: `subscribe` can run before `retain`\n // creates the entry, so listeners must survive independently of entry life.\n const listenersByKey = new Map<string, Set<() => void>>();\n\n function notify(key: string): void {\n const listeners = listenersByKey.get(key);\n if (!listeners) return;\n for (const listener of listeners) listener();\n }\n\n function start(key: string): void {\n const entry = entries.get(key);\n if (!entry) return;\n\n entry.abortController?.abort();\n entry.started = true;\n\n const abortController = new AbortController();\n entry.abortController = abortController;\n\n entry.run({\n signal: abortController.signal,\n abort: () => abortController.abort(),\n patch(next) {\n entry.snapshot = { ...entry.snapshot, ...next };\n notify(key);\n },\n });\n }\n\n function release(key: string): void {\n const entry = entries.get(key);\n if (!entry) return;\n entry.refCount -= 1;\n if (entry.refCount > 0) return;\n\n // Defer teardown one tick: a StrictMode unmount→remount (or fast route\n // swap) re-`retain`s within the same tick and reuses the request.\n entry.teardownTimer = setTimeout(() => {\n const current = entries.get(key);\n if (!current || current.refCount > 0) return;\n current.abortController?.abort();\n entries.delete(key);\n }, 0);\n }\n\n return {\n retain(key, run, autoStart = true) {\n let entry = entries.get(key);\n if (!entry) {\n entry = {\n snapshot: idle,\n refCount: 0,\n abortController: null,\n teardownTimer: null,\n started: false,\n run,\n };\n entries.set(key, entry);\n }\n\n // A late joiner cancels any pending teardown so it keeps the live request.\n if (entry.teardownTimer !== null) {\n clearTimeout(entry.teardownTimer);\n entry.teardownTimer = null;\n }\n entry.refCount += 1;\n\n if (autoStart && !entry.started) {\n start(key);\n }\n\n return () => release(key);\n },\n\n start,\n\n subscribe(key, listener) {\n let listeners = listenersByKey.get(key);\n if (!listeners) {\n listeners = new Set();\n listenersByKey.set(key, listeners);\n }\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) listenersByKey.delete(key);\n };\n },\n\n getSnapshot(key) {\n return entries.get(key)?.snapshot ?? idle;\n },\n\n reset() {\n for (const entry of entries.values()) {\n if (entry.teardownTimer !== null) clearTimeout(entry.teardownTimer);\n entry.abortController?.abort();\n }\n entries.clear();\n listenersByKey.clear();\n },\n };\n}\n"],"mappings":";AAyDA,SAAgB,mBAAsB,MAA0B;CAC9D,MAAM,0BAAU,IAAI,KAAuB;CAI3C,MAAM,iCAAiB,IAAI,KAA8B;CAEzD,SAAS,OAAO,KAAmB;EACjC,MAAM,YAAY,eAAe,IAAI,IAAI;AACzC,MAAI,CAAC,UAAW;AAChB,OAAK,MAAM,YAAY,UAAW,WAAU;;CAG9C,SAAS,MAAM,KAAmB;EAChC,MAAM,QAAQ,QAAQ,IAAI,IAAI;AAC9B,MAAI,CAAC,MAAO;AAEZ,QAAM,iBAAiB,OAAO;AAC9B,QAAM,UAAU;EAEhB,MAAM,kBAAkB,IAAI,iBAAiB;AAC7C,QAAM,kBAAkB;AAExB,QAAM,IAAI;GACR,QAAQ,gBAAgB;GACxB,aAAa,gBAAgB,OAAO;GACpC,MAAM,MAAM;AACV,UAAM,WAAW;KAAE,GAAG,MAAM;KAAU,GAAG;KAAM;AAC/C,WAAO,IAAI;;GAEd,CAAC;;CAGJ,SAAS,QAAQ,KAAmB;EAClC,MAAM,QAAQ,QAAQ,IAAI,IAAI;AAC9B,MAAI,CAAC,MAAO;AACZ,QAAM,YAAY;AAClB,MAAI,MAAM,WAAW,EAAG;AAIxB,QAAM,gBAAgB,iBAAiB;GACrC,MAAM,UAAU,QAAQ,IAAI,IAAI;AAChC,OAAI,CAAC,WAAW,QAAQ,WAAW,EAAG;AACtC,WAAQ,iBAAiB,OAAO;AAChC,WAAQ,OAAO,IAAI;KAClB,EAAE;;AAGP,QAAO;EACL,OAAO,KAAK,KAAK,YAAY,MAAM;GACjC,IAAI,QAAQ,QAAQ,IAAI,IAAI;AAC5B,OAAI,CAAC,OAAO;AACV,YAAQ;KACN,UAAU;KACV,UAAU;KACV,iBAAiB;KACjB,eAAe;KACf,SAAS;KACT;KACD;AACD,YAAQ,IAAI,KAAK,MAAM;;AAIzB,OAAI,MAAM,kBAAkB,MAAM;AAChC,iBAAa,MAAM,cAAc;AACjC,UAAM,gBAAgB;;AAExB,SAAM,YAAY;AAElB,OAAI,aAAa,CAAC,MAAM,QACtB,OAAM,IAAI;AAGZ,gBAAa,QAAQ,IAAI;;EAG3B;EAEA,UAAU,KAAK,UAAU;GACvB,IAAI,YAAY,eAAe,IAAI,IAAI;AACvC,OAAI,CAAC,WAAW;AACd,gCAAY,IAAI,KAAK;AACrB,mBAAe,IAAI,KAAK,UAAU;;AAEpC,aAAU,IAAI,SAAS;AACvB,gBAAa;AACX,cAAU,OAAO,SAAS;AAC1B,QAAI,UAAU,SAAS,EAAG,gBAAe,OAAO,IAAI;;;EAIxD,YAAY,KAAK;AACf,UAAO,QAAQ,IAAI,IAAI,EAAE,YAAY;;EAGvC,QAAQ;AACN,QAAK,MAAM,SAAS,QAAQ,QAAQ,EAAE;AACpC,QAAI,MAAM,kBAAkB,KAAM,cAAa,MAAM,cAAc;AACnE,UAAM,iBAAiB,OAAO;;AAEhC,WAAQ,OAAO;AACf,kBAAe,OAAO;;EAEzB"}
|