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 +480 -155 3.0.5 → trunk View file →
@@ -1,21 +1,38 @@
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';
10 16 import { UsageMessage } from '@agent/components/messages/UsageMessage';
11 -import { PageDocument } from '@agent/components/PageDocument';
12 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';
13 24 import { getRedirectUrl } from '@agent/lib/redirects';
25 +import { doReload } from '@agent/lib/reload';
26 +import { useCanvasStore } from '@agent/state/canvas';
14 27 import { useChatStore } from '@agent/state/chat';
15 28 import { useGlobalStore } from '@agent/state/global';
29 +import { useStatusStore } from '@agent/state/status';
16 30 import { useSuggestionsStore } from '@agent/state/suggestions';
17 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';
18 35 import { digest } from '@shared/api/digest';
19 36 import {
20 37 useCallback,
21 38 useEffect,
@@ -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,22 +436,43 @@
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 || {};
@@ -234,30 +483,32 @@
234 483 caller: `when-finished: ${id}`,
235 484 sessionId,
236 485 },
237 486 });
238 - devmode && console.error(error);
239 - return { error: error.message };
487 + console.error(`Extendify agent tool error: ${id}`, { siteId, error });
488 + return { error: { message: error?.message, code: error?.code } };
240 489 },
241 490 );
242 491 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) {
492 + if (runMessageId) updateMessage(runMessageId, { result: toolResponse });
493 + if (toolResponse?.refused) {
252 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 + };
253 502 addMessage('message', {
254 503 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 - ),
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 + ),
260 511 error: true,
261 512 });
262 513 setWorkflow(null);
263 514 cleanup();
@@ -262,54 +513,69 @@
262 513 setWorkflow(null);
263 514 cleanup();
264 515 return;
265 516 }
266 - addMessage('status', {
267 - label: labels?.confirm,
268 - type: 'workflow-tool-completed',
269 - });
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 +
270 526 addSuggestions(whenFinishedToolProps.agentResponse?.recommendations);
271 527 addMessage('workflow', {
272 528 status: 'completed',
529 + label: labels?.confirm,
273 530 agent: workflow.agent,
531 + workflowId: workflow.id,
274 532 answerId,
275 533 suggestions: getSuggestions(),
276 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 + }
277 552 setWorkflow(null);
553 + useCanvasStore.getState().endSession();
278 554
279 555 const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
280 -
281 - if (url || redirectUrl || shouldRefreshPage) {
282 - await new Promise((resolve) => setTimeout(resolve, 1000));
556 + const refreshForAbility = isAbilityTool(id);
557 + if (url || redirectUrl || shouldRefreshPage || refreshForAbility) {
558 + return doReload(url || redirectUrl);
283 559 }
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
289 560 cleanup();
290 561 };
291 562 const handleCancel = ({ detail }) => {
292 563 if (toolWorking.current) return;
564 + // Without this a canvas closed mid-turn keeps replying into the chat.
565 + controller.abort('Workflow aborted');
293 566 const { answerId, whenFinishedTool } =
294 567 detail.whenFinishedToolProps?.agentResponse || {};
295 - addMessage('status', {
296 - type: 'workflow-canceled',
297 - label: whenFinishedTool?.labels?.cancel,
298 - });
299 568 addMessage('workflow', {
300 569 status: 'canceled',
570 + label: whenFinishedTool?.labels?.cancel,
301 571 agent: workflow.agent,
572 + workflowId: workflow.id,
302 573 answerId,
303 574 suggestions: getSuggestions(),
304 575 });
305 - addWorkflowResult({
306 - answerId,
307 - status: 'canceled',
308 - agentName: workflow?.agent?.name,
309 - language: workflow?.language,
310 - });
311 576 setWorkflow(null);
577 + useCanvasStore.getState().endSession();
312 578 cleanup();
313 579 };
314 580 const handleRetry = () => {
315 581 popMessage();
@@ -333,11 +599,11 @@
333 599 window.removeEventListener('extendify-agent:workflow-retry', handleRetry);
334 600 };
335 601 }, [
336 602 addMessage,
603 + pushStatus,
337 604 popMessage,
338 605 cleanup,
339 - addWorkflowResult,
340 606 setWorkflow,
341 607 workflow,
342 608 getSuggestions,
343 609 addSuggestions,
@@ -353,9 +619,18 @@
353 619 window.removeEventListener('extendify-agent:open', handleOpen);
354 620 };
355 621 }, [setOpen]);
356 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.
357 627 useEffect(() => {
628 + if (open) return;
629 + if (block) setBlock(null);
630 + }, [open, block, setBlock]);
631 +
632 + useEffect(() => {
358 633 if (waitingOnToolOrUser || !open || !workflow?.id) return;
359 634 // Some workflows require they dont change pages
360 635 const theyMoved = workflow?.startingPage !== window.location.href;
361 636 // Requires a block to be selected
@@ -365,8 +640,9 @@
365 640 if (cancelWorkflow) {
366 641 addMessage('workflow', {
367 642 status: 'canceled',
368 643 agent: workflow.agent,
644 + workflowId: workflow.id,
369 645 suggestions: getSuggestions(),
370 646 });
371 647 setWorkflow(null);
372 648 cleanup();
@@ -383,20 +659,16 @@
383 659 if (agentWorking.current) return; // Prevent multiple calls
384 660 if (toolWorking.current) return;
385 661 setCanType(false);
386 662 agentWorking.current = true;
387 - addMessage('status', { type: 'agent-working' });
663 + pushStatus('agent-working');
388 664 const agentResponse = await handleWorkflow({
389 665 workflow,
390 666 workflowData,
391 667 options: { signal: controller.signal, retry: retrying.current },
392 668 }).catch((error) => {
393 - if (error === 'Workflow aborted') {
394 - addMessage('status', { type: 'workflow-canceled' });
395 - setWorkflow(null);
396 - cleanup();
397 - return;
398 - }
669 + // handleCleanup already added the canceled message
670 + if (error === 'Workflow aborted') return;
399 671 const { sessionId } = workflow || {};
400 672 digest({
401 673 error,
402 674 details: { source: 'agent', caller: `handle-workflow`, sessionId },
@@ -405,17 +677,9 @@
405 677 return { error: error.message };
406 678 });
407 679 if (retrying.current) retrying.current = false;
408 680 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 - });
681 + const { answerId, sessionId } = agentResponse;
418 682 if (!open) return;
419 683 if (agentResponse.error) {
420 684 // mutate the window to add failed tools rather than keep state
421 685 window.extAgentData.failedWorkflows =
@@ -423,18 +687,44 @@
423 687 window.extAgentData.failedWorkflows.add(workflow.id);
424 688 throw new Error(`Error handling workflow: ${agentResponse.error}`);
425 689 }
426 690 // The ai sent back some text to show to the user
427 - if (agentResponse.reply) {
691 + const reply =
692 + agentResponse.reply ??
693 + (agentResponse.tool
694 + ? getClientToolFallbackReply(agentResponse.tool.id)
695 + : null);
696 + if (reply) {
428 697 addMessage('message', {
429 698 role: 'assistant',
430 - content: agentResponse.reply,
699 + content: reply,
431 700 followup: !!agentResponse.tool,
432 701 pageSuggestion: agentResponse.pageSuggestion,
702 + qaSuggestions: agentResponse.qaSuggestions,
433 703 agent: workflow.agent,
434 704 sessionId: workflow?.sessionId,
705 + workflowId: workflow?.id,
706 + language: workflow?.language,
435 707 });
436 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 + }
437 727 // This is at the end of the workflow
438 728 // and we are about to execute the final tool
439 729 if (agentResponse.whenFinishedTool?.id) {
440 730 setWhenFinishedToolProps({
@@ -442,15 +732,22 @@
442 732 agentResponse,
443 733 });
444 734 // If static, add it as a message
445 735 const { id, inputs, static: staticC } = agentResponse.whenFinishedTool;
446 - if (staticC) {
447 - 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 + });
448 744 addSuggestions(agentResponse.recommendations);
449 745 setWorkflow(null);
450 746 addMessage('workflow', {
451 747 status: 'completed',
452 748 agent: workflow.agent,
749 + workflowId: workflow.id,
453 750 answerId,
454 751 suggestions: getSuggestions(),
455 752 });
456 753 cleanup();
@@ -466,8 +763,9 @@
466 763 cleanup();
467 764 addMessage('workflow', {
468 765 status: isCompleted ? 'completed' : 'canceled',
469 766 agent: workflow.agent,
767 + workflowId: workflow.id,
470 768 answerId,
471 769 suggestions: getSuggestions(),
472 770 });
473 771 return;
@@ -480,9 +778,18 @@
480 778 mergeWorkflowData(agentResponse.inputs);
481 779 // Agent needs more info from a
482 780 if (agentResponse.tool) {
483 781 const { id, inputs, labels } = agentResponse.tool;
484 - 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);
485 792 const toolData = await Promise.all([
486 793 callTool({ tool: id, inputs }),
487 794 new Promise((resolve) => setTimeout(resolve, 3000)),
488 795 ])
@@ -496,17 +803,26 @@
496 803 caller: `in-progress: ${id}`,
497 804 sessionId,
498 805 },
499 806 });
500 - devmode && console.error(error);
501 - throw error;
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 } };
502 813 });
503 - 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,
504 823 label: labels?.confirm,
505 - type: 'tool-completed',
506 824 });
507 - await new Promise((resolve) => setTimeout(resolve, 1000));
508 - mergeWorkflowData(toolData);
509 825 setWaitingOnToolOrUser(false);
510 826 agentWorking.current = false;
511 827 setLoop((prev) => prev + 1); // Trigger next loop
512 828 return;
@@ -535,21 +851,24 @@
535 851 });
536 852 }, [
537 853 loop,
538 854 cleanup,
539 - addWorkflowResult,
540 855 open,
541 856 workflow,
542 857 workflowData,
543 858 addMessage,
859 + pushStatus,
544 860 setWorkflow,
545 861 agentWorking,
546 862 waitingOnToolOrUser,
547 863 mergeWorkflowData,
864 + requireBlock,
548 865 canType,
549 866 whenFinishedToolProps,
550 867 setWhenFinishedToolProps,
551 868 block,
869 + setBlock,
870 + findAgent,
552 871 addSuggestions,
553 872 getSuggestions,
554 873 ]);
555 874
@@ -558,40 +877,46 @@
558 877 document.querySelector('#extendify-agent-chat-textarea')?.focus();
559 878 }, [canType]);
560 879
561 880 const busy = !canType || !chatAvailable || workflow?.id;
881 + // `busy` is true at rest; 429 is blocked, not working.
882 + const working = !canType && chatAvailable;
562 883
563 884 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 - />
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>
580 917 </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 918 </div>
594 - </div>
595 - </Chat>
919 + </Chat>
920 + </>
596 921 );
597 922 };