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
extendify / src / Agent / components / ChatMessages.jsx

ChatMessages.jsx in Extendify 3.2.2, at src/Agent/components/ChatMessages.jsx

428 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { useCanvasWorkflow } from '@agent/components/Canvas';
2 import { ErrorMessage } from '@agent/components/ErrorMessage';
3 import { AgentMessage } from '@agent/components/messages/AgentMessage';
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';
10 import { UserMessage } from '@agent/components/messages/UserMessage';
11 import { WorkflowComponent } from '@agent/components/messages/WorkflowComponent';
12 import { WorkflowMessage } from '@agent/components/messages/WorkflowMessage';
13 import { SavingState } from '@agent/components/SavingState';
14 import { ScrollDownButton } from '@agent/components/ScrollDownButton';
15 import { cardMessageIndex } from '@agent/follow-ups/pick-next';
16 import { useWhenFinishedToolProps } from '@agent/hooks/useWhenFinishedToolProps';
17 import { useChatStore } from '@agent/state/chat';
18 import { useGlobalStore } from '@agent/state/global';
19 import { useWorkflowStore } from '@agent/state/workflows';
20 import { ImageAcquisitionRequest } from '@agent/workflows/abilities/components/ImageAcquisitionRequest';
21 import {
22 AbilityRun,
23 hasRunComponent,
24 } from '@agent/workflows/abilities/components/run';
25 import {
26 createElement,
27 Fragment,
28 useEffect,
29 useLayoutEffect,
30 useRef,
31 useState,
32 } from '@wordpress/element';
33 import { decodeEntities } from '@wordpress/html-entities';
34 import { __, sprintf } from '@wordpress/i18n';
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
49 export const ChatMessages = () => {
50 const { open } = useGlobalStore();
51 const { messages } = useChatStore();
52 const {
53 getWorkflow,
54 whenFinishedToolProps: staged,
55 reloadedToolProps,
56 } = useWorkflowStore();
57 const workflow = getWorkflow();
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();
62 const whenFinishedComponent = workflow?.whenFinished?.component;
63 const [canScrollDown, setCanScrollDown] = useState(false);
64 const containerRef = useRef(null);
65 const isFreshPageLoad = useRef(true);
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 };
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
93 // If last message is a user message, move it to the top
94 const isUserMessage = messages.at(-1)?.details?.role === 'user';
95
96 useEffect(() => {
97 // The frontend agent shows the chat even while the store says closed.
98 if (!containerRef.current) return;
99 if (!isFreshPageLoad.current) return;
100 isFreshPageLoad.current = false;
101 // Scroll to the bottom of the chat container on load
102 const c = containerRef.current;
103 const last = c.querySelector(
104 '#extendify-agent-chat-scroll-area > :last-child',
105 );
106 let id2;
107 const id = requestAnimationFrame(() => {
108 id2 = requestAnimationFrame(() => {
109 last?.scrollIntoView({ behavior: 'auto', block: 'start' });
110 setReady(true);
111 });
112 });
113 // A hidden tab suspends animation frames, leaving the list invisible.
114 const fallback = setTimeout(() => setReady(true), 500);
115 return () => {
116 cancelAnimationFrame(id);
117 cancelAnimationFrame(id2);
118 clearTimeout(fallback);
119 isFreshPageLoad.current = true;
120 setReady(false);
121 };
122 }, [open]);
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
205 // Handles scrolling to the top of the last user message
206 // TODO: if the user sends in a long message, maybe we scroll to the bottom
207 // of the message offset by 2-3 lines
208 useEffect(() => {
209 if (!containerRef.current) return;
210 if (!isUserMessage) return;
211 const c = containerRef.current;
212 const messages = c.querySelectorAll('[data-agent-message-role="user"]');
213 const last = messages[messages.length - 1];
214 if (!last || messages.length < 2) return;
215 const scrollArea = c.querySelector('#extendify-agent-chat-scroll-area');
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`;
220 last.scrollIntoView({ behavior: 'smooth', block: 'start' });
221 }, [isUserMessage, messages]);
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
243 // Handles the scroll down button visibility
244 useLayoutEffect(() => {
245 const c = containerRef.current;
246 if (!c) return;
247 const last = c.querySelector(
248 '#extendify-agent-chat-scroll-area > :last-child',
249 );
250 if (!last) return;
251
252 const areWeAtBottom = c.scrollTop + c.clientHeight >= c.scrollHeight - 4;
253 if (areWeAtBottom) return setCanScrollDown(false);
254
255 const observer = new IntersectionObserver(
256 ([entry]) => {
257 const last = c.querySelector(
258 '#extendify-agent-chat-scroll-area > :last-child',
259 );
260 if (!last) return setCanScrollDown(false);
261 const areWeAtBottom =
262 c.scrollTop + c.clientHeight >= c.scrollHeight - 4;
263 if (areWeAtBottom) return setCanScrollDown(false);
264 setCanScrollDown(!entry.isIntersecting);
265 },
266 { root: c, threshold: 0.1 },
267 );
268
269 observer.observe(last);
270 return () => observer.disconnect();
271 }, [messages]);
272
273 return (
274 <div
275 ref={containerRef}
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"
279 >
280 <div
281 id="extendify-agent-chat-scroll-area"
282 className={ready ? '' : 'invisible pointer-events-none'}
283 >
284 {messages.map((message) => {
285 if (isStep(message)) {
286 return <ToolStep key={message.id} details={message.details} />;
287 }
288 const freshLoad = isFreshPageLoad.current;
289 if (message.details?.role === 'user') {
290 return <UserMessage key={message.id} message={message} />;
291 }
292 if (message.details?.role === 'assistant') {
293 return (
294 <AgentMessage
295 key={message.id}
296 animate={!freshLoad}
297 active={message.id === messages.at(-1)?.id}
298 message={message}
299 />
300 );
301 }
302 if (message.type === 'workflow') {
303 return (
304 <WorkflowMessage
305 key={message.id}
306 message={message}
307 latest={message.id === cardMessageId}
308 />
309 );
310 }
311 if (message.type === 'workflow-component') {
312 return <WorkflowComponent key={message.id} message={message} />;
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 }
389 return null;
390 })}
391 {!workflow?.needsRedirect?.() &&
392 whenFinishedToolProps?.id &&
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}
410 {workflow?.needsRedirect?.() ? <workflow.redirectComponent /> : null}
411 </div>
412 <ScrollDownButton
413 canScrollDown={canScrollDown}
414 onClick={() => {
415 if (!containerRef.current) return;
416 const c = containerRef.current;
417 // Scroll the last message into view
418 const last = c.querySelector(
419 '#extendify-agent-chat-scroll-area > :last-child',
420 );
421 if (!last) return;
422 last.scrollIntoView({ behavior: 'smooth', block: 'start' });
423 }}
424 />
425 </div>
426 );
427 };
428