PluginProbe
Extendify / 3.0.4
Extendify v3.0.4
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / Agent / components / ChatInput.jsx

ChatInput.jsx in Extendify 3.0.4, at src/Agent/components/ChatInput.jsx

237 lines 7.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { ChatTools } from '@agent/components/ChatTools';
2 import { cancelRequest } from '@agent/icons';
3 import { useGlobalStore } from '@agent/state/global';
4 import { useWorkflowStore } from '@agent/state/workflows';
5 import {
6 useCallback,
7 useEffect,
8 useLayoutEffect,
9 useRef,
10 useState,
11 } from '@wordpress/element';
12 import { __ } from '@wordpress/i18n';
13 import { arrowUp, Icon } from '@wordpress/icons';
14 import classNames from 'classnames';
15
16 export const ChatInput = ({ disabled, handleSubmit }) => {
17 const textareaRef = useRef(null);
18 const [input, setInput] = useState('');
19 const [history, setHistory] = useState([]);
20 const dirtyRef = useRef(false);
21 const [historyIndex, setHistoryIndex] = useState(null);
22 const { getWorkflowsByFeature, block } = useWorkflowStore();
23 const { isMobile } = useGlobalStore();
24 const domTool =
25 getWorkflowsByFeature({ requires: ['block'] })?.length > 0 && !isMobile;
26 const INPUT_LIMIT = 1500;
27 const inputTrimmed = input.trim();
28 const overLimit = inputTrimmed.length > INPUT_LIMIT;
29
30 // resize the height of the textarea based on the content
31 const adjustHeight = useCallback(() => {
32 if (!textareaRef.current) return;
33 textareaRef.current.style.height = 'auto';
34 const chat =
35 textareaRef.current.closest('#extendify-agent-chat').offsetHeight * 0.55;
36 const h = Math.min(chat, textareaRef.current.scrollHeight);
37 textareaRef.current.style.height = `${block && h < 60 ? 60 : h}px`;
38 }, [block]);
39
40 useLayoutEffect(() => {
41 window.addEventListener('extendify-agent:resize-end', adjustHeight);
42 adjustHeight();
43 return () =>
44 window.removeEventListener('extendify-agent:resize-end', adjustHeight);
45 }, [adjustHeight]);
46
47 useEffect(() => {
48 const watchForSubmit = ({ detail }) => {
49 setHistory((prev) => {
50 // avoid duplicates
51 if (prev?.at(-1) === detail.message) return prev;
52 return [...prev, detail.message];
53 });
54 setHistoryIndex(null);
55 };
56 window.addEventListener('extendify-agent:chat-submit', watchForSubmit);
57 return () =>
58 window.removeEventListener('extendify-agent:chat-submit', watchForSubmit);
59 }, []);
60
61 useEffect(() => {
62 adjustHeight();
63 }, [input, adjustHeight]);
64
65 useEffect(() => {
66 const userMessages = Array.from(
67 document.querySelectorAll(
68 '#extendify-agent-chat-scroll-area > [data-agent-message-role="user"]',
69 ),
70 )?.map((el) => el.textContent || '');
71 const deduped = userMessages.filter(
72 (msg, i, arr) => i === 0 || msg !== arr[i - 1],
73 );
74 setHistory(deduped);
75 }, []);
76
77 const submitForm = useCallback(
78 (e) => {
79 e?.preventDefault();
80 if (!input.trim() || overLimit) return;
81 handleSubmit(input.trim());
82 setHistory((prev) => {
83 // avoid duplicates
84 if (prev?.at(-1) === input) return prev;
85 return [...prev, input];
86 });
87 setHistoryIndex(null);
88 setInput('');
89 requestAnimationFrame(() => {
90 dirtyRef.current = false;
91 adjustHeight();
92 textareaRef.current?.focus();
93 });
94 },
95 [input, handleSubmit, adjustHeight, overLimit],
96 );
97
98 const handleKeyDown = useCallback(
99 (event) => {
100 if (
101 event.key === 'Enter' &&
102 !event.shiftKey &&
103 !event.nativeEvent.isComposing
104 ) {
105 event.preventDefault();
106 if (!overLimit) submitForm();
107 return;
108 }
109 if (dirtyRef.current) return;
110 if (event.key === 'ArrowUp') {
111 if (!history.length) return;
112 if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)
113 return;
114 setHistoryIndex((prev) => {
115 const next =
116 prev === null ? history.length - 1 : Math.max(prev - 1, 0);
117 setInput(history[next]);
118 return next;
119 });
120 event.preventDefault();
121 return;
122 }
123 if (event.key === 'ArrowDown') {
124 if (historyIndex === null) return;
125 if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)
126 return;
127 setHistoryIndex((prev) => {
128 if (prev === null) return null;
129 const next = prev + 1;
130 if (next >= history.length) {
131 setInput('');
132 return null;
133 }
134 setInput(history[next]);
135 return next;
136 });
137 event.preventDefault();
138 return;
139 }
140 dirtyRef.current = true;
141 },
142 [history, historyIndex, submitForm, overLimit],
143 );
144
145 const handleCancel = useCallback((e) => {
146 e.stopPropagation();
147 window.dispatchEvent(new CustomEvent('extendify-agent:cancel-workflow'));
148 }, []);
149
150 return (
151 // biome-ignore lint: allow onClick without keyboard
152 <form
153 onSubmit={submitForm}
154 onClick={() => textareaRef.current?.focus()}
155 className={classNames(
156 'relative flex w-full flex-col rounded-sm border border-gray-300 focus-within:outline-design-main focus:rounded-sm focus:border-design-main focus:ring-design-main',
157 {
158 'bg-gray-300': disabled,
159 'bg-gray-50': !disabled,
160 },
161 )}
162 >
163 <textarea
164 ref={textareaRef}
165 id="extendify-agent-chat-textarea"
166 disabled={disabled}
167 className={classNames(
168 'flex max-h-[calc(75dvh)] min-h-16 w-full resize-none overflow-y-auto bg-transparent px-2 pb-4 pt-2.5 text-base placeholder:text-gray-700 focus:shadow-none focus:outline-hidden disabled:opacity-50 md:text-sm border-none text-gray-900',
169 )}
170 placeholder={
171 block
172 ? __(
173 'What do you want to change in the selected content?',
174 'extendify-local',
175 )
176 : __('Ask anything', 'extendify-local')
177 }
178 rows="1"
179 // biome-ignore lint: Allow autofocus here
180 autoFocus
181 value={input}
182 onChange={(e) => {
183 setInput(e.target.value);
184 setHistoryIndex(null);
185 adjustHeight();
186 }}
187 onKeyDown={handleKeyDown}
188 />
189 <div className="flex justify-between gap-4 px-2 pb-2">
190 {domTool ? <ChatTools disabled={disabled} /> : null}
191 <div className="ms-auto flex items-center gap-2">
192 <span
193 className={classNames(
194 'text-xs font-medium',
195 overLimit ? 'text-red-600' : 'invisible',
196 )}
197 role="alert"
198 >
199 {overLimit && __('Message too long', 'extendify-local')}
200 </span>
201 <SubmitButton
202 disabled={disabled}
203 noInput={input.trim().length === 0}
204 overLimit={overLimit}
205 handleCancel={handleCancel}
206 />
207 </div>
208 </div>
209 </form>
210 );
211 };
212
213 const SubmitButton = ({ disabled, noInput, overLimit, handleCancel }) => {
214 if (disabled) {
215 return (
216 <button
217 type="button"
218 onClick={handleCancel}
219 className="inline-flex h-fit items-center justify-center gap-2 whitespace-nowrap rounded-full border-0 bg-design-main p-1 text-sm font-medium text-white transition-colors focus-visible:ring-design-main disabled:opacity-20"
220 >
221 <Icon fill="currentColor" icon={cancelRequest} size={18} />
222 <span className="sr-only">{__('Cancel', 'extendify-local')}</span>
223 </button>
224 );
225 }
226 return (
227 <button
228 type="submit"
229 className="inline-flex h-fit items-center justify-center gap-2 whitespace-nowrap rounded-full border-0 bg-design-main p-0.5 text-sm font-medium text-white transition-colors focus-visible:ring-design-main disabled:opacity-20"
230 disabled={disabled || noInput || overLimit}
231 >
232 <Icon fill="currentColor" icon={arrowUp} size={24} />
233 <span className="sr-only">{__('Send message', 'extendify-local')}</span>
234 </button>
235 );
236 };
237