@newfold/wp-module-ai-chat 1.0.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.
Files changed (51) hide show
  1. package/README.md +98 -0
  2. package/package.json +51 -0
  3. package/src/components/chat/ChatHeader.jsx +63 -0
  4. package/src/components/chat/ChatHistoryDropdown.jsx +182 -0
  5. package/src/components/chat/ChatHistoryList.jsx +257 -0
  6. package/src/components/chat/ChatInput.jsx +157 -0
  7. package/src/components/chat/ChatMessage.jsx +157 -0
  8. package/src/components/chat/ChatMessages.jsx +137 -0
  9. package/src/components/chat/WelcomeScreen.jsx +115 -0
  10. package/src/components/icons/CloseIcon.jsx +27 -0
  11. package/src/components/icons/SparklesOutlineIcon.jsx +30 -0
  12. package/src/components/icons/index.js +5 -0
  13. package/src/components/ui/AILogo.jsx +47 -0
  14. package/src/components/ui/BluBetaHeading.jsx +18 -0
  15. package/src/components/ui/ErrorAlert.jsx +30 -0
  16. package/src/components/ui/HeaderBar.jsx +34 -0
  17. package/src/components/ui/SuggestionButton.jsx +28 -0
  18. package/src/components/ui/ToolExecutionList.jsx +264 -0
  19. package/src/components/ui/TypingIndicator.jsx +268 -0
  20. package/src/constants/nfdAgents/input.js +13 -0
  21. package/src/constants/nfdAgents/storageKeys.js +102 -0
  22. package/src/constants/nfdAgents/typingStatus.js +40 -0
  23. package/src/constants/nfdAgents/websocket.js +44 -0
  24. package/src/hooks/useAIChat.js +432 -0
  25. package/src/hooks/useNfdAgentsWebSocket.js +964 -0
  26. package/src/index.js +66 -0
  27. package/src/services/mcpClient.js +433 -0
  28. package/src/services/openaiClient.js +416 -0
  29. package/src/styles/_branding.scss +151 -0
  30. package/src/styles/_history.scss +180 -0
  31. package/src/styles/_input.scss +170 -0
  32. package/src/styles/_messages.scss +272 -0
  33. package/src/styles/_mixins.scss +21 -0
  34. package/src/styles/_typing-indicator.scss +162 -0
  35. package/src/styles/_ui.scss +173 -0
  36. package/src/styles/_vars.scss +103 -0
  37. package/src/styles/_welcome.scss +81 -0
  38. package/src/styles/app.scss +10 -0
  39. package/src/utils/helpers.js +75 -0
  40. package/src/utils/markdownParser.js +319 -0
  41. package/src/utils/nfdAgents/archiveConversation.js +82 -0
  42. package/src/utils/nfdAgents/chatHistoryList.js +130 -0
  43. package/src/utils/nfdAgents/configFetcher.js +137 -0
  44. package/src/utils/nfdAgents/greeting.js +55 -0
  45. package/src/utils/nfdAgents/jwtUtils.js +59 -0
  46. package/src/utils/nfdAgents/messageHandler.js +328 -0
  47. package/src/utils/nfdAgents/storage.js +112 -0
  48. package/src/utils/nfdAgents/typingIndicatorToolDisplay.js +180 -0
  49. package/src/utils/nfdAgents/url.js +101 -0
  50. package/src/utils/restApi.js +87 -0
  51. package/src/utils/sanitizeHtml.js +94 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { Button } from "@wordpress/components";
5
+ import { useCallback, useEffect, useRef, useState } from "@wordpress/element";
6
+ import { __ } from "@wordpress/i18n";
7
+
8
+ /**
9
+ * External dependencies
10
+ */
11
+ import classnames from "classnames";
12
+ import { ArrowUp, CircleStop } from "lucide-react";
13
+ import { INPUT } from "../../constants/nfdAgents/input";
14
+
15
+ /**
16
+ * ChatInput Component
17
+ *
18
+ * Context-agnostic chat input field. Accepts optional contextComponent prop
19
+ * for consumers to inject their own context indicators (e.g., selected block).
20
+ *
21
+ * @param {Object} props - The component props.
22
+ * @param {Function} props.onSendMessage - Function to call when message is sent.
23
+ * @param {Function} props.onStopRequest - Function to call when stop button is clicked.
24
+ * @param {boolean} props.disabled - Whether the input is disabled.
25
+ * @param {boolean} props.showStopButton - When true, show stop button instead of send (e.g. when generating). When false, show send even if disabled.
26
+ * @param {string} props.placeholder - Input placeholder text.
27
+ * @param {import('react').ReactNode} [props.contextComponent] - Optional context component to render (e.g. selected block).
28
+ * @param {boolean} [props.showTopBorder] - When false, omits the top border. Default true.
29
+ * @return {JSX.Element} The ChatInput component.
30
+ */
31
+ const ChatInput = ({
32
+ onSendMessage,
33
+ onStopRequest,
34
+ disabled = false,
35
+ showStopButton,
36
+ placeholder,
37
+ contextComponent = null,
38
+ showTopBorder = true,
39
+ }) => {
40
+ // Show stop button only when explicitly requested (e.g. generating), not when disabled for connecting/failed
41
+ const showStop = showStopButton === true;
42
+ const [message, setMessage] = useState("");
43
+ const [isStopping, setIsStopping] = useState(false);
44
+ const textareaRef = useRef(null);
45
+
46
+ const defaultPlaceholder = __("How can I help you today?", "wp-module-ai-chat");
47
+
48
+ // Auto-resize textarea as user types
49
+ useEffect(() => {
50
+ if (textareaRef.current) {
51
+ textareaRef.current.style.height = "auto";
52
+ const scrollHeight = textareaRef.current.scrollHeight;
53
+ const newHeight = Math.min(scrollHeight, INPUT.MAX_HEIGHT);
54
+ textareaRef.current.style.height = `${newHeight}px`;
55
+
56
+ // Only show scrollbar when content actually overflows
57
+ // This prevents the disabled scrollbar from appearing when empty
58
+ if (scrollHeight > INPUT.MAX_HEIGHT) {
59
+ textareaRef.current.style.overflowY = "auto";
60
+ } else {
61
+ textareaRef.current.style.overflowY = "hidden";
62
+ }
63
+ }
64
+ }, [message]);
65
+
66
+ // Focus textarea when it becomes enabled again
67
+ useEffect(() => {
68
+ if (!disabled && textareaRef.current) {
69
+ setTimeout(() => {
70
+ textareaRef.current.focus();
71
+ }, INPUT.FOCUS_DELAY);
72
+ }
73
+ }, [disabled]);
74
+
75
+ const handleSubmit = useCallback(() => {
76
+ if (message.trim() && !disabled) {
77
+ onSendMessage(message);
78
+ setMessage("");
79
+ if (textareaRef.current) {
80
+ textareaRef.current.style.height = "auto";
81
+ textareaRef.current.style.overflowY = "hidden";
82
+ textareaRef.current.focus();
83
+ }
84
+ }
85
+ }, [message, disabled, onSendMessage]);
86
+
87
+ const handleKeyDown = useCallback(
88
+ (e) => {
89
+ if (e.key === "Enter" && !e.shiftKey) {
90
+ e.preventDefault();
91
+ handleSubmit();
92
+ }
93
+ },
94
+ [handleSubmit]
95
+ );
96
+
97
+ const handleStopRequest = useCallback(() => {
98
+ if (isStopping) {
99
+ return;
100
+ }
101
+ setIsStopping(true);
102
+ if (onStopRequest) {
103
+ onStopRequest();
104
+ }
105
+ setTimeout(() => {
106
+ setIsStopping(false);
107
+ }, INPUT.STOP_DEBOUNCE);
108
+ }, [isStopping, onStopRequest]);
109
+
110
+ const rootClassName = classnames("nfd-ai-chat-input", {
111
+ "nfd-ai-chat-input--no-top-border": !showTopBorder,
112
+ });
113
+
114
+ return (
115
+ <div className={rootClassName}>
116
+ <div className="nfd-ai-chat-input__container">
117
+ <textarea
118
+ name="nfd-ai-chat-input"
119
+ ref={textareaRef}
120
+ value={message}
121
+ onChange={(e) => setMessage(e.target.value)}
122
+ onKeyDown={handleKeyDown}
123
+ placeholder={placeholder || defaultPlaceholder}
124
+ className="nfd-ai-chat-input__textarea"
125
+ rows={1}
126
+ disabled={disabled}
127
+ />
128
+ <div className="nfd-ai-chat-input__actions">
129
+ {contextComponent}
130
+ {showStop ? (
131
+ <Button
132
+ icon={<CircleStop width={16} height={16} />}
133
+ label={__("Stop generating", "wp-module-ai-chat")}
134
+ onClick={handleStopRequest}
135
+ className="nfd-ai-chat-input__stop"
136
+ disabled={isStopping}
137
+ aria-busy={isStopping}
138
+ />
139
+ ) : (
140
+ <Button
141
+ icon={<ArrowUp width={16} height={16} />}
142
+ label={__("Send message", "wp-module-ai-chat")}
143
+ onClick={handleSubmit}
144
+ className="nfd-ai-chat-input__submit"
145
+ disabled={!message.trim() || disabled}
146
+ />
147
+ )}
148
+ </div>
149
+ </div>
150
+ <div className="nfd-ai-chat-input__disclaimer">
151
+ {__("AI-generated content is not guaranteed for accuracy.", "wp-module-ai-chat")}
152
+ </div>
153
+ </div>
154
+ );
155
+ };
156
+
157
+ export default ChatInput;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useMemo, useState, useEffect, useRef } from "@wordpress/element";
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import classnames from "classnames";
10
+ import { containsHtml, sanitizeHtml } from "../../utils/sanitizeHtml";
11
+ import { containsMarkdown, parseMarkdown, linkifyUrls } from "../../utils/markdownParser";
12
+ import { unescapeAiResponse } from "../../utils/helpers";
13
+ import ToolExecutionList from "../ui/ToolExecutionList";
14
+
15
+ /** Typing animation: chars to reveal per tick, tick interval ms */
16
+ const TYPING_CHARS_PER_TICK = 2;
17
+ const TYPING_TICK_MS = 35;
18
+
19
+ /**
20
+ * ChatMessage Component
21
+ *
22
+ * Displays a single message in the chat with appropriate styling.
23
+ * Supports HTML and Markdown rendering for assistant messages.
24
+ *
25
+ * @param {Object} props - The component props.
26
+ * @param {string} props.message - The message content to display.
27
+ * @param {string} [props.type="assistant"] - The message type ("user" or "assistant").
28
+ * @param {boolean} [props.animateTyping] - When true, reveal assistant content with a typing effect.
29
+ * @param {Function} [props.onContentGrow] - Called when displayed content grows (e.g. for scroll-into-view).
30
+ * @param {Array} [props.executedTools=[]] - List of executed tools to show inline.
31
+ * @param {Array} [props.toolResults=[]] - Results from tool executions.
32
+ * @return {JSX.Element} The ChatMessage component.
33
+ */
34
+ const ChatMessage = ({
35
+ message,
36
+ type = "assistant",
37
+ animateTyping = false,
38
+ onContentGrow,
39
+ executedTools = [],
40
+ toolResults = [],
41
+ }) => {
42
+ // Treat approval_request as assistant so the thread still displays
43
+ const displayType = type === "approval_request" ? "assistant" : type;
44
+ const isUser = displayType === "user";
45
+
46
+ const fullLength = (message || "").length;
47
+ const [displayedLength, setDisplayedLength] = useState(() => (animateTyping ? 0 : fullLength));
48
+
49
+ // When not animating, show full message; when animating, drive displayedLength toward fullLength and clear interval when done
50
+ const intervalRef = useRef(null);
51
+ useEffect(() => {
52
+ if (!animateTyping) {
53
+ setDisplayedLength(fullLength);
54
+ return;
55
+ }
56
+ intervalRef.current = setInterval(() => {
57
+ setDisplayedLength((prev) => {
58
+ if (prev >= fullLength) {
59
+ if (intervalRef.current) {
60
+ clearInterval(intervalRef.current);
61
+ intervalRef.current = null;
62
+ }
63
+ return prev;
64
+ }
65
+ return Math.min(prev + TYPING_CHARS_PER_TICK, fullLength);
66
+ });
67
+ }, TYPING_TICK_MS);
68
+ return () => {
69
+ if (intervalRef.current) {
70
+ clearInterval(intervalRef.current);
71
+ intervalRef.current = null;
72
+ }
73
+ };
74
+ }, [animateTyping, fullLength]);
75
+
76
+ // Notify parent when content grows (for auto-scroll), throttled to avoid excessive updates
77
+ const lastGrowCallRef = useRef(0);
78
+ useEffect(() => {
79
+ if (!onContentGrow || !animateTyping || displayedLength === 0) {
80
+ return;
81
+ }
82
+ const now = Date.now();
83
+ if (now - lastGrowCallRef.current < 80) {
84
+ return;
85
+ }
86
+ lastGrowCallRef.current = now;
87
+ onContentGrow();
88
+ }, [displayedLength, animateTyping, onContentGrow]);
89
+
90
+ const effectiveMessage = animateTyping
91
+ ? (message || "").slice(0, displayedLength)
92
+ : message || "";
93
+
94
+ // Choose rendering path: plain user text, sanitized HTML, parsed markdown, or linkified plain text.
95
+ const { content, isRichContent } = useMemo(() => {
96
+ if (!effectiveMessage) {
97
+ return { content: "", isRichContent: false };
98
+ }
99
+
100
+ const raw = unescapeAiResponse(effectiveMessage);
101
+
102
+ if (isUser) {
103
+ return { content: raw, isRichContent: false };
104
+ }
105
+
106
+ if (containsHtml(raw)) {
107
+ return { content: sanitizeHtml(raw), isRichContent: true };
108
+ }
109
+
110
+ if (containsMarkdown(raw)) {
111
+ const parsed = parseMarkdown(raw);
112
+ return { content: sanitizeHtml(parsed), isRichContent: true };
113
+ }
114
+
115
+ // Plain text: linkify bare URLs and preserve newlines
116
+ const linkified = linkifyUrls(raw);
117
+ if (linkified !== raw) {
118
+ const withBreaks = linkified.replace(/\n/g, "<br>");
119
+ return { content: sanitizeHtml(withBreaks), isRichContent: true };
120
+ }
121
+ return { content: raw, isRichContent: false };
122
+ }, [effectiveMessage, isUser]);
123
+
124
+ // Don't render if there's no content and no tools.
125
+ if (!content && (!executedTools || executedTools.length === 0)) {
126
+ return null;
127
+ }
128
+
129
+ const rootClassName = classnames("nfd-ai-chat-message", `nfd-ai-chat-message--${displayType}`);
130
+
131
+ return (
132
+ <div className={rootClassName}>
133
+ {content &&
134
+ (isRichContent ? (
135
+ <>
136
+ {/* content is sanitized in the useMemo above (sanitizeHtml) */}
137
+ <div
138
+ className="nfd-ai-chat-message__content nfd-ai-chat-message__content--rich"
139
+ dangerouslySetInnerHTML={{ __html: content }}
140
+ />
141
+ </>
142
+ ) : (
143
+ <div
144
+ className="nfd-ai-chat-message__content nfd-ai-chat-message__content--pre-wrap"
145
+ style={{ whiteSpace: "pre-wrap" }}
146
+ >
147
+ {content}
148
+ </div>
149
+ ))}
150
+ {executedTools && executedTools.length > 0 && (
151
+ <ToolExecutionList executedTools={executedTools} toolResults={toolResults} />
152
+ )}
153
+ </div>
154
+ );
155
+ };
156
+
157
+ export default ChatMessage;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useEffect, useRef, useCallback, useState } from "@wordpress/element";
5
+ import { __ } from "@wordpress/i18n";
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import classnames from "classnames";
11
+ import ErrorAlert from "../ui/ErrorAlert";
12
+ import TypingIndicator from "../ui/TypingIndicator";
13
+ import ChatMessage from "./ChatMessage";
14
+
15
+ /**
16
+ * ChatMessages Component
17
+ *
18
+ * Scrollable container for all chat messages.
19
+ * Auto-scrolls to bottom when new messages arrive or when the last message content grows (e.g. typing animation).
20
+ *
21
+ * @param {Object} props - The component props.
22
+ * @param {Array} props.messages - The messages to display.
23
+ * @param {boolean} props.isLoading - Whether the AI is generating a response.
24
+ * @param {string} props.error - Error message to display (optional).
25
+ * @param {string} props.status - The current status.
26
+ * @param {Object} props.activeToolCall - The currently executing tool call (optional).
27
+ * @param {string} props.toolProgress - Real-time progress message (optional).
28
+ * @param {Array} props.executedTools - List of completed tool executions (optional).
29
+ * @param {Array} props.pendingTools - List of pending tools to execute (optional).
30
+ * @param {Function} [props.onRetry] - Callback when user clicks Retry (e.g. after connection failed).
31
+ * @param {boolean} [props.connectionFailed] - Whether connection has failed (show Retry without red error).
32
+ * @param {boolean} [props.isConnectingOrReconnecting] - When true, show a single "Connecting...." assistant message.
33
+ * @param {string} [props.messageBubbleStyle] - 'bubbles' (default) or 'minimal'. Controls container and message bubble styling.
34
+ * @return {JSX.Element} The ChatMessages component.
35
+ */
36
+ const ChatMessages = ({
37
+ messages = [],
38
+ isLoading = false,
39
+ error = null,
40
+ status = null,
41
+ activeToolCall = null,
42
+ toolProgress = null,
43
+ executedTools = [],
44
+ pendingTools = [],
45
+ onRetry,
46
+ connectionFailed = false,
47
+ isConnectingOrReconnecting = false,
48
+ messageBubbleStyle = "bubbles",
49
+ }) => {
50
+ const scrollContainerRef = useRef(null);
51
+ const [scrollTrigger, setScrollTrigger] = useState(0);
52
+
53
+ const scrollToBottom = useCallback(() => {
54
+ const el = scrollContainerRef.current;
55
+ if (!el) {
56
+ return;
57
+ }
58
+ el.scrollTo({
59
+ top: el.scrollHeight - el.clientHeight,
60
+ behavior: "smooth",
61
+ });
62
+ }, []);
63
+
64
+ useEffect(() => {
65
+ scrollToBottom();
66
+ }, [messages, isLoading, toolProgress, scrollTrigger, scrollToBottom]);
67
+
68
+ // Bump scroll trigger so the scroll effect runs again when the last message's content grows (e.g. typing).
69
+ const onContentGrow = useCallback(() => {
70
+ setScrollTrigger((t) => t + 1);
71
+ }, []);
72
+
73
+ // Only show executed tools in TypingIndicator when there is active or pending tool work.
74
+ const hasActiveToolExecution =
75
+ activeToolCall || executedTools.length > 0 || pendingTools.length > 0;
76
+
77
+ const messagesClassName = classnames("nfd-ai-chat-messages", {
78
+ "nfd-ai-chat-messages--minimal": messageBubbleStyle === "minimal",
79
+ });
80
+
81
+ return (
82
+ <div ref={scrollContainerRef} className={messagesClassName}>
83
+ {messages.length > 0 &&
84
+ messages.map((msg, index) => {
85
+ // Animate typing only for the last assistant message that was received live (not loaded from history/restore)
86
+ const isLastAssistant =
87
+ index === messages.length - 1 && (msg.type === "assistant" || msg.role === "assistant");
88
+ return (
89
+ <ChatMessage
90
+ key={msg.id || index}
91
+ message={msg.content}
92
+ type={msg.type}
93
+ animateTyping={isLastAssistant && msg.animateTyping === true}
94
+ onContentGrow={isLastAssistant ? onContentGrow : undefined}
95
+ executedTools={msg.executedTools}
96
+ toolResults={msg.toolResults}
97
+ />
98
+ );
99
+ })}
100
+ {isConnectingOrReconnecting && !connectionFailed && (
101
+ <div className="nfd-ai-chat-message nfd-ai-chat-message--assistant">
102
+ <div className="nfd-ai-chat-message__content">
103
+ <div className="nfd-ai-chat-typing-indicator">
104
+ <span className="nfd-ai-chat-typing-indicator__dots" aria-hidden="true">
105
+ <span></span>
106
+ <span></span>
107
+ <span></span>
108
+ </span>
109
+ <span className="nfd-ai-chat-typing-indicator__text">
110
+ {__("Connecting....", "wp-module-ai-chat")}
111
+ </span>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ )}
116
+ {error && <ErrorAlert message={error} />}
117
+ {onRetry && (error || connectionFailed) && (
118
+ <p className="nfd-ai-chat-messages__retry">
119
+ <button type="button" className="nfd-ai-chat-messages__retry-button" onClick={onRetry}>
120
+ {__("Retry", "wp-module-ai-chat")}
121
+ </button>
122
+ </p>
123
+ )}
124
+ {isLoading && (
125
+ <TypingIndicator
126
+ status={status}
127
+ activeToolCall={activeToolCall}
128
+ toolProgress={toolProgress}
129
+ executedTools={hasActiveToolExecution ? executedTools : []}
130
+ pendingTools={pendingTools}
131
+ />
132
+ )}
133
+ </div>
134
+ );
135
+ };
136
+
137
+ export default ChatMessages;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { __ } from "@wordpress/i18n";
5
+ import { useState, useEffect, useRef } from "@wordpress/element";
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import AILogo from "../ui/AILogo";
11
+ import SuggestionButton from "../ui/SuggestionButton";
12
+
13
+ const TYPING_SPEED_MS = 40;
14
+
15
+ /**
16
+ * WelcomeScreen Component
17
+ *
18
+ * Displays the welcome screen with AI avatar, introduction message, and suggestion tags.
19
+ *
20
+ * @param {Object} props - The component props.
21
+ * @param {Function} props.onSendMessage - Function to call when a suggestion is clicked.
22
+ * @param {string} props.title - Custom welcome title (optional).
23
+ * @param {string} props.subtitle - Custom welcome subtitle (optional).
24
+ * @param {Array} props.suggestions - Custom suggestions array (optional).
25
+ * @param {boolean} props.showSuggestions - Whether to show suggestions (default: false).
26
+ * @param {boolean} props.animateWelcome - Whether to type the welcome text (default: false).
27
+ * @return {JSX.Element} The WelcomeScreen component.
28
+ */
29
+ const WelcomeScreen = ({
30
+ onSendMessage,
31
+ title,
32
+ subtitle,
33
+ suggestions = [],
34
+ showSuggestions = false,
35
+ animateWelcome = false,
36
+ }) => {
37
+ const defaultTitle = __("Hi, I'm your AI assistant.", "wp-module-ai-chat");
38
+ const defaultSubtitle = __("How can I help you today?", "wp-module-ai-chat");
39
+
40
+ const titleText = title || defaultTitle;
41
+ const subtitleText = subtitle || defaultSubtitle;
42
+ const fullText = `${titleText} ${subtitleText}`;
43
+
44
+ const [displayedLength, setDisplayedLength] = useState(0);
45
+ const timeoutRef = useRef(null);
46
+
47
+ useEffect(() => {
48
+ if (!animateWelcome) {
49
+ setDisplayedLength(fullText.length);
50
+ return;
51
+ }
52
+ setDisplayedLength(0);
53
+ const animate = () => {
54
+ setDisplayedLength((prev) => {
55
+ if (prev >= fullText.length) {
56
+ return prev;
57
+ }
58
+ timeoutRef.current = setTimeout(animate, TYPING_SPEED_MS);
59
+ return prev + 1;
60
+ });
61
+ };
62
+ timeoutRef.current = setTimeout(animate, TYPING_SPEED_MS);
63
+ return () => {
64
+ if (timeoutRef.current) {
65
+ clearTimeout(timeoutRef.current);
66
+ }
67
+ };
68
+ }, [animateWelcome, fullText]);
69
+
70
+ const showTyping = animateWelcome && displayedLength < fullText.length;
71
+ const displayedTitle =
72
+ showTyping && displayedLength <= titleText.length
73
+ ? titleText.slice(0, displayedLength)
74
+ : titleText;
75
+ // fullText is "title + space + subtitle"; the -1 below accounts for the space when slicing into subtitle.
76
+ let displayedSubtitle = subtitleText;
77
+ if (showTyping && displayedLength > titleText.length) {
78
+ displayedSubtitle = subtitleText.slice(0, displayedLength - titleText.length - 1);
79
+ } else if (showTyping) {
80
+ displayedSubtitle = "";
81
+ }
82
+
83
+ return (
84
+ <div className="nfd-ai-chat-welcome">
85
+ <div className="nfd-ai-chat-welcome__content">
86
+ <div className="nfd-ai-chat-welcome__avatar">
87
+ <AILogo width={64} height={64} />
88
+ </div>
89
+ <div className="nfd-ai-chat-welcome__message">
90
+ <div className="nfd-ai-chat-welcome__title">{displayedTitle}</div>
91
+ <div className="nfd-ai-chat-welcome__subtitle">
92
+ {displayedSubtitle}
93
+ {showTyping && displayedLength < fullText.length && (
94
+ <span className="nfd-ai-chat-welcome__cursor" aria-hidden="true" />
95
+ )}
96
+ </div>
97
+ </div>
98
+ </div>
99
+ {showSuggestions && suggestions.length > 0 && (
100
+ <div className="nfd-ai-chat-suggestions">
101
+ {suggestions.map((suggestion, index) => (
102
+ <SuggestionButton
103
+ key={suggestion.action ?? suggestion.text ?? index}
104
+ icon={suggestion.icon}
105
+ text={suggestion.text}
106
+ onClick={() => onSendMessage(suggestion.action || suggestion.text)}
107
+ />
108
+ ))}
109
+ </div>
110
+ )}
111
+ </div>
112
+ );
113
+ };
114
+
115
+ export default WelcomeScreen;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Close (×) icon for actions. Inline SVG for accessibility.
3
+ *
4
+ * @param {Object} props - SVG props (e.g. className, aria-*).
5
+ * @return {JSX.Element} Close icon element.
6
+ */
7
+ const CloseIcon = (props) => (
8
+ <svg
9
+ width="16"
10
+ height="16"
11
+ viewBox="0 0 24 24"
12
+ xmlns="http://www.w3.org/2000/svg"
13
+ aria-hidden="true"
14
+ focusable="false"
15
+ {...props}
16
+ >
17
+ <path
18
+ d="M18 6L6 18M6 6l12 12"
19
+ stroke="currentColor"
20
+ strokeWidth="2"
21
+ strokeLinecap="round"
22
+ strokeLinejoin="round"
23
+ />
24
+ </svg>
25
+ );
26
+
27
+ export default CloseIcon;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Outline sparkles icon (decorative). Inline SVG for accessibility.
3
+ *
4
+ * @param {Object} props - SVG props (e.g. width, height, className).
5
+ * @return {JSX.Element} Sparkles icon element.
6
+ */
7
+ const SparklesOutlineIcon = (props) => (
8
+ <svg
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ width="24"
11
+ height="24"
12
+ viewBox="0 0 24 24"
13
+ fill="none"
14
+ stroke="currentColor"
15
+ strokeWidth="2"
16
+ strokeLinecap="round"
17
+ strokeLinejoin="round"
18
+ aria-hidden="true"
19
+ focusable="false"
20
+ {...props}
21
+ >
22
+ <path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
23
+ <path d="M5 3v4" />
24
+ <path d="M19 17v4" />
25
+ <path d="M3 5h4" />
26
+ <path d="M17 19h4" />
27
+ </svg>
28
+ );
29
+
30
+ export default SparklesOutlineIcon;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Icon components. Export individually for tree-shaking and clear imports.
3
+ */
4
+ export { default as CloseIcon } from "./CloseIcon";
5
+ export { default as SparklesOutlineIcon } from "./SparklesOutlineIcon";
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Filled sparks icon (matches editor-chat sparks.svg).
3
+ * Inlined to avoid SVG loader dependency across consuming projects.
4
+ *
5
+ * @param {Object} props - Props to spread onto the SVG element.
6
+ * @param {number} [props.width=24] - SVG width in pixels.
7
+ * @param {number} [props.height=24] - SVG height in pixels.
8
+ * @return {JSX.Element} Inline SVG sparks icon.
9
+ */
10
+ const SparksIcon = ({ width = 24, height = 24, ...props }) => (
11
+ <svg
12
+ xmlns="http://www.w3.org/2000/svg"
13
+ viewBox="0 0 30 30"
14
+ width={width}
15
+ height={height}
16
+ {...props}
17
+ >
18
+ <path d="M14.217,19.707l-1.112,2.547c-0.427,0.979-1.782,0.979-2.21,0l-1.112-2.547c-0.99-2.267-2.771-4.071-4.993-5.057L1.73,13.292c-0.973-0.432-0.973-1.848,0-2.28l2.965-1.316C6.974,8.684,8.787,6.813,9.76,4.47l1.126-2.714c0.418-1.007,1.81-1.007,2.228,0L14.24,4.47c0.973,2.344,2.786,4.215,5.065,5.226l2.965,1.316c0.973,0.432,0.973,1.848,0,2.28l-3.061,1.359C16.988,15.637,15.206,17.441,14.217,19.707z" />
19
+ <path d="M24.481,27.796l-0.339,0.777c-0.248,0.569-1.036,0.569-1.284,0l-0.339-0.777c-0.604-1.385-1.693-2.488-3.051-3.092l-1.044-0.464c-0.565-0.251-0.565-1.072,0-1.323l0.986-0.438c1.393-0.619,2.501-1.763,3.095-3.195l0.348-0.84c0.243-0.585,1.052-0.585,1.294,0l0.348,0.84c0.594,1.432,1.702,2.576,3.095,3.195l0.986,0.438c0.565,0.251,0.565,1.072,0,1.323l-1.044,0.464C26.174,25.308,25.085,26.411,24.481,27.796z" />
20
+ </svg>
21
+ );
22
+
23
+ /**
24
+ * AILogo Component
25
+ *
26
+ * Displays the filled sparks icon inside a gradient circle.
27
+ * Used on welcome screens and as the main AI assistant avatar.
28
+ *
29
+ * @param {Object} props - The component props.
30
+ * @param {number} props.width - The width of the logo (default: 24).
31
+ * @param {number} props.height - The height of the logo (default: 24).
32
+ * @return {JSX.Element} Avatar wrapper with gradient circle and sparks icon.
33
+ */
34
+ const AILogo = ({ width = 24, height = 24 }) => (
35
+ <div
36
+ className="nfd-ai-chat-avatar"
37
+ style={{
38
+ width,
39
+ height,
40
+ }}
41
+ >
42
+ {/* Scale icon to ~62.5% of avatar so it fits inside the gradient circle. */}
43
+ <SparksIcon width={width * 0.625} height={height * 0.625} />
44
+ </div>
45
+ );
46
+
47
+ export default AILogo;