PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
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 0.6.0 All 127 releases
extendify / src / Agent / components / ChatMessages.jsx

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

375 lines 13.1 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 { StatusIndicator } from '@agent/components/messages/StatusIndicator';
6 import { ToolReceipt } from '@agent/components/messages/ToolReceipt';
7 import { UserMessage } from '@agent/components/messages/UserMessage';
8 import { WorkflowComponent } from '@agent/components/messages/WorkflowComponent';
9 import { WorkflowMessage } from '@agent/components/messages/WorkflowMessage';
10 import { ScrollDownButton } from '@agent/components/ScrollDownButton';
11 import { useWhenFinishedToolProps } from '@agent/hooks/useWhenFinishedToolProps';
12 import { useChatStore } from '@agent/state/chat';
13 import { useGlobalStore } from '@agent/state/global';
14 import { useWorkflowStore } from '@agent/state/workflows';
15 import { ImageAcquisitionRequest } from '@agent/workflows/abilities/components/ImageAcquisitionRequest';
16 import {
17 AbilityRun,
18 hasRunComponent,
19 } from '@agent/workflows/abilities/components/run';
20 import {
21 createElement,
22 Fragment,
23 useEffect,
24 useLayoutEffect,
25 useRef,
26 useState,
27 } from '@wordpress/element';
28 import { decodeEntities } from '@wordpress/html-entities';
29 import { __, sprintf } from '@wordpress/i18n';
30
31 // The raw validation text names schema paths the user can do nothing with.
32 const abilityLabel = (name) =>
33 (window.extAgentData?.wpAbilities ?? [])
34 .flatMap((category) => category.abilities ?? [])
35 .find((ability) => ability.name === name)?.label || name;
36
37 export const ChatMessages = () => {
38 const { open } = useGlobalStore();
39 const { messages } = useChatStore();
40 const { getWorkflow } = useWorkflowStore();
41 const workflow = getWorkflow();
42 const whenFinishedToolProps = useWhenFinishedToolProps();
43 const canvasWorkflow = useCanvasWorkflow();
44 const whenFinishedComponent = workflow?.whenFinished?.component;
45 const [canScrollDown, setCanScrollDown] = useState(false);
46 const containerRef = useRef(null);
47 const isFreshPageLoad = useRef(true);
48 const [ready, setReady] = useState(false);
49 const userScrolledAway = useRef(false);
50 const confirmScrolledFor = useRef(null);
51 // Remounting into another layout would otherwise animate the whole backlog past.
52 const settling = useRef(true);
53 const behavior = () => {
54 if (!settling.current) return 'smooth';
55 settling.current = false;
56 return 'auto';
57 };
58
59 const lastId = messages.at(-1)?.id;
60 const lastDetails = messages.at(-1)?.details;
61 const pendingTool =
62 messages.at(-1)?.type === 'tool' && !('result' in (lastDetails ?? {}));
63 // Both render their own waiting state, so the shared status line doubles it.
64 const awaitingPicker =
65 pendingTool &&
66 (lastDetails?.id === 'acquire-image' || hasRunComponent(lastDetails?.id));
67
68 // If last message is a user message, move it to the top
69 const isUserMessage = messages.at(-1)?.details?.role === 'user';
70
71 useEffect(() => {
72 // The frontend agent shows the chat even while the store says closed.
73 if (!containerRef.current) return;
74 if (!isFreshPageLoad.current) return;
75 isFreshPageLoad.current = false;
76 // Scroll to the bottom of the chat container on load
77 const c = containerRef.current;
78 const last = c.querySelector(
79 '#extendify-agent-chat-scroll-area > :last-child',
80 );
81 let id2;
82 const id = requestAnimationFrame(() => {
83 id2 = requestAnimationFrame(() => {
84 last?.scrollIntoView({ behavior: 'auto', block: 'start' });
85 setReady(true);
86 });
87 });
88 // A hidden tab suspends animation frames, leaving the list invisible.
89 const fallback = setTimeout(() => setReady(true), 500);
90 return () => {
91 cancelAnimationFrame(id);
92 cancelAnimationFrame(id2);
93 clearTimeout(fallback);
94 isFreshPageLoad.current = true;
95 setReady(false);
96 };
97 }, [open]);
98
99 // A manual scroll means the user left the live edge on purpose — stop
100 // following until they send again.
101 useEffect(() => {
102 const c = containerRef.current;
103 if (!c) return;
104 const markScrolled = () => {
105 userScrolledAway.current = true;
106 };
107 c.addEventListener('wheel', markScrolled, { passive: true });
108 c.addEventListener('touchmove', markScrolled, { passive: true });
109 return () => {
110 c.removeEventListener('wheel', markScrolled);
111 c.removeEventListener('touchmove', markScrolled);
112 };
113 }, []);
114
115 useEffect(() => {
116 if (isUserMessage) userScrolledAway.current = false;
117 }, [isUserMessage, messages]);
118
119 const pinTarget = whenFinishedToolProps?.id
120 ? 'confirm'
121 : awaitingPicker
122 ? `picker-${lastId}`
123 : null;
124
125 // Follow new agent content while it streams in.
126 useEffect(() => {
127 if (!ready || isUserMessage) return;
128 if (userScrolledAway.current) return;
129 if (pinTarget) return;
130 const c = containerRef.current;
131 const last = c?.querySelector(
132 '#extendify-agent-chat-scroll-area > :last-child',
133 );
134 if (!last) return;
135 const id = requestAnimationFrame(() => {
136 // block:'end' scrolls UP for already-visible content — only reveal overflow.
137 const overflows =
138 last.getBoundingClientRect().bottom > c.getBoundingClientRect().bottom;
139 if (!overflows) return;
140 last.scrollIntoView({ behavior: behavior(), block: 'end' });
141 });
142 return () => cancelAnimationFrame(id);
143 }, [ready, isUserMessage, messages, pinTarget]);
144
145 // A confirm or picker demands action — pin its reply so both stay visible.
146 useEffect(() => {
147 if (!pinTarget) {
148 confirmScrolledFor.current = null;
149 return;
150 }
151 if (!ready || confirmScrolledFor.current === pinTarget) return;
152 const c = containerRef.current;
153 const scrollArea = c?.querySelector('#extendify-agent-chat-scroll-area');
154 const tool = scrollArea?.lastElementChild;
155 if (!c || !scrollArea || !tool) return;
156 const pinWhenOutOfView = () => {
157 if (confirmScrolledFor.current === pinTarget) return;
158 const cRect = c.getBoundingClientRect();
159 const toolRect = tool.getBoundingClientRect();
160 if (toolRect.top >= cRect.top && toolRect.bottom <= cRect.bottom) return;
161 confirmScrolledFor.current = pinTarget;
162 const replies = c.querySelectorAll(
163 '[data-agent-message-role="assistant"]',
164 );
165 const target = replies[replies.length - 1] ?? tool;
166 const offset =
167 target.getBoundingClientRect().top -
168 scrollArea.getBoundingClientRect().top;
169 scrollArea.style.minHeight = `${offset + c.clientHeight}px`;
170 target.scrollIntoView({ behavior: behavior(), block: 'start' });
171 };
172 pinWhenOutOfView();
173 // The confirm grows while its preview loads and can leave the viewport.
174 const observer = new ResizeObserver(pinWhenOutOfView);
175 observer.observe(tool);
176 return () => observer.disconnect();
177 }, [ready, pinTarget]);
178
179 // Handles scrolling to the top of the last user message
180 // TODO: if the user sends in a long message, maybe we scroll to the bottom
181 // of the message offset by 2-3 lines
182 useEffect(() => {
183 if (!containerRef.current) return;
184 if (!isUserMessage) return;
185 const c = containerRef.current;
186 const messages = c.querySelectorAll('[data-agent-message-role="user"]');
187 const last = messages[messages.length - 1];
188 if (!last || messages.length < 2) return;
189 const scrollArea = c.querySelector('#extendify-agent-chat-scroll-area');
190 // Reaching the top needs a viewport of room below the message's own offset.
191 const offset =
192 last.getBoundingClientRect().top - scrollArea.getBoundingClientRect().top;
193 scrollArea.style.minHeight = `${offset + c.clientHeight}px`;
194 last.scrollIntoView({ behavior: 'smooth', block: 'start' });
195 }, [isUserMessage, messages]);
196
197 // Chasing suggestions from far up the transcript would move the page.
198 const lastIsWorkflow = messages.at(-1)?.type === 'workflow';
199 useEffect(() => {
200 if (!lastIsWorkflow) return;
201 const c = containerRef.current;
202 const last = c?.querySelector(
203 '#extendify-agent-chat-scroll-area > :last-child',
204 );
205 if (!last) return;
206 const below =
207 last.getBoundingClientRect().top - c.getBoundingClientRect().bottom;
208 if (below > c.clientHeight * 2) return;
209 last.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
210 }, [lastIsWorkflow, messages]);
211
212 // Handles the scroll down button visibility
213 useLayoutEffect(() => {
214 const c = containerRef.current;
215 if (!c) return;
216 const last = c.querySelector(
217 '#extendify-agent-chat-scroll-area > :last-child',
218 );
219 if (!last) return;
220
221 const areWeAtBottom = c.scrollTop + c.clientHeight >= c.scrollHeight - 4;
222 if (areWeAtBottom) return setCanScrollDown(false);
223
224 const observer = new IntersectionObserver(
225 ([entry]) => {
226 const last = c.querySelector(
227 '#extendify-agent-chat-scroll-area > :last-child',
228 );
229 if (!last) return setCanScrollDown(false);
230 const areWeAtBottom =
231 c.scrollTop + c.clientHeight >= c.scrollHeight - 4;
232 if (areWeAtBottom) return setCanScrollDown(false);
233 setCanScrollDown(!entry.isIntersecting);
234 },
235 { root: c, threshold: 0.1 },
236 );
237
238 observer.observe(last);
239 return () => observer.disconnect();
240 }, [messages]);
241
242 return (
243 <div
244 ref={containerRef}
245 style={{ overscrollBehavior: 'contain' }}
246 className="relative grow overflow-y-auto overflow-x-hidden p-1 pb-0 text-sm text-gray-900 md:p-2"
247 >
248 <div
249 id="extendify-agent-chat-scroll-area"
250 className={ready ? '' : 'invisible pointer-events-none'}
251 >
252 {messages.map((message) => {
253 const freshLoad = isFreshPageLoad.current;
254 if (message.details?.role === 'user') {
255 return <UserMessage key={message.id} message={message} />;
256 }
257 if (message.details?.role === 'assistant') {
258 return (
259 <AgentMessage
260 key={message.id}
261 animate={!freshLoad}
262 active={message.id === messages.at(-1)?.id}
263 message={message}
264 />
265 );
266 }
267 if (message.type === 'workflow') {
268 return <WorkflowMessage key={message.id} message={message} />;
269 }
270 if (message.type === 'workflow-component') {
271 return <WorkflowComponent key={message.id} message={message} />;
272 }
273 if (message.type === 'canvas-notice') {
274 return (
275 <ToolReceipt key={message.id}>
276 {
277 // 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.
278 __(
279 'Canvas interactions are limited. When finished, use the button in the top corner to exit.',
280 'extendify-local',
281 )
282 }
283 </ToolReceipt>
284 );
285 }
286 if (
287 message.type === 'tool' &&
288 message.details?.id === 'acquire-image'
289 ) {
290 // Unanswered and last is still live; anything earlier is history.
291 if (!('result' in message.details) && message.id === lastId) {
292 return (
293 <ImageAcquisitionRequest key={message.id} message={message} />
294 );
295 }
296 // Only an answered picker reports a skip; anything else got no image.
297 const answer = message.details?.result;
298 return (
299 <ImageToolMessage
300 key={message.id}
301 url={answer?.url}
302 failed={!!answer && !answer.url && !answer.skipped}
303 />
304 );
305 }
306 if (message.type === 'tool') {
307 const failure = message.details?.result?.error;
308 const runnable = hasRunComponent(message.details?.id);
309 // A run component reports its own outcome; a second line repeats it.
310 const receipt = !runnable && !failure && message.details?.label;
311 if (!runnable && !failure && !receipt) return null;
312 return (
313 <Fragment key={message.id}>
314 {runnable ? (
315 <AbilityRun
316 id={message.details.id}
317 inputs={message.details.inputs}
318 result={message.details.result}
319 />
320 ) : null}
321 {failure ? (
322 <ErrorMessage>
323 {sprintf(
324 // translators: %s is the name of the task the agent tried to run.
325 __('Error running %s.', 'extendify-local'),
326 abilityLabel(message.details.id),
327 )}
328 </ErrorMessage>
329 ) : null}
330 {receipt ? (
331 <ToolReceipt>{decodeEntities(receipt)}</ToolReceipt>
332 ) : null}
333 </Fragment>
334 );
335 }
336 // Otherwise the live picker sits under its own receipt.
337 if (message.type === 'image') {
338 const live =
339 !message.details?.url &&
340 message.id === lastId &&
341 whenFinishedToolProps?.id;
342 if (live) return null;
343 return (
344 <ImageToolMessage key={message.id} url={message.details?.url} />
345 );
346 }
347 return null;
348 })}
349 {/* The tool-running status reads as stuck while the picker waits on the user. */}
350 {awaitingPicker ? null : <StatusIndicator />}
351 {!workflow?.needsRedirect?.() &&
352 whenFinishedToolProps?.id &&
353 !canvasWorkflow &&
354 whenFinishedComponent
355 ? createElement(whenFinishedComponent, whenFinishedToolProps)
356 : null}
357 {workflow?.needsRedirect?.() ? <workflow.redirectComponent /> : null}
358 </div>
359 <ScrollDownButton
360 canScrollDown={canScrollDown}
361 onClick={() => {
362 if (!containerRef.current) return;
363 const c = containerRef.current;
364 // Scroll the last message into view
365 const last = c.querySelector(
366 '#extendify-agent-chat-scroll-area > :last-child',
367 );
368 if (!last) return;
369 last.scrollIntoView({ behavior: 'smooth', block: 'start' });
370 }}
371 />
372 </div>
373 );
374 };
375