PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
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.1.0, at src/Agent/components/ChatInput.jsx

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