PluginProbe
Extendify / trunk
Extendify vtrunk
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.4 → trunk 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 };