@newfold/wp-module-ai-chat 1.0.1 → 1.0.2

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 (54) hide show
  1. package/build/index-rtl.css +1 -0
  2. package/build/index.asset.php +1 -0
  3. package/build/index.css +1 -0
  4. package/build/index.js +8 -0
  5. package/package.json +11 -9
  6. package/src/components/chat/ChatHeader.jsx +0 -63
  7. package/src/components/chat/ChatHistoryDropdown.jsx +0 -182
  8. package/src/components/chat/ChatHistoryList.jsx +0 -257
  9. package/src/components/chat/ChatInput.jsx +0 -157
  10. package/src/components/chat/ChatMessage.jsx +0 -157
  11. package/src/components/chat/ChatMessages.jsx +0 -137
  12. package/src/components/chat/WelcomeScreen.jsx +0 -115
  13. package/src/components/icons/CloseIcon.jsx +0 -27
  14. package/src/components/icons/SparklesOutlineIcon.jsx +0 -30
  15. package/src/components/icons/index.js +0 -5
  16. package/src/components/ui/AILogo.jsx +0 -47
  17. package/src/components/ui/BluBetaHeading.jsx +0 -18
  18. package/src/components/ui/ErrorAlert.jsx +0 -30
  19. package/src/components/ui/HeaderBar.jsx +0 -34
  20. package/src/components/ui/SuggestionButton.jsx +0 -28
  21. package/src/components/ui/ToolExecutionList.jsx +0 -264
  22. package/src/components/ui/TypingIndicator.jsx +0 -268
  23. package/src/constants/nfdAgents/input.js +0 -13
  24. package/src/constants/nfdAgents/storageKeys.js +0 -102
  25. package/src/constants/nfdAgents/typingStatus.js +0 -40
  26. package/src/constants/nfdAgents/websocket.js +0 -44
  27. package/src/hooks/useAIChat.js +0 -432
  28. package/src/hooks/useNfdAgentsWebSocket.js +0 -964
  29. package/src/index.js +0 -66
  30. package/src/services/mcpClient.js +0 -433
  31. package/src/services/openaiClient.js +0 -416
  32. package/src/styles/_branding.scss +0 -151
  33. package/src/styles/_history.scss +0 -180
  34. package/src/styles/_input.scss +0 -170
  35. package/src/styles/_messages.scss +0 -272
  36. package/src/styles/_mixins.scss +0 -21
  37. package/src/styles/_typing-indicator.scss +0 -162
  38. package/src/styles/_ui.scss +0 -173
  39. package/src/styles/_vars.scss +0 -103
  40. package/src/styles/_welcome.scss +0 -81
  41. package/src/styles/app.scss +0 -10
  42. package/src/utils/helpers.js +0 -75
  43. package/src/utils/markdownParser.js +0 -319
  44. package/src/utils/nfdAgents/archiveConversation.js +0 -82
  45. package/src/utils/nfdAgents/chatHistoryList.js +0 -130
  46. package/src/utils/nfdAgents/configFetcher.js +0 -137
  47. package/src/utils/nfdAgents/greeting.js +0 -55
  48. package/src/utils/nfdAgents/jwtUtils.js +0 -59
  49. package/src/utils/nfdAgents/messageHandler.js +0 -328
  50. package/src/utils/nfdAgents/storage.js +0 -112
  51. package/src/utils/nfdAgents/typingIndicatorToolDisplay.js +0 -180
  52. package/src/utils/nfdAgents/url.js +0 -101
  53. package/src/utils/restApi.js +0 -87
  54. package/src/utils/sanitizeHtml.js +0 -94
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newfold/wp-module-ai-chat",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Reusable AI Chat core for WordPress modules",
5
5
  "license": "GPL-2.0-or-later",
6
6
  "publishConfig": {
@@ -10,17 +10,19 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/newfold-labs/wp-module-ai-chat.git"
12
12
  },
13
- "main": "src/index.js",
13
+ "main": "build/index.js",
14
+ "style": "build/index.css",
14
15
  "files": [
15
- "src"
16
+ "build"
16
17
  ],
17
18
  "exports": {
18
- ".": "./src/index.js",
19
- "./services/*": "./src/services/*.js",
20
- "./components/*": "./src/components/*.jsx",
21
- "./hooks/*": "./src/hooks/*.js",
22
- "./utils/*": "./src/utils/*.js",
23
- "./styles/*": "./src/styles/*.scss"
19
+ ".": "./build/index.js",
20
+ "./style.css": "./build/index.css"
21
+ },
22
+ "scripts": {
23
+ "build": "wp-scripts build ./src/index.js --config ./scripts/webpack.config.js",
24
+ "lint": "wp-scripts lint-js ./src",
25
+ "prepublishOnly": "npm run build"
24
26
  },
25
27
  "peerDependencies": {
26
28
  "@wordpress/element": "^6.0.0",
@@ -1,63 +0,0 @@
1
- /**
2
- * WordPress dependencies
3
- */
4
- import { __ } from "@wordpress/i18n";
5
-
6
- /**
7
- * Internal dependencies
8
- */
9
- import { CloseIcon, SparklesOutlineIcon } from "../icons";
10
- import BluBetaHeading from "../ui/BluBetaHeading";
11
- import HeaderBar from "../ui/HeaderBar";
12
-
13
- /**
14
- * ChatHeader Component
15
- *
16
- * Header for the chat panel: white background; left = outline sparkles icon + title + BETA pill.
17
- * New chat (+) and Close (×) on the right. Built on shared HeaderBar layout.
18
- *
19
- * @param {Object} props - Component props.
20
- * @param {string} [props.title] - Title text next to logo (e.g. "Blu Chat"). Default "Blu Chat".
21
- * @param {Function} props.onNewChat - Called when user clicks New chat (+).
22
- * @param {Function} props.onClose - Called when user clicks Close (×).
23
- * @param {import('react').ReactNode} [props.extraActions] - Optional node(s) rendered between + and × (e.g. history dropdown trigger).
24
- * @param {boolean} [props.newChatDisabled] - When true, the New chat (+) button is disabled (e.g. when already on welcome screen).
25
- * @return {JSX.Element} The ChatHeader component.
26
- */
27
- const ChatHeader = ({ title, onNewChat, onClose, extraActions, newChatDisabled = false }) => (
28
- <HeaderBar
29
- logo={<SparklesOutlineIcon width={20} height={20} />}
30
- title={title || __("Blu Chat", "wp-module-ai-chat")}
31
- badge={<BluBetaHeading />}
32
- rightActions={
33
- <>
34
- {typeof onNewChat === "function" && (
35
- <button
36
- type="button"
37
- className="nfd-ai-chat-header__btn nfd-ai-chat-header__btn--new"
38
- onClick={newChatDisabled ? undefined : onNewChat}
39
- disabled={newChatDisabled}
40
- aria-label={__("New chat", "wp-module-ai-chat")}
41
- title={__("New chat", "wp-module-ai-chat")}
42
- >
43
- +
44
- </button>
45
- )}
46
- {extraActions}
47
- {typeof onClose === "function" && (
48
- <button
49
- type="button"
50
- className="nfd-ai-chat-header__btn nfd-ai-chat-header__btn--close"
51
- onClick={onClose}
52
- aria-label={__("Close", "wp-module-ai-chat")}
53
- title={__("Close", "wp-module-ai-chat")}
54
- >
55
- <CloseIcon />
56
- </button>
57
- )}
58
- </>
59
- }
60
- />
61
- );
62
-
63
- export default ChatHeader;
@@ -1,182 +0,0 @@
1
- /**
2
- * Chat History Dropdown Component
3
- *
4
- * Clock icon button that toggles a dropdown panel containing ChatHistoryList.
5
- * Click outside and Escape close the dropdown.
6
- */
7
-
8
- import {
9
- useState,
10
- useRef,
11
- useEffect,
12
- useLayoutEffect,
13
- useCallback,
14
- createPortal,
15
- } from "@wordpress/element";
16
- import { __ } from "@wordpress/i18n";
17
- import ChatHistoryList from "./ChatHistoryList";
18
-
19
- /**
20
- * Clock / history icon - inline SVG
21
- *
22
- * @param {Object} props - Props to spread onto the SVG element.
23
- */
24
- const ClockIcon = (props) => (
25
- <svg
26
- width="16"
27
- height="16"
28
- viewBox="0 0 24 24"
29
- fill="none"
30
- xmlns="http://www.w3.org/2000/svg"
31
- aria-hidden="true"
32
- focusable="false"
33
- {...props}
34
- >
35
- <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
36
- <path d="M12 7v5l3 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
37
- </svg>
38
- );
39
-
40
- /**
41
- * Dropdown trigger and portal-rendered panel with ChatHistoryList.
42
- *
43
- * @param {Object} props
44
- * @param {string} props.consumer - Must match useNfdAgentsWebSocket for same consumer
45
- * @param {boolean} props.open
46
- * @param {Function} props.onOpenChange
47
- * @param {Function} props.onSelectConversation
48
- * @param {number} [props.refreshTrigger=0]
49
- * @param {boolean} [props.disabled=false]
50
- * @param {number} [props.maxHistoryItems]
51
- * @return {JSX.Element} Dropdown trigger and portal-rendered history panel.
52
- */
53
- const ChatHistoryDropdown = ({
54
- consumer,
55
- open,
56
- onOpenChange,
57
- onSelectConversation,
58
- refreshTrigger = 0,
59
- disabled = false,
60
- maxHistoryItems,
61
- }) => {
62
- const triggerRef = useRef(null);
63
- const panelRef = useRef(null);
64
- const [position, setPosition] = useState({ top: 0, left: 0, openUp: false });
65
-
66
- const updatePosition = useCallback(() => {
67
- if (!triggerRef.current) {
68
- return;
69
- }
70
- const rect = triggerRef.current.getBoundingClientRect();
71
- const panelHeight = 240;
72
- const spaceBelow = window.innerHeight - rect.bottom;
73
- const openUp = spaceBelow < panelHeight && rect.top > spaceBelow;
74
- setPosition({
75
- top: openUp ? rect.top : rect.bottom,
76
- left: rect.right,
77
- openUp,
78
- });
79
- }, []);
80
-
81
- useLayoutEffect(() => {
82
- if (open) {
83
- updatePosition();
84
- }
85
- }, [open, updatePosition]);
86
-
87
- useEffect(() => {
88
- if (!open) {
89
- return;
90
- }
91
- const handleResize = () => updatePosition();
92
- window.addEventListener("resize", handleResize);
93
- return () => window.removeEventListener("resize", handleResize);
94
- }, [open, updatePosition]);
95
-
96
- useEffect(() => {
97
- if (!open) {
98
- return;
99
- }
100
- const handleClickOutside = (e) => {
101
- if (triggerRef.current?.contains(e.target) || panelRef.current?.contains(e.target)) {
102
- return;
103
- }
104
- onOpenChange(false);
105
- };
106
- const handleEscape = (e) => {
107
- if (e.key === "Escape") {
108
- onOpenChange(false);
109
- }
110
- };
111
- document.addEventListener("mousedown", handleClickOutside);
112
- document.addEventListener("keydown", handleEscape);
113
- return () => {
114
- document.removeEventListener("mousedown", handleClickOutside);
115
- document.removeEventListener("keydown", handleEscape);
116
- };
117
- }, [open, onOpenChange]);
118
-
119
- const handleSelect = useCallback(
120
- (conversation) => {
121
- onSelectConversation(conversation);
122
- onOpenChange(false);
123
- },
124
- [onSelectConversation, onOpenChange]
125
- );
126
-
127
- const handleTriggerClick = useCallback(() => {
128
- if (disabled) {
129
- return;
130
- }
131
- onOpenChange(!open);
132
- }, [disabled, open, onOpenChange]);
133
-
134
- const dropdownPanel = (
135
- <div
136
- ref={panelRef}
137
- className="nfd-ai-chat-history-dropdown"
138
- role="dialog"
139
- aria-label={__("Chat history", "wp-module-ai-chat")}
140
- style={{
141
- position: "fixed",
142
- top: position.openUp ? "auto" : position.top,
143
- bottom: position.openUp ? window.innerHeight - position.top : "auto",
144
- left: "auto",
145
- right: window.innerWidth - position.left,
146
- zIndex: 100000,
147
- }}
148
- >
149
- <div className="nfd-ai-chat-history-dropdown-inner">
150
- <ChatHistoryList
151
- consumer={consumer}
152
- onSelectConversation={handleSelect}
153
- disabled={disabled}
154
- refreshTrigger={open ? refreshTrigger : 0}
155
- emptyMessage={__("No conversations yet.", "wp-module-ai-chat")}
156
- maxHistoryItems={maxHistoryItems}
157
- />
158
- </div>
159
- </div>
160
- );
161
-
162
- return (
163
- <div className="nfd-ai-chat-history-dropdown-wrapper">
164
- <button
165
- ref={triggerRef}
166
- type="button"
167
- className={`nfd-ai-chat-history-dropdown-trigger ${open ? "is-open" : ""}`}
168
- onClick={handleTriggerClick}
169
- disabled={disabled}
170
- aria-expanded={open}
171
- aria-haspopup="true"
172
- aria-label={__("Chat history", "wp-module-ai-chat")}
173
- title={__("Chat history", "wp-module-ai-chat")}
174
- >
175
- <ClockIcon />
176
- </button>
177
- {open && createPortal(dropdownPanel, document.body)}
178
- </div>
179
- );
180
- };
181
-
182
- export default ChatHistoryDropdown;
@@ -1,257 +0,0 @@
1
- /**
2
- * Chat History List Component
3
- *
4
- * Displays previous chat sessions from localStorage. Shows archived conversations
5
- * (from "+" new chat) and falls back to current history key for legacy.
6
- * Use consumer that matches useNfdAgentsWebSocket for the same consumer.
7
- */
8
-
9
- import { useState, useEffect, useCallback } from "@wordpress/element";
10
- import { __ } from "@wordpress/i18n";
11
- import { History, Trash2 } from "lucide-react";
12
- import { getChatHistoryStorageKeys } from "../../constants/nfdAgents/storageKeys";
13
- import {
14
- hasMeaningfulUserMessage,
15
- extractConversations,
16
- getLatestMessageTime,
17
- } from "../../utils/nfdAgents/chatHistoryList";
18
-
19
- const DEFAULT_MAX_HISTORY_ITEMS = 3;
20
-
21
- /**
22
- * Human-readable relative time (e.g. 2m, 2h, 2d). Uses i18n for "Just now".
23
- *
24
- * @param {Date|string} dateOrString - Date or ISO string
25
- * @return {string} Relative time string (e.g. "2m", "2h", "Just now").
26
- */
27
- const getRelativeTime = (dateOrString) => {
28
- const date = dateOrString instanceof Date ? dateOrString : new Date(dateOrString);
29
- const now = Date.now();
30
- const diffMs = now - date.getTime();
31
- const diffM = Math.floor(diffMs / 60000);
32
- if (diffM < 1) {
33
- return __("Just now", "wp-module-ai-chat");
34
- }
35
- if (diffM < 60) {
36
- return `${diffM}m`;
37
- }
38
- const diffH = Math.floor(diffMs / 3600000);
39
- if (diffH < 24) {
40
- return `${diffH}h`;
41
- }
42
- const diffD = Math.floor(diffMs / 86400000);
43
- if (diffD < 7) {
44
- return `${diffD}d`;
45
- }
46
- return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
47
- };
48
-
49
- /**
50
- * Get the title for a conversation (first user message). Uses i18n for fallback.
51
- *
52
- * @param {Object} conversation - Conversation with messages
53
- * @return {string} Title string (first user message content or fallback).
54
- */
55
- const getConversationTitle = (conversation) => {
56
- const messages = conversation.messages || conversation;
57
- const firstUserMessage = messages.find((msg) => msg.role === "user" || msg.type === "user");
58
-
59
- if (firstUserMessage && firstUserMessage.content) {
60
- const content = firstUserMessage.content;
61
- return content.length > 50 ? content.substring(0, 50) + "..." : content;
62
- }
63
-
64
- return __("Previous conversation", "wp-module-ai-chat");
65
- };
66
-
67
- /**
68
- * Chat history list UI: load from storage, render items, handle select/delete.
69
- *
70
- * @param {Object} props
71
- * @param {string} props.consumer - Must match useNfdAgentsWebSocket for same consumer
72
- * @param {Function} props.onSelectConversation
73
- * @param {number} [props.refreshTrigger=0]
74
- * @param {boolean} [props.disabled=false]
75
- * @param {string} [props.emptyMessage]
76
- * @param {number} [props.maxHistoryItems=3]
77
- * @return {JSX.Element|null} List of history items or empty state or null.
78
- */
79
- const ChatHistoryList = ({
80
- consumer,
81
- onSelectConversation,
82
- refreshTrigger = 0,
83
- disabled = false,
84
- emptyMessage = null,
85
- maxHistoryItems = DEFAULT_MAX_HISTORY_ITEMS,
86
- }) => {
87
- const [conversations, setConversations] = useState([]);
88
- const keys = getChatHistoryStorageKeys(consumer);
89
-
90
- useEffect(() => {
91
- try {
92
- const rawArchive = window.localStorage.getItem(keys.archive);
93
- if (rawArchive) {
94
- const archive = JSON.parse(rawArchive);
95
- if (Array.isArray(archive) && archive.length > 0) {
96
- const trimmed = archive.slice(0, maxHistoryItems);
97
- if (trimmed.length < archive.length) {
98
- window.localStorage.setItem(keys.archive, JSON.stringify(trimmed));
99
- }
100
- const list = trimmed
101
- .map((entry) => {
102
- const messages = (entry.messages || []).map((msg) => ({
103
- ...msg,
104
- timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
105
- }));
106
- const archivedAt = entry.archivedAt ? new Date(entry.archivedAt) : null;
107
- return {
108
- sessionId: entry.sessionId ?? null,
109
- conversationId: entry.conversationId ?? null,
110
- messages,
111
- archivedAt,
112
- };
113
- })
114
- .filter(hasMeaningfulUserMessage);
115
- setConversations(list);
116
- return;
117
- }
118
- }
119
-
120
- const storedMessages = window.localStorage.getItem(keys.history);
121
- if (storedMessages) {
122
- const parsedMessages = JSON.parse(storedMessages);
123
- if (Array.isArray(parsedMessages) && parsedMessages.length > 0) {
124
- const messages = parsedMessages.map((msg) => ({
125
- ...msg,
126
- timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
127
- }));
128
- const extractedConversations = extractConversations(messages, maxHistoryItems);
129
- setConversations(extractedConversations);
130
- }
131
- }
132
- } catch (err) {
133
- // eslint-disable-next-line no-console
134
- console.warn("[Chat History] Failed to load chat history:", err);
135
- }
136
- }, [consumer, refreshTrigger, maxHistoryItems, keys.archive, keys.history]);
137
-
138
- const handleHistoryClick = useCallback(
139
- (conversation) => {
140
- if (disabled) {
141
- return;
142
- }
143
- try {
144
- const messages = conversation.messages || conversation;
145
- const messagesToStore = messages.map((msg) => ({
146
- ...msg,
147
- timestamp: msg.timestamp instanceof Date ? msg.timestamp.toISOString() : msg.timestamp,
148
- }));
149
-
150
- window.localStorage.setItem(keys.history, JSON.stringify(messagesToStore));
151
-
152
- if (conversation.sessionId) {
153
- window.localStorage.setItem(keys.sessionId, conversation.sessionId);
154
- }
155
- if (conversation.conversationId) {
156
- window.localStorage.setItem(keys.conversationId, conversation.conversationId);
157
- }
158
-
159
- if (onSelectConversation) {
160
- onSelectConversation(conversation);
161
- }
162
- } catch (err) {
163
- // eslint-disable-next-line no-console
164
- console.warn("[Chat History] Failed to restore conversation:", err);
165
- }
166
- },
167
- [disabled, keys, onSelectConversation]
168
- );
169
-
170
- const handleDelete = useCallback(
171
- (e, index) => {
172
- e.stopPropagation();
173
- e.preventDefault();
174
- if (disabled) {
175
- return;
176
- }
177
- try {
178
- const rawArchive = window.localStorage.getItem(keys.archive);
179
- if (rawArchive) {
180
- const archive = JSON.parse(rawArchive);
181
- const filtered = archive.filter((_, i) => i !== index);
182
- window.localStorage.setItem(keys.archive, JSON.stringify(filtered));
183
- }
184
- setConversations((prev) => prev.filter((_, i) => i !== index));
185
- } catch (err) {
186
- // eslint-disable-next-line no-console
187
- console.warn("[Chat History] Failed to delete conversation:", err);
188
- }
189
- },
190
- [disabled, keys]
191
- );
192
-
193
- if (conversations.length === 0) {
194
- if (emptyMessage) {
195
- return (
196
- <div className="nfd-ai-chat-history-list nfd-ai-chat-history-list--empty">
197
- {emptyMessage}
198
- </div>
199
- );
200
- }
201
- return null;
202
- }
203
-
204
- return (
205
- <div className="nfd-ai-chat-history-list">
206
- {conversations.map((conversation, index) => {
207
- const title = getConversationTitle(conversation);
208
- const key = conversation.sessionId || `legacy-${index}`;
209
- const timeDate = conversation.archivedAt || getLatestMessageTime(conversation) || null;
210
- const timeLabel = timeDate ? getRelativeTime(timeDate) : null;
211
- return (
212
- <div
213
- key={key}
214
- className={`nfd-ai-chat-history-item${disabled ? " nfd-ai-chat-history-item--disabled" : ""}`}
215
- role="button"
216
- tabIndex={disabled ? -1 : 0}
217
- aria-disabled={disabled}
218
- onClick={() => handleHistoryClick(conversation)}
219
- onKeyDown={(e) => {
220
- if (disabled) {
221
- return;
222
- }
223
- if (e.key === "Enter" || e.key === " ") {
224
- e.preventDefault();
225
- handleHistoryClick(conversation);
226
- }
227
- }}
228
- >
229
- <History width={14} height={14} aria-hidden />
230
- <div className="nfd-ai-chat-history-item__content">
231
- <span className="nfd-ai-chat-history-item__title">{title}</span>
232
- <div className="nfd-ai-chat-history-item__meta">
233
- {timeLabel && <span className="nfd-ai-chat-history-item__time">{timeLabel}</span>}
234
- <button
235
- type="button"
236
- className="nfd-ai-chat-history-item__delete"
237
- onClick={(e) => handleDelete(e, index)}
238
- aria-label={__("Delete conversation", "wp-module-ai-chat")}
239
- title={__("Delete", "wp-module-ai-chat")}
240
- >
241
- <Trash2
242
- width={14}
243
- height={14}
244
- className="nfd-ai-chat-history-item__delete-icon"
245
- aria-hidden
246
- />
247
- </button>
248
- </div>
249
- </div>
250
- </div>
251
- );
252
- })}
253
- </div>
254
- );
255
- };
256
-
257
- export default ChatHistoryList;
@@ -1,157 +0,0 @@
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;