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

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