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