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