PluginProbe
Extendify / 3.1.4
Extendify v3.1.4
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 0.7.0 All 126 releases
extendify / src / Agent / components / ChatInput.jsx

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

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