From e9871ae4fcde9389be182cb25a5d118697c2ad86 Mon Sep 17 00:00:00 2001 From: Eric Anderson Date: Tue, 2 Sep 2025 17:44:29 -0700 Subject: [PATCH 01/16] add 3s debounce to file watcher --- cli/cli.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/cli/cli.ts b/cli/cli.ts index 180a1dc32090..b8fbab2998cf 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -2811,6 +2811,9 @@ function pxtVersion(): string { function buildAndWatchAsync(f: () => Promise, maxDepth: number): Promise { let currMtime = Date.now() + const DEBOUNCE_MS = 3000; + let debounceTimer: any = null; + return f() .then(dirs => { if (globalConfig.noAutoBuild) return @@ -2821,11 +2824,26 @@ function buildAndWatchAsync(f: () => Promise, maxDepth: number): Promi .then(num => { if (num > currMtime) { currMtime = num - f() - .then(d => { - dirs = d - U.nextTick(loop) - }) + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + + debounceTimer = setTimeout(() => { + debounceTimer = null + f() + .then(d => { + dirs = d + U.nextTick(loop) + }) + .catch((e) => { + buildFailed("debounced build failed: " + (e && e.message ? e.message : ""), e) + U.nextTick(loop) + }) + }, DEBOUNCE_MS) + + // continue polling so subsequent changes reset debounceTimer + U.nextTick(loop) } else { U.nextTick(loop) } From 5bdb8ab30e295d7ac2b4c154be47c729150dc710 Mon Sep 17 00:00:00 2001 From: Eric Anderson Date: Tue, 2 Sep 2025 17:45:15 -0700 Subject: [PATCH 02/16] chat component init --- .../components/controls/Chat/Chat.test.tsx | 97 ++++++++++++ .../components/controls/Chat/Chat.tsx | 43 +++++ .../components/controls/Chat/ChatComposer.tsx | 81 ++++++++++ .../controls/Chat/ChatMessageList.tsx | 149 ++++++++++++++++++ .../components/controls/Chat/README.md | 59 +++++++ .../controls/Chat/chat.registry.tsx | 97 ++++++++++++ .../controls/Chat/chat.serialization.ts | 49 ++++++ .../components/controls/Chat/chat.stories.tsx | 115 ++++++++++++++ .../components/controls/Chat/chat.types.ts | 67 ++++++++ react-common/styles/controls/Chat.less | 122 ++++++++++++++ react-common/styles/react-common.less | 1 + svgicons/chat.svg | 1 + 12 files changed, 881 insertions(+) create mode 100644 react-common/components/controls/Chat/Chat.test.tsx create mode 100644 react-common/components/controls/Chat/Chat.tsx create mode 100644 react-common/components/controls/Chat/ChatComposer.tsx create mode 100644 react-common/components/controls/Chat/ChatMessageList.tsx create mode 100644 react-common/components/controls/Chat/README.md create mode 100644 react-common/components/controls/Chat/chat.registry.tsx create mode 100644 react-common/components/controls/Chat/chat.serialization.ts create mode 100644 react-common/components/controls/Chat/chat.stories.tsx create mode 100644 react-common/components/controls/Chat/chat.types.ts create mode 100644 react-common/styles/controls/Chat.less create mode 100644 svgicons/chat.svg 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..7bd9a19a1088 --- /dev/null +++ b/react-common/components/controls/Chat/Chat.tsx @@ -0,0 +1,43 @@ +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 handleSend = (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); + }; + + 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..b0ab5c6a2b0a --- /dev/null +++ b/react-common/components/controls/Chat/ChatComposer.tsx @@ -0,0 +1,81 @@ +import * as React from "react"; +import { classList, ContainerProps } from "../../util"; +import { Button } from "../Button"; + +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 } = props; + const [value, setValue] = React.useState(""); + const textareaRef = React.useRef(null); + + React.useEffect(() => { + // auto resize on mount + if (textareaRef.current) { + textareaRef.current.style.height = "1px"; + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; + } + }, []); + + const maybeResize = () => { + if (!textareaRef.current) return; + textareaRef.current.style.height = "1px"; + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + // send + e.preventDefault(); + const trimmed = value.trim(); + if (!trimmed) return; + onSend(trimmed); + setValue(""); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + setValue(e.target.value); + maybeResize(); + }; + + const doSend = () => { + const trimmed = value.trim(); + if (!trimmed) return; + onSend(trimmed); + setValue(""); + if (textareaRef.current) { + textareaRef.current.style.height = "1px"; + } + }; + + return ( +
+