PluginProbe
Extendify / 3.2.3
Extendify v3.2.3
3.2.3 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 All 129 releases
← All changes | src/Agent/Agent.jsx +296 -98 3.1.4 → 3.2.3 View file →
@@ -1,14 +1,27 @@
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';
16 +import { StatusIndicator } from '@agent/components/messages/StatusIndicator';
10 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';
11 24 import { useLockPost } from '@agent/hooks/useLockPost';
12 25 import { isAbilityTool, isAbilityWorkflow } from '@agent/lib/abilities';
13 26 import {
14 27 getClientToolFallbackReply,
@@ -14,15 +27,16 @@
14 27 getClientToolFallbackReply,
15 28 getClientTools,
16 29 } from '@agent/lib/client-tools';
17 30 import { localPickWorkflow } from '@agent/lib/local-pick';
18 -import { getRedirectUrl } from '@agent/lib/redirects';
31 +import { doReload } from '@agent/lib/reload';
32 +import { useCanvasStore } from '@agent/state/canvas';
19 33 import { useChatStore } from '@agent/state/chat';
20 34 import { useGlobalStore } from '@agent/state/global';
21 35 import { useStatusStore } from '@agent/state/status';
22 -import { useSuggestionsStore } from '@agent/state/suggestions';
23 36 import { useWorkflowStore } from '@agent/state/workflows';
24 37 import { hasRunComponent } from '@agent/workflows/abilities/components/run';
38 +import startOnboardingWorkflow from '@agent/workflows/misc/start-onboarding';
25 39 import { useQuickEditStore } from '@quick-edit/state/store';
26 40 import { digest } from '@shared/api/digest';
27 41 import {
28 42 useCallback,
@@ -50,11 +64,28 @@
50 64 ]);
51 65 return result;
52 66 };
53 67
68 +// cleanup() swaps in a fresh controller; a signal read after the wait is never aborted.
69 +const canceledDuring = async (ms) => {
70 + const { signal } = controller;
71 + await new Promise((resolve) => setTimeout(resolve, ms));
72 + return signal.aborted;
73 +};
74 +
75 +const nextSteps = (workflowId, status, messages) => {
76 + if (status === 'completed') {
77 + useFollowUpHistory.getState().markDone(workflowId);
78 + }
79 + const card = pickNextCard({ workflowId, status, messages });
80 + return card
81 + ? { followUp: card.id, followUpData: card.data }
82 + : { followUp: null };
83 +};
84 +
54 85 export const Agent = () => {
55 86 const { addMessage, updateMessage, popMessage, messages } = useChatStore();
56 - const { pushStatus, clearStatuses } = useStatusStore();
87 + const { pushStatus, clearStatuses, leavingPage } = useStatusStore();
57 88 const {
58 89 mergeWorkflowData,
59 90 getWorkflow,
60 91 getWorkflowByExample,
@@ -80,10 +111,12 @@
80 111 return last?.type === 'message' && last.details?.role === 'assistant';
81 112 });
82 113 const [loop, setLoop] = useState(0);
83 114 const workflow = getWorkflow();
115 + const canvasOpen = useCanvasOpen();
116 + const canvasAssist = useCanvasAssist();
117 + const canvasNoticeShown = useRef(false);
84 118 const chatAvailable = useMemo(() => isChatAvailable(), [isChatAvailable]);
85 - const { addSuggestions, getSuggestions } = useSuggestionsStore();
86 119 // Options render only while their message is last; a reply dismisses them.
87 120 const lastMessage = messages.at(-1);
88 121 const qaSuggestions =
89 122 lastMessage?.type === 'message' &&
@@ -91,8 +124,25 @@
91 124 Array.isArray(lastMessage.details?.qaSuggestions)
92 125 ? lastMessage.details.qaSuggestions
93 126 : null;
94 127
128 + useEffect(() => {
129 + const { messages, updateMessage } = useChatStore.getState();
130 + const index = cardMessageIndex(messages);
131 + const { id, details } = messages[index] ?? {};
132 + if (!id || details.followUp !== undefined || details.followUpClosed) return;
133 + const { workflowId, status } = details;
134 + const steps = nextSteps(workflowId, status, messages.slice(0, index));
135 + if (steps.followUp) updateMessage(id, steps);
136 + }, []);
137 +
138 + // Without this the input stays disabled for as long as the canvas is open.
139 + useEffect(() => {
140 + if (canvasOpen && canvasAssist) setCanType(true);
141 + // A workflow reached by example carries no sessionId to key this on.
142 + if (!canvasOpen) canvasNoticeShown.current = false;
143 + }, [canvasOpen, canvasAssist]);
144 +
95 145 const cleanup = useCallback(() => {
96 146 setCanType(true);
97 147 agentWorking.current = false;
98 148 setWaitingOnToolOrUser(false);
@@ -101,9 +151,14 @@
101 151 agentBlock && setBlock(null);
102 152 // A class or pin left up freezes Quick Edit hover site-wide.
103 153 document
104 154 .querySelector('.wp-site-blocks')
105 - ?.classList.remove('extendify-agent-working', 'extendify-agent-busy');
155 + ?.classList.remove(
156 + 'extendify-agent-working',
157 + 'extendify-agent-busy',
158 + 'extendify-agent-waiting',
159 + 'extendify-agent-task',
160 + );
106 161 setCommittedSelection(null);
107 162 clearStatuses();
108 163 window.dispatchEvent(new Event('extendify-agent:remove-block-highlight'));
109 164 }, [setBlock, clearStatuses]);
@@ -125,13 +180,14 @@
125 180
126 181 const findAgent = useCallback(
127 182 async (options = {}) => {
128 183 pushStatus('calling-agent');
184 + const { signal } = controller;
129 185 const response = await withMinDuration(
130 186 pickWorkflow({
131 187 // A mid-turn drop clears the block after this closure was made.
132 188 workflows: getAvailableWorkflows().map((w) => w.id),
133 - options: { signal: controller.signal, ...options },
189 + options: { signal, ...options },
134 190 }).catch(async (error) => {
135 191 devmode && console.error(error);
136 192 if (error?.response?.status === 429) {
137 193 updateRetryAfter(error?.response?.headers?.get('Retry-After'));
@@ -139,15 +195,9 @@
139 195 pushStatus('credits-exhausted');
140 196 return;
141 197 }
142 198 setCanType(true);
143 - if (error === 'Workflow aborted') {
144 - addMessage('workflow', {
145 - status: 'canceled',
146 - suggestions: getSuggestions(),
147 - });
148 - return;
149 - }
199 + if (error === 'Workflow aborted') return;
150 200
151 201 await new Promise((resolve) => setTimeout(resolve, 1000));
152 202 addMessage('message', {
153 203 role: 'assistant',
@@ -161,9 +211,9 @@
161 211 return;
162 212 }),
163 213 500,
164 214 );
165 - if (!response) return;
215 + if (!response || signal.aborted) return;
166 216
167 217 const { workflow: wf, reply } = response;
168 218 if (wf?.id) setWorkflow(wf);
169 219 if (reply) {
@@ -177,20 +227,108 @@
177 227 pushStatus,
178 228 updateRetryAfter,
179 229 setWorkflow,
180 230 getAvailableWorkflows,
181 - getSuggestions,
182 231 ],
183 232 );
184 233
234 + // The backend caps tool runs; nothing here bounds the loop.
235 + const handleCanvasMessage = useCallback(async () => {
236 + setCanType(false);
237 + // cleanup() replaces the controller, so a cancel is lost between passes.
238 + const { signal } = controller;
239 + while (!signal.aborted) {
240 + pushStatus('agent-working');
241 + const response = await handleCanvas({
242 + toolId: whenFinishedToolProps?.id,
243 + sessionId: workflow?.sessionId,
244 + abilities: workflow?.abilities,
245 + options: { signal },
246 + }).catch((error) => {
247 + if (error === 'Workflow aborted') return null;
248 + const { sessionId } = workflow || {};
249 + digest({
250 + error,
251 + details: { source: 'agent', caller: 'handle-canvas', sessionId },
252 + });
253 + devmode && console.error(error);
254 + return { error: error.message };
255 + });
256 + // A request that settled before the abort still resolves with a reply.
257 + if (!response || signal.aborted) break;
258 + if (response.error) {
259 + addMessage('message', {
260 + role: 'assistant',
261 + // translators: Shown when the AI agent could not answer a question about the form it has open on screen.
262 + content: __(
263 + 'Sorry, something went wrong. Please try asking again.',
264 + 'extendify-local',
265 + ),
266 + error: true,
267 + });
268 + break;
269 + }
270 + if (response.reply) {
271 + addMessage('message', {
272 + role: 'assistant',
273 + content: response.reply,
274 + followup: !!response.tool,
275 + agent: workflow?.agent,
276 + });
277 + }
278 + // The model is never told what is on screen around the canvas.
279 + if (response.cannotHelp && !canvasNoticeShown.current) {
280 + canvasNoticeShown.current = true;
281 + addMessage('canvas-notice', {});
282 + }
283 + if (!response.tool) break;
284 + const { id, inputs, labels } = response.tool;
285 + pushStatus('tool-started', labels?.started);
286 + const result = await callTool({
287 + tool: id,
288 + inputs,
289 + abilities: workflow?.abilities,
290 + }).catch((error) => {
291 + const { sessionId } = workflow || {};
292 + digest({
293 + error,
294 + details: { source: 'agent', caller: `canvas: ${id}`, sessionId },
295 + });
296 + console.error(`Extendify agent tool error: ${id}`, { siteId, error });
297 + return { error: { message: error?.message, code: error?.code } };
298 + });
299 + addMessage('tool', { id, inputs, result, label: labels?.confirm });
300 + }
301 + setCanType(true);
302 + }, [addMessage, pushStatus, whenFinishedToolProps, workflow]);
303 +
185 304 const handleSubmit = useCallback(
186 - async (message) => {
187 - // Suggestions reach the agent without the textarea; disabling it isn't enough.
305 + async (message, { hidden = false, step } = {}) => {
306 + // chat-submit events skip the textarea; disabling it isn't enough.
188 307 if (useQuickEditStore.getState().selected) return;
308 + if (whenFinishedToolProps?.processing) return;
189 309 setWaitingOnToolOrUser(false);
190 310 agentWorking.current = false;
191 - addMessage('message', { role: 'user', content: message });
311 + if (step) addMessage('tool', { ...step, inputs: {}, fromCard: true });
312 + addMessage('message', { role: 'user', content: message, hidden });
192 313
314 + // Without this a typed message would drop the workflow and close the canvas.
315 + if (canvasOpen && canvasAssist) return handleCanvasMessage();
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 +
193 331 // Let some phrases auto load workflows
194 332 const bypass = getWorkflowByExample(message);
195 333 if (bypass?.example?.agentResponse) {
196 334 // whenFinishedTool → inline input UI; none → ask for a typed reply.
@@ -218,9 +356,9 @@
218 356
219 357 // Skip the network find-agent when the staged block makes the edit certain.
220 358 const localPick = localPickWorkflow({ block });
221 359 if (localPick) {
222 - await new Promise((resolve) => setTimeout(resolve, 500));
360 + if (await canceledDuring(500)) return;
223 361 setWorkflow(localPick);
224 362 return;
225 363 }
226 364
@@ -228,9 +366,12 @@
228 366 },
229 367 [
230 368 addMessage,
231 369 block,
370 + canvasAssist,
371 + canvasOpen,
232 372 findAgent,
373 + handleCanvasMessage,
233 374 mergeWorkflowData,
234 375 whenFinishedToolProps,
235 376 setWorkflow,
236 377 workflow,
@@ -246,9 +387,9 @@
246 387 if (!agentResponse) return;
247 388 setWorkflow(workflow);
248 389 setCanType(false);
249 390 agentWorking.current = true;
250 - await new Promise((resolve) => setTimeout(resolve, 750));
391 + if (await canceledDuring(750)) return;
251 392 addMessage('message', {
252 393 role: 'assistant',
253 394 content: agentResponse.reply,
254 395 });
@@ -272,9 +413,9 @@
272 413 // Without this the loop calls the backend before the user has typed.
273 414 setWaitingOnToolOrUser(true);
274 415 setCanType(false);
275 416 agentWorking.current = true;
276 - await new Promise((resolve) => setTimeout(resolve, 750));
417 + if (await canceledDuring(750)) return;
277 418 addMessage('message', {
278 419 role: 'assistant',
279 420 content: agentResponse.reply,
280 421 // A workflow example can carry its own suggestions; none still asks.
@@ -292,12 +433,15 @@
292 433 useEffect(() => {
293 434 // Allow external messages to trigger the agent
294 435 const handleMessage = ({ detail }) => {
295 436 if (!detail?.message) return;
296 - handleSubmit(detail.message);
437 + const { message, hidden, step } = detail;
438 + handleSubmit(message, { hidden, step });
297 439 };
298 440 // Allow external code to clear the block and workflow
299 441 const handleCleanup = () => {
442 + // cleanup() resets canType and agentWorking, so read them before it runs.
443 + const interrupted = agentWorking.current || !canType;
300 444 controller.abort('Workflow aborted');
301 445 cleanup();
302 446 // Deferred a frame: the input stays disabled until the cancel lands.
303 447 requestAnimationFrame(() =>
@@ -304,15 +448,15 @@
304 448 document.querySelector('#extendify-agent-chat-textarea')?.focus(),
305 449 );
306 450
307 451 // An options panel can outlive its workflow; cancel must still clear it.
308 - if (!workflow?.id && !qaSuggestions) return;
452 + if (!workflow?.id && !qaSuggestions && !interrupted) return;
309 453 setWorkflow(null);
310 454 addMessage('workflow', {
311 455 status: 'canceled',
312 456 agent: workflow?.agent,
313 457 workflowId: workflow?.id,
314 - suggestions: getSuggestions(),
458 + ...nextSteps(workflow?.id, 'canceled'),
315 459 });
316 460 return;
317 461 };
318 462 window.addEventListener('extendify-agent:cancel-workflow', handleCleanup);
@@ -330,25 +474,52 @@
330 474 setWorkflow,
331 475 addMessage,
332 476 workflow,
333 477 qaSuggestions,
334 - getSuggestions,
478 + canType,
335 479 ]);
336 480
481 + // Dispatching before chat-submit has a listener drops the workflow.
482 + useEffect(() => {
483 + if (!startOnboardingWorkflow.available()) return;
484 + window.dispatchEvent(
485 + new CustomEvent('extendify-agent:chat-submit', {
486 + detail: { message: startOnboardingWorkflow.example.text, hidden: true },
487 + }),
488 + );
489 + }, []);
490 +
491 + // ChatMessages hides a confirm restored by a reload; left open, the workflow would sit unfinishable.
492 + useEffect(() => {
493 + const { getWorkflow, reloadedToolProps } = useWorkflowStore.getState();
494 + const { whenFinished, needsRedirect } = getWorkflow() ?? {};
495 + if (!reloadedToolProps?.id || !whenFinished?.component) return;
496 + if (whenFinished.canvas || needsRedirect?.()) return;
497 + window.dispatchEvent(new Event('extendify-agent:cancel-workflow'));
498 + }, []);
499 +
337 500 // Handle whenFinished component confirm/cancel
338 501 useEffect(() => {
339 502 const handleConfirm = async ({ detail }) => {
340 503 if (toolWorking.current) return;
341 - setWhenFinishedToolProps(null);
342 504 toolWorking.current = true;
343 505 const { data, whenFinishedToolProps, shouldRefreshPage, redirectUrl } =
344 506 detail ?? {};
345 - const { whenFinishedTool, answerId, redirectTo } =
507 + // Staged until the tool returns, so the chat shows it saving.
508 + setWhenFinishedToolProps(
509 + whenFinishedToolProps
510 + ? { ...whenFinishedToolProps, processing: true }
511 + : null,
512 + );
513 + const { whenFinishedTool, answerId } =
346 514 whenFinishedToolProps?.agentResponse || {};
347 515 const { id, labels } = whenFinishedTool || {};
348 516 // Staged unanswered so its own component can show the run in progress.
349 517 const runMessageId = id ? addMessage('tool', { id, inputs: data }) : null;
350 - if (!hasRunComponent(id)) pushStatus('workflow-tool-processing');
518 + // In the chat, the saving state shows this label instead.
519 + if (!hasRunComponent(id) && workflow?.whenFinished?.canvas) {
520 + pushStatus('workflow-tool-processing', labels?.started);
521 + }
351 522 // Not all workflows have a tool at the end (e.g. tours)
352 523 const toolResponse = await callTool?.({ tool: id, inputs: data }).catch(
353 524 (error) => {
354 525 const { sessionId } = workflow || {};
@@ -364,8 +535,9 @@
364 535 return { error: { message: error?.message, code: error?.code } };
365 536 },
366 537 );
367 538 toolWorking.current = false;
539 + setWhenFinishedToolProps(null);
368 540 if (runMessageId) updateMessage(runMessageId, { result: toolResponse });
369 541 if (toolResponse?.refused) {
370 542 await new Promise((resolve) => setTimeout(resolve, 1000));
371 543 const refusalMessages = {
@@ -373,13 +545,8 @@
373 545 'no-op': __(
374 546 "That edit came back unchanged, which wasn't expected. Please try rephrasing what you'd like to change.",
375 547 'extendify-local',
376 548 ),
377 - // 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.
378 - 'template-part': __(
379 - "That block is part of your site's template, like the header or footer, and I can't make changes there yet.",
380 - 'extendify-local',
381 - ),
382 549 };
383 550 addMessage('message', {
384 551 role: 'assistant',
385 552 content:
@@ -403,9 +570,13 @@
403 570 setLoop((prev) => prev + 1);
404 571 return;
405 572 }
406 573
407 - addSuggestions(whenFinishedToolProps.agentResponse?.recommendations);
574 + // Reloading here unmounts a run component still watching its ability finish.
575 + const refreshForAbility =
576 + isAbilityTool(id) && shouldRefreshPage !== false;
577 + const willReload = redirectUrl || shouldRefreshPage || refreshForAbility;
578 + // A card shown now would flash just before the reload.
408 579 addMessage('workflow', {
409 580 status: 'completed',
410 581 label: labels?.confirm,
411 582 agent: workflow.agent,
@@ -410,9 +581,9 @@
410 581 label: labels?.confirm,
411 582 agent: workflow.agent,
412 583 workflowId: workflow.id,
413 584 answerId,
414 - suggestions: getSuggestions(),
585 + ...(willReload ? {} : nextSteps(workflow.id, 'completed')),
415 586 });
416 587 // A chat message persists, so the next turn's model knows the save was partial.
417 588 const refusedCount = toolResponse?.refusedOperations?.length;
418 589 if (refusedCount) {
@@ -430,22 +601,17 @@
430 601 ),
431 602 });
432 603 }
433 604 setWorkflow(null);
605 + useCanvasStore.getState().endSession();
434 606
435 - const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
436 - const refreshForAbility = isAbilityTool(id);
437 - if (url || redirectUrl || shouldRefreshPage || refreshForAbility) {
438 - await new Promise((resolve) => setTimeout(resolve, 1000));
439 - }
440 - if (url) return window.location.assign(url);
441 - if (redirectUrl) return window.location.assign(redirectUrl);
442 - if (shouldRefreshPage || refreshForAbility)
443 - return window.location.reload();
607 + if (willReload) return doReload(redirectUrl);
444 608 cleanup();
445 609 };
446 610 const handleCancel = ({ detail }) => {
447 611 if (toolWorking.current) return;
612 + // Without this a canvas closed mid-turn keeps replying into the chat.
613 + controller.abort('Workflow aborted');
448 614 const { answerId, whenFinishedTool } =
449 615 detail.whenFinishedToolProps?.agentResponse || {};
450 616 addMessage('workflow', {
451 617 status: 'canceled',
@@ -452,11 +618,12 @@
452 618 label: whenFinishedTool?.labels?.cancel,
453 619 agent: workflow.agent,
454 620 workflowId: workflow.id,
455 621 answerId,
456 - suggestions: getSuggestions(),
622 + ...nextSteps(workflow.id, 'canceled'),
457 623 });
458 624 setWorkflow(null);
625 + useCanvasStore.getState().endSession();
459 626 cleanup();
460 627 };
461 628 const handleRetry = () => {
462 629 popMessage();
@@ -478,18 +645,9 @@
478 645 handleCancel,
479 646 );
480 647 window.removeEventListener('extendify-agent:workflow-retry', handleRetry);
481 648 };
482 - }, [
483 - addMessage,
484 - pushStatus,
485 - popMessage,
486 - cleanup,
487 - setWorkflow,
488 - workflow,
489 - getSuggestions,
490 - addSuggestions,
491 - ]);
649 + }, [addMessage, pushStatus, popMessage, cleanup, setWorkflow, workflow]);
492 650
493 651 useEffect(() => {
494 652 const handleClose = () => setOpen(false);
495 653 const handleOpen = () => setOpen(true);
@@ -509,9 +667,26 @@
509 667 if (open) return;
510 668 if (block) setBlock(null);
511 669 }, [open, block, setBlock]);
512 670
671 + // A pick answers select-block's "which one?"; making the user also type stalls it.
672 + const previousBlock = useRef(block);
513 673 useEffect(() => {
674 + const picked = block && !previousBlock.current;
675 + previousBlock.current = block;
676 + if (!picked || workflow?.id !== 'select-block') return;
677 + if (!waitingOnToolOrUser || !canType || whenFinishedToolProps?.id) return;
678 + handleSubmit('This is the block I mean.', { hidden: true });
679 + }, [
680 + block,
681 + workflow?.id,
682 + waitingOnToolOrUser,
683 + canType,
684 + whenFinishedToolProps?.id,
685 + handleSubmit,
686 + ]);
687 +
688 + useEffect(() => {
514 689 if (waitingOnToolOrUser || !open || !workflow?.id) return;
515 690 // Some workflows require they dont change pages
516 691 const theyMoved = workflow?.startingPage !== window.location.href;
517 692 // Requires a block to be selected
@@ -522,9 +697,9 @@
522 697 addMessage('workflow', {
523 698 status: 'canceled',
524 699 agent: workflow.agent,
525 700 workflowId: workflow.id,
526 - suggestions: getSuggestions(),
701 + ...nextSteps(workflow.id, 'canceled'),
527 702 });
528 703 setWorkflow(null);
529 704 cleanup();
530 705 return;
@@ -599,9 +774,9 @@
599 774 // translators: Shown while the AI agent deselects a block that can't serve the request.
600 775 __('Removing selected block', 'extendify-local'),
601 776 );
602 777 // findAgent pushes its own status right away; let this one read first.
603 - await new Promise((resolve) => setTimeout(resolve, 2500));
778 + if (await canceledDuring(2500)) return;
604 779 agentWorking.current = false;
605 780 await findAgent();
606 781 return;
607 782 }
@@ -613,9 +788,10 @@
613 788 agentResponse,
614 789 });
615 790 // If static, add it as a message
616 791 const { id, inputs, static: staticC } = agentResponse.whenFinishedTool;
617 - if (staticC) {
792 + // A canvas workflow renders in the canvas and ends on close or submit.
793 + if (staticC && !workflow.whenFinished?.canvas) {
618 794 addMessage('workflow-component', {
619 795 id,
620 796 status: 'completed',
621 797 inputs,
@@ -620,9 +796,8 @@
620 796 status: 'completed',
621 797 inputs,
622 798 workflowId: workflow.id,
623 799 });
624 - addSuggestions(agentResponse.recommendations);
625 800 setWorkflow(null);
626 801 addMessage('workflow', {
627 802 status: 'completed',
628 803 agent: workflow.agent,
@@ -627,9 +802,9 @@
627 802 status: 'completed',
628 803 agent: workflow.agent,
629 804 workflowId: workflow.id,
630 805 answerId,
631 - suggestions: getSuggestions(),
806 + ...nextSteps(workflow.id, 'completed'),
632 807 });
633 808 cleanup();
634 809 }
635 810 return;
@@ -635,19 +810,21 @@
635 810 return;
636 811 }
637 812 // If we're done, it means the AI has the answer
638 813 if (agentResponse.status !== 'in-progress') {
639 - const { recommendations, status } = agentResponse;
640 - const isCompleted = status === 'completed';
641 - if (recommendations) addSuggestions(recommendations);
814 + const status =
815 + agentResponse.status === 'completed' ? 'completed' : 'canceled';
816 + // A reply that changed nothing isn't a finished task to follow up.
817 + const didWork =
818 + toolCallsThisRun(useChatStore.getState().messages).length > 0;
642 819 setWorkflow(null);
643 820 cleanup();
644 821 addMessage('workflow', {
645 - status: isCompleted ? 'completed' : 'canceled',
822 + status,
646 823 agent: workflow.agent,
647 824 workflowId: workflow.id,
648 825 answerId,
649 - suggestions: getSuggestions(),
826 + ...(didWork ? nextSteps(workflow.id, status) : { followUp: null }),
650 827 });
651 828 return;
652 829 }
653 830 if (sessionId && sessionId !== workflow.sessionId) {
@@ -667,9 +844,10 @@
667 844 setWaitingOnToolOrUser(true);
668 845 setCanType(false);
669 846 return;
670 847 }
671 - pushStatus('tool-started', labels?.started);
848 + // A reply sent with the tool clears the status, blanking the line.
849 + pushStatus('agent-working');
672 850 const toolData = await Promise.all([
673 851 callTool({ tool: id, inputs }),
674 852 new Promise((resolve) => setTimeout(resolve, 3000)),
675 853 ])
@@ -700,8 +878,9 @@
700 878 id,
701 879 inputs,
702 880 result: toolData,
703 881 label: labels?.confirm,
882 + started: labels?.started,
704 883 });
705 884 setWaitingOnToolOrUser(false);
706 885 agentWorking.current = false;
707 886 setLoop((prev) => prev + 1); // Trigger next loop
@@ -747,10 +926,8 @@
747 926 setWhenFinishedToolProps,
748 927 block,
749 928 setBlock,
750 929 findAgent,
751 - addSuggestions,
752 - getSuggestions,
753 930 ]);
754 931
755 932 useEffect(() => {
756 933 if (!canType) return;
@@ -756,42 +933,63 @@
756 933 if (!canType) return;
757 934 document.querySelector('#extendify-agent-chat-textarea')?.focus();
758 935 }, [canType]);
759 936
760 - const busy = !canType || !chatAvailable || workflow?.id;
937 + // A task waiting on a reply leaves the page pickable so the user can answer by pointing.
938 + const waiting =
939 + canType &&
940 + chatAvailable &&
941 + Boolean(workflow?.id) &&
942 + !whenFinishedToolProps?.id;
943 + const busy = !canType || !chatAvailable || (workflow?.id && !waiting);
761 944 // `busy` is true at rest; 429 is blocked, not working.
762 945 const working = !canType && chatAvailable;
763 946
764 947 return (
765 - <Chat busy={busy} working={working}>
766 - <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
767 - <ChatMessages
768 - redirectComponent={
769 - workflow?.needsRedirect?.() ? workflow.redirectComponent : null
770 - }
771 - />
772 - <div>
773 - <div className="relative flex flex-col px-4 pb-2 pt-2.5 shadow-lg-flipped">
774 - <UsageMessage
775 - onReady={() => {
776 - cleanup();
777 - pushStatus('credits-restored');
778 - }}
779 - />
948 + <>
949 + <Canvas />
950 + <Chat
951 + busy={busy}
952 + working={working}
953 + waiting={waiting}
954 + taskActive={Boolean(workflow?.id)}
955 + >
956 + <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
957 + <ChatMessages
958 + redirectComponent={
959 + workflow?.needsRedirect?.() ? workflow.redirectComponent : null
960 + }
961 + />
962 + <div>
963 + <div className="relative flex flex-col px-4 pb-2 pt-2.5 shadow-lg-flipped">
964 + <UsageMessage
965 + onReady={() => {
966 + cleanup();
967 + pushStatus('credits-restored');
968 + }}
969 + />
970 + </div>
971 + <div className="p-4 pb-2 pt-0">
972 + <StatusIndicator />
973 + <ChatInput
974 + disabled={
975 + !canType ||
976 + !chatAvailable ||
977 + !!qaSuggestions ||
978 + leavingPage ||
979 + whenFinishedToolProps?.processing
980 + }
981 + handleSubmit={handleSubmit}
982 + />
983 + </div>
984 + <div className="text-pretty px-4 pb-2 text-center text-xss leading-none text-gray-700">
985 + {__(
986 + 'AI Agent can make mistakes. Check changes before saving.',
987 + 'extendify-local',
988 + )}
989 + </div>
780 990 </div>
781 - <div className="p-4 pb-2 pt-0">
782 - <ChatInput
783 - disabled={!canType || !chatAvailable || !!qaSuggestions}
784 - handleSubmit={handleSubmit}
785 - />
786 - </div>
787 - <div className="text-pretty px-4 pb-2 text-center text-xss leading-none text-gray-700">
788 - {__(
789 - 'AI Agent can make mistakes. Check changes before saving.',
790 - 'extendify-local',
791 - )}
792 - </div>
793 991 </div>
794 - </div>
795 - </Chat>
992 + </Chat>
993 + </>
796 994 );
797 995 };