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 +403 -114 3.1.2 → trunk View file →
@@ -1,26 +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';
13 19 import {
14 - abilityAffectsCurrentPage,
15 - isAbilityWorkflow,
16 -} from '@agent/lib/abilities';
20 + getClientToolFallbackReply,
21 + getClientTools,
22 +} from '@agent/lib/client-tools';
23 +import { localPickWorkflow } from '@agent/lib/local-pick';
17 24 import { getRedirectUrl } from '@agent/lib/redirects';
25 +import { doReload } from '@agent/lib/reload';
26 +import { useCanvasStore } from '@agent/state/canvas';
18 27 import { useChatStore } from '@agent/state/chat';
19 28 import { useGlobalStore } from '@agent/state/global';
20 29 import { useStatusStore } from '@agent/state/status';
21 30 import { useSuggestionsStore } from '@agent/state/suggestions';
22 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';
23 34 import { useQuickEditStore } from '@quick-edit/state/store';
24 35 import { digest } from '@shared/api/digest';
25 36 import {
26 37 useCallback,
@@ -28,18 +39,38 @@
28 39 useMemo,
29 40 useRef,
30 41 useState,
31 42 } from '@wordpress/element';
32 -import { __ } from '@wordpress/i18n';
43 +import { __, _n, sprintf } from '@wordpress/i18n';
33 44
34 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;
35 49 // Used to abort when wf canceled - reset in cleanup()
36 50 let controller = new AbortController();
37 51 const { postId } = window?.extAgentData?.context || {};
38 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 +
39 70 export const Agent = () => {
40 - const { addMessage, popMessage } = useChatStore();
41 - const { pushStatus, clearStatuses } = useStatusStore();
71 + const { addMessage, updateMessage, popMessage, messages } = useChatStore();
72 + const { pushStatus, clearStatuses, leavingPage } = useStatusStore();
42 73 const {
43 74 mergeWorkflowData,
44 75 getWorkflow,
45 76 getWorkflowByExample,
@@ -47,12 +78,12 @@
47 78 setWorkflow,
48 79 setWhenFinishedToolProps,
49 80 whenFinishedToolProps,
50 81 getAvailableWorkflows,
82 + requireBlock,
51 83 } = useWorkflowStore();
52 84 const block = useQuickEditStore((s) => s.agentBlock);
53 85 const setBlock = useQuickEditStore((s) => s.setAgentBlock);
54 - const workflowIds = getAvailableWorkflows().map((w) => w.id);
55 86 const { open, setOpen, updateRetryAfter, isChatAvailable } = useGlobalStore();
56 87 useLockPost({ postId, enabled: !!open });
57 88 const [canType, setCanType] = useState(true);
58 89 const agentWorking = useRef(false);
@@ -57,68 +88,104 @@
57 88 const [canType, setCanType] = useState(true);
58 89 const agentWorking = useRef(false);
59 90 const toolWorking = useRef(false);
60 91 const retrying = useRef(false);
61 - 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 + });
62 98 const [loop, setLoop] = useState(0);
63 99 const workflow = getWorkflow();
100 + const canvasOpen = useCanvasOpen();
101 + const canvasAssist = useCanvasAssist();
102 + const canvasNoticeShown = useRef(false);
64 103 const chatAvailable = useMemo(() => isChatAvailable(), [isChatAvailable]);
65 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;
66 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 +
67 121 const cleanup = useCallback(() => {
68 122 setCanType(true);
69 123 agentWorking.current = false;
70 124 setWaitingOnToolOrUser(false);
71 125 controller = new AbortController();
72 - 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);
73 133 clearStatuses();
74 134 window.dispatchEvent(new Event('extendify-agent:remove-block-highlight'));
75 - // scrollIntoView below walks up and scrolls the page itself,
76 - // fighting useLayoutShift's scroll restore when closing.
77 - if (!useGlobalStore.getState().open) return;
78 - const c = Array.from(
79 - document.querySelectorAll(
80 - '#extendify-agent-chat-scroll-area div:last-child',
81 - ),
82 - )?.at(-1);
83 - c?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
84 - c?.scrollBy({ top: -5, behavior: 'smooth' });
85 - }, [setBlock, block, clearStatuses]);
135 + }, [setBlock, clearStatuses]);
86 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 +
87 152 const findAgent = useCallback(
88 153 async (options = {}) => {
89 154 pushStatus('calling-agent');
90 - const response = await pickWorkflow({
91 - workflows: workflowIds,
92 - options: { signal: controller.signal, ...options },
93 - }).catch(async (error) => {
94 - devmode && console.error(error);
95 - if (error?.response?.status === 429) {
96 - updateRetryAfter(error?.response?.headers?.get('Retry-After'));
97 - setCanType(false);
98 - pushStatus('credits-exhausted');
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 + });
99 182 return;
100 - }
101 - setCanType(true);
102 - if (error === 'Workflow aborted') {
103 - addMessage('workflow', { status: 'canceled' });
104 - return;
105 - }
183 + }),
184 + 500,
185 + );
186 + if (!response || signal.aborted) return;
106 187
107 - await new Promise((resolve) => setTimeout(resolve, 1000));
108 - addMessage('message', {
109 - role: 'assistant',
110 - // translators: This message is shown when the AI agent fails to find a suitable workflow.
111 - content: __(
112 - 'Something went wrong while trying to start this request. Please try again.',
113 - 'extendify-local',
114 - ),
115 - error: true,
116 - });
117 - return;
118 - });
119 - if (!response) return;
120 -
121 188 const { workflow: wf, reply } = response;
122 189 if (wf?.id) setWorkflow(wf);
123 190 if (reply) {
124 191 const data = { role: 'assistant', content: reply, agent: wf?.agent };
@@ -125,23 +192,112 @@
125 192 addMessage('message', data);
126 193 }
127 194 if (!wf?.id) setCanType(true);
128 195 },
129 - [addMessage, pushStatus, updateRetryAfter, setWorkflow, workflowIds],
196 + [
197 + addMessage,
198 + pushStatus,
199 + updateRetryAfter,
200 + setWorkflow,
201 + getAvailableWorkflows,
202 + ],
130 203 );
131 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 +
132 275 const handleSubmit = useCallback(
133 - async (message) => {
134 - // Save any in-flight QE canvas edits before the agent runs so
135 - // the user doesn't lose their work to a workflow that touches
136 - // the same block. No-op when no QE canvas is mounted.
137 - window.dispatchEvent(
138 - new CustomEvent('extendify-quick-edit:agent-submit'),
139 - );
276 + async (message, { hidden = false } = {}) => {
277 + // Suggestions reach the agent without the textarea; disabling it isn't enough.
278 + if (useQuickEditStore.getState().selected) return;
140 279 setWaitingOnToolOrUser(false);
141 280 agentWorking.current = false;
142 - addMessage('message', { role: 'user', content: message });
281 + addMessage('message', { role: 'user', content: message, hidden });
143 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 +
144 300 // Let some phrases auto load workflows
145 301 const bypass = getWorkflowByExample(message);
146 302 if (bypass?.example?.agentResponse) {
147 303 // whenFinishedTool → inline input UI; none → ask for a typed reply.
@@ -166,13 +322,25 @@
166 322 mergeWorkflowData(wfData);
167 323 return;
168 324 }
169 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 +
170 334 await findAgent().catch((e) => devmode && console.error(e));
171 335 },
172 336 [
173 337 addMessage,
338 + block,
339 + canvasAssist,
340 + canvasOpen,
174 341 findAgent,
342 + handleCanvasMessage,
175 343 mergeWorkflowData,
176 344 whenFinishedToolProps,
177 345 setWorkflow,
178 346 workflow,
@@ -177,8 +345,9 @@
177 345 setWorkflow,
178 346 workflow,
179 347 workflowData,
180 348 getAvailableWorkflows,
349 + getSuggestions,
181 350 ],
182 351 );
183 352
184 353 // Used to inject a workflow final state
@@ -188,9 +357,9 @@
188 357 if (!agentResponse) return;
189 358 setWorkflow(workflow);
190 359 setCanType(false);
191 360 agentWorking.current = true;
192 - await new Promise((resolve) => setTimeout(resolve, 750));
361 + if (await canceledDuring(750)) return;
193 362 addMessage('message', {
194 363 role: 'assistant',
195 364 content: agentResponse.reply,
196 365 });
@@ -214,12 +383,14 @@
214 383 // Without this the loop calls the backend before the user has typed.
215 384 setWaitingOnToolOrUser(true);
216 385 setCanType(false);
217 386 agentWorking.current = true;
218 - await new Promise((resolve) => setTimeout(resolve, 750));
387 + if (await canceledDuring(750)) return;
219 388 addMessage('message', {
220 389 role: 'assistant',
221 390 content: agentResponse.reply,
391 + // A workflow example can carry its own suggestions; none still asks.
392 + qaSuggestions: agentResponse.qaSuggestions ?? [],
222 393 });
223 394 agentWorking.current = false;
224 395 setCanType(true);
225 396 recordAgentActivity({
@@ -232,21 +403,29 @@
232 403 useEffect(() => {
233 404 // Allow external messages to trigger the agent
234 405 const handleMessage = ({ detail }) => {
235 406 if (!detail?.message) return;
236 - handleSubmit(detail.message);
407 + handleSubmit(detail.message, { hidden: detail.hidden });
237 408 };
238 409 // Allow external code to clear the block and workflow
239 410 const handleCleanup = () => {
411 + // cleanup() resets canType and agentWorking, so read them before it runs.
412 + const interrupted = agentWorking.current || !canType;
240 413 controller.abort('Workflow aborted');
241 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 + );
242 419
243 - if (!workflow?.id) return;
420 + // An options panel can outlive its workflow; cancel must still clear it.
421 + if (!workflow?.id && !qaSuggestions && !interrupted) return;
244 422 setWorkflow(null);
245 423 addMessage('workflow', {
246 424 status: 'canceled',
247 - agent: workflow.agent,
248 - workflowId: workflow.id,
425 + agent: workflow?.agent,
426 + workflowId: workflow?.id,
427 + suggestions: getSuggestions(),
249 428 });
250 429 return;
251 430 };
252 431 window.addEventListener('extendify-agent:cancel-workflow', handleCleanup);
@@ -257,16 +436,34 @@
257 436 handleCleanup,
258 437 );
259 438 window.removeEventListener('extendify-agent:chat-submit', handleMessage);
260 439 };
261 - }, [handleSubmit, cleanup, setWorkflow, addMessage, workflow]);
440 + }, [
441 + handleSubmit,
442 + cleanup,
443 + setWorkflow,
444 + addMessage,
445 + workflow,
446 + qaSuggestions,
447 + getSuggestions,
448 + canType,
449 + ]);
262 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 +
263 461 // Handle whenFinished component confirm/cancel
264 462 useEffect(() => {
265 463 const handleConfirm = async ({ detail }) => {
266 464 if (toolWorking.current) return;
267 465 setWhenFinishedToolProps(null);
268 - pushStatus('workflow-tool-processing');
269 466 toolWorking.current = true;
270 467 const { data, whenFinishedToolProps, shouldRefreshPage, redirectUrl } =
271 468 detail ?? {};
272 469 const { whenFinishedTool, answerId, redirectTo } =
@@ -271,8 +468,11 @@
271 468 detail ?? {};
272 469 const { whenFinishedTool, answerId, redirectTo } =
273 470 whenFinishedToolProps?.agentResponse || {};
274 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');
275 475 // Not all workflows have a tool at the end (e.g. tours)
276 476 const toolResponse = await callTool?.({ tool: id, inputs: data }).catch(
277 477 (error) => {
278 478 const { sessionId } = workflow || {};
@@ -283,17 +483,41 @@
283 483 caller: `when-finished: ${id}`,
284 484 sessionId,
285 485 },
286 486 });
287 - devmode && console.error(error);
487 + console.error(`Extendify agent tool error: ${id}`, { siteId, error });
288 488 return { error: { message: error?.message, code: error?.code } };
289 489 },
290 490 );
291 491 toolWorking.current = false;
492 + if (runMessageId) updateMessage(runMessageId, { result: toolResponse });
493 + if (toolResponse?.refused) {
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 + };
502 + addMessage('message', {
503 + role: 'assistant',
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 + ),
511 + error: true,
512 + });
513 + setWorkflow(null);
514 + cleanup();
515 + return;
516 + }
292 517 // Only loop back on error, so the model can recover. A clean
293 518 // whenFinished tool means the workflow is done.
294 519 if (toolResponse?.error) {
295 - addMessage('tool', { id, inputs: data, result: toolResponse });
296 520 setWaitingOnToolOrUser(false);
297 521 agentWorking.current = false;
298 522 setLoop((prev) => prev + 1);
299 523 return;
@@ -298,10 +522,8 @@
298 522 setLoop((prev) => prev + 1);
299 523 return;
300 524 }
301 525
302 - // Later runs of this workflow need the result (e.g. created ids).
303 - if (id) addMessage('tool', { id, inputs: data, result: toolResponse });
304 526 addSuggestions(whenFinishedToolProps.agentResponse?.recommendations);
305 527 addMessage('workflow', {
306 528 status: 'completed',
307 529 label: labels?.confirm,
@@ -309,23 +531,39 @@
309 531 workflowId: workflow.id,
310 532 answerId,
311 533 suggestions: getSuggestions(),
312 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 + }
313 552 setWorkflow(null);
553 + useCanvasStore.getState().endSession();
314 554
315 555 const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
316 - const refreshForAbility = abilityAffectsCurrentPage(id, data);
556 + const refreshForAbility = isAbilityTool(id);
317 557 if (url || redirectUrl || shouldRefreshPage || refreshForAbility) {
318 - await new Promise((resolve) => setTimeout(resolve, 1000));
558 + return doReload(url || redirectUrl);
319 559 }
320 - if (url) return window.location.assign(url);
321 - if (redirectUrl) return window.location.assign(redirectUrl);
322 - if (shouldRefreshPage || refreshForAbility)
323 - return window.location.reload();
324 560 cleanup();
325 561 };
326 562 const handleCancel = ({ detail }) => {
327 563 if (toolWorking.current) return;
564 + // Without this a canvas closed mid-turn keeps replying into the chat.
565 + controller.abort('Workflow aborted');
328 566 const { answerId, whenFinishedTool } =
329 567 detail.whenFinishedToolProps?.agentResponse || {};
330 568 addMessage('workflow', {
331 569 status: 'canceled',
@@ -335,8 +573,9 @@
335 573 answerId,
336 574 suggestions: getSuggestions(),
337 575 });
338 576 setWorkflow(null);
577 + useCanvasStore.getState().endSession();
339 578 cleanup();
340 579 };
341 580 const handleRetry = () => {
342 581 popMessage();
@@ -448,14 +687,20 @@
448 687 window.extAgentData.failedWorkflows.add(workflow.id);
449 688 throw new Error(`Error handling workflow: ${agentResponse.error}`);
450 689 }
451 690 // The ai sent back some text to show to the user
452 - if (agentResponse.reply) {
691 + const reply =
692 + agentResponse.reply ??
693 + (agentResponse.tool
694 + ? getClientToolFallbackReply(agentResponse.tool.id)
695 + : null);
696 + if (reply) {
453 697 addMessage('message', {
454 698 role: 'assistant',
455 - content: agentResponse.reply,
699 + content: reply,
456 700 followup: !!agentResponse.tool,
457 701 pageSuggestion: agentResponse.pageSuggestion,
702 + qaSuggestions: agentResponse.qaSuggestions,
458 703 agent: workflow.agent,
459 704 sessionId: workflow?.sessionId,
460 705 workflowId: workflow?.id,
461 706 language: workflow?.language,
@@ -460,8 +705,26 @@
460 705 workflowId: workflow?.id,
461 706 language: workflow?.language,
462 707 });
463 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 + }
464 727 // This is at the end of the workflow
465 728 // and we are about to execute the final tool
466 729 if (agentResponse.whenFinishedTool?.id) {
467 730 setWhenFinishedToolProps({
@@ -469,9 +732,10 @@
469 732 agentResponse,
470 733 });
471 734 // If static, add it as a message
472 735 const { id, inputs, static: staticC } = agentResponse.whenFinishedTool;
473 - if (staticC) {
736 + // A canvas workflow renders in the canvas and ends on close or submit.
737 + if (staticC && !workflow.whenFinished?.canvas) {
474 738 addMessage('workflow-component', {
475 739 id,
476 740 status: 'completed',
477 741 inputs,
@@ -514,8 +778,17 @@
514 778 mergeWorkflowData(agentResponse.inputs);
515 779 // Agent needs more info from a
516 780 if (agentResponse.tool) {
517 781 const { id, inputs, labels } = agentResponse.tool;
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 + }
518 791 pushStatus('tool-started', labels?.started);
519 792 const toolData = await Promise.all([
520 793 callTool({ tool: id, inputs }),
521 794 new Promise((resolve) => setTimeout(resolve, 3000)),
@@ -530,19 +803,26 @@
530 803 caller: `in-progress: ${id}`,
531 804 sessionId,
532 805 },
533 806 });
534 - devmode && console.error(error);
807 + console.error(`Extendify agent tool error: ${id}`, {
808 + siteId,
809 + error,
810 + });
535 811 // Don't throw; the loop hands the error to the model.
536 812 return { error: { message: error?.message, code: error?.code } };
537 813 });
538 - pushStatus('tool-completed', labels?.confirm);
539 - await new Promise((resolve) => setTimeout(resolve, 1000));
540 814 // do-when-finished spreads first-class workflowData into the tool.
541 815 if (!toolData?.error && !isAbilityWorkflow(workflow.id)) {
542 816 mergeWorkflowData(toolData);
543 817 }
544 - addMessage('tool', { id, inputs, result: toolData });
818 + if (toolData?.stagedBlockIds?.length) requireBlock();
819 + addMessage('tool', {
820 + id,
821 + inputs,
822 + result: toolData,
823 + label: labels?.confirm,
824 + });
545 825 setWaitingOnToolOrUser(false);
546 826 agentWorking.current = false;
547 827 setLoop((prev) => prev + 1); // Trigger next loop
548 828 return;
@@ -580,12 +860,15 @@
580 860 setWorkflow,
581 861 agentWorking,
582 862 waitingOnToolOrUser,
583 863 mergeWorkflowData,
864 + requireBlock,
584 865 canType,
585 866 whenFinishedToolProps,
586 867 setWhenFinishedToolProps,
587 868 block,
869 + setBlock,
870 + findAgent,
588 871 addSuggestions,
589 872 getSuggestions,
590 873 ]);
591 874
@@ -594,40 +877,46 @@
594 877 document.querySelector('#extendify-agent-chat-textarea')?.focus();
595 878 }, [canType]);
596 879
597 880 const busy = !canType || !chatAvailable || workflow?.id;
881 + // `busy` is true at rest; 429 is blocked, not working.
882 + const working = !canType && chatAvailable;
598 883
599 884 return (
600 - <Chat busy={busy}>
601 - <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
602 - <ChatMessages
603 - redirectComponent={
604 - workflow?.needsRedirect?.() ? workflow.redirectComponent : null
605 - }
606 - />
607 - <div>
608 - <div className="relative flex flex-col px-4 pb-2 pt-2.5 shadow-lg-flipped">
609 - {block ? <PageDocument busy={busy} blockId={block.id} /> : null}
610 - <UsageMessage
611 - onReady={() => {
612 - cleanup();
613 - pushStatus('credits-restored');
614 - }}
615 - />
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>
616 917 </div>
617 - <div className="p-4 pb-2 pt-0">
618 - <ChatInput
619 - disabled={!canType || !chatAvailable}
620 - handleSubmit={handleSubmit}
621 - />
622 - </div>
623 - <div className="text-pretty px-4 pb-2 text-center text-xss leading-none text-gray-700">
624 - {__(
625 - 'AI Agent can make mistakes. Check changes before saving.',
626 - 'extendify-local',
627 - )}
628 - </div>
629 918 </div>
630 - </div>
631 - </Chat>
919 + </Chat>
920 + </>
632 921 );
633 922 };