PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
← All changes | src/Agent/Agent.jsx +162 -63 3.1.6 → 3.2.2 View file →
@@ -12,9 +12,16 @@
12 12 useCanvasOpen,
13 13 } from '@agent/components/Canvas';
14 14 import { ChatInput } from '@agent/components/ChatInput';
15 15 import { ChatMessages } from '@agent/components/ChatMessages';
16 +import { StatusIndicator } from '@agent/components/messages/StatusIndicator';
16 17 import { UsageMessage } from '@agent/components/messages/UsageMessage';
18 +import { useFollowUpHistory } from '@agent/follow-ups/history';
19 +import {
20 + cardMessageIndex,
21 + pickNextCard,
22 + toolCallsThisRun,
23 +} from '@agent/follow-ups/pick-next';
17 24 import { useLockPost } from '@agent/hooks/useLockPost';
18 25 import { isAbilityTool, isAbilityWorkflow } from '@agent/lib/abilities';
19 26 import {
20 27 getClientToolFallbackReply,
@@ -26,11 +33,11 @@
26 33 import { useCanvasStore } from '@agent/state/canvas';
27 34 import { useChatStore } from '@agent/state/chat';
28 35 import { useGlobalStore } from '@agent/state/global';
29 36 import { useStatusStore } from '@agent/state/status';
30 -import { useSuggestionsStore } from '@agent/state/suggestions';
31 37 import { useWorkflowStore } from '@agent/state/workflows';
32 38 import { hasRunComponent } from '@agent/workflows/abilities/components/run';
39 +import startOnboardingWorkflow from '@agent/workflows/misc/start-onboarding';
33 40 import { useQuickEditStore } from '@quick-edit/state/store';
34 41 import { digest } from '@shared/api/digest';
35 42 import {
36 43 useCallback,
@@ -58,8 +65,25 @@
58 65 ]);
59 66 return result;
60 67 };
61 68
69 +// cleanup() swaps in a fresh controller; a signal read after the wait is never aborted.
70 +const canceledDuring = async (ms) => {
71 + const { signal } = controller;
72 + await new Promise((resolve) => setTimeout(resolve, ms));
73 + return signal.aborted;
74 +};
75 +
76 +const nextSteps = (workflowId, status, messages) => {
77 + if (status === 'completed') {
78 + useFollowUpHistory.getState().markDone(workflowId);
79 + }
80 + const card = pickNextCard({ workflowId, status, messages });
81 + return card
82 + ? { followUp: card.id, followUpData: card.data }
83 + : { followUp: null };
84 +};
85 +
62 86 export const Agent = () => {
63 87 const { addMessage, updateMessage, popMessage, messages } = useChatStore();
64 88 const { pushStatus, clearStatuses, leavingPage } = useStatusStore();
65 89 const {
@@ -92,9 +116,8 @@
92 116 const canvasOpen = useCanvasOpen();
93 117 const canvasAssist = useCanvasAssist();
94 118 const canvasNoticeShown = useRef(false);
95 119 const chatAvailable = useMemo(() => isChatAvailable(), [isChatAvailable]);
96 - const { addSuggestions, getSuggestions } = useSuggestionsStore();
97 120 // Options render only while their message is last; a reply dismisses them.
98 121 const lastMessage = messages.at(-1);
99 122 const qaSuggestions =
100 123 lastMessage?.type === 'message' &&
@@ -102,8 +125,18 @@
102 125 Array.isArray(lastMessage.details?.qaSuggestions)
103 126 ? lastMessage.details.qaSuggestions
104 127 : null;
105 128
129 + useEffect(() => {
130 + const { messages, updateMessage } = useChatStore.getState();
131 + const index = cardMessageIndex(messages);
132 + const { id, details } = messages[index] ?? {};
133 + if (!id || details.followUp !== undefined || details.followUpClosed) return;
134 + const { workflowId, status } = details;
135 + const steps = nextSteps(workflowId, status, messages.slice(0, index));
136 + if (steps.followUp) updateMessage(id, steps);
137 + }, []);
138 +
106 139 // Without this the input stays disabled for as long as the canvas is open.
107 140 useEffect(() => {
108 141 if (canvasOpen && canvasAssist) setCanType(true);
109 142 // A workflow reached by example carries no sessionId to key this on.
@@ -119,9 +152,14 @@
119 152 agentBlock && setBlock(null);
120 153 // A class or pin left up freezes Quick Edit hover site-wide.
121 154 document
122 155 .querySelector('.wp-site-blocks')
123 - ?.classList.remove('extendify-agent-working', 'extendify-agent-busy');
156 + ?.classList.remove(
157 + 'extendify-agent-working',
158 + 'extendify-agent-busy',
159 + 'extendify-agent-waiting',
160 + 'extendify-agent-task',
161 + );
124 162 setCommittedSelection(null);
125 163 clearStatuses();
126 164 window.dispatchEvent(new Event('extendify-agent:remove-block-highlight'));
127 165 }, [setBlock, clearStatuses]);
@@ -143,13 +181,14 @@
143 181
144 182 const findAgent = useCallback(
145 183 async (options = {}) => {
146 184 pushStatus('calling-agent');
185 + const { signal } = controller;
147 186 const response = await withMinDuration(
148 187 pickWorkflow({
149 188 // A mid-turn drop clears the block after this closure was made.
150 189 workflows: getAvailableWorkflows().map((w) => w.id),
151 - options: { signal: controller.signal, ...options },
190 + options: { signal, ...options },
152 191 }).catch(async (error) => {
153 192 devmode && console.error(error);
154 193 if (error?.response?.status === 429) {
155 194 updateRetryAfter(error?.response?.headers?.get('Retry-After'));
@@ -157,15 +196,9 @@
157 196 pushStatus('credits-exhausted');
158 197 return;
159 198 }
160 199 setCanType(true);
161 - if (error === 'Workflow aborted') {
162 - addMessage('workflow', {
163 - status: 'canceled',
164 - suggestions: getSuggestions(),
165 - });
166 - return;
167 - }
200 + if (error === 'Workflow aborted') return;
168 201
169 202 await new Promise((resolve) => setTimeout(resolve, 1000));
170 203 addMessage('message', {
171 204 role: 'assistant',
@@ -179,9 +212,9 @@
179 212 return;
180 213 }),
181 214 500,
182 215 );
183 - if (!response) return;
216 + if (!response || signal.aborted) return;
184 217
185 218 const { workflow: wf, reply } = response;
186 219 if (wf?.id) setWorkflow(wf);
187 220 if (reply) {
@@ -195,9 +228,8 @@
195 228 pushStatus,
196 229 updateRetryAfter,
197 230 setWorkflow,
198 231 getAvailableWorkflows,
199 - getSuggestions,
200 232 ],
201 233 );
202 234
203 235 // The backend caps tool runs; nothing here bounds the loop.
@@ -270,18 +302,33 @@
270 302 setCanType(true);
271 303 }, [addMessage, pushStatus, whenFinishedToolProps, workflow]);
272 304
273 305 const handleSubmit = useCallback(
274 - async (message) => {
275 - // Suggestions reach the agent without the textarea; disabling it isn't enough.
306 + async (message, { hidden = false } = {}) => {
307 + // chat-submit events skip the textarea; disabling it isn't enough.
276 308 if (useQuickEditStore.getState().selected) return;
309 + if (whenFinishedToolProps?.processing) return;
277 310 setWaitingOnToolOrUser(false);
278 311 agentWorking.current = false;
279 - addMessage('message', { role: 'user', content: message });
312 + addMessage('message', { role: 'user', content: message, hidden });
280 313
281 314 // Without this a typed message would drop the workflow and close the canvas.
282 315 if (canvasOpen && canvasAssist) return handleCanvasMessage();
283 316
317 + // A staged tool the user walked away from still owes its receipt.
318 + if (workflow?.id && whenFinishedToolProps?.id) {
319 + const { answerId, whenFinishedTool } =
320 + whenFinishedToolProps.agentResponse || {};
321 + addMessage('workflow', {
322 + status: 'canceled',
323 + label: whenFinishedTool?.labels?.cancel,
324 + agent: workflow.agent,
325 + workflowId: workflow.id,
326 + answerId,
327 + followUp: null,
328 + });
329 + }
330 +
284 331 // Let some phrases auto load workflows
285 332 const bypass = getWorkflowByExample(message);
286 333 if (bypass?.example?.agentResponse) {
287 334 // whenFinishedTool → inline input UI; none → ask for a typed reply.
@@ -309,9 +356,9 @@
309 356
310 357 // Skip the network find-agent when the staged block makes the edit certain.
311 358 const localPick = localPickWorkflow({ block });
312 359 if (localPick) {
313 - await new Promise((resolve) => setTimeout(resolve, 500));
360 + if (await canceledDuring(500)) return;
314 361 setWorkflow(localPick);
315 362 return;
316 363 }
317 364
@@ -340,9 +387,9 @@
340 387 if (!agentResponse) return;
341 388 setWorkflow(workflow);
342 389 setCanType(false);
343 390 agentWorking.current = true;
344 - await new Promise((resolve) => setTimeout(resolve, 750));
391 + if (await canceledDuring(750)) return;
345 392 addMessage('message', {
346 393 role: 'assistant',
347 394 content: agentResponse.reply,
348 395 });
@@ -366,9 +413,9 @@
366 413 // Without this the loop calls the backend before the user has typed.
367 414 setWaitingOnToolOrUser(true);
368 415 setCanType(false);
369 416 agentWorking.current = true;
370 - await new Promise((resolve) => setTimeout(resolve, 750));
417 + if (await canceledDuring(750)) return;
371 418 addMessage('message', {
372 419 role: 'assistant',
373 420 content: agentResponse.reply,
374 421 // A workflow example can carry its own suggestions; none still asks.
@@ -386,12 +433,14 @@
386 433 useEffect(() => {
387 434 // Allow external messages to trigger the agent
388 435 const handleMessage = ({ detail }) => {
389 436 if (!detail?.message) return;
390 - handleSubmit(detail.message);
437 + handleSubmit(detail.message, { hidden: detail.hidden });
391 438 };
392 439 // Allow external code to clear the block and workflow
393 440 const handleCleanup = () => {
441 + // cleanup() resets canType and agentWorking, so read them before it runs.
442 + const interrupted = agentWorking.current || !canType;
394 443 controller.abort('Workflow aborted');
395 444 cleanup();
396 445 // Deferred a frame: the input stays disabled until the cancel lands.
397 446 requestAnimationFrame(() =>
@@ -398,15 +447,15 @@
398 447 document.querySelector('#extendify-agent-chat-textarea')?.focus(),
399 448 );
400 449
401 450 // An options panel can outlive its workflow; cancel must still clear it.
402 - if (!workflow?.id && !qaSuggestions) return;
451 + if (!workflow?.id && !qaSuggestions && !interrupted) return;
403 452 setWorkflow(null);
404 453 addMessage('workflow', {
405 454 status: 'canceled',
406 455 agent: workflow?.agent,
407 456 workflowId: workflow?.id,
408 - suggestions: getSuggestions(),
457 + ...nextSteps(workflow?.id, 'canceled'),
409 458 });
410 459 return;
411 460 };
412 461 window.addEventListener('extendify-agent:cancel-workflow', handleCleanup);
@@ -424,25 +473,52 @@
424 473 setWorkflow,
425 474 addMessage,
426 475 workflow,
427 476 qaSuggestions,
428 - getSuggestions,
477 + canType,
429 478 ]);
430 479
480 + // Dispatching before chat-submit has a listener drops the workflow.
481 + useEffect(() => {
482 + if (!startOnboardingWorkflow.available()) return;
483 + window.dispatchEvent(
484 + new CustomEvent('extendify-agent:chat-submit', {
485 + detail: { message: startOnboardingWorkflow.example.text, hidden: true },
486 + }),
487 + );
488 + }, []);
489 +
490 + // ChatMessages hides a confirm restored by a reload; left open, the workflow would sit unfinishable.
491 + useEffect(() => {
492 + const { getWorkflow, reloadedToolProps } = useWorkflowStore.getState();
493 + const { whenFinished, needsRedirect } = getWorkflow() ?? {};
494 + if (!reloadedToolProps?.id || !whenFinished?.component) return;
495 + if (whenFinished.canvas || needsRedirect?.()) return;
496 + window.dispatchEvent(new Event('extendify-agent:cancel-workflow'));
497 + }, []);
498 +
431 499 // Handle whenFinished component confirm/cancel
432 500 useEffect(() => {
433 501 const handleConfirm = async ({ detail }) => {
434 502 if (toolWorking.current) return;
435 - setWhenFinishedToolProps(null);
436 503 toolWorking.current = true;
437 504 const { data, whenFinishedToolProps, shouldRefreshPage, redirectUrl } =
438 505 detail ?? {};
506 + // Staged until the tool returns, so the chat shows it saving.
507 + setWhenFinishedToolProps(
508 + whenFinishedToolProps
509 + ? { ...whenFinishedToolProps, processing: true }
510 + : null,
511 + );
439 512 const { whenFinishedTool, answerId, redirectTo } =
440 513 whenFinishedToolProps?.agentResponse || {};
441 514 const { id, labels } = whenFinishedTool || {};
442 515 // Staged unanswered so its own component can show the run in progress.
443 516 const runMessageId = id ? addMessage('tool', { id, inputs: data }) : null;
444 - if (!hasRunComponent(id)) pushStatus('workflow-tool-processing');
517 + // In the chat, the saving state shows this label instead.
518 + if (!hasRunComponent(id) && workflow?.whenFinished?.canvas) {
519 + pushStatus('workflow-tool-processing', labels?.started);
520 + }
445 521 // Not all workflows have a tool at the end (e.g. tours)
446 522 const toolResponse = await callTool?.({ tool: id, inputs: data }).catch(
447 523 (error) => {
448 524 const { sessionId } = workflow || {};
@@ -458,8 +534,9 @@
458 534 return { error: { message: error?.message, code: error?.code } };
459 535 },
460 536 );
461 537 toolWorking.current = false;
538 + setWhenFinishedToolProps(null);
462 539 if (runMessageId) updateMessage(runMessageId, { result: toolResponse });
463 540 if (toolResponse?.refused) {
464 541 await new Promise((resolve) => setTimeout(resolve, 1000));
465 542 const refusalMessages = {
@@ -467,13 +544,8 @@
467 544 'no-op': __(
468 545 "That edit came back unchanged, which wasn't expected. Please try rephrasing what you'd like to change.",
469 546 'extendify-local',
470 547 ),
471 - // translators: Shown when the user asked the AI agent to edit a block that lives in a site template (e.g. the header or footer), which the agent cannot save yet.
472 - 'template-part': __(
473 - "That block is part of your site's template, like the header or footer, and I can't make changes there yet.",
474 - 'extendify-local',
475 - ),
476 548 };
477 549 addMessage('message', {
478 550 role: 'assistant',
479 551 content:
@@ -497,9 +569,15 @@
497 569 setLoop((prev) => prev + 1);
498 570 return;
499 571 }
500 572
501 - addSuggestions(whenFinishedToolProps.agentResponse?.recommendations);
573 + const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
574 + // Reloading here unmounts a run component still watching its ability finish.
575 + const refreshForAbility =
576 + isAbilityTool(id) && shouldRefreshPage !== false;
577 + const willReload =
578 + url || redirectUrl || shouldRefreshPage || refreshForAbility;
579 + // A card shown now would flash just before the reload.
502 580 addMessage('workflow', {
503 581 status: 'completed',
504 582 label: labels?.confirm,
505 583 agent: workflow.agent,
@@ -504,9 +582,9 @@
504 582 label: labels?.confirm,
505 583 agent: workflow.agent,
506 584 workflowId: workflow.id,
507 585 answerId,
508 - suggestions: getSuggestions(),
586 + ...(willReload ? {} : nextSteps(workflow.id, 'completed')),
509 587 });
510 588 // A chat message persists, so the next turn's model knows the save was partial.
511 589 const refusedCount = toolResponse?.refusedOperations?.length;
512 590 if (refusedCount) {
@@ -526,13 +604,9 @@
526 604 }
527 605 setWorkflow(null);
528 606 useCanvasStore.getState().endSession();
529 607
530 - const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
531 - const refreshForAbility = isAbilityTool(id);
532 - if (url || redirectUrl || shouldRefreshPage || refreshForAbility) {
533 - return doReload(url || redirectUrl);
534 - }
608 + if (willReload) return doReload(url || redirectUrl);
535 609 cleanup();
536 610 };
537 611 const handleCancel = ({ detail }) => {
538 612 if (toolWorking.current) return;
@@ -545,9 +619,9 @@
545 619 label: whenFinishedTool?.labels?.cancel,
546 620 agent: workflow.agent,
547 621 workflowId: workflow.id,
548 622 answerId,
549 - suggestions: getSuggestions(),
623 + ...nextSteps(workflow.id, 'canceled'),
550 624 });
551 625 setWorkflow(null);
552 626 useCanvasStore.getState().endSession();
553 627 cleanup();
@@ -572,18 +646,9 @@
572 646 handleCancel,
573 647 );
574 648 window.removeEventListener('extendify-agent:workflow-retry', handleRetry);
575 649 };
576 - }, [
577 - addMessage,
578 - pushStatus,
579 - popMessage,
580 - cleanup,
581 - setWorkflow,
582 - workflow,
583 - getSuggestions,
584 - addSuggestions,
585 - ]);
650 + }, [addMessage, pushStatus, popMessage, cleanup, setWorkflow, workflow]);
586 651
587 652 useEffect(() => {
588 653 const handleClose = () => setOpen(false);
589 654 const handleOpen = () => setOpen(true);
@@ -603,9 +668,26 @@
603 668 if (open) return;
604 669 if (block) setBlock(null);
605 670 }, [open, block, setBlock]);
606 671
672 + // A pick answers select-block's "which one?"; making the user also type stalls it.
673 + const previousBlock = useRef(block);
607 674 useEffect(() => {
675 + const picked = block && !previousBlock.current;
676 + previousBlock.current = block;
677 + if (!picked || workflow?.id !== 'select-block') return;
678 + if (!waitingOnToolOrUser || !canType || whenFinishedToolProps?.id) return;
679 + handleSubmit('This is the block I mean.', { hidden: true });
680 + }, [
681 + block,
682 + workflow?.id,
683 + waitingOnToolOrUser,
684 + canType,
685 + whenFinishedToolProps?.id,
686 + handleSubmit,
687 + ]);
688 +
689 + useEffect(() => {
608 690 if (waitingOnToolOrUser || !open || !workflow?.id) return;
609 691 // Some workflows require they dont change pages
610 692 const theyMoved = workflow?.startingPage !== window.location.href;
611 693 // Requires a block to be selected
@@ -616,9 +698,9 @@
616 698 addMessage('workflow', {
617 699 status: 'canceled',
618 700 agent: workflow.agent,
619 701 workflowId: workflow.id,
620 - suggestions: getSuggestions(),
702 + ...nextSteps(workflow.id, 'canceled'),
621 703 });
622 704 setWorkflow(null);
623 705 cleanup();
624 706 return;
@@ -693,9 +775,9 @@
693 775 // translators: Shown while the AI agent deselects a block that can't serve the request.
694 776 __('Removing selected block', 'extendify-local'),
695 777 );
696 778 // findAgent pushes its own status right away; let this one read first.
697 - await new Promise((resolve) => setTimeout(resolve, 2500));
779 + if (await canceledDuring(2500)) return;
698 780 agentWorking.current = false;
699 781 await findAgent();
700 782 return;
701 783 }
@@ -715,9 +797,8 @@
715 797 status: 'completed',
716 798 inputs,
717 799 workflowId: workflow.id,
718 800 });
719 - addSuggestions(agentResponse.recommendations);
720 801 setWorkflow(null);
721 802 addMessage('workflow', {
722 803 status: 'completed',
723 804 agent: workflow.agent,
@@ -722,9 +803,9 @@
722 803 status: 'completed',
723 804 agent: workflow.agent,
724 805 workflowId: workflow.id,
725 806 answerId,
726 - suggestions: getSuggestions(),
807 + ...nextSteps(workflow.id, 'completed'),
727 808 });
728 809 cleanup();
729 810 }
730 811 return;
@@ -730,19 +811,21 @@
730 811 return;
731 812 }
732 813 // If we're done, it means the AI has the answer
733 814 if (agentResponse.status !== 'in-progress') {
734 - const { recommendations, status } = agentResponse;
735 - const isCompleted = status === 'completed';
736 - if (recommendations) addSuggestions(recommendations);
815 + const status =
816 + agentResponse.status === 'completed' ? 'completed' : 'canceled';
817 + // A reply that changed nothing isn't a finished task to follow up.
818 + const didWork =
819 + toolCallsThisRun(useChatStore.getState().messages).length > 0;
737 820 setWorkflow(null);
738 821 cleanup();
739 822 addMessage('workflow', {
740 - status: isCompleted ? 'completed' : 'canceled',
823 + status,
741 824 agent: workflow.agent,
742 825 workflowId: workflow.id,
743 826 answerId,
744 - suggestions: getSuggestions(),
827 + ...(didWork ? nextSteps(workflow.id, status) : { followUp: null }),
745 828 });
746 829 return;
747 830 }
748 831 if (sessionId && sessionId !== workflow.sessionId) {
@@ -762,9 +845,10 @@
762 845 setWaitingOnToolOrUser(true);
763 846 setCanType(false);
764 847 return;
765 848 }
766 - pushStatus('tool-started', labels?.started);
849 + // A reply sent with the tool clears the status, blanking the line.
850 + pushStatus('agent-working');
767 851 const toolData = await Promise.all([
768 852 callTool({ tool: id, inputs }),
769 853 new Promise((resolve) => setTimeout(resolve, 3000)),
770 854 ])
@@ -795,8 +879,9 @@
795 879 id,
796 880 inputs,
797 881 result: toolData,
798 882 label: labels?.confirm,
883 + started: labels?.started,
799 884 });
800 885 setWaitingOnToolOrUser(false);
801 886 agentWorking.current = false;
802 887 setLoop((prev) => prev + 1); // Trigger next loop
@@ -842,10 +927,8 @@
842 927 setWhenFinishedToolProps,
843 928 block,
844 929 setBlock,
845 930 findAgent,
846 - addSuggestions,
847 - getSuggestions,
848 931 ]);
849 932
850 933 useEffect(() => {
851 934 if (!canType) return;
@@ -851,9 +934,15 @@
851 934 if (!canType) return;
852 935 document.querySelector('#extendify-agent-chat-textarea')?.focus();
853 936 }, [canType]);
854 937
855 - const busy = !canType || !chatAvailable || workflow?.id;
938 + // A task waiting on a reply leaves the page pickable so the user can answer by pointing.
939 + const waiting =
940 + canType &&
941 + chatAvailable &&
942 + Boolean(workflow?.id) &&
943 + !whenFinishedToolProps?.id;
944 + const busy = !canType || !chatAvailable || (workflow?.id && !waiting);
856 945 // `busy` is true at rest; 429 is blocked, not working.
857 946 const working = !canType && chatAvailable;
858 947
859 948 return (
@@ -858,9 +947,14 @@
858 947
859 948 return (
860 949 <>
861 950 <Canvas />
862 - <Chat busy={busy} working={working}>
951 + <Chat
952 + busy={busy}
953 + working={working}
954 + waiting={waiting}
955 + taskActive={Boolean(workflow?.id)}
956 + >
863 957 <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
864 958 <ChatMessages
865 959 redirectComponent={
866 960 workflow?.needsRedirect?.() ? workflow.redirectComponent : null
@@ -875,11 +969,16 @@
875 969 }}
876 970 />
877 971 </div>
878 972 <div className="p-4 pb-2 pt-0">
973 + <StatusIndicator />
879 974 <ChatInput
880 975 disabled={
881 - !canType || !chatAvailable || !!qaSuggestions || leavingPage
976 + !canType ||
977 + !chatAvailable ||
978 + !!qaSuggestions ||
979 + leavingPage ||
980 + whenFinishedToolProps?.processing
882 981 }
883 982 handleSubmit={handleSubmit}
884 983 />
885 984 </div>