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 +464 -135 3.1.1 → trunk View file →
@@ -1,21 +1,37 @@
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';
18 34 import { useQuickEditStore } from '@quick-edit/state/store';
19 35 import { digest } from '@shared/api/digest';
20 36 import {
21 37 useCallback,
@@ -23,17 +39,38 @@
23 39 useMemo,
24 40 useRef,
25 41 useState,
26 42 } from '@wordpress/element';
27 -import { __ } from '@wordpress/i18n';
43 +import { __, _n, sprintf } from '@wordpress/i18n';
28 44
29 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;
30 49 // Used to abort when wf canceled - reset in cleanup()
31 50 let controller = new AbortController();
32 51 const { postId } = window?.extAgentData?.context || {};
33 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 +
34 70 export const Agent = () => {
35 - const { addMessage, popMessage } = useChatStore();
71 + const { addMessage, updateMessage, popMessage, messages } = useChatStore();
72 + const { pushStatus, clearStatuses, leavingPage } = useStatusStore();
36 73 const {
37 74 mergeWorkflowData,
38 75 getWorkflow,
39 76 getWorkflowByExample,
@@ -41,12 +78,12 @@
41 78 setWorkflow,
42 79 setWhenFinishedToolProps,
43 80 whenFinishedToolProps,
44 81 getAvailableWorkflows,
82 + requireBlock,
45 83 } = useWorkflowStore();
46 84 const block = useQuickEditStore((s) => s.agentBlock);
47 85 const setBlock = useQuickEditStore((s) => s.setAgentBlock);
48 - const workflowIds = getAvailableWorkflows().map((w) => w.id);
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,67 +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 - // scrollIntoView below walks up and scrolls the page itself,
69 - // fighting useLayoutShift's scroll restore when closing.
70 - if (!useGlobalStore.getState().open) return;
71 - const c = Array.from(
72 - document.querySelectorAll(
73 - '#extendify-agent-chat-scroll-area div:last-child',
74 - ),
75 - )?.at(-1);
76 - c?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
77 - c?.scrollBy({ top: -5, behavior: 'smooth' });
78 - }, [setBlock, block]);
135 + }, [setBlock, clearStatuses]);
79 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 +
80 152 const findAgent = useCallback(
81 153 async (options = {}) => {
82 - addMessage('status', { type: 'calling-agent' });
83 - const response = await pickWorkflow({
84 - workflows: workflowIds,
85 - options: { signal: controller.signal, ...options },
86 - }).catch(async (error) => {
87 - devmode && console.error(error);
88 - if (error?.response?.status === 429) {
89 - updateRetryAfter(error?.response?.headers?.get('Retry-After'));
90 - setCanType(false);
91 - 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 + });
92 182 return;
93 - }
94 - setCanType(true);
95 - if (error === 'Workflow aborted') {
96 - addMessage('status', { type: 'workflow-canceled' });
97 - return;
98 - }
183 + }),
184 + 500,
185 + );
186 + if (!response || signal.aborted) return;
99 187
100 - await new Promise((resolve) => setTimeout(resolve, 1000));
101 - addMessage('message', {
102 - role: 'assistant',
103 - // translators: This message is shown when the AI agent fails to find a suitable workflow.
104 - content: __(
105 - 'Something went wrong while trying to start this request. Please try again.',
106 - 'extendify-local',
107 - ),
108 - error: true,
109 - });
110 - return;
111 - });
112 - if (!response) return;
113 -
114 188 const { workflow: wf, reply } = response;
115 189 if (wf?.id) setWorkflow(wf);
116 190 if (reply) {
117 191 const data = { role: 'assistant', content: reply, agent: wf?.agent };
@@ -118,26 +192,120 @@
118 192 addMessage('message', data);
119 193 }
120 194 if (!wf?.id) setCanType(true);
121 195 },
122 - [addMessage, updateRetryAfter, setWorkflow, workflowIds],
196 + [
197 + addMessage,
198 + pushStatus,
199 + updateRetryAfter,
200 + setWorkflow,
201 + getAvailableWorkflows,
202 + ],
123 203 );
124 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 +
125 275 const handleSubmit = useCallback(
126 - async (message) => {
127 - // Save any in-flight QE canvas edits before the agent runs so
128 - // the user doesn't lose their work to a workflow that touches
129 - // the same block. No-op when no QE canvas is mounted.
130 - window.dispatchEvent(
131 - new CustomEvent('extendify-quick-edit:agent-submit'),
132 - );
276 + async (message, { hidden = false } = {}) => {
277 + // Suggestions reach the agent without the textarea; disabling it isn't enough.
278 + if (useQuickEditStore.getState().selected) return;
133 279 setWaitingOnToolOrUser(false);
134 280 agentWorking.current = false;
135 - addMessage('message', { role: 'user', content: message });
281 + addMessage('message', { role: 'user', content: message, hidden });
136 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 +
137 300 // Let some phrases auto load workflows
138 301 const bypass = getWorkflowByExample(message);
139 - 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 + }
140 308
141 309 setCanType(false);
142 310 // If they typed while waiting on a redirect, reset the workflow
143 311 const redirect = workflow?.needsRedirect?.();
@@ -154,13 +322,25 @@
154 322 mergeWorkflowData(wfData);
155 323 return;
156 324 }
157 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 +
158 334 await findAgent().catch((e) => devmode && console.error(e));
159 335 },
160 336 [
161 337 addMessage,
338 + block,
339 + canvasAssist,
340 + canvasOpen,
162 341 findAgent,
342 + handleCanvasMessage,
163 343 mergeWorkflowData,
164 344 whenFinishedToolProps,
165 345 setWorkflow,
166 346 workflow,
@@ -165,8 +345,9 @@
165 345 setWorkflow,
166 346 workflow,
167 347 workflowData,
168 348 getAvailableWorkflows,
349 + getSuggestions,
169 350 ],
170 351 );
171 352
172 353 // Used to inject a workflow final state
@@ -176,9 +357,9 @@
176 357 if (!agentResponse) return;
177 358 setWorkflow(workflow);
178 359 setCanType(false);
179 360 agentWorking.current = true;
180 - await new Promise((resolve) => setTimeout(resolve, 750));
361 + if (await canceledDuring(750)) return;
181 362 addMessage('message', {
182 363 role: 'assistant',
183 364 content: agentResponse.reply,
184 365 });
@@ -192,22 +373,60 @@
192 373 value: { workflow: workflow?.id },
193 374 });
194 375 }, []);
195 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 +
196 403 useEffect(() => {
197 404 // Allow external messages to trigger the agent
198 405 const handleMessage = ({ detail }) => {
199 406 if (!detail?.message) return;
200 - handleSubmit(detail.message);
407 + handleSubmit(detail.message, { hidden: detail.hidden });
201 408 };
202 409 // Allow external code to clear the block and workflow
203 410 const handleCleanup = () => {
411 + // cleanup() resets canType and agentWorking, so read them before it runs.
412 + const interrupted = agentWorking.current || !canType;
204 413 controller.abort('Workflow aborted');
205 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 + );
206 419
207 - if (!workflow?.id) return;
420 + // An options panel can outlive its workflow; cancel must still clear it.
421 + if (!workflow?.id && !qaSuggestions && !interrupted) return;
208 422 setWorkflow(null);
209 - addMessage('status', { type: 'workflow-canceled' });
423 + addMessage('workflow', {
424 + status: 'canceled',
425 + agent: workflow?.agent,
426 + workflowId: workflow?.id,
427 + suggestions: getSuggestions(),
428 + });
210 429 return;
211 430 };
212 431 window.addEventListener('extendify-agent:cancel-workflow', handleCleanup);
213 432 window.addEventListener('extendify-agent:chat-submit', handleMessage);
@@ -217,16 +436,34 @@
217 436 handleCleanup,
218 437 );
219 438 window.removeEventListener('extendify-agent:chat-submit', handleMessage);
220 439 };
221 - }, [handleSubmit, cleanup, setWorkflow, addMessage, workflow]);
440 + }, [
441 + handleSubmit,
442 + cleanup,
443 + setWorkflow,
444 + addMessage,
445 + workflow,
446 + qaSuggestions,
447 + getSuggestions,
448 + canType,
449 + ]);
222 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 +
223 461 // Handle whenFinished component confirm/cancel
224 462 useEffect(() => {
225 463 const handleConfirm = async ({ detail }) => {
226 464 if (toolWorking.current) return;
227 465 setWhenFinishedToolProps(null);
228 - addMessage('status', { type: 'workflow-tool-processing' });
229 466 toolWorking.current = true;
230 467 const { data, whenFinishedToolProps, shouldRefreshPage, redirectUrl } =
231 468 detail ?? {};
232 469 const { whenFinishedTool, answerId, redirectTo } =
@@ -231,8 +468,11 @@
231 468 detail ?? {};
232 469 const { whenFinishedTool, answerId, redirectTo } =
233 470 whenFinishedToolProps?.agentResponse || {};
234 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');
235 475 // Not all workflows have a tool at the end (e.g. tours)
236 476 const toolResponse = await callTool?.({ tool: id, inputs: data }).catch(
237 477 (error) => {
238 478 const { sessionId } = workflow || {};
@@ -243,22 +483,32 @@
243 483 caller: `when-finished: ${id}`,
244 484 sessionId,
245 485 },
246 486 });
247 - devmode && console.error(error);
248 - return { error: error.message };
487 + console.error(`Extendify agent tool error: ${id}`, { siteId, error });
488 + return { error: { message: error?.message, code: error?.code } };
249 489 },
250 490 );
251 491 toolWorking.current = false;
252 - if (toolResponse?.error) {
492 + if (runMessageId) updateMessage(runMessageId, { result: toolResponse });
493 + if (toolResponse?.refused) {
253 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 + };
254 502 addMessage('message', {
255 503 role: 'assistant',
256 - // translators: This message is shown when the AI agent fails to confirm an action.
257 - content: __(
258 - 'Sorry, something went wrong attempting to call the tool. Please try again.',
259 - 'extendify-local',
260 - ),
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 + ),
261 511 error: true,
262 512 });
263 513 setWorkflow(null);
264 514 cleanup();
@@ -263,48 +513,69 @@
263 513 setWorkflow(null);
264 514 cleanup();
265 515 return;
266 516 }
267 - addMessage('status', {
268 - label: labels?.confirm,
269 - type: 'workflow-tool-completed',
270 - });
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 +
271 526 addSuggestions(whenFinishedToolProps.agentResponse?.recommendations);
272 527 addMessage('workflow', {
273 528 status: 'completed',
529 + label: labels?.confirm,
274 530 agent: workflow.agent,
531 + workflowId: workflow.id,
275 532 answerId,
276 533 suggestions: getSuggestions(),
277 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 + }
278 552 setWorkflow(null);
553 + useCanvasStore.getState().endSession();
279 554
280 555 const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
281 -
282 - if (url || redirectUrl || shouldRefreshPage) {
283 - await new Promise((resolve) => setTimeout(resolve, 1000));
556 + const refreshForAbility = isAbilityTool(id);
557 + if (url || redirectUrl || shouldRefreshPage || refreshForAbility) {
558 + return doReload(url || redirectUrl);
284 559 }
285 -
286 - if (url) return window.location.assign(url);
287 - if (redirectUrl) return window.location.assign(redirectUrl);
288 - if (shouldRefreshPage) return window.location.reload();
289 - // Clean up if not redirecting
290 560 cleanup();
291 561 };
292 562 const handleCancel = ({ detail }) => {
293 563 if (toolWorking.current) return;
564 + // Without this a canvas closed mid-turn keeps replying into the chat.
565 + controller.abort('Workflow aborted');
294 566 const { answerId, whenFinishedTool } =
295 567 detail.whenFinishedToolProps?.agentResponse || {};
296 - addMessage('status', {
297 - type: 'workflow-canceled',
298 - label: whenFinishedTool?.labels?.cancel,
299 - });
300 568 addMessage('workflow', {
301 569 status: 'canceled',
570 + label: whenFinishedTool?.labels?.cancel,
302 571 agent: workflow.agent,
572 + workflowId: workflow.id,
303 573 answerId,
304 574 suggestions: getSuggestions(),
305 575 });
306 576 setWorkflow(null);
577 + useCanvasStore.getState().endSession();
307 578 cleanup();
308 579 };
309 580 const handleRetry = () => {
310 581 popMessage();
@@ -328,8 +599,9 @@
328 599 window.removeEventListener('extendify-agent:workflow-retry', handleRetry);
329 600 };
330 601 }, [
331 602 addMessage,
603 + pushStatus,
332 604 popMessage,
333 605 cleanup,
334 606 setWorkflow,
335 607 workflow,
@@ -368,8 +640,9 @@
368 640 if (cancelWorkflow) {
369 641 addMessage('workflow', {
370 642 status: 'canceled',
371 643 agent: workflow.agent,
644 + workflowId: workflow.id,
372 645 suggestions: getSuggestions(),
373 646 });
374 647 setWorkflow(null);
375 648 cleanup();
@@ -386,20 +659,16 @@
386 659 if (agentWorking.current) return; // Prevent multiple calls
387 660 if (toolWorking.current) return;
388 661 setCanType(false);
389 662 agentWorking.current = true;
390 - addMessage('status', { type: 'agent-working' });
663 + pushStatus('agent-working');
391 664 const agentResponse = await handleWorkflow({
392 665 workflow,
393 666 workflowData,
394 667 options: { signal: controller.signal, retry: retrying.current },
395 668 }).catch((error) => {
396 - if (error === 'Workflow aborted') {
397 - addMessage('status', { type: 'workflow-canceled' });
398 - setWorkflow(null);
399 - cleanup();
400 - return;
401 - }
669 + // handleCleanup already added the canceled message
670 + if (error === 'Workflow aborted') return;
402 671 const { sessionId } = workflow || {};
403 672 digest({
404 673 error,
405 674 details: { source: 'agent', caller: `handle-workflow`, sessionId },
@@ -418,14 +687,20 @@
418 687 window.extAgentData.failedWorkflows.add(workflow.id);
419 688 throw new Error(`Error handling workflow: ${agentResponse.error}`);
420 689 }
421 690 // The ai sent back some text to show to the user
422 - if (agentResponse.reply) {
691 + const reply =
692 + agentResponse.reply ??
693 + (agentResponse.tool
694 + ? getClientToolFallbackReply(agentResponse.tool.id)
695 + : null);
696 + if (reply) {
423 697 addMessage('message', {
424 698 role: 'assistant',
425 - content: agentResponse.reply,
699 + content: reply,
426 700 followup: !!agentResponse.tool,
427 701 pageSuggestion: agentResponse.pageSuggestion,
702 + qaSuggestions: agentResponse.qaSuggestions,
428 703 agent: workflow.agent,
429 704 sessionId: workflow?.sessionId,
430 705 workflowId: workflow?.id,
431 706 language: workflow?.language,
@@ -430,8 +705,26 @@
430 705 workflowId: workflow?.id,
431 706 language: workflow?.language,
432 707 });
433 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 + }
434 727 // This is at the end of the workflow
435 728 // and we are about to execute the final tool
436 729 if (agentResponse.whenFinishedTool?.id) {
437 730 setWhenFinishedToolProps({
@@ -439,15 +732,22 @@
439 732 agentResponse,
440 733 });
441 734 // If static, add it as a message
442 735 const { id, inputs, static: staticC } = agentResponse.whenFinishedTool;
443 - if (staticC) {
444 - 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 + });
445 744 addSuggestions(agentResponse.recommendations);
446 745 setWorkflow(null);
447 746 addMessage('workflow', {
448 747 status: 'completed',
449 748 agent: workflow.agent,
749 + workflowId: workflow.id,
450 750 answerId,
451 751 suggestions: getSuggestions(),
452 752 });
453 753 cleanup();
@@ -463,8 +763,9 @@
463 763 cleanup();
464 764 addMessage('workflow', {
465 765 status: isCompleted ? 'completed' : 'canceled',
466 766 agent: workflow.agent,
767 + workflowId: workflow.id,
467 768 answerId,
468 769 suggestions: getSuggestions(),
469 770 });
470 771 return;
@@ -477,9 +778,18 @@
477 778 mergeWorkflowData(agentResponse.inputs);
478 779 // Agent needs more info from a
479 780 if (agentResponse.tool) {
480 781 const { id, inputs, labels } = agentResponse.tool;
481 - 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);
482 792 const toolData = await Promise.all([
483 793 callTool({ tool: id, inputs }),
484 794 new Promise((resolve) => setTimeout(resolve, 3000)),
485 795 ])
@@ -493,17 +803,26 @@
493 803 caller: `in-progress: ${id}`,
494 804 sessionId,
495 805 },
496 806 });
497 - devmode && console.error(error);
498 - 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 } };
499 813 });
500 - 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,
501 823 label: labels?.confirm,
502 - type: 'tool-completed',
503 824 });
504 - await new Promise((resolve) => setTimeout(resolve, 1000));
505 - mergeWorkflowData(toolData);
506 825 setWaitingOnToolOrUser(false);
507 826 agentWorking.current = false;
508 827 setLoop((prev) => prev + 1); // Trigger next loop
509 828 return;
@@ -536,16 +855,20 @@
536 855 open,
537 856 workflow,
538 857 workflowData,
539 858 addMessage,
859 + pushStatus,
540 860 setWorkflow,
541 861 agentWorking,
542 862 waitingOnToolOrUser,
543 863 mergeWorkflowData,
864 + requireBlock,
544 865 canType,
545 866 whenFinishedToolProps,
546 867 setWhenFinishedToolProps,
547 868 block,
869 + setBlock,
870 + findAgent,
548 871 addSuggestions,
549 872 getSuggestions,
550 873 ]);
551 874
@@ -554,40 +877,46 @@
554 877 document.querySelector('#extendify-agent-chat-textarea')?.focus();
555 878 }, [canType]);
556 879
557 880 const busy = !canType || !chatAvailable || workflow?.id;
881 + // `busy` is true at rest; 429 is blocked, not working.
882 + const working = !canType && chatAvailable;
558 883
559 884 return (
560 - <Chat busy={busy}>
561 - <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
562 - <ChatMessages
563 - redirectComponent={
564 - workflow?.needsRedirect?.() ? workflow.redirectComponent : null
565 - }
566 - />
567 - <div>
568 - <div className="relative flex flex-col px-4 pb-2 pt-2.5 shadow-lg-flipped">
569 - {block ? <PageDocument busy={busy} blockId={block.id} /> : null}
570 - <UsageMessage
571 - onReady={() => {
572 - cleanup();
573 - addMessage('status', { type: 'credits-restored' });
574 - }}
575 - />
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>
576 917 </div>
577 - <div className="p-4 pb-2 pt-0">
578 - <ChatInput
579 - disabled={!canType || !chatAvailable}
580 - handleSubmit={handleSubmit}
581 - />
582 - </div>
583 - <div className="text-pretty px-4 pb-2 text-center text-xss leading-none text-gray-700">
584 - {__(
585 - 'AI Agent can make mistakes. Check changes before saving.',
586 - 'extendify-local',
587 - )}
588 - </div>
589 918 </div>
590 - </div>
591 - </Chat>
919 + </Chat>
920 + </>
592 921 );
593 922 };