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

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