From 564a50a95679380ca412fbf27bfafb706af1b4d2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 4 Feb 2026 12:21:54 -0800 Subject: [PATCH 1/4] feat: add shared ai-chat package Add packages/ai-chat with: - Stream client and materialization layer (durable-streams) - useChatSession and useCollectionData hooks - ChatInput and PresenceBar shared components - Type definitions for AI chat messages and sessions - Shared constants for streams URL --- packages/ai-chat/package.json | 37 ++ .../src/components/ChatInput/ChatInput.tsx | 137 ++++++ .../ai-chat/src/components/ChatInput/index.ts | 1 + .../components/PresenceBar/PresenceBar.tsx | 82 ++++ .../src/components/PresenceBar/index.ts | 1 + packages/ai-chat/src/components/index.ts | 2 + packages/ai-chat/src/index.ts | 3 + packages/ai-chat/src/stream/actions.ts | 129 ++++++ packages/ai-chat/src/stream/client.ts | 410 ++++++++++++++++++ packages/ai-chat/src/stream/index.ts | 42 ++ packages/ai-chat/src/stream/materialize.ts | 338 +++++++++++++++ packages/ai-chat/src/stream/schema.ts | 79 ++++ packages/ai-chat/src/stream/useChatSession.ts | 310 +++++++++++++ .../ai-chat/src/stream/useCollectionData.ts | 104 +++++ packages/ai-chat/src/types.ts | 112 +++++ packages/ai-chat/tsconfig.json | 12 + packages/shared/src/constants.ts | 2 + 17 files changed, 1801 insertions(+) create mode 100644 packages/ai-chat/package.json create mode 100644 packages/ai-chat/src/components/ChatInput/ChatInput.tsx create mode 100644 packages/ai-chat/src/components/ChatInput/index.ts create mode 100644 packages/ai-chat/src/components/PresenceBar/PresenceBar.tsx create mode 100644 packages/ai-chat/src/components/PresenceBar/index.ts create mode 100644 packages/ai-chat/src/components/index.ts create mode 100644 packages/ai-chat/src/index.ts create mode 100644 packages/ai-chat/src/stream/actions.ts create mode 100644 packages/ai-chat/src/stream/client.ts create mode 100644 packages/ai-chat/src/stream/index.ts create mode 100644 packages/ai-chat/src/stream/materialize.ts create mode 100644 packages/ai-chat/src/stream/schema.ts create mode 100644 packages/ai-chat/src/stream/useChatSession.ts create mode 100644 packages/ai-chat/src/stream/useCollectionData.ts create mode 100644 packages/ai-chat/src/types.ts create mode 100644 packages/ai-chat/tsconfig.json diff --git a/packages/ai-chat/package.json b/packages/ai-chat/package.json new file mode 100644 index 00000000000..687051bd84e --- /dev/null +++ b/packages/ai-chat/package.json @@ -0,0 +1,37 @@ +{ + "name": "@superset/ai-chat", + "version": "0.0.1", + "description": "Shared AI chat hooks and utilities", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./hooks": "./src/hooks/index.ts", + "./components": "./src/components/index.ts", + "./stream": "./src/stream/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.19", + "@anthropic-ai/sdk": "^0.71.2", + "@durable-streams/client": "^0.2.0", + "@durable-streams/state": "^0.2.0", + "@superset/ui": "workspace:*", + "@tanstack/db": "^0.5.24", + "@tanstack/react-db": "^0.1.68", + "lucide-react": "^0.560.0", + "react": "19.1.0", + "zod": "^4.3.5" + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/node": "^24.9.1", + "@types/react": "~19.1.0", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } +} diff --git a/packages/ai-chat/src/components/ChatInput/ChatInput.tsx b/packages/ai-chat/src/components/ChatInput/ChatInput.tsx new file mode 100644 index 00000000000..bc893e83a0b --- /dev/null +++ b/packages/ai-chat/src/components/ChatInput/ChatInput.tsx @@ -0,0 +1,137 @@ +/** + * Chat input component with send button + */ + +import { Button } from "@superset/ui/button"; +import { Textarea } from "@superset/ui/textarea"; +import { cn } from "@superset/ui/utils"; +import { Send } from "lucide-react"; +import { type KeyboardEvent, useCallback, useRef, useState } from "react"; + +export interface ChatInputProps { + onSend: (content: string) => void; + onTypingChange?: (isTyping: boolean) => void; + disabled?: boolean; + placeholder?: string; + className?: string; + /** Button style: "icon" shows Send icon, "text" shows "Send" label */ + buttonVariant?: "icon" | "text"; + /** Auto-resize textarea as content grows (default: true) */ + autoResize?: boolean; + /** Controlled value (optional - if provided, component is controlled) */ + value?: string; + /** Controlled onChange (optional - required if value is provided) */ + onChange?: (value: string) => void; +} + +export function ChatInput({ + onSend, + onTypingChange, + disabled = false, + placeholder = "Type a message...", + className, + buttonVariant = "icon", + autoResize = true, + value: controlledValue, + onChange: controlledOnChange, +}: ChatInputProps) { + const [internalValue, setInternalValue] = useState(""); + const textareaRef = useRef(null); + const typingTimeoutRef = useRef | null>(null); + + // Support both controlled and uncontrolled modes + const isControlled = controlledValue !== undefined; + const value = isControlled ? controlledValue : internalValue; + const setValue = isControlled + ? (v: string) => controlledOnChange?.(v) + : setInternalValue; + + const handleSubmit = useCallback(() => { + const trimmed = value.trim(); + if (!trimmed || disabled) return; + + onSend(trimmed); + setValue(""); + onTypingChange?.(false); + + // Reset textarea height and focus + if (textareaRef.current) { + if (autoResize) { + textareaRef.current.style.height = "auto"; + } + textareaRef.current.focus(); + } + }, [value, disabled, onSend, onTypingChange, autoResize, setValue]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit], + ); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setValue(newValue); + + // Auto-resize textarea + if (autoResize) { + const textarea = e.target; + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + } + + // Typing indicator with debounce + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + if (newValue.trim()) { + onTypingChange?.(true); + typingTimeoutRef.current = setTimeout(() => { + onTypingChange?.(false); + }, 2000); + } else { + onTypingChange?.(false); + } + }, + [onTypingChange, autoResize, setValue], + ); + + return ( +
+