PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
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.1.5, at src/Agent/components/ChatMessages.jsx

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