diff --git a/cli/cli.ts b/cli/cli.ts index 180a1dc32090..76bca81d82a7 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -8055,4 +8055,4 @@ if (require.main === module) { } } mainCli(targetdir); -} +} \ No newline at end of file diff --git a/react-common/components/controls/Chat/Chat.test.tsx b/react-common/components/controls/Chat/Chat.test.tsx new file mode 100644 index 000000000000..f56895d221ce --- /dev/null +++ b/react-common/components/controls/Chat/Chat.test.tsx @@ -0,0 +1,97 @@ +declare var test: any; +declare var expect: any; +declare var jest: any; +declare var describe: any; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { act } from "react-dom/test-utils"; +import Chat from "./Chat"; +import ChatMessageList from "./ChatMessageList"; +import ChatComposer from "./ChatComposer"; +import { exportEnvelope, importEnvelope } from "./chat.serialization"; +import { Message } from "./chat.types"; + +function query(selector: string) { + return document.querySelector(selector) as HTMLElement | null; +} + +describe("Chat component basics", () => { + test("composer sends on Enter (no Shift)", () => { + let sent: string | null = null; + let root: HTMLDivElement | null = document.createElement("div"); + document.body.appendChild(root); + + act(() => { + ReactDOM.render( (sent = v)} />, root); + }); + + const textarea = query(".common-chat-composer-textarea") as HTMLTextAreaElement | null; + if (!textarea) throw new Error("Textarea not found"); + + act(() => { + textarea.value = "hello world"; + // fire input event to update any internal state (component uses onChange) + const inputEv = new Event("input", { bubbles: true }); + textarea.dispatchEvent(inputEv); + const ev = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }); + textarea.dispatchEvent(ev); + }); + + if (sent !== "hello world") throw new Error(`expected sent 'hello world' got ${sent}`); + + ReactDOM.unmountComponentAtNode(root); + root.remove(); + }); + + test("windowing renders only tail window", () => { + const many: Message[] = []; + for (let i = 0; i < 100; ++i) { + many.push({ id: `m${i}`, author: i % 2 ? "user" : "assistant", timestamp: new Date().toISOString(), parts: [{ kind: "text", text: `message ${i}` }] }); + } + + let root: HTMLDivElement | null = document.createElement("div"); + document.body.appendChild(root); + act(() => { + ReactDOM.render(, root); + }); + + const rendered = (root.querySelectorAll(".common-chat-message") || []) as NodeListOf; + if (rendered.length !== 14) throw new Error(`expected 14, found ${rendered.length}`); + + ReactDOM.unmountComponentAtNode(root); + root.remove(); + }); + + test("registry custom card renders via Chat", () => { + const registry = { + testcard: { + render: (part: any) => React.createElement("div", { "data-testid": "testcard" }, part.title || "X"), + }, + } as any; + + const messages: Message[] = [{ id: "c1", author: "assistant", timestamp: new Date().toISOString(), parts: [{ kind: "testcard", title: "Hello" }] }]; + + let root: HTMLDivElement | null = document.createElement("div"); + document.body.appendChild(root); + act(() => { + ReactDOM.render( {}} registry={registry} />, root); + }); + + const testcard = root.querySelector('[data-testid="testcard"]'); + if (!testcard) throw new Error("testcard did not render"); + + ReactDOM.unmountComponentAtNode(root); + root.remove(); + }); + + test("serialization roundtrip", () => { + const messages: Message[] = [{ id: "s1", author: "assistant", timestamp: new Date().toISOString(), parts: [{ kind: "text", text: "abc" }] }]; + const envelope = exportEnvelope(messages); + const json = JSON.stringify(envelope); + const imported = importEnvelope(json); + if (imported.schemaVersion !== envelope.schemaVersion) throw new Error("schema mismatch"); + if (!imported.messages || imported.messages.length !== 1) throw new Error("missing message after import"); + if ((imported.messages[0].parts[0] as any).text !== "abc") throw new Error("text mismatch"); + }); +}); diff --git a/react-common/components/controls/Chat/Chat.tsx b/react-common/components/controls/Chat/Chat.tsx new file mode 100644 index 000000000000..42da92dd553b --- /dev/null +++ b/react-common/components/controls/Chat/Chat.tsx @@ -0,0 +1,65 @@ +/** @format */ + +import * as React from "react"; +import { classList, ContainerProps } from "../../util"; +import ChatMessageList from "./ChatMessageList"; +import ChatComposer from "./ChatComposer"; +import { ChatRegistry, createRegistry } from "./chat.registry"; +import { Message } from "./chat.types"; + +export interface ChatProps extends ContainerProps { + messages: Message[]; + onSend: (messageOrText: Message | string) => void; + onAppend?: (message: Message) => void; + registry?: ChatRegistry; + uploadHandlers?: any; + tailSize?: number; + overscan?: number; + style?: React.CSSProperties; +} + +export const Chat = (props: ChatProps) => { + const { id, className, style, messages, onSend, onAppend, registry, tailSize, overscan } = props; + + const mergedRegistry = React.useMemo(() => createRegistry(registry), [registry]); + const chatId = id || `chat-${Math.random().toString(36).substr(2, 9)}`; + const messageListId = `${chatId}-messages`; + const composerId = `${chatId}-composer`; + + const handleSend = React.useCallback((text: string) => { + // top-level convenience: allow composer to call with text + const message: Message = { + id: `${Date.now()}`, + parts: [{ kind: "text", text }], + author: "user", + timestamp: new Date().toISOString(), + }; + if (onSend) onSend(message); + }, [onSend]); + + return ( +
+ + +
+ ); +}; + +export default Chat; diff --git a/react-common/components/controls/Chat/ChatComposer.tsx b/react-common/components/controls/Chat/ChatComposer.tsx new file mode 100644 index 000000000000..83d7fdcb173d --- /dev/null +++ b/react-common/components/controls/Chat/ChatComposer.tsx @@ -0,0 +1,111 @@ +/** @format */ + +import * as React from "react"; +import { classList, ContainerProps } from "../../util"; +import { Button } from "../Button"; + +// Localization function - placeholder if not available +const lf = (s: string) => s; + +export interface ChatComposerProps extends ContainerProps { + onSend: (value: string) => void; + placeholder?: string; + disabled?: boolean; + busy?: boolean; + style?: React.CSSProperties; +} + +export const ChatComposer = (props: ChatComposerProps) => { + const { onSend, className, style, placeholder, disabled, busy, id, ...ariaProps } = props; + const [value, setValue] = React.useState(""); + const textareaRef = React.useRef(null); + const resizeTimeoutRef = React.useRef(); + + const debouncedResize = React.useCallback(() => { + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + resizeTimeoutRef.current = setTimeout(() => { + if (!textareaRef.current) return; + // Reset height to measure scrollHeight accurately + textareaRef.current.style.height = "auto"; + const newHeight = Math.min(textareaRef.current.scrollHeight, 160); // max ~10rem + textareaRef.current.style.height = `${newHeight}px`; + }, 16); // ~1 frame at 60fps + }, []); + + React.useEffect(() => { + return () => { + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + }; + }, []); + + React.useEffect(() => { + // Initial resize + debouncedResize(); + }, [debouncedResize]); + + const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + const trimmed = value.trim(); + if (!trimmed || disabled || busy) return; + onSend(trimmed); + setValue(""); + // Reset height after clearing + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + }); + } + }, [value, onSend, disabled, busy]); + + const handleChange = React.useCallback((e: React.ChangeEvent) => { + setValue(e.target.value); + debouncedResize(); + }, [debouncedResize]); + + const doSend = React.useCallback(() => { + const trimmed = value.trim(); + if (!trimmed || disabled || busy) return; + onSend(trimmed); + setValue(""); + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.focus(); + } + }); + }, [value, onSend, disabled, busy]); + + return ( +
+