| @@ -1,8 +1,11 @@ | ||
| 1 | -import { ChatTools } from '@agent/components/ChatTools'; | |
| 1 | +import { useCanvasAssist } from '@agent/components/Canvas'; | |
| 2 | +import { PageDocument } from '@agent/components/PageDocument'; | |
| 2 | 3 | import { cancelRequest } from '@agent/icons'; |
| 3 | -import { useGlobalStore } from '@agent/state/global'; | |
| 4 | +import { useChatStore } from '@agent/state/chat'; | |
| 4 | 5 | import { useWorkflowStore } from '@agent/state/workflows'; |
| 6 | +import { askAiTarget } from '@quick-edit/lib/hover-bar'; | |
| 7 | +import { useQuickEditStore } from '@quick-edit/state/store'; | |
| 5 | 8 | import { |
| 6 | 9 | useCallback, |
| 7 | 10 | useEffect, |
| 8 | 11 | useLayoutEffect, |
| @@ -12,8 +15,24 @@ | ||
| 12 | 15 | import { __ } from '@wordpress/i18n'; |
| 13 | 16 | import { arrowUp, Icon } from '@wordpress/icons'; |
| 14 | 17 | import classNames from 'classnames'; |
| 15 | 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 | + | |
| 16 | 35 | export const ChatInput = ({ disabled, handleSubmit }) => { |
| 17 | 36 | const textareaRef = useRef(null); |
| 18 | 37 | const [input, setInput] = useState(''); |
| 19 | 38 | const [history, setHistory] = useState([]); |
| @@ -18,20 +37,26 @@ | ||
| 18 | 37 | const [input, setInput] = useState(''); |
| 19 | 38 | const [history, setHistory] = useState([]); |
| 20 | 39 | const dirtyRef = useRef(false); |
| 21 | 40 | const [historyIndex, setHistoryIndex] = useState(null); |
| 22 | - const { getWorkflowsByFeature, block } = useWorkflowStore(); | |
| 23 | - const { isMobile } = useGlobalStore(); | |
| 24 | - const domTool = | |
| 25 | - getWorkflowsByFeature({ requires: ['block'] })?.length > 0 && !isMobile; | |
| 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)); | |
| 26 | 46 | const INPUT_LIMIT = 1500; |
| 27 | 47 | const inputTrimmed = input.trim(); |
| 28 | 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; | |
| 29 | 52 | |
| 30 | 53 | // resize the height of the textarea based on the content |
| 31 | 54 | const adjustHeight = useCallback(() => { |
| 32 | 55 | if (!textareaRef.current) return; |
| 33 | 56 | textareaRef.current.style.height = 'auto'; |
| 57 | + // while empty, scrollHeight measures the wrapped placeholder and overshoots | |
| 58 | + if (!textareaRef.current.value) return; | |
| 34 | 59 | const chat = |
| 35 | 60 | textareaRef.current.closest('#extendify-agent-chat').offsetHeight * 0.55; |
| 36 | 61 | const h = Math.min(chat, textareaRef.current.scrollHeight); |
| 37 | 62 | textareaRef.current.style.height = `${block && h < 60 ? 60 : h}px`; |
| @@ -44,36 +69,23 @@ | ||
| 44 | 69 | window.removeEventListener('extendify-agent:resize-end', adjustHeight); |
| 45 | 70 | }, [adjustHeight]); |
| 46 | 71 | |
| 47 | 72 | 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 | 73 | adjustHeight(); |
| 63 | 74 | }, [input, adjustHeight]); |
| 64 | 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); | |
| 65 | 79 | 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], | |
| 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]), | |
| 73 | 85 | ); |
| 74 | - setHistory(deduped); | |
| 75 | - }, []); | |
| 86 | + setHistoryIndex(null); | |
| 87 | + }, [messages]); | |
| 76 | 88 | |
| 77 | 89 | const submitForm = useCallback( |
| 78 | 90 | (e) => { |
| 79 | 91 | e?.preventDefault(); |
| @@ -141,8 +153,15 @@ | ||
| 141 | 153 | }, |
| 142 | 154 | [history, historyIndex, submitForm, overLimit], |
| 143 | 155 | ); |
| 144 | 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 | + | |
| 145 | 164 | const handleCancel = useCallback((e) => { |
| 146 | 165 | e.stopPropagation(); |
| 147 | 166 | window.dispatchEvent(new CustomEvent('extendify-agent:cancel-workflow')); |
| 148 | 167 | }, []); |
| @@ -154,28 +173,26 @@ | ||
| 154 | 173 | onClick={() => textareaRef.current?.focus()} |
| 155 | 174 | className={classNames( |
| 156 | 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', |
| 157 | 176 | { |
| 158 | - 'bg-gray-300': disabled, | |
| 159 | - 'bg-gray-50': !disabled, | |
| 177 | + 'bg-gray-300': inputDisabled, | |
| 178 | + 'bg-gray-50': !inputDisabled, | |
| 160 | 179 | }, |
| 161 | 180 | )} |
| 162 | 181 | > |
| 182 | + {block ? ( | |
| 183 | + <div className="px-2 pt-2"> | |
| 184 | + <PageDocument busy={busy} /> | |
| 185 | + </div> | |
| 186 | + ) : null} | |
| 163 | 187 | <textarea |
| 164 | 188 | ref={textareaRef} |
| 165 | 189 | id="extendify-agent-chat-textarea" |
| 166 | - disabled={disabled} | |
| 190 | + disabled={inputDisabled} | |
| 167 | 191 | className={classNames( |
| 168 | 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', |
| 169 | 193 | )} |
| 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 | - } | |
| 194 | + placeholder={placeholderFor({ disabled, editing, block })} | |
| 178 | 195 | rows="1" |
| 179 | 196 | // biome-ignore lint: Allow autofocus here |
| 180 | 197 | autoFocus |
| 181 | 198 | value={input} |
| @@ -184,12 +201,12 @@ | ||
| 184 | 201 | setHistoryIndex(null); |
| 185 | 202 | adjustHeight(); |
| 186 | 203 | }} |
| 187 | 204 | onKeyDown={handleKeyDown} |
| 205 | + onFocus={stagePinnedSelection} | |
| 188 | 206 | /> |
| 189 | 207 | <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"> | |
| 208 | + <div className="ms-auto flex items-center gap-1"> | |
| 192 | 209 | <span |
| 193 | 210 | className={classNames( |
| 194 | 211 | 'text-xs font-medium', |
| 195 | 212 | overLimit ? 'text-red-600' : 'invisible', |
| @@ -198,11 +215,12 @@ | ||
| 198 | 215 | > |
| 199 | 216 | {overLimit && __('Message too long', 'extendify-local')} |
| 200 | 217 | </span> |
| 201 | 218 | <SubmitButton |
| 202 | - disabled={disabled} | |
| 219 | + disabled={inputDisabled} | |
| 203 | 220 | noInput={input.trim().length === 0} |
| 204 | 221 | overLimit={overLimit} |
| 222 | + showCancel={busy} | |
| 205 | 223 | handleCancel={handleCancel} |
| 206 | 224 | /> |
| 207 | 225 | </div> |
| 208 | 226 | </div> |
| @@ -209,28 +227,30 @@ | ||
| 209 | 227 | </form> |
| 210 | 228 | ); |
| 211 | 229 | }; |
| 212 | 230 | |
| 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 ( | |
| 231 | +const SubmitButton = ({ | |
| 232 | + disabled, | |
| 233 | + noInput, | |
| 234 | + overLimit, | |
| 235 | + showCancel, | |
| 236 | + handleCancel, | |
| 237 | +}) => | |
| 238 | + showCancel ? ( | |
| 227 | 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 | |
| 228 | 249 | 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" | |
| 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" | |
| 230 | 251 | disabled={disabled || noInput || overLimit} |
| 231 | 252 | > |
| 232 | - <Icon fill="currentColor" icon={arrowUp} size={24} /> | |
| 253 | + <Icon fill="currentColor" icon={arrowUp} size={20} /> | |
| 233 | 254 | <span className="sr-only">{__('Send message', 'extendify-local')}</span> |
| 234 | 255 | </button> |
| 235 | 256 | ); |
| 236 | -}; | |