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 +87 -14 3.1.5 → 3.2.2 View file →
@@ -1,14 +1,19 @@
1 1 import { useCanvasWorkflow } from '@agent/components/Canvas';
2 2 import { ErrorMessage } from '@agent/components/ErrorMessage';
3 3 import { AgentMessage } from '@agent/components/messages/AgentMessage';
4 4 import { ImageToolMessage } from '@agent/components/messages/ImageToolMessage';
5 -import { StatusIndicator } from '@agent/components/messages/StatusIndicator';
6 5 import { ToolReceipt } from '@agent/components/messages/ToolReceipt';
6 +import {
7 + highlightToolSteps,
8 + ToolStep,
9 +} from '@agent/components/messages/ToolStep';
7 10 import { UserMessage } from '@agent/components/messages/UserMessage';
8 11 import { WorkflowComponent } from '@agent/components/messages/WorkflowComponent';
9 12 import { WorkflowMessage } from '@agent/components/messages/WorkflowMessage';
13 +import { SavingState } from '@agent/components/SavingState';
10 14 import { ScrollDownButton } from '@agent/components/ScrollDownButton';
15 +import { cardMessageIndex } from '@agent/follow-ups/pick-next';
11 16 import { useWhenFinishedToolProps } from '@agent/hooks/useWhenFinishedToolProps';
12 17 import { useChatStore } from '@agent/state/chat';
13 18 import { useGlobalStore } from '@agent/state/global';
14 19 import { useWorkflowStore } from '@agent/state/workflows';
@@ -33,14 +38,27 @@
33 38 (window.extAgentData?.wpAbilities ?? [])
34 39 .flatMap((category) => category.abilities ?? [])
35 40 .find((ability) => ability.name === name)?.label || name;
36 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 +
37 49 export const ChatMessages = () => {
38 50 const { open } = useGlobalStore();
39 51 const { messages } = useChatStore();
40 - const { getWorkflow } = useWorkflowStore();
52 + const {
53 + getWorkflow,
54 + whenFinishedToolProps: staged,
55 + reloadedToolProps,
56 + } = useWorkflowStore();
41 57 const workflow = getWorkflow();
42 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;
43 61 const canvasWorkflow = useCanvasWorkflow();
44 62 const whenFinishedComponent = workflow?.whenFinished?.component;
45 63 const [canScrollDown, setCanScrollDown] = useState(false);
46 64 const containerRef = useRef(null);
@@ -47,14 +65,28 @@
47 65 const isFreshPageLoad = useRef(true);
48 66 const [ready, setReady] = useState(false);
49 67 const userScrolledAway = useRef(false);
50 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 + };
51 76
52 77 const lastId = messages.at(-1)?.id;
53 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;
54 87 const pendingTool =
55 88 messages.at(-1)?.type === 'tool' && !('result' in (lastDetails ?? {}));
56 - // Both render their own waiting state, so the shared status line doubles it.
57 89 const awaitingPicker =
58 90 pendingTool &&
59 91 (lastDetails?.id === 'acquire-image' || hasRunComponent(lastDetails?.id));
60 92
@@ -129,9 +161,9 @@
129 161 // block:'end' scrolls UP for already-visible content — only reveal overflow.
130 162 const overflows =
131 163 last.getBoundingClientRect().bottom > c.getBoundingClientRect().bottom;
132 164 if (!overflows) return;
133 - last.scrollIntoView({ behavior: 'smooth', block: 'end' });
165 + last.scrollIntoView({ behavior: behavior(), block: 'end' });
134 166 });
135 167 return () => cancelAnimationFrame(id);
136 168 }, [ready, isUserMessage, messages, pinTarget]);
137 169
@@ -146,9 +178,10 @@
146 178 const scrollArea = c?.querySelector('#extendify-agent-chat-scroll-area');
147 179 const tool = scrollArea?.lastElementChild;
148 180 if (!c || !scrollArea || !tool) return;
149 181 const pinWhenOutOfView = () => {
150 - if (confirmScrolledFor.current === pinTarget) return;
182 + // A confirm removed on Save measures as off-screen.
183 + if (!tool.isConnected || confirmScrolledFor.current === pinTarget) return;
151 184 const cRect = c.getBoundingClientRect();
152 185 const toolRect = tool.getBoundingClientRect();
153 186 if (toolRect.top >= cRect.top && toolRect.bottom <= cRect.bottom) return;
154 187 confirmScrolledFor.current = pinTarget;
@@ -159,9 +192,9 @@
159 192 const offset =
160 193 target.getBoundingClientRect().top -
161 194 scrollArea.getBoundingClientRect().top;
162 195 scrollArea.style.minHeight = `${offset + c.clientHeight}px`;
163 - target.scrollIntoView({ behavior: 'smooth', block: 'start' });
196 + target.scrollIntoView({ behavior: behavior(), block: 'start' });
164 197 };
165 198 pinWhenOutOfView();
166 199 // The confirm grows while its preview loads and can leave the viewport.
167 200 const observer = new ResizeObserver(pinWhenOutOfView);
@@ -186,9 +219,9 @@
186 219 scrollArea.style.minHeight = `${offset + c.clientHeight}px`;
187 220 last.scrollIntoView({ behavior: 'smooth', block: 'start' });
188 221 }, [isUserMessage, messages]);
189 222
190 - // 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.
191 224 const lastIsWorkflow = messages.at(-1)?.type === 'workflow';
192 225 useEffect(() => {
193 226 if (!lastIsWorkflow) return;
194 227 const c = containerRef.current;
@@ -201,8 +234,13 @@
201 234 if (below > c.clientHeight * 2) return;
202 235 last.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
203 236 }, [lastIsWorkflow, messages]);
204 237
238 + // Painted up front so opening a step never shows plain JSON first.
239 + useEffect(() => {
240 + highlightToolSteps(containerRef.current);
241 + }, [messages]);
242 +
205 243 // Handles the scroll down button visibility
206 244 useLayoutEffect(() => {
207 245 const c = containerRef.current;
208 246 if (!c) return;
@@ -234,9 +272,10 @@
234 272
235 273 return (
236 274 <div
237 275 ref={containerRef}
238 - style={{ overscrollBehavior: 'contain' }}
276 + // A classic scrollbar appearing would narrow and rewrap the messages.
277 + style={{ overscrollBehavior: 'contain', scrollbarGutter: 'stable' }}
239 278 className="relative grow overflow-y-auto overflow-x-hidden p-1 pb-0 text-sm text-gray-900 md:p-2"
240 279 >
241 280 <div
242 281 id="extendify-agent-chat-scroll-area"
@@ -242,8 +281,11 @@
242 281 id="extendify-agent-chat-scroll-area"
243 282 className={ready ? '' : 'invisible pointer-events-none'}
244 283 >
245 284 {messages.map((message) => {
285 + if (isStep(message)) {
286 + return <ToolStep key={message.id} details={message.details} />;
287 + }
246 288 const freshLoad = isFreshPageLoad.current;
247 289 if (message.details?.role === 'user') {
248 290 return <UserMessage key={message.id} message={message} />;
249 291 }
@@ -257,13 +299,32 @@
257 299 />
258 300 );
259 301 }
260 302 if (message.type === 'workflow') {
261 - 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 + );
262 310 }
263 311 if (message.type === 'workflow-component') {
264 312 return <WorkflowComponent key={message.id} message={message} />;
265 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 + }
266 327 if (
267 328 message.type === 'tool' &&
268 329 message.details?.id === 'acquire-image'
269 330 ) {
@@ -295,8 +356,9 @@
295 356 <AbilityRun
296 357 id={message.details.id}
297 358 inputs={message.details.inputs}
298 359 result={message.details.result}
360 + live={message.id === lastRunId}
299 361 />
300 362 ) : null}
301 363 {failure ? (
302 364 <ErrorMessage>
@@ -325,16 +387,27 @@
325 387 );
326 388 }
327 389 return null;
328 390 })}
329 - {/* The tool-running status reads as stuck while the picker waits on the user. */}
330 - {awaitingPicker ? null : <StatusIndicator />}
331 391 {!workflow?.needsRedirect?.() &&
332 392 whenFinishedToolProps?.id &&
393 + !confirmFromReload &&
333 394 !canvasWorkflow &&
334 - whenFinishedComponent
335 - ? createElement(whenFinishedComponent, whenFinishedToolProps)
336 - : null}
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}
337 410 {workflow?.needsRedirect?.() ? <workflow.redirectComponent /> : null}
338 411 </div>
339 412 <ScrollDownButton
340 413 canScrollDown={canScrollDown}