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/components/ChatMessages.jsx +95 -15 3.1.4 → 3.2.2 View file →
@@ -1,13 +1,19 @@
1 +import { useCanvasWorkflow } from '@agent/components/Canvas';
1 2 import { ErrorMessage } from '@agent/components/ErrorMessage';
2 3 import { AgentMessage } from '@agent/components/messages/AgentMessage';
3 4 import { ImageToolMessage } from '@agent/components/messages/ImageToolMessage';
4 -import { StatusIndicator } from '@agent/components/messages/StatusIndicator';
5 5 import { ToolReceipt } from '@agent/components/messages/ToolReceipt';
6 +import {
7 + highlightToolSteps,
8 + ToolStep,
9 +} from '@agent/components/messages/ToolStep';
6 10 import { UserMessage } from '@agent/components/messages/UserMessage';
7 11 import { WorkflowComponent } from '@agent/components/messages/WorkflowComponent';
8 12 import { WorkflowMessage } from '@agent/components/messages/WorkflowMessage';
13 +import { SavingState } from '@agent/components/SavingState';
9 14 import { ScrollDownButton } from '@agent/components/ScrollDownButton';
15 +import { cardMessageIndex } from '@agent/follow-ups/pick-next';
10 16 import { useWhenFinishedToolProps } from '@agent/hooks/useWhenFinishedToolProps';
11 17 import { useChatStore } from '@agent/state/chat';
12 18 import { useGlobalStore } from '@agent/state/global';
13 19 import { useWorkflowStore } from '@agent/state/workflows';
@@ -32,14 +38,28 @@
32 38 (window.extAgentData?.wpAbilities ?? [])
33 39 .flatMap((category) => category.abilities ?? [])
34 40 .find((ability) => ability.name === name)?.label || name;
35 41
42 +// Stored history can predate started labels; those render as plain receipts.
43 +const isStep = ({ type, details }) =>
44 + type === 'tool' &&
45 + Boolean(details?.started) &&
46 + !details.result?.error &&
47 + !hasRunComponent(details.id);
48 +
36 49 export const ChatMessages = () => {
37 50 const { open } = useGlobalStore();
38 51 const { messages } = useChatStore();
39 - const { getWorkflow } = useWorkflowStore();
52 + const {
53 + getWorkflow,
54 + whenFinishedToolProps: staged,
55 + reloadedToolProps,
56 + } = useWorkflowStore();
40 57 const workflow = getWorkflow();
41 58 const whenFinishedToolProps = useWhenFinishedToolProps();
59 + // Its preview was lost with the page, so Save would write changes nobody saw.
60 + const confirmFromReload = Boolean(staged) && staged === reloadedToolProps;
61 + const canvasWorkflow = useCanvasWorkflow();
42 62 const whenFinishedComponent = workflow?.whenFinished?.component;
43 63 const [canScrollDown, setCanScrollDown] = useState(false);
44 64 const containerRef = useRef(null);
45 65 const isFreshPageLoad = useRef(true);
@@ -45,14 +65,28 @@
45 65 const isFreshPageLoad = useRef(true);
46 66 const [ready, setReady] = useState(false);
47 67 const userScrolledAway = useRef(false);
48 68 const confirmScrolledFor = useRef(null);
69 + // Remounting into another layout would otherwise animate the whole backlog past.
70 + const settling = useRef(true);
71 + const behavior = () => {
72 + if (!settling.current) return 'smooth';
73 + settling.current = false;
74 + return 'auto';
75 + };
49 76
50 77 const lastId = messages.at(-1)?.id;
51 78 const lastDetails = messages.at(-1)?.details;
79 + // A run's receipt follows its tool, so the newest card is never the last message.
80 + const lastRunId = messages
81 + .toReversed()
82 + .find(
83 + (message) =>
84 + message.type === 'tool' && hasRunComponent(message.details?.id),
85 + )?.id;
86 + const cardMessageId = messages[cardMessageIndex(messages)]?.id;
52 87 const pendingTool =
53 88 messages.at(-1)?.type === 'tool' && !('result' in (lastDetails ?? {}));
54 - // Both render their own waiting state, so the shared status line doubles it.
55 89 const awaitingPicker =
56 90 pendingTool &&
57 91 (lastDetails?.id === 'acquire-image' || hasRunComponent(lastDetails?.id));
58 92
@@ -59,9 +93,10 @@
59 93 // If last message is a user message, move it to the top
60 94 const isUserMessage = messages.at(-1)?.details?.role === 'user';
61 95
62 96 useEffect(() => {
63 - if (!containerRef.current || !open) return;
97 + // The frontend agent shows the chat even while the store says closed.
98 + if (!containerRef.current) return;
64 99 if (!isFreshPageLoad.current) return;
65 100 isFreshPageLoad.current = false;
66 101 // Scroll to the bottom of the chat container on load
67 102 const c = containerRef.current;
@@ -74,11 +109,14 @@
74 109 last?.scrollIntoView({ behavior: 'auto', block: 'start' });
75 110 setReady(true);
76 111 });
77 112 });
113 + // A hidden tab suspends animation frames, leaving the list invisible.
114 + const fallback = setTimeout(() => setReady(true), 500);
78 115 return () => {
79 116 cancelAnimationFrame(id);
80 117 cancelAnimationFrame(id2);
118 + clearTimeout(fallback);
81 119 isFreshPageLoad.current = true;
82 120 setReady(false);
83 121 };
84 122 }, [open]);
@@ -123,9 +161,9 @@
123 161 // block:'end' scrolls UP for already-visible content — only reveal overflow.
124 162 const overflows =
125 163 last.getBoundingClientRect().bottom > c.getBoundingClientRect().bottom;
126 164 if (!overflows) return;
127 - last.scrollIntoView({ behavior: 'smooth', block: 'end' });
165 + last.scrollIntoView({ behavior: behavior(), block: 'end' });
128 166 });
129 167 return () => cancelAnimationFrame(id);
130 168 }, [ready, isUserMessage, messages, pinTarget]);
131 169
@@ -140,9 +178,10 @@
140 178 const scrollArea = c?.querySelector('#extendify-agent-chat-scroll-area');
141 179 const tool = scrollArea?.lastElementChild;
142 180 if (!c || !scrollArea || !tool) return;
143 181 const pinWhenOutOfView = () => {
144 - if (confirmScrolledFor.current === pinTarget) return;
182 + // A confirm removed on Save measures as off-screen.
183 + if (!tool.isConnected || confirmScrolledFor.current === pinTarget) return;
145 184 const cRect = c.getBoundingClientRect();
146 185 const toolRect = tool.getBoundingClientRect();
147 186 if (toolRect.top >= cRect.top && toolRect.bottom <= cRect.bottom) return;
148 187 confirmScrolledFor.current = pinTarget;
@@ -153,9 +192,9 @@
153 192 const offset =
154 193 target.getBoundingClientRect().top -
155 194 scrollArea.getBoundingClientRect().top;
156 195 scrollArea.style.minHeight = `${offset + c.clientHeight}px`;
157 - target.scrollIntoView({ behavior: 'smooth', block: 'start' });
196 + target.scrollIntoView({ behavior: behavior(), block: 'start' });
158 197 };
159 198 pinWhenOutOfView();
160 199 // The confirm grows while its preview loads and can leave the viewport.
161 200 const observer = new ResizeObserver(pinWhenOutOfView);
@@ -180,9 +219,9 @@
180 219 scrollArea.style.minHeight = `${offset + c.clientHeight}px`;
181 220 last.scrollIntoView({ behavior: 'smooth', block: 'start' });
182 221 }, [isUserMessage, messages]);
183 222
184 - // Chasing suggestions from far up the transcript would move the page.
223 + // Chasing a follow-up card from far up the transcript would move the page.
185 224 const lastIsWorkflow = messages.at(-1)?.type === 'workflow';
186 225 useEffect(() => {
187 226 if (!lastIsWorkflow) return;
188 227 const c = containerRef.current;
@@ -195,8 +234,13 @@
195 234 if (below > c.clientHeight * 2) return;
196 235 last.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
197 236 }, [lastIsWorkflow, messages]);
198 237
238 + // Painted up front so opening a step never shows plain JSON first.
239 + useEffect(() => {
240 + highlightToolSteps(containerRef.current);
241 + }, [messages]);
242 +
199 243 // Handles the scroll down button visibility
200 244 useLayoutEffect(() => {
201 245 const c = containerRef.current;
202 246 if (!c) return;
@@ -228,9 +272,10 @@
228 272
229 273 return (
230 274 <div
231 275 ref={containerRef}
232 - style={{ overscrollBehavior: 'contain' }}
276 + // A classic scrollbar appearing would narrow and rewrap the messages.
277 + style={{ overscrollBehavior: 'contain', scrollbarGutter: 'stable' }}
233 278 className="relative grow overflow-y-auto overflow-x-hidden p-1 pb-0 text-sm text-gray-900 md:p-2"
234 279 >
235 280 <div
236 281 id="extendify-agent-chat-scroll-area"
@@ -236,8 +281,11 @@
236 281 id="extendify-agent-chat-scroll-area"
237 282 className={ready ? '' : 'invisible pointer-events-none'}
238 283 >
239 284 {messages.map((message) => {
285 + if (isStep(message)) {
286 + return <ToolStep key={message.id} details={message.details} />;
287 + }
240 288 const freshLoad = isFreshPageLoad.current;
241 289 if (message.details?.role === 'user') {
242 290 return <UserMessage key={message.id} message={message} />;
243 291 }
@@ -251,13 +299,32 @@
251 299 />
252 300 );
253 301 }
254 302 if (message.type === 'workflow') {
255 - return <WorkflowMessage key={message.id} message={message} />;
303 + return (
304 + <WorkflowMessage
305 + key={message.id}
306 + message={message}
307 + latest={message.id === cardMessageId}
308 + />
309 + );
256 310 }
257 311 if (message.type === 'workflow-component') {
258 312 return <WorkflowComponent key={message.id} message={message} />;
259 313 }
314 + if (message.type === 'canvas-notice') {
315 + return (
316 + <ToolReceipt key={message.id}>
317 + {
318 + // translators: Shown in the agent chat when the user asks for something the open canvas cannot do. Canvas is the panel open on screen beside the chat.
319 + __(
320 + 'Canvas interactions are limited. When finished, use the button in the top corner to exit.',
321 + 'extendify-local',
322 + )
323 + }
324 + </ToolReceipt>
325 + );
326 + }
260 327 if (
261 328 message.type === 'tool' &&
262 329 message.details?.id === 'acquire-image'
263 330 ) {
@@ -289,8 +356,9 @@
289 356 <AbilityRun
290 357 id={message.details.id}
291 358 inputs={message.details.inputs}
292 359 result={message.details.result}
360 + live={message.id === lastRunId}
293 361 />
294 362 ) : null}
295 363 {failure ? (
296 364 <ErrorMessage>
@@ -319,15 +387,27 @@
319 387 );
320 388 }
321 389 return null;
322 390 })}
323 - {/* The tool-running status reads as stuck while the picker waits on the user. */}
324 - {awaitingPicker ? null : <StatusIndicator />}
325 391 {!workflow?.needsRedirect?.() &&
326 392 whenFinishedToolProps?.id &&
327 - whenFinishedComponent
328 - ? createElement(whenFinishedComponent, whenFinishedToolProps)
329 - : null}
393 + !confirmFromReload &&
394 + !canvasWorkflow &&
395 + whenFinishedComponent ? (
396 + whenFinishedToolProps.processing ? (
397 + <SavingState
398 + label={
399 + whenFinishedToolProps.agentResponse?.whenFinishedTool?.labels
400 + ?.started
401 + }
402 + />
403 + ) : (
404 + createElement(whenFinishedComponent, {
405 + ...whenFinishedToolProps,
406 + live: true,
407 + })
408 + )
409 + ) : null}
330 410 {workflow?.needsRedirect?.() ? <workflow.redirectComponent /> : null}
331 411 </div>
332 412 <ScrollDownButton
333 413 canScrollDown={canScrollDown}