PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
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 +66 -27 3.1.53.2.1 View file →
@@ -5,9 +5,13 @@
5 5 pickWorkflow,
6 6 recordAgentActivity,
7 7 } from '@agent/api';
8 8 import { Chat } from '@agent/Chat';
9 -import { Canvas, useCanvasOpen } from '@agent/components/Canvas';
9 +import {
10 + Canvas,
11 + useCanvasAssist,
12 + useCanvasOpen,
13 +} from '@agent/components/Canvas';
10 14 import { ChatInput } from '@agent/components/ChatInput';
11 15 import { ChatMessages } from '@agent/components/ChatMessages';
12 16 import { UsageMessage } from '@agent/components/messages/UsageMessage';
13 17 import { useLockPost } from '@agent/hooks/useLockPost';
@@ -25,8 +29,9 @@
25 29 import { useStatusStore } from '@agent/state/status';
26 30 import { useSuggestionsStore } from '@agent/state/suggestions';
27 31 import { useWorkflowStore } from '@agent/state/workflows';
28 32 import { hasRunComponent } from '@agent/workflows/abilities/components/run';
33 +import startOnboardingWorkflow from '@agent/workflows/misc/start-onboarding';
29 34 import { useQuickEditStore } from '@quick-edit/state/store';
30 35 import { digest } from '@shared/api/digest';
31 36 import {
32 37 useCallback,
@@ -54,8 +59,15 @@
54 59 ]);
55 60 return result;
56 61 };
57 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 +
58 70 export const Agent = () => {
59 71 const { addMessage, updateMessage, popMessage, messages } = useChatStore();
60 72 const { pushStatus, clearStatuses, leavingPage } = useStatusStore();
61 73 const {
@@ -85,8 +97,10 @@
85 97 });
86 98 const [loop, setLoop] = useState(0);
87 99 const workflow = getWorkflow();
88 100 const canvasOpen = useCanvasOpen();
101 + const canvasAssist = useCanvasAssist();
102 + const canvasNoticeShown = useRef(false);
89 103 const chatAvailable = useMemo(() => isChatAvailable(), [isChatAvailable]);
90 104 const { addSuggestions, getSuggestions } = useSuggestionsStore();
91 105 // Options render only while their message is last; a reply dismisses them.
92 106 const lastMessage = messages.at(-1);
@@ -98,10 +112,12 @@
98 112 : null;
99 113
100 114 // Without this the input stays disabled for as long as the canvas is open.
101 115 useEffect(() => {
102 - if (canvasOpen) setCanType(true);
103 - }, [canvasOpen]);
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]);
104 120
105 121 const cleanup = useCallback(() => {
106 122 setCanType(true);
107 123 agentWorking.current = false;
@@ -135,13 +151,14 @@
135 151
136 152 const findAgent = useCallback(
137 153 async (options = {}) => {
138 154 pushStatus('calling-agent');
155 + const { signal } = controller;
139 156 const response = await withMinDuration(
140 157 pickWorkflow({
141 158 // A mid-turn drop clears the block after this closure was made.
142 159 workflows: getAvailableWorkflows().map((w) => w.id),
143 - options: { signal: controller.signal, ...options },
160 + options: { signal, ...options },
144 161 }).catch(async (error) => {
145 162 devmode && console.error(error);
146 163 if (error?.response?.status === 429) {
147 164 updateRetryAfter(error?.response?.headers?.get('Retry-After'));
@@ -149,15 +166,9 @@
149 166 pushStatus('credits-exhausted');
150 167 return;
151 168 }
152 169 setCanType(true);
153 - if (error === 'Workflow aborted') {
154 - addMessage('workflow', {
155 - status: 'canceled',
156 - suggestions: getSuggestions(),
157 - });
158 - return;
159 - }
170 + if (error === 'Workflow aborted') return;
160 171
161 172 await new Promise((resolve) => setTimeout(resolve, 1000));
162 173 addMessage('message', {
163 174 role: 'assistant',
@@ -171,9 +182,9 @@
171 182 return;
172 183 }),
173 184 500,
174 185 );
175 - if (!response) return;
186 + if (!response || signal.aborted) return;
176 187
177 188 const { workflow: wf, reply } = response;
178 189 if (wf?.id) setWorkflow(wf);
179 190 if (reply) {
@@ -187,9 +198,8 @@
187 198 pushStatus,
188 199 updateRetryAfter,
189 200 setWorkflow,
190 201 getAvailableWorkflows,
191 - getSuggestions,
192 202 ],
193 203 );
194 204
195 205 // The backend caps tool runs; nothing here bounds the loop.
@@ -235,8 +245,13 @@
235 245 followup: !!response.tool,
236 246 agent: workflow?.agent,
237 247 });
238 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 + }
239 254 if (!response.tool) break;
240 255 const { id, inputs, labels } = response.tool;
241 256 pushStatus('tool-started', labels?.started);
242 257 const result = await callTool({
@@ -257,18 +272,32 @@
257 272 setCanType(true);
258 273 }, [addMessage, pushStatus, whenFinishedToolProps, workflow]);
259 274
260 275 const handleSubmit = useCallback(
261 - async (message) => {
276 + async (message, { hidden = false } = {}) => {
262 277 // Suggestions reach the agent without the textarea; disabling it isn't enough.
263 278 if (useQuickEditStore.getState().selected) return;
264 279 setWaitingOnToolOrUser(false);
265 280 agentWorking.current = false;
266 - addMessage('message', { role: 'user', content: message });
281 + addMessage('message', { role: 'user', content: message, hidden });
267 282
268 283 // Without this a typed message would drop the workflow and close the canvas.
269 - if (canvasOpen) return handleCanvasMessage();
284 + if (canvasOpen && canvasAssist) return handleCanvasMessage();
270 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 +
271 300 // Let some phrases auto load workflows
272 301 const bypass = getWorkflowByExample(message);
273 302 if (bypass?.example?.agentResponse) {
274 303 // whenFinishedTool → inline input UI; none → ask for a typed reply.
@@ -296,9 +325,9 @@
296 325
297 326 // Skip the network find-agent when the staged block makes the edit certain.
298 327 const localPick = localPickWorkflow({ block });
299 328 if (localPick) {
300 - await new Promise((resolve) => setTimeout(resolve, 500));
329 + if (await canceledDuring(500)) return;
301 330 setWorkflow(localPick);
302 331 return;
303 332 }
304 333
@@ -306,8 +335,9 @@
306 335 },
307 336 [
308 337 addMessage,
309 338 block,
339 + canvasAssist,
310 340 canvasOpen,
311 341 findAgent,
312 342 handleCanvasMessage,
313 343 mergeWorkflowData,
@@ -315,8 +345,9 @@
315 345 setWorkflow,
316 346 workflow,
317 347 workflowData,
318 348 getAvailableWorkflows,
349 + getSuggestions,
319 350 ],
320 351 );
321 352
322 353 // Used to inject a workflow final state
@@ -326,9 +357,9 @@
326 357 if (!agentResponse) return;
327 358 setWorkflow(workflow);
328 359 setCanType(false);
329 360 agentWorking.current = true;
330 - await new Promise((resolve) => setTimeout(resolve, 750));
361 + if (await canceledDuring(750)) return;
331 362 addMessage('message', {
332 363 role: 'assistant',
333 364 content: agentResponse.reply,
334 365 });
@@ -352,9 +383,9 @@
352 383 // Without this the loop calls the backend before the user has typed.
353 384 setWaitingOnToolOrUser(true);
354 385 setCanType(false);
355 386 agentWorking.current = true;
356 - await new Promise((resolve) => setTimeout(resolve, 750));
387 + if (await canceledDuring(750)) return;
357 388 addMessage('message', {
358 389 role: 'assistant',
359 390 content: agentResponse.reply,
360 391 // A workflow example can carry its own suggestions; none still asks.
@@ -372,12 +403,14 @@
372 403 useEffect(() => {
373 404 // Allow external messages to trigger the agent
374 405 const handleMessage = ({ detail }) => {
375 406 if (!detail?.message) return;
376 - handleSubmit(detail.message);
407 + handleSubmit(detail.message, { hidden: detail.hidden });
377 408 };
378 409 // Allow external code to clear the block and workflow
379 410 const handleCleanup = () => {
411 + // cleanup() resets canType and agentWorking, so read them before it runs.
412 + const interrupted = agentWorking.current || !canType;
380 413 controller.abort('Workflow aborted');
381 414 cleanup();
382 415 // Deferred a frame: the input stays disabled until the cancel lands.
383 416 requestAnimationFrame(() =>
@@ -384,9 +417,9 @@
384 417 document.querySelector('#extendify-agent-chat-textarea')?.focus(),
385 418 );
386 419
387 420 // An options panel can outlive its workflow; cancel must still clear it.
388 - if (!workflow?.id && !qaSuggestions) return;
421 + if (!workflow?.id && !qaSuggestions && !interrupted) return;
389 422 setWorkflow(null);
390 423 addMessage('workflow', {
391 424 status: 'canceled',
392 425 agent: workflow?.agent,
@@ -411,10 +444,21 @@
411 444 addMessage,
412 445 workflow,
413 446 qaSuggestions,
414 447 getSuggestions,
448 + canType,
415 449 ]);
416 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 +
417 461 // Handle whenFinished component confirm/cancel
418 462 useEffect(() => {
419 463 const handleConfirm = async ({ detail }) => {
420 464 if (toolWorking.current) return;
@@ -453,13 +497,8 @@
453 497 'no-op': __(
454 498 "That edit came back unchanged, which wasn't expected. Please try rephrasing what you'd like to change.",
455 499 'extendify-local',
456 500 ),
457 - // 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.
458 - 'template-part': __(
459 - "That block is part of your site's template, like the header or footer, and I can't make changes there yet.",
460 - 'extendify-local',
461 - ),
462 501 };
463 502 addMessage('message', {
464 503 role: 'assistant',
465 504 content:
@@ -679,9 +718,9 @@
679 718 // translators: Shown while the AI agent deselects a block that can't serve the request.
680 719 __('Removing selected block', 'extendify-local'),
681 720 );
682 721 // findAgent pushes its own status right away; let this one read first.
683 - await new Promise((resolve) => setTimeout(resolve, 2500));
722 + if (await canceledDuring(2500)) return;
684 723 agentWorking.current = false;
685 724 await findAgent();
686 725 return;
687 726 }