Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8055,4 +8055,4 @@ if (require.main === module) {
}
}
mainCli(targetdir);
}
}
97 changes: 97 additions & 0 deletions react-common/components/controls/Chat/Chat.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ChatComposer onSend={(v) => (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(<ChatMessageList messages={many} tailSize={10} overscan={4} />, root);
});

const rendered = (root.querySelectorAll(".common-chat-message") || []) as NodeListOf<Element>;
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(<Chat messages={messages} onSend={() => {}} 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");
});
});
65 changes: 65 additions & 0 deletions react-common/components/controls/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
id={chatId}
className={classList("common-chat", className)}
style={style}
role="region"
aria-label="Chat conversation"
>
<ChatMessageList
id={messageListId}
messages={messages}
registry={mergedRegistry}
onAppend={onAppend}
tailSize={tailSize}
overscan={overscan}
/>
<ChatComposer
id={composerId}
onSend={handleSend}
aria-describedby={messageListId}
/>
</div>
);
};

export default Chat;
111 changes: 111 additions & 0 deletions react-common/components/controls/Chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement | null>(null);
const resizeTimeoutRef = React.useRef<number>();

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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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 (
<div className={classList("common-chat-composer", className)} style={style}>
<textarea
id={id}
ref={textareaRef}
className={classList("common-textarea", "common-chat-composer-textarea")}
placeholder={placeholder || lf("Type a message...")}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
aria-label={placeholder || lf("Message input")}
disabled={disabled}
rows={1}
{...ariaProps}
/>
<Button
className={classList("common-chat-send-button")}
title={lf("Send")}
label={busy ? <div className="common-spinner" /> : lf("Send")}
onClick={doSend}
disabled={disabled || busy || value.trim().length === 0}
aria-keyshortcuts="Enter"
/>
</div>
);
};

export default ChatComposer;
64 changes: 64 additions & 0 deletions react-common/components/controls/Chat/ChatErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/** @format */

import * as React from "react";
import { classList } from "../../util";

interface ChatErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{ error?: Error; reset?: () => void }>;
}

interface ChatErrorBoundaryState {
hasError: boolean;
error?: Error;
}

export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
constructor(props: ChatErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error: Error): ChatErrorBoundaryState {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Chat component error:', error, errorInfo);
}

handleReset = () => {
this.setState({ hasError: false, error: undefined });
}

render() {
if (this.state.hasError) {
if (this.props.fallback) {
const Fallback = this.props.fallback;
return <Fallback error={this.state.error} reset={this.handleReset} />;
}

return (
<div className={classList("common-chat-error")}>
<div className="common-chat-error-content">
<div className="common-chat-error-icon">⚠️</div>
<div className="common-chat-error-message">
Chat temporarily unavailable
</div>
<button
className="common-button"
onClick={this.handleReset}
aria-label="Retry chat component"
>
Retry
</button>
</div>
</div>
);
}

return this.props.children;
}
}

export default ChatErrorBoundary;
Loading