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
← All changes | src/Agent/Agent.jsx +505 -160 3.0.43.2.1 View file →
@@ -1,22 +1,39 @@
1 1 import {
2 2 callTool,
3 - digest,
3 + handleCanvas,
4 4 handleWorkflow,
5 5 pickWorkflow,
6 6 recordAgentActivity,
7 7 } from '@agent/api';
8 8 import { Chat } from '@agent/Chat';
9 +import {
10 + Canvas,
11 + useCanvasAssist,
12 + useCanvasOpen,
13 +} from '@agent/components/Canvas';
9 14 import { ChatInput } from '@agent/components/ChatInput';
10 15 import { ChatMessages } from '@agent/components/ChatMessages';
11 16 import { UsageMessage } from '@agent/components/messages/UsageMessage';
12 -import { PageDocument } from '@agent/components/PageDocument';
13 17 import { useLockPost } from '@agent/hooks/useLockPost';
18 +import { isAbilityTool, isAbilityWorkflow } from '@agent/lib/abilities';
19 +import {
20 + getClientToolFallbackReply,
21 + getClientTools,
22 +} from '@agent/lib/client-tools';
23 +import { localPickWorkflow } from '@agent/lib/local-pick';
14 24 import { getRedirectUrl } from '@agent/lib/redirects';
25 +import { doReload } from '@agent/lib/reload';
26 +import { useCanvasStore } from '@agent/state/canvas';
15 27 import { useChatStore } from '@agent/state/chat';
16 28 import { useGlobalStore } from '@agent/state/global';
29 +import { useStatusStore } from '@agent/state/status';
17 30 import { useSuggestionsStore } from '@agent/state/suggestions';
18 31 import { useWorkflowStore } from '@agent/state/workflows';
32 +import { hasRunComponent } from '@agent/workflows/abilities/components/run';
33 +import startOnboardingWorkflow from '@agent/workflows/misc/start-onboarding';
34 +import { useQuickEditStore } from '@quick-edit/state/store';
35 +import { digest } from '@shared/api/digest';
19 36 import {
20 37 useCallback,
21 38 useEffect,
22 39 useMemo,
@@ -22,17 +39,38 @@
22 39 useMemo,
23 40 useRef,
24 41 useState,
25 42 } from '@wordpress/element';
26 -import { __ } from '@wordpress/i18n';
43 +import { __, _n, sprintf } from '@wordpress/i18n';
27 44
28 45 const devmode = window.extSharedData.devbuild;
46 +// Logged with tool errors: the banner sends users here and support needs to
47 +// know which site the console they paste came from.
48 +const { siteId } = window.extSharedData;
29 49 // Used to abort when wf canceled - reset in cleanup()
30 50 let controller = new AbortController();
31 51 const { postId } = window?.extAgentData?.context || {};
32 52
53 +// Floor a resolution so its result doesn't re-render over the still-animating
54 +// scroll-to-top, which leaves the chat scrolled to the wrong place.
55 +const withMinDuration = async (promise, ms) => {
56 + const [result] = await Promise.all([
57 + promise,
58 + new Promise((resolve) => setTimeout(resolve, ms)),
59 + ]);
60 + return result;
61 +};
62 +
63 +// cleanup() swaps in a fresh controller; a signal read after the wait is never aborted.
64 +const canceledDuring = async (ms) => {
65 + const { signal } = controller;
66 + await new Promise((resolve) => setTimeout(resolve, ms));
67 + return signal.aborted;
68 +};
69 +
33 70 export const Agent = () => {
34 - const { addMessage, popMessage } = useChatStore();
71 + const { addMessage, updateMessage, popMessage, messages } = useChatStore();
72 + const { pushStatus, clearStatuses, leavingPage } = useStatusStore();
35 73 const {
36 74 mergeWorkflowData,
37 75 getWorkflow,
38 76 getWorkflowByExample,
@@ -37,16 +75,15 @@
37 75 getWorkflow,
38 76 getWorkflowByExample,
39 77 workflowData,
40 78 setWorkflow,
41 - addWorkflowResult,
42 79 setWhenFinishedToolProps,
43 80 whenFinishedToolProps,
44 81 getAvailableWorkflows,
45 - block,
46 - setBlock,
82 + requireBlock,
47 83 } = useWorkflowStore();
48 - const workflowIds = getAvailableWorkflows().map((w) => w.id);
84 + const block = useQuickEditStore((s) => s.agentBlock);
85 + const setBlock = useQuickEditStore((s) => s.setAgentBlock);
49 86 const { open, setOpen, updateRetryAfter, isChatAvailable } = useGlobalStore();
50 87 useLockPost({ postId, enabled: !!open });
51 88 const [canType, setCanType] = useState(true);
52 89 const agentWorking = useRef(false);
@@ -51,64 +88,104 @@
51 88 const [canType, setCanType] = useState(true);
52 89 const agentWorking = useRef(false);
53 90 const toolWorking = useRef(false);
54 91 const retrying = useRef(false);
55 - const [waitingOnToolOrUser, setWaitingOnToolOrUser] = useState(false);
92 + // Starting false would re-run the agent's last turn on every reload.
93 + const [waitingOnToolOrUser, setWaitingOnToolOrUser] = useState(() => {
94 + const last = useChatStore.getState().messages.at(-1);
95 + if (last?.type === 'tool') return !('result' in (last.details ?? {}));
96 + return last?.type === 'message' && last.details?.role === 'assistant';
97 + });
56 98 const [loop, setLoop] = useState(0);
57 99 const workflow = getWorkflow();
100 + const canvasOpen = useCanvasOpen();
101 + const canvasAssist = useCanvasAssist();
102 + const canvasNoticeShown = useRef(false);
58 103 const chatAvailable = useMemo(() => isChatAvailable(), [isChatAvailable]);
59 104 const { addSuggestions, getSuggestions } = useSuggestionsStore();
105 + // Options render only while their message is last; a reply dismisses them.
106 + const lastMessage = messages.at(-1);
107 + const qaSuggestions =
108 + lastMessage?.type === 'message' &&
109 + lastMessage.details?.role === 'assistant' &&
110 + Array.isArray(lastMessage.details?.qaSuggestions)
111 + ? lastMessage.details.qaSuggestions
112 + : null;
60 113
114 + // Without this the input stays disabled for as long as the canvas is open.
115 + useEffect(() => {
116 + if (canvasOpen && canvasAssist) setCanType(true);
117 + // A workflow reached by example carries no sessionId to key this on.
118 + if (!canvasOpen) canvasNoticeShown.current = false;
119 + }, [canvasOpen, canvasAssist]);
120 +
61 121 const cleanup = useCallback(() => {
62 122 setCanType(true);
63 123 agentWorking.current = false;
64 124 setWaitingOnToolOrUser(false);
65 125 controller = new AbortController();
66 - block && setBlock(null);
126 + const { agentBlock, setCommittedSelection } = useQuickEditStore.getState();
127 + agentBlock && setBlock(null);
128 + // A class or pin left up freezes Quick Edit hover site-wide.
129 + document
130 + .querySelector('.wp-site-blocks')
131 + ?.classList.remove('extendify-agent-working', 'extendify-agent-busy');
132 + setCommittedSelection(null);
133 + clearStatuses();
67 134 window.dispatchEvent(new Event('extendify-agent:remove-block-highlight'));
68 - const c = Array.from(
69 - document.querySelectorAll(
70 - '#extendify-agent-chat-scroll-area div:last-child',
71 - ),
72 - )?.at(-1);
73 - c?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
74 - c?.scrollBy({ top: -5, behavior: 'smooth' });
75 - }, [setBlock, block]);
135 + }, [setBlock, clearStatuses]);
76 136
137 + useEffect(() => {
138 + const handle = ({ detail }) => {
139 + if (!detail?.id) return;
140 + updateMessage(detail.id, { result: detail.result });
141 + setWaitingOnToolOrUser(false);
142 + agentWorking.current = false;
143 + // The main loop parks on a staged tool; leaving one strands the run.
144 + setWhenFinishedToolProps(null);
145 + setLoop((prev) => prev + 1);
146 + };
147 + window.addEventListener('extendify-agent:client-tool-done', handle);
148 + return () =>
149 + window.removeEventListener('extendify-agent:client-tool-done', handle);
150 + }, [updateMessage, setWhenFinishedToolProps]);
151 +
77 152 const findAgent = useCallback(
78 153 async (options = {}) => {
79 - addMessage('status', { type: 'calling-agent' });
80 - const response = await pickWorkflow({
81 - workflows: workflowIds,
82 - options: { signal: controller.signal, ...options },
83 - }).catch(async (error) => {
84 - devmode && console.error(error);
85 - if (error?.response?.status === 429) {
86 - updateRetryAfter(error?.response?.headers?.get('Retry-After'));
87 - setCanType(false);
88 - addMessage('status', { type: 'credits-exhausted' });
154 + pushStatus('calling-agent');
155 + const { signal } = controller;
156 + const response = await withMinDuration(
157 + pickWorkflow({
158 + // A mid-turn drop clears the block after this closure was made.
159 + workflows: getAvailableWorkflows().map((w) => w.id),
160 + options: { signal, ...options },
161 + }).catch(async (error) => {
162 + devmode && console.error(error);
163 + if (error?.response?.status === 429) {
164 + updateRetryAfter(error?.response?.headers?.get('Retry-After'));
165 + setCanType(false);
166 + pushStatus('credits-exhausted');
167 + return;
168 + }
169 + setCanType(true);
170 + if (error === 'Workflow aborted') return;
171 +
172 + await new Promise((resolve) => setTimeout(resolve, 1000));
173 + addMessage('message', {
174 + role: 'assistant',
175 + // translators: This message is shown when the AI agent fails to find a suitable workflow.
176 + content: __(
177 + 'Something went wrong while trying to start this request. Please try again.',
178 + 'extendify-local',
179 + ),
180 + error: true,
181 + });
89 182 return;
90 - }
91 - setCanType(true);
92 - if (error === 'Workflow aborted') {
93 - addMessage('status', { type: 'workflow-canceled' });
94 - return;
95 - }
183 + }),
184 + 500,
185 + );
186 + if (!response || signal.aborted) return;
96 187
97 - await new Promise((resolve) => setTimeout(resolve, 1000));
98 - addMessage('message', {
99 - role: 'assistant',
100 - // translators: This message is shown when the AI agent fails to find a suitable workflow.
101 - content: __(
102 - 'Something went wrong while trying to start this request. Please try again.',
103 - 'extendify-local',
104 - ),
105 - error: true,
106 - });
107 - return;
108 - });
109 - if (!response) return;
110 -
111 188 const { workflow: wf, reply } = response;
112 189 if (wf?.id) setWorkflow(wf);
113 190 if (reply) {
114 191 const data = { role: 'assistant', content: reply, agent: wf?.agent };
@@ -115,20 +192,120 @@
115 192 addMessage('message', data);
116 193 }
117 194 if (!wf?.id) setCanType(true);
118 195 },
119 - [addMessage, updateRetryAfter, setWorkflow, workflowIds],
196 + [
197 + addMessage,
198 + pushStatus,
199 + updateRetryAfter,
200 + setWorkflow,
201 + getAvailableWorkflows,
202 + ],
120 203 );
121 204
205 + // The backend caps tool runs; nothing here bounds the loop.
206 + const handleCanvasMessage = useCallback(async () => {
207 + setCanType(false);
208 + // cleanup() replaces the controller, so a cancel is lost between passes.
209 + const { signal } = controller;
210 + while (!signal.aborted) {
211 + pushStatus('agent-working');
212 + const response = await handleCanvas({
213 + toolId: whenFinishedToolProps?.id,
214 + sessionId: workflow?.sessionId,
215 + abilities: workflow?.abilities,
216 + options: { signal },
217 + }).catch((error) => {
218 + if (error === 'Workflow aborted') return null;
219 + const { sessionId } = workflow || {};
220 + digest({
221 + error,
222 + details: { source: 'agent', caller: 'handle-canvas', sessionId },
223 + });
224 + devmode && console.error(error);
225 + return { error: error.message };
226 + });
227 + // A request that settled before the abort still resolves with a reply.
228 + if (!response || signal.aborted) break;
229 + if (response.error) {
230 + addMessage('message', {
231 + role: 'assistant',
232 + // translators: Shown when the AI agent could not answer a question about the form it has open on screen.
233 + content: __(
234 + 'Sorry, something went wrong. Please try asking again.',
235 + 'extendify-local',
236 + ),
237 + error: true,
238 + });
239 + break;
240 + }
241 + if (response.reply) {
242 + addMessage('message', {
243 + role: 'assistant',
244 + content: response.reply,
245 + followup: !!response.tool,
246 + agent: workflow?.agent,
247 + });
248 + }
249 + // The model is never told what is on screen around the canvas.
250 + if (response.cannotHelp && !canvasNoticeShown.current) {
251 + canvasNoticeShown.current = true;
252 + addMessage('canvas-notice', {});
253 + }
254 + if (!response.tool) break;
255 + const { id, inputs, labels } = response.tool;
256 + pushStatus('tool-started', labels?.started);
257 + const result = await callTool({
258 + tool: id,
259 + inputs,
260 + abilities: workflow?.abilities,
261 + }).catch((error) => {
262 + const { sessionId } = workflow || {};
263 + digest({
264 + error,
265 + details: { source: 'agent', caller: `canvas: ${id}`, sessionId },
266 + });
267 + console.error(`Extendify agent tool error: ${id}`, { siteId, error });
268 + return { error: { message: error?.message, code: error?.code } };
269 + });
270 + addMessage('tool', { id, inputs, result, label: labels?.confirm });
271 + }
272 + setCanType(true);
273 + }, [addMessage, pushStatus, whenFinishedToolProps, workflow]);
274 +
122 275 const handleSubmit = useCallback(
123 - async (message) => {
276 + async (message, { hidden = false } = {}) => {
277 + // Suggestions reach the agent without the textarea; disabling it isn't enough.
278 + if (useQuickEditStore.getState().selected) return;
124 279 setWaitingOnToolOrUser(false);
125 280 agentWorking.current = false;
126 - addMessage('message', { role: 'user', content: message });
281 + addMessage('message', { role: 'user', content: message, hidden });
127 282
283 + // Without this a typed message would drop the workflow and close the canvas.
284 + if (canvasOpen && canvasAssist) return handleCanvasMessage();
285 +
286 + // A staged tool the user walked away from still owes its receipt.
287 + if (workflow?.id && whenFinishedToolProps?.id) {
288 + const { answerId, whenFinishedTool } =
289 + whenFinishedToolProps.agentResponse || {};
290 + addMessage('workflow', {
291 + status: 'canceled',
292 + label: whenFinishedTool?.labels?.cancel,
293 + agent: workflow.agent,
294 + workflowId: workflow.id,
295 + answerId,
296 + suggestions: getSuggestions(),
297 + });
298 + }
299 +
128 300 // Let some phrases auto load workflows
129 301 const bypass = getWorkflowByExample(message);
130 - if (bypass?.example?.agentResponse) return handleBypass(bypass);
302 + if (bypass?.example?.agentResponse) {
303 + // whenFinishedTool → inline input UI; none → ask for a typed reply.
304 + return bypass.example.agentResponse.whenFinishedTool
305 + ? handleBypass(bypass)
306 + : handleInstantAsk(bypass);
307 + }
131 308
132 309 setCanType(false);
133 310 // If they typed while waiting on a redirect, reset the workflow
134 311 const redirect = workflow?.needsRedirect?.();
@@ -145,13 +322,25 @@
145 322 mergeWorkflowData(wfData);
146 323 return;
147 324 }
148 325
326 + // Skip the network find-agent when the staged block makes the edit certain.
327 + const localPick = localPickWorkflow({ block });
328 + if (localPick) {
329 + if (await canceledDuring(500)) return;
330 + setWorkflow(localPick);
331 + return;
332 + }
333 +
149 334 await findAgent().catch((e) => devmode && console.error(e));
150 335 },
151 336 [
152 337 addMessage,
338 + block,
339 + canvasAssist,
340 + canvasOpen,
153 341 findAgent,
342 + handleCanvasMessage,
154 343 mergeWorkflowData,
155 344 whenFinishedToolProps,
156 345 setWorkflow,
157 346 workflow,
@@ -156,8 +345,9 @@
156 345 setWorkflow,
157 346 workflow,
158 347 workflowData,
159 348 getAvailableWorkflows,
349 + getSuggestions,
160 350 ],
161 351 );
162 352
163 353 // Used to inject a workflow final state
@@ -167,9 +357,9 @@
167 357 if (!agentResponse) return;
168 358 setWorkflow(workflow);
169 359 setCanType(false);
170 360 agentWorking.current = true;
171 - await new Promise((resolve) => setTimeout(resolve, 750));
361 + if (await canceledDuring(750)) return;
172 362 addMessage('message', {
173 363 role: 'assistant',
174 364 content: agentResponse.reply,
175 365 });
@@ -183,22 +373,60 @@
183 373 value: { workflow: workflow?.id },
184 374 });
185 375 }, []);
186 376
377 + // Ask the user, then let the normal loop handle their typed reply.
378 + const handleInstantAsk = useCallback(async (workflow) => {
379 + const agentResponse = workflow.example?.agentResponse;
380 + cleanup();
381 + if (!agentResponse) return;
382 + setWorkflow(workflow);
383 + // Without this the loop calls the backend before the user has typed.
384 + setWaitingOnToolOrUser(true);
385 + setCanType(false);
386 + agentWorking.current = true;
387 + if (await canceledDuring(750)) return;
388 + addMessage('message', {
389 + role: 'assistant',
390 + content: agentResponse.reply,
391 + // A workflow example can carry its own suggestions; none still asks.
392 + qaSuggestions: agentResponse.qaSuggestions ?? [],
393 + });
394 + agentWorking.current = false;
395 + setCanType(true);
396 + recordAgentActivity({
397 + sessionId: workflow?.sessionId,
398 + action: 'workflow_instant_ask',
399 + value: { workflow: workflow?.id },
400 + });
401 + }, []);
402 +
187 403 useEffect(() => {
188 404 // Allow external messages to trigger the agent
189 405 const handleMessage = ({ detail }) => {
190 406 if (!detail?.message) return;
191 - handleSubmit(detail.message);
407 + handleSubmit(detail.message, { hidden: detail.hidden });
192 408 };
193 409 // Allow external code to clear the block and workflow
194 410 const handleCleanup = () => {
411 + // cleanup() resets canType and agentWorking, so read them before it runs.
412 + const interrupted = agentWorking.current || !canType;
195 413 controller.abort('Workflow aborted');
196 414 cleanup();
415 + // Deferred a frame: the input stays disabled until the cancel lands.
416 + requestAnimationFrame(() =>
417 + document.querySelector('#extendify-agent-chat-textarea')?.focus(),
418 + );
197 419
198 - if (!workflow?.id) return;
420 + // An options panel can outlive its workflow; cancel must still clear it.
421 + if (!workflow?.id && !qaSuggestions && !interrupted) return;
199 422 setWorkflow(null);
200 - addMessage('status', { type: 'workflow-canceled' });
423 + addMessage('workflow', {
424 + status: 'canceled',
425 + agent: workflow?.agent,
426 + workflowId: workflow?.id,
427 + suggestions: getSuggestions(),
428 + });
201 429 return;
202 430 };
203 431 window.addEventListener('extendify-agent:cancel-workflow', handleCleanup);
204 432 window.addEventListener('extendify-agent:chat-submit', handleMessage);
@@ -208,49 +436,79 @@
208 436 handleCleanup,
209 437 );
210 438 window.removeEventListener('extendify-agent:chat-submit', handleMessage);
211 439 };
212 - }, [handleSubmit, cleanup, setWorkflow, addMessage, workflow]);
440 + }, [
441 + handleSubmit,
442 + cleanup,
443 + setWorkflow,
444 + addMessage,
445 + workflow,
446 + qaSuggestions,
447 + getSuggestions,
448 + canType,
449 + ]);
213 450
451 + // Dispatching before chat-submit has a listener drops the workflow.
452 + useEffect(() => {
453 + if (!startOnboardingWorkflow.available()) return;
454 + window.dispatchEvent(
455 + new CustomEvent('extendify-agent:chat-submit', {
456 + detail: { message: startOnboardingWorkflow.example.text, hidden: true },
457 + }),
458 + );
459 + }, []);
460 +
214 461 // Handle whenFinished component confirm/cancel
215 462 useEffect(() => {
216 463 const handleConfirm = async ({ detail }) => {
217 464 if (toolWorking.current) return;
218 465 setWhenFinishedToolProps(null);
219 - addMessage('status', { type: 'workflow-tool-processing' });
220 466 toolWorking.current = true;
221 467 const { data, whenFinishedToolProps, shouldRefreshPage, redirectUrl } =
222 468 detail ?? {};
223 - const { status, whenFinishedTool, answerId, redirectTo } =
469 + const { whenFinishedTool, answerId, redirectTo } =
224 470 whenFinishedToolProps?.agentResponse || {};
225 471 const { id, labels } = whenFinishedTool || {};
472 + // Staged unanswered so its own component can show the run in progress.
473 + const runMessageId = id ? addMessage('tool', { id, inputs: data }) : null;
474 + if (!hasRunComponent(id)) pushStatus('workflow-tool-processing');
226 475 // Not all workflows have a tool at the end (e.g. tours)
227 476 const toolResponse = await callTool?.({ tool: id, inputs: data }).catch(
228 477 (error) => {
229 478 const { sessionId } = workflow || {};
230 - digest({ caller: `when-finished: ${id}`, sessionId, error });
231 - devmode && console.error(error);
232 - return { error: error.message };
479 + digest({
480 + error,
481 + details: {
482 + source: 'agent',
483 + caller: `when-finished: ${id}`,
484 + sessionId,
485 + },
486 + });
487 + console.error(`Extendify agent tool error: ${id}`, { siteId, error });
488 + return { error: { message: error?.message, code: error?.code } };
233 489 },
234 490 );
235 491 toolWorking.current = false;
236 - // Add the workflow result to the history
237 - addWorkflowResult({
238 - answerId,
239 - agentName: workflow?.agent?.name,
240 - status,
241 - errorMsg: toolResponse?.error,
242 - language: workflow?.language,
243 - });
244 - if (toolResponse?.error) {
492 + if (runMessageId) updateMessage(runMessageId, { result: toolResponse });
493 + if (toolResponse?.refused) {
245 494 await new Promise((resolve) => setTimeout(resolve, 1000));
495 + const refusalMessages = {
496 + // translators: Shown when the AI agent's edit produced no change to the block, which is unexpected.
497 + 'no-op': __(
498 + "That edit came back unchanged, which wasn't expected. Please try rephrasing what you'd like to change.",
499 + 'extendify-local',
500 + ),
501 + };
246 502 addMessage('message', {
247 503 role: 'assistant',
248 - // translators: This message is shown when the AI agent fails to confirm an action.
249 - content: __(
250 - 'Sorry, something went wrong attempting to call the tool. Please try again.',
251 - 'extendify-local',
252 - ),
504 + content:
505 + refusalMessages[toolResponse.reason] ??
506 + // translators: Shown when the AI agent could not safely apply an edit to the selected block.
507 + __(
508 + "I couldn't safely apply that edit to the selected block. Please re-select the block and try again.",
509 + 'extendify-local',
510 + ),
253 511 error: true,
254 512 });
255 513 setWorkflow(null);
256 514 cleanup();
@@ -255,54 +513,69 @@
255 513 setWorkflow(null);
256 514 cleanup();
257 515 return;
258 516 }
259 - addMessage('status', {
260 - label: labels?.confirm,
261 - type: 'workflow-tool-completed',
262 - });
517 + // Only loop back on error, so the model can recover. A clean
518 + // whenFinished tool means the workflow is done.
519 + if (toolResponse?.error) {
520 + setWaitingOnToolOrUser(false);
521 + agentWorking.current = false;
522 + setLoop((prev) => prev + 1);
523 + return;
524 + }
525 +
263 526 addSuggestions(whenFinishedToolProps.agentResponse?.recommendations);
264 527 addMessage('workflow', {
265 528 status: 'completed',
529 + label: labels?.confirm,
266 530 agent: workflow.agent,
531 + workflowId: workflow.id,
267 532 answerId,
268 533 suggestions: getSuggestions(),
269 534 });
535 + // A chat message persists, so the next turn's model knows the save was partial.
536 + const refusedCount = toolResponse?.refusedOperations?.length;
537 + if (refusedCount) {
538 + addMessage('message', {
539 + role: 'assistant',
540 + content: sprintf(
541 + // translators: %d is how many of the requested edits were not applied.
542 + _n(
543 + "Heads up — %d of those changes couldn't be applied. If something still looks the same, ask me to redo just that part.",
544 + "Heads up — %d of those changes couldn't be applied. If something still looks the same, ask me to redo just those parts.",
545 + refusedCount,
546 + 'extendify-local',
547 + ),
548 + refusedCount,
549 + ),
550 + });
551 + }
270 552 setWorkflow(null);
553 + useCanvasStore.getState().endSession();
271 554
272 555 const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
273 -
274 - if (url || redirectUrl || shouldRefreshPage) {
275 - await new Promise((resolve) => setTimeout(resolve, 1000));
556 + const refreshForAbility = isAbilityTool(id);
557 + if (url || redirectUrl || shouldRefreshPage || refreshForAbility) {
558 + return doReload(url || redirectUrl);
276 559 }
277 -
278 - if (url) return window.location.assign(url);
279 - if (redirectUrl) return window.location.assign(redirectUrl);
280 - if (shouldRefreshPage) return window.location.reload();
281 - // Clean up if not redirecting
282 560 cleanup();
283 561 };
284 562 const handleCancel = ({ detail }) => {
285 563 if (toolWorking.current) return;
564 + // Without this a canvas closed mid-turn keeps replying into the chat.
565 + controller.abort('Workflow aborted');
286 566 const { answerId, whenFinishedTool } =
287 567 detail.whenFinishedToolProps?.agentResponse || {};
288 - addMessage('status', {
289 - type: 'workflow-canceled',
290 - label: whenFinishedTool?.labels?.cancel,
291 - });
292 568 addMessage('workflow', {
293 569 status: 'canceled',
570 + label: whenFinishedTool?.labels?.cancel,
294 571 agent: workflow.agent,
572 + workflowId: workflow.id,
295 573 answerId,
296 574 suggestions: getSuggestions(),
297 575 });
298 - addWorkflowResult({
299 - answerId,
300 - status: 'canceled',
301 - agentName: workflow?.agent?.name,
302 - language: workflow?.language,
303 - });
304 576 setWorkflow(null);
577 + useCanvasStore.getState().endSession();
305 578 cleanup();
306 579 };
307 580 const handleRetry = () => {
308 581 popMessage();
@@ -326,11 +599,11 @@
326 599 window.removeEventListener('extendify-agent:workflow-retry', handleRetry);
327 600 };
328 601 }, [
329 602 addMessage,
603 + pushStatus,
330 604 popMessage,
331 605 cleanup,
332 - addWorkflowResult,
333 606 setWorkflow,
334 607 workflow,
335 608 getSuggestions,
336 609 addSuggestions,
@@ -346,9 +619,18 @@
346 619 window.removeEventListener('extendify-agent:open', handleOpen);
347 620 };
348 621 }, [setOpen]);
349 622
623 + // Closing the sidebar dismisses any latent block selection. The X-close
624 + // indicator (in DOMHighlighter) only renders while the sidebar is open,
625 + // so leaving `block` set after close would let Quick Edit's hover-bar
626 + // gate fire on a selection the user can no longer see or clear.
350 627 useEffect(() => {
628 + if (open) return;
629 + if (block) setBlock(null);
630 + }, [open, block, setBlock]);
631 +
632 + useEffect(() => {
351 633 if (waitingOnToolOrUser || !open || !workflow?.id) return;
352 634 // Some workflows require they dont change pages
353 635 const theyMoved = workflow?.startingPage !== window.location.href;
354 636 // Requires a block to be selected
@@ -358,8 +640,9 @@
358 640 if (cancelWorkflow) {
359 641 addMessage('workflow', {
360 642 status: 'canceled',
361 643 agent: workflow.agent,
644 + workflowId: workflow.id,
362 645 suggestions: getSuggestions(),
363 646 });
364 647 setWorkflow(null);
365 648 cleanup();
@@ -376,36 +659,27 @@
376 659 if (agentWorking.current) return; // Prevent multiple calls
377 660 if (toolWorking.current) return;
378 661 setCanType(false);
379 662 agentWorking.current = true;
380 - addMessage('status', { type: 'agent-working' });
663 + pushStatus('agent-working');
381 664 const agentResponse = await handleWorkflow({
382 665 workflow,
383 666 workflowData,
384 667 options: { signal: controller.signal, retry: retrying.current },
385 668 }).catch((error) => {
386 - if (error === 'Workflow aborted') {
387 - addMessage('status', { type: 'workflow-canceled' });
388 - setWorkflow(null);
389 - cleanup();
390 - return;
391 - }
669 + // handleCleanup already added the canceled message
670 + if (error === 'Workflow aborted') return;
392 671 const { sessionId } = workflow || {};
393 - digest({ caller: 'handle-workflow', sessionId, error });
672 + digest({
673 + error,
674 + details: { source: 'agent', caller: `handle-workflow`, sessionId },
675 + });
394 676 devmode && console.error(error);
395 677 return { error: error.message };
396 678 });
397 679 if (retrying.current) retrying.current = false;
398 680 if (!agentResponse) return;
399 - const { status, answerId, sessionId } = agentResponse;
400 - // Add the workflow result to the history
401 - addWorkflowResult({
402 - answerId,
403 - status,
404 - errorMsg: agentResponse?.error,
405 - agentName: workflow?.agent?.name,
406 - language: workflow?.language,
407 - });
681 + const { answerId, sessionId } = agentResponse;
408 682 if (!open) return;
409 683 if (agentResponse.error) {
410 684 // mutate the window to add failed tools rather than keep state
411 685 window.extAgentData.failedWorkflows =
@@ -413,18 +687,44 @@
413 687 window.extAgentData.failedWorkflows.add(workflow.id);
414 688 throw new Error(`Error handling workflow: ${agentResponse.error}`);
415 689 }
416 690 // The ai sent back some text to show to the user
417 - if (agentResponse.reply) {
691 + const reply =
692 + agentResponse.reply ??
693 + (agentResponse.tool
694 + ? getClientToolFallbackReply(agentResponse.tool.id)
695 + : null);
696 + if (reply) {
418 697 addMessage('message', {
419 698 role: 'assistant',
420 - content: agentResponse.reply,
699 + content: reply,
421 700 followup: !!agentResponse.tool,
422 701 pageSuggestion: agentResponse.pageSuggestion,
702 + qaSuggestions: agentResponse.qaSuggestions,
423 703 agent: workflow.agent,
424 704 sessionId: workflow?.sessionId,
705 + workflowId: workflow?.id,
706 + language: workflow?.language,
425 707 });
426 708 }
709 + // Set when the request needs something no block edit can do.
710 + if (agentResponse.dropSelection) {
711 + setBlock(null);
712 + window.dispatchEvent(
713 + new Event('extendify-agent:remove-block-highlight'),
714 + );
715 + setWorkflow(null);
716 + pushStatus(
717 + 'tool-started',
718 + // translators: Shown while the AI agent deselects a block that can't serve the request.
719 + __('Removing selected block', 'extendify-local'),
720 + );
721 + // findAgent pushes its own status right away; let this one read first.
722 + if (await canceledDuring(2500)) return;
723 + agentWorking.current = false;
724 + await findAgent();
725 + return;
726 + }
427 727 // This is at the end of the workflow
428 728 // and we are about to execute the final tool
429 729 if (agentResponse.whenFinishedTool?.id) {
430 730 setWhenFinishedToolProps({
@@ -432,15 +732,22 @@
432 732 agentResponse,
433 733 });
434 734 // If static, add it as a message
435 735 const { id, inputs, static: staticC } = agentResponse.whenFinishedTool;
436 - if (staticC) {
437 - addMessage('workflow-component', { id, status: 'completed', inputs });
736 + // A canvas workflow renders in the canvas and ends on close or submit.
737 + if (staticC && !workflow.whenFinished?.canvas) {
738 + addMessage('workflow-component', {
739 + id,
740 + status: 'completed',
741 + inputs,
742 + workflowId: workflow.id,
743 + });
438 744 addSuggestions(agentResponse.recommendations);
439 745 setWorkflow(null);
440 746 addMessage('workflow', {
441 747 status: 'completed',
442 748 agent: workflow.agent,
749 + workflowId: workflow.id,
443 750 answerId,
444 751 suggestions: getSuggestions(),
445 752 });
446 753 cleanup();
@@ -456,8 +763,9 @@
456 763 cleanup();
457 764 addMessage('workflow', {
458 765 status: isCompleted ? 'completed' : 'canceled',
459 766 agent: workflow.agent,
767 + workflowId: workflow.id,
460 768 answerId,
461 769 suggestions: getSuggestions(),
462 770 });
463 771 return;
@@ -470,9 +778,18 @@
470 778 mergeWorkflowData(agentResponse.inputs);
471 779 // Agent needs more info from a
472 780 if (agentResponse.tool) {
473 781 const { id, inputs, labels } = agentResponse.tool;
474 - addMessage('status', { label: labels?.started, type: 'tool-started' });
782 + // A client tool is answered by in-chat UI, so the turn ends here.
783 + if (getClientTools().includes(id)) {
784 + addMessage('tool', { id, inputs });
785 + // Clearing agentWorking here fires a second turn: the wait flag
786 + // has not committed yet.
787 + setWaitingOnToolOrUser(true);
788 + setCanType(false);
789 + return;
790 + }
791 + pushStatus('tool-started', labels?.started);
475 792 const toolData = await Promise.all([
476 793 callTool({ tool: id, inputs }),
477 794 new Promise((resolve) => setTimeout(resolve, 3000)),
478 795 ])
@@ -478,18 +795,34 @@
478 795 ])
479 796 .then(([data]) => data)
480 797 .catch((error) => {
481 798 const { sessionId } = workflow || {};
482 - digest({ caller: `in-progress: ${id}`, sessionId, error });
483 - devmode && console.error(error);
484 - throw error;
799 + digest({
800 + error,
801 + details: {
802 + source: 'agent',
803 + caller: `in-progress: ${id}`,
804 + sessionId,
805 + },
806 + });
807 + console.error(`Extendify agent tool error: ${id}`, {
808 + siteId,
809 + error,
810 + });
811 + // Don't throw; the loop hands the error to the model.
812 + return { error: { message: error?.message, code: error?.code } };
485 813 });
486 - addMessage('status', {
814 + // do-when-finished spreads first-class workflowData into the tool.
815 + if (!toolData?.error && !isAbilityWorkflow(workflow.id)) {
816 + mergeWorkflowData(toolData);
817 + }
818 + if (toolData?.stagedBlockIds?.length) requireBlock();
819 + addMessage('tool', {
820 + id,
821 + inputs,
822 + result: toolData,
487 823 label: labels?.confirm,
488 - type: 'tool-completed',
489 824 });
490 - await new Promise((resolve) => setTimeout(resolve, 1000));
491 - mergeWorkflowData(toolData);
492 825 setWaitingOnToolOrUser(false);
493 826 agentWorking.current = false;
494 827 setLoop((prev) => prev + 1); // Trigger next loop
495 828 return;
@@ -497,9 +830,12 @@
497 830 setCanType(true);
498 831 setWaitingOnToolOrUser(true);
499 832 })().catch(async (error) => {
500 833 const { sessionId } = workflow || {};
501 - digest({ caller: 'main-loop', sessionId, error });
834 + digest({
835 + error,
836 + details: { source: 'agent', caller: 'main-loop', sessionId },
837 + });
502 838 devmode && console.error(error);
503 839 setWorkflow(null);
504 840 cleanup();
505 841 await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -515,21 +851,24 @@
515 851 });
516 852 }, [
517 853 loop,
518 854 cleanup,
519 - addWorkflowResult,
520 855 open,
521 856 workflow,
522 857 workflowData,
523 858 addMessage,
859 + pushStatus,
524 860 setWorkflow,
525 861 agentWorking,
526 862 waitingOnToolOrUser,
527 863 mergeWorkflowData,
864 + requireBlock,
528 865 canType,
529 866 whenFinishedToolProps,
530 867 setWhenFinishedToolProps,
531 868 block,
869 + setBlock,
870 + findAgent,
532 871 addSuggestions,
533 872 getSuggestions,
534 873 ]);
535 874
@@ -538,40 +877,46 @@
538 877 document.querySelector('#extendify-agent-chat-textarea')?.focus();
539 878 }, [canType]);
540 879
541 880 const busy = !canType || !chatAvailable || workflow?.id;
881 + // `busy` is true at rest; 429 is blocked, not working.
882 + const working = !canType && chatAvailable;
542 883
543 884 return (
544 - <Chat busy={busy}>
545 - <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
546 - <ChatMessages
547 - redirectComponent={
548 - workflow?.needsRedirect?.() ? workflow.redirectComponent : null
549 - }
550 - />
551 - <div>
552 - <div className="relative flex flex-col px-4 pb-2 pt-2.5 shadow-lg-flipped">
553 - {block ? <PageDocument busy={busy} blockId={block.id} /> : null}
554 - <UsageMessage
555 - onReady={() => {
556 - cleanup();
557 - addMessage('status', { type: 'credits-restored' });
558 - }}
559 - />
885 + <>
886 + <Canvas />
887 + <Chat busy={busy} working={working}>
888 + <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
889 + <ChatMessages
890 + redirectComponent={
891 + workflow?.needsRedirect?.() ? workflow.redirectComponent : null
892 + }
893 + />
894 + <div>
895 + <div className="relative flex flex-col px-4 pb-2 pt-2.5 shadow-lg-flipped">
896 + <UsageMessage
897 + onReady={() => {
898 + cleanup();
899 + pushStatus('credits-restored');
900 + }}
901 + />
902 + </div>
903 + <div className="p-4 pb-2 pt-0">
904 + <ChatInput
905 + disabled={
906 + !canType || !chatAvailable || !!qaSuggestions || leavingPage
907 + }
908 + handleSubmit={handleSubmit}
909 + />
910 + </div>
911 + <div className="text-pretty px-4 pb-2 text-center text-xss leading-none text-gray-700">
912 + {__(
913 + 'AI Agent can make mistakes. Check changes before saving.',
914 + 'extendify-local',
915 + )}
916 + </div>
560 917 </div>
561 - <div className="p-4 pb-2 pt-0">
562 - <ChatInput
563 - disabled={!canType || !chatAvailable}
564 - handleSubmit={handleSubmit}
565 - />
566 - </div>
567 - <div className="text-pretty px-4 pb-2 text-center text-xss leading-none text-banner-text/60">
568 - {__(
569 - 'AI Agent can make mistakes. Check changes before saving.',
570 - 'extendify-local',
571 - )}
572 - </div>
573 918 </div>
574 - </div>
575 - </Chat>
919 + </Chat>
920 + </>
576 921 );
577 922 };