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 +278 -18 3.1.2 → 3.2.2 View file →
@@ -1,40 +1,102 @@
1 +import { useCanvasWorkflow } from '@agent/components/Canvas';
2 +import { ErrorMessage } from '@agent/components/ErrorMessage';
1 3 import { AgentMessage } from '@agent/components/messages/AgentMessage';
2 -import { StatusIndicator } from '@agent/components/messages/StatusIndicator';
4 +import { ImageToolMessage } from '@agent/components/messages/ImageToolMessage';
5 +import { ToolReceipt } from '@agent/components/messages/ToolReceipt';
6 +import {
7 + highlightToolSteps,
8 + ToolStep,
9 +} from '@agent/components/messages/ToolStep';
3 10 import { UserMessage } from '@agent/components/messages/UserMessage';
4 11 import { WorkflowComponent } from '@agent/components/messages/WorkflowComponent';
5 12 import { WorkflowMessage } from '@agent/components/messages/WorkflowMessage';
13 +import { SavingState } from '@agent/components/SavingState';
6 14 import { ScrollDownButton } from '@agent/components/ScrollDownButton';
7 -import { ScrollIntoViewOnce } from '@agent/components/ScrollIntoViewOnce';
15 +import { cardMessageIndex } from '@agent/follow-ups/pick-next';
8 16 import { useWhenFinishedToolProps } from '@agent/hooks/useWhenFinishedToolProps';
9 17 import { useChatStore } from '@agent/state/chat';
10 18 import { useGlobalStore } from '@agent/state/global';
11 19 import { useWorkflowStore } from '@agent/state/workflows';
20 +import { ImageAcquisitionRequest } from '@agent/workflows/abilities/components/ImageAcquisitionRequest';
12 21 import {
22 + AbilityRun,
23 + hasRunComponent,
24 +} from '@agent/workflows/abilities/components/run';
25 +import {
13 26 createElement,
27 + Fragment,
14 28 useEffect,
15 29 useLayoutEffect,
16 30 useRef,
17 31 useState,
18 32 } from '@wordpress/element';
33 +import { decodeEntities } from '@wordpress/html-entities';
34 +import { __, sprintf } from '@wordpress/i18n';
19 35
36 +// The raw validation text names schema paths the user can do nothing with.
37 +const abilityLabel = (name) =>
38 + (window.extAgentData?.wpAbilities ?? [])
39 + .flatMap((category) => category.abilities ?? [])
40 + .find((ability) => ability.name === name)?.label || name;
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 +
20 49 export const ChatMessages = () => {
21 50 const { open } = useGlobalStore();
22 51 const { messages } = useChatStore();
23 - const { getWorkflow } = useWorkflowStore();
52 + const {
53 + getWorkflow,
54 + whenFinishedToolProps: staged,
55 + reloadedToolProps,
56 + } = useWorkflowStore();
24 57 const workflow = getWorkflow();
25 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();
26 62 const whenFinishedComponent = workflow?.whenFinished?.component;
27 63 const [canScrollDown, setCanScrollDown] = useState(false);
28 64 const containerRef = useRef(null);
29 65 const isFreshPageLoad = useRef(true);
30 66 const [ready, setReady] = useState(false);
67 + const userScrolledAway = useRef(false);
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 + };
31 76
77 + const lastId = messages.at(-1)?.id;
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;
87 + const pendingTool =
88 + messages.at(-1)?.type === 'tool' && !('result' in (lastDetails ?? {}));
89 + const awaitingPicker =
90 + pendingTool &&
91 + (lastDetails?.id === 'acquire-image' || hasRunComponent(lastDetails?.id));
92 +
32 93 // If last message is a user message, move it to the top
33 94 const isUserMessage = messages.at(-1)?.details?.role === 'user';
34 95
35 96 useEffect(() => {
36 - if (!containerRef.current || !open) return;
97 + // The frontend agent shows the chat even while the store says closed.
98 + if (!containerRef.current) return;
37 99 if (!isFreshPageLoad.current) return;
38 100 isFreshPageLoad.current = false;
39 101 // Scroll to the bottom of the chat container on load
40 102 const c = containerRef.current;
@@ -47,16 +109,100 @@
47 109 last?.scrollIntoView({ behavior: 'auto', block: 'start' });
48 110 setReady(true);
49 111 });
50 112 });
113 + // A hidden tab suspends animation frames, leaving the list invisible.
114 + const fallback = setTimeout(() => setReady(true), 500);
51 115 return () => {
52 116 cancelAnimationFrame(id);
53 117 cancelAnimationFrame(id2);
118 + clearTimeout(fallback);
54 119 isFreshPageLoad.current = true;
55 120 setReady(false);
56 121 };
57 122 }, [open]);
58 123
124 + // A manual scroll means the user left the live edge on purpose — stop
125 + // following until they send again.
126 + useEffect(() => {
127 + const c = containerRef.current;
128 + if (!c) return;
129 + const markScrolled = () => {
130 + userScrolledAway.current = true;
131 + };
132 + c.addEventListener('wheel', markScrolled, { passive: true });
133 + c.addEventListener('touchmove', markScrolled, { passive: true });
134 + return () => {
135 + c.removeEventListener('wheel', markScrolled);
136 + c.removeEventListener('touchmove', markScrolled);
137 + };
138 + }, []);
139 +
140 + useEffect(() => {
141 + if (isUserMessage) userScrolledAway.current = false;
142 + }, [isUserMessage, messages]);
143 +
144 + const pinTarget = whenFinishedToolProps?.id
145 + ? 'confirm'
146 + : awaitingPicker
147 + ? `picker-${lastId}`
148 + : null;
149 +
150 + // Follow new agent content while it streams in.
151 + useEffect(() => {
152 + if (!ready || isUserMessage) return;
153 + if (userScrolledAway.current) return;
154 + if (pinTarget) return;
155 + const c = containerRef.current;
156 + const last = c?.querySelector(
157 + '#extendify-agent-chat-scroll-area > :last-child',
158 + );
159 + if (!last) return;
160 + const id = requestAnimationFrame(() => {
161 + // block:'end' scrolls UP for already-visible content — only reveal overflow.
162 + const overflows =
163 + last.getBoundingClientRect().bottom > c.getBoundingClientRect().bottom;
164 + if (!overflows) return;
165 + last.scrollIntoView({ behavior: behavior(), block: 'end' });
166 + });
167 + return () => cancelAnimationFrame(id);
168 + }, [ready, isUserMessage, messages, pinTarget]);
169 +
170 + // A confirm or picker demands action — pin its reply so both stay visible.
171 + useEffect(() => {
172 + if (!pinTarget) {
173 + confirmScrolledFor.current = null;
174 + return;
175 + }
176 + if (!ready || confirmScrolledFor.current === pinTarget) return;
177 + const c = containerRef.current;
178 + const scrollArea = c?.querySelector('#extendify-agent-chat-scroll-area');
179 + const tool = scrollArea?.lastElementChild;
180 + if (!c || !scrollArea || !tool) return;
181 + const pinWhenOutOfView = () => {
182 + // A confirm removed on Save measures as off-screen.
183 + if (!tool.isConnected || confirmScrolledFor.current === pinTarget) return;
184 + const cRect = c.getBoundingClientRect();
185 + const toolRect = tool.getBoundingClientRect();
186 + if (toolRect.top >= cRect.top && toolRect.bottom <= cRect.bottom) return;
187 + confirmScrolledFor.current = pinTarget;
188 + const replies = c.querySelectorAll(
189 + '[data-agent-message-role="assistant"]',
190 + );
191 + const target = replies[replies.length - 1] ?? tool;
192 + const offset =
193 + target.getBoundingClientRect().top -
194 + scrollArea.getBoundingClientRect().top;
195 + scrollArea.style.minHeight = `${offset + c.clientHeight}px`;
196 + target.scrollIntoView({ behavior: behavior(), block: 'start' });
197 + };
198 + pinWhenOutOfView();
199 + // The confirm grows while its preview loads and can leave the viewport.
200 + const observer = new ResizeObserver(pinWhenOutOfView);
201 + observer.observe(tool);
202 + return () => observer.disconnect();
203 + }, [ready, pinTarget]);
204 +
59 205 // Handles scrolling to the top of the last user message
60 206 // TODO: if the user sends in a long message, maybe we scroll to the bottom
61 207 // of the message offset by 2-3 lines
62 208 useEffect(() => {
@@ -66,18 +212,35 @@
66 212 const messages = c.querySelectorAll('[data-agent-message-role="user"]');
67 213 const last = messages[messages.length - 1];
68 214 if (!last || messages.length < 2) return;
69 215 const scrollArea = c.querySelector('#extendify-agent-chat-scroll-area');
70 - const lastRect = last.getBoundingClientRect();
71 - const innerHeight = Array.from(scrollArea.children).reduce(
72 - (sum, child) => sum + child.offsetHeight,
73 - 0,
74 - );
75 - const minHeight = innerHeight + c.clientHeight - lastRect.height;
76 - scrollArea.style.minHeight = `${minHeight}px`;
216 + // Reaching the top needs a viewport of room below the message's own offset.
217 + const offset =
218 + last.getBoundingClientRect().top - scrollArea.getBoundingClientRect().top;
219 + scrollArea.style.minHeight = `${offset + c.clientHeight}px`;
77 220 last.scrollIntoView({ behavior: 'smooth', block: 'start' });
78 221 }, [isUserMessage, messages]);
79 222
223 + // Chasing a follow-up card from far up the transcript would move the page.
224 + const lastIsWorkflow = messages.at(-1)?.type === 'workflow';
225 + useEffect(() => {
226 + if (!lastIsWorkflow) return;
227 + const c = containerRef.current;
228 + const last = c?.querySelector(
229 + '#extendify-agent-chat-scroll-area > :last-child',
230 + );
231 + if (!last) return;
232 + const below =
233 + last.getBoundingClientRect().top - c.getBoundingClientRect().bottom;
234 + if (below > c.clientHeight * 2) return;
235 + last.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
236 + }, [lastIsWorkflow, messages]);
237 +
238 + // Painted up front so opening a step never shows plain JSON first.
239 + useEffect(() => {
240 + highlightToolSteps(containerRef.current);
241 + }, [messages]);
242 +
80 243 // Handles the scroll down button visibility
81 244 useLayoutEffect(() => {
82 245 const c = containerRef.current;
83 246 if (!c) return;
@@ -109,10 +272,11 @@
109 272
110 273 return (
111 274 <div
112 275 ref={containerRef}
113 - style={{ overscrollBehavior: 'contain' }}
114 - className="relative grow overflow-y-auto overflow-x-hidden p-1 pb-0 text-sm text-gray-900 md:p-2 scheme-light"
276 + // A classic scrollbar appearing would narrow and rewrap the messages.
277 + style={{ overscrollBehavior: 'contain', scrollbarGutter: 'stable' }}
278 + className="relative grow overflow-y-auto overflow-x-hidden p-1 pb-0 text-sm text-gray-900 md:p-2"
115 279 >
116 280 <div
117 281 id="extendify-agent-chat-scroll-area"
118 282 className={ready ? '' : 'invisible pointer-events-none'}
@@ -117,8 +281,11 @@
117 281 id="extendify-agent-chat-scroll-area"
118 282 className={ready ? '' : 'invisible pointer-events-none'}
119 283 >
120 284 {messages.map((message) => {
285 + if (isStep(message)) {
286 + return <ToolStep key={message.id} details={message.details} />;
287 + }
121 288 const freshLoad = isFreshPageLoad.current;
122 289 if (message.details?.role === 'user') {
123 290 return <UserMessage key={message.id} message={message} />;
124 291 }
@@ -126,27 +293,120 @@
126 293 return (
127 294 <AgentMessage
128 295 key={message.id}
129 296 animate={!freshLoad}
297 + active={message.id === messages.at(-1)?.id}
130 298 message={message}
131 299 />
132 300 );
133 301 }
134 302 if (message.type === 'workflow') {
135 - 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 + );
136 310 }
137 311 if (message.type === 'workflow-component') {
138 312 return <WorkflowComponent key={message.id} message={message} />;
139 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 + }
327 + if (
328 + message.type === 'tool' &&
329 + message.details?.id === 'acquire-image'
330 + ) {
331 + // Unanswered and last is still live; anything earlier is history.
332 + if (!('result' in message.details) && message.id === lastId) {
333 + return (
334 + <ImageAcquisitionRequest key={message.id} message={message} />
335 + );
336 + }
337 + // Only an answered picker reports a skip; anything else got no image.
338 + const answer = message.details?.result;
339 + return (
340 + <ImageToolMessage
341 + key={message.id}
342 + url={answer?.url}
343 + failed={!!answer && !answer.url && !answer.skipped}
344 + />
345 + );
346 + }
347 + if (message.type === 'tool') {
348 + const failure = message.details?.result?.error;
349 + const runnable = hasRunComponent(message.details?.id);
350 + // A run component reports its own outcome; a second line repeats it.
351 + const receipt = !runnable && !failure && message.details?.label;
352 + if (!runnable && !failure && !receipt) return null;
353 + return (
354 + <Fragment key={message.id}>
355 + {runnable ? (
356 + <AbilityRun
357 + id={message.details.id}
358 + inputs={message.details.inputs}
359 + result={message.details.result}
360 + live={message.id === lastRunId}
361 + />
362 + ) : null}
363 + {failure ? (
364 + <ErrorMessage>
365 + {sprintf(
366 + // translators: %s is the name of the task the agent tried to run.
367 + __('Error running %s.', 'extendify-local'),
368 + abilityLabel(message.details.id),
369 + )}
370 + </ErrorMessage>
371 + ) : null}
372 + {receipt ? (
373 + <ToolReceipt>{decodeEntities(receipt)}</ToolReceipt>
374 + ) : null}
375 + </Fragment>
376 + );
377 + }
378 + // Otherwise the live picker sits under its own receipt.
379 + if (message.type === 'image') {
380 + const live =
381 + !message.details?.url &&
382 + message.id === lastId &&
383 + whenFinishedToolProps?.id;
384 + if (live) return null;
385 + return (
386 + <ImageToolMessage key={message.id} url={message.details?.url} />
387 + );
388 + }
140 389 return null;
141 390 })}
142 - <StatusIndicator />
143 391 {!workflow?.needsRedirect?.() &&
144 392 whenFinishedToolProps?.id &&
393 + !confirmFromReload &&
394 + !canvasWorkflow &&
145 395 whenFinishedComponent ? (
146 - <ScrollIntoViewOnce>
147 - {createElement(whenFinishedComponent, whenFinishedToolProps)}
148 - </ScrollIntoViewOnce>
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 + )
149 409 ) : null}
150 410 {workflow?.needsRedirect?.() ? <workflow.redirectComponent /> : null}
151 411 </div>
152 412 <ScrollDownButton