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 +66 -27 3.1.5 → trunk 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 }